@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 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.11.1",
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.",
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.11.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.11.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
- Delete the `hooks` block from the project's `.claude/settings.json`. The skill keeps
107
- working every guard is also available as a command, and the board simply records
108
- runs as `ungated` from then on.
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.11.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
- return held.get("run") if sha else None
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
- return json.loads(lock.read_text()).get("run")
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
- if json.loads(lock.read_text()).get("run") in (self.rid, None):
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"),