@mmerterden/multi-agent-pipeline 16.3.0 → 16.4.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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,116 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [16.4.0] - 2026-08-24
20
+
21
+ Two default changes are worth reading before upgrading. `/multi-agent:analysis`
22
+ no longer overwrites a Jira issue's description - it posts a comment unless you
23
+ explicitly choose the description, which is then backed up first. And Phase 6
24
+ gained a blocking gate: a plan step that never reached a terminal status stops
25
+ the commit instead of surfacing in the Phase 7 report afterwards. Both are
26
+ corrections to behaviour that lost work quietly; neither changes a command name
27
+ or an option.
28
+
29
+ Four ideas taken from github/spec-kit, obra/superpowers, karpathy/llm-council and yamadashy/repomix after auditing all five candidate repos against this pipeline at source level. Most of what those projects do the pipeline already had (spec-kit's constitution is `analysis/locked.md`, its `/analyze` is `validate-analysis-doc.mjs`, repomix's packed digest is `repo-map.mjs`, its offload pattern is Phase 4 Step 1.9, its secretlint pass is Gate 4 before any reviewer runs, serena's memories are the learnings ledger). These are the four gaps that were real.
30
+
31
+ ### Fixed
32
+
33
+ - **The triage model could recognise its own findings.** On Claude Code the reviewers are Fable + Sonnet and triage is Fable; on Copilot CLI the reviewers include Opus and triage is Opus. Step 3.2 handed that model a list labelled "Reviewer 1 + Reviewer 2", and the Step 2.5 rebuttal round showed each reviewer "the OTHER reviewers'" findings by attribution - so the judge was marking its own homework, and the word "anonym" appeared nowhere in the pipeline. `scripts/anonymize-findings.mjs` now strips every identity key, relabels findings `Source A/B/C`, and orders them with a PRNG seeded from `taskId:iteration` after a stable content sort, so reviewer completion order cannot leak through position and `/multi-agent:resume` reproduces the same input. The label map is written to a separate file and never enters a prompt. Pattern source: llm-council `backend/council.py` stage 2; the delta is deliberate, since llm-council anonymizes only its peer-ranking step and lets its chairman see names - our triage is chairman AND panel member.
34
+ - **An unfinished plan could reach commit.** Phase 4 answers whether the diff is correct, Step 1.45 covers the planned tests, and the criteria manifest's denominator is rule IDs. Nothing covered the plan's own steps: the `[done]` / `[pending]` rollup is rendered in Phase 7, after the commit. `scripts/plan-coverage-gate.mjs` runs as Phase 6 Step 0a and fails when a step never reached a terminal status, when a skip or failure carries no reason, or when an analysis Section 14 row tagged `Add new` names a file that is not in the tree. Pattern source: spec-kit `converge` - current state rather than a diff, and a clean run stays quiet. Not copied: spec-kit appends remediation tasks to `tasks.md`; rewriting an approved plan is a Phase 2 decision, not a gate's.
35
+
36
+ ### Added
37
+
38
+ - **Test baseline (`prefs.global.testBaseline`, default off).** Phase 4 Gate 3 had no way to tell an inherited red suite from one this run broke, so it blocked on someone else's bug or the dev agent "fixed" tests it never touched (`shadow-git.sh init` snapshots files, not test results). Phase 0 Step 7.6 now runs the same command Gate 3 uses, time-capped, and records `state.baseline.tests` with one of three statuses: `green`, `red` with the failing set, `red` with an empty set plus the log path when the output cannot be parsed into names, or `unknown`. Gate 3 subtracts a known failing set, refuses to either pass or silently block on an unparseable red, and leaves today's behaviour untouched when there is no baseline. Pattern source: superpowers `using-git-worktrees` Step 3, extended from ask-the-user to a stored set.
39
+ - **`smoke-triage-anonymity.sh` (17 assertions), `smoke-plan-coverage.sh` (15), `smoke-test-baseline.sh` (13).** Each proves the behaviour, not the prose: identity keys really are stripped, no model name survives in the payload, the same seed reproduces the order and a different seed does not, reviewer order does not change finding order, per-reviewer metrics degrade to `unavailable` instead of inventing a zero, every unaccounted todo shape is caught, Reuse/Modify rows and template placeholders are not counted against the tree, and no doc collapses baseline `unknown` into `green`.
40
+
41
+ ### Changed
42
+
43
+ - **`reviewIterations[].reviewers` is typed.** It was `{"type": "array"}` with no item schema, so nothing said a reviewer entry names its model. Now `model`, `findings[]` and `roundCount` are declared, with nothing required: a run written before this shape still validates and surfaces as `unknown` rather than being folded into a named model.
44
+ - **`run-metrics.mjs` reports signal-to-noise per reviewer.** `acceptedRatio` pooled every reviewer together, so "which model is worth dispatching" had no answer. With the anonymization map present, accepted findings are attributed back per model; without it the raw counts still land and `perReviewerAttribution` says `unavailable`. Pattern source: llm-council `calculate_aggregate_rankings`.
45
+ - **`smoke-gate-wiring.sh` matches the property, not the section title.** It grepped for the literal heading "3.0 Merge the deterministic findings in" and reported a wiring break when that section was retitled, while the wiring was intact. It now checks that 3.0 merges, that 3.1 reads the merged count, and that anonymization precedes the merge - relabelling gate findings as reviewer findings would cost triage the difference between a fact and a raw signal.
46
+ - **`total_max_tokens` 56600 -> 57600.** Three new phase-doc contracts cost about 1000 tokens after trimming the drafts by 500. Every individual phase stays inside its own max and `phase-0-init` is back under its warn line; only the aggregate needed the same incremental bump the last three feature commits made.
47
+
48
+ ### Fixed (same release, found by reviewing the four items above)
49
+
50
+ - **`run-metrics` understated a reviewer on a mixed-map run.** `attributed` was a
51
+ single global flag, so an iteration with no label map still added its findings
52
+ to the per-reviewer denominator while being unable to contribute a numerator. A
53
+ resumed run that upgraded mid-flight read as noise the reviewer was never
54
+ credited for. The denominator is now the attributable raw count,
55
+ `rawAttributable` is reported next to `rawFindings`,
56
+ `mappedIterations` says how many iterations carried a map, and
57
+ `acceptedUnattributed` counts every accepted finding that resolves to no
58
+ reviewer - a deterministic-gate finding, which carries no `foundBy` by design,
59
+ or anything from an iteration that had no map - so
60
+ `acceptedAll === sum(perReviewer.accepted) + acceptedUnattributed` holds.
61
+ - **`anonymizationMap` was referenced but never declared.** Phase 4 Step 3.0
62
+ writes it and `run-metrics.mjs` reads it, yet it appeared nowhere in
63
+ `agent-state.schema.json` - it validated only because the iteration object
64
+ allows extra keys. That is the same declared-but-undeclared shape the typed
65
+ `reviewers` entry was added to close. Now declared with `seed` and
66
+ `labelToModel`.
67
+ - **`anonymize-findings.mjs` documented an exit code it could not reach.** The
68
+ header promised `64 usage`, but the TTY guard was lost when the file was
69
+ rewritten, so a bare invocation blocked on stdin forever and 64 was
70
+ unreachable. Restored, and verified through a pty rather than a pipe.
71
+ - **`plan-coverage-gate.mjs` could skip a real Section 14 row.** A header-row
72
+ filter matched `^(dosya|file)\b` against the PATH column, so a promised file
73
+ under a `File/` directory left the denominator silently. The filter was
74
+ redundant anyway - the header's tag cell already fails the `Add new` test - so
75
+ it is gone, with a fixture row proving it.
76
+ - **Two gate assertions claimed more than their code.**
77
+ `smoke-triage-anonymity.sh` said "no model name appears anywhere in the
78
+ payload" when the guarantee is structural (no identity FIELD survives; free
79
+ text is deliberately not scrubbed, because that would mangle a finding about
80
+ `ClaudeService.swift`), and the limit is now stated in both the gate and the
81
+ script header. `smoke-gate-wiring.sh` matched a section title instead of the
82
+ property and reported a wiring break on a retitled but intact section.
83
+
84
+ ### Fixed (review round 2)
85
+
86
+ - **The Section 14 file check was wired to a variable nothing sets.** Phase 6
87
+ passed `${ANALYSIS_DOC:+--analysis "$ANALYSIS_DOC"}`, and `ANALYSIS_DOC` is
88
+ defined nowhere in the pipeline - so in practice that half of the gate never
89
+ ran. The real location is `state.analysis.docPath[]`, an array with one entry
90
+ per platform (Phase 1 Step 4), and `--analysis` is now repeatable so every
91
+ platform's promised files enter one denominator instead of the first one
92
+ standing in for the run.
93
+ - **A plan with zero steps passed.** `todos: []` reported `0/0 steps accounted
94
+ for` and exited 0, so a Phase 2 that produced nothing - or a state whose todos
95
+ were cleared - read as a fully delivered plan. An empty plan is now exit 2,
96
+ the same as a missing one.
97
+ - **A malformed reviewer dispatch vanished.** A reviewer whose `findings` came
98
+ back as an object contributed nothing and said nothing, so triage adjudicated
99
+ a smaller panel than actually ran. The anonymizer now warns on stderr and
100
+ records the loss in the map under `malformed`, named by label.
101
+ - **A residual that anonymization does not remove is written down.** With two
102
+ reviewers the label set is `{Source A, Source B}` and the triage model is one
103
+ of them, so it keeps a 50% prior on which findings are its own. The value is
104
+ that nothing tells it; llm-council's four-model council has a stronger version
105
+ of the same property.
106
+
107
+ ### Not adopted, with reasons
108
+
109
+ - **tree-sitter symbol extraction (repomix `--compress`).** `repo-map.mjs` extracts declarations by regex and tree-sitter would be more accurate, but `package.json` has `"dependencies": {}` and that zero-runtime-dependency property is deliberate. If accuracy is wanted, LSP is the compatible route.
110
+ - **serena-style symbol-level reuse detection.** Locked 11's `Reuse existing X (file:line)` rows rest on regex and would benefit, but it means rewriting the Phase 1b collector, and serena was only read at README level. Separate work.
111
+ - **Per-task `verification` field (superpowers).** Not a failure class, overlaps the coverage gate above, and touches schema plus plan approval plus the Phase 3 evidence chain - the widest blast radius of the five candidates.
112
+ - **spec-kit `extensions.yml` hooks.** A HookExecutor layer above the existing `prefs` flags, for one maintainer.
113
+
114
+ ### Fixed - Jira channel
115
+
116
+ - **`/multi-agent:analysis` overwrote the Jira issue description.** The Jira output target was a bare `PUT /rest/api/2/issue/{key}` on the `description` field: one write, no read first, no backup, no preview. An issue whose description held the reporter's own requirement text lost it silently - the picker asked which issue, never what would happen to it. Publishing now goes through `lib/jira-publish.sh`, and the destination question has three answers with **Comment pre-selected** (a comment cannot destroy anything, and it is what an unattended run gets). The description path reads the current value first, writes it to `~/.claude/logs/multi-agent/jira-backups/<KEY>-description-<stamp>.txt` and reports the path, appends below a `----` rule by default, and exits 3 rather than replacing a non-empty field unless the user's explicit "Description - replace" answer supplies `--confirm-overwrite`. The body is escaped by `jira-wiki-escape.mjs` on both paths, and the bearer token is passed through a curl `-K` config so it never reaches argv.
117
+
118
+ - **A Jira comment showed a smiley nobody typed.** The body referenced the Swift selector `login(source:input:)`, whose trailing `:)` Jira's wiki renderer turns into an emoticon image at render time. `channels/jira.md` had carried an "Emoticon escaping (required)" table with all 21 sequences for releases, and its own text said the table "is applied by the model, by hand" - so on a long body it was skipped, silently. The escape is now `pipeline/scripts/jira-wiki-escape.mjs`: it backslash-escapes each documented sequence, skips `{code}` / `{noformat}` blocks, reaches inside `{{monospace}}` (Jira parses emoticons there too), and is idempotent. `--check` is the inverse, for a pre-POST assertion. Wired into all nine Jira comment/description write sites (channels adapter, analysis description PUT, generate-issue create, wiki-to-Jira triad, readiness review, create-jira).
119
+
120
+ ### Added - Jira channel
121
+
122
+ - **`smoke-jira-publish.sh`** - 20 assertions over the safe-write contract, driven by a curl stub so nothing touches the network: comment is the default target, the description path GETs before it writes, the backup exists even on the refused path, a blind replace is refused, a confirmed one writes only the new body, an empty description needs no ceremony, `--dry-run` sends nothing, and the token never appears in argv. It also holds `analysis/render.md` to the contract, so the bare description PUT cannot come back in prose.
123
+
124
+ ### Changed - Jira channel
125
+
126
+ - **`smoke-channel-glyphs.sh` section 3 tests behavior instead of prose.** It used to grep `channels/jira.md` for the escaping table and pass - the table was present, and nothing applied it. It now runs the program: the exact selector that caused the bug, the monospace case, the code-block skip, idempotency, both `--check` exit codes, and a table-to-program tie that reads the 21 sequences out of the doc rather than retyping them, so the doc and the program cannot drift apart in either direction. It also asserts every known Jira write site routes through the escape, which is how a new write site that skips it gets caught.
127
+ - **`pipeline/scripts/README.md` counts recounted from the filesystem** - the header said 148 shell + 45 `.mjs` and the smoke heading said 118 files; the tree holds 186 shell, 58 `.mjs`, 151 smokes.
128
+
19
129
  ## [16.3.0] - 2026-08-24
20
130
 
21
131
  Three defects the 16.2.0 review named but did not close, plus one it caused.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "16.3.0",
3
+ "version": "16.4.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -50,7 +50,7 @@ Full contract: `$HOME/.claude/multi-agent-refs/analysis/synthesis.md`. Pass A bu
50
50
 
51
51
  ### Phases 3, 3.5, 4, 5 - Render, publish, report
52
52
 
53
- Full contract: `$HOME/.claude/multi-agent-refs/analysis/render.md`. Renders one markdown file per platform, runs the **required** `ai-common-toolkit:humanizer` pass, gates on `validate-analysis-doc.mjs`, asks for the output destination, dispatches to Local / Confluence / Jira, then reports and stops. The humanizer pass and the validator are required in every mode; a document that skipped either is not shippable.
53
+ Full contract: `$HOME/.claude/multi-agent-refs/analysis/render.md`. Renders one markdown file per platform, runs the **required** `ai-common-toolkit:humanizer` pass, gates on `validate-analysis-doc.mjs`, asks for the output destination, dispatches to Local / Confluence / Jira (a Jira issue receives a comment unless the user explicitly asks for the description, which is never overwritten without a backup and a confirmation), then reports and stops. The humanizer pass and the validator are required in every mode; a document that skipped either is not shippable.
54
54
 
55
55
  ### Resume contract
56
56
 
@@ -106,6 +106,7 @@ When `phase == "cancelled_at_pass_b_preview"`:
106
106
  | `$HOME/.claude/rules/tdd.md` | Section 15 test naming |
107
107
  | `$HOME/.claude/multi-agent-refs/analysis-template.md` | Template master copy + language matrix (v3 - 23 sections) |
108
108
  | `$HOME/.claude/multi-agent-refs/conventions-defaults.md` | Pass B fallback defaults (4 platforms x 7 pattern groups) - applied when convention confidence is none AND standards binding is silent |
109
+ | `$HOME/.claude/lib/jira-publish.sh` | Phase 4 Jira write: comment by default, description only on explicit choice - reads the current description first, backs it up, appends below a rule, refuses a non-empty replace without `--confirm-overwrite` |
109
110
  | `$HOME/.claude/scripts/validate-analysis-doc.mjs` | Phase 4 pre-dispatch gate: deterministic check of the emitted per-platform doc (front-matter, never-omitted sections, humanizer punctuation, Full-mode BR traceability) |
110
111
  | a project-supplied Confluence-embedded API-table parser (optional) | Parse endpoints from a Confluence page's Request Path / Service Name / Response Body table columns |
111
112
  | `~/<project>-Standards.md` | Canonical home-dir standards reference (auto-detected at Q5 option 2; exact filename from `prefs.projects[<project>].standardsFile`) |
@@ -369,7 +369,7 @@ Aggregated Markdown from Step 5 → PR description. GitHub uses `gh pr edit --bo
369
369
  Full contract: [`$HOME/.claude/multi-agent-refs/channels/pr.md`]($HOME/.claude/multi-agent-refs/channels/pr.md) - Bitbucket payload assembly snippet, version-mismatch retry, `--ready` promotion, multi-repo cross-link block.
370
370
 
371
371
  #### Adapter: Jira comment
372
- Body converted to Jira wiki markup (`### ...` → `*...*`, `- [ ] ...` → `# ...`, `` `x` `` → `{{x}}`, tables to `||h||h|| |c|c|`); first line is the PR URL. POST `/rest/api/2/issue/{id}/comment` via heredoc + `jq --rawfile` + `curl --data-binary @file`. Token resolved from `keychainMapping.jira`.
372
+ Body converted to Jira wiki markup (`### ...` → `*...*`, `- [ ] ...` → `# ...`, `` `x` `` → `{{x}}`, tables to `||h||h|| |c|c|`); first line is the PR URL. The converted body then goes through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` (required, not optional - Jira renders `:)` `(x)` `(!)` `(/)` as emoticon images, and a Swift selector like `login(source:input:)` ends in `:)`). POST `/rest/api/2/issue/{id}/comment` via heredoc + `jq --rawfile` + `curl --data-binary @file`, from the escaped file. Token resolved from `keychainMapping.jira`.
373
373
 
374
374
  Full contract: [`$HOME/.claude/multi-agent-refs/channels/jira.md`]($HOME/.claude/multi-agent-refs/channels/jira.md) - full conversion table, multi-repo PR-list prepend, Wiki→Jira triad interaction.
375
375
 
@@ -20,6 +20,13 @@ Unlike `/multi-agent:purge` (which wipes worktrees, branches, logs, and state
20
20
  together), this only removes log dirs and leaves your worktrees and branches
21
21
  untouched.
22
22
 
23
+ Not in scope: `~/.claude/logs/multi-agent-toolkit/`, the sibling directory where
24
+ `multi-agent-toolkit-mcp` saves tool outputs too large to return inline. That
25
+ directory prunes itself at write time (newest 50 files, 7 days), because an
26
+ MCP-only user has no pipeline installed and "some other tool cleans it" would
27
+ mean nobody does. Mentioned here so the files are not a mystery when you come
28
+ looking for what is using space under `logs/`.
29
+
23
30
  ## Steps
24
31
 
25
32
  1. **Preview (dry-run)** - run the pruner with no `--yes`, forwarding any
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # jira-publish.sh
4
+ #
5
+ # Publishes an analysis / report body to a Jira issue without destroying what
6
+ # is already there. Spec: refs/analysis/render.md (Phase 4 Jira target) and
7
+ # refs/channels/jira.md (transport + emoticon escaping).
8
+ #
9
+ # Why this exists: the analysis Jira target used to PUT the description field
10
+ # directly, so an issue whose description held the analyst's original
11
+ # requirement text lost it with no preview, no backup and no undo. A comment
12
+ # cannot destroy anything, so `comment` is the default target; writing the
13
+ # description is opt-in, always backed up first, appends by default, and
14
+ # refuses to replace a non-empty description without an explicit confirmation.
15
+ #
16
+ # Usage:
17
+ # jira-publish.sh --issue KEY --body-file FILE
18
+ # [--target comment|description] (default: comment)
19
+ # [--mode append|replace] (description only, default: append)
20
+ # [--confirm-overwrite] (required for mode=replace on a non-empty field)
21
+ # [--backup-dir DIR]
22
+ # [--dry-run]
23
+ #
24
+ # Resolution:
25
+ # host JIRA_HOST, else prefs .global.hosts.jira
26
+ # token JIRA_TOKEN, else credential-store.sh get <prefs .global.keychainMapping.jira>
27
+ # The token never reaches argv or a log: it is passed to curl through a -K
28
+ # config on process substitution, the same idiom as post-pr-review.sh.
29
+ #
30
+ # Every body is run through scripts/jira-wiki-escape.mjs before it is sent, so
31
+ # Jira cannot manufacture an emoticon out of a Swift selector or a table cell.
32
+ #
33
+ # Exit codes:
34
+ # 0 published (or, with --dry-run, previewed)
35
+ # 3 refused: replace would destroy a non-empty description and no
36
+ # --confirm-overwrite was given. The backup is already on disk.
37
+ # 4 host or token could not be resolved
38
+ # 5 a Jira API call failed
39
+ # 64 usage error
40
+
41
+ set -euo pipefail
42
+
43
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
44
+ PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
45
+ CURL="${JIRA_PUBLISH_CURL:-curl}"
46
+
47
+ ISSUE=""
48
+ BODY_FILE=""
49
+ TARGET="comment"
50
+ MODE="append"
51
+ CONFIRM=0
52
+ DRY_RUN=0
53
+ BACKUP_DIR="${JIRA_BACKUP_DIR:-$HOME/.claude/logs/multi-agent/jira-backups}"
54
+
55
+ die() { echo "$1" >&2; exit "${2:-64}"; }
56
+
57
+ while [ "$#" -gt 0 ]; do
58
+ case "$1" in
59
+ --issue) ISSUE="${2:-}"; shift 2 ;;
60
+ --body-file) BODY_FILE="${2:-}"; shift 2 ;;
61
+ --target) TARGET="${2:-}"; shift 2 ;;
62
+ --mode) MODE="${2:-}"; shift 2 ;;
63
+ --backup-dir) BACKUP_DIR="${2:-}"; shift 2 ;;
64
+ --confirm-overwrite) CONFIRM=1; shift ;;
65
+ --dry-run) DRY_RUN=1; shift ;;
66
+ -h|--help) sed -n '2,40p' "$0"; exit 0 ;;
67
+ *) die "unknown argument: $1" ;;
68
+ esac
69
+ done
70
+
71
+ [ -n "$ISSUE" ] || die "usage: jira-publish.sh --issue KEY --body-file FILE [--target comment|description]"
72
+ [ -n "$BODY_FILE" ] || die "usage: jira-publish.sh --issue KEY --body-file FILE [--target comment|description]"
73
+ [ -f "$BODY_FILE" ] || die "body file not found: $BODY_FILE"
74
+ case "$TARGET" in comment|description) ;; *) die "--target must be comment or description, got: $TARGET" ;; esac
75
+ case "$MODE" in append|replace) ;; *) die "--mode must be append or replace, got: $MODE" ;; esac
76
+
77
+ ESCAPER=""
78
+ for cand in "$SELF_DIR/../scripts/jira-wiki-escape.mjs" "$HOME/.claude/scripts/jira-wiki-escape.mjs"; do
79
+ [ -f "$cand" ] && { ESCAPER="$cand"; break; }
80
+ done
81
+ [ -n "$ESCAPER" ] || die "jira-wiki-escape.mjs not found next to lib/ or in ~/.claude/scripts" 4
82
+
83
+ HOST="${JIRA_HOST:-}"
84
+ if [ -z "$HOST" ] && [ -f "$PREFS" ]; then
85
+ HOST=$(jq -r '.global.hosts.jira // empty' "$PREFS" 2>/dev/null || echo "")
86
+ fi
87
+ [ -n "$HOST" ] || die "no Jira host: set JIRA_HOST or prefs .global.hosts.jira" 4
88
+ HOST="${HOST#https://}"; HOST="${HOST#http://}"; HOST="${HOST%/}"
89
+
90
+ TOKEN="${JIRA_TOKEN:-}"
91
+ if [ -z "$TOKEN" ]; then
92
+ key="${JIRA_TOKEN_KEY:-}"
93
+ if [ -z "$key" ] && [ -f "$PREFS" ]; then
94
+ key=$(jq -r '.global.keychainMapping.jira // empty' "$PREFS" 2>/dev/null || echo "")
95
+ fi
96
+ [ -n "$key" ] || die "no Jira token: set JIRA_TOKEN or map prefs .global.keychainMapping.jira" 4
97
+ TOKEN=$("$SELF_DIR/credential-store.sh" get "$key" 2>/dev/null || echo "")
98
+ [ -n "$TOKEN" ] || die "Jira token not in the credential store under: $key" 4
99
+ fi
100
+
101
+ auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$1"; }
102
+ api() { "$CURL" -sS -m 30 -K <(auth_cfg "$TOKEN") -H "Content-Type: application/json" "$@"; }
103
+
104
+ WORK="$(mktemp -d)"
105
+ trap 'rm -rf "$WORK"' EXIT
106
+
107
+ node "$ESCAPER" "$BODY_FILE" > "$WORK/new.txt"
108
+
109
+ if [ "$TARGET" = "comment" ]; then
110
+ if [ "$DRY_RUN" = "1" ]; then
111
+ echo "dry-run: would POST a comment to $ISSUE ($(wc -l < "$WORK/new.txt" | tr -d ' ') lines)"
112
+ cat "$WORK/new.txt"
113
+ exit 0
114
+ fi
115
+ jq -n --rawfile body "$WORK/new.txt" '{body: $body}' > "$WORK/payload.json"
116
+ http=$(api -o "$WORK/resp.json" -w '%{http_code}' -X POST \
117
+ --data-binary @"$WORK/payload.json" \
118
+ "https://$HOST/rest/api/2/issue/$ISSUE/comment") || http="000"
119
+ case "$http" in
120
+ 20*) echo "comment posted to $ISSUE (nothing was overwritten)" ;;
121
+ *) echo "Jira comment POST failed with HTTP $http" >&2; sed -n '1,5p' "$WORK/resp.json" >&2; exit 5 ;;
122
+ esac
123
+ exit 0
124
+ fi
125
+
126
+ # --- description: read before write ------------------------------------------
127
+ http=$(api -o "$WORK/issue.json" -w '%{http_code}' \
128
+ "https://$HOST/rest/api/2/issue/$ISSUE?fields=description") || http="000"
129
+ case "$http" in
130
+ 200) ;;
131
+ *) echo "could not read the current description of $ISSUE (HTTP $http)" >&2; exit 5 ;;
132
+ esac
133
+ jq -r '.fields.description // ""' "$WORK/issue.json" > "$WORK/existing.txt"
134
+
135
+ existing_bytes=$(wc -c < "$WORK/existing.txt" | tr -d ' ')
136
+ existing_lines=$(grep -c . "$WORK/existing.txt" || true)
137
+
138
+ mkdir -p "$BACKUP_DIR"
139
+ STAMP=$(date +%Y%m%dT%H%M%S)
140
+ BACKUP="$BACKUP_DIR/$ISSUE-description-$STAMP.txt"
141
+ cp "$WORK/existing.txt" "$BACKUP"
142
+ echo "existing description backed up: $BACKUP ($existing_lines non-empty line(s))"
143
+
144
+ if [ "$existing_bytes" -le 1 ]; then
145
+ cp "$WORK/new.txt" "$WORK/final.txt"
146
+ RESULT="written into an empty description"
147
+ elif [ "$MODE" = "append" ]; then
148
+ { cat "$WORK/existing.txt"; printf '\n\n----\n\n'; cat "$WORK/new.txt"; } > "$WORK/final.txt"
149
+ RESULT="appended below the existing description"
150
+ else
151
+ if [ "$CONFIRM" != "1" ]; then
152
+ echo "REFUSED: --mode replace would discard $existing_lines non-empty line(s) already in the description of $ISSUE." >&2
153
+ echo "The current text is saved at $BACKUP. Re-run with --mode append to keep it, or add --confirm-overwrite to replace it deliberately." >&2
154
+ exit 3
155
+ fi
156
+ cp "$WORK/new.txt" "$WORK/final.txt"
157
+ RESULT="replaced the existing description (backup above)"
158
+ fi
159
+
160
+ if [ "$DRY_RUN" = "1" ]; then
161
+ echo "dry-run: would PUT the description of $ISSUE - $RESULT"
162
+ cat "$WORK/final.txt"
163
+ exit 0
164
+ fi
165
+
166
+ jq -n --rawfile body "$WORK/final.txt" '{fields: {description: $body}}' > "$WORK/payload.json"
167
+ http=$(api -o "$WORK/resp.json" -w '%{http_code}' -X PUT \
168
+ --data-binary @"$WORK/payload.json" \
169
+ "https://$HOST/rest/api/2/issue/$ISSUE") || http="000"
170
+ case "$http" in
171
+ 20*) echo "description of $ISSUE updated - $RESULT" ;;
172
+ *) echo "Jira description PUT failed with HTTP $http" >&2; sed -n '1,5p' "$WORK/resp.json" >&2; exit 5 ;;
173
+ esac
@@ -41,12 +41,29 @@ options:
41
41
  - label: "Local file"
42
42
  description: "analysis/<feature>-<platform>.md in each selected repo's working tree"
43
43
  - label: "Confluence page"
44
- - label: "Jira description"
44
+ - label: "Jira issue"
45
+ description: <localized: "As a comment by default; writing the description is a separate, explicit choice">
45
46
  ```
46
47
 
47
48
  Conditional follow-ups:
48
49
  - If `Confluence` selected: ask `header="Parent page"`, free-text via Other for the parent page key or URL. One Confluence page per platform is created under this parent, each titled `<Feature> - <Platform>`.
49
- - If `Jira` selected: if Step 5 Q4 produced Jira IDs, AskUserQuestion (single-select) to pick which one; otherwise ask via Other. Per Locked decision 9 + design choice "single description with platform separators", the chosen issue receives one combined description body holding all per-platform sections under `h2. Platform: <X>` separators (wiki markup - the description field does not render Markdown; conversion happens at dispatch, see Phase 4).
50
+ - If `Jira` selected: if Step 5 Q4 produced Jira IDs, AskUserQuestion (single-select) to pick which one; otherwise ask via Other. Per Locked decision 9 + design choice "single description with platform separators", the chosen issue receives one combined body holding all per-platform sections under `h2. Platform: <X>` separators (wiki markup - neither the comment nor the description field renders Markdown; conversion happens at dispatch, see Phase 4).
51
+
52
+ Then ask **where in the issue it goes**, because two of the three answers can destroy text somebody else wrote:
53
+
54
+ ```
55
+ header: "Jira write"
56
+ question: <localized: "Where should the analysis go on {ISSUE-KEY}?">
57
+ options:
58
+ - label: "Comment"
59
+ description: <localized: "Added as a new comment. Nothing existing is touched."> # pre-selected
60
+ - label: "Description - append"
61
+ description: <localized: "Kept below the current description, separated by a rule.">
62
+ - label: "Description - replace"
63
+ description: <localized: "Replaces the current description. Its text is backed up first and the run tells you what was there.">
64
+ ```
65
+
66
+ `Comment` is pre-selected and is the default on any non-answer, including autopilot. The description of an issue is often the analyst's original requirement text, and an analysis run is not a reason to lose it. Result: `state.analysisSpec.outputs.jira = { key, target: "comment"|"description", mode: "append"|"replace" }`.
50
67
 
51
68
  Result: `state.analysisSpec.outputs.requested[]`.
52
69
 
@@ -68,7 +85,7 @@ Iterate `state.analysisSpec.outputs.requested`. For each target:
68
85
  |--------|--------|
69
86
  | Local | For each per-platform draft, `cp /tmp/analysis-<feature-slug>-<ts>/<feature>-<platform>.md` into `analysis/<feature>-<platform>.md` in the matching repo's working tree. When multiple repos exist for the same platform, the file is duplicated into each and the dispatch report lists every destination. **No commit.** |
70
87
  | Confluence | Re-humanize each per-platform draft with `formal-stakeholder` tone. One Confluence page per platform under the chosen parent, titled `<Feature> - <Platform>`. Cross-link siblings inside each page via `<ac:link><ri:page ri:content-title="<Feature> - <OtherPlatform>"/></ac:link>`. Markdown -> storage XML via `$HOME/.claude/multi-agent-refs/channels/confluence.md`. Re-emit on existing pages uses PUT with version bump. |
71
- | Jira | Re-humanize the combined body with `informal-technical` tone. Concatenate per-platform drafts under `h2. Platform: iOS`, `h2. Platform: Android`, `h2. Platform: Backend`, `h2. Platform: Frontend` separators (in the order platforms were selected), then run the whole body through the markdown → Jira wiki conversion table in `$HOME/.claude/multi-agent-refs/channels/jira.md` - the `description` field renders wiki markup, so raw `##`/`**`/backticks arrive as literal text. Write with `PUT /rest/api/2/issue/{key}` body `{"fields": {"description": <converted body>}}` (`channels/jira.md` documents only the comment POST; the description update is this PUT). Same rawfile + `--data-binary` transport rules. |
88
+ | Jira | Re-humanize the combined body with `informal-technical` tone. Concatenate per-platform drafts under `h2. Platform: iOS`, `h2. Platform: Android`, `h2. Platform: Backend`, `h2. Platform: Frontend` separators (in the order platforms were selected), then run the whole body through the markdown → Jira wiki conversion table in `$HOME/.claude/multi-agent-refs/channels/jira.md` - both the comment body and the `description` field render wiki markup, so raw `##`/`**`/backticks arrive as literal text. Write the converted body to a file and publish it with `$HOME/.claude/lib/jira-publish.sh`, never with a hand-rolled `curl`: <br><br>`bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" --target comment` <br>`bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" --target description --mode append` <br><br>The script owns the parts that are easy to get wrong: it runs `jira-wiki-escape.mjs` on the body, resolves host + token without putting either in argv, and on the description path it GETs the current value, writes it to a backup under `~/.claude/logs/multi-agent/jira-backups/` and reports the path, appends below a `----` rule by default, and **refuses with exit 3** when `--mode replace` would discard a non-empty description unless `--confirm-overwrite` is passed. Exit 3 is reported to the user with the backup path, never retried with the flag added automatically - only the user's explicit "Description - replace" answer from Phase 3.5 supplies it. `--dry-run` previews the exact final body without writing. |
72
89
 
73
90
  **Output capture**: fill `state.analysisSpec.outputs.localPaths[]` (one entry per per-platform-per-repo write), `outputs.confluencePageUrls[]` (one entry per platform), `outputs.jiraIssueKey` (single string).
74
91
 
@@ -55,7 +55,8 @@ The links are pulled from `agent-state.json.contextLinks[]` (see Phase 0 link ex
55
55
  3. Run the assembled body through the `humanizer` skill (see Hard rules below).
56
56
  4. Apply Cross-link injection (PR URL on line 1).
57
57
  5. Run Wiki markup conversion.
58
- 6. POST.
58
+ 6. Run the emoticon escape program on the converted body (see *Emoticon escaping*).
59
+ 7. POST.
59
60
  ```
60
61
 
61
62
  ## Wiki markup conversion
@@ -79,7 +80,16 @@ The `\|` sequences in the two link/table rows are this table's own escape for a
79
80
 
80
81
  ### Emoticon escaping (required)
81
82
 
82
- Jira's wiki renderer turns ASCII sequences into emoticon images. This is not something the pipeline writes; Jira manufactures it at render time, which is why a comment can show a smiley nobody typed. Escape every sequence below with a leading backslash before POST:
83
+ Jira's wiki renderer turns ASCII sequences into emoticon images. This is not something the pipeline writes; Jira manufactures it at render time, which is why a comment can show a smiley nobody typed. The escape is a program, not a habit - run the converted body through it and POST the output:
84
+
85
+ ```bash
86
+ node "$HOME/.claude/scripts/jira-wiki-escape.mjs" /tmp/channels-$TASK_ID-jira.txt \
87
+ > /tmp/channels-$TASK_ID-jira-escaped.txt
88
+ ```
89
+
90
+ It prefixes a backslash to each sequence below, skips `{code}` / `{noformat}` blocks, and is idempotent, so running it twice is harmless. `--check` is the inverse: it exits 1 and prints `line: sequence` for every unescaped hit, which is what a gate or a pre-POST assertion calls.
91
+
92
+ The sequences it covers:
83
93
 
84
94
  | Sequence | Renders as | Escaped |
85
95
  |---|---|---|
@@ -91,13 +101,17 @@ Jira's wiki renderer turns ASCII sequences into emoticon images. This is not som
91
101
  | `(on)` `(off)` | lightbulb icons | `\(on)` `\(off)` |
92
102
  | `(*)` `(*r)` `(*g)` `(*b)` `(*y)` | star icons | `\(*)` `\(*r)` ... |
93
103
 
94
- The parenthesised forms are the ones that actually bite: `(x)` in a comparison table, `(!)` in a caution note, and `(/)` in a path fragment are ordinary technical prose, and each becomes an image. Apply the escape AFTER the markdown conversion above and BEFORE the POST, and skip it inside `{code}` / `{noformat}` blocks, where Jira does not expand emoticons anyway.
104
+ The parenthesised forms are the ones that actually bite: `(x)` in a comparison table, `(!)` in a caution note, and `(/)` in a path fragment are ordinary technical prose, and each becomes an image. The face forms bite through code: a Swift selector like `login(source:input:)` ends in `:)`, and the comment renders a smiley in the middle of a file reference.
105
+
106
+ The program runs AFTER the markdown conversion above and BEFORE the POST. Order is load-bearing in both directions: run it earlier and the conversion re-introduces sequences behind it; skip it and `{{...}}` monospace does not save you, because Jira parses emoticons inside monospace too.
107
+
108
+ Do not hand-apply this table. It was hand-applied for several releases and a smiley reached a ticket anyway - a long comment gives the eye no reason to stop on the `:)` at the end of a selector.
95
109
 
96
110
  Lines outside these patterns pass through verbatim. Multi-paragraph blocks are joined with one blank line.
97
111
 
98
112
  Every row above is load-bearing, including the ones that look cosmetic. The table must cover each construct the section templates in this file actually emit - `##` for the three required headings, `**Given**` / `**When**` / `**Then**` in the test-scenario skeleton, and `1.`-numbered scenarios. A missing row does not degrade gracefully: the "pass through verbatim" fallback POSTs `## Test Senaryoları` and `**Given**` as literal text, so the comment renders with visible `##` and stray asterisks. Single `*bold*` in Markdown means *italic* in Jira wiki - never map `**bold**` to `*bold*` by dropping one asterisk mechanically without checking the source was bold, not italic.
99
113
 
100
- There is no markdown→Jira-wiki converter program in the pipeline (`lib/` ships `md2confluence-v3.py` for Confluence only). This table is applied by the model, by hand, which is exactly why it has to be complete.
114
+ There is no markdown→Jira-wiki converter program in the pipeline (`lib/` ships `md2confluence-v3.py` for Confluence only, and `scripts/jira-wiki-escape.mjs` covers the emoticon step alone). This conversion table is applied by the model, by hand, which is exactly why it has to be complete.
101
115
 
102
116
  ## Cross-link injection
103
117
 
@@ -128,10 +142,12 @@ Authorization: Bearer $JIRA_TOKEN
128
142
  Content-Type: application/json
129
143
  ```
130
144
 
131
- Body assembled with `jq --rawfile` + `curl --data-binary @file`:
145
+ Body assembled with `jq --rawfile` + `curl --data-binary @file`, from the escaped file that *Emoticon escaping* produced:
132
146
 
133
147
  ```bash
134
- jq -n --rawfile body /tmp/channels-$TASK_ID-jira.txt '{body: $body}' \
148
+ node "$HOME/.claude/scripts/jira-wiki-escape.mjs" --check /tmp/channels-$TASK_ID-jira-escaped.txt \
149
+ || { echo "unescaped Jira emoticon in the body - do not POST" >&2; exit 1; }
150
+ jq -n --rawfile body /tmp/channels-$TASK_ID-jira-escaped.txt '{body: $body}' \
135
151
  > /tmp/channels-$TASK_ID-jira-payload.json
136
152
  curl -s -X POST -H "Authorization: Bearer $JIRA_TOKEN" \
137
153
  -H "Content-Type: application/json" \
@@ -141,6 +157,17 @@ curl -s -X POST -H "Authorization: Bearer $JIRA_TOKEN" \
141
157
 
142
158
  The dispatch summary line includes the comment URL with `?focusedCommentId=...` so the user can paste it into Slack/Teams.
143
159
 
160
+ ### Writing the issue description (not a comment)
161
+
162
+ A comment is additive; the `description` field is not. Replacing it destroys whatever was there, which is usually the reporter's own text. There is no hand-rolled `PUT /rest/api/2/issue/{key}` anywhere in the pipeline and there must not be one: the only supported path is
163
+
164
+ ```bash
165
+ bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" \
166
+ --target description --mode append
167
+ ```
168
+
169
+ which escapes the body, GETs the current description, saves it under `~/.claude/logs/multi-agent/jira-backups/`, appends below a `----` rule, and exits 3 rather than replacing a non-empty field without `--confirm-overwrite`. The same script posts comments (`--target comment`), which is what an unattended run should choose when it is not sure. Contract and gate: `analysis/render.md` Phase 4 and `scripts/smoke-jira-publish.sh`.
170
+
144
171
  ## Wiki → Jira auto-link triad
145
172
 
146
173
  When the **Wiki** adapter writes pages on the same run AND `prefs.global.wikiToJiraComment === true`, the Wiki adapter also posts a humanizer-passed Jira comment summarizing wiki pages (component name + variant count + wiki URL). That secondary comment is independent of this adapter - both can coexist on the same issue. Full contract: `$HOME/.claude/multi-agent-refs/issue-jira-triad.md`.
@@ -201,7 +201,9 @@ This gate has no bypass. No flag, mode, or preference suppresses it.
201
201
  ### [10/12] Create
202
202
 
203
203
  ```bash
204
- jq -n --rawfile desc /tmp/generate-issue-$$.txt \
204
+ node "$HOME/.claude/scripts/jira-wiki-escape.mjs" /tmp/generate-issue-$$.txt \
205
+ > /tmp/generate-issue-$$-escaped.txt
206
+ jq -n --rawfile desc /tmp/generate-issue-$$-escaped.txt \
205
207
  --arg key "$PROJECT_KEY" --arg type "$ISSUE_TYPE" --arg summary "$SUMMARY" \
206
208
  '{fields: {project: {key: $key}, issuetype: {name: $type}, summary: $summary, description: $desc}}' \
207
209
  > /tmp/generate-issue-$$-payload.json
@@ -66,7 +66,7 @@ Flow:
66
66
 
67
67
  1. Emit `→ posting wiki summary to Jira {jiraId}`.
68
68
  2. Read the first N lines of the primary wiki markdown file (the component's overview page - one of `writtenPaths[0]`).
69
- 3. Render as Jira comment: title line (`h3. Component docs - {componentName}`) + an overview paragraph + a link to the full page (wiki URL, if the adapter returned `pushedRemote`). The overview lines come from a Markdown file - convert them via the `channels/jira.md` table before POST, exactly like the title line already is.
69
+ 3. Render as Jira comment: title line (`h3. Component docs - {componentName}`) + an overview paragraph + a link to the full page (wiki URL, if the adapter returned `pushedRemote`). The overview lines come from a Markdown file - convert them via the `channels/jira.md` table before POST, exactly like the title line already is, then run the whole body through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` per that file's *Emoticon escaping* section.
70
70
  4. **Run through the humanizer skill** - same policy as Step 4 Confluence: user-facing content must read naturally.
71
71
  5. `POST {jira.baseUrl}/rest/api/2/issue/{jiraId}/comment`.
72
72
  6. Log: `Phase 7: wiki summary posted to Jira {jiraId}`.
@@ -31,7 +31,7 @@ Language is not the only axis an external payload has. Every surface also has a
31
31
  |---|---|---|---|
32
32
  | PR description (GitHub / Bitbucket / GitLab) | **Markdown**, no conversion | none - post the assembled markdown verbatim | `channels/pr.md` |
33
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` |
34
+ | Jira comment + issue description | **Jira wiki markup** | the table in `channels/jira.md`, applied by the model; the emoticon escape that follows it is a program, `scripts/jira-wiki-escape.mjs`, and is required before every POST/PUT; description writes go through `lib/jira-publish.sh`, which backs the field up and refuses a blind replace | `channels/jira.md` |
35
35
  | Confluence page body | **storage format** (XHTML) | `lib/md2confluence-v3.py` | `channels/confluence.md` |
36
36
  | Wiki pages (`.md` files in a git repo) | **Markdown** | none | `channels/wiki.md` |
37
37
  | Commit message | plain text | none | `rules/git-conventions.md` |
@@ -561,6 +561,19 @@ ASK_CHOICE_DEFAULT="$DEPTH_RECOMMENDATION" \
561
561
 
562
562
  Log: `Phase 0 Step 7.5: depth = {full|short} (recommended {full|short}, source {user|autopilot|default})`
563
563
 
564
+ #### Step 7.6 - Test baseline (opt-in, `prefs.global.testBaseline.enabled`, default `false`)
565
+
566
+ Phase 4 Gate 3 cannot tell an inherited red suite from one this run broke, so it blocks on someone else's bug or the agent "fixes" tests it never touched. Runs after Step 6, only when the stack has a test command; skipped in analysis mode.
567
+
568
+ ```bash
569
+ BASELINE_LOG="$WORKTREE/.baseline-test.log"
570
+ timeout "${prefs_testBaseline_timeoutSeconds:-600}" <same-test-command-as-Phase-4-Gate-3> 2>&1 | tee "$BASELINE_LOG"
571
+ ```
572
+
573
+ Persist `state.baseline.tests` with `command`, `capturedAt`, `logPath` and exactly one status: `green` (passed), `red` + `failing[]` (failed, names parsed), `red` + empty `failing[]` (failed, names unparseable), `unknown` (no test command, `timeout` fired, or flag off). Folding `unknown` into `green` would let a skipped baseline read as a clean tree, which is the failure this record exists to prevent.
574
+
575
+ Log: `Phase 0 Step 7.6: test baseline = {green|red|unknown} ({N} pre-existing failures)`
576
+
564
577
  #### Step 8 - Clarification (opt-in, runs AFTER maturity, BEFORE Phase 1)
565
578
 
566
579
  **Gated by `prefs.global.clarifyAmbiguous.enabled`** (default: `false`). When enabled and `state.maturity.status != "blocker"`:
@@ -34,11 +34,22 @@ node $HOME/.claude/scripts/evidence-gate.mjs --claim test --status passed --evi
34
34
 
35
35
  This prevents a false "it built" claim with no log behind it. On exit 1, treat the gate as failed (do NOT proceed to AI review) and surface the gate's `reason`.
36
36
 
37
+ **Inherited failures (when `state.baseline.tests` exists).** Phase 0 Step 7.6 recorded whether the suite was already red, so Gate 3 blocks on what this work broke, not what it walked into:
38
+
39
+ | baseline status | Gate 3 |
40
+ |---|---|
41
+ | `green` | unchanged; every failure is this run's |
42
+ | `red` + `failing[]` | subtract those ids. Nothing left -> pass, logged `test:pass (inherited {N})`. A NEW failure still blocks. |
43
+ | `red`, empty `failing[]` | do NOT pass and do NOT silently block: report `test:inherited-red (not attributable, <logPath>)` and ask. Inventing a set here masks regressions. |
44
+ | `unknown` / absent | unchanged from today |
45
+
46
+ The subtraction never widens: match on identifier only, and when identifiers cannot be compared fall to the `not attributable` row.
47
+
37
48
  **Gate results:**
38
49
 
39
50
  - All pass (including the evidence gate) -> proceed to AI review
40
51
  - Any fail -> fix immediately, re-run gates (no AI review until clean)
41
- Log: "Phase 4: Gates - build:{pass/fail} lint:{pass/fail} test:{pass/fail} secrets:{clean/found} evidence:{ok/unverified}"
52
+ Log: "Phase 4: Gates - build:{pass/fail} lint:{pass/fail} test:{pass/fail/inherited-red} secrets:{clean/found} evidence:{ok/unverified}"
42
53
 
43
54
  ##### Gate 5 - Fortify SSC findings (runs when `state.contextLinks[]` contains a `fortify` entry, or when `prefs.global.fortify.alwaysCheck === true`)
44
55
 
@@ -374,7 +385,7 @@ Exit 0 = valid. Exit 2 = contradiction (approved=true with blocking findings) -
374
385
  1. Compute disagreement: reviewers agree iff all return `approved=true` with no `blocking` findings, OR all return `approved=false` with overlapping `blocking` findings. Anything else is disagreement.
375
386
  2. Agreement → skip the rebuttal round, go straight to Step 3 triage.
376
387
  3. Disagreement → one rebuttal round:
377
- - For each reviewer, re-prompt with: (a) their original output, (b) the OTHER reviewers' blocker findings verbatim, (c) instruction: *"Given the opposing arguments, keep / withdraw / modify each of your findings. You may also newly agree with a finding you previously missed. Return the SAME JSON schema - this is a revision, not a new review."*
388
+ - For each reviewer, re-prompt with: (a) their original output, (b) the OTHER reviewers' blocker findings **anonymized** through `node $HOME/.claude/scripts/anonymize-findings.mjs` (labels `Source A/B/C`, no model name, order deterministic per `taskId:iteration`), (c) instruction: *"Given the opposing arguments, keep / withdraw / modify each of your findings. You may also newly agree with a finding you previously missed. Return the SAME JSON schema - this is a revision, not a new review."*
378
389
  - Launch all reviewers in parallel (same CLI-aware set as Step 2).
379
390
  - Max one round. Results replace the original outputs.
380
391
  4. Proceed to Step 3 triage with the round-2 outputs.
@@ -393,15 +404,27 @@ Optional: when `ai-analyst-toolkit` is enabled and a finding blames a third-part
393
404
 
394
405
  Opt-in empirical layer: when `prefs.global.verifyByTest.enabled` is `true`, accepted blocking findings additionally go through Step 3.7 (verify-by-test), which tries to reproduce each one with a minimal failing test before the Phase 3 rework loop fires. Full wiring: `$HOME/.claude/multi-agent-refs/features/verify-by-test.md`.
395
406
 
396
- ##### 3.0 Merge the deterministic findings in
407
+ ##### 3.0 Anonymize the reviewer findings, then merge the deterministic ones
408
+
409
+ **Anonymize first (required).** On both CLIs the triage model is also a reviewer (Fable on Claude Code, Opus on Copilot), and a judge that can see which findings are its own is marking its own homework:
397
410
 
398
- Append the Step 1.76 test-integrity findings to the reviewer findings before counting, so they are adjudicated like any reviewer finding rather than resolved by triage never seeing them:
411
+ ```bash
412
+ ANON=$(jq -n --argjson r "$REVIEWERS_JSON" --arg t "$TASK_ID" --argjson i "$ITERATION" \
413
+ '{taskId: $t, iteration: $i, reviewers: $r}' \
414
+ | node "$HOME/.claude/scripts/anonymize-findings.mjs" --map "/tmp/review-$TASK_ID-$ITERATION-map.json")
415
+ ```
416
+
417
+ `$REVIEWERS_JSON` is `state.reviewIterations[i].reviewers`. Findings come back with `foundBy: "Source A|B|C"` and every identity key removed. Persist the map to `state.reviewIterations[i].anonymizationMap` for Phase 7 per-reviewer telemetry, and **never put the map in a prompt**.
418
+
419
+ Then append the Step 1.76 test-integrity findings, so they are adjudicated rather than never seen:
399
420
 
400
421
  ```bash
401
422
  MERGED=$(jq -s '.[0] + (.[1].findings // [])' \
402
- <(printf '%s' "$REVIEWER_FINDINGS_JSON") <(printf '%s' "${TEST_INTEGRITY_JSON:-{\}}"))
423
+ <(printf '%s' "$ANON") <(printf '%s' "${TEST_INTEGRITY_JSON:-{\}}"))
403
424
  ```
404
425
 
426
+ Deterministic findings keep `tag: test_integrity` and carry no `foundBy`: a reviewer finding may be a hallucination, a gate finding is a fact.
427
+
405
428
  ##### 3.1 Short-circuit: no findings
406
429
 
407
430
  If **merged** findings `length === 0`, **skip triage**: write empty result `{"accepted": [], "deferred": [], "rejected": [], "approved": true}`, log, proceed to Phase 5. Note this is the merged count from 3.0: a run with zero reviewer findings but a non-empty test-integrity set must NOT short-circuit.
@@ -410,7 +433,7 @@ If **merged** findings `length === 0`, **skip triage**: write empty result `{"ac
410
433
 
411
434
  Launch **1 Agent** (subagent_type: `general-purpose`, model: `fable` on Claude Code / `opus` on Copilot CLI) with:
412
435
 
413
- - Raw findings from Reviewer 1 + Reviewer 2 (merged JSON)
436
+ - The anonymized merged findings from 3.0 (`Source A/B/C` labels; no model name anywhere in the prompt)
414
437
  - Task scope (Phase 1 analysis summary + Phase 2 plan)
415
438
  - Full diff being reviewed
416
439
  - **Prior-art context (advisory)** - per raw finding, `triage-memory.mjs query --top <prefs.global.priorArtEnrichment.topN>` (default 3). Pass `--top`: without it the script falls back to `memoryRecall.maxResults`, a different concern, and `topN` silently does nothing. Off when `priorArtEnrichment.enabled = false`.