@ssheleg/agent-sync 1.20.0 → 1.20.2
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 +54 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/hooks/guard.sh +4 -2
- package/plugins/agent-sync/hooks/hooks.json +1 -2
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +1 -1
- package/plugins/agent-sync/skills/agent-sync/references/hooks.md +10 -1
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +95 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
+
## v1.20.2 — the override that could not reach the plane the state was on
|
|
2
|
+
|
|
3
|
+
**161 expired lease refs on one remote, from two runs that ended five days earlier, and
|
|
4
|
+
no command in this tool could clear any of them.** Measured on the operator's machine
|
|
5
|
+
2026-09-14: `residue` listed every one, `reap` left them alone (correctly — a ref in a
|
|
6
|
+
dead run's name is `foreign`), and `reap --i-own-this`, the one path a person has for
|
|
7
|
+
exactly this, answered *there is no lock by that name in this checkout*. True, and
|
|
8
|
+
useless: in git mode the authority is `refs/agent-sync/leases/*` on the REMOTE, and the
|
|
9
|
+
override walked the local lock directory only.
|
|
10
|
+
|
|
11
|
+
This is AS-01b returning on the other plane. That row closed *"expired locks accumulate
|
|
12
|
+
with no path out for anybody"* for the filesystem; the same sentence was true of the git
|
|
13
|
+
plane the whole time, and the tool that reports it could not act on it.
|
|
14
|
+
|
|
15
|
+
- `_reap_by_operator_decision` now reads **both planes** and collects entries per key as a
|
|
16
|
+
LIST — a key can be a lock file here AND a ref there, and clearing one while calling the
|
|
17
|
+
key done is how the ref survived every sweep that ran. A git entry is deleted through
|
|
18
|
+
`git_reap`'s existing `--force-with-lease` compare-and-swap and proved gone by a second
|
|
19
|
+
`ls-remote`, never by the push's exit code.
|
|
20
|
+
- **Every refusal the override had, it keeps**, now per plane: a LIVE lease is refused and
|
|
21
|
+
named, a key nobody holds is reported rather than guessed at, the destroyed payload is
|
|
22
|
+
printed with its plane so the decision stays auditable, and the journal line carries it.
|
|
23
|
+
- **A remote that cannot be read says so** instead of answering "no such lock" — that
|
|
24
|
+
answer reads as *there is nothing to clear* over state that may well be there.
|
|
25
|
+
|
|
26
|
+
Three cases in `test/claim_cell_test.py` (27 total): the override clears a dead run's ref
|
|
27
|
+
and proves it gone, a live ref is still refused, and an unreachable remote does not read
|
|
28
|
+
as a missing key.
|
|
29
|
+
|
|
30
|
+
## v1.20.1 — the filter that was never read, and the check that would have said so
|
|
31
|
+
|
|
32
|
+
Claude Code 2.1.270 prints `agent-sync: hooks.json: unknown key "if" in
|
|
33
|
+
hooks.PreToolUse[1] ignored` at every session start. The key was there since 0.1.0.
|
|
34
|
+
|
|
35
|
+
- **`"if": "Bash(git commit *)"` sat beside `matcher`, and a matcher group is only
|
|
36
|
+
`matcher` + `hooks`** — read out of the 2.1.270 binary's schema, where `if` exists on a
|
|
37
|
+
command HANDLER only. So the filter was declared and never evaluated: `guard.sh` has run on
|
|
38
|
+
every Bash call in a coordinated project for its whole life, narrowing to a commit with its
|
|
39
|
+
own parser. Nothing in behaviour changes with the key gone; the warning does.
|
|
40
|
+
- **Removed rather than moved.** A handler-level `if` would be real, and a real
|
|
41
|
+
`Bash(git commit *)` skips `git -C <dir> commit`, `env X=1 git commit` and
|
|
42
|
+
`cd d && git commit` — the three forms the parser covers and the 2026-08-07 measurement was
|
|
43
|
+
about. hooks.json's description, `guard.sh`'s header, `references/hooks.md`, the README hook
|
|
44
|
+
table and the live AS-09 row now say the same thing. `CHANGELOG` v1.18.6 and the 2026-07-29
|
|
45
|
+
spec keep their wording: dated records.
|
|
46
|
+
- **`check_hooks_manifest` now refuses any key Claude Code does not know** — at group level
|
|
47
|
+
`{matcher, hooks}`, on a handler `{type, command, args, if, shell, timeout, statusMessage,
|
|
48
|
+
once, async, asyncRewake}` — with a self-test plant that puts the old `if` back and watches
|
|
49
|
+
the check fire. `claude plugin validate --strict` passes the defective file, so a repo gate
|
|
50
|
+
is the only place this fails before a session start.
|
|
51
|
+
- `test/audit_regressions/fix-sy-07.01.py` derived "git commit is covered" from the group's
|
|
52
|
+
`if` — a declaration that never filtered. It now asserts the matchers, the description and
|
|
53
|
+
the absence of `if` at both levels.
|
|
54
|
+
|
|
1
55
|
## v1.20.0 — the two-sources duty stops carrying its own elaboration
|
|
2
56
|
|
|
3
57
|
Sherlock external-v3 (14 findings) plus the House audit gap they introduced.
|
package/README.md
CHANGED
|
@@ -403,7 +403,7 @@ never grants one that would otherwise be denied.
|
|
|
403
403
|
| Hook | Runs | Effect |
|
|
404
404
|
|---|---|---|
|
|
405
405
|
| `SessionStart` | startup, resume | `status` — the board summary, other runs, one next action |
|
|
406
|
-
| `PreToolUse` | `Edit`/`Write`/`MultiEdit`/`NotebookEdit`, and `git commit` | Denies the edit (exit 2) when the path is guarded and this run holds no lease; a `git commit` is checked against every staged path |
|
|
406
|
+
| `PreToolUse` | `Edit`/`Write`/`MultiEdit`/`NotebookEdit`, and every `Bash` call — of which only a parsed `git commit` is acted on | Denies the edit (exit 2) when the path is guarded and this run holds no lease; a `git commit` is checked against every staged path |
|
|
407
407
|
| `PostToolUse` | every tool call | Throttled `renew` — touches the network at most once per `renewIntervalSeconds` |
|
|
408
408
|
| `SessionEnd` | session end | Releases every lease this run holds |
|
|
409
409
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.2",
|
|
4
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"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "agent-sync",
|
|
4
4
|
"displayName": "Agent Sync",
|
|
5
|
-
"version": "1.20.
|
|
5
|
+
"version": "1.20.2",
|
|
6
6
|
"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.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "ssheleg",
|
|
@@ -32,8 +32,10 @@ S="$AGENT_SYNC_PY"
|
|
|
32
32
|
agent_sync_configured || exit 0
|
|
33
33
|
input=$(cat)
|
|
34
34
|
|
|
35
|
-
# The parser IS the guard: hooks.json
|
|
36
|
-
#
|
|
35
|
+
# The parser IS the guard: hooks.json carries no `if` filter (the one it declared until
|
|
36
|
+
# v1.20.1 sat at the group level, where Claude Code never evaluated it, and a handler-level
|
|
37
|
+
# one would skip `git -C`/env/compound commits), so nothing upstream compensates for a
|
|
38
|
+
# parser that cannot run. A python3
|
|
37
39
|
# missing from PATH used to leave `path` empty, default `is_commit` to 0 and exit 0 —
|
|
38
40
|
# the one machine state that disables the parser silently disabled the guard (ASY-07).
|
|
39
41
|
parser_or_die() {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "agent-sync — ADVISORY lease guard and run lifecycle. The PreToolUse guard covers exactly the mutation surfaces a Claude Code matcher can name: Edit, Write, MultiEdit, NotebookEdit, and a Bash `git commit` (parsed for compound/`-C`/env forms). It is NOT a sandbox: a language-runtime write (Python write_text), a `sed -i`, or a shell redirect is OUTSIDE the boundary and is not enforced — see the capability matrix in guard.sh. Real enforcement needs a trusted mutation API, an isolated worktree, or OS controls with resource locks, not a regex shell parser. Every hook exits 0 immediately when the project has no .claude/agent-sync.json, so installing the plugin globally changes nothing in projects that do not use it.",
|
|
2
|
+
"description": "agent-sync — ADVISORY lease guard and run lifecycle. The PreToolUse guard covers exactly the mutation surfaces a Claude Code matcher can name: Edit, Write, MultiEdit, NotebookEdit, and a Bash `git commit` (parsed for compound/`-C`/env forms). It is NOT a sandbox: a language-runtime write (Python write_text), a `sed -i`, or a shell redirect is OUTSIDE the boundary and is not enforced — see the capability matrix in guard.sh. Real enforcement needs a trusted mutation API, an isolated worktree, or OS controls with resource locks, not a regex shell parser. The Bash group carries NO `if` filter on purpose: guard.sh narrows to a git commit itself. A group-level `if` is not a key Claude Code knows (it is ignored and, from 2.1.270, reported as unknown), and a handler-level one would skip the `git -C`, env-prefixed and compound forms the parser covers. Every hook exits 0 immediately when the project has no .claude/agent-sync.json, so installing the plugin globally changes nothing in projects that do not use it.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"matcher": "Bash",
|
|
31
|
-
"if": "Bash(git commit *)",
|
|
32
31
|
"hooks": [
|
|
33
32
|
{
|
|
34
33
|
"type": "command",
|
|
@@ -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.20.
|
|
7
|
+
version: "1.20.2"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -57,7 +57,7 @@ The hook receives JSON on stdin with `session_id`, `prompt_id`, `transcript_path
|
|
|
57
57
|
"PreToolUse": [
|
|
58
58
|
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
59
59
|
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }] },
|
|
60
|
-
{ "matcher": "Bash",
|
|
60
|
+
{ "matcher": "Bash",
|
|
61
61
|
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard.sh" }] }
|
|
62
62
|
],
|
|
63
63
|
"PostToolUse": [
|
|
@@ -71,6 +71,15 @@ The hook receives JSON on stdin with `session_id`, `prompt_id`, `transcript_path
|
|
|
71
71
|
}
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
The `Bash` group has no `if` filter, and that is deliberate. Until v1.20.1 it declared
|
|
75
|
+
`"if": "Bash(git commit *)"` beside `matcher` — a key Claude Code does not know at
|
|
76
|
+
the group level, so it was never evaluated (and 2.1.270 started saying so at every
|
|
77
|
+
session start). Moving it into the handler would make it real, and a real one skips
|
|
78
|
+
`git -C <dir> commit`, `env X=1 git commit` and `cd d && git commit` — the forms the
|
|
79
|
+
parser in `guard.sh` was written to cover. So `guard.sh` runs on every Bash call in a
|
|
80
|
+
coordinated project, exits 0 in a few milliseconds when the command is not a commit,
|
|
81
|
+
and the parser is the whole narrowing.
|
|
82
|
+
|
|
74
83
|
| Hook | Job |
|
|
75
84
|
|---|---|
|
|
76
85
|
| `session-start.sh` | Register the run, print the board summary and the one next action |
|
|
@@ -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.20.
|
|
36
|
+
VERSION = "1.20.2"
|
|
37
37
|
|
|
38
38
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
39
39
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -4327,6 +4327,18 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
|
|
|
4327
4327
|
* **it prints the payload it destroyed** — run, timestamp, machine — so the decision is
|
|
4328
4328
|
auditable afterwards by somebody who was not there, and journals it where a record
|
|
4329
4329
|
plane is configured.
|
|
4330
|
+
|
|
4331
|
+
**Both planes, because AS-01b came back on the other one.** Until v1.20.2 this walked
|
|
4332
|
+
the lock DIRECTORY only. In git mode the authority is `refs/agent-sync/leases/*` on the
|
|
4333
|
+
remote, a ref outlives the checkout that wrote it, and `reap` clears a git ref only when
|
|
4334
|
+
the classifier calls it `reapable` — this run's own. So an expired ref in a DEAD run's
|
|
4335
|
+
name had no path out from anywhere: `residue` listed it, `reap` left it alone, and
|
|
4336
|
+
`--i-own-this` answered *there is no lock by that name in this checkout*, which is true
|
|
4337
|
+
and useless. Measured on this machine 2026-09-14: **161 refs from two runs that ended on
|
|
4338
|
+
2026-09-09 and 2026-09-10**, every one expired more than four days against a 2700-second
|
|
4339
|
+
TTL, with no command able to reach them. That is the identical shape AS-01b closed for
|
|
4340
|
+
the local plane — "expired locks accumulate with no path out for anybody" — and the
|
|
4341
|
+
remedy is the identical one, applied where the state actually lives.
|
|
4330
4342
|
"""
|
|
4331
4343
|
if not keys:
|
|
4332
4344
|
print("reap --i-own-this needs the keys, one or more, by name.\n"
|
|
@@ -4336,48 +4348,94 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
|
|
|
4336
4348
|
file=sys.stderr)
|
|
4337
4349
|
return 2
|
|
4338
4350
|
|
|
4339
|
-
|
|
4351
|
+
# A key can exist on BOTH planes — a lock file here and a ref on the remote — and the
|
|
4352
|
+
# two are separate pieces of state with separate deletes. Collected as a LIST per key
|
|
4353
|
+
# rather than a dict, because clearing one and calling the key done is how the git ref
|
|
4354
|
+
# survived every sweep that ever ran.
|
|
4355
|
+
by_key: dict[str, list[dict[str, Any]]] = {}
|
|
4356
|
+
|
|
4357
|
+
def offer(entry: dict[str, Any]) -> None:
|
|
4358
|
+
by_key.setdefault(entry["key"], []).append(entry)
|
|
4359
|
+
stem = s._local_lock(entry["key"]).stem
|
|
4360
|
+
if stem != entry["key"]:
|
|
4361
|
+
by_key.setdefault(stem, []).append(entry)
|
|
4362
|
+
|
|
4340
4363
|
for e in s.residue():
|
|
4341
|
-
|
|
4342
|
-
|
|
4364
|
+
e.setdefault("plane", "fs")
|
|
4365
|
+
offer(e)
|
|
4366
|
+
git_unreadable = None
|
|
4367
|
+
if s.lease_mode == "git":
|
|
4368
|
+
refs, git_unreadable = s.git_residue()
|
|
4369
|
+
for e in refs:
|
|
4370
|
+
offer(e)
|
|
4371
|
+
if git_unreadable is not None:
|
|
4372
|
+
print(f" ⚠ the git plane could not be read ({git_unreadable}) — anything on it "
|
|
4373
|
+
"is neither cleared nor\n reported clean. Enumerate by hand: git "
|
|
4374
|
+
f"ls-remote {s.cfg.get('leaseRemote') or 'origin'} "
|
|
4375
|
+
"'refs/agent-sync/leases/*'", file=sys.stderr)
|
|
4343
4376
|
|
|
4344
4377
|
rc = 0
|
|
4345
4378
|
for k in keys:
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
f"{' · host ' + e['host'] if e.get('host') else ''}"
|
|
4360
|
-
f"{' · ' + e['ts'] if e.get('ts') else ''}"
|
|
4361
|
-
f" · {spent(e)}")
|
|
4362
|
-
try:
|
|
4363
|
-
e["path"].unlink()
|
|
4364
|
-
except OSError as exc:
|
|
4365
|
-
print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
|
|
4366
|
-
rc = 1
|
|
4367
|
-
continue
|
|
4368
|
-
# Proved gone by looking again, the same rule the ordinary reap follows.
|
|
4369
|
-
if any(x["key"] == e["key"] for x in s.residue()):
|
|
4370
|
-
print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
|
|
4371
|
-
"verified, whatever the call returned", file=sys.stderr)
|
|
4379
|
+
entries = by_key.get(k) or by_key.get(s._local_lock(k).stem) or []
|
|
4380
|
+
# Deduplicate: the stem alias can offer the same object twice.
|
|
4381
|
+
seen_ids, unique = set(), []
|
|
4382
|
+
for e in entries:
|
|
4383
|
+
if id(e) in seen_ids:
|
|
4384
|
+
continue
|
|
4385
|
+
seen_ids.add(id(e))
|
|
4386
|
+
unique.append(e)
|
|
4387
|
+
if not unique:
|
|
4388
|
+
where = "this checkout" if s.lease_mode != "git" else (
|
|
4389
|
+
"this checkout or on the remote" if git_unreadable is None
|
|
4390
|
+
else "this checkout (the remote could not be read)")
|
|
4391
|
+
print(f" · {k} — there is no lock by that name in {where}", file=sys.stderr)
|
|
4372
4392
|
rc = 1
|
|
4373
4393
|
continue
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4394
|
+
for e in unique:
|
|
4395
|
+
plane = e.get("plane", "fs")
|
|
4396
|
+
if e["state"] == LIVE:
|
|
4397
|
+
print(f" ✗ {e['key']} [{plane}] is LIVE under {e.get('run') or 'a run'}"
|
|
4398
|
+
f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
|
|
4399
|
+
"is for residue;\n a live lease belongs to a run that may still be "
|
|
4400
|
+
"working. Ask the holder, or wait for the TTL.", file=sys.stderr)
|
|
4401
|
+
rc = 1
|
|
4402
|
+
continue
|
|
4403
|
+
had = (f"run {e.get('run') or 'unknown'}"
|
|
4404
|
+
f"{' · host ' + e['host'] if e.get('host') else ''}"
|
|
4405
|
+
f"{' · ' + e['ts'] if e.get('ts') else ''}"
|
|
4406
|
+
f" · {spent(e)}")
|
|
4407
|
+
if plane == "git":
|
|
4408
|
+
# The same compare-and-swap the ordinary reap uses, and the same proof:
|
|
4409
|
+
# a second read of the remote, never the push's exit code.
|
|
4410
|
+
done = s.git_reap([e])[0]
|
|
4411
|
+
if not done.get("gone"):
|
|
4412
|
+
print(f" ✗ {e['key']} is STILL on the remote after the delete "
|
|
4413
|
+
f"({done.get('why_gone') or 'no reason given'}) — either somebody "
|
|
4414
|
+
"won it between the read\n and the delete, or the remote "
|
|
4415
|
+
"refused. Nothing was reported as cleared.", file=sys.stderr)
|
|
4416
|
+
rc = 1
|
|
4417
|
+
continue
|
|
4418
|
+
else:
|
|
4419
|
+
try:
|
|
4420
|
+
e["path"].unlink()
|
|
4421
|
+
except OSError as exc:
|
|
4422
|
+
print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
|
|
4423
|
+
rc = 1
|
|
4424
|
+
continue
|
|
4425
|
+
# Proved gone by looking again, the same rule the ordinary reap follows.
|
|
4426
|
+
if any(x["key"] == e["key"] for x in s.residue()):
|
|
4427
|
+
print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
|
|
4428
|
+
"verified, whatever the call returned", file=sys.stderr)
|
|
4429
|
+
rc = 1
|
|
4430
|
+
continue
|
|
4431
|
+
print(f" cleared {e['key']} [{plane}] by operator decision — it held {had}")
|
|
4432
|
+
print(f" the classifier called it `{e['state']}`, and that has not changed: this "
|
|
4433
|
+
"was a person's\n call, not a proof of ownership.")
|
|
4434
|
+
try:
|
|
4435
|
+
s.journal(f"reap --i-own-this {e['key']} [{plane}] — was {had}, "
|
|
4436
|
+
f"classified {e['state']}")
|
|
4437
|
+
except Exception: # noqa: BLE001 - the record plane is optional
|
|
4438
|
+
pass
|
|
4381
4439
|
return rc
|
|
4382
4440
|
|
|
4383
4441
|
|