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