@ssheleg/agent-sync 1.2.2 → 1.2.4

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
@@ -3,6 +3,61 @@
3
3
  All notable changes to this project are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.2.4 — 2026-07-29
7
+
8
+ ### Fixed — the tool misreported its own version, and disagreed with itself about the lease
9
+ - **`VERSION` drifted a release behind.** The constant said `1.2.2` while every manifest
10
+ said `1.2.3`, so each `status` and `adopt` header named the wrong version — the exact
11
+ number the README tells an operator to compare when hunting a stale install channel.
12
+ `check_version_sync()` read five manifests and not the script; `check_scripts_run()` ran
13
+ `--version` only to prove the process starts, and threw the answer away. The constant is
14
+ now part of the sync check, so this cannot drift silently again.
15
+ - **`gated` was decided by the record backend, which has not decided a lease since 1.0.0.**
16
+ It read the adapter's `atomicAppend`/`totalOrderRead` capabilities, and both directions
17
+ lied: `outline` with a local lock reported `gated` while exclusion was machine-local —
18
+ the pretended lease the skill's own trap 2 warns about — and `fs` with git refs reported
19
+ `ungated` while every lease was a genuine cross-machine compare-and-swap. It now derives
20
+ from `leaseBackend`.
21
+ - **Six surfaces phrased the guarantee independently, and two called the knowledge base
22
+ the "lease authority".** `status` said `lease authority: NO — degraded` for the same
23
+ project where `check` said `exclusive on this machine` and `acquire` said something else
24
+ again. One guarantee described three ways reads as three guarantees, and an operator acts
25
+ on the weakest. The wording now lives in one table (`lease_guarantee()`), used by
26
+ `status`, `acquire`, `check`, the board, the setup snapshot and `init`. `status` reports
27
+ the record plane and the lease as the separate facts they are.
28
+
29
+ ### Added
30
+ - **`test/validate.py` exercises the agreement**: for `leaseBackend` `local` and `git` it
31
+ runs `status`, `acquire` and `check` against a throwaway repository (a real bare remote
32
+ for `git`) and fails if any of them omits the guarantee, or if `status` still calls the
33
+ record backend the lease authority. Verified red against 1.2.3, green after.
34
+
35
+ ## 1.2.3 — 2026-07-29
36
+
37
+ ### Fixed — the guard blocked commits in projects that never installed agent-sync
38
+ `_lib.sh` states the contract: *"Every hook is a no-op in a project that does not use
39
+ agent-sync, so installing the plugin globally changes nothing elsewhere."* `guard.sh` was
40
+ the one hook that never sourced `_lib.sh`, and it honored that contract on only one of its
41
+ two branches.
42
+
43
+ - **The `git commit` branch had no configuration check.** It ran `agent_sync.py guard` on
44
+ every staged path; in an uninitialized project that command exits 2 with *"no
45
+ `.claude/agent-sync.json` in this project"*, which the loop read as "this run holds no
46
+ lease" — so every commit in every repo without agent-sync was blocked, with a message
47
+ naming a lease the project could not possibly need. The single-file branch had the check
48
+ all along, which is why the failure only ever surfaced on commits.
49
+ - `guard.sh` now sources `_lib.sh` and gates on `agent_sync_configured` like the other three
50
+ hooks, so the check cannot drift apart from them again. The hand-rolled `AGENT_SYNC_PY`
51
+ path and the duplicated `[ -f … ]` test are gone.
52
+ - The staged-path listing now runs against `${CLAUDE_PROJECT_DIR:-$PWD}`, the same directory
53
+ the configuration check reads. Before, the two could point at different repositories.
54
+
55
+ ### Added
56
+ - **`test/validate.py` exercises the no-op contract** instead of only checking syntax: every
57
+ hook runs against a throwaway git repository that has a staged file and no
58
+ `.claude/agent-sync.json`, and must exit 0. Verified red against the pre-fix `guard.sh` and
59
+ green after — a `bash -n` pass could never have caught this.
60
+
6
61
  ## 1.2.2 — 2026-07-29
7
62
 
8
63
  ### Changed
package/README.md CHANGED
@@ -282,19 +282,24 @@ configuration defect.
282
282
 
283
283
  ## Backends
284
284
 
285
- The knowledge store is a **pluggable adapter**six primitives, three declared
286
- capabilities. Nothing about a specific vendor is baked in, and no instance address ships
287
- in this repository.
285
+ **Two settings, two jobs.** `backend` chooses the record plane where the log, the
286
+ signals and the board live. `leaseBackend` chooses what actually decides a lease. The
287
+ knowledge base is never the second one: measured against a real instance, twelve
288
+ concurrent appends to one document returned twelve successes and left three lines.
288
289
 
289
- | Backend | Lease authority | Notes |
290
- |---|---|---|
291
- | `outline` | yes | [Outline](https://www.getoutline.com), hosted or self-hosted. Server-side append gives a total order without compare-and-swap |
292
- | `fs` | no — **degraded** | Local files. Real mutual exclusion between agents on one machine, none across machines. Every run is recorded `ungated` |
290
+ | `backend` the record plane | What it gives |
291
+ |---|---|
292
+ | `outline` | [Outline](https://www.getoutline.com), hosted or self-hosted. Every repository and machine reads one plane: shared awareness, cross-repo signals, the board |
293
+ | `fs` | Local files. No credentials, and no visibility to an agent on another machine |
294
+
295
+ | `leaseBackend` — the lease | Guarantee |
296
+ |---|---|
297
+ | `git` | **Exclusive across machines.** The remote's non-fast-forward rejection is a real compare-and-swap |
298
+ | `local` *(default)* | **Exclusive on this machine, advisory across machines.** An atomic file create |
293
299
 
294
- **A backend that cannot arbitrate says so.** When the adapter is not the lease authority,
295
- `agent-sync` announces it, falls back to git-file leases, and marks runs `ungated`
296
- because a lease that is not actually exclusive is worse than none, and the other agent
297
- has stopped checking.
300
+ `status`, `acquire` and `check` all state which of the two you have, in the same words,
301
+ because a lease that is not actually exclusive is worse than none: the other agent has
302
+ stopped checking. `runs recorded: gated` follows the lease mode never the record plane.
298
303
 
299
304
  Adding one: read
300
305
  [`references/adapter-contract.md`](plugins/agent-sync/skills/agent-sync/references/adapter-contract.md).
@@ -331,15 +336,18 @@ board at docs, release everything at acceptance. Wiring:
331
336
  expire one. Agents' clocks differ and the protocol does not depend on them.
332
337
  - **A reserved id that never reaches git is reported, not reclaimed.** A half-written
333
338
  decision on a branch is not an unused number.
334
- - **`fs` is not cross-machine.** It is a real mutex between agents on one host and
335
- nothing more, which is why it never claims lease authority.
339
+ - **`fs` is not cross-machine.** As a record plane it is invisible to agents on another
340
+ host, and the default `local` lease is a mutex on this one. Set `leaseBackend: "git"`
341
+ when a fleet spans machines; the tool says which guarantee you have rather than
342
+ implying the stronger one.
336
343
 
337
344
  ## Troubleshooting
338
345
 
339
346
  | Symptom | Cause and fix |
340
347
  |---|---|
341
348
  | `task-pipeline is not installed` and `status` stops | Intentional — there are no stages to bind to. `npx sshlg-skills install` |
342
- | `⚠ ungated backend — this lease is advisory` | The `fs` backend, or missing credentials. Expected; configure `outline` for enforced leases |
349
+ | `⚠ 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 |
350
+ | `lease: local — advisory across machines` | Expected on the default. Set `leaseBackend: "git"` (and a reachable `leaseRemote`) when agents run on more than one machine |
343
351
  | 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 |
344
352
  | Guarded edit blocked in Claude Code | Working as designed: `acquire` the key first, or unstage the file |
345
353
  | Guarded edit *not* blocked | You are not on Claude Code. Run `guard <path>` yourself; the run is `ungated` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Let concurrent coding agents share one project without colliding \u2014 leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sync",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "appvillis-com"
@@ -3,7 +3,9 @@
3
3
  # Any other non-zero code is NON-blocking in Claude Code, so every internal
4
4
  # failure must also exit 2 — a crashing guard that fails open guards nothing.
5
5
  set -uo pipefail
6
- S="${CLAUDE_PLUGIN_ROOT}/skills/agent-sync/scripts/agent_sync.py"
6
+ . "${CLAUDE_PLUGIN_ROOT}/hooks/_lib.sh"
7
+ S="$AGENT_SYNC_PY"
8
+ agent_sync_configured || exit 0
7
9
  input=$(cat)
8
10
 
9
11
  path=$(python3 -c '
@@ -34,14 +36,12 @@ print((d.get("tool_input") or {}).get("command",""))
34
36
  echo "agent-sync: '$staged' is staged but this run holds no lease on it. Acquire one, or unstage it." >&2
35
37
  exit 2
36
38
  fi
37
- done < <(git diff --cached --name-only 2>/dev/null)
39
+ done < <(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" diff --cached --name-only 2>/dev/null)
38
40
  ;;
39
41
  esac
40
42
  exit 0
41
43
  fi
42
44
 
43
- [ -f "${CLAUDE_PROJECT_DIR:-$PWD}/.claude/agent-sync.json" ] || exit 0
44
-
45
45
  if out=$(python3 "$S" guard "$path" 2>&1); then
46
46
  exit 0
47
47
  else
@@ -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.2.2"
7
+ version: "1.2.4"
8
8
  author: appvillis-com
9
9
  ---
10
10
 
@@ -107,10 +107,13 @@ storage question gets asked and answered, once, and written down.
107
107
  1. **Where should coordination state live?**
108
108
  - a knowledge cloud (`outline`) — the shared record, awareness and board across
109
109
  machines. **It does not decide leases**; nothing in it can (trap 1);
110
- - or local files (`fs`) — no credentials, no shared awareness, every run `ungated`.
110
+ - or local files (`fs`) — no credentials, and no visibility to an agent on another
111
+ machine: no shared awareness, no cross-repo signals, no shared board.
111
112
 
112
113
  The lease is decided separately by `leaseBackend` — `git` for cross-machine exclusion,
113
- `local` otherwise.
114
+ `local` otherwise — and **`gated` follows that choice, never the record plane**. `fs`
115
+ with a local lock is still real exclusion between the agents on this machine; `outline`
116
+ with a local lock is *not* exclusion across them. Report the one you actually have.
114
117
  2. **If cloud: the instance URL.** The URL is configuration, not a secret, so you
115
118
  may write it. The **token is not** — you never ask for it in chat, never read it
116
119
  back, and never place it yourself.
@@ -32,13 +32,33 @@ from datetime import datetime, timezone
32
32
  from pathlib import Path
33
33
  from typing import Any
34
34
 
35
- VERSION = "1.2.2"
35
+ VERSION = "1.2.4"
36
36
 
37
37
  CONFIG_PATH = Path(".claude/agent-sync.json")
38
38
  ENV_FILE = Path(".env.agent-sync")
39
39
  STATE_DIR = Path(".agent-sync")
40
40
  GENERATED_MARKER = "<!-- agent-sync:generated"
41
41
 
42
+ # What a won lease is actually worth, in one place. Six surfaces used to phrase this
43
+ # independently and two of them named the knowledge base as the authority — a role it
44
+ # has not held since 1.0.0, when exclusion moved to a primitive the store cannot lose.
45
+ # One guarantee described two ways reads as two guarantees, and an operator acts on the
46
+ # weaker one.
47
+ LEASE_GUARANTEE = {
48
+ "git": ("exclusive across machines",
49
+ "the remote's non-fast-forward rejection is a real compare-and-swap"),
50
+ "local": ("exclusive on this machine, advisory across machines",
51
+ 'set `leaseBackend: "git"` if agents run on more than one'),
52
+ }
53
+
54
+
55
+ def lease_guarantee(mode: str) -> tuple[str, str]:
56
+ """The headline and the detail for a lease mode. Unknown modes claim nothing."""
57
+ return LEASE_GUARANTEE.get(
58
+ mode, ("NOT a lease — unknown mode, treat this project as unprotected",
59
+ f"'{mode}' is not a known leaseBackend; only {' or '.join(LEASE_GUARANTEE)}"))
60
+
61
+
42
62
  LOGS = {
43
63
  "claims": "30 Claims",
44
64
  "reservations": "40 Reservations",
@@ -615,7 +635,15 @@ class Sync:
615
635
 
616
636
  @property
617
637
  def gated(self) -> bool:
618
- return bool(self.cfg.get("gated", True)) and self.adapter.is_lease_authority
638
+ """Whether exclusion is real — decided by the lease mode, never by the record.
639
+
640
+ Until 1.2.4 this read the record adapter's capabilities, which stopped deciding
641
+ leases in 1.0.0. Both directions were wrong: `outline` with a local lock reported
642
+ `gated` while exclusion was machine-local, and `fs` with git refs reported
643
+ `ungated` while every lease was a genuine cross-machine compare-and-swap. The
644
+ plane carries the record; `leaseBackend` decides the lease.
645
+ """
646
+ return bool(self.cfg.get("gated", True)) and self.lease_mode in LEASE_GUARANTEE
619
647
 
620
648
  def log_id(self, which: str) -> str:
621
649
  """This run's OWN shard. One writer per document, always.
@@ -1377,8 +1405,8 @@ class Sync:
1377
1405
  "Every repository on this plane writes and reads this page. It carries only "
1378
1406
  "facts that are true from any of them.",
1379
1407
  "",
1380
- f"- backend: `{self.adapter.name}` · lease authority: "
1381
- f"**{'yes' if self.adapter.is_lease_authority else 'no'}**",
1408
+ f"- record plane: `{self.adapter.name}` · lease: `{self.lease_mode}` — "
1409
+ f"**{lease_guarantee(self.lease_mode)[0]}**",
1382
1410
  f"- runs are recorded as **{'gated' if self.gated else 'ungated'}**",
1383
1411
  f"- unparseable log lines: {bad}/{total}"
1384
1412
  f"{' ⚠ over 2% — the log cannot be replayed reliably' if bad / total > 0.02 else ''}",
@@ -1446,8 +1474,8 @@ class Sync:
1446
1474
  "",
1447
1475
  "## This project's wiring",
1448
1476
  "",
1449
- f"- backend: **{self.adapter.name}** · lease authority: "
1450
- f"**{'yes' if self.adapter.is_lease_authority else 'NO — degraded'}** · runs recorded "
1477
+ f"- record plane: **{self.adapter.name}** · lease: **{self.lease_mode}** — "
1478
+ f"{lease_guarantee(self.lease_mode)[0]} · runs recorded "
1451
1479
  f"**{'gated' if self.gated else 'ungated'}**",
1452
1480
  f"- lease TTL {cfg.get('leaseTtlSeconds', DEFAULT_TTL)}s, renewed every "
1453
1481
  f"{cfg.get('renewIntervalSeconds', DEFAULT_RENEW)}s",
@@ -1687,8 +1715,11 @@ def cmd_init(args: argparse.Namespace) -> int:
1687
1715
  print(" The token is yours alone: do not paste it into a chat, a commit, "
1688
1716
  "or a command line.")
1689
1717
  else:
1690
- print("Backend 'fs' needs no credentials. It is DEGRADED: it is not the lease")
1691
- print("authority, and every run is recorded as `ungated`. See references/backend-fs.md.")
1718
+ print("Backend 'fs' needs no credentials. It is the record plane only, and a")
1719
+ print("local one: agents on another machine see none of this project's leases,")
1720
+ print("signals or board. The lease itself is decided by `leaseBackend` —")
1721
+ print(f"default `local`, which is {lease_guarantee('local')[0]}.")
1722
+ print("See references/backend-fs.md.")
1692
1723
  return 0
1693
1724
 
1694
1725
 
@@ -1739,14 +1770,18 @@ def cmd_status(_args: argparse.Namespace) -> int:
1739
1770
 
1740
1771
  s = Sync()
1741
1772
  ad = s.adapter
1742
- print(f" backend : {ad.name}")
1743
- print(f" lease authority: {'yes' if ad.is_lease_authority else 'NO — degraded'}")
1773
+ headline, detail = lease_guarantee(s.lease_mode)
1774
+ print(f" record plane : {ad.name}"
1775
+ f"{'' if ad.is_lease_authority else ' — local only, not shared between machines'}")
1776
+ print(f" lease : {s.lease_mode} — {headline}")
1744
1777
  print(f" runs recorded : {'gated' if s.gated else 'UNGATED'}")
1745
1778
  print(f" run id : {s.rid}")
1746
1779
 
1747
- if not ad.is_lease_authority:
1748
- print("\n⚠ This backend cannot hold leases exclusively, so nothing here is")
1749
- print(" enforced. Do not describe this project as protected.")
1780
+ if not s.gated:
1781
+ print("\n⚠ Nothing here is enforced. Do not describe this project as protected.")
1782
+ print(f" {detail}")
1783
+ elif not s.lease_is_cross_machine:
1784
+ print(f" ({detail})")
1750
1785
 
1751
1786
  if ad.name == "outline" and isinstance(ad, OutlineAdapter) and not ad.collection:
1752
1787
  print("\n✗ AGENT_SYNC_OUTLINE_COLLECTION is empty.")
@@ -1846,15 +1881,11 @@ def cmd_acquire(args: argparse.Namespace) -> int:
1846
1881
  s = Sync()
1847
1882
  won, holder = s.acquire(args.key)
1848
1883
  if won:
1884
+ headline, detail = lease_guarantee(s.lease_mode)
1849
1885
  print(f"won {args.key} (run {s.rid}, ttl {s.ttl}s)")
1850
- if s.lease_is_cross_machine:
1851
- print(" exclusive across machines — the remote's non-fast-forward rule is a "
1852
- "real compare-and-swap")
1853
- else:
1854
- print(" exclusive between agents on THIS machine; advisory across machines. "
1855
- "Set `leaseBackend: \"git\"` for cross-machine exclusion.")
1886
+ print(f" {headline} — {detail}")
1856
1887
  if not s.gated:
1857
- print("⚠ ungated backend — this lease is advisory, not enforced")
1888
+ print("⚠ this lease is advisory, not enforced")
1858
1889
  print("Remember: release it on every path, including failure.")
1859
1890
  return 0
1860
1891
  print(f"lost {args.key} — held by {holder or 'another run'}")
@@ -2225,18 +2256,19 @@ def cmd_check(_args: argparse.Namespace) -> int:
2225
2256
  ok.append(f"{len(cfg['claimTags'])} claim-tag mapping(s) declared")
2226
2257
 
2227
2258
  mode = cfg.get("leaseBackend") or "local"
2228
- if mode not in ("local", "git"):
2229
- problems.append(f"leaseBackend '{mode}' is not a known mode")
2259
+ headline, detail = lease_guarantee(mode)
2260
+ if mode not in LEASE_GUARANTEE:
2261
+ problems.append(f"leaseBackend '{mode}': {headline} — {detail}")
2230
2262
  elif mode == "git":
2231
2263
  remote = cfg.get("leaseRemote") or "origin"
2232
2264
  if not git("remote", "get-url", remote):
2233
2265
  problems.append(f"leaseBackend is 'git' but remote '{remote}' does not exist — "
2234
- "the lease cannot be decided at all")
2266
+ f"the lease cannot be decided at all ({headline} claimed, "
2267
+ "none delivered)")
2235
2268
  else:
2236
- ok.append(f"lease decided by git refs on '{remote}' — exclusive across machines")
2269
+ ok.append(f"lease decided by git refs on '{remote}' — {headline}")
2237
2270
  else:
2238
- warn.append("lease is a local file lock: exclusive on this machine, advisory "
2239
- "across machines. Set leaseBackend to 'git' if agents run on more than one")
2271
+ warn.append(f"lease is a local file lock: {headline}. {detail[0].upper()}{detail[1:]}")
2240
2272
 
2241
2273
  for cmd in (cfg.get("gates") or []):
2242
2274
  exe = cmd.split()[0]