@mmerterden/multi-agent-pipeline 14.1.1 → 14.2.2

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 (129) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +10 -6
  3. package/README.tr.md +143 -0
  4. package/docs/architecture.md +23 -8
  5. package/docs/ecosystem.md +237 -0
  6. package/install/_plugin-skills.mjs +16 -2
  7. package/install/codex.mjs +9 -4
  8. package/install/templates/copilot-instructions.md +12 -9
  9. package/package.json +1 -1
  10. package/pipeline/commands/deploy.md +4 -1
  11. package/pipeline/commands/multi-agent/SKILL.md +8 -5
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  13. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  14. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  15. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  16. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  17. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  18. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  19. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  20. package/pipeline/commands/multi-agent/help/SKILL.md +49 -11
  21. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  22. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  23. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  24. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  25. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  26. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
  27. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  28. package/pipeline/commands/multi-agent/store-ready/SKILL.md +340 -0
  29. package/pipeline/commands/multi-agent/sync/SKILL.md +7 -6
  30. package/pipeline/commands/multi-agent/test/SKILL.md +18 -8
  31. package/pipeline/commands/multi-agent/test-accessibility/SKILL.md +33 -0
  32. package/pipeline/commands/multi-agent/test-dark-mode/SKILL.md +33 -0
  33. package/pipeline/commands/multi-agent/test-dynamic-type/SKILL.md +33 -0
  34. package/pipeline/commands/multi-agent/test-screenshots/SKILL.md +41 -0
  35. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +28 -201
  36. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  37. package/pipeline/commands/sim-test.md +45 -36
  38. package/pipeline/lib/extract-conventions.sh +44 -15
  39. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  40. package/pipeline/lib/fetch-fortify.sh +23 -8
  41. package/pipeline/lib/figma-screenshot.sh +11 -1
  42. package/pipeline/lib/issue-fetcher.sh +76 -9
  43. package/pipeline/lib/md2confluence-v3.py +16 -2
  44. package/pipeline/lib/plan-todos.sh +5 -2
  45. package/pipeline/lib/post-pr-review.sh +8 -6
  46. package/pipeline/lib/shadow-git.sh +50 -9
  47. package/pipeline/lib/submodule-detector.sh +8 -1
  48. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  49. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  50. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  51. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  52. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  53. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  54. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  55. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
  56. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  57. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  58. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  59. package/pipeline/multi-agent-refs/knowledge.md +1 -1
  60. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  61. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  62. package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -2
  63. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  64. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  65. package/pipeline/multi-agent-refs/phases.md +6 -0
  66. package/pipeline/multi-agent-refs/rules.md +2 -0
  67. package/pipeline/schemas/prefs.schema.json +2 -2
  68. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  69. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  70. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  71. package/pipeline/scripts/diff-explain.mjs +41 -3
  72. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  73. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  74. package/pipeline/scripts/learning-curve.mjs +8 -2
  75. package/pipeline/scripts/output-quality-check.sh +15 -4
  76. package/pipeline/scripts/phase-tracker.sh +21 -8
  77. package/pipeline/scripts/pre-commit-check.sh +69 -22
  78. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  79. package/pipeline/scripts/render-cost-summary.sh +42 -22
  80. package/pipeline/scripts/render-work-summary.sh +47 -13
  81. package/pipeline/scripts/review-scope.mjs +1 -1
  82. package/pipeline/scripts/run-aggregator.mjs +38 -14
  83. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  84. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  85. package/pipeline/scripts/uninstall.mjs +39 -4
  86. package/pipeline/scripts/update-issue-progress.sh +12 -16
  87. package/pipeline/scripts/worktree-finalize.sh +23 -2
  88. package/pipeline/skills/.skills-index.json +58 -4
  89. package/pipeline/skills/shared/README.md +12 -7
  90. package/pipeline/skills/shared/core/multi-agent/SKILL.md +13 -17
  91. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  92. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  93. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  94. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  95. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  96. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  97. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +50 -12
  98. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  99. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  100. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  101. package/pipeline/skills/shared/core/multi-agent-purge/SKILL.md +18 -3
  102. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  103. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +50 -0
  104. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +6 -5
  105. package/pipeline/skills/shared/core/multi-agent-test/SKILL.md +18 -8
  106. package/pipeline/skills/shared/core/multi-agent-test-accessibility/SKILL.md +37 -0
  107. package/pipeline/skills/shared/core/multi-agent-test-dark-mode/SKILL.md +37 -0
  108. package/pipeline/skills/shared/core/multi-agent-test-dynamic-type/SKILL.md +37 -0
  109. package/pipeline/skills/shared/core/multi-agent-test-screenshots/SKILL.md +44 -0
  110. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +29 -101
  111. package/pipeline/skills/shared/external/firebase/SKILL.md +1 -1
  112. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  113. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  114. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  115. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  116. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  117. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  118. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  119. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  121. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  122. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  123. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  124. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  125. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  126. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  127. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  128. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  129. package/pipeline/skills/skills-index.md +10 -4
@@ -0,0 +1,67 @@
1
+ ---
2
+ description: "Canonical required-reading list for outward-facing payloads (PR body, Jira comment, closing report) plus the markup dialect per surface. Loaded by every mode that runs Phase 6 or Phase 7."
3
+ ---
4
+
5
+ # Outward-facing payload contracts
6
+
7
+ > Every mode that opens a PR, comments on a tracker, or closes out a run reads this file first. It does not restate the contracts - it names them, so no mode has to carry its own copy and drift from the others.
8
+
9
+ ## Read before Phase 6
10
+
11
+ | Read | Before | Governs |
12
+ |---|---|---|
13
+ | [`channels/pr.md`]($HOME/.claude/multi-agent-refs/channels/pr.md) | assembling the PR body | fixed section set (`summary` → `changes` → `architecture` cond. → `verification` → `dependencies` cond. → `related`), Markdown-only rule, reviewer-preserving Bitbucket PUT payload |
14
+ | [`phases/phase-6-commit.md`]($HOME/.claude/multi-agent-refs/phases/phase-6-commit.md) | committing | commit convention, default-reviewer fetch, draft/ready prompt, push-must-succeed loop |
15
+ | [`rules.md`]($HOME/.claude/multi-agent-refs/rules.md) "External System Outputs" | any REST payload | real newlines, no HTML entities, no hand-rolled JSON, markup dialect per surface |
16
+
17
+ ## Read before Phase 7
18
+
19
+ | Read | Before | Governs |
20
+ |---|---|---|
21
+ | [`channels/jira.md`]($HOME/.claude/multi-agent-refs/channels/jira.md) | posting the Jira comment | fixed section set incl. **Test Scenarios** (Given/When/Then, always present), markdown→wiki conversion table |
22
+ | [`channels/confluence.md`]($HOME/.claude/multi-agent-refs/channels/confluence.md) | writing a Confluence page | storage-format conversion, endpoint flavor |
23
+ | [`phases/phase-7-report.md`]($HOME/.claude/multi-agent-refs/phases/phase-7-report.md) | closing out | Timeline + Agent Activity + Cost Breakdown tables, missing-telemetry disclosure |
24
+ | [`tracker-contract.md`]($HOME/.claude/multi-agent-refs/tracker-contract.md) | every phase boundary | per-phase token narration, completion tile suffix |
25
+
26
+ ## Markup dialect per surface
27
+
28
+ Language is not the only axis an external payload has. Every surface also has a markup dialect, and they are not interchangeable - the same body shipped to the wrong dialect renders as visible garbage, not as a slightly-off style.
29
+
30
+ | Surface | Dialect | Converter | Section set owned by |
31
+ |---|---|---|---|
32
+ | PR description (GitHub / Bitbucket / GitLab) | **Markdown**, no conversion | none - post the assembled markdown verbatim | `channels/pr.md` |
33
+ | GitHub issue body + comment | **Markdown**, no conversion | none | `channels/issue-comment.md` |
34
+ | Jira comment + issue description | **Jira wiki markup** | the table in `channels/jira.md`, applied by the model - no program exists | `channels/jira.md` |
35
+ | Confluence page body | **storage format** (XHTML) | `lib/md2confluence-v3.py` | `channels/confluence.md` |
36
+ | Wiki pages (`.md` files in a git repo) | **Markdown** | none | `channels/wiki.md` |
37
+ | Commit message | plain text | none | `rules/git-conventions.md` |
38
+
39
+ Assemble every body once in Markdown, then convert **only** on the branches that require it. The recurring defect is the reverse: the Jira wiki table is the only text-markup table in the doc set, so it reads like the default and gets applied to a PR body. In a Markdown target, `h2. Title` renders as literal text, `{{identifier}}` keeps its braces, `#` starts an H1 instead of a numbered list, and `*bold*` comes out italic. In a Jira target the mirror-image failure applies: `## Heading` and `**bold**` render literally.
40
+
41
+ A payload that is in the right language but the wrong dialect is a defect of the same severity as one that never posted. Both need a fix, not a follow-up ticket.
42
+
43
+ ## Closing report (required)
44
+
45
+ The run ends with the phase tracker glyph block **and** the numbers behind it - per-phase duration and token spend, plus totals.
46
+
47
+ - Record spend as you go: `phase-tracker.sh tokens <N> <in> <out> [cached]` after **every** LLM call, including each Phase 4 reviewer subagent and each Phase 3 chunk. Counts are additive and nothing reconstructs them after the fact.
48
+ - Tag the model once per phase (`phase-tracker.sh model <N> <name>`) or the cost helper cannot price it and prints `-`.
49
+ - Durations come from the phase timestamps and survive a missed `tokens` call; token spend does not.
50
+ - If any phase has no token data, name those phases and say their cost is unavailable. Never print a report whose cost section is simply absent - the reader cannot tell "cheap run" from "nobody recorded it".
51
+
52
+ ### Missing-telemetry disclosure (Phase 7, required)
53
+
54
+ Before composing the report, compute which phases carry no token data:
55
+
56
+ ```bash
57
+ STATE="$HOME/.claude/logs/multi-agent/$TASK_ID/tracker-state.json"
58
+ UNTRACKED=$(jq -r '[.phases[] | select((.tokens_in // 0) + (.tokens_out // 0) == 0) | .id] | join(", ")' "$STATE")
59
+ ```
60
+
61
+ Non-empty `UNTRACKED` → both the agent-log report and the closing chat summary carry one line in `outputLanguage` naming those phase ids as cost-unavailable. Durations come from the phase timestamps and are reported either way.
62
+
63
+ `smoke-tracker-tokens-invocation.sh` only lints that the phase docs *mention* `phase-tracker.sh tokens`. It cannot observe a run that read the doc and skipped the call, so this run-time disclosure is the only thing between a dropped `tokens` call and an invisible failure.
64
+
65
+ ## Fast modes are not exempt
66
+
67
+ `--dev`, `--dev --local`, and the autopilot variants skip Analysis, Planning, and the interactive test gate. They run Phase 6 and Phase 7 **unchanged**. A short pipeline is not a licence for an improvised payload shape, a missing Test Scenarios section, or a report without numbers.
@@ -105,6 +105,26 @@ No separate task breakdown - the agent handles scope autonomously.
105
105
 
106
106
  **Combinable with autopilot**: `--dev autopilot` = fastest path. Init -> Dev -> Review (auto-fix) -> auto-commit -> auto-PR -> Report (Phase 5 skipped). Zero user interaction (except build failures after 3 retries, and review findings that survive 3 rework cycles).
107
107
 
108
+ ### Intake warnings shared by the whole `--dev` family
109
+
110
+ These apply to `--dev`, `--dev autopilot`, `--dev local`, and `--dev local autopilot` alike. A mode entry doc may not carry its own copy - it points here.
111
+
112
+ **An analysis document was supplied.** Because Phase 1 and Phase 2 are skipped, no phase turns that document into a task breakdown. The doc becomes raw context for one Dev pass, and work comes out ordered by whatever the model read first: the bottom of the dependency chain lands, the screen wiring does not.
113
+
114
+ Say so before starting, once, and offer the choice:
115
+
116
+ ```
117
+ This mode skips Analysis and Planning, so the analysis document will not be turned
118
+ into a task breakdown. For analysis-driven screen work, /multi-agent or
119
+ /multi-agent:local run both phases.
120
+ 1. Continue with --dev (doc as context only)
121
+ 2. Switch to the full pipeline
122
+ ```
123
+
124
+ Autopilot picks 1 and logs the warning rather than asking.
125
+
126
+ **The branch already carries the work.** When the change was developed outside the pipeline, or by hand, the `--dev` family is the wrong entry point: it will try to develop again. `/multi-agent:ship` puts the existing diff through the same review, adds a build+test success gate, opens the PR, and posts the Jira technical-analysis + test-scenario comment - without re-developing. Offer it when the working tree or branch is already ahead of the base with the task's changes.
127
+
108
128
  ---
109
129
 
110
130
  ## Local Mode (`--local`)
@@ -35,7 +35,7 @@ Read preferences: `PREFS_FILE="$HOME/.claude/multi-agent-preferences.json"` (if
35
35
  OUTPUT_LANG=$(jq -r '.global.outputLanguage // "en"' "$PREFS_FILE" 2>/dev/null || echo en)
36
36
  ```
37
37
 
38
- From this point on, every assistant-authored conversational line (status updates, findings, phase headers, summaries, error explanations) renders in `$OUTPUT_LANG`. `AskUserQuestion` payloads stay English. External payloads stay English. See `$HOME/.claude/multi-agent-refs/rules.md` "Language Application" for the full contract. Skipping this step is the failure mode where the user sees an English wall of text in a Turkish session.
38
+ From this point on, everything the user reads renders in `$OUTPUT_LANG`: conversational lines, `AskUserQuestion` `question`/`description`, and external payload bodies (PR/Jira/Confluence). English stays only on `label`/`header`, commit messages, branch names, PR titles, identifiers. Full matrix: `rules.md` "Language Application". Skipping this step is why a Turkish session gets an English wall of text - or an English picker.
39
39
 
40
40
  **Model fallback date gate** (same step, once per run): read `prefs.global.modelFallback`. If `premiumTierUntil` is set and in the past, apply the date-gate trigger from `$HOME/.claude/multi-agent-refs/features/model-fallback.md` - `preferredModel` personas dispatch on `fallbackModel` for this run, with the one-line WARN. Dispatch-error and budget triggers in that contract apply per-dispatch later; nothing else to do here.
41
41
 
@@ -406,7 +406,7 @@ git -C $PROJECT_ROOT config user.email "{identity.email}"
406
406
 
407
407
  `worktreePath` = `$PROJECT_ROOT`, `localMode` = `true`.
408
408
 
409
- **If normal mode** (worktree - default): 2. Worktree path: Jira → `.worktrees/{jiraId}/`, GitHub → `.worktrees/GH{issueNo}/`, free-text → `.worktrees/task-{shortId}/` 3. **Heal stale admin state first** (see "Worktree stale-lock heal" below) and **apply the residue guard** (see "Worktree residue guard" below), then `git -C $PROJECT_ROOT worktree add {path} -b {branch} origin/{baseBranch}` (if exists: enter, pull) 4. Set identity: `git -C {worktree-path} config user.name/email` 5. Create log dir + `agent-log.md` + `agent-state.json`:
409
+ **If normal mode** (worktree - default): 2. Worktree path: Jira → `.worktrees/{jiraId}/`, GitHub → `.worktrees/GH{issueNo}/`, free-text → `.worktrees/task-{shortId}/` 3. **Heal stale admin state first** (see "Worktree stale-lock heal" below) and **apply the residue guard** (see "Worktree residue guard" below), then `git -C $PROJECT_ROOT worktree add {path} -b {branch} origin/{baseBranch}` (if exists: enter, pull) 4. Set identity: `git -C {worktree-path} config user.name/email` 5. Create log dir + `agent-log.md` + `agent-state.json` at `$HOME/.claude/logs/multi-agent/{project}/{task-id}/`, never inside the worktree:
410
410
 
411
411
  **Worktree stale-lock heal (required before every `worktree add`):** a run killed mid-`worktree add` (OOM, SIGTERM, disk full) leaves a locked or broken admin entry under `.git/worktrees/{id}/`, so the retry fails with `fatal: '<path>' already exists`. Always run the heal first - it is a no-op on a clean repo:
412
412
 
@@ -106,6 +106,8 @@ Branch **deterministically**, no implicit fallback. Read `agent-state.json` and
106
106
 
107
107
  #### Step 3 - PR Description (technical detail for reviewers)
108
108
 
109
+ **Section set + markup dialect: `channels/pr.md` - read it first.** Phase 7 channels replaces this body with that section set, so build to it. Required reading: `payload-contracts.md`.
110
+
109
111
  Generate a structured PR description based on task type. The PR body targets **code reviewers** - it should be technical: what changed, why, architecture decisions, how to verify.
110
112
 
111
113
  **required**: Run all generated text (PR body, commit message) through the `humanizer` skill before posting. This removes AI-generated patterns (inflated language, filler phrases, repetitive structure) and makes the output sound like a developer wrote it.
@@ -116,49 +118,15 @@ Generate a structured PR description based on task type. The PR body targets **c
116
118
  - Bitbucket: similar patterns
117
119
  - Use `Ref: #N`, `Related: #N`, or `See: {jiraId}` instead
118
120
 
119
- **Default Reviewers**: If `prefs.projects[{project}].defaultReviewers` exists, add `--reviewer` flags. If not, ask on first PR and save to preferences:
120
-
121
- ```
122
- Default reviewers for this project? (comma-separated GitHub usernames, or skip):
123
- ```
124
-
125
- Save to `prefs.projects[{project}].defaultReviewers: ["user1", "user2", "user3", "user4"]`
121
+ **Default Reviewers**: use `prefs.projects[{project}].defaultReviewers` when set; otherwise ask once ("Default reviewers for this project? comma-separated, or skip") and persist the answer there. Per-remote resolution rules are in "Default reviewers (required on every PR)" below - this is the prefs half only.
126
122
 
127
- **Standard PR (bug fix, feature, refactor):**
123
+ **Standard PR (bug fix, feature, refactor)** - invocation shape; body comes from a file so newlines survive:
128
124
 
129
125
  ```bash
130
126
  REVIEWERS=$(jq -r '.projects["'$PROJECT'"].defaultReviewers // [] | map("--reviewer " + .) | join(" ")' "$PREFS_FILE")
131
127
  gh pr create --base {baseBranch} --head {branch} $REVIEWERS \
132
128
  --title "{type}({scope}): {summary} [{jiraId}]" \
133
- --body "$(cat <<'EOF'
134
- ## Summary
135
- {1-3 bullet points: what changed and why}
136
-
137
- ## Jira
138
- {jiraId} - {jira summary or link}
139
-
140
- ## Technical Details
141
- ### Changes
142
- {For each changed file group, explain WHAT changed and WHY}
143
-
144
- ### Architecture
145
- {Any structural decisions: new files, moved code, pattern changes}
146
-
147
- ### Dependencies
148
- {New imports, framework changes, package updates - or "None"}
149
-
150
- ## How to Test
151
- 1. {Step-by-step manual test instructions}
152
- 2. {Verify specific behavior}
153
-
154
- ## Checklist
155
- - [ ] Build passes (0 errors, 0 warnings)
156
- - [ ] Tests pass
157
- - [ ] No hardcoded values (tokens used)
158
- - [ ] Accessibility verified
159
- - [ ] Dark mode verified
160
- EOF
161
- )"
129
+ --body-file /tmp/pr-body-$TASK_ID.md
162
130
  ```
163
131
 
164
132
  **UI Component PR (when SwiftUI component detected):**
@@ -175,9 +143,9 @@ Same `gh pr create` pattern with component-specific body sections:
175
143
 
176
144
  CRITICAL: PR description **MUST** preserve real newlines. Never embed `\n` literally - Bitbucket saves it verbatim. Never use HTML entities. Pattern: heredoc → `jq --rawfile` → `curl --data-binary @file`.
177
145
 
178
- Required payload fields: `title`, `description` (via rawfile), `fromRef` (id + repo slug + project key), `toRef` (same structure), `state: "OPEN"`, `open: true`, `closed: false`, `locked: false`, `reviewers: []`.
146
+ Required payload fields: `title`, `description` (via rawfile), `fromRef` (id + repo slug + project key), `toRef` (same structure), `state: "OPEN"`, `open: true`, `closed: false`, `locked: false`, `reviewers` (the fetched list - `[]` only if that endpoint really returned empty).
179
147
 
180
- To **update** an existing PR: fetch `version`, PUT with same rawfile pattern (version +1). GitHub: prefer `--body-file` over inline `--body`.
148
+ To **update** an existing PR: use the `channels/pr.md` PUT payload - required, not illustrative. It carries `reviewers`/`fromRef`/`toRef`/`draft` (a PUT clears omitted fields) and the GET's `version` unmodified (`version + 1` → 409). Refetch after, assert the reviewer count held. GitHub: `--body-file`.
181
149
 
182
150
  **Default reviewers (required on every PR)**
183
151
 
@@ -307,7 +275,7 @@ update_sibling_links "${PR_URLS[@]}"
307
275
 
308
276
  **Then finalize each repo's worktree**, serially and only now - `update_sibling_links` updates one PR per repo and the loop `cd`s per repo, so removing repo 1's worktree mid-loop breaks 2..N. Run `worktree-finalize.sh` once per repo with that repo's `--worktree` / `--project-root` / `--project`; each skips or removes independently.
309
277
 
310
- `update_sibling_links` does an UPDATE call per PR (Bitbucket: PUT with `version+1`; GitHub: `gh pr edit --body-file`). Idempotent: re-running with the same set is a no-op.
278
+ `update_sibling_links` does an UPDATE call per PR (Bitbucket: the `channels/pr.md` PUT payload, current `version`; GitHub: `gh pr edit --body-file`). Idempotent.
311
279
 
312
280
  ##### GitHub issue body - list all PRs
313
281
 
@@ -153,8 +153,8 @@ Skipped sections: when `planTodos.enabled` is false or no `plan.todos[]` was emi
153
153
 
154
154
  ## Timeline
155
155
 
156
- | # | Phase | Model | Duration | Status | Detail |
157
- | --- | ----- | ----- | -------- | ------ | ------ |
156
+ | # | Phase | Agent | Model | Duration | Tokens (in/out) | Status | Detail |
157
+ | --- | ----- | ----- | ----- | -------- | --------------- | ------ | ------ |
158
158
 
159
159
  ## Agent Activity Report
160
160
 
@@ -200,7 +200,7 @@ $HOME/.claude/scripts/log-metric.sh "$TASK_ID" 7 task.completed \
200
200
 
201
201
  **Per-run outcome metrics (evidence corpus):** also emit `run-metrics.mjs --state <agent-state.json>` and append/persist its JSON. It records the numbers that actually answer "did this run go well" - review iterations (rework loops), first-pass-clean, reviewer signal-to-noise (accepted / raw findings), consensus verdict, build outcome. Accumulating these across real runs is the real-world validation that golden tasks + benchmarks only approximate; keep the corpus so the pipeline's quality can be measured, not asserted.
202
202
 
203
- **Cost Breakdown emission (mandatory):** as part of agent-log compose, append the per-task cost block produced by `render-agent-log-cost.sh`. Best-effort - exit 2 (no tracker data) silently skipped:
203
+ **Cost Breakdown emission (mandatory):** as part of agent-log compose, append the per-task cost block produced by `render-agent-log-cost.sh`:
204
204
 
205
205
  ```bash
206
206
  COST_BLOCK=$(bash $HOME/.claude/scripts/render-agent-log-cost.sh "$TASK_ID" 2>/dev/null) && \
@@ -209,6 +209,8 @@ COST_BLOCK=$(bash $HOME/.claude/scripts/render-agent-log-cost.sh "$TASK_ID" 2>/d
209
209
 
210
210
  This is independent of the channels-side `reportContent.costSummary` (which gates the PR-body Cost Summary). agent-log always carries the breakdown when telemetry is present.
211
211
 
212
+ **Missing-telemetry disclosure (required).** Name untracked phase ids as cost-unavailable in the report and closing summary. Mechanic: `payload-contracts.md`.
213
+
212
214
  **Triage memory ingest (mandatory):** after Phase 4 produces a final triage output, persist the accepted/deferred/rejected rows into the per-repo triage corpus so Phase 1 enrichment and Phase 4 prior-art lookup can recall them on future tasks. Idempotent - re-running on the same task writes 0 rows.
213
215
 
214
216
  ```bash
@@ -63,6 +63,12 @@ $HOME/.claude/scripts/phase-tracker.sh update <N> failed # phase failed
63
63
  $HOME/.claude/scripts/phase-tracker.sh update <N> skipped # --dev mode etc.
64
64
  ```
65
65
 
66
+ After every LLM call (counts are additive; skipping this is why runs end with durations but no cost - nothing reconstructs spend afterwards):
67
+
68
+ ```bash
69
+ $HOME/.claude/scripts/phase-tracker.sh tokens <N> <in> <out> [cached]
70
+ ```
71
+
66
72
  For sub-phase progress (e.g. Phase 1's parallel Explore agents, Phase 4's reviewer dispatch + triage + validator gate, Phase 6's commit + push + PR sub-steps):
67
73
 
68
74
  ```bash
@@ -122,6 +122,8 @@ This is the single source of truth. When a contributor or model is unsure where
122
122
 
123
123
  4. **Terminal output, log lines, and `echo`/`printf` calls** also use real newlines - never `\n` literals. For multi-line messages, prefer heredoc.
124
124
 
125
+ 5. **Markup dialect is a second axis beyond language.** Markdown: PR body, GitHub issue, wiki files. Jira: wiki markup. Confluence: storage format. Wrong dialect renders literally. Table: `payload-contracts.md`.
126
+
125
127
  ## Issue Management
126
128
 
127
129
  - **NEVER auto-close** GitHub issues or Jira tickets. Closure requires team review (configurable, typically 4 approvals).
@@ -184,7 +184,7 @@
184
184
  },
185
185
  "appstore_connect_key_id": {
186
186
  "type": ["string", "null"],
187
- "description": "App Store Connect API key ID. Tier 1 of the App Store Connect access chain, used by /multi-agent:testflight-validation Gate 2. An identifier rather than a secret; mapped anyway so every credential is read through the same layer. Creating an API key needs an Admin or App Manager role, which is why Tier 2 exists."
187
+ "description": "App Store Connect API key ID. Tier 1 of the App Store Connect access chain, used by /multi-agent:store-ready Gate 2 (and its iOS alias /multi-agent:testflight-validation). An identifier rather than a secret; mapped anyway so every credential is read through the same layer. Creating an API key needs an Admin or App Manager role, which is why Tier 2 exists."
188
188
  },
189
189
  "appstore_connect_issuer_id": {
190
190
  "type": ["string", "null"],
@@ -670,7 +670,7 @@
670
670
  "type": "string",
671
671
  "enum": ["en", "tr"],
672
672
  "default": "en",
673
- "description": "Language used for user-facing INTERACTIVE pipeline copy: pickers (account, project, dev-context, base branch, branch name, maturity ack, channels), Phase 5 test prompt, Phase 6 local-checkout prompt, Phase 6 commit/PR confirmations, TaskCreate card subjects, and phase-banner.sh headers (`PHASE_LANG` resolves from this field via the `auto` sentinel). Canonical label table lives in `pipeline/skills/shared/multi-agent/SKILL.md`. External payloads stay English regardless of this setting: reviewer/triage system prompts, commit messages, PR titles/bodies, Jira comments, wiki content, agent-log.md payloads. Set via `/multi-agent:language prompt <en|tr>` or answered on first run by `/multi-agent:setup`."
673
+ "description": "Locked to 'en': the language of LLM-facing instructions (spec files, agent system prompts, reviewer/triage prompts) and picker structural chrome (AskUserQuestion label/header). What humans read is governed by outputLanguage instead - picker question/description, conversational lines, and external payload bodies (PR description, Jira comment, Confluence/Wiki). Always English regardless of either pref: commit messages, branch names, PR titles, code identifiers, agent-log.md. Canonical matrix: multi-agent-refs/rules.md 'Language Application'. Set via /multi-agent:language or /multi-agent:setup."
674
674
  },
675
675
  "outputLanguage": {
676
676
  "type": "string",
@@ -80,6 +80,16 @@ if [ -s "$AUDIT_FILE" ]; then
80
80
  if [ -f "$ROTATED" ] && command -v gzip >/dev/null 2>&1; then
81
81
  gzip -f "$ROTATED" 2>/dev/null || true
82
82
  fi
83
+
84
+ # gzip preserves the source file's mtime by default, so an archive rotated
85
+ # from log content already older than KEEP_DAYS would carry that same old
86
+ # mtime - and the retention sweep below deletes anything past KEEP_DAYS,
87
+ # so a just-created archive could be purged in this very run before ever
88
+ # being read. Retention has to count from rotation time, not from the age
89
+ # of the content it archived. gzip -f removes the plain file on success, so
90
+ # only touch whichever of the two actually exists.
91
+ [ -f "$ROTATED.gz" ] && touch "$ROTATED.gz"
92
+ [ -f "$ROTATED" ] && touch "$ROTATED"
83
93
  fi
84
94
 
85
95
  # Drop archives older than KEEP_DAYS
@@ -61,6 +61,7 @@ const COMMON_SKILLS = [
61
61
  "agent-introspection-debugging",
62
62
  "skill-creator",
63
63
  "backlog",
64
+ "localization-reuse-map",
64
65
  ];
65
66
 
66
67
  // Apple/Xcode-only skills that match no stack pattern → iOS plugin only.
@@ -134,7 +135,13 @@ function treeFingerprint(dir) {
134
135
  // --- 1. enumerate authoring source ---------------------------------------
135
136
  if (!existsSync(EXTERNAL)) {
136
137
  console.error(`source not found: ${EXTERNAL}`);
137
- process.exit(0);
138
+ // --check-routing exists so CI can gate routing correctness without a
139
+ // checkout of the plugins repo, reading only this authoring source. Exiting
140
+ // 0 here regardless of --check-routing meant a misconfigured/missing
141
+ // source (wrong --pipeline path, an incomplete CI checkout) reported the
142
+ // gate as passing having audited nothing - the exact silent-green failure
143
+ // the gate exists to prevent elsewhere.
144
+ process.exit(args.includes("--check-routing") ? 1 : 0);
138
145
  }
139
146
  const allSkills = listDirs(EXTERNAL);
140
147
 
@@ -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"));
@@ -116,7 +128,7 @@ function fromCache(entry) {
116
128
  if (!existsSync(dir)) return null;
117
129
  const versions = readdirSync(dir)
118
130
  .filter((d) => /^\d+\.\d+\.\d+$/.test(d))
119
- .sort();
131
+ .sort(semverCompare);
120
132
  const newest = versions[versions.length - 1];
121
133
  return newest
122
134
  ? { version: newest, source: "plugin cache", authoritative: false, behind: null }
@@ -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;
@@ -104,9 +104,9 @@ function loadNumstat() {
104
104
  const map = new Map();
105
105
  let cur = null;
106
106
  for (const line of text.split("\n")) {
107
- const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
108
- if (m) {
109
- cur = m[2];
107
+ const p = matchDiffGitHeader(line);
108
+ if (p !== null) {
109
+ cur = p;
110
110
  if (!map.has(cur)) map.set(cur, { added: 0, removed: 0 });
111
111
  continue;
112
112
  }
@@ -122,7 +122,7 @@ function loadNumstat() {
122
122
  return out.split("\n").map((line) => {
123
123
  const [a, r, ...rest] = line.split("\t");
124
124
  return {
125
- path: rest.join("\t"),
125
+ path: unquotePathIfNeeded(rest.join("\t")),
126
126
  added: a === "-" ? 0 : Number(a) || 0,
127
127
  removed: r === "-" ? 0 : Number(r) || 0,
128
128
  };
@@ -162,6 +162,60 @@ const TEST_PATH_GLOBS = [
162
162
  /\.spec\.(ts|tsx|js|mjs|py)$/i,
163
163
  ];
164
164
 
165
+ // git quotes a path in a `diff --git` header (core.quotePath, on by default)
166
+ // whenever it contains a non-ASCII byte or other unusual character -
167
+ // "diff --git \"a/\\303\\226deme.swift\" \"b/\\303\\226deme.swift\"" for a
168
+ // Turkish filename, not the bare `a/`/`b/` form. `/^diff --git a\/(.+?)
169
+ // b\/(.+)$/` doesn't match a line starting with a quote at all, so every
170
+ // non-ASCII-named file was silently dropped from risk scoring.
171
+ const DIFF_GIT_QUOTED_RE = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/;
172
+ const DIFF_GIT_UNQUOTED_RE = /^diff --git a\/(.+?) b\/(.+)$/;
173
+
174
+ // Reverse git's C-style quoting: octal byte escapes (\NNN, one per raw byte -
175
+ // a multi-byte UTF-8 character is several consecutive \NNN triplets) plus
176
+ // the standard \\ \" \t \n \r escapes.
177
+ function unquoteGitPath(s) {
178
+ const bytes = [];
179
+ for (let i = 0; i < s.length; i++) {
180
+ if (s[i] === "\\") {
181
+ const octal = s.slice(i + 1, i + 4);
182
+ if (/^[0-7]{3}$/.test(octal)) {
183
+ bytes.push(parseInt(octal, 8));
184
+ i += 3;
185
+ continue;
186
+ }
187
+ const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
188
+ const next = s[i + 1];
189
+ if (next in simple) {
190
+ bytes.push(simple[next]);
191
+ i += 1;
192
+ continue;
193
+ }
194
+ }
195
+ bytes.push(s.charCodeAt(i));
196
+ }
197
+ return Buffer.from(bytes).toString("utf-8");
198
+ }
199
+
200
+ // `git diff --numstat` quotes a non-ASCII/unusual path the same C-style way
201
+ // as the `diff --git` header line (e.g. `"\303\226deme.swift"`), just
202
+ // without the surrounding "diff --git a/...b/..." context to detect it by.
203
+ function unquotePathIfNeeded(s) {
204
+ if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
205
+ return unquoteGitPath(s.slice(1, -1));
206
+ }
207
+ return s;
208
+ }
209
+
210
+ // Returns the b/-side path from a `diff --git` header line, or null if the
211
+ // line isn't one. Handles both the quoted and unquoted forms.
212
+ function matchDiffGitHeader(line) {
213
+ const q = line.match(DIFF_GIT_QUOTED_RE);
214
+ if (q) return unquoteGitPath(q[2]);
215
+ const u = line.match(DIFF_GIT_UNQUOTED_RE);
216
+ return u ? u[2] : null;
217
+ }
218
+
165
219
  function matchesAny(path, regexes) {
166
220
  return regexes.some((r) => r.test(path));
167
221
  }
@@ -170,6 +224,16 @@ function isTestPath(path) {
170
224
  return matchesAny(path, TEST_PATH_GLOBS);
171
225
  }
172
226
 
227
+ // pairedTestPaths only knows how to derive a test-file name for these
228
+ // extensions; for anything else (README.md, package.json, .yaml configs,
229
+ // lockfiles, ...) it falls back to the untouched filename as `base` with an
230
+ // empty `ext`, producing candidates like "README.mdTests." that can never
231
+ // match a real path. Gating no_test_change on this is what "source touched"
232
+ // in the comment above actually means - without it, the signal fired on
233
+ // every non-test file in a diff, README included, which made HIGH_STAKES in
234
+ // review-scope.mjs unavoidable for almost any real-world diff.
235
+ const PAIRABLE_SOURCE_EXT_RE = /\.(swift|kt|ts|tsx|js|mjs|py)$/;
236
+
173
237
  function pairedTestPaths(srcPath) {
174
238
  // Return likely test path candidates for a source file.
175
239
  const parts = srcPath.split("/");
@@ -189,9 +253,9 @@ function parseHunks(diffText) {
189
253
  const perFile = new Map();
190
254
  let cur = null;
191
255
  for (const line of diffText.split("\n")) {
192
- const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
193
- if (m) {
194
- cur = m[2];
256
+ const p = matchDiffGitHeader(line);
257
+ if (p !== null) {
258
+ cur = p;
195
259
  if (!perFile.has(cur)) perFile.set(cur, []);
196
260
  continue;
197
261
  }
@@ -271,7 +335,7 @@ function buildRow(stat, addedLines, allChangedPaths) {
271
335
  }
272
336
 
273
337
  // No-test-change: source touched + no matching test file in the changed set.
274
- if (!isTestPath(path)) {
338
+ if (!isTestPath(path) && PAIRABLE_SOURCE_EXT_RE.test(path)) {
275
339
  const candidates = pairedTestPaths(path).map((c) => c.toLowerCase());
276
340
  const anyTest = allChangedPaths.some((p) => {
277
341
  if (!isTestPath(p)) return false;
@@ -145,7 +145,7 @@ bash $HOME/.claude/scripts/phase-tracker.sh update 0 in_progress
145
145
  bash $HOME/.claude/scripts/phase-tracker.sh update <N> in_progress|completed|failed|skipped
146
146
 
147
147
  # After every LLM call (every CLI):
148
- bash $HOME/.claude/scripts/phase-tracker.sh tokens <N> <in> <out>
148
+ bash $HOME/.claude/scripts/phase-tracker.sh tokens <N> <in> <out> [cached]
149
149
  \`\`\`
150
150
 
151
151
  ### Visual channel - Claude Code (native TaskList widget, required)
@@ -78,8 +78,14 @@ for (const e of events) {
78
78
  }
79
79
  if (e.ts && (!t.firstTs || e.ts < t.firstTs)) t.firstTs = e.ts;
80
80
  const d = e.details || {};
81
- if (e.event === "review.completed" && d.review_cycles != null)
82
- t.reviewCycles = num(d.review_cycles);
81
+ // phase-4-review.md's actual review.completed emission (raw_count=/
82
+ // accepted=/deferred=/rejected=/approved=/duration_ms=) carries no
83
+ // review_cycles field - that name only appears on Phase 7's task.completed
84
+ // summary event, which this never matched. Phase 4 re-runs (and logs its
85
+ // own review.completed) once per rework cycle, so the cycle count for a
86
+ // task is how many review.completed events share its task_id - the same
87
+ // derivation aggregate-metrics.mjs already uses for this metric.
88
+ if (e.event === "review.completed") t.reviewCycles = (t.reviewCycles ?? 0) + 1;
83
89
  if (e.event === "rework.started") t.rework += 1;
84
90
  t.tokensIn += num(d.tokens_in);
85
91
  t.tokensOut += num(d.tokens_out);
@@ -80,11 +80,22 @@ if [ -f "$PR_BODY" ]; then
80
80
  else
81
81
  ok "PR body free of HTML entities"
82
82
  fi
83
- for sec in "## Summary" "## How to Test"; do
84
- grep -q "$sec" "$PR_BODY" \
85
- && ok "PR body has '$sec' section" \
86
- || warn "PR body missing '$sec' section"
83
+ # Canonical section set is channels/pr.md's: summary -> changes ->
84
+ # architecture (cond.) -> verification -> dependencies (cond.) -> related.
85
+ # Headings render in outputLanguage, so match the always-required three by
86
+ # either language rather than pinning English. "## How to Test" was the old
87
+ # phase-6 template's heading and is not part of the canonical set.
88
+ for sec in "Summary|Özet" "Changes|Değişiklikler" "Verification|Doğrulama"; do
89
+ label="${sec%%|*}"
90
+ grep -qE "^## ($sec)" "$PR_BODY" \
91
+ && ok "PR body has '$label' section" \
92
+ || warn "PR body missing '$label' section (canonical set: channels/pr.md)"
87
93
  done
94
+ if grep -qE '^h[1-6]\. |\{\{[A-Za-z_]' "$PR_BODY"; then
95
+ bad "PR body contains Jira wiki markup (h2./{{code}}) - PR bodies are Markdown"
96
+ else
97
+ ok "PR body free of Jira wiki markup"
98
+ fi
88
99
  else
89
100
  warn "no .pr-body.md in task dir (skipped PR-body check)"
90
101
  fi