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