@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.
- package/CHANGELOG.md +332 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/agent-sync.example.json +40 -0
- package/agent-sync.schema.json +144 -0
- package/bin/agent-sync.js +149 -0
- package/package.json +46 -0
- package/plugins/agent-sync/.claude-plugin/plugin.json +18 -0
- package/plugins/agent-sync/commands/agent-sync.md +16 -0
- package/plugins/agent-sync/hooks/_lib.sh +39 -0
- package/plugins/agent-sync/hooks/guard.sh +55 -0
- package/plugins/agent-sync/hooks/hooks.json +69 -0
- package/plugins/agent-sync/hooks/renew.sh +10 -0
- package/plugins/agent-sync/hooks/session-end.sh +14 -0
- package/plugins/agent-sync/hooks/session-start.sh +8 -0
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +372 -0
- package/plugins/agent-sync/skills/agent-sync/references/adapter-contract.md +80 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-fs.md +57 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-outline.md +103 -0
- package/plugins/agent-sync/skills/agent-sync/references/hooks.md +99 -0
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +145 -0
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +76 -0
- package/plugins/agent-sync/skills/agent-sync/references/roadmap.md +103 -0
- package/plugins/agent-sync/skills/agent-sync/references/two-sources.md +131 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +2454 -0
|
@@ -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())
|