@mmerterden/multi-agent-pipeline 14.2.2 → 15.1.0

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 (132) hide show
  1. package/CHANGELOG.md +186 -6
  2. package/README.md +19 -12
  3. package/README.tr.md +19 -12
  4. package/SECURITY.md +43 -0
  5. package/docs/FIGMA_PIPELINE.md +3 -3
  6. package/docs/adr/0006-skills-core-external-split.md +1 -1
  7. package/docs/adr/0007-multi-tool-adapter-framework.md +1 -1
  8. package/docs/adr/0009-claude-stack-skills-plugin-only.md +31 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/architecture.md +13 -13
  11. package/docs/ecosystem.md +31 -31
  12. package/docs/features.md +5 -5
  13. package/index.js +6 -1
  14. package/install/_codex-agents.mjs +11 -2
  15. package/install/_common.mjs +109 -3
  16. package/install/_dev-only-files.mjs +0 -1
  17. package/install/_platform-filter.mjs +54 -113
  18. package/install/_plugin-skills.mjs +36 -36
  19. package/install/claude.mjs +251 -61
  20. package/install/codex.mjs +28 -6
  21. package/install/copilot.mjs +69 -9
  22. package/install/index.mjs +9 -3
  23. package/install/templates/codex-instructions.md +1 -1
  24. package/install/templates/copilot-instructions.md +3 -3
  25. package/package.json +2 -3
  26. package/pipeline/commands/multi-agent/SKILL.md +2 -0
  27. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
  28. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
  29. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +9 -9
  30. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
  31. package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +186 -0
  32. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -1
  33. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -1
  34. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  35. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -1
  36. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  37. package/pipeline/commands/multi-agent/help/SKILL.md +19 -4
  38. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
  39. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -1
  40. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +81 -0
  41. package/pipeline/commands/multi-agent/refactor/SKILL.md +36 -1
  42. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
  43. package/pipeline/commands/multi-agent/{ship → resume-local}/SKILL.md +8 -8
  44. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -1
  45. package/pipeline/commands/multi-agent/setup/SKILL.md +5 -5
  46. package/pipeline/commands/multi-agent/stack/SKILL.md +62 -40
  47. package/pipeline/commands/multi-agent/store-ready/SKILL.md +3 -3
  48. package/pipeline/commands/multi-agent/sync/SKILL.md +18 -11
  49. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
  50. package/pipeline/commands/multi-agent/uninstall/SKILL.md +2 -0
  51. package/pipeline/commands/multi-agent/update/SKILL.md +4 -4
  52. package/pipeline/lib/issue-fetcher.sh +1 -1
  53. package/pipeline/lib/parse-complaints.sh +316 -0
  54. package/pipeline/multi-agent-refs/channels/wiki.md +3 -3
  55. package/pipeline/multi-agent-refs/complaint-analysis-template.md +99 -0
  56. package/pipeline/multi-agent-refs/component-dispatch.md +6 -6
  57. package/pipeline/multi-agent-refs/cross-cli-contract.md +16 -16
  58. package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
  59. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +5 -5
  60. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  61. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  62. package/pipeline/multi-agent-refs/phases/operations.md +7 -1
  63. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  64. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +7 -7
  65. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +5 -5
  66. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +3 -3
  67. package/pipeline/multi-agent-refs/phases/phase-4-review.md +12 -12
  68. package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
  69. package/pipeline/multi-agent-refs/phases/phase-7-report.md +6 -0
  70. package/pipeline/multi-agent-refs/tracker-contract.md +3 -2
  71. package/pipeline/multi-agent-refs/wiki-capture.md +2 -2
  72. package/pipeline/preferences-template.json +18 -5
  73. package/pipeline/rules/figma-pipeline.md +2 -2
  74. package/pipeline/schemas/agent-state.schema.json +1 -1
  75. package/pipeline/schemas/complaint-analysis-spec.schema.json +216 -0
  76. package/pipeline/schemas/migrations/prefs-2.5.0-to-2.6.0.mjs +46 -0
  77. package/pipeline/schemas/prefs.schema.json +296 -66
  78. package/pipeline/schemas/token-budget.json +2 -2
  79. package/pipeline/scripts/README.md +4 -3
  80. package/pipeline/scripts/_stack-routing.mjs +79 -0
  81. package/pipeline/scripts/audit-log-rotate.sh +4 -1
  82. package/pipeline/scripts/build-skills-index.mjs +11 -0
  83. package/pipeline/scripts/build-stack-plugins.mjs +28 -60
  84. package/pipeline/scripts/check-derived-drift.mjs +55 -28
  85. package/pipeline/scripts/gc-worktrees.sh +4 -1
  86. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  87. package/pipeline/scripts/match-skills.mjs +12 -2
  88. package/pipeline/scripts/migrate-prefs.mjs +33 -21
  89. package/pipeline/scripts/phase-tracker.sh +32 -5
  90. package/pipeline/scripts/phase0-exit-gate.mjs +3 -2
  91. package/pipeline/scripts/run-aggregator.mjs +7 -2
  92. package/pipeline/scripts/scan-agent-config.sh +1 -1
  93. package/pipeline/scripts/skill-conformance.mjs +165 -30
  94. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  95. package/pipeline/scripts/test-gap-rules/android.json +25 -0
  96. package/pipeline/scripts/test-gap-rules/ios.json +34 -0
  97. package/pipeline/scripts/test-gap-rules/node.json +29 -0
  98. package/pipeline/scripts/test-gap-rules/python.json +25 -0
  99. package/pipeline/scripts/uninstall.mjs +160 -11
  100. package/pipeline/scripts/usage-report.mjs +426 -0
  101. package/pipeline/scripts/validate-complaint-doc.mjs +250 -0
  102. package/pipeline/scripts/validate-reviewer.mjs +9 -3
  103. package/pipeline/skills/.skill-manifest.json +156 -108
  104. package/pipeline/skills/.skills-index.json +449 -12
  105. package/pipeline/skills/shared/README.md +14 -10
  106. package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +1 -1
  107. package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +1 -1
  108. package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +49 -0
  109. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +1 -1
  110. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +1 -1
  111. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +1 -1
  112. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +1 -1
  113. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -2
  114. package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +83 -0
  115. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +153 -90
  116. package/pipeline/skills/shared/core/{multi-agent-ship → multi-agent-resume-local}/SKILL.md +6 -6
  117. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +89 -22
  118. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +1 -1
  119. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +8 -8
  120. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +1 -1
  121. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +1 -1
  122. package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +2 -2
  123. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +368 -33
  124. package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +1 -2
  125. package/pipeline/skills/shared/external/ios-coding-standard/scripts/check_structure.py +765 -0
  126. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +75 -0
  127. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +131 -0
  128. package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +559 -0
  129. package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +765 -0
  130. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +53 -10
  131. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +4 -3
  132. package/pipeline/skills/skills-index.md +7 -4
@@ -33,15 +33,15 @@ export const PLUGIN_SKILLS_MANIFEST = ".plugin-skills-manifest.json";
33
33
  import { copyDir, countFiles, ensureDir, ensureRealDir, isDryRun, wipeDir } from "./_common.mjs";
34
34
 
35
35
  /** Subtrees a plugin authors itself. `knowledge/` is generated, so it is excluded. */
36
- export const AUTHORED_GROUPS = Object.freeze(["index", "reference", "workflow", "tools"]);
36
+ const AUTHORED_GROUPS = Object.freeze(["index", "reference", "workflow", "tools"]);
37
37
 
38
38
  /** Stack plugins, and the platform each belongs to. */
39
- export const STACK_PLUGINS = Object.freeze([
40
- { name: "ai-ios-engineering-toolkit", platform: "ios" },
41
- { name: "ai-android-engineering-toolkit", platform: "android" },
42
- { name: "ai-frontend-engineering-toolkit", platform: "all" },
39
+ const STACK_PLUGINS = Object.freeze([
40
+ { name: "ai-ios-toolkit", platform: "ios" },
41
+ { name: "ai-android-toolkit", platform: "android" },
42
+ { name: "ai-frontend-toolkit", platform: "all" },
43
43
  { name: "ai-backend-toolkit", platform: "all" },
44
- { name: "ai-common-engineering-toolkit", platform: "all" },
44
+ { name: "ai-common-toolkit", platform: "all" },
45
45
  ]);
46
46
 
47
47
  /**
@@ -71,7 +71,7 @@ function semverCompare(a, b) {
71
71
  * @param {string} pluginName
72
72
  * @returns {{path: string, source: string}|null}
73
73
  */
74
- export function resolvePluginSkills(home, pluginName) {
74
+ function resolvePluginSkills(home, pluginName) {
75
75
  const checkout = join(home, "multi-agent-plugins", "plugins", pluginName, "skills");
76
76
  if (existsSync(checkout)) return { path: checkout, source: "local checkout" };
77
77
 
@@ -104,22 +104,27 @@ export function resolvePluginSkills(home, pluginName) {
104
104
  * A flat copy of all of them is last-write-wins, so an iOS repo could end up running
105
105
  * the Android `create-component`.
106
106
  *
107
- * So the enabled list in Claude Code's settings is the source of truth; the
108
- * `--platform` flag is only the fallback for a machine that has no Claude Code
109
- * settings to read.
107
+ * So the enabled list in Claude Code's settings is the source of truth, and it is
108
+ * read where `/multi-agent:stack` writes it: the invoking repo's
109
+ * `.claude/settings.json` first, the user-global `~/.claude/settings.json` second.
110
+ * The `--platform` flag is only the fallback for a machine with neither.
110
111
  *
111
112
  * @param {string} home
112
113
  * @param {"ios"|"android"|"all"} platformFlag
113
114
  * @returns {{names: string[], source: string}}
114
115
  */
115
116
  export function pluginsToDeliver(home, platformFlag) {
116
- const settingsPath = join(home, ".claude", "settings.json");
117
- if (existsSync(settingsPath)) {
117
+ const candidates = [
118
+ { path: join(process.cwd(), ".claude", "settings.json"), label: "./.claude/settings.json" },
119
+ { path: join(home, ".claude", "settings.json"), label: "~/.claude/settings.json" },
120
+ ];
121
+ for (const { path: settingsPath, label } of candidates) {
122
+ if (!existsSync(settingsPath)) continue;
118
123
  try {
119
124
  const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
120
125
  const enabled = Object.entries(settings.enabledPlugins || {})
121
126
  .filter(([, on]) => on === true)
122
- // keys look like `ai-ios-engineering-toolkit@multi-agent-plugins`
127
+ // keys look like `ai-ios-toolkit@multi-agent-plugins`
123
128
  .map(([k]) => k.split("@")[0])
124
129
  .filter((n) => STACK_PLUGINS.some((p) => p.name === n));
125
130
  if (enabled.length > 0) {
@@ -127,10 +132,10 @@ export function pluginsToDeliver(home, platformFlag) {
127
132
  // collision the first delivery wins, and the stack-specific version is the
128
133
  // one the repo actually wants.
129
134
  const ordered = STACK_PLUGINS.map((p) => p.name).filter((n) => enabled.includes(n));
130
- return { names: ordered, source: "enabledPlugins in ~/.claude/settings.json" };
135
+ return { names: ordered, source: `enabledPlugins in ${label}` };
131
136
  }
132
137
  } catch {
133
- /* unreadable settings fall through to the platform default */
138
+ /* unreadable settings fall through to the next candidate */
134
139
  }
135
140
  }
136
141
  const names = STACK_PLUGINS.filter(
@@ -194,7 +199,7 @@ export function installAuthoredPluginSkills(opts) {
194
199
  // That is not a duplicate: the pipeline's `architecture` is a generic ADR
195
200
  // framework while the iOS plugin's is that stack's structural rules, and the
196
201
  // same holds for `backlog`. Claude Code reaches both because its loader
197
- // namespaces plugin skills (`ai-ios-engineering-toolkit:architecture`); the
202
+ // namespaces plugin skills (`ai-ios-toolkit:architecture`); the
198
203
  // copy hosts had no namespace, so the stack-specific version was silently
199
204
  // unreachable on exactly the repos that need it most.
200
205
  //
@@ -263,35 +268,30 @@ export function installAuthoredPluginSkills(opts) {
263
268
  // this pass delivered so uninstall can remove precisely those, nothing more.
264
269
  if (!isDryRun()) {
265
270
  try {
266
- writeFileSync(join(dest, PLUGIN_SKILLS_MANIFEST), JSON.stringify([...delivered], null, 2) + "\n");
271
+ writeFileSync(
272
+ join(dest, PLUGIN_SKILLS_MANIFEST),
273
+ JSON.stringify([...delivered], null, 2) + "\n",
274
+ );
267
275
  } catch {
268
276
  /* best-effort - a missing manifest just means uninstall skips this cleanup */
269
277
  }
270
278
  }
271
- return { copied, collided, renamed, plugins, missing, selectionSource, deliveredNames: [...delivered] };
272
- }
273
-
274
- /**
275
- * Names already present in a flat skills directory, so a second delivery pass does
276
- * not overwrite what the pipeline itself installed.
277
- *
278
- * @param {string} dir
279
- * @returns {Set<string>}
280
- */
281
- export function existingSkillNames(dir) {
282
- if (!existsSync(dir)) return new Set();
283
- return new Set(
284
- readdirSync(dir, { withFileTypes: true })
285
- .filter((e) => e.isDirectory())
286
- .map((e) => e.name),
287
- );
279
+ return {
280
+ copied,
281
+ collided,
282
+ renamed,
283
+ plugins,
284
+ missing,
285
+ selectionSource,
286
+ deliveredNames: [...delivered],
287
+ };
288
288
  }
289
289
 
290
290
  /**
291
291
  * Skill names the PIPELINE owns, derived from its source tree.
292
292
  *
293
- * This is what `skipNames` should be. Callers used to pass
294
- * `existingSkillNames(dest)` - a snapshot of whatever was already installed - which
293
+ * This is what `skipNames` should be. Callers used to pass a snapshot of
294
+ * whatever was already installed (a now-deleted `existingSkillNames(dest)`) - which
295
295
  * did protect pipeline skills from being shadowed by a plugin, but also meant a
296
296
  * plugin skill was copied exactly once and then frozen: on the second install its
297
297
  * own name was "already there", so it was skipped, forever. A stale plugin skill
@@ -14,7 +14,7 @@
14
14
  * @module install/claude
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
17
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync } from "fs";
18
18
  import { join, dirname } from "path";
19
19
  import { execSync } from "child_process";
20
20
 
@@ -25,9 +25,11 @@ import {
25
25
  copyFile,
26
26
  copySkillsIndex,
27
27
  countFiles,
28
+ dirsIdentical,
28
29
  ensureDir,
29
30
  ensureRealDir,
30
31
  isDryRun,
32
+ isLocalOnlySkill,
31
33
  pruneAbandonedTrees,
32
34
  pruneLegacyMultiAgentSkills,
33
35
  pruneOrphanSkillFiles,
@@ -35,7 +37,7 @@ import {
35
37
  wipeDir,
36
38
  writeFile,
37
39
  } from "./_common.mjs";
38
- import { copyExternalSkillsFiltered } from "./_platform-filter.mjs";
40
+ import { EXTERNAL_SKILLS_MANIFEST } from "./_platform-filter.mjs";
39
41
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
40
42
  import { registerMcpServer } from "./_mcp-register.mjs";
41
43
 
@@ -46,6 +48,16 @@ import { registerMcpServer } from "./_mcp-register.mjs";
46
48
  */
47
49
  export const LEGACY_PRE_COMMIT_MATCHER = "Bash(git commit:*)";
48
50
 
51
+ /**
52
+ * The only skills installed locally on Claude Code. Everything else reaches
53
+ * Claude Code through the marketplace plugins (`ai-<stack>-toolkit`), namespaced
54
+ * by the plugin loader. These two stay local by policy: they are pipeline-owned
55
+ * compliance catalogs that may carry audit rules not meant for the public
56
+ * marketplace, and `scripts/uninstall.mjs` (PIPELINE_CORE_SKILL_DIRS) already
57
+ * treats exactly this pair as pipeline-owned.
58
+ */
59
+ export const PIPELINE_LOCAL_SKILLS = ["apple-archive-compliance", "google-play-compliance"];
60
+
49
61
  /**
50
62
  * @param {{
51
63
  * home: string,
@@ -53,10 +65,11 @@ export const LEGACY_PRE_COMMIT_MATCHER = "Bash(git commit:*)";
53
65
  * indexOnly: boolean,
54
66
  * useSymlinks: boolean,
55
67
  * platformFlag: "ios"|"android"|"all",
68
+ * pruneExternal?: boolean,
56
69
  * }} ctx
57
70
  */
58
71
  export function installClaude(ctx) {
59
- const { home, pipelineSrc, indexOnly, useSymlinks, platformFlag } = ctx;
72
+ const { home, pipelineSrc, indexOnly, useSymlinks, platformFlag, pruneExternal } = ctx;
60
73
 
61
74
  const CLAUDE_COMMANDS = join(home, ".claude", "commands");
62
75
  const CLAUDE_AGENTS = join(home, ".claude", "agents");
@@ -84,7 +97,7 @@ export function installClaude(ctx) {
84
97
  installSchemas(pipelineSrc, CLAUDE_SCHEMAS, useSymlinks);
85
98
  installLib(pipelineSrc, CLAUDE_LIB, useSymlinks);
86
99
  runPreDeployScans(pipelineSrc);
87
- installSkills({ pipelineSrc, dest: CLAUDE_SKILLS, indexOnly, useSymlinks, platformFlag });
100
+ installSkills({ pipelineSrc, dest: CLAUDE_SKILLS, indexOnly, useSymlinks, platformFlag, pruneExternal });
88
101
  ensureClaudeMd(home, pipelineSrc);
89
102
  ensurePreferences(PREFS_PATH, pipelineSrc);
90
103
  configureSettings(home);
@@ -128,13 +141,16 @@ function installCommands(pipelineSrc, dest, useSymlinks) {
128
141
  //
129
142
  // Preserve local-only alias wrappers (frontmatter `local-only: true`) across
130
143
  // the wipe. They live ONLY under ~/.claude (never in pipeline/commands, and
131
- // never synced), so without this snapshot+restore they would be destroyed on
132
- // every install/update. Pipeline never ships these names, so restore is safe.
133
- const preservedWrappers = snapshotLocalOnlyWrappers(ownedCmdDir);
144
+ // never synced), so without this stash+restore they would be destroyed on
145
+ // every install/update. The stash is an on-disk rename, not an in-memory
146
+ // snapshot: the only copy of user-authored content stays on disk at every
147
+ // instant, so an install interrupted between wipe and restore loses nothing,
148
+ // and the next run adopts any leftover stash.
149
+ const wrapperStash = stashLocalOnlyWrappers(ownedCmdDir);
134
150
  wipeDir(ownedCmdDir);
135
151
 
136
152
  copyDir(commandsSrc, dest, { useSymlinks });
137
- const restored = restoreLocalOnlyWrappers(ownedCmdDir, preservedWrappers);
153
+ const restored = unstashLocalOnlyWrappers(ownedCmdDir, wrapperStash);
138
154
  console.log(` -> ${countFiles(commandsSrc)} files copied to ${dest}`);
139
155
  if (restored > 0) {
140
156
  console.log(` -> preserved ${restored} local-only alias wrapper(s)`);
@@ -163,36 +179,51 @@ function localizeCommandDescriptions(pipelineSrc, ownedCmdDir) {
163
179
  }
164
180
  }
165
181
 
166
- // Snapshot single-level command dirs whose SKILL.md declares `local-only: true`
167
- // (their files are read into memory) so installCommands can restore them after
168
- // the namespace wipe.
169
- function snapshotLocalOnlyWrappers(ownedCmdDir) {
170
- const out = [];
171
- if (!existsSync(ownedCmdDir)) return out;
182
+ // Move command dirs whose SKILL.md declares `local-only: true` into an on-disk
183
+ // stash beside the namespace, so installCommands can restore them after the
184
+ // namespace wipe. Returns the stash path.
185
+ function stashLocalOnlyWrappers(ownedCmdDir) {
186
+ const stashDir = join(dirname(ownedCmdDir), ".multi-agent-wrapper-stash");
187
+ if (isDryRun() || !existsSync(ownedCmdDir)) return stashDir;
172
188
  for (const name of readdirSync(ownedCmdDir)) {
173
189
  const dir = join(ownedCmdDir, name);
174
190
  const skill = join(dir, "SKILL.md");
175
191
  if (!existsSync(skill)) continue;
176
192
  if (!/^local-only:\s*true\s*$/m.test(readFileSync(skill, "utf-8"))) continue;
177
- const files = readdirSync(dir)
178
- .filter((f) => statSync(join(dir, f)).isFile())
179
- .map((f) => ({ rel: f, content: readFileSync(join(dir, f), "utf-8") }));
180
- out.push({ name, files });
193
+ ensureDir(stashDir);
194
+ const target = join(stashDir, name);
195
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
196
+ renameSync(dir, target);
181
197
  }
182
- return out;
198
+ return stashDir;
183
199
  }
184
200
 
185
- // Restore snapshotted local-only wrappers, but never clobber a real command the
186
- // pipeline just installed under the same name. Returns the count restored.
187
- function restoreLocalOnlyWrappers(ownedCmdDir, preserved) {
201
+ // Restore stashed local-only wrappers (including any leftover from an earlier
202
+ // interrupted run), but never clobber a real command the pipeline just
203
+ // installed under the same name. Returns the count restored.
204
+ function unstashLocalOnlyWrappers(ownedCmdDir, stashDir) {
205
+ if (isDryRun() || !existsSync(stashDir)) return 0;
188
206
  let n = 0;
189
- for (const w of preserved) {
190
- const dir = join(ownedCmdDir, w.name);
191
- if (existsSync(dir)) continue; // pipeline shipped a real command with this name
192
- ensureDir(dir);
193
- for (const f of w.files) writeFileSync(join(dir, f.rel), f.content);
207
+ for (const name of readdirSync(stashDir)) {
208
+ const from = join(stashDir, name);
209
+ const to = join(ownedCmdDir, name);
210
+ if (existsSync(to)) {
211
+ // The pipeline now ships a real command under a name the user's wrapper
212
+ // was using, so the wrapper cannot be restored. Say whose content is
213
+ // being dropped: /multi-agent:save writes exactly these, and a release
214
+ // claiming a new name would otherwise delete a saved routine in silence.
215
+ console.log(
216
+ ` -> WARNING: dropped local-only wrapper '${name}' - this release ships a command ` +
217
+ `with that name. Re-save it under a different name if you still need it.`,
218
+ );
219
+ rmSync(from, { recursive: true, force: true });
220
+ continue;
221
+ }
222
+ ensureDir(ownedCmdDir);
223
+ renameSync(from, to);
194
224
  n++;
195
225
  }
226
+ rmSync(stashDir, { recursive: true, force: true });
196
227
  return n;
197
228
  }
198
229
 
@@ -327,7 +358,7 @@ function runPreDeployScans(pipelineSrc) {
327
358
  }
328
359
 
329
360
  function installSkills(opts) {
330
- const { pipelineSrc, dest, indexOnly, useSymlinks, platformFlag } = opts;
361
+ const { pipelineSrc, dest, indexOnly, useSymlinks, pruneExternal } = opts;
331
362
  console.log(" [Claude Code] Installing skills...");
332
363
 
333
364
  // Same symlink guard as the other owned trees: never prune or copy through
@@ -343,40 +374,57 @@ function installSkills(opts) {
343
374
  }
344
375
 
345
376
  const sharedCoreSrc = join(pipelineSrc, "skills", "shared", "core");
346
- const sharedExternalSrc = join(pipelineSrc, "skills", "shared", "external");
347
377
 
348
378
  let claudeSkillCount = 0;
349
379
 
350
- if (existsSync(sharedCoreSrc)) {
351
- copyDir(sharedCoreSrc, dest, { useSymlinks });
352
- claudeSkillCount += countFiles(sharedCoreSrc);
380
+ // Claude Code no longer receives a local copy of the stack skills. The
381
+ // marketplace plugins (`ai-<stack>-toolkit`) are its only stack-skill source -
382
+ // a local copy duplicated ~110 skills against the enabled plugins (~10k
383
+ // tokens/session) and shadowed the plugin the moment it went stale. Only the
384
+ // two pipeline-owned compliance catalogs stay local (PIPELINE_LOCAL_SKILLS);
385
+ // they are deliberately not published to the marketplace.
386
+ ensureDir(dest);
387
+ for (const name of PIPELINE_LOCAL_SKILLS) {
388
+ const from = join(sharedCoreSrc, name);
389
+ if (!existsSync(from)) continue;
390
+ if (!useSymlinks) wipeDir(join(dest, name));
391
+ copyDir(from, join(dest, name), { useSymlinks });
392
+ claudeSkillCount += countFiles(from);
353
393
  }
354
394
 
355
- if (existsSync(sharedExternalSrc)) {
356
- const { copied, skipped } = copyExternalSkillsFiltered(sharedExternalSrc, dest, {
357
- platformFlag,
358
- useSymlinks,
359
- });
360
- claudeSkillCount += copied;
361
- if (skipped > 0) {
362
- console.log(
363
- ` -> --platform=${platformFlag} filter skipped ${skipped} external skill dir(s)`,
364
- );
365
- }
395
+ // Single-standard enforcement: Claude Code invokes pipeline commands via
396
+ // the `/multi-agent:*` slash-command namespace, so prune duplicate skill
397
+ // dirs left by older installs that copied shared/core wholesale. This used
398
+ // to live inside the external-copy branch; it must run unconditionally or
399
+ // the 51 multi-agent-* dirs return on the first install after a migration.
400
+ const pruned = pruneLegacyMultiAgentSkills(dest);
401
+ if (pruned > 0) {
402
+ console.log(
403
+ ` -> pruned ${pruned} legacy multi-agent-* skill dirs (slash commands cover these)`,
404
+ );
405
+ }
366
406
 
367
- // Single-standard enforcement: Claude Code invokes pipeline commands via
368
- // the `/multi-agent:*` slash-command namespace, so prune duplicate skill
369
- // dirs that come from the shared/external/ tree.
370
- const pruned = pruneLegacyMultiAgentSkills(dest);
371
- if (pruned > 0) {
372
- claudeSkillCount -= pruned;
373
- console.log(
374
- ` -> pruned ${pruned} legacy multi-agent-* skill dirs (slash commands cover these)`,
375
- );
376
- }
407
+ // Migration prune: an older install delivered the full external catalog here.
408
+ // Its manifest names exactly what was delivered, so removal is manifest-scoped -
409
+ // user-authored skills in the same directory are untouched.
410
+ const migrated = pruneManifestDeliveredSkills(dest);
411
+ if (migrated > 0) {
412
+ console.log(
413
+ ` -> migration: removed ${migrated} stack skill dir(s) previously copied here (now plugin-only)`,
414
+ );
377
415
  }
378
416
 
379
- // Figma component skills moved to the ai-<platform>-engineering-toolkit marketplace
417
+ // Pre-manifest installs (<= v14.x) never wrote that manifest, so the branch
418
+ // above cannot fire for them and the full 151-dir catalog would outlive every
419
+ // upgrade. For those, prune by proof instead of by name: a dir byte-identical
420
+ // to the shipped catalog contains nothing of the user's and goes now; a dir
421
+ // that differs is kept unless --prune-external explicitly opts in (and even
422
+ // then a `local-only: true` SKILL.md keeps it).
423
+ migratePreManifestExternalSkills(dest, join(pipelineSrc, "skills", "shared", "external"), {
424
+ pruneExternal: Boolean(pruneExternal),
425
+ });
426
+
427
+ // Figma component skills moved to the ai-<platform>-toolkit marketplace
380
428
  // plugin and are no longer bundled here. Prune any stale copies left by a
381
429
  // pre-migration install so the installed tree stays consistent.
382
430
  for (const sub of ["figma-ios", "figma-android", "figma-common", "figma-to-component"]) {
@@ -410,21 +458,163 @@ function installSkills(opts) {
410
458
  console.log(` -> pruned ${orphans} stale flat skill file(s) from an older layout`);
411
459
  }
412
460
 
413
- console.log(` -> ${claudeSkillCount} skill files installed to ${dest}`);
461
+ console.log(` -> ${claudeSkillCount} skill files installed to ${dest} (stack skills: marketplace plugins)`);
414
462
 
415
- // Keep the shared skills index README in sync.
416
- const sharedReadmeSrc = join(pipelineSrc, "skills", "shared", "README.md");
417
- if (existsSync(sharedReadmeSrc)) {
418
- const claudeReadme = join(dest, "README.md");
463
+ // The shared skills README described the full local catalog; with the catalog
464
+ // gone it would document skills that are not here. Drop it, including copies
465
+ // an older install left behind.
466
+ const claudeReadme = join(dest, "README.md");
467
+ if (existsSync(claudeReadme) && !isDryRun()) {
419
468
  try {
420
- copyFile(sharedReadmeSrc, claudeReadme);
421
- console.log(` -> updated ${claudeReadme}`);
469
+ rmSync(claudeReadme);
470
+ console.log(` -> removed ${claudeReadme} (local catalog no longer installed)`);
422
471
  } catch {
423
472
  /* non-fatal */
424
473
  }
425
474
  }
426
475
  }
427
476
 
477
+ /**
478
+ * Remove the external stack skills an older install copied into `dest`, scoped
479
+ * strictly to the delivery manifest that install wrote. NOTICE files rode along
480
+ * with the external catalog, so they go with it.
481
+ *
482
+ * @param {string} dest - the installed skills directory
483
+ * @returns {number} directories removed
484
+ */
485
+ function pruneManifestDeliveredSkills(dest) {
486
+ const manifestPath = join(dest, EXTERNAL_SKILLS_MANIFEST);
487
+ if (!existsSync(manifestPath)) return 0;
488
+ if (isDryRun()) {
489
+ console.log(` [dry-run] would remove externally-delivered skills named in ${manifestPath}`);
490
+ return 0;
491
+ }
492
+ let removed = 0;
493
+ try {
494
+ const names = JSON.parse(readFileSync(manifestPath, "utf-8"));
495
+ for (const name of Array.isArray(names) ? names : []) {
496
+ // The manifest is untrusted input feeding a recursive rmSync: a name
497
+ // carrying a path separator or leading dot would escape the skills dir
498
+ // (join normalizes "../x"), so only plain directory names pass.
499
+ if (typeof name !== "string" || /[/\\]/.test(name) || name.startsWith(".")) continue;
500
+ // The two local compliance catalogs are never external-delivered, but a
501
+ // corrupted manifest must not be able to take them out.
502
+ if (PIPELINE_LOCAL_SKILLS.includes(name)) continue;
503
+ const dir = join(dest, name);
504
+ if (!existsSync(dir)) continue;
505
+ try {
506
+ rmSync(dir, { recursive: true, force: true });
507
+ removed++;
508
+ } catch {
509
+ /* non-fatal */
510
+ }
511
+ }
512
+ for (const entry of readdirSync(dest)) {
513
+ if (entry.startsWith("NOTICE-") && entry.endsWith(".md")) {
514
+ try {
515
+ rmSync(join(dest, entry));
516
+ } catch {
517
+ /* non-fatal */
518
+ }
519
+ }
520
+ }
521
+ rmSync(manifestPath, { force: true });
522
+ } catch {
523
+ /* unreadable manifest: leave everything in place rather than guess */
524
+ }
525
+ return removed;
526
+ }
527
+
528
+ /**
529
+ * Migration for pre-manifest installs (<= v14.x): the external catalog was
530
+ * copied here with no delivery manifest, so ours and the user's are told apart
531
+ * by content, not by name. Byte-identical to the shipped catalog = provably
532
+ * ours, removed. Different = kept, reported, and removable only via the
533
+ * explicit `--prune-external` flag (which still honors `local-only: true`).
534
+ *
535
+ * @param {string} dest - the installed skills directory
536
+ * @param {string} externalSrc - absolute path to `pipeline/skills/shared/external/`
537
+ * @param {{ pruneExternal: boolean }} opts
538
+ */
539
+ function migratePreManifestExternalSkills(dest, externalSrc, opts) {
540
+ if (!existsSync(externalSrc) || !existsSync(dest)) return;
541
+ const { pruneExternal } = opts;
542
+ let identical = 0;
543
+ let flagged = 0;
544
+ const leftover = [];
545
+ for (const entry of readdirSync(externalSrc, { withFileTypes: true })) {
546
+ const name = entry.name;
547
+ if (PIPELINE_LOCAL_SKILLS.includes(name)) continue;
548
+ const stale = join(dest, name);
549
+ if (!existsSync(stale)) continue;
550
+
551
+ // NOTICE-*.md files rode along with the catalog as plain files.
552
+ if (!entry.isDirectory()) {
553
+ if (!/^NOTICE-.*\.md$/.test(name)) continue;
554
+ if (isDryRun()) {
555
+ console.log(` [dry-run] would prune stale ${name} (pre-manifest catalog copy)`);
556
+ continue;
557
+ }
558
+ try {
559
+ rmSync(stale, { force: true });
560
+ identical++;
561
+ } catch {
562
+ /* non-fatal */
563
+ }
564
+ continue;
565
+ }
566
+
567
+ let provablyOurs;
568
+ try {
569
+ provablyOurs = dirsIdentical(join(externalSrc, name), stale);
570
+ } catch {
571
+ provablyOurs = false;
572
+ }
573
+ if (provablyOurs) {
574
+ if (isDryRun()) {
575
+ console.log(` [dry-run] would prune ${name}/ (identical to shipped catalog)`);
576
+ identical++;
577
+ continue;
578
+ }
579
+ try {
580
+ rmSync(stale, { recursive: true, force: true });
581
+ identical++;
582
+ } catch {
583
+ /* non-fatal */
584
+ }
585
+ } else if (pruneExternal && !isLocalOnlySkill(stale)) {
586
+ if (isDryRun()) {
587
+ console.log(` [dry-run] would prune ${name}/ (--prune-external)`);
588
+ flagged++;
589
+ continue;
590
+ }
591
+ try {
592
+ rmSync(stale, { recursive: true, force: true });
593
+ flagged++;
594
+ } catch {
595
+ /* non-fatal */
596
+ }
597
+ } else {
598
+ leftover.push(name);
599
+ }
600
+ }
601
+ if (identical > 0) {
602
+ console.log(
603
+ ` -> migration: pruned ${identical} pre-manifest catalog entr(ies) identical to the shipped catalog (now plugin-only)`,
604
+ );
605
+ }
606
+ if (flagged > 0) {
607
+ console.log(` -> --prune-external: removed ${flagged} catalog-named skill dir(s) that had local differences`);
608
+ }
609
+ if (leftover.length > 0) {
610
+ console.log(
611
+ ` note: ${leftover.length} skill dir(s) share catalog names but differ from the shipped catalog - kept ` +
612
+ `(cannot tell an older delivery from user edits). Remove them too with: node install.js --claude --prune-external`,
613
+ );
614
+ }
615
+ }
616
+
617
+
428
618
  function ensureClaudeMd(home, pipelineSrc) {
429
619
  console.log(" [Claude Code] Checking CLAUDE.md template...");
430
620
  const CLAUDE_MD = join(home, ".claude", "CLAUDE.md");
package/install/codex.mjs CHANGED
@@ -34,7 +34,15 @@ import {
34
34
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
35
35
  import { registerMcpServer } from "./_mcp-register.mjs";
36
36
  import { installCodexAgents } from "./_codex-agents.mjs";
37
- import { installAuthoredPluginSkills, pipelineOwnedSkillNames } from "./_plugin-skills.mjs";
37
+ import {
38
+ installAuthoredPluginSkills,
39
+ pipelineOwnedSkillNames,
40
+ pluginsToDeliver,
41
+ } from "./_plugin-skills.mjs";
42
+ import {
43
+ partitionExternalSkillsByPlugins,
44
+ writeExternalSkillsManifest,
45
+ } from "./_platform-filter.mjs";
38
46
  import { generateCodexInstructions } from "./_codex-instructions.mjs";
39
47
  import { mergeManagedBlock } from "./_managed-block.mjs";
40
48
 
@@ -44,9 +52,6 @@ export const AGENTS_MD_START_MARKER = "# Multi-Agent Development Pipeline";
44
52
  /** Explicit end marker written after the pipeline section. */
45
53
  export const AGENTS_MD_END_MARKER = "<!-- multi-agent-pipeline:codex-instructions:end -->";
46
54
 
47
- /** Re-exported for the uninstaller and the Codex install smoke. */
48
- export { MCP_SERVER_NAME } from "./_mcp-register.mjs";
49
-
50
55
  /**
51
56
  * `$HOME/.claude/...` path rewrites applied to every file installed into the
52
57
  * Codex tree.
@@ -269,7 +274,7 @@ function pruneLegacyCommandSkills(skillsDir) {
269
274
  }
270
275
 
271
276
  /**
272
- * Install the reference tree: the shared `multi-agent-refs/` docs plus the 42
277
+ * Install the reference tree: the shared `multi-agent-refs/` docs plus every
273
278
  * sub-command specs, all path-rewritten. These are read on demand and never
274
279
  * enter the skills block.
275
280
  */
@@ -394,7 +399,24 @@ function installSkillRefs(pipelineSrc, dest, home, platformFlag) {
394
399
  wipeDir(dest);
395
400
 
396
401
  let count = 0;
397
- if (existsSync(externalSrc)) count += copyTreeRewritten(externalSrc, dest);
402
+ if (existsSync(externalSrc)) {
403
+ // Active-stack filter (same contract as Copilot): Codex used to receive all
404
+ // 151 external skills regardless of stack - platformFlag only shaped the
405
+ // authored-plugin delivery below. The enabled-plugin set from
406
+ // ~/.claude/settings.json now bounds this copy too, so a Claude+Codex user
407
+ // sees one consistent stack surface across both hosts. The manifest makes
408
+ // the delivery inspectable; the wholesale wipeDir above already handles
409
+ // staleness on every install.
410
+ const { names: enabled, source: selectionSource } = pluginsToDeliver(home, platformFlag);
411
+ const { keep, skipped } = partitionExternalSkillsByPlugins(externalSrc, enabled);
412
+ for (const name of keep) {
413
+ count += copyTreeRewritten(join(externalSrc, name), join(dest, name));
414
+ }
415
+ writeExternalSkillsManifest(dest, keep);
416
+ console.log(
417
+ ` -> stack filter (${selectionSource}): ${keep.length} kept, ${skipped.length} skipped`,
418
+ );
419
+ }
398
420
 
399
421
  // `shared/core` also holds two skills that are NOT pipeline sub-commands:
400
422
  // apple-archive-compliance and google-play-compliance. Copying only `external`