@mmerterden/multi-agent-pipeline 14.2.2 → 15.0.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 (122) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/README.md +15 -8
  3. package/README.tr.md +15 -8
  4. package/docs/FIGMA_PIPELINE.md +3 -3
  5. package/docs/adr/0006-skills-core-external-split.md +1 -1
  6. package/docs/adr/0009-claude-stack-skills-plugin-only.md +31 -0
  7. package/docs/adr/README.md +1 -0
  8. package/docs/architecture.md +7 -7
  9. package/docs/ecosystem.md +28 -28
  10. package/docs/features.md +5 -5
  11. package/index.js +2 -0
  12. package/install/_codex-agents.mjs +11 -2
  13. package/install/_common.mjs +65 -1
  14. package/install/_dev-only-files.mjs +0 -1
  15. package/install/_platform-filter.mjs +73 -7
  16. package/install/_plugin-skills.mjs +19 -8
  17. package/install/claude.mjs +144 -59
  18. package/install/codex.mjs +28 -3
  19. package/install/copilot.mjs +36 -11
  20. package/install/index.mjs +6 -2
  21. package/install/templates/codex-instructions.md +1 -1
  22. package/install/templates/copilot-instructions.md +3 -3
  23. package/package.json +1 -2
  24. package/pipeline/commands/multi-agent/SKILL.md +2 -0
  25. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
  27. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +9 -9
  28. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
  29. package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +186 -0
  30. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -1
  31. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -1
  32. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  33. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -1
  34. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  35. package/pipeline/commands/multi-agent/help/SKILL.md +19 -4
  36. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
  37. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -1
  38. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +81 -0
  39. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
  40. package/pipeline/commands/multi-agent/{ship → resume-local}/SKILL.md +8 -8
  41. package/pipeline/commands/multi-agent/setup/SKILL.md +5 -5
  42. package/pipeline/commands/multi-agent/stack/SKILL.md +55 -43
  43. package/pipeline/commands/multi-agent/store-ready/SKILL.md +3 -3
  44. package/pipeline/commands/multi-agent/sync/SKILL.md +18 -11
  45. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
  46. package/pipeline/commands/multi-agent/uninstall/SKILL.md +2 -0
  47. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  48. package/pipeline/lib/issue-fetcher.sh +1 -1
  49. package/pipeline/lib/parse-complaints.sh +306 -0
  50. package/pipeline/multi-agent-refs/channels/wiki.md +3 -3
  51. package/pipeline/multi-agent-refs/complaint-analysis-template.md +99 -0
  52. package/pipeline/multi-agent-refs/component-dispatch.md +6 -6
  53. package/pipeline/multi-agent-refs/cross-cli-contract.md +16 -16
  54. package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
  55. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +5 -5
  56. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  57. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  58. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  59. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +7 -7
  60. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +5 -5
  61. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +3 -3
  62. package/pipeline/multi-agent-refs/phases/phase-4-review.md +12 -12
  63. package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
  64. package/pipeline/multi-agent-refs/tracker-contract.md +1 -1
  65. package/pipeline/multi-agent-refs/wiki-capture.md +2 -2
  66. package/pipeline/preferences-template.json +13 -5
  67. package/pipeline/rules/figma-pipeline.md +2 -2
  68. package/pipeline/schemas/agent-state.schema.json +1 -1
  69. package/pipeline/schemas/complaint-analysis-spec.schema.json +216 -0
  70. package/pipeline/schemas/migrations/prefs-2.5.0-to-2.6.0.mjs +46 -0
  71. package/pipeline/schemas/prefs.schema.json +276 -66
  72. package/pipeline/schemas/token-budget.json +2 -2
  73. package/pipeline/scripts/_stack-routing.mjs +79 -0
  74. package/pipeline/scripts/audit-log-rotate.sh +4 -1
  75. package/pipeline/scripts/build-skills-index.mjs +11 -0
  76. package/pipeline/scripts/build-stack-plugins.mjs +28 -60
  77. package/pipeline/scripts/check-derived-drift.mjs +52 -28
  78. package/pipeline/scripts/gc-worktrees.sh +4 -1
  79. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  80. package/pipeline/scripts/match-skills.mjs +8 -2
  81. package/pipeline/scripts/migrate-prefs.mjs +28 -20
  82. package/pipeline/scripts/phase-tracker.sh +13 -5
  83. package/pipeline/scripts/phase0-exit-gate.mjs +3 -2
  84. package/pipeline/scripts/run-aggregator.mjs +7 -2
  85. package/pipeline/scripts/scan-agent-config.sh +1 -1
  86. package/pipeline/scripts/skill-conformance.mjs +165 -30
  87. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  88. package/pipeline/scripts/test-gap-rules/android.json +25 -0
  89. package/pipeline/scripts/test-gap-rules/ios.json +34 -0
  90. package/pipeline/scripts/test-gap-rules/node.json +29 -0
  91. package/pipeline/scripts/test-gap-rules/python.json +25 -0
  92. package/pipeline/scripts/uninstall.mjs +158 -11
  93. package/pipeline/scripts/validate-complaint-doc.mjs +229 -0
  94. package/pipeline/scripts/validate-reviewer.mjs +9 -3
  95. package/pipeline/skills/.skill-manifest.json +156 -108
  96. package/pipeline/skills/.skills-index.json +449 -12
  97. package/pipeline/skills/shared/README.md +14 -10
  98. package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +1 -1
  99. package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +1 -1
  100. package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +49 -0
  101. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +1 -1
  102. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +1 -1
  103. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +1 -1
  104. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +1 -1
  105. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -2
  106. package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +83 -0
  107. package/pipeline/skills/shared/core/{multi-agent-ship → multi-agent-resume-local}/SKILL.md +6 -6
  108. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +79 -22
  109. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +1 -1
  110. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +8 -8
  111. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +1 -1
  112. package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +2 -2
  113. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +368 -33
  114. package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +1 -2
  115. package/pipeline/skills/shared/external/ios-coding-standard/scripts/check_structure.py +765 -0
  116. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +75 -0
  117. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +131 -0
  118. package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +559 -0
  119. package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +765 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +53 -10
  121. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +4 -3
  122. package/pipeline/skills/skills-index.md +7 -4
@@ -10,11 +10,14 @@
10
10
  */
11
11
 
12
12
  import {
13
+ chmodSync,
13
14
  cpSync,
14
15
  existsSync,
15
16
  lstatSync,
16
17
  mkdirSync,
17
18
  readdirSync,
19
+ realpathSync,
20
+ renameSync,
18
21
  rmSync,
19
22
  statSync,
20
23
  symlinkSync,
@@ -279,6 +282,24 @@ export const ABANDONED_TREES = Object.freeze([
279
282
  },
280
283
  ]);
281
284
 
285
+ /**
286
+ * Registry of command renames. Command names are an interface: users type
287
+ * them, docs and saved routines reference them, and Copilot installs derive
288
+ * `multi-agent-<name>` skill dirs from them. A shipped name may only ever
289
+ * disappear by being recorded here as `oldName: newName` - the
290
+ * install-lifecycle test compares the shipped tree against its committed
291
+ * snapshot and fails on any removal that has no rename entry, so a command
292
+ * cannot vanish between versions by accident.
293
+ *
294
+ * @type {Readonly<Record<string, string>>}
295
+ */
296
+ export const COMMAND_RENAMES = Object.freeze({
297
+ delete: "uninstall",
298
+ // v15.0.0: "continue local work through the pipeline tail" reads as a resume
299
+ // variant, not a shipping action (the command opens the PR but does not merge).
300
+ ship: "resume-local",
301
+ });
302
+
282
303
  /**
283
304
  * Remove trees an older install left behind.
284
305
  *
@@ -404,7 +425,50 @@ export function writeFile(path, content) {
404
425
  console.log(` [dry-run] would write ${path}`);
405
426
  return;
406
427
  }
407
- writeFileSync(path, content);
428
+ atomicWrite(path, content);
429
+ }
430
+
431
+ /**
432
+ * tmp + rename so a crash mid-write can never leave a truncated file behind -
433
+ * several call sites rewrite host config the CLI needs in order to start
434
+ * (`~/.claude/settings.json`, `copilot-instructions.md`).
435
+ *
436
+ * Two properties a naive rename would silently destroy on co-owned files:
437
+ *
438
+ * - **symlinks**: users keep these files in a dotfiles repo and symlink them
439
+ * in. Renaming over the link replaces it with a regular file, so the
440
+ * dotfiles copy goes stale and the next dotfiles apply reverts our edits.
441
+ * Resolve the link first and write through it.
442
+ * - **mode**: a rename installs the tmp file's default 0644 over whatever the
443
+ * user set (0600 on a settings file holding tokens is a real case). Carry
444
+ * the existing mode across.
445
+ *
446
+ * @param {string} path
447
+ * @param {string} content
448
+ */
449
+ export function atomicWrite(path, content) {
450
+ let target = path;
451
+ try {
452
+ if (lstatSync(path).isSymbolicLink()) target = realpathSync(path);
453
+ } catch {
454
+ // Missing file: nothing to preserve, write the path as given.
455
+ }
456
+ let mode;
457
+ try {
458
+ mode = statSync(target).mode & 0o777;
459
+ } catch {
460
+ mode = undefined;
461
+ }
462
+ const tmp = `${target}.tmp-${process.pid}`;
463
+ writeFileSync(tmp, content);
464
+ if (mode !== undefined) {
465
+ try {
466
+ chmodSync(tmp, mode);
467
+ } catch {
468
+ // Best effort: a failed chmod must not lose the write.
469
+ }
470
+ }
471
+ renameSync(tmp, target);
408
472
  }
409
473
 
410
474
  /**
@@ -64,7 +64,6 @@ const DEV_ONLY_TOOLING = Object.freeze([
64
64
  "validate-schemas.mjs", // validates the repo's own schema files, needs ajv
65
65
  "sync-parity-check.sh",
66
66
  "benchmark-phase-0.sh",
67
- "test-gap-rules", // rule corpus for the repo's own test-gap gate
68
67
  ]);
69
68
 
70
69
  /** Eval harnesses (`eval-*.mjs`) read `pipeline/eval/**`, which never ships. */
@@ -7,9 +7,13 @@
7
7
  * @module install/_platform-filter
8
8
  */
9
9
 
10
- import { readdirSync } from "fs";
10
+ import { readdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { copyDir, countFiles, ensureDir } from "./_common.mjs";
12
+ import { copyDir, countFiles, ensureDir, isDryRun } from "./_common.mjs";
13
+ import { routeSkill } from "../pipeline/scripts/_stack-routing.mjs";
14
+
15
+ /** Uninstall reads this to know exactly which external skill dirs are ours to remove. */
16
+ export const EXTERNAL_SKILLS_MANIFEST = ".external-skills-manifest.json";
13
17
 
14
18
  /**
15
19
  * Heuristic prefix lists - intentionally loose so new skills added to
@@ -107,26 +111,88 @@ export function classifyExternalSkill(skillName) {
107
111
  */
108
112
  export function copyExternalSkillsFiltered(externalSrc, dest, opts) {
109
113
  const { platformFlag, useSymlinks = false } = opts;
114
+ const allNames = readdirSync(externalSrc, { withFileTypes: true })
115
+ .filter((e) => e.isDirectory())
116
+ .map((e) => e.name);
117
+
110
118
  if (platformFlag === "all") {
111
119
  copyDir(externalSrc, dest, { useSymlinks });
120
+ writeExternalSkillsManifest(dest, allNames);
112
121
  return { copied: countFiles(externalSrc), skipped: 0 };
113
122
  }
114
123
 
115
124
  ensureDir(dest);
116
125
  let copied = 0;
117
126
  let skipped = 0;
118
- for (const entry of readdirSync(externalSrc, { withFileTypes: true })) {
119
- if (!entry.isDirectory()) continue;
120
- const classification = classifyExternalSkill(entry.name);
127
+ const delivered = [];
128
+ for (const name of allNames) {
129
+ const classification = classifyExternalSkill(name);
121
130
  const shouldCopy = classification === "generic" || classification === platformFlag;
122
- const src = join(externalSrc, entry.name);
123
- const dst = join(dest, entry.name);
131
+ const src = join(externalSrc, name);
132
+ const dst = join(dest, name);
124
133
  if (shouldCopy) {
125
134
  copyDir(src, dst, { useSymlinks });
126
135
  copied += countFiles(src);
136
+ delivered.push(name);
127
137
  } else {
128
138
  skipped += 1;
129
139
  }
130
140
  }
141
+ writeExternalSkillsManifest(dest, delivered);
131
142
  return { copied, skipped };
132
143
  }
144
+
145
+ /**
146
+ * Enabled-stack partition of the external skill catalog.
147
+ *
148
+ * The prefix classifier above only knows ios/android/generic, so it cannot
149
+ * express "backend toolkit not enabled". Routing can: `routeSkill` is the same
150
+ * table `build-stack-plugins.mjs` ships plugins with, so filtering by it keeps
151
+ * a host's local copy byte-aligned with what the enabled plugins would serve on
152
+ * Claude Code. Unrouted skills are kept - dropping a skill no table claims
153
+ * would make it unreachable everywhere.
154
+ *
155
+ * @param {string} externalSrc - absolute path to `pipeline/skills/shared/external/`
156
+ * @param {string[]} enabledPluginNames - e.g. ["ai-ios-toolkit", "ai-common-toolkit"]
157
+ * @returns {{ keep: string[], skipped: string[] }}
158
+ */
159
+ export function partitionExternalSkillsByPlugins(externalSrc, enabledPluginNames) {
160
+ const keep = [];
161
+ const skipped = [];
162
+ for (const e of readdirSync(externalSrc, { withFileTypes: true })) {
163
+ if (!e.isDirectory()) continue;
164
+ const plugins = routeSkill(e.name);
165
+ if (plugins.length === 0 || plugins.some((p) => enabledPluginNames.includes(p))) {
166
+ keep.push(e.name);
167
+ } else {
168
+ skipped.push(e.name);
169
+ }
170
+ }
171
+ return { keep, skipped };
172
+ }
173
+
174
+ /**
175
+ * Record exactly which external skill dirs THIS install delivered.
176
+ *
177
+ * Uninstall needs to remove the distributed catalog without touching
178
+ * user-authored skill dirs that happen to share a name. The shipped
179
+ * `.skills-index.json` cannot answer that: it is the full catalog, so it lists
180
+ * platform-filtered skills that were never installed, and a newer package's
181
+ * tree lists skills the installed version never shipped. Both cases would make
182
+ * uninstall delete a user's own directory. Same contract as
183
+ * `.plugin-skills-manifest.json` for plugin-delivered skills.
184
+ *
185
+ * @param {string} dest
186
+ * @param {string[]} names
187
+ */
188
+ export function writeExternalSkillsManifest(dest, names) {
189
+ if (isDryRun()) return;
190
+ try {
191
+ writeFileSync(
192
+ join(dest, EXTERNAL_SKILLS_MANIFEST),
193
+ JSON.stringify([...names].sort(), null, 2) + "\n",
194
+ );
195
+ } catch {
196
+ /* best-effort - a missing manifest just means uninstall falls back */
197
+ }
198
+ }
@@ -37,11 +37,11 @@ export const AUTHORED_GROUPS = Object.freeze(["index", "reference", "workflow",
37
37
 
38
38
  /** Stack plugins, and the platform each belongs to. */
39
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" },
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
  /**
@@ -119,7 +119,7 @@ export function pluginsToDeliver(home, platformFlag) {
119
119
  const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
120
120
  const enabled = Object.entries(settings.enabledPlugins || {})
121
121
  .filter(([, on]) => on === true)
122
- // keys look like `ai-ios-engineering-toolkit@multi-agent-plugins`
122
+ // keys look like `ai-ios-toolkit@multi-agent-plugins`
123
123
  .map(([k]) => k.split("@")[0])
124
124
  .filter((n) => STACK_PLUGINS.some((p) => p.name === n));
125
125
  if (enabled.length > 0) {
@@ -194,7 +194,7 @@ export function installAuthoredPluginSkills(opts) {
194
194
  // That is not a duplicate: the pipeline's `architecture` is a generic ADR
195
195
  // framework while the iOS plugin's is that stack's structural rules, and the
196
196
  // same holds for `backlog`. Claude Code reaches both because its loader
197
- // namespaces plugin skills (`ai-ios-engineering-toolkit:architecture`); the
197
+ // namespaces plugin skills (`ai-ios-toolkit:architecture`); the
198
198
  // copy hosts had no namespace, so the stack-specific version was silently
199
199
  // unreachable on exactly the repos that need it most.
200
200
  //
@@ -263,12 +263,23 @@ export function installAuthoredPluginSkills(opts) {
263
263
  // this pass delivered so uninstall can remove precisely those, nothing more.
264
264
  if (!isDryRun()) {
265
265
  try {
266
- writeFileSync(join(dest, PLUGIN_SKILLS_MANIFEST), JSON.stringify([...delivered], null, 2) + "\n");
266
+ writeFileSync(
267
+ join(dest, PLUGIN_SKILLS_MANIFEST),
268
+ JSON.stringify([...delivered], null, 2) + "\n",
269
+ );
267
270
  } catch {
268
271
  /* best-effort - a missing manifest just means uninstall skips this cleanup */
269
272
  }
270
273
  }
271
- return { copied, collided, renamed, plugins, missing, selectionSource, deliveredNames: [...delivered] };
274
+ return {
275
+ copied,
276
+ collided,
277
+ renamed,
278
+ plugins,
279
+ missing,
280
+ selectionSource,
281
+ deliveredNames: [...delivered],
282
+ };
272
283
  }
273
284
 
274
285
  /**
@@ -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
 
@@ -35,7 +35,7 @@ import {
35
35
  wipeDir,
36
36
  writeFile,
37
37
  } from "./_common.mjs";
38
- import { copyExternalSkillsFiltered } from "./_platform-filter.mjs";
38
+ import { EXTERNAL_SKILLS_MANIFEST } from "./_platform-filter.mjs";
39
39
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
40
40
  import { registerMcpServer } from "./_mcp-register.mjs";
41
41
 
@@ -46,6 +46,16 @@ import { registerMcpServer } from "./_mcp-register.mjs";
46
46
  */
47
47
  export const LEGACY_PRE_COMMIT_MATCHER = "Bash(git commit:*)";
48
48
 
49
+ /**
50
+ * The only skills installed locally on Claude Code. Everything else reaches
51
+ * Claude Code through the marketplace plugins (`ai-<stack>-toolkit`), namespaced
52
+ * by the plugin loader. These two stay local by policy: they are pipeline-owned
53
+ * compliance catalogs that may carry audit rules not meant for the public
54
+ * marketplace, and `scripts/uninstall.mjs` (PIPELINE_CORE_SKILL_DIRS) already
55
+ * treats exactly this pair as pipeline-owned.
56
+ */
57
+ export const PIPELINE_LOCAL_SKILLS = ["apple-archive-compliance", "google-play-compliance"];
58
+
49
59
  /**
50
60
  * @param {{
51
61
  * home: string,
@@ -128,13 +138,16 @@ function installCommands(pipelineSrc, dest, useSymlinks) {
128
138
  //
129
139
  // Preserve local-only alias wrappers (frontmatter `local-only: true`) across
130
140
  // 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);
141
+ // never synced), so without this stash+restore they would be destroyed on
142
+ // every install/update. The stash is an on-disk rename, not an in-memory
143
+ // snapshot: the only copy of user-authored content stays on disk at every
144
+ // instant, so an install interrupted between wipe and restore loses nothing,
145
+ // and the next run adopts any leftover stash.
146
+ const wrapperStash = stashLocalOnlyWrappers(ownedCmdDir);
134
147
  wipeDir(ownedCmdDir);
135
148
 
136
149
  copyDir(commandsSrc, dest, { useSymlinks });
137
- const restored = restoreLocalOnlyWrappers(ownedCmdDir, preservedWrappers);
150
+ const restored = unstashLocalOnlyWrappers(ownedCmdDir, wrapperStash);
138
151
  console.log(` -> ${countFiles(commandsSrc)} files copied to ${dest}`);
139
152
  if (restored > 0) {
140
153
  console.log(` -> preserved ${restored} local-only alias wrapper(s)`);
@@ -163,36 +176,51 @@ function localizeCommandDescriptions(pipelineSrc, ownedCmdDir) {
163
176
  }
164
177
  }
165
178
 
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;
179
+ // Move command dirs whose SKILL.md declares `local-only: true` into an on-disk
180
+ // stash beside the namespace, so installCommands can restore them after the
181
+ // namespace wipe. Returns the stash path.
182
+ function stashLocalOnlyWrappers(ownedCmdDir) {
183
+ const stashDir = join(dirname(ownedCmdDir), ".multi-agent-wrapper-stash");
184
+ if (isDryRun() || !existsSync(ownedCmdDir)) return stashDir;
172
185
  for (const name of readdirSync(ownedCmdDir)) {
173
186
  const dir = join(ownedCmdDir, name);
174
187
  const skill = join(dir, "SKILL.md");
175
188
  if (!existsSync(skill)) continue;
176
189
  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 });
190
+ ensureDir(stashDir);
191
+ const target = join(stashDir, name);
192
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
193
+ renameSync(dir, target);
181
194
  }
182
- return out;
195
+ return stashDir;
183
196
  }
184
197
 
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) {
198
+ // Restore stashed local-only wrappers (including any leftover from an earlier
199
+ // interrupted run), but never clobber a real command the pipeline just
200
+ // installed under the same name. Returns the count restored.
201
+ function unstashLocalOnlyWrappers(ownedCmdDir, stashDir) {
202
+ if (isDryRun() || !existsSync(stashDir)) return 0;
188
203
  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);
204
+ for (const name of readdirSync(stashDir)) {
205
+ const from = join(stashDir, name);
206
+ const to = join(ownedCmdDir, name);
207
+ if (existsSync(to)) {
208
+ // The pipeline now ships a real command under a name the user's wrapper
209
+ // was using, so the wrapper cannot be restored. Say whose content is
210
+ // being dropped: /multi-agent:save writes exactly these, and a release
211
+ // claiming a new name would otherwise delete a saved routine in silence.
212
+ console.log(
213
+ ` -> WARNING: dropped local-only wrapper '${name}' - this release ships a command ` +
214
+ `with that name. Re-save it under a different name if you still need it.`,
215
+ );
216
+ rmSync(from, { recursive: true, force: true });
217
+ continue;
218
+ }
219
+ ensureDir(ownedCmdDir);
220
+ renameSync(from, to);
194
221
  n++;
195
222
  }
223
+ rmSync(stashDir, { recursive: true, force: true });
196
224
  return n;
197
225
  }
198
226
 
@@ -327,7 +355,7 @@ function runPreDeployScans(pipelineSrc) {
327
355
  }
328
356
 
329
357
  function installSkills(opts) {
330
- const { pipelineSrc, dest, indexOnly, useSymlinks, platformFlag } = opts;
358
+ const { pipelineSrc, dest, indexOnly, useSymlinks } = opts;
331
359
  console.log(" [Claude Code] Installing skills...");
332
360
 
333
361
  // Same symlink guard as the other owned trees: never prune or copy through
@@ -343,40 +371,49 @@ function installSkills(opts) {
343
371
  }
344
372
 
345
373
  const sharedCoreSrc = join(pipelineSrc, "skills", "shared", "core");
346
- const sharedExternalSrc = join(pipelineSrc, "skills", "shared", "external");
347
374
 
348
375
  let claudeSkillCount = 0;
349
376
 
350
- if (existsSync(sharedCoreSrc)) {
351
- copyDir(sharedCoreSrc, dest, { useSymlinks });
352
- claudeSkillCount += countFiles(sharedCoreSrc);
377
+ // Claude Code no longer receives a local copy of the stack skills. The
378
+ // marketplace plugins (`ai-<stack>-toolkit`) are its only stack-skill source -
379
+ // a local copy duplicated ~110 skills against the enabled plugins (~10k
380
+ // tokens/session) and shadowed the plugin the moment it went stale. Only the
381
+ // two pipeline-owned compliance catalogs stay local (PIPELINE_LOCAL_SKILLS);
382
+ // they are deliberately not published to the marketplace.
383
+ ensureDir(dest);
384
+ for (const name of PIPELINE_LOCAL_SKILLS) {
385
+ const from = join(sharedCoreSrc, name);
386
+ if (!existsSync(from)) continue;
387
+ if (!useSymlinks) wipeDir(join(dest, name));
388
+ copyDir(from, join(dest, name), { useSymlinks });
389
+ claudeSkillCount += countFiles(from);
353
390
  }
354
391
 
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
- }
392
+ // Single-standard enforcement: Claude Code invokes pipeline commands via
393
+ // the `/multi-agent:*` slash-command namespace, so prune duplicate skill
394
+ // dirs left by older installs that copied shared/core wholesale. This used
395
+ // to live inside the external-copy branch; it must run unconditionally or
396
+ // the 51 multi-agent-* dirs return on the first install after a migration.
397
+ const pruned = pruneLegacyMultiAgentSkills(dest);
398
+ if (pruned > 0) {
399
+ console.log(
400
+ ` -> pruned ${pruned} legacy multi-agent-* skill dirs (slash commands cover these)`,
401
+ );
402
+ }
366
403
 
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
- }
404
+ // Migration prune: an older install delivered the full external catalog here.
405
+ // Its manifest names exactly what was delivered, so removal is manifest-scoped -
406
+ // user-authored skills in the same directory are untouched. Without a manifest
407
+ // nothing is guessed; the stale copy then outlives this install and the user
408
+ // can clean it via uninstall --claude.
409
+ const migrated = pruneManifestDeliveredSkills(dest);
410
+ if (migrated > 0) {
411
+ console.log(
412
+ ` -> migration: removed ${migrated} stack skill dir(s) previously copied here (now plugin-only)`,
413
+ );
377
414
  }
378
415
 
379
- // Figma component skills moved to the ai-<platform>-engineering-toolkit marketplace
416
+ // Figma component skills moved to the ai-<platform>-toolkit marketplace
380
417
  // plugin and are no longer bundled here. Prune any stale copies left by a
381
418
  // pre-migration install so the installed tree stays consistent.
382
419
  for (const sub of ["figma-ios", "figma-android", "figma-common", "figma-to-component"]) {
@@ -410,21 +447,69 @@ function installSkills(opts) {
410
447
  console.log(` -> pruned ${orphans} stale flat skill file(s) from an older layout`);
411
448
  }
412
449
 
413
- console.log(` -> ${claudeSkillCount} skill files installed to ${dest}`);
450
+ console.log(` -> ${claudeSkillCount} skill files installed to ${dest} (stack skills: marketplace plugins)`);
414
451
 
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");
452
+ // The shared skills README described the full local catalog; with the catalog
453
+ // gone it would document skills that are not here. Drop it, including copies
454
+ // an older install left behind.
455
+ const claudeReadme = join(dest, "README.md");
456
+ if (existsSync(claudeReadme) && !isDryRun()) {
419
457
  try {
420
- copyFile(sharedReadmeSrc, claudeReadme);
421
- console.log(` -> updated ${claudeReadme}`);
458
+ rmSync(claudeReadme);
459
+ console.log(` -> removed ${claudeReadme} (local catalog no longer installed)`);
422
460
  } catch {
423
461
  /* non-fatal */
424
462
  }
425
463
  }
426
464
  }
427
465
 
466
+ /**
467
+ * Remove the external stack skills an older install copied into `dest`, scoped
468
+ * strictly to the delivery manifest that install wrote. NOTICE files rode along
469
+ * with the external catalog, so they go with it.
470
+ *
471
+ * @param {string} dest - the installed skills directory
472
+ * @returns {number} directories removed
473
+ */
474
+ function pruneManifestDeliveredSkills(dest) {
475
+ const manifestPath = join(dest, EXTERNAL_SKILLS_MANIFEST);
476
+ if (!existsSync(manifestPath)) return 0;
477
+ if (isDryRun()) {
478
+ console.log(` [dry-run] would remove externally-delivered skills named in ${manifestPath}`);
479
+ return 0;
480
+ }
481
+ let removed = 0;
482
+ try {
483
+ const names = JSON.parse(readFileSync(manifestPath, "utf-8"));
484
+ for (const name of Array.isArray(names) ? names : []) {
485
+ // The two local compliance catalogs are never external-delivered, but a
486
+ // corrupted manifest must not be able to take them out.
487
+ if (PIPELINE_LOCAL_SKILLS.includes(name)) continue;
488
+ const dir = join(dest, name);
489
+ if (!existsSync(dir)) continue;
490
+ try {
491
+ rmSync(dir, { recursive: true, force: true });
492
+ removed++;
493
+ } catch {
494
+ /* non-fatal */
495
+ }
496
+ }
497
+ for (const entry of readdirSync(dest)) {
498
+ if (entry.startsWith("NOTICE-") && entry.endsWith(".md")) {
499
+ try {
500
+ rmSync(join(dest, entry));
501
+ } catch {
502
+ /* non-fatal */
503
+ }
504
+ }
505
+ }
506
+ rmSync(manifestPath, { force: true });
507
+ } catch {
508
+ /* unreadable manifest: leave everything in place rather than guess */
509
+ }
510
+ return removed;
511
+ }
512
+
428
513
  function ensureClaudeMd(home, pipelineSrc) {
429
514
  console.log(" [Claude Code] Checking CLAUDE.md template...");
430
515
  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
 
@@ -269,7 +277,7 @@ function pruneLegacyCommandSkills(skillsDir) {
269
277
  }
270
278
 
271
279
  /**
272
- * Install the reference tree: the shared `multi-agent-refs/` docs plus the 42
280
+ * Install the reference tree: the shared `multi-agent-refs/` docs plus every
273
281
  * sub-command specs, all path-rewritten. These are read on demand and never
274
282
  * enter the skills block.
275
283
  */
@@ -394,7 +402,24 @@ function installSkillRefs(pipelineSrc, dest, home, platformFlag) {
394
402
  wipeDir(dest);
395
403
 
396
404
  let count = 0;
397
- if (existsSync(externalSrc)) count += copyTreeRewritten(externalSrc, dest);
405
+ if (existsSync(externalSrc)) {
406
+ // Active-stack filter (same contract as Copilot): Codex used to receive all
407
+ // 151 external skills regardless of stack - platformFlag only shaped the
408
+ // authored-plugin delivery below. The enabled-plugin set from
409
+ // ~/.claude/settings.json now bounds this copy too, so a Claude+Codex user
410
+ // sees one consistent stack surface across both hosts. The manifest makes
411
+ // the delivery inspectable; the wholesale wipeDir above already handles
412
+ // staleness on every install.
413
+ const { names: enabled, source: selectionSource } = pluginsToDeliver(home, platformFlag);
414
+ const { keep, skipped } = partitionExternalSkillsByPlugins(externalSrc, enabled);
415
+ for (const name of keep) {
416
+ count += copyTreeRewritten(join(externalSrc, name), join(dest, name));
417
+ }
418
+ writeExternalSkillsManifest(dest, keep);
419
+ console.log(
420
+ ` -> stack filter (${selectionSource}): ${keep.length} kept, ${skipped.length} skipped`,
421
+ );
422
+ }
398
423
 
399
424
  // `shared/core` also holds two skills that are NOT pipeline sub-commands:
400
425
  // apple-archive-compliance and google-play-compliance. Copying only `external`