baldart 4.16.1 → 4.17.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
@@ -5,6 +5,31 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.17.0] - 2026-06-07
9
+
10
+ **`new2` hardening single-wave: 36 finding dal primo run reale risolti in un colpo.** Il primo run reale di `new2` (batch FEAT-0021, 9 card) sotto un outage 529 prolungato è costato **7.4M token / 100 agent / 5h08m producendo solo 6/9 card reali**, con 2 stati `DONE` falsi nel backlog, codice security RLS-bypass non revisionato finito su `develop` via un "safety commit", e 1 follow-up persa. Una raccolta diagnostica end-to-end (36 finding verificati su codice + telemetria reale) ha isolato cause **strutturali**; questo rilascio le risolve tutte in un'unica wave, riportando il costo atteso a ~1.5-2.5M e — prioritario — azzerando le violazioni di integrità/sicurezza sotto degradazione. **MINOR** (cambio sostanziale di capacità del workflow esperimentale `new2`; nessuna rimozione/cambio layout d'install; **nessuna nuova chiave `baldart.config.yml`** → schema-propagation N/A).
11
+
12
+ > **Why.** `new2` è un A/B di `/new` per misurare l'economia di contesto del workflow-hosting. Il run reale ha mostrato che, oltre al costo, l'autonomia zero-ask degradava in modo **non sicuro** sotto outage (DONE falsi, codice non revisionato su trunk, follow-up persa) — esattamente il recovery che `/new` (gated, main-loop) avrebbe fermato. Decisioni utente vincolanti: *Decision-auto, infra-deferred* (niente `BALDART_AUTONOMOUS=1`; azioni infra owner-gated sempre policy-deferred) e *agenti specializzati come `/new`* (no general-purpose dove conta la qualità).
13
+
14
+ ### Changed
15
+ - **`framework/.claude/workflows/new2.js` — scheduler a DAG + resilienza + integrità.** Il for-loop sequenziale e il ramo team-mode sono sostituiti da uno **scheduler dependency-gated** (semantica single-worktree): una card parte solo quando TUTTE le dipendenze in-batch sono `committed`; i dipendenti di una dep fallita vanno `blocked` (niente resolve doomed, F-017/F-021); fallimento transitorio → `pending` + re-invocazione (cap); outage sostenuto → **degrada pulito** (`degraded:true` + worktree clean + return-as-ledger, F-012/F-019/F-020). Implement usa l'**owner_agent** della card; review è un **fan-out specializzato** sfrondato dal `review_profile` (F-024/F-025); commit è **Haiku** con `git status` reconcile, mai `git add -A` (F-023). Skip-completed idempotente su commit-sha+receipt+no-open-followup, NON sul flag `DONE` (F-026). Merge **integrity-gated**: mai force-DONE (F-029), mai commit di codice non revisionato (F-030), mai merge di batch incompleto/degradato (F-031). Telemetria veritiera: `total_tokens` via `budget.spent()`, `agent_count`, `degraded`, `cards_real_done` vs `cards_force_done`=0 (F-032). Parse-guard `args` (F-001).
16
+ - **`framework/.claude/workflows/new2-resolve.js` — short-circuit + anti-fabbricazione + batching.** Verdetto **terminale** che salta il multi-attempt quando il problema è impossibile-by-definition (`out-of-ownership` verificato in JS contro MAY-EDIT; altri reason ratificati dal judge → niente escape-hatch, F-008). **Judge avversariale OBBLIGATORIO** su ogni claim `verified`, con **cross-check JS** dei file confermati indipendentemente dal judge (defeats fabricated success, beccata 2× nel run reale — F-015/F-033). Lista **`findings` batchata** (1 resolve per fix-area, F-007/F-035/F-037). Fixer/judge **per dominio** (doc→doc-reviewer, ui→ui-expert, security→security-reviewer judge, perf→api-perf-cost-auditor judge — F-024). `outOfScopeFindings` non più persi (F-022). Follow-up su Haiku, **offline-safe** (deferita al skill se nessun agente può scrivere, F-020). Parse-guard `args` (F-004).
17
+ - **`framework/.claude/skills/new2/SKILL.md` — reconcile + resume + telemetria reale.** Step 4 passa `ts` (il workflow non ha clock). Step 5: materializza le follow-up dei `residuals` non ancora su disco (offline-safe — il skill ha il filesystem → chiude "nothing dropped" sotto outage, F-020), **rilancia** con `resumeFromRunId` se `degraded` (idempotente via skip-completed), registra telemetria veritiera (`wall_clock_s`, `followups_on_disk` contando i file — F-032).
18
+ - **`framework/.claude/agents/prd-card-writer.md` — Rule A.1 (AC↔ownership consistency).** Fix a monte del difetto ricorrente "AC fuori dal MAY-EDIT" (F-016): ogni AC dev'essere soddisfabile editando i file della propria card.
19
+ - **`framework/docs/WORKFLOWS.md`** — righe `new2`/`new2-resolve` aggiornate al comportamento v4.17.0.
20
+
21
+ ## [4.16.2] - 2026-06-06
22
+
23
+ **`baldart update` non si blocca più all'infinito su un edit `.framework/` ormai assorbito a monte.** Un edit locale a un file framework (es. `agents/ui-expert.md`) committato con un subject che *non* contiene la parola "baldart" sfuggiva alla classificazione `chore-wrapper` (rumore) e veniva marcato `custom-overlay-able`. Quel commit resta per sempre ancestor di `HEAD` ma mai di `FETCH_HEAD`, quindi `git log FETCH_HEAD..HEAD -- .framework` lo ripesca a **ogni rilascio successivo** — e il flusso seamless si fermava sul gate `divergence-capture-incomplete` perché l'overlay (additivo) non riproduceva byte-a-byte il file `.framework`, anche quando il contenuto era già identico all'upstream e la personalizzazione viveva correttamente nell'overlay. Risultato: lo stesso commit "fantasma" rifaceva muro a ogni `update`, senza via d'uscita via overlay (riconciliare un overlay additivo non può mai far passare il round-trip stretto). **PATCH** (bugfix del detector di divergenza + reset-safety degli overlay additivi; nessun cambio di capability, nessuna chiave `baldart.config.yml`).
24
+
25
+ > **Why.** Il check di fedeltà capture+verify pretendeva `merge(base_upstream, overlay) == file_.framework`. Per un overlay `extend` (additivo) questo è **strutturalmente falso** quando `.framework` è pristine-upstream: il merge aggiunge sezioni, il file no → "stale" eterno. E il classifier non distingueva un edit *vivo* (da preservare) da uno *assorbito* (contenuto già a monte → un re-sync non perde nulla). Due correzioni complementari chiudono la classe di falso positivo per tutti i consumer.
26
+
27
+ ### Fixed
28
+
29
+ - **`src/utils/git.js` — categoria `absorbed` nel classifier di divergenza.** `classifyDivergence` ora demota a rumore (`absorbed`) ogni commit `custom-*` i cui file `.framework/` toccati sono **byte-identici all'upstream (`FETCH_HEAD`)** — confronto di blob-SHA, prefisso `.framework/` strippato per mappare il path upstream; conservativo (file fuori da `.framework/`, mancante a monte, o SHA diverso → resta drift reale). `aggregateDivergenceClass` include `absorbed` tra le `noiseCategories`, così un edit ormai assorbito non spinge più la classe verso `overlay-able`/`mixed`: la batch torna `all-noise` → auto-resolve pulito.
30
+ - **`src/utils/overlay-capture.js` — reset-safety per overlay additivi (`captureAndVerify`).** Quando esiste già un overlay agent/command, il file `.framework` viene riconosciuto come **reset-safe** (nessun edit inline da perdere) se è pristine: (a) byte-identico alla base upstream verso cui si farebbe reset, **oppure** (b) corrisponde alla base con cui l'overlay è stato autorato (`base_file_sha` in frontmatter) — pristine anche dopo che l'upstream è andato avanti. In quel caso è `captured` (non più `stale`), invece di pretendere il round-trip stretto che un overlay additivo non può mai soddisfare. Il caso *genuinamente* stale (edit inline reale che l'overlay non riproduce) resta correttamente un blocker.
31
+ - **Test.** `classify-divergence.test.js`: 3 fixture per `absorbed` (da solo / con merge plumbing → `all-noise`; con overlay-able genuino → `overlay-able`). `overlay-capture.test.js`: 2 fixture per la reset-safety (overlay additivo + `.framework` == base → `captured`; upstream spostato ma `.framework` pristine vs `base_file_sha` → `captured`).
32
+
8
33
  ## [4.16.1] - 2026-06-05
9
34
 
10
35
  **Review di allineamento `new` ↔ `new2`: 9 fix di fedeltà sui workflow `new2`/`new2-resolve` emersi da una review adversariale a 3 reviewer paralleli.** La v4.16.0 ha introdotto `new2` come variante workflow-hosted di `/new`; una review di allineamento (gate-coverage · child-workflow wiring · control-flow) ha refutato l'implementazione su difetti reali — di semantica/allineamento, non di esecuzione (il wiring tra i 3 workflow era contract-fedele). **PATCH** (bugfix di allineamento del nuovo `new2`, nessun cambio di capability, nessuna chiave `baldart.config.yml`).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.16.1
1
+ 4.17.0
@@ -136,6 +136,31 @@ that list — not this one — is the source of truth.
136
136
  **Epic cards** use `owner_agent: ""` (empty string) because they are
137
137
  trackers, not implementation. The dispatcher skips them.
138
138
 
139
+ ### Rule A.1 — AC↔ownership consistency (every AC must be satisfiable inside the card)
140
+
141
+ For EVERY acceptance criterion you write, the file(s) that AC requires editing
142
+ MUST be a subset of that card's `files_likely_touched` (its MAY-EDIT ownership).
143
+ An AC whose ONLY implementation site lives in another card's file is
144
+ **structurally unsatisfiable** by this card — at execution time it can never
145
+ close, so it burns an entire resolve cycle and then defers anyway (and, on
146
+ impossible tasks, *induces* fabricated "fixed" claims). Concretely:
147
+
148
+ - If an AC needs the JSX **render** of a value, the card that owns the render
149
+ file (e.g. `homepage-store.tsx`) must carry that AC — not the data/view-model
150
+ card that merely computes the value.
151
+ - If an AC needs **wiring at a call-site** (e.g. `after(() => dispatch())` inside
152
+ a create funnel in `data.ts`), assign it to the card that owns that file, or
153
+ add that file to this card's `files_likely_touched`, or express the dependency
154
+ explicitly via `depends_on` + a card that owns the site.
155
+ - An AC whose remedy is an **owner-gated infra action** (remote DB push, deploy,
156
+ secret rotation, DNS) is NOT a code AC — phrase it as a tracked manual/ops
157
+ step, never as a per-card code acceptance criterion.
158
+
159
+ Self-check before emitting cards: for each AC, name the file it edits and
160
+ confirm that file is in the same card's `files_likely_touched`. If it isn't,
161
+ move the AC or widen ownership. (This is the upstream fix for the recurring
162
+ "AC outside MAY-EDIT" failure; `new2`/`/new` also re-assert it at pre-flight.)
163
+
139
164
  ### Rule B — UI cards (`ui-expert`) have UI-ONLY scope
140
165
 
141
166
  When you assign `owner_agent: ui-expert` to a card, its scope MUST cover
@@ -99,30 +99,55 @@ Workflow({ name: 'new2', args: {
99
99
  metricsDir, // $METRICS
100
100
  refModulesBase, // .claude/skills/new/references (semantic SSOT)
101
101
  config, // the parsed baldart.config.yml (paths.*/stack.*/features.*/git.*)
102
+ ts, // ISO timestamp NOW — the workflow has no clock (Date.now() unavailable there)
102
103
  flags: { stats, effort, full }
103
104
  }})
104
105
  ```
105
106
 
107
+ Record the kickoff time (`ts`) before the call — Step 5 uses it to stamp the
108
+ real wall-clock duration (the workflow cannot read a clock).
109
+
106
110
  The workflow runs the entire batch in the background. **Do not poll it** — it
107
111
  returns when the batch is done. It returns:
108
112
 
109
113
  ```
110
- { report, perCardResults, gateLedger, residualFollowups, telemetry }
114
+ { report, perCardResults, gateLedger, residualFollowups, residuals,
115
+ degraded, degradationReasons, telemetry }
111
116
  ```
112
117
 
113
- `report` is a ready-to-show markdown batch summary (files changed per card, review
114
- findings, QA, merge hash, any auto-created follow-up cards). `residualFollowups`
115
- lists every follow-up card `new2-resolve` materialised for an unresolved
116
- gate/scope-expansion (nothing was silently dropped). `telemetry` is the Phase-8
117
- record (same schema as `/new`, with `variant: "new2"`).
118
-
119
- ### Step 5 Present + record
120
-
121
- - Print `report` to the user verbatim (it is already the single batch summary).
122
- - If `residualFollowups` is non-empty, surface them prominently ("queste AC/finding
123
- non risolti autonomamente sono stati tracciati come follow-up card: …") — this is
124
- the post-run review that replaced the ~25 mid-run questions.
125
- - Append `telemetry` (with `variant: "new2"`) to `${metricsDir}/skill-runs.jsonl`
126
- and, when `flags.stats`, run the session token-telemetry script the same way
127
- `/new` Phase 8 does (`-stats`), so the A/B comparison has real `usage` data for
128
- both variants. Do NOT re-summarise the cards yourselfthe workflow already did.
118
+ - `report` ready-to-show markdown batch summary.
119
+ - `residuals` the **OFFLINE-SAFE ledger of record**: every residual the workflow
120
+ could not finish, each `{ card, kind, evidence, materialized }`. A residual with
121
+ `materialized:false` has NO follow-up card on disk yet (e.g. the workflow hit an
122
+ outage where no agent could write the file). **You (the skill) must reconcile it.**
123
+ - `degraded` / `degradationReasons` — the batch stopped early under a sustained
124
+ outage (or another degradation). The batch is NOT complete; it must be resumed.
125
+ - `telemetry` — the Phase-8 record (`variant:"new2"`).
126
+
127
+ ### Step 5 Reconcile, resume, present, record
128
+
129
+ 1. **Materialise missing follow-ups (offline-safe you have filesystem access, the
130
+ workflow does not).** For every `residuals[]` entry with `materialized:false`,
131
+ write a follow-up card `${paths.backlog_dir}/<card>-followup-<kind>.yml` (status:
132
+ TODO; ≥1 requirement derived from the residual; acceptance_criteria = the verbatim
133
+ residual; files_likely_touched from the card's ownership)it MUST pass the `/new`
134
+ pre-flight field check. This is the layer that guarantees **nothing is ever
135
+ dropped, even when every agent was dead** during the run.
136
+ 2. **Resume if degraded.** If `degraded` is true, re-invoke the workflow with
137
+ `Workflow({ scriptPath, resumeFromRunId })` (same `args` + the new `ts`). The
138
+ per-card **skip-completed** guard makes the resume idempotent — already-committed
139
+ cards are skipped, only the incomplete/blocked ones run. Repeat until `degraded`
140
+ is false (or the same cards stall twice → surface to the user).
141
+ 3. **Present.** Print `report` verbatim. Surface `residuals` prominently
142
+ ("questi residui sono tracciati come follow-up: …") — the post-run review that
143
+ replaced the ~25 mid-run questions. If `degraded`, say so plainly (the run was
144
+ incomplete and resumed).
145
+ 4. **Record truthful telemetry.** Before appending `telemetry` to
146
+ `${metricsDir}/skill-runs.jsonl`, fill the fields the workflow could not compute:
147
+ `wall_clock_s` (now − kickoff `ts`) and `followups_on_disk` (count the actual
148
+ follow-up files on disk, NOT `residualFollowups.length` — which double-counts).
149
+ `total_tokens`/`agent_count` come from the workflow (`budget.spent()` delta +
150
+ spawn counter); if `total_tokens` is null, run the `/new` Phase-8 `-stats` script
151
+ to backfill real `usage`. Keep `degraded`/`degradation_reasons` in the record so
152
+ the A/B comparison can exclude or weight degraded runs. Do NOT re-summarise the
153
+ cards — the workflow already did.
@@ -1,32 +1,24 @@
1
1
  export const meta = {
2
2
  name: 'new2-resolve',
3
3
  description:
4
- "Self-healing resolution pass for the autonomous new2 batch workflow. Called by new2 whenever a deterministic gate would otherwise need a human: a card fail/blocker (ac-unmet | blocker | qa-fail | e2e-blocked | merge-blocker), a legitimate scope-EXPANDING finding (scope-expansion), or a cross-cutting edge (agent-crash | baseline-fail). Runs an aggressive fix→verify loop (targeted coder judged multi-attempt adversarial re-verify); its TERMINAL is auto-materialising a tracked follow-up card so nothing is ever silently dropped. Domain-Override aware: doc fixes go to doc-reviewer, security/migration to coder and NEVER inline-bypassed. Agents Read /new's reference modules for semantics (args.refModulesBase). Uses agent()/parallel() only — no nested workflows.",
4
+ "Self-healing resolution pass for the autonomous new2 batch workflow. Called by new2 whenever a deterministic gate would otherwise need a human: a card fail/blocker (ac-unmet | blocker | qa-fail | e2e-blocked | merge-blocker), a legitimate scope-EXPANDING finding (scope-expansion), or a cross-cutting edge (agent-crash | baseline-fail). Tier-1 targeted fix with a TERMINAL short-circuit (skips the costly multi-attempt when the problem is impossible-by-definition, verified not trusted), then a judged multi-attempt; a MANDATORY adversarial judge cross-checks every verified claim against the real diff (prevents fabricated success). Specialized per domain (doc→doc-reviewer, ui→ui-expert, security→security-reviewer judge, perf→api-perf-cost-auditor judge). Terminal is a tracked follow-up. Accepts a `findings` list (batched per area). Uses agent()/parallel() only — no nested workflows.",
5
5
  phases: [
6
- { title: 'Diagnose', detail: 'classify the problem + decide scope-expansion boundary' },
6
+ { title: 'Diagnose', detail: 'classify + terminal short-circuit + scope-expansion boundary' },
7
7
  { title: 'Repair', detail: 'targeted fix, then judged multi-attempt if needed' },
8
- { title: 'Verify', detail: 'adversarial re-verify against the originating gate' },
8
+ { title: 'Verify', detail: 'mandatory adversarial judge + JS cross-check of verified files' },
9
9
  ],
10
10
  }
11
11
 
12
12
  // ───────────────────────────────────────────────────────────────────────────
13
13
  // args contract (from new2.js resolve()):
14
- // kind 'ac-unmet'|'blocker'|'qa-fail'|'e2e-blocked'|'merge-blocker'
15
- // |'scope-expansion'|'agent-crash'|'baseline-fail'
16
- // cardId string card (or finding id) the problem belongs to
17
- // evidence string verbatim AC / failing gate / finding evidence
18
- // worktreePath string where the fix happens
19
- // mayEditPaths string[] the card's file-ownership MAY-EDIT set
20
- // scopeFiles string[] files in scope for re-verify
21
- // domain 'doc'|'security'|'migration'|'code'|'perf'|'test'
22
- // refModulesBase string /new reference modules dir (semantics)
23
- // config object parsed baldart.config.yml
24
- //
25
- // return (consumed by new2.js):
26
- // { status: 'resolved'|'followup'|'fatal', followupCard?, reason? }
14
+ // kind, cardId, evidence, worktreePath, mayEditPaths[], scopeFiles[], domain,
15
+ // refModulesBase, config, ts, findings[] (batched: [{kind,evidence,domain}])
16
+ // return: { status:'resolved'|'followup'|'fatal', followupCard?, reason?, outOfScopeFindings? }
27
17
  // ───────────────────────────────────────────────────────────────────────────
28
18
 
29
- const a = args || {}
19
+ // F-004 tolerate args delivered as a JSON string.
20
+ let a = args || {}
21
+ if (typeof a === 'string') { try { a = JSON.parse(a) } catch (_) { a = {} } }
30
22
  const kind = a.kind || 'blocker'
31
23
  const card = a.cardId || 'CARD'
32
24
  const evidence = a.evidence || ''
@@ -34,106 +26,187 @@ const worktree = a.worktreePath || '(cwd)'
34
26
  const mayEdit = Array.isArray(a.mayEditPaths) ? a.mayEditPaths : []
35
27
  const scopeFiles = Array.isArray(a.scopeFiles) ? a.scopeFiles : []
36
28
  const domain = a.domain || 'code'
29
+ const findings = Array.isArray(a.findings) && a.findings.length ? a.findings : [{ kind, evidence, domain }]
37
30
  const REF = (a.refModulesBase || '.claude/skills/new/references').replace(/\/$/, '')
38
31
  const cfg = a.config || {}
39
32
  const paths = cfg.paths || {}
40
33
  const backlogDir = paths.backlog_dir || 'backlog'
41
34
  const protectedDomain = domain === 'security' || domain === 'migration'
42
35
 
36
+ // F-019 — bounded immediate retry on transient errors (shared pattern; no import allowed).
37
+ const TRANSIENT = /overload|529|429|rate.?limit|5\d\d|econn|etimedout|timeout|temporar|unavailable/i
38
+ function isTransient(e) { return TRANSIENT.test(String((e && e.message) || e || '')) }
39
+ async function agentSafe(prompt, opts, maxAttempts) {
40
+ const cap = maxAttempts || 3
41
+ let lastErr = null
42
+ for (let i = 0; i < cap; i++) {
43
+ try {
44
+ const o = i === 0 ? opts : Object.assign({}, opts, { label: `${(opts && opts.label) || 'agent'}~retry${i}` })
45
+ return await agent(prompt, o)
46
+ } catch (e) { lastErr = e; if (!isTransient(e)) throw e }
47
+ }
48
+ const err = new Error('transient-exhausted: ' + String((lastErr && lastErr.message) || lastErr)); err.transientExhausted = true; throw err
49
+ }
50
+
43
51
  const FIX_SCHEMA = {
44
52
  type: 'object', required: ['applied', 'verified'], additionalProperties: true,
45
53
  properties: {
46
- applied: { type: 'boolean', description: 'true if an edit was made' },
47
- verified: { type: 'boolean', description: 'true if the originating gate now passes (re-run it)' },
48
- crashed: { type: 'boolean', description: 'agent could not act (for agent-crash routing)' },
54
+ applied: { type: 'boolean' },
55
+ verified: { type: 'boolean', description: 'true only if you re-ran the originating gate and it passed' },
56
+ // F-008 terminal short-circuit (impossible-by-definition). Not trusted blindly.
57
+ terminal: { type: 'boolean', description: 'true if no in-MAY-EDIT fix can ever satisfy the gate' },
58
+ terminalReason: { enum: ['out-of-ownership', 'baseline-not-reached', 'owner-gated', 'not-a-code-defect', ''] },
59
+ remedyFiles: { type: 'array', items: { type: 'string' }, description: 'files the real fix would require editing (for out-of-ownership JS verification)' },
60
+ crashed: { type: 'boolean' },
61
+ // F-022 — incidental findings outside this card's MAY-EDIT (so they are not dropped).
62
+ outOfScopeFindings: { type: 'array', items: { type: 'object', additionalProperties: true } },
49
63
  note: { type: 'string' },
50
64
  },
51
65
  }
52
66
  const JUDGE_SCHEMA = {
53
67
  type: 'object', required: ['best'], additionalProperties: false,
54
- properties: { best: { type: 'number', description: '1-based index of the best attempt, or 0 if none works' }, rationale: { type: 'string' } },
68
+ properties: {
69
+ best: { type: 'number', description: '1-based index of the soundest verified attempt, or 0 if none truly holds' },
70
+ // F-015 — the files the judge INDEPENDENTLY confirmed exist and changed (cross-checked in JS).
71
+ verifiedFiles: { type: 'array', items: { type: 'string' } },
72
+ rationale: { type: 'string' },
73
+ },
74
+ }
75
+ const TERMINAL_JUDGE_SCHEMA = {
76
+ type: 'object', required: ['confirmed'], additionalProperties: false,
77
+ properties: { confirmed: { type: 'boolean', description: 'true if the terminal verdict genuinely holds' }, rationale: { type: 'string' } },
55
78
  }
56
79
  const FOLLOWUP_SCHEMA = {
57
80
  type: 'object', required: ['created', 'followupCard'], additionalProperties: true,
58
81
  properties: { created: { type: 'boolean' }, followupCard: { type: 'string' }, note: { type: 'string' } },
59
82
  }
60
83
 
84
+ // F-024 — domain-specialized fixer + judge (full map, not just doc|coder).
85
+ const fixerAgent = ({ doc: 'doc-reviewer', ui: 'ui-expert' })[domain] || 'coder'
86
+ const judgeAgent = (domain === 'security' || domain === 'migration') ? 'security-reviewer' : domain === 'perf' ? 'api-perf-cost-auditor' : 'code-reviewer'
87
+
88
+ const findingsBlock = findings.map((f, i) => ` ${i + 1}. [${f.kind || kind}/${f.domain || domain}] ${f.evidence}`).join('\n')
61
89
  const brief = [
62
90
  `Worktree: ${worktree} (cd into it; run all gates/git yourself).`,
63
91
  `Card: ${card}`,
64
92
  `Problem kind: ${kind}`,
65
- `Evidence: ${evidence}`,
93
+ findings.length > 1 ? `Findings to fix (batched — same fix-area):\n${findingsBlock}` : `Evidence: ${evidence}`,
66
94
  `MAY-EDIT (file-ownership — do NOT touch anything else): ${mayEdit.join(', ') || '(card scope)'}`,
67
95
  `Scope files for re-verify: ${scopeFiles.join(', ') || '(diff)'}`,
68
96
  `Domain: ${domain}${protectedDomain ? ' (PROTECTED — security/migration: never inline-bypass; the right specialist MUST apply)' : ''}`,
69
97
  `Reference modules (Read for exact semantics): ${REF}/`,
70
- `UI re-verify obligation: if your fix touches a UI file (app dir / components / *.css / *.scss), you MUST re-run the Phase 2.6 E2E review (per ${REF}/review-cycle.md) before declaring verified — a code-review/Codex fix can reintroduce visual/functional drift (mirrors codex-gate.md step 6).`,
71
- ].join('\n')
98
+ `UI re-verify obligation: if your fix touches a UI file, re-run the Phase 2.6 E2E review (per ${REF}/review-cycle.md) before declaring verified.`,
99
+ `If the fix requires editing files OUTSIDE MAY-EDIT, return terminal:true terminalReason:'out-of-ownership' remedyFiles:[...] (do NOT edit them). If the remedy is an owner-gated infra action, terminal:true terminalReason:'owner-gated'. Report any incidental defect outside MAY-EDIT in outOfScopeFindings.`,
100
+ ].filter(Boolean).join('\n')
72
101
 
73
- // The agent that OWNS a fix in this domain (Domain-Override): doc doc-reviewer.
74
- const fixerAgent = domain === 'doc' ? 'doc-reviewer' : 'coder'
102
+ // JS-deterministic ownership check (F-008 anti-escape-hatch): are remedy files ⊆ MAY-EDIT?
103
+ function filesInScope(files) {
104
+ if (!Array.isArray(files) || !files.length) return false
105
+ if (!mayEdit.length) return false
106
+ return files.every((f) => mayEdit.some((m) => String(f).includes(m) || m.includes(String(f))))
107
+ }
108
+ function collectOOS(...attemptObjs) {
109
+ const out = []
110
+ for (const o of attemptObjs) for (const x of (o && o.outOfScopeFindings) || []) out.push(x)
111
+ return out
112
+ }
75
113
 
76
- // ───────────────────────────────────────────────────────────────────────────
77
- // Phase Diagnose — branch by kind.
78
- // ───────────────────────────────────────────────────────────────────────────
79
114
  phase('Diagnose')
80
115
 
81
116
  // ---- baseline-fail (E2): try to make the trunk build; fatal if irrecoverable ---
82
117
  if (kind === 'baseline-fail') {
83
- const r = await agent(
84
- `The batch worktree's BASELINE build fails (trunk may not build — pre-existing). Try to make it build per ${REF}/setup.md step 6a.\n\n${brief}\n\nFix the build errors if they are tractable (missing dep, stale lock, trivial type error). Re-run tsc+lint+build. Return verified:true only if the baseline now passes. If the breakage is non-trivial/pre-existing and not safely fixable, return verified:false (the caller will treat the batch as fatal).`,
85
- { label: `resolve:baseline:${card}`, phase: 'Diagnose', agentType: 'coder', schema: FIX_SCHEMA }
86
- )
87
- if (r && r.verified) { log('baseline recovered.'); return { status: 'resolved' } }
88
- log('baseline irrecoverable — batch fatal.')
118
+ let r = null
119
+ try {
120
+ r = await agentSafe(
121
+ `The batch worktree's BASELINE build fails (trunk may not build). Try to make it build per ${REF}/setup.md step 6a.\n\n${brief}\n\nFix tractable build errors (missing dep, stale lock, trivial type error). Re-run tsc+lint+build. verified:true only if the baseline now passes; else verified:false.`,
122
+ { label: `resolve:baseline:${card}`, phase: 'Diagnose', agentType: 'coder', schema: FIX_SCHEMA }
123
+ )
124
+ } catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during baseline repair', outOfScopeFindings: [] } ; throw e }
125
+ if (r && r.verified) { log('baseline recovered.'); return { status: 'resolved', outOfScopeFindings: collectOOS(r) } }
89
126
  return { status: 'fatal', reason: 'baseline build irrecoverable' }
90
127
  }
91
128
 
92
- // ---- scope-expansion (Asse 2): deterministic boundary, no arbitrary threshold ---
129
+ // ---- scope-expansion (Asse 2): deterministic boundary ---
93
130
  if (kind === 'scope-expansion') {
94
- // Integrate iff: within MAY-EDIT ownership AND not security/migration AND not a NEW AC.
95
- const decide = await agent(
96
- `A review finding EXPANDS scope beyond card ${card}'s acceptance criteria (not a fail — extra legitimate work). Decide per the deterministic boundary (no line-count threshold):\n` +
97
- `INTEGRATE NOW iff ALL hold: (1) the fix stays inside MAY-EDIT ownership; (2) domain is NOT security/migration; (3) it does NOT introduce a new user-facing acceptance criterion. Otherwise → MATERIALISE A FOLLOW-UP.\n\n${brief}\n\n` +
98
- `If INTEGRATE: apply the fix via the domain owner discipline (you are ${fixerAgent}), re-run lint+tsc, return applied:true verified:true. If FOLLOW-UP: return applied:false verified:false note:'needs-followup: <why boundary failed>'. Do NOT prompt.`,
99
- { label: `resolve:scope:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
100
- )
101
- if (decide && decide.verified) { log('scope-expansion integrated within ownership.'); return { status: 'resolved' } }
102
- return await materialiseFollowup('scope-expansion', (decide && decide.note) || 'outside ownership / new AC / protected domain')
131
+ let decide = null
132
+ try {
133
+ decide = await agentSafe(
134
+ `A review finding EXPANDS scope beyond card ${card}'s acceptance criteria. Decide per the deterministic boundary (no line-count threshold):\n` +
135
+ `INTEGRATE NOW iff ALL: (1) fix stays inside MAY-EDIT; (2) domain NOT security/migration; (3) no NEW user-facing AC. Else MATERIALISE A FOLLOW-UP.\n\n${brief}\n\n` +
136
+ `If INTEGRATE: apply (you are ${fixerAgent}), re-run lint+tsc, return applied:true verified:true. If FOLLOW-UP: applied:false verified:false note:'needs-followup: <why>'.`,
137
+ { label: `resolve:scope:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
138
+ )
139
+ } catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during scope-expansion', outOfScopeFindings: [] }; throw e }
140
+ if (decide && decide.verified) {
141
+ const ok = await judgeVerify([{ i: 1, r: decide }])
142
+ if (ok.ok) { log('scope-expansion integrated within ownership.'); return { status: 'resolved', outOfScopeFindings: collectOOS(decide) } }
143
+ }
144
+ return await materialiseFollowup('scope-expansion', (decide && decide.note) || 'outside ownership / new AC / protected', collectOOS(decide))
103
145
  }
104
146
 
105
- // ---- agent-crash (E1): retry → route to the right specialist; protected = never bypass --
147
+ // ---- agent-crash (E1) ---
106
148
  if (kind === 'agent-crash') {
107
- const r = await agent(
108
- `A subagent crashed while handling this work. Re-attempt it with the CORRECT specialist (${fixerAgent}) per ${REF}/commit.md § Sub-agent failure protocol.\n\n${brief}\n\nRetry the work once. ${protectedDomain ? 'This is a PROTECTED domain (security/migration): if you cannot complete it, return verified:false — NEVER inline-bypass.' : 'If transient, complete it.'} Re-verify the originating gate. Return verified:true only if done + verified.`,
109
- { label: `resolve:crash:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
110
- )
111
- if (r && r.verified) return { status: 'resolved' }
112
- return await materialiseFollowup('agent-crash', 'subagent crash unrecovered' + (protectedDomain ? ' (protected domain — not bypassed)' : ''))
149
+ let r = null
150
+ try {
151
+ r = await agentSafe(
152
+ `A subagent crashed. Re-attempt with the CORRECT specialist (${fixerAgent}) per ${REF}/commit.md § Sub-agent failure protocol.\n\n${brief}\n\nRetry once. ${protectedDomain ? 'PROTECTED domain: if you cannot complete it, verified:false — NEVER inline-bypass.' : ''} Re-verify the gate; verified:true only if done.`,
153
+ { label: `resolve:crash:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
154
+ )
155
+ } catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during crash recovery', outOfScopeFindings: [] }; throw e }
156
+ if (r && r.verified) { const ok = await judgeVerify([{ i: 1, r }]); if (ok.ok) return { status: 'resolved', outOfScopeFindings: collectOOS(r) } }
157
+ return await materialiseFollowup('agent-crash', 'subagent crash unrecovered' + (protectedDomain ? ' (protected — not bypassed)' : ''), collectOOS(r))
113
158
  }
114
159
 
115
160
  // ───────────────────────────────────────────────────────────────────────────
116
- // Asse 1 (ac-unmet | blocker | qa-fail | e2e-blocked | merge-blocker)
117
- // Tier 1: targeted fix → re-verify. Tier 2: judged multi-attempt. Terminal: follow-up.
161
+ // Asse 1 — Tier-1 (with terminal short-circuit) judged multi-attempt follow-up.
118
162
  // ───────────────────────────────────────────────────────────────────────────
119
163
  const gateHint = {
120
- 'ac-unmet': `Implement the unmet acceptance criterion EXACTLY (no scope expansion), per ${REF}/completeness.md Phase 2.5b. Re-verify the AC against the code.`,
164
+ 'ac-unmet': `Implement the unmet acceptance criterion EXACTLY, per ${REF}/completeness.md Phase 2.5b. Re-verify against the code.`,
121
165
  blocker: `Fix the blocking finding per ${REF}/codex-gate.md / ${REF}/completeness.md. Re-run the failing gate.`,
122
- 'qa-fail': `Fix the failing QA gates per ${REF}/review-cycle.md Phase 3.5 — fix the code, not the tests (unless a test is wrong). Re-run qa-sentinel.`,
123
- 'e2e-blocked': `Fix the implementation so the E2E review passes per ${REF}/review-cycle.md Phase 2.6 (never override known-broken UI). Re-run /e2e-review.`,
166
+ 'qa-fail': `Fix the failing QA gates per ${REF}/review-cycle.md Phase 3.5 — fix the code, not the tests. Re-run qa-sentinel.`,
167
+ 'e2e-blocked': `Fix so the E2E review passes per ${REF}/review-cycle.md Phase 2.6. Re-run /e2e-review.`,
124
168
  'merge-blocker': `Fix the verified BLOCKER/HIGH from the final review per ${REF}/final-review.md F.5. Re-verify.`,
125
169
  }[kind] || `Fix the blocker and re-verify.`
126
170
 
127
171
  phase('Repair')
128
- let attempt = await agent(
129
- `Tier-1 targeted repair for card ${card} (${kind}).\n\n${brief}\n\n${gateHint}\n\nApply the minimal correct fix within MAY-EDIT only. Re-run the originating gate yourself and report verified honestly (do NOT claim verified without re-running it).`,
130
- { label: `resolve:${kind}:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
131
- )
132
- if (attempt && attempt.verified) { log(`${kind} resolved (tier 1).`); return { status: 'resolved' } }
133
-
134
- // Tier 2 judged multi-attempt (parallel divergent fixes judge adversarial verify).
135
- // Budget guard: skip the fan-out when the run is near its token target.
136
- const canFanOut = !budget.total || budget.remaining() > 60000
172
+ let attempt = null
173
+ try {
174
+ attempt = await agentSafe(
175
+ `Tier-1 targeted repair for card ${card} (${kind}).\n\n${brief}\n\n${gateHint}\n\nApply the minimal correct fix within MAY-EDIT only. Re-run the originating gate and report verified honestly (never claim verified without re-running it).`,
176
+ { label: `resolve:${kind}:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
177
+ )
178
+ } catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during tier-1', outOfScopeFindings: [] }; throw e }
179
+
180
+ // F-008 terminal short-circuit, verified not trusted.
181
+ if (attempt && attempt.terminal) {
182
+ const tr = attempt.terminalReason || ''
183
+ let confirmed = false
184
+ if (tr === 'out-of-ownership') {
185
+ confirmed = !filesInScope(attempt.remedyFiles) // genuinely terminal iff remedy files are NOT in MAY-EDIT
186
+ } else {
187
+ // owner-gated / not-a-code-defect / baseline-not-reached — ratify with the judge.
188
+ try {
189
+ const tj = await agentSafe(
190
+ `A repair agent declared this problem TERMINAL (reason: ${tr}) — no in-MAY-EDIT code fix can satisfy it.\n\n${brief}\n\nIndependently verify: is that verdict genuinely correct, or is it an excuse to avoid work? Return confirmed:true only if it truly holds.`,
191
+ { label: `resolve:terminal-judge:${card}`, phase: 'Verify', agentType: judgeAgent, schema: TERMINAL_JUDGE_SCHEMA }
192
+ )
193
+ confirmed = !!(tj && tj.confirmed)
194
+ } catch (_) { confirmed = false }
195
+ }
196
+ if (confirmed) { log(`${kind} terminal (${tr}) — short-circuit to follow-up.`); return await materialiseFollowup(kind, `terminal: ${tr} — ${attempt.note || ''}`, collectOOS(attempt)) }
197
+ log(`terminal verdict (${tr}) rejected — proceeding to multi-attempt.`)
198
+ }
199
+
200
+ // F-015 — a verified Tier-1 still goes through the mandatory judge cross-check.
201
+ if (attempt && attempt.verified) {
202
+ const ok = await judgeVerify([{ i: 1, r: attempt }])
203
+ if (ok.ok) { log(`${kind} resolved (tier 1, judge-confirmed).`); return { status: 'resolved', outOfScopeFindings: collectOOS(attempt) } }
204
+ log('tier-1 verified rejected by judge cross-check — escalating.')
205
+ }
206
+
207
+ // Tier 2 — judged multi-attempt (budget-guarded; single specialist for protected domains).
208
+ const canFanOut = (typeof budget === 'undefined' || !budget.total || budget.remaining() > 60000)
209
+ let tier2OOS = []
137
210
  if (canFanOut && !protectedDomain) {
138
211
  phase('Repair')
139
212
  const angles = [
@@ -141,40 +214,64 @@ if (canFanOut && !protectedDomain) {
141
214
  'Fix defensively at the call site (guard/validate without changing the contract).',
142
215
  'Fix by reusing an existing utility/pattern instead of new code.',
143
216
  ]
144
- const attempts = (await parallel(angles.map((angle, i) => () =>
145
- agent(
146
- `Tier-2 repair attempt #${i + 1} for card ${card} (${kind}), angle: ${angle}\n\n${brief}\n\n${gateHint}\n\nApply your fix within MAY-EDIT, re-run the gate, and report whether it now passes. Work in isolation; the best attempt will be selected.`,
217
+ const tries = (await parallel(angles.map((angle, i) => () =>
218
+ agentSafe(
219
+ `Tier-2 repair attempt #${i + 1} for card ${card} (${kind}), angle: ${angle}\n\n${brief}\n\n${gateHint}\n\nApply within MAY-EDIT, re-run the gate, report whether it passes. Work in isolation; the best attempt is selected.`,
147
220
  { label: `resolve:${kind}:${card}#${i + 1}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
148
221
  ).then((r) => ({ i: i + 1, r })).catch(() => null)
149
222
  ))).filter(Boolean)
150
-
151
- const verified = attempts.filter((x) => x.r && x.r.verified)
223
+ tier2OOS = collectOOS(...tries.map((t) => t.r))
224
+ const verified = tries.filter((x) => x.r && x.r.verified)
152
225
  if (verified.length) {
153
- phase('Verify')
154
- // Adversarial pick: judge which verified attempt is the soundest (refute the rest).
155
- const judge = await agent(
156
- `Adversarially judge these repair attempts for card ${card} (${kind}). Each claims the gate now passes. Pick the SOUNDEST (best minimal, least regression-prone) — try to refute each with path:line reasoning. Return its 1-based index, or 0 if none truly holds.\n\nEvidence: ${evidence}\nAttempts that claim verified: ${verified.map((v) => `#${v.i}: ${v.r.note || ''}`).join(' | ')}`,
157
- { label: `resolve:judge:${card}`, phase: 'Verify', agentType: 'code-reviewer', schema: JUDGE_SCHEMA }
158
- )
159
- if (judge && judge.best > 0) { log(`${kind} resolved (tier 2, attempt #${judge.best}).`); return { status: 'resolved' } }
226
+ const ok = await judgeVerify(verified)
227
+ if (ok.ok) { log(`${kind} resolved (tier 2, attempt #${ok.best}, judge-confirmed).`); return { status: 'resolved', outOfScopeFindings: collectOOS(attempt).concat(tier2OOS) } }
160
228
  }
161
229
  } else if (protectedDomain) {
162
- log('protected domain — single specialist attempt only (no parallel fan-out).')
230
+ log('protected domain — single specialist attempt only (no fan-out).')
163
231
  } else {
164
232
  log('budget near target — skipping tier-2 fan-out.')
165
233
  }
166
234
 
167
- // Terminal nothing resolved autonomously: materialise a tracked follow-up card.
168
- return await materialiseFollowup(kind, attempt && attempt.note ? attempt.note : 'unresolved after repair tiers')
235
+ return await materialiseFollowup(kind, (attempt && attempt.note) || 'unresolved after repair tiers', collectOOS(attempt).concat(tier2OOS))
169
236
 
170
237
  // ───────────────────────────────────────────────────────────────────────────
171
- async function materialiseFollowup(k, reason) {
172
- const r = await agent(
173
- `Materialise a follow-up backlog card so this residual is TRACKED, not dropped (per ${REF}/completeness.md Phase 2.5b option 3 — the sanctioned, NON-silent deferral).\n\n${brief}\nKind: ${k}\nReason it could not be auto-resolved: ${reason}\n\n` +
174
- `Create ${backlogDir}/${card}-followup-<gate>.yml with status: TODO and the MINIMUM valid fields (non-empty requirements ≥1 derived from the residual, acceptance_criteria ≥1 = the verbatim residual, files_likely_touched ≥1 from this card's ownership). It MUST pass the /new pre-flight field check. Return the created card id.`,
175
- { label: `resolve:followup:${card}`, phase: 'Verify', agentType: 'general-purpose', schema: FOLLOWUP_SCHEMA }
176
- )
177
- const followupCard = (r && r.followupCard) || `${card}-followup`
178
- log(`${k} follow-up card ${followupCard} (nothing dropped).`)
179
- return { status: 'followup', followupCard, reason }
238
+ // F-015/F-033 mandatory adversarial judge + deterministic JS cross-check.
239
+ // The judge (cross-model code-reviewer / domain specialist) does its OWN grep and
240
+ // returns the files it independently confirmed changed; we cross-check MAY-EDIT.
241
+ async function judgeVerify(verifiedAttempts) {
242
+ if (!verifiedAttempts.length) return { ok: false, best: 0 }
243
+ let judge = null
244
+ try {
245
+ judge = await agentSafe(
246
+ `Adversarially judge these repair attempts for card ${card} (${kind}). Each CLAIMS the gate now passes — verify INDEPENDENTLY (grep/read the files yourself; do NOT trust the claim). Pick the SOUNDEST (best minimal, least regression-prone); return 0 if NONE genuinely holds. Also return verifiedFiles = the files you independently confirmed exist AND changed.\n\nEvidence: ${evidence}\nAttempts claiming verified: ${verifiedAttempts.map((v) => `#${v.i}: ${(v.r && v.r.note) || ''}`).join(' | ')}`,
247
+ { label: `resolve:judge:${card}`, phase: 'Verify', agentType: judgeAgent, schema: JUDGE_SCHEMA }
248
+ )
249
+ } catch (e) { if (e && e.transientExhausted) return { ok: false, best: 0 }; throw e }
250
+ if (!judge || !(judge.best > 0)) return { ok: false, best: 0 }
251
+ // Deterministic JS cross-check: the judge-confirmed files must be within MAY-EDIT.
252
+ if (mayEdit.length && Array.isArray(judge.verifiedFiles) && judge.verifiedFiles.length && !filesInScope(judge.verifiedFiles)) {
253
+ log('judge verifiedFiles fall outside MAY-EDIT — rejecting (possible fabrication).')
254
+ return { ok: false, best: 0 }
255
+ }
256
+ return { ok: true, best: judge.best }
257
+ }
258
+
259
+ async function materialiseFollowup(k, reason, oos) {
260
+ let r = null
261
+ try {
262
+ // F-023 — mechanical YAML write → Haiku.
263
+ r = await agentSafe(
264
+ `Materialise a follow-up backlog card so this residual is TRACKED, not dropped (per ${REF}/completeness.md Phase 2.5b option 3). MECHANICAL — do not re-read large modules.\n\n${brief}\nKind: ${k}\nReason unresolved: ${reason}\n\n` +
265
+ `Create ${backlogDir}/${card}-followup-<gate>.yml: status: TODO + MINIMUM valid fields (requirements ≥1 from the residual, acceptance_criteria ≥1 = verbatim residual, files_likely_touched ≥1 from the card ownership / remedy files). MUST pass the /new pre-flight field check. Return the created card id.`,
266
+ { label: `resolve:followup:${card}`, phase: 'Verify', agentType: 'general-purpose', model: 'haiku', schema: FOLLOWUP_SCHEMA }
267
+ )
268
+ } catch (e) {
269
+ // F-020 — could not materialise (e.g. outage): return WITHOUT a followupCard so the
270
+ // SKILL writes it from the offline-safe residual ledger. Never claim it was created.
271
+ log(`follow-up materialisation failed (${String(e && e.message)}) — skill will reconcile.`)
272
+ return { status: 'followup', followupCard: null, reason, outOfScopeFindings: oos || [] }
273
+ }
274
+ const followupCard = (r && r.created && r.followupCard) ? r.followupCard : null
275
+ log(`${k} → follow-up ${followupCard || '(deferred to skill)'} (nothing dropped).`)
276
+ return { status: 'followup', followupCard, reason, outOfScopeFindings: oos || [] }
180
277
  }