baldart 4.65.0 → 4.66.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,35 @@ 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.1] - 2026-06-23
9
+
10
+ **The component-manifest HEAD is now serialized DETERMINISTICALLY — fixing invalid-YAML HEADs found on a real `/design-system-init` upgrade.** A real upgrade run produced 74 per-component specs but **73/74 frontmatter HEADs failed to parse as YAML** — the v4.65.0 skill let the model hand-write the HEAD, so it emitted `props:{}` / `variants:[]` (no space after the colon) and unquoted/multiline `purpose`/`a11y` strings (embedded `:`, quotes, newlines). The HEADs were unparseable → "frontmatter-first" discovery silently fell back → the machine-readable benefit was lost. Same run also wired the DTCG token output to **overwrite a hand-authored `tokens.ts`** (semantic exports `uiTokens`/`categoryPalette` replaced by a generic `export const tokens` — would break every import). This release closes the model-in-the-loop-for-a-deterministic-artifact gap (the recurring lesson behind `setup-worktree.sh` / `merge-worktree.sh`).
11
+
12
+ **PATCH** — bugfix to the v4.65.0 layer; no new config key, no install change. Schema-change propagation rule does not apply.
13
+
14
+ ### Fixed
15
+
16
+ - **Deterministic HEAD serializer** — new `framework/.claude/skills/design-system-init/scripts/serialize-spec.mjs` (pure Node, zero-dep): the model supplies field **data** (deterministic JSON ∪ agentic JSON), the script emits valid YAML (every string scalar via `JSON.stringify` = a valid YAML double-quoted scalar; empty collections `{}`/`[]`; block lists; flow-map props) **and the thin INDEX router**, then **self-validates every HEAD and fails closed** (never writes an unparseable HEAD). Proven on the exact DrilldownHeader content that previously failed (backticks, colons, quotes, newline in `purpose`) → parses clean. `/design-system-init` now routes all spec writes through it and **salvages** agentic values from existing malformed HEADs before re-serializing; the spec template marks the HEAD serializer-owned.
17
+ - **Token-output clobber guard (ENFORCED)** — `/design-system-init` must run an **export-preservation check** before pointing `design_tokens.outputs` at an existing token file: the output path may be a hand-authored file only if every existing `export` is reproduced; otherwise it writes to a NEW path and leaves the hand-authored SSOT untouched. The deferred "don't clobber in place" guard from v4.65.0 is now mandatory, not advisory. The config note must state the real direction (`.tokens.json` → output), never the reverse.
18
+ - **Invalid-HEAD detection (safety net)** — `baldart doctor` now parse-checks a sample of spec HEADs (not just "is there a `---`") and the `ds-manifest-nudge` reports `N/M sampled HEADs invalid YAML → re-run /design-system-init`; the `ds-drift` routine + `code-reviewer`/`doc-reviewer`/`component-manifest-schema.md` treat an unparseable HEAD as `DS_COMPONENT_STALE` drift. This is the check that would have caught the bug at ship time.
19
+
20
+ ## [4.66.0] - 2026-06-23
21
+
22
+ **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.
23
+
24
+ **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).
25
+
26
+ ### Fixed
27
+
28
+ - **`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.
29
+ - **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.
30
+ - **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.
31
+
32
+ ### Changed
33
+
34
+ - **`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**.
35
+ - **`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).
36
+
8
37
  ## [4.65.0] - 2026-06-23
9
38
 
10
39
  **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.65.0
1
+ 4.66.1
@@ -85,27 +85,30 @@ precedence caveats: `framework/agents/effort-protocol.md`.
85
85
  imports), `category` (import-fan-in heuristic). **This half runs with NO
86
86
  subagent** — it is the Codex-portable path. Empty results on a non-TS stack are
87
87
  acceptable (advisory), never a failure.
88
- 4. **Enrich — AGENTIC.** Launch `doc-reviewer` to fill the agentic HEAD fields
89
- (`purpose`, `a11y`, `token_bindings`, `related`, `must_rules`) — in UPGRADE
90
- mode by lifting them from the existing prose body; in GREENFIELD by reading the
91
- source. Never clobber a filled agentic field on re-run. Under Codex (no
92
- subagents) this degrades to leaving those fields empty + a TODO note.
93
- 5. **Bootstrap the DTCG token SSOT.** See § Token SSOT bootstrap below: derive
94
- `.tokens.json` from the existing token source, write `paths.design_tokens`,
95
- configure `design_tokens.outputs`, run `baldart tokens build`, and
96
- **round-trip verify** (regenerate, diff vs original; surface a non-empty
97
- semantic diff, never auto-accept).
98
- 6. **Write the files** under the target path:
99
- - `INDEX.md` — **thin generated router**: one row per primitive
100
- (`name | source | one-line purpose | spec link`). No Authority Matrix here
101
- (it lives in `design-system-protocol.md`). Regenerated from the specs; never
102
- hand-edited.
88
+ 4. **Enrich — AGENTIC, AS DATA (never hand-write YAML).** Launch `doc-reviewer`
89
+ to produce the agentic fields (`purpose`, `a11y`, `token_bindings`, `related`,
90
+ `must_rules`) **as JSON values**, NOT as text typed into a `.md`. In UPGRADE
91
+ mode, lift them from each spec's existing prose / **salvage** them from the
92
+ current HEAD even if it is malformed. Never clobber a filled agentic field on
93
+ re-run. Under Codex (no subagents) this degrades to empty fields + a TODO note.
94
+ 5. **Bootstrap the DTCG token SSOT.** See § Token SSOT bootstrap below.
95
+ 6. **Write the files DETERMINISTICALLY (the HEAD is NEVER model-authored).**
96
+ Merge the deterministic JSON (step 3) with the agentic JSON (step 4) per
97
+ component into a bundle and run the serializer:
98
+ ```
99
+ node .framework/framework/.claude/skills/design-system-init/scripts/serialize-spec.mjs \
100
+ --bundle <bundle.json> --out-dir ${paths.design_system}/components \
101
+ --index ${paths.design_system}/INDEX.md --brand "${identity.brand_name}"
102
+ ```
103
+ It emits every `components/<Name>.md` (valid-YAML HEAD via deterministic
104
+ serialization + preserved prose) **and** the thin INDEX router, then
105
+ **self-validates** every HEAD and **exits non-zero if any fails to parse**
106
+ (fail-closed — never leave an unparseable HEAD). This is the fix for the
107
+ model-hand-written-YAML bug class (`props:{}` without a space, unquoted/multiline
108
+ `purpose`/`a11y`): the model supplies field *data*, the script owns the YAML.
109
+ Do NOT write the HEAD by hand or via the prose template.
103
110
  - `tokens-reference.md` — **generated** human view of `.tokens.json` (or pure
104
111
  narrative). Not a third source of values.
105
- - `components/<Name>.md` — one per primitive from
106
- `scripts/component-spec.template.md`: the frontmatter HEAD (deterministic +
107
- agentic) + prose body. In UPGRADE mode, prepend/refresh the HEAD on existing
108
- specs in place, preserving the prose.
109
112
  - `patterns/.gitkeep` — empty dir for future pattern docs.
110
113
  7. **Update config.** Set `features.has_design_system: true`,
111
114
  `paths.design_system: <target>`, and (when tokens bootstrapped)
@@ -136,9 +139,11 @@ precedence caveats: `framework/agents/effort-protocol.md`.
136
139
  4. **Token SSOT bootstrap** — § Token SSOT bootstrap (derive `.tokens.json`,
137
140
  set outputs, `baldart tokens build`, round-trip verify).
138
141
 
139
- 5. **Write files** — per-component HEAD+prose from
140
- `scripts/component-spec.template.md`; thin INDEX router; generated
141
- tokens-reference; `patterns/.gitkeep`. UPGRADE refreshes HEADs in place.
142
+ 5. **Write files DETERMINISTICALLY** — merge deterministic+agentic field data per
143
+ component and run `scripts/serialize-spec.mjs` (emits valid-YAML HEADs +
144
+ thin INDEX router, self-validates, fails closed). Never hand-write the HEAD.
145
+ Then the generated tokens-reference + `patterns/.gitkeep`. UPGRADE salvages
146
+ agentic values from existing (even malformed) HEADs before re-serializing.
142
147
 
143
148
  6. **Persist config** — `features.has_design_system: true`, `paths.design_system`,
144
149
  and (if tokens bootstrapped) `paths.design_tokens` + `design_tokens.outputs`.
@@ -168,20 +173,27 @@ Tailwind theme) but no DTCG source, bootstrap the SSOT:
168
173
  `{color.gray.200}`?) to `doc-reviewer` for `$type` assignment + alias
169
174
  reconstruction. Write it to `paths.design_tokens` (default
170
175
  `${paths.design_system}/tokens.tokens.json` or `src/ui/tokens.tokens.json`).
171
- 2. **Configure outputs.** Set `design_tokens.outputs` to regenerate the EXISTING
172
- token file in place (e.g. `{ format: ts, path: src/ui/tokens.ts }`) so there is
173
- exactly one token artifact and it becomes generated.
176
+ 2. **Configure outputs NEVER clobber a hand-authored token file (ENFORCED).**
177
+ Before pointing `design_tokens.outputs` at an existing token file, run the
178
+ **export-preservation check**: generate to a TEMP path and compare the set of
179
+ `export`ed names (and value count) against the current file. The output path
180
+ may be the existing file **only if** every existing export is reproduced
181
+ (byte/format diff allowed; missing/renamed exports NOT). Otherwise — the common
182
+ case for a hand-authored token file that exports several named, semantically
183
+ structured objects (which the generic `export const tokens` shape does NOT
184
+ reproduce) — write to a **NEW path** (e.g. `tokens.generated.ts`) and leave the
185
+ hand-authored file untouched. The user flips the import site when ready.
186
+ **A mismatched in-place output is the footgun this guard exists to prevent**:
187
+ `baldart tokens build` (and the `doctor`/`ds-drift` backfills) would otherwise
188
+ silently overwrite the hand-authored SSOT and break every import.
174
189
  3. **Build + round-trip verify.** Run `baldart tokens build`, then diff the
175
190
  regenerated output against the original. **A non-empty semantic diff (values
176
- changed/dropped) is surfaced to the user, never auto-accepted** — it means the
177
- `.tokens.json` derivation lost something. Only a formatting-only diff is safe.
178
- 4. **Mark generated.** The output carries `// baldart-generated from .tokens.json`
179
- from now on, edit `.tokens.json`, never the output (the DS_TOKENS_DRIFT gate
180
- enforces this).
181
-
182
- **Do NOT replace a hand-authored token file in place on the first run** unless the
183
- round-trip is clean — prefer generating to a new path and let the user flip the
184
- import site once they trust it (the in-place replacement is the deferred step).
191
+ changed/dropped, or exports missing) is surfaced and never auto-accepted.**
192
+ 4. **Mark generated + record direction honestly.** The output carries
193
+ `// baldart-generated from .tokens.json`. The config note MUST state the real
194
+ direction (`.tokens.json` output via `baldart tokens build`), never the
195
+ reverse — a note that says "edit tokens.ts, regenerate .tokens.json" contradicts
196
+ the pipeline and misleads the user. From now on edit `.tokens.json` only.
185
197
 
186
198
  ## INDEX.md template (thin generated router)
187
199
 
@@ -244,7 +256,8 @@ hardcode literals that duplicate these tokens.
244
256
  - `framework/agents/project-context.md` — config + overlay contract.
245
257
  - `framework/.claude/skills/ui-design/SKILL.md` — primary consumer (reads
246
258
  the registry BLOCKING).
247
- - `scripts/component-spec.template.md` — per-component spec template (HEAD + prose).
259
+ - `scripts/component-spec.template.md` — prose-body reference (HEAD is serializer-owned).
248
260
  - `scripts/extract-manifest.mjs` — deterministic, zero-dep HEAD extractor (Codex-portable).
261
+ - `scripts/serialize-spec.mjs` — deterministic HEAD serializer (valid-YAML, fail-closed) + INDEX router emitter.
249
262
  - `framework/agents/component-manifest-schema.md` — the frontmatter HEAD schema.
250
263
  - `framework/docs/COMPONENT-MANIFEST-LAYER.md` — layer design + lifecycle.
@@ -1,3 +1,8 @@
1
+ <!-- NOTE: the frontmatter HEAD below is EMITTED DETERMINISTICALLY by
2
+ scripts/serialize-spec.mjs (field data in, valid YAML out). Do NOT hand-write
3
+ or hand-fill it — invalid YAML (e.g. `props:{}` without a space, unquoted
4
+ multiline strings) is exactly the bug the serializer prevents. This template
5
+ documents the SHAPE + is the reference for the PROSE BODY below the fences. -->
1
6
  ---
2
7
  # Machine-readable HEAD — schema: framework/agents/component-manifest-schema.md
3
8
  # DETERMINISTIC fields (regenerated from source — do not hand-curate):
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // Deterministic component-spec serializer (since v4.66.1).
3
+ //
4
+ // Pure Node, ZERO dependencies. This is the fix for the class of bug where the
5
+ // frontmatter HEAD was hand-written (by a model) and came out as INVALID YAML
6
+ // (`props:{}` without a space, unquoted/multiline `purpose`/`a11y` strings, …),
7
+ // which defeated the whole machine-readable point. The HEAD MUST be emitted by
8
+ // THIS serializer, never typed by a model.
9
+ //
10
+ // Key trick for zero-dep validity: `JSON.stringify(s)` is always a valid YAML
11
+ // double-quoted scalar (YAML double-quoted strings are a superset of JSON string
12
+ // escaping), so every string scalar / list item is JSON.stringify'd. Empty
13
+ // collections use flow `{}` / `[]`; non-empty arrays use block lists; `props`
14
+ // uses a flow map per entry. The output is deterministic (source order).
15
+ //
16
+ // Usage:
17
+ // node serialize-spec.mjs --bundle bundle.json --out-dir <dir> [--index <path>] [--brand <name>]
18
+ // bundle.json = { brand?, components: [ { name, source, source_sha, status,
19
+ // category, props:{}, variants:[], composes:[], purpose, token_bindings:[],
20
+ // a11y, related:[], must_rules:[], last_verified, owner, prose } ] }
21
+ // Exits non-zero if any emitted HEAD fails to re-parse (when js-yaml is present).
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+
27
+ const require = createRequire(import.meta.url);
28
+
29
+ const SAFE = /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/; // plain-scalar-safe (no quoting needed)
30
+ const RESERVED = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off', '~']);
31
+
32
+ /** A YAML-safe scalar: plain when obviously safe, else a JSON double-quoted string. */
33
+ function scalar(v) {
34
+ if (v === null || v === undefined) return '""';
35
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
36
+ const s = String(v);
37
+ if (s !== '' && SAFE.test(s) && !RESERVED.has(s.toLowerCase()) && !/^\d{4}-\d{2}-\d{2}/.test(s)) return s;
38
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; // a bare date is fine
39
+ return JSON.stringify(s); // valid YAML double-quoted scalar (handles quotes/colons/newlines)
40
+ }
41
+
42
+ /** Block list (or flow [] when empty) for an array of string items. */
43
+ function list(key, arr, indent = '') {
44
+ if (!Array.isArray(arr) || arr.length === 0) return `${indent}${key}: []`;
45
+ const lines = arr.map((it) => `${indent} - ${scalar(it)}`);
46
+ return `${indent}${key}:\n${lines.join('\n')}`;
47
+ }
48
+
49
+ /** props: nested flow map per entry (or flow {} when empty). */
50
+ function propsBlock(props) {
51
+ const keys = props && typeof props === 'object' ? Object.keys(props) : [];
52
+ if (keys.length === 0) return 'props: {}';
53
+ const lines = keys.map((k) => {
54
+ const p = props[k] || {};
55
+ const parts = [`type: ${scalar(p.type ?? '')}`, `required: ${p.required ? 'true' : 'false'}`];
56
+ if (p.default !== undefined && p.default !== null && p.default !== '') parts.push(`default: ${scalar(p.default)}`);
57
+ // The map KEY (prop name) may be non-identifier (rare) — quote if needed.
58
+ const key = SAFE.test(k) ? k : JSON.stringify(k);
59
+ return ` ${key}: { ${parts.join(', ')} }`;
60
+ });
61
+ return `props:\n${lines.join('\n')}`;
62
+ }
63
+
64
+ /** Emit the full frontmatter HEAD body (between the --- fences) for one component. */
65
+ export function emitHead(c) {
66
+ const L = [];
67
+ L.push('# Machine-readable HEAD — schema: framework/agents/component-manifest-schema.md');
68
+ L.push('# DETERMINISTIC (regenerated from source — do not hand-edit):');
69
+ L.push(`name: ${scalar(c.name)}`);
70
+ L.push(`source: ${scalar(c.source)}`);
71
+ L.push(`source_sha: ${scalar(c.source_sha ?? '')}`);
72
+ L.push(`status: ${scalar(c.status || 'stable')}`);
73
+ L.push(`category: ${scalar(c.category || 'primitive')}`);
74
+ L.push(propsBlock(c.props));
75
+ L.push(list('variants', c.variants));
76
+ L.push(list('composes', c.composes));
77
+ L.push('# AGENTIC (curated by doc-reviewer — preserved across regeneration):');
78
+ L.push(`purpose: ${scalar(c.purpose ?? '')}`);
79
+ L.push(list('token_bindings', c.token_bindings));
80
+ L.push(`a11y: ${scalar(c.a11y ?? '')}`);
81
+ L.push(list('related', c.related));
82
+ L.push(list('must_rules', c.must_rules));
83
+ L.push(`last_verified: ${scalar(c.last_verified || TODAY)}`);
84
+ L.push(`owner: ${scalar(c.owner || 'doc-reviewer')}`);
85
+ return L.join('\n');
86
+ }
87
+
88
+ // Date is fine in a CLI script (this is not a resumable workflow).
89
+ const TODAY = new Date().toISOString().slice(0, 10);
90
+
91
+ /** Full spec file = HEAD fences + preserved prose body. */
92
+ export function emitSpec(c) {
93
+ const prose = (c.prose && c.prose.trim()) ? c.prose.replace(/^\s+/, '') : `# ${c.name}\n\n> Per-component spec. The HEAD above is the machine-readable contract.\n`;
94
+ return `---\n${emitHead(c)}\n---\n\n${prose.replace(/\s*$/, '')}\n`;
95
+ }
96
+
97
+ /** Thin INDEX router from the component set. */
98
+ export function emitIndex(components, brand = 'Project') {
99
+ const rows = components
100
+ .slice()
101
+ .sort((a, b) => a.name.localeCompare(b.name))
102
+ .map((c) => `| ${c.name} | ${c.source} | ${(c.purpose || '').replace(/\|/g, '\\|').slice(0, 80)} | [components/${c.name}.md](components/${c.name}.md) |`);
103
+ return [
104
+ '<!-- baldart-generated router — regenerated from components/*.md HEAD. Do not hand-edit. -->',
105
+ '---',
106
+ 'title: "UI Components Index"',
107
+ 'canonicality: canonical',
108
+ 'owner: doc-reviewer',
109
+ 'routing_scope: ui-components',
110
+ 'baldart_generated: true',
111
+ '---',
112
+ '',
113
+ `# ${brand} Design System — Component Router`,
114
+ '',
115
+ 'Discovery entry point. Read a row\'s spec HEAD (not the source) to reuse a primitive.',
116
+ 'Authority Matrix + token contract: framework/agents/design-system-protocol.md.',
117
+ '',
118
+ '| Component | Source | Purpose | Spec |',
119
+ '|-----------|--------|---------|------|',
120
+ ...rows,
121
+ '',
122
+ ].join('\n');
123
+ }
124
+
125
+ /** Optional strict re-parse (only when js-yaml is resolvable). */
126
+ function tryValidate(headBody) {
127
+ let yaml;
128
+ try { yaml = require('js-yaml'); } catch (_) { return { checked: false }; }
129
+ try { yaml.load(headBody); return { checked: true, ok: true }; }
130
+ catch (e) { return { checked: true, ok: false, error: e.message.split('\n')[0] }; }
131
+ }
132
+
133
+ function arg(name, def = '') {
134
+ const i = process.argv.indexOf(name);
135
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : def;
136
+ }
137
+
138
+ // CLI
139
+ if (import.meta.url === `file://${process.argv[1]}`) {
140
+ const bundlePath = arg('--bundle');
141
+ const outDir = arg('--out-dir');
142
+ const indexPath = arg('--index');
143
+ const brandArg = arg('--brand');
144
+ if (!bundlePath || !outDir) {
145
+ console.error('usage: serialize-spec.mjs --bundle bundle.json --out-dir <dir> [--index <path>] [--brand <name>]');
146
+ process.exit(2);
147
+ }
148
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
149
+ const components = bundle.components || [];
150
+ const brand = brandArg || bundle.brand || 'Project';
151
+ fs.mkdirSync(outDir, { recursive: true });
152
+ let invalid = 0;
153
+ for (const c of components) {
154
+ const spec = emitSpec(c);
155
+ fs.writeFileSync(path.join(outDir, `${c.name}.md`), spec, 'utf8');
156
+ const head = spec.match(/^---\n([\s\S]*?)\n---/)[1];
157
+ const v = tryValidate(head);
158
+ if (v.checked && !v.ok) { invalid++; console.error(`INVALID HEAD ${c.name}.md: ${v.error}`); }
159
+ }
160
+ if (indexPath) fs.writeFileSync(indexPath, emitIndex(components, brand), 'utf8');
161
+ const report = { schema: 'baldart.serialize-spec/1', written: components.length, index: !!indexPath, invalid };
162
+ console.log(JSON.stringify(report));
163
+ if (invalid > 0) process.exit(1); // fail-closed: never leave invalid HEADs
164
+ }
@@ -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. **Since `applyFixes: true` is passed (since v4.59.0),
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(() => agent(simplifyPrompt(c), { label: `simplify:${c.cardId}`, phase: 'Discovery', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'simplify', card: c, r })))
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(() => agent(securityPrompt(c), { label: `security:${c.cardId}`, phase: 'Discovery', agentType: 'security-reviewer', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'security', card: c, r })))
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(() => agent(codexPrompt, { label: 'codex', phase: 'Discovery', model: 'sonnet', schema: CODEX_SCHEMA }).then((r) => ({ kind: 'codex', card: null, r })))
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(() => agent(qaPrompt, { label: 'qa-sentinel', phase: 'Discovery', agentType: 'qa-sentinel', schema: GATES_SCHEMA }).then((r) => ({ kind: 'qa', card: null, r })))
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
- gateTable = (item.r && item.r.gates) || []
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
- const scopedQa = await agent(scopedQaPrompt, { label: 'qa-sentinel (light-tier post-fix)', phase: 'Fix', agentType: 'qa-sentinel', schema: GATES_SCHEMA })
587
- const scopedGates = (scopedQa && Array.isArray(scopedQa.gates)) ? scopedQa.gates : []
588
- if (scopedGates.length) {
589
- gateTable = [...gateTable, ...scopedGates]
590
- log(`Fix: light-tier diff-scoped qa-sentinel ran after applying code fixes ${scopedGates.filter((g) => g.status === 'FAIL').length} failing gate(s) merged into gateTable.`)
591
- }
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
- () => agent(qaPrompt, { label: 'qa-sentinel', phase: 'Review', agentType: 'qa-sentinel', schema: GATES_SCHEMA }).then((r) => ({ kind: 'qa', r })),
373
+ () => reviewSafe('qa', qaPrompt, { label: 'qa-sentinel', phase: 'Review', agentType: 'qa-sentinel', schema: GATES_SCHEMA }),
329
374
  ]
330
375
  if (!slimDoc) {
331
- reviewThunks.unshift(() => agent(docPrompt, { label: 'doc-reviewer', phase: 'Review', agentType: 'doc-reviewer', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'doc', r })))
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(() => agent(codexPrompt, { label: 'codex', phase: 'Review', model: 'sonnet', schema: CODEX_SCHEMA }).then((r) => ({ kind: 'codex', r })))
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(() => agent(apiPrompt, { label: 'api-perf-cost-auditor', phase: 'Review', agentType: 'api-perf-cost-auditor', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'api', r })))
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
- gateTable = (item.r && item.r.gates) || []
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 }
@@ -97,6 +97,24 @@ NO human-curated judgment (the Authority Matrix moved to
97
97
  `design-system-protocol.md`; per-component detail lives in each spec). Because it
98
98
  is generated, it is always safe to regenerate from the specs — never hand-edit it.
99
99
 
100
+ ## Serialization contract (HARD — the HEAD is never model-authored)
101
+
102
+ The frontmatter HEAD MUST be emitted by the deterministic serializer
103
+ (`framework/.claude/skills/design-system-init/scripts/serialize-spec.mjs`), which
104
+ takes the fields as **data** (deterministic JSON ∪ agentic JSON) and writes valid
105
+ YAML. A model (doc-reviewer / ui-expert) supplies the agentic field **values**,
106
+ never types YAML into the `.md`. Rationale: hand-written HEADs produced invalid
107
+ YAML at scale (`props:{}` missing the space after the colon; unquoted/multiline
108
+ `purpose`/`a11y` strings with embedded `:` / quotes / newlines), which made the
109
+ whole HEAD unparseable and defeated the machine-readable purpose.
110
+
111
+ **Validity invariant:** every HEAD MUST parse as YAML. The serializer self-checks
112
+ and fails closed (never writes an unparseable HEAD); `code-reviewer`, `ds-drift`,
113
+ and `baldart doctor` treat an unparseable HEAD as drift to repair (re-run
114
+ `/design-system-init`). When refreshing an existing spec whose HEAD is malformed,
115
+ **salvage** the agentic values leniently, then re-serialize — never strict-parse
116
+ and lose them.
117
+
100
118
  ## Regeneration contract
101
119
 
102
120
  - **Anchor**: `source_sha`. A regeneration run skips a component whose source is
@@ -17,13 +17,16 @@ prompt: |
17
17
  1. **INDEX coverage**: the `${paths.design_system}/INDEX.md` router must list
18
18
  every primitive that has a spec. Flag `DS_INDEX_DRIFT` for any mismatch
19
19
  (fix by regenerating the router, never hand-editing it).
20
- 2. **Per-component HEAD accuracy**: for each
21
- `${paths.design_system}/components/<Name>.md`, compare its frontmatter HEAD
22
- against the source`source_sha` vs the current git blob, `props`/`variants`
23
- vs the TS types (schema: `framework/agents/component-manifest-schema.md`).
24
- Flag `DS_COMPONENT_STALE` when `source_sha` is stale (or >7 days unverified
25
- with a source change). A prose-only spec with no HEAD is an upgrade candidate
26
- (recommend `/design-system-init`), NOT itself stale.
20
+ 2. **Per-component HEAD accuracy + validity**: for each
21
+ `${paths.design_system}/components/<Name>.md`, first verify the frontmatter
22
+ HEAD **parses as YAML** an unparseable HEAD (e.g. `props:{}` without a
23
+ space, unquoted multiline strings) is `DS_COMPONENT_STALE`; fix by re-running
24
+ `/design-system-init` (which re-serializes deterministically), never by hand.
25
+ Then compare the HEAD against the source `source_sha` vs the current git
26
+ blob, `props`/`variants` vs the TS types (schema:
27
+ `framework/agents/component-manifest-schema.md`). Flag `DS_COMPONENT_STALE`
28
+ when `source_sha` is stale (or >7 days unverified with a source change). A
29
+ prose-only spec with no HEAD is an upgrade candidate, NOT itself stale.
27
30
  3. **Animations reconciliation**: `${paths.design_system}/patterns/animations.md`
28
31
  must match keyframes in the project's global styles. Flag
29
32
  `DS_ANIMATIONS_DRIFT`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.65.0",
3
+ "version": "4.66.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"
@@ -501,14 +501,24 @@ async function detectState(cwd, opts = {}) {
501
501
  state.dsManifestNudge = true;
502
502
  state.dsManifestReason = 'no per-component specs found';
503
503
  } else {
504
- // Cheap sample: does ANY spec carry a frontmatter HEAD (starts with `---`)?
505
- const structured = specs.slice(0, 8).some((f) => {
506
- try { return fs.readFileSync(path.join(compDir, f), 'utf8').startsWith('---'); }
507
- catch (_) { return false; }
508
- });
509
- if (!structured) {
504
+ // Sample up to 8 specs. Two failure modes: (a) prose-only (no HEAD),
505
+ // (b) HEAD present but INVALID YAML (the model-hand-written bug class).
506
+ const sample = specs.slice(0, 8);
507
+ let withHead = 0, headParseFail = 0;
508
+ for (const f of sample) {
509
+ let t = '';
510
+ try { t = fs.readFileSync(path.join(compDir, f), 'utf8'); } catch (_) { continue; }
511
+ const m = t.match(/^---\n([\s\S]*?)\n---/);
512
+ if (!m) continue;
513
+ withHead++;
514
+ try { yaml.load(m[1]); } catch (_) { headParseFail++; }
515
+ }
516
+ if (withHead === 0) {
510
517
  state.dsManifestNudge = true;
511
518
  state.dsManifestReason = 'specs are prose-only (no machine-readable HEAD)';
519
+ } else if (headParseFail > 0) {
520
+ state.dsManifestNudge = true;
521
+ state.dsManifestReason = `${headParseFail}/${withHead} sampled spec HEADs are invalid YAML — re-run /design-system-init to re-serialize`;
512
522
  }
513
523
  }
514
524
  }