baldart 4.17.0 → 4.17.1

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,20 @@ 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.1] - 2026-06-08
9
+
10
+ **`new2` post-refactor: tre falle di routing/detection emerse al secondo run reale (card FEAT-0019-followup).** Dopo l'hardening v4.17.0, gli agent principali sono correttamente specializzati, ma il *resolve* e il *final review* mostravano tre errori di instradamento. **PATCH** (bugfix di orchestrazione nei workflow `new2-resolve`/`new-final-review`; nessuna nuova capability, nessun cambio layout, **nessuna chiave `baldart.config.yml`** → schema-propagation N/A).
11
+
12
+ > **Why.** Tutti e tre i bug condividono la stessa radice: una decisione che dovrebbe essere **deterministica** (quale agent per quale dominio / esiste Codex) era affidata o a una mappa incompleta o al self-report soft di un subagent. Il dominio freeform `documentation` non matchava la chiave `doc`, il companion Codex (installato e funzionante) veniva dichiarato assente per un timeout di una Bash-call sincrona, e le follow-up card nascevano da un Haiku general-purpose anziché dal loro owner.
13
+
14
+ ### Fixed
15
+
16
+ - **`framework/.claude/workflows/new2-resolve.js` — routing per dominio robusto (F-038/F-024).** Le review agent emettono `domain` come **stringa libera** (`documentation`, `frontend`, …); la mappa fixer keyava solo su `doc`/`ui`, così `documentation` cadeva sul costoso `coder` (Opus) per un finding di pura documentazione. Aggiunto `normDomain()` che normalizza ai bucket di routing (`doc|ui|security|migration|perf|test|code`) **prima** di scegliere fixer e judge, e mappa fixer estesa (`doc→doc-reviewer`, `ui→ui-expert`, `security→security-reviewer`, resto→`coder`) — reviewer-owns-its-domain anche in fase di *apply*, non solo di *judge*.
17
+ - **`framework/.claude/workflows/new2-resolve.js` — follow-up card via `prd-card-writer` (F-039).** La materializzazione della follow-up usava `general-purpose` + Haiku scrivendo uno stub YAML minimale; ora delega all'agent **`prd-card-writer`** (owner del `card-template`, Rule C per `review_profile`, `owner_agent`, traceability) — le card di follow-up rispettano le stesse regole SSOT di quelle native.
18
+ - **`framework/.claude/workflows/new-final-review.js` — detection Codex deterministica + background (F-040).** Codex risultava "non disponibile" pur essendo installato e funzionante: il workflow delegava l'intera invocazione a un subagent generico che la eseguiva **in modo sincrono** dentro una Bash-call e auto-dichiarava `codexAvailable` — un run reale di Codex (minuti) superava il timeout della call → falso negativo → fallback a `code-reviewer`. Ora un **pre-flight deterministico** (un Haiku risolve SOLO il glob del companion e ritorna il path: la decisione di esistenza è tolta al review agent), poi Codex gira in **background con poll su file** e finestra 10 min (allineato a `references/final-review.md` F.3/F.4); `codexAvailable:false` è ammesso solo per `CODEX_NOT_FOUND` reale o file vuoto a fine timeout. Un **unico** fallback deterministico a `code-reviewer` copre sia "companion assente" sia "run non completato".
19
+ - **`new2.js` + `new-final-review.js` — final review snella per batch mono-card (F-041).** Un batch da 1 sola card eseguiva la batch final review **per intero** (`doc-reviewer` + `api-perf-cost-auditor` + `qa-sentinel`), **duplicando** la review per-card (Phase 3) che aveva già girato gli stessi agent sugli stessi file — e senza alcun conflitto cross-card da trovare (~300k token ripetuti su un run reale da 1,28M). Ora `new2.js` passa `singleCard: committed.length === 1`; con quel flag la final review tiene **solo** il passaggio cross-model Codex (valore unico: modello diverso → bug diversi) + i gate `qa-sentinel` (letti dall'integrity gate di merge), e salta i duplicati Claude. Batch multi-card invariati.
20
+ - **`new2.js` + `new2-resolve.js` — fan-out Tier-2 inutile su finding doc (F-038/F-033/F-036).** Un singolo merge-blocker doc-domain già **risolto al Tier-1 e confermato dal judge** (`best:1`) faceva comunque partire **3 `doc-reviewer` paralleli (~150k token)** — con rischio di follow-up spuria per un problema già chiuso. Tre cause composte, tutte corrette: **(A)** la resolve riceveva come MAY-EDIT i file di **codice** della card (`reviewScopeFiles`), così il fixer doc editava `docs/` "fuori ownership" — ora `domainMayEdit()` dà ai finding doc la loro territory reale (`docs_dir`/`references_dir`/`wiki_dir`/`prd_dir`); **(B)** la guardia anti-fabbricazione del judge usava `.every()` (rigetto se *anche un solo* file confermato cade fuori MAY-EDIT) — ma il judge lista naturalmente i file adiacenti (il codice che una doc descrive); ora `someInScope()` (`.some()`) rigetta solo un verdetto che non conferma **nessun** file di proprietà (phantom puro); **(C)** il fan-out a 3 angoli (`root-cause`/`call-site`/`reuse-utility`) è code-bug-shaped → per domini deterministici non-code (doc/test) ora un **singolo** retry mirato invece di 3.
21
+
8
22
  ## [4.17.0] - 2026-06-07
9
23
 
10
24
  **`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).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.17.0
1
+ 4.17.1
@@ -116,11 +116,40 @@ if (baselinePaths.length) {
116
116
  // ───────────────────────────────────────────────────────────────────────────
117
117
  phase('Review')
118
118
 
119
+ // F-040 — DETERMINISTIC Codex pre-flight (fixes the false-negative where Codex is
120
+ // installed+working but the review subagent reported codexAvailable:false because a
121
+ // synchronous inline run hit its Bash timeout). The workflow sandbox has no Bash, so a
122
+ // minimal Haiku agent runs ONLY the resolution glob and returns the path — the existence
123
+ // decision is thus taken OUT of the review agent's hands. Found → Codex runs in the
124
+ // BACKGROUND with file-poll (canonical final-review.md F.3/F.4). Not found → straight to
125
+ // the code-reviewer fallback, no wasted Codex agent.
126
+ const PREFLIGHT_SCHEMA = {
127
+ type: 'object', required: ['codexScriptPath'], additionalProperties: false,
128
+ properties: { codexScriptPath: { type: 'string', description: 'absolute path to codex-companion.mjs, or "" if none' } },
129
+ }
130
+ let codexScriptPath = ''
131
+ try {
132
+ const pf = await agent(
133
+ `Resolve the Codex companion script path. Run EXACTLY this one Bash command and nothing else:\n` +
134
+ " ls -d ~/.claude/plugins/marketplaces/openai-codex/plugins/codex/scripts/codex-companion.mjs ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs 2>/dev/null | sort -V | tail -1\n" +
135
+ `Return codexScriptPath = the trimmed stdout (a single absolute path), or "" if the command printed nothing. Do NOT run Codex, do NOT read any other file, do NOT reason about availability — just report the command's stdout.`,
136
+ { label: 'codex-preflight', phase: 'Review', model: 'haiku', schema: PREFLIGHT_SCHEMA }
137
+ )
138
+ codexScriptPath = (pf && typeof pf.codexScriptPath === 'string') ? pf.codexScriptPath.trim() : ''
139
+ } catch (_) { codexScriptPath = '' }
140
+ const codexResolved = /codex-companion\.mjs$/.test(codexScriptPath)
141
+ log(codexResolved
142
+ ? `Codex companion resolved deterministically: ${codexScriptPath}`
143
+ : 'Codex companion NOT found (deterministic pre-flight) — primary review via code-reviewer fallback.')
144
+
119
145
  const codexPrompt =
120
146
  `Run a deep, cross-model code review over this batch diff, following the /codexreview protocol summarized in ${protocolRef} (Step F.3). The code is already written and committed — find bugs, regressions, security issues, and quality problems.\n\n` +
121
- `Resolve and invoke the Codex companion (a non-Anthropic frontier model) via Bash:\n` +
122
- " CODEX_SCRIPT=\"$(ls -d ~/.claude/plugins/marketplaces/openai-codex/plugins/codex/scripts/codex-companion.mjs ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs 2>/dev/null | sort -V | tail -1)\"\n" +
123
- `If no companion script is found, OR the review does not complete, return { "codexAvailable": false, "findings": [] } — do NOT fabricate findings.\n\n` +
147
+ `The Codex companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n` +
148
+ `Launch it in the BACKGROUND and poll for completion — do NOT run it synchronously (a sync run would hit the Bash tool timeout and is the exact bug this design fixes):\n` +
149
+ ` REVIEW_FILE=a unique /tmp file (e.g. /tmp/codexreview-batch-${a.firstCardId || 'batch'}-$$.md)\n` +
150
+ ` • node "${codexScriptPath}" task "<your review instructions>" > "$REVIEW_FILE" 2>&1 — run this with run_in_background:true (the launching call returns immediately).\n` +
151
+ ` • Then POLL $REVIEW_FILE (BashOutput / repeated reads) until it holds a terminal result, up to a full 10-minute window.\n` +
152
+ `Return codexAvailable:false ONLY if $REVIEW_FILE ends up containing "CODEX_NOT_FOUND" or stays empty after the FULL 10-minute window — NEVER because a single Bash call returned slowly. The script's existence is already verified, so a premature false here is a bug, not a capability gap.\n\n` +
124
153
  `${scopeBrief}\n\n${baselineBrief}\n\n` +
125
154
  `For each finding return: finding_id, title, severity (BLOCKER|HIGH|MEDIUM|LOW), confidence (0-100), evidence (exact file:line + code quote), minimal_fix_direction, and domain (doc|security|migration|code|perf|test). ` +
126
155
  `Run the mandatory false-positive check on every finding and suppress the unconvincing ones (your findings are treated as already FP-validated). Set codexAvailable:true when the review ran.`
@@ -134,16 +163,29 @@ const apiPrompt =
134
163
  const qaPrompt =
135
164
  `Run MECHANICAL GATES ONLY over the batch scope, per ${protocolRef} Step F.3 (qa-sentinel row): lint, type-check, the full test suite, build, dependency audit, and markdownlint as applicable to this project. Do NOT read source for code findings, do NOT emit severities — return only a PASS/FAIL/SKIP gate table.\n\nWorktree: ${a.worktreePath || '(cwd)'}\nChanged files:\n${scope.join('\n')}`
136
165
 
166
+ // F-041 — single-card batch: the per-card review (Phase 3) already ran doc-reviewer +
167
+ // api-perf-cost-auditor over these exact files, and a 1-card batch has NO cross-card
168
+ // conflict to surface. Keep ONLY the cross-model Codex pass (its unique value — a different
169
+ // model finds different bugs) + qa-sentinel gates; skip the Claude-agent duplicates.
170
+ const slim = a.singleCard === true
171
+ // qa-sentinel always runs — the merge integrity gate reads its PASS/FAIL table.
137
172
  const reviewThunks = [
138
- () => agent(codexPrompt, { label: 'codex', phase: 'Review', schema: CODEX_SCHEMA }).then((r) => ({ kind: 'codex', r })),
139
- () => agent(docPrompt, { label: 'doc-reviewer', phase: 'Review', agentType: 'doc-reviewer', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'doc', r })),
140
173
  () => agent(qaPrompt, { label: 'qa-sentinel', phase: 'Review', agentType: 'qa-sentinel', schema: GATES_SCHEMA }).then((r) => ({ kind: 'qa', r })),
141
174
  ]
142
- // api-perf-cost-auditor is skipped when the batch touches no API/data files (F.3 note).
143
- if (a.hasApiDataFiles !== false) {
175
+ if (!slim) {
176
+ reviewThunks.unshift(() => agent(docPrompt, { label: 'doc-reviewer', phase: 'Review', agentType: 'doc-reviewer', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'doc', r })))
177
+ }
178
+ // Codex thunk runs ONLY when the pre-flight resolved the companion (else: no wasted agent).
179
+ if (codexResolved) {
180
+ reviewThunks.unshift(() => agent(codexPrompt, { label: 'codex', phase: 'Review', schema: CODEX_SCHEMA }).then((r) => ({ kind: 'codex', r })))
181
+ }
182
+ // api-perf-cost-auditor: skipped when no API/data files OR on a slim single-card pass.
183
+ if (!slim && a.hasApiDataFiles !== false) {
144
184
  reviewThunks.push(() => agent(apiPrompt, { label: 'api-perf-cost-auditor', phase: 'Review', agentType: 'api-perf-cost-auditor', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'api', r })))
145
185
  } else {
146
- log('Review: api-perf-cost-auditor skipped (no API/data files in scope).')
186
+ log(slim
187
+ ? 'Review: single-card batch — doc-reviewer + api-perf skipped (already run per-card); kept cross-model Codex + qa gates.'
188
+ : 'Review: api-perf-cost-auditor skipped (no API/data files in scope).')
147
189
  }
148
190
 
149
191
  const reviewResults = (await parallel(reviewThunks)).filter(Boolean)
@@ -151,20 +193,16 @@ const reviewResults = (await parallel(reviewThunks)).filter(Boolean)
151
193
  // ---- Fan-in (F.4 step 9): collect findings + Codex fallback branch ----------
152
194
  let raw = []
153
195
  let gateTable = []
154
- let codexEngine = 'codex'
196
+ let codexEngine = codexResolved ? 'codex' : 'code-reviewer (fallback)'
197
+ let codexRan = false
155
198
  for (const item of reviewResults) {
156
199
  if (item.kind === 'codex') {
157
200
  if (item.r && item.r.codexAvailable && Array.isArray(item.r.findings)) {
201
+ codexRan = true
158
202
  raw.push(...item.r.findings.map((f) => ({ ...f, source: 'codex', preValidated: true })))
159
203
  } else {
160
- // CODEX_NOT_FOUND / TIMED_OUT Claude code-reviewer fallback (F.4 step 8).
161
- codexEngine = 'code-reviewer (fallback)'
162
- log('Review: Codex unavailable — falling back to code-reviewer for the primary code review.')
163
- const fb = await agent(
164
- `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.`,
165
- { label: 'code-reviewer (fallback)', phase: 'Review', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
166
- )
167
- if (fb && Array.isArray(fb.findings)) raw.push(...fb.findings.map((f) => ({ ...f, source: 'code-reviewer', preValidated: false })))
204
+ // Resolved but the run itself failed (CODEX_NOT_FOUND written / empty after timeout).
205
+ log('Review: Codex companion resolved but its review did not complete — falling back to code-reviewer.')
168
206
  }
169
207
  } else if (item.kind === 'qa') {
170
208
  gateTable = (item.r && item.r.gates) || []
@@ -173,6 +211,17 @@ for (const item of reviewResults) {
173
211
  }
174
212
  }
175
213
 
214
+ // F-040 — ONE deterministic fallback: either the pre-flight found no companion, or it ran
215
+ // but did not complete. Either way the primary code review must still happen (F.4 step 8).
216
+ if (!codexRan) {
217
+ codexEngine = 'code-reviewer (fallback)'
218
+ const fb = await agent(
219
+ `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.`,
220
+ { label: 'code-reviewer (fallback)', phase: 'Review', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
221
+ )
222
+ if (fb && Array.isArray(fb.findings)) raw.push(...fb.findings.map((f) => ({ ...f, source: 'code-reviewer', preValidated: false })))
223
+ }
224
+
176
225
  // ───────────────────────────────────────────────────────────────────────────
177
226
  // Phase Verify (F.4) — adversarial validation of low-confidence findings
178
227
  // Codex findings are pre-FP-validated → VERIFIED. Claude-agent findings with
@@ -25,7 +25,20 @@ const evidence = a.evidence || ''
25
25
  const worktree = a.worktreePath || '(cwd)'
26
26
  const mayEdit = Array.isArray(a.mayEditPaths) ? a.mayEditPaths : []
27
27
  const scopeFiles = Array.isArray(a.scopeFiles) ? a.scopeFiles : []
28
- const domain = a.domain || 'code'
28
+ // F-038 — review agents emit FREEFORM domain strings (e.g. 'documentation', 'frontend').
29
+ // Normalize to the routing buckets the fixer/judge maps key on, so a doc finding never
30
+ // falls through to the costly `coder` (the v4.17.0 'documentation'→coder regression).
31
+ function normDomain(d) {
32
+ const s = String(d || 'code').toLowerCase()
33
+ if (/doc|wiki|ssot|readme/.test(s)) return 'doc'
34
+ if (/ui|frontend|front-end|design|css|visual|component/.test(s)) return 'ui'
35
+ if (/sec|auth|secret|rls|tenant|xss|csrf|injection/.test(s)) return 'security'
36
+ if (/migrat|schema|ddl|\bsql\b/.test(s)) return 'migration'
37
+ if (/perf|cost|n\+1|latency|throughput/.test(s)) return 'perf'
38
+ if (/\btest|qa\b|spec|coverage/.test(s)) return 'test'
39
+ return 'code'
40
+ }
41
+ const domain = normDomain(a.domain || 'code')
29
42
  const findings = Array.isArray(a.findings) && a.findings.length ? a.findings : [{ kind, evidence, domain }]
30
43
  const REF = (a.refModulesBase || '.claude/skills/new/references').replace(/\/$/, '')
31
44
  const cfg = a.config || {}
@@ -81,8 +94,9 @@ const FOLLOWUP_SCHEMA = {
81
94
  properties: { created: { type: 'boolean' }, followupCard: { type: 'string' }, note: { type: 'string' } },
82
95
  }
83
96
 
84
- // F-024 — domain-specialized fixer + judge (full map, not just doc|coder).
85
- const fixerAgent = ({ doc: 'doc-reviewer', ui: 'ui-expert' })[domain] || 'coder'
97
+ // F-024 — domain-specialized fixer + judge (full map; reviewer-owns-its-domain a doc
98
+ // finding is fixed by doc-reviewer, a security finding by security-reviewer, never coder).
99
+ const fixerAgent = ({ doc: 'doc-reviewer', ui: 'ui-expert', security: 'security-reviewer' })[domain] || 'coder'
86
100
  const judgeAgent = (domain === 'security' || domain === 'migration') ? 'security-reviewer' : domain === 'perf' ? 'api-perf-cost-auditor' : 'code-reviewer'
87
101
 
88
102
  const findingsBlock = findings.map((f, i) => ` ${i + 1}. [${f.kind || kind}/${f.domain || domain}] ${f.evidence}`).join('\n')
@@ -105,6 +119,15 @@ function filesInScope(files) {
105
119
  if (!mayEdit.length) return false
106
120
  return files.every((f) => mayEdit.some((m) => String(f).includes(m) || m.includes(String(f))))
107
121
  }
122
+ // F-033 — fabrication guard for the judge cross-check: a REAL fix touched AT LEAST ONE
123
+ // owned file. The judge naturally also lists adjacent changed files (e.g. the code a doc
124
+ // describes) — that is NOT fabrication, so `.every()` here over-rejected a sound fix and
125
+ // forced a wasted Tier-2 fan-out. `.some()` rejects only a verdict that confirmed ZERO
126
+ // owned files (pure phantom).
127
+ function someInScope(files) {
128
+ if (!Array.isArray(files) || !files.length || !mayEdit.length) return false
129
+ return files.some((f) => mayEdit.some((m) => String(f).includes(m) || m.includes(String(f))))
130
+ }
108
131
  function collectOOS(...attemptObjs) {
109
132
  const out = []
110
133
  for (const o of attemptObjs) for (const x of (o && o.outOfScopeFindings) || []) out.push(x)
@@ -209,10 +232,16 @@ const canFanOut = (typeof budget === 'undefined' || !budget.total || budget.rema
209
232
  let tier2OOS = []
210
233
  if (canFanOut && !protectedDomain) {
211
234
  phase('Repair')
212
- const angles = [
235
+ // F-036 the 3-angle fan-out (root-cause / call-site / reuse-utility) is code-bug-shaped.
236
+ // For deterministic non-code domains (doc/test) it is 3× waste — a single targeted retry
237
+ // is enough. Reserve the fan-out for code/perf/ui where the angles genuinely diverge.
238
+ const codeShaped = !/^(doc|test)$/.test(domain)
239
+ const angles = codeShaped ? [
213
240
  'Fix at the root cause (change the underlying logic/contract).',
214
241
  'Fix defensively at the call site (guard/validate without changing the contract).',
215
242
  'Fix by reusing an existing utility/pattern instead of new code.',
243
+ ] : [
244
+ 'Apply the single correct fix — the residual is deterministic: re-derive the exact change and apply it.',
216
245
  ]
217
246
  const tries = (await parallel(angles.map((angle, i) => () =>
218
247
  agentSafe(
@@ -249,8 +278,8 @@ async function judgeVerify(verifiedAttempts) {
249
278
  } catch (e) { if (e && e.transientExhausted) return { ok: false, best: 0 }; throw e }
250
279
  if (!judge || !(judge.best > 0)) return { ok: false, best: 0 }
251
280
  // 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).')
281
+ if (mayEdit.length && Array.isArray(judge.verifiedFiles) && judge.verifiedFiles.length && !someInScope(judge.verifiedFiles)) {
282
+ log('judge verifiedFiles are ENTIRELY outside MAY-EDIT — rejecting (possible fabrication).')
254
283
  return { ok: false, best: 0 }
255
284
  }
256
285
  return { ok: true, best: judge.best }
@@ -259,11 +288,12 @@ async function judgeVerify(verifiedAttempts) {
259
288
  async function materialiseFollowup(k, reason, oos) {
260
289
  let r = null
261
290
  try {
262
- // F-023mechanical YAML write Haiku.
291
+ // F-039backlog cards are owned by prd-card-writer (card-template + Rule C
292
+ // review_profile + owner_agent + traceability), NOT a hand-written Haiku stub.
263
293
  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 }
294
+ `Create ONE follow-up backlog card so this residual is TRACKED, not dropped (per ${REF}/completeness.md Phase 2.5b option 3). You are prd-card-writer: apply your card-template, Rule C (review_profile), owner_agent routing, and traceability rules — do NOT emit a minimal stub.\n\n${brief}\nKind: ${k}\nResidual domain: ${domain}\nReason unresolved: ${reason}\n\n` +
295
+ `Write ${backlogDir}/${card}-followup-<gate>.yml with status: TODO, derived from the residual: requirements + acceptance_criteria (the verbatim residual as ≥1 AC), owner_agent routed to the residual domain (${domain}), review_profile per Rule C, files_likely_touched ≥1 from the card ownership / remedy files. It MUST pass the /new pre-flight field check. Return the created card id.`,
296
+ { label: `resolve:followup:${card}`, phase: 'Verify', agentType: 'prd-card-writer', schema: FOLLOWUP_SCHEMA }
267
297
  )
268
298
  } catch (e) {
269
299
  // F-020 — could not materialise (e.g. outage): return WITHOUT a followupCard so the
@@ -234,6 +234,18 @@ cardIds.forEach((id, i) => { pathById[id] = cardPaths[i] || `${paths.backlog_dir
234
234
  // new2-resolve bridge — dedup (F-009) + accepted-deferral skip (F-028) + group batching (F-007).
235
235
  // `findings` is a list; a single residual is a list of 1 (retro-compat). Returns 'resolved'|'followup'|'fatal'.
236
236
  // ───────────────────────────────────────────────────────────────────────────
237
+ // F-038 — doc-domain findings are fixed in the doc tree, NOT the card's code MAY-EDIT.
238
+ // Passing code-only ownership made the doc fixer edit docs/ "out of scope" and the JS
239
+ // fabrication cross-check reject a correct, judge-approved fix → a wasted Tier-2 fan-out
240
+ // (~150k tok) and a risk of a bogus follow-up for an already-resolved finding. Give doc
241
+ // findings their real owner territory.
242
+ function isDocDomain(d) { return /doc|wiki|ssot|readme/i.test(String(d || '')) }
243
+ function domainMayEdit(dom, codeScope) {
244
+ if (!isDocDomain(dom)) return codeScope
245
+ const docPaths = [paths.docs_dir, paths.references_dir, paths.wiki_dir, paths.prd_dir].filter(Boolean)
246
+ return docPaths.length ? docPaths : codeScope // doc-only ownership; fall back to code scope if no doc paths configured
247
+ }
248
+
237
249
  async function resolve(kind, card, evidence, extra) {
238
250
  const s = sig(card, kind, evidence)
239
251
  if (resolvedSignatures.has(s) || acceptedDeferrals.has(s)) {
@@ -241,15 +253,16 @@ async function resolve(kind, card, evidence, extra) {
241
253
  return 'resolved'
242
254
  }
243
255
  resolvedSignatures.add(s)
256
+ const dom = (extra && extra.domain) || 'code'
244
257
  let res
245
258
  try {
246
259
  res = await workflow('new2-resolve', {
247
260
  kind, cardId: card, evidence,
248
- findings: (extra && extra.findings) || [{ kind, evidence, domain: (extra && extra.domain) || 'code' }],
261
+ findings: (extra && extra.findings) || [{ kind, evidence, domain: dom }],
249
262
  worktreePath: sharedCtx.worktreePath,
250
- mayEditPaths: (extra && extra.mayEditPaths) || [],
263
+ mayEditPaths: domainMayEdit(dom, (extra && extra.mayEditPaths) || []),
251
264
  scopeFiles: (extra && extra.scopeFiles) || [],
252
- domain: (extra && extra.domain) || 'code',
265
+ domain: dom,
253
266
  refModulesBase: REF, config: cfg, ts: TS,
254
267
  })
255
268
  } catch (e) {
@@ -503,6 +516,9 @@ if (committed.length && !batchFatal && !degraded) {
503
516
  firstCardId: firstCard, worktreePath: sharedCtx.worktreePath, baseBranch: TRUNK,
504
517
  cardPaths: committed.map((r) => pathById[r.card]).filter(Boolean),
505
518
  reviewScopeFiles, archBaselinePaths: allArch, hasApiDataFiles, config: cfg,
519
+ // F-041 — single-card batch: doc-reviewer + api-perf already ran per-card and there is
520
+ // NO cross-card conflict to find. Keep only the cross-model Codex pass + qa gates.
521
+ singleCard: committed.length === 1,
506
522
  })
507
523
  } catch (e) { if (e && isTransient(e)) noteDegraded('outage'); final = null }
508
524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.17.0",
3
+ "version": "4.17.1",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"