@ssheleg/agent-sync 1.2.3 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,112 @@
3
3
  All notable changes to this project are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.3.1 — 2026-07-29
7
+
8
+ ### The git lease was invisible to everything that reads a lease — fixed
9
+
10
+ Found in production, blocking real work three times in one session. In `git` lease mode `acquire`
11
+ won the lease by pushing `refs/agent-sync/leases/<key>` and stopped there, while `held()` — the one
12
+ function behind `whoami`, `status` and the **PreToolUse guard** — read `.agent-sync/leases/*.lock`,
13
+ which nothing in that path ever wrote. The result was the exact inversion of the tool's purpose:
14
+ `acquire` printed *won*, `whoami` printed *holds: nothing*, and the guard **denied the run that held
15
+ the lease**. Every guarded register was unwritable under the mode this tool recommends, and the only
16
+ way past it was to bypass the guard — which is the behaviour the guard exists to prevent.
17
+
18
+ The git ref remains the authority; it is what makes exclusion hold across machines. What was missing
19
+ is that the winner now leaves a local note, so the local question — *does this run hold that key?* —
20
+ is answered locally instead of putting a network round-trip in front of every Edit. `release`
21
+ already removed that note, which is why only one half of the loop was ever written.
22
+
23
+ **Why it shipped:** the lease-visibility assertion existed only for the local mode. `test/validate.py`
24
+ now runs acquire → `whoami` → `guard` → release against **both** modes; against 1.3.0 it fails with
25
+ the two symptoms above, which is the point of adding it.
26
+
27
+ ## 1.3.0 — 2026-07-29
28
+
29
+ ### Two agents in one checkout were one identity — fixed
30
+
31
+ Found in production, in the case this plugin exists for: **two Claude sessions working the same
32
+ checkout shared a single run id**, so the lease could not separate them. A hook runs with
33
+ `CLAUDE_SESSION_ID` in its environment and a plain shell command does not, and the marker file held
34
+ one id per checkout — so the second session adopted whatever the first had stamped. Both acquired
35
+ as one run, both were guarded as one run, and `release` would take a lease the caller never
36
+ acquired. The failure is silent: `whoami` reports a lease, and it is somebody else's.
37
+
38
+ - the marker is now a **map** keyed by session, and migrates the old single-value file into it
39
+ - a plain shell has no session id, so the `SessionStart` hook stamps
40
+ `.agent-sync/sessions/<CLI pid>` with the session it *does* know, and later commands find
41
+ themselves by walking their own ancestry. Exact, and no command-line parsing: the throwaway
42
+ shell every tool call runs in carries claude paths in its argv and defeated every heuristic
43
+ aimed at the CLI binary
44
+ - stale stamps are removed when their process is gone, so the directory cannot grow
45
+ - where identity still cannot be established, the run says so rather than presenting a shared
46
+ entry as separation
47
+
48
+ ### `scaffold --full` — the architecture that keeps documentation linked, not merely present
49
+
50
+ `scaffold` seeded a decision register and an agent protocol. That is enough to be coordinated and
51
+ not enough to stay coherent: the things that rot are the links between documents, and nothing was
52
+ seeding the pieces that hold them — a question register that resolves into decisions, an index
53
+ nobody has to scan the register to use, one place for facts about two repositories, one definition
54
+ per entity with a checkable address, **and a gate**, because each of those decays silently.
55
+
56
+ `--full` adds `OPEN_QUESTIONS.md`, `INDEX.md`, `DEPENDENCIES.md`, `DATA_MODEL.md` (with the entity
57
+ register and the one-definition rule) and `scripts/check-docs.sh`, which fails on: an id cited and
58
+ never defined, a next-free-ID line that is not next, a relative link to a file that does not exist,
59
+ a `#anchor` that does not exist in the file it points at, and a decision with no index row. All
60
+ five probed against planted defects.
61
+
62
+ **A fresh scaffold passes its own gate.** The first version did not — it counted the template block
63
+ and the allocation line as real ids — and a project that starts red teaches everyone that the gate
64
+ is noise.
65
+
66
+ ### `finish` — the gate expressions this plugin declares, executed
67
+
68
+ `references/pipeline-binding.md` has always listed *submodule pointers current* and *every lease
69
+ released* as gate expressions "verified by the coordinator, not by prose". Nothing ran them:
70
+ `check` validates the **setup** — config, registers, credentials, snapshot — and never looks at the
71
+ state of the repositories.
72
+
73
+ `finish` answers the other question, *is the work finished*:
74
+
75
+ - every submodule's recorded gitlink equals its HEAD. This is the failure it exists for and it is
76
+ invisible from either side alone: the submodule is pushed, its CI is green, its roadmap says
77
+ done, and a clone of the parent has the commit before the work
78
+ - every repository — parent included — is clean and pushed, with a detached submodule accepted
79
+ only when its commit exists on some remote branch
80
+ - no lease left held, because a run that ends holding one blocks the next agent for the whole TTL
81
+ - `--gates` also runs the project's own declared gate commands
82
+
83
+ ## 1.2.4 — 2026-07-29
84
+
85
+ ### Fixed — the tool misreported its own version, and disagreed with itself about the lease
86
+ - **`VERSION` drifted a release behind.** The constant said `1.2.2` while every manifest
87
+ said `1.2.3`, so each `status` and `adopt` header named the wrong version — the exact
88
+ number the README tells an operator to compare when hunting a stale install channel.
89
+ `check_version_sync()` read five manifests and not the script; `check_scripts_run()` ran
90
+ `--version` only to prove the process starts, and threw the answer away. The constant is
91
+ now part of the sync check, so this cannot drift silently again.
92
+ - **`gated` was decided by the record backend, which has not decided a lease since 1.0.0.**
93
+ It read the adapter's `atomicAppend`/`totalOrderRead` capabilities, and both directions
94
+ lied: `outline` with a local lock reported `gated` while exclusion was machine-local —
95
+ the pretended lease the skill's own trap 2 warns about — and `fs` with git refs reported
96
+ `ungated` while every lease was a genuine cross-machine compare-and-swap. It now derives
97
+ from `leaseBackend`.
98
+ - **Six surfaces phrased the guarantee independently, and two called the knowledge base
99
+ the "lease authority".** `status` said `lease authority: NO — degraded` for the same
100
+ project where `check` said `exclusive on this machine` and `acquire` said something else
101
+ again. One guarantee described three ways reads as three guarantees, and an operator acts
102
+ on the weakest. The wording now lives in one table (`lease_guarantee()`), used by
103
+ `status`, `acquire`, `check`, the board, the setup snapshot and `init`. `status` reports
104
+ the record plane and the lease as the separate facts they are.
105
+
106
+ ### Added
107
+ - **`test/validate.py` exercises the agreement**: for `leaseBackend` `local` and `git` it
108
+ runs `status`, `acquire` and `check` against a throwaway repository (a real bare remote
109
+ for `git`) and fails if any of them omits the guarantee, or if `status` still calls the
110
+ record backend the lease authority. Verified red against 1.2.3, green after.
111
+
6
112
  ## 1.2.3 — 2026-07-29
7
113
 
8
114
  ### Fixed — the guard blocked commits in projects that never installed agent-sync
package/README.md CHANGED
@@ -282,19 +282,24 @@ configuration defect.
282
282
 
283
283
  ## Backends
284
284
 
285
- The knowledge store is a **pluggable adapter**six primitives, three declared
286
- capabilities. Nothing about a specific vendor is baked in, and no instance address ships
287
- in this repository.
285
+ **Two settings, two jobs.** `backend` chooses the record plane where the log, the
286
+ signals and the board live. `leaseBackend` chooses what actually decides a lease. The
287
+ knowledge base is never the second one: measured against a real instance, twelve
288
+ concurrent appends to one document returned twelve successes and left three lines.
288
289
 
289
- | Backend | Lease authority | Notes |
290
- |---|---|---|
291
- | `outline` | yes | [Outline](https://www.getoutline.com), hosted or self-hosted. Server-side append gives a total order without compare-and-swap |
292
- | `fs` | no — **degraded** | Local files. Real mutual exclusion between agents on one machine, none across machines. Every run is recorded `ungated` |
290
+ | `backend` the record plane | What it gives |
291
+ |---|---|
292
+ | `outline` | [Outline](https://www.getoutline.com), hosted or self-hosted. Every repository and machine reads one plane: shared awareness, cross-repo signals, the board |
293
+ | `fs` | Local files. No credentials, and no visibility to an agent on another machine |
294
+
295
+ | `leaseBackend` — the lease | Guarantee |
296
+ |---|---|
297
+ | `git` | **Exclusive across machines.** The remote's non-fast-forward rejection is a real compare-and-swap |
298
+ | `local` *(default)* | **Exclusive on this machine, advisory across machines.** An atomic file create |
293
299
 
294
- **A backend that cannot arbitrate says so.** When the adapter is not the lease authority,
295
- `agent-sync` announces it, falls back to git-file leases, and marks runs `ungated`
296
- because a lease that is not actually exclusive is worse than none, and the other agent
297
- has stopped checking.
300
+ `status`, `acquire` and `check` all state which of the two you have, in the same words,
301
+ because a lease that is not actually exclusive is worse than none: the other agent has
302
+ stopped checking. `runs recorded: gated` follows the lease mode never the record plane.
298
303
 
299
304
  Adding one: read
300
305
  [`references/adapter-contract.md`](plugins/agent-sync/skills/agent-sync/references/adapter-contract.md).
@@ -331,15 +336,18 @@ board at docs, release everything at acceptance. Wiring:
331
336
  expire one. Agents' clocks differ and the protocol does not depend on them.
332
337
  - **A reserved id that never reaches git is reported, not reclaimed.** A half-written
333
338
  decision on a branch is not an unused number.
334
- - **`fs` is not cross-machine.** It is a real mutex between agents on one host and
335
- nothing more, which is why it never claims lease authority.
339
+ - **`fs` is not cross-machine.** As a record plane it is invisible to agents on another
340
+ host, and the default `local` lease is a mutex on this one. Set `leaseBackend: "git"`
341
+ when a fleet spans machines; the tool says which guarantee you have rather than
342
+ implying the stronger one.
336
343
 
337
344
  ## Troubleshooting
338
345
 
339
346
  | Symptom | Cause and fix |
340
347
  |---|---|
341
348
  | `task-pipeline is not installed` and `status` stops | Intentional — there are no stages to bind to. `npx sshlg-skills install` |
342
- | `⚠ ungated backend — this lease is advisory` | The `fs` backend, or missing credentials. Expected; configure `outline` for enforced leases |
349
+ | `⚠ this lease is advisory, not enforced` | `gated: false` in the config, or a `leaseBackend` that is neither `local` nor `git`. Fix the mode — an unknown one claims nothing on purpose |
350
+ | `lease: local — advisory across machines` | Expected on the default. Set `leaseBackend: "git"` (and a reachable `leaseRemote`) when agents run on more than one machine |
343
351
  | Every `acquire` reports `lost` | Check the holder in `status`. If the log itself is unreadable, `acquire` raises instead — that is a parse failure, not a race |
344
352
  | Guarded edit blocked in Claude Code | Working as designed: `acquire` the key first, or unstage the file |
345
353
  | Guarded edit *not* blocked | You are not on Claude Code. Run `guard <path>` yourself; the run is `ungated` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
4
4
  "description": "Let concurrent coding agents share one project without colliding \u2014 leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sync",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
4
4
  "description": "Coordination layer for multi-agent repositories — leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
5
5
  "author": {
6
6
  "name": "appvillis-com"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Coordinate concurrent agents — initialise the shared knowledge store, check status, claim a task, reserve an id, or regenerate the board.
3
- argument-hint: "[init|status|claim <KEY>|release <KEY>|reserve <REG>|board]"
3
+ argument-hint: "[init|status|claim <KEY>|release <KEY>|reserve <REG>|board|finish]"
4
4
  ---
5
5
 
6
6
  Invoke the `agent-sync` skill.
@@ -14,3 +14,7 @@ instance URL) or local files. Never guess that answer.
14
14
 
15
15
  If the project is already initialised, report status and name exactly one next
16
16
  action.
17
+
18
+ With `finish`, run the end-of-work check instead: every repository clean, pushed and pointed at,
19
+ and no lease left held. In a project of git submodules that is the one failure nobody sees — the
20
+ submodule is pushed and the parent still points at the commit before the work.
@@ -4,5 +4,24 @@ set -uo pipefail
4
4
  . "${CLAUDE_PLUGIN_ROOT}/hooks/_lib.sh"
5
5
  S="$AGENT_SYNC_PY"
6
6
  agent_sync_configured || exit 0
7
+
8
+ # Stamp who this session is, keyed by the process every command in it descends from.
9
+ # A hook has CLAUDE_SESSION_ID in its environment and a plain shell command does not, so without
10
+ # this a second session in the same checkout adopts the first one's identity: both acquire and
11
+ # release as one run, and the lease stops separating the exact case it exists for. $PPID here is
12
+ # the CLI process, which is the one ancestor every later command shares.
13
+ if [ -n "${CLAUDE_SESSION_ID:-}" ]; then
14
+ d="$(git rev-parse --show-toplevel 2>/dev/null)/.agent-sync/sessions"
15
+ if mkdir -p "$d" 2>/dev/null; then
16
+ printf '%s' "$CLAUDE_SESSION_ID" > "$d/$PPID" 2>/dev/null || true
17
+ # forget the stamps of processes that are gone, so the directory cannot grow without bound
18
+ for f in "$d"/*; do
19
+ b="$(basename "$f")"
20
+ case "$b" in *[!0-9]*) continue ;; esac
21
+ kill -0 "$b" 2>/dev/null || rm -f "$f"
22
+ done
23
+ fi
24
+ fi
25
+
7
26
  run_limited 10 python3 "$S" status 2>&1 || true
8
27
  exit 0
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.2.3"
7
+ version: "1.3.1"
8
8
  author: appvillis-com
9
9
  ---
10
10
 
@@ -107,10 +107,13 @@ storage question gets asked and answered, once, and written down.
107
107
  1. **Where should coordination state live?**
108
108
  - a knowledge cloud (`outline`) — the shared record, awareness and board across
109
109
  machines. **It does not decide leases**; nothing in it can (trap 1);
110
- - or local files (`fs`) — no credentials, no shared awareness, every run `ungated`.
110
+ - or local files (`fs`) — no credentials, and no visibility to an agent on another
111
+ machine: no shared awareness, no cross-repo signals, no shared board.
111
112
 
112
113
  The lease is decided separately by `leaseBackend` — `git` for cross-machine exclusion,
113
- `local` otherwise.
114
+ `local` otherwise — and **`gated` follows that choice, never the record plane**. `fs`
115
+ with a local lock is still real exclusion between the agents on this machine; `outline`
116
+ with a local lock is *not* exclusion across them. Report the one you actually have.
114
117
  2. **If cloud: the instance URL.** The URL is configuration, not a secret, so you
115
118
  may write it. The **token is not** — you never ask for it in chat, never read it
116
119
  back, and never place it yourself.
@@ -188,11 +191,34 @@ npx sshlg-skills install
188
191
  | `adopt` | Inspect an existing project and **propose** a config — writes nothing |
189
192
  | `scaffold` | Create the missing documentation architecture. Never overwrites |
190
193
  | `check` | Validate the whole setup end to end. Non-zero when it is not healthy |
194
+ | `scaffold [--full]` | Create only what is missing, never a line over anything that exists. `--full` also seeds the question register, the index, the dependency board, the data model with its entity register, and the docs gate |
195
+ | `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at, no lease left held. `check` answers whether the project is wired correctly; this answers whether you are done |
191
196
 
192
197
  `$SKILL_DIR` is this skill's own directory. Every command reads
193
198
  `.claude/agent-sync.json` from the project root and needs no arguments beyond those
194
199
  listed.
195
200
 
201
+ ## One identity per session, and how it is decided
202
+
203
+ A lease is only a lease if two agents get two identities. Ordering matters here and both ends have
204
+ bitten: deriving the id from `CLAUDE_SESSION_ID` alone gave **one session two identities** — it
205
+ acquired as one and was denied by its own guard as the other — and keeping one id per checkout gave
206
+ **two sessions one identity**, which is worse. The second is silent: both sessions acquire, both are
207
+ guarded, and `release` takes a lease the caller never had.
208
+
209
+ The order is: `AGENT_SYNC_RUN_ID` · `CLAUDE_SESSION_ID` · **the session that started this shell** ·
210
+ shared.
211
+
212
+ The third is the one that matters, because a plain shell command has no session id and a hook does.
213
+ So `SessionStart` stamps `.agent-sync/sessions/<the CLI's pid>` with the session it knows, and a
214
+ later command finds itself by walking its own process ancestry to a pid that appears there. It is
215
+ exact, and it deliberately does **not** parse command lines: the throwaway shell every tool call
216
+ runs in carries claude paths in its own argv, so every heuristic aimed at the CLI binary matched it
217
+ instead. Stamps are removed when their process is gone.
218
+
219
+ When none of the four can be established the run says so — *"this identity is shared with any other
220
+ session in this checkout"* — rather than presenting a shared entry as separation.
221
+
196
222
  ## Claiming — the shape that matters
197
223
 
198
224
  ```
@@ -64,13 +64,17 @@ user-facing work) and `make-skill`, and it prunes the duplicate plain-copy shado
64
64
 
65
65
  ## Gate expressions
66
66
 
67
- Each `check` above is verified by the coordinator, not by prose:
67
+ Each `check` above is verified by the coordinator, not by prose — and since 1.3.0 the repository
68
+ half of that verification is a command rather than a promise: **`agent_sync.py finish`** runs the
69
+ pointer, cleanliness, pushed-ness and lease checks, and `finish --gates` adds the project's own
70
+ declared gates. Until then this table described work nothing performed.
68
71
 
69
72
  | Check | How it is decided |
70
73
  |---|---|
71
74
  | lease held | replay the log; holder == this run |
72
75
  | every id reserved | every `DEC-`/`OQ-`/`DEP-`-shaped token new in the diff has a `reserve` line in this run |
73
76
  | no two parallel tasks write one file | intersect the file lists journaled at stage 4 |
74
- | submodule pointers current | `git submodule status` reports no `+` prefix |
77
+ | submodule pointers current | `git submodule status` reports no `+` prefix — `finish` |
78
+ | every repository pushed | no commits ahead of upstream anywhere, parent included — `finish` |
75
79
  | board regenerated, no drift | each mirror stamp equals `git rev-parse HEAD` for its source |
76
80
  | every lease released | replay the log; this run holds nothing |
@@ -32,13 +32,33 @@ from datetime import datetime, timezone
32
32
  from pathlib import Path
33
33
  from typing import Any
34
34
 
35
- VERSION = "1.2.2"
35
+ VERSION = "1.3.1"
36
36
 
37
37
  CONFIG_PATH = Path(".claude/agent-sync.json")
38
38
  ENV_FILE = Path(".env.agent-sync")
39
39
  STATE_DIR = Path(".agent-sync")
40
40
  GENERATED_MARKER = "<!-- agent-sync:generated"
41
41
 
42
+ # What a won lease is actually worth, in one place. Six surfaces used to phrase this
43
+ # independently and two of them named the knowledge base as the authority — a role it
44
+ # has not held since 1.0.0, when exclusion moved to a primitive the store cannot lose.
45
+ # One guarantee described two ways reads as two guarantees, and an operator acts on the
46
+ # weaker one.
47
+ LEASE_GUARANTEE = {
48
+ "git": ("exclusive across machines",
49
+ "the remote's non-fast-forward rejection is a real compare-and-swap"),
50
+ "local": ("exclusive on this machine, advisory across machines",
51
+ 'set `leaseBackend: "git"` if agents run on more than one'),
52
+ }
53
+
54
+
55
+ def lease_guarantee(mode: str) -> tuple[str, str]:
56
+ """The headline and the detail for a lease mode. Unknown modes claim nothing."""
57
+ return LEASE_GUARANTEE.get(
58
+ mode, ("NOT a lease — unknown mode, treat this project as unprotected",
59
+ f"'{mode}' is not a known leaseBackend; only {' or '.join(LEASE_GUARANTEE)}"))
60
+
61
+
42
62
  LOGS = {
43
63
  "claims": "30 Claims",
44
64
  "reservations": "40 Reservations",
@@ -188,46 +208,103 @@ def load_config(root: Path) -> dict[str, Any]:
188
208
  raise Fail(f".claude/agent-sync.json is not valid JSON: {exc}") from exc
189
209
 
190
210
 
191
- def run_id(root: Path) -> str:
192
- """One identity per session, whichever way the tool is invoked.
211
+ def _session_key() -> tuple[str, str]:
212
+ """Who is asking and how confidently.
213
+
214
+ Returns `(key, how)`. `how` is reported to the user, because the three answers are not
215
+ equally strong and an identity nobody can question is how two agents end up as one.
193
216
 
194
- This is load-bearing. A hook runs with CLAUDE_SESSION_ID in its environment and a
195
- plain shell command usually does not, so deriving the id from that variable gave
196
- one session two identities: the agent acquired a lease as one and was then denied
197
- by its own guard as the other. The gate blocked the lease holder.
217
+ The hard case is real and was found in production: **two Claude sessions in the same
218
+ checkout.** A hook runs with `CLAUDE_SESSION_ID` in its environment; a plain shell command
219
+ does not. With a single marker file per checkout, the second session adopts whatever the
220
+ first stamped so both acquire, release and are guarded as one run. The lease then fails to
221
+ separate exactly the case it exists for, silently, and one agent can release the other's
222
+ lease mid-work.
198
223
 
199
- The marker file is therefore authoritative for the checkout, with the session name
200
- recorded beside it. A genuinely different session rotates it; a run that merely
201
- *learns* its session name adopts it instead of rotating otherwise the first shell
202
- command in a fresh checkout would fork the identity all over again.
224
+ The process tree is what a plain shell still has. Every Claude session runs under its own
225
+ `claude` process, so the nearest such ancestor identifies the session even when the
226
+ environment does not. A pid can be recycled, so it is paired with that process's start time.
203
227
  """
204
228
  override = os.environ.get("AGENT_SYNC_RUN_ID")
205
229
  if override:
206
- return "r-" + re.sub(r"[^a-z0-9]", "", override.lower())[:12]
230
+ return "env:" + override, "AGENT_SYNC_RUN_ID"
207
231
 
208
232
  session = os.environ.get("CLAUDE_SESSION_ID") or ""
209
- marker = root / STATE_DIR / "run-id"
233
+ if session:
234
+ return "session:" + session, "CLAUDE_SESSION_ID"
235
+
236
+ # No session id in this environment — a plain shell command has none. What it does have is
237
+ # an ancestor process that the SessionStart hook ran under, and that hook DID have the id.
238
+ # So the hook stamps `<state>/sessions/<its own parent pid>` with the session, and this walk
239
+ # looks for an ancestor that appears there. That is exact: no command-line parsing, which was
240
+ # tried and failed — the throwaway shell this very command runs in carries claude paths in
241
+ # its argv and matched every heuristic aimed at the CLI.
242
+ try:
243
+ root = project_root()
244
+ sessions = root / STATE_DIR / "sessions"
245
+ pid = os.getpid()
246
+ for _ in range(10):
247
+ marker = sessions / str(pid)
248
+ if marker.exists():
249
+ return "session:" + marker.read_text().strip(), "the session that started this shell"
250
+ out = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)],
251
+ capture_output=True, text=True, timeout=5)
252
+ ppid = out.stdout.strip()
253
+ if not ppid or ppid in ("0", "1", str(pid)):
254
+ break
255
+ pid = int(ppid)
256
+ except (OSError, ValueError, subprocess.SubprocessError):
257
+ pass
258
+
259
+ return "", "nothing — this identity is shared with any other session in this checkout"
210
260
 
211
- stored: dict[str, str] = {}
261
+
262
+ def run_id(root: Path) -> str:
263
+ """One identity per session, whichever way the tool is invoked.
264
+
265
+ Load-bearing in both directions. Deriving the id from `CLAUDE_SESSION_ID` alone gave one
266
+ session two identities — the agent acquired a lease as one and was denied by its own guard as
267
+ the other. Keeping a single id per checkout gave two sessions one identity, which is worse:
268
+ the guard let each of them write the other's guarded files and `release` took a lease its run
269
+ never acquired.
270
+
271
+ So the marker is a **map**, keyed by whatever `_session_key()` could establish. A key that
272
+ cannot be established falls back to the shared entry — the old behaviour, kept because it is
273
+ better than minting a fresh identity on every shell command, and reported as weak rather than
274
+ presented as separation.
275
+ """
276
+ key, _how = _session_key()
277
+ if key.startswith("env:"):
278
+ return "r-" + re.sub(r"[^a-z0-9]", "", key[4:].lower())[:12]
279
+
280
+ marker = root / STATE_DIR / "run-id"
281
+ data: dict[str, Any] = {}
212
282
  if marker.exists():
213
283
  raw = marker.read_text().strip()
214
284
  try:
215
- stored = json.loads(raw)
285
+ parsed = json.loads(raw)
216
286
  except json.JSONDecodeError:
217
- stored = {"run": raw, "session": ""} # legacy plain-text marker
218
-
219
- if stored.get("run"):
220
- known = stored.get("session", "")
221
- if not session or known == session:
222
- return stored["run"]
223
- if not known:
224
- marker.write_text(json.dumps({"run": stored["run"], "session": session}))
225
- return stored["run"]
226
-
227
- rid = ("r-" + re.sub(r"[^a-z0-9]", "", session.lower())[:12]) if session else \
228
- "r-%06x%s" % (random.getrandbits(24), format(int(time.time()) & 0xFFF, "03x"))
287
+ parsed = {"run": raw, "session": ""}
288
+ if isinstance(parsed, dict) and "runs" in parsed:
289
+ data = parsed
290
+ elif isinstance(parsed, dict) and parsed.get("run"):
291
+ # migrate the single-value marker: it belonged to whichever session stamped it
292
+ legacy_key = ("session:" + parsed["session"]) if parsed.get("session") else "shared"
293
+ data = {"runs": {legacy_key: {"run": parsed["run"], "seen": now_iso()}}}
294
+ data.setdefault("runs", {})
295
+
296
+ entry = data["runs"].get(key or "shared")
297
+ if entry and entry.get("run"):
298
+ entry["seen"] = now_iso()
299
+ rid = entry["run"]
300
+ else:
301
+ session = os.environ.get("CLAUDE_SESSION_ID") or ""
302
+ rid = ("r-" + re.sub(r"[^a-z0-9]", "", session.lower())[:12]) if session else \
303
+ "r-%06x%s" % (random.getrandbits(24), format(int(time.time()) & 0xFFF, "03x"))
304
+ data["runs"][key or "shared"] = {"run": rid, "seen": now_iso()}
305
+
229
306
  marker.parent.mkdir(parents=True, exist_ok=True)
230
- marker.write_text(json.dumps({"run": rid, "session": session}))
307
+ marker.write_text(json.dumps(data, indent=1))
231
308
  return rid
232
309
 
233
310
 
@@ -615,7 +692,15 @@ class Sync:
615
692
 
616
693
  @property
617
694
  def gated(self) -> bool:
618
- return bool(self.cfg.get("gated", True)) and self.adapter.is_lease_authority
695
+ """Whether exclusion is real — decided by the lease mode, never by the record.
696
+
697
+ Until 1.2.4 this read the record adapter's capabilities, which stopped deciding
698
+ leases in 1.0.0. Both directions were wrong: `outline` with a local lock reported
699
+ `gated` while exclusion was machine-local, and `fs` with git refs reported
700
+ `ungated` while every lease was a genuine cross-machine compare-and-swap. The
701
+ plane carries the record; `leaseBackend` decides the lease.
702
+ """
703
+ return bool(self.cfg.get("gated", True)) and self.lease_mode in LEASE_GUARANTEE
619
704
 
620
705
  def log_id(self, which: str) -> str:
621
706
  """This run's OWN shard. One writer per document, always.
@@ -690,6 +775,27 @@ class Sync:
690
775
  except (json.JSONDecodeError, ValueError):
691
776
  return sha, {}
692
777
 
778
+ def _note_local(self, key: str, payload: str) -> None:
779
+ """Record locally that THIS run won THIS key here.
780
+
781
+ The git ref is the authority and stays so — it is what makes the lease exclusive across
782
+ machines. But `held()`, `whoami`, `status` and the PreToolUse guard all ask a *local*
783
+ question: does this run hold that key? Answering it by reading the remote would put a
784
+ network round-trip in front of every single Edit. So the winner leaves a note, and
785
+ `release()` — which already removes it — closes the loop.
786
+
787
+ Without this the guard denied the run that held the lease: `acquire` wrote a ref, `held()`
788
+ read a directory nothing had written, and every guarded file became unwritable in git mode.
789
+ """
790
+ lock = self._local_lock(key)
791
+ try:
792
+ fd = os.open(str(lock), os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
793
+ with os.fdopen(fd, "w") as fh:
794
+ fh.write(payload)
795
+ except OSError as exc:
796
+ # Never fatal: the lease is already won on the remote, which is the part that matters.
797
+ print(f"note: lease won but not noted locally ({exc})", file=sys.stderr)
798
+
693
799
  def _git_acquire(self, key: str) -> tuple[bool, str | None]:
694
800
  """Push a ref that must not already exist. The remote's non-fast-forward rule
695
801
  IS the compare-and-swap — verified against a hosted remote, not assumed."""
@@ -697,6 +803,7 @@ class Sync:
697
803
  held_sha, held = self._git_read_lease(key)
698
804
  if held:
699
805
  if held.get("run") == self.rid:
806
+ self._note_local(key, json.dumps(held))
700
807
  self._touch_renew()
701
808
  return True, self.rid
702
809
  alive = time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
@@ -719,6 +826,7 @@ class Sync:
719
826
  # Rejected: somebody won between our read and our push. Ask who.
720
827
  _s, now_held = self._git_read_lease(key)
721
828
  return False, now_held.get("run") or "another run"
829
+ self._note_local(key, payload)
722
830
  self._touch_renew()
723
831
  return True, self.rid
724
832
 
@@ -1377,8 +1485,8 @@ class Sync:
1377
1485
  "Every repository on this plane writes and reads this page. It carries only "
1378
1486
  "facts that are true from any of them.",
1379
1487
  "",
1380
- f"- backend: `{self.adapter.name}` · lease authority: "
1381
- f"**{'yes' if self.adapter.is_lease_authority else 'no'}**",
1488
+ f"- record plane: `{self.adapter.name}` · lease: `{self.lease_mode}` — "
1489
+ f"**{lease_guarantee(self.lease_mode)[0]}**",
1382
1490
  f"- runs are recorded as **{'gated' if self.gated else 'ungated'}**",
1383
1491
  f"- unparseable log lines: {bad}/{total}"
1384
1492
  f"{' ⚠ over 2% — the log cannot be replayed reliably' if bad / total > 0.02 else ''}",
@@ -1446,8 +1554,8 @@ class Sync:
1446
1554
  "",
1447
1555
  "## This project's wiring",
1448
1556
  "",
1449
- f"- backend: **{self.adapter.name}** · lease authority: "
1450
- f"**{'yes' if self.adapter.is_lease_authority else 'NO — degraded'}** · runs recorded "
1557
+ f"- record plane: **{self.adapter.name}** · lease: **{self.lease_mode}** — "
1558
+ f"{lease_guarantee(self.lease_mode)[0]} · runs recorded "
1451
1559
  f"**{'gated' if self.gated else 'ungated'}**",
1452
1560
  f"- lease TTL {cfg.get('leaseTtlSeconds', DEFAULT_TTL)}s, renewed every "
1453
1561
  f"{cfg.get('renewIntervalSeconds', DEFAULT_RENEW)}s",
@@ -1687,8 +1795,11 @@ def cmd_init(args: argparse.Namespace) -> int:
1687
1795
  print(" The token is yours alone: do not paste it into a chat, a commit, "
1688
1796
  "or a command line.")
1689
1797
  else:
1690
- print("Backend 'fs' needs no credentials. It is DEGRADED: it is not the lease")
1691
- print("authority, and every run is recorded as `ungated`. See references/backend-fs.md.")
1798
+ print("Backend 'fs' needs no credentials. It is the record plane only, and a")
1799
+ print("local one: agents on another machine see none of this project's leases,")
1800
+ print("signals or board. The lease itself is decided by `leaseBackend` —")
1801
+ print(f"default `local`, which is {lease_guarantee('local')[0]}.")
1802
+ print("See references/backend-fs.md.")
1692
1803
  return 0
1693
1804
 
1694
1805
 
@@ -1739,14 +1850,18 @@ def cmd_status(_args: argparse.Namespace) -> int:
1739
1850
 
1740
1851
  s = Sync()
1741
1852
  ad = s.adapter
1742
- print(f" backend : {ad.name}")
1743
- print(f" lease authority: {'yes' if ad.is_lease_authority else 'NO — degraded'}")
1853
+ headline, detail = lease_guarantee(s.lease_mode)
1854
+ print(f" record plane : {ad.name}"
1855
+ f"{'' if ad.is_lease_authority else ' — local only, not shared between machines'}")
1856
+ print(f" lease : {s.lease_mode} — {headline}")
1744
1857
  print(f" runs recorded : {'gated' if s.gated else 'UNGATED'}")
1745
1858
  print(f" run id : {s.rid}")
1746
1859
 
1747
- if not ad.is_lease_authority:
1748
- print("\n⚠ This backend cannot hold leases exclusively, so nothing here is")
1749
- print(" enforced. Do not describe this project as protected.")
1860
+ if not s.gated:
1861
+ print("\n⚠ Nothing here is enforced. Do not describe this project as protected.")
1862
+ print(f" {detail}")
1863
+ elif not s.lease_is_cross_machine:
1864
+ print(f" ({detail})")
1750
1865
 
1751
1866
  if ad.name == "outline" and isinstance(ad, OutlineAdapter) and not ad.collection:
1752
1867
  print("\n✗ AGENT_SYNC_OUTLINE_COLLECTION is empty.")
@@ -1846,15 +1961,11 @@ def cmd_acquire(args: argparse.Namespace) -> int:
1846
1961
  s = Sync()
1847
1962
  won, holder = s.acquire(args.key)
1848
1963
  if won:
1964
+ headline, detail = lease_guarantee(s.lease_mode)
1849
1965
  print(f"won {args.key} (run {s.rid}, ttl {s.ttl}s)")
1850
- if s.lease_is_cross_machine:
1851
- print(" exclusive across machines — the remote's non-fast-forward rule is a "
1852
- "real compare-and-swap")
1853
- else:
1854
- print(" exclusive between agents on THIS machine; advisory across machines. "
1855
- "Set `leaseBackend: \"git\"` for cross-machine exclusion.")
1966
+ print(f" {headline} — {detail}")
1856
1967
  if not s.gated:
1857
- print("⚠ ungated backend — this lease is advisory, not enforced")
1968
+ print("⚠ this lease is advisory, not enforced")
1858
1969
  print("Remember: release it on every path, including failure.")
1859
1970
  return 0
1860
1971
  print(f"lost {args.key} — held by {holder or 'another run'}")
@@ -2044,6 +2155,149 @@ def cmd_adopt(_args: argparse.Namespace) -> int:
2044
2155
  return 0
2045
2156
 
2046
2157
 
2158
+ OPEN_QUESTIONS_SEED = """# Open questions
2159
+
2160
+ **One job: what is not decided yet, and what settling it would unblock.**
2161
+
2162
+ **Next free ID:** `OQ-0001`
2163
+
2164
+ A question here is answered by a decision, never by a conversation: when it is settled, its row
2165
+ becomes `Resolved→DEC-####` and the reasoning goes in that decision. A question with no owner and
2166
+ no consequence is not an open question — it is a note, and it belongs somewhere else.
2167
+
2168
+ | ID | Question | Area | Status | Affects |
2169
+ |---|---|---|---|---|
2170
+ """
2171
+
2172
+ INDEX_SEED = """# Index — one row per decision
2173
+
2174
+ **One job: find the decision without reading the register.** Generated by hand and gated: a
2175
+ decision with no row here fails the docs gate, because a register nobody can scan is a register
2176
+ nobody reads.
2177
+
2178
+ **It quotes no counts and no rule.** A restated rule is a second source with a decay rate; this
2179
+ file holds titles and status only.
2180
+
2181
+ | ID | Title | Status |
2182
+ |---|---|---|
2183
+ """
2184
+
2185
+ DEPENDENCIES_SEED = """# Dependencies — the only place a fact about two repositories lives
2186
+
2187
+ **One job: name what one repository needs from another, who produces it, and who is waiting.**
2188
+
2189
+ A row carries **both sides**. A dependency with no producer task is a dependency nobody is going to
2190
+ build, and the block it causes is invisible from either side alone — the consumer says *blocked on
2191
+ DEP-003*, and DEP-003 names nobody.
2192
+
2193
+ **No status rollup.** Each row names the producer's task; the current answer is read at its source,
2194
+ never copied here where it drifts.
2195
+
2196
+ | ID | What is needed | Producer | Consumer | State | Notes |
2197
+ |---|---|---|---|---|---|
2198
+ """
2199
+
2200
+ DATA_MODEL_SEED = """# Data model — one definition per thing
2201
+
2202
+ **One job: every entity defined once, with an address, so nothing is described twice and differently.**
2203
+
2204
+ Two layers, and the distinction is not cosmetic:
2205
+
2206
+ - **Conceptual entity** — what the thing *is* in the product, its identity, its relationships, and
2207
+ the rules that travel with it. Here.
2208
+ - **Physical table** — columns, types, nullability, indexes. In the owning service's own schema
2209
+ document.
2210
+
2211
+ A physical table **must name the conceptual entity it implements**; one that names none is a
2212
+ finding, because that is where two services drift without either being wrong. The reverse is a
2213
+ finding too: an entity with no table anywhere is a thing everyone agreed on and nobody built.
2214
+
2215
+ Every entity heading carries an explicit anchor — `## <a id="thing"></a>Thing` — so a mention
2216
+ elsewhere links to the definition rather than to the top of this file. An explicit id is checkable
2217
+ at both ends; an auto-generated slug changes the moment somebody rewords a heading, and every
2218
+ inbound link breaks silently.
2219
+
2220
+ ## <a id="entity_register"></a>Entity register
2221
+
2222
+ | Entity | Address | Physical table in | Introduced by |
2223
+ |---|---|---|---|
2224
+ """
2225
+
2226
+ CHECK_DOCS_SEED = r'''#!/usr/bin/env bash
2227
+ # The documentation gate. Seeded by agent-sync; extend it, do not replace it.
2228
+ #
2229
+ # It exists because linked documentation rots quietly: a decision cites a document that never
2230
+ # mentions it, a link points at a file that moved, an id is minted twice, an index row is missing.
2231
+ # None of those break anything visibly, and all of them cost the next reader an hour.
2232
+ set -uo pipefail
2233
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$ROOT" || exit 1
2234
+ fail=0; err() { printf 'FAIL: %s\n' "$1"; fail=1; }
2235
+ DOCS="${DOCS_DIR:-docs}"; [ -d "$DOCS" ] || DOCS="."
2236
+ md=$(find "$DOCS" -name '*.md' 2>/dev/null; ls ./*.md 2>/dev/null)
2237
+
2238
+ # 1. every id that is cited is defined somewhere
2239
+ for reg in DEC OQ DEP; do
2240
+ file=$(grep -rl "Next free ID:\*\* \`$reg-" $md 2>/dev/null | head -1)
2241
+ [ -z "$file" ] && continue
2242
+ # A definition is a heading or a table row in the register itself. The template block and the
2243
+ # allocation line are neither, and a gate that counts them fails a project on the day it is
2244
+ # created — which teaches everyone that the gate is noise.
2245
+ body=$(sed -n '/^```/,/^```/!p' "$file" | grep -v "Next free ID")
2246
+ defined=$(echo "$body" | grep -ohE "^#+ $reg-[0-9]{3,4}|^\| \[?$reg-[0-9]{3,4}" | grep -oE "$reg-[0-9]{3,4}" | sort -u)
2247
+ cited=$(cat $md | sed -n '/^```/,/^```/!p' | grep -v "Next free ID" | grep -ohE "\b$reg-[0-9]{3,4}\b" | sort -u)
2248
+ missing=$(comm -13 <(echo "$defined") <(echo "$cited"))
2249
+ [ -n "$missing" ] && err "$reg cited but never defined: $(echo $missing | tr '\n' ' ')"
2250
+ # 2. the next-free-ID line is the next one, or two agents mint the same number
2251
+ next=$(grep -oE "Next free ID:\*\* \`$reg-[0-9]{3,4}" "$file" | grep -oE '[0-9]{3,4}$')
2252
+ max=$(echo "$defined" | grep -oE '[0-9]{3,4}$' | sort -n | tail -1)
2253
+ if [ -n "$next" ] && [ -n "$max" ] && [ "$((10#$next))" -le "$((10#$max))" ]; then
2254
+ err "$reg next free id is $next but $reg-$max exists"
2255
+ fi
2256
+ done
2257
+
2258
+ # 3. every relative link resolves
2259
+ while IFS= read -r hit; do
2260
+ src="${hit%%:*}"; link="${hit#*:}"
2261
+ tgt="$(cd "$(dirname "$src")" && cd "$(dirname "$link")" 2>/dev/null && pwd)/$(basename "$link")"
2262
+ [ -e "$tgt" ] || err "$src → $link does not exist"
2263
+ done < <(grep -rhoE '\]\(\.{1,2}/[A-Za-z0-9_./-]+\.md' $md 2>/dev/null | sed 's/](//' | sort -u | while read -r l; do grep -rl -- "]($l" $md | head -1 | sed "s|$|:$l|"; done)
2264
+
2265
+ # 4. every #anchor a link points at exists in the file it points at
2266
+ python3 - "$ROOT" <<'PYEOF' || fail=1
2267
+ import os, re, sys
2268
+ root = sys.argv[1]; bad = 0; cache = {}
2269
+ def ids(path):
2270
+ if path not in cache:
2271
+ try: t = open(path, errors="replace").read()
2272
+ except OSError: cache[path] = None; return None
2273
+ cache[path] = set(re.findall(r'<a id="([^"]+)"', t))
2274
+ return cache[path]
2275
+ for dp, _, fns in os.walk(root):
2276
+ if any(p in dp for p in (".git", "node_modules")): continue
2277
+ for fn in fns:
2278
+ if not fn.endswith(".md"): continue
2279
+ src = os.path.join(dp, fn)
2280
+ for rel, frag in re.findall(r'\]\((\.{1,2}/[A-Za-z0-9_./-]+\.md)#([A-Za-z0-9_-]+)\)',
2281
+ open(src, errors="replace").read()):
2282
+ tgt = os.path.normpath(os.path.join(dp, rel)); have = ids(tgt)
2283
+ if have is not None and frag not in have:
2284
+ print("FAIL: %s -> %s#%s (no such anchor)" % (os.path.relpath(src, root), rel, frag)); bad = 1
2285
+ sys.exit(bad)
2286
+ PYEOF
2287
+
2288
+ # 5. every decision has an index row
2289
+ idx=$(ls "$DOCS/INDEX.md" 2>/dev/null || true)
2290
+ dec=$(ls "$DOCS/DECISIONS.md" 2>/dev/null || true)
2291
+ if [ -n "$idx" ] && [ -n "$dec" ]; then
2292
+ for d in $(sed -n '/^```/,/^```/!p' "$dec" | grep -ohE '^#+ DEC-[0-9]{3,4}' | grep -oE 'DEC-[0-9]{3,4}'); do
2293
+ grep -q "$d" "$idx" || err "$d has no INDEX row"
2294
+ done
2295
+ fi
2296
+
2297
+ [ "$fail" -eq 0 ] && printf 'OK: documentation consistent.\n'
2298
+ exit "$fail"
2299
+ '''
2300
+
2047
2301
  DECISIONS_SEED = """# Decisions
2048
2302
 
2049
2303
  Every settled decision about this project, append-only. A decision is any answer that
@@ -2129,6 +2383,19 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
2129
2383
 
2130
2384
  seed(docs / "DECISIONS.md", DECISIONS_SEED)
2131
2385
  seed(root / "AGENTS.md", AGENTS_SEED.format(snapshot=snapshot))
2386
+ if args.full:
2387
+ # The rest of the architecture that keeps documentation LINKED rather than merely present:
2388
+ # a question register that resolves into decisions, an index nobody has to scan the
2389
+ # register to use, a place for facts about two repositories, one definition per entity —
2390
+ # and the gate, because every one of those rots silently without a check that fails.
2391
+ seed(docs / "OPEN_QUESTIONS.md", OPEN_QUESTIONS_SEED)
2392
+ seed(docs / "INDEX.md", INDEX_SEED)
2393
+ seed(docs / "DEPENDENCIES.md", DEPENDENCIES_SEED)
2394
+ seed(docs / "DATA_MODEL.md", DATA_MODEL_SEED)
2395
+ gate = root / "scripts" / "check-docs.sh"
2396
+ seed(gate, CHECK_DOCS_SEED)
2397
+ if gate.exists():
2398
+ gate.chmod(0o755)
2132
2399
 
2133
2400
  for c in created:
2134
2401
  print(f" + {c}")
@@ -2144,6 +2411,124 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
2144
2411
  return 0
2145
2412
 
2146
2413
 
2414
+ def _repo_state(path: Path, label: str, ignore: str) -> list[str]:
2415
+ """Is this repository clean, and is its work anywhere but here?
2416
+
2417
+ Both halves matter and only one of them is obvious. Uncommitted work is visible to whoever
2418
+ is sitting in front of it; work committed and never pushed is invisible to everyone else
2419
+ while looking finished to its author — the roadmap says done, the test suite is green, and
2420
+ nobody else can fetch a line of it.
2421
+ """
2422
+ problems: list[str] = []
2423
+ porcelain = git("status", "--porcelain", cwd=path)
2424
+ dirty = [ln for ln in porcelain.split("\n")
2425
+ if ln.strip() and not re.search(ignore, ln.split()[-1] if ln.split() else "")]
2426
+ if dirty:
2427
+ problems.append(f"{label} has uncommitted work: " + ", ".join(d.split()[-1] for d in dirty[:6]))
2428
+ upstream = git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", cwd=path)
2429
+ branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=path)
2430
+ if upstream:
2431
+ ahead = git("rev-list", "--count", f"{upstream}..HEAD", cwd=path) or "0"
2432
+ if ahead != "0":
2433
+ problems.append(f"{label} is {ahead} commit(s) ahead of {upstream} — pushed nowhere")
2434
+ elif branch == "HEAD":
2435
+ # A submodule sits at a detached pointer by design; what matters is that the commit exists
2436
+ # somewhere others can fetch from.
2437
+ if not git("branch", "-r", "--contains", "HEAD", cwd=path):
2438
+ problems.append(f"{label} is at a commit on no remote branch — nobody else can fetch it")
2439
+ else:
2440
+ problems.append(f"{label} ({branch}) tracks no remote — its work cannot be seen by anyone")
2441
+ return problems
2442
+
2443
+
2444
+ def cmd_finish(args: argparse.Namespace) -> int:
2445
+ """The gate expressions this plugin's pipeline binding declares, actually executed.
2446
+
2447
+ `check` answers *is this project wired correctly*. This answers *is the work finished*, and
2448
+ they are different questions with different failure modes. The one it exists for is silent by
2449
+ construction: a project of git submodules records each submodule's commit as a pointer in its
2450
+ parent, and moving the submodule does not move the pointer. So the submodule is pushed, its CI
2451
+ is green, its roadmap says `done` — and anyone who clones the parent gets the commit before the
2452
+ work. Nothing in either repository looks wrong on its own; the disagreement only exists between
2453
+ them, which is why nothing had been checking it.
2454
+ """
2455
+ s = Sync()
2456
+ root = project_root()
2457
+ ok: list[str] = []
2458
+ problems: list[str] = []
2459
+ ignore = r"\.agent-sync/"
2460
+
2461
+ print(f"run {s.rid} · {'gated' if s.gated else 'ungated'}\n")
2462
+
2463
+ # 1. the parent, then every submodule: clean, pushed, and pointed at
2464
+ p = _repo_state(root, root.name, ignore)
2465
+ problems.extend(p)
2466
+ if not p:
2467
+ ok.append(f"{root.name} clean and pushed")
2468
+
2469
+ for line in (git("submodule", "status") or "").split("\n"):
2470
+ if not line.strip():
2471
+ continue
2472
+ prefix, rest = line[0], line[1:].split()
2473
+ sub = rest[1] if len(rest) > 1 else "?"
2474
+ if prefix == "+":
2475
+ recorded = (git("ls-tree", "HEAD", sub) or "").split()
2476
+ problems.append(
2477
+ f"{sub} — the parent points at {recorded[2][:8] if len(recorded) > 2 else '?'}, "
2478
+ f"the submodule is at {rest[0][:8]}. The bump commit is missing")
2479
+ elif prefix == "-":
2480
+ problems.append(f"{sub} is not checked out — a gate run here skips everything it owns")
2481
+ elif prefix == "U":
2482
+ problems.append(f"{sub} has merge conflicts")
2483
+ else:
2484
+ ok.append(f"{sub} pointer current")
2485
+ subp = root / sub
2486
+ if subp.is_dir():
2487
+ sp = _repo_state(subp, sub, ignore)
2488
+ problems.extend(sp)
2489
+ if not sp and prefix == " ":
2490
+ ok.append(f"{sub} clean and pushed")
2491
+
2492
+ # 2. leases. A run that ends holding one blocks the next agent for the whole TTL, and the
2493
+ # holder is a run id nobody can ask about once its session is gone.
2494
+ held = s.held()
2495
+ if held:
2496
+ problems.append("this run still holds " + ", ".join(held) + " — release before you finish")
2497
+ else:
2498
+ ok.append("no lease left held")
2499
+
2500
+ # 3. the declared gates, on request. They are the project's own commands and can be slow, so
2501
+ # running them is opt-in — but a `finish` that never ran them is a claim, not a check.
2502
+ if args.gates:
2503
+ for cmd in s.cfg.get("gates", []):
2504
+ try:
2505
+ r = subprocess.run(cmd, shell=True, cwd=str(root), capture_output=True,
2506
+ text=True, timeout=600)
2507
+ except (OSError, subprocess.SubprocessError) as exc:
2508
+ problems.append(f"gate `{cmd}` could not run: {exc}")
2509
+ continue
2510
+ if r.returncode == 0:
2511
+ ok.append(f"gate `{cmd}`")
2512
+ else:
2513
+ tail = [ln for ln in (r.stdout + r.stderr).split("\n") if ln.strip()][-3:]
2514
+ problems.append(f"gate `{cmd}` failed: " + " / ".join(tail))
2515
+
2516
+ for line in ok:
2517
+ print(f" \u2713 {line}")
2518
+ for line in problems:
2519
+ print(f" \u2717 {line}")
2520
+ print()
2521
+ if problems:
2522
+ print(f"{len(problems)} problem(s) — this work is not finished. The usual one is a "
2523
+ "submodule commit with no parent bump:")
2524
+ print(' git -C <submodule> push && git add <submodule> && '
2525
+ 'git commit -m "chore: bump <name> submodule — <why>"')
2526
+ return 1
2527
+ print(f"finished cleanly ({len(ok)} checks passed) — every repository is clean, pushed, "
2528
+ "and pointed at.")
2529
+ return 0
2530
+
2531
+
2147
2532
  def cmd_check(_args: argparse.Namespace) -> int:
2148
2533
  """Validate the whole setup, end to end, and refuse to call a broken one healthy.
2149
2534
 
@@ -2225,18 +2610,19 @@ def cmd_check(_args: argparse.Namespace) -> int:
2225
2610
  ok.append(f"{len(cfg['claimTags'])} claim-tag mapping(s) declared")
2226
2611
 
2227
2612
  mode = cfg.get("leaseBackend") or "local"
2228
- if mode not in ("local", "git"):
2229
- problems.append(f"leaseBackend '{mode}' is not a known mode")
2613
+ headline, detail = lease_guarantee(mode)
2614
+ if mode not in LEASE_GUARANTEE:
2615
+ problems.append(f"leaseBackend '{mode}': {headline} — {detail}")
2230
2616
  elif mode == "git":
2231
2617
  remote = cfg.get("leaseRemote") or "origin"
2232
2618
  if not git("remote", "get-url", remote):
2233
2619
  problems.append(f"leaseBackend is 'git' but remote '{remote}' does not exist — "
2234
- "the lease cannot be decided at all")
2620
+ f"the lease cannot be decided at all ({headline} claimed, "
2621
+ "none delivered)")
2235
2622
  else:
2236
- ok.append(f"lease decided by git refs on '{remote}' — exclusive across machines")
2623
+ ok.append(f"lease decided by git refs on '{remote}' — {headline}")
2237
2624
  else:
2238
- warn.append("lease is a local file lock: exclusive on this machine, advisory "
2239
- "across machines. Set leaseBackend to 'git' if agents run on more than one")
2625
+ warn.append(f"lease is a local file lock: {headline}. {detail[0].upper()}{detail[1:]}")
2240
2626
 
2241
2627
  for cmd in (cfg.get("gates") or []):
2242
2628
  exe = cmd.split()[0]
@@ -2386,8 +2772,13 @@ def build_parser() -> argparse.ArgumentParser:
2386
2772
  sub.add_parser("setup", help="write the generated snapshot of how this project is wired").set_defaults(fn=cmd_setup)
2387
2773
  sub.add_parser("adopt", help="inspect an existing project and propose a config (writes nothing)").set_defaults(fn=cmd_adopt)
2388
2774
  sub.add_parser("check", help="validate the whole setup; non-zero if it is not healthy").set_defaults(fn=cmd_check)
2775
+ fi = sub.add_parser("finish", help="is the work finished — every repo clean, pushed and pointed at; no lease held")
2776
+ fi.add_argument("--gates", action="store_true", help="also run the project's declared gate commands")
2777
+ fi.set_defaults(fn=cmd_finish)
2389
2778
  sc = sub.add_parser("scaffold", help="create the missing documentation architecture (never overwrites)")
2390
2779
  sc.add_argument("--docs-dir", action="store_true", help="put the register under docs/ even if it does not exist yet")
2780
+ sc.add_argument("--full", action="store_true",
2781
+ help="also seed OPEN_QUESTIONS, INDEX, DEPENDENCIES, DATA_MODEL and the docs gate")
2391
2782
  sc.set_defaults(fn=cmd_scaffold)
2392
2783
  bd = sub.add_parser("board", help="regenerate the read-only board")
2393
2784
  bd.add_argument("--mirror", action="store_true",