@mmerterden/multi-agent-pipeline 15.0.0 → 15.2.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 (39) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +9 -9
  3. package/README.tr.md +9 -9
  4. package/SECURITY.md +43 -0
  5. package/docs/adr/0007-multi-tool-adapter-framework.md +1 -1
  6. package/docs/architecture.md +9 -9
  7. package/docs/ecosystem.md +10 -10
  8. package/index.js +4 -1
  9. package/install/_common.mjs +44 -2
  10. package/install/_platform-filter.mjs +6 -131
  11. package/install/_plugin-skills.mjs +17 -28
  12. package/install/claude.mjs +111 -6
  13. package/install/codex.mjs +0 -3
  14. package/install/copilot.mjs +36 -1
  15. package/install/index.mjs +3 -1
  16. package/package.json +2 -2
  17. package/pipeline/commands/multi-agent/refactor/SKILL.md +36 -1
  18. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/stack/SKILL.md +18 -8
  20. package/pipeline/commands/multi-agent/update/SKILL.md +31 -4
  21. package/pipeline/lib/parse-complaints.sh +12 -2
  22. package/pipeline/multi-agent-refs/complaint-analysis-template.md +1 -1
  23. package/pipeline/multi-agent-refs/phases/operations.md +7 -1
  24. package/pipeline/multi-agent-refs/phases/phase-7-report.md +6 -0
  25. package/pipeline/multi-agent-refs/tracker-contract.md +2 -1
  26. package/pipeline/preferences-template.json +6 -0
  27. package/pipeline/schemas/prefs.schema.json +27 -0
  28. package/pipeline/scripts/README.md +4 -3
  29. package/pipeline/scripts/check-derived-drift.mjs +5 -2
  30. package/pipeline/scripts/match-skills.mjs +4 -0
  31. package/pipeline/scripts/migrate-prefs.mjs +5 -1
  32. package/pipeline/scripts/phase-tracker.sh +19 -0
  33. package/pipeline/scripts/uninstall.mjs +3 -1
  34. package/pipeline/scripts/usage-report.mjs +457 -0
  35. package/pipeline/scripts/validate-complaint-doc.mjs +32 -11
  36. package/pipeline/skills/.skill-manifest.json +4 -4
  37. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +153 -90
  38. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +18 -8
  39. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +1 -1
@@ -1,147 +1,22 @@
1
1
  /**
2
- * Platform classification + filtered copy for the external skill catalog.
2
+ * Stack partition + delivery manifest for the external skill catalog.
3
3
  *
4
- * The `--platform=ios|android|all` flag narrows `pipeline/skills/shared/external/`
5
- * to the requested platform. Generic skills always copy regardless of filter.
4
+ * The pre-v15 prefix classifier and its filtered-copy path
5
+ * (`classifyExternalSkill` / `copyExternalSkillsFiltered`) were removed once the
6
+ * routing table (`_stack-routing.mjs`) became the single source of stack
7
+ * affinity - `partitionExternalSkillsByPlugins` is what every installer uses.
6
8
  *
7
9
  * @module install/_platform-filter
8
10
  */
9
11
 
10
12
  import { readdirSync, writeFileSync } from "fs";
11
13
  import { join } from "path";
12
- import { copyDir, countFiles, ensureDir, isDryRun } from "./_common.mjs";
14
+ import { isDryRun } from "./_common.mjs";
13
15
  import { routeSkill } from "../pipeline/scripts/_stack-routing.mjs";
14
16
 
15
17
  /** Uninstall reads this to know exactly which external skill dirs are ours to remove. */
16
18
  export const EXTERNAL_SKILLS_MANIFEST = ".external-skills-manifest.json";
17
19
 
18
- /**
19
- * Heuristic prefix lists - intentionally loose so new skills added to
20
- * `shared/external/` are classified by name (no manifest required). A skill
21
- * that doesn't match either list is treated as "generic" and copies for every
22
- * platform.
23
- */
24
- const IOS_PREFIXES = [
25
- "swift",
26
- "swiftui",
27
- "ios",
28
- "apple",
29
- "xcode",
30
- "uikit",
31
- "watchkit",
32
- "passkit",
33
- "storekit",
34
- "healthkit",
35
- "activitykit",
36
- "weatherkit",
37
- "pencilkit",
38
- "callkit",
39
- "eventkit",
40
- "homekit",
41
- "mapkit",
42
- "musickit",
43
- "permissionkit",
44
- "speech",
45
- "metrickit",
46
- "widgetkit",
47
- "vision",
48
- "app-store",
49
- "app-clips",
50
- "app-intents",
51
- "app-tracking",
52
- "hig-",
53
- "coreml",
54
- "core-nfc",
55
- "core-bluetooth",
56
- "core-motion",
57
- "cloudkit",
58
- "live-activities",
59
- "realitykit",
60
- "tipkit",
61
- "alarmkit",
62
- "energykit",
63
- "natural-language",
64
- "authentication",
65
- "background-processing",
66
- "contacts",
67
- "device-integrity",
68
- "debugging-instruments",
69
- "macos-",
70
- "ios-",
71
- "photos-camera",
72
- "push-notifications",
73
- "shareplay",
74
- "apple-on-device",
75
- "swiftdata",
76
- "swift-",
77
- ];
78
-
79
- const ANDROID_PREFIXES = [
80
- "android",
81
- "kotlin",
82
- "jetpack",
83
- "compose",
84
- "room",
85
- "retrofit",
86
- "gradle",
87
- "material",
88
- "play-store",
89
- ];
90
-
91
- /**
92
- * Classify an external skill directory name into its platform affinity.
93
- *
94
- * @param {string} skillName - directory name under `shared/external/`
95
- * @returns {"ios" | "android" | "generic"}
96
- */
97
- export function classifyExternalSkill(skillName) {
98
- const lower = skillName.toLowerCase();
99
- if (IOS_PREFIXES.some((p) => lower.startsWith(p))) return "ios";
100
- if (ANDROID_PREFIXES.some((p) => lower.startsWith(p))) return "android";
101
- return "generic";
102
- }
103
-
104
- /**
105
- * Platform-filtered copy of `shared/external/` skills.
106
- *
107
- * @param {string} externalSrc - absolute path to `pipeline/skills/shared/external/`
108
- * @param {string} dest - absolute path to target install dir
109
- * @param {{ platformFlag: "ios"|"android"|"all", useSymlinks?: boolean }} opts
110
- * @returns {{ copied: number, skipped: number }}
111
- */
112
- export function copyExternalSkillsFiltered(externalSrc, dest, opts) {
113
- const { platformFlag, useSymlinks = false } = opts;
114
- const allNames = readdirSync(externalSrc, { withFileTypes: true })
115
- .filter((e) => e.isDirectory())
116
- .map((e) => e.name);
117
-
118
- if (platformFlag === "all") {
119
- copyDir(externalSrc, dest, { useSymlinks });
120
- writeExternalSkillsManifest(dest, allNames);
121
- return { copied: countFiles(externalSrc), skipped: 0 };
122
- }
123
-
124
- ensureDir(dest);
125
- let copied = 0;
126
- let skipped = 0;
127
- const delivered = [];
128
- for (const name of allNames) {
129
- const classification = classifyExternalSkill(name);
130
- const shouldCopy = classification === "generic" || classification === platformFlag;
131
- const src = join(externalSrc, name);
132
- const dst = join(dest, name);
133
- if (shouldCopy) {
134
- copyDir(src, dst, { useSymlinks });
135
- copied += countFiles(src);
136
- delivered.push(name);
137
- } else {
138
- skipped += 1;
139
- }
140
- }
141
- writeExternalSkillsManifest(dest, delivered);
142
- return { copied, skipped };
143
- }
144
-
145
20
  /**
146
21
  * Enabled-stack partition of the external skill catalog.
147
22
  *
@@ -33,10 +33,10 @@ 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([
39
+ const STACK_PLUGINS = Object.freeze([
40
40
  { name: "ai-ios-toolkit", platform: "ios" },
41
41
  { name: "ai-android-toolkit", platform: "android" },
42
42
  { name: "ai-frontend-toolkit", platform: "all" },
@@ -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,17 +104,22 @@ 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 || {})
@@ -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(
@@ -282,27 +287,11 @@ export function installAuthoredPluginSkills(opts) {
282
287
  };
283
288
  }
284
289
 
285
- /**
286
- * Names already present in a flat skills directory, so a second delivery pass does
287
- * not overwrite what the pipeline itself installed.
288
- *
289
- * @param {string} dir
290
- * @returns {Set<string>}
291
- */
292
- export function existingSkillNames(dir) {
293
- if (!existsSync(dir)) return new Set();
294
- return new Set(
295
- readdirSync(dir, { withFileTypes: true })
296
- .filter((e) => e.isDirectory())
297
- .map((e) => e.name),
298
- );
299
- }
300
-
301
290
  /**
302
291
  * Skill names the PIPELINE owns, derived from its source tree.
303
292
  *
304
- * This is what `skipNames` should be. Callers used to pass
305
- * `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
306
295
  * did protect pipeline skills from being shadowed by a plugin, but also meant a
307
296
  * plugin skill was copied exactly once and then frozen: on the second install its
308
297
  * own name was "already there", so it was skipped, forever. A stale plugin skill
@@ -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,
@@ -63,10 +65,11 @@ export const PIPELINE_LOCAL_SKILLS = ["apple-archive-compliance", "google-play-c
63
65
  * indexOnly: boolean,
64
66
  * useSymlinks: boolean,
65
67
  * platformFlag: "ios"|"android"|"all",
68
+ * pruneExternal?: boolean,
66
69
  * }} ctx
67
70
  */
68
71
  export function installClaude(ctx) {
69
- const { home, pipelineSrc, indexOnly, useSymlinks, platformFlag } = ctx;
72
+ const { home, pipelineSrc, indexOnly, useSymlinks, platformFlag, pruneExternal } = ctx;
70
73
 
71
74
  const CLAUDE_COMMANDS = join(home, ".claude", "commands");
72
75
  const CLAUDE_AGENTS = join(home, ".claude", "agents");
@@ -94,7 +97,7 @@ export function installClaude(ctx) {
94
97
  installSchemas(pipelineSrc, CLAUDE_SCHEMAS, useSymlinks);
95
98
  installLib(pipelineSrc, CLAUDE_LIB, useSymlinks);
96
99
  runPreDeployScans(pipelineSrc);
97
- installSkills({ pipelineSrc, dest: CLAUDE_SKILLS, indexOnly, useSymlinks, platformFlag });
100
+ installSkills({ pipelineSrc, dest: CLAUDE_SKILLS, indexOnly, useSymlinks, platformFlag, pruneExternal });
98
101
  ensureClaudeMd(home, pipelineSrc);
99
102
  ensurePreferences(PREFS_PATH, pipelineSrc);
100
103
  configureSettings(home);
@@ -355,7 +358,7 @@ function runPreDeployScans(pipelineSrc) {
355
358
  }
356
359
 
357
360
  function installSkills(opts) {
358
- const { pipelineSrc, dest, indexOnly, useSymlinks } = opts;
361
+ const { pipelineSrc, dest, indexOnly, useSymlinks, pruneExternal } = opts;
359
362
  console.log(" [Claude Code] Installing skills...");
360
363
 
361
364
  // Same symlink guard as the other owned trees: never prune or copy through
@@ -403,9 +406,7 @@ function installSkills(opts) {
403
406
 
404
407
  // Migration prune: an older install delivered the full external catalog here.
405
408
  // 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
+ // user-authored skills in the same directory are untouched.
409
410
  const migrated = pruneManifestDeliveredSkills(dest);
410
411
  if (migrated > 0) {
411
412
  console.log(
@@ -413,6 +414,16 @@ function installSkills(opts) {
413
414
  );
414
415
  }
415
416
 
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
+
416
427
  // Figma component skills moved to the ai-<platform>-toolkit marketplace
417
428
  // plugin and are no longer bundled here. Prune any stale copies left by a
418
429
  // pre-migration install so the installed tree stays consistent.
@@ -482,6 +493,10 @@ function pruneManifestDeliveredSkills(dest) {
482
493
  try {
483
494
  const names = JSON.parse(readFileSync(manifestPath, "utf-8"));
484
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;
485
500
  // The two local compliance catalogs are never external-delivered, but a
486
501
  // corrupted manifest must not be able to take them out.
487
502
  if (PIPELINE_LOCAL_SKILLS.includes(name)) continue;
@@ -510,6 +525,96 @@ function pruneManifestDeliveredSkills(dest) {
510
525
  return removed;
511
526
  }
512
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
+
513
618
  function ensureClaudeMd(home, pipelineSrc) {
514
619
  console.log(" [Claude Code] Checking CLAUDE.md template...");
515
620
  const CLAUDE_MD = join(home, ".claude", "CLAUDE.md");
package/install/codex.mjs CHANGED
@@ -52,9 +52,6 @@ export const AGENTS_MD_START_MARKER = "# Multi-Agent Development Pipeline";
52
52
  /** Explicit end marker written after the pipeline section. */
53
53
  export const AGENTS_MD_END_MARKER = "<!-- multi-agent-pipeline:codex-instructions:end -->";
54
54
 
55
- /** Re-exported for the uninstaller and the Codex install smoke. */
56
- export { MCP_SERVER_NAME } from "./_mcp-register.mjs";
57
-
58
55
  /**
59
56
  * `$HOME/.claude/...` path rewrites applied to every file installed into the
60
57
  * Codex tree.
@@ -16,6 +16,7 @@ import {
16
16
  copyFile,
17
17
  copySkillsIndex,
18
18
  countFiles,
19
+ dirsIdentical,
19
20
  ensureDir,
20
21
  ensureRealDir,
21
22
  isDryRun,
@@ -25,6 +26,7 @@ import {
25
26
  writeFile,
26
27
  } from "./_common.mjs";
27
28
  import {
29
+ EXTERNAL_SKILLS_MANIFEST,
28
30
  partitionExternalSkillsByPlugins,
29
31
  writeExternalSkillsManifest,
30
32
  } from "./_platform-filter.mjs";
@@ -372,6 +374,15 @@ function installSkills(opts) {
372
374
  const { names: enabled, source: selectionSource } = pluginsToDeliver(home, platformFlag);
373
375
  const { keep, skipped } = partitionExternalSkillsByPlugins(sharedExternalSrc, enabled);
374
376
  ensureDir(dest);
377
+ // Read the PREVIOUS delivery manifest before this install overwrites it -
378
+ // it is what makes the stale prune below manifest-scoped.
379
+ let prevDelivered = null;
380
+ try {
381
+ const parsed = JSON.parse(readFileSync(join(dest, EXTERNAL_SKILLS_MANIFEST), "utf-8"));
382
+ if (Array.isArray(parsed)) prevDelivered = new Set(parsed.filter((n) => typeof n === "string"));
383
+ } catch {
384
+ prevDelivered = null;
385
+ }
375
386
  for (const name of keep) {
376
387
  const src = join(sharedExternalSrc, name);
377
388
  const dst = join(dest, name);
@@ -380,10 +391,29 @@ function installSkills(opts) {
380
391
  copilotSkillCount += countFiles(src);
381
392
  }
382
393
  // Stale prune: a skill delivered by a previous, wider stack selection must
383
- // leave when the selection narrows, or the old stack lingers forever.
394
+ // leave when the selection narrows, or the old stack lingers forever. Scope:
395
+ // only dirs the previous manifest names (delivered by us), or - on a
396
+ // pre-manifest install - dirs byte-identical to the shipped catalog. A
397
+ // user-authored dir that happens to share a catalog name matches neither
398
+ // and is never touched.
399
+ let keptForeign = 0;
384
400
  for (const name of skipped) {
385
401
  const stale = join(dest, name);
386
402
  if (!existsSync(stale) || isDryRun()) continue;
403
+ let ours;
404
+ if (prevDelivered) {
405
+ ours = prevDelivered.has(name);
406
+ } else {
407
+ try {
408
+ ours = dirsIdentical(join(sharedExternalSrc, name), stale);
409
+ } catch {
410
+ ours = false;
411
+ }
412
+ }
413
+ if (!ours) {
414
+ keptForeign++;
415
+ continue;
416
+ }
387
417
  try {
388
418
  rmSync(stale, { recursive: true, force: true });
389
419
  } catch {
@@ -392,6 +422,11 @@ function installSkills(opts) {
392
422
  }
393
423
  writeExternalSkillsManifest(dest, keep);
394
424
  console.log(` -> stack filter (${selectionSource}): ${keep.length} kept, ${skipped.length} skipped`);
425
+ if (keptForeign > 0) {
426
+ console.log(
427
+ ` note: ${keptForeign} skipped-stack dir(s) kept - not delivered by this pipeline (no manifest entry, differs from catalog)`,
428
+ );
429
+ }
395
430
  }
396
431
 
397
432
  // The pre-migration figma-* skill copies are gone; the plugin owns component work
package/install/index.mjs CHANGED
@@ -45,7 +45,7 @@ export async function runInstall(argv) {
45
45
  // `multi-agent-pipeline install --all` (via bin, argv[2]="install")
46
46
  const flags = argv.slice(2).filter((a) => a !== "install");
47
47
 
48
- const KNOWN_FLAGS = [...TOOL_FLAGS, "--all", "--link", "--index-only", "--dry-run"];
48
+ const KNOWN_FLAGS = [...TOOL_FLAGS, "--all", "--link", "--index-only", "--dry-run", "--prune-external"];
49
49
  const KNOWN_PREFIXES = ["--target=", "--platform="];
50
50
  const unknown = flags.filter(
51
51
  (f) =>
@@ -74,6 +74,7 @@ export async function runInstall(argv) {
74
74
 
75
75
  const useSymlinks = flags.includes("--link");
76
76
  const indexOnly = flags.includes("--index-only");
77
+ const pruneExternal = flags.includes("--prune-external");
77
78
  const platformFlag = parsePlatformFlag(flags);
78
79
 
79
80
  const installerCtx = {
@@ -82,6 +83,7 @@ export async function runInstall(argv) {
82
83
  indexOnly,
83
84
  useSymlinks,
84
85
  platformFlag,
86
+ pruneExternal,
85
87
  };
86
88
 
87
89
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.0.0",
3
+ "version": "15.2.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -68,11 +68,11 @@
68
68
  "install.js",
69
69
  "install/**/*",
70
70
  "pipeline/**/*",
71
- "pipeline/scripts/**/*",
72
71
  "docs/**/*",
73
72
  "README.md",
74
73
  "CHANGELOG.md",
75
74
  "LICENSE",
75
+ "SECURITY.md",
76
76
  "!pipeline/scripts/smoke-figma-config-schema.sh",
77
77
  "!pipeline/scripts/smoke-personal-data.sh",
78
78
  "!pipeline/scripts/validate-prefs.mjs",
@@ -18,10 +18,11 @@ Deep-analyses the current project, extracts the global best-practices worth adop
18
18
  Step 0: BEST-PRACTICES Research the field, extract the best approaches, ADAPT them to our stack -> plan band A
19
19
  Step 0b: DRIFT Check upstream-derived skills for updates we have not pulled -> plan band D
20
20
  Step 0c: DEV-TOOLKIT Research current MCP practice + audit the companion dev-toolkit repo -> plan band E
21
+ Step 0d: RUN-ERRORS Read the local run-error ledger, rank recurring failures -> plan band F
21
22
  Step 1: SCAN Walk the project structure (files, LOC, dependencies, CI, tests)
22
23
  Step 2: ANALYZE 10 categories + an explicit BUG HUNT (real defects, not just scores) -> plan bands B, C
23
24
  Step 3: SCORE Each category /10, overall /100
24
- Step 4: PLAN Merge bands A (best-practice) + B (bugs) + C (improvements) + D (drift) + E (dev-toolkit)
25
+ Step 4: PLAN Merge bands A (best-practice) + B (bugs) + C (improvements) + D (drift) + E (dev-toolkit) + F (run-errors)
25
26
  Step 5: ASK "Start the work?" - wait for user approval
26
27
  Step 6: IMPLEMENT Apply approved items one by one (lint, test, commit)
27
28
  Step 7: VERIFY Confirm every test + lint passes
@@ -34,6 +35,7 @@ Plan bands (all five feed the single Step 4 table):
34
35
  - **C - Improvement**: quality/perf/DX gaps surfaced by the 10-category analysis.
35
36
  - **D - Drift**: upstream updates to skills we derived from an external source.
36
37
  - **E - Dev-toolkit**: current-practice gaps in the companion dev-toolkit MCP server (the pipeline's device and browser hands), applied in that repo.
38
+ - **F - Run-errors**: recurring real failures the pipeline actually hit across past runs, read from the local run-error ledger. These are lived evidence, not speculation - a failure that recurs across users or tasks is a prioritized improvement area.
37
39
 
38
40
  ## Step 0: BEST-PRACTICES - research the field, adapt to us
39
41
 
@@ -178,6 +180,39 @@ Rules for this band:
178
180
  - A finding that changes the tool surface (new / renamed / removed tool) pairs with a pipeline-side item: bump the minimum toolkit version wherever a pipeline skill declares one.
179
181
  - If the current working directory IS the toolkit repo, skip band E and let bands A/B/C cover it - never report the same finding twice.
180
182
 
183
+ ## Step 0d: RUN-ERRORS - what the pipeline actually failed on
184
+
185
+ Before speculating about improvements, read what real runs already failed on. When usage logging is enabled, every terminated run appends its errors to a local ledger:
186
+
187
+ ```bash
188
+ LEDGER="$HOME/.claude/logs/multi-agent/errors-ledger.jsonl"
189
+ [ -f "$LEDGER" ] || echo "no run-error ledger yet - skip band F"
190
+ ```
191
+
192
+ Each line is one run: `{ t, id, u, c, rp, ph, v, errs[] }`. The `errs[]` entries are cause tags (`<phase>:<cause>` halt reasons, `phase-<id>-failed`, `run-failed`).
193
+
194
+ 1. Read the ledger (best-effort; a missing or unreadable file means skip band F, not a failure).
195
+ 2. Group by error tag. For each tag compute: occurrences, distinct users affected, distinct repos, the phase it usually strikes, first + last seen, and which pipeline versions it spans (a tag that persists across versions is unfixed; one that stopped at a version is already resolved - do not re-raise it).
196
+ 3. Rank by `occurrences x users-affected`. A failure that recurs across users or tasks is lived evidence, not a hypothesis - it outranks a speculative improvement.
197
+ 4. For each surviving tag, trace it to the phase doc / script that emits that cause and propose the concrete fix.
198
+
199
+ This band mirrors the admin dashboard's "Gelişim alanları" panel, but reads the local ledger so it needs no auth and works offline. If `$ARGUMENTS` names a focus area, still read the ledger - a recurring run error in that area is the strongest possible signal.
200
+
201
+ Output (plan band F):
202
+
203
+ ```
204
+ | # | Error tag | Occurrences | Users | Usual phase | Versions | Root cause (file) | Fix | In plan? |
205
+ |---|-----------|-------------|-------|-------------|----------|-------------------|-----|----------|
206
+ | 1 | 4:reviewer-json-invalid | 12 | 3 | 4 | 14.x-15.x | reviewer prompt lets prose leak | tighten schema instruction in phase-4-review.md | Yes (P0) |
207
+ | 2 | phase-3-failed | 5 | 2 | 3 | 15.0.x | build step misses a stack toolchain | add preflight in phase-3-dev.md | Yes (P1) |
208
+ ```
209
+
210
+ Rules for this band:
211
+
212
+ - The ledger is evidence of the past, not a spec. A tag that stopped recurring after a version bump is resolved - report it as resolved, do not add a plan item.
213
+ - Never quote a user's identity as blame. The `u` field is for counting distinct affected users, not for naming anyone in the plan.
214
+ - If the ledger is empty or absent, skip band F silently - it is additive signal, never a gate.
215
+
181
216
  ## Step 1: SCAN
182
217
 
183
218
  ```
@@ -66,7 +66,7 @@ Known legitimate domains are embedded in the scanner (github, anthropic, figma,
66
66
 
67
67
  ## Smoke - self-verification
68
68
 
69
- `pipeline/scripts/smoke-skill-scan.sh` confirms the scanner triggers on positive fixtures for every severity and produces no false positives on the real tree. The smoke must be 13/13 green before a team rollout.
69
+ `pipeline/scripts/smoke-skill-scan.sh` confirms the scanner triggers on positive fixtures for every severity and produces no false positives on the real tree. This is a maintainer-repo gate (smoke scripts are excluded from the npm package): it runs from a pipeline checkout and in CI, where it must be 13/13 green before a team rollout.
70
70
 
71
71
  ## Integration points
72
72
 
@@ -47,7 +47,8 @@ Multiple args union their plugin sets: `ios backend` → common + iOS + backend.
47
47
  - `question` (in `outputLanguage`): which stacks should be active in this repo, noting the currently enabled ones
48
48
  - `header`: "Stacks" (English, UI contract)
49
49
  - `options` (4): `ios` / `android` / `frontend (web)` / `backend`, each `description` (in `outputLanguage`) naming the plugin it enables and marking the ones already enabled with "(currently on)" / "(şu an açık)"
50
- - Empty selection or cancel → **status mode**: print the currently enabled `@multi-agent-plugins` plugins and exit without modifying anything.
50
+ - Map each selected label back to its canonical arg before step 2: the token before any parenthesis (`frontend (web)` → `frontend`).
51
+ - Empty selection or cancel → **status mode**: print the currently enabled `@multi-agent-plugins` plugins and exit without modifying anything. Status mode never runs the Implementation block below - that block is enable-mode only.
51
52
  2. **Arg(s) present → enable mode.** Accept multiple space-separated stacks. Resolve each through the alias table (`web`→`frontend`, `mobile`→`ios android`, `fullstack`→`frontend backend`, `all`→every stack), union the plugin sets, then write the **current repo's** `.claude/settings.json`.
52
53
  3. **Write rules:**
53
54
  - every plugin in the union is set to `true`
@@ -75,17 +76,26 @@ ANDROID="ai-android-toolkit@${MP}"
75
76
  FRONTEND="ai-frontend-toolkit@${MP}"
76
77
  BACKEND="ai-backend-toolkit@${MP}"
77
78
 
79
+ # Enable-mode only: with zero args the write rules below would set every stack
80
+ # toolkit to false (nothing but $COMMON is in $ON), silently wiping the repo's
81
+ # selection. No args belongs to the picker / status path (Behaviour step 1).
82
+ if [ "$#" -eq 0 ]; then
83
+ echo "No stack given - nothing changed. Pass stacks (ios backend ...) or use the picker."
84
+ exit 0
85
+ fi
86
+
78
87
  # resolve every arg through the alias table, union the ON set
79
88
  ON="$COMMON"
80
89
  for ARG in "$@"; do
81
90
  case "$ARG" in
82
- ios) ON="$ON $IOS" ;;
83
- android) ON="$ON $ANDROID" ;;
84
- frontend|web) ON="$ON $FRONTEND" ;;
85
- backend) ON="$ON $BACKEND" ;;
86
- mobile) ON="$ON $IOS $ANDROID" ;;
87
- fullstack) ON="$ON $FRONTEND $BACKEND" ;;
88
- all) ON="$ON $IOS $ANDROID $FRONTEND $BACKEND" ;;
91
+ ios) ON="$ON $IOS" ;;
92
+ android) ON="$ON $ANDROID" ;;
93
+ frontend|web) ON="$ON $FRONTEND" ;;
94
+ "frontend (web)") ON="$ON $FRONTEND" ;;
95
+ backend) ON="$ON $BACKEND" ;;
96
+ mobile) ON="$ON $IOS $ANDROID" ;;
97
+ fullstack) ON="$ON $FRONTEND $BACKEND" ;;
98
+ all) ON="$ON $IOS $ANDROID $FRONTEND $BACKEND" ;;
89
99
  *) echo "Unknown stack '$ARG'. One of: ios android mobile backend frontend web fullstack all"; exit 1 ;;
90
100
  esac
91
101
  done