baldart 4.67.0 → 4.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,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.68.0] - 2026-06-24
9
+
10
+ **Closed-Set Selection Policy — the third semantic layer that makes the "third header" structurally impossible.** Found on real `mayo` PRDs: every PRD ignored the two existing canonical headers (`SectionHeader` depth-0, `DrilldownHeader` depth-1+) and wrote new ones, requiring an expensive consolidation epic (`FEAT-0045` + an ADR) and leaving `Header.tsx` deprecated-but-live in 3 call-sites. Root cause (confirmed by a BALDART trace + 40-source 2026 research): the registry has a token layer and a component layer, but **no layer asserts the *boundary* of a category** — so adding a third header violated no rule, only an undiscovered convention. The registry-first cascade was advisory at generation-time + reactive at review-time; nothing prevented the duplicate at build-time. The 2026 market confirms the gap: no vendor (Figma Code Connect, Storybook MCP, shadcn) hard-gates the reuse decision, and no deterministic tool detects a semantically-equivalent rewrite — but BALDART already ships the machine-readable manifest signature, so it is "one manifest-signature-diff away".
11
+
12
+ **MINOR** — adds a capability (the `baldart ds-gate` verb, three manifest fields, the `/prd` reconciliation gate); rides on `features.has_design_system` (NO new config key — the schema-change propagation rule does NOT apply), additive, no install change. Three slices.
13
+
14
+ ### Added
15
+
16
+ - **Selection-policy manifest fields (Slice A)** — `canonical_for` (`"<family>@<context>"` roles a component is THE canonical answer for), `use_when` (the selection predicate, lifted from `must_rules`), and `selection_closed` (the role family is a closed set) join the AGENTIC HEAD in `component-manifest-schema.md`. The serializer (`serialize-spec.mjs`) emits them + aggregates a generated **§ "Selection Policy"** `families:` block into `INDEX.md` (a family is `closed` iff ≥1 member declares it) — the closure lives in the HEADs, the INDEX stays a pure derived router.
17
+ - **`baldart ds-gate` (Slice A — the deterministic build-time gate)** — BALDART-owned, the design-system analogue of the i18n gate. Reads the **committed** `INDEX.md` § Selection Policy (baseline closure) + the diff and BLOCKS (exit 1) on `DS_CLOSED_SET_VIOLATION`: a change that adds a new canonical component in a CLOSED family. Zero-false-positive (it checks declared `canonical_for` against baseline members). `DS_CLOSED_SET_SUSPECT` (name heuristic) and `DS_DUPLICATE_PRIMITIVE` (Slice C manifest signature-diff, Jaccard ≥0.7) are advisory warnings. Self-scoping + self-skipping (registry/policy absent → clean no-op); SKIPs, never silently passes. Logic in `src/utils/ds-reuse-gate.js`, verb in `src/commands/ds-gate.js`. Wired into `qa-sentinel`, `/new` Phase 2 (`implement.md` Step 8), `new2`, the Final review, and `code-reviewer`.
18
+ - **`/prd` Component Reconciliation gate (Slice B — the human-in-the-loop prevention)** — `ui-design-phase.md`'s passive "Component Registry Lookup" becomes an active *match-before-generate* gate: build a Mockup Element Inventory → classify each region by ROLE (closing the name-vs-need vocabulary gap) → ask ONLY on ambiguity (`REUSE+VARIANT` / `NEW`, never plain reuse) in the prohibition+alternative form → persist. A closed-family hit surfaces *"this becomes the standard?"*; a confirmed standard writes `canonical_for`/`selection_closed` + opens a migration follow-up card (never migrates existing views in-PRD).
19
+ - **`component_bindings` card field (Slice B)** — the resolved `region → component (reuse|reuse-variant|new)` map (`card-schema.md`) is the authority `ui-expert` implements against: the mockup becomes a visual reference, the bindings decide which component each region IS. Resolves the mockup-vs-registry contradiction in `implement.md` (the old "implementation MUST match the approved mockup 1:1" that let the approved-mockup pixels beat the dusty registry).
20
+
21
+ ### Changed
22
+
23
+ - **`/design-system-init` derives the closed-set on upgrade — confidence-gated (Slice A)** — the bootstrap auto-fills only what is unambiguous (`canonical_for`/`use_when` lifted from each component's `must_rules`, e.g. *"MUST use only for depth 0"* → `page-header@depth-0`) and **interacts for the judgement calls**: it COLLECTS closure candidates (families that look like they partition all cases) + suspected duplicates (manifest signature-overlap ≥0.7, reusing `signatureDuplicates`; deprecated-but-used primitives) and confirms them with the user in ONE consolidated `AskUserQuestion` (step 4c — family-level, ~5–10 confirmations for a ~100-component registry, never per-component). `selection_closed` is written only for confirmed families; a "consolidate" answer opens a migration follow-up card. Unattended / Codex runs propose-only — never auto-close (closing a family without a human is the governance bypass the policy forbids). Idempotent: a settled family is not re-asked. The bootstrap-time twin of the `/prd` reconciliation gate.
24
+ - **Closed-set enforcement across the discipline (Slice B/C)** — the Component Discovery Cascade now queries by role first (`design-system-protocol.md` § Closed-Set Selection Policy, the textual SSOT); `ui-expert` (cascade step 6 + Implementation briefing), `ui-design` (closed-set query + bindings output), `code-reviewer` (rule 4 closed-set BLOCKER + Post-Intervention check (e) + the new drift code, UI-reinvention fixes route to `ui-expert`), and the weekly `ds-drift` routine (Selection-Policy integrity audit) all consume it. New canonical drift code `DS_CLOSED_SET_VIOLATION`.
25
+
26
+ ## [4.67.1] - 2026-06-24
27
+
28
+ **Rule C is now i18n-aware — a presentational card that only changes labels no longer gets forced to `balanced`.** Found on a real `mayo` run: FEAT-0050 is a "PURAMENTE PRESENTAZIONALE" fidelity-drift epic, yet its child cards ran the full per-card review cluster (full-depth Codex + simplify) instead of the lighter UI path. Root cause: the i18n layer (v4.52.0) silently broke the v4.x UI-presentational carve-out in `prd-card-writer.md § Rule C`. The LIGHT branch (b) requires "≤3 files, every one a component/style file", and the SKIP rule requires "all files .md/.yml/CSS" — but with `features.has_i18n: true`, every label change forces an edit to **all** native locale files (`it.ts`/`en.ts`/`de.ts`/`es.ts`/`fr.ts`), which blows the ≤3 count and injects non-component `.ts` files. So a genuinely pixels-only card was structurally incapable of being classified `light`/`skip`.
29
+
30
+ **PATCH** — corrects a blind spot in existing Rule C logic; reuses `features.has_i18n` + `i18n.locales_root` (both already in the schema), no new config key, no install change. Schema-change propagation rule does NOT apply.
31
+
32
+ ### Fixed
33
+
34
+ - **i18n locale-file transparency in Rule C** (`prd-card-writer.md`) — when `features.has_i18n: true`, locale files under `i18n.locales_root` are now TRANSPARENT to BOTH the LIGHT branch (b) and the SKIP rule: excluded from the file count AND the component/style test, classifying the card on its non-locale files only. A locale file is a flat string table whose correctness is owned by the i18n gate (anti-hardcoded lint) + `i18n-translator`, NOT the logic cluster — so it must not disqualify the presentational carve-out. Effect: a genuinely-presentational card (`.tsx`/`.css` + N locale files) now correctly lands on `light` (full→light Codex depth, doc-review deferred to the Final FULL gate), and a pure-cosmetic CSS/copy card that retouches locale strings now correctly lands on `skip`. The DS safety net is unchanged — a `light` card still runs its per-card Codex finder + `code-reviewer` rule 8 / registry-first cascade, plus the batch-wide Final FULL gate.
35
+ - **Why Codex stays per-card (not deferred to Final)** — deferring per-card Codex onto the Final gate for UI cards was considered and rejected: it reverts the deliberate v4.18.0 choice (at `light`, Codex is the SOLE deep finder) and re-opens the refuted `slimCodex` design (v4.56.1 — per-card runs PRE-fix/PRE-E2E, the Final runs POST-fix over the whole batch; they review different states). The sanctioned lever for "less Codex on a graphic card" is the `light` profile's reduced Codex depth, which this fix now lets presentational cards actually reach.
36
+
8
37
  ## [4.67.0] - 2026-06-23
9
38
 
10
39
  **Token-binding CORRECTNESS check + PRD-Inventory-sourced enrichment — the survivor of an adversarial pass that killed a design-time "intent seed".** Goal: capture design intent ("when/how/which-tokens") so the implementing agent doesn't reverse-engineer the manifest's agentic fields from code. The proposed `component-intent.yml` seed (emitted by `/prd`) was refuted 3/3 — it duplicates the PRD's existing UI Element Inventory + the manifest (twin), goes stale via `/prd-add` REDO, survives-but-wrong (the v4.66.2 guard checks token EXISTENCE, not correctness-for-this-component), and as an on-disk prose file would be skipped/unowned/Codex-inert. This ships the deterministic survivors instead.
package/README.md CHANGED
@@ -229,6 +229,8 @@ Bundled skills:
229
229
 
230
230
  When `features.has_design_system: true`, every UI-touching agent/skill/command (`ui-expert`, `ui-design`, `frontend-design`, `code-reviewer`, `/design-review`) follows the same BLOCKING cascade: read `${paths.design_system}/INDEX.md` (Authority Matrix), `tokens-reference.md` (token contract), and `components/<Name>.md` for every primitive in scope — BEFORE producing or reviewing any UI. New components must reuse from the registry or ship their per-component spec in the same change. Hardcoded color/shadow/radius/spacing values are HIGH findings. The textual SSOT for the protocol lives in [`framework/agents/design-system-protocol.md`](framework/agents/design-system-protocol.md). Projects without a registry can bootstrap one with `/design-system-init`, which inventories existing primitives, extracts tokens from global styles + Tailwind config, scaffolds the registry, and flips the flag.
231
231
 
232
+ **Closed-Set Selection Policy (v4.68.0).** The registry has a token layer and a component layer, but until now nothing asserted the *boundary* of a category — so an agent designing from a mockup could write a third "header" when two canonical ones already cover every case, and the duplicate was only caught (reactively) at review. The closed-set policy adds the missing third layer: each component spec declares the presentational **role** it is canonical for (`canonical_for: [page-header@depth-0]`) and whether that role family is **closed** (`selection_closed`), aggregated into a generated `INDEX.md` § "Selection Policy". A closed family means *introducing a new canonical member is a governance decision, not an implementation act*. Three layers enforce it: (1) **`/prd` Component Reconciliation** — a human-in-the-loop *match-before-generate* gate that binds each mockup region to an existing component (or a governed NEW one) and asks *"this becomes the standard?"* only on genuine ambiguity, persisting the decision into the card's `component_bindings`; (2) **`baldart ds-gate`** — a deterministic, zero-false-positive build-time gate that BLOCKS (`DS_CLOSED_SET_VIOLATION`) any change adding a new canonical in a closed family (wired into `qa-sentinel` / `/new` / `code-reviewer`, the design-system analogue of the i18n gate); (3) the existing review + weekly `ds-drift` safety net. Rides on `features.has_design_system` — no new config key; `/design-system-init --upgrade` derives the closed sets from your existing `must_rules`. This closes a gap no 2026 design-to-code vendor hard-gates (Figma Code Connect, Storybook MCP, shadcn are all advisory).
233
+
232
234
  ### End-to-End Review BLOCKING Gate (new in v3.18.0)
233
235
 
234
236
  When `features.has_e2e_review: true`, the `/new` orchestrator Phase 2.6 invokes the new `/e2e-review` skill on every UI card (auto-skipped on backend-only cards). The skill is a **deterministic, BLOCKING** orchestrator that combines functional E2E (Playwright spec written by `coder`, executed via `playwright-skill`) with visual fidelity diff (`visual-fidelity-verifier` multimodal agent) and aggregates findings under a strict severity gate. This replaces the legacy advisory pair (Phase 2.6 conditional E2E + Phase 2.7 non-blocking design review) that left the user manually re-verifying every screen.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.67.0
1
+ 4.68.0
package/bin/baldart.js CHANGED
@@ -174,6 +174,18 @@ program
174
174
  await teardownCommand({ cwd: options.cwd, json: !!options.json });
175
175
  });
176
176
 
177
+ program
178
+ .command('ds-gate')
179
+ .description('Enforce the closed-set Selection Policy: BLOCK (exit 1) when the change adds a new canonical component in a CLOSED role family (the "third header" failure mode). Registry/policy absent → clean no-op. Rides on features.has_design_system.')
180
+ .option('--cwd <path>', 'Project root (defaults to the current directory).')
181
+ .option('--base <ref>', 'Baseline git ref to read the committed Selection Policy + diff against (defaults to merge-base with the trunk, else HEAD).')
182
+ .option('--json', 'Machine-readable output: emit a single JSON result object on stdout')
183
+ .action(async (options) => {
184
+ const dsGate = require('../src/commands/ds-gate');
185
+ const code = await dsGate.run({ cwd: options.cwd, base: options.base, json: !!options.json });
186
+ if (typeof code === 'number' && code !== 0) process.exitCode = code;
187
+ });
188
+
177
189
  const tokensGroup = program
178
190
  .command('tokens')
179
191
  .description('Design-token operations. The DTCG `.tokens.json` (paths.design_tokens) is the SSOT; `tokens build` regenerates the stack-native artifacts (tokens.ts / CSS vars) from it.');
@@ -75,6 +75,14 @@ Concrete enforcement contract for the review pass:
75
75
  - Motion honors the project's reduced-motion variant rules.
76
76
  4. Re-implementation of an existing registry primitive is a HIGH finding —
77
77
  the diff must reuse the primitive or propose a new variant in the registry.
78
+ **Closed-set violation (BLOCKER):** a new canonical component in a role family
79
+ marked `closed` in `INDEX.md` § "Selection Policy" (the "third header" failure
80
+ mode) is a `DS_CLOSED_SET_VIOLATION` — run `baldart ds-gate --json` to confirm
81
+ deterministically; pair the finding with the canonical member to reuse (the
82
+ Augment-measured prohibition+alternative form). A `DS_DUPLICATE_PRIMITIVE`
83
+ signature-overlap warning from the gate is a HIGH finding to verify (real
84
+ rewrite vs legitimately-similar primitive). UI-presentation reinvention fixes
85
+ route to `ui-expert` (the UI domain owner), not `coder`.
78
86
  5. New primitives introduced without their `components/<Name>.md` spec are a
79
87
  HIGH finding.
80
88
  6. Violations of project-declared design-system MUST rules are HIGH — they
@@ -92,9 +100,13 @@ Concrete enforcement contract for the review pass:
92
100
  (c) new/changed token ⇒ DTCG `.tokens.json` updated + outputs regenerated
93
101
  (`baldart tokens build`) when `paths.design_tokens` is set, else the
94
102
  project token source/reference,
95
- (d) silently-existing primitive reused ⇒ INDEX router entry added in the same diff.
103
+ (d) silently-existing primitive reused ⇒ INDEX router entry added in the same diff,
104
+ (e) **closed-set integrity** ⇒ no new canonical component in a `closed` role
105
+ family (run `baldart ds-gate`); a violation is a `DS_CLOSED_SET_VIOLATION`
106
+ BLOCKER, not a HIGH.
96
107
  Each failing condition is a HIGH finding with the canonical code
97
- (`DS_INDEX_DRIFT` / `DS_COMPONENT_STALE` / `DS_TOKENS_DRIFT`). This is the
108
+ (`DS_INDEX_DRIFT` / `DS_COMPONENT_STALE` / `DS_TOKENS_DRIFT` /
109
+ `DS_CLOSED_SET_VIOLATION`). This is the
98
110
  third and final gate after `ui-design`/`ui-expert`'s own post-intervention
99
111
  check and the weekly `ds-drift` routine; if it trips here at review time,
100
112
  it means the upstream checks were skipped — flag the protocol violation
@@ -214,13 +214,14 @@ Apply the **first matching rule** (priority order — top wins):
214
214
  | Profile | When to assign |
215
215
  |---------|----------------|
216
216
  | **DEEP** | ANY of: `areas` includes both `api` AND `data` — OR title/scope/areas touch `auth`, `payment`, `permission`, `schema`, `migration`, `cron`, `webhook`, `transaction` — OR a path in `files_likely_touched` falls under a `paths.high_risk_modules` entry — OR `data_fields` has ≥3 entries with `status: new` or `status: modified` — OR `db_indexes` present (legacy `firestore_indexes` too) — OR acceptance criteria count > 5 — OR `estimated_complexity: HIGH` — OR API contract changed — OR `> 15` files in `files_likely_touched` (broad blast radius — review every group deeply) |
217
- | **LIGHT** | EITHER (a) Card is a `bugfix` or `refactor` — AND ≤3 files in `files_likely_touched` — AND none of the DEEP high-risk keywords/areas apply; **OR** (b) **UI-presentational card** — ALL of: the card is pure-UI — `areas` == `[ui]` exactly (no `api`/`data`/`logic`/`auth`), or when `areas` is absent the Rule B signal `owner_agent: ui-expert` stands in for it — AND ≤3 files in `files_likely_touched`, every one a component/style file (no data-fetching, state-management, API-client, or server file) — AND NO path in `files_likely_touched` falls under `paths.components_primitives` (i.e. the card only *composes* existing design-system primitives, it does NOT introduce or modify one) — AND none of the DEEP high-risk keywords/areas apply. Branch (a) is **NEVER for `feature`/`enhancement` cards**; branch (b) is the ONLY route by which a `feature`/`enhancement` card may be `light`. |
218
- | **SKIP** | Card is `docs`, `chore`, or `config` — OR all `files_likely_touched` are `.md`/non-API `.yml`/CSS with zero logic files — OR a pure-cosmetic card (typo/rename/copy/wording/style) with no code areas |
217
+ | **LIGHT** | EITHER (a) Card is a `bugfix` or `refactor` — AND ≤3 files in `files_likely_touched` — AND none of the DEEP high-risk keywords/areas apply; **OR** (b) **UI-presentational card** — ALL of: the card is pure-UI — `areas` == `[ui]` exactly (no `api`/`data`/`logic`/`auth`), or when `areas` is absent the Rule B signal `owner_agent: ui-expert` stands in for it — AND ≤3 files in `files_likely_touched`, every one a component/style file (no data-fetching, state-management, API-client, or server file) — AND NO path in `files_likely_touched` falls under `paths.components_primitives` (i.e. the card only *composes* existing design-system primitives, it does NOT introduce or modify one) — AND none of the DEEP high-risk keywords/areas apply. **i18n locale-file transparency (when `features.has_i18n: true`):** locale files under `i18n.locales_root` are TRANSPARENT to this branch — exclude them from BOTH the `≤3` count AND the "every file is component/style" test, classifying the card on its NON-locale files only. A label change forces an edit to every locale file (`it.ts`/`en.ts`/…), which would otherwise push a genuinely-presentational card over the file count and inject non-component `.ts` files; but a locale file is a flat string table whose correctness is owned by the i18n gate (anti-hardcoded lint) + `i18n-translator`, NOT the logic cluster — so it must not disqualify the carve-out. Branch (a) is **NEVER for `feature`/`enhancement` cards**; branch (b) is the ONLY route by which a `feature`/`enhancement` card may be `light`. |
218
+ | **SKIP** | Card is `docs`, `chore`, or `config` — OR all `files_likely_touched` are `.md`/non-API `.yml`/CSS with zero logic files (**when `features.has_i18n: true`, locale files under `i18n.locales_root` are EXCLUDED from this set the same way they are for the LIGHT branch a pure-cosmetic CSS/copy fix that also retouches locale strings stays `skip`; classify on the non-locale files only**) — OR a pure-cosmetic card (typo/rename/copy/wording/style) with no code areas |
219
219
  | **BALANCED** | Default for everything else — ALL `feature`/`enhancement` cards not matching DEEP, plus any `bugfix`/`refactor` with >3 files or breadth that isn't DEEP |
220
220
 
221
221
  **Critical**: `feature` and `enhancement` cards are `balanced` minimum — **with exactly one
222
222
  exception**: the LIGHT branch (b) above, a strictly UI-presentational card (`areas == [ui]`,
223
- ≤3 component/style files, composes existing primitives without touching any
223
+ ≤3 component/style files locale files under `i18n.locales_root` excluded from the count when
224
+ `features.has_i18n: true`, see the LIGHT row — composes existing primitives without touching any
224
225
  `paths.components_primitives` path). This narrow carve-out targets the `-ui` half of a
225
226
  logic/ui split pair (e.g. a `…-submit-ui` card whose logic lives in its `…-submit-logic`
226
227
  sibling): reviewing a pixels-only diff at Codex-`full` depth is waste, and the design-system
@@ -49,6 +49,18 @@ string slipped through). If NEITHER resolves, report `i18n-lint: SKIP (no
49
49
  anti-hardcoded gate wired — run \`baldart doctor\`)` as an informational note,
50
50
  never a pass — do not claim the gate ran when it did not.
51
51
 
52
+ **DS closed-set gate (since v4.68.0):** when `features.has_design_system: true`
53
+ AND the diff touches UI (files under `${paths.components_primitives}` /
54
+ `${paths.components}`, or any component spec under
55
+ `${paths.design_system}/components/`), run **`npx baldart ds-gate --json`** (from
56
+ the worktree). It enforces the closed-set Selection Policy — a non-zero exit
57
+ (`blockingCount > 0`, a `DS_CLOSED_SET_VIOLATION`) is a real **FAIL**: the change
58
+ introduces a new canonical component in a CLOSED role family (the "third header"
59
+ failure mode). `DS_CLOSED_SET_SUSPECT` warnings are informational, never a FAIL.
60
+ The verb is self-scoping (it reads the committed Selection Policy + the diff) and
61
+ self-skipping (registry/policy absent → clean no-op) — no diff-scoping needed. If
62
+ it reports `skipped`, note it informationally, never claim a pass.
63
+
52
64
  Default pre-commit gates (MUST pass before any commit verdict) — used when no
53
65
  `toolchain.commands.*` is configured:
54
66
  1. Lint: `npx eslint --max-warnings=0 <changed source files>` (or project equivalent)
@@ -334,6 +334,15 @@ project context layer at invocation time:
334
334
  `${paths.components_primitives}/*` → external catalog (shadcn / 21st.dev /
335
335
  Radix) → create from scratch. Stop at the first hit. Document which
336
336
  existing primitives you will reuse and which (if any) need creation.
337
+ **Query by ROLE first, then closure (BLOCKING):** before deciding an element
338
+ needs a new component, consult `INDEX.md` § "Selection Policy". If the element
339
+ maps to a role in a **closed** family (e.g. `page-header`), you MUST reuse that
340
+ family's canonical member — creating a new one is a `DS_CLOSED_SET_VIOLATION`
341
+ (`baldart ds-gate` blocks it). When a card carries `component_bindings` (the
342
+ `/prd` reconciliation map), those bindings are authoritative — honor them over a
343
+ contrary reading of the mockup. A genuinely-new member of a closed family is a
344
+ governance decision: STOP and surface it, never ship it silently. "Document"
345
+ here means *act on a mismatch*, not just list it.
337
346
  2. **Read `${paths.ui_guidelines}`** for current design standards
338
347
  3. Establish the user type (from `identity.audience_segments`)
339
348
  4. If brand-themed: request brand inputs if not provided
@@ -87,10 +87,12 @@ precedence caveats: `framework/agents/effort-protocol.md`.
87
87
  acceptable (advisory), never a failure.
88
88
  4. **Enrich — AGENTIC, AS DATA (never hand-write YAML).** Launch `doc-reviewer`
89
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.
90
+ `must_rules`, **plus the selection-policy fields `canonical_for` / `use_when` /
91
+ `selection_closed`** step 4b) **as JSON values**, NOT as text typed into a
92
+ `.md`. In UPGRADE mode, lift them from each spec's existing prose / **salvage**
93
+ them from the current HEAD even if it is malformed. Never clobber a filled
94
+ agentic field on re-run. Under Codex (no subagents) this degrades to empty
95
+ fields + a TODO note.
94
96
  **`token_bindings` MUST be chosen from the REAL token vocabulary** — read the
95
97
  id list from `${paths.design_tokens}` (the DTCG `.tokens.json`) and pick exact
96
98
  ids (`typography.fontWeight.medium`, `zIndex.overlay`, `space.sm`), never
@@ -104,6 +106,56 @@ precedence caveats: `framework/agents/effort-protocol.md`.
104
106
  `purpose`/`a11y`/`must_rules` (design-time intent the code does not encode),
105
107
  then reconcile against the implemented source. Fallback to source-only when no
106
108
  PRD/Inventory exists.
109
+ 4b. **Derive the closed-set Selection Policy — confidence-gated (the third semantic
110
+ layer).** Per `agents/design-system-protocol.md § Closed-Set Selection Policy`
111
+ (`doc-reviewer` owns this; degrades on Codex). This step is **bootstrap-shaped**:
112
+ it auto-fills what is unambiguous and **interacts with the user** for the
113
+ judgement calls — it must NOT silently impose closures or merge duplicates. Three
114
+ confidence tiers:
115
+ - **HIGH confidence → auto-fill, no question** (reversible agentic fields):
116
+ - **`canonical_for`** — the `"<family>@<context>"` role(s) the component is THE
117
+ canonical answer for, when its name + `purpose` + an existing `must_rules`
118
+ boundary state it cleanly (e.g. a `must_rules` *"MUST use only for depth 0"*
119
+ → `page-header@depth-0`). Most components serve no named role — leave empty.
120
+ - **`use_when`** — lift the selection predicate verbatim from that `must_rules`
121
+ boundary.
122
+ - **GOVERNANCE / AMBIGUOUS → COLLECT, do not write yet** (gather into a proposal
123
+ for step 4c, never auto-decide):
124
+ - **Closure candidates** — a family whose members look like they *partition all
125
+ cases* (e.g. the two-header depth model: `{SectionHeader@depth-0,
126
+ DrilldownHeader@depth-1+}`). Closing a family is a **governance act** — record
127
+ it as a *proposal* with the evidence (the ADR / "these are the only N"
128
+ statement / the partition), NOT a written `selection_closed: true`.
129
+ - **Suspected duplicates** — components whose manifest **signature** (props ∪
130
+ variants ∪ composes) overlaps an existing one ≥0.7, or a `status: deprecated`
131
+ primitive still used, or two components that plainly serve the same role
132
+ (e.g. a legacy `Header` alongside `SectionHeader`). Reuse the gate's
133
+ signature-diff (`src/utils/ds-reuse-gate.js → signatureDuplicates`) to surface
134
+ them. Each is a question: *which is canonical, consolidate or keep both?*
135
+ - **LOW confidence → leave empty + TODO** (no question — don't over-ask): a
136
+ component whose role you cannot infer stays outside the policy.
137
+ UPGRADE re-runs PRESERVE a human-set `selection_closed` and any human answer from
138
+ a prior run (idempotent — never re-ask a settled family).
139
+ 4c. **Selection Policy confirmation gate (interactive).** Before writing files,
140
+ present the step-4b proposal **once, consolidated** — family-level, never
141
+ per-component (mayo's ~100 components yield only a handful of candidate families
142
+ + duplicates, so this is ~5–10 confirmations total, not a barrage):
143
+ - **Interactive session (a human is present):** one batched `AskUserQuestion`
144
+ round. For each closure candidate: *"Propongo di CHIUDERE la famiglia
145
+ `page-header` con membri {SectionHeader@depth-0, DrilldownHeader@depth-1+} —
146
+ diventano gli unici header canonici (un terzo richiederà governance). Confermi /
147
+ correggi i membri-contesto / lascia aperta?"*. For each suspected duplicate:
148
+ *"`Header` sembra duplicare `SectionHeader` — è canonico, da consolidare, o
149
+ coesistono?"*. Write `selection_closed: true` + the agreed members ONLY for
150
+ confirmed closures; record the rationale (and open a migration follow-up card
151
+ for any "consolidate" answer — never migrate call-sites inside the bootstrap).
152
+ - **Unattended (Codex / no-TTY / autonomous / `BALDART_AUTONOMOUS`):** do NOT
153
+ close any family and do NOT merge any duplicate — write the proposals into the
154
+ step-8 next-steps report + leave `selection_closed: false` + a TODO, so a human
155
+ ratifies later. Auto-closing without a human is exactly the governance bypass
156
+ the policy forbids.
157
+ This makes the bootstrap the bootstrap-time twin of the `/prd` Component
158
+ Reconciliation gate: auto where confident, ask where it is the user's call.
107
159
  5. **Bootstrap the DTCG token SSOT.** See § Token SSOT bootstrap below.
108
160
  6. **Write the files — DETERMINISTICALLY (the HEAD is NEVER model-authored).**
109
161
  Merge the deterministic JSON (step 3) with the agentic JSON (step 4) per
@@ -157,8 +209,18 @@ precedence caveats: `framework/agents/effort-protocol.md`.
157
209
  `composes`, `category`. Empty fields on a non-TS stack are advisory, not errors.
158
210
 
159
211
  3. **Agentic enrichment (`doc-reviewer`).** Fill `purpose`, `a11y`,
160
- `token_bindings`, `related`, `must_rules`. UPGRADE: lift from each spec's
161
- existing prose. Never clobber a filled field. Under Codex: leave empty + TODO.
212
+ `token_bindings`, `related`, `must_rules`, and the selection-policy fields
213
+ `canonical_for` / `use_when` What This Skill Does step 4b auto-fill HIGH-
214
+ confidence roles; COLLECT closure + duplicate candidates). UPGRADE: lift from
215
+ each spec's existing prose. Never clobber a filled field. Under Codex: leave
216
+ empty + TODO.
217
+
218
+ 3b. **Selection Policy confirmation gate (step 4c).** Present the closure +
219
+ suspected-duplicate proposals ONCE, consolidated (family-level). Interactive:
220
+ one batched `AskUserQuestion` → write `selection_closed` only for confirmed
221
+ families (+ migration follow-up cards for "consolidate" answers). Unattended /
222
+ Codex: propose into the report + leave open, never auto-close. Idempotent — a
223
+ settled family is not re-asked.
162
224
 
163
225
  4. **Token SSOT bootstrap** — § Token SSOT bootstrap (derive `.tokens.json`,
164
226
  set outputs, `baldart tokens build`, round-trip verify).
@@ -80,6 +80,9 @@ export function emitHead(c) {
80
80
  L.push(`a11y: ${scalar(c.a11y ?? '')}`);
81
81
  L.push(list('related', c.related));
82
82
  L.push(list('must_rules', c.must_rules));
83
+ L.push(list('canonical_for', c.canonical_for));
84
+ L.push(`use_when: ${scalar(c.use_when ?? '')}`);
85
+ L.push(`selection_closed: ${c.selection_closed ? 'true' : 'false'}`);
83
86
  L.push(`last_verified: ${scalar(c.last_verified || TODAY)}`);
84
87
  L.push(`owner: ${scalar(c.owner || 'doc-reviewer')}`);
85
88
  return L.join('\n');
@@ -94,12 +97,71 @@ export function emitSpec(c) {
94
97
  return `---\n${emitHead(c)}\n---\n\n${prose.replace(/\s*$/, '')}\n`;
95
98
  }
96
99
 
100
+ /**
101
+ * Aggregate the closed-set Selection Policy from the component HEADs.
102
+ * A role family (the `<family>` part of a `canonical_for` `"<family>@<context>"`
103
+ * entry) is `closed` iff ≥1 of its members carries `selection_closed: true`.
104
+ * Returns a deterministic { family: { closed, members:[{component,context}] } } map.
105
+ */
106
+ export function selectionPolicy(components) {
107
+ const fam = {};
108
+ for (const c of components) {
109
+ for (const role of c.canonical_for || []) {
110
+ const at = String(role).indexOf('@');
111
+ const family = (at >= 0 ? role.slice(0, at) : role).trim();
112
+ const context = at >= 0 ? role.slice(at + 1).trim() : '';
113
+ if (!family) continue;
114
+ (fam[family] = fam[family] || { closed: false, members: [] });
115
+ fam[family].members.push({ component: c.name, context });
116
+ if (c.selection_closed) fam[family].closed = true;
117
+ }
118
+ }
119
+ // Determinism: sort families + members.
120
+ const out = {};
121
+ for (const k of Object.keys(fam).sort()) {
122
+ out[k] = {
123
+ closed: fam[k].closed,
124
+ members: fam[k].members.sort((a, b) => (a.component + a.context).localeCompare(b.component + b.context)),
125
+ };
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** Machine-parseable Selection Policy section for the INDEX (or '' if no roles). */
131
+ export function emitSelectionPolicy(components) {
132
+ const fam = selectionPolicy(components);
133
+ const names = Object.keys(fam);
134
+ if (names.length === 0) return '';
135
+ const L = [];
136
+ L.push('## Selection Policy');
137
+ L.push('');
138
+ L.push('Closed role families: introducing a NEW canonical member is a governance');
139
+ L.push('decision, not an implementation act — `baldart ds-gate` blocks it. Open');
140
+ L.push('families stay advisory (Component Discovery Cascade + review). This block is');
141
+ L.push('GENERATED from each spec HEAD\'s `canonical_for` + `selection_closed`.');
142
+ L.push('');
143
+ L.push('```yaml');
144
+ L.push('# baldart-generated selection-policy — do not hand-edit (edit the component HEADs)');
145
+ L.push('families:');
146
+ for (const f of names) {
147
+ L.push(` ${scalar(f)}:`);
148
+ L.push(` closed: ${fam[f].closed ? 'true' : 'false'}`);
149
+ L.push(' members:');
150
+ for (const m of fam[f].members) {
151
+ L.push(` - { component: ${scalar(m.component)}, context: ${scalar(m.context || '')} }`);
152
+ }
153
+ }
154
+ L.push('```');
155
+ return L.join('\n');
156
+ }
157
+
97
158
  /** Thin INDEX router from the component set. */
98
159
  export function emitIndex(components, brand = 'Project') {
99
160
  const rows = components
100
161
  .slice()
101
162
  .sort((a, b) => a.name.localeCompare(b.name))
102
163
  .map((c) => `| ${c.name} | ${c.source} | ${(c.purpose || '').replace(/\|/g, '\\|').slice(0, 80)} | [components/${c.name}.md](components/${c.name}.md) |`);
164
+ const policy = emitSelectionPolicy(components);
103
165
  return [
104
166
  '<!-- baldart-generated router — regenerated from components/*.md HEAD. Do not hand-edit. -->',
105
167
  '---',
@@ -119,6 +181,7 @@ export function emitIndex(components, brand = 'Project') {
119
181
  '|-----------|--------|---------|------|',
120
182
  ...rows,
121
183
  '',
184
+ ...(policy ? [policy, ''] : []),
122
185
  ].join('\n');
123
186
  }
124
187
 
@@ -111,8 +111,20 @@
111
111
 
112
112
  ### Design Reference (UI cards only — include if card has links.design)
113
113
  Design file: [path from card's links.design field]
114
- Read the design.html file and use it as the visual reference for your implementation.
115
- The design was approved by the user — your implementation MUST match it.
114
+ Read the design.html file and use it as the **visual reference** for your
115
+ implementation (layout, spacing, hierarchy, copy). The design was approved by
116
+ the user — your implementation MUST match its appearance.
117
+
118
+ **The mockup is a visual reference, NOT a license to re-create components.**
119
+ When the card carries `component_bindings` (the `/prd` Component Reconciliation
120
+ map — see `agents/card-schema.md`), THAT map is authoritative over *which
121
+ component each region IS*: a region bound `action: reuse → SectionHeader` MUST
122
+ be implemented by reusing `SectionHeader`, even if the mockup's pixels look like
123
+ a fresh header. Honor the binding over any contrary reading of the mockup. Only
124
+ `action: new` entries (confirmed with the user at `/prd` time) may introduce a
125
+ component. A card with UI scope and `features.has_design_system: true` but NO
126
+ `component_bindings` → run the cascade below as your own reconciliation before
127
+ writing code (do not assume a mockup affordance is a new component).
116
128
 
117
129
  ### i18n constraint (include when `features.has_i18n: true`)
118
130
  No user-facing string may be hardcoded. Externalize every label through the
@@ -142,10 +154,18 @@
142
154
  component named in the mockup, the requirements, or `files_likely_touched`.
143
155
  5. For EACH pattern in scope, read the relevant
144
156
  `${paths.design_system}/patterns/<topic>.md`.
157
+ 6. Read `${paths.design_system}/INDEX.md` § "Selection Policy" (the closed role
158
+ families). For EACH region you are about to build, if it maps to a role in a
159
+ **closed** family you MUST reuse that family's canonical member — never write
160
+ a new component for it (the "third header" failure mode). A genuinely-new
161
+ member of a closed family is a governance decision, not yours to make: STOP
162
+ and surface it as a blocker. `baldart ds-gate` enforces this deterministically
163
+ at the gate (Step 8) — a `DS_CLOSED_SET_VIOLATION` means you created one.
145
164
 
146
165
  State, in your first response, which Authority Matrix rows govern this
147
- card, which component specs you read (step 4), and which patterns you
148
- read (step 5). Skipping this declaration = the orchestrator will reject
166
+ card, which component specs you read (step 4), which patterns you
167
+ read (step 5), and which `component_bindings` / Selection-Policy roles you are
168
+ reusing (step 6). Skipping this declaration = the orchestrator will reject
149
169
  the implementation and re-spawn.
150
170
 
151
171
  The `features.has_design_system: true` flag in `baldart.config.yml` is
@@ -370,9 +390,15 @@
370
390
  else
371
391
  echo "i18n:SKIP (no changed JS/TS files in this card)" # guard: features.has_i18n: true
372
392
  fi
393
+ # DS closed-set gate — ONLY when features.has_design_system: true AND this card touches UI.
394
+ # Deterministic: BLOCKS (non-zero exit) when the card adds a new canonical component in a
395
+ # CLOSED role family (the "third header" failure mode). Self-scoping + self-skipping — no
396
+ # diff-scoping needed (it reads the committed Selection Policy + the diff). See
397
+ # agents/design-system-protocol.md § Closed-Set Selection Policy. Skip the line when has_design_system is false.
398
+ npx baldart ds-gate --json > /tmp/dsgate-<CARD-ID>.txt 2>&1; echo "ds-gate:$?" # guard: features.has_design_system: true
373
399
  ```
374
400
  The `echo "<gate>:$?"` lines surface only the **exit code** inline — the full log stays on disk. When `stack.language` does NOT include `typescript`, skip the `tsc` line (no equivalent gate). Capturing to `/tmp/*-<CARD-ID>.txt` guarantees the failing output is available for step 9. On a non-zero exit, read a **bounded** extract only (`tail -n 30 /tmp/<gate>-<CARD-ID>.txt`), never the whole log — do NOT run the gates without redirect-to-file capture.
375
- 9. **If any check fails**: categorize the error (`lint | TypeScript | test | build | i18n`), log it in the tracker as `retry-cause: <category>`. An `i18n` failure means a hardcoded user-facing string — the fix is to externalize it via `t()` + register the key (coder STEP 9), never to weaken the gate. Because the gate is **diff-scoped to this card's changed files**, an `i18n` failure is unambiguously a string **this card** introduced — it is never pre-existing baseline debt in an untouched file (so do NOT spend a retry triaging "is this mine?"; it is). The Final review re-runs the gate over the whole batch diff as the merge backstop.
401
+ 9. **If any check fails**: categorize the error (`lint | TypeScript | test | build | i18n | ds-gate`), log it in the tracker as `retry-cause: <category>`. An `i18n` failure means a hardcoded user-facing string — the fix is to externalize it via `t()` + register the key (coder STEP 9), never to weaken the gate. A `ds-gate` failure (`DS_CLOSED_SET_VIOLATION`) means this card introduced a NEW canonical component in a CLOSED role family — the fix is to **reuse the family's canonical member** (named in the gate message), never to weaken the gate or add the duplicate; if the requirement genuinely needs a new member, that is a governance decision (escalate as a blocking finding for a `/prd` reconciliation + ADR, do not self-approve). Because the gate is **diff-scoped to this card's changed files**, an `i18n` failure is unambiguously a string **this card** introduced — it is never pre-existing baseline debt in an untouched file (so do NOT spend a retry triaging "is this mine?"; it is). The Final review re-runs the gate over the whole batch diff as the merge backstop.
376
402
  - **i18n locale-PARITY ≠ anti-hardcoded (do NOT retry the coder on it).** The `i18n` category above is the *anti-hardcoded* gate. A separate **locale-parity** failure — the project's OWN parity test/build complaining that a new key exists in the source locale but not in the others — surfaces under `test`/`build`, and is **NOT a `coder` retry**: the `coder` writes the source locale only **by design** (`coder.md` STEP 9.6), so retrying it just loops. Route it to **`i18n-translator`** (fills the missing target locales context-aware from the registry), then re-run the gate. **Proactively — to avoid the fail-then-fix round-trip (a measured ~2M tokens, FEAT-0042): when `features.has_i18n` AND this card's diff added new source-locale keys, spawn the `i18n-translator` fill pass right after the coder commits and BEFORE these gates**, so parity holds first-try. This is the planned per-card i18n cascade step (`agents/i18n-protocol.md`); the coder still never translates.
377
403
  **Code-recovery check (MUST do before rewriting)**: before spawning a fix agent or rewriting code, check whether lint-staged or a pre-commit hook removed code that is actually needed (e.g. a field "unused" at commit time but consumed by later code). Inspect the captured diff and `git diff`/`git log -p` for the worktree branch and restore the needed code by an explicit file write. **Do NOT `git stash pop` inside the worktree** — `refs/stash` is globally shared across worktrees (`$GIT_COMMON_DIR`), so a stash created or popped in a worktree can corrupt another worktree's state (see Phase 4 WORKTREE COMMIT RULE). If you need to set work aside in a worktree, make a clearly-labelled WIP commit and record its hash in the tracker for later squash, never a stash.
378
404
  When spawning a fix agent: scope it exclusively to this card's Edit-allowed files (from `## File Ownership Map`) — it MUST NOT touch files owned by other cards. Pass the fix agent: the **path** to the captured gate log (`/tmp/<gate>-<CARD-ID>.txt` — it Reads the log itself; do NOT inline-paste the full log into its prompt), the error category, and the explicit list of files it may edit. Do NOT ask the user — just fix and re-run. Fix the code, not the tests (unless the test itself is wrong). Repeat up to **3 times**. **Stuck-loop guard**: compare the failing error's fingerprint (file:line + message) between retries; if the SAME error reproduces on 2 consecutive retries, the fix is not converging (often a constraint outside the card's ownership map) — stop early, log `[STUCK-LOOP] <error>` in `## Issues & Flags`, and escalate to the user rather than burning the 3rd retry on an identical failure.
@@ -131,25 +131,74 @@ When invoking `ui-design`, ensure it has access to:
131
131
  - The `<slug>` for file naming
132
132
  - Existing component matches (from registry lookup below)
133
133
 
134
- ### Component Registry Lookup (before design)
134
+ ### Component Reconciliation (BLOCKING before design)
135
+
136
+ This is the **prevention** layer for the duplicate-component failure mode: an
137
+ active *match-before-generate* gate that binds every mockup region to an existing
138
+ component (or an explicit, governed NEW decision) BEFORE any design is generated —
139
+ not a passive list the subskill may ignore. It is the human-in-the-loop twin of
140
+ the deterministic `baldart ds-gate` (which blocks at build time); resolving here
141
+ means the cards `/new` emits already NAME the component to reuse, so the
142
+ ambiguity that causes divergence never reaches implementation.
135
143
 
136
144
  Before invoking `ui-design`:
137
145
 
138
146
  1. Read `${paths.references_dir}/component-registry.md`.
139
- 2. When `features.has_design_system: true`, ALSO read `${paths.design_system}/INDEX.md` (component index + authority matrix) — required by HARD RULE 14 in `prd/SKILL.md` and by the BLOCKING cascade in `ui-design/SKILL.md`. Skipping this read is a protocol violation.
140
- 3. From feature description, identify UI elements likely needed (tables, forms, modals, etc.).
141
- 4. Match against both registries. Pass results to `ui-design`:
147
+ 2. When `features.has_design_system: true`, ALSO read
148
+ `${paths.design_system}/INDEX.md` **including its § "Selection Policy" block**
149
+ (the closed role families) and the in-scope component spec HEADs. Required by
150
+ HARD RULE 14 in `prd/SKILL.md` and the BLOCKING cascade in `ui-design/SKILL.md`.
151
+ Skipping this read is a protocol violation.
152
+ 3. **Build a Mockup Element Inventory.** From the mockup (or the feature
153
+ description when mockup-less), list every interactive/visual component the UI
154
+ sketches: headers, tables, forms, modals, cards, toolbars, …
155
+ 4. **Classify each element by ROLE, not by name** (this closes the vocabulary gap —
156
+ an agent thinks "I need a header", the registry is keyed by component name). For
157
+ each element, walk the Component Discovery Cascade + query the Selection Policy:
158
+ - **REUSE** — an existing component (or a closed-family canonical member) covers
159
+ it. Record the exact component. *No question asked — this is the silent path.*
160
+ - **REUSE+VARIANT** — an existing component covers it but the mockup needs a
161
+ prop/variant not yet in its spec.
162
+ - **NEW** — genuinely no match.
163
+ 5. **Ask ONLY on ambiguity** (`REUSE+VARIANT` and `NEW` — never on plain REUSE).
164
+ One consolidated `AskUserQuestion` per ambiguous element, in the
165
+ prohibition+alternative form. Two cases:
166
+ - **Closed-family hit** (the element maps to a role in a family marked
167
+ `selection_closed: true`): you MUST reuse the canonical member. Surface it:
168
+ *"Questo mockup disegna un header: il design system ha già `SectionHeader`
169
+ (depth 0) e `DrilldownHeader` (depth ≥1) come set CHIUSO. Riuso il membro
170
+ adatto, oppure questo diventa una nuova variante/standard?"* Creating a third
171
+ member is a **governance decision** — only on explicit user confirmation, and
172
+ it means flipping/extending the closed set (record an ADR; `/design-system-init`
173
+ or the reconciliation persists `canonical_for`/`selection_closed`).
174
+ - **Open / no role** NEW element: *"Da questo mockup emerge un nuovo componente
175
+ X. Diventa lo standard per [contesto]? Quando va usato vs i simili esistenti?"*
176
+ If the user says it becomes a standard, capture the **selection predicate**
177
+ (`use_when`) and whether its family should be closed.
178
+ 6. **Persist the decisions** (so they are not re-litigated downstream):
179
+ - Into each affected card → the `component_bindings` block (see
180
+ `agents/card-schema.md`): `region → component (reuse | new) [+ variant]`. This
181
+ is the authority `ui-expert` implements against (the mockup becomes a visual
182
+ reference, not a license to re-create).
183
+ - Into the manifest → when the user confirms a new standard / closure, the
184
+ component's `canonical_for` / `use_when` / `selection_closed` are written (via
185
+ `/design-system-init` enrich or `doc-reviewer`), so `baldart ds-gate` enforces
186
+ it from then on. A new standard for *all* views also opens a **migration
187
+ follow-up card** for the existing views (never migrate them in this PRD).
188
+
189
+ Pass the resolved inventory to `ui-design`:
142
190
 
143
191
  ```
144
- ### Existing Components (from component-registry.md)
145
- REUSE — already exist:
146
- - [Component] — [path] — [notes]
192
+ ### Component Reconciliation (resolved)
193
+ REUSE — bind region → existing component:
194
+ - [region] → [Component] — [path] — [variant if any]
147
195
 
148
- MISSINGno match found:
149
- - [element description]
196
+ NEW (governed) confirmed with user:
197
+ - [region] → [NewComponent] — role: [family@context] — standard: [yes/no] — closed: [yes/no]
150
198
  ```
151
199
 
152
- This prevents designing new components when 450+ reusable ones already exist.
200
+ This prevents designing new components when the registry already covers the need,
201
+ and turns the lookup from advisory into a checkable, persisted contract.
153
202
 
154
203
  ### Gates (Full branch)
155
204
 
@@ -106,6 +106,8 @@ Before running the cascade, consult `${paths.design_system}/INDEX.md` (when pres
106
106
 
107
107
  Run the full cascade: design-system INDEX → project search (`${paths.components_primitives}/*`, `${paths.components_root}/shared/*` if applicable) → 21st.dev catalog → create from scratch. Document which existing components to reuse and which need creation. Any new component must consume the project's design tokens.
108
108
 
109
+ **Closed-set query (BLOCKING when `features.has_design_system: true`).** Consult `${paths.design_system}/INDEX.md` § "Selection Policy". For every region you design, query by **role** first: if it maps to a **closed** role family (e.g. `page-header`), design with that family's canonical member — do NOT mock up a new variant of a closed primitive. A new member of a closed family is a governance decision surfaced to the user (per `/prd` Component Reconciliation), never a design choice you make unilaterally. Emit the resolved `region → component` bindings in the inventory deliverable so they flow into the card's `component_bindings` (the authority `ui-expert` implements against — see `agents/card-schema.md`).
110
+
109
111
  ### Step B — Context Capture
110
112
 
111
113
  Read [references/generation.md](references/generation.md) § Context Capture.
@@ -352,7 +352,7 @@ const apiPrompt =
352
352
  `Return findings with domain in {code, perf, migration, security}. Use the finding schema fields.`
353
353
 
354
354
  const qaPrompt =
355
- `Run MECHANICAL GATES ONLY over the batch scope, per ${protocolRef} Step F.3 (qa-sentinel row): lint, type-check, the full test suite, build, dependency audit, markdownlint, AND — when features.has_i18n is true — the i18n anti-hardcoded gate (resolve i18n.lint_command, else the standalone eslint.i18n.config.mjs by convention; non-zero exit = FAIL; neither present = SKIP, never a silent pass) as applicable to this project. DIFF-SCOPE the i18n gate to the batch's changed files listed below: strip the trailing ' .' full-sweep target from the resolved command and run it over only the *.{ts,tsx,js,jsx,mjs,cjs} files in the Changed files list, so pre-existing baseline debt in untouched files never fails the batch (whole-repo \`.\` is only for /i18n, /i18n-adopt, and the i18n-align routine). Do NOT read source for code findings, do NOT emit severities — return only a PASS/FAIL/SKIP gate table.\n\nWorktree: ${a.worktreePath || '(cwd)'}\nChanged files:\n${scope.join('\n')}`
355
+ `Run MECHANICAL GATES ONLY over the batch scope, per ${protocolRef} Step F.3 (qa-sentinel row): lint, type-check, the full test suite, build, dependency audit, markdownlint, AND — when features.has_i18n is true — the i18n anti-hardcoded gate (resolve i18n.lint_command, else the standalone eslint.i18n.config.mjs by convention; non-zero exit = FAIL; neither present = SKIP, never a silent pass) as applicable to this project, AND — when features.has_design_system is true and the batch touches UI — the DS closed-set gate \`npx baldart ds-gate --json\` (self-scoping + self-skipping; non-zero exit / blockingCount>0 = FAIL = a DS_CLOSED_SET_VIOLATION, a new canonical component in a CLOSED role family slipped through; registry/policy absent → SKIP, never a silent pass). DIFF-SCOPE the i18n gate to the batch's changed files listed below: strip the trailing ' .' full-sweep target from the resolved command and run it over only the *.{ts,tsx,js,jsx,mjs,cjs} files in the Changed files list, so pre-existing baseline debt in untouched files never fails the batch (whole-repo \`.\` is only for /i18n, /i18n-adopt, and the i18n-align routine). Do NOT read source for code findings, do NOT emit severities — return only a PASS/FAIL/SKIP gate table.\n\nWorktree: ${a.worktreePath || '(cwd)'}\nChanged files:\n${scope.join('\n')}`
356
356
 
357
357
  // F-041 — single-card batch: a per-card review already covered these exact files and a
358
358
  // 1-card batch has NO cross-card conflict to surface, so the duplicate Claude finders can be
@@ -242,6 +242,9 @@ const projectBrief = [
242
242
  features.has_i18n
243
243
  ? `i18n (features.has_i18n=true, per agents/i18n-protocol.md): the coder MUST NOT hardcode any user-facing string — route every label through the stack t() function and apply coder.md STEP 9 (add the key + a hyper-brief context to the registry at ${paths.i18n_registry || 'paths.i18n_registry'}). The Phase-2 gate MUST run the anti-hardcoded gate IN ADDITION to lint: resolve i18n.lint_command (else the standalone eslint.i18n.config.mjs by convention) — base command \`${(cfg.i18n && cfg.i18n.lint_command) || 'npx eslint --config eslint.i18n.config.mjs .'}\` — but DIFF-SCOPED: strip the trailing ' .' full-sweep target and run it over ONLY this card's changed *.{ts,tsx,js,jsx,mjs,cjs} files (\`git diff --name-only TRUNK...HEAD | grep -E '\\.(ts|tsx|js|jsx|mjs|cjs)$'\`), so pre-existing baseline debt in untouched files never fails this card. No changed JS/TS files → SKIP. A non-zero exit FAILS the gate (a string THIS card introduced); if neither command nor config exists report i18n-lint SKIP, never a silent pass. Whole-repo \`.\` is only for /i18n, /i18n-adopt, and the i18n-align routine.`
244
244
  : '',
245
+ features.has_design_system
246
+ ? `DS closed-set gate (features.has_design_system=true, per agents/design-system-protocol.md § Closed-Set Selection Policy): when a card touches UI (components dir or a component spec), the Phase-2 gate MUST run \`npx baldart ds-gate --json\` IN ADDITION to lint. It deterministically BLOCKS (non-zero exit, blockingCount>0, DS_CLOSED_SET_VIOLATION) when the card adds a NEW canonical component in a CLOSED role family (the "third header" failure mode). It is self-scoping + self-skipping (reads the committed INDEX Selection Policy + the diff; registry/policy absent → clean no-op exit 0). A block is NOT a coder retry on style — the fix is to REUSE the canonical member named in the gate message; a genuinely-needed new member is a governance decision → route to the self-healing resolution pass as a blocking finding (ADR + /prd reconciliation), never auto-approve. DS_CLOSED_SET_SUSPECT is a warning, never a fail.`
247
+ : '',
245
248
  FLAGS.effort ? `Reasoning effort for this run: ${FLAGS.effort}.` : '',
246
249
  ].filter(Boolean).join('\n')
247
250
 
@@ -65,6 +65,7 @@ Legend: **R** = required, present **and non-empty** · **E** = required key, val
65
65
  | `files_likely_touched` | E (`[]`, never code) | R | R | EPIC tracks docs only |
66
66
  | `links.prd` | C | R | C | STANDALONE/non-PRD cards legitimately lack a PRD |
67
67
  | `links.design` | C | C | C | required when the card has UI scope |
68
+ | `component_bindings` | — | C | C | UI cards only — the resolved mockup-region → component map from `/prd` Component Reconciliation; the authority `ui-expert` implements against (see below) |
68
69
  | `canonical_docs` | R | R | C (WARN) | "REQUIRED for all cards generated from a PRD"; a manual CHORE may lack it |
69
70
  | `data_sources` | E (`[]`) | E (`[]` if no data) | E | key always present, value may be `[]` |
70
71
  | `documentation_impact` | R | C | C | |
@@ -130,6 +131,33 @@ downstream cost in a real batch:
130
131
  A back-fill never rescues a card missing a HALT field: a card without `scope` blocks regardless
131
132
  of how many derivable fields were filled.
132
133
 
134
+ ### `component_bindings` (UI cards — the reconciliation contract)
135
+
136
+ When a card has UI scope and the `/prd` Component Reconciliation gate ran (see
137
+ `prd/references/ui-design-phase.md` § Component Reconciliation), the card carries
138
+ the resolved mockup-region → component map. This is the **authority** `ui-expert`
139
+ implements against — the mockup is the visual reference, this map decides *which
140
+ component each region IS*. It eliminates the build-time ambiguity that lets an
141
+ implementer re-create an existing primitive.
142
+
143
+ ```yaml
144
+ component_bindings:
145
+ - region: "page header"
146
+ component: SectionHeader # an existing registry component, or a NEW one
147
+ action: reuse # reuse | reuse-variant | new
148
+ variant: "" # when action: reuse-variant
149
+ role: "page-header@depth-0" # when it maps a Selection-Policy role
150
+ - region: "row actions"
151
+ component: IconButton
152
+ action: reuse
153
+ ```
154
+
155
+ `action: new` is only legitimate when the `/prd` gate confirmed it with the user
156
+ (and, for a closed role family, recorded the governance decision). A `ui-expert`
157
+ that finds a `component_bindings` entry MUST honor it over any contrary reading of
158
+ the mockup; a UI card that went through `/prd` with `features.has_design_system:
159
+ true` but lacks this field is under-specified (the reconciliation did not run).
160
+
133
161
  ## Writer contract
134
162
 
135
163
  The canonical writer is the `prd-card-writer` agent. It emits the full baseline for the
@@ -63,6 +63,10 @@ token_bindings: [color.action.primary, radius.md, space.2, motion.fast] # DTCG
63
63
  a11y: "Icon-only requires aria-label; ≥44px target; respects prefers-reduced-motion."
64
64
  related: [IconButton, Link] # sibling primitives (not composition)
65
65
  must_rules: ["MUST consume color.action.primary", "MUST set aria-label when icon-only"]
66
+ # --- Selection policy (closed-set; see design-system-protocol.md § Closed-Set Selection Policy) ---
67
+ canonical_for: [action-trigger@primary] # presentational role(s) this component is THE canonical answer for: "<family>@<context>"
68
+ use_when: "Primary action of a surface; one per view. Secondary/tertiary → variant ghost/destructive." # human/agent-readable selection predicate
69
+ selection_closed: false # true ⇒ this role FAMILY is closed: a new canonical member requires governance, not code
66
70
  last_verified: 2026-06-23 # set by doc-reviewer / ds-drift on semantic re-verify
67
71
  owner: doc-reviewer # curation owner (constant; documents the SSOT)
68
72
  ---
@@ -73,7 +77,28 @@ owner: doc-reviewer # curation owner (constant; documents
73
77
  | Origin | Fields | Rule |
74
78
  |---|---|---|
75
79
  | **Deterministic** (TS/git) | `name`, `source`, `source_sha`, `status` (default), `category` (heuristic), `props`, `variants`, `composes` | Always safe to **overwrite** on regeneration — they are derived. Extracted by a pure Node script (no agent), so the path is **portable to Codex**. |
76
- | **Agentic** (doc-reviewer) | `purpose`, `token_bindings`, `a11y`, `related`, `must_rules`, `last_verified`; plus `status: deprecated` as a judgment | **Never clobbered** when non-empty. Empty agentic fields = "not yet enriched", filled later by `doc-reviewer` / `ds-drift`, never auto-invented. |
80
+ | **Agentic** (doc-reviewer) | `purpose`, `token_bindings`, `a11y`, `related`, `must_rules`, `canonical_for`, `use_when`, `selection_closed`, `last_verified`; plus `status: deprecated` as a judgment | **Never clobbered** when non-empty. Empty agentic fields = "not yet enriched", filled later by `doc-reviewer` / `ds-drift`, never auto-invented. |
81
+
82
+ ### Selection-policy fields (the closed-set layer)
83
+
84
+ `canonical_for`, `use_when`, and `selection_closed` are the **third semantic
85
+ layer** (token → component → *selection*): they encode *when a component is the
86
+ right one* and *whether its role family is closed*, so an agent reconciling a
87
+ mockup queries by **role**, not by guessing from the component name.
88
+
89
+ | Field | Type | Meaning | Written by |
90
+ |---|---|---|---|
91
+ | `canonical_for` | list of `"<family>@<context>"` | The presentational role(s) this component is THE canonical answer for. `<family>` is the role class (`page-header`, `action-trigger`); `<context>` disambiguates within it (`depth-0`, `primary`). A component may serve several. Empty = not (yet) a declared canonical. | `/design-system-init` (upgrade), `/prd` reconciliation (Slice B), `doc-reviewer` |
92
+ | `use_when` | string | One-line selection predicate — the prose rule that picks this over its siblings. Lift it from the component's own `must_rules` when one already states it (e.g. *"MUST use only for depth 0"*). | same |
93
+ | `selection_closed` | boolean (default `false`) | `true` ⇒ the **role family** (the `<family>` part of this component's `canonical_for`) is a **closed set**: introducing a *new* canonical member is a governance decision, not an implementation act. The deterministic `baldart ds-gate` blocks a new member of a closed family; an *open* family stays advisory-only (cascade + review). | same; flip to `true` only on an explicit governance decision (often recorded as an ADR) |
94
+
95
+ **Family-level truth from per-component flags.** A role family is closed iff at
96
+ least one of its canonical members carries `selection_closed: true`. The generated
97
+ `INDEX.md` § "Selection Policy" aggregates the members per family from their
98
+ `canonical_for` and marks the family `closed` accordingly — so the closure lives in
99
+ the HEADs (regenerable) and the INDEX stays a pure derived router. Mark **every**
100
+ member of a closed family `selection_closed: true` to keep the aggregation
101
+ unambiguous.
77
102
 
78
103
  ## Read contract (the context win)
79
104
 
@@ -369,6 +369,99 @@ agent has two choices:
369
369
  Never silently introduce a new component that overlaps with an existing
370
370
  registry primitive — that is the exact failure mode this protocol prevents.
371
371
 
372
+ ## Closed-Set Selection Policy (the third semantic layer)
373
+
374
+ The registry has three semantic layers, and components consume the top of each:
375
+
376
+ 1. **Token** (`.tokens.json`) — atoms: colour, spacing, radius, motion.
377
+ 2. **Component manifest** (per-spec frontmatter HEAD) — *what a component is*:
378
+ props, variants, a11y.
379
+ 3. **Selection policy** (this section) — *when a component is the right one*, and
380
+ **whether its role family is closed**.
381
+
382
+ The duplicate-component failure mode survives the Discovery Cascade because the
383
+ cascade is keyed by component **name**, while an agent reading a mockup thinks in
384
+ terms of **need** ("I need a header for this page"). Nothing in layers 1–2 asserts
385
+ the *boundary* of a category — so adding a third header violates no rule, only an
386
+ undiscovered convention. Layer 3 closes that gap by making the boundary explicit,
387
+ machine-readable, and (for closed families) deterministically enforced.
388
+
389
+ ### Roles, families, and `canonical_for`
390
+
391
+ A **role** is a `"<family>@<context>"` string a component declares it is the
392
+ canonical answer for, in its HEAD's `canonical_for` (see
393
+ `agents/component-manifest-schema.md` § Selection-policy fields):
394
+
395
+ - `<family>` — the role class: `page-header`, `action-trigger`, `data-table`.
396
+ - `<context>` — disambiguates within the family: `depth-0`, `depth-1+`, `primary`.
397
+
398
+ Example (the two-header model): `SectionHeader` →
399
+ `canonical_for: [page-header@depth-0]`; `DrilldownHeader` →
400
+ `canonical_for: [page-header@depth-1+]`. The selection predicate (`use_when`) is
401
+ lifted from the component's own `must_rules` where one already states it (e.g.
402
+ *"MUST use only for depth 0"*).
403
+
404
+ ### Closure (`selection_closed`) — the asserted boundary
405
+
406
+ A role family is **closed** when its members carry `selection_closed: true`,
407
+ meaning: *these are the only canonical components for this family; introducing a
408
+ new one is a governance decision, not an implementation act.* This is where a
409
+ prose decision — typically an ADR — becomes machine-readable so agents actually
410
+ consult it.
411
+
412
+ - **Where it lives:** in the spec HEADs (`canonical_for` + `selection_closed`),
413
+ aggregated by the deterministic serializer into the **generated** `INDEX.md`
414
+ § "Selection Policy" block (a fenced `yaml` `families:` map). The INDEX stays a
415
+ pure derived router — never hand-edit the block; edit the HEADs and regenerate.
416
+ - **Open vs closed:** an *open* family (no `selection_closed`) is advisory-only —
417
+ covered by the Discovery Cascade + review, exactly as today. Closure is
418
+ **opt-in per family** and high-precision: only assert it where the boundary is a
419
+ real product decision.
420
+
421
+ ### Enforcement — `baldart ds-gate` (deterministic)
422
+
423
+ The closed-set is enforced at **build time**, before the duplicate is reviewed,
424
+ by the BALDART-owned `baldart ds-gate` (the design-system analogue of the i18n
425
+ gate; rides on `features.has_design_system`, **no new config key**). It reads the
426
+ **committed** `INDEX.md` § "Selection Policy" (the baseline closure) and the
427
+ change's diff, and emits:
428
+
429
+ - `DS_CLOSED_SET_VIOLATION` (**BLOCK**) — the diff adds a component that claims
430
+ `canonical_for` a role in a **closed** family but is **not** one of that
431
+ family's baseline members. Deterministic, zero false-positives. Message names
432
+ the family, its canonical members, and the governance ref. The fix is always
433
+ *reuse a member* or *open governance to extend the family* — never *ship the
434
+ third one*.
435
+ - A **heuristic warning** (not a block) when a new component's name matches a
436
+ closed family (e.g. a new `*Header*` primitive while `page-header` is closed)
437
+ without declaring `canonical_for`. Slice C upgrades this to a manifest
438
+ signature-diff (`DS_DUPLICATE_PRIMITIVE`).
439
+
440
+ `ds-gate` is wired wherever the i18n gate runs (`qa-sentinel`, `/new` Phase 2,
441
+ `new2`, the Final review, `code-reviewer`). Like every BALDART gate it **SKIPs,
442
+ never silently passes**, when the registry or the policy block is absent.
443
+
444
+ ### Governance — flipping a family to closed
445
+
446
+ Marking a family `selection_closed: true` (or adding a member to a closed family)
447
+ is a deliberate act that usually follows an ADR. An agent never flips closure on
448
+ its own — it **proposes, a human confirms**:
449
+ - `/design-system-init` (upgrade) auto-fills the unambiguous roles (`canonical_for`
450
+ / `use_when` lifted from `must_rules`) but **collects closure candidates +
451
+ suspected duplicates** and confirms them with the user in one consolidated
452
+ `AskUserQuestion` (its step 4c) before writing any `selection_closed`. Unattended
453
+ / Codex runs propose-only — they never auto-close.
454
+ - The `/prd` reconciliation gate (Slice B) flips/extends closure when the user
455
+ confirms *"this becomes the standard"* for a newly-designed component.
456
+
457
+ ### How it composes
458
+
459
+ - **Discovery Cascade step 1** now queries by **role** (the INDEX Selection Policy
460
+ map) before falling to name/source — so a mockup's "header" resolves to the
461
+ canonical member instead of a fresh component.
462
+ - **Post-Intervention Coherence Check** gains check 5 below: a closed-family
463
+ violation is reconciled at the source of introduction, not next Monday.
464
+
372
465
  ## Functional Traceability Gate (BLOCKING — unconditional)
373
466
 
374
467
  A mockup is a fidelity target, **not** a requirements oracle. Design tools
@@ -494,6 +587,13 @@ For each item in the diff that touched a visual surface, run these 4 checks:
494
587
  `INDEX.md`? If yes, add it to the INDEX in the same change (or open a
495
588
  follow-up card if the registry entry requires design judgement beyond
496
589
  the current scope). Silent primitive = `DS_INDEX_DRIFT` finding.
590
+ 5. **New component in a closed role family ⇒ governance, not code**: did the
591
+ change introduce a component that serves a role in a **closed** family (per
592
+ `INDEX.md` § Selection Policy) without being an existing member of it? If yes,
593
+ that is a `DS_CLOSED_SET_VIOLATION` — task blocked. Reuse the canonical member,
594
+ or open governance (an ADR + a `/prd` reconciliation decision) to extend the
595
+ family. Run `baldart ds-gate` to check deterministically. (An *open* family is
596
+ advisory — the Discovery Cascade + review cover it, no block.)
497
597
 
498
598
  ### Decision matrix
499
599
 
@@ -504,6 +604,7 @@ For each item in the diff that touched a visual surface, run these 4 checks:
504
604
  | Reused undocumented primitive that is broadly useful | Add INDEX entry inline; if a full spec needs design work, scaffold a stub spec marked `status: needs-review` |
505
605
  | Reused undocumented primitive that is surface-specific | Add INDEX entry with `scope: local` and a 2-line note explaining why no full spec is needed |
506
606
  | Introduced a primitive that **duplicates** an existing registry entry | **Stop**. This is the failure mode the cascade exists to prevent. Revert and reuse the existing primitive, or escalate to refactor the existing primitive if the new requirement is legitimately broader |
607
+ | Introduced a new canonical component in a **closed** role family (`DS_CLOSED_SET_VIOLATION`) | **Stop — blocked by `baldart ds-gate`.** Reuse the family's canonical member, or open governance (ADR + `/prd` reconciliation) to extend the closed set; never ship the third one silently |
507
608
 
508
609
  ### Reporting
509
610
 
@@ -20,6 +20,7 @@ Route agents to the right module with minimal reading.
20
20
  - If touching database schema or fields -> read `agents/data-model.md` and `${paths.references_dir}/data-model.md`.
21
21
  - If touching UI pages/routes or flows -> read `${paths.references_dir}/ui/index.md` (then specific UI module for your domain).
22
22
  - If touching ANY visual surface (component, page, mockup, design review) and `features.has_design_system: true` -> read `agents/design-system-protocol.md` and treat the BLOCKING cascade (`${paths.design_system}/INDEX.md` + `tokens-reference.md` + `components/<Name>.md`) as mandatory pre-work. When the flag is `false`, recommend `/design-system-init` to scaffold a registry.
23
+ - If a UI change might INTRODUCE a component (designing from a mockup, building a new primitive) and `features.has_design_system: true` -> read `agents/design-system-protocol.md` § "Closed-Set Selection Policy": query the registry by ROLE (`INDEX.md` § Selection Policy), reuse the canonical member of any `closed` family, honor the card's `component_bindings`, and run `baldart ds-gate` (a `DS_CLOSED_SET_VIOLATION` — a new canonical in a closed family, the "third header" failure mode — BLOCKS). A new member of a closed family is a governance decision (`/prd` reconciliation + ADR), never an implementation act.
23
24
  - If CREATING, UPDATING, or READING a per-component spec (`${paths.design_system}/components/<Name>.md`) -> read `agents/component-manifest-schema.md` for the machine-readable frontmatter HEAD (deterministic-from-TS + agentic fields) and the on-demand read contract (INDEX router → frontmatter-first, prose only when modifying). The drift codes + gate stay in `design-system-protocol.md`.
24
25
  - If touching design-review workflows or UI guidelines -> read `agents/design-review.md` and project-specific UI guidelines.
25
26
  - If touching architecture, auth, or tech stack -> read `agents/architecture.md`.
@@ -39,7 +39,15 @@ prompt: |
39
39
  id not used in its component's styling surface is a `DS_TOKENS_DRIFT`
40
40
  **advisory** — list it for review (fix the binding or justify as indirect),
41
41
  do not auto-remove.
42
- 5. Emit a consolidated report under
42
+ 5. **Closed-set Selection Policy integrity**: the `INDEX.md` § "Selection Policy"
43
+ block is generated from the spec HEADs' `canonical_for` + `selection_closed`.
44
+ Verify it is in sync (a member whose HEAD changed its `canonical_for`, or a
45
+ family whose closure drifted from its members, is `DS_INDEX_DRIFT` — fix by
46
+ regenerating). Sanity-check each `closed` family still partitions its cases
47
+ (no two members claim the same `<family>@<context>`; no orphaned role). This is
48
+ the safety net for the per-merge `baldart ds-gate` — report, never silently
49
+ reopen a closed family.
50
+ 6. Emit a consolidated report under
43
51
  `docs/reports/{{YYYYMMDD}}-ds-drift.md`.
44
52
 
45
53
  Apply trivial fixes inline (regenerate the INDEX router; `baldart tokens build`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.67.0",
3
+ "version": "4.68.0",
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"
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `baldart ds-gate` — enforce the closed-set Selection Policy (since v4.68.0).
3
+ *
4
+ * Reads the committed design-system `INDEX.md` § "Selection Policy", inspects the
5
+ * change set, and BLOCKS (exit 1) when a diff introduces a new canonical
6
+ * component in a CLOSED role family. Heuristic suspects are reported as warnings
7
+ * (exit 0). Registry/policy absent → clean no-op (exit 0). The logic lives in
8
+ * `src/utils/ds-reuse-gate.js`; this verb is the runnable surface the gates call
9
+ * (`qa-sentinel`, `/new` Phase 2, `new2`, the Final review, `code-reviewer`).
10
+ */
11
+
12
+ const UI = require('../utils/ui');
13
+ const gate = require('../utils/ds-reuse-gate');
14
+
15
+ async function dsGate(opts = {}) {
16
+ const json = !!opts.json;
17
+ const res = gate.run({ cwd: opts.cwd, base: opts.base });
18
+
19
+ if (json) { process.stdout.write(JSON.stringify(res) + '\n'); return res.blockingCount > 0 ? 1 : 0; }
20
+
21
+ if (res.skipped) {
22
+ UI.info(`ds-gate skipped (${res.reason}) — no closed-set check applies here.`);
23
+ return 0;
24
+ }
25
+ const blocks = res.findings.filter((f) => f.severity === 'block');
26
+ const warns = res.findings.filter((f) => f.severity === 'warn');
27
+
28
+ for (const w of warns) UI.warning(`${w.code}: ${w.message}`);
29
+ for (const b of blocks) UI.error(`${b.code}: ${b.message}`);
30
+
31
+ if (blocks.length) {
32
+ UI.error(`ds-gate: ${blocks.length} closed-set violation(s) — blocked.`);
33
+ return 1;
34
+ }
35
+ UI.success(`ds-gate: no closed-set violations${warns.length ? ` (${warns.length} warning(s))` : ''}.`);
36
+ return 0;
37
+ }
38
+
39
+ module.exports = { run: dsGate };
@@ -0,0 +1,335 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+ const { execFileSync } = require('child_process');
5
+
6
+ /**
7
+ * DS reuse gate — the closed-set Selection Policy enforcer (since v4.68.0).
8
+ *
9
+ * BALDART-owned, deterministic, build-time. The design-system analogue of the
10
+ * i18n gate (`src/utils/i18n-gate.js`): a self-contained check BALDART runs so a
11
+ * UI change cannot introduce a NEW canonical component in a role family the
12
+ * design system declared CLOSED (the "third header" failure mode). It rides on
13
+ * `features.has_design_system` — NO new config key.
14
+ *
15
+ * What it does NOT do: it is not an ESLint wrapper (the closure check needs the
16
+ * registry, not the AST) and it does not detect semantically-equivalent rewrites
17
+ * by itself — that is the Slice-C signature-diff. Here the BLOCK is the
18
+ * deterministic, zero-false-positive part: a diff that adds a component claiming
19
+ * a CLOSED family's role, where that component is not a baseline member.
20
+ *
21
+ * Baseline truth = the COMMITTED `INDEX.md` § "Selection Policy" (read via
22
+ * `git show <baselineRef>:<index>`), never the working-tree INDEX — otherwise a
23
+ * change that regenerates the INDEX to include the new member would mask itself.
24
+ *
25
+ * Fail-safe contract (like every BALDART gate): registry absent / policy block
26
+ * absent / git unavailable → SKIP with a reason, never a silent pass and never a
27
+ * crash that blocks unrelated work.
28
+ */
29
+
30
+ const CONFIG_FILE = 'baldart.config.yml';
31
+
32
+ function loadConfig(cwd) {
33
+ try { return yaml.load(fs.readFileSync(path.join(cwd, CONFIG_FILE), 'utf8')) || {}; }
34
+ catch (_) { return null; }
35
+ }
36
+
37
+ /** Extract the fenced `yaml` `families:` map from an INDEX.md § Selection Policy. */
38
+ function parseSelectionPolicy(indexText) {
39
+ if (!indexText) return null;
40
+ const at = indexText.indexOf('## Selection Policy');
41
+ if (at < 0) return null;
42
+ const m = indexText.slice(at).match(/```ya?ml\n([\s\S]*?)```/);
43
+ if (!m) return null;
44
+ try {
45
+ const doc = yaml.load(m[1]);
46
+ return (doc && doc.families && typeof doc.families === 'object') ? doc.families : {};
47
+ } catch (_) { return null; }
48
+ }
49
+
50
+ /** Parse the INDEX router table → { ComponentName: sourcePath }. */
51
+ function parseIndexSources(indexText) {
52
+ const map = {};
53
+ if (!indexText) return map;
54
+ for (const line of indexText.split('\n')) {
55
+ const m = line.match(/^\|\s*([A-Za-z0-9_]+)\s*\|\s*([^|]+?)\s*\|/);
56
+ if (m && m[1] !== 'Component' && !/^-+$/.test(m[1])) map[m[1]] = m[2].trim();
57
+ }
58
+ return map;
59
+ }
60
+
61
+ /** Read the first `---`-fenced frontmatter HEAD of a spec file → parsed object. */
62
+ function parseSpecHead(text) {
63
+ if (!text) return null;
64
+ const m = text.match(/^---\n([\s\S]*?)\n---/);
65
+ if (!m) return null;
66
+ try { return yaml.load(m[1]) || {}; } catch (_) { return null; }
67
+ }
68
+
69
+ /** Closed families as { family: { members:Set<name>, contexts, memberSources:Set } }. */
70
+ function closedFamilies(families, indexSources) {
71
+ const out = {};
72
+ for (const fam of Object.keys(families || {})) {
73
+ const f = families[fam] || {};
74
+ if (!f.closed) continue;
75
+ const members = new Set();
76
+ const memberSources = new Set();
77
+ for (const m of f.members || []) {
78
+ if (m && m.component) {
79
+ members.add(m.component);
80
+ if (indexSources[m.component]) memberSources.add(indexSources[m.component]);
81
+ }
82
+ }
83
+ out[fam] = { members, memberSources, raw: f };
84
+ }
85
+ return out;
86
+ }
87
+
88
+ const familyOf = (role) => {
89
+ const at = String(role).indexOf('@');
90
+ return (at >= 0 ? String(role).slice(0, at) : String(role)).trim();
91
+ };
92
+
93
+ /** Last meaningful segment of a family name, used as the source-heuristic keyword. */
94
+ function familyKeyword(fam) {
95
+ const segs = String(fam).split(/[-_/]/).filter(Boolean);
96
+ return (segs[segs.length - 1] || fam).toLowerCase();
97
+ }
98
+
99
+ /**
100
+ * Pure evaluator (deterministic, git-free → unit-testable).
101
+ * @param closed result of closedFamilies()
102
+ * @param changes [{ file, kind:'spec'|'source', added:bool, name, canonicalFor:[] }]
103
+ * @returns { findings:[...], blockingCount }
104
+ */
105
+ function evaluate(closed, changes) {
106
+ const findings = [];
107
+ for (const ch of changes) {
108
+ if (ch.kind === 'spec') {
109
+ for (const role of ch.canonicalFor || []) {
110
+ const fam = familyOf(role);
111
+ const cf = closed[fam];
112
+ if (!cf) continue; // open family → advisory only
113
+ if (cf.members.has(ch.name)) continue; // an existing canonical member → fine
114
+ findings.push({
115
+ code: 'DS_CLOSED_SET_VIOLATION',
116
+ severity: 'block',
117
+ file: ch.file,
118
+ component: ch.name,
119
+ family: fam,
120
+ members: [...cf.members],
121
+ message: `"${ch.name}" claims role "${role}" but family "${fam}" is CLOSED `
122
+ + `(canonical members: ${[...cf.members].join(', ') || '—'}). Reuse a member, `
123
+ + `or open governance (ADR + /prd reconciliation) to extend the closed set — `
124
+ + `do not ship a new ${fam}.`,
125
+ });
126
+ }
127
+ } else if (ch.kind === 'source' && ch.added) {
128
+ const base = String(ch.name || '').toLowerCase();
129
+ for (const fam of Object.keys(closed)) {
130
+ const cf = closed[fam];
131
+ if (cf.memberSources.has(ch.file)) continue; // it IS a member's source
132
+ const kw = familyKeyword(fam);
133
+ if (kw.length >= 4 && base.includes(kw) && !cf.members.has(ch.name)) {
134
+ findings.push({
135
+ code: 'DS_CLOSED_SET_SUSPECT',
136
+ severity: 'warn',
137
+ file: ch.file,
138
+ component: ch.name,
139
+ family: fam,
140
+ members: [...cf.members],
141
+ message: `new source "${ch.file}" looks like a "${fam}" but does not declare `
142
+ + `canonical_for; family "${fam}" is CLOSED (${[...cf.members].join(', ')}). `
143
+ + `Confirm you are reusing a member, not reinventing one.`,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ }
149
+ return { findings, blockingCount: findings.filter((f) => f.severity === 'block').length };
150
+ }
151
+
152
+ // ---- signature-diff (Slice C — DS_DUPLICATE_PRIMITIVE, advisory) ------------
153
+
154
+ /** Manifest signature of a spec HEAD: the union of prop names, variants, composes. */
155
+ function specSignature(head) {
156
+ const s = new Set();
157
+ if (head && head.props && typeof head.props === 'object') for (const k of Object.keys(head.props)) s.add(`prop:${k}`);
158
+ for (const v of (head && head.variants) || []) s.add(`var:${v}`);
159
+ for (const c of (head && head.composes) || []) s.add(`use:${c}`);
160
+ return s;
161
+ }
162
+
163
+ const jaccard = (a, b) => {
164
+ if (a.size === 0 && b.size === 0) return 0;
165
+ let inter = 0;
166
+ for (const x of a) if (b.has(x)) inter++;
167
+ return inter / (a.size + b.size - inter);
168
+ };
169
+
170
+ /**
171
+ * Flag a newly-added spec whose manifest signature is too close to an existing
172
+ * one — the "semantically-equivalent rewrite" the closed-set check cannot catch
173
+ * by name. ADVISORY (warning), high threshold, never a block: signatures overlap
174
+ * legitimately (two list-like primitives), so a human/critic confirms.
175
+ * @param baseSpecs Map<name, {sig:Set, file}> existing (on-disk, non-added) specs
176
+ * @param added [{ name, sig:Set, file }]
177
+ */
178
+ function signatureDuplicates(baseSpecs, added, threshold = 0.7) {
179
+ const findings = [];
180
+ for (const a of added) {
181
+ if (a.sig.size < 3) continue; // too small to judge
182
+ let best = null;
183
+ for (const [name, b] of baseSpecs) {
184
+ if (name === a.name) continue;
185
+ const j = jaccard(a.sig, b.sig);
186
+ if (j >= threshold && (!best || j > best.j)) best = { name, j, file: b.file };
187
+ }
188
+ if (best) {
189
+ findings.push({
190
+ code: 'DS_DUPLICATE_PRIMITIVE',
191
+ severity: 'warn',
192
+ file: a.file,
193
+ component: a.name,
194
+ message: `new component "${a.name}" has a manifest signature ${Math.round(best.j * 100)}% `
195
+ + `overlapping existing "${best.name}" (${best.file}). Confirm it is not a rewrite of `
196
+ + `an existing primitive — reuse or add a variant instead.`,
197
+ });
198
+ }
199
+ }
200
+ return findings;
201
+ }
202
+
203
+ // ---- git plumbing ----------------------------------------------------------
204
+
205
+ function git(cwd, args) {
206
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
207
+ }
208
+
209
+ /** Resolve the baseline ref to read the COMMITTED INDEX/specs from. */
210
+ function resolveBaseline(cwd, base, trunk) {
211
+ if (base) return base;
212
+ // Prefer the merge-base with the trunk (branch-committed changes); else HEAD.
213
+ for (const t of [trunk, 'main', 'master'].filter(Boolean)) {
214
+ try { return git(cwd, ['merge-base', 'HEAD', t]).trim(); } catch (_) { /* try next */ }
215
+ }
216
+ try { git(cwd, ['rev-parse', 'HEAD']); return 'HEAD'; } catch (_) { return null; }
217
+ }
218
+
219
+ /** Show a path at a ref, or '' if absent there. */
220
+ function showAt(cwd, ref, rel) {
221
+ try { return git(cwd, ['show', `${ref}:${rel}`]); } catch (_) { return ''; }
222
+ }
223
+
224
+ /** Added/modified files in the change set (committed-vs-base ∪ working ∪ untracked). */
225
+ function collectChangedFiles(cwd, baselineRef) {
226
+ const set = new Map(); // file → added(bool); modified overrides to false only if not already added
227
+ const add = (file, added) => { if (!set.has(file)) set.set(file, added); };
228
+ const parseNameStatus = (out, addedCodes) => {
229
+ for (const line of out.split('\n')) {
230
+ if (!line.trim()) continue;
231
+ const parts = line.split('\t');
232
+ const code = parts[0][0];
233
+ const file = parts[parts.length - 1];
234
+ add(file, addedCodes.includes(code));
235
+ }
236
+ };
237
+ try { if (baselineRef) parseNameStatus(git(cwd, ['diff', '--name-status', baselineRef, '--']), ['A']); } catch (_) {}
238
+ try { parseNameStatus(git(cwd, ['diff', '--name-status']), ['A']); } catch (_) {}
239
+ try { parseNameStatus(git(cwd, ['diff', '--name-status', '--cached']), ['A']); } catch (_) {}
240
+ try {
241
+ const un = git(cwd, ['ls-files', '--others', '--exclude-standard']);
242
+ for (const f of un.split('\n')) if (f.trim()) add(f.trim(), true);
243
+ } catch (_) {}
244
+ return set;
245
+ }
246
+
247
+ const PascalFromFile = (rel) => {
248
+ const b = path.basename(rel).replace(/\.(tsx|ts|jsx|js)$/, '');
249
+ return b;
250
+ };
251
+
252
+ /**
253
+ * Full run: load config → read baseline policy → collect diff → classify → report.
254
+ * Returns { schema, ok, skipped?, reason?, findings, blockingCount }.
255
+ */
256
+ function run(opts = {}) {
257
+ const cwd = opts.cwd || process.cwd();
258
+ const config = loadConfig(cwd);
259
+ const result = { schema: 'baldart.ds-gate/1', ok: true, findings: [], blockingCount: 0 };
260
+
261
+ if (!config) return { ...result, skipped: true, reason: 'no-config' };
262
+ const features = config.features || {};
263
+ if (!features.has_design_system) return { ...result, skipped: true, reason: 'no-design-system' };
264
+
265
+ const dsRoot = (config.paths && config.paths.design_system) || '';
266
+ const primitivesRoot = (config.paths && (config.paths.components_primitives || config.paths.components_root)) || '';
267
+ if (!dsRoot) return { ...result, skipped: true, reason: 'no-design-system-path' };
268
+
269
+ const indexRel = path.posix.join(dsRoot, 'INDEX.md');
270
+ const trunk = (config.git && config.git.trunk_branch) || null;
271
+ const baselineRef = resolveBaseline(cwd, opts.base, trunk);
272
+ if (!baselineRef) return { ...result, skipped: true, reason: 'no-git' };
273
+
274
+ const baselineIndex = showAt(cwd, baselineRef, indexRel) || (fs.existsSync(path.join(cwd, indexRel)) ? fs.readFileSync(path.join(cwd, indexRel), 'utf8') : '');
275
+ const families = parseSelectionPolicy(baselineIndex);
276
+ if (!families) return { ...result, skipped: true, reason: 'no-policy' };
277
+ const closed = closedFamilies(families, parseIndexSources(baselineIndex));
278
+ if (Object.keys(closed).length === 0) return { ...result, skipped: true, reason: 'no-closed-families' };
279
+
280
+ const changedFiles = collectChangedFiles(cwd, baselineRef);
281
+ const specDir = path.posix.join(dsRoot, 'components') + '/';
282
+ const changes = [];
283
+ for (const [file, added] of changedFiles) {
284
+ const norm = file.replace(/\\/g, '/');
285
+ if (norm.startsWith(specDir) && norm.endsWith('.md')) {
286
+ // a component spec → read its (working-tree, post-change) HEAD
287
+ let text = '';
288
+ try { text = fs.readFileSync(path.join(cwd, file), 'utf8'); } catch (_) { continue; }
289
+ const head = parseSpecHead(text);
290
+ if (!head) continue;
291
+ changes.push({
292
+ file, kind: 'spec', added,
293
+ name: head.name || PascalFromFile(norm),
294
+ canonicalFor: Array.isArray(head.canonical_for) ? head.canonical_for : [],
295
+ sig: specSignature(head),
296
+ });
297
+ } else if (primitivesRoot && norm.startsWith(primitivesRoot.replace(/\\/g, '/')) && /\.(tsx|jsx|ts|js)$/.test(norm) && !/\.(test|spec|stories)\./.test(norm)) {
298
+ changes.push({ file, kind: 'source', added, name: PascalFromFile(norm), canonicalFor: [] });
299
+ }
300
+ }
301
+
302
+ const ev = evaluate(closed, changes);
303
+ result.findings = ev.findings;
304
+
305
+ // Signature-diff (advisory): compare each ADDED spec against the on-disk
306
+ // sibling specs (the non-added ones are the baseline).
307
+ const addedSpecs = changes.filter((c) => c.kind === 'spec' && c.added && c.sig);
308
+ if (addedSpecs.length) {
309
+ const addedNames = new Set(addedSpecs.map((c) => c.name));
310
+ const baseSpecs = new Map();
311
+ const compDir = path.join(cwd, dsRoot, 'components');
312
+ let files = [];
313
+ try { files = fs.readdirSync(compDir).filter((f) => f.endsWith('.md')); } catch (_) {}
314
+ for (const f of files) {
315
+ const name = f.replace(/\.md$/, '');
316
+ if (addedNames.has(name)) continue;
317
+ try {
318
+ const h = parseSpecHead(fs.readFileSync(path.join(compDir, f), 'utf8'));
319
+ if (h) baseSpecs.set(h.name || name, { sig: specSignature(h), file: path.posix.join(dsRoot, 'components', f) });
320
+ } catch (_) {}
321
+ }
322
+ result.findings.push(...signatureDuplicates(baseSpecs, addedSpecs));
323
+ }
324
+
325
+ result.blockingCount = result.findings.filter((f) => f.severity === 'block').length;
326
+ result.ok = result.blockingCount === 0;
327
+ result.baselineRef = baselineRef;
328
+ return result;
329
+ }
330
+
331
+ module.exports = {
332
+ parseSelectionPolicy, parseIndexSources, parseSpecHead, closedFamilies,
333
+ familyOf, familyKeyword, evaluate, specSignature, signatureDuplicates,
334
+ collectChangedFiles, resolveBaseline, run,
335
+ };