@ssheleg/agent-sync 1.2.4 → 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,83 @@
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
+
6
83
  ## 1.2.4 — 2026-07-29
7
84
 
8
85
  ### Fixed — the tool misreported its own version, and disagreed with itself about the lease
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.2.4",
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.4",
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.4"
7
+ version: "1.3.1"
8
8
  author: appvillis-com
9
9
  ---
10
10
 
@@ -191,11 +191,34 @@ npx sshlg-skills install
191
191
  | `adopt` | Inspect an existing project and **propose** a config — writes nothing |
192
192
  | `scaffold` | Create the missing documentation architecture. Never overwrites |
193
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 |
194
196
 
195
197
  `$SKILL_DIR` is this skill's own directory. Every command reads
196
198
  `.claude/agent-sync.json` from the project root and needs no arguments beyond those
197
199
  listed.
198
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
+
199
222
  ## Claiming — the shape that matters
200
223
 
201
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,7 +32,7 @@ from datetime import datetime, timezone
32
32
  from pathlib import Path
33
33
  from typing import Any
34
34
 
35
- VERSION = "1.2.4"
35
+ VERSION = "1.3.1"
36
36
 
37
37
  CONFIG_PATH = Path(".claude/agent-sync.json")
38
38
  ENV_FILE = Path(".env.agent-sync")
@@ -208,46 +208,103 @@ def load_config(root: Path) -> dict[str, Any]:
208
208
  raise Fail(f".claude/agent-sync.json is not valid JSON: {exc}") from exc
209
209
 
210
210
 
211
- def run_id(root: Path) -> str:
212
- """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.
213
216
 
214
- This is load-bearing. A hook runs with CLAUDE_SESSION_ID in its environment and a
215
- plain shell command usually does not, so deriving the id from that variable gave
216
- one session two identities: the agent acquired a lease as one and was then denied
217
- 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.
218
223
 
219
- The marker file is therefore authoritative for the checkout, with the session name
220
- recorded beside it. A genuinely different session rotates it; a run that merely
221
- *learns* its session name adopts it instead of rotating otherwise the first shell
222
- 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.
223
227
  """
224
228
  override = os.environ.get("AGENT_SYNC_RUN_ID")
225
229
  if override:
226
- return "r-" + re.sub(r"[^a-z0-9]", "", override.lower())[:12]
230
+ return "env:" + override, "AGENT_SYNC_RUN_ID"
227
231
 
228
232
  session = os.environ.get("CLAUDE_SESSION_ID") or ""
229
- 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"
260
+
261
+
262
+ def run_id(root: Path) -> str:
263
+ """One identity per session, whichever way the tool is invoked.
230
264
 
231
- stored: dict[str, str] = {}
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] = {}
232
282
  if marker.exists():
233
283
  raw = marker.read_text().strip()
234
284
  try:
235
- stored = json.loads(raw)
285
+ parsed = json.loads(raw)
236
286
  except json.JSONDecodeError:
237
- stored = {"run": raw, "session": ""} # legacy plain-text marker
238
-
239
- if stored.get("run"):
240
- known = stored.get("session", "")
241
- if not session or known == session:
242
- return stored["run"]
243
- if not known:
244
- marker.write_text(json.dumps({"run": stored["run"], "session": session}))
245
- return stored["run"]
246
-
247
- rid = ("r-" + re.sub(r"[^a-z0-9]", "", session.lower())[:12]) if session else \
248
- "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
+
249
306
  marker.parent.mkdir(parents=True, exist_ok=True)
250
- marker.write_text(json.dumps({"run": rid, "session": session}))
307
+ marker.write_text(json.dumps(data, indent=1))
251
308
  return rid
252
309
 
253
310
 
@@ -718,6 +775,27 @@ class Sync:
718
775
  except (json.JSONDecodeError, ValueError):
719
776
  return sha, {}
720
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
+
721
799
  def _git_acquire(self, key: str) -> tuple[bool, str | None]:
722
800
  """Push a ref that must not already exist. The remote's non-fast-forward rule
723
801
  IS the compare-and-swap — verified against a hosted remote, not assumed."""
@@ -725,6 +803,7 @@ class Sync:
725
803
  held_sha, held = self._git_read_lease(key)
726
804
  if held:
727
805
  if held.get("run") == self.rid:
806
+ self._note_local(key, json.dumps(held))
728
807
  self._touch_renew()
729
808
  return True, self.rid
730
809
  alive = time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
@@ -747,6 +826,7 @@ class Sync:
747
826
  # Rejected: somebody won between our read and our push. Ask who.
748
827
  _s, now_held = self._git_read_lease(key)
749
828
  return False, now_held.get("run") or "another run"
829
+ self._note_local(key, payload)
750
830
  self._touch_renew()
751
831
  return True, self.rid
752
832
 
@@ -2075,6 +2155,149 @@ def cmd_adopt(_args: argparse.Namespace) -> int:
2075
2155
  return 0
2076
2156
 
2077
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
+
2078
2301
  DECISIONS_SEED = """# Decisions
2079
2302
 
2080
2303
  Every settled decision about this project, append-only. A decision is any answer that
@@ -2160,6 +2383,19 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
2160
2383
 
2161
2384
  seed(docs / "DECISIONS.md", DECISIONS_SEED)
2162
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)
2163
2399
 
2164
2400
  for c in created:
2165
2401
  print(f" + {c}")
@@ -2175,6 +2411,124 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
2175
2411
  return 0
2176
2412
 
2177
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
+
2178
2532
  def cmd_check(_args: argparse.Namespace) -> int:
2179
2533
  """Validate the whole setup, end to end, and refuse to call a broken one healthy.
2180
2534
 
@@ -2418,8 +2772,13 @@ def build_parser() -> argparse.ArgumentParser:
2418
2772
  sub.add_parser("setup", help="write the generated snapshot of how this project is wired").set_defaults(fn=cmd_setup)
2419
2773
  sub.add_parser("adopt", help="inspect an existing project and propose a config (writes nothing)").set_defaults(fn=cmd_adopt)
2420
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)
2421
2778
  sc = sub.add_parser("scaffold", help="create the missing documentation architecture (never overwrites)")
2422
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")
2423
2782
  sc.set_defaults(fn=cmd_scaffold)
2424
2783
  bd = sub.add_parser("board", help="regenerate the read-only board")
2425
2784
  bd.add_argument("--mirror", action="store_true",