@ssheleg/agent-sync 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ ## v1.15.0 — a claim tag outlived its lease, and no command reached it
2
+
3
+ **GitHub issue #5, filed 2026-08-17, reproduced verbatim at v1.14.0.** A board row shipped
4
+ in a published release reading `(claimed: r-6e62c4dab)` while the lease plane said
5
+ `leases held: none`. `release <key>` printed `released`, exited **0** and changed nothing;
6
+ `residue` said *nothing on disk*; `reconcile` found *no mechanical divergence*. Three
7
+ commands, none of which could reach the tag, because `write_claim(key, None)` restores the
8
+ cell for the key it holds and it held none.
9
+
10
+ This is #4 from the other side. #4 was a lease that outlived its run; this is a tag that
11
+ outlived its lease — and the second is worse, because an invisible lease invites a check
12
+ while a confident wrong answer does not. Closed the same way: **one notion of held,
13
+ consulted by both planes.** `_lease_holder` stays the single reader, memoised per command
14
+ so a report over a tagged board costs one `ls-remote` per *tagged row* in git mode rather
15
+ than one per board row. `orphan_claims()` classifies a tag `orphan` — the TTL has ended its
16
+ lease — or `disputed`, live under another run, which is reported and never touched.
17
+ `release` clears an orphan and names the run it belonged to. `status`, `residue` and
18
+ `reconcile` each report one, so the disagreement is visible to a gate rather than to a
19
+ diff.
20
+
21
+ Four more, each with its plant watched refusing:
22
+
23
+ - **`residue` read as a complete answer in git mode and could not be.** It walks
24
+ `.agent-sync/leases/*.lock`, which only the local plane writes, so a ref won on another
25
+ machine was invisible. It now prints `⚠ INCOMPLETE IN THIS MODE` with the `ls-remote`
26
+ command it does not run. The sweep itself is still open, and the row says so — a check
27
+ that cannot look must not read as one that looked.
28
+ - **A `local` lock recorded no host**, so residue could not tell a lock written here from
29
+ one written on another machine; 25 of 25 locks on this machine carried none. Both modes
30
+ write it now, with a fixture separating two machines.
31
+ - **The ledger described an artifact nobody shipped**: its newest section was headed
32
+ *(in tree, unreleased)* and quoted `PASS: agent-sync v1.13.0` while v1.14.0 was tagged,
33
+ in `package.json` and on npm. Guarded three ways.
34
+ - **Two divisors for one token budget.** The family's auditor measured the pack's body at
35
+ ~5084 tokens against a 5000 limit; this repo's own gate divided by 4 and passed at 4957.
36
+ One divisor now (3.9, the auditor's), and the body is under it at ~4683 after a split.
37
+
38
+ Also corrected: the board's *"14 of the 17 live in repositories this row must not touch"*
39
+ recomputes to **16 outside this checkout, 13 outside it and the umbrella**, with the `find`
40
+ that produces each.
41
+
42
+ **Found while doing it:** a plant had stopped planting. `the script path is prose only`
43
+ substituted a paragraph that had since been reflowed, so it changed nothing — and a no-op
44
+ plant still reported `detected` for a check that never ran. Re-anchored on the values the
45
+ check actually reads.
46
+
47
+ Self-test fixtures 43 → **51**; the claim-cell suite 9 → **16 cases**.
48
+
1
49
  ## v1.14.0 — expiry ended a lease and left the file, and every reader folded that away
2
50
 
3
51
  **`status` reported `leases held: none` over three expired locks in the directory it had
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Let concurrent coding agents share one project without colliding — leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.14.0",
4
+ "version": "1.15.0",
5
5
  "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.",
6
6
  "author": {
7
7
  "name": "ssheleg",
@@ -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.14.0"
7
+ version: "1.15.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -24,7 +24,6 @@ keep that true while several agents write at once.
24
24
  **1. The knowledge base never decides a lease.** It cannot: twelve concurrent appends to
25
25
  one Outline document returned twelve successes and left **three** lines. Exclusion comes
26
26
  from something with real compare-and-swap. The plane carries the record and nothing else.
27
- Full measurements in `references/lease-protocol.md`.
28
27
 
29
28
  **2. Know which lease you have, and say so.** `leaseBackend: "local"` is an atomic file
30
29
  create — exclusive between processes on one filesystem, **advisory across machines**.
@@ -38,14 +37,12 @@ round-trip. **Read `references/roadmap.md`** before configuring `claimTags` or c
38
37
  task; closing is a statement about the work and stays yours.
39
38
 
40
39
  **Work on a branch; the integration branch is somebody else's stable base.** `acquire`
41
- writes the claim through **only** there; on any other branch the holder stays in the
42
- coordination plane, where `status` shows it to every agent without anyone fetching your
43
- branch. Committed to a branch, a claim is invisible until the merge and turns the shared
44
- roadmap into a file two branches both edit. Land work with `merge`: conflicts computed by
45
- `git merge-tree` **before anything is touched**, named and refused if any, the merge
46
- recorded in `docs/MERGES.md` (recent days in full, older compacted on write), the `--key`
47
- lease released. `merges` tells the next agent what landed while it was away. **Read
48
- `references/branching.md`** before merging.
40
+ writes the claim through **only** there on any other branch the holder stays in the
41
+ coordination plane, where `status` shows it to every agent. Committed to a branch, a claim
42
+ is invisible until the merge and turns the shared roadmap into a file two branches both
43
+ edit. Land work with `merge`: conflicts computed **before** anything is touched, the merge
44
+ recorded in `docs/MERGES.md`, the `--key` lease released; `merges` says what landed while
45
+ you were away. **Read `references/branching.md`** before merging.
49
46
 
50
47
  **3. Hooks exist only in Claude Code.** Elsewhere nothing blocks a guarded edit: run
51
48
  `guard` yourself and record the run as `ungated`. Do not describe a project as protected
@@ -55,8 +52,7 @@ when it is not.
55
52
  what you wrote — Outline turns a `- ` bullet into `* `. Emit `- `, accept `-`/`*`/`+`,
56
53
  count anything entry-shaped that fails, and **fail loudly** past 2% unparseable. Reporting
57
54
  `lost` when the truth is *unreadable* names a holder who does not exist. Watch for a
58
- silent pre-filter: a `continue` before the regex hides bad lines from the counter built to
59
- expose them.
55
+ silent pre-filter: a `continue` before the regex hides bad lines from the counter.
60
56
 
61
57
  ## Bringing this into ANY project — the whole chain
62
58
 
@@ -70,19 +66,15 @@ setup → generate the snapshot that describes this project's wiring
70
66
  check → validate the whole thing; non-zero if it is not healthy
71
67
  ```
72
68
 
73
- **`check` is what makes the skill self-sufficient.** It refuses to call a setup healthy on
74
- a rule that protects nothing (a register, guard glob, claim pattern, gate or mirror source
69
+ **`check` is what makes the skill self-sufficient.** It refuses to call a setup healthy on a
70
+ rule that protects nothing (a register, guard glob, claim pattern, gate or mirror source
75
71
  pointing at what is not there), on missing credentials, on an env file **tracked by git** —
76
- the one unrecoverable mistake here — on a stale or unlinked snapshot, and on a register
77
- with no baseline. It names each one; every one failed for real during this tool's own
78
- adoption.
72
+ the one unrecoverable mistake here — on a stale snapshot, or on a register with no baseline.
73
+ It names each one; every one failed for real during this tool's own adoption. Run it after
74
+ adopting, after changing the config, and in CI.
79
75
 
80
- Run `check` after adopting, after changing the config, and in CI.
81
-
82
- **`scaffold` never overwrites.** It seeds a decision register with an allocation line and
83
- an `AGENTS.md` that points at the snapshot, and leaves every existing file untouched — a
84
- tool that rewrites a project's own conventions on adoption is worse than one that does
85
- nothing.
76
+ **`scaffold` never overwrites.** It seeds a decision register and an `AGENTS.md` pointing at
77
+ the snapshot, and leaves every existing file untouched.
86
78
 
87
79
  ## Existing project: start with `adopt`
88
80
 
@@ -95,15 +87,14 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" adopt
95
87
  ```
96
88
 
97
89
  Confirm the registers and guarded files with the operator first: a register pointed at the
98
- wrong file makes every later check confidently wrong, and a guarded list that misses a
99
- shared file leaves the one place collisions happen unprotected. In a submodule it declares
100
- no registers: decisions belong to the parent repository. Then take the chain above from
101
- `init`.
90
+ wrong file makes every later check confidently wrong, and a guarded list that misses a shared
91
+ file leaves the one place collisions happen unprotected. In a submodule it declares no
92
+ registers decisions belong to the parent. Then take the chain above from `init`.
102
93
 
103
94
  ## First command in a project: `init`
104
95
 
105
- **Never run anything else against an uninitialised project.** `init` is where the
106
- storage question gets asked and answered, once, and written down.
96
+ **Never run anything else against an uninitialised project.** `init` is where the storage
97
+ question gets asked and answered, once, and written down.
107
98
 
108
99
  **Ask the operator these two things in chat — do not guess, do not pick a default:**
109
100
 
@@ -127,15 +118,11 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend outline --url https://
127
118
  python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend fs
128
119
  ```
129
120
 
130
- `init` writes `.claude/agent-sync.json` (shape, committed), writes
131
- `.env.agent-sync` with the keys and an **empty** token line (identity, mode 600),
132
- adds `.env.agent-sync` and `.agent-sync/` to `.gitignore`, and then prints exactly
133
- what the operator must do themselves create the token in their own instance and
134
- paste it into that one line. It never overwrites an existing config or env file
135
- without `--force`.
136
-
137
- Relay those closing instructions to the operator verbatim. Getting the token into
138
- the file is their step, and the design depends on it staying theirs.
121
+ `init` writes `.claude/agent-sync.json` (shape, committed) and `.env.agent-sync` with an
122
+ **empty** token line (identity, mode 600), gitignores both, and prints the one step that is
123
+ the operator's creating the token and pasting it into that line. Never overwrites either
124
+ file without `--force`. **Relay those closing instructions verbatim**; the design depends on
125
+ the token staying theirs.
139
126
 
140
127
  ## Then, before every session
141
128
 
@@ -144,25 +131,23 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" status
144
131
  ```
145
132
 
146
133
  Idempotent. Inspects, repairs, reports, names exactly ONE next action — and carries
147
- `check`'s verdict, so the command every session runs and the one that validates the
148
- setup cannot give two answers about one project.
134
+ `check`'s verdict, so the command every session runs and the one that validates the setup
135
+ cannot give two answers about one project.
149
136
 
150
137
  **Read the two awareness sections it prints — they are the point, not decoration.**
151
138
 
152
- - **Other runs working this project right now.** Who holds what, this minute. Do not
153
- take those on, and do not "just look at" the files they cover. A lease you cannot
154
- see makes you blocked; a lease you can see makes you coordinated.
155
- - **New since you last looked.** Cross-repo dependency moves that landed while you
156
- were away. A dependency that moved may unblock what you planned or invalidate it.
157
- This list is watermarked per run, so it stays quiet until something actually
158
- changes; when it speaks, it matters.
139
+ - **Other runs working this project right now.** Who holds what, this minute. Do not take
140
+ those on, and do not "just look at" the files they cover.
141
+ - **New since you last looked.** Cross-repo dependency moves that landed while you were
142
+ away watermarked per run, so it stays quiet until something changes. A dependency that
143
+ moved may unblock what you planned, or invalidate it.
159
144
 
160
145
  An agent that skips this block will re-derive work someone else is doing and act on a
161
146
  dependency state that changed an hour ago.
162
147
 
163
- What else `status` decides: no credentials → degraded mode, reported, and it continues (a
164
- smaller mode, not an error); `task-pipeline` absent → it prints the install line and stops.
165
- Do not improvise a substitute flow — without those stages there is nothing to bind to.
148
+ What else `status` decides: no credentials → degraded mode, reported, and it continues;
149
+ `task-pipeline` absent → it prints the install line and stops. Do not improvise a substitute
150
+ flow — without those stages there is nothing to bind to.
166
151
 
167
152
  ```bash
168
153
  npx sshlg-skills install
@@ -187,37 +172,32 @@ npx sshlg-skills install
187
172
  | `guard <path>` | Answer whether this run may write that path. Exit 0 = yes, 2 = no |
188
173
  | `board` | Regenerate the shared board and this repo's page. `--mirror` also renders the configured git docs into the plane |
189
174
  | `whoami` | Print this run's id and its held leases |
190
- | `residue` | Expired locks still on disk: this run's spent ones, and the foreign or ambiguous ones it reports and never touches |
175
+ | `residue` | What a run left behind in BOTH planes: expired locks on disk, and claim tags with no live lease behind them |
191
176
  | `reap [KEY…]` | Clear only what this run **proves** it owns and has spent, then re-read the directory to confirm it went |
192
177
  | `setup` | Write the generated snapshot of how **this** project is wired, for agents to read |
193
178
  | `adopt` | Inspect an existing project and **propose** a config — writes nothing |
194
- | `merge` | Land this branch: local target fast-forwarded, conflicts checked **before** anything is touched, merge log written, the `--key` lease released. `--summary`, `--dry-run`, `--push` |
179
+ | `merge` | Land this branch: target fast-forwarded, conflicts checked **before** anything is touched, merge log written, the `--key` lease released. `--summary`, `--dry-run`, `--push` |
195
180
  | `merges` | What landed while you were on your branch. `--all` includes the compacted tail |
196
181
  | `check` | Validate the whole setup end to end. Non-zero when it is not healthy |
197
- | `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 |
198
- | `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 |
182
+ | `scaffold [--full]` | Create only what is missing, never a line over what exists. `--full` also seeds the question register, index, dependency board, data model and docs gate |
183
+ | `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at, nothing left held. `check` answers whether the project is wired right; this, whether you are done |
199
184
 
200
- `$SKILL_DIR` is this skill's own directory: `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
201
- under the Claude Code plugin, `~/.agents/skills/agent-sync` elsewhere. Resolve it once
202
- per session and reuse it — do not guess a path. Every command reads
203
- `.claude/agent-sync.json` from the project root and needs no arguments beyond those
204
- listed.
185
+ `$SKILL_DIR` is this skill's own directory: `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync` under
186
+ the Claude Code plugin, `~/.agents/skills/agent-sync` elsewhere. Resolve it once per session
187
+ and reuse it — do not guess. Every command reads `.claude/agent-sync.json` from the project
188
+ root and needs no arguments beyond those listed.
205
189
 
206
190
  ## One identity per session, and how it is decided
207
191
 
208
- A lease is only a lease if two agents get two identities. Ordering matters here and both ends have
209
- bitten: deriving the id from `CLAUDE_SESSION_ID` alone gave **one session two identities** — it
210
- acquired as one and was denied by its own guard as the other — and keeping one id per checkout gave
211
- **two sessions one identity**, which is worse. The second is silent: both sessions acquire, both are
212
- guarded, and `release` takes a lease the caller never had.
192
+ A lease is only a lease if two agents get two identities, and both ends have bitten:
193
+ `CLAUDE_SESSION_ID` alone gave **one session two identities** — it acquired as one and was denied by
194
+ its own guard as the other — and one id per checkout gave **two sessions one identity**, silently,
195
+ so `release` took a lease the caller never had.
213
196
 
214
197
  The order is: `AGENT_SYNC_RUN_ID` · `CLAUDE_SESSION_ID` · **the session that started this shell** ·
215
- shared.
216
-
217
- The third matters because a plain shell command has no session id and a hook does. So
218
- `SessionStart` stamps `.agent-sync/sessions/<CLI pid>` with the session it knows, and a later
219
- command finds itself by walking its own process ancestry to a stamped pid. Why that and not
220
- command-line parsing: `references/earned-rules.md`.
198
+ shared. The third exists because a plain shell command has no session id and a hook does, so
199
+ `SessionStart` stamps `.agent-sync/sessions/<CLI pid>` and a later command walks its own process
200
+ ancestry to it. Why that and not command-line parsing: `references/earned-rules.md`.
221
201
 
222
202
  When none of the four can be established the run says so — *"this identity is shared with any other
223
203
  session in this checkout"* — rather than presenting a shared entry as separation.
@@ -235,6 +215,11 @@ TTL expires, and the next agent cannot tell "in progress" from "crashed an hour
235
215
  claim is the tag in git, written through by `acquire` and cleared by `release`. One fact,
236
216
  one home — do not invent a third place that records ownership.
237
217
 
218
+ **One notion of held, consulted by both planes.** The TTL ends the lease, so a tag naming a
219
+ run whose lease has ended is residue: reported by `status`, `residue` and `reconcile`, and
220
+ cleared by `release <KEY>`, which says whose it was. A tag whose lease is still live is
221
+ never touched by another run.
222
+
238
223
  **Read `references/lease-protocol.md`** before changing acquisition, expiry, stealing or
239
224
  id allocation.
240
225
 
@@ -246,65 +231,59 @@ The config lists registry files several agents write. Before editing one:
246
231
  python3 "$SKILL_DIR/scripts/agent_sync.py" guard docs/DECISIONS.md
247
232
  ```
248
233
 
249
- **Exit 2 is about *this run*: it holds no lease** — not that somebody else holds that
250
- file. One lease covers every guarded file; hold one or write none. A denial names the
251
- other run **and its key**, because "r-x holds a lease" beside a path gets repeated as
252
- "r-x holds this file". Do not edit anyway, and do not "just fix one line" — a clobbered
253
- decision looks exactly like a decision.
234
+ **Exit 2 is about *this run*: it holds no lease** — not that somebody else holds that file.
235
+ One lease covers every guarded file; hold one or write none. A denial names the other run
236
+ **and its key**, because "r-x holds a lease" beside a path gets repeated as "r-x holds this
237
+ file". Do not edit anyway, and do not "just fix one line" — a clobbered decision looks exactly
238
+ like a decision.
254
239
 
255
240
  Claude Code's `PreToolUse` hook runs this for you. Elsewhere nothing does.
256
241
 
257
242
  ## Reserving an id
258
243
 
259
- Reading a "Next free ID" line is not reserving it — two agents read the same number and
260
- both use it.
244
+ Reading a "Next free ID" line is not reserving it — two agents read the same number and both
245
+ use it.
261
246
 
262
247
  ```bash
263
248
  python3 "$SKILL_DIR/scripts/agent_sync.py" reserve DEC # → DEC-0216
264
249
  ```
265
250
 
266
- Allocation is positional over the **merged** log — every shard, never just this run's —
267
- so every agent computes the same answer. Reserved and not written to git? `release-id`
268
- it, or the number is a hole the board reports as a leak.
251
+ Allocation is positional over the **merged** log — every shard, never just this run's — so
252
+ every agent computes the same answer. Reserved and not written to git? `release-id` it, or the
253
+ number is a hole the board reports as a leak.
269
254
 
270
255
  ## Nothing in a log is ever edited or deleted
271
256
 
272
- Logs are **replayed in order**, so an edit silently rewrites a conclusion other agents
273
- already acted on. Correct by **appending**: release a lease, `release-id` an unused id,
274
- supersede a wrong as-built entry with a later one. Generated pages are the only
275
- exception, and one that lost its `agent-sync:generated` marker is **refused**, not
276
- overwritten. Lifetimes: `references/two-sources.md`.
257
+ Logs are **replayed in order**, so an edit silently rewrites a conclusion other agents already
258
+ acted on. Correct by **appending**: release a lease, `release-id` an unused id, supersede a
259
+ wrong as-built entry with a later one. Generated pages are the only exception. Lifetimes and
260
+ the generated-object contract: `references/two-sources.md`.
277
261
 
278
262
  ## Two documentation sources, and the duty to reconcile them
279
263
 
280
- Git docs answer **how it should be**; the as-built record answers **how it actually
281
- is**. Neither outranks the other, because they answer different questions and
282
- **the gap between them is the finding**, not a defect.
264
+ Git docs answer **how it should be**; the as-built record answers **how it actually is**.
265
+ Neither outranks the other, and **the gap between them is the finding**, not a defect.
283
266
 
284
267
  The duty runs at both ends of a task: `reconcile` and resolve every divergence before
285
- starting, then `record` and `reconcile` again after finishing. Building on an
286
- unresolved divergence is writing code against a system that does not exist.
268
+ starting, then `record` and `reconcile` again after finishing. Building on an unresolved
269
+ divergence is writing code against a system that does not exist.
287
270
 
288
- **The trap: `reconcile` is mechanical and refuses to judge** whether the built thing
289
- matches the document — it compares ids, commits and presence. That reading is yours,
290
- and treating its green as agreement is how a divergence survives both ends.
271
+ **The trap: `reconcile` is mechanical and refuses to judge** whether the built thing matches
272
+ the document — it compares ids, commits, presence and claim tags. That reading is yours, and
273
+ treating its green as agreement is how a divergence survives both ends.
291
274
 
292
- Every project also carries a **generated snapshot** of its own wiring (`setup`) — commit
293
- it and link it from the agent instructions, so agents read the pipeline instead of
294
- inferring it.
275
+ Every project also carries a **generated snapshot** of its own wiring (`setup`) — commit it
276
+ and link it from the agent instructions, so agents read the pipeline instead of inferring it.
295
277
 
296
- **Read `references/two-sources.md`** before the first reconcile, and whenever deciding
297
- which side a document belongs on: it carries what each side is for, what `reconcile`
298
- decides, why the check is a ratchet, where a document belongs, and why nothing is
299
- deleted.
278
+ **Read `references/two-sources.md`** before the first reconcile, and whenever deciding which
279
+ side a document belongs on.
300
280
 
301
281
  ## Binding to task-pipeline
302
282
 
303
- This skill supplies stages; the names are `task-pipeline`'s own.
304
-
305
- Five of the eleven stages carry an ordering rule: **0** `acquire` before the brief is
306
- committed; **1** `reconcile` before writing code; **3** `reserve` every id before it
307
- reaches git; **9** the main write point; **10** `merge` or `release` every lease.
283
+ This skill supplies stages; the names are `task-pipeline`'s own. Five of the eleven stages
284
+ carry an ordering rule: **0** `acquire` before the brief is committed; **1** `reconcile` before
285
+ writing code; **3** `reserve` every id before it reaches git; **9** the main write point;
286
+ **10** `merge` or `release` every lease.
308
287
 
309
288
  **Read `references/pipeline-binding.md`** when wiring `pipeline.json` — it holds the
310
289
  per-stage reasoning, the `skills[]` entries, what must be guarded, and the gate
@@ -326,41 +305,17 @@ the collection id to paste beside it. Found here, or in a superproject, or where
326
305
  set -a && . ./.env.agent-sync && set +a
327
306
  ```
328
307
 
329
- Never write a host name or token into the config, a test, an example or a commit. Do not
330
- handle a token value, echo it, or pass it in `argv`. If the operator offers one in chat,
331
- tell them to put it in that file instead.
308
+ Never write a host name or token into the config, a test, an example or a commit; never
309
+ handle, echo or `argv`-pass a token value. Offered one in chat, point at that file instead.
332
310
 
333
311
  **A submodule's config declares only its own registers.** Cross-repository facts belong to
334
312
  the parent; a service repo listing the parent's decision register is a config defect.
335
313
 
336
- ## Backends
337
-
338
- | Backend | Read when |
339
- |---|---|
340
- | `outline` | `references/backend-outline.md` — before any Outline call |
341
- | `fs` | `references/backend-fs.md` — local files, no shared awareness |
342
-
343
- **Read `references/adapter-contract.md` before adding a backend** — six primitives, the
344
- capability flags, and a degradation path that must be honest.
345
-
346
- ## Generated objects
347
-
348
- The board and the mirror are machine-written. Their first line is
349
-
350
- ```
351
- <!-- agent-sync:generated source=<repo>@<sha> at=<iso8601> — edit in git, not here -->
352
- ```
353
-
354
- A write to an object missing that marker is refused, not forced. If a human took over a
355
- generated page, report it and stop.
356
-
357
- The mirror is a **rendering** of git, stamped with the source commit. It has no
358
- authority. When its stamp and `HEAD` disagree, the board gate fails — that is
359
- drift, not a formatting problem.
360
-
361
314
  ## Non-negotiables
362
315
 
363
316
  - Append, read back, then act. Never rewrite a coordination document.
317
+ - A generated object without its `agent-sync:generated` marker is **refused**, not
318
+ overwritten — a human took it over. Contract: `references/two-sources.md`.
364
319
  - `release` what you `acquire`, on every path including failure.
365
320
  - A run reports what it leaves behind: expiry ends a lease, not the file — `residue`.
366
321
  - Credentials never reach `argv`, a log line, or the repository.
@@ -375,16 +330,15 @@ Each file is loaded on its own trigger, not by default.
375
330
 
376
331
  | File | Read it when |
377
332
  |---|---|
378
- | `references/adapter-contract.md` | adding or auditing a knowledge backend |
333
+ | `references/adapter-contract.md` | adding or auditing a knowledge backend — six primitives, the capability flags, an honest degradation path |
379
334
  | `references/lease-protocol.md` | changing acquisition, expiry, stealing or id allocation |
380
335
  | `references/backend-outline.md` | making any Outline API call, or debugging one |
381
336
  | `references/backend-fs.md` | running without a cloud backend, or explaining degraded mode |
382
337
  | `references/pipeline-binding.md` | wiring `pipeline.json`, or adding a stage hook |
383
338
  | `references/hooks.md` | installing, debugging or removing the Claude Code hooks |
384
- | `references/two-sources.md` | before the first reconcile, or when deciding where a document belongs |
339
+ | `references/two-sources.md` | before the first reconcile, deciding where a document belongs, or writing a generated object |
385
340
  | `references/roadmap.md` | configuring `claimTags`, taking or closing a task, or re-planning a board |
386
341
  | `references/branching.md` | starting work that will produce commits, merging a branch, or asking what landed while you were away |
387
342
  | `references/earned-rules.md` | asking why identity resolves the way it does, or why `finish` exists |
388
343
 
389
- If this copy arrived without `references/`, fetch them from
390
- `https://raw.githubusercontent.com/ssheleg/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
344
+ Missing `references/`? Fetch from `https://raw.githubusercontent.com/ssheleg/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
@@ -130,6 +130,16 @@ Generated pages are the exception, and a narrow one: they are rewritten wholesal
130
130
  page whose first line has lost its `agent-sync:generated` marker is **refused** rather
131
131
  than overwritten, because a human took it over.
132
132
 
133
+ **The marker itself.** The board and the mirror are machine-written, and their first line is
134
+
135
+ ```
136
+ <!-- agent-sync:generated source=<repo>@<sha> at=<iso8601> — edit in git, not here -->
137
+ ```
138
+
139
+ A write to an object missing it is refused, not forced; report the takeover and stop. The
140
+ mirror is a **rendering** of git stamped with the source commit, and has no authority — when
141
+ its stamp and `HEAD` disagree the board gate fails, and that is drift, not formatting.
142
+
133
143
  **Growth.** These logs are small — one line per event — so rotation is not urgent. When a
134
144
  log does need trimming, archive the whole document and start a fresh one with a `base`
135
145
  line carrying the current allocation state. Never delete lines from a live log to shrink
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- VERSION = "1.14.0"
36
+ VERSION = "1.15.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -847,6 +847,34 @@ REAPABLE = "reapable"
847
847
  FOREIGN = "foreign"
848
848
  AMBIGUOUS = "ambiguous"
849
849
 
850
+ # The same three-way split for the OTHER plane: a claim tag written into a registry file.
851
+ # `held` there means the lease the tag names is still inside its TTL; `orphan` means it is
852
+ # not, and `disputed` means the lease is live under a different run than the tag names.
853
+ TAG_HELD = "held"
854
+ ORPHAN = "orphan"
855
+ DISPUTED = "disputed"
856
+
857
+ DEFAULT_CLAIM_TEMPLATE = "{prev} (claimed: {holder})"
858
+
859
+
860
+ def claim_marker_re(template: str) -> "re.Pattern[str] | None":
861
+ """A regex for this template's claim marker, capturing the run it names.
862
+
863
+ `write_claim` builds the marker by emptying `{prev}` and substituting the run id. Read
864
+ back, the run id is the one part not known in advance: it is the capture and everything
865
+ around it is literal, so one function defines the marker in both directions instead of
866
+ two spellings that can disagree.
867
+
868
+ `None` for a template with no `{holder}`. Such a marker names no owner, and a tag whose
869
+ owner cannot be read must never be cleared on somebody's behalf — the same rule
870
+ `classify_lock` applies to a lock that records no run.
871
+ """
872
+ marker = template.replace("{prev}", "").strip()
873
+ if "{holder}" not in marker:
874
+ return None
875
+ head, _, tail = marker.partition("{holder}")
876
+ return re.compile(re.escape(head) + r"(?P<holder>[^|\s]+)" + re.escape(tail))
877
+
850
878
 
851
879
  def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
852
880
  repo: str, host: str, default_ttl: int, at: float) -> dict[str, Any]:
@@ -872,8 +900,8 @@ def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
872
900
  session, and a shell with no session id is served one shared entry whose own
873
901
  docstring says the identity "is shared with any other session in this checkout".
874
902
  Under that key a matching run id proves nothing, so it does not license a delete.
875
- - its `repo` is this checkout, and its `host` — written by the git lease mode, absent
876
- in local mode, where the lease is machine-local by construction — is this machine.
903
+ - its `repo` is this checkout, and its `host` — written by both lease modes since
904
+ AS-03, absent only in locks taken before it — is this machine.
877
905
 
878
906
  Everything else is reported and left alone: `foreign` where it demonstrably belongs to
879
907
  somebody else, `ambiguous` where the question cannot be answered at all. In doubt the
@@ -1018,6 +1046,7 @@ class Sync:
1018
1046
  self.rid = run_id(self.root)
1019
1047
  self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
1020
1048
  self._identity: tuple[str, str] | None = None
1049
+ self._holders: dict[str, str | None] = {}
1021
1050
 
1022
1051
  @property
1023
1052
  def gated(self) -> bool:
@@ -1284,8 +1313,14 @@ class Sync:
1284
1313
  return won, holder
1285
1314
 
1286
1315
  lock = self._local_lock(key)
1316
+ # `host` is written in BOTH lease modes (AS-03). It used to be the git mode's alone,
1317
+ # on the reasoning that a `local` lease is machine-local by construction — but the
1318
+ # *file* is not: a checkout on a synced or shared directory is read by two machines,
1319
+ # and `classify_lock` consumes `host` to decide `foreign`. Without it the classifier
1320
+ # had one fewer way to refuse, on 25 of the 25 locks this family had on disk
1321
+ # (2026-08-20). Absent stays legal: locks written before this line exist.
1287
1322
  payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
1288
- "repo": repo_name()})
1323
+ "repo": repo_name(), "host": platform.node()})
1289
1324
 
1290
1325
  if lock.exists():
1291
1326
  try:
@@ -1593,6 +1628,87 @@ class Sync:
1593
1628
  return {"reaped": reaped, "remaining": remaining, "refused": refused,
1594
1629
  "left": [e for e in before if e["state"] in (FOREIGN, AMBIGUOUS)]}
1595
1630
 
1631
+ def claim_tags_on_disk(self) -> list[dict[str, Any]]:
1632
+ """Every claim tag written into a registry file, with the run each one names.
1633
+
1634
+ The enumerating read for the OTHER plane, and it did not exist. `held()`,
1635
+ `_lease_holder()` and `residue()` all answer from the lease; nothing answered from
1636
+ the board — so a tag whose lease had ended was reported by no command at all.
1637
+ `release` printed success and changed nothing (`write_claim` had no saved cell to
1638
+ undo), `residue` said "nothing on disk", `status` said "leases held: none", and
1639
+ `reconcile` never mentioned it. Filed as ssheleg/agent-sync#5 and reproduced
1640
+ verbatim at 1.14.0.
1641
+ """
1642
+ out: list[dict[str, Any]] = []
1643
+ for pattern, spec in (self.cfg.get("claimTags") or {}).items():
1644
+ if spec.get("mode") != "cell":
1645
+ continue
1646
+ rx = claim_marker_re(spec.get("held") or DEFAULT_CLAIM_TEMPLATE)
1647
+ if rx is None:
1648
+ continue
1649
+ idx = int(spec.get("cell", -1))
1650
+ for path in sorted(glob_files(self.root, pattern)):
1651
+ if not path.is_file():
1652
+ continue
1653
+ try:
1654
+ lines = path.read_text().splitlines()
1655
+ except OSError:
1656
+ continue
1657
+ rel = str(path.relative_to(self.root))
1658
+ for n, line in enumerate(lines, 1):
1659
+ cells = self._row_cells(line)
1660
+ if not cells or not -len(cells) <= idx < len(cells):
1661
+ continue
1662
+ m = rx.search(cells[idx])
1663
+ if not m:
1664
+ continue
1665
+ out.append({"key": cells[0].strip(), "holder": m.group("holder"),
1666
+ "file": rel, "line": n, "cell": cells[idx].strip()})
1667
+ return out
1668
+
1669
+ def _holder_of(self, key: str) -> str | None:
1670
+ """`_lease_holder`, memoised for the length of one command.
1671
+
1672
+ One notion of held, and only one reader of it. Memoised because in git mode that
1673
+ reader is an `ls-remote`: a report over a board with several tags would otherwise
1674
+ pay a network round-trip per row. The bound is the number of TAGGED rows, not the
1675
+ size of the board — an untagged row is never looked up.
1676
+ """
1677
+ if key not in self._holders:
1678
+ self._holders[key] = self._lease_holder(key)
1679
+ return self._holders[key]
1680
+
1681
+ def orphan_claims(self) -> list[dict[str, Any]]:
1682
+ """Claim tags with no live lease behind them — one notion of held, both planes.
1683
+
1684
+ The TTL is the contract. A tag is a claim about a lease, so once `_lease_holder` —
1685
+ the single reader every other command already trusts — answers `None`, the tag is
1686
+ residue in the registry exactly as an expired lock is residue on disk, and is
1687
+ reported the same way.
1688
+
1689
+ A tag naming a run that still holds the lease is not residue and never appears
1690
+ here. A tag naming one run while the live lease belongs to another is `disputed`:
1691
+ reported, never cleared, because clearing it would edit a registry under a run
1692
+ that is still working.
1693
+ """
1694
+ out: list[dict[str, Any]] = []
1695
+ for e in self.claim_tags_on_disk():
1696
+ holder = self._holder_of(e["key"])
1697
+ if holder == e["holder"]:
1698
+ continue
1699
+ e = dict(e)
1700
+ if holder is None:
1701
+ e["state"] = ORPHAN
1702
+ e["why"] = (f"the tag names run {e['holder']}, and the {self.lease_mode} "
1703
+ f"lease plane holds no live lease for `{e['key']}` — the TTL "
1704
+ "has already ended it")
1705
+ else:
1706
+ e["state"] = DISPUTED
1707
+ e["why"] = (f"the tag names run {e['holder']} while the live lease for "
1708
+ f"`{e['key']}` is held by {holder}")
1709
+ out.append(e)
1710
+ return out
1711
+
1596
1712
  def held(self) -> list[str]:
1597
1713
  d = self.root / STATE_DIR / "leases"
1598
1714
  mine = []
@@ -2015,7 +2131,40 @@ class Sync:
2015
2131
  state.setdefault(key, {})[str(rel)] = current
2016
2132
  else:
2017
2133
  if saved is None:
2018
- continue # nothing of ours to undo
2134
+ # NOT "nothing to undo" that reading is ssheleg/agent-sync#5. The
2135
+ # state file is this run's memory of what it overwrote, and a tag
2136
+ # outlives it routinely: written by a run that died, by a session on
2137
+ # another machine, or with `.agent-sync/` wiped between the acquire and
2138
+ # the release. `release` then printed success, exited 0, and left
2139
+ # `(claimed: r-…)` on the board with nothing behind it — a claim no
2140
+ # command could reach.
2141
+ #
2142
+ # The TTL decides, exactly as it decides for a lock file: the lease
2143
+ # plane is asked who holds the key, and only a tag with no live lease
2144
+ # behind it is cleared. A live lease is somebody working, and its tag
2145
+ # is left alone whoever runs this.
2146
+ template = spec.get("held") or DEFAULT_CLAIM_TEMPLATE
2147
+ rx = claim_marker_re(template)
2148
+ m = rx.search(current) if rx else None
2149
+ if m is None:
2150
+ continue # no tag here either, so nothing to undo
2151
+ whose = m.group("holder")
2152
+ live = self._holder_of(key)
2153
+ if live is not None and live != self.rid:
2154
+ notes.append(
2155
+ f"{rel}: `{key}` carries a claim tag naming {whose}, and the "
2156
+ f"lease is live under {live} — left alone")
2157
+ continue
2158
+ stripped = (current[:m.start()] + current[m.end():]).strip()
2159
+ cells[idx] = f" {stripped} " if stripped else " "
2160
+ notes.append(
2161
+ f"{rel}: cleared an orphaned claim tag on `{key}` — it named run "
2162
+ f"{whose}, and no live lease stands behind it (ssheleg/agent-sync#5)")
2163
+ lines[i] = row_prefix + "|" + "|".join(cells) + row_suffix
2164
+ tmp = path.with_suffix(path.suffix + ".agent-sync.tmp")
2165
+ tmp.write_text("".join(lines))
2166
+ tmp.replace(path)
2167
+ continue
2019
2168
  # Restoring VERBATIM loses any edit made while the claim was held, and in
2020
2169
  # this family the claim cell IS the status cell — so `close then release`
2021
2170
  # silently reopened a row closed with evidence minutes earlier (B-35,
@@ -2057,14 +2206,18 @@ class Sync:
2057
2206
  that rewrites a shared registry file on its own is the exact mechanism that
2058
2207
  clobbers another agent's work, and it would do it from a hook, unattended.
2059
2208
  So this reports, and the agent writes.
2209
+
2210
+ Both directions, since AS-04. It used to return on `if not held` — so divergence
2211
+ was reported only to a run holding a lease, and the one shape that needs reporting
2212
+ most, a tag with NO lease behind it, was structurally invisible: nobody holds it,
2213
+ so nobody could be told (ssheleg/agent-sync#5). The lease side is still keyed by
2214
+ what this run holds; the registry side is now swept whether it holds anything or not.
2060
2215
  """
2061
2216
  out: list[str] = []
2062
2217
  tags = self.cfg.get("claimTags") or {}
2063
2218
  if not tags:
2064
2219
  return out
2065
2220
  held = set(self.held())
2066
- if not held:
2067
- return out
2068
2221
  for pattern, spec in tags.items():
2069
2222
  for path in sorted(self.root.glob(pattern)):
2070
2223
  if not path.is_file():
@@ -2092,6 +2245,15 @@ class Sync:
2092
2245
  out.append(f"{rel}: cannot verify the claim tag for `{key}` — "
2093
2246
  f"`{spec['open']}` appears in the file but not on that "
2094
2247
  "id's line. Fix claimTags, or write the tag by hand")
2248
+
2249
+ # The other direction: a tag whose lease the TTL has already ended, or one naming a
2250
+ # run that is not the run holding the key. Reported to every session, not only to a
2251
+ # session that happens to hold a lease.
2252
+ for e in self.orphan_claims():
2253
+ remedy = ("`release` it" if e["state"] == ORPHAN
2254
+ else "ask the holder, and do not edit the row")
2255
+ out.append(f"{e['file']}:{e['line']}: `{e['key']}` [{e['state']}] — "
2256
+ f"{e['why']}. NEXT: {remedy}")
2095
2257
  return out
2096
2258
 
2097
2259
  # -- as-built record and reconciliation ---------------------------------
@@ -2231,6 +2393,15 @@ class Sync:
2231
2393
  "no id registers declared here, so register checks are not evaluated in "
2232
2394
  "this repository — run reconcile in the umbrella for those")
2233
2395
 
2396
+ # 4. A claim tag on the board whose lease the TTL has already ended. Mechanical,
2397
+ # two-plane divergence — the definition of what this command reports — and it
2398
+ # was the one kind `reconcile` never mentioned (ssheleg/agent-sync#5).
2399
+ for e in self.orphan_claims():
2400
+ findings.append({
2401
+ "kind": f"claim tag with no live lease ({e['state']})",
2402
+ "detail": f"{e['file']}:{e['line']} `{e['key']}` names {e['holder']}",
2403
+ "means": e["why"]})
2404
+
2234
2405
  self.backlog = notes_backlog
2235
2406
  return findings
2236
2407
 
@@ -2775,6 +2946,26 @@ def cmd_status(_args: argparse.Namespace) -> int:
2775
2946
  if left_alone:
2776
2947
  print(" The rest are foreign or ambiguously owned — reported, not touched.")
2777
2948
 
2949
+ # The registry plane's residue, on the same footing as the lease plane's. A claim tag
2950
+ # is a claim about a lease, and until this line the TTL could end the lease while the
2951
+ # tag stayed on the board reading live — with `leases held: none` printed above it.
2952
+ try:
2953
+ orphans = s.orphan_claims()
2954
+ except Fail:
2955
+ orphans = []
2956
+ if not (s.cfg.get("claimTags") or {}):
2957
+ pass
2958
+ elif not orphans:
2959
+ print(" orphan claims : none")
2960
+ else:
2961
+ print(f" orphan claims : {len(orphans)} claim tag(s) with no live lease behind "
2962
+ "them")
2963
+ for e in orphans[:6]:
2964
+ print(f" · {e['file']}:{e['line']} {e['key']} [{e['state']}] {e['why']}")
2965
+ if len(orphans) > 6:
2966
+ print(f" · … and {len(orphans) - 6} more")
2967
+ print(" Clear one: agent_sync.py release <KEY>")
2968
+
2778
2969
  # Who else is in here, and what landed while this run was away. Without this a
2779
2970
  # lease only tells an agent it is blocked, never who by or on what.
2780
2971
  plane_broken = False
@@ -2820,7 +3011,7 @@ def cmd_status(_args: argparse.Namespace) -> int:
2820
3011
 
2821
3012
  claim_issues = s.claim_divergence()
2822
3013
  if claim_issues:
2823
- print("\n Claim tags not written through:")
3014
+ print("\n Claim tags out of step with the lease plane:")
2824
3015
  for c in claim_issues:
2825
3016
  print(f" ! {c}")
2826
3017
 
@@ -2952,9 +3143,8 @@ def cmd_residue(_args: argparse.Namespace) -> int:
2952
3143
  print(f"lease mode {s.lease_mode} · ttl {s.ttl}s · {len(entries)} lock file(s) in "
2953
3144
  f"{STATE_DIR}/leases\n")
2954
3145
  if not entries:
2955
- print(" nothing on disk — no lease has been taken in this checkout, or every one "
2956
- "was released")
2957
- return 0
3146
+ print(" nothing in the lock directory — no lease has been taken in this checkout, "
3147
+ "or every\n one was released")
2958
3148
  for e in entries:
2959
3149
  print(f" {e['key']}")
2960
3150
  print(f" state : {e['state']}"
@@ -2964,15 +3154,49 @@ def cmd_residue(_args: argparse.Namespace) -> int:
2964
3154
  print(f" why : {e['why']}")
2965
3155
  reapable = [e for e in entries if e["state"] == REAPABLE]
2966
3156
  other = [e for e in entries if e["state"] in (FOREIGN, AMBIGUOUS)]
2967
- print()
2968
- if reapable:
2969
- print(f" {len(reapable)} reapable — this run's own, spent: agent_sync.py reap")
2970
- if other:
2971
- print(f" {len(other)} foreign or ambiguous — reported, never cleared from here. "
2972
- "An expired\n lock in another run's name is that run's to explain, and a "
2973
- "lock whose owner\n cannot be established is nobody's to delete.")
2974
- if not reapable and not other:
2975
- print(" no residue every lock on disk is a live lease")
3157
+ if entries:
3158
+ print()
3159
+ if reapable:
3160
+ print(f" {len(reapable)} reapable — this run's own, spent: agent_sync.py reap")
3161
+ if other:
3162
+ print(f" {len(other)} foreign or ambiguous reported, never cleared from here. "
3163
+ "An expired\n lock in another run's name is that run's to explain, and a "
3164
+ "lock whose owner\n cannot be established is nobody's to delete.")
3165
+ if not reapable and not other:
3166
+ print(" no residue — every lock on disk is a live lease")
3167
+
3168
+ # The second plane. A claim tag is residue too, and reporting only the lock directory
3169
+ # was the whole of ssheleg/agent-sync#5: `residue` printed "nothing on disk" over a
3170
+ # board row reading `(claimed: r-…)` that no command could reach.
3171
+ if s.cfg.get("claimTags"):
3172
+ orphans = s.orphan_claims()
3173
+ tags = len(s.claim_tags_on_disk())
3174
+ print(f"\n claim tags: {tags} on disk, {len(orphans)} with no live lease behind them")
3175
+ for e in orphans:
3176
+ print(f" · {e['file']}:{e['line']} {e['key']} [{e['state']}]")
3177
+ print(f" why : {e['why']}")
3178
+ if e["state"] == ORPHAN:
3179
+ print(f" clear : agent_sync.py release {e['key']}")
3180
+ else:
3181
+ print(" leave : the lease is live under another run — ask the holder")
3182
+ if tags and not orphans:
3183
+ print(" every tag names the run that still holds its key")
3184
+ else:
3185
+ print("\n claim tags: not configured here, so none are swept "
3186
+ "(`claimTags` in the config)")
3187
+
3188
+ # AS-01a. Said where the report prints, not only on a board: a check that cannot look
3189
+ # must not read as one that looked.
3190
+ if s.lease_mode == "git":
3191
+ print("\n ⚠ INCOMPLETE IN THIS MODE. The read above walks "
3192
+ f"{STATE_DIR}/leases only, and in\n"
3193
+ " git mode the authority is `refs/agent-sync/leases/*` on "
3194
+ f"{s.cfg.get('leaseRemote') or 'origin'}.\n"
3195
+ " A ref won on ANOTHER machine leaves no local note here, so it is absent "
3196
+ "from this\n report — absent, not proven gone. Sweeping the refs is board row "
3197
+ "AS-01a; until it\n lands, enumerate them by hand:\n"
3198
+ f" git ls-remote {s.cfg.get('leaseRemote') or 'origin'} "
3199
+ "'refs/agent-sync/leases/*'")
2976
3200
  return 0
2977
3201
 
2978
3202