@ssheleg/agent-sync 1.18.4 → 1.18.5

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,45 @@
1
+ ## v1.18.5 — a pipe reaches the guard, and the installers stop deleting blind
2
+
3
+ Two enforcement holes, each of the same shape: a mechanism whose own text claimed more
4
+ than its code did.
5
+
6
+ - **ASY-05 — a piped commit bypassed the lease guard entirely.** `guard.sh`'s tokenizer
7
+ said "each `&&`/`;`/`|` segment is its own command" and consumed only `&&`, `;` and
8
+ `||` — so `echo msg | git commit -F -`, the ordinary way to commit a generated
9
+ message, was one segment whose first token is `echo`, and the whole pipeline skipped
10
+ the guard. The single pipe and `|&` are now consumed (ordered after `||`, or each
11
+ would shatter into stray halves), three new `GUARD_SHAPES` cover the bypass and the
12
+ pipe that must NOT block (`git log | grep commit`), and the self-test plants the
13
+ shipped tokenizer back (`a piped commit slips past the guard`). Watched failing
14
+ against the pre-fix guard: both piped shapes reached a guarded file with exit 0.
15
+ - **The installers consulted nothing before deleting the Claude Code channel.** This
16
+ member's installers never write `~/.claude/skills/agent-sync` themselves — the skills
17
+ CLI they drive recreates it — and both deleted that copy unconditionally afterwards.
18
+ The family canon this implements (make-skill v0.25.0, distribution.md §3) names the
19
+ fail-open class, and here it ran in mirror image: on a home where the plugin is NOT
20
+ installed — no claude CLI, or the plugin install failed — the prune destroyed the only
21
+ Claude Code channel the very same run had just installed, and exited 0. The fate of
22
+ the copy is now a decision read from the target home's
23
+ `~/.claude/plugins/installed_plugins.json` (the record of what is installed; plugin
24
+ and marketplace names differ, so the spec is taken from the JSON), with the
25
+ `marketplaces/<name>` dir kept only as the fallback signal: plugin present → the
26
+ shadow is pruned and the message names the real spec, the plugin-channel remedy and
27
+ the family launcher; no plugin → the copy is kept, because it IS the Claude Code
28
+ channel; `--force` → kept beside the plugin, as the recorded choice to run two
29
+ channels where the stale one wins. Absent or corrupt JSON reads as "no plugin" — fail
30
+ open, never crash. Only the Claude Code channel is gated; other agents' installs are
31
+ untouched.
32
+ - **The install now says how the next version arrives** — `npx @ssheleg/agent-sync@latest
33
+ update`, or the family launcher — because an installer that never mentions updates has
34
+ still chosen an update model: never.
35
+ - `test/installer_test.js`: 11 cases against throwaway HOMEs with the delegated CLIs
36
+ stubbed through PATH — plugin-present prune with the spec from the JSON, a
37
+ differently-named marketplace, `--force`, corrupt JSON, a prefix-collider
38
+ (`agent-sync-extra@x`), the marketplaces-dir fallback, the update path, and the
39
+ install.sh mirrors. Ten of eleven watched failing against the pre-fix installers.
40
+ Wired into `npm test` and CI; `hooks_session_test.py` — already in `npm test` —
41
+ joins CI in the same step block.
42
+
1
43
  ## v1.18.4 — the channel that sends the installs, on npm too
2
44
 
3
45
  - 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.5",
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.5",
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",
@@ -38,8 +38,12 @@ except Exception:
38
38
  print("0 ."); sys.exit(0)
39
39
  cmd = (d.get("tool_input") or {}).get("command", "")
40
40
  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"):
41
+ # Each &&/;/||/|&/| segment is its own command; a commit anywhere in the chain counts.
42
+ # The single pipe was CLAIMED by this comment and never consumed (ASY-05, fixed 2026-08-29):
43
+ # only "||" was replaced, so `echo msg | git commit -F -` stayed one segment whose first
44
+ # token is `echo`, and the whole pipeline skipped the guard. Order matters: "||" and "|&"
45
+ # must be consumed before the bare "|", or each would be split into a stray half.
46
+ for seg in cmd.replace("&&", "\n").replace("||", "\n").replace("|&", "\n").replace(";", "\n").replace("|", "\n").split("\n"):
43
47
  try:
44
48
  toks = shlex.split(seg)
45
49
  except ValueError:
@@ -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.5"
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.5"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")