@ssheleg/agent-sync 1.11.1 → 1.13.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 +95 -0
- package/package.json +4 -2
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +1 -1
- package/plugins/agent-sync/skills/agent-sync/references/hooks.md +15 -3
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +42 -6
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-312.pyc +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
+
## v1.13.0 — the file carried two notions of *held* and they disagreed where it mattered
|
|
2
|
+
|
|
3
|
+
**A lease from a run that died could not be cleared by any command.** Measured in the field:
|
|
4
|
+
three locks past their TTL, one of them by **604x**, and the only way out was deleting the file
|
|
5
|
+
by hand — which is the single thing a coordination tool exists to stop anybody doing. The
|
|
6
|
+
refusal itself was correct; there was simply no path.
|
|
7
|
+
|
|
8
|
+
The cause is one function. `acquire` has always known a TTL runs out — `_steal_expired` exists
|
|
9
|
+
for exactly that. `_lease_holder`, which `release` consults, read **any** lock file as held and
|
|
10
|
+
never looked at the timestamp. So an expired lease was *gone* for `acquire` and *eternal* for
|
|
11
|
+
`release`, and the file never noticed it was answering one question two ways.
|
|
12
|
+
|
|
13
|
+
`_lease_alive()` is now the one definition, used by both. `release` reaps an expired foreign
|
|
14
|
+
lease and **says whose it was**, because the operator asked to release their own lease and is
|
|
15
|
+
getting somebody else's corpse cleared alongside it.
|
|
16
|
+
|
|
17
|
+
**What did not change is the half that matters.** A lease inside its TTL is still refused, and
|
|
18
|
+
that is fixtured beside the reap so the two cannot drift. An unparseable timestamp is treated
|
|
19
|
+
as expired rather than as a licence to hold forever — a corrupt lock was the other way to reach
|
|
20
|
+
the same unclearable state.
|
|
21
|
+
|
|
22
|
+
Fixtures 6 → **9**, each driving the shipped script as a process against a real project.
|
|
23
|
+
Closes #4.
|
|
24
|
+
|
|
25
|
+
### Also closed by measurement: #1
|
|
26
|
+
|
|
27
|
+
`claimTags` whole-row id matching made the claim unwritable on boards with a `Depends` column —
|
|
28
|
+
filed against **1.3.5**, and fixed by **B-42** on 2026-08-14 without anyone connecting the two.
|
|
29
|
+
Re-run against this version on the exact board shape the issue names (`| id | what | acceptance
|
|
30
|
+
| depends | decisions | status |`, with `T-02` and `T-03` both citing `T-01`):
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
$ agent_sync.py acquire T-01
|
|
34
|
+
docs/ROADMAP.md: `T-01` claim written through
|
|
35
|
+
| T-01 | … | open (claimed: r-cca8b75db) |
|
|
36
|
+
| T-02 | … | open | ← untouched
|
|
37
|
+
| T-03 | … | open | ← untouched
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Closes #1.
|
|
41
|
+
|
|
42
|
+
## v1.12.0 — 246 kB of someone else's bytecode, in every install
|
|
43
|
+
|
|
44
|
+
**The published tarball carried a `.pyc` both ignore files were written to exclude.**
|
|
45
|
+
`@ssheleg/agent-sync@1.11.1` ships
|
|
46
|
+
`plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-312.pyc` —
|
|
47
|
+
**245.8 kB against 175.7 kB of source beside it**, 40% of the tarball, compiled by
|
|
48
|
+
whatever interpreter the publisher happened to be running. Verified by unpacking the
|
|
49
|
+
published artefact, not by reading the manifest.
|
|
50
|
+
|
|
51
|
+
`.gitignore:3-4` and `.npmignore:1-2` both exclude `__pycache__/` and `*.pyc`. **Neither
|
|
52
|
+
is consulted once `files` names a directory** — the whitelist wins, so the intent was
|
|
53
|
+
recorded twice and enforced nowhere. `files` now carries `!plugins/**/__pycache__` and
|
|
54
|
+
`!plugins/**/*.pyc`, and the packed result drops from **231.5 kB / 28 files to
|
|
55
|
+
125.8 kB / 27**.
|
|
56
|
+
|
|
57
|
+
**A filesystem walk would not have caught this**, which is why the new check asks npm.
|
|
58
|
+
`check_the_tarball_carries_no_bytecode()` reads `npm pack --dry-run --json` — the
|
|
59
|
+
packer's own answer to *what would ship* — and fails on any `.pyc` or `__pycache__` in
|
|
60
|
+
it. Where npm is absent it discloses rather than passing: a check that cannot look must
|
|
61
|
+
never read as one that looked. Watched failing against the exact state that shipped, and
|
|
62
|
+
added to `--self-test`, now **38 fixtures**.
|
|
63
|
+
|
|
64
|
+
### Fixed — three documents that told the reader something untrue
|
|
65
|
+
|
|
66
|
+
- **`references/hooks.md` said the hooks are removed by editing `.claude/settings.json`.**
|
|
67
|
+
Nothing here ever writes a `hooks` block there, and Claude Code has no per-hook disable
|
|
68
|
+
for a hook a plugin ships. A user who wanted them gone edited a file with no such block
|
|
69
|
+
and concluded it had worked, while the SessionStart hook kept speaking in every session.
|
|
70
|
+
The two real levers are named now — `enabledPlugins[…] = false` or `plugin uninstall` —
|
|
71
|
+
plus the fact that every hook already self-disables in a project with no
|
|
72
|
+
`.claude/agent-sync.json`.
|
|
73
|
+
- **`CONTRIBUTING.md` said five version surfaces move together; the validator enforces
|
|
74
|
+
six.** The missing one is `VERSION` in `scripts/agent_sync.py`, and it is exactly what
|
|
75
|
+
forced the 1.11.1 patch: a bump driver written from that page moved four of six and CI
|
|
76
|
+
refused the tag. It is the constant `status` prints into every session, so its drift is
|
|
77
|
+
invisible in the manifests and loud in the banner.
|
|
78
|
+
- **`CONTRIBUTING.md` said the self-test injects five defects; it injected thirty-seven.**
|
|
79
|
+
The count is read off the run's own last line now. The number is the whole claim — a
|
|
80
|
+
contributor who reads *five* will not think to add a fixture for the sixth thing they
|
|
81
|
+
change.
|
|
82
|
+
|
|
83
|
+
Found by the nine-repository audit of 2026-08-16 (umbrella `B-72`; the three documents
|
|
84
|
+
are `F-agent-sync-05`, `-08` and `-11`).
|
|
85
|
+
|
|
1
86
|
## v1.11.1 — the gate can see an invariant it breaks elsewhere
|
|
2
87
|
|
|
3
88
|
**This gate can now see an invariant it breaks one repository away.** The family umbrella
|
|
@@ -378,6 +463,11 @@ plants and catches 27 distinct defects.
|
|
|
378
463
|
|
|
379
464
|
## v1.6.0
|
|
380
465
|
|
|
466
|
+
> **Never released on its own.** There is no `v1.6.0` tag and no `1.6.0` on npm,
|
|
467
|
+
> so `npm install @ssheleg/agent-sync@1.6.0` and `git checkout v1.6.0` both fail. This section
|
|
468
|
+
> describes work that shipped inside a later version. The note is here because
|
|
469
|
+
> the section reads as a release (2026-08-17, umbrella `B-71`).
|
|
470
|
+
|
|
381
471
|
**Four guarantees that were described but not delivered, and the one number now stated once.**
|
|
382
472
|
1.5.3 stopped the tool reporting things that were untrue; this release makes the properties it
|
|
383
473
|
claims actually hold, each with a check that has been watched fail against the defect it exists to
|
|
@@ -451,6 +541,11 @@ planting each defect back.
|
|
|
451
541
|
|
|
452
542
|
## v1.5.3
|
|
453
543
|
|
|
544
|
+
> **Never released on its own.** There is no `v1.5.3` tag and no `1.5.3` on npm,
|
|
545
|
+
> so `npm install @ssheleg/agent-sync@1.5.3` and `git checkout v1.5.3` both fail. This section
|
|
546
|
+
> describes work that shipped inside a later version. The note is here because
|
|
547
|
+
> the section reads as a release (2026-08-17, umbrella `B-71`).
|
|
548
|
+
|
|
454
549
|
**Five surfaces told the caller something that was not true.** An audit on 2026-08-10 ran the
|
|
455
550
|
commands instead of reading them, and every finding below was reproduced before it was fixed. The
|
|
456
551
|
validator was green throughout — which is the finding behind the findings, and the reason this
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Let concurrent coding agents share one project without colliding
|
|
3
|
+
"version": "1.13.0",
|
|
4
|
+
"description": "Let concurrent coding agents share one project without colliding — leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-sync": "bin/agent-sync.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/",
|
|
10
10
|
"plugins/",
|
|
11
|
+
"!plugins/**/__pycache__",
|
|
12
|
+
"!plugins/**/*.pyc",
|
|
11
13
|
"agent-sync.schema.json",
|
|
12
14
|
"agent-sync.example.json",
|
|
13
15
|
"README.md",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sync",
|
|
3
3
|
"displayName": "Agent Sync",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.13.0",
|
|
5
5
|
"description": "Coordination layer for multi-agent repositories — leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ssheleg",
|
|
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
|
|
|
4
4
|
compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
|
|
5
5
|
license: MIT
|
|
6
6
|
metadata:
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.13.0"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -103,6 +103,18 @@ echo '{"tool_name":"Edit","tool_input":{"file_path":"docs/DECISIONS.md"},"cwd":"
|
|
|
103
103
|
|
|
104
104
|
## Removing them
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
**Not** by editing `.claude/settings.json` — nothing here ever writes a `hooks`
|
|
107
|
+
block there, and Claude Code has no per-hook disable for a hook a plugin ships. A
|
|
108
|
+
reader who follows that instruction edits a file with no such block and concludes
|
|
109
|
+
the removal worked while every hook keeps firing.
|
|
110
|
+
|
|
111
|
+
Two levers actually work:
|
|
112
|
+
|
|
113
|
+
- `enabledPlugins["agent-sync@agent-sync"] = false` in `~/.claude/settings.json` —
|
|
114
|
+
enablement is the only switch a plugin hook has; or
|
|
115
|
+
- `claude plugin uninstall agent-sync@agent-sync`.
|
|
116
|
+
|
|
117
|
+
And one you usually do not need: every hook already self-disables in a project with
|
|
118
|
+
no `.claude/agent-sync.json` (`hooks/_lib.sh`), so a repository that never opted in
|
|
119
|
+
is not paying for them. The skill keeps working either way — every guard is also
|
|
120
|
+
available as a command, and the board simply records runs as `ungated` from then on.
|
|
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
|
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
|
-
VERSION = "1.
|
|
36
|
+
VERSION = "1.13.0"
|
|
37
37
|
|
|
38
38
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
39
39
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -1271,18 +1271,44 @@ class Sync:
|
|
|
1271
1271
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
1272
1272
|
marker.write_text(now_iso())
|
|
1273
1273
|
|
|
1274
|
+
@staticmethod
|
|
1275
|
+
def _lease_alive(held: dict, default_ttl: int) -> bool:
|
|
1276
|
+
"""Is this lease still within its TTL? The ONE definition of held.
|
|
1277
|
+
|
|
1278
|
+
`acquire` has always answered this question — `_steal_expired` exists
|
|
1279
|
+
precisely to take a lease whose TTL has run out. `release` did not ask it,
|
|
1280
|
+
and read any lock file as held. So the file carried two notions of *held*
|
|
1281
|
+
and they disagreed exactly where it mattered: a lease from a run that died
|
|
1282
|
+
was expired for `acquire` and eternal for `release`, and no command could
|
|
1283
|
+
clear it. Measured in the field at **604x** its 2700-second TTL, released
|
|
1284
|
+
only by deleting the file by hand — which is the one thing a coordination
|
|
1285
|
+
tool exists to stop anybody doing.
|
|
1286
|
+
"""
|
|
1287
|
+
try:
|
|
1288
|
+
return time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", default_ttl))
|
|
1289
|
+
except (ValueError, TypeError):
|
|
1290
|
+
# An unparseable timestamp is not a licence to hold forever.
|
|
1291
|
+
return False
|
|
1292
|
+
|
|
1274
1293
|
def _lease_holder(self, key: str) -> str | None:
|
|
1275
|
-
"""Who holds this lease right now, in whichever plane arbitrates it.
|
|
1294
|
+
"""Who holds this lease right now, in whichever plane arbitrates it.
|
|
1295
|
+
|
|
1296
|
+
`None` for an expired lease as well as an absent one: the TTL is the
|
|
1297
|
+
contract, and a lease past it is not held by anybody.
|
|
1298
|
+
"""
|
|
1276
1299
|
if self.lease_mode == "git":
|
|
1277
1300
|
sha, held = self._git_read_lease(key)
|
|
1278
|
-
|
|
1301
|
+
if not sha or not self._lease_alive(held, self.ttl):
|
|
1302
|
+
return None
|
|
1303
|
+
return held.get("run")
|
|
1279
1304
|
lock = self._local_lock(key)
|
|
1280
1305
|
if not lock.exists():
|
|
1281
1306
|
return None
|
|
1282
1307
|
try:
|
|
1283
|
-
|
|
1308
|
+
held = json.loads(lock.read_text())
|
|
1284
1309
|
except (json.JSONDecodeError, OSError):
|
|
1285
1310
|
return None
|
|
1311
|
+
return held.get("run") if self._lease_alive(held, self.ttl) else None
|
|
1286
1312
|
|
|
1287
1313
|
def release(self, key: str) -> bool:
|
|
1288
1314
|
"""Release only what this run holds, and say so plainly when it does not.
|
|
@@ -1308,10 +1334,20 @@ class Sync:
|
|
|
1308
1334
|
lock = self._local_lock(key)
|
|
1309
1335
|
if lock.exists():
|
|
1310
1336
|
try:
|
|
1311
|
-
|
|
1312
|
-
lock.unlink(missing_ok=True)
|
|
1337
|
+
held = json.loads(lock.read_text())
|
|
1313
1338
|
except (json.JSONDecodeError, OSError):
|
|
1314
1339
|
lock.unlink(missing_ok=True)
|
|
1340
|
+
else:
|
|
1341
|
+
owner = held.get("run")
|
|
1342
|
+
if owner in (self.rid, None):
|
|
1343
|
+
lock.unlink(missing_ok=True)
|
|
1344
|
+
elif not self._lease_alive(held, self.ttl):
|
|
1345
|
+
# Reaped, not stolen — and said out loud, because the operator
|
|
1346
|
+
# asked to release THEIR lease and is getting somebody else's
|
|
1347
|
+
# corpse cleared as well.
|
|
1348
|
+
print(f" reaped {key}: expired lease from {owner}, "
|
|
1349
|
+
f"whose run left it past its TTL")
|
|
1350
|
+
lock.unlink(missing_ok=True)
|
|
1315
1351
|
if self.adapter.is_lease_authority:
|
|
1316
1352
|
try:
|
|
1317
1353
|
self.adapter.log_append(self.log_id("claims"),
|