@ssheleg/agent-sync 1.18.4 → 1.18.6
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 +79 -0
- package/bin/agent-sync.js +109 -13
- package/package.json +2 -2
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/hooks/_lib.sh +5 -0
- package/plugins/agent-sync/hooks/guard.sh +23 -5
- package/plugins/agent-sync/hooks/hooks.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +1 -1
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
+
## v1.18.6 — the guard keeps its own header's promise, and /clear gets its own identity
|
|
2
|
+
|
|
3
|
+
Wave-2 hardening from the 2026-08-29 family audit (ASY-07, ASY-08, ASY-06, ASY-01).
|
|
4
|
+
Same shape as v1.18.5: text that claimed more than the code did.
|
|
5
|
+
|
|
6
|
+
- **ASY-07 — a machine without python3 had no guard, and said nothing.** `guard.sh`'s
|
|
7
|
+
header promises that every internal failure exits 2, because any other outcome is
|
|
8
|
+
non-blocking in Claude Code — and the python3-absent path broke it: both parser
|
|
9
|
+
substitutions failed under `2>/dev/null`, `path` came back empty, `is_commit`
|
|
10
|
+
defaulted to 0 and the hook exited 0. Fail OPEN, exactly where the header says it
|
|
11
|
+
must not, and nothing upstream compensates — hooks.json's `if` filter is best-effort
|
|
12
|
+
by doctrine, so the parser IS the guard. Both substitutions now report through
|
|
13
|
+
`parser_or_die`, which exits 2 and names the remedy (install python3 / fix PATH, or
|
|
14
|
+
remove `.claude/agent-sync.json` to switch coordination off).
|
|
15
|
+
`check_guard_fails_closed_without_python3` drives the real hook through a shim PATH
|
|
16
|
+
holding bash and cat but no interpreter — watched failing against the shipped guard
|
|
17
|
+
(`the guard exited 0`) — and the self-test plants the fail-open back
|
|
18
|
+
(`an absent interpreter fails open`).
|
|
19
|
+
- **ASY-08 — a post-/clear session inherited the ended run's identity.** The
|
|
20
|
+
SessionStart matcher read `startup|resume`, and `/clear` ends the run while keeping
|
|
21
|
+
the CLI process — so the stamp keyed by that process survived into the next session:
|
|
22
|
+
"two sessions, one identity", the case SKILL.md itself names, and `release` then
|
|
23
|
+
takes a lease the caller never had. The matcher now carries `clear`; `compact` stays
|
|
24
|
+
excluded deliberately, because a compaction continues the SAME session.
|
|
25
|
+
`hooks_session_test.py` gained the case (matcher parts asserted both directions, and
|
|
26
|
+
the hook re-stamps on a clear payload) — watched failing against the shipped matcher.
|
|
27
|
+
- **ASY-06 — the guard's internal-failure refusal named no next step.** A refusal with
|
|
28
|
+
no remedy is how an operator learns to switch a hook off. The message now says:
|
|
29
|
+
diagnose with `python3 <script> check`, or acquire a lease first.
|
|
30
|
+
- **ASY-01 — `scripts/__pycache__` regenerated forever.** Hooks now export
|
|
31
|
+
`PYTHONDONTWRITEBYTECODE=1` in `_lib.sh` (sourced by all four), and the test files
|
|
32
|
+
that import `agent_sync.py` set `sys.dont_write_bytecode` — the shipped `scripts/`
|
|
33
|
+
directory stays source, not build products.
|
|
34
|
+
- ASY-04 (this test class missing from CI) was already closed in v1.18.5:
|
|
35
|
+
`validate.yml` runs `hooks_session_test.py` since commit `5446990`. Verified, not
|
|
36
|
+
re-done. ASY-02 (command rename) closed by operator decision 2026-08-30: names stay.
|
|
37
|
+
|
|
38
|
+
## v1.18.5 — a pipe reaches the guard, and the installers stop deleting blind
|
|
39
|
+
|
|
40
|
+
Two enforcement holes, each of the same shape: a mechanism whose own text claimed more
|
|
41
|
+
than its code did.
|
|
42
|
+
|
|
43
|
+
- **ASY-05 — a piped commit bypassed the lease guard entirely.** `guard.sh`'s tokenizer
|
|
44
|
+
said "each `&&`/`;`/`|` segment is its own command" and consumed only `&&`, `;` and
|
|
45
|
+
`||` — so `echo msg | git commit -F -`, the ordinary way to commit a generated
|
|
46
|
+
message, was one segment whose first token is `echo`, and the whole pipeline skipped
|
|
47
|
+
the guard. The single pipe and `|&` are now consumed (ordered after `||`, or each
|
|
48
|
+
would shatter into stray halves), three new `GUARD_SHAPES` cover the bypass and the
|
|
49
|
+
pipe that must NOT block (`git log | grep commit`), and the self-test plants the
|
|
50
|
+
shipped tokenizer back (`a piped commit slips past the guard`). Watched failing
|
|
51
|
+
against the pre-fix guard: both piped shapes reached a guarded file with exit 0.
|
|
52
|
+
- **The installers consulted nothing before deleting the Claude Code channel.** This
|
|
53
|
+
member's installers never write `~/.claude/skills/agent-sync` themselves — the skills
|
|
54
|
+
CLI they drive recreates it — and both deleted that copy unconditionally afterwards.
|
|
55
|
+
The family canon this implements (make-skill v0.25.0, distribution.md §3) names the
|
|
56
|
+
fail-open class, and here it ran in mirror image: on a home where the plugin is NOT
|
|
57
|
+
installed — no claude CLI, or the plugin install failed — the prune destroyed the only
|
|
58
|
+
Claude Code channel the very same run had just installed, and exited 0. The fate of
|
|
59
|
+
the copy is now a decision read from the target home's
|
|
60
|
+
`~/.claude/plugins/installed_plugins.json` (the record of what is installed; plugin
|
|
61
|
+
and marketplace names differ, so the spec is taken from the JSON), with the
|
|
62
|
+
`marketplaces/<name>` dir kept only as the fallback signal: plugin present → the
|
|
63
|
+
shadow is pruned and the message names the real spec, the plugin-channel remedy and
|
|
64
|
+
the family launcher; no plugin → the copy is kept, because it IS the Claude Code
|
|
65
|
+
channel; `--force` → kept beside the plugin, as the recorded choice to run two
|
|
66
|
+
channels where the stale one wins. Absent or corrupt JSON reads as "no plugin" — fail
|
|
67
|
+
open, never crash. Only the Claude Code channel is gated; other agents' installs are
|
|
68
|
+
untouched.
|
|
69
|
+
- **The install now says how the next version arrives** — `npx @ssheleg/agent-sync@latest
|
|
70
|
+
update`, or the family launcher — because an installer that never mentions updates has
|
|
71
|
+
still chosen an update model: never.
|
|
72
|
+
- `test/installer_test.js`: 11 cases against throwaway HOMEs with the delegated CLIs
|
|
73
|
+
stubbed through PATH — plugin-present prune with the spec from the JSON, a
|
|
74
|
+
differently-named marketplace, `--force`, corrupt JSON, a prefix-collider
|
|
75
|
+
(`agent-sync-extra@x`), the marketplaces-dir fallback, the update path, and the
|
|
76
|
+
install.sh mirrors. Ten of eleven watched failing against the pre-fix installers.
|
|
77
|
+
Wired into `npm test` and CI; `hooks_session_test.py` — already in `npm test` —
|
|
78
|
+
joins CI in the same step block.
|
|
79
|
+
|
|
1
80
|
## v1.18.4 — the channel that sends the installs, on npm too
|
|
2
81
|
|
|
3
82
|
- The `skills.sh` badge and the canonical `homepage` reached GitHub in the previous cycle and stopped
|
package/bin/agent-sync.js
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* One channel per agent: Claude Code gets the plugin, every other agent gets the
|
|
8
8
|
* skill through the vercel skills CLI, and the plain ~/.claude/skills/agent-sync
|
|
9
|
-
* copy that the skills CLI recreates on its own is
|
|
10
|
-
*
|
|
9
|
+
* copy that the skills CLI recreates on its own is settled afterwards against
|
|
10
|
+
* the home's installed_plugins.json — pruned when the plugin owns Claude Code
|
|
11
|
+
* (the duplicate would shadow it and silently serve a stale skill), kept when no
|
|
12
|
+
* plugin does (then it IS the Claude Code channel), kept on --force as the
|
|
13
|
+
* recorded choice to run two channels. Canon: make-skill distribution.md §3.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
const { spawnSync } = require('child_process');
|
|
@@ -19,6 +22,41 @@ const REPO = 'ssheleg/agent-sync';
|
|
|
19
22
|
const NAME = 'agent-sync';
|
|
20
23
|
const SHADOW = path.join(os.homedir(), '.claude', 'skills', NAME);
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The plugin spec (`<name>@<marketplace>`) installed for `name` in this home,
|
|
27
|
+
* or null.
|
|
28
|
+
*
|
|
29
|
+
* `installed_plugins.json` is the record of what is actually installed. The
|
|
30
|
+
* `plugins/marketplaces/<name>` directory — the only signal the family's
|
|
31
|
+
* installers read until the 2026-08-29 canon (make-skill v0.25.0,
|
|
32
|
+
* distribution.md §3) — under-reports: a marketplace added from a local
|
|
33
|
+
* `directory` source has no dir there at all, and plugin names differ from
|
|
34
|
+
* marketplace names, so a check keyed on it stays green while the shadow
|
|
35
|
+
* lands. Absence and corruption both read as "no plugin": the fresh HOME is
|
|
36
|
+
* the common case, and an installer that crashes on a parse error refuses the
|
|
37
|
+
* machines that need it most.
|
|
38
|
+
*/
|
|
39
|
+
function installedPluginSpec(home, name) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(
|
|
42
|
+
path.join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
const plugins =
|
|
45
|
+
parsed && typeof parsed === 'object' &&
|
|
46
|
+
parsed.plugins && typeof parsed.plugins === 'object'
|
|
47
|
+
? parsed.plugins
|
|
48
|
+
: parsed;
|
|
49
|
+
if (!plugins || typeof plugins !== 'object') return null;
|
|
50
|
+
for (const spec of Object.keys(plugins)) {
|
|
51
|
+
if (spec === name) return `${name}@${name}`;
|
|
52
|
+
if (spec.startsWith(name + '@')) return spec;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// missing or corrupt = no plugin — fail open on absence, never crash
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
22
60
|
const C = {
|
|
23
61
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
24
62
|
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
@@ -46,7 +84,12 @@ ${C.bold('agent-sync')} — coordination for concurrent agents
|
|
|
46
84
|
npx @ssheleg/${NAME} install install for Claude Code and other agents
|
|
47
85
|
npx @ssheleg/${NAME} install --claude-only Claude Code plugin only
|
|
48
86
|
npx @ssheleg/${NAME} install --agent a,b pick agents for the skills CLI
|
|
49
|
-
npx @ssheleg/${NAME} update update every channel, then
|
|
87
|
+
npx @ssheleg/${NAME} update update every channel, then settle the
|
|
88
|
+
~/.claude/skills copy: pruned when it
|
|
89
|
+
would shadow the installed plugin, kept
|
|
90
|
+
when no plugin owns Claude Code
|
|
91
|
+
npx @ssheleg/${NAME} install|update --force keep the plain copy even beside the
|
|
92
|
+
plugin — two channels, the stale one wins
|
|
50
93
|
npx @ssheleg/${NAME} --help
|
|
51
94
|
|
|
52
95
|
After installing, initialise the project — this is the step that asks where
|
|
@@ -64,6 +107,7 @@ asks for a token and never stores one.
|
|
|
64
107
|
function install(argv) {
|
|
65
108
|
const claudeOnly = argv.includes('--claude-only');
|
|
66
109
|
const noClaude = argv.includes('--no-claude');
|
|
110
|
+
const force = argv.includes('--force');
|
|
67
111
|
const agentIdx = argv.indexOf('--agent');
|
|
68
112
|
const agents = agentIdx !== -1 && argv[agentIdx + 1] ? argv[agentIdx + 1].split(',') : null;
|
|
69
113
|
|
|
@@ -88,7 +132,7 @@ function install(argv) {
|
|
|
88
132
|
ok = run('npx', args) && ok;
|
|
89
133
|
}
|
|
90
134
|
|
|
91
|
-
|
|
135
|
+
settleClaudeChannel(force);
|
|
92
136
|
|
|
93
137
|
console.log(
|
|
94
138
|
ok
|
|
@@ -98,8 +142,14 @@ function install(argv) {
|
|
|
98
142
|
// Before the "Next:" block, so the last thing on screen stays the instruction
|
|
99
143
|
// rather than the tail of a delegated command.
|
|
100
144
|
offerRouters();
|
|
145
|
+
// How the next version arrives — an installer that never says has still
|
|
146
|
+
// chosen an update model: never.
|
|
101
147
|
console.log(`
|
|
102
|
-
${C.bold('
|
|
148
|
+
${C.bold('Updates:')} npx @ssheleg/${NAME}@latest update — every channel, and it settles
|
|
149
|
+
the plain copy that would shadow the plugin. Whole family:
|
|
150
|
+
npx --yes sshlg-skills@latest update.
|
|
151
|
+
`);
|
|
152
|
+
console.log(`${C.bold('Next:')} restart Claude Code, then run ${C.bold('/agent-sync init')} in your project.
|
|
103
153
|
It will ask where coordination state should live before writing anything.
|
|
104
154
|
`);
|
|
105
155
|
return ok ? 0 : 1;
|
|
@@ -137,13 +187,30 @@ function offerRouters() {
|
|
|
137
187
|
}
|
|
138
188
|
|
|
139
189
|
/**
|
|
190
|
+
* Decide the fate of ~/.claude/skills/<name> after a skills-CLI run.
|
|
191
|
+
*
|
|
140
192
|
* The shadow regrows on its own: `npx skills add|update --global` auto-detects
|
|
141
193
|
* Claude Code and recreates ~/.claude/skills/<name> — often as a symlink — even when
|
|
142
|
-
* claude-code was never named as a target.
|
|
143
|
-
*
|
|
144
|
-
*
|
|
194
|
+
* claude-code was never named as a target. What that copy IS depends on the home it
|
|
195
|
+
* landed in, and until v1.18.5 this function consulted nothing and deleted it
|
|
196
|
+
* unconditionally — the family's fail-open class (make-skill distribution.md §3) in
|
|
197
|
+
* mirror image: on a home where the plugin is NOT installed (no claude CLI, or the
|
|
198
|
+
* plugin install failed), the unconditional prune destroyed the only Claude Code
|
|
199
|
+
* channel this very run had just installed, and exited 0.
|
|
200
|
+
*
|
|
201
|
+
* - Plugin installed in this home — read from installed_plugins.json, with the
|
|
202
|
+
* marketplaces/<name> dir kept only as the fallback signal: the copy is a SHADOW.
|
|
203
|
+
* It outranks the plugin and serves the version it was copied from forever.
|
|
204
|
+
* Prune it, and name the plugin spec it would have shadowed plus the channel
|
|
205
|
+
* that owns updates.
|
|
206
|
+
* - No plugin: the copy is Claude Code's only channel. Keep it, and say so.
|
|
207
|
+
* - --force: the deliberate choice to run two channels, where the stale one wins.
|
|
208
|
+
* The copy stays even beside the plugin, and the output records the choice.
|
|
209
|
+
*
|
|
210
|
+
* The gate lives INSIDE every command that touches the skills CLI, not in a human's
|
|
211
|
+
* memory. lstatSync, because a symlink shadows exactly as a dir does.
|
|
145
212
|
*/
|
|
146
|
-
function
|
|
213
|
+
function settleClaudeChannel(force) {
|
|
147
214
|
let present = false;
|
|
148
215
|
try {
|
|
149
216
|
fs.lstatSync(SHADOW);
|
|
@@ -152,11 +219,40 @@ function pruneShadow() {
|
|
|
152
219
|
/* not there */
|
|
153
220
|
}
|
|
154
221
|
if (!present) return;
|
|
222
|
+
|
|
223
|
+
const home = os.homedir();
|
|
224
|
+
const spec = installedPluginSpec(home, NAME);
|
|
225
|
+
const marketplace = path.join(home, '.claude', 'plugins', 'marketplaces', NAME);
|
|
226
|
+
const viaMarketplaceDir = !spec && fs.existsSync(marketplace);
|
|
227
|
+
|
|
228
|
+
if (!spec && !viaMarketplaceDir) {
|
|
229
|
+
console.log(C.dim(
|
|
230
|
+
` kept ${SHADOW} — no ${NAME} plugin is installed in this home,\n` +
|
|
231
|
+
` so this plain copy is Claude Code's only channel for the skill`));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (force) {
|
|
235
|
+
console.log(C.yellow(
|
|
236
|
+
` kept ${SHADOW} beside the installed plugin (--force) — two channels\n` +
|
|
237
|
+
` on one agent, and the stale plain copy is the one Claude Code reads`));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const found = spec
|
|
241
|
+
? `the Claude Code plugin ${spec} is installed (installed_plugins.json)`
|
|
242
|
+
: `a Claude Code marketplace is registered at ${marketplace}`;
|
|
155
243
|
fs.rmSync(SHADOW, { recursive: true, force: true });
|
|
156
|
-
console.log(C.dim(
|
|
244
|
+
console.log(C.dim(
|
|
245
|
+
` pruned ${SHADOW} — ${found};\n` +
|
|
246
|
+
` a plain copy there would shadow the plugin and serve a frozen version\n` +
|
|
247
|
+
` forever. The plugin channel owns updates:\n` +
|
|
248
|
+
` claude plugin marketplace update ${NAME}\n` +
|
|
249
|
+
` claude plugin update ${spec || `${NAME}@${NAME}`}\n` +
|
|
250
|
+
` Pass --force to keep the plain copy anyway — a deliberate choice to run\n` +
|
|
251
|
+
` two channels, where the stale one wins.`));
|
|
157
252
|
}
|
|
158
253
|
|
|
159
|
-
function update() {
|
|
254
|
+
function update(argv) {
|
|
255
|
+
const force = argv.includes('--force');
|
|
160
256
|
console.log(C.bold('\nUpdating every channel'));
|
|
161
257
|
let ok = true;
|
|
162
258
|
if (has('claude')) {
|
|
@@ -166,7 +262,7 @@ function update() {
|
|
|
166
262
|
ok = run('claude', ['plugin', 'update', `${NAME}@${NAME}`]) && ok;
|
|
167
263
|
}
|
|
168
264
|
ok = run('npx', ['--yes', 'skills', 'update', NAME, '--global', '--yes']) && ok;
|
|
169
|
-
|
|
265
|
+
settleClaudeChannel(force);
|
|
170
266
|
console.log(ok ? C.green('\n✓ updated') : C.red('\n✗ a channel failed — see above'));
|
|
171
267
|
console.log('\nRestart Claude Code so it picks the new version up.');
|
|
172
268
|
return ok ? 0 : 1;
|
|
@@ -178,6 +274,6 @@ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
|
178
274
|
process.exit(0);
|
|
179
275
|
}
|
|
180
276
|
if (argv[0] === 'install') process.exit(install(argv.slice(1)));
|
|
181
|
-
if (argv[0] === 'update') process.exit(update());
|
|
277
|
+
if (argv[0] === 'update') process.exit(update(argv.slice(1)));
|
|
182
278
|
usage();
|
|
183
279
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.6",
|
|
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"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "python3 test/validate.py && python3 test/validate.py --self-test && python3 test/claim_cell_test.py && python3 test/hooks_session_test.py",
|
|
20
|
+
"test": "python3 test/validate.py && python3 test/validate.py --self-test && python3 test/claim_cell_test.py && python3 test/hooks_session_test.py && node test/installer_test.js",
|
|
21
21
|
"prepublishOnly": "python3 test/validate.py"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sync",
|
|
3
3
|
"displayName": "Agent Sync",
|
|
4
|
-
"version": "1.18.
|
|
4
|
+
"version": "1.18.6",
|
|
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",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Shared helpers for the agent-sync hooks. Sourced, never executed.
|
|
3
3
|
|
|
4
|
+
# Every hook runs python3 against the shipped scripts/ directory, which people read
|
|
5
|
+
# as source, not as a build product. Bytecode caching buys nothing at this call rate
|
|
6
|
+
# and left scripts/__pycache__ regenerating forever (ASY-01).
|
|
7
|
+
export PYTHONDONTWRITEBYTECODE=1
|
|
8
|
+
|
|
4
9
|
# Run a command under a time limit, portably.
|
|
5
10
|
#
|
|
6
11
|
# `timeout` is GNU coreutils and is NOT on a stock macOS. Calling it directly made
|
|
@@ -8,6 +8,17 @@ S="$AGENT_SYNC_PY"
|
|
|
8
8
|
agent_sync_configured || exit 0
|
|
9
9
|
input=$(cat)
|
|
10
10
|
|
|
11
|
+
# The parser IS the guard: hooks.json's `if` filter is best-effort and fails open by
|
|
12
|
+
# doctrine, so nothing upstream compensates for a parser that cannot run. A python3
|
|
13
|
+
# missing from PATH used to leave `path` empty, default `is_commit` to 0 and exit 0 —
|
|
14
|
+
# the one machine state that disables the parser silently disabled the guard (ASY-07).
|
|
15
|
+
parser_or_die() {
|
|
16
|
+
local rc="$1"
|
|
17
|
+
[ "$rc" -eq 0 ] && return 0
|
|
18
|
+
echo "agent-sync: the guard could not run its parser (python3 exit $rc — likely missing from PATH). Failing closed. Install python3 or fix PATH and retry, or remove .claude/agent-sync.json to switch coordination off here." >&2
|
|
19
|
+
exit 2
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
path=$(python3 -c '
|
|
12
23
|
import json,sys
|
|
13
24
|
try:
|
|
@@ -17,6 +28,7 @@ except Exception:
|
|
|
17
28
|
ti=d.get("tool_input") or {}
|
|
18
29
|
print(ti.get("file_path") or ti.get("path") or ti.get("notebook_path") or "")
|
|
19
30
|
' <<<"$input" 2>/dev/null)
|
|
31
|
+
parser_or_die $?
|
|
20
32
|
|
|
21
33
|
# git commit: check every staged path instead of a single file argument.
|
|
22
34
|
if [ -z "$path" ]; then
|
|
@@ -30,7 +42,7 @@ if [ -z "$path" ]; then
|
|
|
30
42
|
#
|
|
31
43
|
# Tokenised in python rather than globbed in shell: `git log --grep=commit` must not match, and
|
|
32
44
|
# `git -c user.name=x -C dir commit` must.
|
|
33
|
-
|
|
45
|
+
parsed=$(python3 -c '
|
|
34
46
|
import json, shlex, sys
|
|
35
47
|
try:
|
|
36
48
|
d = json.load(sys.stdin)
|
|
@@ -38,8 +50,12 @@ except Exception:
|
|
|
38
50
|
print("0 ."); sys.exit(0)
|
|
39
51
|
cmd = (d.get("tool_input") or {}).get("command", "")
|
|
40
52
|
is_commit, repo = 0, "."
|
|
41
|
-
# Each
|
|
42
|
-
|
|
53
|
+
# Each &&/;/||/|&/| segment is its own command; a commit anywhere in the chain counts.
|
|
54
|
+
# The single pipe was CLAIMED by this comment and never consumed (ASY-05, fixed 2026-08-29):
|
|
55
|
+
# only "||" was replaced, so `echo msg | git commit -F -` stayed one segment whose first
|
|
56
|
+
# token is `echo`, and the whole pipeline skipped the guard. Order matters: "||" and "|&"
|
|
57
|
+
# must be consumed before the bare "|", or each would be split into a stray half.
|
|
58
|
+
for seg in cmd.replace("&&", "\n").replace("||", "\n").replace("|&", "\n").replace(";", "\n").replace("|", "\n").split("\n"):
|
|
43
59
|
try:
|
|
44
60
|
toks = shlex.split(seg)
|
|
45
61
|
except ValueError:
|
|
@@ -67,7 +83,9 @@ for seg in cmd.replace("&&", "\n").replace(";", "\n").replace("||", "\n").split(
|
|
|
67
83
|
repo = r
|
|
68
84
|
break
|
|
69
85
|
print(is_commit, repo)
|
|
70
|
-
' <<<"$input" 2>/dev/null)
|
|
86
|
+
' <<<"$input" 2>/dev/null)
|
|
87
|
+
parser_or_die $?
|
|
88
|
+
read -r is_commit repo <<<"$parsed"
|
|
71
89
|
[ -n "${is_commit:-}" ] || is_commit=0
|
|
72
90
|
[ -n "${repo:-}" ] || repo="."
|
|
73
91
|
[ -d "$repo" ] || repo="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
@@ -96,6 +114,6 @@ else
|
|
|
96
114
|
echo "$out" >&2
|
|
97
115
|
exit 2
|
|
98
116
|
fi
|
|
99
|
-
echo "agent-sync guard failed to run ($code): $out" >&2
|
|
117
|
+
echo "agent-sync guard failed to run ($code): $out — diagnose with \`python3 $S check\`, or acquire a lease first: \`python3 $S acquire <TASK-ID>\`" >&2
|
|
100
118
|
exit 2
|
|
101
119
|
fi
|
|
@@ -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.18.
|
|
7
|
+
version: "1.18.6"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|