@adia-ai/adia-ui-forge 0.8.50 → 0.8.52

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.
Files changed (31) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +30 -0
  4. package/README.md +23 -1
  5. package/__init__.py +63 -0
  6. package/agents/a2ui-maintenance-agent.md +6 -4
  7. package/agents/a2ui-planner-agent.md +5 -5
  8. package/agents/demo-audit-agent.md +4 -4
  9. package/agents/framework-checker.md +5 -5
  10. package/agents/framework-planner-agent.md +7 -6
  11. package/agents/gen-ui-review-agent.md +4 -4
  12. package/agents/package-release-agent.md +4 -2
  13. package/agents/primitive-authoring-agent.md +1 -0
  14. package/package.json +10 -2
  15. package/plugin.yaml +4 -0
  16. package/prompts/demo-audit.md +10 -0
  17. package/prompts/gen-ui-review.md +9 -0
  18. package/prompts/package-release.md +12 -0
  19. package/prompts/site-deployment.md +9 -0
  20. package/skills/cross-harness-authoring-standards/SKILL.md +120 -0
  21. package/skills/cross-harness-authoring-standards/agents/openai.yaml +3 -0
  22. package/skills/cross-harness-compatibility-standards/SKILL.md +219 -0
  23. package/skills/cross-harness-compatibility-standards/agents/openai.yaml +3 -0
  24. package/skills/package-release/references/cut-procedure.md +7 -0
  25. package/skills/package-release/scripts/gate-roster.mjs +5 -0
  26. package/skills/package-release/scripts/release-pack.mjs +244 -42
  27. package/skills/primitive-authoring/references/INDEX.md +1 -1
  28. package/skills/primitive-authoring/references/api-contract.md +35 -0
  29. package/skills/primitive-authoring/references/css-patterns.md +79 -0
  30. package/skills/primitive-authoring/references/lifecycle-patterns.md +17 -0
  31. package/skills/primitive-authoring/references/module-promotion.md +2 -0
@@ -0,0 +1,219 @@
1
+ ---
2
+ name: cross-harness-compatibility-standards
3
+ description: >-
4
+ The engineering/architecture standard for actually BUILDING a Claude
5
+ agent-family (or any multi-runtime capability) so it genuinely works
6
+ across Claude Code, Codex, Pi, and Hermes — not the consumer-facing
7
+ install docs once it already works (that's
8
+ cross-harness-authoring-standards). Use when deciding whether a
9
+ capability earns a new user-facing skill or should stay a thin
10
+ runtime-specific agent/seat/extension, before writing a Pi extension or
11
+ Hermes command and unsure whether the target API actually exists,
12
+ defining a structured handoff record two or more runtimes must exchange,
13
+ or naming a new plugin distribution/marketplace channel alongside an
14
+ existing one. NOT for writing the resulting per-harness install commands
15
+ (cross-harness-authoring-standards owns that, once the capability
16
+ already works).
17
+ disable-model-invocation: false
18
+ user-invocable: true
19
+ ---
20
+
21
+ # cross-harness-compatibility-standards — build it portable, don't just document it
22
+
23
+ `cross-harness-authoring-standards` covers the downstream concern: writing the
24
+ consumer-facing install instructions once a capability already works across
25
+ harnesses. This skill covers the upstream concern — the engineering standard
26
+ for actually **building** that capability so it works on Claude Code, Codex,
27
+ Pi, and Hermes in the first place. If you're drafting a "how to install"
28
+ block, you want the other skill; if you're deciding whether something needs
29
+ a new skill, writing a Pi/Hermes adapter, or designing a structured record
30
+ two runtimes must exchange, you're in the right place.
31
+
32
+ This standard is not invented doctrine — it is extracted from
33
+ [ADR-0091](../../../../../docs/ops/adr/adr-0091-ui-architect-cross-harness-portability.md)
34
+ and its companion
35
+ [PRD](../../../../../docs/ops/prd/prd-ui-architect-cross-harness-portability.md)/
36
+ [SPEC](../../../../../docs/ops/spec/spec-ui-architect-cross-harness-portability.md)/
37
+ [LLD](../../../../../docs/ops/lld/lld-ui-architect-cross-harness-portability.md),
38
+ ratified and built across four merged PRs (#1997 docs, #1998 skill + contracts
39
+ + Codex marketplace, #1999 agent pinning, #2000 Pi extension + Hermes
40
+ command) that made `ui-architect` portable. Every rule below cites the
41
+ decision or requirement it comes from — re-read the source before extending
42
+ this standard, don't paraphrase from this skill alone.
43
+
44
+ ## Rule 1 — skill vs. thin adapter: earn the promotion `[gate]`
45
+
46
+ Substantive reusable behavior belongs in a portable skill body; a
47
+ runtime-specific agent/seat/extension stays **only if** it provides one of:
48
+ restricted tools, isolated context, parallelism, multi-skill preload, or a
49
+ distinct model/effort configuration (ADR-0091 D1). Delete an agent, or fold
50
+ it into its skill, if it provides none of those structural properties. Never
51
+ convert every internal seat into a user-facing skill merely because
52
+ portability is required — a capability is user-invocable only when a user
53
+ has a sensible reason to request it directly, not because promoting it was
54
+ convenient.
55
+
56
+ Apply the rule by checking each existing seat against it, on the record, not
57
+ by assertion:
58
+
59
+ - `ui-architect` was the one seat of four in `adia-ui-factory` missing its
60
+ skill twin — it earned `skills/ui-architect/SKILL.md` because the
61
+ Gear-selection/wave/generator-≠-reviewer/four-lap-cap/failure-routing
62
+ procedure is exactly the "substantive reusable behavior" class the rule
63
+ routes to a skill, and Codex/Pi/Hermes users are the concrete "sensible
64
+ reason to request it directly." The agent seat was **retained, not
65
+ replaced**: no Write/Edit (restricted tools), dispatches into isolated
66
+ per-screen contexts (isolated context + parallelism), pinned `fable`/
67
+ `high` never-`inherit` (distinct model/effort) — independently sufficient
68
+ to keep it.
69
+ - `app-planning-agent`, `screen-composition-agent`, `surface-qa-agent`
70
+ already each preloaded a same-named user-invocable skill before this
71
+ change touched anything — the rule was already satisfied; nothing about
72
+ their shape changed beyond the same Claude-adapter slimming (Rule 3)
73
+ applied to all four seats equally.
74
+ - `wave-coordination` stayed `user-invocable: false`, Claude-only,
75
+ unpromoted — team-pattern *mechanics* for one runtime's dispatch model,
76
+ not a capability a Codex/Pi/Hermes user has a sensible reason to request
77
+ directly. Failing the rule's own admission test on purpose is a legitimate
78
+ outcome, not an oversight to fix later.
79
+
80
+ ## Rule 2 — verify before coding, per harness, every time `[gate]`
81
+
82
+ Never assume a target harness's API, extension mechanism, or delegation
83
+ surface from documentation alone. Check the actually-installed version
84
+ live — read its real source, run its real CLI — before writing one line of
85
+ adapter code against it. This is the single most load-bearing lesson from
86
+ how `ui-architect`'s Pi and Hermes adapters were actually built (PR #2000):
87
+
88
+ - **Pi** was verified present by reading the installed
89
+ `@earendil-works/pi-coding-agent` package directly: its own shipped
90
+ `examples/extensions/subagent/` proved a real subprocess-based
91
+ subagent-dispatch pattern (`registerCommand` + `child_process` spawn of a
92
+ nested `pi --mode json -p --no-session`), and `package.json`'s
93
+ `pi.extensions` field was confirmed real and documented — not inferred by
94
+ analogy to Claude's Agent tool. Once verified, the extension was
95
+ **live-tested end to end**: a real `pi` session ran `/ui-architect`
96
+ against a live model, dispatched all three isolated passes, wrote a real
97
+ file, took a real screenshot, and returned a clean `VerifyProof`.
98
+ - **Hermes** was verified sufficient by reading the installed Hermes
99
+ Agent's own source — `PluginContext.register_command` + `dispatch_tool`,
100
+ and `tools/delegate_tool.py`'s `delegate_task(goal, context, role, ...)` —
101
+ confirmed as real, current APIs before the generator surgery (Rule 3)
102
+ was written against them. The generated command was built behind its own
103
+ `--selftest` fixture (a real `py_compile` check plus a fake `delegate_task`
104
+ double driving the actual generated logic against the real linters)
105
+ *before* wiring it into the real plugin tree, then verified live via
106
+ `hermes plugins doctor --ci`. A full interactive dispatch was blocked by
107
+ the build sandbox refusing to mutate Hermes's global plugin-enable
108
+ state — that limitation was **noted honestly in the README rather than
109
+ worked around or silently dropped**.
110
+
111
+ If a required capability is genuinely absent, or materially different from
112
+ what the docs claimed (SPEC REQ-011/012's capability-boundary discipline):
113
+ stop that adapter at an explicit, clearly-labeled capability boundary and
114
+ record four things — the API checked, the observed mismatch, the smallest
115
+ viable fallback, and the remaining work. Never simulate success with an
116
+ unverified manifest field or a dead adapter file that looks like it works
117
+ but was never actually exercised. One of the two outcomes (working adapter
118
+ + passing native smoke test, or a capability-boundary record with all four
119
+ fields) must exist — never neither, never both.
120
+
121
+ ## Rule 3 — one authored copy, many thin consumers
122
+
123
+ The portable behavior lives in exactly **one** skill body. Every runtime's
124
+ own adapter mechanism *consumes* that one body — it never gets a second,
125
+ hand-copied prose duplicate:
126
+
127
+ - **Claude** preloads it (`skills:` frontmatter on the owning agent file).
128
+ - **Codex** auto-derives its manifest sibling
129
+ (`skills/<name>/agents/openai.yaml`) from the same skill frontmatter via
130
+ this repo's own generator (`scripts/build/codex-manifests.mjs`) — no
131
+ manual authoring, ever.
132
+ - **Pi**'s extension reads the skill file *at runtime* rather than copying
133
+ its prose into the extension source (`ui-architect`'s
134
+ `pi/ui-architect.extension.ts` loads agent descriptions from the
135
+ plugin-local skill directly).
136
+ - **Hermes**'s command derives from the same source
137
+ (`scripts/build/harness-manifests.mjs`'s generated `__init__.py`
138
+ registers the skill; the generated `/ui-architect` command runs the same
139
+ planning/composition/QA loop through `delegate_task`, gated by the same
140
+ contracts).
141
+
142
+ A change to the workflow means editing one file, not four. If you find
143
+ yourself pasting a skill's prose into an agent file, an extension, or a
144
+ generator template instead of citing or reading it, stop — that's the
145
+ duplication this rule exists to prevent.
146
+
147
+ ## Rule 4 — one contract grammar, not a schema per runtime
148
+
149
+ When two or more runtimes need to exchange a structured handoff record,
150
+ define it once as a plain strict-prose-record shape validated by a small
151
+ stdlib CLI linter — never introduce JSON Schema or another
152
+ schema-validation library into every runtime's own toolchain to do the same
153
+ job (ADR-0091 D3). This repo's own `record-lint` family is the reusable
154
+ grammar: `<script> <file>|-|selftest`, exit 0 clean / 1 on findings, with
155
+ `UNMEASURED — <reason>` as a legal per-slot value (silent omission is not
156
+ legal). `OrientationRecord` (`scripts/record-lint`), `VerifyProof`
157
+ (`scripts/verify-proof-lint`), and `BuildResult`
158
+ (`scripts/build-result-lint`) — all three in `adia-ui-factory`'s
159
+ `scripts/` — are structurally identical siblings, not three different
160
+ formats doing the same job. Every runtime adapter that produces or consumes
161
+ one of these contracts calls the same linter script; a second,
162
+ runtime-specific parsing or validation implementation for the same contract
163
+ is a defect, not a stylistic choice.
164
+
165
+ Before minting a new contract shape, check whether an existing one already
166
+ covers it under a different name — reuse beats a fourth near-duplicate
167
+ grammar.
168
+
169
+ ## Rule 5 — name distribution channels distinctly
170
+
171
+ When the same plugin ships **both** an in-repo/access-gated channel and a
172
+ public/npm-sourced channel for the same runtime, name them distinctly. A
173
+ collision — both channels sharing one marketplace name — makes
174
+ `<plugin>@<marketplace-name>` resolution ambiguous for a consumer trying to
175
+ install from a specific channel.
176
+
177
+ The confirmed, working precedent is Claude's own two-channel split: the
178
+ repo-root, access-gated `.claude-plugin/marketplace.json` is named
179
+ `adia-ui-kit`; the public, npm-sourced
180
+ `packages/plugins/adia-plugins/marketplace.json` is named `adia-plugins`
181
+ (ADR-0091 D2's own table) — two distinct names, two distinct reachability
182
+ tiers, no ambiguity for a consumer running `/plugin install
183
+ <plugin>@<marketplace-name>`.
184
+
185
+ ADR-0091 D2 designed the Codex side to mirror that same split (a
186
+ repo-root/local-source channel alongside the existing
187
+ `packages/plugins/adia-plugins/.agents/plugins/marketplace.json` npm
188
+ sibling) — but the LLD (§C7) flagged the new manifest's `name` field as an
189
+ explicit, unverified, build-time question: does Codex key off `name` for
190
+ `<plugin>@<marketplace-name>` resolution the way Claude does? **Verify this
191
+ against the actually-shipped manifests before citing the Codex side as a
192
+ settled precedent** — Rule 2's verify-before-coding discipline applies to
193
+ this rule's own worked example, not only to Pi/Hermes adapters. Diff the
194
+ `name` field of both Codex marketplace manifests against each other before
195
+ assuming they follow the Claude-side pattern.
196
+
197
+ ## Failure branches
198
+
199
+ A capability promoted to a user-facing skill with none of Rule 1's five
200
+ qualifying properties on its remaining agent seat → fold the seat into the
201
+ skill or delete it; don't keep a Write/Edit-free, non-isolated, no-model-pin
202
+ shell around "for consistency." An adapter written against a harness's
203
+ documented API with no live verification → stop, verify against the
204
+ installed runtime first (Rule 2); a passing test against a mocked or
205
+ assumed API is not verification. A second hand-copied prose block for a
206
+ skill's workflow anywhere outside its one `SKILL.md` → delete the copy,
207
+ point the consumer at the source (Rule 3). A new structured record
208
+ introduced as JSON Schema, or as a shape that already has a linter under a
209
+ different name → stop, extend `record-lint`'s family instead or reuse the
210
+ existing contract (Rule 4). A new distribution manifest reusing an existing
211
+ sibling's marketplace name → rename before shipping (Rule 5).
212
+
213
+ Done when: every new or promoted seat cites the specific Rule 1 property
214
+ that keeps it (or is deleted/folded); every harness adapter names the API
215
+ it verified live and how (Rule 2); the skill body has exactly one authored
216
+ copy with every consumer citing or reading it, never duplicating it (Rule
217
+ 3); every cross-runtime contract has exactly one shape and one stdlib
218
+ linter (Rule 4); and every distribution channel's marketplace name is
219
+ diffed against its siblings and confirmed distinct (Rule 5).
@@ -0,0 +1,3 @@
1
+ interface:
2
+ display_name: "Cross Harness Compatibility Standards"
3
+ short_description: "The engineering/architecture standard for actually BUILDING a Claude agent-family (or any multi-runtime capability) so it genuinely works across Claude Code, Codex, Pi, and Hermes — not the consumer-facing install docs once it already works (that's cross-harness-authoring-standards)."
@@ -89,6 +89,8 @@ When only source *hashes* move and chunk content does not, `check:embeddings-fre
89
89
 
90
90
  ### 3.1 The full roster — every gate runs; a subset = pre-flight failure
91
91
 
92
+ **Execution model (gh#2006): three phases, not one serial walk.** `step3PreFlight()` runs gate 4 solo first (see its own note below), then gates 16 → 27 → 28 strictly in order (the eval-health write-then-read dependency — gate 28 reads whichever `evals/mcp/runs/` directory sorts lexically LAST, so nothing else may write there between 27 and 28), concurrently with a bounded pool running every other gate at once (`PREFLIGHT_CONCURRENCY`, default 4 — override for a dedicated/idle host). Every gate still resolves the same command, still fails the whole pre-flight on a red result, and still reports its own number — only the WALL-CLOCK schedule changed, never the roster below or its numbering. `--dry` previews stay the original flat serial walk unchanged.
93
+
92
94
  ```bash
93
95
  node scripts/build/components.mjs --verify # 1 yaml ↔ sidecar ↔ .d.ts
94
96
  npm run verify:traits # 2 trait coverage
@@ -120,6 +122,8 @@ npm run eval:diff -- --engine free-form # 27 fresh free-form
120
122
  node scripts/release/write-eval-health.mjs --version <cut> # 28 evals/health/<version>.json committed, AC-01/AC-02 run for real (gh#1135, WS-4 SPEC REQ-06)
121
123
  node scripts/release/check-estate-split-latch.mjs # 29 no lockstep cut mid estate-split (ADR-0048 / gh#1192) — see the note below
122
124
  npm run check:catalog-tiers # 30 tier-index.json vs committed catalog (gh#1494 — ADR-0069 moved its PR-blocking half to derived-resync; the pre-cut roster re-asserts Class-R freshness before a tag. Gate 13 can't catch this: the harvester hashes tier-index.json as a SOURCE)
125
+ npm run check:codex-manifests-fresh # 31 Codex plugin.json + openai.yaml vs .claude-plugin/plugin.json SoT (gh#1888)
126
+ npm run check:harness-manifests-fresh # 32 Hermes/Pi plugin.yaml + __init__.py + prompts vs .claude-plugin/plugin.json + commands SoT (gh#1954)
123
127
  ```
124
128
 
125
129
  **Gate 29 was the ADR-0048 latch; since P5 it is a permanent invariant.** Between P1 and P5 the repo was correct in-repo but deliberately **not publishable** (old-name stubs marked `private: true` that the roster still mapped, plus dependency edges onto workspace packages no cut published), and a cut in that window would have shipped broken packages that npm cannot unpublish. **P5 cleared it by landing the real thing** — the six stubs became publishable shims and `PACKAGE_ROSTER` gained the three remaining new names, at which point all 9 offending edges resolved and the gate went green on its own. No gate logic was changed.
@@ -153,6 +157,8 @@ Any red → route via [`gates-catalog.md`](gates-catalog.md); fix at the source,
153
157
 
154
158
  **4d.6. Regenerate the Codex plugin manifests** (gh#1888, gh#1899): `node scripts/build/codex-manifests.mjs`, then `npm run check:codex-manifests-fresh` to confirm. Both plugins' `.codex-plugin/plugin.json` embed `version` from `.claude-plugin/plugin.json`, which the 4b bump just moved — same "derived artifact carries the lockstep version" class as 4d.5's genui catalog. Gate 31 runs pre-bump in Step 3 and only proves freshness against the PREVIOUS version; nothing re-ran the generator post-bump before this line existed — the v0.8.48 release PR (#1897) shipped stale manifests as a result, caught by `check:codex-manifests-fresh` in CI and fixed by hand on the release branch. `packages/plugins/*/.codex-plugin/plugin.json` and `packages/plugins/*/skills/*/agents/openai.yaml` ride the release commit (Step 5 stages them).
155
159
 
160
+ **4d.7. Regenerate the Hermes/Pi plugin manifests** (gh#1954): `node scripts/build/harness-manifests.mjs`, then `npm run check:harness-manifests-fresh` to confirm. Same hazard as 4d.6, same fix — both plugins' `plugin.yaml` embed `version` from `.claude-plugin/plugin.json`. Gate 32 runs pre-bump in Step 3 and only proves freshness against the PREVIOUS version; run this post-bump every cut, not just when a skill/command changed. `packages/plugins/*/plugin.yaml`, `packages/plugins/*/__init__.py`, `packages/plugins/*/hermes-mcp.yaml` (factory only), and `packages/plugins/*/prompts/*.md` ride the release commit (Step 5 stages them).
161
+
156
162
  **4e. Release docs + team notes** (gated, not optional): review the entry
157
163
  files the release touches (root README/CHANGELOG, per-package READMEs —
158
164
  content currency is YOUR judgment; the gate only proves presence), then
@@ -215,6 +221,7 @@ npm install --package-lock-only --no-audit --no-fund
215
221
  npm run check:lockstep # 4d
216
222
  node scripts/build/derive-genui-catalog.mjs # 4d.5 — catalogId carries the bumped version (gh#617)
217
223
  node scripts/build/codex-manifests.mjs # 4d.6 — Codex manifest version carries the bumped version (gh#1899)
224
+ node scripts/build/harness-manifests.mjs # 4d.7 — Hermes/Pi manifest version carries the bumped version (gh#1954)
218
225
  node scripts/release/check-release.mjs --pending-version X.Y.Z --fix # 4f — AFTER the stubs exist
219
226
  node scripts/release/generate-release-notes.mjs --version X.Y.Z --write # 4e
220
227
  node scripts/release/check-release-docs.mjs --version X.Y.Z # 4e gate
@@ -137,6 +137,11 @@ export const GATE_ROSTER = [
137
137
  // hazard the same way a stale dist bundle is a real Claude-install
138
138
  // hazard. Appended, not inserted — same numbering discipline as gate 30.
139
139
  { n: 31, cmd: 'npm run check:codex-manifests-fresh', what: 'Codex plugin.json + openai.yaml vs .claude-plugin/plugin.json SoT' },
140
+ // gh#1954: the Hermes (plugin.yaml + __init__.py + hermes-mcp.yaml) and Pi
141
+ // (prompts/*.md) external-distribution overlays are derived the same way
142
+ // gate 31's Codex tree is — a stale copy is the same real install hazard.
143
+ // Appended, not inserted — same numbering discipline as gate 30/31.
144
+ { n: 32, cmd: 'npm run check:harness-manifests-fresh', what: 'Hermes/Pi plugin.yaml + __init__.py + prompts vs .claude-plugin/plugin.json + commands SoT' },
140
145
  ];
141
146
 
142
147
  // -- CLI ------------------------------------------------------------------
@@ -60,7 +60,8 @@
60
60
  // block BEFORE the prompt (H5) — never a bare y/N — whether it waits for input
61
61
  // (manual flags) or auto-confirms (--go).
62
62
 
63
- import { execSync } from 'node:child_process';
63
+ import { execSync, exec as execCb } from 'node:child_process';
64
+ import { promisify } from 'node:util';
64
65
  import { createInterface } from 'node:readline/promises';
65
66
  import { stdin, stdout } from 'node:process';
66
67
  import fs from 'node:fs';
@@ -347,6 +348,15 @@ function shCapture(cmd, args) {
347
348
  }
348
349
  }
349
350
 
351
+ // Async, CAPTURING exec — the concurrent pre-flight phases (gh#2006) can't
352
+ // use sh()'s stdio:'inherit' (N processes' live output interleaved is
353
+ // unreadable), so every gate's stdout+stderr is captured and printed as one
354
+ // block the moment that gate finishes instead. maxBuffer generous (64 MB) —
355
+ // a truncated capture on a real failure (tsc, vitest) would read as a false
356
+ // pass/silent-gap the same class of defect shFailure's own doc-comment warns
357
+ // against.
358
+ const execAsync = promisify(execCb);
359
+
350
360
  // ── Half-cut detection (gh#765) ──────────────────────────────────
351
361
  // An aborted Step-4 run (the v0.8.29 cut aborted three times at Step 4e)
352
362
  // leaves a half-cut tree: package versions bumped to the cut version +
@@ -428,53 +438,245 @@ function step1ReBaseline(args) {
428
438
  console.log(' (empty — origin not ahead)');
429
439
  }
430
440
 
431
- // ── Step 3 — Pre-flight gates ────────────────────────────────────
432
- function step3PreFlight(args) {
433
- if (args.skipGates) {
434
- console.log('\n=== Step 3 Pre-flight (SKIPPED via --skip-gates; danger!) ===');
441
+ // ── Step 3 — Pre-flight gates (gh#2006) ───────────────────────────
442
+ //
443
+ // gh#1987's audit found the real "feels slow" lever isn't roster size (all
444
+ // 32 gates are justified defense-in-depth `main`'s branch protection only
445
+ // hard-requires the browser-scheme probe, so the `npm run check` name-
446
+ // duplicates in this roster are real re-checks, not dead weight) — it's
447
+ // that step3PreFlight ran every gate one at a time. Most of the 32 are
448
+ // independent, read-only checks with no shared mutable state; this splits
449
+ // them into three phases instead of one flat serial for-loop. Two real
450
+ // sequencing requirements survived the audit — everything else runs
451
+ // concurrently:
452
+ //
453
+ // Phase 1 (solo) — gate 4 (test:unit:serial) alone, no sibling gate
454
+ // process running at the same time. Not a proven requirement — the
455
+ // roster's own "serial on purpose" note is about vitest's OWN
456
+ // --no-file-parallelism, not about co-running with sibling PROCESSES —
457
+ // but a deliberately conservative one: gate 4 is the roster's single
458
+ // heaviest gate, and this repo's own host-audit F3 remedy
459
+ // (scripts/dev/worker-cap.mjs, gh#1792) exists precisely because
460
+ // unbounded concurrent lanes on a shared multi-session host turn a gate
461
+ // that's clean in isolation red under contention. Revisit with real
462
+ // measurement before loosening this.
463
+ // Phase 2 (concurrent) — two workstreams launched together and awaited
464
+ // together:
465
+ // (a) a bounded-concurrency pool of every remaining gate EXCEPT the
466
+ // eval-health chain below — each independently read-only
467
+ // (--verify/--fix-gated/dry-diff, checked directly against every
468
+ // script in the roster) or, for gate 17 (dogfood:status), writing
469
+ // only to a gate-private path (qa/findings/dogfood-tracker.md)
470
+ // no other gate reads or writes;
471
+ // (b) the eval-health chain — gates 16 → 27 → 28 — run strictly
472
+ // serially relative to EACH OTHER (still concurrent with (a),
473
+ // since neither touches (a)'s files). This is the roster's one
474
+ // CSS-before-JS-shaped file-write ordering dependency: gate 27
475
+ // (`eval:diff --engine free-form`) writes a fresh
476
+ // evals/mcp/runs/<ISO>/ directory; gate 28
477
+ // (write-eval-health.mjs) reads whichever directory under
478
+ // evals/mcp/runs/ SORTS LEXICALLY LAST — not specifically the
479
+ // latest free-form run — and expects free-form.json there. Gate
480
+ // 16 (`eval:diff --engine zettel`) writes its OWN fresh directory
481
+ // under that same root; if 16 and 27 ran concurrently and 16
482
+ // happened to finish after 27, gate 28 would silently pick 16's
483
+ // directory (no free-form.json) and commit
484
+ // evals/health/<version>.json with `engines.free-form: null` — a
485
+ // real, silent coverage loss the roster's fixed serial order
486
+ // prevents today only by construction. Keeping 16 before 27
487
+ // before 28, uninterrupted by any other evals/mcp/runs/ writer,
488
+ // preserves that guarantee.
489
+ //
490
+ // Fail-fast BETWEEN phases (a Phase 1 failure skips Phase 2 outright — no
491
+ // point spending Phase 2's wall-clock once the roster's known-flakiest gate
492
+ // is already red); fail-TOGETHER within Phase 2 (every Phase 2 gate runs to
493
+ // completion regardless of a sibling's failure, so one red pre-flight run
494
+ // reports every failing gate at once instead of the old one-at-a-time
495
+ // discovery) — the explicit fail-fast-vs-fail-together decision gh#2006's
496
+ // acceptance criteria calls for. Within the eval-health chain specifically,
497
+ // a failure DOES stop the chain (16 failing skips 27 and 28; 27 failing
498
+ // skips 28) — that trio has a real dependency relationship the rest of
499
+ // Phase 2 doesn't, so it keeps today's strict fail-fast semantics.
500
+ //
501
+ // versionArg/targetVersionArg/resume-mode branching is unchanged — every
502
+ // gate resolves its actual command through the same resolveGateCommand()
503
+ // logic step3PreFlight always applied, now factored out so all three
504
+ // phases share it verbatim.
505
+
506
+ const SOLO_GATE_NUMBER = 4; // test:unit:serial — see the phase-model note above.
507
+ const EVAL_HEALTH_CHAIN_NUMBERS = [16, 27, 28]; // eval:diff zettel → eval:diff free-form → write-eval-health.mjs
508
+
509
+ // Concurrency cap for Phase 2's pool — a fixed, deliberately conservative
510
+ // constant rather than the host's full core count. Most Phase-2 gates are
511
+ // lightweight verify/diff scripts, but a few (typecheck, the bundle
512
+ // verifiers, corpus verify) are real tsc/esbuild/Node processes, and
513
+ // worker-cap.mjs's own host-audit F3 finding is that unbounded concurrent
514
+ // lanes on a shared multi-session host reintroduce exactly the contention
515
+ // class this ticket must not reintroduce. Override via PREFLIGHT_CONCURRENCY
516
+ // for a dedicated/idle host.
517
+ const PREFLIGHT_CONCURRENCY = Number(process.env.PREFLIGHT_CONCURRENCY) || 4;
518
+
519
+ // Resolves one gate's actual command string — the same versionArg/
520
+ // targetVersionArg/resume branching step3PreFlight has always applied.
521
+ // Returns null for a versionArg gate deferred by an in-flight resume (Step
522
+ // 4g re-runs it once the notes it needs actually exist).
523
+ function resolveGateCommand(g, args) {
524
+ // versionArg gates take a MODE-DEPENDENT version (both directions bit the
525
+ // first 0.8.8 run): cut modes run pre-flight PRE-BUMP, where the README
526
+ // "Current version" claim still equals the PREVIOUS version (the bump
527
+ // moves it in Step 4; Step 4g then re-proves at the cut version) — while
528
+ // handoff runs POST-MERGE, where the claim already equals the CUT version
529
+ // and validating at previous fails by construction. A RESUMED half-cut run
530
+ // (gh#765) is post-bump too: the earlier aborted run already moved every
531
+ // reference to the cut version, so validating at previous false-fails
532
+ // gate 24 exactly like handoff would.
533
+ const hygieneVersion = (args.mode === 'handoff' || args.resume) ? args.version : args.previous;
534
+ // A RESUMED run's versionArg gates (cut-hygiene) demand artifacts Step 4
535
+ // has not recreated yet — docs/ops/releases/vX.Y.Z.md exists only after 4e,
536
+ // which runs AFTER this pre-flight. Step 4g re-runs these same gates at
537
+ // the cut version once the notes exist, so skipping them here loses no
538
+ // coverage — running them here false-fails by construction (the v0.8.32
539
+ // cut hit exactly this: 23/24 green, gate 24 dead on the missing notes).
540
+ if (g.versionArg && args.resume) return null;
541
+ if (g.versionArg) return `${g.cmd} --version ${hygieneVersion}`;
542
+ // targetVersionArg (gh#1135, REQ-06) is a distinct flag from versionArg:
543
+ // write-eval-health.mjs NAMES a new file after the cut's target version
544
+ // from the very start — there is no pre/post-bump claim to reconcile the
545
+ // way gate 24's README-currency check has, so it always takes
546
+ // `args.version` verbatim regardless of mode or resume.
547
+ if (g.targetVersionArg) return `${g.cmd} --version ${args.version}`;
548
+ return g.cmd;
549
+ }
550
+
551
+ // Runs ONE gate to completion, capturing rather than streaming its output —
552
+ // see the file-header note above for why. Never throws: a failed gate is a
553
+ // normal, expected outcome the caller decides how to act on, exactly like
554
+ // shOk()'s own contract.
555
+ async function runGateCaptured(g, args) {
556
+ const cmd = resolveGateCommand(g, args);
557
+ if (cmd === null) {
558
+ return { n: g.n, cmd: g.cmd, what: g.what, ok: true, skipped: true,
559
+ note: '[resume] deferred to Step 4g (notes not regenerated yet)' };
560
+ }
561
+ const start = Date.now();
562
+ try {
563
+ const { stdout, stderr } = await execAsync(cmd, { cwd: REPO, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
564
+ return { n: g.n, cmd, what: g.what, ok: true, stdout, stderr, ms: Date.now() - start };
565
+ } catch (e) {
566
+ return {
567
+ n: g.n, cmd, what: g.what, ok: false, ms: Date.now() - start,
568
+ status: e.code ?? (e.signal ? `signal ${e.signal}` : '?'),
569
+ stdout: e.stdout, stderr: e.stderr,
570
+ };
571
+ }
572
+ }
573
+
574
+ // Prints one captured gate's result in the same shape a serial gate would
575
+ // have streamed live, so a scan-back-through-the-log habit built on the old
576
+ // stdio:'inherit' output still works.
577
+ function printGateResult(r) {
578
+ if (r.skipped) {
579
+ console.log(`\n ${r.n}/${GATE_ROSTER.length}. ${r.cmd} # ${r.what} — ${r.note}`);
435
580
  return;
436
581
  }
437
- console.log(`\n=== Step 3 — Pre-flight gates (${GATE_ROSTER.length}-gate roster gate-roster.mjs) ===`);
582
+ console.log(`\n ${r.n}/${GATE_ROSTER.length}. ${r.cmd} # ${r.what} (${(r.ms / 1000).toFixed(1)}s)`);
583
+ const out = [r.stdout, r.stderr].map((s) => (s || '').toString().trim()).filter(Boolean).join('\n');
584
+ if (out) console.log(out.split('\n').map((l) => ` ${l}`).join('\n'));
585
+ if (!r.ok) {
586
+ console.error(` ✗ FAILED (${r.n}/${GATE_ROSTER.length}): ${r.cmd} (exit ${r.status})`);
587
+ }
588
+ }
589
+
590
+ // Runs a fixed array of gates strictly one after another, in array order —
591
+ // the eval-health chain (16 → 27 → 28), where the ordering IS the
592
+ // correctness requirement (see the phase-model note above), never a
593
+ // scheduling nicety. A failure stops the chain — the gates after it have a
594
+ // real dependency on what it would have produced, unlike the pool below.
595
+ async function runGateChain(gates, args) {
596
+ const results = [];
597
+ for (const g of gates) {
598
+ const r = await runGateCaptured(g, args);
599
+ printGateResult(r);
600
+ results.push(r);
601
+ if (!r.ok) break;
602
+ }
603
+ return results;
604
+ }
605
+
606
+ // Runs a list of gates concurrently, capped at `limit` in flight at once —
607
+ // a minimal pool, not a dependency graph: every gate here is fully
608
+ // independent, so order WITHIN the pool carries no meaning, only the cap
609
+ // does. Every gate runs to completion regardless of a sibling's failure
610
+ // (fail-together within the pool — see the file-header note above).
611
+ async function runGatePool(gates, args, limit) {
612
+ const queue = [...gates];
613
+ const results = [];
614
+ async function worker() {
615
+ while (queue.length) {
616
+ const g = queue.shift();
617
+ const r = await runGateCaptured(g, args);
618
+ printGateResult(r);
619
+ results.push(r);
620
+ }
621
+ }
622
+ await Promise.all(Array.from({ length: Math.min(limit, gates.length) }, worker));
623
+ return results;
624
+ }
625
+
626
+ // DRY preview stays the original flat serial walk, byte-for-byte — no real
627
+ // process ever spawns in dry mode, so there is no concurrency win to chase,
628
+ // and selftest()'s exact-output assertions (below) only ever run `--dry`.
629
+ function step3PreFlightDry(args) {
438
630
  for (const g of GATE_ROSTER) {
439
- // versionArg gates take a MODE-DEPENDENT version (both directions bit
440
- // the first 0.8.8 run): cut modes run pre-flight PRE-BUMP, where the
441
- // README "Current version" claim still equals the PREVIOUS version (the
442
- // bump moves it in Step 4; Step 4g then re-proves at the cut version) —
443
- // while handoff runs POST-MERGE, where the claim already equals the CUT
444
- // version and validating at previous fails by construction. A RESUMED
445
- // half-cut run (gh#765) is post-bump too: the earlier aborted run already
446
- // moved every reference to the cut version, so validating at previous
447
- // false-fails gate 24 exactly like handoff would.
448
- const hygieneVersion = (args.mode === 'handoff' || args.resume) ? args.version : args.previous;
449
- // A RESUMED run's versionArg gates (cut-hygiene) demand artifacts Step 4
450
- // has not recreated yet — docs/ops/releases/vX.Y.Z.md exists only after 4e,
451
- // which runs AFTER this pre-flight. Step 4g re-runs these same gates at
452
- // the cut version once the notes exist, so skipping them here loses no
453
- // coverage — running them here false-fails by construction (the v0.8.32
454
- // cut hit exactly this: 23/24 green, gate 24 dead on the missing notes).
455
- if (g.versionArg && args.resume) {
631
+ const cmd = resolveGateCommand(g, args);
632
+ if (cmd === null) {
456
633
  console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${g.cmd} # ${g.what} — [resume] deferred to Step 4g (notes not regenerated yet)`);
457
634
  continue;
458
635
  }
459
- // targetVersionArg (gh#1135, REQ-06) is a distinct flag from versionArg:
460
- // write-eval-health.mjs NAMES a new file after the cut's target version
461
- // from the very start — there is no pre/post-bump claim to reconcile
462
- // the way gate 24's README-currency check has, so it always takes
463
- // `args.version` verbatim regardless of mode or resume.
464
- const cmd = g.versionArg
465
- ? `${g.cmd} --version ${hygieneVersion}`
466
- : g.targetVersionArg
467
- ? `${g.cmd} --version ${args.version}`
468
- : g.cmd;
469
636
  console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${cmd} # ${g.what}`);
470
- try {
471
- sh(cmd, args, { stdio: 'inherit' });
472
- } catch (e) {
473
- console.error(`\n ✗ FAILED (${g.n}/${GATE_ROSTER.length}): ${cmd}`);
474
- console.error(' Pre-flight aborted. Fix the gate failure before proceeding.');
475
- console.error(' See references/gates-catalog.md for failure-mode → recovery.');
476
- process.exit(1);
477
- }
637
+ sh(cmd, args, { stdio: 'inherit' }); // dry: logs `[dry] cmd`, never executes
638
+ }
639
+ console.log(`\n ✓ all ${GATE_ROSTER.length}/${GATE_ROSTER.length} gates passed`);
640
+ }
641
+
642
+ async function step3PreFlight(args) {
643
+ if (args.skipGates) {
644
+ console.log('\n=== Step 3 — Pre-flight (SKIPPED via --skip-gates; danger!) ===');
645
+ return;
646
+ }
647
+ console.log(`\n=== Step 3 — Pre-flight gates (${GATE_ROSTER.length}-gate roster — gate-roster.mjs) ===`);
648
+ if (args.dry) {
649
+ step3PreFlightDry(args);
650
+ return;
651
+ }
652
+
653
+ const soloGate = GATE_ROSTER.find((g) => g.n === SOLO_GATE_NUMBER);
654
+ const chainGates = EVAL_HEALTH_CHAIN_NUMBERS.map((n) => GATE_ROSTER.find((g) => g.n === n));
655
+ const chainNumbers = new Set(EVAL_HEALTH_CHAIN_NUMBERS);
656
+ const poolGates = GATE_ROSTER.filter((g) => g.n !== SOLO_GATE_NUMBER && !chainNumbers.has(g.n));
657
+
658
+ console.log(`\n-- Phase 1/2: gate ${SOLO_GATE_NUMBER} solo (test:unit:serial) --`);
659
+ const soloResult = await runGateCaptured(soloGate, args);
660
+ printGateResult(soloResult);
661
+ if (!soloResult.ok) {
662
+ console.error('\n Pre-flight aborted at Phase 1 — fix the gate failure before proceeding.');
663
+ console.error(' See references/gates-catalog.md for failure-mode → recovery.');
664
+ process.exit(1);
665
+ }
666
+
667
+ console.log(`\n-- Phase 2/2: ${poolGates.length} gates (concurrency ${PREFLIGHT_CONCURRENCY}) + eval-health chain (gates ${EVAL_HEALTH_CHAIN_NUMBERS.join(' → ')}) --`);
668
+ const [poolResults, chainResults] = await Promise.all([
669
+ runGatePool(poolGates, args, PREFLIGHT_CONCURRENCY),
670
+ runGateChain(chainGates, args),
671
+ ]);
672
+
673
+ const allResults = [soloResult, ...poolResults, ...chainResults].sort((a, b) => a.n - b.n);
674
+ const failed = allResults.filter((r) => !r.ok);
675
+ if (failed.length > 0) {
676
+ console.error(`\n ✗ ${failed.length}/${GATE_ROSTER.length} gate(s) FAILED: ${failed.map((r) => r.n).join(', ')}`);
677
+ console.error(' Pre-flight aborted. Fix the gate failure(s) before proceeding.');
678
+ console.error(' See references/gates-catalog.md for failure-mode → recovery.');
679
+ process.exit(1);
478
680
  }
479
681
  console.log(`\n ✓ all ${GATE_ROSTER.length}/${GATE_ROSTER.length} gates passed`);
480
682
  }
@@ -1129,7 +1331,7 @@ async function main() {
1129
1331
  process.exit(2);
1130
1332
  }
1131
1333
  step1ReBaseline(args);
1132
- step3PreFlight(args);
1334
+ await step3PreFlight(args);
1133
1335
  step4PromoteAndBump(args);
1134
1336
  step5Commit(args);
1135
1337
  // Invariant 3 (operator ruling 2026-07-12): release commits land via PR,
@@ -16,7 +16,7 @@ file, including the depth references those entries cross-link.
16
16
  - [api-contract.md](api-contract.md) — how do I declare `static properties` (type, default, reflect, attribute), avoid the `attr:` silent-typo trap, and expose a selection-item's `selected` state (declared reflected prop, never a `data-*` stamp — ADR-0056)?
17
17
  - [yaml-contract.md](yaml-contract.md) — what fields does the `<name>.yaml` SoT take, and how does it feed the generated `.a2ui.json` sidecar?
18
18
  - [token-contract.md](token-contract.md) — is this component's CSS honoring the two-block `@scope` token contract, and why does it misrender under a theme?
19
- - [css-patterns.md](css-patterns.md) — how do I structure a component stylesheet: two-block `@scope`, variants vs modes, token layering?
19
+ - [css-patterns.md](css-patterns.md) — how do I structure a component stylesheet: two-block `@scope`, variants vs modes, token layering, deriving an inset item's border-radius from its rounded container (concentric-corner radius)?
20
20
  - [lifecycle-patterns.md](lifecycle-patterns.md) — how do I keep `connected()`/`disconnected()` symmetric for listeners, timers, observers, and popovers?
21
21
  - [svg-authoring.md](svg-authoring.md) — viewBox/coordinate scaling, stroke-width, text-anchor/dominant-baseline, card-bleed clipping, currentColor vs `var()` inside SVG markup, hit-testing — for `chart-ui`/`qr-code-ui`/`icon-ui`/any new `<svg>`-emitting primitive.
22
22
  - [form-control-sizing.md](form-control-sizing.md) — does a form-control host fill its container or hug its content, and where does the legibility floor live (ADR-0077)?