baldart 4.65.0 → 4.66.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 +17 -0
- package/VERSION +1 -1
- package/framework/.claude/skills/new/references/final-review.md +12 -2
- package/framework/.claude/skills/new/references/team-mode.md +1 -1
- package/framework/.claude/workflows/new-card-review.js +67 -13
- package/framework/.claude/workflows/new-final-review.js +63 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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.66.0] - 2026-06-23
|
|
9
|
+
|
|
10
|
+
**Server-side reviewer death is no longer a silent drop in the `/new` review workflows — from a real `new-final-review` run where `qa-sentinel` died on `API Error: 529 Overloaded`.** When a reviewer agent inside `new-final-review.js` / `new-card-review.js` was killed by a transient server-side error (529 Overloaded, 429, 5xx, rate-limit — the org-level limit is SHARED across every terminal/session), the worker pool's `try/catch → null` + `.filter(Boolean)` **silently discarded it**. For `qa-sentinel` this was worst: a dead gate-runner produced an **empty `gateTable`**, which the fan-in read as "no mechanical gates ran ⇒ nothing to block" — so the batch's lint/tsc/test/build/i18n merge gate was lost without any FAIL signal. The two workflows had NO transient retry and NO degraded-coverage signal at all; the canonical handling already existed in `new2.js` (`agentSafe` + `noteDegraded`) and in the prose SSOT (`references/team-mode.md` § transient-vs-genuine) but had **never been propagated** to the other two workflows.
|
|
11
|
+
|
|
12
|
+
**MINOR** — reliability hardening of two distributed dynamic workflows; additive, backward-compatible (the return shape gains an additive `summary.degradedReviewers`), no new config key, schema-change propagation rule does NOT apply. Claude-only (workflows are Claude-only).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`new-final-review.js` + `new-card-review.js` — transient-aware reviewer spawn (SSOT parity with `new2.js`).** Lifted the canonical `TRANSIENT`/`isTransient`/`agentSafe` helper (the same code `new2.js` already uses) into both workflows and routed every review thunk (codex / doc-reviewer / api-perf-cost-auditor / qa-sentinel / simplify / security) through a new `reviewSafe()` wrapper. A reviewer killed by a transient error is **retried in-workflow** (cap 3; honest about the lack of a reliable runtime sleep — this absorbs brief blips, it is NOT timed backoff). A sustained outage exhausts the cap and is **recorded**, never silently dropped.
|
|
17
|
+
- **A dead `qa-sentinel` now BLOCKS the merge instead of passing blind.** Both workflows inject a synthetic `{ gate: 'mechanical-gates (qa-sentinel)', status: 'FAIL', detail: … }` when qa-sentinel returns null after retries, so `summary.failingGates` (and the skill's F.5 / wave-boundary gate) treat the UNKNOWN mechanical gates as **non-PASS**. Verified deterministically: the synthetic FAIL flows into `failingGates` and does NOT read as a build PASS (so the O5(b) build-claim demotion stays suppressed). Also applied to `new-card-review.js`'s light-tier post-fix scoped qa-sentinel.
|
|
18
|
+
- **Degraded-coverage ledger.** Both workflows return an additive `summary.degradedReviewers: [{ reviewer, reason }]` listing reviewers that died after retries, and log it on completion — so the `/new` skill knows the batch ran with INCOMPLETE coverage rather than mistaking a thin `findings` set for "clean". `new2.js` was already correct (its authoritative mechanical gate is the owner-run Phase-2 `buildBlocked` path, transient-handled; the qa-sentinel reviewer death already triggers `noteDegraded`) — **left untouched** to avoid perturbing the A/B experiment.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **`references/final-review.md`** — documents the non-silent reviewer-death contract in the delegated-workflow return-shape section + the F.3 qa-sentinel row (dead qa ⇒ synthetic FAIL ⇒ merge blocked; `summary.degradedReviewers` ⇒ surface terse / AUTONOMOUS materializes a re-run follow-up). Framed as the **per-agent twin of the F.1.5 whole-workflow-killed recovery**.
|
|
23
|
+
- **`references/team-mode.md`** — the transient-vs-genuine § now cross-links the workflow twin, making the SSOT relationship bidirectional (same discipline, two surfaces: prose for orchestrator-spawned teammates, workflow JS for in-workflow fan-out).
|
|
24
|
+
|
|
8
25
|
## [4.65.0] - 2026-06-23
|
|
9
26
|
|
|
10
27
|
**Machine-readable component manifest + DTCG token SSOT — from a real slow-discovery diagnosis on a consumer (mayo).** UI component discovery was slow because the design-system `INDEX.md` had become a 33KB monolith read in full on every UI task, there was no token reference (tokens parsed from a 17KB `tokens.ts`), and 0/~135 components had a per-component spec — so every reuse lookup degraded to reading component source. The framework already prescribes per-component specs (owned by `doc-reviewer`, gated by `code-reviewer`, audited by `ds-drift`); this release **evolves that existing layer** (no twin) into a machine-readable, on-demand one, and gives tokens a real W3C DTCG source of truth.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.66.0
|
|
@@ -113,7 +113,17 @@ that is a **gate violation**: log it as
|
|
|
113
113
|
The workflow returns `{ codexEngine, findings, noActionFindings, gateTable, summary }` where
|
|
114
114
|
`findings` are already consolidated and classified (`VERIFIED` /
|
|
115
115
|
`NEEDS_MANUAL_CONFIRMATION`; `FALSE_POSITIVE` already dropped) and `gateTable`
|
|
116
|
-
is the qa-sentinel PASS/FAIL/SKIP table. **
|
|
116
|
+
is the qa-sentinel PASS/FAIL/SKIP table. **Server-side reviewer death is non-silent (since v4.66.0):**
|
|
117
|
+
a reviewer killed by a transient API error (529 Overloaded / 429 / 5xx / rate-limit — the org-level
|
|
118
|
+
limit is SHARED across every terminal) is retried in-workflow (`agentSafe`), and if it stays dead it
|
|
119
|
+
is recorded in `summary.degradedReviewers` (the batch review ran with INCOMPLETE coverage — do NOT
|
|
120
|
+
read a clean `findings` as "nothing found"). In particular a dead **qa-sentinel** does NOT yield an
|
|
121
|
+
empty `gateTable`: the workflow injects a synthetic `{ gate: 'mechanical-gates (qa-sentinel)', status:
|
|
122
|
+
'FAIL' }` so the merge is treated as **non-PASS** (blocked) rather than silently passing on UNKNOWN
|
|
123
|
+
mechanical gates. When `summary.degradedReviewers` is non-empty or the qa gate is the synthetic FAIL,
|
|
124
|
+
surface it terse in F.5 and treat the merge as gated; **AUTONOMOUS** → materialize a follow-up card to
|
|
125
|
+
re-run the final review/gates (never merge blind). This is the per-agent twin of the F.1.5 whole-workflow
|
|
126
|
+
recovery, and mirrors the transient-vs-genuine discipline in `references/team-mode.md` § empty-result. **Since `applyFixes: true` is passed (since v4.59.0),
|
|
117
127
|
the workflow ALSO ran its Fix phase** and returns the additive fields
|
|
118
128
|
`{ ...above, fixesApplied:[…1-line strings], docFixesApplied:[…1-line strings], residual:[…slim findings] }`:
|
|
119
129
|
it already applied every VERIFIED fix by domain owner (security-reviewer → coder → doc-reviewer, all
|
|
@@ -266,7 +276,7 @@ that is a **gate violation**: log it as
|
|
|
266
276
|
|-------|-----------------|-------|--------|
|
|
267
277
|
| **doc-reviewer** | `doc-reviewer` | Cross-card doc consistency, ssot-registry completeness, invariants | Findings: `finding_id`, `title`, `severity`, `confidence`, `evidence`, `minimal_fix_direction` |
|
|
268
278
|
| **api-perf-cost-auditor** | `api-perf-cost-auditor` | API/data/performance/cost defects (skip if no API/data files in scope) | Same findings schema |
|
|
269
|
-
| **qa-sentinel** | `qa-sentinel` | **Mechanical gates ONLY** over the batch scope (lint, tsc, full test suite, build, `npm audit`, markdownlint) | A PASS/FAIL gate table — NOT a findings list. qa-sentinel does not read source files, does not emit severities, and does not do edge-case/reproducibility analysis (its system prompt forbids it). A gate FAILURE feeds the fix-loop the same way a VERIFIED finding does. |
|
|
279
|
+
| **qa-sentinel** | `qa-sentinel` | **Mechanical gates ONLY** over the batch scope (lint, tsc, full test suite, build, `npm audit`, markdownlint) | A PASS/FAIL gate table — NOT a findings list. qa-sentinel does not read source files, does not emit severities, and does not do edge-case/reproducibility analysis (its system prompt forbids it). A gate FAILURE feeds the fix-loop the same way a VERIFIED finding does. **If qa-sentinel itself DIES (transient 529/rate-limit exhausted, or a terminal API error) the gate table is UNKNOWN, never empty: the workflow injects a synthetic `status: FAIL` so the merge is blocked rather than passing blind (since v4.66.0).** |
|
|
270
280
|
|
|
271
281
|
**F-041 per-finder slim — N=1 only (coverage-gated; identical rule to the delegated path's
|
|
272
282
|
`slimDoc`/`slimApi`).** This inline prose is the SSOT the workflow mirrors, so it carries the same
|
|
@@ -155,7 +155,7 @@ For each completed agent:
|
|
|
155
155
|
- Subagent transcripts live at `<session-subagents-dir>/agent-*.jsonl`, each with a sibling `agent-*.meta.json` whose `agentType` equals the teammate label. Resolve the file and read its tail, e.g.:
|
|
156
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"`
|
|
157
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").
|
|
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.
|
|
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. **(Workflow twin, since v4.66.0:** inside the `new-card-review` / `new-final-review` dynamic workflows the same transient-vs-terminal discipline is enforced in code — `agentSafe` retries a transient-killed reviewer in-workflow, and a reviewer that stays dead is recorded in `summary.degradedReviewers` rather than silently dropped by `filter(Boolean)`; a dead **qa-sentinel** specifically injects a synthetic `FAIL` gate so the merge is blocked, never passed on UNKNOWN mechanical gates. Same SSOT, two surfaces: this prose for orchestrator-spawned teammates, the workflow JS for in-workflow fan-out.)**
|
|
159
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.)
|
|
160
160
|
|
|
161
161
|
**If an agent fails** (status: failed after 3 retries — the central repair cap):
|
|
@@ -61,6 +61,47 @@ async function parallelCapped(thunks, cap) {
|
|
|
61
61
|
return results
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// ---- Transient-aware spawn (SSOT parity with new2.js `agentSafe`, v4.66.0) ---
|
|
65
|
+
// A reviewer killed by a SERVER-SIDE API error (529 Overloaded, 429/5xx, rate-limit —
|
|
66
|
+
// the org-level limit is SHARED across every terminal/session) must NOT vanish from the
|
|
67
|
+
// wave review. `agent()` THROWS a transient error → retried in-workflow up to `cap`; it
|
|
68
|
+
// RETURNS null (terminal API death after the tool's own retries, or a skip) → recorded as
|
|
69
|
+
// degraded. No reliable sleep in the runtime ⇒ NOT timed backoff (absorbs brief blips only);
|
|
70
|
+
// a sustained outage exhausts the cap and surfaces as DEGRADED COVERAGE (and, for the
|
|
71
|
+
// qa-sentinel merge gate, a synthetic FAIL), NEVER a silent drop. Mirrors the transient-vs-
|
|
72
|
+
// genuine discipline in references/team-mode.md § empty-result classification.
|
|
73
|
+
const TRANSIENT = /overload|529|429|rate.?limit|5\d\d|econn|etimedout|timeout|temporar|unavailable/i
|
|
74
|
+
const isTransient = (e) => TRANSIENT.test(String((e && e.message) || e || ''))
|
|
75
|
+
async function agentSafe(prompt, opts, maxAttempts) {
|
|
76
|
+
const cap = maxAttempts || 3
|
|
77
|
+
let lastErr = null
|
|
78
|
+
for (let i = 0; i < cap; i++) {
|
|
79
|
+
try {
|
|
80
|
+
const o = i === 0 ? opts : Object.assign({}, opts, { label: `${(opts && opts.label) || 'agent'}~retry${i}` })
|
|
81
|
+
return await agent(prompt, o)
|
|
82
|
+
} catch (e) { lastErr = e; if (!isTransient(e)) throw e } // permanent error → surface
|
|
83
|
+
}
|
|
84
|
+
const err = new Error('transient-exhausted: ' + String((lastErr && lastErr.message) || lastErr))
|
|
85
|
+
err.transientExhausted = true
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
const degradedReviewers = []
|
|
89
|
+
function reviewSafe(kind, card, prompt, opts) {
|
|
90
|
+
return agentSafe(prompt, opts)
|
|
91
|
+
.then((r) => {
|
|
92
|
+
if (r == null) {
|
|
93
|
+
degradedReviewers.push({ reviewer: (opts && opts.label) || kind, reason: 'null-return' })
|
|
94
|
+
log(`Discovery: ${(opts && opts.label) || kind} returned null (terminal API error after tool retries / skip) — coverage degraded, NOT silently dropped.`)
|
|
95
|
+
}
|
|
96
|
+
return { kind, card, r }
|
|
97
|
+
})
|
|
98
|
+
.catch((e) => {
|
|
99
|
+
degradedReviewers.push({ reviewer: (opts && opts.label) || kind, reason: e && e.transientExhausted ? 'transient-exhausted' : 'error' })
|
|
100
|
+
log(`Discovery: ${(opts && opts.label) || kind} FAILED after retries (${e && e.transientExhausted ? 'transient/529 exhausted' : 'terminal'}) — coverage degraded, NOT silently dropped.`)
|
|
101
|
+
return { kind, card, r: null }
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
// Curated toolchain (since v4.41.0): when features.has_toolchain is on, the
|
|
65
106
|
// consumer records LITERAL gate commands in toolchain.commands.* — agents run
|
|
66
107
|
// THOSE instead of guessing (e.g. `npx biome check .` not `npm run lint`). Empty
|
|
@@ -336,10 +377,10 @@ function securityPrompt(c) {
|
|
|
336
377
|
const findThunks = []
|
|
337
378
|
for (const c of cards) {
|
|
338
379
|
if (c.runSimplify !== false) {
|
|
339
|
-
findThunks.push(() =>
|
|
380
|
+
findThunks.push(() => reviewSafe('simplify', c, simplifyPrompt(c), { label: `simplify:${c.cardId}`, phase: 'Discovery', schema: FINDINGS_SCHEMA }))
|
|
340
381
|
}
|
|
341
382
|
if (c.hasSecurityFiles === true) {
|
|
342
|
-
findThunks.push(() =>
|
|
383
|
+
findThunks.push(() => reviewSafe('security', c, securityPrompt(c), { label: `security:${c.cardId}`, phase: 'Discovery', agentType: 'security-reviewer', schema: FINDINGS_SCHEMA }))
|
|
343
384
|
}
|
|
344
385
|
}
|
|
345
386
|
if (codexResolved) {
|
|
@@ -348,10 +389,10 @@ if (codexResolved) {
|
|
|
348
389
|
// can't depend on the tier. The relay now returns RAW command outputs and the JS fan-in owns the
|
|
349
390
|
// success decision (+ a one-shot deterministic re-extract), so a downgraded relay can't drop a
|
|
350
391
|
// finding. We still REQUEST sonnet because when capacity allows it follows steps more reliably.
|
|
351
|
-
findThunks.push(() =>
|
|
392
|
+
findThunks.push(() => reviewSafe('codex', null, codexPrompt, { label: 'codex', phase: 'Discovery', model: 'sonnet', schema: CODEX_SCHEMA }))
|
|
352
393
|
}
|
|
353
394
|
if (maxQaTier === 'full') {
|
|
354
|
-
findThunks.push(() =>
|
|
395
|
+
findThunks.push(() => reviewSafe('qa', null, qaPrompt, { label: 'qa-sentinel', phase: 'Discovery', agentType: 'qa-sentinel', schema: GATES_SCHEMA }))
|
|
355
396
|
} else {
|
|
356
397
|
log('Discovery: qa-sentinel SKIPPED (wave max tier ≤ light — full suite deferred to the Final FULL gate).')
|
|
357
398
|
}
|
|
@@ -390,7 +431,16 @@ for (const item of findResults) {
|
|
|
390
431
|
log(`Discovery: Codex relay returned no parseable JSON (marker=${item.r && item.r.marker})${codexProse ? ' — salvaging its prose as fallback leads' : ''} — falling back to code-reviewer.`)
|
|
391
432
|
}
|
|
392
433
|
} else if (item.kind === 'qa') {
|
|
393
|
-
|
|
434
|
+
if (item.r && Array.isArray(item.r.gates)) {
|
|
435
|
+
gateTable = item.r.gates
|
|
436
|
+
} else {
|
|
437
|
+
// qa-sentinel DIED (null after retries — the 529 case). The full-tier mechanical gate is
|
|
438
|
+
// UNKNOWN. NEVER pass blind: inject a synthetic FAIL so summary.failingGates + the skill's
|
|
439
|
+
// gateTable-OR gate treat it as non-PASS (blocks the wave boundary). An empty [] would
|
|
440
|
+
// silently read as "no gates ran ⇒ nothing to block" — the bug.
|
|
441
|
+
gateTable = [{ gate: 'mechanical-gates (qa-sentinel)', status: 'FAIL', detail: 'qa-sentinel agent died (transient/529 exhausted or terminal API error) — full-tier mechanical gates UNKNOWN, treated as non-PASS. Re-run the review (or re-run lint/tsc/test/build manually) before merging the wave.' }]
|
|
442
|
+
log('Discovery: qa-sentinel DIED — injected a synthetic FAIL mechanical gate (wave-blocking, non-silent).')
|
|
443
|
+
}
|
|
394
444
|
} else if (item.r && Array.isArray(item.r.findings)) {
|
|
395
445
|
// simplify / security own their lane and FP-check their OWN findings → already validated.
|
|
396
446
|
raw.push(...item.r.findings.map((f) => ({ ...f, source: item.kind, preValidated: true })))
|
|
@@ -583,12 +633,15 @@ const fixPhaseTouchedCode = appliedIds.size > 0
|
|
|
583
633
|
if (maxQaTier !== 'full' && fixPhaseTouchedCode) {
|
|
584
634
|
const scopedQaPrompt = qaPrompt +
|
|
585
635
|
`\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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
636
|
+
let scopedQa = null
|
|
637
|
+
try { scopedQa = await agentSafe(scopedQaPrompt, { label: 'qa-sentinel (light-tier post-fix)', phase: 'Fix', agentType: 'qa-sentinel', schema: GATES_SCHEMA }) } catch (e) { scopedQa = null }
|
|
638
|
+
const scopedGates = (scopedQa && Array.isArray(scopedQa.gates)) ? scopedQa.gates
|
|
639
|
+
// qa-sentinel DIED after a code fix actually touched code → the post-fix regression gate is
|
|
640
|
+
// UNKNOWN. NEVER pass blind: a synthetic FAIL so the skill's gateTable-OR catches it.
|
|
641
|
+
: [{ gate: 'mechanical-gates (qa-sentinel, light-tier post-fix)', status: 'FAIL', detail: 'diff-scoped qa-sentinel died (transient/529 exhausted or terminal API error) after code fixes were applied — post-fix regression UNKNOWN, treated as non-PASS.' }]
|
|
642
|
+
gateTable = [...gateTable, ...scopedGates]
|
|
643
|
+
if (!(scopedQa && Array.isArray(scopedQa.gates))) degradedReviewers.push({ reviewer: 'qa-sentinel (light-tier post-fix)', reason: 'died-synthetic-fail' })
|
|
644
|
+
log(`Fix: light-tier diff-scoped qa-sentinel ${scopedQa && Array.isArray(scopedQa.gates) ? 'ran' : 'DIED (synthetic FAIL injected)'} after applying code fixes — ${scopedGates.filter((g) => g.status === 'FAIL').length} failing gate(s) merged into gateTable.`)
|
|
592
645
|
}
|
|
593
646
|
|
|
594
647
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -681,8 +734,9 @@ const summary = makeSummary({
|
|
|
681
734
|
failingGates: gateTable.filter((g) => g.status === 'FAIL').map((g) => g.gate), // qa-sentinel FAIL gates (test/build/audit/markdownlint)
|
|
682
735
|
blockers: surviving.filter((f) => f.classification === 'VERIFIED' && f.severity === 'BLOCKER').length,
|
|
683
736
|
highs: surviving.filter((f) => f.classification === 'VERIFIED' && f.severity === 'HIGH').length,
|
|
737
|
+
degradedReviewers, // reviewers that died after retries (transient/529 or terminal) — coverage was incomplete; NEVER a silent drop.
|
|
684
738
|
})
|
|
685
|
-
log(`Wave review done: ${summary.fixesApplied} fixed, ${summary.docApplied} doc-applied, ${summary.codeResidual} code-residual, ${summary.docResidual} doc-residual, ${summary.noAction} no-action (recorded), ${summary.needsManual} needs-manual, ${summary.failingGates.length} failing gate(s)${checksFailed ? ', post-fix checks FAILED' : ''}. Engine: ${codexEngine}.`)
|
|
739
|
+
log(`Wave review done: ${summary.fixesApplied} fixed, ${summary.docApplied} doc-applied, ${summary.codeResidual} code-residual, ${summary.docResidual} doc-residual, ${summary.noAction} no-action (recorded), ${summary.needsManual} needs-manual, ${summary.failingGates.length} failing gate(s)${degradedReviewers.length ? `, ${degradedReviewers.length} DEGRADED reviewer(s): ${degradedReviewers.map((d) => d.reviewer).join(', ')}` : ''}${checksFailed ? ', post-fix checks FAILED' : ''}. Engine: ${codexEngine}.`)
|
|
686
740
|
|
|
687
741
|
return { codexEngine, perCard, gateTable, summary }
|
|
688
742
|
|
|
@@ -690,7 +744,7 @@ return { codexEngine, perCard, gateTable, summary }
|
|
|
690
744
|
function asArr(x) { return Array.isArray(x) ? x.filter(Boolean) : [] }
|
|
691
745
|
function dedupe(xs) { return Array.from(new Set(asArr(xs))) }
|
|
692
746
|
function makeSummary(o) {
|
|
693
|
-
return Object.assign({ cards: 0, totalFindings: 0, verified: 0, noAction: 0, falsePositive: 0, needsManual: 0, fixesApplied: 0, docApplied: 0, docResidual: 0, codeResidual: 0, qaRan: false, checksFailed: false, failingGates: [], blockers: 0, highs: 0 }, o || {})
|
|
747
|
+
return Object.assign({ cards: 0, totalFindings: 0, verified: 0, noAction: 0, falsePositive: 0, needsManual: 0, fixesApplied: 0, docApplied: 0, docResidual: 0, codeResidual: 0, qaRan: false, checksFailed: false, failingGates: [], blockers: 0, highs: 0, degradedReviewers: [] }, o || {})
|
|
694
748
|
}
|
|
695
749
|
function slimFinding(f) {
|
|
696
750
|
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, card: f.card }
|
|
@@ -72,6 +72,51 @@ async function parallelCapped(thunks, cap) {
|
|
|
72
72
|
return results
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// ---- Transient-aware spawn (SSOT parity with new2.js `agentSafe`, v4.66.0) ---
|
|
76
|
+
// A reviewer killed by a SERVER-SIDE API error (529 Overloaded, 429/5xx, rate-limit —
|
|
77
|
+
// the org-level limit is SHARED across every terminal/session) must NOT vanish from the
|
|
78
|
+
// batch review. Two failure shapes exist and BOTH are handled: (1) `agent()` THROWS a
|
|
79
|
+
// transient error → retried in-workflow up to `cap`; (2) `agent()` RETURNS null (terminal
|
|
80
|
+
// API death after the tool's own retries, or a user skip) → recorded as degraded. There is
|
|
81
|
+
// NO reliable sleep/jitter in the workflow runtime, so this is NOT timed backoff — it
|
|
82
|
+
// absorbs brief blips; a sustained outage exhausts the cap and surfaces as DEGRADED COVERAGE
|
|
83
|
+
// (and, for the qa-sentinel merge gate, a synthetic FAIL), NEVER a silent drop. Mirrors the
|
|
84
|
+
// transient-vs-genuine discipline in references/team-mode.md § empty-result classification.
|
|
85
|
+
const TRANSIENT = /overload|529|429|rate.?limit|5\d\d|econn|etimedout|timeout|temporar|unavailable/i
|
|
86
|
+
const isTransient = (e) => TRANSIENT.test(String((e && e.message) || e || ''))
|
|
87
|
+
async function agentSafe(prompt, opts, maxAttempts) {
|
|
88
|
+
const cap = maxAttempts || 3
|
|
89
|
+
let lastErr = null
|
|
90
|
+
for (let i = 0; i < cap; i++) {
|
|
91
|
+
try {
|
|
92
|
+
const o = i === 0 ? opts : Object.assign({}, opts, { label: `${(opts && opts.label) || 'agent'}~retry${i}` })
|
|
93
|
+
return await agent(prompt, o)
|
|
94
|
+
} catch (e) { lastErr = e; if (!isTransient(e)) throw e } // permanent error → surface
|
|
95
|
+
}
|
|
96
|
+
const err = new Error('transient-exhausted: ' + String((lastErr && lastErr.message) || lastErr))
|
|
97
|
+
err.transientExhausted = true
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
// Degraded-coverage ledger — a reviewer that died (transient-exhausted OR null terminal
|
|
101
|
+
// return) is RECORDED here and surfaced in the return summary, so the skill's F.5 knows the
|
|
102
|
+
// batch review ran with incomplete coverage instead of mistaking it for "clean".
|
|
103
|
+
const degradedReviewers = []
|
|
104
|
+
function reviewSafe(kind, prompt, opts) {
|
|
105
|
+
return agentSafe(prompt, opts)
|
|
106
|
+
.then((r) => {
|
|
107
|
+
if (r == null) {
|
|
108
|
+
degradedReviewers.push({ reviewer: (opts && opts.label) || kind, reason: 'null-return' })
|
|
109
|
+
log(`Review: ${(opts && opts.label) || kind} returned null (terminal API error after tool retries / skip) — coverage degraded, NOT silently dropped.`)
|
|
110
|
+
}
|
|
111
|
+
return { kind, r }
|
|
112
|
+
})
|
|
113
|
+
.catch((e) => {
|
|
114
|
+
degradedReviewers.push({ reviewer: (opts && opts.label) || kind, reason: e && e.transientExhausted ? 'transient-exhausted' : 'error' })
|
|
115
|
+
log(`Review: ${(opts && opts.label) || kind} FAILED after retries (${e && e.transientExhausted ? 'transient/529 exhausted' : 'terminal'}) — coverage degraded, NOT silently dropped.`)
|
|
116
|
+
return { kind, r: null }
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
75
120
|
if (!scope.length) {
|
|
76
121
|
log('new-final-review: empty review scope — nothing to review.')
|
|
77
122
|
return { codexEngine: 'none', findings: [], gateTable: [], summary: emptySummary() }
|
|
@@ -325,10 +370,10 @@ const slimDoc = a.slimDoc !== undefined ? a.slimDoc === true : a.singleCard ===
|
|
|
325
370
|
const slimApi = a.slimApi !== undefined ? a.slimApi === true : a.singleCard === true
|
|
326
371
|
// qa-sentinel always runs — the merge integrity gate reads its PASS/FAIL table.
|
|
327
372
|
const reviewThunks = [
|
|
328
|
-
() =>
|
|
373
|
+
() => reviewSafe('qa', qaPrompt, { label: 'qa-sentinel', phase: 'Review', agentType: 'qa-sentinel', schema: GATES_SCHEMA }),
|
|
329
374
|
]
|
|
330
375
|
if (!slimDoc) {
|
|
331
|
-
reviewThunks.unshift(() =>
|
|
376
|
+
reviewThunks.unshift(() => reviewSafe('doc', docPrompt, { label: 'doc-reviewer', phase: 'Review', agentType: 'doc-reviewer', schema: FINDINGS_SCHEMA }))
|
|
332
377
|
} else {
|
|
333
378
|
log('Review: single-card batch — final doc-reviewer skipped (already ran per-card); kept cross-model Codex + qa gates.')
|
|
334
379
|
}
|
|
@@ -338,11 +383,11 @@ if (codexResolved) {
|
|
|
338
383
|
// so correctness can't depend on the tier. The relay returns RAW outputs and the JS fan-in owns the
|
|
339
384
|
// success decision (+ a one-shot deterministic re-extract), so a downgraded relay can't drop a finding.
|
|
340
385
|
// We still REQUEST sonnet: this is the last cross-model gate before merge and a dropped find is final.
|
|
341
|
-
reviewThunks.unshift(() =>
|
|
386
|
+
reviewThunks.unshift(() => reviewSafe('codex', codexPrompt, { label: 'codex', phase: 'Review', model: 'sonnet', schema: CODEX_SCHEMA }))
|
|
342
387
|
}
|
|
343
388
|
// api-perf-cost-auditor: skipped when no API/data files OR when it already ran per-card (slimApi).
|
|
344
389
|
if (!slimApi && a.hasApiDataFiles !== false) {
|
|
345
|
-
reviewThunks.push(() =>
|
|
390
|
+
reviewThunks.push(() => reviewSafe('api', apiPrompt, { label: 'api-perf-cost-auditor', phase: 'Review', agentType: 'api-perf-cost-auditor', schema: FINDINGS_SCHEMA }))
|
|
346
391
|
} else {
|
|
347
392
|
log(slimApi
|
|
348
393
|
? 'Review: single-card batch — final api-perf-cost-auditor skipped (already ran per-card).'
|
|
@@ -383,7 +428,17 @@ for (const item of reviewResults) {
|
|
|
383
428
|
log(`Review: Codex relay returned no parseable JSON (marker=${item.r && item.r.marker})${codexProse ? ' — salvaging its prose as fallback leads' : ''} — falling back to code-reviewer.`)
|
|
384
429
|
}
|
|
385
430
|
} else if (item.kind === 'qa') {
|
|
386
|
-
|
|
431
|
+
if (item.r && Array.isArray(item.r.gates)) {
|
|
432
|
+
gateTable = item.r.gates
|
|
433
|
+
} else {
|
|
434
|
+
// qa-sentinel DIED (null after retries — the screenshotted 529 case). The mechanical
|
|
435
|
+
// merge gate (lint/tsc/test/build/audit/i18n) is UNKNOWN. NEVER pass blind: inject a
|
|
436
|
+
// synthetic FAIL so summary.failingGates + the skill's F.5 gate treat it as non-PASS
|
|
437
|
+
// (merge-blocking), and the O5(b) build-claim demotion below is suppressed (buildGateFail).
|
|
438
|
+
// An empty [] here would silently read as "no gates ran ⇒ nothing to block" — the bug.
|
|
439
|
+
gateTable = [{ gate: 'mechanical-gates (qa-sentinel)', status: 'FAIL', detail: 'qa-sentinel agent died (transient/529 exhausted or terminal API error) — mechanical gates UNKNOWN, treated as non-PASS to block the merge. Re-run the final review (or re-run lint/tsc/test/build manually) before merging.' }]
|
|
440
|
+
log('Final review: qa-sentinel DIED — injected a synthetic FAIL mechanical gate (merge-blocking, non-silent).')
|
|
441
|
+
}
|
|
387
442
|
} else if (item.r && Array.isArray(item.r.findings)) {
|
|
388
443
|
// doc-reviewer / api-perf-cost-auditor own their domain and FP-check their OWN findings in
|
|
389
444
|
// the finding pass (their prompts mandate it) → already validated, no cross-domain re-judge.
|
|
@@ -526,8 +581,9 @@ const summary = {
|
|
|
526
581
|
blockers: classified.filter((f) => f.classification === 'VERIFIED' && f.severity === 'BLOCKER').length,
|
|
527
582
|
highs: classified.filter((f) => f.classification === 'VERIFIED' && f.severity === 'HIGH').length,
|
|
528
583
|
failingGates: gateTable.filter((g) => g.status === 'FAIL').map((g) => g.gate),
|
|
584
|
+
degradedReviewers, // reviewers that died after retries (transient/529 or terminal) — coverage was incomplete; NEVER a silent drop.
|
|
529
585
|
}
|
|
530
|
-
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}.`)
|
|
586
|
+
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)${degradedReviewers.length ? `, ${degradedReviewers.length} DEGRADED reviewer(s): ${degradedReviewers.map((d) => d.reviewer).join(', ')}` : ''}. Engine: ${codexEngine}.`)
|
|
531
587
|
|
|
532
588
|
// ───────────────────────────────────────────────────────────────────────────
|
|
533
589
|
// Phase Fix (P0-A, v4.59.0) — OPT-IN. Mirrors new-card-review.js's Fix+Doc phases EXACTLY:
|
|
@@ -670,7 +726,7 @@ const base = { codexEngine, findings, noActionFindings, gateTable, summary }
|
|
|
670
726
|
return applyFixes ? { ...base, fixesApplied, docFixesApplied, residual } : base
|
|
671
727
|
|
|
672
728
|
function emptySummary() {
|
|
673
|
-
return { total: 0, verified: 0, falsePositive: 0, needsManual: 0, blockers: 0, highs: 0, failingGates: [] }
|
|
729
|
+
return { total: 0, verified: 0, falsePositive: 0, needsManual: 0, blockers: 0, highs: 0, failingGates: [], degradedReviewers: [] }
|
|
674
730
|
}
|
|
675
731
|
function slimFinding(f) {
|
|
676
732
|
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 }
|