@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.3

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 (77) hide show
  1. package/CHANGELOG.md +42 -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 +535 -53
  6. package/install/_dev-only-files.mjs +1 -0
  7. package/install/_mcp-register.mjs +173 -117
  8. package/install/catalog-history.json +1 -0
  9. package/install/claude.mjs +293 -226
  10. package/install/codex.mjs +7 -7
  11. package/install/copilot.mjs +13 -11
  12. package/install/index.mjs +92 -27
  13. package/install/templates/claude-hooks.json +9 -9
  14. package/manifest.json +77 -79
  15. package/package.json +6 -2
  16. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  17. package/pipeline/lib/confusables.json +79 -33
  18. package/pipeline/lib/extract-conventions.sh +3 -3
  19. package/pipeline/lib/json-file-lock.mjs +27 -7
  20. package/pipeline/lib/normalize-text.mjs +86 -17
  21. package/pipeline/lib/outbound-gate.mjs +13 -4
  22. package/pipeline/lib/redact.mjs +87 -14
  23. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  24. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  25. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  26. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  27. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  28. package/pipeline/scripts/agent-guard.py +150 -25
  29. package/pipeline/scripts/audit-log.sh +3 -4
  30. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  31. package/pipeline/scripts/doctor.mjs +8 -2
  32. package/pipeline/scripts/log-metric.sh +9 -3
  33. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  34. package/pipeline/scripts/pre-commit-check.sh +119 -31
  35. package/pipeline/scripts/scan-agent-config.sh +9 -9
  36. package/pipeline/scripts/unattended_policy.py +12 -3
  37. package/pipeline/scripts/uninstall.mjs +88 -1
  38. package/pipeline/scripts/usage-identity.mjs +1 -1
  39. package/pipeline/scripts/usage-register.mjs +1 -1
  40. package/pipeline/skills/.skill-manifest.json +11 -11
  41. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  42. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  43. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  44. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  45. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  47. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  49. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  50. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  60. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  61. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  62. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  63. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  64. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  65. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  66. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  67. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  68. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  69. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  70. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  71. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  72. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  73. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  74. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  75. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  76. package/pipeline/scripts/make-manifest.mjs +0 -199
  77. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -5,46 +5,51 @@
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,
27
25
  countFiles,
28
26
  dirsIdentical,
27
+ loadCatalogHistory,
28
+ matchesShippedVersion,
29
29
  ensureDir,
30
30
  ensureRealDir,
31
31
  isDryRun,
32
32
  isLocalOnlySkill,
33
+ listFiles,
33
34
  pruneAbandonedTrees,
34
35
  pruneLegacyMultiAgentSkills,
35
36
  pruneOrphanSkillFiles,
37
+ readInstallManifest,
36
38
  removePipelineAgentFiles,
39
+ stageAndSwapTrees,
40
+ syncTrackedFiles,
37
41
  wipeDir,
38
42
  writeFile,
43
+ writeInstallManifest,
39
44
  } from "./_common.mjs";
40
45
  import { EXTERNAL_SKILLS_MANIFEST } from "./_platform-filter.mjs";
41
46
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
42
47
  import { registerMcpServer } from "./_mcp-register.mjs";
43
48
 
44
49
  /**
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.
50
+ * A matcher an older install wrote. Hook matchers only ever see the tool name,
51
+ * so this string never matches and the hook never runs. Kept so installs can
52
+ * migrate it and uninstall can clean it.
48
53
  */
49
54
  export const LEGACY_PRE_COMMIT_MATCHER = "Bash(git commit:*)";
50
55
  export const AGENT_GUARD_FILE_MATCHER = "Edit|Write|NotebookEdit";
@@ -87,33 +92,35 @@ export const PIPELINE_LOCAL_SKILLS = ["apple-archive-compliance", "google-play-c
87
92
  export function installClaude(ctx) {
88
93
  const { home, pipelineSrc, indexOnly, useSymlinks, platformFlag, pruneExternal } = ctx;
89
94
 
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");
95
+ const CLAUDE_DIR = join(home, ".claude");
96
+ const CLAUDE_AGENTS = join(CLAUDE_DIR, "agents");
97
+ const PREFS_PATH = join(CLAUDE_DIR, "multi-agent-preferences.json");
98
+ const CLAUDE_RULES = join(CLAUDE_DIR, "rules");
99
+ const CLAUDE_SKILLS = join(CLAUDE_DIR, "skills");
100
100
 
101
101
  // Before laying anything down: drop trees an older install created that no
102
102
  // 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"));
103
+ // written, so an abandoned one would otherwise stay on disk indefinitely.
104
+ const prunedTrees = pruneAbandonedTrees(CLAUDE_DIR);
105
105
  if (prunedTrees > 0) {
106
106
  console.log(` [Claude Code] Removed ${prunedTrees} abandoned tree(s) from an older install`);
107
107
  }
108
108
 
109
- installCommands(pipelineSrc, CLAUDE_COMMANDS, useSymlinks);
110
- installMultiAgentRefs(pipelineSrc, CLAUDE_MA_REFS, useSymlinks);
111
- installScripts(pipelineSrc, CLAUDE_SCRIPTS, useSymlinks);
109
+ const manifest = readInstallManifest(CLAUDE_DIR);
110
+ // An install without a manifest overwrote every file it shipped on each run,
111
+ // so after one (the version stamp says one happened) a file carrying a
112
+ // shipped name is that installer's copy. On a machine with no prior install
113
+ // the same file is the user's.
114
+ const adopt = !manifest.present && existsSync(join(CLAUDE_DIR, ".pipeline-version"));
115
+
116
+ if (useSymlinks) {
117
+ linkOwnedTrees(pipelineSrc, CLAUDE_DIR);
118
+ } else {
119
+ installOwnedTrees({ pipelineSrc, claudeDir: CLAUDE_DIR, manifest, adopt });
120
+ installTopLevelCommands({ pipelineSrc, claudeDir: CLAUDE_DIR, manifest, adopt });
121
+ }
112
122
  installAgents(pipelineSrc, CLAUDE_AGENTS, useSymlinks);
113
123
  installRules(pipelineSrc, CLAUDE_RULES, useSymlinks);
114
- installSchemas(pipelineSrc, CLAUDE_SCHEMAS, useSymlinks);
115
- installLib(pipelineSrc, CLAUDE_LIB, useSymlinks);
116
- installTemplates(pipelineSrc, CLAUDE_TEMPLATES, useSymlinks);
117
124
  runPreDeployScans(pipelineSrc);
118
125
  installSkills({
119
126
  pipelineSrc,
@@ -126,72 +133,179 @@ export function installClaude(ctx) {
126
133
  ensureClaudeMd(home, pipelineSrc);
127
134
  ensurePreferences(PREFS_PATH, pipelineSrc);
128
135
  configureSettings(home);
136
+ if (!useSymlinks) writeInstallManifest(CLAUDE_DIR, manifest);
129
137
 
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.
138
+ // The multi-agent-toolkit server backs design-check, every ios_* / android_*
139
+ // simulator call and the archive audits; the skills fail only when a run
140
+ // reaches for one of its tools, so the installer registers it up front.
136
141
  registerMcpServer("claude", "Claude Code");
137
142
 
138
143
  console.log("");
139
144
  }
140
145
 
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;
146
+ /**
147
+ * The trees under ~/.claude the pipeline owns outright, laid down as one
148
+ * transaction: each is built beside its destination and all are swapped in
149
+ * together, so a failed copy leaves the previous install whole.
150
+ *
151
+ * `commands/multi-agent` is the pipeline's namespace; local-only alias
152
+ * wrappers (frontmatter `local-only: true`, written by /multi-agent:save and
153
+ * never shipped) are carried into the new copy. `lib/` and `templates/` can
154
+ * hold files the user put there, so they are built from the current copy and
155
+ * updated file by file against the install manifest.
156
+ */
157
+ function installOwnedTrees({ pipelineSrc, claudeDir, manifest, adopt }) {
158
+ const commandsDir = ensureRealDir(join(claudeDir, "commands"), {
159
+ pipelineSrc,
160
+ userOwned: true,
161
+ });
162
+ const nsDest = join(commandsDir, "multi-agent");
163
+ const stashDir = join(commandsDir, ".multi-agent-wrapper-stash");
164
+ const trees = [];
165
+ const logs = [];
166
+ const add = (dest, label, src, build) => {
167
+ if (!src || !existsSync(src)) return;
168
+ if (classifyInstallDir(dest, pipelineSrc) === "foreign-link") {
169
+ ensureRealDir(dest, { pipelineSrc });
170
+ return;
171
+ }
172
+ trees.push({ dest, label, build });
173
+ };
145
174
 
146
- const ownedCmdDir = join(dest, "multi-agent");
175
+ console.log(
176
+ " [Claude Code] Installing pipeline commands, refs, scripts, schemas, lib, templates...",
177
+ );
147
178
 
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;
179
+ const nsSrc = join(pipelineSrc, "commands", "multi-agent");
180
+ add(nsDest, "commands", nsSrc, (staged) => {
181
+ copyDir(nsSrc, staged);
182
+ const restored = carryLocalOnlyWrappers(nsDest, stashDir, staged, pipelineSrc);
183
+ localizeCommandDescriptions(claudeDir, staged);
184
+ logs.push(` -> ${countFiles(nsSrc)} files copied to ${nsDest}`);
185
+ if (restored > 0) logs.push(` -> preserved ${restored} local-only alias wrapper(s)`);
186
+ });
187
+
188
+ // refs/ and the internal pickers live outside commands/ so Claude Code does
189
+ // not register them as `/multi-agent:refs:*` slash commands; commands read
190
+ // them by absolute path.
191
+ const refsSrc = join(pipelineSrc, "multi-agent-refs");
192
+ const refsDest = join(claudeDir, "multi-agent-refs");
193
+ add(refsDest, "refs", refsSrc, (staged) => {
194
+ copyDir(refsSrc, staged);
195
+ logs.push(` -> ${countFiles(refsSrc)} ref files copied to ${refsDest}`);
196
+ });
197
+
198
+ const scriptsSrc = join(pipelineSrc, "scripts");
199
+ const scriptsDest = join(claudeDir, "scripts");
200
+ add(scriptsDest, "scripts", scriptsSrc, (staged) => {
201
+ copyDir(scriptsSrc, staged, { exclude: DEV_ONLY_SCRIPTS });
202
+ // DEV_ONLY_SCRIPTS mixes literal names, directories and regexes, so the
203
+ // excluded count comes from walking the tree.
204
+ const excluded = countDevOnlyFiles(scriptsSrc);
205
+ logs.push(
206
+ ` -> ${countFiles(scriptsSrc) - excluded} files copied to ${scriptsDest} (${excluded} dev-only excluded)`,
207
+ );
208
+ });
209
+
210
+ const schemasSrc = join(pipelineSrc, "schemas");
211
+ const schemasDest = join(claudeDir, "schemas");
212
+ add(schemasDest, "schemas", schemasSrc, (staged) => {
213
+ copyDir(schemasSrc, staged);
214
+ logs.push(` -> ${countFiles(schemasSrc)} files copied to ${schemasDest}`);
215
+ });
216
+
217
+ for (const [name, src] of [
218
+ ["lib", join(pipelineSrc, "lib")],
219
+ ["templates", join(dirname(pipelineSrc), "install", "templates")],
220
+ ]) {
221
+ const dest = join(claudeDir, name);
222
+ add(dest, name, src, (staged) => {
223
+ if (classifyInstallDir(dest, pipelineSrc) === "real") {
224
+ cpSync(dest, staged, { recursive: true });
225
+ } else {
226
+ ensureDir(staged);
227
+ }
228
+ const res = syncTrackedFiles({
229
+ srcDir: src,
230
+ destDir: staged,
231
+ displayDir: dest,
232
+ rels: listFiles(src),
233
+ prefix: `${name}/`,
234
+ manifest,
235
+ adopt,
236
+ reportUnknown: true,
237
+ label: "shipped",
238
+ });
239
+ logs.push(
240
+ ` -> ${countFiles(src) - res.kept.length} files in place at ${dest}` +
241
+ (res.removed ? ` (${res.removed} retired file(s) removed)` : ""),
242
+ );
243
+ });
155
244
  }
156
245
 
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)`);
246
+ stageAndSwapTrees(trees, { swapRoot: join(claudeDir, ".pipeline-swap") });
247
+ for (const line of logs) console.log(line);
248
+ if (!isDryRun() && existsSync(stashDir) && trees.some((t) => t.dest === nsDest)) {
249
+ rmSync(stashDir, { recursive: true, force: true });
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Top-level command files (`commands/*.md` outside the multi-agent namespace)
255
+ * share `~/.claude/commands/` with the user's own commands, so each is written
256
+ * only when absent or unchanged since the last install (install manifest).
257
+ */
258
+ function installTopLevelCommands({ pipelineSrc, claudeDir, manifest, adopt }) {
259
+ const commandsSrc = join(pipelineSrc, "commands");
260
+ if (!existsSync(commandsSrc)) return;
261
+ const commandsDir = ensureRealDir(join(claudeDir, "commands"), {
262
+ pipelineSrc,
263
+ userOwned: true,
264
+ });
265
+ const rels = listFiles(commandsSrc).filter((r) => !r.startsWith("multi-agent/"));
266
+ if (!isDryRun()) ensureDir(commandsDir);
267
+ const res = syncTrackedFiles({
268
+ srcDir: commandsSrc,
269
+ destDir: commandsDir,
270
+ rels,
271
+ prefix: "commands/",
272
+ manifest,
273
+ adopt,
274
+ label: "shipped",
275
+ });
276
+ if (res.written || res.removed) {
277
+ console.log(
278
+ ` -> ${res.written} top-level command file(s) written, ${res.removed} retired one(s) removed`,
279
+ );
280
+ }
281
+ }
282
+
283
+ /** `--link` (dev) mode: every owned tree becomes a symlink into the checkout. */
284
+ function linkOwnedTrees(pipelineSrc, claudeDir) {
285
+ const links = [
286
+ ["commands", join(pipelineSrc, "commands")],
287
+ ["multi-agent-refs", join(pipelineSrc, "multi-agent-refs")],
288
+ ["scripts", join(pipelineSrc, "scripts")],
289
+ ["schemas", join(pipelineSrc, "schemas")],
290
+ ["lib", join(pipelineSrc, "lib")],
291
+ ["templates", join(dirname(pipelineSrc), "install", "templates")],
292
+ ];
293
+ console.log(" [Claude Code] Linking pipeline trees (--link)...");
294
+ for (const [name, src] of links) {
295
+ if (!existsSync(src)) continue;
296
+ copyDir(src, join(claudeDir, name), { useSymlinks: true });
297
+ console.log(` -> ${join(claudeDir, name)} -> ${src}`);
182
298
  }
183
- localizeCommandDescriptions(pipelineSrc, ownedCmdDir);
184
299
  }
185
300
 
186
301
  // Picker descriptions follow prefs.global.outputLanguage: files shipping a
187
302
  // description-tr frontmatter line get their description swapped in the
188
303
  // INSTALLED tree only (repo and npm package stay English; /multi-agent:sync
189
304
  // restores English before mirroring back). Failure is non-fatal.
190
- function localizeCommandDescriptions(pipelineSrc, ownedCmdDir) {
305
+ function localizeCommandDescriptions(claudeDir, ownedCmdDir) {
191
306
  if (isDryRun()) return;
192
307
  try {
193
- const home = dirname(dirname(ownedCmdDir));
194
- const prefsPath = join(home, "multi-agent-preferences.json");
308
+ const prefsPath = join(claudeDir, "multi-agent-preferences.json");
195
309
  if (!existsSync(prefsPath)) return;
196
310
  const prefs = JSON.parse(readFileSync(prefsPath, "utf-8"));
197
311
  if (prefs?.global?.outputLanguage !== "tr") return;
@@ -204,87 +318,45 @@ function localizeCommandDescriptions(pipelineSrc, ownedCmdDir) {
204
318
  }
205
319
  }
206
320
 
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);
321
+ const isLocalOnlyDir = (dir) => {
322
+ const skill = join(dir, "SKILL.md");
323
+ try {
324
+ return existsSync(skill) && /^local-only:\s*true\s*$/m.test(readFileSync(skill, "utf-8"));
325
+ } catch {
326
+ return false;
222
327
  }
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;
328
+ };
329
+
330
+ // Copy local-only wrappers from the current namespace, and from a stash an
331
+ // older installer's interrupted run left beside it, into the staged namespace.
332
+ // A wrapper whose name the release now ships cannot be carried; say whose
333
+ // content is dropped, since /multi-agent:save writes exactly these.
334
+ function carryLocalOnlyWrappers(nsDest, stashDir, staged, pipelineSrc) {
335
+ const sources = [];
336
+ if (classifyInstallDir(nsDest, pipelineSrc) === "real") sources.push(nsDest);
337
+ if (existsSync(stashDir)) sources.push(stashDir);
231
338
  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;
339
+ for (const from of sources) {
340
+ for (const name of readdirSync(from)) {
341
+ const dir = join(from, name);
342
+ if (from === nsDest && !isLocalOnlyDir(dir)) continue;
343
+ const to = join(staged, name);
344
+ if (existsSync(to)) {
345
+ if (from === nsDest && existsSync(join(pipelineSrc, "commands", "multi-agent", name))) {
346
+ console.log(
347
+ ` -> WARNING: dropped local-only wrapper '${name}' - this release ships a command ` +
348
+ `with that name. Re-save it under a different name if you still need it.`,
349
+ );
350
+ }
351
+ continue;
352
+ }
353
+ cpSync(dir, to, { recursive: true });
354
+ n++;
246
355
  }
247
- ensureDir(ownedCmdDir);
248
- renameSync(from, to);
249
- n++;
250
356
  }
251
- rmSync(stashDir, { recursive: true, force: true });
252
357
  return n;
253
358
  }
254
359
 
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
360
  function installAgents(pipelineSrc, dest, useSymlinks) {
289
361
  console.log(" [Claude Code] Installing agent definitions...");
290
362
  const agentsSrc = join(pipelineSrc, "agents");
@@ -292,11 +364,12 @@ function installAgents(pipelineSrc, dest, useSymlinks) {
292
364
  // ~/.claude/agents also holds user-authored agent files. Never wipe the
293
365
  // whole dir; remove only the pipeline-shipped agent files (name set derived
294
366
  // from the source tree so it cannot go stale), then copy the fresh set in.
367
+ let target = dest;
295
368
  if (!useSymlinks) {
296
- ensureRealDir(dest);
297
- removePipelineAgentFiles(dest, agentsSrc);
369
+ target = ensureRealDir(dest, { pipelineSrc, userOwned: true });
370
+ removePipelineAgentFiles(target, agentsSrc);
298
371
  }
299
- copyDir(agentsSrc, dest, { useSymlinks });
372
+ copyDir(agentsSrc, target, { useSymlinks });
300
373
  console.log(` -> ${countFiles(agentsSrc)} files copied to ${dest}`);
301
374
  }
302
375
 
@@ -304,59 +377,19 @@ function installRules(pipelineSrc, dest, useSymlinks) {
304
377
  console.log(" [Claude Code] Installing rules...");
305
378
  const rulesSrc = join(pipelineSrc, "rules");
306
379
  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.)
380
+ // rules/ is user-owned: users evolve their global rules locally and the sync
381
+ // never pushes rules/ back to the repo. An update installs only files that
382
+ // do not exist yet and leaves the user's rules untouched. (Symlink mode is a
383
+ // dev choice and keeps its link-replace behavior.)
313
384
  if (useSymlinks) {
314
385
  copyDir(rulesSrc, dest, { useSymlinks });
315
386
  } else {
316
- ensureRealDir(dest);
317
- copyDir(rulesSrc, dest, { skipExisting: true });
387
+ const target = ensureRealDir(dest, { pipelineSrc, userOwned: true });
388
+ copyDir(rulesSrc, target, { skipExisting: true });
318
389
  }
319
390
  console.log(` -> rules present (existing local rules preserved)`);
320
391
  }
321
392
 
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
393
  function runPreDeployScans(pipelineSrc) {
361
394
  // Pre-deploy security scan - warn-only, never halts install on its own.
362
395
  const scanScript = join(pipelineSrc, "scripts", "scan-skills.sh");
@@ -398,12 +431,13 @@ function runPreDeployScans(pipelineSrc) {
398
431
  }
399
432
 
400
433
  function installSkills(opts) {
401
- const { pipelineSrc, dest, indexOnly, useSymlinks, pruneExternal } = opts;
434
+ const { pipelineSrc, indexOnly, useSymlinks, pruneExternal } = opts;
402
435
  console.log(" [Claude Code] Installing skills...");
403
436
 
404
437
  // 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);
438
+ // a --link-era symlink into the repo checkout. The skills root also holds
439
+ // the user's own skills, so a symlink elsewhere is written through.
440
+ const dest = useSymlinks ? opts.dest : ensureRealDir(opts.dest, { pipelineSrc, userOwned: true });
407
441
 
408
442
  if (indexOnly) {
409
443
  ensureDir(dest);
@@ -417,12 +451,11 @@ function installSkills(opts) {
417
451
 
418
452
  let claudeSkillCount = 0;
419
453
 
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.
454
+ // Claude Code gets its stack skills from the marketplace plugins
455
+ // (`ai-<stack>-toolkit`) only: a local copy would duplicate them against the
456
+ // enabled plugins and shadow a plugin once the copy went stale. Only the two
457
+ // pipeline-owned compliance catalogs stay local (PIPELINE_LOCAL_SKILLS); they
458
+ // are deliberately not published to the marketplace.
426
459
  ensureDir(dest);
427
460
  for (const name of PIPELINE_LOCAL_SKILLS) {
428
461
  const from = join(sharedCoreSrc, name);
@@ -434,9 +467,8 @@ function installSkills(opts) {
434
467
 
435
468
  // Single-standard enforcement: Claude Code invokes pipeline commands via
436
469
  // 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.
470
+ // dirs left by older installs that copied shared/core wholesale. It runs on
471
+ // every install, so such dirs cannot survive a migration.
440
472
  const pruned = pruneLegacyMultiAgentSkills(dest);
441
473
  if (pruned > 0) {
442
474
  console.log(
@@ -457,11 +489,13 @@ function installSkills(opts) {
457
489
  // Pre-manifest installs (<= v14.x) never wrote that manifest, so the branch
458
490
  // above cannot fire for them and the full 151-dir catalog would outlive every
459
491
  // upgrade. For those, prune by proof instead of by name: a dir byte-identical
460
- // to the shipped catalog contains nothing of the user's and goes now; a dir
492
+ // to the shipped catalog, or to any version of it ever shipped
493
+ // (install/catalog-history.json), contains nothing of the user's and goes now; a dir
461
494
  // that differs is kept unless --prune-external explicitly opts in (and even
462
495
  // then a `local-only: true` SKILL.md keeps it).
463
496
  migratePreManifestExternalSkills(dest, join(pipelineSrc, "skills", "shared", "external"), {
464
497
  pruneExternal: Boolean(pruneExternal),
498
+ history: loadCatalogHistory(join(dirname(pipelineSrc), "install", "catalog-history.json")),
465
499
  });
466
500
 
467
501
  // Figma component skills moved to the ai-<platform>-toolkit marketplace
@@ -482,12 +516,9 @@ function installSkills(opts) {
482
516
  }
483
517
  }
484
518
 
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.
519
+ // The index ships on full installs too: `match-skills.mjs` reads it when
520
+ // prefs.global.dynamicSkillLoading is on, and the preferences schema states
521
+ // that a full install provides it.
491
522
  copySkillsIndex(pipelineSrc, dest);
492
523
 
493
524
  // Same pre-directory-layout leftovers Copilot accumulated. Claude Code's tree is
@@ -570,8 +601,8 @@ function pruneManifestDeliveredSkills(dest) {
570
601
  /**
571
602
  * Migration for pre-manifest installs (<= v14.x): the external catalog was
572
603
  * copied here with no delivery manifest, so ours and the user's are told apart
573
- * by content, not by name. Byte-identical to the shipped catalog = provably
574
- * ours, removed. Different = kept, reported, and removable only via the
604
+ * by content, not by name. Byte-identical to the shipped catalog, or every
605
+ * file matching a version the catalog once shipped = provably ours, removed. Different = kept, reported, and removable only via the
575
606
  * explicit `--prune-external` flag (which still honors `local-only: true`).
576
607
  *
577
608
  * @param {string} dest - the installed skills directory
@@ -580,7 +611,7 @@ function pruneManifestDeliveredSkills(dest) {
580
611
  */
581
612
  function migratePreManifestExternalSkills(dest, externalSrc, opts) {
582
613
  if (!existsSync(externalSrc) || !existsSync(dest)) return;
583
- const { pruneExternal } = opts;
614
+ const { pruneExternal, history } = opts;
584
615
  let identical = 0;
585
616
  let flagged = 0;
586
617
  const leftover = [];
@@ -608,13 +639,15 @@ function migratePreManifestExternalSkills(dest, externalSrc, opts) {
608
639
 
609
640
  let provablyOurs;
610
641
  try {
611
- provablyOurs = dirsIdentical(join(externalSrc, name), stale);
642
+ provablyOurs =
643
+ dirsIdentical(join(externalSrc, name), stale) ||
644
+ matchesShippedVersion(stale, history, name);
612
645
  } catch {
613
646
  provablyOurs = false;
614
647
  }
615
648
  if (provablyOurs) {
616
649
  if (isDryRun()) {
617
- console.log(` [dry-run] would prune ${name}/ (identical to shipped catalog)`);
650
+ console.log(` [dry-run] would prune ${name}/ (matches a shipped catalog version)`);
618
651
  identical++;
619
652
  continue;
620
653
  }
@@ -718,6 +751,39 @@ export function collapseDuplicateHooks(hooks) {
718
751
  return changed;
719
752
  }
720
753
 
754
+ /**
755
+ * The hook command for a script installed under ~/.claude/scripts. The path is
756
+ * quoted: Claude Code runs the command through a shell, and an unquoted $HOME
757
+ * containing a space splits into two words, bash exits 126/127 and the gate
758
+ * stops running without anyone seeing it.
759
+ *
760
+ * @param {string} script file name under ~/.claude/scripts
761
+ */
762
+ export const pipelineHookCommand = (script) => `bash "$HOME/.claude/scripts/${script}"`;
763
+
764
+ const UNQUOTED_PIPELINE_HOOK =
765
+ /^bash (?:\$HOME|\$\{HOME\})\/\.claude\/scripts\/([\w.-]+\.sh)(\s.*)?$/;
766
+
767
+ // Rewrite pipeline hook commands registered with an unquoted $HOME (by this
768
+ // installer or a merged hooks template) to the quoted form, in place, so a
769
+ // re-install fixes them instead of adding a second copy.
770
+ export function quotePipelineHookCommands(hooks) {
771
+ if (!hooks || typeof hooks !== "object") return false;
772
+ let changed = false;
773
+ for (const groups of Object.values(hooks)) {
774
+ if (!Array.isArray(groups)) continue;
775
+ for (const group of groups) {
776
+ for (const h of Array.isArray(group?.hooks) ? group.hooks : []) {
777
+ const m = typeof h?.command === "string" ? UNQUOTED_PIPELINE_HOOK.exec(h.command) : null;
778
+ if (!m) continue;
779
+ h.command = `${pipelineHookCommand(m[1])}${m[2] || ""}`;
780
+ changed = true;
781
+ }
782
+ }
783
+ }
784
+ return changed;
785
+ }
786
+
721
787
  export function configureSettings(home) {
722
788
  console.log(" [Claude Code] Configuring hooks + context management...");
723
789
  const SETTINGS_PATH = join(home, ".claude", "settings.json");
@@ -736,10 +802,11 @@ export function configureSettings(home) {
736
802
 
737
803
  if (!settings.hooks) settings.hooks = {};
738
804
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
805
+ if (quotePipelineHookCommands(settings.hooks)) settingsChanged = true;
739
806
  // 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.
807
+ // matcher is "Bash". A "Bash(git commit:*)" matcher (LEGACY_PRE_COMMIT_MATCHER)
808
+ // never matches a tool name, so the gate would never fire; such an entry
809
+ // is migrated in place instead of stacking a duplicate.
743
810
  // Command-level filtering (only scan on actual `git commit`) happens
744
811
  // inside pre-commit-check.sh via the hook's stdin JSON.
745
812
  const isPreCommitEntry = (h) =>
@@ -762,7 +829,7 @@ export function configureSettings(home) {
762
829
  hooks: [
763
830
  {
764
831
  type: "command",
765
- command: "bash $HOME/.claude/scripts/pre-commit-check.sh",
832
+ command: pipelineHookCommand("pre-commit-check.sh"),
766
833
  timeout: 15,
767
834
  statusMessage: "Scanning staged changes for secrets...",
768
835
  },
@@ -813,7 +880,7 @@ export function configureSettings(home) {
813
880
  hooks: [
814
881
  {
815
882
  type: "command",
816
- command: "bash $HOME/.claude/scripts/agent-guard.sh",
883
+ command: pipelineHookCommand("agent-guard.sh"),
817
884
  timeout: AGENT_GUARD_TIMEOUT,
818
885
  statusMessage,
819
886
  },