baldart 4.66.0 → 4.66.2

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,29 @@ 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.2] - 2026-06-23
9
+
10
+ **`token_bindings` are now validated against the real DTCG vocabulary — fixing dangling bindings found on a real upgrade.** After the v4.66.1 HEAD-serialization fix, a real `/design-system-init` upgrade still produced 26/586 `token_bindings` that didn't resolve in `.tokens.json` (`font.weight.medium` instead of `typography.fontWeight.medium`, `z.overlay` vs `zIndex.overlay`, `space.2` vs `space.sm`, …). Root cause: the agentic enricher (`doc-reviewer`) writes `token_bindings` as free text and guesses plausible-but-wrong names. A dangling binding id is `DS_TOKENS_DRIFT`. This closes the gap so it cannot be written, and instructs the enricher to pick from the actual token vocabulary.
11
+
12
+ **PATCH** — refines the v4.65.0/v4.66.1 layer; no config/install change.
13
+
14
+ ### Fixed
15
+
16
+ - **Deterministic binding vocabulary guard** — `serialize-spec.mjs` gains `--tokens <.tokens.json>`: it flattens the DTCG to its real id set and **drops + reports** any `token_bindings` id that does not resolve (the `droppedBindings` count + the dropped list on stderr) — so a dangling binding can never be serialized. Exposed as `tokenIds()` + `validateBindings()`. Proven: `z.overlay`/`font.weight.medium` dropped, `zIndex.overlay`/`typography.fontWeight.medium`/`color.surface` kept.
17
+ - **Prevention at the source** — `/design-system-init` step 4 + `doc-reviewer` (HEAD owner) + `component-manifest-schema.md` now require `token_bindings` to be chosen from the real `${paths.design_tokens}` id list (exact ids, never invented names). The serializer invocation passes `--tokens ${paths.design_tokens}` so the guard runs every generation.
18
+
19
+ ## [4.66.1] - 2026-06-23
20
+
21
+ **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`).
22
+
23
+ **PATCH** — bugfix to the v4.65.0 layer; no new config key, no install change. Schema-change propagation rule does not apply.
24
+
25
+ ### Fixed
26
+
27
+ - **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.
28
+ - **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.
29
+ - **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.
30
+
8
31
  ## [4.66.0] - 2026-06-23
9
32
 
10
33
  **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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.66.0
1
+ 4.66.2
@@ -67,7 +67,11 @@ documentation scope.
67
67
  `token_bindings`, `related`, `must_rules`, `last_verified`. You curate these —
68
68
  you do NOT hand-write the deterministic half (`name`/`source`/`source_sha`/
69
69
  `props`/`variants`/`composes`), which is regenerated from source by the extractor.
70
- Never clobber a filled agentic field on a refresh. (This is the design-system twin
70
+ Never clobber a filled agentic field on a refresh. **Pick `token_bindings` from
71
+ the REAL DTCG vocabulary** — read the id list from `${paths.design_tokens}` and
72
+ use exact ids (`typography.fontWeight.medium`, `zIndex.overlay`, `space.sm`),
73
+ never invented names (`font.weight.medium`, `z.overlay`, `space.2`); the
74
+ serializer drops unresolved ids as drift. (This is the design-system twin
71
75
  of your i18n-registry ownership: you curate context, tooling fills the mechanical part.)
72
76
 
73
77
  Standard drift checks against this corpus:
@@ -85,27 +85,43 @@ 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
+ **`token_bindings` MUST be chosen from the REAL token vocabulary** read the
95
+ id list from `${paths.design_tokens}` (the DTCG `.tokens.json`) and pick exact
96
+ ids (`typography.fontWeight.medium`, `zIndex.overlay`, `space.sm`), never
97
+ invent plausible-but-wrong names (`font.weight.medium`, `z.overlay`, `space.2`).
98
+ The serializer's `--tokens` guard (step 6) drops any unknown id, but picking
99
+ correctly here avoids losing real intent.
100
+ 5. **Bootstrap the DTCG token SSOT.** See § Token SSOT bootstrap below.
101
+ 6. **Write the files DETERMINISTICALLY (the HEAD is NEVER model-authored).**
102
+ Merge the deterministic JSON (step 3) with the agentic JSON (step 4) per
103
+ component into a bundle and run the serializer:
104
+ ```
105
+ node .framework/framework/.claude/skills/design-system-init/scripts/serialize-spec.mjs \
106
+ --bundle <bundle.json> --out-dir ${paths.design_system}/components \
107
+ --index ${paths.design_system}/INDEX.md --brand "${identity.brand_name}" \
108
+ --tokens ${paths.design_tokens}
109
+ ```
110
+ It emits every `components/<Name>.md` (valid-YAML HEAD via deterministic
111
+ serialization + preserved prose) **and** the thin INDEX router, then
112
+ **self-validates** every HEAD and **exits non-zero if any fails to parse**
113
+ (fail-closed — never leave an unparseable HEAD). This is the fix for the
114
+ model-hand-written-YAML bug class (`props:{}` without a space, unquoted/multiline
115
+ `purpose`/`a11y`): the model supplies field *data*, the script owns the YAML.
116
+ Do NOT write the HEAD by hand or via the prose template.
117
+ `--tokens` (when `paths.design_tokens` is set) activates the **binding
118
+ vocabulary guard**: any `token_bindings` id that does not resolve in the DTCG
119
+ `.tokens.json` is DROPPED and REPORTED (the `droppedBindings` count + the list
120
+ on stderr) — so a `DS_TOKENS_DRIFT` can never be written. Review the dropped
121
+ list: a drop means doc-reviewer guessed a name that isn't a real token (fix the
122
+ enrichment or add the token), per step 4.
103
123
  - `tokens-reference.md` — **generated** human view of `.tokens.json` (or pure
104
124
  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
125
  - `patterns/.gitkeep` — empty dir for future pattern docs.
110
126
  7. **Update config.** Set `features.has_design_system: true`,
111
127
  `paths.design_system: <target>`, and (when tokens bootstrapped)
@@ -136,9 +152,11 @@ precedence caveats: `framework/agents/effort-protocol.md`.
136
152
  4. **Token SSOT bootstrap** — § Token SSOT bootstrap (derive `.tokens.json`,
137
153
  set outputs, `baldart tokens build`, round-trip verify).
138
154
 
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.
155
+ 5. **Write files DETERMINISTICALLY** — merge deterministic+agentic field data per
156
+ component and run `scripts/serialize-spec.mjs` (emits valid-YAML HEADs +
157
+ thin INDEX router, self-validates, fails closed). Never hand-write the HEAD.
158
+ Then the generated tokens-reference + `patterns/.gitkeep`. UPGRADE salvages
159
+ agentic values from existing (even malformed) HEADs before re-serializing.
142
160
 
143
161
  6. **Persist config** — `features.has_design_system: true`, `paths.design_system`,
144
162
  and (if tokens bootstrapped) `paths.design_tokens` + `design_tokens.outputs`.
@@ -168,20 +186,27 @@ Tailwind theme) but no DTCG source, bootstrap the SSOT:
168
186
  `{color.gray.200}`?) to `doc-reviewer` for `$type` assignment + alias
169
187
  reconstruction. Write it to `paths.design_tokens` (default
170
188
  `${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.
189
+ 2. **Configure outputs NEVER clobber a hand-authored token file (ENFORCED).**
190
+ Before pointing `design_tokens.outputs` at an existing token file, run the
191
+ **export-preservation check**: generate to a TEMP path and compare the set of
192
+ `export`ed names (and value count) against the current file. The output path
193
+ may be the existing file **only if** every existing export is reproduced
194
+ (byte/format diff allowed; missing/renamed exports NOT). Otherwise — the common
195
+ case for a hand-authored token file that exports several named, semantically
196
+ structured objects (which the generic `export const tokens` shape does NOT
197
+ reproduce) — write to a **NEW path** (e.g. `tokens.generated.ts`) and leave the
198
+ hand-authored file untouched. The user flips the import site when ready.
199
+ **A mismatched in-place output is the footgun this guard exists to prevent**:
200
+ `baldart tokens build` (and the `doctor`/`ds-drift` backfills) would otherwise
201
+ silently overwrite the hand-authored SSOT and break every import.
174
202
  3. **Build + round-trip verify.** Run `baldart tokens build`, then diff the
175
203
  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).
204
+ changed/dropped, or exports missing) is surfaced and never auto-accepted.**
205
+ 4. **Mark generated + record direction honestly.** The output carries
206
+ `// baldart-generated from .tokens.json`. The config note MUST state the real
207
+ direction (`.tokens.json` output via `baldart tokens build`), never the
208
+ reverse — a note that says "edit tokens.ts, regenerate .tokens.json" contradicts
209
+ the pipeline and misleads the user. From now on edit `.tokens.json` only.
185
210
 
186
211
  ## INDEX.md template (thin generated router)
187
212
 
@@ -244,7 +269,8 @@ hardcode literals that duplicate these tokens.
244
269
  - `framework/agents/project-context.md` — config + overlay contract.
245
270
  - `framework/.claude/skills/ui-design/SKILL.md` — primary consumer (reads
246
271
  the registry BLOCKING).
247
- - `scripts/component-spec.template.md` — per-component spec template (HEAD + prose).
272
+ - `scripts/component-spec.template.md` — prose-body reference (HEAD is serializer-owned).
248
273
  - `scripts/extract-manifest.mjs` — deterministic, zero-dep HEAD extractor (Codex-portable).
274
+ - `scripts/serialize-spec.mjs` — deterministic HEAD serializer (valid-YAML, fail-closed) + INDEX router emitter.
249
275
  - `framework/agents/component-manifest-schema.md` — the frontmatter HEAD schema.
250
276
  - `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,216 @@
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
+ /** Flatten a DTCG `.tokens.json` tree to the set of dot-path token ids. */
139
+ export function tokenIds(dtcg) {
140
+ const ids = new Set();
141
+ (function walk(node, prefix) {
142
+ if (!node || typeof node !== 'object') return;
143
+ if (Object.prototype.hasOwnProperty.call(node, '$value')) { ids.add(prefix); return; }
144
+ for (const k of Object.keys(node)) {
145
+ if (k.startsWith('$')) continue;
146
+ walk(node[k], prefix ? `${prefix}.${k}` : k);
147
+ }
148
+ })(dtcg, '');
149
+ return ids;
150
+ }
151
+
152
+ /**
153
+ * Drop `token_bindings` ids that don't resolve in the DTCG vocabulary. The
154
+ * agentic enricher (doc-reviewer) writes bindings as free text and can guess
155
+ * names that don't exist (`font.*` vs `typography.*`, `z.*` vs `zIndex.*`); a
156
+ * dangling id is DS_TOKENS_DRIFT. This is the deterministic guard: keep only
157
+ * real ids, return what was dropped so it is SURFACED (never silently lost).
158
+ */
159
+ export function validateBindings(components, idSet) {
160
+ const dropped = [];
161
+ for (const c of components) {
162
+ const kept = [];
163
+ for (const id of c.token_bindings || []) {
164
+ if (idSet.has(id)) kept.push(id);
165
+ else dropped.push({ component: c.name, id });
166
+ }
167
+ c.token_bindings = [...new Set(kept)];
168
+ }
169
+ return dropped;
170
+ }
171
+
172
+ // CLI
173
+ if (import.meta.url === `file://${process.argv[1]}`) {
174
+ const bundlePath = arg('--bundle');
175
+ const outDir = arg('--out-dir');
176
+ const indexPath = arg('--index');
177
+ const brandArg = arg('--brand');
178
+ const tokensPath = arg('--tokens'); // optional DTCG .tokens.json → binding vocab guard
179
+ if (!bundlePath || !outDir) {
180
+ console.error('usage: serialize-spec.mjs --bundle bundle.json --out-dir <dir> [--index <path>] [--brand <name>] [--tokens <.tokens.json>]');
181
+ process.exit(2);
182
+ }
183
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
184
+ const components = bundle.components || [];
185
+ const brand = brandArg || bundle.brand || 'Project';
186
+
187
+ // Token-binding vocabulary guard (since v4.66.2): drop ids absent from the DTCG.
188
+ let dropped = [];
189
+ if (tokensPath) {
190
+ try {
191
+ const ids = tokenIds(JSON.parse(fs.readFileSync(tokensPath, 'utf8')));
192
+ dropped = validateBindings(components, ids);
193
+ if (dropped.length) {
194
+ console.error(`token_bindings: dropped ${dropped.length} id(s) not in ${path.basename(tokensPath)} (DS_TOKENS_DRIFT avoided):`);
195
+ for (const d of dropped.slice(0, 20)) console.error(` - ${d.component}: ${d.id}`);
196
+ if (dropped.length > 20) console.error(` … +${dropped.length - 20} more`);
197
+ }
198
+ } catch (e) {
199
+ console.error(`token vocab guard skipped: cannot read ${tokensPath} (${e.message.split('\n')[0]})`);
200
+ }
201
+ }
202
+
203
+ fs.mkdirSync(outDir, { recursive: true });
204
+ let invalid = 0;
205
+ for (const c of components) {
206
+ const spec = emitSpec(c);
207
+ fs.writeFileSync(path.join(outDir, `${c.name}.md`), spec, 'utf8');
208
+ const head = spec.match(/^---\n([\s\S]*?)\n---/)[1];
209
+ const v = tryValidate(head);
210
+ if (v.checked && !v.ok) { invalid++; console.error(`INVALID HEAD ${c.name}.md: ${v.error}`); }
211
+ }
212
+ if (indexPath) fs.writeFileSync(indexPath, emitIndex(components, brand), 'utf8');
213
+ const report = { schema: 'baldart.serialize-spec/1', written: components.length, index: !!indexPath, invalid, droppedBindings: dropped.length };
214
+ console.log(JSON.stringify(report));
215
+ if (invalid > 0) process.exit(1); // fail-closed: never leave invalid HEADs
216
+ }
@@ -97,6 +97,33 @@ 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
+
118
+ **Token-binding vocabulary invariant:** every `token_bindings` id MUST resolve to
119
+ a real node in the DTCG `.tokens.json` (`paths.design_tokens`). The agentic
120
+ enricher picks ids from that file's actual vocabulary — never invents
121
+ plausible-but-wrong names (`font.weight.medium`/`z.overlay`/`space.2` instead of
122
+ `typography.fontWeight.medium`/`zIndex.overlay`/`space.sm`). The serializer's
123
+ `--tokens` guard DROPS + REPORTS any unresolved id (so a `DS_TOKENS_DRIFT` is
124
+ never written); a dropped id means the enricher guessed wrong (fix it or add the
125
+ token).
126
+
100
127
  ## Regeneration contract
101
128
 
102
129
  - **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.66.0",
3
+ "version": "4.66.2",
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
  }