@ssheleg/agent-sync 1.4.3 → 1.7.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 CHANGED
@@ -1,3 +1,403 @@
1
+ ## v1.7.1
2
+
3
+ **A second audit, along a different axis: not whether the tool keeps its promises, but whether an
4
+ agent reading this skill can follow it and get a correct result.** The scenarios were executed, not
5
+ imagined — cold start, the whole adoption chain, the per-task cycle, two agents contending for one
6
+ task, every hook with a realistic payload, and the edges (no git repository, uninitialised project,
7
+ absolute paths, `git -C <dir> commit`). The coordination core came through clean. What did not was
8
+ everything the agent *reads*.
9
+
10
+ ### The documents the tool generates were two versions behind the doctrine it teaches
11
+
12
+ `setup` writes the snapshot every agent is told to read first — *"it states how documentation and
13
+ coordination work here"* — and `scaffold` seeds `AGENTS.md`. Neither mentioned a branch or `merge`
14
+ even once. Both prescribed a cycle ending in `release`, and the snapshot stated *"the claim tag in
15
+ git is written through"* as an unconditional fact, which has been false on any branch since 1.4.0 —
16
+ the branch being where the doctrine says the work belongs.
17
+
18
+ So an agent doing exactly what the skill instructs — trust the generated snapshot — got the
19
+ workflow from two releases ago. Regenerating did not help: the generator was what was stale. Both
20
+ now carry the branch rule, `merge --key`, and this project's integration branch by name.
21
+
22
+ `AGENTS.md` stopped restating the cycle altogether. It is seeded once and **never overwritten**, so
23
+ a copy of the protocol there is frozen on the day the project was created while the tool moves on —
24
+ and every project scaffolded before today would have kept the old one forever. It now points at the
25
+ snapshot, which is regenerable and which `check` fails on when it goes stale. One fact, one home.
26
+
27
+ ### `check` blessed a project an agent cannot work in
28
+
29
+ A configuration declaring `idRegisters` on a backend whose `reserve` always raises passed as
30
+ `setup healthy`. `check`'s own promise is that it refuses a rule pointing at what is not there, and
31
+ a register nobody can allocate from is exactly that — while the snapshot it generates instructs
32
+ every agent to run `agent_sync.py reserve DEC`, which cannot succeed there. It is now a problem,
33
+ named with both ways out.
34
+
35
+ ### The slash command offered a verb the CLI does not have
36
+
37
+ `argument-hint` advertised `claim <KEY>` and the README showed `/agent-sync claim ASC-072`. The
38
+ command is `acquire`; `claim` is an `invalid choice`. First thing an agent reads, first thing it
39
+ types.
40
+
41
+ ### `$SKILL_DIR` was used in every example and defined nowhere
42
+
43
+ Six invocations tell the agent to run `python3 "$SKILL_DIR/scripts/agent_sync.py"`, and the only
44
+ explanation was the prose *"this skill's own directory"*. Nothing gave a value. The Cursor rule
45
+ names a concrete path; the skill body now names both — `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
46
+ and `~/.agents/skills/agent-sync`.
47
+
48
+ ### What the audit found working
49
+
50
+ Worth recording, because a report that only lists faults says nothing about the rest: two agents
51
+ contending for one task behave correctly end to end — the second loses, sees who holds it and in
52
+ which repository, is denied the guarded file, and cannot release a lease it does not hold. The
53
+ guard denies `Edit`, `Write`, `NotebookEdit` and absolute paths, and blocks `git commit`,
54
+ `git -C <dir> commit` and `cd <dir> && git commit` when a guarded file is staged, while letting
55
+ `git log --grep=commit` and malformed JSON through. `SessionStart` stamps the identity and prints
56
+ the awareness block; `SessionEnd` releases. The adoption chain works with and without a pre-existing
57
+ `docs/`. An uninitialised project and a non-git directory both answer with the next action and a
58
+ non-zero exit.
59
+
60
+ ### New checks
61
+
62
+ `check_every_advertised_verb_exists`, `check_generated_docs_carry_current_doctrine`,
63
+ `check_registers_need_a_backend_that_can_reserve`, `check_skill_gives_a_resolvable_script_path` —
64
+ with four more self-test fixtures. The validator now plants and catches 32 distinct defects.
65
+
66
+ ## v1.7.0
67
+
68
+ **Observability, honest degradation, and the removal of things that were never load-bearing.**
69
+ 1.5.3 stopped the tool saying untrue things; 1.6.0 made its guarantees hold. This closes the
70
+ remaining findings from the 2026-08-10 audit — each one a place where the tool was quiet rather
71
+ than wrong, which is the harder failure to notice.
72
+
73
+ ### `status` and `check` gave two answers about one project
74
+
75
+ `status` printed `NEXT: acquire a lease` and exited 0 on a setup `check` called NOT healthy — a
76
+ guard pattern matching no file, a snapshot nobody links, an env file tracked by git. `status` is
77
+ the command every session runs and the only one most agents ever read, so anything it stays quiet
78
+ about is effectively unreported.
79
+
80
+ The validation now lives in one function, `check_setup()`, which both commands call. `status`
81
+ prints the count, the first four problems and one next action; `check` prints the whole list.
82
+
83
+ ### Credentials were adopted from anywhere above the project
84
+
85
+ `find_env_file` walked every parent directory until something matched, so a stray
86
+ `.env.agent-sync` in a home or work directory silently configured every project beneath it and
87
+ pointed them all at one collection — a coordination plane shared by projects with nothing to do
88
+ with each other. A found file looks exactly like a configured one, so nothing reported it.
89
+
90
+ The search is now `AGENT_SYNC_ENV` if set, then this repository, then each **superproject** in
91
+ turn — a tree git can vouch for, which is the case the walk existed to serve. `check` prints which
92
+ file is in force and says when it comes from outside the repository.
93
+
94
+ ### "New since you last looked" lost entries that arrived out of order
95
+
96
+ The watermark was an index into a list re-sorted on every read. An entry appended with an earlier
97
+ timestamp — clock skew, or a shard that was briefly unreachable — lands before the mark, shifts
98
+ everything after it, and is never reported; the slice returns an entry already seen instead. The
99
+ one section of `status` whose job is to announce what changed went quiet about exactly the change
100
+ that arrived late.
101
+
102
+ Entries are now remembered by identity, capped at 500, with a floor timestamp covering only what
103
+ fell out of that window — set only when something actually did, or the fix would have re-created
104
+ the bug in a new shape.
105
+
106
+ ### `merge` released every lease the run held
107
+
108
+ The documentation says it releases the lease; it released all of them, quietly freeing work that
109
+ had not landed. It now releases the one named by `--key`, and says what it left held.
110
+
111
+ ### An `acquire`/`release` round-trip left a diff
112
+
113
+ `SKILL.md` promises `git diff` empty afterwards. The claimed row was rebuilt from its cells, so
114
+ indentation and the original line ending were dropped — an unexplained change to a shared registry
115
+ file, which is the one kind of file agents are told never to touch casually. The bytes outside the
116
+ edited cell are now carried through rather than reconstructed.
117
+
118
+ ### Declarations that were never load-bearing
119
+
120
+ `LOGS` carried a `blockers` document nothing wrote and nothing read, so a reader looking for
121
+ blockers found an empty page and concluded there were none. `_held_legacy` and
122
+ `Adapter.is_exclusive` had no callers. `Sync.settle` was computed and never used —
123
+ `settleSeconds` stays in the schema for a backend that must wait for writes to become visible,
124
+ and `check` now says plainly that nothing shipped reads it.
125
+
126
+ `os.uname()` and a literal `/dev/null` are gone in favour of `platform.node()` and `os.devnull`;
127
+ the coordinator now runs wherever python3 does, and only the enforcement hooks need bash.
128
+
129
+ ### The setup verdict sat behind the task-pipeline gate
130
+
131
+ `status` checked for `task-pipeline` and returned before it ever reported on the project. On any
132
+ machine without the dependency installed — every CI runner — a project defect was therefore
133
+ invisible: the command exited non-zero for a reason that had nothing to do with it. The order is
134
+ now project first, machine second, because one is a fact about the repository everyone shares and
135
+ the other is a fact about the box you happen to be on.
136
+
137
+ Found by CI, on the release run for this very version, against a check that had passed locally
138
+ all day — and the check has been tightened to assert the problem is *named*, not merely that the
139
+ exit code is non-zero.
140
+
141
+ ### Three tagged releases never reached npm
142
+
143
+ `v1.5.0`, `v1.5.1` and `v1.5.2` each pushed a tag, ran the release workflow, and failed at the same
144
+ step: *"no CHANGELOG section for 1.5.2"*. The extraction matched `## 1.5.2` while this file writes
145
+ `## v1.5.2` — a heading style that changed at 1.4.x and a workflow that did not. The registry sat
146
+ three releases behind while every tag looked delivered, and the failure lived in the one place CI
147
+ never runs on `main`.
148
+
149
+ Both patterns now accept the `v` prefix — the stop pattern too, or the notes run to the bottom of
150
+ the file — and `check_release_notes_are_extractable` runs the workflow's **own** awk program,
151
+ lifted out of the YAML, against the current version. A release that cannot be described now fails
152
+ on `main`, before the tag.
153
+
154
+ ### New checks
155
+
156
+ `check_status_reports_the_setup_verdict`, `check_env_discovery_is_bounded`,
157
+ `check_watermark_survives_a_late_entry`, `check_no_orphan_logs`, `check_no_dead_declarations`,
158
+ `check_claim_round_trip_is_byte_exact` — with six more self-test fixtures. The validator now
159
+ plants and catches 27 distinct defects.
160
+
161
+ ## v1.6.0
162
+
163
+ **Four guarantees that were described but not delivered, and the one number now stated once.**
164
+ 1.5.3 stopped the tool reporting things that were untrue; this release makes the properties it
165
+ claims actually hold, each with a check that has been watched fail against the defect it exists to
166
+ catch.
167
+
168
+ ### Stealing an expired lease had a window two runs could both pass through
169
+
170
+ `unlink` then `O_EXCL create` are two operations. A second stealer that has already read the lock
171
+ as expired removes the lock the first one just created, and both then hold what each believes is an
172
+ exclusive lease. Twelve racing processes never exposed it — with a 300 ms delay injected between the
173
+ two calls, two of two won, and in production that delay is an ordinary scheduler hiccup.
174
+
175
+ The reap and the create are now one critical section: `<key>.lock.steal` is created with `O_EXCL`,
176
+ the expiry is re-read **inside** it (the holder may have renewed; another stealer may have finished),
177
+ and the section carries a 30-second abandonment grace, because without one a crash between two
178
+ filesystem calls costs the key until a human deletes a file nobody documents.
179
+
180
+ This mattered more after 1.5.3 than before it: with `renew` finally refreshing leases, the steal path
181
+ stops being the common case — but for four versions every long task went through it.
182
+
183
+ ### `merge` measured one base and merged into another
184
+
185
+ Conflicts and the diff were computed against `origin/<target>`; the merge was made into the **local**
186
+ `<target>`, which nothing advances. So `merge` printed the staleness it had just measured — `main
187
+ moved: 1 commit(s)` — then `✓ merged as …`, wrote a merge-log entry, released the lease, and the push
188
+ was rejected as non-fast-forward. The work had not landed, `docs/MERGES.md` said it had, and the task
189
+ was free for somebody else to take.
190
+
191
+ The local integration branch is now fast-forwarded to its upstream before anything else, so the
192
+ preflight and the merge share a base. One that has genuinely diverged cannot be fast-forwarded and is
193
+ refused, with both counts named.
194
+
195
+ ### One glob meant two things
196
+
197
+ The guard matched with `Path.match`, which anchors at the **right**: with `docs/DECISIONS.md` in
198
+ `guardedFiles`, an edit to `vendor/docs/DECISIONS.md` was denied — a file `check` never enumerated
199
+ and never validated, protected by a rule nobody wrote. In the other direction `Path.match` does not
200
+ walk `**` before Python 3.13, so `docs/**/*.md` guarded less than `check` reported. A pattern that
201
+ means two things means nothing. Both commands now resolve patterns through one function, anchored at
202
+ the repository root.
203
+
204
+ ### The 2% rule was a constant nothing read
205
+
206
+ `MAX_UNPARSEABLE` was declared in 1.0.0 and never referenced. `SKILL.md`, `lease-protocol.md` and the
207
+ README all stated that a log past the threshold stops the run; the only implementation was a warning
208
+ line on the board that returned 0. Every reader now refuses a log past the limit and names the ratio,
209
+ so `status`, `board`, `check`, `reconcile` and `reserve` stop rather than replaying a partial history
210
+ — which reports holders who do not exist and silence where the real ones are, both of which look like
211
+ an answer.
212
+
213
+ `acquire` is deliberately not in that list, and the documents now say so: a lease is decided by the
214
+ lock or the ref, never by the log, so a corrupt log cannot make one look lost.
215
+
216
+ ### The stage binding said three different things
217
+
218
+ `SKILL.md` announced "four of the eleven stages" and listed five (0, 1, 3, 9, 10); the README named
219
+ 0, 3, 4, 5, 9 and 10; `pipeline-binding.md` agreed with the README and separately called stage 1
220
+ *"nothing shared to coordinate"* — the stage `reconcile` belongs to, and the one the tool's own
221
+ doctrine says must resolve every divergence before code is written. An agent wiring `pipeline.json`
222
+ from any one of the three got a pipeline missing a rule.
223
+
224
+ The numbers now live in a marker in `pipeline-binding.md` — `rules=0,1,3,9,10`,
225
+ `wired=0,1,3,4,5,9,10` — the two lists answer two different questions instead of being conflated,
226
+ stage 1 has its row and its `pipeline.json` entry, and a check fails when a surface stops agreeing.
227
+
228
+ ### New checks
229
+
230
+ `check_steal_is_atomic`, `check_merge_refuses_stale_target`, `check_guard_and_check_agree_on_globs`,
231
+ `check_unparseable_log_fails_loudly`, `check_stage_binding_agrees` — plus five self-test fixtures
232
+ planting each defect back.
233
+
234
+ ## v1.5.3
235
+
236
+ **Five surfaces told the caller something that was not true.** An audit on 2026-08-10 ran the
237
+ commands instead of reading them, and every finding below was reproduced before it was fixed. The
238
+ validator was green throughout — which is the finding behind the findings, and the reason this
239
+ release ships six new checks that drive the tool rather than its functions.
240
+
241
+ ### `reserve` handed three runs the same id
242
+
243
+ `reserve` replayed `log_id("reservations")` — the document **this run writes**. Every other run's
244
+ shard was invisible to it, so three runs each saw an empty history, each seeded a `base` from the
245
+ register, and each was handed `DEC-0007`. Measured, three processes, one number.
246
+
247
+ The pure allocator was correct and tested; nothing ever asked it about the whole log. This is the
248
+ same failure that disqualified per-writer documents as a *lease* store in 1.0.0 — eight processes
249
+ reading only their own shard, eight winners — arriving in the allocation path, where the tested
250
+ unit hid it. Allocation now runs over the merged log.
251
+
252
+ Merging alone would have replaced one collision with another: two runs opening a register in the
253
+ same minute both append the same seed, and a `base` that re-seated unconditionally restarts the
254
+ count and hands the second run the id the first just took. A `base` now only ever moves allocation
255
+ **forward**; one at or below the current position is ignored.
256
+
257
+ ### `renew` renewed nothing
258
+
259
+ It appended `op=renew` to the record plane — which has not decided a lease since 1.0.0 — and
260
+ touched a throttle file. The timestamp expiry is actually computed from, the one inside the lock
261
+ (or the git ref payload), was written once, by `acquire`.
262
+
263
+ So a run holding a lease lost it at TTL **while still working**: `whoami` reported `holds:
264
+ nothing`, its own `PreToolUse` guard began denying its edits, and another run acquired the task it
265
+ was in the middle of. With the default 2700 s, every task longer than forty-five minutes. The
266
+ `PostToolUse` hook made no difference, because there was nothing for it to move — and from the
267
+ record plane's side the renewals arrived exactly on schedule, so nothing looked wrong anywhere.
268
+ `renew` now rewrites the lock in `local` mode and re-pushes the ref with `--force-with-lease`
269
+ against the exact object it read in `git` mode.
270
+
271
+ ### `check` rejected the config `init` had just written
272
+
273
+ `check` carried its own literal list of legal keys. `mergeLog` — written by `init` itself — and
274
+ `integrationBranch` — in the schema, in the shipped example, read by the code — were not in it, so
275
+ a freshly initialised project reported `config key 'mergeLog' is not in the schema — it will be
276
+ ignored`. Both halves false: it is in the schema, and it is not ignored.
277
+
278
+ That is worse than a wrong message; it is an instruction. An agent making `check` green deletes
279
+ working configuration. The list now lives in one place, `CONFIG_KEYS`, and the validator asserts it
280
+ equals the schema's properties exactly.
281
+
282
+ ### Three commands reported success they had not achieved
283
+
284
+ `release-id` printed `released DEC-0007` and exited 0 on a backend that records nothing — the id
285
+ stayed a hole the board reports as a leak, and the only party who could have fixed it had been told
286
+ it was handled. `record` and `signal` printed success and exited 0 while stderr said the entry was
287
+ never published. All three now fail loudly.
288
+
289
+ An adapter `OSError` also walked past every `except Fail` into `main`: an unwritable state
290
+ directory handed the agent a Python traceback as the state of the coordination plane. Store errors
291
+ are now the tool's own failure type.
292
+
293
+ ### The guard named a holder who held something else
294
+
295
+ A denial read `<path> is a guarded registry file and this run holds no lease — r-x holds a lease
296
+ right now`, where `r-x` held an unrelated task. Beside a path, that sentence gets repeated as "r-x
297
+ holds this file". The denial now names the run **and its key**, and says plainly that exit 2 is a
298
+ statement about the asking run, not about the file.
299
+
300
+ ### The marketplace listing still sold the design 1.0.0 refuted
301
+
302
+ `marketplace.json` — the first thing anyone reads — described coordination as "decided by replaying
303
+ one append-only log so no backend needs compare-and-swap". That is the belief the first trap in
304
+ `SKILL.md` exists to forbid. Rewritten, and a check now fails on the phrase.
305
+
306
+ ### Documentation corrected where it described behaviour that did not exist
307
+
308
+ `SKILL.md` and the Cursor rule on what exit 2 means; `lease-protocol.md` on what `renew` moves and
309
+ on the log a reader replays; the claim-tag vocabulary in `README.md` and `lease-protocol.md`, which
310
+ showed a role where the tool writes a run id; `hooks.md` on the `NotebookEdit` matcher, on what
311
+ `session-end.sh` actually does, and on the two timeouts that really apply.
312
+
313
+ ### New checks
314
+
315
+ `check_reserve_is_race_free`, `check_renew_extends_the_lease`, `check_config_round_trip`,
316
+ `check_no_success_on_failed_publish`, `check_guard_denial_names_only_what_it_knows`,
317
+ `check_doctrine_is_current`. Each drives the real commands from more than one identity, because
318
+ every defect above lived in the gap between two things the validator already tested separately.
319
+
320
+ ## v1.5.2
321
+
322
+ ### `reserve` handed out ids that were already written
323
+
324
+ `reserve DEC` returned `DEC-0270`, then `0271`, then `0272`, in a project whose register's highest
325
+ heading was `DEC-0281` and whose own "next free" line read `DEC-0282`. All three were occupied;
326
+ `DEC-0270` was cited by name in another document.
327
+
328
+ The counter was not stuck — it incremented on every call. It was **11 behind**. The `base` event is
329
+ seeded once, from the register, and never consulted again. Every id written by a path that is not
330
+ this tool — a person editing the file, another session's Doc Loop, a merge — advances the register
331
+ and leaves the log where it was. The gap only ever grows.
332
+
333
+ This inverts the mechanism. An agent that follows the protocol exactly — reserve before minting,
334
+ never trust the register's own "next free" line, which is the rule the protocol states — is the one
335
+ that writes a duplicate, and it is silent at the point of use: the id looks fresh, the register
336
+ accepts a second heading with that number, and every citation to it becomes ambiguous. An agent that
337
+ ignored the tool and read the line would have been correct. That is the worst incentive a
338
+ coordination tool can teach.
339
+
340
+ The register is now consulted on **every** reserve and treated as a **floor**: it can push the
341
+ allocation forward, never pull it back. Ids reserved through this tool but not yet written do not
342
+ appear in the register, so honouring the floor never revokes a live reservation. The floor is
343
+ applied by re-basing mid-log, which the allocator now supports properly — a new `base` restarts the
344
+ count (it previously kept serving from the old one, skipping as many ids as had been handed out) and
345
+ drops freed ids that fall below it (the register has moved past them, so a heading exists there now,
346
+ and recycling one is the same collision through the other door). Ids freed at or above the base are
347
+ still reused, so the fix does not turn every release into a leak.
348
+
349
+ Proven end to end against the register that exposed it: the shipped 1.5.1 returns `DEC-0273`, an
350
+ occupied id; this build returns `DEC-0282`, then `DEC-0283`. Three assertions in `test/validate.py`,
351
+ all watched failing against a planted revert of the allocator — 202 where 200 was due, and a freed
352
+ `0100` handed back.
353
+
354
+ Found in nicegram-business, 2026-08-09, by an agent that recognised the returned number from another
355
+ document. That is luck, not a control, which is why the check now exists.
356
+
357
+ ## v1.5.1
358
+
359
+ ### `release` could not remove the claim it had written
360
+
361
+ `acquire` writes a marker into the status cell; `release` found the row again **by searching for the
362
+ task id**. Between the two, a run's own work can add another mention of that id — a new row, a
363
+ cross-reference — and release then sees several candidates, refuses to guess, and leaves
364
+ `(claimed: r-…)` in the cell **permanently**.
365
+
366
+ The result is worse than no claim: a status cell advertising a live lease that nobody holds, which
367
+ the next agent reads as an occupied file.
368
+
369
+ Release now narrows by the marker before falling back to the id. The marker names *this run*, so it
370
+ is unambiguous however many rows mention the id. Proven both ways on the same scenario — acquire
371
+ writes the tag, a second mention is inserted, and release removes it (`claim restored`); the
372
+ previous version leaves it behind.
373
+
374
+ ## v1.5.0
375
+
376
+ ### The commit guard was blind to `git -C <dir> commit`, and to submodules
377
+
378
+ Two defects, and the second is why the first went unnoticed.
379
+
380
+ **The test was a contiguous substring.** `case "$cmd" in *"git commit"*)` — and `git -C <dir> commit`
381
+ does not contain the string `git commit`. Every commit made that way skipped the guard entirely, in
382
+ any repository, submodule or not.
383
+
384
+ **The repository was hardcoded to `CLAUDE_PROJECT_DIR`.** So even a bare `git commit` inside a
385
+ submodule read the *umbrella's* index, found it empty, and passed: the staged files live in the
386
+ submodule's index.
387
+
388
+ Together they meant a full day of commits to guarded registers went unchecked, on 2026-08-07, while
389
+ the Edit-tool half of the same hook refused correctly the whole time — so the protection looked
390
+ present and was measured as present by anyone who tested it with the Edit tool.
391
+
392
+ The command is now tokenised in python rather than globbed in shell: `-C`, `-c` and `--namespace`
393
+ are consumed with their arguments, `cd <dir> &&` is honoured, each `&&`/`;`/`||` segment is examined,
394
+ and the guard runs **from** the resolved repository — `agent_sync.py` resolves the project from
395
+ `git rev-parse --show-toplevel` of its cwd, so a submodule gets its own `guardedFiles`.
396
+
397
+ Proven against the previous version on all three forms: `git -C <sub> commit` and
398
+ `cd <sub> && git commit` go from `rc=0` to `rc=2`, and `git log --grep=commit` stays `rc=0` — the
399
+ tokeniser exists so that one does not become a false positive.
400
+
1
401
  # Changelog
2
402
 
3
403
  All notable changes to this project are documented here.
package/README.md CHANGED
@@ -225,8 +225,9 @@ action:
225
225
 
226
226
  ## Everyday use
227
227
 
228
- In an agent session you use the slash command (`/agent-sync claim ASC-072`); the same
229
- commands run directly against the coordinator script, which is what the hooks and CI do:
228
+ In an agent session you use the slash command (`/agent-sync acquire ASC-072` the verb is
229
+ `acquire`, the same one the CLI takes); the same commands run directly against the
230
+ coordinator script, which is what the hooks and CI do:
230
231
 
231
232
  ```bash
232
233
  python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
@@ -235,10 +236,10 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
235
236
  | Command | Does |
236
237
  |---|---|
237
238
  | `init` | **Run first.** Ask where state lives, write config + gitignored env file, print your step |
238
- | `status` | Inspect, repair, report — including other runs' leases and signals new since you last looked |
239
+ | `status` | Inspect, repair, report — other runs' leases, signals new since you last looked, and `check`'s verdict on the setup |
239
240
  | `bootstrap` | Create the cloud container and print the id to paste into the env file |
240
241
  | `acquire <KEY>` | Take the lease on a task id. Prints `won`, or `lost <holder>` |
241
- | `renew <KEY>` | Extend the lease. In Claude Code the `PostToolUse` hook does this for you |
242
+ | `renew <KEY>` | Extend the lease — moves the timestamp expiry is computed from. In Claude Code the `PostToolUse` hook does this for you |
242
243
  | `release <KEY>` | Give the lease back. Always, including on failure |
243
244
  | `reserve <REG>` | Reserve the next id in a register (`DEC`, `OQ`, `DEP`, …). Prints the id |
244
245
  | `release-id <REG> <ID>` | Return an id you did not end up writing to git |
@@ -273,11 +274,13 @@ see who has what, and the shared roadmap does not become the file every branch e
273
274
  python3 "$SKILL_DIR/scripts/agent_sync.py" merge --key ASC-072 --summary "what landed"
274
275
  ```
275
276
 
276
- Conflicts are computed with `git merge-tree` **before anything is touched** — on conflict
277
- it names the files, changes nothing and exits non-zero, so a resolution nobody reviewed
278
- never reaches the integration branch. Then it merges `--no-ff`, records the merge, and
279
- releases every lease this run holds. `--dry-run` stops after the checks; `--push` pushes
280
- afterwards.
277
+ The local integration branch is fast-forwarded to `origin/<target>` first, so the
278
+ preflight and the merge share a base; one that has genuinely diverged is refused with both
279
+ counts. Conflicts are then computed with `git merge-tree` **before anything is touched** —
280
+ on conflict it names the files, changes nothing and exits non-zero, so a resolution nobody
281
+ reviewed never reaches the integration branch. Then it merges `--no-ff`, records the merge,
282
+ and releases the lease named by `--key`. `--dry-run` stops after the checks; `--push`
283
+ pushes afterwards.
281
284
 
282
285
  **The merge log** — `docs/MERGES.md`, configurable via `mergeLog` — answers the question an
283
286
  agent coming back from a branch cannot answer from `git log` alone: *what landed while I
@@ -296,8 +299,9 @@ right one. Full doctrine:
296
299
  [`references/branching.md`](plugins/agent-sync/skills/agent-sync/references/branching.md).
297
300
 
298
301
  **The lease is not the claim.** The lease says who holds the task *now* and expires; the
299
- durable claim is the tag in git (`[name]`, `todo (claimed: <role>)`). `acquire` writes
300
- that tag through and `release` clears it, so one fact keeps one home.
302
+ durable claim is the tag in git `todo (claimed: r-7f3a91)`, rendered from the
303
+ `claimTags.held` template, naming the **run**, not a role. `acquire` writes that tag
304
+ through and `release` restores exactly what was there, so one fact keeps one home.
301
305
 
302
306
  ## Configuration
303
307
 
@@ -319,7 +323,12 @@ that tag through and `release` clears it, so one fact keeps one home.
319
323
  | `integrationBranch` | where work lands and the only branch a claim is written on (default: the repo's own) |
320
324
  | `mergeLog` | `file` and `retentionDays` for the merge log (default `docs/MERGES.md`, 7) |
321
325
 
322
- `.env.agent-sync` — gitignored, mode 600:
326
+ `.env.agent-sync` — gitignored, mode 600. It is looked for in this repository, then in
327
+ each **superproject** above it (so one credentials file serves a tree of submodules), and
328
+ nowhere else. `AGENT_SYNC_ENV=/path/to/file` names one explicitly and wins over both.
329
+ Until 1.7.0 the search continued into any parent directory, so a stray file in a home or
330
+ work directory silently pointed every project beneath it at one collection — `check`
331
+ now prints which file is in force, and says when it comes from outside the repository.
323
332
 
324
333
  ```
325
334
  AGENT_SYNC_BACKEND=outline
@@ -384,9 +393,11 @@ Details and removal:
384
393
  ## Where it plugs into task-pipeline
385
394
 
386
395
  `agent-sync` supplies stages; it does not define them. It binds to task-pipeline's
387
- stages 0, 3, 4, 5, 9 and 10 — lease before the brief is committed, reserve ids before
388
- they reach git, register file ownership for parallel groups, signal and regenerate the
389
- board at docs, release everything at acceptance. Wiring:
396
+ stages 0, 1, 3, 4, 5, 9 and 10 — lease before the brief is committed, reconcile before
397
+ code is written, reserve ids before they reach git, register file ownership for parallel
398
+ groups, signal and regenerate the board at docs, release everything at acceptance. The
399
+ stage numbers are stated once, in the marker at the top of the binding reference, and a
400
+ check fails when a document stops agreeing with it. Wiring:
390
401
  [`references/pipeline-binding.md`](plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md).
391
402
 
392
403
  ## Limits, stated plainly
@@ -410,7 +421,8 @@ board at docs, release everything at acceptance. Wiring:
410
421
  | `task-pipeline is not installed` and `status` stops | Intentional — there are no stages to bind to. `npx sshlg-skills install` |
411
422
  | `⚠ 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 |
412
423
  | `lease: local — advisory across machines` | Expected on the default. Set `leaseBackend: "git"` (and a reachable `leaseRemote`) when agents run on more than one machine |
413
- | 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 |
424
+ | Every `acquire` reports `lost` | Check the holder in `status`. The lease is decided by a lock file or a git ref, never by the log, so this is a real holder not a parse failure |
425
+ | A command stops with `the … log is N/M unparseable` | Past 2%, every command that *replays* a log refuses it rather than acting on a partial history. Fix or remove the malformed lines; `acquire` is unaffected, because a lease is not decided there |
414
426
  | Guarded edit blocked in Claude Code | Working as designed: `acquire` the key first, or unstage the file |
415
427
  | Guarded edit *not* blocked | You are not on Claude Code. Run `guard <path>` yourself; the run is `ungated` |
416
428
  | `AGENT_SYNC_OUTLINE_COLLECTION is not set` | Run `bootstrap` and paste the printed id into `.env.agent-sync` |
@@ -437,7 +449,7 @@ npm test # both of the above
437
449
  ```
438
450
 
439
451
  What ships: one skill (`agent-sync`), `scripts/agent_sync.py` (stdlib only), four hook
440
- scripts, the slash command, `agent-sync.schema.json`, and eight reference contracts the
452
+ scripts, the slash command, `agent-sync.schema.json`, and ten reference contracts the
441
453
  agent loads on their own trigger rather than by default:
442
454
 
443
455
  | Reference | Read it when |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.4.3",
3
+ "version": "1.7.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,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.4.3",
4
+ "version": "1.7.1",
5
5
  "description": "Coordination layer for multi-agent repositories \u2014 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",
@@ -1,6 +1,6 @@
1
1
  ---
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|finish]"
2
+ description: Coordinate concurrent agents — initialise the shared knowledge store, check status, take a task, reserve an id, or regenerate the board.
3
+ argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|board|merge|finish]"
4
4
  ---
5
5
 
6
6
  Invoke the `agent-sync` skill.
@@ -20,25 +20,71 @@ print(ti.get("file_path") or ti.get("path") or ti.get("notebook_path") or "")
20
20
 
21
21
  # git commit: check every staged path instead of a single file argument.
22
22
  if [ -z "$path" ]; then
23
- cmd=$(python3 -c '
24
- import json,sys
23
+ # What repository, and is this even a commit. Both used to be wrong, and the second one is why
24
+ # the first went unnoticed: the old test was `case "$cmd" in *"git commit"*)`, a CONTIGUOUS
25
+ # substring. `git -C <dir> commit` does not contain it, so every commit made that way skipped the
26
+ # guard entirely -- in any repository, submodule or not. The repo was then hardcoded to
27
+ # CLAUDE_PROJECT_DIR, so even a bare `git commit` inside a submodule read the umbrella's empty
28
+ # index and passed. Measured 2026-08-07: a full day of commits to guarded registers, with the
29
+ # Edit-tool half of this hook refusing correctly the whole time, so the protection looked present.
30
+ #
31
+ # Tokenised in python rather than globbed in shell: `git log --grep=commit` must not match, and
32
+ # `git -c user.name=x -C dir commit` must.
33
+ read -r is_commit repo <<<"$(python3 -c '
34
+ import json, shlex, sys
25
35
  try:
26
- d=json.load(sys.stdin)
36
+ d = json.load(sys.stdin)
27
37
  except Exception:
28
- sys.exit(0)
29
- print((d.get("tool_input") or {}).get("command",""))
30
- ' <<<"$input" 2>/dev/null)
31
- case "$cmd" in
32
- *"git commit"*)
33
- while IFS= read -r staged; do
34
- [ -n "$staged" ] || continue
35
- if ! python3 "$S" guard "$staged" >/dev/null 2>&1; then
36
- echo "agent-sync: '$staged' is staged but this run holds no lease on it. Acquire one, or unstage it." >&2
37
- exit 2
38
- fi
39
- done < <(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" diff --cached --name-only 2>/dev/null)
40
- ;;
41
- esac
38
+ print("0 ."); sys.exit(0)
39
+ cmd = (d.get("tool_input") or {}).get("command", "")
40
+ is_commit, repo = 0, "."
41
+ # Each &&/;/| segment is its own command; a commit anywhere in the chain counts.
42
+ for seg in cmd.replace("&&", "\n").replace(";", "\n").replace("||", "\n").split("\n"):
43
+ try:
44
+ toks = shlex.split(seg)
45
+ except ValueError:
46
+ continue
47
+ if not toks:
48
+ continue
49
+ if toks[0] == "cd" and len(toks) > 1:
50
+ repo = toks[1]
51
+ continue
52
+ if toks[0] != "git":
53
+ continue
54
+ k, r = 1, None
55
+ while k < len(toks):
56
+ t = toks[k]
57
+ if t == "-C" and k + 1 < len(toks):
58
+ r = toks[k + 1]; k += 2; continue
59
+ if t in ("-c", "--namespace") and k + 1 < len(toks):
60
+ k += 2; continue
61
+ if t.startswith("-"):
62
+ k += 1; continue
63
+ break
64
+ if k < len(toks) and toks[k] == "commit":
65
+ is_commit = 1
66
+ if r:
67
+ repo = r
68
+ break
69
+ print(is_commit, repo)
70
+ ' <<<"$input" 2>/dev/null)"
71
+ [ -n "${is_commit:-}" ] || is_commit=0
72
+ [ -n "${repo:-}" ] || repo="."
73
+ [ -d "$repo" ] || repo="${CLAUDE_PROJECT_DIR:-$PWD}"
74
+
75
+ if [ "$is_commit" = "1" ]; then
76
+ # The guard runs FROM that repository, not merely against its file list: agent_sync.py resolves
77
+ # the project from `git rev-parse --show-toplevel` of its cwd, so a submodule gets its own
78
+ # .claude/agent-sync.json and its own guardedFiles -- the only reading under which
79
+ # "docs/ROADMAP.md" means the right file in each repo.
80
+ while IFS= read -r staged; do
81
+ [ -n "$staged" ] || continue
82
+ if ! (cd "$repo" && python3 "$S" guard "$staged") >/dev/null 2>&1; then
83
+ echo "agent-sync: '$staged' is staged in $repo and this run holds no lease on it. Acquire one, or unstage it." >&2
84
+ exit 2
85
+ fi
86
+ done < <(git -C "$repo" diff --cached --name-only 2>/dev/null)
87
+ fi
42
88
  exit 0
43
89
  fi
44
90