@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +75 -78
  13. package/package.json +4 -1
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -14
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/log-metric.sh +9 -3
  31. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  32. package/pipeline/scripts/pre-commit-check.sh +119 -31
  33. package/pipeline/scripts/scan-agent-config.sh +9 -9
  34. package/pipeline/scripts/unattended_policy.py +12 -3
  35. package/pipeline/scripts/uninstall.mjs +88 -1
  36. package/pipeline/scripts/usage-identity.mjs +1 -1
  37. package/pipeline/scripts/usage-register.mjs +1 -1
  38. package/pipeline/skills/.skill-manifest.json +11 -11
  39. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  40. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  41. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  42. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  43. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  44. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  47. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  49. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  50. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  60. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  61. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  62. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  63. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  64. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  65. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  66. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  67. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  68. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  69. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  70. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  71. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  72. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  73. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  74. package/pipeline/scripts/make-manifest.mjs +0 -199
  75. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -5,22 +5,20 @@
5
5
  * `CLAUDE.md` template, preferences template, and the secret-detection
6
6
  * pre-commit hook in `settings.json`.
7
7
  *
8
- * Layout MUST stay equivalent to the pre-v8.0.0 monolithic install path -
9
- * install layout smoke (`smoke-install-layout.sh`) compares before/after
10
- * trees on every release. (v11.4.1 intentionally changed two behaviours
11
- * without changing the layout: agents are cleaned per-file instead of wiping
12
- * the dir, and symlinked install targets are replaced before writing.)
8
+ * The install layout smoke (`smoke-install-layout.sh`) compares the installed
9
+ * tree against a committed fixture on every release.
13
10
  *
14
11
  * @module install/claude
15
12
  */
16
13
 
17
- import { existsSync, readFileSync, readdirSync, renameSync, rmSync } from "fs";
14
+ import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from "fs";
18
15
  import { join, dirname } from "path";
19
16
  import { execSync } from "child_process";
20
17
 
21
18
  import { localizeCommands } from "../pipeline/scripts/localize-commands.mjs";
22
19
 
23
20
  import {
21
+ classifyInstallDir,
24
22
  copyDir,
25
23
  copyFile,
26
24
  copySkillsIndex,
@@ -30,21 +28,26 @@ import {
30
28
  ensureRealDir,
31
29
  isDryRun,
32
30
  isLocalOnlySkill,
31
+ listFiles,
33
32
  pruneAbandonedTrees,
34
33
  pruneLegacyMultiAgentSkills,
35
34
  pruneOrphanSkillFiles,
35
+ readInstallManifest,
36
36
  removePipelineAgentFiles,
37
+ stageAndSwapTrees,
38
+ syncTrackedFiles,
37
39
  wipeDir,
38
40
  writeFile,
41
+ writeInstallManifest,
39
42
  } from "./_common.mjs";
40
43
  import { EXTERNAL_SKILLS_MANIFEST } from "./_platform-filter.mjs";
41
44
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
42
45
  import { registerMcpServer } from "./_mcp-register.mjs";
43
46
 
44
47
  /**
45
- * Dead matcher written by pre-v11.4.1 installs. Hook matchers only ever see
46
- * the tool name, so this string never matched and the hook never ran.
47
- * Kept so installs can migrate it and uninstall can clean it.
48
+ * A matcher an older install wrote. Hook matchers only ever see the tool name,
49
+ * so this string never matches and the hook never runs. Kept so installs can
50
+ * migrate it and uninstall can clean it.
48
51
  */
49
52
  export const LEGACY_PRE_COMMIT_MATCHER = "Bash(git commit:*)";
50
53
  export const AGENT_GUARD_FILE_MATCHER = "Edit|Write|NotebookEdit";
@@ -87,33 +90,35 @@ export const PIPELINE_LOCAL_SKILLS = ["apple-archive-compliance", "google-play-c
87
90
  export function installClaude(ctx) {
88
91
  const { home, pipelineSrc, indexOnly, useSymlinks, platformFlag, pruneExternal } = ctx;
89
92
 
90
- const CLAUDE_COMMANDS = join(home, ".claude", "commands");
91
- const CLAUDE_AGENTS = join(home, ".claude", "agents");
92
- const PREFS_PATH = join(home, ".claude", "multi-agent-preferences.json");
93
- const CLAUDE_SCRIPTS = join(home, ".claude", "scripts");
94
- const CLAUDE_RULES = join(home, ".claude", "rules");
95
- const CLAUDE_SKILLS = join(home, ".claude", "skills");
96
- const CLAUDE_SCHEMAS = join(home, ".claude", "schemas");
97
- const CLAUDE_LIB = join(home, ".claude", "lib");
98
- const CLAUDE_MA_REFS = join(home, ".claude", "multi-agent-refs");
99
- const CLAUDE_TEMPLATES = join(home, ".claude", "templates");
93
+ const CLAUDE_DIR = join(home, ".claude");
94
+ const CLAUDE_AGENTS = join(CLAUDE_DIR, "agents");
95
+ const PREFS_PATH = join(CLAUDE_DIR, "multi-agent-preferences.json");
96
+ const CLAUDE_RULES = join(CLAUDE_DIR, "rules");
97
+ const CLAUDE_SKILLS = join(CLAUDE_DIR, "skills");
100
98
 
101
99
  // Before laying anything down: drop trees an older install created that no
102
100
  // current installer manages. Wipe-before-copy only protects trees still being
103
- // written, so an abandoned one persists on every existing machine forever.
104
- const prunedTrees = pruneAbandonedTrees(join(home, ".claude"));
101
+ // written, so an abandoned one would otherwise stay on disk indefinitely.
102
+ const prunedTrees = pruneAbandonedTrees(CLAUDE_DIR);
105
103
  if (prunedTrees > 0) {
106
104
  console.log(` [Claude Code] Removed ${prunedTrees} abandoned tree(s) from an older install`);
107
105
  }
108
106
 
109
- installCommands(pipelineSrc, CLAUDE_COMMANDS, useSymlinks);
110
- installMultiAgentRefs(pipelineSrc, CLAUDE_MA_REFS, useSymlinks);
111
- installScripts(pipelineSrc, CLAUDE_SCRIPTS, useSymlinks);
107
+ const manifest = readInstallManifest(CLAUDE_DIR);
108
+ // An install without a manifest overwrote every file it shipped on each run,
109
+ // so after one (the version stamp says one happened) a file carrying a
110
+ // shipped name is that installer's copy. On a machine with no prior install
111
+ // the same file is the user's.
112
+ const adopt = !manifest.present && existsSync(join(CLAUDE_DIR, ".pipeline-version"));
113
+
114
+ if (useSymlinks) {
115
+ linkOwnedTrees(pipelineSrc, CLAUDE_DIR);
116
+ } else {
117
+ installOwnedTrees({ pipelineSrc, claudeDir: CLAUDE_DIR, manifest, adopt });
118
+ installTopLevelCommands({ pipelineSrc, claudeDir: CLAUDE_DIR, manifest, adopt });
119
+ }
112
120
  installAgents(pipelineSrc, CLAUDE_AGENTS, useSymlinks);
113
121
  installRules(pipelineSrc, CLAUDE_RULES, useSymlinks);
114
- installSchemas(pipelineSrc, CLAUDE_SCHEMAS, useSymlinks);
115
- installLib(pipelineSrc, CLAUDE_LIB, useSymlinks);
116
- installTemplates(pipelineSrc, CLAUDE_TEMPLATES, useSymlinks);
117
122
  runPreDeployScans(pipelineSrc);
118
123
  installSkills({
119
124
  pipelineSrc,
@@ -126,72 +131,179 @@ export function installClaude(ctx) {
126
131
  ensureClaudeMd(home, pipelineSrc);
127
132
  ensurePreferences(PREFS_PATH, pipelineSrc);
128
133
  configureSettings(home);
134
+ if (!useSymlinks) writeInstallManifest(CLAUDE_DIR, manifest);
129
135
 
130
- // Claude Code was the last target still shipping skills without the tools those
131
- // skills call. The multi-agent-toolkit server backs design-check, every ios_* / android_*
132
- // simulator call and the archive audits, and its absence is invisible until a run
133
- // reaches for one of them. `--scope user` is not optional here: `claude mcp add`
134
- // defaults to project scope, which would register the server only for whichever
135
- // directory the installer ran in.
136
+ // The multi-agent-toolkit server backs design-check, every ios_* / android_*
137
+ // simulator call and the archive audits; the skills fail only when a run
138
+ // reaches for one of its tools, so the installer registers it up front.
136
139
  registerMcpServer("claude", "Claude Code");
137
140
 
138
141
  console.log("");
139
142
  }
140
143
 
141
- function installCommands(pipelineSrc, dest, useSymlinks) {
142
- console.log(" [Claude Code] Installing pipeline commands...");
143
- const commandsSrc = join(pipelineSrc, "commands");
144
- if (!existsSync(commandsSrc)) return;
144
+ /**
145
+ * The trees under ~/.claude the pipeline owns outright, laid down as one
146
+ * transaction: each is built beside its destination and all are swapped in
147
+ * together, so a failed copy leaves the previous install whole.
148
+ *
149
+ * `commands/multi-agent` is the pipeline's namespace; local-only alias
150
+ * wrappers (frontmatter `local-only: true`, written by /multi-agent:save and
151
+ * never shipped) are carried into the new copy. `lib/` and `templates/` can
152
+ * hold files the user put there, so they are built from the current copy and
153
+ * updated file by file against the install manifest.
154
+ */
155
+ function installOwnedTrees({ pipelineSrc, claudeDir, manifest, adopt }) {
156
+ const commandsDir = ensureRealDir(join(claudeDir, "commands"), {
157
+ pipelineSrc,
158
+ userOwned: true,
159
+ });
160
+ const nsDest = join(commandsDir, "multi-agent");
161
+ const stashDir = join(commandsDir, ".multi-agent-wrapper-stash");
162
+ const trees = [];
163
+ const logs = [];
164
+ const add = (dest, label, src, build) => {
165
+ if (!src || !existsSync(src)) return;
166
+ if (classifyInstallDir(dest, pipelineSrc) === "foreign-link") {
167
+ ensureRealDir(dest, { pipelineSrc });
168
+ return;
169
+ }
170
+ trees.push({ dest, label, build });
171
+ };
145
172
 
146
- const ownedCmdDir = join(dest, "multi-agent");
173
+ console.log(
174
+ " [Claude Code] Installing pipeline commands, refs, scripts, schemas, lib, templates...",
175
+ );
147
176
 
148
- if (useSymlinks) {
149
- // --link (dev) mode: dest becomes a symlink into the repo. Never pre-wipe
150
- // or restore wrappers through an existing link; both would delete from or
151
- // write into the developer's repo checkout itself.
152
- copyDir(commandsSrc, dest, { useSymlinks });
153
- console.log(` -> ${countFiles(commandsSrc)} files linked at ${dest}`);
154
- return;
177
+ const nsSrc = join(pipelineSrc, "commands", "multi-agent");
178
+ add(nsDest, "commands", nsSrc, (staged) => {
179
+ copyDir(nsSrc, staged);
180
+ const restored = carryLocalOnlyWrappers(nsDest, stashDir, staged, pipelineSrc);
181
+ localizeCommandDescriptions(claudeDir, staged);
182
+ logs.push(` -> ${countFiles(nsSrc)} files copied to ${nsDest}`);
183
+ if (restored > 0) logs.push(` -> preserved ${restored} local-only alias wrapper(s)`);
184
+ });
185
+
186
+ // refs/ and the internal pickers live outside commands/ so Claude Code does
187
+ // not register them as `/multi-agent:refs:*` slash commands; commands read
188
+ // them by absolute path.
189
+ const refsSrc = join(pipelineSrc, "multi-agent-refs");
190
+ const refsDest = join(claudeDir, "multi-agent-refs");
191
+ add(refsDest, "refs", refsSrc, (staged) => {
192
+ copyDir(refsSrc, staged);
193
+ logs.push(` -> ${countFiles(refsSrc)} ref files copied to ${refsDest}`);
194
+ });
195
+
196
+ const scriptsSrc = join(pipelineSrc, "scripts");
197
+ const scriptsDest = join(claudeDir, "scripts");
198
+ add(scriptsDest, "scripts", scriptsSrc, (staged) => {
199
+ copyDir(scriptsSrc, staged, { exclude: DEV_ONLY_SCRIPTS });
200
+ // DEV_ONLY_SCRIPTS mixes literal names, directories and regexes, so the
201
+ // excluded count comes from walking the tree.
202
+ const excluded = countDevOnlyFiles(scriptsSrc);
203
+ logs.push(
204
+ ` -> ${countFiles(scriptsSrc) - excluded} files copied to ${scriptsDest} (${excluded} dev-only excluded)`,
205
+ );
206
+ });
207
+
208
+ const schemasSrc = join(pipelineSrc, "schemas");
209
+ const schemasDest = join(claudeDir, "schemas");
210
+ add(schemasDest, "schemas", schemasSrc, (staged) => {
211
+ copyDir(schemasSrc, staged);
212
+ logs.push(` -> ${countFiles(schemasSrc)} files copied to ${schemasDest}`);
213
+ });
214
+
215
+ for (const [name, src] of [
216
+ ["lib", join(pipelineSrc, "lib")],
217
+ ["templates", join(dirname(pipelineSrc), "install", "templates")],
218
+ ]) {
219
+ const dest = join(claudeDir, name);
220
+ add(dest, name, src, (staged) => {
221
+ if (classifyInstallDir(dest, pipelineSrc) === "real") {
222
+ cpSync(dest, staged, { recursive: true });
223
+ } else {
224
+ ensureDir(staged);
225
+ }
226
+ const res = syncTrackedFiles({
227
+ srcDir: src,
228
+ destDir: staged,
229
+ displayDir: dest,
230
+ rels: listFiles(src),
231
+ prefix: `${name}/`,
232
+ manifest,
233
+ adopt,
234
+ reportUnknown: true,
235
+ label: "shipped",
236
+ });
237
+ logs.push(
238
+ ` -> ${countFiles(src) - res.kept.length} files in place at ${dest}` +
239
+ (res.removed ? ` (${res.removed} retired file(s) removed)` : ""),
240
+ );
241
+ });
155
242
  }
156
243
 
157
- // After a prior --link install, dest (or the owned subdir) is a symlink
158
- // into the repo. Replace it with a real directory before any wipe/copy so
159
- // the install can never land inside the repo checkout.
160
- ensureRealDir(dest);
161
- ensureRealDir(ownedCmdDir);
162
-
163
- // Wipe only our namespace dir so files renamed/removed in the source don't
164
- // linger as ghost slash commands. Other namespaces under
165
- // `~/.claude/commands/` are untouched.
166
- //
167
- // Preserve local-only alias wrappers (frontmatter `local-only: true`) across
168
- // the wipe. They live ONLY under ~/.claude (never in pipeline/commands, and
169
- // never synced), so without this stash+restore they would be destroyed on
170
- // every install/update. The stash is an on-disk rename, not an in-memory
171
- // snapshot: the only copy of user-authored content stays on disk at every
172
- // instant, so an install interrupted between wipe and restore loses nothing,
173
- // and the next run adopts any leftover stash.
174
- const wrapperStash = stashLocalOnlyWrappers(ownedCmdDir);
175
- wipeDir(ownedCmdDir);
176
-
177
- copyDir(commandsSrc, dest, { useSymlinks });
178
- const restored = unstashLocalOnlyWrappers(ownedCmdDir, wrapperStash);
179
- console.log(` -> ${countFiles(commandsSrc)} files copied to ${dest}`);
180
- if (restored > 0) {
181
- console.log(` -> preserved ${restored} local-only alias wrapper(s)`);
244
+ stageAndSwapTrees(trees, { swapRoot: join(claudeDir, ".pipeline-swap") });
245
+ for (const line of logs) console.log(line);
246
+ if (!isDryRun() && existsSync(stashDir) && trees.some((t) => t.dest === nsDest)) {
247
+ rmSync(stashDir, { recursive: true, force: true });
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Top-level command files (`commands/*.md` outside the multi-agent namespace)
253
+ * share `~/.claude/commands/` with the user's own commands, so each is written
254
+ * only when absent or unchanged since the last install (install manifest).
255
+ */
256
+ function installTopLevelCommands({ pipelineSrc, claudeDir, manifest, adopt }) {
257
+ const commandsSrc = join(pipelineSrc, "commands");
258
+ if (!existsSync(commandsSrc)) return;
259
+ const commandsDir = ensureRealDir(join(claudeDir, "commands"), {
260
+ pipelineSrc,
261
+ userOwned: true,
262
+ });
263
+ const rels = listFiles(commandsSrc).filter((r) => !r.startsWith("multi-agent/"));
264
+ if (!isDryRun()) ensureDir(commandsDir);
265
+ const res = syncTrackedFiles({
266
+ srcDir: commandsSrc,
267
+ destDir: commandsDir,
268
+ rels,
269
+ prefix: "commands/",
270
+ manifest,
271
+ adopt,
272
+ label: "shipped",
273
+ });
274
+ if (res.written || res.removed) {
275
+ console.log(
276
+ ` -> ${res.written} top-level command file(s) written, ${res.removed} retired one(s) removed`,
277
+ );
278
+ }
279
+ }
280
+
281
+ /** `--link` (dev) mode: every owned tree becomes a symlink into the checkout. */
282
+ function linkOwnedTrees(pipelineSrc, claudeDir) {
283
+ const links = [
284
+ ["commands", join(pipelineSrc, "commands")],
285
+ ["multi-agent-refs", join(pipelineSrc, "multi-agent-refs")],
286
+ ["scripts", join(pipelineSrc, "scripts")],
287
+ ["schemas", join(pipelineSrc, "schemas")],
288
+ ["lib", join(pipelineSrc, "lib")],
289
+ ["templates", join(dirname(pipelineSrc), "install", "templates")],
290
+ ];
291
+ console.log(" [Claude Code] Linking pipeline trees (--link)...");
292
+ for (const [name, src] of links) {
293
+ if (!existsSync(src)) continue;
294
+ copyDir(src, join(claudeDir, name), { useSymlinks: true });
295
+ console.log(` -> ${join(claudeDir, name)} -> ${src}`);
182
296
  }
183
- localizeCommandDescriptions(pipelineSrc, ownedCmdDir);
184
297
  }
185
298
 
186
299
  // Picker descriptions follow prefs.global.outputLanguage: files shipping a
187
300
  // description-tr frontmatter line get their description swapped in the
188
301
  // INSTALLED tree only (repo and npm package stay English; /multi-agent:sync
189
302
  // restores English before mirroring back). Failure is non-fatal.
190
- function localizeCommandDescriptions(pipelineSrc, ownedCmdDir) {
303
+ function localizeCommandDescriptions(claudeDir, ownedCmdDir) {
191
304
  if (isDryRun()) return;
192
305
  try {
193
- const home = dirname(dirname(ownedCmdDir));
194
- const prefsPath = join(home, "multi-agent-preferences.json");
306
+ const prefsPath = join(claudeDir, "multi-agent-preferences.json");
195
307
  if (!existsSync(prefsPath)) return;
196
308
  const prefs = JSON.parse(readFileSync(prefsPath, "utf-8"));
197
309
  if (prefs?.global?.outputLanguage !== "tr") return;
@@ -204,87 +316,45 @@ function localizeCommandDescriptions(pipelineSrc, ownedCmdDir) {
204
316
  }
205
317
  }
206
318
 
207
- // Move command dirs whose SKILL.md declares `local-only: true` into an on-disk
208
- // stash beside the namespace, so installCommands can restore them after the
209
- // namespace wipe. Returns the stash path.
210
- function stashLocalOnlyWrappers(ownedCmdDir) {
211
- const stashDir = join(dirname(ownedCmdDir), ".multi-agent-wrapper-stash");
212
- if (isDryRun() || !existsSync(ownedCmdDir)) return stashDir;
213
- for (const name of readdirSync(ownedCmdDir)) {
214
- const dir = join(ownedCmdDir, name);
215
- const skill = join(dir, "SKILL.md");
216
- if (!existsSync(skill)) continue;
217
- if (!/^local-only:\s*true\s*$/m.test(readFileSync(skill, "utf-8"))) continue;
218
- ensureDir(stashDir);
219
- const target = join(stashDir, name);
220
- if (existsSync(target)) rmSync(target, { recursive: true, force: true });
221
- renameSync(dir, target);
319
+ const isLocalOnlyDir = (dir) => {
320
+ const skill = join(dir, "SKILL.md");
321
+ try {
322
+ return existsSync(skill) && /^local-only:\s*true\s*$/m.test(readFileSync(skill, "utf-8"));
323
+ } catch {
324
+ return false;
222
325
  }
223
- return stashDir;
224
- }
225
-
226
- // Restore stashed local-only wrappers (including any leftover from an earlier
227
- // interrupted run), but never clobber a real command the pipeline just
228
- // installed under the same name. Returns the count restored.
229
- function unstashLocalOnlyWrappers(ownedCmdDir, stashDir) {
230
- if (isDryRun() || !existsSync(stashDir)) return 0;
326
+ };
327
+
328
+ // Copy local-only wrappers from the current namespace, and from a stash an
329
+ // older installer's interrupted run left beside it, into the staged namespace.
330
+ // A wrapper whose name the release now ships cannot be carried; say whose
331
+ // content is dropped, since /multi-agent:save writes exactly these.
332
+ function carryLocalOnlyWrappers(nsDest, stashDir, staged, pipelineSrc) {
333
+ const sources = [];
334
+ if (classifyInstallDir(nsDest, pipelineSrc) === "real") sources.push(nsDest);
335
+ if (existsSync(stashDir)) sources.push(stashDir);
231
336
  let n = 0;
232
- for (const name of readdirSync(stashDir)) {
233
- const from = join(stashDir, name);
234
- const to = join(ownedCmdDir, name);
235
- if (existsSync(to)) {
236
- // The pipeline now ships a real command under a name the user's wrapper
237
- // was using, so the wrapper cannot be restored. Say whose content is
238
- // being dropped: /multi-agent:save writes exactly these, and a release
239
- // claiming a new name would otherwise delete a saved routine in silence.
240
- console.log(
241
- ` -> WARNING: dropped local-only wrapper '${name}' - this release ships a command ` +
242
- `with that name. Re-save it under a different name if you still need it.`,
243
- );
244
- rmSync(from, { recursive: true, force: true });
245
- continue;
337
+ for (const from of sources) {
338
+ for (const name of readdirSync(from)) {
339
+ const dir = join(from, name);
340
+ if (from === nsDest && !isLocalOnlyDir(dir)) continue;
341
+ const to = join(staged, name);
342
+ if (existsSync(to)) {
343
+ if (from === nsDest && existsSync(join(pipelineSrc, "commands", "multi-agent", name))) {
344
+ console.log(
345
+ ` -> WARNING: dropped local-only wrapper '${name}' - this release ships a command ` +
346
+ `with that name. Re-save it under a different name if you still need it.`,
347
+ );
348
+ }
349
+ continue;
350
+ }
351
+ cpSync(dir, to, { recursive: true });
352
+ n++;
246
353
  }
247
- ensureDir(ownedCmdDir);
248
- renameSync(from, to);
249
- n++;
250
354
  }
251
- rmSync(stashDir, { recursive: true, force: true });
252
355
  return n;
253
356
  }
254
357
 
255
- // The pipeline's reference docs (refs/) + internal pickers live OUTSIDE the
256
- // commands/ tree so Claude Code does NOT register them as invocable
257
- // `/multi-agent:refs:*` slash commands. Commands read them by absolute path
258
- // ($HOME/.claude/multi-agent-refs/...). Wipe-before-copy so renamed/removed
259
- // refs don't linger.
260
- function installMultiAgentRefs(pipelineSrc, dest, useSymlinks) {
261
- const refsSrc = join(pipelineSrc, "multi-agent-refs");
262
- if (!existsSync(refsSrc)) return;
263
- console.log(" [Claude Code] Installing multi-agent refs (non-command)...");
264
- if (!useSymlinks) ensureRealDir(dest);
265
- wipeDir(dest);
266
- copyDir(refsSrc, dest, { useSymlinks });
267
- console.log(` -> ${countFiles(refsSrc)} ref files copied to ${dest}`);
268
- }
269
-
270
- function installScripts(pipelineSrc, dest, useSymlinks) {
271
- console.log(" [Claude Code] Installing scripts...");
272
- const scriptsSrc = join(pipelineSrc, "scripts");
273
- if (!existsSync(scriptsSrc)) return;
274
-
275
- // Wipe-before-copy - scripts/ is a 100% pipeline-managed tree, so files
276
- // removed upstream should not linger.
277
- if (!useSymlinks) ensureRealDir(dest);
278
- wipeDir(dest);
279
- copyDir(scriptsSrc, dest, { exclude: DEV_ONLY_SCRIPTS, useSymlinks });
280
- // Count files actually excluded by walking the tree: DEV_ONLY_SCRIPTS mixes
281
- // literal names, whole directories, and regexes (the maintainer-smoke rule),
282
- // so summing countFiles() over the array reported 0 for every regex entry.
283
- const excludedCount = countDevOnlyFiles(scriptsSrc);
284
- const scriptCount = countFiles(scriptsSrc) - excludedCount;
285
- console.log(` -> ${scriptCount} files copied to ${dest} (${excludedCount} dev-only excluded)`);
286
- }
287
-
288
358
  function installAgents(pipelineSrc, dest, useSymlinks) {
289
359
  console.log(" [Claude Code] Installing agent definitions...");
290
360
  const agentsSrc = join(pipelineSrc, "agents");
@@ -292,11 +362,12 @@ function installAgents(pipelineSrc, dest, useSymlinks) {
292
362
  // ~/.claude/agents also holds user-authored agent files. Never wipe the
293
363
  // whole dir; remove only the pipeline-shipped agent files (name set derived
294
364
  // from the source tree so it cannot go stale), then copy the fresh set in.
365
+ let target = dest;
295
366
  if (!useSymlinks) {
296
- ensureRealDir(dest);
297
- removePipelineAgentFiles(dest, agentsSrc);
367
+ target = ensureRealDir(dest, { pipelineSrc, userOwned: true });
368
+ removePipelineAgentFiles(target, agentsSrc);
298
369
  }
299
- copyDir(agentsSrc, dest, { useSymlinks });
370
+ copyDir(agentsSrc, target, { useSymlinks });
300
371
  console.log(` -> ${countFiles(agentsSrc)} files copied to ${dest}`);
301
372
  }
302
373
 
@@ -304,59 +375,19 @@ function installRules(pipelineSrc, dest, useSymlinks) {
304
375
  console.log(" [Claude Code] Installing rules...");
305
376
  const rulesSrc = join(pipelineSrc, "rules");
306
377
  if (!existsSync(rulesSrc)) return;
307
- // rules/ is USER-OWNED: users evolve their global rules locally and the sync
308
- // deliberately never pushes rules/ back to the repo (privacy). So an update
309
- // must NOT wipe+overwrite them (that reverts local edits, including the git
310
- // attribution rule). Install only files that do not already exist; leave the
311
- // user's existing rules untouched. (Symlink mode is a dev choice and keeps its
312
- // link-replace behavior.)
378
+ // rules/ is user-owned: users evolve their global rules locally and the sync
379
+ // never pushes rules/ back to the repo. An update installs only files that
380
+ // do not exist yet and leaves the user's rules untouched. (Symlink mode is a
381
+ // dev choice and keeps its link-replace behavior.)
313
382
  if (useSymlinks) {
314
383
  copyDir(rulesSrc, dest, { useSymlinks });
315
384
  } else {
316
- ensureRealDir(dest);
317
- copyDir(rulesSrc, dest, { skipExisting: true });
385
+ const target = ensureRealDir(dest, { pipelineSrc, userOwned: true });
386
+ copyDir(rulesSrc, target, { skipExisting: true });
318
387
  }
319
388
  console.log(` -> rules present (existing local rules preserved)`);
320
389
  }
321
390
 
322
- function installSchemas(pipelineSrc, dest, useSymlinks) {
323
- console.log(" [Claude Code] Installing JSON schemas...");
324
- const schemasSrc = join(pipelineSrc, "schemas");
325
- if (!existsSync(schemasSrc)) return;
326
- // Wipe-before-copy - schemas/ is a 100% pipeline-managed tree, so files
327
- // removed upstream should not linger.
328
- if (!useSymlinks) ensureRealDir(dest);
329
- wipeDir(dest);
330
- copyDir(schemasSrc, dest, { useSymlinks });
331
- console.log(` -> ${countFiles(schemasSrc)} files copied to ${dest}`);
332
- }
333
-
334
- function installLib(pipelineSrc, dest, useSymlinks) {
335
- console.log(" [Claude Code] Installing shell libraries...");
336
- const libSrc = join(pipelineSrc, "lib");
337
- if (!existsSync(libSrc)) return;
338
- // Wipe-before-copy - lib/ is a 100% pipeline-managed tree.
339
- if (!useSymlinks) ensureRealDir(dest);
340
- wipeDir(dest);
341
- copyDir(libSrc, dest, { useSymlinks });
342
- console.log(` -> ${countFiles(libSrc)} files copied to ${dest}`);
343
- }
344
-
345
- function installTemplates(pipelineSrc, dest, useSymlinks) {
346
- // The templates shipped in the tarball but never reached the user tree, so
347
- // `setup` told people to merge `install/templates/claude-hooks.json` - a path
348
- // that exists only in a checkout. From an install the instruction named a file
349
- // the reader did not have, and nothing said so.
350
- console.log(" [Claude Code] Installing templates...");
351
- const templatesSrc = join(dirname(pipelineSrc), "install", "templates");
352
- if (!existsSync(templatesSrc)) return;
353
- // Wipe-before-copy - templates/ is a 100% pipeline-managed tree.
354
- if (!useSymlinks) ensureRealDir(dest);
355
- wipeDir(dest);
356
- copyDir(templatesSrc, dest, { useSymlinks });
357
- console.log(` -> ${countFiles(templatesSrc)} files copied to ${dest}`);
358
- }
359
-
360
391
  function runPreDeployScans(pipelineSrc) {
361
392
  // Pre-deploy security scan - warn-only, never halts install on its own.
362
393
  const scanScript = join(pipelineSrc, "scripts", "scan-skills.sh");
@@ -398,12 +429,13 @@ function runPreDeployScans(pipelineSrc) {
398
429
  }
399
430
 
400
431
  function installSkills(opts) {
401
- const { pipelineSrc, dest, indexOnly, useSymlinks, pruneExternal } = opts;
432
+ const { pipelineSrc, indexOnly, useSymlinks, pruneExternal } = opts;
402
433
  console.log(" [Claude Code] Installing skills...");
403
434
 
404
435
  // Same symlink guard as the other owned trees: never prune or copy through
405
- // a --link-era symlink into the repo checkout.
406
- if (!useSymlinks) ensureRealDir(dest);
436
+ // a --link-era symlink into the repo checkout. The skills root also holds
437
+ // the user's own skills, so a symlink elsewhere is written through.
438
+ const dest = useSymlinks ? opts.dest : ensureRealDir(opts.dest, { pipelineSrc, userOwned: true });
407
439
 
408
440
  if (indexOnly) {
409
441
  ensureDir(dest);
@@ -417,12 +449,11 @@ function installSkills(opts) {
417
449
 
418
450
  let claudeSkillCount = 0;
419
451
 
420
- // Claude Code no longer receives a local copy of the stack skills. The
421
- // marketplace plugins (`ai-<stack>-toolkit`) are its only stack-skill source -
422
- // a local copy duplicated ~110 skills against the enabled plugins (~10k
423
- // tokens/session) and shadowed the plugin the moment it went stale. Only the
424
- // two pipeline-owned compliance catalogs stay local (PIPELINE_LOCAL_SKILLS);
425
- // they are deliberately not published to the marketplace.
452
+ // Claude Code gets its stack skills from the marketplace plugins
453
+ // (`ai-<stack>-toolkit`) only: a local copy would duplicate them against the
454
+ // enabled plugins and shadow a plugin once the copy went stale. Only the two
455
+ // pipeline-owned compliance catalogs stay local (PIPELINE_LOCAL_SKILLS); they
456
+ // are deliberately not published to the marketplace.
426
457
  ensureDir(dest);
427
458
  for (const name of PIPELINE_LOCAL_SKILLS) {
428
459
  const from = join(sharedCoreSrc, name);
@@ -434,9 +465,8 @@ function installSkills(opts) {
434
465
 
435
466
  // Single-standard enforcement: Claude Code invokes pipeline commands via
436
467
  // the `/multi-agent:*` slash-command namespace, so prune duplicate skill
437
- // dirs left by older installs that copied shared/core wholesale. This used
438
- // to live inside the external-copy branch; it must run unconditionally or
439
- // the 51 multi-agent-* dirs return on the first install after a migration.
468
+ // dirs left by older installs that copied shared/core wholesale. It runs on
469
+ // every install, so such dirs cannot survive a migration.
440
470
  const pruned = pruneLegacyMultiAgentSkills(dest);
441
471
  if (pruned > 0) {
442
472
  console.log(
@@ -482,12 +512,9 @@ function installSkills(opts) {
482
512
  }
483
513
  }
484
514
 
485
- // The index ships on FULL installs too, not just --index-only. It is what
486
- // `match-skills.mjs` reads when prefs.global.dynamicSkillLoading is on, and the
487
- // schema has always claimed a full install provides it. It did not: only
488
- // --index-only copied it, so flipping the pref on a normal install produced an
489
- // exit-1 "cannot read index" on the first dispatch. Two small files, and the
490
- // feature is either wired or it is not.
515
+ // The index ships on full installs too: `match-skills.mjs` reads it when
516
+ // prefs.global.dynamicSkillLoading is on, and the preferences schema states
517
+ // that a full install provides it.
491
518
  copySkillsIndex(pipelineSrc, dest);
492
519
 
493
520
  // Same pre-directory-layout leftovers Copilot accumulated. Claude Code's tree is
@@ -718,6 +745,39 @@ export function collapseDuplicateHooks(hooks) {
718
745
  return changed;
719
746
  }
720
747
 
748
+ /**
749
+ * The hook command for a script installed under ~/.claude/scripts. The path is
750
+ * quoted: Claude Code runs the command through a shell, and an unquoted $HOME
751
+ * containing a space splits into two words, bash exits 126/127 and the gate
752
+ * stops running without anyone seeing it.
753
+ *
754
+ * @param {string} script file name under ~/.claude/scripts
755
+ */
756
+ export const pipelineHookCommand = (script) => `bash "$HOME/.claude/scripts/${script}"`;
757
+
758
+ const UNQUOTED_PIPELINE_HOOK =
759
+ /^bash (?:\$HOME|\$\{HOME\})\/\.claude\/scripts\/([\w.-]+\.sh)(\s.*)?$/;
760
+
761
+ // Rewrite pipeline hook commands registered with an unquoted $HOME (by this
762
+ // installer or a merged hooks template) to the quoted form, in place, so a
763
+ // re-install fixes them instead of adding a second copy.
764
+ export function quotePipelineHookCommands(hooks) {
765
+ if (!hooks || typeof hooks !== "object") return false;
766
+ let changed = false;
767
+ for (const groups of Object.values(hooks)) {
768
+ if (!Array.isArray(groups)) continue;
769
+ for (const group of groups) {
770
+ for (const h of Array.isArray(group?.hooks) ? group.hooks : []) {
771
+ const m = typeof h?.command === "string" ? UNQUOTED_PIPELINE_HOOK.exec(h.command) : null;
772
+ if (!m) continue;
773
+ h.command = `${pipelineHookCommand(m[1])}${m[2] || ""}`;
774
+ changed = true;
775
+ }
776
+ }
777
+ }
778
+ return changed;
779
+ }
780
+
721
781
  export function configureSettings(home) {
722
782
  console.log(" [Claude Code] Configuring hooks + context management...");
723
783
  const SETTINGS_PATH = join(home, ".claude", "settings.json");
@@ -736,10 +796,11 @@ export function configureSettings(home) {
736
796
 
737
797
  if (!settings.hooks) settings.hooks = {};
738
798
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
799
+ if (quotePipelineHookCommands(settings.hooks)) settingsChanged = true;
739
800
  // PreToolUse matchers are regexes over the TOOL NAME only, so the correct
740
- // matcher is "Bash". The pre-v11.4.1 entry used "Bash(git commit:*)",
741
- // which never matches any tool name - the secret gate never fired.
742
- // Migrate that dead entry in place instead of stacking a duplicate.
801
+ // matcher is "Bash". A "Bash(git commit:*)" matcher (LEGACY_PRE_COMMIT_MATCHER)
802
+ // never matches a tool name, so the gate would never fire; such an entry
803
+ // is migrated in place instead of stacking a duplicate.
743
804
  // Command-level filtering (only scan on actual `git commit`) happens
744
805
  // inside pre-commit-check.sh via the hook's stdin JSON.
745
806
  const isPreCommitEntry = (h) =>
@@ -762,7 +823,7 @@ export function configureSettings(home) {
762
823
  hooks: [
763
824
  {
764
825
  type: "command",
765
- command: "bash $HOME/.claude/scripts/pre-commit-check.sh",
826
+ command: pipelineHookCommand("pre-commit-check.sh"),
766
827
  timeout: 15,
767
828
  statusMessage: "Scanning staged changes for secrets...",
768
829
  },
@@ -813,7 +874,7 @@ export function configureSettings(home) {
813
874
  hooks: [
814
875
  {
815
876
  type: "command",
816
- command: "bash $HOME/.claude/scripts/agent-guard.sh",
877
+ command: pipelineHookCommand("agent-guard.sh"),
817
878
  timeout: AGENT_GUARD_TIMEOUT,
818
879
  statusMessage,
819
880
  },