@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 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 pruned afterwards — that
10
- * duplicate shadows the plugin and silently serves a stale skill.
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 prune the shadow
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
- pruneShadow();
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('Next:')} restart Claude Code, then run ${C.bold('/agent-sync init')} in your project.
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. That copy shadows the plugin and serves a
143
- * stale skill, so the prune belongs INSIDE every command that touches the skills CLI,
144
- * not in a human's memory. lstatSync, because a symlink shadows exactly as a dir does.
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 pruneShadow() {
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(` pruned duplicate ${SHADOW}`));
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
- pruneShadow();
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.4",
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",
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
- read -r is_commit repo <<<"$(python3 -c '
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 &&/;/| segment is its own command; a commit anywhere in the chain counts.
42
- for seg in cmd.replace("&&", "\n").replace(";", "\n").replace("||", "\n").split("\n"):
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
@@ -3,7 +3,7 @@
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
6
- "matcher": "startup|resume",
6
+ "matcher": "startup|resume|clear",
7
7
  "hooks": [
8
8
  {
9
9
  "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.18.4"
7
+ version: "1.18.6"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -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.18.4"
36
+ VERSION = "1.18.6"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")