@mmerterden/multi-agent-pipeline 14.2.1 → 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 (184) hide show
  1. package/CHANGELOG.md +118 -6
  2. package/README.md +20 -9
  3. package/README.tr.md +150 -0
  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 +24 -9
  9. package/docs/ecosystem.md +237 -0
  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 +33 -8
  17. package/install/claude.mjs +144 -59
  18. package/install/codex.mjs +37 -7
  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 +15 -12
  23. package/package.json +1 -2
  24. package/pipeline/commands/multi-agent/SKILL.md +4 -2
  25. package/pipeline/commands/multi-agent/analysis/SKILL.md +5 -5
  26. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
  27. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  28. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +9 -9
  29. package/pipeline/commands/multi-agent/channels/SKILL.md +16 -5
  30. package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +186 -0
  31. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  32. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  33. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  34. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  35. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  36. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  37. package/pipeline/commands/multi-agent/help/SKILL.md +19 -4
  38. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
  39. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  40. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  41. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  42. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  43. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  44. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +81 -0
  45. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
  46. package/pipeline/commands/multi-agent/{ship → resume-local}/SKILL.md +13 -9
  47. package/pipeline/commands/multi-agent/setup/SKILL.md +5 -5
  48. package/pipeline/commands/multi-agent/stack/SKILL.md +55 -43
  49. package/pipeline/commands/multi-agent/store-ready/SKILL.md +3 -3
  50. package/pipeline/commands/multi-agent/sync/SKILL.md +18 -11
  51. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
  52. package/pipeline/commands/multi-agent/uninstall/SKILL.md +2 -0
  53. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  54. package/pipeline/lib/extract-conventions.sh +44 -15
  55. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  56. package/pipeline/lib/fetch-fortify.sh +23 -8
  57. package/pipeline/lib/figma-screenshot.sh +11 -1
  58. package/pipeline/lib/issue-fetcher.sh +77 -10
  59. package/pipeline/lib/md2confluence-v3.py +16 -2
  60. package/pipeline/lib/parse-complaints.sh +306 -0
  61. package/pipeline/lib/plan-todos.sh +5 -2
  62. package/pipeline/lib/post-pr-review.sh +8 -6
  63. package/pipeline/lib/shadow-git.sh +50 -9
  64. package/pipeline/lib/submodule-detector.sh +8 -1
  65. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  66. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  67. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  68. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  69. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  70. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  71. package/pipeline/multi-agent-refs/channels/wiki.md +4 -4
  72. package/pipeline/multi-agent-refs/complaint-analysis-template.md +99 -0
  73. package/pipeline/multi-agent-refs/component-dispatch.md +6 -6
  74. package/pipeline/multi-agent-refs/cross-cli-contract.md +16 -16
  75. package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
  76. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +5 -5
  77. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  78. package/pipeline/multi-agent-refs/generate-issue.md +3 -3
  79. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  80. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  81. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  82. package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -2
  83. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +7 -7
  84. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +5 -5
  85. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +3 -3
  86. package/pipeline/multi-agent-refs/phases/phase-4-review.md +12 -12
  87. package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
  88. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  89. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  90. package/pipeline/multi-agent-refs/phases.md +6 -0
  91. package/pipeline/multi-agent-refs/rules.md +2 -0
  92. package/pipeline/multi-agent-refs/tracker-contract.md +1 -1
  93. package/pipeline/multi-agent-refs/wiki-capture.md +2 -2
  94. package/pipeline/preferences-template.json +13 -5
  95. package/pipeline/rules/figma-pipeline.md +2 -2
  96. package/pipeline/schemas/agent-state.schema.json +1 -1
  97. package/pipeline/schemas/complaint-analysis-spec.schema.json +216 -0
  98. package/pipeline/schemas/migrations/prefs-2.5.0-to-2.6.0.mjs +46 -0
  99. package/pipeline/schemas/prefs.schema.json +277 -67
  100. package/pipeline/schemas/token-budget.json +2 -2
  101. package/pipeline/scripts/_stack-routing.mjs +79 -0
  102. package/pipeline/scripts/audit-log-rotate.sh +14 -1
  103. package/pipeline/scripts/build-skills-index.mjs +11 -0
  104. package/pipeline/scripts/build-stack-plugins.mjs +35 -60
  105. package/pipeline/scripts/check-derived-drift.mjs +65 -29
  106. package/pipeline/scripts/diff-explain.mjs +41 -3
  107. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  108. package/pipeline/scripts/gc-worktrees.sh +4 -1
  109. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  110. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  111. package/pipeline/scripts/learning-curve.mjs +8 -2
  112. package/pipeline/scripts/match-skills.mjs +8 -2
  113. package/pipeline/scripts/migrate-prefs.mjs +28 -20
  114. package/pipeline/scripts/output-quality-check.sh +15 -4
  115. package/pipeline/scripts/phase-tracker.sh +33 -12
  116. package/pipeline/scripts/phase0-exit-gate.mjs +3 -2
  117. package/pipeline/scripts/pre-commit-check.sh +69 -22
  118. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  119. package/pipeline/scripts/render-cost-summary.sh +42 -22
  120. package/pipeline/scripts/render-work-summary.sh +47 -13
  121. package/pipeline/scripts/review-scope.mjs +1 -1
  122. package/pipeline/scripts/run-aggregator.mjs +43 -14
  123. package/pipeline/scripts/scan-agent-config.sh +1 -1
  124. package/pipeline/scripts/skill-conformance.mjs +165 -30
  125. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  126. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  127. package/pipeline/scripts/test-gap-rules/android.json +25 -0
  128. package/pipeline/scripts/test-gap-rules/ios.json +34 -0
  129. package/pipeline/scripts/test-gap-rules/node.json +29 -0
  130. package/pipeline/scripts/test-gap-rules/python.json +25 -0
  131. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  132. package/pipeline/scripts/uninstall.mjs +196 -14
  133. package/pipeline/scripts/update-issue-progress.sh +12 -16
  134. package/pipeline/scripts/validate-complaint-doc.mjs +229 -0
  135. package/pipeline/scripts/validate-reviewer.mjs +9 -3
  136. package/pipeline/scripts/worktree-finalize.sh +23 -2
  137. package/pipeline/skills/.skill-manifest.json +156 -108
  138. package/pipeline/skills/.skills-index.json +459 -13
  139. package/pipeline/skills/shared/README.md +15 -11
  140. package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +1 -1
  141. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  142. package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +1 -1
  143. package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +49 -0
  144. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  145. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  146. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  147. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  148. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  149. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -2
  150. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  151. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  152. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  153. package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +83 -0
  154. package/pipeline/skills/shared/core/{multi-agent-ship → multi-agent-resume-local}/SKILL.md +10 -6
  155. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +79 -22
  156. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +1 -1
  157. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +8 -8
  158. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +1 -1
  159. package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +2 -2
  160. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +368 -33
  161. package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +1 -2
  162. package/pipeline/skills/shared/external/ios-coding-standard/scripts/check_structure.py +765 -0
  163. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +75 -0
  164. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +131 -0
  165. package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +559 -0
  166. package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +765 -0
  167. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  168. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +187 -0
  169. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  170. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  171. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +176 -0
  172. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  173. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  174. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  175. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  176. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  177. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  178. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  179. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  180. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  181. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  182. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  183. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  184. package/pipeline/skills/skills-index.md +9 -5
@@ -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).
@@ -80,6 +83,16 @@ if [ -s "$AUDIT_FILE" ]; then
80
83
  if [ -f "$ROTATED" ] && command -v gzip >/dev/null 2>&1; then
81
84
  gzip -f "$ROTATED" 2>/dev/null || true
82
85
  fi
86
+
87
+ # gzip preserves the source file's mtime by default, so an archive rotated
88
+ # from log content already older than KEEP_DAYS would carry that same old
89
+ # mtime - and the retention sweep below deletes anything past KEEP_DAYS,
90
+ # so a just-created archive could be purged in this very run before ever
91
+ # being read. Retention has to count from rotation time, not from the age
92
+ # of the content it archived. gzip -f removes the plain file on success, so
93
+ # only touch whichever of the two actually exists.
94
+ [ -f "$ROTATED.gz" ] && touch "$ROTATED.gz"
95
+ [ -f "$ROTATED" ] && touch "$ROTATED"
83
96
  fi
84
97
 
85
98
  # Drop archives older than KEEP_DAYS
@@ -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,53 +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
- ];
65
-
66
- // Apple/Xcode-only skills that match no stack pattern → iOS plugin only.
67
- const APPLE_ONLY = [
68
- "avkit",
69
- "cryptokit",
70
- "metrickit-diagnostics",
71
- "pdfkit",
72
- "spm-build-analysis",
73
- "swiftlint",
74
- "xcode-build-benchmark",
75
- "xcode-build-fixer",
76
- "xcode-build-orchestrator",
77
- "xcode-compilation-analyzer",
78
- "xcode-project-analyzer",
79
- ];
80
-
81
- // stack → matching pattern (per-stack routing patterns (formerly in stack-swap.sh))
82
- //
83
- // A skill lands in every plugin whose pattern it matches, so a bare substring
84
- // alternative that is also a suffix of another stack's skill name silently ships
85
- // that skill twice. `architecture` did exactly that: it pulled
86
- // `android-architecture` and `swift-architecture` into ai-backend-toolkit
87
- // alongside their correct plugins, so a backend project loading the toolkit got
88
- // Compose and SwiftUI architecture skills. It is anchored to the exact name now.
89
- // Keep any new single-word alternative anchored unless it genuinely reads as a
90
- // substring across stacks.
91
- const STACK_PATTERNS = {
92
- "ai-ios-engineering-toolkit":
93
- /(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,
94
- "ai-android-engineering-toolkit":
95
- /(android|compose-|kotlin-|room-database|retrofit-|gradle-|play-store)/i,
96
- "ai-backend-toolkit":
97
- /(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,
98
- "ai-frontend-engineering-toolkit":
99
- /(react-|nextjs-|typescript-|tailwind-|vue-|web-accessibility|web-performance|css-modern|web-testing|html-semantic)/i,
100
- };
55
+ import {
56
+ COMMON_PLUGIN,
57
+ COMMON_SKILLS,
58
+ APPLE_ONLY,
59
+ STACK_PATTERNS,
60
+ } from "./_stack-routing.mjs";
101
61
 
102
62
  const isDir = (p) => {
103
63
  try {
@@ -109,18 +69,23 @@ const isDir = (p) => {
109
69
  const listDirs = (p) => (existsSync(p) ? readdirSync(p).filter((n) => isDir(join(p, n))) : []);
110
70
 
111
71
  /**
112
- * Cheap content fingerprint for a skill directory: every file's relative path and
113
- * size. Enough to notice an edit without hashing megabytes on every run, and it is
114
- * 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.
115
77
  */
116
78
  function treeFingerprint(dir) {
117
79
  const parts = [];
118
80
  const walk = (d, rel) => {
119
- 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
+ )) {
120
84
  const full = join(d, e.name);
121
85
  const r = rel ? `${rel}/${e.name}` : e.name;
122
86
  if (e.isDirectory()) walk(full, r);
123
- 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")}`);
124
89
  }
125
90
  };
126
91
  try {
@@ -134,7 +99,13 @@ function treeFingerprint(dir) {
134
99
  // --- 1. enumerate authoring source ---------------------------------------
135
100
  if (!existsSync(EXTERNAL)) {
136
101
  console.error(`source not found: ${EXTERNAL}`);
137
- process.exit(0);
102
+ // --check-routing exists so CI can gate routing correctness without a
103
+ // checkout of the plugins repo, reading only this authoring source. Exiting
104
+ // 0 here regardless of --check-routing meant a misconfigured/missing
105
+ // source (wrong --pipeline path, an incomplete CI checkout) reported the
106
+ // gate as passing having audited nothing - the exact silent-green failure
107
+ // the gate exists to prevent elsewhere.
108
+ process.exit(args.includes("--check-routing") ? 1 : 0);
138
109
  }
139
110
  const allSkills = listDirs(EXTERNAL);
140
111
 
@@ -149,7 +120,7 @@ for (const skill of allSkills) {
149
120
  continue;
150
121
  }
151
122
  if (APPLE_ONLY.includes(skill)) {
152
- desired["ai-ios-engineering-toolkit"].add(skill);
123
+ desired["ai-ios-toolkit"].add(skill);
153
124
  continue;
154
125
  }
155
126
  let matched = false;
@@ -211,7 +182,8 @@ for (const [plugin, want] of Object.entries(desired)) {
211
182
  const toRemove = [...have].filter((s) => !want.has(s)).sort();
212
183
  const setChanged = toAdd.length > 0 || toRemove.length > 0;
213
184
 
214
- // 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.
215
187
  //
216
188
  // This used to copy only on a set change, which quietly broke the single-source
217
189
  // claim: editing a skill in shared/external left the plugin serving the old text
@@ -219,17 +191,20 @@ for (const [plugin, want] of Object.entries(desired)) {
219
191
  // seeing the plugin copy stay stale while the generator reported "all plugins up
220
192
  // to date".
221
193
  //
222
- // cpSync overwrites, so a full re-copy is idempotent and cheap relative to a
223
- // 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.
224
198
  let contentChanged = false;
225
199
  if (!DRY) {
226
200
  if (!existsSync(kdir)) mkdirSync(kdir, { recursive: true });
227
201
  for (const s of want) {
228
202
  const from = join(EXTERNAL, s);
229
203
  const to = join(kdir, s);
230
- const before = existsSync(to) ? treeFingerprint(to) : "";
204
+ if (treeFingerprint(from) === (existsSync(to) ? treeFingerprint(to) : "")) continue;
205
+ rmSync(to, { recursive: true, force: true });
231
206
  cpSync(from, to, { recursive: true });
232
- if (treeFingerprint(to) !== before) contentChanged = true;
207
+ contentChanged = true;
233
208
  }
234
209
  for (const s of toRemove) rmSync(join(kdir, s), { recursive: true, force: true });
235
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
 
@@ -38,6 +38,18 @@ const CACHE = process.env.DERIVED_DRIFT_CACHE || join(homedir(), ".claude", "plu
38
38
 
39
39
  const expandHome = (p) => p.replace(/^\$HOME\b|^~/, homedir());
40
40
 
41
+ // Array.prototype.sort() with no comparator sorts these lexicographically
42
+ // ("10.0.0" < "9.0.0" as strings), so a directory listing with both would
43
+ // treat 9.0.0 as newer.
44
+ function semverCompare(a, b) {
45
+ const pa = a.split(".").map(Number);
46
+ const pb = b.split(".").map(Number);
47
+ for (let i = 0; i < 3; i++) {
48
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
49
+ }
50
+ return 0;
51
+ }
52
+
41
53
  function readJson(path) {
42
54
  try {
43
55
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -60,29 +72,44 @@ function versionFrom(manifest, pluginName) {
60
72
  function fromLocalClone(entry) {
61
73
  if (!entry.upstreamLocalClone) return null;
62
74
  const root = expandHome(entry.upstreamLocalClone);
63
- const file =
75
+ const relFile =
64
76
  entry.upstreamVersionSource === "plugin.json"
65
- ? join(root, "plugins", entry.upstreamPlugin, ".claude-plugin", "plugin.json")
66
- : join(root, ".claude-plugin", "marketplace.json");
67
- if (!existsSync(file)) return null;
68
- const version = versionFrom(readJson(file), entry.upstreamPlugin);
69
- if (!version) return null;
77
+ ? `plugins/${entry.upstreamPlugin}/.claude-plugin/plugin.json`
78
+ : ".claude-plugin/marketplace.json";
70
79
 
71
- // A clone behind its own remote is still a stale answer, just a less stale
72
- // one than the cache. Say so rather than presenting it as current.
73
- 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.
74
83
  try {
75
- const out = execFileSync("git", ["-C", root, "rev-list", "--count", "HEAD..@{u}"], {
84
+ const raw = execFileSync("git", ["-C", root, "show", `origin/main:${relFile}`], {
76
85
  encoding: "utf-8",
77
86
  stdio: ["ignore", "pipe", "ignore"],
78
- }).trim();
79
- 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
+ }
80
103
  } catch {
81
- // No upstream branch configured, or not a git checkout at all.
82
- 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.
83
106
  }
84
107
 
85
- 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 };
86
113
  }
87
114
 
88
115
  function fromRepoApi(entry) {
@@ -116,7 +143,7 @@ function fromCache(entry) {
116
143
  if (!existsSync(dir)) return null;
117
144
  const versions = readdirSync(dir)
118
145
  .filter((d) => /^\d+\.\d+\.\d+$/.test(d))
119
- .sort();
146
+ .sort(semverCompare);
120
147
  const newest = versions[versions.length - 1];
121
148
  return newest
122
149
  ? { version: newest, source: "plugin cache", authoritative: false, behind: null }
@@ -149,46 +176,55 @@ for (const entry of entries) {
149
176
  continue;
150
177
  }
151
178
  const drifted = resolved.version !== entry.derivedFromVersion;
152
- const status = drifted
153
- ? "drift"
154
- : resolved.authoritative
155
- ? "up to date"
156
- : "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)";
157
191
  rows.push({
158
192
  label: entry.label,
159
193
  plugin: entry.upstreamPlugin,
160
194
  from: entry.derivedFromVersion,
161
195
  upstream: resolved.version,
162
196
  source: resolved.source,
163
- cloneBehind: resolved.behind,
197
+ fetchAgeDays: resolved.fetchAgeDays ?? null,
164
198
  status,
165
199
  });
166
200
  }
167
201
 
168
202
  const drifted = rows.filter((r) => r.status === "drift").length;
203
+ const acknowledged = rows.filter((r) => r.status === "drift (acknowledged)").length;
169
204
  const unverified = rows.filter((r) => r.status.startsWith("unverified")).length;
170
205
  const unreachable = rows.filter((r) => r.status === "unreachable").length;
171
206
 
172
207
  if (asJson) {
173
208
  console.log(
174
209
  JSON.stringify(
175
- { configured: rows.length, drifted, unverified, unreachable, entries: rows },
210
+ { configured: rows.length, drifted, acknowledged, unverified, unreachable, entries: rows },
176
211
  null,
177
212
  2,
178
213
  ),
179
214
  );
180
215
  } else {
181
216
  for (const r of rows) {
182
- 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]` : "";
183
219
  console.log(
184
- ` ${r.status === "up to date" ? "✓" : "✗"} ${r.plugin}: recorded ${r.from}, ` +
185
- `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}`,
186
222
  );
187
223
  }
188
224
  console.log("");
189
225
  console.log(
190
- `══ check-derived-drift: ${rows.length - drifted - unverified - unreachable} passed, ` +
191
- `${drifted + unverified + unreachable} failed ══`,
226
+ `══ check-derived-drift: ${rows.length - drifted - unverified - unreachable} passed ` +
227
+ `(${acknowledged} acknowledged), ${drifted + unverified + unreachable} failed ══`,
192
228
  );
193
229
  }
194
230
 
@@ -117,6 +117,44 @@ function resolveBase() {
117
117
  die("no base ref found (tried origin/main, main, origin/master, master) - pass --base");
118
118
  }
119
119
 
120
+ // git quotes a `diff --git` path (core.quotePath, on by default) whenever it
121
+ // has a non-ASCII byte or other unusual character - e.g. a Turkish filename
122
+ // renders as `diff --git "a/\303\226deme.swift" "b/\303\226deme.swift"`, not
123
+ // the bare a/...b/... form. The plain regex this replaced never matched that
124
+ // line at all, so every non-ASCII-named file dropped out of the explanation.
125
+ const DIFF_GIT_QUOTED_RE = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/;
126
+ const DIFF_GIT_UNQUOTED_RE = /^diff --git a\/(.+?) b\/(.+)$/;
127
+
128
+ function unquoteGitPath(s) {
129
+ const bytes = [];
130
+ for (let i = 0; i < s.length; i++) {
131
+ if (s[i] === "\\") {
132
+ const octal = s.slice(i + 1, i + 4);
133
+ if (/^[0-7]{3}$/.test(octal)) {
134
+ bytes.push(parseInt(octal, 8));
135
+ i += 3;
136
+ continue;
137
+ }
138
+ const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
139
+ const next = s[i + 1];
140
+ if (next in simple) {
141
+ bytes.push(simple[next]);
142
+ i += 1;
143
+ continue;
144
+ }
145
+ }
146
+ bytes.push(s.charCodeAt(i));
147
+ }
148
+ return Buffer.from(bytes).toString("utf-8");
149
+ }
150
+
151
+ function matchDiffGitHeader(line) {
152
+ const q = line.match(DIFF_GIT_QUOTED_RE);
153
+ if (q) return unquoteGitPath(q[2]);
154
+ const u = line.match(DIFF_GIT_UNQUOTED_RE);
155
+ return u ? u[2] : null;
156
+ }
157
+
120
158
  /**
121
159
  * Parse unified diff into a map of {path: [{ oldStart, newStart, lines: [...] }]}
122
160
  * Each hunk's `lines` retains the leading +/- /space prefix.
@@ -127,9 +165,9 @@ function parseDiff(raw) {
127
165
  let currentHunks = null;
128
166
  let currentHunk = null;
129
167
  for (const line of raw.split("\n")) {
130
- const fileMatch = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
131
- if (fileMatch) {
132
- currentPath = fileMatch[2];
168
+ const fileMatch = matchDiffGitHeader(line);
169
+ if (fileMatch !== null) {
170
+ currentPath = fileMatch;
133
171
  currentHunks = [];
134
172
  files[currentPath] = currentHunks;
135
173
  currentHunk = null;