@ssheleg/agent-sync 1.2.3 → 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 +29 -0
- package/README.md +22 -14
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +6 -3
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-314.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +58 -26
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
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
|
+
|
|
6
35
|
## 1.2.3 — 2026-07-29
|
|
7
36
|
|
|
8
37
|
### Fixed — the guard blocked commits in projects that never installed agent-sync
|
package/README.md
CHANGED
|
@@ -282,19 +282,24 @@ configuration defect.
|
|
|
282
282
|
|
|
283
283
|
## Backends
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
|
290
|
-
|
|
291
|
-
| `outline` |
|
|
292
|
-
| `fs` |
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.**
|
|
335
|
-
|
|
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
|
-
| `⚠
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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.
|
|
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
|
|
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.
|
|
Binary file
|
|
@@ -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.
|
|
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
|
-
|
|
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"-
|
|
1381
|
-
f"**{
|
|
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"-
|
|
1450
|
-
f"
|
|
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
|
|
1691
|
-
print("
|
|
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
|
-
|
|
1743
|
-
print(f"
|
|
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
|
|
1748
|
-
print("\n⚠
|
|
1749
|
-
print("
|
|
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
|
-
|
|
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("⚠
|
|
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
|
-
|
|
2229
|
-
|
|
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}' —
|
|
2269
|
+
ok.append(f"lease decided by git refs on '{remote}' — {headline}")
|
|
2237
2270
|
else:
|
|
2238
|
-
warn.append("lease is a local file lock:
|
|
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]
|