## v1.23.0 (2026-10-20): a quiet session start and a guard inside its budget

**SessionStart says only what this run must act on (AS-25).** The hook printed the full
`status` at every startup, resume and `/clear`: 834 characters in the hub checkout
(measured 2026-10-20), mostly the setup and other runs' residue, closing on "NEXT: acquire
a lease", which the family's routing block already says and a read-only session never
needs. It now runs `status --brief`: one line naming the leases this run holds, a lease
held past `scheduledIntervalSeconds`, this run's own expired locks (with `reap`) and claim
tags out of step with the leases, or nothing at all. The brief reads local state only (no
record plane, no remote, no vault) and leaves the signal watermark alone, so the next
`status` still shows what is new. It prints on `startup` only; `resume` and `clear` still
stamp the session (the matcher keeps both, ASY-08) and print nothing, without starting
python. `AGENT_SYNC_SESSION_VERBOSE=1` restores the full block; `status` is unchanged.
Measured interleaved on this machine at load average ~470: startup 834 to 0 characters,
median 1037 to 533 ms (p90 1399 to 853 ms, n=15 each); resume 920 to 63 ms median.

**The PreToolUse guard answers inside its budget (AS-26).** In git lease mode the first
write of a guarded file took that file's claim on the lease remote, synchronously: one or
two `ls-remote` calls (1.6 s each to github.com) and a `push` with no timeout, plus a cloud
record-plane append where one is configured. Measured against github.com, the 1.22.2 guard
took 8.6, 8.9 and 10.9 s for three first writes; the 2026-10-20 audit saw 10 to 20 s
against the hook's 20 s timeout, past which the host lets the call through anyway. The
claim now runs in a forked, detached process and the guard waits for it only until
`AGENT_SYNC_GUARD_BUDGET` (1.5 s from the coordinator's start by default; `0` restores the
synchronous claim). Past the budget the write proceeds under the task lease; the claim
then lands and leaves this run's local note, or records the run that holds it, and the
next write of that file is refused from that record without a round-trip (rechecked after
60 s). A second write of the same file while the claim is in flight waits for that claim
instead of racing it, and a claim this run already won is never reported as another
run's. The acquire `push` is bounded at 60 s. Local leases on a local record plane are
unchanged: they are a file create and are never backgrounded. Same three files on the
same remote with 1.23.0: 4.0, 5.2 and 4.0 s, with the remaining time spent in local file
writes on a machine at load average ~500 (two 50-byte writes profiled at 1.2 s).

**The skill description is a router again (AS-27):** 445 characters, down from 936; every
trigger the hub routes on (`lib/triggers.js`, route `agent-sync`) is still in it, in
English and Russian, with the boundary and the refusal phrase. The "before editing a
shared registry, even unasked" rule moved into the body.

- Tests: `test/hooks_session_test.py` gains 8 cases (7 watched failing on `5bd8bec`);
  new `test/guard_budget_test.py`, 5 cases over a local bare remote with a git shim that
  slows `ls-remote` and `push` by 5 s (3 watched failing on `5bd8bec`: guard calls of
  11.06 s, 5.51 s and 11.36 s). Both run in `npm test` and CI.
- Not covered: the window between a write that proceeds and its claim landing; a run that
  writes a file another run claimed in that window is refused only from its next write.
  Trigger evals were not re-measured for the new description (backlog AS-28).

## v1.22.2 — active work keeps a lease alive

- Automatic heartbeats renew only claims with recent guarded activity or explicit
  acquisition/renewal. Unrelated tool calls no longer extend every task indefinitely.
  Manual renewal remains intentional. The activity window is two renewal intervals;
  the configured lease TTL still controls exclusion from the last successful renewal.
- A guard requires a live task and durable activity/binding metadata. Missing task
  ownership or an unwritable binding refuses admission while retaining the claim for
  explicit repair. Expired ambiguous task associations are cleared, not guessed.
- Releasing a handed-off task releases its associated file claims without freeing
  another task's files. Incomplete authority release remains retryable; activity-file
  cleanup after confirmed release is best-effort and cannot resurrect a lease.
- Document prompt release after a pushed PR handoff and distinguish dependency
  `signal` state from a cross-project board ask. Existing lease keys, arbitration,
  generations and wire payloads are unchanged. Old installed heartbeats retain their
  old behavior until upgraded.
- Verification: 19 real CLI/fake-clock controls, including same-second metadata
  failures and a local bare Git remote. The source candidate passed 20 suites and
  all 75 existing negative plants before this synchronized packaging bump.

## v1.22.1 — the AS-18 commit fallback reads only a command

Patch from a re-certification reading of 1.22.0 (`517b4b6`).

**A PR body that mentions `git commit` is no longer a commit.** When a Bash command does not
tokenise (an odd-quote heredoc), 1.22.0's AS-18 fallback in `bash_writes.py` counted the
payload as a commit if `git … commit` appeared ANYWHERE in it — so `gh pr create --body
"$(cat <<'EOF' … every git commit … a 12" display … EOF)"` with a guarded file staged and no
lease was refused with "this commit … stages a guarded file". The fallback now reads only
command text: backslash-newline continuations joined, each balanced `$(…)` and backtick span
removed (quote-aware, nested), each heredoc body removed (from the `<<DELIM` / `<<'DELIM'` /
`<<-DELIM` line through the line that is exactly DELIM) and comments dropped; an opener that
never closes ends the text there. In what remains, split into segments outside quotes, a
commit counts only where a segment starts with `git … commit` (after env assignments, the
usual prefixes and `git -C <dir>` / `-c k=v`).

**The fallback checks the repository the commit runs in.** `git -C <dir> commit …` and
`cd <dir> && git commit …` now name that repository's index, not the payload's cwd. A
directory it cannot name (`cd -`, `cd "$(…)"`, a `$VAR`, a path that does not exist) falls
back to cwd, as `guard.sh` already did for a repo that is not a directory.

- `test/coordination_test.py`: case "the untokenisable commit fallback reads only a git
  commit command" — **52 cases**. Against `517b4b6`, watched failing: the PR body exits 2
  (want 0); `git -C <other> commit` with the guarded file staged only in cwd exits 2 (want 0)
  and with it staged only in `<other>` exits 0 (want 2); `cd <other> && git commit` the same
  (0, want 2). The existing odd-quote `git commit -m "$(cat <<'EOF'…)"` case stays exit 2.
- Blind reading of the first candidate (`ea936ae`, PR #33) failed it: it cut the command at
  the FIRST `$(` / `<<`, so a commit after a leading substitution was missed — main refused
  these, the candidate passed them. Added, each watched exiting 0 on `ea936ae` (want 2):
  `cd "$(git rev-parse --show-toplevel)" && git commit -m <odd heredoc>`,
  `MSG="$(cat <<'EOF' … it's "x … EOF)" && git commit -m "$MSG"`,
  `git add "$(git ls-files -m)" && git commit -m <odd heredoc>` (at the parser,
  `(False, None)` on `ea936ae`, now a commit in cwd), and `git \<newline> commit -m <odd
  heredoc>` (continuation). Regression guards that pass on both: ``echo `date` && git
  commit -m <odd heredoc>`` exits 2; a heredoc body saying `git commit` is not a commit.
- `references/hooks.md` "A commit" states the fallback; `guard.sh`'s capability matrix has
  a row for an untokenisable commit. README's `scheduledIntervalSeconds` row and the
  ledger's S9 row describe the held-too-long notice as one JSON object: `systemMessage` for
  the person, `hookSpecificOutput.additionalContext` for the model.
- Not covered: what an untokenisable command stages before its commit (only the index is
  read); a `)` in a `case` pattern inside `$(…)` closes the span early; a heredoc opened
  inside a `$(…)` that closes on the same line keeps its body as command text.

## v1.22.0 — coordination that Fabric and Observatory can lean on

Integration wave 3 of the family's 2026-10-11 waves run (hub plan R17–R22), and two fix
rounds after blind verifiers failed the first candidate (`27f485a`) and the second
(`e32c7c9`, PR #32). `test/coordination_test.py` joins `npm test` with **50 cases**. Against
1.21.7, 30 of the first 33 fail (13 on an assertion, 17 because the code they call does not
exist there) and 3 pass — "an id really missing is still a leak", "a short key keeps its
readable id" and "the same key is the same run every time" are regression guards. Against
the first candidate `27f485a`, the 14 cases added in the first fix round all fail, as do the
extended parser case and the long-expired case (its message changed from `> 7d` to `>= 7d`).
Against the second candidate `e32c7c9`, the 17 cases added in the second fix round all fail
(11 on an assertion, 6 with an exception — three call a function that does not exist there, one a function whose signature changed, one hits the very ImportError it pins, one parses the hook's empty output), as do the
extended parser case and the vault-cache case.

**`npm test` now runs every CI test suite, sandboxed.** It runs `test/run_gate.py`: every suite
CI runs — the five it ran before, every `test/audit_regressions/*.py` (CI ran them and
`npm test` did not, so CI went red on FIX-SY-04.02 behind a green local gate) and the
installer test — each with its own temporary HOME and without CLAUDE_CODE_SESSION_ID,
CLAUDE_SESSION_ID or AGENT_SYNC_RUN_ID, then checks that the HOME it was started with has
the same `~/.agent-sync` files as before. A test run had written 14 files into the
operator's real `~/.agent-sync/session-repos`: `claim_cell_test.py`'s `cli()` passed the
real HOME and the live session id through, and `SESSION_REPOS_DIR` was resolved at import.
Every suite now calls `test/_sandbox.py` first (a temp HOME that also holds its `mkdtemp`
fixtures, removed at exit), `session_repos_dir()` reads HOME at each call, and the case
"the suite never writes the HOME it was given" fails without the sandbox (watched: a
sentinel HOME received 3 files). The other direction too: CI's `validate` job had never run
`guard_uncoordinated_test.py` or `coordination_test.py`, which `npm test` did; it runs both
now, so the two gates run the same set.

### Fix round 2 (second blind reading of `e32c7c9`)

- **SKILL.md states the two write modes again (FIX-SY-04.02, CI red).** The compaction
  had dropped the paragraph its audit regression pins: a task lease authorizes the TASK,
  never the file; the resource claim is the short transaction lock; heavy overlap takes an
  isolated worktree + merge; the guard does NOT promise enforcement from a single task
  owner. Body 4726/4750 tokens (the `$SKILL_DIR` paragraph and the interval line trimmed).
- **`git add X && git commit` in one call is guarded (AS-16).** The hook reads the index
  before the command runs, so X was not staged yet and the commit passed. The commit parser
  moved into `bash_writes.py --commit` (the same tokeniser as the write parse) and reads
  what the same command stages — `git add <specs>`, `-A`, `-u`, `.`, `git commit -a`,
  `git commit <paths>` — asking `git ls-files` which files that is; those are checked with
  the index in ONE coordinator call.
- **`status` names a token read from the file as degraded (unit B1).** `status` loaded the
  file, then Sync() loaded it again, found the token in the environment and relabelled it
  "from the environment". The source is now assigned once per process.
- **The vault is asked once per process, and only where a token is used.** The decision is
  memoised per root before any child starts (risk b). `renew` and `release --held` no longer
  ask at all (seam S2/S3): the lease they act on is a local lock or a git ref, and what the
  token would add is one visibility line in the Notion claims log, whose readers apply the
  TTL anyway — while renew.sh runs after every tool call (two `use` audit rows per call
  once a slot existed) and SessionEnd has 2 s, which a probe and a re-run consumed. With the
  token in the plaintext file they still publish. A re-run keeps the hook's clock: the first
  process records its start in `AGENT_SYNC_HOOK_T0` before `execv` (risk a). The `run` probe
  is cached beside the slot answer (a pass for the hour, a refusal for a minute), and a
  missing slot is asked again after a minute even with no file token (risk g).
- **A host without `fcntl`** no longer crashes `acquire` after the lease was won (seam S4).
- **The guard is cheaper and gated on the repository that owns the write (seam S5/S6/S8).**
  `bash_writes.py --guarded` resolves each target's repository by walking to `.git`, drops
  every target no `guardedFiles` pattern matches, and the hook runs the coordinator once per
  owning repository with all its targets; `guard` memoises its read-only git questions; a
  Bash call spawns no interpreter until its payload carries a write shape (a `>`, or
  tee/sed/cp/mv as a WORD — `used` and `guarantee` no longer match) or `commit`; hook
  bytecode is cached under `~/.cache/agent-sync/pycache` instead of recompiled. Because
  the owner decides, a session rooted outside every coordinated repository that writes into
  one is refused at the write. renew.sh decides without reading stdin wherever it can and
  reads only the payload's first 4 KB otherwise. Measured interleaved against `e32c7c9`
  under load average ~45 (absolute numbers on this machine are noisy; the spawn counts are
  not): a coordinated two-target redirect with the lease held 244 ms (git 2, python3 3) vs
  477 ms (git 10, python3 4); renew.sh on a 2 MB PostToolUse payload in an uncoordinated
  repository 8.1 ms vs 89.1 ms (medians of 15).
- **`mv` writes its source too (seam S7)**, and the parser reads `<<\EOF`, `<( … )` and
  `>( … )`, `cd -L/-P`, `timeout`/`nice`/`stdbuf`/`xargs` prefixes, `sed --expression=`,
  `env -C DIR`, a `cd` in a pipeline or behind `&` (no move), `<>` (a write), and an absolute
  `cp`/`mv` destination directory whatever the current directory is (risk d). What it still
  skips is listed as UNPARSED in `guard.sh` and the module docstring. Its `main()` prefilter
  no longer misses `sed 's/;/x/' -i f` (risk c).
- **A lease held too long is said where the session sees it (seam S9).** R19's warning
  printed only from `status` at SessionStart, when a fresh session holds nothing. The guard
  now emits it once per lease per run, as one JSON object with `systemMessage` (the person)
  and PreToolUse `additionalContext` (the model) — see the final polish below.
- **Smaller corrections.** `init --backend outline` no longer says `set -a && . ./.env.agent-sync`
  (every command and hook reads the file itself); the generated wiring snapshot names the
  vault slot first for `notion`, the file only as a degraded fallback; `check` enforces the
  schema's `scheduledIntervalSeconds` minimum of 60; a lease ref is flagged keyless only when
  its payload parses as a JSON object without `key`, and the warning says it "was written by
  ≤1.21.2 (ts …)" — a date, not proof of a live writer; citations of project-observatory
  DECISIONS.md corrected to :2023 and :12873.

### Final polish (third blind reading of `6ab4c47`)

- **`git commit --gpg-sign <path>` is guarded.** The AS-16 parser took `--gpg-sign` as an
  option with a value, so the pathspec after it was swallowed and the commit passed with a
  guarded file unleased; the key id attaches only as `--gpg-sign=<keyid>`. Case added to
  "git add and commit in one call is guarded" (it fails on `6ab4c47`: the plan staged nothing).
- **A heredoc commit message with an odd number of quotes is still a commit (AS-18).** The
  tokenizer closes a `"…"` string at the first inner quote, so such a message did not parse
  and the commit check was skipped, staged guarded files included (1.21.x skipped every
  heredoc commit). It is now checked against the index of the payload's directory. Case
  added; on `6ab4c47` `commit_plan` answered "not a commit".
- **The held-too-long notice reaches the model, once (AS-21).** Claude Code shows a
  `systemMessage` to the person only, and it reads two JSON lines on stdout as a parse
  failure that drops both — which a Bash write into two repositories, each with a lease held
  too long, printed. The guard now prints one object with `systemMessage` and
  `hookSpecificOutput.additionalContext`, merging several repositories' notices. Cases "a
  lease held too long is said once where the session sees it" (extended) and "a write into
  two repositories says both notices in one object" — both failed on `7009bb9`.
- **`main()` starts clean.** The git answer memo, the token source and the vault-degraded
  note are reset per invocation, so a second in-process call never sees the first's.
- **Docs.** `fabric-coordination.md` names the real flag (`reserve <register> --key <k>`,
  not `--rkey`); this entry's feature list sits under its own heading. `npm test` runs every
  CI *test suite*: the validator self-test, the coordinator smoke test, the stray-SKILL.md
  check, `claude plugin validate --strict` and the `skill-audit` job stay CI-only.

### Upgrading to 1.22.0 mid-flight

- **A session's run id changes.** Claude Code exports CLAUDE_CODE_SESSION_ID to every Bash
  call, and 1.22.0 reads it: a session whose Bash calls used the shared identity under
  1.21.x now resolves to its own id, so a lease it took under the shared id looks like
  another run's. Release before upgrading, or let it expire. 1.22.0 does NOT adopt the
  shared entry's id on first sight of a session key — that would merge this session with
  whichever session last wrote the shared entry, the collision B-150 removed.
- **The session list moved.** 1.22.0 keys the list of other repositories by the session
  (`~/.agent-sync/session-repos/<sha256>.json`); a list keyed by project slug from a
  pre-release build is not read. Leases listed only there are renewed and released by hand.
- **Observatory's tick changes identity across the upgrade.** `tools/tick.sh` exports
  `AGENT_SYNC_RUN_ID="ti$$-observatory-tick"`, and `tools/tick_lease.py` acquires and
  releases in separate processes, each importing the HIGHEST cached plugin version. That id
  is longer than twelve characters, so 1.22.0 derives a different run id from it (R17): a
  release under 1.22.0 of a lease acquired under 1.21.7 is refused as another run's until
  the TTL. Upgrade between ticks — let the running tick release first.
- **Sessions started before the upgrade keep the old plugin.** CLAUDE_PLUGIN_ROOT pins the
  cache directory a session started with (1.21.0–1.21.2 are still in this machine's cache):
  restart those sessions; a keyless lease ref is what they write.
- **Observatory's own config has no `scheduledIntervalSeconds`**, so R19's warning cannot
  fire there yet. Adding it is the Observatory owner's change (backlog AS-17).

### What changed

- **A run id is unique per key (R17).** It was the first twelve `[a-z0-9]` of the key, so
  `observatory-scheduled-tick-1` and `-2` were one run (`r-observatorys`). Now a key that
  fits in twelve characters is kept whole, and a longer one keeps six readable characters
  plus six hex of a sha256 of the whole key (project-observatory DECISIONS.md:2023).
  Ids already stamped in a marker read back unchanged. **Upgrade effect:** a process that
  derives its id from a long `AGENT_SYNC_RUN_ID` (or session id) with no marker entry gets
  a different id than 1.21.7 gave it, so a lease 1.21.7 took under the old id is not this
  run's after the upgrade — release it before upgrading, or let it expire. A new session
  entry is also derived from the session id rather than minted at random.
- **Ids print and match at the register's own width (AS-08).** A three-digit register
  (`CO-051`) was handed `CO-0051`, and every id it held was reported as a permanent leak.
- **The description cap is the family's 970 (AS-10)**, not the spec's 1024, with a
  self-test plant in the band between the two.
- **A lease held past the repository's scheduled interval is reported (R19).** The new
  optional `scheduledIntervalSeconds` names the interval; it is in `CONFIG_KEYS` and the
  schema, so `check` accepts it (the first candidate's `check` called it unknown).
  `status` warns when a held lease is older, measured from `since`, which `acquire` now
  writes and `renew` keeps. Observatory's scheduled tick stands aside while a lease is
  held (DECISIONS.md:12873).
- **`reap --long-expired` (AS-11) — a sweep by age, and the operator's decision.** It
  lists locks on both planes expired at least seven days ago (`--older-than-days N`, at
  least 1 — `0` used to become 7 silently), each with the classifier's verdict and reason,
  and with `--apply` clears exactly those through the `--i-own-this` path: live leases
  refused, each payload printed and journaled, a younger entry of the same key on the
  other plane left alone. `--apply` or `--older-than-days` without `--long-expired` is
  refused. `references/lease-protocol.md` carries the dated amendment — reap otherwise
  never touches foreign state — and `SKILL.md` says `--apply` is never an agent's call.
  `status` counts candidates in the lock directory only, and says so.
- **The `notion` token comes from the Observatory vault first (R20).** A command that
  touches the record plane, with no token in the environment, re-runs itself under
  `use_secret.py run --vault-only <project> AGENT_SYNC_NOTION_TOKEN`, so the value never
  reaches argv. Fixed after the first candidate: `guard`, `write-targets` and `whoami`
  (and since fix round 2 `renew` and `release --held`) never ask the vault (a vault whose `where` said yes and whose `run` refused locked every
  Edit, Write and commit with exit 2); `run` is probed with a no-op child before the
  re-run, and a refusal falls back to the file and is reported; the project is the main
  worktree's folder (or the remote's name), not the checkout's, so a linked worktree finds
  its slot; a positive `where` is cached for the hour (a negative for a minute, or the hour
  while the file still holds a token), and the `use_secret.py` path is cached too; the config's own
  `backend` counts; the argv of this invocation is the one re-run. A token read from
  `.env.agent-sync` is reported by `status` as `degraded: plaintext token file` with the
  `vault.py put` command. `init --backend notion` prints vault-first steps and no longer
  tells anyone to `source` the file. Vault-first is `notion`-only in this release; `outline`
  is an open board row.
- **A lease in a second repository is renewed and released (AS-07).** `acquire` lists the
  repository under the SESSION — `~/.agent-sync/session-repos/<sha256 of the session
  key>.json` — and the renew and SessionEnd hooks pass `--session-repos`. Fixed after the
  first candidate, which keyed the list by `CLAUDE_PROJECT_DIR` (absent from a Bash tool
  call, so nothing was ever registered from one, and shared by two sessions, so one's
  SessionEnd dropped the other's repository) and ran every repository in one process (so
  repository X inherited P's backend, URL, collection and token): each listed repository
  now runs in its own child process, in parallel, from the environment the hook started
  with, inside the hook's budget; one failing repository never strands the rest. The
  identity order gains `CLAUDE_CODE_SESSION_ID` — what Claude Code exports to every Bash
  call — and the hooks hand the payload's session id to the coordinator, so a shell and
  a hook resolve one run in a repository no SessionStart stamped. bash and Python name
  the list file identically for long and non-ASCII keys under any locale.
- **The guard sees Bash writes (AS-09).** Into a coordinated repository, `>`, `>>`, `>|`,
  `&>`, `>& FILE`, `tee`, `sed -i`, `cp` and `mv` into a guarded file need a lease. The
  parse lives in the small `scripts/bash_writes.py`, because the hook runs on every Bash
  call. Fixed after the first candidate: it tokenises before it segments, so a heredoc's
  body (`cat > notes.md <<'EOF'` followed by `> docs/ROADMAP.md`) and a quoted `;` or `|`
  are no longer read as commands; `( … )` subshells, `cd -` (stop following),
  `env`/`sudo`/`nohup` prefixes are handled. `$VAR`, backtick and glob targets stay
  unparsed, and the capability matrix in `guard.sh` lists exactly the shapes the tests
  cover.
- **A full disk no longer locks every Bash call (found live, 2026-10-12).** `guard.sh` fed
  its parsers through bash here-strings, and bash 3.2 (macOS) writes a here-string to a temp
  file. With the disk full, the parser read nothing and the guard failed closed: every Bash call
  was refused, including the cleanup that would have freed space. Payloads now travel through
  pipes and process substitution, which need no file. The case "the guard needs no temp file to
  allow a harmless command" runs under `ulimit -f 0`, and 1.21.7 failed it with the exact
  message seen live.
- **Fabric conformance map (R21).** `references/fabric-coordination.md` maps each operation
  of `fabric-agent-contract@bcada4a` `coordination.md` to the command that implements it
  (Fabric names no reference implementation). A test checks every named command exists.
  The gaps are listed: idempotency keys, `release` without an outcome, `reserveId` without
  count or ttl, scope containment, residue categories, and the contract's "expiry does not
  prove the writer stopped" against `acquire`'s steal and the age sweep.
- **AS-14 reopened, not dropped.** The first candidate dropped it on the premise that no
  1.21.2-or-older writer is alive. The Fabric remote disproves that: on 2026-10-12 it held
  `refs/agent-sync/leases/docs-architecture-report-workspace.md`, with no `key`, written
  2026-10-08 by `r-d8d027823`, beside the same path's 1.21.3 ref. That dates a write; it
  does not prove a live writer — the ref's host is this machine, whose plugin cache still
  holds 1.21.0–1.21.2, which a session started before an upgrade keeps running. The remedy
  is to restart such sessions. The drop stays a proposal pending the operator's decision;
  `residue` flags every lease ref without a `key` in one line, with its timestamp.
- **A self-test plant had gone blind.** `since` in the local lock payload moved the anchor
  of "a local lock records no host", so on the first candidate that plant changed nothing
  and would have reported MISSED. Re-anchored, run by hand on a copy: detected.

## v1.21.7 — a lease the guard would not recognise is refused, and a worktree finds its session

- **`acquire` refuses the shared fallback identity where hooks know their session.** From
  a shell with no `CLAUDE_SESSION_ID`, `acquire` won the lease under the shared fallback,
  while the PreToolUse guard — which runs with the session id — refused every edit with
  "this run holds no lease" (reported 2026-10-11 from a linked worktree). When any session
  stamp is in reach, `acquire` now exits 2 and names the remedy (`CLAUDE_SESSION_ID`, or
  `AGENT_SYNC_RUN_ID` for a non-Claude caller). Where no hook stamps sessions, nothing
  changes: CLI and hook share the fallback and agree.
- **A linked worktree reads the main checkout's session stamp.** A session started in the
  main checkout stamped that checkout's state dir; the walk from a worktree read only its
  own and fell back to the shared identity. The walk now reads both.

Two cases in `test/hooks_session_test.py`, both watched failing first.
- **A direct `guard <path>` in a repository with no coordination allows.** `guard.sh`
  already exited early there, but `agent_sync.py guard <path>` called directly still
  exited 2 with a lease message no lease could satisfy. Recovered from the unmerged
  2026-09-11 branch: a missing config is `NotCoordinated`, an answer the guard allows;
  an unreadable config still denies. `test/guard_uncoordinated_test.py` (4 cases, 2
  failing on 1.21.6) joins `npm test`.

## v1.21.6 — a default gate that finishes, and a tag that must be annotated

- **`npm test` no longer runs the validator's self-test.** The default gate ran
  `python3 test/validate.py --self-test` — 74 planted defects, each a full validator run in
  its own copy of the tree — and locally it did not finish in 58 minutes (measured
  2026-10-10). The self-test moves to `npm run test:negatives`, as in the sibling members;
  `npm run test:all` runs both. Nothing CI enforced moved: `validate.yml` and `release.yml`
  already invoke `validate.py --self-test` directly, not through `npm test`.
- **The release refuses a lightweight tag.** A new step right after checkout in
  `release.yml` fails unless `git cat-file -t <tag>` is `tag`. `git describe` and
  `git submodule status` see annotated tags only; on 2026-10-09 four family members cut
  lightweight tags and the hub read each as its previous release. Same step, same wording,
  in every member.
- Not a code change: ssheleg/sshlg-skills#154 (reported against 1.21.0) was fixed in
  1.21.2 — the guard asks the repository that owns the write. Its reproduction exits 2 on
  the 1.21.0 plugin cache and 0 on 1.21.5 (verified 2026-10-10).

## v1.21.5 — portable paths and measured hook enforcement

Resolve scripts from the active skill directory so native, project and custom
installs work without assuming a shared global root. Hook guidance now distinguishes
the shipped Claude adapter from other hosts' capabilities: unverified adapters
require separate runtime evidence, with explicit self-checks.

The ledger mutation fixture now plants its invalid version in the current section
even when that section has no quoted command output, preserving historical entries.

## v1.21.4 — merge from a linked worktree

`merge` run from a linked worktree failed with `could not check out main` whenever another
worktree — usually the main checkout — had the integration branch checked out
(ssheleg/agent-sync#27). The dry run had just reported no conflicts. That is the ordinary layout
of several agents in one repository, and it surfaced landing a guarded file in a shared
workspace.

- **Lands through a temporary worktree.** With `--push`, the merge, the merge-log entry and the
  push happen in a detached temporary worktree at `origin/<target>`, removed on every path. The
  worktree that holds the target is never touched — not its files, not its ref. Without `--push`
  it refuses before anything is touched and says why.
- **The holder's branch no longer moves under it.** The fast-forward of the local target
  (`update-ref`) is skipped when another worktree has it checked out; it would have shown that
  worktree the reverse of every change as its own uncommitted edits.
- **A rejected push keeps the lease.** `--push` used to release leases first and push after, so a
  rejected push printed `✗ push failed` with the work's lease already free. The push now comes
  first, on both paths; a rejection fails and every lease stays held.

Check: `check_merge_from_a_linked_worktree` (refusal without `--push`; landing with it; holder
unmoved and clean; temporary worktree gone; lease released after the push and kept on a rejected
one). Two mutations caught — dropping the holder detection reproduces the original
`could not check out main`; releasing before a rejected push is reported.

## v1.21.3 — the key that came back under a different name

Under `leaseBackend: "git"` a lease key containing a dot was reported and released under a
name nothing had been taken under (ssheleg/agent-sync#25). Reproduced against 1.21.2 with a
local bare remote:

```
acquire T-1.2            # won T-1.2
whoami                   # holds: T-1-2
release --held           # released T-1-2      (exit 0)
git ls-remote origin 'refs/agent-sync/leases/*'
                         # .../refs/agent-sync/leases/T-1.2   <- still there
```

**Root cause: the key was stored under two different lossy slugs, and the local one was
reported.** `_ref` kept dots (`re.sub(r"[^A-Za-z0-9._-]+", "-", key).strip("-")`),
`_local_lock` did not (`re.sub(r"[^A-Za-z0-9_-]", "-", key)`), and `held()` returned the
lock file's stem. `release --held` then released `T-1-2`: `_git_release` found no ref of
that name, returned without a word, and `release` printed "released". The same slugs
broke more than dots: every path key (`docs/x.md` → note `docs-x-md`, ref `docs-x.md`) was
stranded the same way; `a/b` and `a-b` were one ref; a key with a leading dot
(`.claude-plugin/marketplace.json`) produced an invalid ref, and the rejected push was
reported as "held by another run".

- **One key, one name.** `lease_name` percent-escapes every byte outside
  `[A-Za-z0-9._-]`, and a `.` wherever git forbids one in a ref component; `lease_key`
  reverses it. The ref and the note carry that same name, and both payloads carry the key
  verbatim, which is what `held()`, `whoami`, `residue` and the board now report. A key
  made only of safe characters is its own name, so every ref 1.21.2 pushed for one is
  already under the right name.
- **A key that cannot travel unchanged is refused at `acquire`**, before anything is
  written: empty, whitespace or a control character, a backtick, `|`, or a stored name over
  200 bytes. Two spellings that land on one note file (`T-1`/`t-1` on a case-insensitive
  disk) are refused instead of the second overwriting the first.
- **`released` is said only after the remote is read again.** `release` asks the remote
  first, deletes with `--force-with-lease`, re-reads the ref with a strict `ls-remote`, and
  only then clears the board claim and the note. A refused delete or an unreachable remote
  is `NOT released`, exit 1, with the claim and the note kept — they are the only trace of
  a lease that is still out there. `release --held` and `merge` report the same way; `merge`
  printed `✓ released` unconditionally.
- **What 1.21.2 left behind stays exclusive and releasable.** A 1.21.2 ref with a slugged
  name is still that key's lease (`acquire docs/x.md` loses to its holder; `release
  docs/x.md` deletes it); a 1.21.2 note is re-noted under the key its ref was taken as by
  `whoami`, `status`, `release` and `release --held`; the slug 1.21.2 printed (`release
  T-1-2`) resolves to this run's ref — only refs 1.21.2 wrote, so a lease taken under 1.21.3
  is released by its own key only; and in local mode a 1.21.2 lock held by another run
  still blocks the dotted key. The PreToolUse guard does none of this: it stays local.
- **Checked for the same defect:** `reserve` built `refs/agent-sync/ids/<REG>` from the same
  slug, so `A B` and `A-B` shared one counter; a register name the ref cannot carry verbatim
  is now refused (every name the slug left unchanged keeps its ref). `release-id` writes a
  log receipt keyed by the exact register name and touches no ref — unaffected. Resource
  claims (`res--<repo>--<path>`) are built from `[A-Za-z0-9_-]` only and round-trip
  unchanged; kept byte-identical so two versions cannot claim one register under two names.
  `residue`/`reap` now report a git-plane ref by its key and delete the ref that exists;
  `reap --i-own-this` still accepts the slugs 1.21.2 printed.
- **Left open, stated:** a run still on 1.21.2 and one on 1.21.3 taking the same PATH key at
  the same moment, with no ref yet under either name, push two different refs. Task ids made
  of safe characters never had two names. Update every machine.
- Eight new `check_*` in `test/validate.py`, all against a real bare origin and confirmed by
  `git ls-remote`: the issue's round trip, a ten-key bijection in both modes, refusals,
  legacy release (`--held`, by key, by the printed slug), legacy exclusion on both planes,
  the false "released" (a pre-receive hook refusing deletes, and a vanished remote), case
  variants, and register names. Run against the 1.21.2 script, seven of the eight fail
  with 37 problems (case variants only on a case-insensitive disk, the macOS default); the
  legacy-exclusion guard passes there and must keep passing. Eight self-test plants put back the
  slugged ref, the slugged stem, `held()` reporting the stem, the unverified delete, the
  missing migration, the unread legacy ref, the accepted unrepresentable key and the slugged
  register; all eight are caught.
- Docs: `references/lease-protocol.md` (one key, one name; what 1.21.2 left behind; the
  release proof; register names), README command table and troubleshooting, SKILL.md.

## v1.21.2 — the guard that asked the session instead of the repository it guarded

The `PreToolUse` guard decided whether coordination was on from
`${CLAUDE_PROJECT_DIR:-$PWD}/.claude/agent-sync.json` — the SESSION's project
(`hooks/_lib.sh` `agent_sync_configured`, called at the top of `guard.sh`) — and then
ran the check somewhere else. Reproduced 2026-10-01 against 1.21.1, three ways:

- **A commit into a repository with no config was blocked.** For `git -C <repo> commit`
  or `cd <repo> && git commit` the guard ran `agent_sync.py guard` per staged path inside
  `<repo>`, which exits 2 for *no .claude/agent-sync.json in this project*; the guard read
  that as "no lease" and refused a commit in a repository where coordination is off.
- **An edit in another repository was judged by the session's rules.** `agent_sync.py
  guard <abs path>` ran from the session's cwd, so the path was made relative to the wrong
  root (`../other/docs/ROADMAP.md`): the other repository's own `guardedFiles` never
  applied, and a session glob such as `**/HANDOFF.md` matched files it was never written for.
- **A session rooted in an unconfigured project guarded nothing**, so it could write a
  guarded register in a configured repository with no lease at all.

Now **the repository that owns the write decides**. `agent_sync_owner` (`hooks/_lib.sh`)
resolves `git -C <dir> rev-parse --show-toplevel` — the file's nearest existing directory
for `Edit`/`Write`/`MultiEdit`/`NotebookEdit`, the repository named by `-C`/`cd` for a
commit — and prints it only when that toplevel carries its own config. None → exit 0,
silently; a path inside no git repository → exit 0; otherwise `agent_sync.py guard` runs
from that toplevel, against its `guardedFiles` and its leases. The guard no longer exits
early on the session's config.

- **Failing closed is unchanged where it was promised.** A parser that cannot run (no
  python3) — and now also a missing git — still exits 2 in a session whose project is
  configured. In an unconfigured session the owning repository cannot be named without
  them, so the guard exits 0 and says so in its capability matrix rather than refusing
  every call on a machine that never opted in.
- **The common Bash call stays cheap** now that the guard runs in every session: a payload
  without the substring `commit` exits before the commit tokeniser starts an interpreter.
- A commit refusal now names the repository to take the lease in and carries the
  coordinator's own reason.
- `check_guard_asks_the_repository_that_owns_the_file` drives fifteen cases through the
  real hook (configured / unconfigured session × repository with / without a config, a
  path in no repository, a subdirectory commit, a `Write` into a directory that does not
  exist yet, and the same writes with the owning repository's lease). Seven failed on
  1.21.1. Two self-test plants put back the session lookup and the early exit; both are
  caught.
- **Unchanged, checked:** `session-start.sh`, `renew.sh` and `session-end.sh` still act on
  the session's project. They register, renew and release this session's run there, which
  is the right scope for them; a lease taken in a second repository is still neither
  renewed nor released by them (backlog AS-07, open).
- Docs: `references/hooks.md` (which repository decides, debugging rows), README hook
  table and troubleshooting, SKILL.md, SECURITY.md, the `hooks.json` description.

## v1.21.1 — the check that asked the wrong plane who hands out ids

`check` refused every `idRegisters` entry on `backend: "fs"` + `leaseBackend: "git"`,
while `reserve DEC` in that same repository allocated race-free. Found 2026-10-01 while
installing agent-sync across an organisation's repositories: each one with a local record
plane had to drop its DEC/OQ registers and take numbers by hand — the collision this tool
exists to prevent, recommended by the tool's own health check.

The two commands asked different questions. `reserve` allocates when the record plane
orders writes OR the lease is git — in git mode the allocator is a compare-and-swap on
`refs/agent-sync/ids/<REG>` at the remote, independent of the record plane, exactly like
the lease. `check` asked only the record plane.

- **One predicate, `id_allocator(cfg, adapter)`**, now answers for `reserve`,
  `release_id`, `check` and the generated snapshot, which also names the allocator under
  *Id registers*. `check` accepts registers under `leaseBackend: "git"` whatever the
  record plane, and still refuses the two configurations that genuinely cannot allocate:
  a non-ordering plane (`fs`, or a cloud plane degraded to `fs`) with `leaseBackend:
  "local"`, and `leaseBackend: "git"` pointed at a remote that does not exist.
- **A missing lease remote is refused by name** in `acquire` and `reserve`. It used to
  surface as "held by another run" and "another allocator is racing" — a holder and a
  contention that did not exist.
- `check_check_and_reserve_agree_on_who_allocates_ids` drives all three configurations
  through both commands against a real bare remote; two self-test plants put back the
  record-plane-only condition and the phantom-contention path, and each is caught.
- Docs: `lease-protocol.md` → *Id reservation* carries the configuration table;
  `backend-fs.md`, the schema description, the README and SKILL.md say that registers
  follow the lease, not the record plane.

## v1.21.0 — the SessionEnd budget no host gave, and the watchdog that held the pipe

Codex 0.157 prints `clamping SessionEnd hook timeout to 3s in …/agent-sync/…/hooks.json`
at every session start. The hook declared 20 s; Codex clamps a SessionEnd handler to 3 s,
and Claude Code sizes its own SessionEnd wait from the largest handler timeout. So the
number was never honoured, and the loop behind it did not fit the budget it really had.

- **`hooks.json` declares `timeout: 3`** for SessionEnd, and `check_hooks_manifest` refuses
  anything larger, with a self-test plant of the exact 20 that shipped 0.1.0 → 1.20.2.
- **`release --held`** gives back everything this run holds in ONE process. `session-end.sh`
  used to run `whoami` plus one `release` per key, each with its own 10 s limit, so the host
  killed it part-way and the tail of the list stayed out until its TTL. It now runs
  `release --held` under a 2 s limit. The re-read of `held()` after each release is kept,
  because a run's last task key takes its resource claims with it.
- **`run_limited`'s fallback watchdog held the caller's pipe.** Stock macOS has neither
  `timeout` nor `gtimeout`; the bash fallback was `( sleep N; kill ) &` with the caller's
  stdout inherited, and `kill "$watchdog"` ended the subshell but not its `sleep`. The orphan
  kept the pipe open, so `$(run_limited 10 …)` and `… | sed` waited the full limit after the
  command had finished. That covered session-end, and SessionStart's `status` output too.
  Measured 2026-09-27: session-end took 10.4 s to release three leases. It is a race, so a
  trap-based stop still lost it now and then. The watchdog now polls `kill -0` in 0.1 s steps
  with its stdio on `/dev/null`.

Seven cases in `test/hooks_session_test.py` (13 total). The watchdog cases force the
fallback with a PATH that carries no timeout binary, so a Linux runner exercises it too.
They repeat the shape forty times, because a single draw of a race proves nothing.

## v1.20.2 — the override that could not reach the plane the state was on

**161 expired lease refs on one remote, from two runs that ended five days earlier, and
no command in this tool could clear any of them.** Measured on the operator's machine
2026-09-14: `residue` listed every one, `reap` left them alone (correctly — a ref in a
dead run's name is `foreign`), and `reap --i-own-this`, the one path a person has for
exactly this, answered *there is no lock by that name in this checkout*. True, and
useless: in git mode the authority is `refs/agent-sync/leases/*` on the REMOTE, and the
override walked the local lock directory only.

This is AS-01b returning on the other plane. That row closed *"expired locks accumulate
with no path out for anybody"* for the filesystem; the same sentence was true of the git
plane the whole time, and the tool that reports it could not act on it.

- `_reap_by_operator_decision` now reads **both planes** and collects entries per key as a
  LIST — a key can be a lock file here AND a ref there, and clearing one while calling the
  key done is how the ref survived every sweep that ran. A git entry is deleted through
  `git_reap`'s existing `--force-with-lease` compare-and-swap and proved gone by a second
  `ls-remote`, never by the push's exit code.
- **Every refusal the override had, it keeps**, now per plane: a LIVE lease is refused and
  named, a key nobody holds is reported rather than guessed at, the destroyed payload is
  printed with its plane so the decision stays auditable, and the journal line carries it.
- **A remote that cannot be read says so** instead of answering "no such lock" — that
  answer reads as *there is nothing to clear* over state that may well be there.

Three cases in `test/claim_cell_test.py` (27 total): the override clears a dead run's ref
and proves it gone, a live ref is still refused, and an unreachable remote does not read
as a missing key.

## v1.20.1 — the filter that was never read, and the check that would have said so

Claude Code 2.1.270 prints `agent-sync: hooks.json: unknown key "if" in
hooks.PreToolUse[1] ignored` at every session start. The key was there since 0.1.0.

- **`"if": "Bash(git commit *)"` sat beside `matcher`, and a matcher group is only
  `matcher` + `hooks`** — read out of the 2.1.270 binary's schema, where `if` exists on a
  command HANDLER only. So the filter was declared and never evaluated: `guard.sh` has run on
  every Bash call in a coordinated project for its whole life, narrowing to a commit with its
  own parser. Nothing in behaviour changes with the key gone; the warning does.
- **Removed rather than moved.** A handler-level `if` would be real, and a real
  `Bash(git commit *)` skips `git -C <dir> commit`, `env X=1 git commit` and
  `cd d && git commit` — the three forms the parser covers and the 2026-08-07 measurement was
  about. hooks.json's description, `guard.sh`'s header, `references/hooks.md`, the README hook
  table and the live AS-09 row now say the same thing. `CHANGELOG` v1.18.6 and the 2026-07-29
  spec keep their wording: dated records.
- **`check_hooks_manifest` now refuses any key Claude Code does not know** — at group level
  `{matcher, hooks}`, on a handler `{type, command, args, if, shell, timeout, statusMessage,
  once, async, asyncRewake}` — with a self-test plant that puts the old `if` back and watches
  the check fire. `claude plugin validate --strict` passes the defective file, so a repo gate
  is the only place this fails before a session start.
- `test/audit_regressions/fix-sy-07.01.py` derived "git commit is covered" from the group's
  `if` — a declaration that never filtered. It now asserts the matchers, the description and
  the absence of `if` at both levels.

## v1.20.0 — the two-sources duty stops carrying its own elaboration

Sherlock external-v3 (14 findings) plus the House audit gap they introduced.

- The audit's doctrine grew SKILL.md past the house working limit, and the rule
  for that is a SPLIT, not a trim. The `Two documentation sources` section keeps
  what a reader needs before opening anything — the two questions, the gap IS the
  finding, the duty at both ends, and that `reconcile` is mechanical so its green
  is not agreement — and hands the rest to `references/two-sources.md`, which
  already held every one of those points in full.
- `references/adapter-contract.md` passed 100 lines and gained a `## Contents`.
- CI now MEASURES the token budget with a real tokenizer instead of estimating it.

## v1.19.3 — the boundary ships with the skill, and two descriptions stop looking like drift

Close-out of the 2026-09-06 family audit (wave AUDIT-WAVE-0906). One bullet
enters the skill body, one decision gets a written home; no behaviour changes.

- **The refusal the operator's routing block promises now travels with the
  skill.** The routing doctrine names this router's boundary — a file in a
  project where coordination is on — and a waiver, **"no coordination" /
  «без координации»**, and neither phrase appeared anywhere in the shipped
  `SKILL.md`: a standalone install advertised no refusal at all. One bullet in
  Non-negotiables now carries both. The body had **3 tokens of headroom**
  (~4747/4750 against the house working limit), so the line displaced prose
  rather than joining it — standing instruction 7 — and the auditor was watched
  refusing both oversteps on the way: a first draft measured 4753
  (`GAP BODY_HEADROOM`), the second exactly 4750 and still refused, the shipped
  text ~4746/4750 after trimming one motivational sentence from the `status`
  section and three words from the `$SKILL_DIR` paragraph. `audit_skill.py
  --house`: **0 GAP, 15 PASS**.
- **`plugin.json` and `marketplace.json` describe this plugin in two entirely
  different texts — now recorded as deliberate, which is what makes it
  distinguishable from drift.** The audit's finding was not that the texts
  differ but that nothing RECORDED whether they should, and a sibling shipped
  accidental manifest drift this same week. They should: the marketplace entry
  is the storefront read before install and carries the lease doctrine in full —
  that surface is where the 1.5.2 audit caught this repo selling the refuted
  no-CAS design, and `check_doctrine_is_current` still watches it — while
  `plugin.json` is the installed listing's one-line layer summary. JSON takes no
  comment, so the decision lives where this board's decisions live
  (`docs/evidence/backlog.md`, beside B-002 and B-004), naming both texts and
  which surface reads which.

## [1.19.2] - 2026-09-04

### Fixed

- **An expired foreign lease printed at every SessionStart for ever, and the block it sat
  in stopped being read.** Measured on the machine that filed the issue: sixteen expired
  leases, none newer than two days, the oldest **thirteen days old** — six printed in full
  plus *"… and 11 more"*, identically, every session for a fortnight. None of it was
  actionable: a foreign lease is reported and deliberately never touched, so the operator
  could not clear a single line of it. A block reprinted verbatim is a block an operator
  learns to skim, **including the lines that would have mattered**.

  Age splits the list now. A lease expired inside `STALE_DETAIL_SECONDS` (24h) may still
  mean a run is in trouble and keeps its own line; anything older collapses into **one**
  summary — which still **names the keys**, because a summary that hides which leases are
  stuck replaces noise with a different uselessness. Sixteen lines became one.

  Watched failing before it shipped: with the split removed the new check reports
  *"6 ancient lease(s) still print a line each"* and *"nine ancient leases produced no
  summary line at all, so they vanished"* — the second assertion exists because collapsing
  and hiding are one edit apart.

## v1.19.1 — re-acquiring your own lease now refreshes it

**`renew` was fixed for exactly this and `acquire` was not, in the same file.**
`_refresh_lease`'s docstring records the bug it exists to have fixed: *"`renew`
appended `op=renew` to the RECORD plane — which has not decided a lease since 1.0.0 —
and touched a throttle file. The lock's own `ts` was written once, by `acquire`. So a
run holding a lease lost it at TTL while still working."* The repair landed in `renew`.
The own-lock branch of `acquire` kept doing precisely what that paragraph describes.

**Two harms, and the second is the one that bites.** The lock stayed expired, so
`classify_lock` read it as spent and another run's `acquire` took it over while this one
believed it held — measured 2026-09-01: a foreign expired lock IS taken over, timestamp
and host rewritten. And `_touch_renew` resets the marker `renew()` throttles on, so a
re-acquire **actively suppressed the next real refresh** while refreshing nothing
itself. That is the mechanism behind a lease expiring three times in one run against a
450-step CI job.

Measured before and after, on a lock aged past its own TTL:

```
old: won mykey (…)   ts after re-acquire: 2026-08-29T20:59:04Z   still expired
new: won mykey (…)   ts after re-acquire: 2026-09-01T12:38:48Z
```

`check_acquire_refreshes_its_own_lease` is beside `check_renew_extends_the_lease`,
watched failing against the previous commit with both of its assertions.

**One finding retracted in the same run, and it belongs here.** `reap` refuses to clear
a lock three days past a forty-five-minute TTL when this run's identity is the shared
fallback, and that reads like a deadlock. It is not: `acquire` takes over an expired
lock regardless of who wrote it — measured, timestamp and host rewritten — so the
expired lock blocks nothing, and `reap`'s stricter bar for a *tidying* operation is
correct as designed. The claim that expiry alone should license a delete was wrong
about which operation matters.

## v1.19.0 — the skill now promises the finish it has always performed

Wave-4 close-out of the 2026-08-29 family audit (ASY-10), filed by this
repository's own first eval run. One front-matter line changes; no behaviour
does.

- **ASY-10 — the submodule-finish request reached nothing, and now reaches this
  skill on every sample.** `finish` exists for exactly one silent failure — a
  submodule pushed while the parent still points at the commit before it — and
  the description advertised none of its words, so "Finish a submodule change so
  the parent points at the pushed commit" (eval q06) routed past the skill on
  every probe of the v1.18.7 run. The *Use when* clause now names it, in the
  words `finish` itself prints (`finishing work across a parent repo and its
  submodules - clean, pushed, and pointed at`), and the trigger list carries the
  symptom in both languages: `'the submodule is pushed, the parent points at the
  old commit' / 'сабмодуль запушен, родитель на старом коммите'`. The
  description is **951 characters — against the 970 house working limit, not the
  1024 spec cap**: a first draft at 1017 passed `test/validate.py` and was
  refused by the family's pinned auditor (`GAP DESC_HEADROOM`, exit 1, a CI
  failure), so it was rewritten rather than shipped. `yaml.safe_load` reads the
  front matter — the two things this family has shipped broken before.
- **The fix is measured, not asserted, and both arms ran in this session.** Two
  corpus files differing in exactly one line (`- agent-sync: …`, old vs new),
  one enforced protocol, fresh context-free subagents: **q06 went 0/3 → 3/3 on
  haiku and 0/3 → 3/3 on sonnet**, and every number in the results file comes
  from the text that ships — the discarded 1017-char draft was re-measured from
  scratch after the rewrite rather than having its numbers carried over. The
  pilot probes are reported too, because
  they changed the protocol: offered the corpus rather than required to read it,
  all three sonnet probes answered with no tool call at all, from the machine's
  own installed listing — so the read was made mandatory and provable, and both
  arms re-run under it.
- **Every other query was re-probed, because a widened trigger steals a
  neighbour's.** All twelve queries × two models: **12/12 on both, train 6/6 and
  validation 6/6**, up from 11/12. Nothing moved except q06 — including q07
  (solo git branching) and q08 (the full delivery cycle), the two negatives with
  the most to lose from submodule and finish vocabulary. Tables, the 68-probe
  accounting across three arms and the limits are in `test/evals/RESULTS.md`;
  the v1.18.7 rows are left standing beside the new ones rather than rewritten.
- Ten routed triggers still advertised — the umbrella's own
  `advertised_check.js` was run against this checkout (`ok: agent-sync
  advertises all 10 routed trigger(s)`), not a copy of its table.
- **Filed, not fixed: AS-10.** `npm test` accepts a description up to 1024
  characters and CI's pinned auditor refuses one past 970, so a description in
  that band goes green locally and red on the runner. That is AS-06's shape a
  second time — the same auditor, the other half of the same budget — and the
  fix belongs in `test/validate.py` with a planted self-test, not inside a
  release whose subject is one description line. The retro entry records what
  the divergence cost here.

## v1.18.7 — the evals stop being a claim, and the manifests say what shapes them

Wave-3 close-out of the 2026-08-29 family audit (ASY-03, ASY-09). No behavior
changes; a measurement that did not exist now does, and two manifests declare
their schemas.

- **ASY-03 — the behavioral evals were executed, dated, and the numbers are worse
  than silence pretended.** `test/evals/RESULTS.md` had one row: "no run yet".
  It now carries two dated rows (2026-08-31, haiku and sonnet): 24 blind
  fresh-context trigger probes — one per query per model, the query verbatim plus
  the installed family list, nothing else — and all six scenario lines driven as
  real agent sessions against scratch repositories, end states verified on disk.
  Both models score 11/12 on triggers (train 6/6, validation 5/6) and fail the
  SAME query: **q06, the submodule-finish positive, 0/3 samples on each model** —
  the description has no submodule vocabulary to catch it. That gap is filed as
  ASY-10 (a description change is a minor release, not this patch). The Method
  section states the harness limits out loud: n=1 sampling except the failing
  query, the operator's routing doctrine present in probe context, fs-backend
  `reserve` refusing by design in s02, model aliases rather than pinned snapshot
  ids. SKILL-CARD.md's "never executed" line now points at the dated rows.
- **ASY-09 — both plugin manifests declare `$schema`.**
  `.claude-plugin/marketplace.json` names
  `https://json.schemastore.org/claude-code-marketplace.json` and
  `plugins/agent-sync/.claude-plugin/plugin.json` names
  `https://json.schemastore.org/claude-code-plugin-manifest.json`; both files
  validate against the schemas those addresses serve (fetched and checked at
  edit time), and `claude plugin validate --strict` stays green on both. The
  repo's own no-host-address rule refused the plugin manifest — correctly, by
  its letter — so `json.schemastore.org` joined `ALLOWED_HOSTS` beside
  `json-schema.org`: a vendor-neutral public schema registry, not an instance
  address. The planted `leaked host` self-test still proves the check can fail.

## v1.18.6 — the guard keeps its own header's promise, and /clear gets its own identity

Wave-2 hardening from the 2026-08-29 family audit (ASY-07, ASY-08, ASY-06, ASY-01).
Same shape as v1.18.5: text that claimed more than the code did.

- **ASY-07 — a machine without python3 had no guard, and said nothing.** `guard.sh`'s
  header promises that every internal failure exits 2, because any other outcome is
  non-blocking in Claude Code — and the python3-absent path broke it: both parser
  substitutions failed under `2>/dev/null`, `path` came back empty, `is_commit`
  defaulted to 0 and the hook exited 0. Fail OPEN, exactly where the header says it
  must not, and nothing upstream compensates — hooks.json's `if` filter is best-effort
  by doctrine, so the parser IS the guard. Both substitutions now report through
  `parser_or_die`, which exits 2 and names the remedy (install python3 / fix PATH, or
  remove `.claude/agent-sync.json` to switch coordination off).
  `check_guard_fails_closed_without_python3` drives the real hook through a shim PATH
  holding bash and cat but no interpreter — watched failing against the shipped guard
  (`the guard exited 0`) — and the self-test plants the fail-open back
  (`an absent interpreter fails open`).
- **ASY-08 — a post-/clear session inherited the ended run's identity.** The
  SessionStart matcher read `startup|resume`, and `/clear` ends the run while keeping
  the CLI process — so the stamp keyed by that process survived into the next session:
  "two sessions, one identity", the case SKILL.md itself names, and `release` then
  takes a lease the caller never had. The matcher now carries `clear`; `compact` stays
  excluded deliberately, because a compaction continues the SAME session.
  `hooks_session_test.py` gained the case (matcher parts asserted both directions, and
  the hook re-stamps on a clear payload) — watched failing against the shipped matcher.
- **ASY-06 — the guard's internal-failure refusal named no next step.** A refusal with
  no remedy is how an operator learns to switch a hook off. The message now says:
  diagnose with `python3 <script> check`, or acquire a lease first.
- **ASY-01 — `scripts/__pycache__` regenerated forever.** Hooks now export
  `PYTHONDONTWRITEBYTECODE=1` in `_lib.sh` (sourced by all four), and the test files
  that import `agent_sync.py` set `sys.dont_write_bytecode` — the shipped `scripts/`
  directory stays source, not build products.
- ASY-04 (this test class missing from CI) was already closed in v1.18.5:
  `validate.yml` runs `hooks_session_test.py` since commit `5446990`. Verified, not
  re-done. ASY-02 (command rename) closed by operator decision 2026-08-30: names stay.

## v1.18.5 — a pipe reaches the guard, and the installers stop deleting blind

Two enforcement holes, each of the same shape: a mechanism whose own text claimed more
than its code did.

- **ASY-05 — a piped commit bypassed the lease guard entirely.** `guard.sh`'s tokenizer
  said "each `&&`/`;`/`|` segment is its own command" and consumed only `&&`, `;` and
  `||` — so `echo msg | git commit -F -`, the ordinary way to commit a generated
  message, was one segment whose first token is `echo`, and the whole pipeline skipped
  the guard. The single pipe and `|&` are now consumed (ordered after `||`, or each
  would shatter into stray halves), three new `GUARD_SHAPES` cover the bypass and the
  pipe that must NOT block (`git log | grep commit`), and the self-test plants the
  shipped tokenizer back (`a piped commit slips past the guard`). Watched failing
  against the pre-fix guard: both piped shapes reached a guarded file with exit 0.
- **The installers consulted nothing before deleting the Claude Code channel.** This
  member's installers never write `~/.claude/skills/agent-sync` themselves — the skills
  CLI they drive recreates it — and both deleted that copy unconditionally afterwards.
  The family canon this implements (make-skill v0.25.0, distribution.md §3) names the
  fail-open class, and here it ran in mirror image: on a home where the plugin is NOT
  installed — no claude CLI, or the plugin install failed — the prune destroyed the only
  Claude Code channel the very same run had just installed, and exited 0. The fate of
  the copy is now a decision read from the target home's
  `~/.claude/plugins/installed_plugins.json` (the record of what is installed; plugin
  and marketplace names differ, so the spec is taken from the JSON), with the
  `marketplaces/<name>` dir kept only as the fallback signal: plugin present → the
  shadow is pruned and the message names the real spec, the plugin-channel remedy and
  the family launcher; no plugin → the copy is kept, because it IS the Claude Code
  channel; `--force` → kept beside the plugin, as the recorded choice to run two
  channels where the stale one wins. Absent or corrupt JSON reads as "no plugin" — fail
  open, never crash. Only the Claude Code channel is gated; other agents' installs are
  untouched.
- **The install now says how the next version arrives** — `npx @ssheleg/agent-sync@latest
  update`, or the family launcher — because an installer that never mentions updates has
  still chosen an update model: never.
- `test/installer_test.js`: 11 cases against throwaway HOMEs with the delegated CLIs
  stubbed through PATH — plugin-present prune with the spec from the JSON, a
  differently-named marketplace, `--force`, corrupt JSON, a prefix-collider
  (`agent-sync-extra@x`), the marketplaces-dir fallback, the update path, and the
  install.sh mirrors. Ten of eleven watched failing against the pre-fix installers.
  Wired into `npm test` and CI; `hooks_session_test.py` — already in `npm test` —
  joins CI in the same step block.

## v1.18.4 — the channel that sends the installs, on npm too

- The `skills.sh` badge and the canonical `homepage` reached GitHub in the previous cycle and stopped
  there: npm serves the README and the metadata from the last **publish**, so the package
  page still showed a badge-less README and a homepage pointing at GitHub.
  This release carries both across.
- No behaviour changes. Cut because a change that lands on `main` and never publishes is a
  change the package's own readers cannot see.

## v1.18.3 — the shared seam is explicit

Both shared validators now state `diverges: none`, completing the umbrella
mechanism contract.

## v1.18.2 — shared guards identify their owner

The eval and social-preview validators now declare their umbrella-owned shared
mechanisms, so the family drift gate can verify the seam without treating the
copies as unrelated implementations.

## v1.18.1 — the coordination skill publishes its own trust surface

A root skill card now states the credential, backend and enforcement boundaries.
Portable trigger and behavioral evals cover leases, id reservation and the
submodule finish protocol, while recording no model score that was not run. The
README's first viewport is installable, CI adds the pinned house audit and eval
plant, and the generated social preview is committed at 1200×630.
The open Bash-guard gap is now `AS-09`, removing its collision with the already
closed token-budget row `AS-06`.

## v1.18.0 — a third backend, and two people who can finally see each other

`fs` keeps the record on one machine and `outline` costs $10 a month for a team; between
them sat the case this release is for — **two people, two machines, one plane, nothing to
pay**. `notion` is that plane. The container is a page, every log is a child page of it,
and every line is one paragraph block appended by `PATCH /v1/blocks/{id}/children`, which
is a server-side append with no read-modify-write anywhere in the class.

**The capability flags are earned, and the earning found two defects.**
`test/notion_live_test.py` ran against a live workspace on 2026-08-25 and passed on the
fourth attempt: `200 appends from two processes, 200 lines on one page`, `two reads return
one order`, `tree.ensure is idempotent`, `both fresh shards enumerated, 200 lines across
them`, and the forced degradation. The three runs before it are the reason to trust the
fourth. Run two went red at 171 lines with a dead writer: the retry path had never executed
in run one, and it turned out `TimeoutError` is an `OSError` rather than a `URLError`, so a
read that timed out **after** the connection was established fell past both retry branches
into the catch-all and failed permanently. Run three went red differently — 100 lines, all
from one writer, both exiting 0 — because the two writers each called `tree.ensure` on a
shared title and the check-then-create raced into two pages of that name. That one is a
finding about the measurement: `Sync.log_id()` says *this run's OWN shard, one writer per
document, always*, so the protocol never creates it. The contention case is now handed the
page id, and the case the sharded design actually depends on took its place — two fresh
shards, both enumerated, which is precisely what Outline's search index could not do.

The suite covers both halves of the retry loop now. The success half had never existed:
every assertion drove a transport that always failed, so nothing measured whether a retry
can succeed — a loop that cannot is a loop that only fails more slowly.

It needs credentials and a network, so it stays outside `npm test` and **says SKIP loudly**
rather than passing quietly where it cannot run. `exclusiveLease` stays false and no
measurement can move it: Notion has no compare-and-swap, and the endpoint's own
`conflict_error` is a retry hint rather than an arbiter.

**One registry of backends, because three copies of a two-item list is how a backend comes
to half-exist.** `CLOUD_ADAPTERS` is the single home; `init`'s argument parser, `check`'s
known-adapter test and `bootstrap`'s dispatch all read it. `bootstrap` used to build an
Outline collection whatever the project was configured for — on an `fs` project it demanded
Outline credentials for a backend that has no container at all. `check`'s credential test
now asks the adapter (`REQUIRED_ENV`, `preflight()`) instead of naming Outline's variables
in an `if`, and the Notion preflight is a real call — `GET /v1/users/me` — because "knowledge
base reachable" printed after a function that only parsed a string is the defect class this
suite exists to refuse.

**B-34's other half.** `_allocated_ids` still read `nextFreeIdPattern` directly while
`id_pattern()` beside it accepted both keys, so a config written with the modern `pattern`
key never discarded its own *Next free ID* line: `--set-baseline` stamped one above reality,
and every id at the true top read as pre-baseline and was never asked for an as-built
record. Reproduced in `fabric` on 2026-08-25, fixed here, fixture plants it back.

**`FsAdapter`'s docstring said its files are "committed and pushed"** while `check` reports a
tracked `.agent-sync/` as a problem — a committed run id hands one checkout's identity to
every clone. The check was right; the line now says so.

**And a defect in the suite itself.** `Sync()` calls `load_env_file`, which writes the
project's variables into `os.environ` — correct for the CLI, a leak in-process. Two checks
built temporary `fs` projects and left `AGENT_SYNC_BACKEND=fs` behind, and that variable
OVERRIDES the configured backend, so the next check to depend on it silently read the
previous check's project. The new dispatch check passed alone and failed in the suite, which
is the signature of this class rather than of the code under test. Every check now runs
through `_guarded`, which restores the environment and the working directory after it.

Five checks, five planted defects: 51 → 56 fixtures.

## v1.17.0 — the identity that had never once been established

`_session_key()` falls back to one `shared` entry per checkout when it cannot tell which
session is asking, and everything downstream hedges about it: `classify_lock` answers
`ambiguous` rather than `reapable`, so an expired lease can never be cleared by the run that
took it. The fallback was documented as the rare case.

It was the only case. Measured 2026-08-25 in a checkout that had been running the tool all
day: `.agent-sync/sessions` did not exist, and `.agent-sync/run-id` held exactly one key —
`shared`. The stamping block in `session-start.sh` required `CLAUDE_SESSION_ID` in the hook's
**environment**, and Claude Code delivers the id to a hook on **stdin as JSON**, which is how
`guard.sh` next to it has always read its own payload. So the block had never run, in any
session, since it was written. A fallback that is always taken is not a fallback.

The hook reads its payload now, with the environment variable still winning where it exists.
`test/hooks_session_test.py` runs the real hook as a process — the only way this was ever
going to be caught, because every unit around it was correct — and covers the four ways it
must behave: an id from stdin is stamped, an id from the environment still wins, a payload
carrying no id stamps nothing rather than keying every session alike, and a payload that is
not JSON leaves the hook exit 0, because a SessionStart hook that throws takes the session
with it. A fifth case walks the whole chain: stamp, then the descendant's key, then the
run-id map, then `classify_lock` returning `reapable` where it used to return `ambiguous`.

Watched failing against the previous hook: 2 of the 5 cases red, and green after.

Also: the README no longer tells a reader to run `python3 test/validate.py` and `npm test`
from a package that ships no `test/` directory. It names where they run.

## Unreleased — both `AS-01` halves exercised outside their fixtures

The two rows sat at priority `unverified`: shipped, and confirmed by nothing but their own
fixtures. Both are now exercised against real state, and both hold. **Nothing in the tool
changed** — this records that the mechanism was watched working on something other than its
own test data, which is what `unverified` meant.

**The git plane, on a real remote.** A git-mode checkout was built against a local bare
remote, a lease taken at `ttl 2`, and its local note **deleted** — exactly the state a ref
won on another machine leaves behind, and the state the row said `residue` could not see.
`residue` printed *nothing in the lock directory* and then enumerated the git plane:
`refs/agent-sync/leases/DEMO-KEY @ 495370743e`, run `r-rverifya`, **expired 18s ago**,
`foreign`, closing with *1 ref(s) on the remote, 0 this run can prove it owns and has spent*.

**Reap, in both directions.** Reaping as a **different** run left the ref standing and said so
by name. Reaping as the run that took it removed it and reported *confirmed gone by re-reading
the remote* — the proof coming from `ls-remote`, not from a push's exit code.

**And on live residue.** `~/DATA/0xDEV` carries a genuine foreign expired lock —
`BLOG-SITEMAP`, run `r-blog-1429e`, **expired 4d 22h ago**. `reap` named it, said *left alone
— it belongs to run r-blog-1429e, not to this one*, and **deleted nothing**: both lock files
byte-identical afterwards. State a run cannot prove is its own is reported and untouched.

## v1.16.0 — the release that closes one version string over two trees

**281 lines of shipped behaviour had been sitting behind the tag.** `AS-01a` (the
git plane is swept, not only disclosed) and `AS-01b` (an ambiguous lock is
clearable by a person, per key, attributably) landed on `main`, were pinned by the
umbrella, and were never tagged. So three channels served `1.15.0` and two
different trees:

| channel | source | `agent_sync.py` |
|---|---|---|
| npm `@ssheleg/agent-sync@1.15.0` | the tag | 4344 lines |
| the plugin marketplace | the branch tip | 4575 lines |
| the skills CLI | the branch tip | 4575 lines |

All three reported `1.15.0`. `check_pins.py` was green throughout — correctly,
because it compares the version STRING, and the string did match. A version that
identifies two artefacts cannot be reasoned about, and the family's own invariant
("the pin is the promise") was satisfied to the letter while being false in
substance.

Found on 2026-08-23 by fetching the npm tarball and counting lines in all three
channels rather than trusting any of them. This release makes the number true.

Also in this range: the release workflow refuses a tag whose commit no clone can
reach, and the evidence ledger records both `AS-01` halves exercised on a real
remote rather than in a fixture.

## v1.15.0 — a claim tag outlived its lease, and no command reached it

**GitHub issue #5, filed 2026-08-17, reproduced verbatim at v1.14.0.** A board row shipped
in a published release reading `(claimed: r-6e62c4dab)` while the lease plane said
`leases held: none`. `release <key>` printed `released`, exited **0** and changed nothing;
`residue` said *nothing on disk*; `reconcile` found *no mechanical divergence*. Three
commands, none of which could reach the tag, because `write_claim(key, None)` restores the
cell for the key it holds and it held none.

This is #4 from the other side. #4 was a lease that outlived its run; this is a tag that
outlived its lease — and the second is worse, because an invisible lease invites a check
while a confident wrong answer does not. Closed the same way: **one notion of held,
consulted by both planes.** `_lease_holder` stays the single reader, memoised per command
so a report over a tagged board costs one `ls-remote` per *tagged row* in git mode rather
than one per board row. `orphan_claims()` classifies a tag `orphan` — the TTL has ended its
lease — or `disputed`, live under another run, which is reported and never touched.
`release` clears an orphan and names the run it belonged to. `status`, `residue` and
`reconcile` each report one, so the disagreement is visible to a gate rather than to a
diff.

Four more, each with its plant watched refusing:

- **`residue` read as a complete answer in git mode and could not be.** It walks
  `.agent-sync/leases/*.lock`, which only the local plane writes, so a ref won on another
  machine was invisible. It now prints `⚠ INCOMPLETE IN THIS MODE` with the `ls-remote`
  command it does not run. The sweep itself is still open, and the row says so — a check
  that cannot look must not read as one that looked.
- **A `local` lock recorded no host**, so residue could not tell a lock written here from
  one written on another machine; 25 of 25 locks on this machine carried none. Both modes
  write it now, with a fixture separating two machines.
- **The ledger described an artifact nobody shipped**: its newest section was headed
  *(in tree, unreleased)* and quoted `PASS: agent-sync v1.13.0` while v1.14.0 was tagged,
  in `package.json` and on npm. Guarded three ways.
- **Two divisors for one token budget.** The family's auditor measured the pack's body at
  ~5084 tokens against a 5000 limit; this repo's own gate divided by 4 and passed at 4957.
  One divisor now (3.9, the auditor's), and the body is under it at ~4683 after a split.

Also corrected: the board's *"14 of the 17 live in repositories this row must not touch"*
recomputes to **16 outside this checkout, 13 outside it and the umbrella**, with the `find`
that produces each.

**Found while doing it:** a plant had stopped planting. `the script path is prose only`
substituted a paragraph that had since been reflowed, so it changed nothing — and a no-op
plant still reported `detected` for a check that never ran. Re-anchored on the values the
check actually reads.

Self-test fixtures 43 → **51**; the claim-cell suite 9 → **16 cases**.

## v1.14.0 — expiry ended a lease and left the file, and every reader folded that away

**`status` reported `leases held: none` over three expired locks in the directory it had
just read.** Not a bug in one function: *every* reader of lease state folded the TTL into
the read. `held()` keeps only what is this run's **and** alive; `_lease_holder()` returns
`None` for an expired lock; `all_holdings()` drops it. All three are correct for
exclusion — an expired lease is not held — and all three therefore give one answer for
*expired* and for *absent*. There was no fourth reader, so nothing could tell the two
apart, and `finish` printed `✓ no lease left held` beside a two-day-old corpse.

Measured across the family when the conformance audit went looking: **17 expired lock
files across 9 checkouts, the oldest 3 days 11 hours.**

### The mechanism

`classify_lock()` — pure, all arguments in — reads every `.agent-sync/leases/*.lock` as
`live` · `reapable` · `foreign` · `ambiguous`. `status` and `finish` report residue
instead of silence. Two new verbs: `residue` (report only, safe in any checkout) and
`reap`, which clears **only** provably-own spent state and refuses foreign or ambiguous
state out loud, with exit 1, even when it is named on the command line.

`reapable` requires four things together, and anything short of all four is reported and
never deleted: the lease is spent with a parseable clock; it records a run; that run is
this one; and the run id means something. That last clause carries the rule — a shell
with no session id is served one **shared** identity, so a matching run id under it
proves nothing and does not license a delete. In doubt: `ambiguous`.

**Teardown is verified by re-reading the directory**, not by trusting the delete's return
value. Driven by hand: with the lease directory made read-only, `reap` exits 1 with
*"MINE is STILL PRESENT after the delete … the teardown was not verified, whatever the
call returned"* — and the lock is still there, which is the point.

### Standing instruction 9

*A predicate cannot report the condition it folds into its answer.* That is the class,
and it is now in `docs/evidence/retro.md` rather than in this entry alone.

Self-test 38 → 43 fixtures, every one detected. Latency measured, not assumed: `status`
0.39 s against 0.31 s before; `guard` unchanged at 0.15 s — residue is not on the guard
path.

## v1.13.0 — the file carried two notions of *held* and they disagreed where it mattered

**A lease from a run that died could not be cleared by any command.** Measured in the field:
three locks past their TTL, one of them by **604x**, and the only way out was deleting the file
by hand — which is the single thing a coordination tool exists to stop anybody doing. The
refusal itself was correct; there was simply no path.

The cause is one function. `acquire` has always known a TTL runs out — `_steal_expired` exists
for exactly that. `_lease_holder`, which `release` consults, read **any** lock file as held and
never looked at the timestamp. So an expired lease was *gone* for `acquire` and *eternal* for
`release`, and the file never noticed it was answering one question two ways.

`_lease_alive()` is now the one definition, used by both. `release` reaps an expired foreign
lease and **says whose it was**, because the operator asked to release their own lease and is
getting somebody else's corpse cleared alongside it.

**What did not change is the half that matters.** A lease inside its TTL is still refused, and
that is fixtured beside the reap so the two cannot drift. An unparseable timestamp is treated
as expired rather than as a licence to hold forever — a corrupt lock was the other way to reach
the same unclearable state.

Fixtures 6 → **9**, each driving the shipped script as a process against a real project.
Closes #4.

### Also closed by measurement: #1

`claimTags` whole-row id matching made the claim unwritable on boards with a `Depends` column —
filed against **1.3.5**, and fixed by **B-42** on 2026-08-14 without anyone connecting the two.
Re-run against this version on the exact board shape the issue names (`| id | what | acceptance
| depends | decisions | status |`, with `T-02` and `T-03` both citing `T-01`):

```
$ agent_sync.py acquire T-01
docs/ROADMAP.md: `T-01` claim written through
| T-01 | … | open (claimed: r-cca8b75db) |
| T-02 | … | open |          ← untouched
| T-03 | … | open |          ← untouched
```

Closes #1.

## v1.12.0 — 246 kB of someone else's bytecode, in every install

**The published tarball carried a `.pyc` both ignore files were written to exclude.**
`@ssheleg/agent-sync@1.11.1` ships
`plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-312.pyc` —
**245.8 kB against 175.7 kB of source beside it**, 40% of the tarball, compiled by
whatever interpreter the publisher happened to be running. Verified by unpacking the
published artefact, not by reading the manifest.

`.gitignore:3-4` and `.npmignore:1-2` both exclude `__pycache__/` and `*.pyc`. **Neither
is consulted once `files` names a directory** — the whitelist wins, so the intent was
recorded twice and enforced nowhere. `files` now carries `!plugins/**/__pycache__` and
`!plugins/**/*.pyc`, and the packed result drops from **231.5 kB / 28 files to
125.8 kB / 27**.

**A filesystem walk would not have caught this**, which is why the new check asks npm.
`check_the_tarball_carries_no_bytecode()` reads `npm pack --dry-run --json` — the
packer's own answer to *what would ship* — and fails on any `.pyc` or `__pycache__` in
it. Where npm is absent it discloses rather than passing: a check that cannot look must
never read as one that looked. Watched failing against the exact state that shipped, and
added to `--self-test`, now **38 fixtures**.

### Fixed — three documents that told the reader something untrue

- **`references/hooks.md` said the hooks are removed by editing `.claude/settings.json`.**
  Nothing here ever writes a `hooks` block there, and Claude Code has no per-hook disable
  for a hook a plugin ships. A user who wanted them gone edited a file with no such block
  and concluded it had worked, while the SessionStart hook kept speaking in every session.
  The two real levers are named now — `enabledPlugins[…] = false` or `plugin uninstall` —
  plus the fact that every hook already self-disables in a project with no
  `.claude/agent-sync.json`.
- **`CONTRIBUTING.md` said five version surfaces move together; the validator enforces
  six.** The missing one is `VERSION` in `scripts/agent_sync.py`, and it is exactly what
  forced the 1.11.1 patch: a bump driver written from that page moved four of six and CI
  refused the tag. It is the constant `status` prints into every session, so its drift is
  invisible in the manifests and loud in the banner.
- **`CONTRIBUTING.md` said the self-test injects five defects; it injected thirty-seven.**
  The count is read off the run's own last line now. The number is the whole claim — a
  contributor who reads *five* will not think to add a fixture for the sixth thing they
  change.

Found by the nine-repository audit of 2026-08-16 (umbrella `B-72`; the three documents
are `F-agent-sync-05`, `-08` and `-11`).

## v1.11.1 — the gate can see an invariant it breaks elsewhere

**This gate can now see an invariant it breaks one repository away.** The family umbrella
routes work by matching a prompt against a table in `lib/triggers.js`, and every trigger
there must be a word this skill's own `description` advertises. Nothing here knew that
table existed. On 2026-08-16 `sheleg-design` 1.37.0 shipped green having dropped a phrase
that was still a live trigger, the umbrella found out minutes after the tag, and it cost a
patch release — because the member releases FIRST and the umbrella re-pins after.

`test/validate.py` now asks the umbrella's own checker (`test/advertised_check.js`), which
reads the module the hook itself calls. **No copy of the table lives here**, so there is
nothing to drift. With no umbrella above this checkout — a standalone clone, and CI — it
discloses rather than passing, because a check that cannot look must never read as one
that looked.

Watched refusing a real drop before shipping: every one of the seven members carrying
routed triggers had one of its own advertised phrases removed and every one of them failed
its own gate.

## v1.11.0 — releasing stops rewinding a cell, and a register with no pattern says so

Two more defects in the same afternoon that produced v1.10.1, both from using this tool
on a board that had just been worked through.

### Fixed

- **Releasing no longer rewinds a cell that moved.** The restore was verbatim, and in this
  family the claim cell IS the status cell — so `close then release` silently reopened a
  row closed with evidence minutes earlier. Caught by `finish` reporting the board
  uncommitted, not by anybody reading it. The protocol's intent is to remove *this run's*
  marker, not to rewind the cell, so a changed cell keeps its change, loses only the
  marker, and the note says so. An untouched cell is still restored exactly.
- **An id register with no pattern reports instead of crashing.** The script read
  `nextFreeIdPattern`; every config this family ships writes `pattern`. Both are accepted
  now — but the defect was the fallback: absence became `re.search("", text)`, which
  matches the empty string at position 0, so `check` took the found branch and died with
  `IndexError: no such group` rather than saying the register has no pattern. A component
  that never received its input approved and then fell over.

### Added

- **`test/claim_cell_test.py`** — 6 cases driving the shipped script as a process against
  real project directories, because these defects are about what the command does to a
  file on disk. **Four were watched failing** against the pre-fix script, including that
  exact `IndexError`. One of the six is a regression test for v1.10.1's own fix: a board
  where an id is cited by two other rows must still tag the row that owns it.

## v1.10.1

The claim tag could not be placed on nearly half a real board, and the lease was granted
anyway.

### Fixed

- **A row that CITES an id no longer defeats that id's claim.** `claimTags` in `cell` mode
  selected every row *containing* the id, so an ordinary board sentence — "closed by
  B-12", "blocked until T-1 ships" — made the id ambiguous and the tag was refused.
  Cross-referencing is what boards do: measured on `sshlg-skills`' own board on
  2026-08-14, **14 of 41 rows cited others, leaving 19 ids that could never be tagged**.

  The refusal itself was correct — guessing which row carries a claim is how a claim lands
  on somebody else's work. What was wrong is that `acquire` still returned `won`: the
  lease was granted while the registry silently carried no claim, which is precisely the
  state a claim exists to make visible.

  A markdown board declares which row is which in its **first cell**. `acquire` now
  narrows to the row whose id cell equals the key, and refuses only when no row's id cell
  matches. Tried *after* the existing marker narrowing, so `release` still trusts the
  marker it actually wrote rather than re-deriving the row.

  Covered by `check_claim_lands_on_the_row_whose_id_cell_matches()`, which builds a two-row
  board where the second row cites the first, and asserts the tag lands on exactly one row
  and that it is the right one — then that release still restores the file byte for byte.
  Watched failing before the fix: *"acquire wrote no claim — a row citing T-1 defeated the
  selector, and the lease was granted with the registry unmarked"*.

## v1.10.0

Following `task-pipeline` v1.53.0, which renamed the artifact root's default from
`docs/superpowers/` to `docs/evidence/` and made the root resolvable (`paths.artifacts`
in `pipeline.json`, else an existing `docs/evidence/`, else an existing
`docs/superpowers/` — **the legacy name stays supported and no run warns about it**).

Shipped surfaces name the new default now, which is why this is a minor rather than a
patch. This project's own records moved with the directory and were not rewritten: a
brief describes where things were when it was written.

## v1.9.0

### Changed

- **The installer now offers the family's routing block** (closing B-06 in the
  umbrella). Until now only `super-ux` delegated: install this skill on its own
  and no router was written at all, so an agent had the skill and no rule saying
  when to reach for it. The bundle installer wrote all eight, which is why
  nothing looked broken — the gap only opened for someone installing one member.

  Delegated to `npx sshlg-skills routers --member agent-sync` rather than
  reimplemented, for three reasons:

  - The block describes what the machine actually has. A lone member rendering
    the whole thing would print a table for routers nobody installed.
  - `--member` scopes the write to this skill's own section. Verified by damaging
    two sections of a real block and running this installer: its own was
    repaired, the other left exactly as it was.
  - The launcher is the only writer that copies the operator's global instruction
    file before touching it. That file has no version control behind it.

  `--no-install` keeps it from silently downloading a package nobody asked for.
  When the launcher is absent the command is printed instead of failing: ending
  an install in an error over an optional follow-up reads as a failed install.
  Both paths were exercised.

## v1.8.3

### Changed

- **The body is back inside the token budget** — ~5124 → ~4996 of 5000. Two sections
  were restating references that already carry the depth: *Two documentation sources*
  duplicated three sections of `two-sources.md`, and the stage list duplicated
  `pipeline-binding.md`. The body keeps the principle and the trap — `reconcile` is
  mechanical and refuses to judge whether the built thing matches the document, so
  reading its green as agreement is how a divergence survives both ends — and the depth
  stays where it already was.

## v1.8.2

### Changed

- `references/hooks.md` crossed 100 lines and gains the `## Contents` list the canon
  requires past that mark — generated from its own six headings. v1.8.1 did this for the
  six references that were over the line then; this one grew past it afterwards, which
  is the shape of the defect: the rule fires on a length that keeps changing.

## v1.8.1

### Changed

- **Six references over 100 lines now open with a `## Contents` list.** The
  canon asks for it because a partial read is what an agent actually does with
  a long reference; without the list, a partial read returns an arbitrary
  slice. Generated from each file's own `##` headings, so the list cannot
  disagree with the document.

## v1.8.0

**The board, cleared.** Nine rows opened by the two audits of 2026-08-10, closed with a check, a
measurement or a written decision — because a backlog nobody empties is a list of things everyone
has agreed to stop seeing.

### The two scenarios that were only ever driven by hand are now gated

Two agents contending for one task, and the guard across every shape a write arrives in, were
verified by executing them and had no check that fails on its own — the exact state in which the
first audit found six shipped defects. `check_two_agents_cannot_share_one_task` drives the full
sequence from two identities: the second run loses, is told who holds it, sees the holding in
`status`, is denied the guarded registry, cannot release a lease it does not hold, and the holder
can. `check_guard_covers_every_write_shape` drives nine payloads through the real hook — `Edit`,
`Write`, `NotebookEdit`, an unguarded file, `git commit`, `git -C <dir> commit`,
`cd <dir> && git commit`, `git log --grep=commit`, and malformed input — with and without the lease.

Planting the first defect took two mutations, not one, and that is worth recording: breaking only
`acquire`'s expiry check still refuses the steal, because `_steal_expired` re-reads the expiry
**inside** the critical section. The exclusion has two independent layers. The single-point fixture
was MISSED, which is how that was discovered.

### `check_merge_releases_only_its_key`

`merge --key` must release that lease and leave the others held. It was verified by reading the
code, and every defect the first audit found was in something verified by reading.

### `status` on a large repository: 3.2 s → 0.5 s

`check` resolved guarded and claim-tag patterns by walking the whole tree **once per pattern** —
five patterns over a 20 000-file repository is five full walks, measured at 3.2 s for a single
`status`, which is a `SessionStart` hook. It now walks once, and uses git's own index where there is
one, so `.gitignore` is honoured for free. Same output, six times faster.

### `check_commands_work_without_the_family_installed`

Runs the commands with `HOME` redirected at an empty directory — the state of every CI runner, and
where the task-pipeline ordering defect was obvious while this development machine could not see it.
The check proves its own isolation on a healthy project before asserting on a broken one, because a
probe that cannot fail is not a probe.

### The self-test: 6 min → 2.8 min

Each fixture now runs as its own process, eight at a time. It was a loop that reassigned module
globals and called `main()` in-band, once per fixture; at 32 fixtures it had already blown a
ten-minute command budget, and a suite people stop running is a suite that does not exist. The
subprocess also removes the global-state reset that made parallelism impossible.

### Decisions recorded rather than deferred again

**`settleSeconds` stays.** It is the adapter contract's extension point for a store whose writes are
not immediately readable; removing it would fail every config that carries one to buy nothing.
Written into `references/adapter-contract.md`, where the next reader will look.

**Blockers stay gone.** The concept needs a writer, a reader and a place on the board before the
word earns a document.

**Guard latency is fine:** 130–220 ms per guarded edit with no run id in the environment, against a
`PreToolUse` budget of 20 s. Measured, recorded, no change.

### Also

`actions/checkout@v5`, `actions/setup-node@v5`, `actions/setup-python@v6` — the v4 pins were being
forced onto Node 24 by the runner and annotated every release.

`merge` also gained a preflight it was missing. A merge commit is authorship, so it needs a real
git identity — unlike a lease object, which is plumbing and is written with a synthetic one on
purpose. Without one the merge started, git refused at the commit, and the abort path ran: it
recovers, and it is still not what a command whose whole doctrine is *every check before anything
is touched* promises. Found by CI, because a runner has no global `.gitconfig` and this machine
does — the same blind spot, twice in one day.

The validator now runs 44 checks and plants and catches 37 distinct defects.

## v1.7.1

**A second audit, along a different axis: not whether the tool keeps its promises, but whether an
agent reading this skill can follow it and get a correct result.** The scenarios were executed, not
imagined — cold start, the whole adoption chain, the per-task cycle, two agents contending for one
task, every hook with a realistic payload, and the edges (no git repository, uninitialised project,
absolute paths, `git -C <dir> commit`). The coordination core came through clean. What did not was
everything the agent *reads*.

### The documents the tool generates were two versions behind the doctrine it teaches

`setup` writes the snapshot every agent is told to read first — *"it states how documentation and
coordination work here"* — and `scaffold` seeds `AGENTS.md`. Neither mentioned a branch or `merge`
even once. Both prescribed a cycle ending in `release`, and the snapshot stated *"the claim tag in
git is written through"* as an unconditional fact, which has been false on any branch since 1.4.0 —
the branch being where the doctrine says the work belongs.

So an agent doing exactly what the skill instructs — trust the generated snapshot — got the
workflow from two releases ago. Regenerating did not help: the generator was what was stale. Both
now carry the branch rule, `merge --key`, and this project's integration branch by name.

`AGENTS.md` stopped restating the cycle altogether. It is seeded once and **never overwritten**, so
a copy of the protocol there is frozen on the day the project was created while the tool moves on —
and every project scaffolded before today would have kept the old one forever. It now points at the
snapshot, which is regenerable and which `check` fails on when it goes stale. One fact, one home.

### `check` blessed a project an agent cannot work in

A configuration declaring `idRegisters` on a backend whose `reserve` always raises passed as
`setup healthy`. `check`'s own promise is that it refuses a rule pointing at what is not there, and
a register nobody can allocate from is exactly that — while the snapshot it generates instructs
every agent to run `agent_sync.py reserve DEC`, which cannot succeed there. It is now a problem,
named with both ways out.

### The slash command offered a verb the CLI does not have

`argument-hint` advertised `claim <KEY>` and the README showed `/agent-sync claim ASC-072`. The
command is `acquire`; `claim` is an `invalid choice`. First thing an agent reads, first thing it
types.

### `$SKILL_DIR` was used in every example and defined nowhere

Six invocations tell the agent to run `python3 "$SKILL_DIR/scripts/agent_sync.py"`, and the only
explanation was the prose *"this skill's own directory"*. Nothing gave a value. The Cursor rule
names a concrete path; the skill body now names both — `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
and `~/.agents/skills/agent-sync`.

### What the audit found working

Worth recording, because a report that only lists faults says nothing about the rest: two agents
contending for one task behave correctly end to end — the second loses, sees who holds it and in
which repository, is denied the guarded file, and cannot release a lease it does not hold. The
guard denies `Edit`, `Write`, `NotebookEdit` and absolute paths, and blocks `git commit`,
`git -C <dir> commit` and `cd <dir> && git commit` when a guarded file is staged, while letting
`git log --grep=commit` and malformed JSON through. `SessionStart` stamps the identity and prints
the awareness block; `SessionEnd` releases. The adoption chain works with and without a pre-existing
`docs/`. An uninitialised project and a non-git directory both answer with the next action and a
non-zero exit.

### New checks

`check_every_advertised_verb_exists`, `check_generated_docs_carry_current_doctrine`,
`check_registers_need_a_backend_that_can_reserve`, `check_skill_gives_a_resolvable_script_path` —
with four more self-test fixtures. The validator now plants and catches 32 distinct defects.

## v1.7.0

**Observability, honest degradation, and the removal of things that were never load-bearing.**
1.5.3 stopped the tool saying untrue things; 1.6.0 made its guarantees hold. This closes the
remaining findings from the 2026-08-10 audit — each one a place where the tool was quiet rather
than wrong, which is the harder failure to notice.

### `status` and `check` gave two answers about one project

`status` printed `NEXT: acquire a lease` and exited 0 on a setup `check` called NOT healthy — a
guard pattern matching no file, a snapshot nobody links, an env file tracked by git. `status` is
the command every session runs and the only one most agents ever read, so anything it stays quiet
about is effectively unreported.

The validation now lives in one function, `check_setup()`, which both commands call. `status`
prints the count, the first four problems and one next action; `check` prints the whole list.

### Credentials were adopted from anywhere above the project

`find_env_file` walked every parent directory until something matched, so a stray
`.env.agent-sync` in a home or work directory silently configured every project beneath it and
pointed them all at one collection — a coordination plane shared by projects with nothing to do
with each other. A found file looks exactly like a configured one, so nothing reported it.

The search is now `AGENT_SYNC_ENV` if set, then this repository, then each **superproject** in
turn — a tree git can vouch for, which is the case the walk existed to serve. `check` prints which
file is in force and says when it comes from outside the repository.

### "New since you last looked" lost entries that arrived out of order

The watermark was an index into a list re-sorted on every read. An entry appended with an earlier
timestamp — clock skew, or a shard that was briefly unreachable — lands before the mark, shifts
everything after it, and is never reported; the slice returns an entry already seen instead. The
one section of `status` whose job is to announce what changed went quiet about exactly the change
that arrived late.

Entries are now remembered by identity, capped at 500, with a floor timestamp covering only what
fell out of that window — set only when something actually did, or the fix would have re-created
the bug in a new shape.

### `merge` released every lease the run held

The documentation says it releases the lease; it released all of them, quietly freeing work that
had not landed. It now releases the one named by `--key`, and says what it left held.

### An `acquire`/`release` round-trip left a diff

`SKILL.md` promises `git diff` empty afterwards. The claimed row was rebuilt from its cells, so
indentation and the original line ending were dropped — an unexplained change to a shared registry
file, which is the one kind of file agents are told never to touch casually. The bytes outside the
edited cell are now carried through rather than reconstructed.

### Declarations that were never load-bearing

`LOGS` carried a `blockers` document nothing wrote and nothing read, so a reader looking for
blockers found an empty page and concluded there were none. `_held_legacy` and
`Adapter.is_exclusive` had no callers. `Sync.settle` was computed and never used —
`settleSeconds` stays in the schema for a backend that must wait for writes to become visible,
and `check` now says plainly that nothing shipped reads it.

`os.uname()` and a literal `/dev/null` are gone in favour of `platform.node()` and `os.devnull`;
the coordinator now runs wherever python3 does, and only the enforcement hooks need bash.

### The setup verdict sat behind the task-pipeline gate

`status` checked for `task-pipeline` and returned before it ever reported on the project. On any
machine without the dependency installed — every CI runner — a project defect was therefore
invisible: the command exited non-zero for a reason that had nothing to do with it. The order is
now project first, machine second, because one is a fact about the repository everyone shares and
the other is a fact about the box you happen to be on.

Found by CI, on the release run for this very version, against a check that had passed locally
all day — and the check has been tightened to assert the problem is *named*, not merely that the
exit code is non-zero.

### Three tagged releases never reached npm

`v1.5.0`, `v1.5.1` and `v1.5.2` each pushed a tag, ran the release workflow, and failed at the same
step: *"no CHANGELOG section for 1.5.2"*. The extraction matched `## 1.5.2` while this file writes
`## v1.5.2` — a heading style that changed at 1.4.x and a workflow that did not. The registry sat
three releases behind while every tag looked delivered, and the failure lived in the one place CI
never runs on `main`.

Both patterns now accept the `v` prefix — the stop pattern too, or the notes run to the bottom of
the file — and `check_release_notes_are_extractable` runs the workflow's **own** awk program,
lifted out of the YAML, against the current version. A release that cannot be described now fails
on `main`, before the tag.

### New checks

`check_status_reports_the_setup_verdict`, `check_env_discovery_is_bounded`,
`check_watermark_survives_a_late_entry`, `check_no_orphan_logs`, `check_no_dead_declarations`,
`check_claim_round_trip_is_byte_exact` — with six more self-test fixtures. The validator now
plants and catches 27 distinct defects.

## v1.6.0

> **Never released on its own.** There is no `v1.6.0` tag and no `1.6.0` on npm,
> so `npm install @ssheleg/agent-sync@1.6.0` and `git checkout v1.6.0` both fail. This section
> describes work that shipped inside a later version. The note is here because
> the section reads as a release (2026-08-17, umbrella `B-71`).

**Four guarantees that were described but not delivered, and the one number now stated once.**
1.5.3 stopped the tool reporting things that were untrue; this release makes the properties it
claims actually hold, each with a check that has been watched fail against the defect it exists to
catch.

### Stealing an expired lease had a window two runs could both pass through

`unlink` then `O_EXCL create` are two operations. A second stealer that has already read the lock
as expired removes the lock the first one just created, and both then hold what each believes is an
exclusive lease. Twelve racing processes never exposed it — with a 300 ms delay injected between the
two calls, two of two won, and in production that delay is an ordinary scheduler hiccup.

The reap and the create are now one critical section: `<key>.lock.steal` is created with `O_EXCL`,
the expiry is re-read **inside** it (the holder may have renewed; another stealer may have finished),
and the section carries a 30-second abandonment grace, because without one a crash between two
filesystem calls costs the key until a human deletes a file nobody documents.

This mattered more after 1.5.3 than before it: with `renew` finally refreshing leases, the steal path
stops being the common case — but for four versions every long task went through it.

### `merge` measured one base and merged into another

Conflicts and the diff were computed against `origin/<target>`; the merge was made into the **local**
`<target>`, which nothing advances. So `merge` printed the staleness it had just measured — `main
moved: 1 commit(s)` — then `✓ merged as …`, wrote a merge-log entry, released the lease, and the push
was rejected as non-fast-forward. The work had not landed, `docs/MERGES.md` said it had, and the task
was free for somebody else to take.

The local integration branch is now fast-forwarded to its upstream before anything else, so the
preflight and the merge share a base. One that has genuinely diverged cannot be fast-forwarded and is
refused, with both counts named.

### One glob meant two things

The guard matched with `Path.match`, which anchors at the **right**: with `docs/DECISIONS.md` in
`guardedFiles`, an edit to `vendor/docs/DECISIONS.md` was denied — a file `check` never enumerated
and never validated, protected by a rule nobody wrote. In the other direction `Path.match` does not
walk `**` before Python 3.13, so `docs/**/*.md` guarded less than `check` reported. A pattern that
means two things means nothing. Both commands now resolve patterns through one function, anchored at
the repository root.

### The 2% rule was a constant nothing read

`MAX_UNPARSEABLE` was declared in 1.0.0 and never referenced. `SKILL.md`, `lease-protocol.md` and the
README all stated that a log past the threshold stops the run; the only implementation was a warning
line on the board that returned 0. Every reader now refuses a log past the limit and names the ratio,
so `status`, `board`, `check`, `reconcile` and `reserve` stop rather than replaying a partial history
— which reports holders who do not exist and silence where the real ones are, both of which look like
an answer.

`acquire` is deliberately not in that list, and the documents now say so: a lease is decided by the
lock or the ref, never by the log, so a corrupt log cannot make one look lost.

### The stage binding said three different things

`SKILL.md` announced "four of the eleven stages" and listed five (0, 1, 3, 9, 10); the README named
0, 3, 4, 5, 9 and 10; `pipeline-binding.md` agreed with the README and separately called stage 1
*"nothing shared to coordinate"* — the stage `reconcile` belongs to, and the one the tool's own
doctrine says must resolve every divergence before code is written. An agent wiring `pipeline.json`
from any one of the three got a pipeline missing a rule.

The numbers now live in a marker in `pipeline-binding.md` — `rules=0,1,3,9,10`,
`wired=0,1,3,4,5,9,10` — the two lists answer two different questions instead of being conflated,
stage 1 has its row and its `pipeline.json` entry, and a check fails when a surface stops agreeing.

### New checks

`check_steal_is_atomic`, `check_merge_refuses_stale_target`, `check_guard_and_check_agree_on_globs`,
`check_unparseable_log_fails_loudly`, `check_stage_binding_agrees` — plus five self-test fixtures
planting each defect back.

## v1.5.3

> **Never released on its own.** There is no `v1.5.3` tag and no `1.5.3` on npm,
> so `npm install @ssheleg/agent-sync@1.5.3` and `git checkout v1.5.3` both fail. This section
> describes work that shipped inside a later version. The note is here because
> the section reads as a release (2026-08-17, umbrella `B-71`).

**Five surfaces told the caller something that was not true.** An audit on 2026-08-10 ran the
commands instead of reading them, and every finding below was reproduced before it was fixed. The
validator was green throughout — which is the finding behind the findings, and the reason this
release ships six new checks that drive the tool rather than its functions.

### `reserve` handed three runs the same id

`reserve` replayed `log_id("reservations")` — the document **this run writes**. Every other run's
shard was invisible to it, so three runs each saw an empty history, each seeded a `base` from the
register, and each was handed `DEC-0007`. Measured, three processes, one number.

The pure allocator was correct and tested; nothing ever asked it about the whole log. This is the
same failure that disqualified per-writer documents as a *lease* store in 1.0.0 — eight processes
reading only their own shard, eight winners — arriving in the allocation path, where the tested
unit hid it. Allocation now runs over the merged log.

Merging alone would have replaced one collision with another: two runs opening a register in the
same minute both append the same seed, and a `base` that re-seated unconditionally restarts the
count and hands the second run the id the first just took. A `base` now only ever moves allocation
**forward**; one at or below the current position is ignored.

### `renew` renewed nothing

It appended `op=renew` to the record plane — which has not decided a lease since 1.0.0 — and
touched a throttle file. The timestamp expiry is actually computed from, the one inside the lock
(or the git ref payload), was written once, by `acquire`.

So a run holding a lease lost it at TTL **while still working**: `whoami` reported `holds:
nothing`, its own `PreToolUse` guard began denying its edits, and another run acquired the task it
was in the middle of. With the default 2700 s, every task longer than forty-five minutes. The
`PostToolUse` hook made no difference, because there was nothing for it to move — and from the
record plane's side the renewals arrived exactly on schedule, so nothing looked wrong anywhere.
`renew` now rewrites the lock in `local` mode and re-pushes the ref with `--force-with-lease`
against the exact object it read in `git` mode.

### `check` rejected the config `init` had just written

`check` carried its own literal list of legal keys. `mergeLog` — written by `init` itself — and
`integrationBranch` — in the schema, in the shipped example, read by the code — were not in it, so
a freshly initialised project reported `config key 'mergeLog' is not in the schema — it will be
ignored`. Both halves false: it is in the schema, and it is not ignored.

That is worse than a wrong message; it is an instruction. An agent making `check` green deletes
working configuration. The list now lives in one place, `CONFIG_KEYS`, and the validator asserts it
equals the schema's properties exactly.

### Three commands reported success they had not achieved

`release-id` printed `released DEC-0007` and exited 0 on a backend that records nothing — the id
stayed a hole the board reports as a leak, and the only party who could have fixed it had been told
it was handled. `record` and `signal` printed success and exited 0 while stderr said the entry was
never published. All three now fail loudly.

An adapter `OSError` also walked past every `except Fail` into `main`: an unwritable state
directory handed the agent a Python traceback as the state of the coordination plane. Store errors
are now the tool's own failure type.

### The guard named a holder who held something else

A denial read `<path> is a guarded registry file and this run holds no lease — r-x holds a lease
right now`, where `r-x` held an unrelated task. Beside a path, that sentence gets repeated as "r-x
holds this file". The denial now names the run **and its key**, and says plainly that exit 2 is a
statement about the asking run, not about the file.

### The marketplace listing still sold the design 1.0.0 refuted

`marketplace.json` — the first thing anyone reads — described coordination as "decided by replaying
one append-only log so no backend needs compare-and-swap". That is the belief the first trap in
`SKILL.md` exists to forbid. Rewritten, and a check now fails on the phrase.

### Documentation corrected where it described behaviour that did not exist

`SKILL.md` and the Cursor rule on what exit 2 means; `lease-protocol.md` on what `renew` moves and
on the log a reader replays; the claim-tag vocabulary in `README.md` and `lease-protocol.md`, which
showed a role where the tool writes a run id; `hooks.md` on the `NotebookEdit` matcher, on what
`session-end.sh` actually does, and on the two timeouts that really apply.

### New checks

`check_reserve_is_race_free`, `check_renew_extends_the_lease`, `check_config_round_trip`,
`check_no_success_on_failed_publish`, `check_guard_denial_names_only_what_it_knows`,
`check_doctrine_is_current`. Each drives the real commands from more than one identity, because
every defect above lived in the gap between two things the validator already tested separately.

## v1.5.2

### `reserve` handed out ids that were already written

`reserve DEC` returned `DEC-0270`, then `0271`, then `0272`, in a project whose register's highest
heading was `DEC-0281` and whose own "next free" line read `DEC-0282`. All three were occupied;
`DEC-0270` was cited by name in another document.

The counter was not stuck — it incremented on every call. It was **11 behind**. The `base` event is
seeded once, from the register, and never consulted again. Every id written by a path that is not
this tool — a person editing the file, another session's Doc Loop, a merge — advances the register
and leaves the log where it was. The gap only ever grows.

This inverts the mechanism. An agent that follows the protocol exactly — reserve before minting,
never trust the register's own "next free" line, which is the rule the protocol states — is the one
that writes a duplicate, and it is silent at the point of use: the id looks fresh, the register
accepts a second heading with that number, and every citation to it becomes ambiguous. An agent that
ignored the tool and read the line would have been correct. That is the worst incentive a
coordination tool can teach.

The register is now consulted on **every** reserve and treated as a **floor**: it can push the
allocation forward, never pull it back. Ids reserved through this tool but not yet written do not
appear in the register, so honouring the floor never revokes a live reservation. The floor is
applied by re-basing mid-log, which the allocator now supports properly — a new `base` restarts the
count (it previously kept serving from the old one, skipping as many ids as had been handed out) and
drops freed ids that fall below it (the register has moved past them, so a heading exists there now,
and recycling one is the same collision through the other door). Ids freed at or above the base are
still reused, so the fix does not turn every release into a leak.

Proven end to end against the register that exposed it: the shipped 1.5.1 returns `DEC-0273`, an
occupied id; this build returns `DEC-0282`, then `DEC-0283`. Three assertions in `test/validate.py`,
all watched failing against a planted revert of the allocator — 202 where 200 was due, and a freed
`0100` handed back.

Found in nicegram-business, 2026-08-09, by an agent that recognised the returned number from another
document. That is luck, not a control, which is why the check now exists.

## v1.5.1

### `release` could not remove the claim it had written

`acquire` writes a marker into the status cell; `release` found the row again **by searching for the
task id**. Between the two, a run's own work can add another mention of that id — a new row, a
cross-reference — and release then sees several candidates, refuses to guess, and leaves
`(claimed: r-…)` in the cell **permanently**.

The result is worse than no claim: a status cell advertising a live lease that nobody holds, which
the next agent reads as an occupied file.

Release now narrows by the marker before falling back to the id. The marker names *this run*, so it
is unambiguous however many rows mention the id. Proven both ways on the same scenario — acquire
writes the tag, a second mention is inserted, and release removes it (`claim restored`); the
previous version leaves it behind.

## v1.5.0

### The commit guard was blind to `git -C <dir> commit`, and to submodules

Two defects, and the second is why the first went unnoticed.

**The test was a contiguous substring.** `case "$cmd" in *"git commit"*)` — and `git -C <dir> commit`
does not contain the string `git commit`. Every commit made that way skipped the guard entirely, in
any repository, submodule or not.

**The repository was hardcoded to `CLAUDE_PROJECT_DIR`.** So even a bare `git commit` inside a
submodule read the *umbrella's* index, found it empty, and passed: the staged files live in the
submodule's index.

Together they meant a full day of commits to guarded registers went unchecked, on 2026-08-07, while
the Edit-tool half of the same hook refused correctly the whole time — so the protection looked
present and was measured as present by anyone who tested it with the Edit tool.

The command is now tokenised in python rather than globbed in shell: `-C`, `-c` and `--namespace`
are consumed with their arguments, `cd <dir> &&` is honoured, each `&&`/`;`/`||` segment is examined,
and the guard runs **from** the resolved repository — `agent_sync.py` resolves the project from
`git rev-parse --show-toplevel` of its cwd, so a submodule gets its own `guardedFiles`.

Proven against the previous version on all three forms: `git -C <sub> commit` and
`cd <sub> && git commit` go from `rc=0` to `rc=2`, and `git log --grep=commit` stays `rc=0` — the
tokeniser exists so that one does not become a false positive.

# Changelog

All notable changes to this project are documented here.
This project adheres to [Semantic Versioning](https://semver.org/).

## 1.4.3 — 2026-08-03

### Fixed

- **The `pipeline.json` example did not validate against the schema it cited.** It
  claimed `task-pipeline`'s `pipeline.schema.json` permitted it while carrying a
  string `id`, a `title` where the schema says `name`, and no `state` at all — which
  is required. Anyone who copied it got a config `task-pipeline` rejects. The example
  is now checked against that schema.
- **Gate texts stated only this plugin's half.** Each `check` read as if it replaced
  the stage's own criteria, so a host that copied the block silently dropped them —
  stage 9 lost the propagation sweep and the documentation gate, stage 10 lost the
  ladder walk and the evidence rule. Every `check` is now written as
  *`<the stage's own criteria>` **AND** agent-sync's clause*.
- **Stage 9 pointed at the wrong doctrine.** `task-pipeline:artifacts` is the
  artifact-layout reference; that stage runs on `task-pipeline:documentation` and
  `task-pipeline:gates` since the documentation track landed.
- **`guardedFiles` did not cover what the pipeline now creates.** `docs/DOCMAP.md`
  holds a project's registers, propagation matrix and ratchet floors, and
  `docs/superpowers/retro.md` is capped at ten standing instructions — so a
  concurrent write there drops a lesson instead of conflicting visibly. Both are
  guarded, with the reasoning in `references/pipeline-binding.md` because the config
  schema keeps `agent-sync.json` to known keys.

## 1.4.2 — 2026-07-30

### Changed
- **The README's hook section now opens with what it actually is:** the only part
  of this plugin that executes code on your machine, four bash scripts with 15-20s
  timeouts, run by Claude Code on named events, with a pointer to `SECURITY.md`
  for every path the install touches. The facts were already in `SECURITY.md`; the
  README described the hooks' behavior without ever framing them as the security
  surface a reader should check first.

### Added
- **`displayName`** ("Agent Sync") in both manifests.

## 1.4.1 — 2026-07-30

### Fixed
- **`homepage` and `repository` sat at the top level of `marketplace.json`,
  where Claude Code does not recognize them.** They are plugin-entry fields;
  moved there, so the values reach the plugin listing instead of being ignored.
  This plugin's `argument-hint` was already quoted — the only one in the family
  that was.

### Added
- `claude plugin validate --strict` runs in CI against both the plugin and the
  marketplace manifest: the upstream schema, next to this repo's own validator.

## 1.4.0 — 2026-07-30

### Work happens on a branch, and the integration branch stays somebody else's stable base

Two agents renamed this repository the same minute yesterday, both committing straight to
`main`. The second push was rejected — after the work was already duplicated. Nothing in
the tool said where work should happen, so both did the obvious thing.

- **`acquire` writes the claim through only on the integration branch.** On any other
  branch it says so and writes nothing: a claim committed to a branch is invisible to
  every other agent until the merge, and it turns the shared roadmap into a file two
  branches both edit — a conflict on the one file that exists to prevent collisions. The
  holder lives in the coordination plane, where `status` already shows it to everyone
  without anyone fetching a branch.
- **`integrationBranch`** in the config, or the repository's own default branch when unset.
  Asked of the repository, never assumed.

### `merge` — land a branch, with every check before anything is touched

`merge` refuses a detached HEAD, the integration branch itself and a dirty tree; fetches
the target and reports how far it moved; computes conflicts with **`git merge-tree`, in
memory**; lists every other run's live lease; merges `--no-ff`; records the merge; and
releases every lease this run holds. On conflict it names the files, changes nothing and
exits non-zero — a merge that starts and aborts leaves the operator in a repository they
did not ask for, and a resolution nobody reviewed does not belong on the integration
branch. `--dry-run` stops after the checks, `--push` pushes afterwards.

### `merges` — what landed while you were on your branch

A merge log at `docs/MERGES.md` (`mergeLog.file`), written by `merge`. It answers what
`git log` cannot without reading every commit and what a changelog only covers once
released: *what landed while I was away, and was any of it near my work.*

**Compaction happens on write.** Entries inside `mergeLog.retentionDays` (7) keep their
detail; older ones fold to one line each on the next merge. No cron, no second command,
and no log that grows until people stop reading it — which is the same as not having one.

### Added
- `test/validate.py` exercises both rules against throwaway repositories: `acquire` on a
  feature branch must leave the roadmap untouched **and** say where the claim lives, while
  on the integration branch it must still write through; a conflicting `merge` must exit
  non-zero, name the conflicting file, leave the checkout on the original branch with a
  clean tree, and write **no** log entry for a merge that did not happen. Both have
  self-test fixtures that remove the guard and confirm the check goes red.
- `references/branching.md`, and the doctrine in the skill body.

## 1.3.9 — 2026-07-30

### Changed
- `license: MIT` added to the `marketplace.json` plugin entry. The front matter
  already declared it — this repo was the only one in the family that did — but
  the plugin listing, which is what a user reads before installing, did not.


## 1.3.8 — 2026-07-30

The 1.3.6 move was performed twice, by two agents in this repository within the same
minute, and neither held a lease on it. Both renames were correct and nearly identical —
the duplicated effort is the cost, and this project exists to make it visible. This
release carries the two things only one of them had, and it is the release that moves the
npm package page: 1.3.6 and 1.3.7 were tagged and never published.

### Changed
- **`LICENSE` and the README footer read `ssheleg`.** The rename moved every address and
  every manifest identity, and left the copyright line naming the previous owner — the one
  statement in this repository with legal weight.

### Added
- **`test/validate.py` fails a half-finished rename.** It derives the canonical slug from
  `package.json` and rejects any other owner of this repository name anywhere in the tree:
  in a URL, in a `github:` install argument, in a `marketplace add` line, or as a bare
  quoted slug. That last shape is what the installers actually clone, and the first draft
  of the check missed it — `bin/agent-sync.js` and `install.sh` both passed while still
  pointing at the old owner. Verified red against the pre-rename tree; the self-test
  fixture assembles its slug from parts, because written whole it makes the validator the
  file that fails the check.

## 1.3.7 — 2026-07-30

### Fixed
- **The 1.3.6 note below claimed `raw.githubusercontent.com` does not follow a
  repository transfer. It does** — the old owner's raw path returns 200 with the
  current file, no redirect involved, because GitHub resolves a transferred
  repository by identity on every surface (git, web and raw alike). Measured
  after the move, which is what the claim should have been before it was
  written. The paragraph is corrected in place so no install ships the wrong
  fact; the reason to update the URLs is unchanged and stated accurately there.


## 1.3.6 — 2026-07-30

### Changed

The repository moved from `appvillis-com/agent-sync` to **`ssheleg/agent-sync`**,
joining the rest of the family under one owner. GitHub keeps serving the old path
on every surface, so nothing breaks today — but that only holds while the
`appvillis-com` name is never re-registered, and a reference that depends on
somebody else not taking a name is worth one commit to remove.

- **Install paths** — `install.sh`, `bin/agent-sync.js` (the `npx github:…`
  fallback), and the README's npx and `claude plugin marketplace add` commands.
- **Identity** — `package.json` (homepage, repository, bugs, author),
  `.claude-plugin/marketplace.json` (owner, homepage, repository, plugin author),
  `plugins/agent-sync/.claude-plugin/plugin.json`, and the `author` in the skill's
  front matter: `appvillis-com` → `ssheleg`.
- **Raw URLs** — `agent-sync.schema.json` `$id`, the `$schema` in
  `agent-sync.example.json`, and the reference-loading fallback URL in `SKILL.md`.
  A `$id` is an identifier as much as a location, so it should name the repository
  that actually holds the schema.
- Cursor rule, code of conduct and the security-advisory link in the issue-template
  config.

The changelog entries below and `docs/superpowers/specs/` keep the old path on
purpose: they record where this was published at the time.

## 1.3.5 — 2026-07-29

### The two rules this plugin enforces are now stated, with the failures that taught them

Both are already code. Neither was written down, and a mechanism nobody can explain is a mechanism
the next person removes.

- **Identity comes before coordination.** Both ways of getting it wrong have happened here: one
  session with two identities (acquired as one, denied by its own guard as the other) and two
  sessions with one identity, which is worse because it is silent — `whoami` reported a lease that
  belonged to somebody else and `release` would have taken it.
- **Work in a submodule is not finished until its parent points at it.** Neither repository looks
  wrong alone; the disagreement lives between them, which is why it survives every check that runs
  inside one. That is what `finish` is for.
- **The rule under both:** before trusting a tool's report about the world, make it report something
  you can already verify.

## 1.3.4 — 2026-07-29

### Fixed
- **`release` reported success for a lease it did not release, and cleared the
  board claim on the way.** The lease plane refused correctly — a lease held by
  another run stayed held, and `_git_release` printed a note saying so — but the
  command printed `released <key>` over the top of it and exited 0, and
  `write_claim` had already blanked the claim cell before the refusal was
  reached. The result was the board advertising a task as free while the lease
  still held it: the exact disagreement a lease exists to prevent, manufactured
  by the tool. Ownership is now checked **first**, in whichever plane arbitrates
  it; nothing is written when the answer is no; the command exits non-zero and
  says who holds it. Both lease modes were affected.
- A regression check covers it in `local` and `git` mode, and was probed against
  the old code in both before being trusted.

## 1.3.3 — 2026-07-29

### Fixed
- **The git lease backend could not take a lease on any machine without a git
  identity.** Acquiring writes a lease object with `git commit-tree`, which
  refuses when `user.email` is unset and cannot be auto-detected — CI runners,
  containers, a freshly provisioned box. So the backend that advertises
  *exclusive across machines* failed on precisely the machines least likely to
  have a personal git config, with `could not create the lease object` and no
  further detail. The lease object is plumbing, not authorship: it now carries a
  fixed `agent-sync <agent-sync@localhost>` identity passed inline, so it never
  depends on ambient config.
- The same failure now reports git's own last line instead of swallowing it. The
  bug survived six red CI runs because the message named a possible cause and
  showed no evidence.

## 1.3.2 — 2026-07-29

Open-source hygiene — the repo is public and ships in the `sshlg-skills` bundle,
so the files a first-time contributor looks for now exist.

### Added
- `CODE_OF_CONDUCT.md`, issue forms and a pull-request template. The forms ask
  the question that actually matters for this project: **how many agents were
  running, against what checkout** — a coordination bug reported without the
  concurrency shape is not reproducible.
- The PR checklist requires a negative self-test with any new validator guard —
  plant the defect, watch the check fail, then trust the green.
- README points at the code of conduct and at the family bundle.

## 1.3.1 — 2026-07-29

### The git lease was invisible to everything that reads a lease — fixed

Found in production, blocking real work three times in one session. In `git` lease mode `acquire`
won the lease by pushing `refs/agent-sync/leases/<key>` and stopped there, while `held()` — the one
function behind `whoami`, `status` and the **PreToolUse guard** — read `.agent-sync/leases/*.lock`,
which nothing in that path ever wrote. The result was the exact inversion of the tool's purpose:
`acquire` printed *won*, `whoami` printed *holds: nothing*, and the guard **denied the run that held
the lease**. Every guarded register was unwritable under the mode this tool recommends, and the only
way past it was to bypass the guard — which is the behaviour the guard exists to prevent.

The git ref remains the authority; it is what makes exclusion hold across machines. What was missing
is that the winner now leaves a local note, so the local question — *does this run hold that key?* —
is answered locally instead of putting a network round-trip in front of every Edit. `release`
already removed that note, which is why only one half of the loop was ever written.

**Why it shipped:** the lease-visibility assertion existed only for the local mode. `test/validate.py`
now runs acquire → `whoami` → `guard` → release against **both** modes; against 1.3.0 it fails with
the two symptoms above, which is the point of adding it.

## 1.3.1 — 2026-07-29

### Ignoring the state directory does nothing once git is tracking it

Found in the project this plugin was built for: `.agent-sync/` was gitignored **and committed**,
because the files went in before the rule existed. Consequences, all of them silent:

- every tool call rewrites `last-renew`, so all three repositories were permanently dirty and no
  run could ever report itself finished
- `run-id` is the checkout's **agent identity**. Committed, it reaches every clone — two machines
  would then coordinate as one run, which is the failure 1.3.0 fixed at the other end

`init` now untracks the directory when it finds it tracked, and `check` reports it as a problem
rather than passing a project whose state is versioned. Probed: a repository with a committed
`.agent-sync/run-id` fails `check` with the exact removal command, and passes once it is untracked.

## 1.3.0 — 2026-07-29

### Two agents in one checkout were one identity — fixed

Found in production, in the case this plugin exists for: **two Claude sessions working the same
checkout shared a single run id**, so the lease could not separate them. A hook runs with
`CLAUDE_SESSION_ID` in its environment and a plain shell command does not, and the marker file held
one id per checkout — so the second session adopted whatever the first had stamped. Both acquired
as one run, both were guarded as one run, and `release` would take a lease the caller never
acquired. The failure is silent: `whoami` reports a lease, and it is somebody else's.

- the marker is now a **map** keyed by session, and migrates the old single-value file into it
- a plain shell has no session id, so the `SessionStart` hook stamps
  `.agent-sync/sessions/<CLI pid>` with the session it *does* know, and later commands find
  themselves by walking their own ancestry. Exact, and no command-line parsing: the throwaway
  shell every tool call runs in carries claude paths in its argv and defeated every heuristic
  aimed at the CLI binary
- stale stamps are removed when their process is gone, so the directory cannot grow
- where identity still cannot be established, the run says so rather than presenting a shared
  entry as separation

### `scaffold --full` — the architecture that keeps documentation linked, not merely present

`scaffold` seeded a decision register and an agent protocol. That is enough to be coordinated and
not enough to stay coherent: the things that rot are the links between documents, and nothing was
seeding the pieces that hold them — a question register that resolves into decisions, an index
nobody has to scan the register to use, one place for facts about two repositories, one definition
per entity with a checkable address, **and a gate**, because each of those decays silently.

`--full` adds `OPEN_QUESTIONS.md`, `INDEX.md`, `DEPENDENCIES.md`, `DATA_MODEL.md` (with the entity
register and the one-definition rule) and `scripts/check-docs.sh`, which fails on: an id cited and
never defined, a next-free-ID line that is not next, a relative link to a file that does not exist,
a `#anchor` that does not exist in the file it points at, and a decision with no index row. All
five probed against planted defects.

**A fresh scaffold passes its own gate.** The first version did not — it counted the template block
and the allocation line as real ids — and a project that starts red teaches everyone that the gate
is noise.

### `finish` — the gate expressions this plugin declares, executed

`references/pipeline-binding.md` has always listed *submodule pointers current* and *every lease
released* as gate expressions "verified by the coordinator, not by prose". Nothing ran them:
`check` validates the **setup** — config, registers, credentials, snapshot — and never looks at the
state of the repositories.

`finish` answers the other question, *is the work finished*:

- every submodule's recorded gitlink equals its HEAD. This is the failure it exists for and it is
  invisible from either side alone: the submodule is pushed, its CI is green, its roadmap says
  done, and a clone of the parent has the commit before the work
- every repository — parent included — is clean and pushed, with a detached submodule accepted
  only when its commit exists on some remote branch
- no lease left held, because a run that ends holding one blocks the next agent for the whole TTL
- `--gates` also runs the project's own declared gate commands

## 1.2.4 — 2026-07-29

### Fixed — the tool misreported its own version, and disagreed with itself about the lease
- **`VERSION` drifted a release behind.** The constant said `1.2.2` while every manifest
  said `1.2.3`, so each `status` and `adopt` header named the wrong version — the exact
  number the README tells an operator to compare when hunting a stale install channel.
  `check_version_sync()` read five manifests and not the script; `check_scripts_run()` ran
  `--version` only to prove the process starts, and threw the answer away. The constant is
  now part of the sync check, so this cannot drift silently again.
- **`gated` was decided by the record backend, which has not decided a lease since 1.0.0.**
  It read the adapter's `atomicAppend`/`totalOrderRead` capabilities, and both directions
  lied: `outline` with a local lock reported `gated` while exclusion was machine-local —
  the pretended lease the skill's own trap 2 warns about — and `fs` with git refs reported
  `ungated` while every lease was a genuine cross-machine compare-and-swap. It now derives
  from `leaseBackend`.
- **Six surfaces phrased the guarantee independently, and two called the knowledge base
  the "lease authority".** `status` said `lease authority: NO — degraded` for the same
  project where `check` said `exclusive on this machine` and `acquire` said something else
  again. One guarantee described three ways reads as three guarantees, and an operator acts
  on the weakest. The wording now lives in one table (`lease_guarantee()`), used by
  `status`, `acquire`, `check`, the board, the setup snapshot and `init`. `status` reports
  the record plane and the lease as the separate facts they are.

### Added
- **`test/validate.py` exercises the agreement**: for `leaseBackend` `local` and `git` it
  runs `status`, `acquire` and `check` against a throwaway repository (a real bare remote
  for `git`) and fails if any of them omits the guarantee, or if `status` still calls the
  record backend the lease authority. Verified red against 1.2.3, green after.

## 1.2.3 — 2026-07-29

### Fixed — the guard blocked commits in projects that never installed agent-sync
`_lib.sh` states the contract: *"Every hook is a no-op in a project that does not use
agent-sync, so installing the plugin globally changes nothing elsewhere."* `guard.sh` was
the one hook that never sourced `_lib.sh`, and it honored that contract on only one of its
two branches.

- **The `git commit` branch had no configuration check.** It ran `agent_sync.py guard` on
  every staged path; in an uninitialized project that command exits 2 with *"no
  `.claude/agent-sync.json` in this project"*, which the loop read as "this run holds no
  lease" — so every commit in every repo without agent-sync was blocked, with a message
  naming a lease the project could not possibly need. The single-file branch had the check
  all along, which is why the failure only ever surfaced on commits.
- `guard.sh` now sources `_lib.sh` and gates on `agent_sync_configured` like the other three
  hooks, so the check cannot drift apart from them again. The hand-rolled `AGENT_SYNC_PY`
  path and the duplicated `[ -f … ]` test are gone.
- The staged-path listing now runs against `${CLAUDE_PROJECT_DIR:-$PWD}`, the same directory
  the configuration check reads. Before, the two could point at different repositories.

### Added
- **`test/validate.py` exercises the no-op contract** instead of only checking syntax: every
  hook runs against a throwaway git repository that has a staged file and no
  `.claude/agent-sync.json`, and must exit 0. Verified red against the pre-fix `guard.sh` and
  green after — a `bash -n` pass could never have caught this.

## 1.2.2 — 2026-07-29

### Changed
- **The npm package is `@ssheleg/agent-sync`.** Unscoped `agent-sync` was rejected on publish
  with a 403: npm's name-similarity policy fires only on `PUT`, so `npm view` reporting E404
  ("free") predicts nothing — the collision was with an existing `agentsync`. Scoped names are
  exempt from that policy, which is the documented fix.
- **The command is still `agent-sync`.** A package's `bin` name is independent of its package
  name, so nothing about daily use changes; only the install line grows a scope.
- GitHub install (`npx github:appvillis-com/agent-sync`) and the Claude Code plugin are
  unaffected — the registry only ever bought the short name.

## 1.2.1 — 2026-07-29

### Fixed — documentation that contradicted the code
Compressing the skill surfaced three statements that measurement had already disproved and
that nobody had gone back to correct. This is the drift the tool exists to catch, in the
tool's own documentation.

- `lease-protocol.md` opened by declaring that *no backend offers compare-and-swap, so both
  leases and id reservations are decided by replaying one append-only log*. Half of that is
  still true — id allocation is positional over the log — and half has been false since
  1.0.0. It now separates the two mechanisms, because confusing them is how this went wrong
  twice.
- The same file still said the **run** writes the claim tag and the tool only verifies. As
  of 1.2.0 the tool writes it through.
- `backend-fs.md` described a "git-file lease" — commit the lock, push it, read the
  rejection — a design that was never built. Leases have never depended on which knowledge
  backend is configured, and the file now says so.

### Changed
- `SKILL.md` trimmed from 4779 to 4325 tokens (13% headroom under the 5000 cap), with the
  full measurement history left in `CHANGELOG.md` and `lease-protocol.md` where it belongs.
- `lease-protocol.md` gains the cross-machine section it was missing.

## 1.2.0 — 2026-07-29

### Added — the claim is written through to the roadmap again
Demoted to a check in 1.0.0 because an unattended process rewriting a shared registry is
the collision a lease exists to prevent. It is back, with that objection engineered out:

- **One row.** The single table row containing the task id as a whole word. Zero rows →
  nothing happens. Two or more → **refused**, with the reason. It never guesses.
- **One cell.** Only the configured cell changes; links, notes and every other column are
  untouched byte for byte. `cell` is 0-based, negative counts from the end.
- **Reversible.** The previous text is stored in `.agent-sync/claims.json` and restored
  verbatim on release — not a default, what was actually there. After acquire→release,
  `git diff` on the roadmap is **empty**.
- **Atomic.** Written to a temp file and moved into place, so a crash cannot leave the
  register half-edited.

Closing a task is still yours: the tool refuses to write `done` on your behalf, because a
status a machine sets is a status nobody checked. `references/roadmap.md` documents the
whole cycle — claiming, closing, re-planning, and what to do when the claim cannot be
written.

### Added — cross-machine leases
`leaseBackend: "git"` pushes a commit to `refs/agent-sync/leases/<key>`, and the remote's
non-fast-forward rejection **is** a compare-and-swap. Verified against a hosted remote
before being written: A created the ref, B pushed a different commit without force and was
rejected, the ref still held A. Then eight parallel processes against the real remote —
**one winner, seven losers all naming it**.

Expired leases are stolen with `--force-with-lease` against the exact object seen, so a
steal cannot clobber a holder who renewed in between. `local` remains the default and is
still exclusive between processes on one filesystem; `acquire` and `check` now say which
guarantee you actually have instead of implying the stronger one.

## 1.1.0 — 2026-07-29

The skill can now take **any** project from nothing to a validated setup on its own.

### Added
- **`check`** — validates the whole setup and refuses to call a broken one healthy. It fails
  on a register whose allocation pattern matches nothing, a guard glob that matches no file
  (a rule protecting nothing), a gate whose script is missing, a mirror source that is not
  there, empty credentials, a `.gitignore` that misses the env file — or that file being
  **tracked by git**, the one unrecoverable mistake here — a hand-edited or stale snapshot,
  **a snapshot no agent instruction file links**, and a register with no baseline. Every one
  of those failed for real during this tool's own adoption.
- **`scaffold`** — creates the documentation architecture where it is absent: a decision
  register with an allocation line and an `AGENTS.md` pointing at the generated snapshot. It
  never touches an existing file. A tool that rewrites a project's conventions on adoption is
  worse than one that does nothing.
- The snapshot is stamped with a **hash of the configuration** it describes, so staleness
  means the configuration moved on — not that a commit happened. Comparing commits was wrong
  at both boundaries: a snapshot is generated before the commit that carries it, and the
  config is usually added in that same commit, so the very first adoption always looked stale.

## 1.0.1 — 2026-07-29

### Fixed
- **A rate-limited knowledge base stopped the work, not just the record.** `journal`, `record`
  and `signal` raised when the store was unreachable or throttling, so a burst of shard creation
  could fail a run outright. The plane carries visibility, not correctness: publishing now
  reports the gap loudly and lets the caller continue. Swallowing it would hide a hole in the
  record; raising made an availability dependency out of a notebook.
- Retries widened to seven attempts for `429` and transient `5xx`, which is what a burst of
  document creation actually needs.
- Run journals moved to the shard naming scheme (`20 Runs — <run>`) so they are enumerated like
  every other log.

## 1.0.0 — 2026-07-29

A full audit of the running system against its own promises. Three surfaces were
configured and unimplemented, and the central safety claim was false. All measured, none
inferred.

### Fixed — the lease was not exclusive
- **A shared append-only document loses writes.** Twelve concurrent appends to one Outline
  document returned twelve successes and left **three** lines: `editMode: append` reads,
  appends and writes back, so simultaneous writers clobber each other and each is told it
  succeeded. A lease decided on that can be held by two runs, each with proof.
- **Sharding per writer fixes the loss and breaks the decision.** 12/12 land, but without
  compare-and-swap nothing can answer "is a contender still writing?", so eight parallel
  processes each read only their own shard and **eight won one key**. A three-second settle
  window took it to five. It cannot reach one.
- **Exclusion now comes from `os.open(O_EXCL)`** — an atomic create is the decision, and the
  plane carries the record. Twelve parallel processes, **one winner, eleven losers all naming
  the same holder**. Publishing to the plane can fail without affecting correctness, so it is
  reported rather than raised.
- The limit is stated instead of implied: a lock file is exclusive between processes on one
  filesystem, advisory across machines. `exclusiveLease` joins the capability set and defaults
  to false, because declaring it without compare-and-swap is the most damaging lie an adapter
  can tell.

### Fixed — configured but not implemented
- **`claimTags`** appeared in the schema, in every config and in DEC-0216, and was read
  nowhere. `status` now reports where a held lease and the git claim tag disagree — and says
  plainly when the configured mapping *cannot be verified at all*, which is the case in the
  project that shipped it. The tool verifies; the run writes. A process that rewrites a shared
  registry unattended from a hook is the mechanism that clobbers other agents' work.
- **Mirror drift detection** was asserted in a docstring beside code that never checked it.
  `status` now reports pages whose stamped commit is not HEAD.
- Transient `5xx` from the knowledge base are retried like `429`; twelve concurrent document
  creations had been failing outright.

## 0.6.0 — 2026-07-29

### Fixed
- **The mirror was configured, documented and not implemented.** `mirror.enabled` and
  `mirror.sources` existed in the schema, the config and the generated setup snapshot, and
  nothing read them — a surface with nothing behind it, which is the failure mode that reads as
  finished. `board --mirror` now renders each configured document into the plane, stamped with
  the commit it was made from, refusing any page whose generated marker a human removed. A cap
  on the number of files is reported rather than applied silently: a quiet truncation reads as
  "everything is mirrored" when it is not.

## 0.5.0 — 2026-07-29

### Added
- **`adopt`** — inspect an existing project and *propose* a configuration. Adoption is where a
  coordination tool most easily starts lying: guess a register wrong and every later check is
  confidently about the wrong file. So it reads the repository, prints what it found, prints the
  decisions it **refuses to make for you** (a registry file carrying ids with no "next free id"
  line cannot have allocation reserved safely), and writes nothing. In a submodule it proposes no
  registers at all, because decisions belong to the parent repository.

## 0.4.0 — 2026-07-29

Found by simulating three agents working three repositories at once, entered from one
umbrella — the arrangement this tool is for. Every defect below was invisible from inside
a single checkout.

### Fixed
- **Every submodule agent ran isolated, in degraded mode, seeing nobody.** A submodule is
  its own git repository, so the project root is the submodule and `.env.agent-sync` — which
  lives in the superproject — was never found. Three agents entered from one umbrella and
  coordinated with nothing, each reporting `ungated` while believing it was configured. The
  env file is now located from the superproject and parent directories, so one credential
  file serves the whole tree.
- **A submodule's `reconcile` reported every umbrella decision as an orphan.** The as-built
  log is shared by all repositories; id registers are per-repository, and a service repo
  declares none because decisions live in the parent. Comparing the shared log against a
  local register produced a wall of false findings — the loudest possible way to teach
  people to ignore a check. Register checks are now scoped to what the checkout can judge,
  and say plainly when they are not evaluated here.
- **Regenerating the board from a submodule replaced the shared view with a narrower one.**
  Four repositories wrote one page; last writer won. The board now carries only facts true
  from every checkout, and repo-local findings moved to their own generated page.

### Added
- **`setup`** writes a generated snapshot of how *this* project is wired — registers,
  guarded files, gates, the two documentation sources, what is written where, and what is
  never deleted. Commit it and link it from the project's agent instructions so every agent
  reads the same description of the pipeline instead of inferring it from behaviour. It is
  generated rather than hand-written, because a hand-written description of a configuration
  drifts from it, which is the exact failure this tool exists to surface.
- **The lifetime and deletion protocol is now stated.** Nothing in a log is edited or
  deleted: the logs are replayed in order, so removing a line silently rewrites a
  conclusion other agents already acted on. Correct by appending. Generated pages are the
  narrow exception, and one that has lost its marker is refused, not overwritten.

## 0.3.3 — 2026-07-29

### Fixed
- **The enforcement hook ran in a different mode from the agent it was guarding.** A hook is
  spawned with a bare environment and never inherits the operator's
  `set -a && . ./.env.agent-sync`, so every hook silently fell back to the `fs` backend while
  the agent's own commands used the cloud. Consequences, both invisible: the guard **denied
  edits whose lease was properly held**, and it recorded runs as `ungated` while the agent had
  been told `gated`. The gate was structurally broken in exactly the scenario it exists for.
  The tool now loads `.env.agent-sync` itself — the path is deterministic, so correctness must
  not depend on how the process was invoked. An already-set variable still wins.

## 0.3.2 — 2026-07-29

### Fixed
- **`reconcile` demanded an as-built record for an id nobody had taken.** The id scraper
  matched every `DEC-\d+` token in a register, including the "Next free ID" pointer — the one
  number that by definition is *not* allocated. Two symptoms, one cause: a permanent false
  finding on the unallocated id, and a baseline stamped one higher than reality, which quietly
  excused the newest real decision from ever being checked. The register's own
  `nextFreeIdPattern` now identifies that pointer and subtracts it.
  Found by running the new duty against this project rather than by reading the code.

## 0.3.1 — 2026-07-29

### Fixed
- **The guard blocked the lease holder.** A hook runs with `CLAUDE_SESSION_ID` in its
  environment and a plain shell command usually does not, so the run id was derived two
  different ways for one session: the agent acquired a lease as `r-f49d900b9` and was then
  denied by its own `PreToolUse` guard as `r-5ef2554fe611`. The primary flow — acquire,
  then edit a guarded register — could not complete. Found when the gate refused the very
  commit that was writing its decision record.
  The marker file is now authoritative for the checkout, with the session name recorded
  beside it: a different session rotates the id, while a run that merely *learns* its
  session name adopts it instead of rotating.

## 0.3.0 — 2026-07-29

### Fixed
- **Three of the four hooks were dead on macOS.** They called `timeout`, which is GNU
  coreutils and absent from a stock macOS, so `session-start`, `renew` and `session-end`
  all died with "command not found" — leases were never renewed and never released
  there, the exact abandoned-lease failure this tool exists to prevent. A portable
  `run_limited` helper now uses `timeout`, then `gtimeout`, then a plain-bash watchdog.
  `guard` was unaffected, so enforcement itself never lapsed.

### Added
- **The as-built record, and the duty to reconcile it against git.** Git documents say
  how it *should* be — written before the code, often without it. `70 As-built` says how
  it *actually is*, derived from what agents really wrote. Two source-of-truths answering
  two different questions; the gap between them is the finding, not a defect. New
  `record` and `reconcile` commands, wired into the pipeline's docs-study stage (resolve
  divergence before writing code) and docs stage (update both sides, then re-check).
- **`reconcile` is a ratchet, not a flood.** `--set-baseline` stamps today's ids as a
  counted backlog that may only shrink; ids written after it must carry an as-built
  record. A check that fails on all of history is a check that gets switched off.
- **Awareness names the repository.** Work spans several repos entered from one umbrella,
  and "r-alpha holds ASC-072" is only actionable once you know which checkout it is in.
- **`npx agent-sync update`** — updates every channel and prunes the shadow copy in the
  same step, because `npx skills update --global` recreates it on its own even when
  claude-code was never targeted.
- **`install.sh`** POSIX fallback and a **Cursor rule** (`cursor/rules/agent-sync.mdc`,
  no relative links, since the file gets copied into foreign projects).

## 0.2.0 — 2026-07-29

Coordination is not only mutual exclusion. An audit against the stated purpose — *agents
see what each other are doing and pick up important changes in time* — found the tool
enforced exclusion and delivered neither half of the awareness.

### Fixed
- **`status` shows what other runs are doing.** It reported only the caller's own leases,
  so an agent learned a task was taken and nothing about who held it or what they were
  touching. A lease you cannot see makes you blocked; a lease you can see makes you
  coordinated.
- **Cross-repo signals were write-only.** `signal` appended and nothing ever read the log,
  so a producer was still never told a dependency had been filed against them — the exact
  failure the feature exists to prevent. `status` now surfaces what landed since this run
  last looked, watermarked per run so it stays quiet until something actually changes.
- **The board renders recent signals** alongside live leases.

### Notes
- Verified with three concurrent runs across two repositories: an agent working inside a
  submodule alone sees the leases and signals of agents in the parent repository, because
  both read one coordination plane.

## 0.1.0 — 2026-07-29

First release.

### Added
- **Lease authority with TTL** — `acquire` / `renew` / `release`, decided by replaying
  one append-only log. Document order is authoritative; timestamps only expire a lease,
  because agents' clocks differ and the protocol must not depend on them.
- **Race-free id reservation** — positional allocation over the same log, so two agents
  cannot be handed one number. An id reserved and never written to git is reported as a
  leak rather than silently reclaimed.
- **Pluggable adapter contract** — six primitives, three declared capabilities
  (`atomicAppend`, `totalOrderRead`, `search`), and a mandatory honest-degradation path:
  a backend that cannot arbitrate exclusively must refuse lease authority and say so.
- **Backends** — `outline` (hosted or self-hosted; server-side append gives a total order
  without compare-and-swap) and `fs` (local, degraded, `ungated`).
- **Run journal and cross-repo signal feed** — `filed → accepted → delivered → closed`,
  so a producer learns a dependency was filed against them.
- **Generated board** — commit-stamped, and it refuses to overwrite a page that lacks the
  generated marker, so a page a human took over is reported instead of clobbered.
- **Claude Code hooks** — `SessionStart`, `PreToolUse` (deny a guarded edit or a commit
  staging one without a live lease), throttled `PostToolUse` renew, `SessionEnd` release.
  The guard exits 2 on its own internal failures, because any other code fails open.
- **`init` as the first command** — it asks where coordination state lives instead of
  guessing, writes committed shape and a gitignored mode-600 env file, and leaves the
  token line empty for the operator to fill.
- **Validator with a negative self-test**, plus CI.

### Verified against a live instance
Built and then exercised end to end against a real Outline deployment, which surfaced three
defects the unit-level work had not:
- **Markdown normalisation.** The store rewrites a `- ` bullet to `* `, so the log parser now
  emits `- ` and accepts `-`/`*`/`+`. Anchoring to the character written rejected every line
  the server returned.
- **A silent pre-filter hid malformed lines from the counter meant to expose them**, so the
  unparseable ratio read 0% while nothing parsed. Anything entry-shaped now reaches the pattern
  and is counted.
- **An unreadable log reported as a lost race.** `acquire` now raises above a 2% unparseable
  ratio rather than naming a holder who does not exist.
- HTTP error bodies are surfaced instead of dropped — a bare `400` cost a debugging round when
  the response said `collectionId: Invalid UUID`.
- The collection may be given as a UUID, a `urlId`, or the whole `name-urlId` slug from the
  address bar, because that is what a person actually copies.

### Notes
- Hooks exist only in Claude Code. Elsewhere the same checks run as a self-check and the
  run is recorded `ungated` — a documented limit, surfaced rather than hidden.
- Requires [task-pipeline](https://github.com/ssheleg/task-pipeline) for its stages.
