baldart 4.58.0 → 4.59.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.
@@ -6,7 +6,9 @@
6
6
 
7
7
  **Why this exists**: FEAT-0006 incident left the main repo with an unpushed orphan commit (`c9d41f9`) and `develop` diverged from `origin/develop` because `/new` started work on a dirty/diverged repo without surfacing the situation. This phase reconciles the main repo state BEFORE any worktree is created, and snapshots any in-progress user work so Phase 6c can restore it after the batch.
8
8
 
9
- **Auto Mode does NOT override this phase.** Every `AskUserQuestion` below is non-bypassable — the model has no authority to pick an option on the user's behalf.
9
+ **Auto Mode does NOT override this phase.** Every `AskUserQuestion` below is non-bypassable in the interactive path — the model has no authority to pick an option on the user's behalf.
10
+
11
+ > **When AUTONOMOUS** (per § AUTONOMOUS RESOLUTION RULE + § AUTONOMOUS SAFETY BOUNDARY, SKILL.md), the orchestrator still runs every Phase 0 gate — it does NOT skip workspace hygiene — but resolves each by the per-action disposition instead of `AskUserQuestion`: reversible/local choices (stash with a labeled message, ff-pull, push genuine unpushed commits) are AUTO-RUN; anything needing human judgment (a genuine divergence with no safe default, a missing required card field) materializes a follow-up card / HALTs rather than guessing; and the **Migration Gate's remote schema deploy is treated per § AUTONOMOUS SAFETY BOUNDARY** — see step 1b. Each auto-resolution is logged to `## Issues & Flags` as `AUTO: <gate> → <chosen> (<evidence>)`.
10
12
 
11
13
  **Steps:**
12
14
 
@@ -24,6 +26,7 @@
24
26
  3. **(declared) Verify the artifacts exist on disk.** For each path in `migration_plan.artifacts`, check it exists under `$MAIN`. If **any is missing** → do **NOT** apply or prompt; write `## Migration\ndeclared but artifact(s) missing: <paths> — degraded to deferred (author the migration first)` to the tracker and **proceed to step 2** (the migration falls back to the current end-of-batch owner-gated deferral — no regression). *(Auto-generating a missing artifact is out of scope; the migration must already be authored to be applied up-front.)*
25
27
  4. **(declared + artifacts present) Assemble the apply modalities**, in this source order, de-duplicating by `id`: (a) `migration_plan.apply_modalities` from the epic block; (b) a `## Migration modalities` section in `.baldart/overlays/new.md` if present; (c) any project-memory note on how this project applies migrations; (d) the built-in tail `["Già applicata — prosegui", "Abort"]`. Each modality is `{ id, label, command? }`.
26
28
  5. **`AskUserQuestion`** — `"La epic dichiara una migrazione DB (<summary>). Va applicata PRIMA del batch così le card downstream verificano contro lo schema reale. Come procedo?"` with up to 4 of the assembled modalities (always include "Già applicata — prosegui" and "Abort" as the last options). This is a legitimate Phase 0 question (Auto Mode does not override it).
29
+ **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE + § AUTONOMOUS SAFETY BOUNDARY (SKILL.md): a *command* modality is a remote schema deploy (PRE-AUTH action). category=outward; recommended=DEFER — do NOT execute the command modality. Degrade the front-load to the end-of-batch owner-gated deferral exactly as the missing-artifact / off-trunk-guard branches do: write `## Migration\ndeclared but AUTONOMOUS without AUTO_SHIP authorization — front-load deferred (apply after merge / from trunk)` and proceed to step 2. A "Già applicata — prosegui" disposition (no deploy) stays available — pick it only when a deterministic signal (the optional `verify` probe green) proves the schema is already live; otherwise DEFER. EXECUTE the command modality ONLY when `AUTO_SHIP` is set AND `git.auto_deploy` lists this deploy target/intent AND the `schema_deploy_from_trunk_only` guard (step 6) still passes — `git.auto_deploy` layers ON TOP of the branch invariant, never replaces it.**
27
30
  6. **Execute the choice in `$MAIN`** (project env):
28
31
  - **Schema-deploy-from-trunk-only guard** (when `stack.schema_deploy_from_trunk_only: true`): a **command** modality is a remote schema deploy, so it may run only from trunk. Before executing one, check `git -C "$MAIN" rev-parse --abbrev-ref HEAD`; if it is **not** `git.trunk_branch`, do **NOT** apply or prompt for a command modality — write `## Migration\ndeclared but $MAIN is on '<branch>' (not trunk '<trunk>') — front-load deferred per schema_deploy_from_trunk_only (apply from trunk, then re-run)` to the tracker and **proceed to step 2** (the migration falls back to the end-of-batch owner-gated deferral — no regression). "Già applicata — prosegui" stays available (it executes no deploy). When the key is `false`/unset this guard is a no-op.
29
32
  - a **command** modality → run it with output to disk (`<cmd> > /tmp/migration-<FIRST-CARD-ID>.log 2>&1`), surface only the exit code; on exit 0 run the optional `migration_plan.verify` probe and require it green too. On **failure** → surface a bounded extract (`tail -n 30`) and re-ask (re-offer the modalities + Abort); never silently proceed against a non-live schema. **Never run a command without the user having selected it.**
@@ -59,6 +62,7 @@
59
62
  **Why**: Phase 8 copies the batch tracker into `$METRICS/archive/` and appends to `$METRICS/skill-runs.jsonl` *after* the merge, and never commits them. And `baldart update`/`add` regenerate `.baldart/generated/*` + bump `.baldart/state.json` on every framework sync — left uncommitted until the next reconcile. If either class tripped this gate, **every batch would block on the previous update's own output** — a self-inflicted loop. They are not user work and the user is never asked about them.
60
63
  - If `$PORCELAIN` is non-empty but `$USER_DIRTY` is **empty** (only telemetry under `$METRICS` and/or BALDART-managed `.baldart/` artifacts are dirty) → do **NOT** gate. Write `Dirty: framework-managed-only (auto-ignored)` to the tracker and proceed to step 4. No `AskUserQuestion`.
61
64
  - If `$USER_DIRTY` is **non-empty** (genuine uncommitted user work) → invoke `AskUserQuestion`:
65
+ - **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=reversible; recommended=option 1 "Stash automatico (restore in Phase 6c)" — AUTO-RUN (reversible, the labeled stash is restored post-batch); log `AUTO: dirty-tree → stashed (<label>)`. NEVER option 3 "Procedi senza stash" autonomously (it accepts responsibility a human must own).**
62
66
  - Question: `"Main repo ($MAIN) ha modifiche non committate prima di partire con la batch. Come procedo?"`
63
67
  - Options (max 4):
64
68
  1. **"Stash automatico (restore in Phase 6c)"** — `$MAIN` is the main checkout (NOT a worktree), so a stash here is safe. Run `git -C "$MAIN" stash push -u -m "baldart-new-phase0-<FIRST-CARD-ID>-<timestamp>"` and write the **stash MESSAGE label** (`baldart-new-phase0-<FIRST-CARD-ID>-<timestamp>`) to the tracker under `## Workspace Snapshot: <message-label> (created <timestamp>)`. Phase 6c resolves the stash by this message — NEVER by a positional `stash@{N}`, because other stashes pushed during the batch (on the main checkout) would shift the index. Do NOT record `stash@{0}`.
@@ -95,6 +99,8 @@
95
99
  ```
96
100
  `$FW_SKIPPED` framework-management commits are auto-ignored (log them — see below). Branch on `$BEHIND` and the **genuine** ahead count `$GENUINE_AHEAD` (NOT the raw `$AHEAD`):
97
101
 
102
+ > **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE + § AUTONOMOUS SAFETY BOUNDARY (SKILL.md) at each branch below:** behind-only → ff-pull (AUTO-RUN, only when `$MAIN` HEAD == `$TRUNK`); ahead-only → push the genuine commits to `origin/$TRUNK` (AUTO-RUN, FF only) — `Cherry-pick` / `Reset --hard` are NOT auto-selected (reset-hard is HARD-NEVER); diverged-both-ways → no safe default → materialize a follow-up card and proceed without reconciling (NEVER auto-rebase/reset). Log `AUTO: divergence → <chosen> (<evidence>)`. `Abort` is never auto-selected (an unattended run does not silently abort a hygiene gate it can defer).
103
+
98
104
  - **`BEHIND=0` and `GENUINE_AHEAD=0`** — synchronized for `/new`'s purposes. If `$FW_SKIPPED > 0`, write `Divergence: $FW_SKIPPED framework-management commits ahead (auto-ignored — baldart update/subtree)` to the tracker. **No `AskUserQuestion`.** Proceed to step 5.
99
105
  - **`BEHIND>0` and `GENUINE_AHEAD=0`** (only behind) — invoke `AskUserQuestion`:
100
106
  - Question: `"Local $TRUNK è behind origin/$TRUNK di $BEHIND commit. Come procedo?"`
@@ -132,7 +138,7 @@
132
138
  **1b-i — HALT on non-derivable fields (ask the user).** Verify the fields that cannot be safely synthesized are present and non-empty:
133
139
  - `requirements` (>=1), `acceptance_criteria` (>=1), `files_likely_touched` (>=1 file), and `scope`.
134
140
  - `scope_boundaries` is **NOT** in this set (it is conditional — legitimately omitted for standalone cards with no siblings; see card-schema.md).
135
- If any card fails: log the specific missing fields in `## Issues & Flags`, ask the user to fill them in before proceeding with that card, and continue pre-flight for any remaining valid cards.
141
+ If any card fails: log the specific missing fields in `## Issues & Flags`, ask the user to fill them in before proceeding with that card, and continue pre-flight for any remaining valid cards. **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=none safe (non-derivable fields cannot be fabricated — SC-4 "missing fields → SKIP+report, never fabricate") → skip that card from the batch and log `AUTO: card-baseline → skipped <CARD-ID> (missing <fields>)`; continue with the valid cards. NEVER invent requirements/AC/scope.**
136
142
 
137
143
  **1b-ii — Back-fill deterministically-computable fields (compute → persist → log).** For each non-epic card missing a derivable field, compute it and **write it back to the card on disk in `$MAIN/${paths.backlog_dir}`** (the main repo path resolved at Phase 0 step 1 — NOT the worktree copy; same F-040 discipline `new2` uses for follow-ups):
138
144
  - `review_profile` absent → compute via `prd-card-writer.md § Rule C` (the SSOT), write it.
@@ -142,10 +148,10 @@
142
148
  - **Commit discipline:** after all cards are processed, if ANY back-fill was written, commit them together in `$MAIN` with the COMMIT_LOCK + doc-freshness precautions of `merge-cleanup.md` Phase 6b (clear stale `COMMIT_LOCK`; `git add` only the back-filled card YAMLs; if the doc-freshness hook blocks, stage `ssot-registry.md`; ≤2 retries): `git commit -m "chore(backlog): normalise card baseline [BACKFILL]"`. Do NOT leave `$MAIN` dirty for the batch duration — an uncommitted backlog edit would surface as noise in the Phase 3/4 git-diff gates.
143
149
  - `canonical_docs` / `links.prd` absent on a standalone/non-PRD card → **WARN** only (log, do not block, do not back-fill).
144
150
 
145
- **1b-iii — Conformance validation.** Run the framework validator `node .framework/framework/scripts/validate-card-baseline.js <card-yaml-path>` (or the path resolved for your install) against each card. It is profile-aware (epic/child/standalone) and catches what 1b-i/1b-ii could not — invalid enum values (`review_profile` ∉ `{skip,light,balanced,deep}`, `owner_agent` ∉ Rule A enum), or a REQUIRED field still missing. Exit 1 → display the per-field errors in `## Issues & Flags` and HALT that card (continue others). If the validator is not reachable in this install, skip with a one-line note (the 1b-i/1b-ii gates still applied).
151
+ **1b-iii — Conformance validation.** Run the framework validator `node .framework/framework/scripts/validate-card-baseline.js <card-yaml-path>` (or the path resolved for your install) against each card. It is profile-aware (epic/child/standalone) and catches what 1b-i/1b-ii could not — invalid enum values (`review_profile` ∉ `{skip,light,balanced,deep}`, `owner_agent` ∉ Rule A enum), or a REQUIRED field still missing. Exit 1 → display the per-field errors in `## Issues & Flags` and HALT that card (continue others). **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=none safe (a conformance error is a data defect the orchestrator must not fabricate around) → skip that card from the batch and log `AUTO: card-conformance → skipped <CARD-ID>`; continue with the valid cards.** If the validator is not reachable in this install, skip with a one-line note (the 1b-i/1b-ii gates still applied).
146
152
 
147
153
  1c. **Field Registry Validation (pre-flight gate)** — for each card with a `data_fields` block, run the project's field-validation tool if available (path listed in `.baldart/overlays/new.md`; typically `python3 tools/validate-card-fields.py <card-yaml-path>`).
148
- - If exit 1: display the field errors in `## Issues & Flags` and HALT — ask the user to fix the card before proceeding. Do not start implementation until the card passes validation.
154
+ - If exit 1: display the field errors in `## Issues & Flags` and HALT — ask the user to fix the card before proceeding. Do not start implementation until the card passes validation. **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=none safe (a field-registry error is a data defect the orchestrator must not fabricate around) → skip that card from the batch and log `AUTO: field-validation → skipped <CARD-ID>`; continue with the valid cards.**
149
155
  - If the card has DB-index signals (`db_indexes` — or legacy `firestore_indexes` — or `data.collections`/`data.tables`) but NO `data_fields` block: log WARNING in `## Issues & Flags` — "Card `<ID>` touches the persistence layer but has no `data_fields` block. Field/column names are unvalidated."
150
156
  - Cards with no `data_fields` and no storage signals: skip silently.
151
157
  - If the project does not ship a field-validation tool: skip this step.
@@ -259,7 +265,7 @@
259
265
  ```
260
266
  Parse PASS / `CODEX_NOT_FOUND` / conflict-findings from that filtered output only. When logging to the tracker, write the **distilled findings** (one line per conflict: cards + type + one-line fix) — never paste the raw block, and never the trace.
261
267
  - Read the exact `$AUDIT_FILE` path recorded in the tracker after the background command completes (always via the trace-stripping filter above).
262
- - If the file contains **`CODEX_NOT_FOUND`** (Codex unavailable): do NOT silently proceed — **fallback**: spawn the `code-reviewer` agent over the full batch's card YAMLs + file-ownership map with the same cross-card conflict questions (FILE_CONFLICT / IMPLICIT_DEP / ORDER_RISK / STATE_MUTATION). If `code-reviewer` is also unavailable, surface `CAPABILITY_UNAVAILABLE: cross-card-review` to the user and ask whether to proceed without cross-card detection or halt. Never run a multi-card batch with cross-card review silently skipped.
268
+ - If the file contains **`CODEX_NOT_FOUND`** (Codex unavailable): do NOT silently proceed — **fallback**: spawn the `code-reviewer` agent over the full batch's card YAMLs + file-ownership map with the same cross-card conflict questions (FILE_CONFLICT / IMPLICIT_DEP / ORDER_RISK / STATE_MUTATION). If `code-reviewer` is also unavailable, surface `CAPABILITY_UNAVAILABLE: cross-card-review` to the user and ask whether to proceed without cross-card detection or halt. Never run a multi-card batch with cross-card review silently skipped. **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=use the `code-reviewer` fallback (the deterministic safe default); only if BOTH Codex and `code-reviewer` are unavailable → proceed without cross-card detection but log `AUTO: cross-card-review → unavailable, proceeded (CAPABILITY_UNAVAILABLE)` to `## Issues & Flags` (an unattended run cannot halt indefinitely on a missing capability; the per-card + Final gates remain).**
263
269
  - If **PASS** or file empty: proceed normally.
264
270
  - If **conflicts found**: log in tracker under `## Cross-Card Conflicts (Codex)` and present to user. For each conflict:
265
271
  - `FILE_CONFLICT` / `ORDER_RISK` → force the conflicting cards sequential (update file-ownership map).
@@ -6,6 +6,8 @@
6
6
 
7
7
  When the complexity assessment (step 3c) selects team mode, the orchestrator changes role: instead of executing each card's pipeline sequentially, it coordinates parallel coder agents — each with its OWN isolated context window.
8
8
 
9
+ > **When AUTONOMOUS, team-mode is FORCED to sequential single-worktree** (per § AUTONOMOUS RESOLUTION RULE, SKILL.md — cost/predictability: a wide parallel fan-out saturates the shared API rate-limit pool and kills coders mid-work, paying twice; an unattended run cannot afford that non-determinism). So under `-auto`/`--auto`/`BALDART_AUTONOMOUS`/`CI`, step 3c's team-mode decision is overridden to `sequential` regardless of card count / group width, and the per-card pipeline (the rest of `/new`) runs instead of this section. Log `AUTO: execution-mode → sequential (AUTONOMOUS forces single-worktree)` in `## Execution Mode`. This section applies ONLY to the non-AUTONOMOUS interactive path.
10
+
9
11
  **Key principle**: the orchestrator stays LEAN. It holds only:
10
12
  - The tracker file path
11
13
  - Parallel group status (pending/active/done)
@@ -154,12 +156,12 @@ For each completed agent:
154
156
  `D="$(ls -dt ~/.claude/projects/*/${CLAUDE_SESSION_ID:-*}/subagents 2>/dev/null | head -1)"; f="$(grep -l '"<teammate-label>"' "$D"/*.meta.json 2>/dev/null | head -1 | sed 's/\.meta\.json$/.jsonl/')"; tail -c 4000 "$f"`
155
157
  (if `$CLAUDE_SESSION_ID` is unset, the most recent `subagents/` dir under this project is the live session's.) If the transcript truly cannot be located, fall back to whatever the `Agent` tool result surfaced — but do NOT default to "genuine empty-result" without having looked. **State the classification you reached + the evidence in your narration** (e.g. "03 last event = `Rate limited` → transient").
156
158
  - **Transient infra failure (the common cause of a silent empty rest in a WIDE parallel wave).** The agent's last event is an API error — signatures: `isApiErrorMessage:true`, `Rate limited`, `Server is temporarily limiting requests`, `overload`, `(not your usage limit)` — i.e. the background teammate was **killed mid-flight by API rate-limiting** (it had done real work — reads, a plan — then died), NOT a model fabrication or "decided nothing to do". The runtime does NOT auto-resume a dead teammate, so it rests with no report + no diff. **Re-spawn it, but STAGGERED with backoff — do NOT re-fire several transient-failed agents in the same parallel burst** (that re-saturates the API and kills them again). Space them out (one at a time, or after a brief pause). This still costs a fresh run (the killed agent's reads are lost — the runtime cannot resume it), so **prevention beats recovery**: if a wave repeatedly hits rate limits, NARROW the parallel fan-out width for that wave (spawn fewer coders at once) rather than re-firing the full wave. A transient failure does NOT consume the Step-B genuine-failure budget.
157
- - **Genuine empty-result / fabrication.** The agent rested CLEANLY (no error in its last event), with no completion report and no diff. THIS is the model-fault case: take the one Step-B re-spawn below (same cap), re-briefing the coder with an explicit "write your ownership files and confirm them on disk before reporting done" mandate. A second empty result → `AskUserQuestion` (skip/abandon) — do not re-spawn a third time.
159
+ - **Genuine empty-result / fabrication.** The agent rested CLEANLY (no error in its last event), with no completion report and no diff. THIS is the model-fault case: take the one Step-B re-spawn below (same cap), re-briefing the coder with an explicit "write your ownership files and confirm them on disk before reporting done" mandate. A second empty result → `AskUserQuestion` (skip/abandon) — do not re-spawn a third time. **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=none safe (a card whose coder produced nothing twice has no implementation) → skip the card AND materialize a follow-up card carrying its spec (NEVER mark it DONE / no-op).** (Classify the cause first — a transient rate-limit death is re-spawned staggered, not counted against this budget.)
158
160
 
159
161
  **If an agent fails** (status: failed after 3 retries — the central repair cap):
160
162
  - Log failure in tracker `## Issues & Flags`.
161
163
  - Other agents in the group continue unaffected.
162
- - After group completes, ask user: retry failed card or skip?
164
+ - After group completes, ask user: retry failed card or skip? **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=take the one Step-B re-entry below (the central re-try cap); if it fails again → skip the card AND materialize a follow-up card (NEVER drop it silently).**
163
165
  - **Retry re-entry (defined path):** on "retry", re-run the failed card from **Step B** (re-spawn the autonomous coder with a fresh briefing) — NOT from Step A, because the Step A arch context for the group is already in hand and is reused. The retried card then rejoins the group's Step D pipeline and MUST pass the SAME mandatory per-card sub-steps as its peers (D.3a AC-Closure → D.3b Simplify → D.3c E2E → D.4 QA → D.4a doc → D.4b codex → D.5 commit → D.6 backlog); there is no shortcut. The Step D coverage assertion will block Step E until the retried card has all mandatory tracker entries — so a retried card cannot silently skip a gate. **Cap: one Step-B re-entry per card** (consistent with the central cap discipline); on a second failure, re-ask with only skip/abandon so the loop cannot recur unbounded.
164
166
 
165
167
  #### Step D: Post-group review + QA (ALL sub-steps MANDATORY)
@@ -228,7 +230,7 @@ After ALL agents in the group complete successfully:
228
230
  residual → targeted `security-reviewer`; else unconverged code/perf residual → targeted `coder`).
229
231
  Apply the **same BLOCKING QA-gate consumption**:
230
232
  `gateTable` with any `status:"FAIL"` OR `summary.checksFailed` → coder fix (≤2 retries) then
231
- `AskUserQuestion`; **D.5 commit MUST NOT happen until `gateTable` is PASS/SKIP and `checksFailed` is
233
+ `AskUserQuestion` (**when AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE, SKILL.md: category=blocker; recommended=none safe → follow-up card, do NOT commit the wave's affected card; NEVER force-proceed past a FAIL**); **D.5 commit MUST NOT happen until `gateTable` is PASS/SKIP and `checksFailed` is
232
234
  false** (a delegated QA FAIL blocks exactly as inline D.4 / Phase 3.5 would — `gateTable` is
233
235
  wave-scoped). Append the workflow `fixesApplied`/`residual` to `## Fix Application Log`
234
236
  (`decision=workflow`, with `card=<ID>`).
@@ -263,14 +265,14 @@ After ALL agents in the group complete successfully:
263
265
 
264
266
  3. **D.3 — Apply fixes (group)** — If D.2 findings exist, spawn ONE fix-coder to apply all fixes in a single pass. Run build + lint after.
265
267
 
266
- 3a. **D.3a — Phase 2.5b AC-Closure Gate (per-card, BLOCKING — non-skippable)** — For EACH card in the group, **sequentially**, invoke the full Phase 2.5b gate as documented in `### Phase 2.5b — AC-Closure Gate (BLOCKING — Scope Closure Discipline)`. This includes: build the AC Closure Ledger from the card YAML, run the rationalization scan, invoke `AskUserQuestion` one-per-deferred-AC, run the `implementation_notes` deferral audit, and persist the ledger in the tracker. Until EVERY card in the group exits PASS, do NOT proceed to D.3b. Cards exiting with `not_implemented` ACs that the user routes to "Implementa adesso" must finish their fix-coder loop and re-pass the gate before D.3b starts for the next card. Log under `## AC Closure Ledger — <CARD-ID>` per card.
268
+ 3a. **D.3a — Phase 2.5b AC-Closure Gate (per-card, BLOCKING — non-skippable)** — For EACH card in the group, **sequentially**, invoke the full Phase 2.5b gate as documented in `### Phase 2.5b — AC-Closure Gate (BLOCKING — Scope Closure Discipline)`. (Its Step-4 user gate carries the AUTONOMOUS pointer in `references/completeness.md` — under AUTONOMOUS a deferred AC becomes a follow-up card, never an auto-approved deferral.) This includes: build the AC Closure Ledger from the card YAML, run the rationalization scan, invoke `AskUserQuestion` one-per-deferred-AC, run the `implementation_notes` deferral audit, and persist the ledger in the tracker. Until EVERY card in the group exits PASS, do NOT proceed to D.3b. Cards exiting with `not_implemented` ACs that the user routes to "Implementa adesso" must finish their fix-coder loop and re-pass the gate before D.3b starts for the next card. Log under `## AC Closure Ledger — <CARD-ID>` per card.
267
269
 
268
270
  3b. **D.3b — Phase 2.55 Simplify (per-card, FANNED OUT across the group)** — The Simplify agents are **read-only analysis on file-disjoint per-card diffs** (the orchestrator applies the fixes afterward), so there is NO reason to run them one card at a time. **Spawn the per-card Simplify analysis for ALL eligible cards in PARALLEL** — in a SINGLE message, fire each card's Phase 2.55 trio (Reuse / Quality / Efficiency) against that card's diff captured to `/tmp/diff-<CARD-ID>.txt` (per Phase 2.55 step 2 — pass each trio the **path**, scoped to the card's File Ownership Map; never inline the diff). Per-card (not group-aggregate) so findings stay attributable. When all analyses return, **apply fixes per card** (file-disjoint → no write conflict), then re-run `npm run lint` and `npx tsc --noEmit` (when `has_toolchain`, the configured `toolchain.commands.{lint,typecheck}` verbatim — § "Toolchain gates") on the worktree ONCE for the whole group (redirect to disk per § "Context economy"). (Concurrency is capped by the platform; passing N cards is safe — excess agents queue.)
269
271
  - **Gate (enumerated, `TRIVIAL_CARDS`-driven)**: SKIP D.3b for a card in **`TRIVIAL_CARDS`** (the set already computed at D.1.5 — `review_profile == skip` AND 0 Step-A triggers AND **non-source diff**), aligning team mode with sequential Phase 2.55's `IS_TRIVIAL` re-confirmation on the ACTUAL diff and with team-mode's own D.1.5 SSOT. A trivial card has no substantive diff to simplify, and Simplify is quality-only (no merge-gate coverage to lose). Log `simplify: SKIPPED (trivial — non-source diff)`. **A card with `review_profile == skip` whose committed diff DID touch a source file is NOT in `TRIVIAL_CARDS` → run D.3b for it** (exactly as sequential 2.55 does — `skip` is the floor, the real diff is the deciding check). For `light`/`balanced`/`deep` cards D.3b runs unchanged. This is the ONLY enumerated skip — never skip D.3b "for time" on a non-trivial card. (Skipped cards are simply omitted from the parallel fan-out.)
270
272
 
271
273
  3c. **D.3c — Phase 2.6 E2E-Review (per-card)** — First, evaluate the existing Gate table for EVERY card at once (skip when `features.has_e2e_review: false`, backend-only diff per the diff predicate documented in Phase 2.6, or card type in the Phase 2.6 skip set — `backend`/`api`/`db`/`infra`/`docs`/`chore`/`config`). In practice most cards in a group skip this gate (backend/db/api), so the eligible set is usually 0–1. For the cards that PASS the gate, invoke `/e2e-review` in programmatic mode with that card's payload. Each `/e2e-review` keeps its own isolated state dir (`.baldart/e2e-review/<CARD-ID>/`), so multiple runs do not clobber each other's artifacts.
272
274
  - **Parallel-when-safe**: if **two or more** cards pass the gate, you MAY fan them out in parallel ONLY when they hit **disjoint routes/pages** — the genuinely shared resource is the worktree's single dev-server port, and concurrent Playwright sessions against one server are fine for disjoint routes. If the eligible cards touch overlapping routes (or `/e2e-review` spins its own server and a port clash is possible), run those **sequentially** to avoid a flaky cross-test. Default to parallel for the common disjoint case; fall back to sequential on any contention.
273
- - BLOCKING per-card: if `/e2e-review` returns `"blocked"` or `"error"`, surface to the user via the same `AskUserQuestion` documented in Phase 2.6 — do NOT proceed to D.4 with an unresolved card. Skips are logged with the documented gate reason, never with `"time budget"` or similar.
275
+ - BLOCKING per-card: if `/e2e-review` returns `"blocked"` or `"error"`, surface to the user via the same `AskUserQuestion` documented in Phase 2.6 — do NOT proceed to D.4 with an unresolved card. Skips are logged with the documented gate reason, never with `"time budget"` or similar. **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md) exactly as the Phase 2.6 `blocked`/`error` rows do (`references/review-cycle.md`): category=blocker; `blocked` → escalate to a follow-up card (never auto-override); `error` → retry once then skip-E2E + follow-up card.**
274
276
 
275
277
  4. **D.4 — QA gate (group; since v4.7.0 — runs only for `deep` / risk-escalation, else deferred to Final)** — Read each card's `review_profile` field and the D.1.5 Step-A trigger result. **Run the group qa-sentinel (once, at FULL) iff** the group's MAX profile is `deep` **OR** any card in the group had a Step-A risk escalation. **Otherwise (max profile ≤ `balanced`, no escalation) → DEFER the group QA to the Final Review F.3 qa-sentinel** (FULL suite + build + audit over the entire batch — the unconditional merge gate); log `D.4 QA: DEFERRED to Final FULL gate (group max=balanced, no risk escalation)`. This mirrors sequential step 21b. When it DOES run, invoke qa-sentinel at FULL (the combined group diff must be validated together) using the same prompt contract as Phase 3.5 step 22. **Granularity (INTENTIONAL — not a drift)**: the predicate is identical to sequential 21b (`deep`/risk-escalation → FULL, else defer to the Final FULL gate), but it is evaluated **per-GROUP** here whereas sequential evaluates it per-CARD. This is by design — qa-sentinel runs once over the group's combined diff (the wave-batched model gives it no per-card granularity), so the gate is deliberately the more conservative side: a single `deep`/risk card escalates the WHOLE group to FULL rather than just itself. No correctness gap — every card still reaches a FULL suite no later than the unconditional Final gate. ⚠️ A balanced-only group runs its first suite at the Final gate; merge safety is preserved because Final is FULL over the whole batch.
276
278
 
@@ -280,7 +282,7 @@ After ALL agents in the group complete successfully:
280
282
  - **`LIGHT_CARDS \ TRIVIAL_CARDS` → run `/codexreview` at `profile: light`** (since v4.18.0): **Codex is the sole finder** + the Step 3 FP-gate (`code-reviewer` + `codebase-architect` validate); no api-perf/doc/CoVe. This is where these cards' per-card code review now lives (D.2 no longer code-reviews them). On Codex-unavailable, `light` degrades to `code-reviewer` finder. Log `codex-review: <verdict> (light — Codex finder + FP-gate)`.
281
283
  - **`TRIVIAL_CARDS` → SKIP D.4b** (non-source diff — nothing for Codex to review; covered by the doc-reviewer group pass + the Final FULL gate). Log `codex-review: SKIPPED (trivial — non-source diff; covered by Final FULL gate)`.
282
284
  - **`FULL_CARDS` → run `/codexreview` at `profile: full`** (Codex adversarial + `code-reviewer` finder + CoVe + FP-gate). Keep the sequential one-at-a-time loop (avoid N concurrent `/codexreview`, each of which fans out multiple sub-agents → rate-limit risk). ⚠️ Since light cards now also run here, the D.4b loop is longer per group — accepted cost of moving the diff sweep onto Codex.
283
- Apply the same fix sub-loop as sequential Phase 3.7 Step C.4: if the consolidated report shows verified BLOCKER/HIGH findings, spawn a fix-coder and — **re-writing the consumed-once lean contract first** — re-invoke `/codexreview` via the Skill tool with `args: <CARD-ID>` (max 2 retries per card; a bare prose mention or a missing card-ID would let the retry review the wrong card). If still BLOCKER/HIGH after retries, ask the user before proceeding to D.5. The D.5 commits MUST NOT happen until every card in the group has a PASS verdict (or explicit user override via `AskUserQuestion`). Log results in the tracker under `## Pre-Merge Codex Review` per card. **Lean + profile (since v3.35.0)**: before each card's `/codexreview`, apply the same Review Profile Selector and write the same `/tmp/codexreview-lean-<CARD-ID>.json` contract as sequential Phase 3.7 Step C — `arch_baseline_path` pointing at `/tmp/arch-baseline-group-<FIRST-CARD-ID>.md`, `skip_doc_reviewer: true`, `skip_api_perf_auditor: true` (since v4.7.0 — api-perf deferred to the Final F.3 batch-wide auditor), and `profile` resolved from D.1.5 (`light` cards → `light`, `full` cards → `full`; do NOT recompute). The post-batch **Final-review FULL gate** (a single FULL `/codexreview` over the entire batch diff, which team mode reaches via "Post-batch — same as sequential mode") remains the cross-card safety net for every card including the trivial ones.
285
+ Apply the same fix sub-loop as sequential Phase 3.7 Step C.4: if the consolidated report shows verified BLOCKER/HIGH findings, spawn a fix-coder and — **re-writing the consumed-once lean contract first** — re-invoke `/codexreview` via the Skill tool with `args: <CARD-ID>` (max 2 retries per card; a bare prose mention or a missing card-ID would let the retry review the wrong card). If still BLOCKER/HIGH after retries, ask the user before proceeding to D.5. **When AUTONOMOUS, apply § AUTONOMOUS RESOLUTION RULE (SKILL.md): category=blocker; recommended=route the residual BLOCKER/HIGH to the writer (security-reviewer/coder); if still unresolved → follow-up card and do NOT commit that card (NEVER force a PASS verdict).** The D.5 commits MUST NOT happen until every card in the group has a PASS verdict (or explicit user override via `AskUserQuestion`). Log results in the tracker under `## Pre-Merge Codex Review` per card. **Lean + profile (since v3.35.0)**: before each card's `/codexreview`, apply the same Review Profile Selector and write the same `/tmp/codexreview-lean-<CARD-ID>.json` contract as sequential Phase 3.7 Step C — `arch_baseline_path` pointing at `/tmp/arch-baseline-group-<FIRST-CARD-ID>.md`, `skip_doc_reviewer: true`, `skip_api_perf_auditor: true` (since v4.7.0 — api-perf deferred to the Final F.3 batch-wide auditor), and `profile` resolved from D.1.5 (`light` cards → `light`, `full` cards → `full`; do NOT recompute). The post-batch **Final-review FULL gate** (a single FULL `/codexreview` over the entire batch diff, which team mode reaches via "Post-batch — same as sequential mode") remains the cross-card safety net for every card including the trivial ones.
284
286
 
285
287
  5. **D.5 — Commit** — One commit per card using explicit staging from the ownership map. **NO stash in worktrees** (stashes are globally shared via `refs/stash` — see Phase 4 WORKTREE COMMIT RULE):
286
288
  ```bash
@@ -294,7 +296,7 @@ After ALL agents in the group complete successfully:
294
296
  git commit -m "[CARD-ID] Brief description"
295
297
  fi
296
298
  ```
297
- If `git commit` itself fails with a non-empty staging area (a pre-commit / lint-staged hook rejected it), apply the SAME bounded handler as sequential Phase 4 step 27: clear a stale `COMMIT_LOCK`, re-stage explicitly, retry at most **2 times**, then log `[COMMIT-BLOCKED]` and escalate via `AskUserQuestion` — never loop unbounded and never silently leave a card uncommitted.
299
+ If `git commit` itself fails with a non-empty staging area (a pre-commit / lint-staged hook rejected it), apply the SAME bounded handler as sequential Phase 4 step 27: clear a stale `COMMIT_LOCK`, re-stage explicitly, retry at most **2 times**, then log `[COMMIT-BLOCKED]` and escalate via `AskUserQuestion` — never loop unbounded and never silently leave a card uncommitted. (Under AUTONOMOUS this inherits the same § AUTONOMOUS RESOLUTION RULE disposition as sequential Phase 4 step 27 — see `references/commit.md`: a persistent hook rejection has no safe default → follow-up card, never force-commit / never mark DONE.)
298
300
 
299
301
  6. **D.6 — Update backlog (MANDATORY — do NOT skip)** — For EACH card in the group:
300
302
  a. Edit the backlog YAML (`${paths.backlog_dir}/<CARD-ID>.yml`): set `status: DONE`, add `completed_date: <today>`, add implementation notes (NEVER include `[USER-APPROVED DEFERRAL]` lines that didn't actually pass through D.3a's gate).
@@ -138,6 +138,19 @@ const parseFindingsArray = (s) => {
138
138
  if (typeof s !== 'string' || !s.trim()) return null
139
139
  try { const v = JSON.parse(s.trim()); return Array.isArray(v) ? v : null } catch { return null }
140
140
  }
141
+ // Collision-safe: map a writer-returned token to the canonical prefixed finding_id.
142
+ // Accept exact equality OR a UNIQUE colon-delimited tail match (id.endsWith(':'+token)).
143
+ // A bare suffix (e.g. '001' vs 'CR-001') must NOT match → only ':'+token. 0 or >1 → return
144
+ // the token unchanged (caller logs the mismatch; never silently mis-attribute across cards).
145
+ function makeResolveId(canonIds) {
146
+ return (returned) => {
147
+ const t = String(returned == null ? '' : returned).trim()
148
+ if (!t) return returned
149
+ if (canonIds.includes(t)) return t
150
+ const hits = canonIds.filter((id) => id === t || id.endsWith(':' + t))
151
+ return hits.length === 1 ? hits[0] : returned
152
+ }
153
+ }
141
154
  // SSOT for the sentinel-extraction awk — used by BOTH the relay prompt and the re-extract agent.
142
155
  const AWK_EXTRACT = `awk '/<<<FINDINGS_JSON>>>/{c=1;b="";next}/<<<END_FINDINGS_JSON>>>/{c=0;last=b;next}c{b=b $0 ORS}END{printf "%s",last}'`
143
156
  const reExtractPrompt = (file) =>
@@ -385,22 +398,37 @@ for (const item of findResults) {
385
398
  }
386
399
 
387
400
  // ONE deterministic fallback: pre-flight found no companion, or it ran but did not complete.
388
- // PROSE-ONLY salvage (v4.56.0): when Codex FINISHED but emitted prose (no sentinel JSON), seed the
389
- // fallback with that prose as UNVERIFIED leads. The relay never parses prose into findings; the
390
- // fallback code-reviewer (a real reviewer WITH code access) re-verifies each lead against the actual
391
- // diff and emits the schema itself so Codex's cross-model finds are recovered, not discarded, and
392
- // the trust model is unchanged (output is validated by a reviewer over real code). Net vs the old
393
- // cold fallback: same full pass, but seeded recovers the finds, typically cheaper (confirm vs hunt).
401
+ // Two sub-paths (SC-3):
402
+ // SEEDED (Codex FINISHED but emitted PROSE, no sentinel JSON): run a CHEAP CONVERTER that turns the
403
+ // prose into the finding schema WITHOUT an independent re-hunt Codex already did the cross-model
404
+ // review, re-hunting the whole diff is the wasted second pass. The converted findings are source
405
+ // 'codex' but NON-preValidated, so the existing Verify phase validates each over real code (trust
406
+ // model unchanged: Verify validates). Layered coverage stays per-card simplify/security + qa +
407
+ // Verify + the batch Final pass keep hunting; only the duplicate full re-hunt is removed.
408
+ // COLD (no prose at all): no Codex signal to convert → run today's FULL code-reviewer (preValidated).
394
409
  if (!codexRan) {
395
- codexEngine = codexProse ? 'code-reviewer (codex-seeded)' : 'code-reviewer (fallback)'
396
- const seedBlock = codexProse
397
- ? `\n\nCodex (a DIFFERENT model) reviewed this exact diff and reported the following findings in PROSE — it failed to emit machine-readable JSON, so these are UNVERIFIED leads, NOT validated findings. VERIFY each against the actual diff at its cited file:line, DROP any that don't hold, then run your own full independent review for anything Codex missed. Treat the prose as hints to check, never as ground truth:\n<codex-prose>\n${codexProse}\n</codex-prose>`
398
- : ''
399
- const fb = await agent(
400
- `Codex was unavailable for this wave's code review. Run the FULL code review yourself over the wave diff, per ${protocolRef} (Phase 3.7).\n\n${waveBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied (your findings are treated as validated). An observation you VERIFIED needs NO change (fix direction "no fix required" / "acceptable as-is") is not work — set requires_action:false (recorded, never sent to a fixer), or do not emit it.${seedBlock}`,
401
- { label: codexProse ? 'code-reviewer (codex-seeded)' : 'code-reviewer (fallback)', phase: 'Discovery', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
402
- )
403
- if (fb && Array.isArray(fb.findings)) raw.push(...fb.findings.map((f) => ({ ...f, source: 'code-reviewer', preValidated: true })))
410
+ if (codexProse) {
411
+ codexEngine = 'codex (prose-converted)'
412
+ const conv = await agent(
413
+ `Convert the Codex PROSE findings below into the structured finding schema. Codex (a DIFFERENT model) already reviewed this exact diff; your job is ONLY transcription, not review.\n\n` +
414
+ `For each finding in the prose: keep the cited file:line + severity + a one-line minimal_fix_direction. Do a LIGHT plausibility check — DROP a lead whose cited file:line is obviously wrong/empty/missing. Do NOT perform an independent code review, do NOT hunt for new issues, do NOT re-read the whole diff.\n\n` +
415
+ `${waveBrief}\n\n<codex-prose>\n${codexProse}\n</codex-prose>\n\n` +
416
+ `Return findings[] using the schema fields (domain in {doc, security, migration, code, perf, test}).`,
417
+ { label: 'codex (prose-converted)', phase: 'Discovery', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
418
+ )
419
+ // NON-preValidated (no preValidated:true) → verifyFinding routes each to the domain specialist.
420
+ // Clamp confidence < 80: the converter transcribes Codex's (often high) self-confidence, and
421
+ // verifyFinding short-circuits to VERIFIED at confidence >= 80 — which would skip the very Verify
422
+ // pass SC-3 relies on. Capping forces the domain-specialist validation over real code.
423
+ if (conv && Array.isArray(conv.findings)) raw.push(...conv.findings.map((f) => ({ ...f, source: 'codex', confidence: Math.min(Number(f.confidence) || 0, 70) })))
424
+ } else {
425
+ codexEngine = 'code-reviewer (fallback)'
426
+ const fb = await agent(
427
+ `Codex was unavailable for this wave's code review. Run the FULL code review yourself over the wave diff, per ${protocolRef} (Phase 3.7).\n\n${waveBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied (your findings are treated as validated). An observation you VERIFIED needs NO change (fix direction "no fix required" / "acceptable as-is") is not work — set requires_action:false (recorded, never sent to a fixer), or do not emit it.`,
428
+ { label: 'code-reviewer (fallback)', phase: 'Discovery', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
429
+ )
430
+ if (fb && Array.isArray(fb.findings)) raw.push(...fb.findings.map((f) => ({ ...f, source: 'code-reviewer', preValidated: true })))
431
+ }
404
432
  }
405
433
 
406
434
  // Globally-unique finding_id at the fan-in. Finders number F### INDEPENDENTLY, so a Codex F003 and
@@ -526,8 +554,16 @@ if (!securityFix.length && !actionable.length) log('Fix: no actionable code/perf
526
554
  // Merge the two passes. A FAIL in EITHER pass fails the wave; PASS only when a pass actually ran it.
527
555
  const fixPasses = [secResult, codeFixResult]
528
556
  const allActionable = [...securityFix, ...actionable]
529
- const appliedIds = new Set(fixPasses.flatMap((p) => (p.applied || []).map((x) => x.finding_id)))
530
- const unresolvedIds = new Set(fixPasses.flatMap((p) => p.unresolved || []))
557
+ // Collision-safe id reconciliation (SC-1): a writer often echoes only the human-readable tail
558
+ // (e.g. FEAT-0040-04-CR-001) instead of the full prefixed finding_id, so an exact-Set match would
559
+ // drop an applied fix into residual. resolveId maps each echoed token back to its canonical id.
560
+ const canonIds = allActionable.map((f) => f.finding_id)
561
+ const resolveId = makeResolveId(canonIds)
562
+ const appliedIds = new Set(fixPasses.flatMap((p) => (p.applied || []).map((x) => resolveId(x.finding_id))))
563
+ const unresolvedIds = new Set(fixPasses.flatMap((p) => (p.unresolved || []).map(resolveId)))
564
+ const matched = [...appliedIds].filter((id) => canonIds.includes(id)).length
565
+ const claimed = fixPasses.reduce((n, p) => n + (p.applied || []).length, 0)
566
+ if (matched < claimed) log(`Fix: id-reconciliation — ${claimed - matched} applied id(s) did not map to a canonical finding_id (left as residual; check writer echo).`)
531
567
  const ranChecks = fixPasses.filter((p) => p.ran).map((p) => p.checks)
532
568
  const mergedChecks = ['lint', 'tsc', 'build'].reduce((acc, k) => {
533
569
  acc[k] = ranChecks.some((c) => c[k] === 'FAIL') ? 'FAIL' : (ranChecks.some((c) => c[k] === 'PASS') ? 'PASS' : 'SKIP')
@@ -537,6 +573,24 @@ const mergedChecks = ['lint', 'tsc', 'build'].reduce((acc, k) => {
537
573
  const codeResidual = allActionable.filter((f) => !appliedIds.has(f.finding_id) || unresolvedIds.has(f.finding_id))
538
574
  const checksFailed = ['lint', 'tsc', 'build'].some((k) => mergedChecks[k] === 'FAIL')
539
575
 
576
+ // Light-tier test-regression gate (P1-D). At light tier qa-sentinel was SKIPPED in Discovery and the
577
+ // Fix-phase writers run only lint/tsc/build — so a fix that breaks a test slips through (the full suite
578
+ // is deferred to the Final FULL gate, which runs only at the END of the batch). When the Fix phase
579
+ // ACTUALLY applied code fixes at light tier, run ONE diff-scoped qa-sentinel pass over just the tests
580
+ // that import the changed files (NOT the whole suite) and merge its gates into gateTable, so the
581
+ // skill's existing gateTable-OR gate catches a fix-introduced test regression at the wave boundary.
582
+ const fixPhaseTouchedCode = appliedIds.size > 0
583
+ if (maxQaTier !== 'full' && fixPhaseTouchedCode) {
584
+ const scopedQaPrompt = qaPrompt +
585
+ `\n\nDIFF-SCOPED RUN: do NOT run the whole test suite. Scope tests to the files that import the changed files above (the post-fix blast radius) — run only those, plus lint/type-check on the changed files. If the test runner cannot select a diff-scoped subset, SKIP the test gate gracefully (return status SKIP with a one-line reason) rather than running the full suite.`
586
+ const scopedQa = await agent(scopedQaPrompt, { label: 'qa-sentinel (light-tier post-fix)', phase: 'Fix', agentType: 'qa-sentinel', schema: GATES_SCHEMA })
587
+ const scopedGates = (scopedQa && Array.isArray(scopedQa.gates)) ? scopedQa.gates : []
588
+ if (scopedGates.length) {
589
+ gateTable = [...gateTable, ...scopedGates]
590
+ log(`Fix: light-tier diff-scoped qa-sentinel ran after applying code fixes — ${scopedGates.filter((g) => g.status === 'FAIL').length} failing gate(s) merged into gateTable.`)
591
+ }
592
+ }
593
+
540
594
  // ───────────────────────────────────────────────────────────────────────────
541
595
  // Phase Doc — doc-reviewer audit-and-apply on the wave diff (since v4.56.0).
542
596
  // MOVED OFF the orchestrator (was /new Phase 3) so doc-reviewer's analysis +
@@ -574,7 +628,10 @@ if ((docSurfaceTouched || docFindings.length)) {
574
628
  const dres = (dr && typeof dr === 'object') ? dr : {}
575
629
  docApplied = Array.isArray(dres.applied) ? dres.applied : []
576
630
  const docUnresolved = Array.isArray(dres.unresolved) ? dres.unresolved : []
577
- const docAppliedIds = new Set(docApplied.map((x) => x && x.finding_id).filter(Boolean))
631
+ // Collision-safe id reconciliation (SC-1) for doc echoes too. Synthetic ids (doc-audit#i) with no
632
+ // canonical match return unchanged — correct (they don't map to a routed finding).
633
+ const resolveDoc = makeResolveId(docFindings.map((f) => f.finding_id))
634
+ const docAppliedIds = new Set(docApplied.map((x) => resolveDoc(x && x.finding_id)).filter(Boolean))
578
635
  // Verified doc findings NOT applied → residual (skill runs a targeted Phase 3 doc pass / routes code-rooted drift).
579
636
  docResidualOut = docFindings.filter((f) => !docAppliedIds.has(f.finding_id))
580
637
  // doc-reviewer's own unresolved audit items (no matching finding) → slim residual notes.
@@ -20,9 +20,16 @@ export const meta = {
20
20
  // archBaselinePaths string[] | null per-card baselines; null → spawn architect (F.2)
21
21
  // hasApiDataFiles boolean gates api-perf-cost-auditor (default true)
22
22
  // config object resolved baldart.config.yml (paths.*, …)
23
+ // applyFixes boolean (default false) when true, run the opt-in Fix phase (P0-A, v4.59.0)
24
+ // editableFiles string[] (default []) union ownership map — files writers MAY edit (Fix scope)
23
25
  //
24
26
  // Return value (consumed by the skill at F.5):
25
- // { codexEngine, findings:[…classified, FALSE_POSITIVE dropped], gateTable, summary }
27
+ // applyFixes !== true → EXACTLY { codexEngine, findings:[…classified, FALSE_POSITIVE dropped],
28
+ // noActionFindings, gateTable, summary } (byte-compatible; new2 untouched)
29
+ // applyFixes === true → that PLUS additive { fixesApplied:[…1-line], docFixesApplied:[…1-line],
30
+ // residual:[…slim findings] }. `findings` STAYS the full classified
31
+ // actionable set (telemetry/compat); the skill reads `residual` to
32
+ // decide what (if anything) it still spawns at F.5.
26
33
  // ───────────────────────────────────────────────────────────────────────────
27
34
 
28
35
  // Tolerate args delivered as a JSON string (parse-or-default) — the Workflow tool
@@ -36,6 +43,16 @@ const cfg = a.config || {}
36
43
  const highRisk = (cfg.paths && cfg.paths.high_risk_modules) || [] // security-domain hint
37
44
  const protocolRef = '.claude/skills/new/references/final-review.md'
38
45
 
46
+ // P0-A (v4.59.0) — opt-in Fix phase. Both default safe-OFF: a caller that does not opt in (new2,
47
+ // or any pre-v4.59.0 caller) gets a byte-identical return. Gated below on applyFixes && editable.length.
48
+ const applyFixes = a.applyFixes === true
49
+ const editableFiles = Array.isArray(a.editableFiles) ? a.editableFiles.filter(Boolean) : []
50
+ // Curated toolchain (parity with new-card-review.js): when features.has_toolchain is on the consumer
51
+ // records LITERAL gate commands in toolchain.commands.* — the Fix-phase writers run THOSE. Empty /
52
+ // absent → silent fallback to the project-standard default. Read from args.config, never hardcoded.
53
+ const tcCmds = (cfg.toolchain && cfg.toolchain.commands) || {}
54
+ const tc = (key, fallback) => (tcCmds[key] && String(tcCmds[key]).trim()) || fallback
55
+
39
56
  // Concurrency cap (v4.53.6 — API rate-limit guardrail; mirrors new-card-review.js + the team-mode
40
57
  // coder cap). The org-level rate limit is SHARED across every terminal/session; a wide agent fan-out
41
58
  // saturates it and the server throttles. Cap concurrent agents at 3 via a rolling worker pool (NOT
@@ -135,6 +152,38 @@ const VERDICT_SCHEMA = {
135
152
  rationale: { type: 'string' },
136
153
  },
137
154
  }
155
+ const FIX_SCHEMA = {
156
+ type: 'object', required: ['applied', 'unresolved', 'checks'], additionalProperties: false,
157
+ properties: {
158
+ applied: {
159
+ type: 'array',
160
+ items: {
161
+ type: 'object', required: ['finding_id'], additionalProperties: false,
162
+ properties: { finding_id: { type: 'string' }, note: { type: 'string' } },
163
+ },
164
+ },
165
+ unresolved: { type: 'array', items: { type: 'string', description: 'finding_id left unfixed' } },
166
+ checks: {
167
+ type: 'object', required: ['lint', 'tsc', 'build'], additionalProperties: false,
168
+ properties: { lint: { enum: ['PASS', 'FAIL', 'SKIP'] }, tsc: { enum: ['PASS', 'FAIL', 'SKIP'] }, build: { enum: ['PASS', 'FAIL', 'SKIP'] } },
169
+ },
170
+ note: { type: 'string' },
171
+ },
172
+ }
173
+ // SC-1 — collision-safe finding_id reconciliation. The fan-in rewrites every id to `${source}#${i}:${orig}`,
174
+ // but a writer often echoes only the human-readable tail → an exact-Set match drops applied fixes into
175
+ // residual. Accept exact equality OR a UNIQUE colon-delimited tail (id.endsWith(':'+token)); a bare suffix
176
+ // (e.g. '001' vs 'CR-001') must NOT match → only ':'+token. 0 or >1 hits → return the token unchanged
177
+ // (caller logs the mismatch; never silently mis-attribute across cards).
178
+ function makeResolveId(canonIds) {
179
+ return (returned) => {
180
+ const t = String(returned == null ? '' : returned).trim()
181
+ if (!t) return returned
182
+ if (canonIds.includes(t)) return t
183
+ const hits = canonIds.filter((id) => id === t || id.endsWith(':' + t))
184
+ return hits.length === 1 ? hits[0] : returned
185
+ }
186
+ }
138
187
 
139
188
  // ---- Shared brief fragments -------------------------------------------------
140
189
  const scopeBrief = [
@@ -342,20 +391,34 @@ for (const item of reviewResults) {
342
391
  }
343
392
  }
344
393
 
345
- // F-040 — ONE deterministic fallback: either the pre-flight found no companion, or it ran
346
- // but did not complete. Either way the primary code review must still happen (F.4 step 8).
347
- // PROSE-ONLY salvage (v4.56.0): when Codex FINISHED but emitted prose (no sentinel JSON), seed the
348
- // fallback with that prose as UNVERIFIED leads the relay never parses prose; the fallback
349
- // code-reviewer (real reviewer WITH code access) re-verifies each lead against the actual diff and
350
- // emits the schema, so Codex's cross-model finds are recovered, not discarded. Same trust model.
351
- if (!codexRan) {
352
- codexEngine = codexProse ? 'code-reviewer (codex-seeded)' : 'code-reviewer (fallback)'
353
- const seedBlock = codexProse
354
- ? `\n\nCodex (a DIFFERENT model) reviewed this exact batch diff and reported the following findings in PROSE — it failed to emit machine-readable JSON, so these are UNVERIFIED leads, NOT validated findings. VERIFY each against the actual diff at its cited file:line, DROP any that don't hold, then run your own full independent review for anything Codex missed. Treat the prose as hints to check, never as ground truth:\n<codex-prose>\n${codexProse}\n</codex-prose>`
355
- : ''
394
+ // F-040 — fallback when Codex did not produce parseable JSON. TWO distinct sub-branches:
395
+ // SEEDED (codexProse present, v4.59.0 Opt-2): Codex FINISHED and reported findings in PROSE.
396
+ // The cross-model coverage is ALREADY OBTAINED re-running a full re-hunting code-reviewer over
397
+ // the whole diff would be a wasted duplicate pass. Instead run a CHEAP CONVERTER that ONLY turns
398
+ // the prose into the finding schema (no independent re-hunt) and marks the findings source
399
+ // 'codex' but NON-preValidated the existing Verify phase validates each over the real code.
400
+ // This preserves the trust model (Verify validates) while removing the duplicate full re-hunt;
401
+ // layered coverage stays (per-card simplify/security + qa + Verify + the rest of this Final pass).
402
+ // COLD (no prose at all): genuinely no Codex output → run today's FULL code-reviewer as the primary
403
+ // review engine; its own FP check makes it preValidated (a second pass would self-judge).
404
+ if (!codexRan && codexProse) {
405
+ codexEngine = 'codex (prose-converted)'
406
+ const conv = await agent(
407
+ `Convert the Codex PROSE findings below into the structured finding schema. For each finding: keep the cited file:line + severity + a one-line minimal_fix_direction; do a LIGHT plausibility check (drop a lead whose cited file:line is obviously wrong/empty); do NOT perform an independent code review or hunt for new issues. Return findings[].\n\n` +
408
+ `Codex (a DIFFERENT model) reviewed this exact batch diff and reported the following in PROSE (it failed to emit machine-readable JSON):\n<codex-prose>\n${codexProse}\n</codex-prose>`,
409
+ { label: 'codex-prose-convert', phase: 'Review', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
410
+ )
411
+ // NON-preValidated (no `preValidated:true`): the converter did not re-hunt or independently
412
+ // verify, so verifyFinding routes each to its domain specialist over the real code. Clamp
413
+ // confidence < 80 — verifyFinding short-circuits to VERIFIED at >= 80, which would skip the
414
+ // domain-specialist validation SC-3 relies on (the converter just transcribes Codex's own confidence).
415
+ if (conv && Array.isArray(conv.findings)) raw.push(...conv.findings.map((f) => ({ ...f, source: 'codex', confidence: Math.min(Number(f.confidence) || 0, 70) })))
416
+ } else if (!codexRan) {
417
+ // COLD branch: no Codex JSON and no prose → the primary code review must still happen (F.4 step 8).
418
+ codexEngine = 'code-reviewer (fallback)'
356
419
  const fb = await agent(
357
- `Codex was unavailable for the batch final review. Run the FULL code review yourself over the batch diff, per ${protocolRef} Step F.3.\n\n${scopeBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied. An observation you VERIFIED needs NO change (fix direction "no fix required" / "acceptable as-is") is not work — set requires_action:false (recorded, never sent to a fixer), or do not emit it.${seedBlock}`,
358
- { label: codexProse ? 'code-reviewer (codex-seeded)' : 'code-reviewer (fallback)', phase: 'Review', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
420
+ `Codex was unavailable for the batch final review. Run the FULL code review yourself over the batch diff, per ${protocolRef} Step F.3.\n\n${scopeBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied. An observation you VERIFIED needs NO change (fix direction "no fix required" / "acceptable as-is") is not work — set requires_action:false (recorded, never sent to a fixer), or do not emit it.`,
421
+ { label: 'code-reviewer (fallback)', phase: 'Review', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
359
422
  )
360
423
  // the fallback code-reviewer IS the primary code-review engine here and applied its own FP
361
424
  // check (prompt above) → trusted, exactly like Codex. A SECOND code-reviewer pass over its own
@@ -438,8 +501,142 @@ const summary = {
438
501
  }
439
502
  log(`Final review done: ${summary.verified} verified (${summary.blockers} BLOCKER / ${summary.highs} HIGH), ${summary.noAction} no-action (recorded), ${summary.needsManual} needs-manual, ${summary.failingGates.length} failing gate(s). Engine: ${codexEngine}.`)
440
503
 
441
- return { codexEngine, findings, noActionFindings, gateTable, summary }
504
+ // ───────────────────────────────────────────────────────────────────────────
505
+ // Phase Fix (P0-A, v4.59.0) — OPT-IN. Mirrors new-card-review.js's Fix+Doc phases EXACTLY:
506
+ // security writer FIRST (owns the security-invariant contract — never folded into the coder pass),
507
+ // then the coder (code/perf/migration/test/simplify), then a doc-reviewer audit-and-apply pass
508
+ // (WORKTREE ISOLATION). Passes run SEQUENTIALLY so edits on shared files never conflict — no
509
+ // disjoint-ownership gate needed; the last code pass to run carries the build it verified.
510
+ // Gated on applyFixes && editableFiles.length: a caller that does NOT opt in (new2, any pre-v4.59.0
511
+ // caller) skips the whole phase and returns the byte-identical shape below.
512
+ // Unfixed BLOCKER/HIGH (and every unresolved actionable / NEEDS_MANUAL) flow into `residual` —
513
+ // never silently dropped.
514
+ // ───────────────────────────────────────────────────────────────────────────
515
+ let fixesApplied = []
516
+ let docFixesApplied = []
517
+ let residual = []
518
+ let checksFailed = false
519
+ if (applyFixes && editableFiles.length) {
520
+ phase('Fix')
521
+ const isDoc = (f) => /doc|wiki|ssot|readme/.test(String(f.domain).toLowerCase())
522
+ // 'security' domain → security-reviewer. migration STAYS coder (canonical writer map), so match the
523
+ // exact 'security' domain, not the broader verifier regex.
524
+ const isSecurity = (f) => String(f.domain).toLowerCase() === 'security'
525
+ const isManual = (f) => f.classification === 'NEEDS_MANUAL_CONFIRMATION'
526
+ // Partition the actionable set out of `findings` (= VERIFIED + NEEDS_MANUAL; FP + no-action already
527
+ // removed above). No overlap:
528
+ // securityFix = VERIFIED security → security-reviewer applies (owns the security invariants).
529
+ // codeActionable = VERIFIED non-doc non-security → the coder fixes these.
530
+ // docFindings = VERIFIED doc → the doc-reviewer audit-and-apply pass.
531
+ // manualResidual = NEEDS_MANUAL any → human gate, owned by the skill (carries its classification out).
532
+ const securityFix = findings.filter((f) => f.classification === 'VERIFIED' && !isDoc(f) && isSecurity(f))
533
+ const codeActionable = findings.filter((f) => f.classification === 'VERIFIED' && !isDoc(f) && !isSecurity(f))
534
+ const docFindings = findings.filter((f) => f.classification === 'VERIFIED' && isDoc(f))
535
+ const manualResidual = findings.filter(isManual)
536
+
537
+ const SKIP_CHECKS = { lint: 'SKIP', tsc: 'SKIP', build: 'SKIP' }
538
+
539
+ // One fix pass: the domain WRITER applies its verified findings to the worktree, then re-verifies.
540
+ async function applyFixPass(items, writer, label, role) {
541
+ if (!items.length) return { applied: [], unresolved: [], checks: { ...SKIP_CHECKS }, ran: false }
542
+ const fixBrief =
543
+ `Apply ALL of the verified ${role} review findings below to the worktree, then verify the build. You are the ${writer} fix pass for this batch's FINAL review.\n\n` +
544
+ `Worktree: ${a.worktreePath || '(cwd)'} — cd into it.\n` +
545
+ `You MAY edit ONLY these files (ownership map — touching anything else is a violation):\n${editableFiles.join('\n')}\n\n` +
546
+ `Findings to fix (fix the code, not the tests unless a test itself is wrong; do NOT expand scope beyond the finding):\n` +
547
+ items.map((f) => `- [${f.finding_id}] (${f.domain} / ${f.severity}) ${f.title}\n evidence: ${f.evidence}\n direction: ${f.minimal_fix_direction}`).join('\n') +
548
+ `\n\nSCOPE DISCIPLINE (do not run away — every finding already cites the exact site to fix): go DIRECTLY to each finding's \`evidence\` file:line and apply its \`direction\`. Do NOT re-map the architecture, do NOT re-survey the codebase, do NOT read files beyond the cited evidence + their immediate dependencies. A verified finding is a precise instruction, not a research prompt.` +
549
+ `\n\nAfter applying: run \`${tc('lint', 'npm run lint')}\` and (when the project uses typescript) \`${tc('typecheck', 'npx tsc --noEmit')}\` and \`${tc('build', 'npm run build')}\` in the worktree. If a check fails because of an edit you made, fix the regression — at most 2 retries — staying within the allowed files. ` +
550
+ `Do NOT commit. Do NOT git stash (refs/stash is shared across worktrees). ` +
551
+ `Return: applied (finding_ids you fixed — list a finding here if you MADE the edit, even when the build stays red for reasons OUTSIDE this finding / your ownership; the build status is reported separately in checks), unresolved (finding_ids you could NOT fix within the allowed files / 2 retries), and checks (PASS/FAIL/SKIP for lint, tsc, build).`
552
+ const r = await agent(fixBrief, { label, phase: 'Fix', agentType: writer, schema: FIX_SCHEMA })
553
+ const res = (r && typeof r === 'object') ? r : { applied: [], unresolved: items.map((f) => f.finding_id), checks: { ...SKIP_CHECKS } }
554
+ if (!Array.isArray(res.applied)) res.applied = []
555
+ if (!Array.isArray(res.unresolved)) res.unresolved = []
556
+ if (!res.checks || typeof res.checks !== 'object') res.checks = { ...SKIP_CHECKS }
557
+ res.ran = true
558
+ log(`Fix: ${writer} applied ${res.applied.length}/${items.length} ${label} finding(s); checks lint=${res.checks.lint} tsc=${res.checks.tsc} build=${res.checks.build}.`)
559
+ return res
560
+ }
561
+
562
+ // Security writer FIRST, then the coder — sequential → no edit conflict on shared files.
563
+ const secResult = await applyFixPass(securityFix, 'security-reviewer', 'fix-security', 'security')
564
+ const codeFixResult = await applyFixPass(codeActionable, 'coder', 'fix-coder', 'code/perf/simplify')
565
+ if (!securityFix.length && !codeActionable.length) log('Fix: no actionable code/perf/security/simplify findings — fixers skipped.')
566
+
567
+ // Reconcile applied/unresolved via SC-1 (the writer may echo only the human-readable id tail).
568
+ const fixPasses = [secResult, codeFixResult]
569
+ const allActionable = [...securityFix, ...codeActionable]
570
+ const canonIds = allActionable.map((f) => f.finding_id)
571
+ const resolveId = makeResolveId(canonIds)
572
+ const appliedIds = new Set(fixPasses.flatMap((p) => (p.applied || []).map((x) => resolveId(x.finding_id))))
573
+ const unresolvedIds = new Set(fixPasses.flatMap((p) => (p.unresolved || []).map(resolveId)))
574
+ const matched = [...appliedIds].filter((id) => canonIds.includes(id)).length
575
+ const claimed = fixPasses.reduce((n, p) => n + (p.applied || []).length, 0)
576
+ if (matched < claimed) log(`Fix: id-reconciliation — ${claimed - matched} applied id(s) did not map to a canonical finding_id (left as residual; check writer echo).`)
577
+ const ranChecks = fixPasses.filter((p) => p.ran).map((p) => p.checks)
578
+ const mergedChecks = ['lint', 'tsc', 'build'].reduce((acc, k) => {
579
+ acc[k] = ranChecks.some((c) => c[k] === 'FAIL') ? 'FAIL' : (ranChecks.some((c) => c[k] === 'PASS') ? 'PASS' : 'SKIP')
580
+ return acc
581
+ }, {})
582
+ checksFailed = ['lint', 'tsc', 'build'].some((k) => mergedChecks[k] === 'FAIL')
583
+ const codeResidual = allActionable.filter((f) => !appliedIds.has(f.finding_id) || unresolvedIds.has(f.finding_id))
584
+
585
+ // Doc audit-and-apply pass (WORKTREE ISOLATION — worktree-rooted paths, do NOT git stash).
586
+ const docDir = (cfg.paths && (cfg.paths.docs_dir || cfg.paths.references_dir)) || 'docs'
587
+ let docApplied = []
588
+ let docResidualOut = docFindings // default (no-run branch) keeps the contract explicit
589
+ const DOC_SURFACE_RE = /(^|\/)(docs?|api|server\/data|lib\/server|schema|migrations?)(\/|\.|$)|\.(md|sql)$/i
590
+ const docSurfaceTouched = scope.some((f) => DOC_SURFACE_RE.test(String(f)))
591
+ if (docSurfaceTouched || docFindings.length) {
592
+ const docBrief =
593
+ `You OWN the \`doc\` domain end-to-end for this batch's FINAL review: AUDIT the committed diff for doc drift AND APPLY the fixes in THIS invocation (audit-and-apply mode). Do NOT defer doc writes to another agent.\n\n` +
594
+ `**WORKTREE ISOLATION (per AGENTS.md § Worktree isolation):** every doc you Edit/Write MUST be UNDER the worktree root ${a.worktreePath || '(cwd)'}. NEVER write a path under the main repo — it is checked out live by other sessions; \`cd\` alone does not stop an absolute main-repo path. cd into the worktree first and edit the worktree-rooted copy of each doc (e.g. ${docDir}/… UNDER the worktree).\n\n` +
595
+ `${scopeBrief}\n\n${baselineBrief}\n\n` +
596
+ (docFindings.length
597
+ ? `Verified doc-domain findings from this batch's review (apply each):\n${docFindings.map((f) => `- [${f.finding_id}] ${f.title}\n evidence: ${f.evidence}\n direction: ${f.minimal_fix_direction}`).join('\n')}\n\n`
598
+ : '') +
599
+ `Then run your standard doc audit on the changed files: sync canonical reference docs (api/, data-model, ssot-registry as applicable), set freshness_status:fresh + last_verified_from_code:<today> on docs you touch. ` +
600
+ `Do NOT commit (the orchestrator commits). Do NOT git stash (refs/stash is shared across worktrees). ` +
601
+ `A doc-drift→bug finding rooted in CODE (the impl contradicts a documented contract) is the ONE thing you do NOT fix yourself — return it in \`unresolved\` with the conflicting code file:line + the doc it violates.\n\n` +
602
+ `Return: applied (one entry per doc fix you MADE — finding_id REQUIRED: use the listed finding's id when it maps to one, else a short synthetic id like "doc-audit-1"; put a 1-line summary in note), unresolved (finding_ids / descriptions you could NOT resolve, incl. code-rooted drift), checks (lint/tsc/build PASS/SKIP — SKIP unless you touched a source-adjacent file).`
603
+ const dr = await agent(docBrief, { label: 'fix-doc', phase: 'Fix', agentType: 'doc-reviewer', schema: FIX_SCHEMA })
604
+ const dres = (dr && typeof dr === 'object') ? dr : {}
605
+ docApplied = Array.isArray(dres.applied) ? dres.applied : []
606
+ const docUnresolved = Array.isArray(dres.unresolved) ? dres.unresolved : []
607
+ // SC-1 reconciliation over the doc canon (synthetic doc-audit#i ids return unchanged — correct).
608
+ const resolveDoc = makeResolveId(docFindings.map((f) => f.finding_id))
609
+ const docAppliedIds = new Set(docApplied.map((x) => resolveDoc(x && x.finding_id)).filter(Boolean))
610
+ docResidualOut = docFindings.filter((f) => !docAppliedIds.has(f.finding_id))
611
+ const extraDocResidual = docUnresolved
612
+ .filter((u) => typeof u === 'string' && u.trim() && !docFindings.some((f) => f.finding_id === u))
613
+ .map((u, i) => ({ finding_id: `doc-audit#${i}`, title: String(u).slice(0, 160), severity: 'MEDIUM', domain: 'doc', evidence: '(doc-reviewer audit)', minimal_fix_direction: String(u), classification: 'VERIFIED' }))
614
+ docResidualOut = [...docResidualOut, ...extraDocResidual]
615
+ log(`Fix: doc-reviewer applied ${docApplied.length} doc fix(es)${docFindings.length ? ` (incl. ${docFindings.length} routed finding(s))` : ''}; ${docResidualOut.length} unresolved → residual.`)
616
+ } else {
617
+ log('Fix: doc audit SKIPPED (no doc-relevant surface in the batch diff and no verified doc finding).')
618
+ }
619
+
620
+ // Assemble the additive return fields.
621
+ for (const f of allActionable) {
622
+ if (appliedIds.has(f.finding_id) && !unresolvedIds.has(f.finding_id)) fixesApplied.push(`[${f.finding_id}] ${f.title}`)
623
+ }
624
+ for (const x of docApplied) {
625
+ const note = (x && (x.note || x.finding_id)) ? `${x.finding_id ? `[${x.finding_id}] ` : ''}${x.note || ''}`.trim() : '(doc fix)'
626
+ docFixesApplied.push(note)
627
+ }
628
+ // Residual = unfixed code/perf/security + unresolved doc + every NEEDS_MANUAL (BLOCKER/HIGH ALWAYS land here).
629
+ residual = [...codeResidual, ...docResidualOut, ...manualResidual].map(slimFinding)
630
+ log(`Final Fix phase done: ${fixesApplied.length} fixed, ${docFixesApplied.length} doc-applied, ${residual.length} residual${checksFailed ? ', post-fix checks FAILED' : ''}.`)
631
+ }
632
+
633
+ const base = { codexEngine, findings, noActionFindings, gateTable, summary }
634
+ // Additive ONLY when the Fix phase ran — otherwise byte-identical to today's shape (new2 untouched).
635
+ return applyFixes ? { ...base, fixesApplied, docFixesApplied, residual } : base
442
636
 
443
637
  function emptySummary() {
444
638
  return { total: 0, verified: 0, falsePositive: 0, needsManual: 0, blockers: 0, highs: 0, failingGates: [] }
445
639
  }
640
+ function slimFinding(f) {
641
+ return { finding_id: f.finding_id, title: f.title, severity: f.severity, domain: f.domain, evidence: f.evidence, minimal_fix_direction: f.minimal_fix_direction, classification: f.classification }
642
+ }