@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
@@ -59,8 +59,8 @@ Run every step automatically:
59
59
  ```
60
60
  Step 1: PLATFORM Detect macOS / Linux / Windows (Git Bash / WSL); export PLATFORM env
61
61
  Step 1.5: DETECT Compare timestamps, find stale targets
62
- Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 49 sub-command skills)
63
- Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 44 specs as refs + 8 agent TOML)
62
+ Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 51 sub-command skills)
63
+ Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 51 specs as refs + 8 agent TOML)
64
64
  Step 3: REPO Claude Code -> pipeline repo (genericized, personal data scrub, bash -n on all sh)
65
65
  Step 3c: PLUGINS pipeline shared/external -> multi-agent-plugins marketplace (rebuild knowledge/,
66
66
  bump changed plugins' patch version, commit + push the plugins repo)
@@ -127,13 +127,13 @@ If nothing is stale → report "All targets up to date" and stop.
127
127
  - `~/.claude/CLAUDE.md`, `~/.claude/rules/`, `~/.claude/knowledge/`
128
128
  - `~/.claude/scripts/` - EXCEPT `pre-commit-check.sh`, `agent-guard.sh`, `agent-guard.py`, and `build-stack-plugins.mjs` (generic, synced)
129
129
  - `~/.claude/settings.json`
130
- - **Any `~/.claude/commands/multi-agent/*.md` whose frontmatter has `local-only: true`** - these are user/repo-specific alias wrappers that delegate to a private marketplace plugin (e.g. corporate `ai-ios-engineering-toolkit` skills exposed as `multi-agent:<name>`). Syncing them would leak the private plugin/skill names into the public pipeline. Filter before copy: skip every source file containing `local-only: true`, and after copy assert none reached `pipeline/commands/`.
130
+ - **Any `~/.claude/commands/multi-agent/*.md` whose frontmatter has `local-only: true`** - these are user/repo-specific alias wrappers that delegate to a private marketplace plugin (e.g. corporate `ai-ios-toolkit` skills exposed as `multi-agent:<name>`). Syncing them would leak the private plugin/skill names into the public pipeline. Filter before copy: skip every source file containing `local-only: true`, and after copy assert none reached `pipeline/commands/`.
131
131
  ```bash
132
132
  # backstop: no local-only wrapper may exist in the synced target
133
133
  grep -rl "^local-only: true" ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
134
134
  && { echo "ABORT: local-only wrapper leaked into pipeline/commands"; exit 1; } || true
135
135
  # backstop: no corporate marketplace/skill reference in the synced tree
136
- grep -rniE "ai-ios-engineering-toolkit:(create-ui-component|evolve-ui-component|fix-bug|branch-and-pr|backlog|code-connect|figma-utility|resource-utility|component-wiki|component-docs|figma-setup)" \
136
+ grep -rniE "ai-ios(-engineering)?-toolkit:(create-ui-component|evolve-ui-component|fix-bug|branch-and-pr|backlog|code-connect|figma-utility|resource-utility|component-wiki|component-docs|figma-setup)" \
137
137
  ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
138
138
  && { echo "ABORT: corporate reference leaked into pipeline/commands"; exit 1; } || true
139
139
  ```
@@ -176,7 +176,7 @@ If nothing is stale → report "All targets up to date" and stop.
176
176
  Unlike the Copilot step, this one does **not** hand-copy files. The Codex tree is a
177
177
  *transform* of the Claude tree, not a mirror of it, and the transform is real work:
178
178
 
179
- - the 43 sub-command specs become reference files, because Codex silently truncates
179
+ - the 51 sub-command specs become reference files, because Codex silently truncates
180
180
  its skills block (see `cross-cli-contract.md` 2.6 for the measurement)
181
181
  - every `$HOME/.claude/...` reference to a CLI-owned tree is retargeted, with
182
182
  `agents/<persona>.md` becoming `.toml` and the dispatcher becoming the router skill
@@ -233,13 +233,20 @@ node "$HOME/multi-agent-pipeline/pipeline/scripts/build-stack-plugins.mjs" --dry
233
233
 
234
234
  # 2. Apply: rebuild each stack plugin's knowledge/ from shared/external,
235
235
  # regenerate plugin.json skills[], bump the patch version of any plugin
236
- # whose skill set changed. Cross-stack skills -> ai-common-engineering-toolkit;
236
+ # whose skill set changed. Cross-stack skills -> ai-common-toolkit;
237
237
  # Apple/Xcode-only skills -> iOS plugin only.
238
238
  node "$HOME/multi-agent-pipeline/pipeline/scripts/build-stack-plugins.mjs"
239
239
  ```
240
240
 
241
241
  **Version-based management:** the generator bumps a plugin's `version` (patch) only when its skill set actually changed. Idempotent - a no-op run bumps nothing. Consumers pick up new versions via `/multi-agent:update` (which runs `claude marketplace update multi-agent-plugins`).
242
242
 
243
+ **Help + README freshness (mandatory when the generator reported any change):**
244
+
245
+ 1. Every plugin ships a `tools/help` skill that renders its catalog LIVE from `plugin.json`, so it needs no per-skill edits - but verify each changed plugin still registers it (`grep -c "./skills/tools/help" plugins/<name>/.claude-plugin/plugin.json` = 1) and that a new plugin gets one before publish.
246
+ 2. Plugin README tables sync through `python3 tools/bump.py <plugin> --set <version>`; run `python3 tools/bump.py --check` and treat DRIFT as a blocker.
247
+ 3. `python3 tools/validate.py` must be clean (it also enforces the CHANGELOG heading for every bumped version - write the entry, do not backdate).
248
+ 4. When the pipeline's command surface changed in the same sync (new/renamed `/multi-agent:*` commands), confirm `commands/multi-agent/help/SKILL.md` and both pipeline READMEs (`README.md`, `README.tr.md`) were updated in Step 3 before pushing here.
249
+
243
250
  **Publish (only if the generator reported bumps):**
244
251
 
245
252
  ```bash
@@ -484,21 +491,21 @@ When invoked with the `release` argument:
484
491
  > Codex takes the Step 2b path instead; see that section.
485
492
 
486
493
  This runs on the Claude <-> Copilot axis. Codex is NOT synced here: it receives the
487
- same 43 specs as reference files rather than as peer skills, via Step 2b - see
494
+ same 51 specs as reference files rather than as peer skills, via Step 2b - see
488
495
  `cross-cli-contract.md` 2.6 for why the parity axis differs per host.
489
496
 
490
497
  | Claude Code | Copilot CLI |
491
498
  |-------------|-------------|
492
499
  | `~/.claude/commands/multi-agent/{cmd}/SKILL.md` | `~/.copilot/skills/multi-agent-{cmd}/SKILL.md` |
493
500
 
494
- **49 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
501
+ **51 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
495
502
 
496
503
  ```
497
- analysis, analysis-resolve, autopilot, build-optimize, channels, create-jira, design-check, dev,
504
+ analysis, analysis-resolve, autopilot, build-optimize, channels, complaint-analysis, create-jira, design-check, dev,
498
505
  dev-autopilot, dev-local, dev-local-autopilot, diff-explain, forget, garbage-collect,
499
506
  help, ios-coding-standard, issue, jira, kill, language, local,
500
- local-autopilot, log, manual-test, prune-logs, purge, refactor, resume, review, review-issue, review-jira,
501
- routines, save, scan, search, setup, ship, stack, status, store-ready, sync, test, test-accessibility,
507
+ local-autopilot, log, manual-test, prune-logs, prune-prompts, purge, refactor, resume, review, review-issue, review-jira,
508
+ routines, save, scan, search, setup, resume-local, stack, status, store-ready, sync, test, test-accessibility,
502
509
  test-dark-mode, test-dynamic-type, test-screenshots, testflight-validation, uninstall, update
503
510
  ```
504
511
 
@@ -40,7 +40,7 @@ Unchanged from before the merge, on the iOS path:
40
40
  |---|---|---|
41
41
  | **1. Static** | `ios_app_store_audit` (18 rules, real ITMS codes) | an `.xcarchive` |
42
42
  | **2. Authoritative** | `ios_testflight_validate` → `altool --validate-app` | an `.ipa` + credentials |
43
- | **3. Policy** | `app-store-review` skill vs repo source | repo checkout |
43
+ | **3. Policy** | `ai-ios-toolkit:app-store-review` skill vs repo source | repo checkout |
44
44
 
45
45
  A skipped gate is never folded into the pass count, and the run never uploads. The
46
46
  report lands in `~/StoreChecks/ios-<repo>-<branch>-<timestamp>/report.md`.
@@ -28,6 +28,8 @@ Tokens in the OS credential store are NEVER removed by either mode.
28
28
  - `~/.claude/CLAUDE.md` (user customisations)
29
29
  - `~/.claude/rules/` (user-owned; installed write-if-missing, never overwritten)
30
30
  - User content OUTSIDE the `<!-- multi-agent-pipeline:begin/end -->` markers (`copilot-instructions.md`)
31
+ - Local-only alias wrappers (`local-only: true` frontmatter) under `commands/multi-agent/` (user-authored, exist nowhere else)
32
+ - User-authored skill dirs and agent files (only pipeline-shipped names are removed)
31
33
 
32
34
  Settings (`multi-agent-preferences.json`) and the log root (`~/.claude/logs/multi-agent/`) are kept by default and removed only when the user picks full cleanup (`--all-data`).
33
35
 
@@ -101,7 +101,7 @@ Update the pipeline in one command. Existing preferences are preserved; only ski
101
101
  ```
102
102
  Current: v3.6.0
103
103
  -> git pull origin main
104
- -> node install.js --all (44 commands, 50 scripts, 291 skills)
104
+ -> node install.js --all (50 commands, 80 scripts, 203 skills)
105
105
  -> migrate-prefs.mjs (0 changes - already v2.1.0)
106
106
 
107
107
  ✓ Updated: v3.6.0 → v3.7.0
@@ -140,9 +140,31 @@ run_find() {
140
140
  if [ "${#roots[@]}" -eq 0 ]; then
141
141
  return 0
142
142
  fi
143
+ # No explicit -print anywhere in this expression means find falls back to
144
+ # its own implicit print on the WHOLE expression - which fires for every
145
+ # true branch of the -prune OR-chain too, including the pruned directories
146
+ # themselves (their contents are still correctly skipped; the dir path
147
+ # itself was not). An explicit -print, implicit-AND'd onto the caller's own
148
+ # criteria as the OR-chain's last branch, only fires on that branch.
143
149
  find "${roots[@]}" \
144
150
  "${SKIP_PRUNE_ARGS[@]}" \
145
- "$@" 2>/dev/null || true
151
+ "$@" -print 2>/dev/null || true
152
+ }
153
+
154
+ # Count how many of the newline-separated paths in $1 contain a line matching
155
+ # grep pattern $2. `xargs grep -l` here used to word-split on whitespace by
156
+ # default, so any path with a space in it was silently mis-scanned as several
157
+ # nonexistent partial paths (a false-negative on real matches, not an error -
158
+ # stderr was already redirected to /dev/null). Reading line-by-line handles
159
+ # that correctly since each line is one whole path regardless of embedded
160
+ # spaces.
161
+ count_grep_hits() {
162
+ local list="$1" pattern="$2" n=0 f
163
+ while IFS= read -r f; do
164
+ [ -z "$f" ] && continue
165
+ grep -lq "$pattern" "$f" 2>/dev/null && n=$((n + 1))
166
+ done <<< "$list"
167
+ printf '%s' "$n"
146
168
  }
147
169
 
148
170
  # ---- confidence classifier ----------------------------------------------------
@@ -849,8 +871,15 @@ bucket_localization_key() {
849
871
  case "$PLATFORM" in
850
872
  ios)
851
873
  local strings_files xcstrings_files
852
- strings_files=$(find "$REPO_PATH" -name 'Localizable.strings' "${SKIP_PRUNE_ARGS[@]}" -o -name 'Localizable.strings' -type f -print 2>/dev/null | head -5 || true)
853
- xcstrings_files=$(find "$REPO_PATH" -name 'Localizable.xcstrings' -type f 2>/dev/null | head -3 || true)
874
+ # An earlier version ANDed -name onto the FRONT of the prune chain
875
+ # (`-name X SKIP_PRUNE_ARGS...`), which requires every -prune branch to
876
+ # ALSO be named "Localizable.strings" - a directory prune can never
877
+ # satisfy that, so no prune ever fired and the search walked into
878
+ # Pods/.build/node_modules looking for vendored copies. Prunes must
879
+ # stay their own independent OR-chain, ahead of (not ANDed with) the
880
+ # real search criteria. The xcstrings search had no prune chain at all.
881
+ strings_files=$(find "$REPO_PATH" "${SKIP_PRUNE_ARGS[@]}" -name 'Localizable.strings' -type f -print 2>/dev/null | head -5 || true)
882
+ xcstrings_files=$(find "$REPO_PATH" "${SKIP_PRUNE_ARGS[@]}" -name 'Localizable.xcstrings' -type f -print 2>/dev/null | head -3 || true)
854
883
  if [ -n "$strings_files" ]; then
855
884
  keys=$(printf '%s\n' "$strings_files" | while IFS= read -r f; do
856
885
  [ -n "$f" ] && grep -hEo '^"[^"]+"' "$f" 2>/dev/null | tr -d '"'
@@ -918,9 +947,9 @@ bucket_di_registration() {
918
947
  fi
919
948
  files=$(run_find -type f -name '*.swift' 2>/dev/null || true)
920
949
  if [ -n "$files" ]; then
921
- resolver_count=$(printf '%s\n' "$files" | xargs grep -l 'Resolver.register' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
922
- factory_count=$(printf '%s\n' "$files" | xargs grep -l '@Injected\|Container.shared' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
923
- swinject_count=$(printf '%s\n' "$files" | xargs grep -l 'Swinject\|Container()' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
950
+ resolver_count=$(count_grep_hits "$files" 'Resolver.register')
951
+ factory_count=$(count_grep_hits "$files" '@Injected\|Container.shared')
952
+ swinject_count=$(count_grep_hits "$files" 'Swinject\|Container()')
924
953
  fi
925
954
  manual_count=${manual_count:-0}; resolver_count=${resolver_count:-0}; factory_count=${factory_count:-0}; swinject_count=${swinject_count:-0}
926
955
 
@@ -941,9 +970,9 @@ bucket_di_registration() {
941
970
  files=$(run_find -type f -name '*.kt' 2>/dev/null || true)
942
971
  if [ -z "$files" ]; then empty_bucket; return; fi
943
972
  local hilt_count koin_count dagger_count
944
- hilt_count=$(printf '%s\n' "$files" | xargs grep -l '@Module\|@InstallIn\|@HiltViewModel' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
945
- koin_count=$(printf '%s\n' "$files" | xargs grep -l 'koinViewModel\|module {' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
946
- dagger_count=$(printf '%s\n' "$files" | xargs grep -l '@Component\|@Subcomponent' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
973
+ hilt_count=$(count_grep_hits "$files" '@Module\|@InstallIn\|@HiltViewModel')
974
+ koin_count=$(count_grep_hits "$files" 'koinViewModel\|module {')
975
+ dagger_count=$(count_grep_hits "$files" '@Component\|@Subcomponent')
947
976
  hilt_count=${hilt_count:-0}; koin_count=${koin_count:-0}; dagger_count=${dagger_count:-0}
948
977
  if [ "$hilt_count" -ge "$koin_count" ] && [ "$hilt_count" -ge "$dagger_count" ] && [ "$hilt_count" -gt 0 ]; then
949
978
  emit_bucket "Hilt" "@Module @InstallIn(SingletonComponent::class)" "$(confidence_for "$hilt_count")" "[]" '["Koin","Dagger"]'
@@ -959,9 +988,9 @@ bucket_di_registration() {
959
988
  files=$(run_find -type f -name '*.py' 2>/dev/null || true)
960
989
  if [ -z "$files" ]; then empty_bucket; return; fi
961
990
  local depends_count dishka_count di_count
962
- depends_count=$(printf '%s\n' "$files" | xargs grep -l 'Depends(' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
963
- dishka_count=$(printf '%s\n' "$files" | xargs grep -l 'dishka' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
964
- di_count=$(printf '%s\n' "$files" | xargs grep -l 'dependency_injector' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
991
+ depends_count=$(count_grep_hits "$files" 'Depends(')
992
+ dishka_count=$(count_grep_hits "$files" 'dishka')
993
+ di_count=$(count_grep_hits "$files" 'dependency_injector')
965
994
  depends_count=${depends_count:-0}; dishka_count=${dishka_count:-0}; di_count=${di_count:-0}
966
995
  if [ "$depends_count" -ge "$dishka_count" ] && [ "$depends_count" -ge "$di_count" ] && [ "$depends_count" -gt 0 ]; then
967
996
  emit_bucket "FastAPI Depends" "def handler(svc: FooService = Depends(get_service))" "$(confidence_for "$depends_count")" "[]" '["dishka","dependency_injector"]'
@@ -977,9 +1006,9 @@ bucket_di_registration() {
977
1006
  files=$(run_find -type f \( -name '*.tsx' -o -name '*.ts' \) 2>/dev/null || true)
978
1007
  if [ -z "$files" ]; then empty_bucket; return; fi
979
1008
  local context_count zustand_count hook_count
980
- context_count=$(printf '%s\n' "$files" | xargs grep -l 'createContext\|\.Provider' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
981
- zustand_count=$(printf '%s\n' "$files" | xargs grep -l "from 'zustand'\|from \"zustand\"" 2>/dev/null | wc -l | tr -d ' ' || echo 0)
982
- hook_count=$(printf '%s\n' "$files" | xargs grep -l 'export function use[A-Z]' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
1009
+ context_count=$(count_grep_hits "$files" 'createContext\|\.Provider')
1010
+ zustand_count=$(count_grep_hits "$files" "from 'zustand'\\|from \"zustand\"")
1011
+ hook_count=$(count_grep_hits "$files" 'export function use[A-Z]')
983
1012
  context_count=${context_count:-0}; zustand_count=${zustand_count:-0}; hook_count=${hook_count:-0}
984
1013
  if [ "$context_count" -ge "$zustand_count" ] && [ "$context_count" -ge "$hook_count" ] && [ "$context_count" -gt 0 ]; then
985
1014
  emit_bucket "React Context Provider" "const FooContext = createContext<Foo>(...)" "$(confidence_for "$context_count")" "[]" '["zustand","custom-hook-factory"]'
@@ -124,10 +124,17 @@ trap 'rm -rf "$TMPDIR_A"' EXIT INT TERM
124
124
  # curl one /nodes batch (token via -K config so it never lands in argv).
125
125
  curl_nodes() {
126
126
  local out="$1" ids="$2" code
127
+ # curl already writes "000" via -w on a total connection failure (and
128
+ # still exits non-zero for it, which the inner `|| echo "000"` used to
129
+ # catch too - so `code` became the literal string "000000", never
130
+ # matching a plain "000" comparison anywhere downstream). `|| true` keeps
131
+ # a failing curl from tripping any caller's `set -e` without re-printing
132
+ # into $code; `:-000` only fills in when curl fails before -w ever runs.
127
133
  code=$(curl -sS -o "$out" -w '%{http_code}' \
128
134
  --max-time "$HTTP_TIMEOUT" --connect-timeout 5 \
129
135
  -K <(printf 'header = "X-Figma-Token: %s"\n' "$FIGMA_TOKEN") \
130
- "$FIGMA_API/files/$FILE_KEY/nodes?ids=$ids" || echo "000")
136
+ "$FIGMA_API/files/$FILE_KEY/nodes?ids=$ids") || true
137
+ code="${code:-000}"
131
138
  printf '%s' "$code"
132
139
  }
133
140
 
@@ -202,18 +202,33 @@ fortify_get() {
202
202
  return 1
203
203
  }
204
204
 
205
- VERSION_JSON=$(fortify_get "projectVersions/$VERSION_ID" || true)
206
- case "$VERSION_JSON" in
207
- HTTP_AUTH_FAILED|*HTTP_4*)
208
- echo "ERR: Fortify auth failed (Bearer + FortifyToken both rejected)" >&2
209
- exit 3 ;;
210
- esac
205
+ # Check fortify_get's actual exit status, not a string pattern over its
206
+ # output - the old `case ... HTTP_AUTH_FAILED|*HTTP_4*)` only recognized
207
+ # auth failures and 4xx. A dropped connection, timeout, or 5xx made
208
+ # fortify_get return "...\nHTTP_000"/"...\nHTTP_5xx", which matched neither
209
+ # arm, so the case fell through, VERSION_JSON stayed unparseable garbage,
210
+ # and the Python block below silently treated that as "0 issues found" -
211
+ # the security gate passing precisely when it couldn't check anything.
212
+ if ! VERSION_JSON=$(fortify_get "projectVersions/$VERSION_ID"); then
213
+ echo "ERR: Fortify fetch failed for projectVersions/$VERSION_ID: ${VERSION_JSON:-no response}" >&2
214
+ exit 3
215
+ fi
211
216
 
212
217
  ISSUES_JSON=""
213
218
  if [ -n "$ISSUE_ID" ]; then
214
- ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=20" || true)
219
+ if ! ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=20"); then
220
+ echo "ERR: Fortify issues fetch failed for issue $ISSUE_ID: ${ISSUES_JSON:-no response}" >&2
221
+ exit 3
222
+ fi
215
223
  else
216
- ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?limit=200&filter=ANALYSIS%3D%3DNotAnIssue,ANALYSIS%3D%3DFalsePositive&filterset=Critical%2CHigh" || true)
224
+ # filter=ANALYSIS!=NotAnIssue,ANALYSIS!=FalsePositive (%21%3D is `!=`) is
225
+ # meant to EXCLUDE suppressed findings. It was encoded as %3D%3D (`==`),
226
+ # which instead selected only suppressed findings - the opposite of the
227
+ # gate's intent whenever SSC honors the filter.
228
+ if ! ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?limit=200&filter=ANALYSIS%21%3DNotAnIssue,ANALYSIS%21%3DFalsePositive&filterset=Critical%2CHigh"); then
229
+ echo "ERR: Fortify issues fetch failed for version $VERSION_ID: ${ISSUES_JSON:-no response}" >&2
230
+ exit 3
231
+ fi
217
232
  fi
218
233
 
219
234
  # Issue details are fetched per-issue lazily - only for the first 10 to keep
@@ -180,10 +180,20 @@ curl_figma() {
180
180
  for attempt in 1 2 3; do
181
181
  # Token header goes through a curl config via process substitution so it
182
182
  # never appears in argv (argv is visible to ps).
183
+ # curl already writes "000" via -w on a total connection failure (and
184
+ # still exits non-zero for it, which the inner `|| echo "000"` used to
185
+ # catch too - so `code` became the literal string "000000", matching
186
+ # neither the `000)` retry branch below nor handle_http_code's "000)
187
+ # network timeout" case, just the generic catch-all). `|| true` on its
188
+ # own line keeps `set -e` from aborting on curl's exit status without
189
+ # re-printing anything into $code; the `:-000` fallback only fires for
190
+ # the rarer case where curl fails before -w ever runs and $code is
191
+ # genuinely empty.
183
192
  code=$(curl -sS -o "$out" -w '%{http_code}' \
184
193
  --max-time "$HTTP_TIMEOUT" --connect-timeout 5 \
185
194
  -K <(printf 'header = "X-Figma-Token: %s"\n' "$FIGMA_TOKEN") \
186
- "$url" || echo "000")
195
+ "$url") || true
196
+ code="${code:-000}"
187
197
  case "$code" in
188
198
  2*) printf '%s' "$code"; return 0 ;;
189
199
  401|403) printf '%s' "$code"; return 0 ;;
@@ -12,7 +12,15 @@
12
12
  # "title": "...",
13
13
  # "type": "Bug" | "Task" | "Story" | "Issue",
14
14
  # "status": "...",
15
- # "description":"...", // raw body (truncated to 4000 chars)
15
+ # "description":"...", // raw body (truncated to 4000 chars); this
16
+ # // issue's OWN description - never auto-substituted
17
+ # "parentKey": "PROJ-1" | null, // jira only; set when own description is empty
18
+ # // AND a parent link exists (single hop, no chasing
19
+ # // the parent's parent)
20
+ # "parentDescription": "..." | null, // jira only; the parent's description (truncated
21
+ # // 4000 chars), only when own is empty and parent
22
+ # // has content - a CANDIDATE, not applied automatically;
23
+ # // the caller must confirm before using it as "description"
16
24
  # "url": "https://...",
17
25
  # "host": "jira.example.com" | "github.com",
18
26
  # "owner": "org" (gh) | null,
@@ -21,11 +29,17 @@
21
29
  # "maturity": {
22
30
  # "score": 0..100,
23
31
  # "blockers": ["status_closed", "description_empty"],
24
- # "warnings": ["short_description", "no_repro_steps"],
32
+ # "warnings": ["short_description", "no_repro_steps", "description_empty_parent_available"],
25
33
  # "summary": "Description boş - pipeline başlatılamaz." (human-readable, tr|en)
26
34
  # }
27
35
  # }
28
36
  #
37
+ # "description_empty_parent_available": own description is empty but parentDescription is
38
+ # non-empty. This is a WARNING, not a blocker - the caller must ask the user (or, in
39
+ # autopilot, auto-accept per the same "warnings auto-continue" rule) whether to proceed
40
+ # using parentDescription as the working description. See jira/SKILL.md's maturity
41
+ # section for the exact question wording.
42
+ #
29
43
  # Required env:
30
44
  # ACCOUNT_JIRA_TOKEN_KEY keychain service name (optional)
31
45
  # ACCOUNT_JIRA_HOST e.g. jira.example.com (optional)
@@ -51,8 +65,16 @@ detect_kind() {
51
65
  case "$in" in
52
66
  *atlassian.net/browse/*|*jira*/browse/*|http*://*/browse/*) echo "jira-url"; return ;;
53
67
  *github.com/*/issues/*) echo "gh-url"; return ;;
54
- [A-Z]*-[0-9]*) echo "jira-id"; return ;;
55
68
  esac
69
+ # A bare Jira key is PROJECT-NUMBER, anchored end-to-end. The case glob this
70
+ # replaced ([A-Z]*-[0-9]*) matches whenever that shape appears ANYWHERE in a
71
+ # longer string (case patterns match the whole string, but `*` is greedy
72
+ # enough to swallow surrounding words) - "Add iOS-16 support" contains
73
+ # "S-16" and satisfied it, silently misrouting a free-text task description
74
+ # into a Jira lookup instead of the freetext path below.
75
+ if [[ "$in" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]]; then
76
+ echo "jira-id"; return
77
+ fi
56
78
  # Bare issue number ("316" or "#316") must resolve BEFORE the repo#N glob:
57
79
  # `*\#[0-9]*` also matches "#316" with an empty repo prefix, which used to
58
80
  # misroute it to gh-short and build https://github.com/<owner>//issues/316.
@@ -140,7 +162,7 @@ fetch_jira() {
140
162
  return 1
141
163
  fi
142
164
  curl -sf -K <(jira_auth_cfg "$token") \
143
- "https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions" \
165
+ "https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions,parent" \
144
166
  || { echo "ERR: Jira fetch failed for $key" >&2; return 1; }
145
167
  }
146
168
 
@@ -197,6 +219,8 @@ descriptor = {
197
219
  "resolution": resolution or None,
198
220
  "fixVersions": fix_versions,
199
221
  "description": desc,
222
+ "parentKey": fields.get("parentKey") or None,
223
+ "parentDescription": fields.get("parentDescription") or None,
200
224
  "host": fields.get("host") or None,
201
225
  "url": fields.get("url") or None,
202
226
  "owner": fields.get("owner") or None,
@@ -213,6 +237,9 @@ title = (descriptor.get("title") or "").strip()
213
237
  status = (descriptor.get("status") or "").lower()
214
238
  itype = (descriptor.get("type") or "").lower()
215
239
 
240
+ parent_desc = (descriptor.get("parentDescription") or "").strip()
241
+ parent_key = descriptor.get("parentKey") or ""
242
+
216
243
  blockers, warnings = [], []
217
244
  closed = {"done","closed","cancelled","canceled","resolved"}
218
245
  if status in closed:
@@ -221,7 +248,14 @@ if status in closed:
221
248
  if resolution:
222
249
  blockers.append("already_resolved")
223
250
  if not desc.strip():
224
- blockers.append("description_empty")
251
+ # A parent with actual content is a candidate, not an auto-fix - surface it
252
+ # as a warning so the caller asks the user before substituting it in (or,
253
+ # under autopilot, auto-accepts per the same "warnings auto-continue" rule).
254
+ # No parent / an equally-empty parent keeps the hard blocker as before.
255
+ if parent_desc:
256
+ warnings.append("description_empty_parent_available")
257
+ else:
258
+ blockers.append("description_empty")
225
259
  elif len(desc.strip()) < 80:
226
260
  warnings.append("short_description")
227
261
  if len(title) < 10:
@@ -239,6 +273,7 @@ labels_tr = {
239
273
  "status_closed": "Issue kapalı veya iptal edilmiş",
240
274
  "already_resolved": "Issue zaten resolved/merged ({})".format(resolution or " - "),
241
275
  "description_empty": "Açıklama boş",
276
+ "description_empty_parent_available":"Açıklama boş ama parent'ta ({}) içerik var - oradan devam edilsin mi?".format(parent_key or " - "),
242
277
  "short_description": "Açıklama çok kısa (<80 karakter)",
243
278
  "short_title": "Başlık çok kısa (<10 karakter)",
244
279
  "no_repro_steps": "Bug için repro adımları yok",
@@ -248,6 +283,7 @@ labels_en = {
248
283
  "status_closed": "Issue is closed/cancelled",
249
284
  "already_resolved": "Issue already resolved/merged ({})".format(resolution or " - "),
250
285
  "description_empty": "Description is empty",
286
+ "description_empty_parent_available":"Description is empty but the parent ({}) has content - continue from there?".format(parent_key or " - "),
251
287
  "short_description": "Description too short (<80 chars)",
252
288
  "short_title": "Title too short (<10 chars)",
253
289
  "no_repro_steps": "Bug missing reproduction steps",
@@ -309,21 +345,52 @@ description = (f.get("description") or "")
309
345
  priority = _name(f.get("priority"))
310
346
  resolution = _name(f.get("resolution"))
311
347
  fixversions = ",".join((v or {}).get("name", "") for v in (f.get("fixVersions") or []))
312
- parts = [title, itype, status, description, priority, resolution, fixversions]
348
+ parentkey = (f.get("parent") or {}).get("key", "") or ""
349
+ parts = [title, itype, status, description, priority, resolution, fixversions, parentkey]
313
350
  parts = [v.replace("\x1f", " ") for v in parts]
314
351
  sys.stdout.write("\x1f".join(parts))
315
352
  ')
316
- IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions <<< "$parsed" || true
353
+ IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions parentkey <<< "$parsed" || true
354
+ # `<<<` appends a trailing newline to its input; `read -d ''` has no
355
+ # delimiter to stop on, so that newline folds into whichever field
356
+ # comes last - now parentkey. Strip it, or a genuinely-empty parent
357
+ # (no fallback needed) or a real key gets a stray \n appended, which
358
+ # breaks the parent lookup's URL and misreports parentKey in output.
359
+ parentkey="${parentkey%$'\n'}"
317
360
  else
318
361
  title="(unfetched)"; itype="Task"; status="Unknown"; description=""
319
- priority=""; resolution=""; fixversions=""
362
+ priority=""; resolution=""; fixversions=""; parentkey=""
363
+ fi
364
+ # Development sub-tasks are frequently filed with an empty description while
365
+ # the real requirements live on the parent (story/epic). Single hop only -
366
+ # if the parent is also empty/unreachable, description stays empty and
367
+ # description_empty blocks below, same as before this existed. This fetcher
368
+ # only surfaces the parent's description as a CANDIDATE (parentDescription)
369
+ # - it never substitutes it into "description" itself. Whether to actually
370
+ # use it is a decision for the caller to put in front of the user (or
371
+ # auto-accept under autopilot), same as any other maturity warning.
372
+ parentdesc=""
373
+ if [ -z "$(printf '%s' "$description" | tr -d '[:space:]')" ] && [ -n "${parentkey:-}" ]; then
374
+ praw=$(fetch_jira "$parentkey") || praw=""
375
+ if [ -n "$praw" ]; then
376
+ pdesc=$(printf '%s' "$praw" | python3 -c '
377
+ import json, sys
378
+ d = json.load(sys.stdin)
379
+ f = d.get("fields", {}) or {}
380
+ sys.stdout.write((f.get("description") or "").replace("\x1f", " "))
381
+ ')
382
+ if [ -n "$(printf '%s' "$pdesc" | tr -d '[:space:]')" ]; then
383
+ parentdesc="$pdesc"
384
+ fi
385
+ fi
320
386
  fi
321
387
  branch=$(branch_for "$KEY" "$itype" "$title")
322
388
  emit_descriptor \
323
389
  "kind=jira" "key=$KEY" "title=$title" "type=$itype" "status=$status" \
324
390
  "description=$description" "host=$JIRA_HOST" \
325
391
  "url=https://$JIRA_HOST/browse/$KEY" "branchHint=$branch" \
326
- "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions"
392
+ "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions" \
393
+ "parentKey=$parentkey" "parentDescription=$parentdesc"
327
394
  ;;
328
395
 
329
396
  jira-url)
@@ -335,7 +402,7 @@ sys.stdout.write("\x1f".join(parts))
335
402
  gh-url)
336
403
  OWNER=$(printf '%s' "$INPUT" | sed -E 's#https?://github.com/##; s#/.*##')
337
404
  REPO=$(printf '%s' "$INPUT" | sed -E 's#https?://github.com/[^/]+/##; s#/.*##')
338
- NUM=$(printf '%s' "$INPUT" | sed -E 's#.*/issues/##; s#[/?].*##')
405
+ NUM=$(printf '%s' "$INPUT" | sed -E 's#.*/issues/##; s#[/?#].*##')
339
406
  raw=$(fetch_github "$OWNER" "$REPO" "$NUM") || raw=""
340
407
  if [ -n "$raw" ]; then
341
408
  # Single python3 pass - see jira-id branch for rationale on the
@@ -137,7 +137,7 @@ MERMAID_FENCE_OPEN_RE = re.compile(r"^```mermaid\s*$")
137
137
  def mermaid_macro(body: str) -> str:
138
138
  return (
139
139
  '<ac:structured-macro ac:name="mermaid">'
140
- f'<ac:plain-text-body><![CDATA[\n{body}\n]]></ac:plain-text-body>'
140
+ f'<ac:plain-text-body><![CDATA[\n{cdata_escape(body)}\n]]></ac:plain-text-body>'
141
141
  '</ac:structured-macro>'
142
142
  )
143
143
 
@@ -205,6 +205,20 @@ def esc(s: str) -> str:
205
205
  return html.escape(s, quote=False)
206
206
 
207
207
 
208
+ def cdata_escape(s: str) -> str:
209
+ """Make `s` safe to embed inside a CDATA section.
210
+
211
+ A code fence or Mermaid diagram can legitimately contain the literal
212
+ three-character sequence "]]>" (a real XML CDATA example, a shell case
213
+ pattern, ...), which is the CDATA terminator - embedding it unescaped
214
+ closes the section early and leaves the rest of the storage-format XML
215
+ unparsed as markup. Splitting it across a close/reopen pair is the
216
+ standard CDATA-in-CDATA escape: it renders identically, but is no longer
217
+ a literal terminator to the XML parser.
218
+ """
219
+ return s.replace("]]>", "]]]]><![CDATA[>")
220
+
221
+
208
222
  @dataclass
209
223
  class InlineContext:
210
224
  attachments_available: set[str]
@@ -388,7 +402,7 @@ def markdown_to_storage(
388
402
  out.append(
389
403
  '<ac:structured-macro ac:name="code">'
390
404
  f'<ac:parameter ac:name="language">{esc(code_lang)}</ac:parameter>'
391
- f'<ac:plain-text-body><![CDATA[{body}]]></ac:plain-text-body>'
405
+ f'<ac:plain-text-body><![CDATA[{cdata_escape(body)}]]></ac:plain-text-body>'
392
406
  '</ac:structured-macro>'
393
407
  )
394
408
  i += 1