@ssheleg/agent-sync 1.2.2

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.
@@ -0,0 +1,2454 @@
1
+ #!/usr/bin/env python3
2
+ """agent-sync — coordination for concurrent agents over a pluggable knowledge cloud.
3
+
4
+ Stdlib only. Python 3.9+.
5
+
6
+ Two planes: git is the record plane, the cloud is the coordination plane.
7
+ Leases and id reservations are decided by replaying one append-only log, because
8
+ no supported backend offers compare-and-swap. Document order is authoritative;
9
+ timestamps only expire leases.
10
+
11
+ Credentials are read from the environment and never appear in argv, a log line,
12
+ a journal entry or a rendered board. HTTP goes through urllib inside this
13
+ process — there is no subprocess and nothing for another process to read.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import random
22
+ import re
23
+ import stat
24
+ import subprocess
25
+ import hashlib
26
+ import shutil
27
+ import sys
28
+ import time
29
+ import urllib.error
30
+ import urllib.request
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ VERSION = "1.2.2"
36
+
37
+ CONFIG_PATH = Path(".claude/agent-sync.json")
38
+ ENV_FILE = Path(".env.agent-sync")
39
+ STATE_DIR = Path(".agent-sync")
40
+ GENERATED_MARKER = "<!-- agent-sync:generated"
41
+
42
+ LOGS = {
43
+ "claims": "30 Claims",
44
+ "reservations": "40 Reservations",
45
+ "signals": "50 Signals",
46
+ "blockers": "60 Blockers",
47
+ # The as-built record: what agents actually implemented, as they implemented it.
48
+ # Git documentation says how it SHOULD be — written before the code and often
49
+ # without it. This says how it IS, derived from what was really written. They are
50
+ # two source-of-truths answering two different questions, and the gap between them
51
+ # is the finding, not a defect in either.
52
+ "asbuilt": "70 As-built",
53
+ }
54
+
55
+ # Write with "- ", read any bullet. A knowledge base normalises markdown on the way
56
+ # in — Outline rewrites "- " to "* " — so a parser anchored to the character we wrote
57
+ # rejects every line the server gave back, and the caller sees "lost" instead of
58
+ # "unreadable". Be strict in what you emit, liberal in what you accept.
59
+ LINE_RE = re.compile(r"^[-*+] `(?P<ts>[^`]+)`(?P<pairs>(?: `[a-z_]+=[^`]*`)+)$")
60
+ CANDIDATE_RE = re.compile(r"^[-*+] `")
61
+ PAIR_RE = re.compile(r"`([a-z_]+)=([^`]*)`")
62
+ MAX_UNPARSEABLE = 0.02
63
+
64
+ DEFAULT_SETTLE = 3.0
65
+ DEFAULT_TTL = 2700
66
+ DEFAULT_RENEW = 300
67
+
68
+
69
+ # --------------------------------------------------------------------------- utils
70
+
71
+ def now_iso() -> str:
72
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
73
+
74
+
75
+ def parse_iso(ts: str) -> float:
76
+ try:
77
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(
78
+ tzinfo=timezone.utc).timestamp()
79
+ except ValueError:
80
+ return 0.0
81
+
82
+
83
+ def git(*args: str, cwd: Path | None = None) -> str:
84
+ try:
85
+ out = subprocess.run(
86
+ ["git", *args], cwd=str(cwd) if cwd else None,
87
+ capture_output=True, text=True, timeout=15)
88
+ return out.stdout.strip() if out.returncode == 0 else ""
89
+ except (OSError, subprocess.SubprocessError):
90
+ return ""
91
+
92
+
93
+ def project_root() -> Path:
94
+ top = git("rev-parse", "--show-toplevel")
95
+ return Path(top) if top else Path.cwd()
96
+
97
+
98
+ def head_sha() -> str:
99
+ return git("rev-parse", "--short", "HEAD") or "unknown"
100
+
101
+
102
+ def repo_name() -> str:
103
+ url = git("config", "--get", "remote.origin.url")
104
+ if url:
105
+ return url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
106
+ return project_root().name
107
+
108
+
109
+ class Fail(Exception):
110
+ """A failure the caller must see. Never swallowed into a success."""
111
+
112
+
113
+ # --------------------------------------------------------------------------- config
114
+
115
+ def find_env_file(root: Path) -> Path | None:
116
+ """Locate .env.agent-sync for this checkout, including from inside a submodule.
117
+
118
+ Submodules are separate git repositories, so `project_root()` in one of them is the
119
+ submodule — and the credentials sit in the SUPERPROJECT. Looking only in the local
120
+ root put every submodule agent into degraded `fs` mode, isolated in local files and
121
+ unable to see anyone: three agents entered from one umbrella, coordinating with
122
+ nobody, and each one saying `ungated` while believing it was configured.
123
+
124
+ One credential file therefore serves the whole tree: local root first, then the
125
+ superproject git reports, then plain parent directories.
126
+ """
127
+ local = root / ENV_FILE
128
+ if local.exists():
129
+ return local
130
+
131
+ superproject = git("rev-parse", "--show-superproject-working-tree", cwd=root)
132
+ if superproject:
133
+ candidate = Path(superproject) / ENV_FILE
134
+ if candidate.exists():
135
+ return candidate
136
+
137
+ for parent in root.resolve().parents:
138
+ candidate = parent / ENV_FILE
139
+ if candidate.exists():
140
+ return candidate
141
+ if (parent / ".git").exists() and (parent / CONFIG_PATH).exists():
142
+ break # a configured project that simply has no env file — stop here
143
+ return None
144
+
145
+
146
+ def load_env_file(root: Path) -> None:
147
+ """Read .env.agent-sync into the environment when it is not already there.
148
+
149
+ Load-bearing, and it was missing. A Claude Code hook is spawned with a bare
150
+ environment: it never sees the `set -a && . ./.env.agent-sync` the operator ran in
151
+ their own shell. So every hook silently fell back to the `fs` backend while the
152
+ agent's own commands used the cloud — the guard denied edits whose lease WAS held,
153
+ and recorded the run `ungated` while the agent had been told `gated`. Two views of
154
+ one project, and the enforcement half held the wrong one.
155
+
156
+ The file's location is deterministic, so the tool loads it rather than depending on
157
+ how it was invoked. An already-set variable always wins: explicit beats implicit,
158
+ and an operator overriding a value for one command must not be undone here.
159
+ """
160
+ path = find_env_file(root)
161
+ if path is None:
162
+ return
163
+ try:
164
+ text = path.read_text()
165
+ except OSError:
166
+ return
167
+ for line in text.splitlines():
168
+ line = line.strip()
169
+ if not line or line.startswith("#") or "=" not in line:
170
+ continue
171
+ key, _, value = line.partition("=")
172
+ key, value = key.strip(), value.strip().strip('"').strip("'")
173
+ if key and value and key not in os.environ:
174
+ os.environ[key] = value
175
+
176
+
177
+ def load_config(root: Path) -> dict[str, Any]:
178
+ path = root / CONFIG_PATH
179
+ if not path.exists():
180
+ raise Fail(
181
+ "no .claude/agent-sync.json in this project.\n"
182
+ "Run `init` first — it asks which backend to use and writes the config.\n"
183
+ " agent_sync.py init --backend outline --url <instance-url>\n"
184
+ " agent_sync.py init --backend fs")
185
+ try:
186
+ return json.loads(path.read_text())
187
+ except json.JSONDecodeError as exc:
188
+ raise Fail(f".claude/agent-sync.json is not valid JSON: {exc}") from exc
189
+
190
+
191
+ def run_id(root: Path) -> str:
192
+ """One identity per session, whichever way the tool is invoked.
193
+
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.
198
+
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.
203
+ """
204
+ override = os.environ.get("AGENT_SYNC_RUN_ID")
205
+ if override:
206
+ return "r-" + re.sub(r"[^a-z0-9]", "", override.lower())[:12]
207
+
208
+ session = os.environ.get("CLAUDE_SESSION_ID") or ""
209
+ marker = root / STATE_DIR / "run-id"
210
+
211
+ stored: dict[str, str] = {}
212
+ if marker.exists():
213
+ raw = marker.read_text().strip()
214
+ try:
215
+ stored = json.loads(raw)
216
+ 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"))
229
+ marker.parent.mkdir(parents=True, exist_ok=True)
230
+ marker.write_text(json.dumps({"run": rid, "session": session}))
231
+ return rid
232
+
233
+
234
+ # --------------------------------------------------------------------------- adapters
235
+
236
+ class Adapter:
237
+ name = "none"
238
+ capabilities = {"atomicAppend": False, "totalOrderRead": False, "search": False,
239
+ "exclusiveLease": False}
240
+
241
+ def configured(self) -> bool:
242
+ raise NotImplementedError
243
+
244
+ def tree_ensure(self, path: str) -> str:
245
+ raise NotImplementedError
246
+
247
+ def log_append(self, oid: str, line: str) -> None:
248
+ raise NotImplementedError
249
+
250
+ def log_read(self, oid: str) -> str:
251
+ raise NotImplementedError
252
+
253
+ def doc_put(self, oid: str, text: str) -> None:
254
+ raise NotImplementedError
255
+
256
+ def doc_get(self, oid: str) -> str:
257
+ raise NotImplementedError
258
+
259
+ def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
260
+ return []
261
+
262
+ def log_shards(self, prefix: str) -> list[str]:
263
+ """Every object whose title starts with `prefix` — one per writer."""
264
+ raise NotImplementedError
265
+
266
+ @property
267
+ def is_lease_authority(self) -> bool:
268
+ return bool(self.capabilities["atomicAppend"]
269
+ and self.capabilities["totalOrderRead"])
270
+
271
+ @property
272
+ def is_exclusive(self) -> bool:
273
+ """Whether a won lease is a guarantee or advice. Never assume the first."""
274
+ return bool(self.capabilities.get("exclusiveLease"))
275
+
276
+
277
+ class OutlineAdapter(Adapter):
278
+ """Outline knowledge base. Hosted or self-hosted; the URL is configuration.
279
+
280
+ No compare-and-swap exists in this API: documents.update has editMode
281
+ append/replace/prepend/patch and no lastRevision. Coordination state is
282
+ therefore never a document we rewrite.
283
+ """
284
+
285
+ name = "outline"
286
+ # exclusiveLease is FALSE and that is not a formality. Outline has no
287
+ # compare-and-swap, so a decision cannot be made after all contenders have
288
+ # written — only after a settle window that is long enough in practice. Two runs
289
+ # starting inside that window can both win. Measured, not assumed.
290
+ capabilities = {"atomicAppend": True, "totalOrderRead": True, "search": True,
291
+ "exclusiveLease": False}
292
+
293
+ def __init__(self) -> None:
294
+ self.url = (os.environ.get("AGENT_SYNC_OUTLINE_URL") or "").rstrip("/")
295
+ self.token = os.environ.get("AGENT_SYNC_OUTLINE_TOKEN") or ""
296
+ self.collection = os.environ.get("AGENT_SYNC_OUTLINE_COLLECTION") or ""
297
+ self._collection_uuid = ""
298
+ self._ids: dict[str, str] = {}
299
+
300
+ def configured(self) -> bool:
301
+ return bool(self.url and self.token)
302
+
303
+ def _call(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
304
+ if not self.configured():
305
+ raise Fail("Outline is not configured (URL or token missing from the environment)")
306
+ payload = json.dumps(body).encode()
307
+ req = urllib.request.Request(
308
+ f"{self.url}/api/{endpoint}", data=payload, method="POST")
309
+ req.add_header("Authorization", f"Bearer {self.token}")
310
+ req.add_header("Content-Type", "application/json")
311
+ req.add_header("Accept", "application/json")
312
+
313
+ delay = 1.0
314
+ for attempt in range(7):
315
+ try:
316
+ with urllib.request.urlopen(req, timeout=20) as resp:
317
+ data = json.loads(resp.read().decode())
318
+ if not data.get("ok", False):
319
+ raise Fail(f"outline {endpoint}: {data.get('message') or data.get('error')}")
320
+ return data.get("data") or {}
321
+ except urllib.error.HTTPError as exc:
322
+ # The useful part of an Outline failure is in the body. Dropping it
323
+ # turns "collectionId: Invalid UUID" into a bare 400 and costs a
324
+ # debugging round — never swallow the reason.
325
+ detail = ""
326
+ try:
327
+ payload_err = json.loads(exc.read().decode())
328
+ detail = payload_err.get("message") or payload_err.get("error") or ""
329
+ except (ValueError, OSError):
330
+ pass
331
+ if exc.code in (401, 403):
332
+ raise Fail(
333
+ f"outline {endpoint}: {exc.code} — the token is rejected"
334
+ f"{': ' + detail if detail else ''}. "
335
+ "A credential does not become valid on retry.") from exc
336
+ # 429 and transient 5xx deserve a retry; 401/403 never will.
337
+ if exc.code in (429, 500, 502, 503, 504) and attempt < 6:
338
+ time.sleep(float(exc.headers.get("Retry-After") or delay)
339
+ + random.random() * 0.4)
340
+ delay *= 2
341
+ continue
342
+ raise Fail(f"outline {endpoint}: HTTP {exc.code}"
343
+ f"{' — ' + detail if detail else ''}") from exc
344
+ except urllib.error.URLError as exc:
345
+ if attempt < 2:
346
+ time.sleep(delay)
347
+ delay *= 2
348
+ continue
349
+ raise Fail(f"outline {endpoint}: cannot reach the instance ({exc.reason})") from exc
350
+ raise Fail(f"outline {endpoint}: gave up after 7 attempts")
351
+
352
+ def resolve_collection(self) -> str:
353
+ """Accept a UUID, a urlId, or the whole `name-urlId` slug from the browser.
354
+
355
+ The API takes a UUID, and the value a person copies out of the address bar
356
+ is a slug. Rejecting that with 'Invalid UUID' is technically correct and
357
+ useless, so match it instead."""
358
+ if self._collection_uuid:
359
+ return self._collection_uuid
360
+ value = self.collection.strip()
361
+ if not value:
362
+ raise Fail("AGENT_SYNC_OUTLINE_COLLECTION is not set — run `bootstrap` to create "
363
+ "the container, then put the id it prints into .env.agent-sync")
364
+ if re.fullmatch(r"[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", value):
365
+ self._collection_uuid = value
366
+ return value
367
+
368
+ rows = self._call("collections.list", {"limit": 100})
369
+ rows = rows if isinstance(rows, list) else []
370
+ tail = value.rsplit("-", 1)[-1]
371
+ for c in rows:
372
+ if value in (c.get("urlId"), c.get("name")) or (tail and tail == c.get("urlId")):
373
+ self._collection_uuid = c["id"]
374
+ print(f"note: resolved collection '{c.get('name')}' → {c['id']}\n"
375
+ f" put that UUID in AGENT_SYNC_OUTLINE_COLLECTION to skip this lookup",
376
+ file=sys.stderr)
377
+ return str(c["id"])
378
+ names = ", ".join(repr(c.get("name")) for c in rows) or "none visible to this token"
379
+ raise Fail(f"no collection matches '{value}'. Available: {names}")
380
+
381
+ def tree_ensure(self, path: str) -> str:
382
+ if path in self._ids:
383
+ return self._ids[path]
384
+ collection = self.resolve_collection()
385
+ found = self._call("documents.search",
386
+ {"query": path, "limit": 5, "collectionId": collection})
387
+ for row in (found if isinstance(found, list) else []):
388
+ doc = row.get("document") or {}
389
+ if doc.get("title") == path:
390
+ self._ids[path] = doc["id"]
391
+ return doc["id"]
392
+ doc = self._call("documents.create", {
393
+ "collectionId": collection, "title": path,
394
+ "text": f"{GENERATED_MARKER} container -->\n", "publish": True})
395
+ self._ids[path] = doc["id"]
396
+ return doc["id"]
397
+
398
+ def log_append(self, oid: str, line: str) -> None:
399
+ self._call("documents.update",
400
+ {"id": oid, "text": line.rstrip("\n") + "\n", "editMode": "append"})
401
+
402
+ def log_read(self, oid: str) -> str:
403
+ return self._call("documents.info", {"id": oid}).get("text", "")
404
+
405
+ def doc_put(self, oid: str, text: str) -> None:
406
+ self._call("documents.update", {"id": oid, "text": text, "editMode": "replace"})
407
+
408
+ def doc_get(self, oid: str) -> str:
409
+ return self._call("documents.info", {"id": oid}).get("text", "")
410
+
411
+ def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
412
+ rows = self._call("documents.search", {"query": query, "limit": limit})
413
+ out = []
414
+ for row in (rows if isinstance(rows, list) else []):
415
+ doc = row.get("document") or {}
416
+ out.append({"id": doc.get("id"), "title": doc.get("title"),
417
+ "snippet": row.get("context", "")})
418
+ return out
419
+
420
+ def log_shards(self, prefix: str) -> list[str]:
421
+ """Enumerate by the collection's structure, never by the search index.
422
+
423
+ `documents.search` matches TEXT; a shard's identity is in its TITLE, and a
424
+ freshly created shard is not reliably returned. Under concurrency that produced
425
+ the worst possible failure: each process saw only its own shard, replayed it,
426
+ and concluded it had won — eight processes, eight winners, one key.
427
+ `documents.list` reads the collection structure and returns a new document at
428
+ once.
429
+ """
430
+ out: list[str] = []
431
+ offset = 0
432
+ while True:
433
+ rows = self._call("documents.list",
434
+ {"collectionId": self.resolve_collection(),
435
+ "limit": 100, "offset": offset})
436
+ rows = rows if isinstance(rows, list) else []
437
+ for doc in rows:
438
+ title = doc.get("title") or ""
439
+ if title.startswith(prefix):
440
+ out.append(doc["id"])
441
+ self._ids[title] = doc["id"]
442
+ if len(rows) < 100:
443
+ break
444
+ offset += 100
445
+ if offset > 1000: # a bound, and it is reported rather than silent
446
+ print("agent-sync: more than 1000 documents in the collection; "
447
+ "shard enumeration truncated", file=sys.stderr)
448
+ break
449
+ return out
450
+
451
+
452
+ class FsAdapter(Adapter):
453
+ """Degraded mode. Files under .agent-sync/, committed and pushed.
454
+
455
+ atomicAppend is FALSE on purpose: agents here are separated by git, not by a
456
+ filesystem, so ordering is decided by a merge after the fact — which is not
457
+ when the protocol needs it. This adapter is never the lease authority.
458
+ """
459
+
460
+ name = "fs"
461
+ # A local lock file IS an exclusive primitive between processes on one machine.
462
+ capabilities = {"atomicAppend": False, "totalOrderRead": False, "search": False,
463
+ "exclusiveLease": True}
464
+
465
+ def __init__(self, root: Path) -> None:
466
+ self.base = root / STATE_DIR
467
+ self.base.mkdir(parents=True, exist_ok=True)
468
+
469
+ def configured(self) -> bool:
470
+ return True
471
+
472
+ def _p(self, path: str) -> Path:
473
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "-", path).strip("-").lower()
474
+ return self.base / f"{safe}.md"
475
+
476
+ def tree_ensure(self, path: str) -> str:
477
+ p = self._p(path)
478
+ if not p.exists():
479
+ p.parent.mkdir(parents=True, exist_ok=True)
480
+ p.write_text("")
481
+ return str(p)
482
+
483
+ def log_append(self, oid: str, line: str) -> None:
484
+ with open(oid, "a") as fh:
485
+ fh.write(line.rstrip("\n") + "\n")
486
+
487
+ def log_read(self, oid: str) -> str:
488
+ p = Path(oid)
489
+ return p.read_text() if p.exists() else ""
490
+
491
+ def doc_put(self, oid: str, text: str) -> None:
492
+ Path(oid).write_text(text)
493
+
494
+ def doc_get(self, oid: str) -> str:
495
+ p = Path(oid)
496
+ return p.read_text() if p.exists() else ""
497
+
498
+ def log_shards(self, prefix: str) -> list[str]:
499
+ stem = re.sub(r"[^A-Za-z0-9._-]+", "-", prefix).strip("-").lower()
500
+ return [str(q) for q in sorted(self.base.glob(f"{stem}*.md"))]
501
+
502
+
503
+ def make_adapter(cfg: dict[str, Any], root: Path) -> Adapter:
504
+ backend = os.environ.get("AGENT_SYNC_BACKEND") or cfg.get("backend") or "fs"
505
+ if backend == "outline":
506
+ ad = OutlineAdapter()
507
+ if not ad.configured():
508
+ return FsAdapter(root)
509
+ return ad
510
+ return FsAdapter(root)
511
+
512
+
513
+ # --------------------------------------------------------------------------- log
514
+
515
+ def fmt_line(op: str, key: str, rid: str, **extra: Any) -> str:
516
+ pairs = [f"`op={op}`", f"`key={key}`", f"`run={rid}`"]
517
+ pairs += [f"`{k}={v}`" for k, v in extra.items() if v not in (None, "")]
518
+ return f"- `{now_iso()}` " + " ".join(pairs)
519
+
520
+
521
+ def parse_log(text: str) -> tuple[list[dict[str, str]], int]:
522
+ events: list[dict[str, str]] = []
523
+ bad = 0
524
+ for raw in text.splitlines():
525
+ raw = raw.rstrip()
526
+ # Skip only what is plainly not an entry (blank lines, prose, the generated
527
+ # marker). Anything shaped like an entry must reach LINE_RE, or a silent
528
+ # pre-filter hides malformed lines from the very counter meant to expose them.
529
+ if not CANDIDATE_RE.match(raw):
530
+ continue
531
+ m = LINE_RE.match(raw)
532
+ if not m:
533
+ bad += 1
534
+ continue
535
+ ev = dict(PAIR_RE.findall(m.group("pairs")))
536
+ if not {"op", "key", "run"} <= ev.keys():
537
+ bad += 1
538
+ continue
539
+ ev["ts"] = m.group("ts")
540
+ events.append(ev)
541
+ return events, bad
542
+
543
+
544
+ def resolve_holding(events: list[dict[str, str]], key: str, at: float) -> dict[str, Any] | None:
545
+ """Replay: the holder is the earliest acquire for key that is, at this point,
546
+ neither released nor expired. Pure function of the log text.
547
+
548
+ Returns the holding record — run, repo and when it was taken — because a caller
549
+ that only learns *that* a key is held cannot tell an agent where to look."""
550
+ live: list[dict[str, Any]] = []
551
+ for ev in events:
552
+ if ev["key"] != key:
553
+ continue
554
+ if ev["op"] == "acquire":
555
+ live.append({"run": ev["run"], "ts": parse_iso(ev["ts"]),
556
+ "ttl": int(ev.get("ttl") or DEFAULT_TTL),
557
+ "repo": ev.get("repo", "")})
558
+ elif ev["op"] == "release":
559
+ live = [h for h in live if h["run"] != ev["run"]]
560
+ elif ev["op"] == "renew":
561
+ for h in live:
562
+ if h["run"] == ev["run"]:
563
+ h["ts"] = parse_iso(ev["ts"])
564
+ for h in live:
565
+ if at <= h["ts"] + h["ttl"]:
566
+ return h
567
+ return None
568
+
569
+
570
+ def resolve_holder(events: list[dict[str, str]], key: str, at: float) -> str | None:
571
+ h = resolve_holding(events, key, at)
572
+ return str(h["run"]) if h else None
573
+
574
+
575
+ def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, list[int], list[tuple[str, int]]]:
576
+ """Positional allocation over the log. Returns (base, free_list, assignments)."""
577
+ base = None
578
+ free: list[int] = []
579
+ served = 0
580
+ assignments: list[tuple[str, int]] = []
581
+ for ev in events:
582
+ if ev["key"] != reg:
583
+ continue
584
+ if ev["op"] == "base":
585
+ base = int(ev.get("value") or 0)
586
+ continue
587
+ if base is None:
588
+ continue
589
+ if ev["op"] == "release_id":
590
+ try:
591
+ free.append(int(ev.get("value") or 0))
592
+ except ValueError:
593
+ pass
594
+ elif ev["op"] == "reserve":
595
+ if free:
596
+ assignments.append((ev["run"], free.pop(0)))
597
+ else:
598
+ assignments.append((ev["run"], base + served))
599
+ served += 1
600
+ return (base or 0), free, assignments
601
+
602
+
603
+ # --------------------------------------------------------------------------- coordinator
604
+
605
+ class Sync:
606
+ def __init__(self) -> None:
607
+ self.root = project_root()
608
+ os.chdir(self.root)
609
+ load_env_file(self.root)
610
+ self.cfg = load_config(self.root)
611
+ self.adapter = make_adapter(self.cfg, self.root)
612
+ self.rid = run_id(self.root)
613
+ self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
614
+ self.settle = float(self.cfg.get("settleSeconds") or DEFAULT_SETTLE)
615
+
616
+ @property
617
+ def gated(self) -> bool:
618
+ return bool(self.cfg.get("gated", True)) and self.adapter.is_lease_authority
619
+
620
+ def log_id(self, which: str) -> str:
621
+ """This run's OWN shard. One writer per document, always.
622
+
623
+ Outline's `editMode: append` is not atomic under concurrency: the server reads
624
+ the text, appends and writes it back, so simultaneous requests clobber each
625
+ other — and every one of them returns `ok: true`. Measured: twelve concurrent
626
+ appends, twelve successes reported, three lines present. Nine writes lost
627
+ silently.
628
+
629
+ That breaks mutual exclusion outright. If B's write erases A's acquire, A has
630
+ already read back and seen itself win, and B reads back and sees itself win —
631
+ two holders of one lease, each with proof.
632
+
633
+ Sharding removes the race rather than fighting it: nobody else writes this
634
+ document, so nothing can be clobbered. The cost is that a total order can no
635
+ longer come from one document's line order, so reads merge the shards and sort
636
+ by (timestamp, run). Every reader computes the SAME order — which is the
637
+ property the protocol actually needs. Clock skew now affects who wins a tie,
638
+ not whether readers agree, and an unfair winner is survivable where
639
+ disagreement is not.
640
+ """
641
+ return self.adapter.tree_ensure(f"{LOGS[which]} — {self.rid}")
642
+
643
+ def events(self, which: str) -> tuple[list[dict[str, str]], int]:
644
+ """Merge every shard, plus any pre-sharding single document, into one order."""
645
+ prefix = LOGS[which]
646
+ texts: list[str] = []
647
+ seen: set[str] = set()
648
+ for oid in self.adapter.log_shards(prefix):
649
+ if oid in seen:
650
+ continue
651
+ seen.add(oid)
652
+ texts.append(self.adapter.log_read(oid))
653
+
654
+ events: list[dict[str, str]] = []
655
+ bad = 0
656
+ for seq, text in enumerate(texts):
657
+ evs, b = parse_log(text)
658
+ bad += b
659
+ for i, ev in enumerate(evs):
660
+ ev["_shard"] = str(seq)
661
+ ev["_i"] = str(i)
662
+ events.append(ev)
663
+
664
+ # Deterministic for every reader: time, then run, then position within a shard.
665
+ events.sort(key=lambda e: (e["ts"], e["run"], int(e["_i"])))
666
+ return events, bad
667
+
668
+ # -- leases ------------------------------------------------------------
669
+
670
+ # -- git lease: real compare-and-swap, across machines --------------------
671
+
672
+ @staticmethod
673
+ def _ref(key: str) -> str:
674
+ return "refs/agent-sync/leases/" + re.sub(r"[^A-Za-z0-9._-]+", "-", key).strip("-")
675
+
676
+ def _git_remote(self) -> str:
677
+ return self.cfg.get("leaseRemote") or "origin"
678
+
679
+ def _git_read_lease(self, key: str) -> tuple[str | None, dict[str, Any]]:
680
+ """(sha, payload) currently on the remote for this key, or (None, {})."""
681
+ out = git("ls-remote", self._git_remote(), self._ref(key))
682
+ if not out:
683
+ return None, {}
684
+ sha = out.split()[0]
685
+ git("fetch", "-q", self._git_remote(), f"{self._ref(key)}:refs/agent-sync/fetched")
686
+ body = git("log", "-1", "--format=%B", sha) or git("log", "-1", "--format=%B",
687
+ "refs/agent-sync/fetched")
688
+ try:
689
+ return sha, json.loads(body.strip())
690
+ except (json.JSONDecodeError, ValueError):
691
+ return sha, {}
692
+
693
+ def _git_acquire(self, key: str) -> tuple[bool, str | None]:
694
+ """Push a ref that must not already exist. The remote's non-fast-forward rule
695
+ IS the compare-and-swap — verified against a hosted remote, not assumed."""
696
+ remote, ref = self._git_remote(), self._ref(key)
697
+ held_sha, held = self._git_read_lease(key)
698
+ if held:
699
+ if held.get("run") == self.rid:
700
+ self._touch_renew()
701
+ return True, self.rid
702
+ alive = time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
703
+ if alive:
704
+ return False, held.get("run")
705
+
706
+ payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
707
+ "repo": repo_name(), "host": os.uname().nodename})
708
+ empty_tree = git("hash-object", "-t", "tree", "/dev/null")
709
+ commit = subprocess.run(["git", "commit-tree", empty_tree], input=payload,
710
+ capture_output=True, text=True).stdout.strip()
711
+ if not commit:
712
+ raise Fail("could not create the lease object — is this a git repository?")
713
+
714
+ args = ["git", "push", remote, f"{commit}:{ref}"]
715
+ if held_sha: # stealing an expired lease, and only that
716
+ args.insert(2, f"--force-with-lease={ref}:{held_sha}")
717
+ r = subprocess.run(args, capture_output=True, text=True)
718
+ if r.returncode != 0:
719
+ # Rejected: somebody won between our read and our push. Ask who.
720
+ _s, now_held = self._git_read_lease(key)
721
+ return False, now_held.get("run") or "another run"
722
+ self._touch_renew()
723
+ return True, self.rid
724
+
725
+ def _git_release(self, key: str) -> None:
726
+ sha, held = self._git_read_lease(key)
727
+ if not sha:
728
+ return
729
+ if held.get("run") not in (self.rid, None):
730
+ print(f"note: {key} is held by {held.get('run')}, not this run — not released",
731
+ file=sys.stderr)
732
+ return
733
+ r = subprocess.run(["git", "push", self._git_remote(),
734
+ f"--force-with-lease={self._ref(key)}:{sha}",
735
+ f":{self._ref(key)}"], capture_output=True, text=True)
736
+ if r.returncode != 0:
737
+ print(f"note: could not release {key} on the remote: {r.stderr.strip()[:160]}",
738
+ file=sys.stderr)
739
+
740
+ @property
741
+ def lease_mode(self) -> str:
742
+ return self.cfg.get("leaseBackend") or "local"
743
+
744
+ @property
745
+ def lease_is_cross_machine(self) -> bool:
746
+ return self.lease_mode == "git"
747
+
748
+ def _local_lock(self, key: str) -> Path:
749
+ d = self.root / STATE_DIR / "leases"
750
+ d.mkdir(parents=True, exist_ok=True)
751
+ return d / f"{re.sub(r'[^A-Za-z0-9_-]', '-', key)}.lock"
752
+
753
+ def acquire(self, key: str) -> tuple[bool, str | None]:
754
+ """Exclusion comes from an atomic file create; the cloud carries the record.
755
+
756
+ This is the third design, and the first that is true. A single shared document
757
+ lost writes (twelve concurrent appends, twelve reported successes, three lines
758
+ present). Sharding fixed the loss and broke the decision: with no way to know a
759
+ contender is still writing, eight processes each read only their own shard and
760
+ eight of them won. No settle window closes that — the store has no
761
+ compare-and-swap, so the question "has everyone written yet?" has no answer.
762
+
763
+ `O_EXCL` does have one. It is a genuine mutex between processes on one machine,
764
+ which is how these agents actually run. Across machines it is not, and the tool
765
+ says so rather than implying a guarantee it cannot keep.
766
+ """
767
+ if self.lease_mode == "git":
768
+ won, holder = self._git_acquire(key)
769
+ if won:
770
+ for n in self.write_claim(key, self.rid):
771
+ print(f" {n}")
772
+ try:
773
+ self.adapter.log_append(self.log_id("claims"), fmt_line(
774
+ "acquire", key, self.rid, ttl=self.ttl,
775
+ repo=repo_name(), sha=head_sha()))
776
+ except Fail:
777
+ pass
778
+ return won, holder
779
+
780
+ lock = self._local_lock(key)
781
+
782
+ # Reap an expired lock first: it is a crashed run, not a live holder.
783
+ if lock.exists():
784
+ try:
785
+ held = json.loads(lock.read_text())
786
+ except (json.JSONDecodeError, OSError):
787
+ held = {}
788
+ expired = time.time() > parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
789
+ if held.get("run") == self.rid:
790
+ self._touch_renew()
791
+ return True, self.rid
792
+ if not expired:
793
+ return False, held.get("run")
794
+ lock.unlink(missing_ok=True)
795
+
796
+ payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
797
+ "repo": repo_name()})
798
+ try:
799
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
800
+ except FileExistsError:
801
+ try:
802
+ other = json.loads(lock.read_text()).get("run")
803
+ except (json.JSONDecodeError, OSError):
804
+ other = None
805
+ return False, other
806
+ with os.fdopen(fd, "w") as fh:
807
+ fh.write(payload)
808
+
809
+ self._touch_renew()
810
+ for n in self.write_claim(key, self.rid):
811
+ print(f" {n}")
812
+ # Record it for everyone else to see. A failure here costs visibility, never
813
+ # correctness — the lock is already held — so it must not fail the acquire.
814
+ if self.adapter.is_lease_authority:
815
+ try:
816
+ self.adapter.log_append(self.log_id("claims"), fmt_line(
817
+ "acquire", key, self.rid, ttl=self.ttl,
818
+ repo=repo_name(), sha=head_sha()))
819
+ except Fail as exc:
820
+ print(f"note: lease held, but not published to the plane ({exc})",
821
+ file=sys.stderr)
822
+ return True, self.rid
823
+
824
+ def renew(self, key: str | None = None) -> bool:
825
+ marker = self.root / STATE_DIR / "last-renew"
826
+ interval = int(self.cfg.get("renewIntervalSeconds") or DEFAULT_RENEW)
827
+ if marker.exists() and time.time() - marker.stat().st_mtime < interval:
828
+ return False
829
+ keys = [key] if key else self.held()
830
+ if not keys:
831
+ self._touch_renew()
832
+ return False
833
+ if self.adapter.is_lease_authority:
834
+ oid = self.log_id("claims")
835
+ for k in keys:
836
+ self.adapter.log_append(oid, fmt_line("renew", k, self.rid))
837
+ self._touch_renew()
838
+ return True
839
+
840
+ def _touch_renew(self) -> None:
841
+ marker = self.root / STATE_DIR / "last-renew"
842
+ marker.parent.mkdir(parents=True, exist_ok=True)
843
+ marker.write_text(now_iso())
844
+
845
+ def release(self, key: str) -> None:
846
+ for n in self.write_claim(key, None):
847
+ print(f" {n}")
848
+ if self.lease_mode == "git":
849
+ self._git_release(key)
850
+ lock = self._local_lock(key)
851
+ if lock.exists():
852
+ try:
853
+ if json.loads(lock.read_text()).get("run") in (self.rid, None):
854
+ lock.unlink(missing_ok=True)
855
+ except (json.JSONDecodeError, OSError):
856
+ lock.unlink(missing_ok=True)
857
+ if self.adapter.is_lease_authority:
858
+ try:
859
+ self.adapter.log_append(self.log_id("claims"),
860
+ fmt_line("release", key, self.rid))
861
+ except Fail as exc:
862
+ print(f"note: released locally, not published ({exc})", file=sys.stderr)
863
+
864
+ def held(self) -> list[str]:
865
+ d = self.root / STATE_DIR / "leases"
866
+ mine = []
867
+ for q in sorted(d.glob("*.lock") if d.exists() else []):
868
+ try:
869
+ h = json.loads(q.read_text())
870
+ except (json.JSONDecodeError, OSError):
871
+ continue
872
+ if h.get("run") == self.rid and \
873
+ time.time() <= parse_iso(h.get("ts", "")) + int(h.get("ttl", self.ttl)):
874
+ mine.append(q.stem)
875
+ return sorted(mine)
876
+
877
+ def _held_legacy(self) -> list[str]:
878
+ if not self.adapter.is_lease_authority:
879
+ d = self.root / STATE_DIR / "leases"
880
+ out = []
881
+ for p in (d.glob("*.lock") if d.exists() else []):
882
+ try:
883
+ if json.loads(p.read_text()).get("run") == self.rid:
884
+ out.append(p.stem)
885
+ except json.JSONDecodeError:
886
+ continue
887
+ return out
888
+ events, _ = self.events("claims")
889
+ now = time.time()
890
+ keys = {e["key"] for e in events}
891
+ return sorted(k for k in keys if resolve_holder(events, k, now) == self.rid)
892
+
893
+ # -- ids ---------------------------------------------------------------
894
+
895
+ def reserve(self, reg: str) -> int:
896
+ if not self.adapter.is_lease_authority:
897
+ raise Fail(
898
+ f"backend '{self.adapter.name}' cannot reserve ids safely "
899
+ "(atomicAppend is false). Allocate by hand and record it, or configure a "
900
+ "cloud backend. Pretending would hand two agents the same id.")
901
+ oid = self.log_id("reservations")
902
+ events, _ = parse_log(self.adapter.log_read(oid))
903
+ base, _free, _assign = resolve_reservations(events, reg)
904
+ if not base:
905
+ base = self._seed_base(reg)
906
+ self.adapter.log_append(oid, fmt_line("base", reg, self.rid, value=f"{base:04d}"))
907
+ events, _ = parse_log(self.adapter.log_read(oid))
908
+ self.adapter.log_append(oid, fmt_line("reserve", reg, self.rid))
909
+ time.sleep(0.25 + random.random() * 0.15)
910
+ events, _ = parse_log(self.adapter.log_read(oid))
911
+ _b, _f, assignments = resolve_reservations(events, reg)
912
+ mine = [v for r, v in assignments if r == self.rid]
913
+ if not mine:
914
+ raise Fail(f"reserve {reg}: the append did not read back — retry")
915
+ return mine[-1]
916
+
917
+ def _seed_base(self, reg: str) -> int:
918
+ spec = (self.cfg.get("idRegisters") or {}).get(reg)
919
+ if not spec:
920
+ raise Fail(f"register '{reg}' is not declared in .claude/agent-sync.json")
921
+ path = self.root / spec["file"]
922
+ if not path.exists():
923
+ raise Fail(f"register file {spec['file']} does not exist")
924
+ m = re.search(spec["nextFreeIdPattern"], path.read_text())
925
+ if not m:
926
+ raise Fail(f"could not read the next free id out of {spec['file']}")
927
+ return int(m.group(1))
928
+
929
+ def release_id(self, reg: str, value: str) -> None:
930
+ if self.adapter.is_lease_authority:
931
+ self.adapter.log_append(self.log_id("reservations"),
932
+ fmt_line("release_id", reg, self.rid, value=value))
933
+
934
+ # -- journal / signals -------------------------------------------------
935
+
936
+ def _publish(self, which: str, line: str) -> bool:
937
+ """Write to the plane, and never let that failure destroy the caller's work.
938
+
939
+ The plane carries visibility, not correctness. A rate limit or an outage must
940
+ surface loudly and leave the run able to continue — swallowing it would hide a
941
+ gap in the record, and raising would make a knowledge base an availability
942
+ dependency of doing any work at all.
943
+ """
944
+ try:
945
+ self.adapter.log_append(self.log_id(which), line)
946
+ return True
947
+ except Fail as exc:
948
+ print(f"agent-sync: NOT published to the plane ({exc}). The work stands; "
949
+ "the record has a gap — re-run this step when the store is reachable.",
950
+ file=sys.stderr)
951
+ return False
952
+
953
+ def journal(self, text: str) -> None:
954
+ try:
955
+ oid = self.adapter.tree_ensure(f"20 Runs — {self.rid}")
956
+ self.adapter.log_append(oid, fmt_line(
957
+ "journal", self.rid, self.rid, sha=head_sha(),
958
+ note=text.replace("`", "'")[:400]))
959
+ except Fail as exc:
960
+ print(f"agent-sync: journal NOT published ({exc})", file=sys.stderr)
961
+
962
+ def signal(self, dep: str, state: str) -> None:
963
+ allowed = {"filed", "accepted", "delivered", "closed", "refused"}
964
+ if state not in allowed:
965
+ raise Fail(f"state must be one of {sorted(allowed)}")
966
+ self._publish("signals", fmt_line(
967
+ "signal", dep, self.rid, state=state, repo=repo_name(), sha=head_sha()))
968
+
969
+ # -- awareness ---------------------------------------------------------
970
+
971
+ def _watermark(self, which: str) -> int:
972
+ p = self.root / STATE_DIR / "seen.json"
973
+ try:
974
+ return int(json.loads(p.read_text()).get(which, 0))
975
+ except (OSError, ValueError, AttributeError):
976
+ return 0
977
+
978
+ def _set_watermark(self, which: str, value: int) -> None:
979
+ p = self.root / STATE_DIR / "seen.json"
980
+ p.parent.mkdir(parents=True, exist_ok=True)
981
+ try:
982
+ data = json.loads(p.read_text())
983
+ except (OSError, ValueError):
984
+ data = {}
985
+ data[which] = value
986
+ p.write_text(json.dumps(data))
987
+
988
+ def activity(self, limit: int = 6, mark_read: bool = True) -> dict[str, Any]:
989
+ """What OTHER runs are doing, and what changed since this run last looked.
990
+
991
+ Coordination is not only mutual exclusion. An agent that cannot see the
992
+ others is merely blocked by them: it learns a task is taken and nothing
993
+ about who has it, what they are touching, or what landed while it was away.
994
+ """
995
+ others = {k: v for k, v in self.all_holdings().items() if v["run"] != self.rid}
996
+
997
+ signals, _ = self.events("signals")
998
+ seen = self._watermark("signals")
999
+ fresh = signals[seen:] if len(signals) > seen else []
1000
+ if mark_read:
1001
+ self._set_watermark("signals", len(signals))
1002
+
1003
+ return {"others": others, "signals": signals[-limit:], "new_signals": fresh}
1004
+
1005
+ # -- claim tags ---------------------------------------------------------
1006
+
1007
+ def _claim_targets(self, key: str) -> list[tuple[Path, dict[str, Any]]]:
1008
+ out = []
1009
+ for pattern, spec in (self.cfg.get("claimTags") or {}).items():
1010
+ for path in sorted(self.root.glob(pattern)):
1011
+ if path.is_file():
1012
+ out.append((path, spec))
1013
+ return out
1014
+
1015
+ @staticmethod
1016
+ def _row_cells(line: str) -> list[str] | None:
1017
+ """Split a markdown table row, or None if this is not one."""
1018
+ if not line.lstrip().startswith("|"):
1019
+ return None
1020
+ raw = line.strip()
1021
+ if raw.endswith("|"):
1022
+ raw = raw[:-1]
1023
+ return raw[1:].split("|")
1024
+
1025
+ def write_claim(self, key: str, holder: str | None) -> list[str]:
1026
+ """Write the claim through to git, or restore it. Surgical and reversible.
1027
+
1028
+ One row, one cell, one substitution. Ambiguity is refused rather than guessed:
1029
+ this edits a shared registry, so a wrong line is exactly the collision the lease
1030
+ exists to prevent. The previous cell text is stored in the lock file, so release
1031
+ restores what was there rather than an assumed default.
1032
+ """
1033
+ notes: list[str] = []
1034
+ for path, spec in self._claim_targets(key):
1035
+ if spec.get("mode") != "cell":
1036
+ continue
1037
+ idx = int(spec.get("cell", -1))
1038
+ lines = path.read_text().splitlines(keepends=True)
1039
+ hits = [i for i, l in enumerate(lines)
1040
+ if re.search(rf"(?<![A-Za-z0-9-]){re.escape(key)}(?![A-Za-z0-9-])", l)
1041
+ and self._row_cells(l) is not None]
1042
+ rel = path.relative_to(self.root)
1043
+ if not hits:
1044
+ continue
1045
+ if len(hits) > 1:
1046
+ notes.append(f"{rel}: `{key}` appears in {len(hits)} table rows — refusing "
1047
+ "to guess which one is the claim. Narrow the pattern or edit by hand")
1048
+ continue
1049
+
1050
+ i = hits[0]
1051
+ cells = self._row_cells(lines[i])
1052
+ assert cells is not None
1053
+ if idx < 0:
1054
+ idx = len(cells) + idx
1055
+ if not 0 <= idx < len(cells):
1056
+ notes.append(f"{rel}: cell {spec.get('cell')} is out of range for `{key}`'s row")
1057
+ continue
1058
+
1059
+ # Kept beside the run state, not in the lock file: the git lease mode has no
1060
+ # lock file, and a release that cannot find what it replaced leaves the claim
1061
+ # written through forever — which is worse than never writing it.
1062
+ store = self.root / STATE_DIR / "claims.json"
1063
+ try:
1064
+ state = json.loads(store.read_text())
1065
+ except (json.JSONDecodeError, OSError):
1066
+ state = {}
1067
+ saved = (state.get(key) or {}).get(str(rel))
1068
+
1069
+ current = cells[idx]
1070
+ if holder is not None:
1071
+ if saved is not None:
1072
+ continue # already written through
1073
+ template = spec.get("held") or "{prev} (claimed: {holder})"
1074
+ new = template.replace("{prev}", current.strip()).replace("{holder}", holder)
1075
+ cells[idx] = f" {new.strip()} "
1076
+ state.setdefault(key, {})[str(rel)] = current
1077
+ else:
1078
+ if saved is None:
1079
+ continue # nothing of ours to undo
1080
+ cells[idx] = saved
1081
+ state.get(key, {}).pop(str(rel), None)
1082
+ if not state.get(key):
1083
+ state.pop(key, None)
1084
+
1085
+ lines[i] = "|" + "|".join(cells) + "|\n"
1086
+ tmp = path.with_suffix(path.suffix + ".agent-sync.tmp")
1087
+ tmp.write_text("".join(lines))
1088
+ tmp.replace(path)
1089
+ store.parent.mkdir(parents=True, exist_ok=True)
1090
+ store.write_text(json.dumps(state, indent=2))
1091
+ notes.append(f"{rel}: `{key}` claim "
1092
+ + ("written through" if holder else "restored"))
1093
+ return notes
1094
+
1095
+ # -- claim divergence ---------------------------------------------------------
1096
+
1097
+ def claim_divergence(self) -> list[str]:
1098
+ """Where a held lease and the durable git claim tag disagree.
1099
+
1100
+ DEC-0216 makes the git tag the durable record and the lease the live one, with
1101
+ the run writing the tag through. The tool verifies rather than edits: a process
1102
+ that rewrites a shared registry file on its own is the exact mechanism that
1103
+ clobbers another agent's work, and it would do it from a hook, unattended.
1104
+ So this reports, and the agent writes.
1105
+ """
1106
+ out: list[str] = []
1107
+ tags = self.cfg.get("claimTags") or {}
1108
+ if not tags:
1109
+ return out
1110
+ held = set(self.held())
1111
+ if not held:
1112
+ return out
1113
+ for pattern, spec in tags.items():
1114
+ for path in sorted(self.root.glob(pattern)):
1115
+ if not path.is_file():
1116
+ continue
1117
+ try:
1118
+ text = path.read_text()
1119
+ except OSError:
1120
+ continue
1121
+ rel = path.relative_to(self.root)
1122
+ marker = (spec.get("held") or "").replace("{holder}", self.rid)
1123
+ for key in sorted(held):
1124
+ if key not in text:
1125
+ continue
1126
+ line = next((l for l in text.splitlines() if key in l), "")
1127
+ if marker and marker in line:
1128
+ continue
1129
+ if spec.get("open") and spec["open"] in line:
1130
+ out.append(f"{rel}: `{key}` still reads `{spec['open']}` while "
1131
+ "this run holds the lease — write the claim through")
1132
+ elif spec.get("open") and spec["open"] in text:
1133
+ # The tag exists in the file but not on the id's line. The
1134
+ # configured mapping cannot be verified for this key, and saying
1135
+ # so beats passing silently — an unverifiable check that reports
1136
+ # clean is indistinguishable from one that works.
1137
+ out.append(f"{rel}: cannot verify the claim tag for `{key}` — "
1138
+ f"`{spec['open']}` appears in the file but not on that "
1139
+ "id's line. Fix claimTags, or write the tag by hand")
1140
+ return out
1141
+
1142
+ # -- as-built record and reconciliation ---------------------------------
1143
+
1144
+ def record(self, text: str, decision: str = "", files: str = "") -> None:
1145
+ """Append what was ACTUALLY built. Not a plan, not an intention."""
1146
+ self._publish("asbuilt", fmt_line(
1147
+ "asbuilt", decision or "-", self.rid, repo=repo_name(), sha=head_sha(),
1148
+ files=files.replace("`", "'")[:200],
1149
+ note=text.replace("`", "'")[:400]))
1150
+
1151
+
1152
+ def _allocated_ids(self, reg: str, spec: dict[str, Any]) -> set[str]:
1153
+ """Ids that actually exist, excluding the register's "next free" pointer.
1154
+
1155
+ That line names the id nobody has taken yet. Scraping it as an allocated id
1156
+ makes every reconcile demand an as-built record for a decision that has not
1157
+ been written — and poisons the baseline with a number one higher than reality.
1158
+ """
1159
+ path = self.root / spec["file"]
1160
+ if not path.exists():
1161
+ return set()
1162
+ text = path.read_text()
1163
+ ids = set(re.findall(rf"\b{reg}-\d+\b", text))
1164
+ pattern = spec.get("nextFreeIdPattern")
1165
+ if pattern:
1166
+ m = re.search(pattern, text)
1167
+ if m:
1168
+ ids.discard(f"{reg}-{m.group(1)}")
1169
+ return ids
1170
+
1171
+ def set_baseline(self) -> dict[str, int]:
1172
+ """Stamp today's highest id per register as the line before which nothing is
1173
+ expected to carry an as-built record. Idempotent-ish: re-stamping moves the
1174
+ line forward, which is why it prints what it did."""
1175
+ out = {}
1176
+ oid = self.log_id("asbuilt")
1177
+ for reg, spec in (self.cfg.get("idRegisters") or {}).items():
1178
+ path = self.root / spec["file"]
1179
+ if not path.exists():
1180
+ continue
1181
+ nums = [int(i.rsplit("-", 1)[1]) for i in self._allocated_ids(reg, spec)]
1182
+ top = max(nums) if nums else 0
1183
+ self.adapter.log_append(oid, fmt_line(
1184
+ "baseline", reg, self.rid, value=f"{top:04d}", repo=repo_name()))
1185
+ out[reg] = top
1186
+ return out
1187
+
1188
+ def reconcile(self) -> list[dict[str, str]]:
1189
+ """Compare intent (git) against the as-built record (cloud).
1190
+
1191
+ Only mechanical divergence is decided here. Whether a built thing actually
1192
+ matches what the document describes is a reading, not a diff — this reports
1193
+ where to look and refuses to pretend it judged the substance.
1194
+ """
1195
+ findings: list[dict[str, str]] = []
1196
+ notes_backlog: list[str] = []
1197
+ events, _ = self.events("asbuilt")
1198
+
1199
+ # 1. Recorded as built, but the commit is not in this history: recorded from a
1200
+ # branch that never landed, or from a different repository.
1201
+ for ev in events:
1202
+ sha = ev.get("sha", "")
1203
+ repo = ev.get("repo", "")
1204
+ if not sha or sha == "unknown" or repo != repo_name():
1205
+ continue
1206
+ # `git cat-file -e` prints nothing on success, so the exit code is the
1207
+ # only signal — a stdout-returning helper cannot answer this.
1208
+ if subprocess.run(["git", "cat-file", "-e", f"{sha}^{{commit}}"],
1209
+ capture_output=True).returncode != 0:
1210
+ findings.append({
1211
+ "kind": "as-built commit missing from git",
1212
+ "detail": f"{sha} recorded by {ev['run']}: {ev.get('note', '')[:90]}",
1213
+ "means": "recorded as built, but that commit is not in this history"})
1214
+
1215
+ # 2. Intent with no as-built record — as a RATCHET, not a flood.
1216
+ # A project adopting this on day one has every prior decision unrecorded, and
1217
+ # a check that reports all of them reports nothing: it is noise, and noise is
1218
+ # what gets a gate switched off. Ids at or below the baseline are counted as a
1219
+ # backlog that may only shrink; ids after it fail.
1220
+ recorded_ids = {ev["key"] for ev in events if ev["key"] != "-"}
1221
+ baselines = {ev["key"]: int(ev.get("value") or 0)
1222
+ for ev in events if ev["op"] == "baseline"}
1223
+ for reg, spec in (self.cfg.get("idRegisters") or {}).items():
1224
+ path = self.root / spec["file"]
1225
+ if not path.exists():
1226
+ continue
1227
+ ids = self._allocated_ids(reg, spec)
1228
+ base = baselines.get(reg)
1229
+ if base is None:
1230
+ findings.append({
1231
+ "kind": f"{reg} has no as-built baseline",
1232
+ "detail": f"{len(ids)} ids exist and none is evaluated",
1233
+ "means": "run `reconcile --set-baseline` once, then only new ids are checked"})
1234
+ continue
1235
+ missing_new, backlog = [], 0
1236
+ for i in sorted(ids - recorded_ids):
1237
+ num = int(i.rsplit("-", 1)[1])
1238
+ if num > base:
1239
+ missing_new.append(i)
1240
+ else:
1241
+ backlog += 1
1242
+ if missing_new:
1243
+ findings.append({
1244
+ "kind": f"{reg} written after the baseline with no as-built record",
1245
+ "detail": ", ".join(missing_new[:8]) +
1246
+ (f" (+{len(missing_new)-8} more)" if len(missing_new) > 8 else ""),
1247
+ "means": "decided since adoption; nothing reports it was built"})
1248
+ if backlog:
1249
+ notes_backlog.append(f"{reg}: {backlog} pre-baseline ids unevaluated (backlog)")
1250
+
1251
+ # 3. As-built citing an id that does not exist in git: built against something
1252
+ # that was never recorded as a decision.
1253
+ # Only judge what this checkout can actually judge. The as-built log is shared by
1254
+ # every repository on the plane, while id registers are per-repository — a service
1255
+ # repo declares none, because decisions live in the umbrella. Comparing the shared
1256
+ # log against a local register reported every umbrella decision as an orphan when
1257
+ # run from a submodule: a false finding produced by scope, and the loudest possible
1258
+ # way to teach people to ignore the check.
1259
+ registers = self.cfg.get("idRegisters") or {}
1260
+ if registers:
1261
+ known: set[str] = set()
1262
+ for reg, spec in registers.items():
1263
+ known |= self._allocated_ids(reg, spec)
1264
+ prefixes = tuple(f"{reg}-" for reg in registers)
1265
+ orphan = sorted({ev["key"] for ev in events
1266
+ if ev.get("repo") == repo_name()
1267
+ and ev["key"].startswith(prefixes)
1268
+ and ev["key"] not in known})
1269
+ if orphan:
1270
+ findings.append({
1271
+ "kind": "as-built cites an unknown id",
1272
+ "detail": ", ".join(orphan[:8]),
1273
+ "means": "built against a decision that is not in the git register"})
1274
+ else:
1275
+ notes_backlog.append(
1276
+ "no id registers declared here, so register checks are not evaluated in "
1277
+ "this repository — run reconcile in the umbrella for those")
1278
+
1279
+ self.backlog = notes_backlog
1280
+ return findings
1281
+
1282
+ # -- guard -------------------------------------------------------------
1283
+
1284
+ def guard(self, path: str) -> tuple[bool, str]:
1285
+ rel = os.path.relpath(os.path.abspath(path), str(self.root))
1286
+ patterns = self.cfg.get("guardedFiles") or []
1287
+ if not any(Path(rel).match(p) for p in patterns):
1288
+ return True, "not a guarded file"
1289
+
1290
+ # A lease is required in every mode. What differs between backends is how
1291
+ # strongly it is arbitrated, and that is what `gated` reports — not whether
1292
+ # the check runs. A local lock file is genuine mutual exclusion between
1293
+ # agents on one machine; it is only across machines that fs cannot arbitrate.
1294
+ held = self.held()
1295
+ if held:
1296
+ note = "" if self.gated else " (advisory: arbitrated locally only)"
1297
+ return True, f"held by this run ({', '.join(held)}){note}"
1298
+
1299
+ other = self._any_other_holder()
1300
+ who = f" — {other} holds a lease right now" if other else ""
1301
+ return False, (f"{rel} is a guarded registry file and this run holds no lease{who}. "
1302
+ f"Acquire one first: agent_sync.py acquire <TASK-ID>")
1303
+
1304
+ def _any_other_holder(self) -> str | None:
1305
+ if self.adapter.is_lease_authority:
1306
+ events, _ = self.events("claims")
1307
+ now = time.time()
1308
+ for key in {e["key"] for e in events}:
1309
+ holder = resolve_holder(events, key, now)
1310
+ if holder and holder != self.rid:
1311
+ return holder
1312
+ return None
1313
+ d = self.root / STATE_DIR / "leases"
1314
+ for p in (d.glob("*.lock") if d.exists() else []):
1315
+ try:
1316
+ held = json.loads(p.read_text())
1317
+ except (json.JSONDecodeError, OSError):
1318
+ continue
1319
+ if held.get("run") != self.rid and \
1320
+ time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl)):
1321
+ return str(held.get("run"))
1322
+ return None
1323
+
1324
+ # -- board -------------------------------------------------------------
1325
+
1326
+ def all_holdings(self) -> dict[str, dict[str, Any]]:
1327
+ """Every key currently held, with who holds it and in which repository.
1328
+
1329
+ The repository matters: work spans several repos that are entered from one
1330
+ umbrella, so "r-alpha holds ASC-072" is only actionable once you know which
1331
+ checkout r-alpha is in."""
1332
+ now = time.time()
1333
+ if self.adapter.is_lease_authority:
1334
+ events, _ = self.events("claims")
1335
+ out: dict[str, dict[str, Any]] = {}
1336
+ for key in sorted({e["key"] for e in events}):
1337
+ holding = resolve_holding(events, key, now)
1338
+ if holding:
1339
+ out[key] = holding
1340
+ return out
1341
+ out = {}
1342
+ d = self.root / STATE_DIR / "leases"
1343
+ for p in sorted(d.glob("*.lock") if d.exists() else []):
1344
+ try:
1345
+ held = json.loads(p.read_text())
1346
+ except (json.JSONDecodeError, OSError):
1347
+ continue
1348
+ if now <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl)):
1349
+ out[p.stem] = {"run": str(held.get("run")), "repo": repo_name(),
1350
+ "ts": parse_iso(held.get("ts", ""))}
1351
+ return out
1352
+
1353
+ def all_holders(self) -> dict[str, str]:
1354
+ return {k: str(v["run"]) for k, v in self.all_holdings().items()}
1355
+
1356
+ def board(self) -> str:
1357
+ """The cross-repository view — identical whoever generates it.
1358
+
1359
+ Four repositories share one plane and every one of them may regenerate this
1360
+ page, so its content must not depend on who did. It once did: the header named
1361
+ the generating repo and the id-leak section read that repo's registers, so a
1362
+ submodule agent's run replaced the umbrella's board with a narrower one. Repo-
1363
+ local findings now live on their own page (`12 Repo — <name>`); only facts that
1364
+ are true from every checkout belong here.
1365
+ """
1366
+ events, bad = self.events("claims")
1367
+ total = max(len(events) + bad, 1)
1368
+ rows = [f"| `{k}` | {h['run']} | {h.get('repo') or '—'} | held |"
1369
+ for k, h in self.all_holdings().items()]
1370
+
1371
+ lines = [
1372
+ f"{GENERATED_MARKER} source={repo_name()}@{head_sha()} at={now_iso()} "
1373
+ "— edit in git, not here -->",
1374
+ "",
1375
+ "# Board — the coordination plane",
1376
+ "",
1377
+ "Every repository on this plane writes and reads this page. It carries only "
1378
+ "facts that are true from any of them.",
1379
+ "",
1380
+ f"- backend: `{self.adapter.name}` · lease authority: "
1381
+ f"**{'yes' if self.adapter.is_lease_authority else 'no'}**",
1382
+ f"- runs are recorded as **{'gated' if self.gated else 'ungated'}**",
1383
+ f"- unparseable log lines: {bad}/{total}"
1384
+ f"{' ⚠ over 2% — the log cannot be replayed reliably' if bad / total > 0.02 else ''}",
1385
+ "",
1386
+ "## Live leases",
1387
+ "",
1388
+ "| Key | Holder | Repo | State |",
1389
+ "|---|---|---|---|",
1390
+ ]
1391
+ lines += rows or ["| — | — | — | none held |"]
1392
+
1393
+ sig, _ = self.events("signals")
1394
+ if sig:
1395
+ lines += ["", "## Recent cross-repo signals", "",
1396
+ "| Dependency | State | By | Repo |", "|---|---|---|---|"]
1397
+ for ev in sig[-10:]:
1398
+ lines.append(f"| `{ev['key']}` | {ev.get('state','?')} | {ev['run']} "
1399
+ f"| {ev.get('repo','—')} |")
1400
+ return "\n".join(lines) + "\n"
1401
+
1402
+ def config_digest(self) -> str:
1403
+ """Identity of the configuration this snapshot describes.
1404
+
1405
+ Stamping the commit instead made the very first adoption look stale: the config
1406
+ is added in the same commit as the snapshot, so a commit-range diff always found
1407
+ a change. A content hash has no such boundary.
1408
+ """
1409
+ raw = (self.root / CONFIG_PATH).read_bytes()
1410
+ return hashlib.sha256(raw).hexdigest()[:12]
1411
+
1412
+ def setup_snapshot(self) -> str:
1413
+ """A snapshot of how THIS project is actually wired, generated from the config.
1414
+
1415
+ Written into the repository so every agent — and every human — reads the same
1416
+ description of the documentation pipeline before touching it, instead of
1417
+ inferring it from behaviour. Generated, never hand-written: a hand-written
1418
+ description of a configuration drifts from it, which is the failure this whole
1419
+ tool exists to surface.
1420
+ """
1421
+ cfg = self.cfg
1422
+ regs = cfg.get("idRegisters") or {}
1423
+ guarded = cfg.get("guardedFiles") or []
1424
+ gates = cfg.get("gates") or []
1425
+ mirror = cfg.get("mirror") or {}
1426
+ env_path = find_env_file(self.root)
1427
+ L = [
1428
+ f"{GENERATED_MARKER} source={repo_name()}@{head_sha()} "
1429
+ f"cfg={self.config_digest()} at={now_iso()} "
1430
+ "— regenerate with `agent_sync.py setup`, do not hand-edit -->",
1431
+ "",
1432
+ f"# How documentation and coordination work in {repo_name()}",
1433
+ "",
1434
+ "This file is **generated** from the live configuration. If it disagrees with",
1435
+ "what the tool does, the tool is right and this file is stale — regenerate it.",
1436
+ "",
1437
+ "## Two documentation sources",
1438
+ "",
1439
+ "| Source | Answers | Where |",
1440
+ "|---|---|---|",
1441
+ "| Git documents | *how it should be* — intent, decisions, contracts | this repository |",
1442
+ "| As-built record | *how it actually is* — what agents wrote, with commits | the coordination plane |",
1443
+ "",
1444
+ "Neither outranks the other; they answer different questions. **The gap between",
1445
+ "them is the finding.** Reconcile before starting a task and after finishing it.",
1446
+ "",
1447
+ "## This project's wiring",
1448
+ "",
1449
+ f"- backend: **{self.adapter.name}** · lease authority: "
1450
+ f"**{'yes' if self.adapter.is_lease_authority else 'NO — degraded'}** · runs recorded "
1451
+ f"**{'gated' if self.gated else 'ungated'}**",
1452
+ f"- lease TTL {cfg.get('leaseTtlSeconds', DEFAULT_TTL)}s, renewed every "
1453
+ f"{cfg.get('renewIntervalSeconds', DEFAULT_RENEW)}s",
1454
+ f"- credentials read from `{env_path.name if env_path else '(none found)'}`"
1455
+ f"{' in ' + str(env_path.parent.name) if env_path and env_path.parent != self.root else ''}"
1456
+ " — gitignored, never committed",
1457
+ "",
1458
+ "### Id registers — reserve before you write",
1459
+ "",
1460
+ ]
1461
+ if regs:
1462
+ L += ["| Register | File | Reserve with |", "|---|---|---|"]
1463
+ L += [f"| `{r}` | `{s['file']}` | `agent_sync.py reserve {r}` |" for r, s in sorted(regs.items())]
1464
+ L += ["", "Reading a *next free id* line is **not** reserving it — two agents read the same number."]
1465
+ else:
1466
+ L += ["None declared here. Ids live in the parent repository; reserve them there."]
1467
+
1468
+ L += ["", "### Guarded files — a live lease is required to write these", ""]
1469
+ L += ([f"- `{g}`" for g in guarded] or ["- none"])
1470
+ L += ["", "### Gates run before a change is considered done", ""]
1471
+ L += ([f"- `{g}`" for g in gates] or ["- none configured"])
1472
+
1473
+ L += ["", "### Mirrored into the plane (read-only rendering of git)", ""]
1474
+ L += ([f"- `{s}`" for s in (mirror.get("sources") or [])]
1475
+ if mirror.get("enabled") else ["- disabled"])
1476
+
1477
+ L += [
1478
+ "",
1479
+ "## What is written where, and what is never deleted",
1480
+ "",
1481
+ "| Information | Home | Lifetime |",
1482
+ "|---|---|---|",
1483
+ "| Decisions, specs, contracts, user-facing behaviour | git | permanent, append-only register |",
1484
+ "| What was actually built, with its commit | as-built log | permanent, append-only |",
1485
+ "| Cross-repo dependency state | signal log | permanent, append-only |",
1486
+ "| Who holds a task right now | claims log | expires by TTL |",
1487
+ "| Per-run narrative | that run's journal | permanent |",
1488
+ "| The board and these pages | generated | replaced on every regeneration |",
1489
+ "",
1490
+ "**Nothing in a log is edited or deleted.** A mistake is corrected by appending",
1491
+ "the correcting entry, because the logs are replayed in order and a deletion",
1492
+ "would silently rewrite a decision every other agent already read. A lease is",
1493
+ "released, never removed. A reserved id that is not used is returned with",
1494
+ "`release-id`, which appends — it does not erase.",
1495
+ "",
1496
+ "Generated pages are the exception: they are rewritten wholesale, and a page",
1497
+ "whose first line lost its generated marker is **refused**, not overwritten.",
1498
+ "",
1499
+ "## The cycle, per task",
1500
+ "",
1501
+ "```",
1502
+ "status → who else is working, and what changed while you were away",
1503
+ "reconcile → resolve every divergence BEFORE writing code",
1504
+ "acquire ID → take the lease; the claim tag in git is written through",
1505
+ " … work …",
1506
+ "record → what you ACTUALLY built, with the decision id and files",
1507
+ " … update the git documents in the same change …",
1508
+ "reconcile → check both sides again",
1509
+ "board → regenerate the shared view",
1510
+ "release ID → on every path, including failure",
1511
+ "```",
1512
+ "",
1513
+ "Full doctrine ships with the skill: `references/two-sources.md`,",
1514
+ "`references/lease-protocol.md`, `references/pipeline-binding.md`.",
1515
+ ]
1516
+ return "\n".join(L) + "\n"
1517
+
1518
+ def mirror(self, limit: int = 120) -> list[str]:
1519
+ """Render the configured git documents into the plane, one-way and stamped.
1520
+
1521
+ A rendering, never a source: each page carries the commit it was made from, and
1522
+ the drift gate compares that stamp with HEAD. It is not a place to edit — a page
1523
+ whose generated marker is gone is refused rather than overwritten.
1524
+ """
1525
+ cfg = self.cfg.get("mirror") or {}
1526
+ if not cfg.get("enabled"):
1527
+ return ["mirror: disabled in this project's config"]
1528
+
1529
+ files: list[Path] = []
1530
+ for source in cfg.get("sources") or []:
1531
+ q = self.root / source
1532
+ if q.is_file():
1533
+ files.append(q)
1534
+ elif q.is_dir():
1535
+ files += sorted(f for f in q.rglob("*.md") if f.is_file())
1536
+ files = sorted(set(files))
1537
+
1538
+ out: list[str] = []
1539
+ truncated = 0
1540
+ if len(files) > limit:
1541
+ truncated = len(files) - limit
1542
+ files = files[:limit]
1543
+
1544
+ sha = head_sha()
1545
+ for f in files:
1546
+ rel = f.relative_to(self.root)
1547
+ title = f"90 Mirror — {rel}"
1548
+ body = (f"{GENERATED_MARKER} source={repo_name()}:{rel}@{sha} at={now_iso()} "
1549
+ "— a rendering of git, edit the source there -->\n\n" + f.read_text())
1550
+ out.append(self.put_generated(title, body))
1551
+
1552
+ # A silent cap reads as "everything is mirrored" when it is not.
1553
+ if truncated:
1554
+ out.append(f"NOTE: {truncated} further file(s) not mirrored — raise the limit "
1555
+ "or narrow mirror.sources; they are absent, not up to date")
1556
+ return out
1557
+
1558
+ def mirror_drift(self) -> list[str]:
1559
+ """Mirror pages whose stamped commit is not this repository's HEAD.
1560
+
1561
+ The docstring beside `mirror` claimed this gate existed before any code did —
1562
+ prose asserting a check that was never written, which is worse than silence
1563
+ because a reader stops looking.
1564
+ """
1565
+ cfg = self.cfg.get("mirror") or {}
1566
+ if not cfg.get("enabled"):
1567
+ return []
1568
+ sha = head_sha()
1569
+ out: list[str] = []
1570
+ for oid in self.adapter.log_shards("90 Mirror — "):
1571
+ text = self.adapter.doc_get(oid)
1572
+ m = re.search(r"source=[^@]+@(\S+)", text.splitlines()[0] if text else "")
1573
+ if m and m.group(1) != sha:
1574
+ out.append(f"mirror page stamped {m.group(1)}, HEAD is {sha} — "
1575
+ "regenerate with `board --mirror`")
1576
+ return out
1577
+
1578
+ def setup_path(self) -> Path:
1579
+ configured = self.cfg.get("setupFile")
1580
+ if configured:
1581
+ return self.root / configured
1582
+ return self.root / ("docs/AGENT_SYNC.md" if (self.root / "docs").is_dir()
1583
+ else "AGENT_SYNC.md")
1584
+
1585
+ def repo_page(self) -> str:
1586
+ """Findings only this checkout can produce — registers it owns, its own history."""
1587
+ res_events, _ = self.events("reservations")
1588
+ lines = [
1589
+ f"{GENERATED_MARKER} source={repo_name()}@{head_sha()} at={now_iso()} "
1590
+ "— edit in git, not here -->",
1591
+ "",
1592
+ f"# {repo_name()}",
1593
+ "",
1594
+ f"- registers declared here: "
1595
+ f"{', '.join(sorted(self.cfg.get('idRegisters') or {})) or 'none — they live in the parent repository'}",
1596
+ f"- guarded files: {len(self.cfg.get('guardedFiles') or [])}",
1597
+ ]
1598
+ leaks = self._leaks(res_events)
1599
+ lines += ["", "## Reserved ids not found in git", ""]
1600
+ lines += ([f"- `{r}-{v:04d}` reserved by {run}" for r, v, run in leaks]
1601
+ or ["- none"])
1602
+
1603
+ findings = self.reconcile()
1604
+ lines += ["", "## Intent vs as-built", ""]
1605
+ lines += ([f"- **{f['kind']}** — {f['detail']}" for f in findings] or
1606
+ ["- no mechanical divergence found"])
1607
+ for n in getattr(self, "backlog", []):
1608
+ lines.append(f"- {n}")
1609
+ return "\n".join(lines) + "\n"
1610
+
1611
+ def _leaks(self, events: list[dict[str, str]]) -> list[tuple[str, int, str]]:
1612
+ out = []
1613
+ for reg, spec in (self.cfg.get("idRegisters") or {}).items():
1614
+ _b, _f, assignments = resolve_reservations(events, reg)
1615
+ path = self.root / spec["file"]
1616
+ text = path.read_text() if path.exists() else ""
1617
+ for run, value in assignments:
1618
+ if f"{reg}-{value:04d}" not in text:
1619
+ out.append((reg, value, run))
1620
+ return out
1621
+
1622
+ def put_generated(self, path: str, text: str) -> str:
1623
+ oid = self.adapter.tree_ensure(path)
1624
+ current = self.adapter.doc_get(oid)
1625
+ if current.strip() and not current.lstrip().startswith(GENERATED_MARKER):
1626
+ return (f"REFUSED: '{path}' was not written by agent-sync "
1627
+ "(no generated marker on line 1). Someone took it over; "
1628
+ "reporting instead of overwriting.")
1629
+ self.adapter.doc_put(oid, text)
1630
+ return f"wrote '{path}'"
1631
+
1632
+
1633
+ # --------------------------------------------------------------------------- init
1634
+
1635
+ ENV_TEMPLATE = """# agent-sync — identity lives here, shape lives in .claude/agent-sync.json
1636
+ # This file is gitignored on purpose. Never commit it, never paste its contents.
1637
+ AGENT_SYNC_BACKEND={backend}
1638
+ {extra}"""
1639
+
1640
+
1641
+ def cmd_init(args: argparse.Namespace) -> int:
1642
+ root = project_root()
1643
+ os.chdir(root)
1644
+ cfg_path = root / CONFIG_PATH
1645
+ backend = args.backend
1646
+
1647
+ if backend == "outline" and not args.url:
1648
+ raise Fail("--url is required for the outline backend "
1649
+ "(the instance URL, e.g. https://wiki.example.com)")
1650
+
1651
+ if cfg_path.exists() and not args.force:
1652
+ print(f"• {CONFIG_PATH} already exists — left untouched (use --force to replace)")
1653
+ else:
1654
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
1655
+ cfg_path.write_text(json.dumps(default_config(backend), indent=2) + "\n")
1656
+ print(f"✓ wrote {CONFIG_PATH}")
1657
+
1658
+ extra = ""
1659
+ if backend == "outline":
1660
+ extra = (f"AGENT_SYNC_OUTLINE_URL={args.url}\n"
1661
+ "AGENT_SYNC_OUTLINE_TOKEN=\n"
1662
+ "AGENT_SYNC_OUTLINE_COLLECTION=\n")
1663
+ env_path = root / ENV_FILE
1664
+ if env_path.exists() and not args.force:
1665
+ print(f"• {ENV_FILE} already exists — left untouched")
1666
+ else:
1667
+ env_path.write_text(ENV_TEMPLATE.format(backend=backend, extra=extra))
1668
+ os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
1669
+ print(f"✓ wrote {ENV_FILE} (mode 600)")
1670
+
1671
+ ensure_gitignored(root, str(ENV_FILE))
1672
+ ensure_gitignored(root, f"{STATE_DIR}/")
1673
+
1674
+ print()
1675
+ if backend == "outline":
1676
+ print("NEXT — two things only you can do:")
1677
+ print(f" 1. Create an API token in your Outline instance at {args.url}")
1678
+ print(" (Settings → API and access), then put it in this line of "
1679
+ f"{ENV_FILE}:")
1680
+ print(" AGENT_SYNC_OUTLINE_TOKEN=<paste it here>")
1681
+ print(f" 2. Load the file into your shell before running agents:")
1682
+ print(f" set -a && . ./{ENV_FILE} && set +a")
1683
+ print()
1684
+ print(" Then run `status` again — it will create the cloud layout and print "
1685
+ "the collection id to paste into AGENT_SYNC_OUTLINE_COLLECTION.")
1686
+ print()
1687
+ print(" The token is yours alone: do not paste it into a chat, a commit, "
1688
+ "or a command line.")
1689
+ 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.")
1692
+ return 0
1693
+
1694
+
1695
+ def default_config(backend: str) -> dict[str, Any]:
1696
+ return {
1697
+ "backend": backend,
1698
+ "leaseTtlSeconds": DEFAULT_TTL,
1699
+ "renewIntervalSeconds": DEFAULT_RENEW,
1700
+ "gated": True,
1701
+ "idRegisters": {},
1702
+ "guardedFiles": [],
1703
+ "claimTags": {},
1704
+ "gates": [],
1705
+ "mirror": {"enabled": False, "sources": []},
1706
+ }
1707
+
1708
+
1709
+ def ensure_gitignored(root: Path, entry: str) -> None:
1710
+ gi = root / ".gitignore"
1711
+ lines = gi.read_text().splitlines() if gi.exists() else []
1712
+ if any(line.strip() == entry for line in lines):
1713
+ print(f"• .gitignore already ignores {entry}")
1714
+ return
1715
+ header = "# agent-sync"
1716
+ with open(gi, "a") as fh:
1717
+ if lines and lines[-1].strip():
1718
+ fh.write("\n")
1719
+ if header not in lines:
1720
+ fh.write(f"{header}\n")
1721
+ fh.write(f"{entry}\n")
1722
+ print(f"✓ added {entry} to .gitignore")
1723
+
1724
+
1725
+ # --------------------------------------------------------------------------- status
1726
+
1727
+ def cmd_status(_args: argparse.Namespace) -> int:
1728
+ root = project_root()
1729
+ os.chdir(root)
1730
+ load_env_file(root)
1731
+ print(f"agent-sync {VERSION} — {repo_name()}@{head_sha()}")
1732
+
1733
+ if not (root / CONFIG_PATH).exists():
1734
+ print("\n✗ not initialised.")
1735
+ print("\nNEXT: run init. It asks nothing it can guess and writes nothing secret.")
1736
+ print(" agent_sync.py init --backend outline --url <instance-url>")
1737
+ print(" agent_sync.py init --backend fs # local, degraded, no credentials")
1738
+ return 1
1739
+
1740
+ s = Sync()
1741
+ ad = s.adapter
1742
+ print(f" backend : {ad.name}")
1743
+ print(f" lease authority: {'yes' if ad.is_lease_authority else 'NO — degraded'}")
1744
+ print(f" runs recorded : {'gated' if s.gated else 'UNGATED'}")
1745
+ print(f" run id : {s.rid}")
1746
+
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.")
1750
+
1751
+ if ad.name == "outline" and isinstance(ad, OutlineAdapter) and not ad.collection:
1752
+ print("\n✗ AGENT_SYNC_OUTLINE_COLLECTION is empty.")
1753
+ print("\nNEXT: create the container, then paste the id into "
1754
+ f"{ENV_FILE}:")
1755
+ print(" agent_sync.py bootstrap")
1756
+ return 1
1757
+
1758
+ try:
1759
+ held = s.held()
1760
+ except Fail as exc:
1761
+ print(f"\n✗ {exc}")
1762
+ return 1
1763
+ print(f" leases held : {', '.join(held) if held else 'none'}")
1764
+
1765
+ # Who else is in here, and what landed while this run was away. Without this a
1766
+ # lease only tells an agent it is blocked, never who by or on what.
1767
+ try:
1768
+ act = s.activity()
1769
+ except Fail as exc:
1770
+ print(f"\n⚠ could not read the coordination plane: {exc}")
1771
+ act = {"others": {}, "signals": [], "new_signals": []}
1772
+
1773
+ if act["others"]:
1774
+ print("\n Other runs working this project right now:")
1775
+ for key, h in sorted(act["others"].items()):
1776
+ where = h.get("repo") or "unknown repo"
1777
+ mine = " ← this repo" if where == repo_name() else ""
1778
+ print(f" · {h['run']} holds {key} in {where}{mine}")
1779
+ print(" Do not take these on. If one looks abandoned, its lease expires on its own.")
1780
+ else:
1781
+ print(" other runs : none holding anything")
1782
+
1783
+ if act["new_signals"]:
1784
+ print(f"\n New since you last looked ({len(act['new_signals'])}):")
1785
+ for ev in act["new_signals"][-6:]:
1786
+ print(f" · {ev['key']} → {ev.get('state', '?')} "
1787
+ f"(by {ev['run']}, {ev.get('repo', 'unknown repo')})")
1788
+ print(" A dependency that moved may unblock — or invalidate — what you were about to do.")
1789
+ elif act["signals"]:
1790
+ print(f" signals : {len(act['signals'])} recent, nothing new since you last looked")
1791
+
1792
+ claim_issues = s.claim_divergence()
1793
+ if claim_issues:
1794
+ print("\n Claim tags not written through:")
1795
+ for c in claim_issues:
1796
+ print(f" ! {c}")
1797
+
1798
+ try:
1799
+ drift = s.mirror_drift()
1800
+ except Fail:
1801
+ drift = []
1802
+ if drift:
1803
+ print(f"\n Mirror drift ({len(drift)} page(s)): regenerate with `board --mirror`")
1804
+
1805
+ if not pipeline_installed():
1806
+ print("\n✗ task-pipeline is not installed. agent-sync binds to its stages and")
1807
+ print(" will not improvise a substitute flow.")
1808
+ print("\nNEXT:\n npx sshlg-skills install")
1809
+ return 1
1810
+
1811
+ print("\nNEXT: acquire a lease before you touch a guarded file —")
1812
+ print(" agent_sync.py acquire <TASK-ID>")
1813
+ return 0
1814
+
1815
+
1816
+ def pipeline_installed() -> bool:
1817
+ home = Path.home()
1818
+ if list(home.glob(".claude/plugins/cache/task-pipeline/**/skills/task-pipeline/SKILL.md")):
1819
+ return True
1820
+ if (home / ".agents/skills/task-pipeline/SKILL.md").exists():
1821
+ return True
1822
+ return (home / ".claude/skills/task-pipeline/SKILL.md").exists()
1823
+
1824
+
1825
+ def cmd_bootstrap(_args: argparse.Namespace) -> int:
1826
+ load_env_file(project_root())
1827
+ ad = OutlineAdapter()
1828
+ if not ad.configured():
1829
+ raise Fail("set AGENT_SYNC_OUTLINE_URL and AGENT_SYNC_OUTLINE_TOKEN first")
1830
+ if ad.collection:
1831
+ print(f"collection already set: {ad.collection}")
1832
+ return 0
1833
+ name = f"agent-sync — {repo_name()}"
1834
+ data = ad._call("collections.create", {"name": name, "description":
1835
+ "Coordination plane for agent-sync. Generated pages "
1836
+ "are stamped; edit sources in git."})
1837
+ print(f"✓ created collection '{name}'")
1838
+ print(f"\nNEXT: put this in {ENV_FILE}:")
1839
+ print(f" AGENT_SYNC_OUTLINE_COLLECTION={data['id']}")
1840
+ return 0
1841
+
1842
+
1843
+ # --------------------------------------------------------------------------- cli
1844
+
1845
+ def cmd_acquire(args: argparse.Namespace) -> int:
1846
+ s = Sync()
1847
+ won, holder = s.acquire(args.key)
1848
+ if won:
1849
+ 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.")
1856
+ if not s.gated:
1857
+ print("⚠ ungated backend — this lease is advisory, not enforced")
1858
+ print("Remember: release it on every path, including failure.")
1859
+ return 0
1860
+ print(f"lost {args.key} — held by {holder or 'another run'}")
1861
+ return 1
1862
+
1863
+
1864
+ def cmd_renew(args: argparse.Namespace) -> int:
1865
+ Sync().renew(args.key)
1866
+ return 0
1867
+
1868
+
1869
+ def cmd_release(args: argparse.Namespace) -> int:
1870
+ Sync().release(args.key)
1871
+ print(f"released {args.key}")
1872
+ return 0
1873
+
1874
+
1875
+ def cmd_reserve(args: argparse.Namespace) -> int:
1876
+ value = Sync().reserve(args.register)
1877
+ print(f"{args.register}-{value:04d}")
1878
+ return 0
1879
+
1880
+
1881
+ def cmd_release_id(args: argparse.Namespace) -> int:
1882
+ Sync().release_id(args.register, args.value)
1883
+ print(f"released {args.register}-{args.value}")
1884
+ return 0
1885
+
1886
+
1887
+ def cmd_journal(args: argparse.Namespace) -> int:
1888
+ Sync().journal(" ".join(args.text))
1889
+ return 0
1890
+
1891
+
1892
+ def cmd_signal(args: argparse.Namespace) -> int:
1893
+ Sync().signal(args.dep, args.state)
1894
+ print(f"{args.dep} → {args.state}")
1895
+ return 0
1896
+
1897
+
1898
+ def cmd_guard(args: argparse.Namespace) -> int:
1899
+ """Exit 0 = allowed, 2 = denied. Any other code is non-blocking in Claude Code,
1900
+ so internal failures must also exit 2 rather than fail open."""
1901
+ try:
1902
+ allowed, reason = Sync().guard(args.path)
1903
+ except Fail as exc:
1904
+ print(f"agent-sync guard: {exc}", file=sys.stderr)
1905
+ return 2
1906
+ if allowed:
1907
+ print(reason)
1908
+ return 0
1909
+ print(f"agent-sync: {reason}", file=sys.stderr)
1910
+ return 2
1911
+
1912
+
1913
+ def cmd_board(args: argparse.Namespace) -> int:
1914
+ s = Sync()
1915
+ results = [s.put_generated("10 Board", s.board()),
1916
+ s.put_generated(f"12 Repo — {repo_name()}", s.repo_page())]
1917
+ if getattr(args, "mirror", False):
1918
+ results += s.mirror()
1919
+ for r in results:
1920
+ print(r)
1921
+ # A refusal must be visible to a gate, not just to a reader.
1922
+ return 1 if any(r.startswith("REFUSED") for r in results) else 0
1923
+
1924
+
1925
+ def cmd_record(args: argparse.Namespace) -> int:
1926
+ Sync().record(" ".join(args.text), decision=args.decision or "", files=args.files or "")
1927
+ print("recorded")
1928
+ return 0
1929
+
1930
+
1931
+ def cmd_reconcile(args: argparse.Namespace) -> int:
1932
+ """Mechanical divergence only. The semantic read is the agent's job, and the
1933
+ output says so rather than implying the check was complete."""
1934
+ s = Sync()
1935
+ if getattr(args, "set_baseline", False):
1936
+ stamped = s.set_baseline()
1937
+ for reg, top in stamped.items():
1938
+ print(f"baseline {reg} = {reg}-{top:04d} — ids after this must carry an as-built record")
1939
+ return 0
1940
+ findings = s.reconcile()
1941
+ print("Intent (git) vs as-built (coordination plane)\n")
1942
+ if not findings:
1943
+ print(" no mechanical divergence found")
1944
+ for f in findings:
1945
+ print(f" ! {f['kind']}\n {f['detail']}\n → {f['means']}")
1946
+ for n in getattr(s, "backlog", []):
1947
+ print(f" · {n}")
1948
+ print("\nThis compares ids, commits and presence. It does NOT judge whether the")
1949
+ print("built thing matches what the document describes — read both and decide.")
1950
+ print("Before starting: resolve divergence or record why it stands.")
1951
+ print("After finishing: update BOTH sides, then run this again.")
1952
+ return 1 if findings else 0
1953
+
1954
+
1955
+ REGISTER_HINTS = [
1956
+ (r"\*\*Next free ID:\*\*\s*`([A-Z]{2,4})-(\d+)`", "explicit next-free-id line"),
1957
+ (r"^#{2,4}\s+([A-Z]{2,4})-\d+\s+—", "id-prefixed headings"),
1958
+ ]
1959
+
1960
+ DOC_NAMES = ("DECISIONS", "OPEN_QUESTIONS", "ROADMAP", "WORKSTREAMS", "DEPENDENCIES",
1961
+ "BUILD_ORDER", "INDEX", "ADR", "TESTING")
1962
+
1963
+
1964
+ def cmd_adopt(_args: argparse.Namespace) -> int:
1965
+ """Inspect an existing project and PROPOSE a configuration.
1966
+
1967
+ Adoption is where a coordination tool most easily starts lying: guess a register
1968
+ wrong and every later check is confidently about the wrong file. So this reads the
1969
+ repository, shows what it found and what it could not decide, and prints a config
1970
+ for a human to approve. It writes nothing.
1971
+ """
1972
+ root = project_root()
1973
+ os.chdir(root)
1974
+ print(f"agent-sync {VERSION} — adopting {repo_name()}\n")
1975
+
1976
+ docs_dir = "docs" if (root / "docs").is_dir() else ""
1977
+ candidates: list[Path] = []
1978
+ for pattern in ("*.md", "docs/*.md", "docs/**/*.md", "doc/*.md"):
1979
+ candidates += [q for q in root.glob(pattern) if q.is_file()]
1980
+ candidates = sorted({q for q in candidates if ".git" not in q.parts})[:400]
1981
+
1982
+ registers: dict[str, dict[str, str]] = {}
1983
+ guarded: list[str] = []
1984
+ notes: list[str] = []
1985
+
1986
+ for q in candidates:
1987
+ rel = str(q.relative_to(root))
1988
+ try:
1989
+ text = q.read_text(errors="ignore")
1990
+ except OSError:
1991
+ continue
1992
+ m = re.search(REGISTER_HINTS[0][0], text)
1993
+ if m:
1994
+ registers[m.group(1)] = {
1995
+ "file": rel,
1996
+ "nextFreeIdPattern": r"\*\*Next free ID:\*\* `" + m.group(1) + r"-(\d{" + str(len(m.group(2))) + r"})`",
1997
+ }
1998
+ guarded.append(rel)
1999
+ continue
2000
+ if any(n in q.stem.upper() for n in DOC_NAMES):
2001
+ guarded.append(rel)
2002
+ ids = set(re.findall(r"\b([A-Z]{2,4})-\d{3,4}\b", text))
2003
+ if ids:
2004
+ notes.append(f"{rel}: carries ids {', '.join(sorted(ids)[:4])} but no "
2005
+ "\"Next free ID\" line — allocation cannot be reserved safely "
2006
+ "until one exists, or a pattern is written by hand")
2007
+
2008
+ gates = []
2009
+ for cmd, probe in (("bash scripts/check-docs.sh", "scripts/check-docs.sh"),
2010
+ ("python3 docs/ux/lint.py --strict", "docs/ux/lint.py"),
2011
+ ("npm test", "package.json"),
2012
+ ("pytest -q", "pyproject.toml")):
2013
+ if (root / probe).exists():
2014
+ gates.append(cmd)
2015
+
2016
+ sub = git("rev-parse", "--show-superproject-working-tree")
2017
+ if sub:
2018
+ notes.append(f"this is a submodule of {Path(sub).name} — declare ONLY this "
2019
+ "repository's registers; decisions belong to the parent")
2020
+ registers = {}
2021
+
2022
+ print("Found:")
2023
+ print(f" documents scanned : {len(candidates)}")
2024
+ print(f" id registers : {', '.join(registers) or 'none detected'}")
2025
+ print(f" registry files : {len(guarded)}")
2026
+ print(f" gates : {', '.join(gates) or 'none detected'}")
2027
+ print(f" setup snapshot : {'docs/AGENT_SYNC.md' if docs_dir else 'AGENT_SYNC.md'}")
2028
+ if notes:
2029
+ print("\nNeeds a human decision:")
2030
+ for n in notes:
2031
+ print(f" ! {n}")
2032
+
2033
+ proposed = default_config("outline")
2034
+ proposed["idRegisters"] = registers
2035
+ proposed["guardedFiles"] = sorted(set(guarded))
2036
+ proposed["gates"] = gates
2037
+ proposed["mirror"] = {"enabled": bool(docs_dir), "sources": [docs_dir] if docs_dir else []}
2038
+
2039
+ print("\nProposed .claude/agent-sync.json — review every line, then write it:\n")
2040
+ print(json.dumps(proposed, indent=2))
2041
+ print("\nNothing was written. Confirm the registers and guarded files with the operator")
2042
+ print("first: a register pointed at the wrong file makes every later check confidently")
2043
+ print("wrong. Then run `init`, paste this config, `reconcile --set-baseline`, and `setup`.")
2044
+ return 0
2045
+
2046
+
2047
+ DECISIONS_SEED = """# Decisions
2048
+
2049
+ Every settled decision about this project, append-only. A decision is any answer that
2050
+ shapes the product, architecture, scope, security, data or process — recorded here so it
2051
+ is never lost to a chat log.
2052
+
2053
+ **Reserve an id before you write one.** Reading the line below is not reserving it: two
2054
+ agents read the same number and both use it. Run `agent-sync reserve DEC`.
2055
+
2056
+ **Next free ID:** `DEC-0001`
2057
+
2058
+ ---
2059
+
2060
+ ## How to write one
2061
+
2062
+ ```
2063
+ ### DEC-0001 — a title that states the decision, not the topic
2064
+ - **Date:** YYYY-MM-DD · **Status:** Accepted
2065
+ - **Context:** what forced the decision, with evidence
2066
+ - **Decision:** what we do now, in numbered clauses
2067
+ - **Consequences / affects:** every document this changes — each MUST then cite this id
2068
+ - **Source:** where this came from
2069
+ ```
2070
+
2071
+ **Never edit a decision to change it.** Add a new one that names what it supersedes, and
2072
+ annotate the old entry's status line. The body of the old entry stays as history.
2073
+
2074
+ ---
2075
+ """
2076
+
2077
+ AGENTS_SEED = """# AGENTS.md — working protocol
2078
+
2079
+ **Read [`{snapshot}`]({snapshot}) first.** It is generated from the live configuration and
2080
+ states how documentation and coordination work here: which registers exist, which files
2081
+ need a lease, which gates run, what is written where, and what is never deleted.
2082
+
2083
+ ## Before you write anything
2084
+
2085
+ Several agents may work this repository at once.
2086
+
2087
+ 1. `agent-sync status` — who else is working, and what changed while you were away.
2088
+ 2. `agent-sync reconcile` — the git documents say how it *should* be, the as-built record
2089
+ says how it *is*. Resolve every divergence **before** writing code.
2090
+ 3. `agent-sync acquire <TASK-ID>` — the guarded registers refuse an unleased write.
2091
+ 4. Reserve any new id with `agent-sync reserve <REGISTER>` before writing it.
2092
+
2093
+ ## When you finish
2094
+
2095
+ 1. `agent-sync record` — what you actually built, with the decision id and the files.
2096
+ 2. Update the git documents in the **same** change.
2097
+ 3. `agent-sync reconcile` again, then `agent-sync board`.
2098
+ 4. `agent-sync release <TASK-ID>` — on every path, including failure.
2099
+
2100
+ ## The one rule that matters most
2101
+
2102
+ **No decision lives only in chat.** Record it in the decision register, propagate it to
2103
+ every document it affects, and commit referencing the id.
2104
+ """
2105
+
2106
+
2107
+ def cmd_scaffold(args: argparse.Namespace) -> int:
2108
+ """Create the documentation architecture a project needs to be coordinated.
2109
+
2110
+ Only what is absent, never a line over anything that exists. A tool that rewrites a
2111
+ project's own conventions on adoption is worse than one that does nothing, so this
2112
+ seeds the minimum — a register with an allocation line, and an agent protocol that
2113
+ points at the generated snapshot — and leaves every existing file alone.
2114
+ """
2115
+ root = project_root()
2116
+ os.chdir(root)
2117
+ docs = root / "docs" if (root / "docs").is_dir() or args.docs_dir else root
2118
+ snapshot = "docs/AGENT_SYNC.md" if docs != root else "AGENT_SYNC.md"
2119
+
2120
+ created, skipped = [], []
2121
+
2122
+ def seed(path: Path, body: str) -> None:
2123
+ if path.exists():
2124
+ skipped.append(str(path.relative_to(root)))
2125
+ return
2126
+ path.parent.mkdir(parents=True, exist_ok=True)
2127
+ path.write_text(body)
2128
+ created.append(str(path.relative_to(root)))
2129
+
2130
+ seed(docs / "DECISIONS.md", DECISIONS_SEED)
2131
+ seed(root / "AGENTS.md", AGENTS_SEED.format(snapshot=snapshot))
2132
+
2133
+ for c in created:
2134
+ print(f" + {c}")
2135
+ for s in skipped:
2136
+ print(f" · {s} already exists — untouched")
2137
+
2138
+ print()
2139
+ if created:
2140
+ print("Scaffolded. Now: `adopt` to see the config it implies, `init` to write it,")
2141
+ print("`reconcile --set-baseline` once, `setup` to generate the snapshot, `check`.")
2142
+ else:
2143
+ print("Nothing to scaffold — this project already has the files. Run `adopt`.")
2144
+ return 0
2145
+
2146
+
2147
+ def cmd_check(_args: argparse.Namespace) -> int:
2148
+ """Validate the whole setup, end to end, and refuse to call a broken one healthy.
2149
+
2150
+ Every item here failed for real at some point in this tool's own adoption. A glob
2151
+ that matches nothing, a register pattern that matches nothing, a gate command that
2152
+ does not exist, a snapshot nobody links — each looks like a working install and
2153
+ protects nothing.
2154
+ """
2155
+ root = project_root()
2156
+ os.chdir(root)
2157
+ load_env_file(root)
2158
+ problems: list[str] = []
2159
+ warn: list[str] = []
2160
+ ok: list[str] = []
2161
+
2162
+ cfg_path = root / CONFIG_PATH
2163
+ if not cfg_path.exists():
2164
+ print("✗ not initialised — run `adopt`, then `init`")
2165
+ return 1
2166
+ try:
2167
+ cfg = json.loads(cfg_path.read_text())
2168
+ except json.JSONDecodeError as exc:
2169
+ print(f"✗ {CONFIG_PATH} is not valid JSON: {exc}")
2170
+ return 1
2171
+ ok.append(f"config parses ({CONFIG_PATH})")
2172
+
2173
+ if cfg.get("backend") not in ("outline", "fs"):
2174
+ problems.append(f"backend '{cfg.get('backend')}' is not a known adapter")
2175
+ unknown = set(cfg) - {"$schema", "backend", "leaseTtlSeconds", "renewIntervalSeconds",
2176
+ "gated", "idRegisters", "guardedFiles", "claimTags", "gates",
2177
+ "mirror", "setupFile", "leaseBackend", "leaseRemote",
2178
+ "settleSeconds"}
2179
+ for k in sorted(unknown):
2180
+ problems.append(f"config key '{k}' is not in the schema — it will be ignored")
2181
+
2182
+ # Registers must exist AND their allocation pattern must actually match.
2183
+ regs = cfg.get("idRegisters") or {}
2184
+ for reg, spec in sorted(regs.items()):
2185
+ f = root / spec.get("file", "")
2186
+ if not f.exists():
2187
+ problems.append(f"register {reg}: file '{spec.get('file')}' does not exist")
2188
+ continue
2189
+ text = f.read_text()
2190
+ try:
2191
+ m = re.search(spec.get("nextFreeIdPattern", ""), text)
2192
+ except re.error as exc:
2193
+ problems.append(f"register {reg}: nextFreeIdPattern is not valid regex ({exc})")
2194
+ continue
2195
+ if not m:
2196
+ problems.append(f"register {reg}: nextFreeIdPattern matches nothing in "
2197
+ f"{spec['file']} — ids cannot be reserved, only guessed")
2198
+ else:
2199
+ ok.append(f"register {reg} allocates from {spec['file']} ({reg}-{m.group(1)})")
2200
+
2201
+ # A guard glob that matches nothing protects nothing.
2202
+ for pattern in (cfg.get("guardedFiles") or []):
2203
+ hits = [q for q in root.glob(pattern) if q.is_file()]
2204
+ if not hits:
2205
+ problems.append(f"guarded pattern '{pattern}' matches no file — it guards nothing")
2206
+ if cfg.get("guardedFiles"):
2207
+ ok.append(f"{len(cfg['guardedFiles'])} guarded pattern(s) declared")
2208
+ else:
2209
+ warn.append("no guarded files — nothing requires a lease in this repository")
2210
+
2211
+ for pattern, spec in (cfg.get("claimTags") or {}).items():
2212
+ files = [q for q in root.glob(pattern) if q.is_file()]
2213
+ if not files:
2214
+ problems.append(f"claimTags pattern '{pattern}' matches no file")
2215
+ continue
2216
+ if spec.get("mode") != "cell":
2217
+ problems.append(f"claimTags '{pattern}': mode must be 'cell'")
2218
+ continue
2219
+ if "cell" not in spec:
2220
+ problems.append(f"claimTags '{pattern}': no `cell` index — nothing to write")
2221
+ if "{holder}" not in (spec.get("held") or ""):
2222
+ problems.append(f"claimTags '{pattern}': `held` must contain {{holder}}, "
2223
+ "or the claim names nobody")
2224
+ if cfg.get("claimTags"):
2225
+ ok.append(f"{len(cfg['claimTags'])} claim-tag mapping(s) declared")
2226
+
2227
+ mode = cfg.get("leaseBackend") or "local"
2228
+ if mode not in ("local", "git"):
2229
+ problems.append(f"leaseBackend '{mode}' is not a known mode")
2230
+ elif mode == "git":
2231
+ remote = cfg.get("leaseRemote") or "origin"
2232
+ if not git("remote", "get-url", remote):
2233
+ problems.append(f"leaseBackend is 'git' but remote '{remote}' does not exist — "
2234
+ "the lease cannot be decided at all")
2235
+ else:
2236
+ ok.append(f"lease decided by git refs on '{remote}' — exclusive across machines")
2237
+ 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")
2240
+
2241
+ for cmd in (cfg.get("gates") or []):
2242
+ exe = cmd.split()[0]
2243
+ target = cmd.split()[1] if len(cmd.split()) > 1 else ""
2244
+ if target and not target.startswith("-") and "/" in target and not (root / target).exists():
2245
+ problems.append(f"gate '{cmd}': {target} does not exist")
2246
+ elif not shutil.which(exe):
2247
+ warn.append(f"gate '{cmd}': {exe} is not on PATH here")
2248
+ if cfg.get("gates"):
2249
+ ok.append(f"{len(cfg['gates'])} gate command(s) declared")
2250
+
2251
+ mirror = cfg.get("mirror") or {}
2252
+ if mirror.get("enabled"):
2253
+ for src in mirror.get("sources") or []:
2254
+ if not (root / src).exists():
2255
+ problems.append(f"mirror source '{src}' does not exist")
2256
+ if not mirror.get("sources"):
2257
+ problems.append("mirror is enabled with no sources — it renders nothing")
2258
+
2259
+ # Identity and reachability.
2260
+ env = find_env_file(root)
2261
+ if cfg.get("backend") == "outline":
2262
+ if env is None:
2263
+ problems.append(f"no {ENV_FILE} found here or in any parent — the backend "
2264
+ "cannot be reached, and every run silently degrades")
2265
+ else:
2266
+ ok.append(f"credentials file found at {env}")
2267
+ missing = [k for k in ("AGENT_SYNC_OUTLINE_URL", "AGENT_SYNC_OUTLINE_TOKEN")
2268
+ if not os.environ.get(k)]
2269
+ if missing:
2270
+ problems.append(f"{', '.join(missing)} is empty — runs will degrade to `fs`")
2271
+ else:
2272
+ try:
2273
+ OutlineAdapter().resolve_collection()
2274
+ ok.append("knowledge base reachable and the collection resolves")
2275
+ except Fail as exc:
2276
+ problems.append(f"knowledge base unreachable: {exc}")
2277
+
2278
+ # Ignore rules — a committed token is the one unrecoverable mistake here.
2279
+ gi = (root / ".gitignore").read_text() if (root / ".gitignore").exists() else ""
2280
+ for entry in (str(ENV_FILE), f"{STATE_DIR}/"):
2281
+ if entry not in gi:
2282
+ problems.append(f".gitignore does not cover '{entry}'")
2283
+ tracked = git("ls-files", str(ENV_FILE))
2284
+ if tracked:
2285
+ problems.append(f"{ENV_FILE} IS TRACKED BY GIT — it holds a token; remove it now")
2286
+
2287
+ # The snapshot, and whether anything points at it.
2288
+ snap = root / (cfg.get("setupFile") or
2289
+ ("docs/AGENT_SYNC.md" if (root / "docs").is_dir() else "AGENT_SYNC.md"))
2290
+ if not snap.exists():
2291
+ problems.append(f"no setup snapshot at {snap.relative_to(root)} — run `setup`")
2292
+ else:
2293
+ head = snap.read_text().splitlines()[0] if snap.read_text() else ""
2294
+ if GENERATED_MARKER not in head:
2295
+ problems.append(f"{snap.relative_to(root)} lost its generated marker — "
2296
+ "someone hand-edited it; regenerate or keep it out of the way")
2297
+ else:
2298
+ # Stale means "the configuration moved on". Comparing commits was wrong at
2299
+ # both boundaries: a snapshot is generated before the commit that carries it,
2300
+ # and the config is often added in that same commit. A content hash is exact.
2301
+ stamped = re.search(r"cfg=(\w+)", head)
2302
+ actual = hashlib.sha256((root / CONFIG_PATH).read_bytes()).hexdigest()[:12]
2303
+ if not stamped:
2304
+ warn.append("snapshot predates configuration stamping — regenerate with `setup`")
2305
+ elif stamped.group(1) != actual:
2306
+ problems.append("the configuration changed since the snapshot was "
2307
+ "generated — regenerate with `setup`")
2308
+ else:
2309
+ ok.append("setup snapshot present and describes the current configuration")
2310
+ linked = [f for f in ("AGENTS.md", "CLAUDE.md", "README.md", "CONTRIBUTING.md")
2311
+ if (root / f).exists() and snap.name in (root / f).read_text()]
2312
+ if linked:
2313
+ ok.append(f"snapshot linked from {', '.join(linked)}")
2314
+ else:
2315
+ problems.append("no agent instruction file links the snapshot — agents will "
2316
+ "not find it, and will infer the pipeline instead")
2317
+
2318
+ # Baselines: without one, reconcile cannot separate history from new work.
2319
+ if regs:
2320
+ try:
2321
+ s = Sync()
2322
+ ev, _ = s.events("asbuilt")
2323
+ based = {e["key"] for e in ev if e["op"] == "baseline"}
2324
+ for reg in regs:
2325
+ if reg not in based:
2326
+ warn.append(f"register {reg} has no as-built baseline — "
2327
+ "run `reconcile --set-baseline` once")
2328
+ except Fail:
2329
+ pass
2330
+
2331
+ for line in ok:
2332
+ print(f" ✓ {line}")
2333
+ for line in warn:
2334
+ print(f" ! {line}")
2335
+ for line in problems:
2336
+ print(f" ✗ {line}")
2337
+ print()
2338
+ if problems:
2339
+ print(f"{len(problems)} problem(s) — this setup is NOT healthy. Fix them before "
2340
+ "telling anyone the project is coordinated.")
2341
+ return 1
2342
+ print(f"setup healthy ({len(ok)} checks passed"
2343
+ + (f", {len(warn)} warning(s)" if warn else "") + ")")
2344
+ return 0
2345
+
2346
+
2347
+ def cmd_setup(_args: argparse.Namespace) -> int:
2348
+ s = Sync()
2349
+ path = s.setup_path()
2350
+ if path.exists():
2351
+ current = path.read_text()
2352
+ if current.strip() and not current.lstrip().startswith(GENERATED_MARKER):
2353
+ print(f"REFUSED: {path} exists and was not generated by agent-sync — "
2354
+ "reporting instead of overwriting", file=sys.stderr)
2355
+ return 1
2356
+ path.parent.mkdir(parents=True, exist_ok=True)
2357
+ path.write_text(s.setup_snapshot())
2358
+ print(f"wrote {path.relative_to(s.root)}")
2359
+ print("Commit it, and link it from the project's agent instructions so every agent "
2360
+ "reads the same description of the pipeline before touching it.")
2361
+ return 0
2362
+
2363
+
2364
+ def cmd_whoami(_args: argparse.Namespace) -> int:
2365
+ s = Sync()
2366
+ print(f"run {s.rid} · backend {s.adapter.name} · "
2367
+ f"{'gated' if s.gated else 'ungated'}")
2368
+ print(f"holds: {', '.join(s.held()) or 'nothing'}")
2369
+ return 0
2370
+
2371
+
2372
+ def build_parser() -> argparse.ArgumentParser:
2373
+ p = argparse.ArgumentParser(prog="agent_sync.py", description=__doc__.splitlines()[0])
2374
+ p.add_argument("--version", action="version", version=VERSION)
2375
+ sub = p.add_subparsers(dest="cmd", required=True)
2376
+
2377
+ i = sub.add_parser("init", help="ask where to store, write config and env file")
2378
+ i.add_argument("--backend", required=True, choices=["outline", "fs"])
2379
+ i.add_argument("--url", help="instance URL (required for outline)")
2380
+ i.add_argument("--force", action="store_true")
2381
+ i.set_defaults(fn=cmd_init)
2382
+
2383
+ sub.add_parser("status", help="inspect, repair, report, one next action").set_defaults(fn=cmd_status)
2384
+ sub.add_parser("bootstrap", help="create the cloud container").set_defaults(fn=cmd_bootstrap)
2385
+ sub.add_parser("whoami", help="this run and its leases").set_defaults(fn=cmd_whoami)
2386
+ sub.add_parser("setup", help="write the generated snapshot of how this project is wired").set_defaults(fn=cmd_setup)
2387
+ sub.add_parser("adopt", help="inspect an existing project and propose a config (writes nothing)").set_defaults(fn=cmd_adopt)
2388
+ sub.add_parser("check", help="validate the whole setup; non-zero if it is not healthy").set_defaults(fn=cmd_check)
2389
+ sc = sub.add_parser("scaffold", help="create the missing documentation architecture (never overwrites)")
2390
+ sc.add_argument("--docs-dir", action="store_true", help="put the register under docs/ even if it does not exist yet")
2391
+ sc.set_defaults(fn=cmd_scaffold)
2392
+ bd = sub.add_parser("board", help="regenerate the read-only board")
2393
+ bd.add_argument("--mirror", action="store_true",
2394
+ help="also render the configured git documents into the plane")
2395
+ bd.set_defaults(fn=cmd_board)
2396
+
2397
+ for name, fn, arg in (("acquire", cmd_acquire, "key"), ("release", cmd_release, "key")):
2398
+ q = sub.add_parser(name)
2399
+ q.add_argument(arg)
2400
+ q.set_defaults(fn=fn)
2401
+
2402
+ r = sub.add_parser("renew")
2403
+ r.add_argument("key", nargs="?")
2404
+ r.set_defaults(fn=cmd_renew)
2405
+
2406
+ rv = sub.add_parser("reserve", help="reserve the next id in a register")
2407
+ rv.add_argument("register")
2408
+ rv.set_defaults(fn=cmd_reserve)
2409
+
2410
+ ri = sub.add_parser("release-id", help="return an id you did not write to git")
2411
+ ri.add_argument("register")
2412
+ ri.add_argument("value")
2413
+ ri.set_defaults(fn=cmd_release_id)
2414
+
2415
+ rec = sub.add_parser("record", help="append what was ACTUALLY built")
2416
+ rec.add_argument("text", nargs="+")
2417
+ rec.add_argument("--decision", help="the id it implements, e.g. DEC-0216")
2418
+ rec.add_argument("--files", help="comma-separated paths actually changed")
2419
+ rec.set_defaults(fn=cmd_record)
2420
+
2421
+ rc = sub.add_parser("reconcile", help="intent (git) vs as-built (cloud)")
2422
+ rc.add_argument("--set-baseline", action="store_true",
2423
+ help="stamp today's ids as the pre-adoption backlog, once")
2424
+ rc.set_defaults(fn=cmd_reconcile)
2425
+
2426
+ j = sub.add_parser("journal")
2427
+ j.add_argument("text", nargs="+")
2428
+ j.set_defaults(fn=cmd_journal)
2429
+
2430
+ sg = sub.add_parser("signal")
2431
+ sg.add_argument("dep")
2432
+ sg.add_argument("state")
2433
+ sg.set_defaults(fn=cmd_signal)
2434
+
2435
+ g = sub.add_parser("guard", help="may this run write that path? exit 2 = no")
2436
+ g.add_argument("path")
2437
+ g.set_defaults(fn=cmd_guard)
2438
+
2439
+ return p
2440
+
2441
+
2442
+ def main(argv: list[str] | None = None) -> int:
2443
+ args = build_parser().parse_args(argv)
2444
+ try:
2445
+ return int(args.fn(args))
2446
+ except Fail as exc:
2447
+ print(f"agent-sync: {exc}", file=sys.stderr)
2448
+ return 1
2449
+ except KeyboardInterrupt:
2450
+ return 130
2451
+
2452
+
2453
+ if __name__ == "__main__":
2454
+ sys.exit(main())