@ssheleg/agent-sync 1.2.4 → 1.3.3

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,129 @@
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.3 — 2026-07-29
7
+
8
+ ### Fixed
9
+ - **The git lease backend could not take a lease on any machine without a git
10
+ identity.** Acquiring writes a lease object with `git commit-tree`, which
11
+ refuses when `user.email` is unset and cannot be auto-detected — CI runners,
12
+ containers, a freshly provisioned box. So the backend that advertises
13
+ *exclusive across machines* failed on precisely the machines least likely to
14
+ have a personal git config, with `could not create the lease object` and no
15
+ further detail. The lease object is plumbing, not authorship: it now carries a
16
+ fixed `agent-sync <agent-sync@localhost>` identity passed inline, so it never
17
+ depends on ambient config.
18
+ - The same failure now reports git's own last line instead of swallowing it. The
19
+ bug survived six red CI runs because the message named a possible cause and
20
+ showed no evidence.
21
+
22
+ ## 1.3.2 — 2026-07-29
23
+
24
+ Open-source hygiene — the repo is public and ships in the `sshlg-skills` bundle,
25
+ so the files a first-time contributor looks for now exist.
26
+
27
+ ### Added
28
+ - `CODE_OF_CONDUCT.md`, issue forms and a pull-request template. The forms ask
29
+ the question that actually matters for this project: **how many agents were
30
+ running, against what checkout** — a coordination bug reported without the
31
+ concurrency shape is not reproducible.
32
+ - The PR checklist requires a negative self-test with any new validator guard —
33
+ plant the defect, watch the check fail, then trust the green.
34
+ - README points at the code of conduct and at the family bundle.
35
+
36
+ ## 1.3.1 — 2026-07-29
37
+
38
+ ### The git lease was invisible to everything that reads a lease — fixed
39
+
40
+ Found in production, blocking real work three times in one session. In `git` lease mode `acquire`
41
+ won the lease by pushing `refs/agent-sync/leases/<key>` and stopped there, while `held()` — the one
42
+ function behind `whoami`, `status` and the **PreToolUse guard** — read `.agent-sync/leases/*.lock`,
43
+ which nothing in that path ever wrote. The result was the exact inversion of the tool's purpose:
44
+ `acquire` printed *won*, `whoami` printed *holds: nothing*, and the guard **denied the run that held
45
+ the lease**. Every guarded register was unwritable under the mode this tool recommends, and the only
46
+ way past it was to bypass the guard — which is the behaviour the guard exists to prevent.
47
+
48
+ The git ref remains the authority; it is what makes exclusion hold across machines. What was missing
49
+ is that the winner now leaves a local note, so the local question — *does this run hold that key?* —
50
+ is answered locally instead of putting a network round-trip in front of every Edit. `release`
51
+ already removed that note, which is why only one half of the loop was ever written.
52
+
53
+ **Why it shipped:** the lease-visibility assertion existed only for the local mode. `test/validate.py`
54
+ now runs acquire → `whoami` → `guard` → release against **both** modes; against 1.3.0 it fails with
55
+ the two symptoms above, which is the point of adding it.
56
+
57
+ ## 1.3.1 — 2026-07-29
58
+
59
+ ### Ignoring the state directory does nothing once git is tracking it
60
+
61
+ Found in the project this plugin was built for: `.agent-sync/` was gitignored **and committed**,
62
+ because the files went in before the rule existed. Consequences, all of them silent:
63
+
64
+ - every tool call rewrites `last-renew`, so all three repositories were permanently dirty and no
65
+ run could ever report itself finished
66
+ - `run-id` is the checkout's **agent identity**. Committed, it reaches every clone — two machines
67
+ would then coordinate as one run, which is the failure 1.3.0 fixed at the other end
68
+
69
+ `init` now untracks the directory when it finds it tracked, and `check` reports it as a problem
70
+ rather than passing a project whose state is versioned. Probed: a repository with a committed
71
+ `.agent-sync/run-id` fails `check` with the exact removal command, and passes once it is untracked.
72
+
73
+ ## 1.3.0 — 2026-07-29
74
+
75
+ ### Two agents in one checkout were one identity — fixed
76
+
77
+ Found in production, in the case this plugin exists for: **two Claude sessions working the same
78
+ checkout shared a single run id**, so the lease could not separate them. A hook runs with
79
+ `CLAUDE_SESSION_ID` in its environment and a plain shell command does not, and the marker file held
80
+ one id per checkout — so the second session adopted whatever the first had stamped. Both acquired
81
+ as one run, both were guarded as one run, and `release` would take a lease the caller never
82
+ acquired. The failure is silent: `whoami` reports a lease, and it is somebody else's.
83
+
84
+ - the marker is now a **map** keyed by session, and migrates the old single-value file into it
85
+ - a plain shell has no session id, so the `SessionStart` hook stamps
86
+ `.agent-sync/sessions/<CLI pid>` with the session it *does* know, and later commands find
87
+ themselves by walking their own ancestry. Exact, and no command-line parsing: the throwaway
88
+ shell every tool call runs in carries claude paths in its argv and defeated every heuristic
89
+ aimed at the CLI binary
90
+ - stale stamps are removed when their process is gone, so the directory cannot grow
91
+ - where identity still cannot be established, the run says so rather than presenting a shared
92
+ entry as separation
93
+
94
+ ### `scaffold --full` — the architecture that keeps documentation linked, not merely present
95
+
96
+ `scaffold` seeded a decision register and an agent protocol. That is enough to be coordinated and
97
+ not enough to stay coherent: the things that rot are the links between documents, and nothing was
98
+ seeding the pieces that hold them — a question register that resolves into decisions, an index
99
+ nobody has to scan the register to use, one place for facts about two repositories, one definition
100
+ per entity with a checkable address, **and a gate**, because each of those decays silently.
101
+
102
+ `--full` adds `OPEN_QUESTIONS.md`, `INDEX.md`, `DEPENDENCIES.md`, `DATA_MODEL.md` (with the entity
103
+ register and the one-definition rule) and `scripts/check-docs.sh`, which fails on: an id cited and
104
+ never defined, a next-free-ID line that is not next, a relative link to a file that does not exist,
105
+ a `#anchor` that does not exist in the file it points at, and a decision with no index row. All
106
+ five probed against planted defects.
107
+
108
+ **A fresh scaffold passes its own gate.** The first version did not — it counted the template block
109
+ and the allocation line as real ids — and a project that starts red teaches everyone that the gate
110
+ is noise.
111
+
112
+ ### `finish` — the gate expressions this plugin declares, executed
113
+
114
+ `references/pipeline-binding.md` has always listed *submodule pointers current* and *every lease
115
+ released* as gate expressions "verified by the coordinator, not by prose". Nothing ran them:
116
+ `check` validates the **setup** — config, registers, credentials, snapshot — and never looks at the
117
+ state of the repositories.
118
+
119
+ `finish` answers the other question, *is the work finished*:
120
+
121
+ - every submodule's recorded gitlink equals its HEAD. This is the failure it exists for and it is
122
+ invisible from either side alone: the submodule is pushed, its CI is green, its roadmap says
123
+ done, and a clone of the parent has the commit before the work
124
+ - every repository — parent included — is clean and pushed, with a detached submodule accepted
125
+ only when its commit exists on some remote branch
126
+ - no lease left held, because a run that ends holding one blocks the next agent for the whole TTL
127
+ - `--gates` also runs the project's own declared gate commands
128
+
6
129
  ## 1.2.4 — 2026-07-29
7
130
 
8
131
  ### Fixed — the tool misreported its own version, and disagreed with itself about the lease
package/README.md CHANGED
@@ -389,8 +389,13 @@ agent loads on their own trigger rather than by default:
389
389
  | [`two-sources.md`](plugins/agent-sync/skills/agent-sync/references/two-sources.md) | before the first reconcile, or when deciding where a document belongs |
390
390
  | [`roadmap.md`](plugins/agent-sync/skills/agent-sync/references/roadmap.md) | configuring `claimTags`, taking or closing a task, or re-planning a board |
391
391
 
392
- See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). Security
393
- reports: [SECURITY.md](SECURITY.md).
392
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). Everyone
393
+ taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
394
+ Security reports: [SECURITY.md](SECURITY.md).
395
+
396
+ `agent-sync` also ships in the [sshlg-skills](https://github.com/ssheleg/sshlg-skills)
397
+ bundle, which installs the whole family for Claude Code, Cursor, Codex and 70+
398
+ other agents with one command.
394
399
 
395
400
  ## License
396
401
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.2.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.",
3
+ "version": "1.3.3",
4
+ "description": "Let concurrent coding agents share one project without colliding 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"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sync",
3
- "version": "1.2.4",
3
+ "version": "1.3.3",
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.3"
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.3"
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.
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.
230
270
 
231
- stored: dict[str, str] = {}
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))
@@ -734,10 +813,20 @@ class Sync:
734
813
  payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
735
814
  "repo": repo_name(), "host": os.uname().nodename})
736
815
  empty_tree = git("hash-object", "-t", "tree", "/dev/null")
737
- commit = subprocess.run(["git", "commit-tree", empty_tree], input=payload,
738
- capture_output=True, text=True).stdout.strip()
816
+ # A lease object is plumbing, not authorship, so it must not depend on the
817
+ # machine having a git identity. Without these `-c` flags `commit-tree`
818
+ # refuses wherever user.email is unset and cannot be auto-detected — CI
819
+ # runners, containers, a freshly provisioned box — and the lease backend
820
+ # silently becomes unusable on exactly the machines that need it most.
821
+ made = subprocess.run(
822
+ ["git", "-c", "user.name=agent-sync", "-c", "user.email=agent-sync@localhost",
823
+ "commit-tree", empty_tree],
824
+ input=payload, capture_output=True, text=True)
825
+ commit = made.stdout.strip()
739
826
  if not commit:
740
- raise Fail("could not create the lease object — is this a git repository?")
827
+ detail = (made.stderr or "").strip().splitlines()
828
+ why = detail[-1] if detail else "no output from git commit-tree"
829
+ raise Fail(f"could not create the lease object — is this a git repository? ({why})")
741
830
 
742
831
  args = ["git", "push", remote, f"{commit}:{ref}"]
743
832
  if held_sha: # stealing an expired lease, and only that
@@ -747,6 +836,7 @@ class Sync:
747
836
  # Rejected: somebody won between our read and our push. Ask who.
748
837
  _s, now_held = self._git_read_lease(key)
749
838
  return False, now_held.get("run") or "another run"
839
+ self._note_local(key, payload)
750
840
  self._touch_renew()
751
841
  return True, self.rid
752
842
 
@@ -1698,6 +1788,7 @@ def cmd_init(args: argparse.Namespace) -> int:
1698
1788
 
1699
1789
  ensure_gitignored(root, str(ENV_FILE))
1700
1790
  ensure_gitignored(root, f"{STATE_DIR}/")
1791
+ ensure_untracked(root, f"{STATE_DIR}/")
1701
1792
 
1702
1793
  print()
1703
1794
  if backend == "outline":
@@ -1753,6 +1844,25 @@ def ensure_gitignored(root: Path, entry: str) -> None:
1753
1844
  print(f"✓ added {entry} to .gitignore")
1754
1845
 
1755
1846
 
1847
+ def ensure_untracked(root: Path, entry: str) -> None:
1848
+ """Ignoring a path does nothing once git is already tracking it.
1849
+
1850
+ Found in a real project: the state directory had been committed before the ignore rule
1851
+ existed, so `git status` reported it modified after **every** tool call — the repository was
1852
+ permanently dirty and no run could report itself finished. `run-id` is the worse half: it is
1853
+ this checkout's agent identity, and committed it reaches every clone, so two machines
1854
+ coordinate as one run.
1855
+ """
1856
+ tracked = git("ls-files", "--", entry.rstrip("/"), cwd=root)
1857
+ if not tracked:
1858
+ return
1859
+ if git("rm", "-r", "--cached", "-q", "--", entry.rstrip("/"), cwd=root) is None:
1860
+ return
1861
+ n = len(tracked.split("\n"))
1862
+ print(f"✓ untracked {n} committed file(s) under {entry} — commit that removal; "
1863
+ "ignoring a tracked path has no effect")
1864
+
1865
+
1756
1866
  # --------------------------------------------------------------------------- status
1757
1867
 
1758
1868
  def cmd_status(_args: argparse.Namespace) -> int:
@@ -2075,6 +2185,149 @@ def cmd_adopt(_args: argparse.Namespace) -> int:
2075
2185
  return 0
2076
2186
 
2077
2187
 
2188
+ OPEN_QUESTIONS_SEED = """# Open questions
2189
+
2190
+ **One job: what is not decided yet, and what settling it would unblock.**
2191
+
2192
+ **Next free ID:** `OQ-0001`
2193
+
2194
+ A question here is answered by a decision, never by a conversation: when it is settled, its row
2195
+ becomes `Resolved→DEC-####` and the reasoning goes in that decision. A question with no owner and
2196
+ no consequence is not an open question — it is a note, and it belongs somewhere else.
2197
+
2198
+ | ID | Question | Area | Status | Affects |
2199
+ |---|---|---|---|---|
2200
+ """
2201
+
2202
+ INDEX_SEED = """# Index — one row per decision
2203
+
2204
+ **One job: find the decision without reading the register.** Generated by hand and gated: a
2205
+ decision with no row here fails the docs gate, because a register nobody can scan is a register
2206
+ nobody reads.
2207
+
2208
+ **It quotes no counts and no rule.** A restated rule is a second source with a decay rate; this
2209
+ file holds titles and status only.
2210
+
2211
+ | ID | Title | Status |
2212
+ |---|---|---|
2213
+ """
2214
+
2215
+ DEPENDENCIES_SEED = """# Dependencies — the only place a fact about two repositories lives
2216
+
2217
+ **One job: name what one repository needs from another, who produces it, and who is waiting.**
2218
+
2219
+ A row carries **both sides**. A dependency with no producer task is a dependency nobody is going to
2220
+ build, and the block it causes is invisible from either side alone — the consumer says *blocked on
2221
+ DEP-003*, and DEP-003 names nobody.
2222
+
2223
+ **No status rollup.** Each row names the producer's task; the current answer is read at its source,
2224
+ never copied here where it drifts.
2225
+
2226
+ | ID | What is needed | Producer | Consumer | State | Notes |
2227
+ |---|---|---|---|---|---|
2228
+ """
2229
+
2230
+ DATA_MODEL_SEED = """# Data model — one definition per thing
2231
+
2232
+ **One job: every entity defined once, with an address, so nothing is described twice and differently.**
2233
+
2234
+ Two layers, and the distinction is not cosmetic:
2235
+
2236
+ - **Conceptual entity** — what the thing *is* in the product, its identity, its relationships, and
2237
+ the rules that travel with it. Here.
2238
+ - **Physical table** — columns, types, nullability, indexes. In the owning service's own schema
2239
+ document.
2240
+
2241
+ A physical table **must name the conceptual entity it implements**; one that names none is a
2242
+ finding, because that is where two services drift without either being wrong. The reverse is a
2243
+ finding too: an entity with no table anywhere is a thing everyone agreed on and nobody built.
2244
+
2245
+ Every entity heading carries an explicit anchor — `## <a id="thing"></a>Thing` — so a mention
2246
+ elsewhere links to the definition rather than to the top of this file. An explicit id is checkable
2247
+ at both ends; an auto-generated slug changes the moment somebody rewords a heading, and every
2248
+ inbound link breaks silently.
2249
+
2250
+ ## <a id="entity_register"></a>Entity register
2251
+
2252
+ | Entity | Address | Physical table in | Introduced by |
2253
+ |---|---|---|---|
2254
+ """
2255
+
2256
+ CHECK_DOCS_SEED = r'''#!/usr/bin/env bash
2257
+ # The documentation gate. Seeded by agent-sync; extend it, do not replace it.
2258
+ #
2259
+ # It exists because linked documentation rots quietly: a decision cites a document that never
2260
+ # mentions it, a link points at a file that moved, an id is minted twice, an index row is missing.
2261
+ # None of those break anything visibly, and all of them cost the next reader an hour.
2262
+ set -uo pipefail
2263
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$ROOT" || exit 1
2264
+ fail=0; err() { printf 'FAIL: %s\n' "$1"; fail=1; }
2265
+ DOCS="${DOCS_DIR:-docs}"; [ -d "$DOCS" ] || DOCS="."
2266
+ md=$(find "$DOCS" -name '*.md' 2>/dev/null; ls ./*.md 2>/dev/null)
2267
+
2268
+ # 1. every id that is cited is defined somewhere
2269
+ for reg in DEC OQ DEP; do
2270
+ file=$(grep -rl "Next free ID:\*\* \`$reg-" $md 2>/dev/null | head -1)
2271
+ [ -z "$file" ] && continue
2272
+ # A definition is a heading or a table row in the register itself. The template block and the
2273
+ # allocation line are neither, and a gate that counts them fails a project on the day it is
2274
+ # created — which teaches everyone that the gate is noise.
2275
+ body=$(sed -n '/^```/,/^```/!p' "$file" | grep -v "Next free ID")
2276
+ defined=$(echo "$body" | grep -ohE "^#+ $reg-[0-9]{3,4}|^\| \[?$reg-[0-9]{3,4}" | grep -oE "$reg-[0-9]{3,4}" | sort -u)
2277
+ cited=$(cat $md | sed -n '/^```/,/^```/!p' | grep -v "Next free ID" | grep -ohE "\b$reg-[0-9]{3,4}\b" | sort -u)
2278
+ missing=$(comm -13 <(echo "$defined") <(echo "$cited"))
2279
+ [ -n "$missing" ] && err "$reg cited but never defined: $(echo $missing | tr '\n' ' ')"
2280
+ # 2. the next-free-ID line is the next one, or two agents mint the same number
2281
+ next=$(grep -oE "Next free ID:\*\* \`$reg-[0-9]{3,4}" "$file" | grep -oE '[0-9]{3,4}$')
2282
+ max=$(echo "$defined" | grep -oE '[0-9]{3,4}$' | sort -n | tail -1)
2283
+ if [ -n "$next" ] && [ -n "$max" ] && [ "$((10#$next))" -le "$((10#$max))" ]; then
2284
+ err "$reg next free id is $next but $reg-$max exists"
2285
+ fi
2286
+ done
2287
+
2288
+ # 3. every relative link resolves
2289
+ while IFS= read -r hit; do
2290
+ src="${hit%%:*}"; link="${hit#*:}"
2291
+ tgt="$(cd "$(dirname "$src")" && cd "$(dirname "$link")" 2>/dev/null && pwd)/$(basename "$link")"
2292
+ [ -e "$tgt" ] || err "$src → $link does not exist"
2293
+ 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)
2294
+
2295
+ # 4. every #anchor a link points at exists in the file it points at
2296
+ python3 - "$ROOT" <<'PYEOF' || fail=1
2297
+ import os, re, sys
2298
+ root = sys.argv[1]; bad = 0; cache = {}
2299
+ def ids(path):
2300
+ if path not in cache:
2301
+ try: t = open(path, errors="replace").read()
2302
+ except OSError: cache[path] = None; return None
2303
+ cache[path] = set(re.findall(r'<a id="([^"]+)"', t))
2304
+ return cache[path]
2305
+ for dp, _, fns in os.walk(root):
2306
+ if any(p in dp for p in (".git", "node_modules")): continue
2307
+ for fn in fns:
2308
+ if not fn.endswith(".md"): continue
2309
+ src = os.path.join(dp, fn)
2310
+ for rel, frag in re.findall(r'\]\((\.{1,2}/[A-Za-z0-9_./-]+\.md)#([A-Za-z0-9_-]+)\)',
2311
+ open(src, errors="replace").read()):
2312
+ tgt = os.path.normpath(os.path.join(dp, rel)); have = ids(tgt)
2313
+ if have is not None and frag not in have:
2314
+ print("FAIL: %s -> %s#%s (no such anchor)" % (os.path.relpath(src, root), rel, frag)); bad = 1
2315
+ sys.exit(bad)
2316
+ PYEOF
2317
+
2318
+ # 5. every decision has an index row
2319
+ idx=$(ls "$DOCS/INDEX.md" 2>/dev/null || true)
2320
+ dec=$(ls "$DOCS/DECISIONS.md" 2>/dev/null || true)
2321
+ if [ -n "$idx" ] && [ -n "$dec" ]; then
2322
+ for d in $(sed -n '/^```/,/^```/!p' "$dec" | grep -ohE '^#+ DEC-[0-9]{3,4}' | grep -oE 'DEC-[0-9]{3,4}'); do
2323
+ grep -q "$d" "$idx" || err "$d has no INDEX row"
2324
+ done
2325
+ fi
2326
+
2327
+ [ "$fail" -eq 0 ] && printf 'OK: documentation consistent.\n'
2328
+ exit "$fail"
2329
+ '''
2330
+
2078
2331
  DECISIONS_SEED = """# Decisions
2079
2332
 
2080
2333
  Every settled decision about this project, append-only. A decision is any answer that
@@ -2160,6 +2413,19 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
2160
2413
 
2161
2414
  seed(docs / "DECISIONS.md", DECISIONS_SEED)
2162
2415
  seed(root / "AGENTS.md", AGENTS_SEED.format(snapshot=snapshot))
2416
+ if args.full:
2417
+ # The rest of the architecture that keeps documentation LINKED rather than merely present:
2418
+ # a question register that resolves into decisions, an index nobody has to scan the
2419
+ # register to use, a place for facts about two repositories, one definition per entity —
2420
+ # and the gate, because every one of those rots silently without a check that fails.
2421
+ seed(docs / "OPEN_QUESTIONS.md", OPEN_QUESTIONS_SEED)
2422
+ seed(docs / "INDEX.md", INDEX_SEED)
2423
+ seed(docs / "DEPENDENCIES.md", DEPENDENCIES_SEED)
2424
+ seed(docs / "DATA_MODEL.md", DATA_MODEL_SEED)
2425
+ gate = root / "scripts" / "check-docs.sh"
2426
+ seed(gate, CHECK_DOCS_SEED)
2427
+ if gate.exists():
2428
+ gate.chmod(0o755)
2163
2429
 
2164
2430
  for c in created:
2165
2431
  print(f" + {c}")
@@ -2175,6 +2441,124 @@ def cmd_scaffold(args: argparse.Namespace) -> int:
2175
2441
  return 0
2176
2442
 
2177
2443
 
2444
+ def _repo_state(path: Path, label: str, ignore: str) -> list[str]:
2445
+ """Is this repository clean, and is its work anywhere but here?
2446
+
2447
+ Both halves matter and only one of them is obvious. Uncommitted work is visible to whoever
2448
+ is sitting in front of it; work committed and never pushed is invisible to everyone else
2449
+ while looking finished to its author — the roadmap says done, the test suite is green, and
2450
+ nobody else can fetch a line of it.
2451
+ """
2452
+ problems: list[str] = []
2453
+ porcelain = git("status", "--porcelain", cwd=path)
2454
+ dirty = [ln for ln in porcelain.split("\n")
2455
+ if ln.strip() and not re.search(ignore, ln.split()[-1] if ln.split() else "")]
2456
+ if dirty:
2457
+ problems.append(f"{label} has uncommitted work: " + ", ".join(d.split()[-1] for d in dirty[:6]))
2458
+ upstream = git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", cwd=path)
2459
+ branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=path)
2460
+ if upstream:
2461
+ ahead = git("rev-list", "--count", f"{upstream}..HEAD", cwd=path) or "0"
2462
+ if ahead != "0":
2463
+ problems.append(f"{label} is {ahead} commit(s) ahead of {upstream} — pushed nowhere")
2464
+ elif branch == "HEAD":
2465
+ # A submodule sits at a detached pointer by design; what matters is that the commit exists
2466
+ # somewhere others can fetch from.
2467
+ if not git("branch", "-r", "--contains", "HEAD", cwd=path):
2468
+ problems.append(f"{label} is at a commit on no remote branch — nobody else can fetch it")
2469
+ else:
2470
+ problems.append(f"{label} ({branch}) tracks no remote — its work cannot be seen by anyone")
2471
+ return problems
2472
+
2473
+
2474
+ def cmd_finish(args: argparse.Namespace) -> int:
2475
+ """The gate expressions this plugin's pipeline binding declares, actually executed.
2476
+
2477
+ `check` answers *is this project wired correctly*. This answers *is the work finished*, and
2478
+ they are different questions with different failure modes. The one it exists for is silent by
2479
+ construction: a project of git submodules records each submodule's commit as a pointer in its
2480
+ parent, and moving the submodule does not move the pointer. So the submodule is pushed, its CI
2481
+ is green, its roadmap says `done` — and anyone who clones the parent gets the commit before the
2482
+ work. Nothing in either repository looks wrong on its own; the disagreement only exists between
2483
+ them, which is why nothing had been checking it.
2484
+ """
2485
+ s = Sync()
2486
+ root = project_root()
2487
+ ok: list[str] = []
2488
+ problems: list[str] = []
2489
+ ignore = r"\.agent-sync/"
2490
+
2491
+ print(f"run {s.rid} · {'gated' if s.gated else 'ungated'}\n")
2492
+
2493
+ # 1. the parent, then every submodule: clean, pushed, and pointed at
2494
+ p = _repo_state(root, root.name, ignore)
2495
+ problems.extend(p)
2496
+ if not p:
2497
+ ok.append(f"{root.name} clean and pushed")
2498
+
2499
+ for line in (git("submodule", "status") or "").split("\n"):
2500
+ if not line.strip():
2501
+ continue
2502
+ prefix, rest = line[0], line[1:].split()
2503
+ sub = rest[1] if len(rest) > 1 else "?"
2504
+ if prefix == "+":
2505
+ recorded = (git("ls-tree", "HEAD", sub) or "").split()
2506
+ problems.append(
2507
+ f"{sub} — the parent points at {recorded[2][:8] if len(recorded) > 2 else '?'}, "
2508
+ f"the submodule is at {rest[0][:8]}. The bump commit is missing")
2509
+ elif prefix == "-":
2510
+ problems.append(f"{sub} is not checked out — a gate run here skips everything it owns")
2511
+ elif prefix == "U":
2512
+ problems.append(f"{sub} has merge conflicts")
2513
+ else:
2514
+ ok.append(f"{sub} pointer current")
2515
+ subp = root / sub
2516
+ if subp.is_dir():
2517
+ sp = _repo_state(subp, sub, ignore)
2518
+ problems.extend(sp)
2519
+ if not sp and prefix == " ":
2520
+ ok.append(f"{sub} clean and pushed")
2521
+
2522
+ # 2. leases. A run that ends holding one blocks the next agent for the whole TTL, and the
2523
+ # holder is a run id nobody can ask about once its session is gone.
2524
+ held = s.held()
2525
+ if held:
2526
+ problems.append("this run still holds " + ", ".join(held) + " — release before you finish")
2527
+ else:
2528
+ ok.append("no lease left held")
2529
+
2530
+ # 3. the declared gates, on request. They are the project's own commands and can be slow, so
2531
+ # running them is opt-in — but a `finish` that never ran them is a claim, not a check.
2532
+ if args.gates:
2533
+ for cmd in s.cfg.get("gates", []):
2534
+ try:
2535
+ r = subprocess.run(cmd, shell=True, cwd=str(root), capture_output=True,
2536
+ text=True, timeout=600)
2537
+ except (OSError, subprocess.SubprocessError) as exc:
2538
+ problems.append(f"gate `{cmd}` could not run: {exc}")
2539
+ continue
2540
+ if r.returncode == 0:
2541
+ ok.append(f"gate `{cmd}`")
2542
+ else:
2543
+ tail = [ln for ln in (r.stdout + r.stderr).split("\n") if ln.strip()][-3:]
2544
+ problems.append(f"gate `{cmd}` failed: " + " / ".join(tail))
2545
+
2546
+ for line in ok:
2547
+ print(f" \u2713 {line}")
2548
+ for line in problems:
2549
+ print(f" \u2717 {line}")
2550
+ print()
2551
+ if problems:
2552
+ print(f"{len(problems)} problem(s) — this work is not finished. The usual one is a "
2553
+ "submodule commit with no parent bump:")
2554
+ print(' git -C <submodule> push && git add <submodule> && '
2555
+ 'git commit -m "chore: bump <name> submodule — <why>"')
2556
+ return 1
2557
+ print(f"finished cleanly ({len(ok)} checks passed) — every repository is clean, pushed, "
2558
+ "and pointed at.")
2559
+ return 0
2560
+
2561
+
2178
2562
  def cmd_check(_args: argparse.Namespace) -> int:
2179
2563
  """Validate the whole setup, end to end, and refuse to call a broken one healthy.
2180
2564
 
@@ -2360,6 +2744,16 @@ def cmd_check(_args: argparse.Namespace) -> int:
2360
2744
  except Fail:
2361
2745
  pass
2362
2746
 
2747
+ tracked_state = git("ls-files", "--", STATE_DIR)
2748
+ if tracked_state:
2749
+ problems.append(
2750
+ f"{STATE_DIR}/ is tracked by git ({len(tracked_state.split(chr(10)))} file(s)) — it is "
2751
+ "generated state, the repository is dirty after every tool call, and a committed "
2752
+ "run-id hands this checkout's identity to every clone. "
2753
+ f"Run: git rm -r --cached {STATE_DIR} && commit")
2754
+ else:
2755
+ ok.append(f"{STATE_DIR}/ is not tracked")
2756
+
2363
2757
  for line in ok:
2364
2758
  print(f" ✓ {line}")
2365
2759
  for line in warn:
@@ -2418,8 +2812,13 @@ def build_parser() -> argparse.ArgumentParser:
2418
2812
  sub.add_parser("setup", help="write the generated snapshot of how this project is wired").set_defaults(fn=cmd_setup)
2419
2813
  sub.add_parser("adopt", help="inspect an existing project and propose a config (writes nothing)").set_defaults(fn=cmd_adopt)
2420
2814
  sub.add_parser("check", help="validate the whole setup; non-zero if it is not healthy").set_defaults(fn=cmd_check)
2815
+ fi = sub.add_parser("finish", help="is the work finished — every repo clean, pushed and pointed at; no lease held")
2816
+ fi.add_argument("--gates", action="store_true", help="also run the project's declared gate commands")
2817
+ fi.set_defaults(fn=cmd_finish)
2421
2818
  sc = sub.add_parser("scaffold", help="create the missing documentation architecture (never overwrites)")
2422
2819
  sc.add_argument("--docs-dir", action="store_true", help="put the register under docs/ even if it does not exist yet")
2820
+ sc.add_argument("--full", action="store_true",
2821
+ help="also seed OPEN_QUESTIONS, INDEX, DEPENDENCIES, DATA_MODEL and the docs gate")
2423
2822
  sc.set_defaults(fn=cmd_scaffold)
2424
2823
  bd = sub.add_parser("board", help="regenerate the read-only board")
2425
2824
  bd.add_argument("--mirror", action="store_true",