@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 +359 -0
- package/README.md +33 -15
- package/agent-sync.example.json +3 -1
- package/package.json +5 -2
- package/plugins/agent-sync/.claude-plugin/plugin.json +3 -2
- package/plugins/agent-sync/hooks/guard.sh +63 -17
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +20 -25
- package/plugins/agent-sync/skills/agent-sync/references/branching.md +17 -4
- package/plugins/agent-sync/skills/agent-sync/references/earned-rules.md +9 -0
- package/plugins/agent-sync/skills/agent-sync/references/hooks.md +4 -4
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +53 -16
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +70 -15
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-312.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +565 -152
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 —
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
300
|
-
|
|
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,
|
|
388
|
-
they reach git, register file ownership for parallel
|
|
389
|
-
board at docs, release everything at acceptance.
|
|
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`.
|
|
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
|
|
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
|
package/agent-sync.example.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
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":
|
|
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
|
+
"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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|