Repository navigation
Releases: ssheleg/agent-sync
Release list
v1.23.4 — agent-sync
- The guard fails silent on an Edit payload of the wrong shape (hub B-211 follow-up): a payload
that is not an object, atool_inputthat is a string, or afile_paththat is not a string
raised inside the parser, and the parser's non-zero exit failed CLOSED (exit 2) in a configured
session. Such a payload names no file and now passes, as the apply_patch branch already did.
Three GUARD_SHAPES rows; two of them fail on 1.23.3. - The generated
AGENT_SYNC.mdcarries no em dash (hub B-289): the setup snapshot's headings,
table cells, wiring and credential lines, and the generated-file marker use a colon, semicolon
or parentheses. The marker keeps itsagent-sync:generated source=... cfg=... at=...fields;
a project's snapshot changes on its nextagent_sync.py setup.
v1.23.3 — agent-sync
- Codex edits are guarded (hub board row B-211). Codex sends every file edit as one tool,
apply_patch, with the patch text intool_input.commandand nofile_path. The PreToolUse
matcher did not name it, and an empty path fell into the Bash branch, which parsed the patch as a
shell command: a guarded file edited through a patch passed, and a patch whose body held
> DECISIONS.mdorgit commitwas refused. The matcher now namesapply_patch;guard.sh
reads every*** Add File:,*** Update File:,*** Delete File:and*** Move to:path from
the patch (relative paths against the payload'scwd), checks each like an Edit, and never hands
the patch to the Bash branch. A payload that does not parse names no path and passes silently.
Twelveapply_patchrows inGUARD_SHAPES(test/validate.py); eight of them fail on 1.23.2. - Board: AS-23 and AS-24 are closed with their receipt (PR #35,
8c93030, released in v1.22.2);
their status had read "merge pending" since the merge.
v1.23.2 — agent-sync
- Description within a 300-character budget (hub board row B-255 (a)): Claude Code lists every
skill's description in one ~30,000-character budget, and on the operator's machine the listing
stood at 51,178. The description falls from 447 to 277 characters (hub measure); the phrases it
dropped (the submodule and parent-pointer pair among them) move to a new## Triggerssection,
where the family routing hook still finds them.
v1.23.1 — agent-sync
test/family_gate.py, the family's canonical member gate (hub B-108, B-109): the board
parses (every row as wide as its header, no id twice) andrelease.ymlkeeps the tag-ancestry
check (fetch-depth: 0,git merge-base --is-ancestor), each watched failing on a plant by
--self-test. Wired into the gate; a byte-for-byte copy of the hub'smember-gate/family_gate.py.
v1.23.0 — agent-sync
SessionStart says only what this run must act on (AS-25). The hook printed the full
status at every startup, resume and /clear: 834 characters in the hub checkout
(measured 2026-10-20), mostly the setup and other runs' residue, closing on "NEXT: acquire
a lease", which the family's routing block already says and a read-only session never
needs. It now runs status --brief: one line naming the leases this run holds, a lease
held past scheduledIntervalSeconds, this run's own expired locks (with reap) and claim
tags out of step with the leases, or nothing at all. The brief reads local state only (no
record plane, no remote, no vault) and leaves the signal watermark alone, so the next
status still shows what is new. It prints on startup only; resume and clear still
stamp the session (the matcher keeps both, ASY-08) and print nothing, without starting
python. AGENT_SYNC_SESSION_VERBOSE=1 restores the full block; status is unchanged.
Measured interleaved on this machine at load average ~470: startup 834 to 0 characters,
median 1037 to 533 ms (p90 1399 to 853 ms, n=15 each); resume 920 to 63 ms median.
The PreToolUse guard answers inside its budget (AS-26). In git lease mode the first
write of a guarded file took that file's claim on the lease remote, synchronously: one or
two ls-remote calls (1.6 s each to github.com) and a push with no timeout, plus a cloud
record-plane append where one is configured. Measured against github.com, the 1.22.2 guard
took 8.6, 8.9 and 10.9 s for three first writes; the 2026-10-20 audit saw 10 to 20 s
against the hook's 20 s timeout, past which the host lets the call through anyway. The
claim now runs in a forked, detached process and the guard waits for it only until
AGENT_SYNC_GUARD_BUDGET (1.5 s from the coordinator's start by default; 0 restores the
synchronous claim). Past the budget the write proceeds under the task lease; the claim
then lands and leaves this run's local note, or records the run that holds it, and the
next write of that file is refused from that record without a round-trip (rechecked after
60 s). A second write of the same file while the claim is in flight waits for that claim
instead of racing it, and a claim this run already won is never reported as another
run's. The acquire push is bounded at 60 s. Local leases on a local record plane are
unchanged: they are a file create and are never backgrounded. Same three files on the
same remote with 1.23.0: 4.0, 5.2 and 4.0 s, with the remaining time spent in local file
writes on a machine at load average ~500 (two 50-byte writes profiled at 1.2 s).
The skill description is a router again (AS-27): 445 characters, down from 936; every
trigger the hub routes on (lib/triggers.js, route agent-sync) is still in it, in
English and Russian, with the boundary and the refusal phrase. The "before editing a
shared registry, even unasked" rule moved into the body.
- Tests:
test/hooks_session_test.pygains 8 cases (7 watched failing on5bd8bec);
newtest/guard_budget_test.py, 5 cases over a local bare remote with a git shim that
slowsls-remoteandpushby 5 s (3 watched failing on5bd8bec: guard calls of
11.06 s, 5.51 s and 11.36 s). Both run innpm testand CI. - Not covered: the window between a write that proceeds and its claim landing; a run that
writes a file another run claimed in that window is refused only from its next write.
Trigger evals were not re-measured for the new description (backlog AS-28).
v1.22.2 — agent-sync
- Automatic heartbeats renew only claims with recent guarded activity or explicit
acquisition/renewal. Unrelated tool calls no longer extend every task indefinitely.
Manual renewal remains intentional. The activity window is two renewal intervals;
the configured lease TTL still controls exclusion from the last successful renewal. - A guard requires a live task and durable activity/binding metadata. Missing task
ownership or an unwritable binding refuses admission while retaining the claim for
explicit repair. Expired ambiguous task associations are cleared, not guessed. - Releasing a handed-off task releases its associated file claims without freeing
another task's files. Incomplete authority release remains retryable; activity-file
cleanup after confirmed release is best-effort and cannot resurrect a lease. - Document prompt release after a pushed PR handoff and distinguish dependency
signalstate from a cross-project board ask. Existing lease keys, arbitration,
generations and wire payloads are unchanged. Old installed heartbeats retain their
old behavior until upgraded. - Verification: 19 real CLI/fake-clock controls, including same-second metadata
failures and a local bare Git remote. The source candidate passed 20 suites and
all 75 existing negative plants before this synchronized packaging bump.
v1.22.1 — agent-sync
Patch from a re-certification reading of 1.22.0 (517b4b6).
A PR body that mentions git commit is no longer a commit. When a Bash command does not
tokenise (an odd-quote heredoc), 1.22.0's AS-18 fallback in bash_writes.py counted the
payload as a commit if git … commit appeared ANYWHERE in it — so gh pr create --body "$(cat <<'EOF' … every git commit … a 12" display … EOF)" with a guarded file staged and no
lease was refused with "this commit … stages a guarded file". The fallback now reads only
command text: backslash-newline continuations joined, each balanced $(…) and backtick span
removed (quote-aware, nested), each heredoc body removed (from the <<DELIM / <<'DELIM' /
<<-DELIM line through the line that is exactly DELIM) and comments dropped; an opener that
never closes ends the text there. In what remains, split into segments outside quotes, a
commit counts only where a segment starts with git … commit (after env assignments, the
usual prefixes and git -C <dir> / -c k=v).
The fallback checks the repository the commit runs in. git -C <dir> commit … and
cd <dir> && git commit … now name that repository's index, not the payload's cwd. A
directory it cannot name (cd -, cd "$(…)", a $VAR, a path that does not exist) falls
back to cwd, as guard.sh already did for a repo that is not a directory.
test/coordination_test.py: case "the untokenisable commit fallback reads only a git
commit command" — 52 cases. Against517b4b6, watched failing: the PR body exits 2
(want 0);git -C <other> commitwith the guarded file staged only in cwd exits 2 (want 0)
and with it staged only in<other>exits 0 (want 2);cd <other> && git committhe same
(0, want 2). The existing odd-quotegit commit -m "$(cat <<'EOF'…)"case stays exit 2.- Blind reading of the first candidate (
ea936ae, PR #33) failed it: it cut the command at
the FIRST$(/<<, so a commit after a leading substitution was missed — main refused
these, the candidate passed them. Added, each watched exiting 0 onea936ae(want 2):
cd "$(git rev-parse --show-toplevel)" && git commit -m <odd heredoc>,
MSG="$(cat <<'EOF' … it's "x … EOF)" && git commit -m "$MSG",
git add "$(git ls-files -m)" && git commit -m <odd heredoc>(at the parser,
(False, None)onea936ae, now a commit in cwd), andgit \<newline> commit -m <odd heredoc>(continuation). Regression guards that pass on both:echo `date` && git commit -m <odd heredoc>exits 2; a heredoc body sayinggit commitis not a commit. references/hooks.md"A commit" states the fallback;guard.sh's capability matrix has
a row for an untokenisable commit. README'sscheduledIntervalSecondsrow and the
ledger's S9 row describe the held-too-long notice as one JSON object:systemMessagefor
the person,hookSpecificOutput.additionalContextfor the model.- Not covered: what an untokenisable command stages before its commit (only the index is
read); a)in acasepattern inside$(…)closes the span early; a heredoc opened
inside a$(…)that closes on the same line keeps its body as command text.
v1.22.0 — agent-sync
Integration wave 3 of the family's 2026-10-11 waves run (hub plan R17–R22), and two fix
rounds after blind verifiers failed the first candidate (27f485a) and the second
(e32c7c9, PR #32). test/coordination_test.py joins npm test with 50 cases. Against
1.21.7, 30 of the first 33 fail (13 on an assertion, 17 because the code they call does not
exist there) and 3 pass — "an id really missing is still a leak", "a short key keeps its
readable id" and "the same key is the same run every time" are regression guards. Against
the first candidate 27f485a, the 14 cases added in the first fix round all fail, as do the
extended parser case and the long-expired case (its message changed from > 7d to >= 7d).
Against the second candidate e32c7c9, the 17 cases added in the second fix round all fail
(11 on an assertion, 6 with an exception — three call a function that does not exist there, one a function whose signature changed, one hits the very ImportError it pins, one parses the hook's empty output), as do the
extended parser case and the vault-cache case.
npm test now runs every CI test suite, sandboxed. It runs test/run_gate.py: every suite
CI runs — the five it ran before, every test/audit_regressions/*.py (CI ran them and
npm test did not, so CI went red on FIX-SY-04.02 behind a green local gate) and the
installer test — each with its own temporary HOME and without CLAUDE_CODE_SESSION_ID,
CLAUDE_SESSION_ID or AGENT_SYNC_RUN_ID, then checks that the HOME it was started with has
the same ~/.agent-sync files as before. A test run had written 14 files into the
operator's real ~/.agent-sync/session-repos: claim_cell_test.py's cli() passed the
real HOME and the live session id through, and SESSION_REPOS_DIR was resolved at import.
Every suite now calls test/_sandbox.py first (a temp HOME that also holds its mkdtemp
fixtures, removed at exit), session_repos_dir() reads HOME at each call, and the case
"the suite never writes the HOME it was given" fails without the sandbox (watched: a
sentinel HOME received 3 files). The other direction too: CI's validate job had never run
guard_uncoordinated_test.py or coordination_test.py, which npm test did; it runs both
now, so the two gates run the same set.
Fix round 2 (second blind reading of e32c7c9)
- SKILL.md states the two write modes again (FIX-SY-04.02, CI red). The compaction
had dropped the paragraph its audit regression pins: a task lease authorizes the TASK,
never the file; the resource claim is the short transaction lock; heavy overlap takes an
isolated worktree + merge; the guard does NOT promise enforcement from a single task
owner. Body 4726/4750 tokens (the$SKILL_DIRparagraph and the interval line trimmed). git add X && git commitin one call is guarded (AS-16). The hook reads the index
before the command runs, so X was not staged yet and the commit passed. The commit parser
moved intobash_writes.py --commit(the same tokeniser as the write parse) and reads
what the same command stages —git add <specs>,-A,-u,.,git commit -a,
git commit <paths>— askinggit ls-fileswhich files that is; those are checked with
the index in ONE coordinator call.statusnames a token read from the file as degraded (unit B1).statusloaded the
file, then Sync() loaded it again, found the token in the environment and relabelled it
"from the environment". The source is now assigned once per process.- The vault is asked once per process, and only where a token is used. The decision is
memoised per root before any child starts (risk b).renewandrelease --heldno longer
ask at all (seam S2/S3): the lease they act on is a local lock or a git ref, and what the
token would add is one visibility line in the Notion claims log, whose readers apply the
TTL anyway — while renew.sh runs after every tool call (twouseaudit rows per call
once a slot existed) and SessionEnd has 2 s, which a probe and a re-run consumed. With the
token in the plaintext file they still publish. A re-run keeps the hook's clock: the first
process records its start inAGENT_SYNC_HOOK_T0beforeexecv(risk a). Therunprobe
is cached beside the slot answer (a pass for the hour, a refusal for a minute), and a
missing slot is asked again after a minute even with no file token (risk g). - A host without
fcntlno longer crashesacquireafter the lease was won (seam S4). - The guard is cheaper and gated on the repository that owns the write (seam S5/S6/S8).
bash_writes.py --guardedresolves each target's repository by walking to.git, drops
every target noguardedFilespattern matches, and the hook runs the coordinator once per
owning repository with all its targets;guardmemoises its read-only git questions; a
Bash call spawns no interpreter until its payload carries a write shape (a>, or
tee/sed/cp/mv as a WORD —usedandguaranteeno longer match) orcommit; hook
bytecode is cached under~/.cache/agent-sync/pycacheinstead of recompiled. Because
the owner decides, a session rooted outside every coordinated repository that writes into
one is refused at the write. renew.sh decides without reading stdin wherever it can and
reads only the payload's first 4 KB otherwise. Measured interleaved againste32c7c9
under load average ~45 (absolute numbers on this machine are noisy; the spawn counts are
not): a coordinated two-target redirect with the lease held 244 ms (git 2, python3 3) vs
477 ms (git 10, python3 4); renew.sh on a 2 MB PostToolUse payload in an uncoordinated
repository 8.1 ms vs 89.1 ms (medians of 15). mvwrites its source too (seam S7), and the parser reads<<\EOF,<( … )and
>( … ),cd -L/-P,timeout/nice/stdbuf/xargsprefixes,sed --expression=,
env -C DIR, acdin a pipeline or behind&(no move),<>(a write), and an absolute
cp/mvdestination directory whatever the current directory is (risk d). What it still
skips is listed as UNPARSED inguard.shand the module docstring. Itsmain()prefilter
no longer missessed 's/;/x/' -i f(risk c).- A lease held too long is said where the session sees it (seam S9). R19's warning
printed only fromstatusat SessionStart, when a fresh session holds nothing. The guard
now emits it once per lease per run, as one JSON object withsystemMessage(the person)
and PreToolUseadditionalContext(the model) — see the final polish below. - Smaller corrections.
init --backend outlineno longer saysset -a && . ./.env.agent-sync
(every command and hook reads the file itself); the generated wiring snapshot names the
vault slot first fornotion, the file only as a degraded fallback;checkenforces the
schema'sscheduledIntervalSecondsminimum of 60; a lease ref is flagged keyless only when
its payload parses as a JSON object withoutkey, and the warning says it "was written by
≤1.21.2 (ts …)" — a date, not proof of a live writer; citations of project-observatory
DECISIONS.md corrected to :2023 and :12873.
Final polish (third blind reading of 6ab4c47)
git commit --gpg-sign <path>is guarded. The AS-16 parser took--gpg-signas an
option with a value, so the pathspec after it was swallowed and the commit passed with a
guarded file unleased; the key id attaches only as--gpg-sign=<keyid>. Case added to
"git add and commit in one call is guarded" (it fails on6ab4c47: the plan staged nothing).- A heredoc commit message with an odd number of quotes is still a commit (AS-18). The
tokenizer closes a"…"string at the first inner quote, so such a message did not parse
and the commit check was skipped, staged guarded files included (1.21.x skipped every
heredoc commit). It is now checked against the index of the payload's directory. Case
added; on6ab4c47commit_plananswered "not a commit". - The held-too-long notice reaches the model, once (AS-21). Claude Code shows a
systemMessageto the person only, and it reads two JSON lines on stdout as a parse
failure that drops both — which a Bash write into two repositories, each with a lease held
too long, printed. The guard now prints one object withsystemMessageand
hookSpecificOutput.additionalContext, merging several repositories' notices. Cases "a
lease held too long is said once where the session sees it" (extended) and "a write into
two repositories says both notices in one object" — both failed on7009bb9. main()starts clean. The git answer memo, the token source and the vault-degraded
note are reset per invocation, so a second in-process call never sees the first's.- Docs.
fabric-coordination.mdnames the real flag (reserve <register> --key <k>,
not--rkey); this entry's feature list sits under its own heading.npm testruns every
CI test suite: the validator self-test, the coordinator smoke test, the stray-SKILL.md
check,claude plugin validate --strictand theskill-auditjob stay CI-only.
Upgrading to 1.22.0 mid-flight
- A session's run id changes. Claude Code exports CLAUDE_CODE_SESSION_ID to every Bash
call, and 1.22.0 reads it: a session whose Bash calls used the shared identity under
1.21.x now resolves to its own id, so a lease it took under the shared id looks like
another run's. Release before upgrading, or let it expire. 1.22.0 does NOT adopt the
shared entry's id on first sight of a session key — that would merge this session with
whichever session last wrote the shared entry, the collision B-150 removed. - The session list moved. 1.22.0 keys the list of other repositories by the session
(~/.agent-sync/session-repos/<sha256>.json); a list keyed by project slug from a
pre-release build is not read. Leases listed only there are renewed and released by hand. - **Observatory's tick cha...
v1.21.7 — agent-sync
acquirerefuses the shared fallback identity where hooks know their session. From
a shell with noCLAUDE_SESSION_ID,acquirewon the lease under the shared fallback,
while the PreToolUse guard — which runs with the session id — refused every edit with
"this run holds no lease" (reported 2026-10-11 from a linked worktree). When any session
stamp is in reach,acquirenow exits 2 and names the remedy (CLAUDE_SESSION_ID, or
AGENT_SYNC_RUN_IDfor a non-Claude caller). Where no hook stamps sessions, nothing
changes: CLI and hook share the fallback and agree.- A linked worktree reads the main checkout's session stamp. A session started in the
main checkout stamped that checkout's state dir; the walk from a worktree read only its
own and fell back to the shared identity. The walk now reads both.
Two cases in test/hooks_session_test.py, both watched failing first.
- A direct
guard <path>in a repository with no coordination allows.guard.sh
already exited early there, butagent_sync.py guard <path>called directly still
exited 2 with a lease message no lease could satisfy. Recovered from the unmerged
2026-09-11 branch: a missing config isNotCoordinated, an answer the guard allows;
an unreadable config still denies.test/guard_uncoordinated_test.py(4 cases, 2
failing on 1.21.6) joinsnpm test.
v1.21.6 — agent-sync
npm testno longer runs the validator's self-test. The default gate ran
python3 test/validate.py --self-test— 74 planted defects, each a full validator run in
its own copy of the tree — and locally it did not finish in 58 minutes (measured
2026-10-10). The self-test moves tonpm run test:negatives, as in the sibling members;
npm run test:allruns both. Nothing CI enforced moved:validate.ymlandrelease.yml
already invokevalidate.py --self-testdirectly, not throughnpm test.- The release refuses a lightweight tag. A new step right after checkout in
release.ymlfails unlessgit cat-file -t <tag>istag.git describeand
git submodule statussee annotated tags only; on 2026-10-09 four family members cut
lightweight tags and the hub read each as its previous release. Same step, same wording,
in every member. - Not a code change: ssheleg/sshlg-skills#154 (reported against 1.21.0) was fixed in
1.21.2 — the guard asks the repository that owns the write. Its reproduction exits 2 on
the 1.21.0 plugin cache and 0 on 1.21.5 (verified 2026-10-10).