@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
@@ -36,6 +36,6 @@
36
36
  "warn_tokens": 5600
37
37
  }
38
38
  },
39
- "total_max_tokens": 52750,
40
- "note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration. Total bumped 50000 -> 51000 at v12.5.0 after the worktree residue/traversal-prune contract (Phase 0 + Phase 5 heal) and the Reflexion causal-diagnosis contract (Phase 4 lesson memory) landed; the prose was compressed first (161 tokens reclaimed) and every per-phase max still passes - only the aggregate needed room. Recalibrated again at v13.6.0 after the install-relative path correction: an instruction that names `pipeline/scripts/x` resolves only from a repo checkout, and a run happens in the user's worktree, so 157 references across these docs moved to `$HOME/.claude/...` at +5 bytes each - 196 tokens of pure correctness cost. Same discipline as before: prose was compressed FIRST (149 tokens reclaimed, by pointing Phase 1's Figma tier table at the Phase 0 probe that already resolved it and Phase 4's Codex constraints at the always-loaded AGENTS.md block), and only then were the budgets moved. Five warn lines had been permanently amber, which makes the amber tier useless as a signal, so every warn was reset to the documented current+10% and the four maxes that the new warn would have collided with were reset to current+25%. Aggregate 51000 -> 51500. Total bumped 51500 -> 52200 at v14.0.0 after Phase 4 Review entered the four --dev mode phase sets and the criteria-resolution contract (Step 1.78) landed. Same discipline as every prior bump: prose was compressed FIRST, 820 tokens reclaimed, before the number moved. Two of those compressions are structural rather than cosmetic - the hardcoded SwiftUI interaction list in Step 1.5 and the SwiftUI convention paragraph in Step 2.8 were transcriptions of rules that now live in a scoped registry, so keeping them here would have re-created the drift this release exists to remove, and the third moved the Step 1.78 full contract into refs/features/skill-conformance.md leaving a pointer. What remains is contract text that cannot be inferred: the manifest's four consumer-visible parts, the conformance checklist the reviewers must return, and the fail-closed semantics. Every per-phase max still passes (phase-4 12405/14750); only the aggregate needed room. Total bumped 52200 -> 52700 at v14.1.0 after two more contracts landed: stack skill routing (Phase 3 pre-flight step 9) and worktree finalize (Phase 6 step 9). Compression came first, as always, and twice: 224 tokens out of Phase 3 by pointing its criteria-ledger and routing steps at their feature files instead of restating them, and 190 out of Phase 6 by moving the finalize contract into refs/features/worktree-finalize.md and leaving the invocation plus the exit-3 semantics. Both new contracts follow the pattern the earlier ones set: the phase doc carries the call and the decision, the feature file carries the reasoning, and the feature files are outside this budget because it loops only the eight phase-N-* keys. Every per-phase max still passes (phase-3 7677/8950, phase-6 5223/6150 and both under warn); only the aggregate needed room. Total bumped 52700 -> 52750 for the Phase 0 Step 3 branch-persistence correction: the step wrote the legacy `projects[].branches` while the TTL filter two sections below read `global.recentBranches`, and both spots named a `{name, lastUsed}` shape the schema rejects (`branch` required, `additionalProperties: false`), so the recent-branch picker option could never populate and a literal implementation would have failed prefs validation. Naming the right target, the right key and the legacy field to avoid costs 41 tokens over the one line it replaces. Compression came first and was applied three times to the replacement text itself, from 120 tokens down to 66, by moving the rationale out of the phase doc entirely: the reasoning now lives where it is enforced, in the migrate-prefs carry-forward comment and the smoke-pref-migration f7 block, leaving the phase doc with only the instruction. 50 was the smallest step that clears it; phase-0-init sits at 10893/12400, far under its own max, so this is purely an aggregate ceiling."
39
+ "total_max_tokens": 53100,
40
+ "note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration. Total bumped 50000 -> 51000 at v12.5.0 after the worktree residue/traversal-prune contract (Phase 0 + Phase 5 heal) and the Reflexion causal-diagnosis contract (Phase 4 lesson memory) landed; the prose was compressed first (161 tokens reclaimed) and every per-phase max still passes - only the aggregate needed room. Recalibrated again at v13.6.0 after the install-relative path correction: an instruction that names `pipeline/scripts/x` resolves only from a repo checkout, and a run happens in the user's worktree, so 157 references across these docs moved to `$HOME/.claude/...` at +5 bytes each - 196 tokens of pure correctness cost. Same discipline as before: prose was compressed FIRST (149 tokens reclaimed, by pointing Phase 1's Figma tier table at the Phase 0 probe that already resolved it and Phase 4's Codex constraints at the always-loaded AGENTS.md block), and only then were the budgets moved. Five warn lines had been permanently amber, which makes the amber tier useless as a signal, so every warn was reset to the documented current+10% and the four maxes that the new warn would have collided with were reset to current+25%. Aggregate 51000 -> 51500. Total bumped 51500 -> 52200 at v14.0.0 after Phase 4 Review entered the four --dev mode phase sets and the criteria-resolution contract (Step 1.78) landed. Same discipline as every prior bump: prose was compressed FIRST, 820 tokens reclaimed, before the number moved. Two of those compressions are structural rather than cosmetic - the hardcoded SwiftUI interaction list in Step 1.5 and the SwiftUI convention paragraph in Step 2.8 were transcriptions of rules that now live in a scoped registry, so keeping them here would have re-created the drift this release exists to remove, and the third moved the Step 1.78 full contract into refs/features/skill-conformance.md leaving a pointer. What remains is contract text that cannot be inferred: the manifest's four consumer-visible parts, the conformance checklist the reviewers must return, and the fail-closed semantics. Every per-phase max still passes (phase-4 12405/14750); only the aggregate needed room. Total bumped 52200 -> 52700 at v14.1.0 after two more contracts landed: stack skill routing (Phase 3 pre-flight step 9) and worktree finalize (Phase 6 step 9). Compression came first, as always, and twice: 224 tokens out of Phase 3 by pointing its criteria-ledger and routing steps at their feature files instead of restating them, and 190 out of Phase 6 by moving the finalize contract into refs/features/worktree-finalize.md and leaving the invocation plus the exit-3 semantics. Both new contracts follow the pattern the earlier ones set: the phase doc carries the call and the decision, the feature file carries the reasoning, and the feature files are outside this budget because it loops only the eight phase-N-* keys. Every per-phase max still passes (phase-3 7677/8950, phase-6 5223/6150 and both under warn); only the aggregate needed room. Total bumped 52700 -> 52750 for the Phase 0 Step 3 branch-persistence correction: the step wrote the legacy `projects[].branches` while the TTL filter two sections below read `global.recentBranches`, and both spots named a `{name, lastUsed}` shape the schema rejects (`branch` required, `additionalProperties: false`), so the recent-branch picker option could never populate and a literal implementation would have failed prefs validation. Naming the right target, the right key and the legacy field to avoid costs 41 tokens over the one line it replaces. Compression came first and was applied three times to the replacement text itself, from 120 tokens down to 66, by moving the rationale out of the phase doc entirely: the reasoning now lives where it is enforced, in the migrate-prefs carry-forward comment and the smoke-pref-migration f7 block, leaving the phase doc with only the instruction. 50 was the smallest step that clears it; phase-0-init sits at 10893/12400, far under its own max, so this is purely an aggregate ceiling. v15.0.0: total 52750 -> 53100, the stack-skill tables in phase-1/2/4 now carry plugin-namespaced names (ai-<stack>-toolkit:<skill>) - functional prefixes, ~170 tokens."
41
41
  }
@@ -0,0 +1,79 @@
1
+ // _stack-routing.mjs
2
+ //
3
+ // Single source of truth for "which marketplace plugin carries this external
4
+ // skill". Extracted from build-stack-plugins.mjs so the skills-index builder can
5
+ // stamp a `plugin` field per entry without duplicating the routing table - a
6
+ // second copy would drift the first time a pattern changed, and the index would
7
+ // namespace skills into a plugin that no longer carries them.
8
+ //
9
+ // Routing order is load-bearing and mirrors build-stack-plugins step 2:
10
+ // COMMON_SKILLS first (explicit allowlist, wins over any pattern), then
11
+ // APPLE_ONLY (Apple/Xcode-only names no pattern catches), then STACK_PATTERNS.
12
+ // A skill may match several stack patterns; build-stack-plugins ships it to all
13
+ // of them and --check-routing polices accidental multi-routing. routeSkill()
14
+ // returns the full match list so callers can decide.
15
+
16
+ export const COMMON_PLUGIN = "ai-common-toolkit";
17
+
18
+ export const COMMON_SKILLS = [
19
+ "accessibility-compliance-accessibility-audit",
20
+ "firebase",
21
+ "humanizer",
22
+ "council",
23
+ "search-first",
24
+ "agent-introspection-debugging",
25
+ "skill-creator",
26
+ "backlog",
27
+ "localization-reuse-map",
28
+ ];
29
+
30
+ // Apple/Xcode-only skills that match no stack pattern → iOS plugin only.
31
+ export const APPLE_ONLY = [
32
+ "avkit",
33
+ "cryptokit",
34
+ "metrickit-diagnostics",
35
+ "pdfkit",
36
+ "spm-build-analysis",
37
+ "swiftlint",
38
+ "xcode-build-benchmark",
39
+ "xcode-build-fixer",
40
+ "xcode-build-orchestrator",
41
+ "xcode-compilation-analyzer",
42
+ "xcode-project-analyzer",
43
+ ];
44
+
45
+ // stack → matching pattern (per-stack routing patterns (formerly in stack-swap.sh))
46
+ //
47
+ // A skill lands in every plugin whose pattern it matches, so a bare substring
48
+ // alternative that is also a suffix of another stack's skill name silently ships
49
+ // that skill twice. `architecture` did exactly that: it pulled
50
+ // `android-architecture` and `swift-architecture` into ai-backend-toolkit
51
+ // alongside their correct plugins, so a backend project loading the toolkit got
52
+ // Compose and SwiftUI architecture skills. It is anchored to the exact name now.
53
+ // Keep any new single-word alternative anchored unless it genuinely reads as a
54
+ // substring across stacks.
55
+ export const STACK_PATTERNS = {
56
+ "ai-ios-toolkit":
57
+ /(swiftui|swift-|ios-|hig-|apple-|storekit|widgetkit|healthkit|homekit|mapkit|musickit|passkit|pencilkit|realitykit|weatherkit|alarmkit|callkit|cloudkit|coreml|core-|eventkit|energykit|permissionkit|tipkit|shareplay|live-activities|background-processing|app-store|app-clips|app-intents|authentication|contacts-framework|device-integrity|macos-|natural-language|photos-camera|push-notifications|speech-recognition|swiftdata|vision-framework|debugging-instruments|help-skills)/i,
58
+ "ai-android-toolkit":
59
+ /(android|compose-|kotlin-|room-database|retrofit-|gradle-|play-store)/i,
60
+ "ai-backend-toolkit":
61
+ /(fastapi|nodejs|docker|api-pattern|api-security|github-actions|observability|^architecture$|monorepo|clean-code|debugging-strategies|agentflow|closed-loop|context-compression|python-patterns|database-patterns|rest-api-design|testing-backend|ci-cd-pipelines)/i,
62
+ "ai-frontend-toolkit":
63
+ /(react-|nextjs-|typescript-|tailwind-|vue-|web-accessibility|web-performance|css-modern|web-testing|html-semantic)/i,
64
+ };
65
+
66
+ /**
67
+ * Which plugins carry this external skill, in routing order.
68
+ * @param {string} name - skill directory name
69
+ * @returns {string[]} plugin names; empty when unrouted
70
+ */
71
+ export function routeSkill(name) {
72
+ if (COMMON_SKILLS.includes(name)) return [COMMON_PLUGIN];
73
+ if (APPLE_ONLY.includes(name)) return ["ai-ios-toolkit"];
74
+ const hits = [];
75
+ for (const [plugin, re] of Object.entries(STACK_PATTERNS)) {
76
+ if (re.test(name)) hits.push(plugin);
77
+ }
78
+ return hits;
79
+ }
@@ -57,7 +57,10 @@ if [ -s "$AUDIT_FILE" ]; then
57
57
  # event appended between the copy and the truncate.
58
58
  SPOOL="$AUDIT_DIR/.audit.rotate.$$"
59
59
  mv "$AUDIT_FILE" "$SPOOL"
60
- : > "$AUDIT_FILE"
60
+ # touch, not truncate: a writer appending between the mv and this line
61
+ # recreates the live file, and `: >` would destroy that event. touch keeps
62
+ # whatever a racing writer already appended.
63
+ touch "$AUDIT_FILE"
61
64
  chmod 600 "$AUDIT_FILE" 2>/dev/null || true
62
65
 
63
66
  # Don't clobber a rotated file from earlier today (multiple invocations).
@@ -26,6 +26,7 @@
26
26
  import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
27
27
  import { dirname, join, relative, resolve } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
+ import { routeSkill } from "./_stack-routing.mjs";
29
30
 
30
31
  const here = dirname(fileURLToPath(import.meta.url));
31
32
  const repoRoot = resolve(here, "..", "..");
@@ -92,11 +93,21 @@ for (const full of walkSkills(root)) {
92
93
  .map((s) => s.trim())
93
94
  .filter(Boolean);
94
95
 
96
+ // External skills reach Claude Code only through the marketplace plugins, so
97
+ // the index carries which plugin owns each one. `invokeAs` is the name a
98
+ // consumer must use: plugin-namespaced on Claude Code, bare elsewhere (the
99
+ // Copilot/Codex copies keep bare directory names). Routing comes from
100
+ // _stack-routing.mjs - the same table build-stack-plugins ships with.
101
+ const plugins = group === "external" ? routeSkill(name) : [];
102
+ const plugin = plugins[0] || null;
103
+
95
104
  entries.push({
96
105
  name,
97
106
  description,
98
107
  platform,
99
108
  group,
109
+ plugin,
110
+ invokeAs: plugin ? `${plugin}:${name}` : name,
100
111
  triggerKeywords: kws,
101
112
  triggerPaths: paths,
102
113
  relativePath: rel,
@@ -14,7 +14,7 @@
14
14
  * knowledge entries are regenerated.
15
15
  *
16
16
  * Routing uses the per-stack skill patterns (formerly carried by stack-swap.sh). Genuinely cross-stack
17
- * skills go to ai-common-engineering-toolkit (not into each stack plugin). Apple/
17
+ * skills go to ai-common-toolkit (not into each stack plugin). Apple/
18
18
  * Xcode-only vendored skills that match no stack pattern belong to iOS only.
19
19
  *
20
20
  * Usage:
@@ -34,6 +34,7 @@ import {
34
34
  } from "node:fs";
35
35
  import { join } from "node:path";
36
36
  import { spawnSync } from "node:child_process";
37
+ import { createHash } from "node:crypto";
37
38
 
38
39
  const args = process.argv.slice(2);
39
40
  // Accepts both `--key value` and `--key=value`. The `=` form used to fall through to
@@ -51,54 +52,12 @@ const PIPE_ROOT = getArg("--pipeline", join(HOME, "multi-agent-pipeline"));
51
52
  const EXTERNAL = join(PIPE_ROOT, "pipeline/skills/shared/external");
52
53
  const DRY = args.includes("--dry-run");
53
54
 
54
- const COMMON_PLUGIN = "ai-common-engineering-toolkit";
55
- const COMMON_SKILLS = [
56
- "accessibility-compliance-accessibility-audit",
57
- "firebase",
58
- "humanizer",
59
- "council",
60
- "search-first",
61
- "agent-introspection-debugging",
62
- "skill-creator",
63
- "backlog",
64
- "localization-reuse-map",
65
- ];
66
-
67
- // Apple/Xcode-only skills that match no stack pattern → iOS plugin only.
68
- const APPLE_ONLY = [
69
- "avkit",
70
- "cryptokit",
71
- "metrickit-diagnostics",
72
- "pdfkit",
73
- "spm-build-analysis",
74
- "swiftlint",
75
- "xcode-build-benchmark",
76
- "xcode-build-fixer",
77
- "xcode-build-orchestrator",
78
- "xcode-compilation-analyzer",
79
- "xcode-project-analyzer",
80
- ];
81
-
82
- // stack → matching pattern (per-stack routing patterns (formerly in stack-swap.sh))
83
- //
84
- // A skill lands in every plugin whose pattern it matches, so a bare substring
85
- // alternative that is also a suffix of another stack's skill name silently ships
86
- // that skill twice. `architecture` did exactly that: it pulled
87
- // `android-architecture` and `swift-architecture` into ai-backend-toolkit
88
- // alongside their correct plugins, so a backend project loading the toolkit got
89
- // Compose and SwiftUI architecture skills. It is anchored to the exact name now.
90
- // Keep any new single-word alternative anchored unless it genuinely reads as a
91
- // substring across stacks.
92
- const STACK_PATTERNS = {
93
- "ai-ios-engineering-toolkit":
94
- /(swiftui|swift-|ios-|hig-|apple-|storekit|widgetkit|healthkit|homekit|mapkit|musickit|passkit|pencilkit|realitykit|weatherkit|alarmkit|callkit|cloudkit|coreml|core-|eventkit|energykit|permissionkit|tipkit|shareplay|live-activities|background-processing|app-store|app-clips|app-intents|authentication|contacts-framework|device-integrity|macos-|natural-language|photos-camera|push-notifications|speech-recognition|swiftdata|vision-framework|debugging-instruments|help-skills)/i,
95
- "ai-android-engineering-toolkit":
96
- /(android|compose-|kotlin-|room-database|retrofit-|gradle-|play-store)/i,
97
- "ai-backend-toolkit":
98
- /(fastapi|nodejs|docker|api-pattern|api-security|github-actions|observability|^architecture$|monorepo|clean-code|debugging-strategies|agentflow|closed-loop|context-compression|python-patterns|database-patterns|rest-api-design|testing-backend|ci-cd-pipelines)/i,
99
- "ai-frontend-engineering-toolkit":
100
- /(react-|nextjs-|typescript-|tailwind-|vue-|web-accessibility|web-performance|css-modern|web-testing|html-semantic)/i,
101
- };
55
+ import {
56
+ COMMON_PLUGIN,
57
+ COMMON_SKILLS,
58
+ APPLE_ONLY,
59
+ STACK_PATTERNS,
60
+ } from "./_stack-routing.mjs";
102
61
 
103
62
  const isDir = (p) => {
104
63
  try {
@@ -110,18 +69,23 @@ const isDir = (p) => {
110
69
  const listDirs = (p) => (existsSync(p) ? readdirSync(p).filter((n) => isDir(join(p, n))) : []);
111
70
 
112
71
  /**
113
- * Cheap content fingerprint for a skill directory: every file's relative path and
114
- * size. Enough to notice an edit without hashing megabytes on every run, and it is
115
- * only used to decide whether a version bump is warranted.
72
+ * Content fingerprint for a skill directory: every file's relative path and a
73
+ * hash of its bytes. A size-only fingerprint missed same-length edits, so a
74
+ * content update could ship without a version bump. The whole authoring tree
75
+ * is a few MB of markdown; hashing it per run costs milliseconds and is only
76
+ * used to decide whether a copy + version bump is warranted.
116
77
  */
117
78
  function treeFingerprint(dir) {
118
79
  const parts = [];
119
80
  const walk = (d, rel) => {
120
- for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
81
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) =>
82
+ a.name.localeCompare(b.name),
83
+ )) {
121
84
  const full = join(d, e.name);
122
85
  const r = rel ? `${rel}/${e.name}` : e.name;
123
86
  if (e.isDirectory()) walk(full, r);
124
- else if (e.isFile()) parts.push(`${r}:${statSync(full).size}`);
87
+ else if (e.isFile())
88
+ parts.push(`${r}:${createHash("sha1").update(readFileSync(full)).digest("hex")}`);
125
89
  }
126
90
  };
127
91
  try {
@@ -156,7 +120,7 @@ for (const skill of allSkills) {
156
120
  continue;
157
121
  }
158
122
  if (APPLE_ONLY.includes(skill)) {
159
- desired["ai-ios-engineering-toolkit"].add(skill);
123
+ desired["ai-ios-toolkit"].add(skill);
160
124
  continue;
161
125
  }
162
126
  let matched = false;
@@ -218,7 +182,8 @@ for (const [plugin, want] of Object.entries(desired)) {
218
182
  const toRemove = [...have].filter((s) => !want.has(s)).sort();
219
183
  const setChanged = toAdd.length > 0 || toRemove.length > 0;
220
184
 
221
- // Re-copy EVERY wanted skill, not just the newly added ones.
185
+ // Compare EVERY wanted skill against the source, not just the newly added
186
+ // ones, and replace (rm + copy) rather than overlay.
222
187
  //
223
188
  // This used to copy only on a set change, which quietly broke the single-source
224
189
  // claim: editing a skill in shared/external left the plugin serving the old text
@@ -226,17 +191,20 @@ for (const [plugin, want] of Object.entries(desired)) {
226
191
  // seeing the plugin copy stay stale while the generator reported "all plugins up
227
192
  // to date".
228
193
  //
229
- // cpSync overwrites, so a full re-copy is idempotent and cheap relative to a
230
- // publish. Removals still need the explicit delete.
194
+ // The rm-before-copy matters too: cpSync overlays and never deletes, so a file
195
+ // REMOVED inside a still-wanted skill survived in every plugin copy forever -
196
+ // and because the overlay kept the orphan on both sides of the fingerprint
197
+ // comparison, the version was not bumped either.
231
198
  let contentChanged = false;
232
199
  if (!DRY) {
233
200
  if (!existsSync(kdir)) mkdirSync(kdir, { recursive: true });
234
201
  for (const s of want) {
235
202
  const from = join(EXTERNAL, s);
236
203
  const to = join(kdir, s);
237
- const before = existsSync(to) ? treeFingerprint(to) : "";
204
+ if (treeFingerprint(from) === (existsSync(to) ? treeFingerprint(to) : "")) continue;
205
+ rmSync(to, { recursive: true, force: true });
238
206
  cpSync(from, to, { recursive: true });
239
- if (treeFingerprint(to) !== before) contentChanged = true;
207
+ contentChanged = true;
240
208
  }
241
209
  for (const s of toRemove) rmSync(join(kdir, s), { recursive: true, force: true });
242
210
  }
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { Buffer } from "node:buffer";
26
26
  import { execFileSync } from "node:child_process";
27
- import { existsSync, readFileSync, readdirSync } from "node:fs";
27
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
30
 
@@ -72,29 +72,44 @@ function versionFrom(manifest, pluginName) {
72
72
  function fromLocalClone(entry) {
73
73
  if (!entry.upstreamLocalClone) return null;
74
74
  const root = expandHome(entry.upstreamLocalClone);
75
- const file =
75
+ const relFile =
76
76
  entry.upstreamVersionSource === "plugin.json"
77
- ? join(root, "plugins", entry.upstreamPlugin, ".claude-plugin", "plugin.json")
78
- : join(root, ".claude-plugin", "marketplace.json");
79
- if (!existsSync(file)) return null;
80
- const version = versionFrom(readJson(file), entry.upstreamPlugin);
81
- if (!version) return null;
77
+ ? `plugins/${entry.upstreamPlugin}/.claude-plugin/plugin.json`
78
+ : ".claude-plugin/marketplace.json";
82
79
 
83
- // A clone behind its own remote is still a stale answer, just a less stale
84
- // one than the cache. Say so rather than presenting it as current.
85
- let behind;
80
+ // Read the manifest from origin/main, never the checked-out worktree: a
81
+ // clone parked on an old feature branch reported stale versions as current,
82
+ // flagging correctly-bumped derivations as drifted.
86
83
  try {
87
- const out = execFileSync("git", ["-C", root, "rev-list", "--count", "HEAD..@{u}"], {
84
+ const raw = execFileSync("git", ["-C", root, "show", `origin/main:${relFile}`], {
88
85
  encoding: "utf-8",
89
86
  stdio: ["ignore", "pipe", "ignore"],
90
- }).trim();
91
- behind = Number(out) || 0;
87
+ });
88
+ const version = versionFrom(JSON.parse(raw), entry.upstreamPlugin);
89
+ if (version) {
90
+ // origin/main is only as fresh as the last fetch. Surface fetch age so a
91
+ // months-old ref is not silently trusted.
92
+ let fetchAgeDays = null;
93
+ try {
94
+ const fetchHead = join(root, ".git", "FETCH_HEAD");
95
+ if (existsSync(fetchHead)) {
96
+ fetchAgeDays = Math.floor((Date.now() - statSync(fetchHead).mtimeMs) / 86_400_000);
97
+ }
98
+ } catch {
99
+ fetchAgeDays = null;
100
+ }
101
+ return { version, source: "local clone origin/main", authoritative: true, fetchAgeDays };
102
+ }
92
103
  } catch {
93
- // No upstream branch configured, or not a git checkout at all.
94
- behind = null;
104
+ // No origin/main ref (not a git checkout, or unfetched): fall through to
105
+ // the worktree file, which is better than nothing but never fresher.
95
106
  }
96
107
 
97
- return { version, source: "local clone", authoritative: true, behind };
108
+ const file = join(root, relFile);
109
+ if (!existsSync(file)) return null;
110
+ const version = versionFrom(readJson(file), entry.upstreamPlugin);
111
+ if (!version) return null;
112
+ return { version, source: "local clone worktree", authoritative: true, fetchAgeDays: null };
98
113
  }
99
114
 
100
115
  function fromRepoApi(entry) {
@@ -161,46 +176,55 @@ for (const entry of entries) {
161
176
  continue;
162
177
  }
163
178
  const drifted = resolved.version !== entry.derivedFromVersion;
164
- const status = drifted
165
- ? "drift"
166
- : resolved.authoritative
167
- ? "up to date"
168
- : "unverified (cache only)";
179
+ // An acknowledged drift is a conscious "port pending" decision, pinned to the
180
+ // upstream version it was made against: it stays visible but does not fail
181
+ // the gate, and the moment upstream moves past the pin it is plain drift
182
+ // again.
183
+ const acknowledged = drifted && entry.driftAcknowledged === resolved.version;
184
+ const status = acknowledged
185
+ ? "drift (acknowledged)"
186
+ : drifted
187
+ ? "drift"
188
+ : resolved.authoritative
189
+ ? "up to date"
190
+ : "unverified (cache only)";
169
191
  rows.push({
170
192
  label: entry.label,
171
193
  plugin: entry.upstreamPlugin,
172
194
  from: entry.derivedFromVersion,
173
195
  upstream: resolved.version,
174
196
  source: resolved.source,
175
- cloneBehind: resolved.behind,
197
+ fetchAgeDays: resolved.fetchAgeDays ?? null,
176
198
  status,
177
199
  });
178
200
  }
179
201
 
180
202
  const drifted = rows.filter((r) => r.status === "drift").length;
203
+ const acknowledged = rows.filter((r) => r.status === "drift (acknowledged)").length;
181
204
  const unverified = rows.filter((r) => r.status.startsWith("unverified")).length;
182
205
  const unreachable = rows.filter((r) => r.status === "unreachable").length;
183
206
 
184
207
  if (asJson) {
185
208
  console.log(
186
209
  JSON.stringify(
187
- { configured: rows.length, drifted, unverified, unreachable, entries: rows },
210
+ { configured: rows.length, drifted, acknowledged, unverified, unreachable, entries: rows },
188
211
  null,
189
212
  2,
190
213
  ),
191
214
  );
192
215
  } else {
193
216
  for (const r of rows) {
194
- const behind = r.cloneBehind ? ` [clone ${r.cloneBehind} behind its remote]` : "";
217
+ const age =
218
+ r.fetchAgeDays != null && r.fetchAgeDays > 7 ? ` [last fetch ${r.fetchAgeDays}d ago]` : "";
195
219
  console.log(
196
- ` ${r.status === "up to date" ? "✓" : "✗"} ${r.plugin}: recorded ${r.from}, ` +
197
- `upstream ${r.upstream ?? "?"} (${r.source})${behind} -> ${r.status}`,
220
+ ` ${r.status === "up to date" ? "✓" : r.status === "drift (acknowledged)" ? "!" : "✗"} ${r.plugin}: recorded ${r.from}, ` +
221
+ `upstream ${r.upstream ?? "?"} (${r.source})${age} -> ${r.status}`,
198
222
  );
199
223
  }
200
224
  console.log("");
201
225
  console.log(
202
- `══ check-derived-drift: ${rows.length - drifted - unverified - unreachable} passed, ` +
203
- `${drifted + unverified + unreachable} failed ══`,
226
+ `══ check-derived-drift: ${rows.length - drifted - unverified - unreachable} passed ` +
227
+ `(${acknowledged} acknowledged), ${drifted + unverified + unreachable} failed ══`,
204
228
  );
205
229
  }
206
230
 
@@ -125,7 +125,10 @@ done
125
125
 
126
126
  # 3. Gitlink residue in the index ("Subproject commit" entries under
127
127
  # .worktrees/ from a pre-guard `git add -A`).
128
- gitlinks=$(git -C "$REPO" ls-files -s -- .worktrees 2>/dev/null | awk '$1 == "160000" { print $4 }')
128
+ # Path is the tab-separated second field ("mode sha stage\tpath"); splitting on
129
+ # whitespace truncated any path containing a space and the follow-up
130
+ # `git rm --ignore-unmatch` exited 0 on the phantom, reporting a false success.
131
+ gitlinks=$(git -C "$REPO" ls-files -s -- .worktrees 2>/dev/null | awk -F'\t' '$1 ~ /^160000 / { print $2 }')
129
132
  if [ -n "$gitlinks" ]; then
130
133
  while IFS= read -r path; do
131
134
  [ -z "$path" ] && continue
@@ -82,7 +82,7 @@ md += `**Total:** ${total} skills (${breakdown}). Auto-generated by \`scripts/ge
82
82
  md += `## Directory layout\n\n`;
83
83
  md += `- **\`core/\`** - ${counts.core} \`multi-agent*\` orchestration skills that are pipeline-critical. Edits here are core-code changes.\n`;
84
84
  md += `- **\`external/\`** - ${counts.external} iOS / Android / generic skills imported from the upstream skill library. Mirrors of third-party guidance.\n`;
85
- md += `- Install destination stays flat: both trees flatten into \`~/.claude/skills/\` and \`~/.copilot/skills/\`. See ADR-0006 for the rationale.\n\n`;
85
+ md += `- Install destinations (ADR-0009): Claude Code gets NO local copy of \`external/\` - it loads those skills from the \`multi-agent-plugins\` marketplace, namespaced (\`ai-<stack>-toolkit:<name>\`); only the two compliance catalogs from \`core/\` land in \`~/.claude/skills/\`. Copilot CLI and Codex CLI receive a flat copy filtered to the enabled stacks. \`external/\` remains the single authoring source that \`build-stack-plugins.mjs\` publishes from.\n\n`;
86
86
  md += `Source layout is logical grouping only - skill discovery at runtime is unchanged.\n\n`;
87
87
  md += `## Categories\n\n`;
88
88
  for (const k of order) {
@@ -202,13 +202,19 @@ for (const entry of index.entries) {
202
202
  results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
203
203
  const top = results.slice(0, opts.limit);
204
204
 
205
+ // On Claude Code an external skill resolves only through its marketplace plugin,
206
+ // so surface `invokeAs` (plugin-namespaced) as the primary name. Old indexes
207
+ // without the field fall back to the bare name - same behaviour as before.
205
208
  if (opts.json) {
206
- process.stdout.write(JSON.stringify({ task: taskText, matches: top }, null, 2) + "\n");
209
+ const matches = top.map((m) => ({ ...m, invokeAs: m.invokeAs || m.name }));
210
+ process.stdout.write(JSON.stringify({ task: taskText, matches }, null, 2) + "\n");
207
211
  } else {
208
212
  for (const m of top) {
209
213
  const tag = `[${m.group}${m.platform ? "/" + m.platform : ""}]`;
210
214
  const desc = (m.description || "").slice(0, 80);
211
- console.log(`${String(m.score).padStart(3)} ${m.name.padEnd(32)} ${tag.padEnd(18)} ${desc}`);
215
+ console.log(
216
+ `${String(m.score).padStart(3)} ${(m.invokeAs || m.name).padEnd(46)} ${tag.padEnd(18)} ${desc}`,
217
+ );
212
218
  }
213
219
  if (top.length === 0) console.log("(no skills matched)");
214
220
  }
@@ -13,12 +13,13 @@
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import os from "node:os";
16
+ import { fileURLToPath } from "node:url";
16
17
 
17
18
  const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences.json");
18
19
 
19
20
  // Single source of truth for the migration target version. Referenced by the
20
21
  // migrate() bump chain AND every user-facing message, so they cannot drift.
21
- const TARGET_VERSION = "2.5.0";
22
+ const TARGET_VERSION = "2.6.0";
22
23
 
23
24
  const USAGE = "Usage: migrate-prefs.mjs [--dry-run] [--file <path>]";
24
25
 
@@ -75,7 +76,7 @@ function defaultSettings() {
75
76
  * @returns {Set<string>}
76
77
  */
77
78
  function readMigratableVersions(target) {
78
- const here = path.dirname(new URL(import.meta.url).pathname);
79
+ const here = path.dirname(fileURLToPath(import.meta.url));
79
80
  const candidates = [
80
81
  path.join(here, "..", "schemas", "prefs.schema.json"),
81
82
  path.join(here, "..", "..", "pipeline", "schemas", "prefs.schema.json"),
@@ -297,7 +298,9 @@ function migrate(prefs) {
297
298
  seeded += 1;
298
299
  }
299
300
  if (seeded > 0) {
300
- changes.push(`seeded ${seeded} recentBranches entr${seeded === 1 ? "y" : "ies"} for ${projectKey} from legacy projects[].branches`);
301
+ changes.push(
302
+ `seeded ${seeded} recentBranches entr${seeded === 1 ? "y" : "ies"} for ${projectKey} from legacy projects[].branches`,
303
+ );
301
304
  }
302
305
  }
303
306
  if (!Array.isArray(out.global.recentGroups)) {
@@ -332,27 +335,32 @@ function migrate(prefs) {
332
335
  out.global.skillConformance.blockOnCoverageGap = false;
333
336
  changes.push("added skillConformance.blockOnCoverageGap=false (report, do not halt)");
334
337
  }
335
- // v2.5.0: /multi-agent:finish became /multi-agent:ship, and its autoFix key -
336
- // referenced by the command spec since it shipped but never declared in the
337
- // schema - is declared here. Carry any value the user had rather than reset it.
338
- if (!out.global.ship || typeof out.global.ship !== "object") {
339
- out.global.ship = {};
340
- changes.push("added ship (v2.5.0 - /multi-agent:finish renamed to :ship)");
341
- }
342
- if (typeof out.global.ship.autoFix !== "boolean") {
343
- const legacyAutoFix = out.global.finish?.autoFix;
344
- out.global.ship.autoFix = typeof legacyAutoFix === "boolean" ? legacyAutoFix : false;
338
+ // Rename chain for the pipeline-tail command: /multi-agent:finish (pre-v2.5.0)
339
+ // -> :ship (v2.5.0) -> :resume-local (current). The autoFix value survives every
340
+ // hop - carry whichever legacy block still holds one rather than reset it.
341
+ if (!out.global.resumeLocal || typeof out.global.resumeLocal !== "object") {
342
+ out.global.resumeLocal = {};
343
+ changes.push("added resumeLocal (/multi-agent:ship renamed to :resume-local)");
344
+ }
345
+ if (typeof out.global.resumeLocal.autoFix !== "boolean") {
346
+ const legacyAutoFix =
347
+ typeof out.global.ship?.autoFix === "boolean"
348
+ ? out.global.ship.autoFix
349
+ : out.global.finish?.autoFix;
350
+ out.global.resumeLocal.autoFix = typeof legacyAutoFix === "boolean" ? legacyAutoFix : false;
345
351
  changes.push(
346
352
  typeof legacyAutoFix === "boolean"
347
- ? "moved finish.autoFix -> ship.autoFix (value preserved)"
348
- : "added ship.autoFix=false",
353
+ ? "moved ship/finish autoFix -> resumeLocal.autoFix (value preserved)"
354
+ : "added resumeLocal.autoFix=false",
349
355
  );
350
356
  }
351
- if (out.global.finish && typeof out.global.finish === "object") {
352
- delete out.global.finish.autoFix;
353
- if (Object.keys(out.global.finish).length === 0) {
354
- delete out.global.finish;
355
- changes.push("removed obsolete finish block (renamed to ship)");
357
+ for (const legacy of ["ship", "finish"]) {
358
+ if (out.global[legacy] && typeof out.global[legacy] === "object") {
359
+ delete out.global[legacy].autoFix;
360
+ if (Object.keys(out.global[legacy]).length === 0) {
361
+ delete out.global[legacy];
362
+ changes.push(`removed obsolete ${legacy} block (renamed to resumeLocal)`);
363
+ }
356
364
  }
357
365
  }
358
366
  if (!out.global.serviceStatus) {
@@ -382,6 +382,9 @@ load_state() {
382
382
  fi
383
383
  }
384
384
  save_state() {
385
+ # Refuse to persist an empty document: every caller pipes jq output in, and
386
+ # a failed jq yields "" - writing that would destroy the whole tracker state.
387
+ [ -n "$1" ] || { echo "save_state: refusing to write empty state" >&2; return 65; }
385
388
  mkdir -p "$TRACKER_DIR" 2>/dev/null
386
389
  printf '%s\n' "$1" > "${TRACKER_FILE}.tmp"
387
390
  mv "${TRACKER_FILE}.tmp" "$TRACKER_FILE"
@@ -693,7 +696,7 @@ case "$ACTION" in
693
696
  state=$(load_state)
694
697
  # Idempotent: a phase id that already exists is a no-op (name, status, and
695
698
  # token history are preserved). This is what lets continuation commands
696
- # (/multi-agent:ship, resume) re-declare their phase set against a
699
+ # (/multi-agent:resume-local, resume) re-declare their phase set against a
697
700
  # pre-existing tracker without duplicating or resetting tiles.
698
701
  new=$(echo "$state" | jq --arg id "$PID" --arg name "$PNAME" '
699
702
  if any(.phases[]?; .id == $id) then .
@@ -766,9 +769,13 @@ case "$ACTION" in
766
769
  [ "$#" -ge 3 ] || { echo "tokens needs <phase_id> <in> <out> [cached]" >&2; exit 64; }
767
770
  PID="$1"; T_IN="$2"; T_OUT="$3"; T_CACHED="${4:-0}"
768
771
  # Validate integers (cached is optional, defaults to 0 for back-compat).
769
- case "$T_IN$T_OUT$T_CACHED" in
770
- *[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
771
- esac
772
+ # Each field checked separately: concatenation would let an empty field
773
+ # pass and feed jq --argjson an empty string, nuking the state file.
774
+ for v in "$T_IN" "$T_OUT" "$T_CACHED"; do
775
+ case "$v" in
776
+ ''|*[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
777
+ esac
778
+ done
772
779
  acquire_state_lock
773
780
  state=$(load_state)
774
781
  # Additive: add to existing totals so multiple LLM calls accumulate.
@@ -819,7 +826,8 @@ case "$ACTION" in
819
826
  )')
820
827
  save_state "$new"
821
828
  release_state_lock
822
- emit_otel_span "phase.meta" "$PID" "$KEY" "{\"meta_key\": \"$KEY\", \"meta_value\": \"$VALUE\"}"
829
+ META_EXTRA=$(jq -nc --arg k "$KEY" --arg v "$VALUE" '{meta_key: $k, meta_value: $v}')
830
+ emit_otel_span "phase.meta" "$PID" "$KEY" "$META_EXTRA"
823
831
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
824
832
  render
825
833
  fi
@@ -76,7 +76,8 @@ export function inputTextOf(state) {
76
76
  // evidence[].url / figmaFrames[] are where the analysis phase parks references.
77
77
  for (const key of ["figmaFrames", "designRefs"]) {
78
78
  const v = state[key];
79
- if (Array.isArray(v)) parts.push(v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" "));
79
+ if (Array.isArray(v))
80
+ parts.push(v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(" "));
80
81
  }
81
82
  return parts.join("\n");
82
83
  }
@@ -198,7 +199,7 @@ function main(argv) {
198
199
  const args = argv.slice(2);
199
200
  const taskId = args.find((a) => !a.startsWith("--"));
200
201
  if (!taskId) {
201
- console.error("usage: phase0-exit-gate.mjs <task_id> [--input \"<text>\"] [--json]");
202
+ console.error('usage: phase0-exit-gate.mjs <task_id> [--input "<text>"] [--json]');
202
203
  return 2;
203
204
  }
204
205
  const inputIdx = args.indexOf("--input");