@adia-ai/adia-ui-forge 0.8.55 → 0.8.57

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 (44) hide show
  1. package/.claude-plugin/plugin.json +3 -2
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +40 -0
  4. package/README.md +1 -1
  5. package/__init__.py +5 -0
  6. package/commands/demo-audit.md +1 -1
  7. package/commands/gen-ui-review.md +1 -1
  8. package/commands/package-release.md +1 -1
  9. package/commands/site-deployment.md +1 -1
  10. package/package.json +1 -1
  11. package/plugin.yaml +1 -1
  12. package/prompts/demo-audit.md +1 -1
  13. package/prompts/gen-ui-review.md +1 -1
  14. package/prompts/package-release.md +1 -1
  15. package/prompts/site-deployment.md +1 -1
  16. package/scripts/forge-lint.mjs +168 -0
  17. package/scripts/lint-rules.generated.mjs +1700 -0
  18. package/scripts/site-postwrite-derivation-gate +23 -127
  19. package/skills/a2ui-maintenance/SKILL.md +1 -1
  20. package/skills/a2ui-maintenance/references/data-model-reactivity.md +49 -29
  21. package/skills/a2ui-maintenance/references/pipeline-overview.md +58 -22
  22. package/skills/a2ui-maintenance/references/surface-lifecycle.md +14 -7
  23. package/skills/component-md-authoring/SKILL.md +116 -0
  24. package/skills/component-md-authoring/agents/openai.yaml +3 -0
  25. package/skills/demo-audit/references/visual-probe-triage.md +4 -1
  26. package/skills/gen-ui-review/SKILL.md +4 -1
  27. package/skills/gen-ui-review/references/loop-protocol.md +6 -5
  28. package/skills/package-release/references/changelog-discipline.md +6 -3
  29. package/skills/package-release/references/cut-procedure.md +61 -14
  30. package/skills/package-release/references/gates-catalog.md +6 -2
  31. package/skills/package-release/references/recovery-paths.md +6 -3
  32. package/skills/package-release/scripts/gate-roster.mjs +10 -7
  33. package/skills/package-release/scripts/release-pack.mjs +289 -17
  34. package/skills/primitive-authoring/references/anti-patterns.md +2 -2
  35. package/skills/primitive-authoring/references/api-contract.md +17 -4
  36. package/skills/primitive-authoring/references/authoring-cycle.md +1 -1
  37. package/skills/primitive-authoring/references/code-style.md +1 -1
  38. package/skills/primitive-authoring/references/common-gotchas.md +6 -6
  39. package/skills/primitive-authoring/references/form-control-sizing.md +22 -7
  40. package/skills/primitive-authoring/references/token-contract.md +5 -0
  41. package/skills/primitive-authoring/references/yaml-contract.md +132 -14
  42. package/skills/site-docs-authoring/SKILL.md +7 -9
  43. package/hooks/hooks.json +0 -44
  44. package/scripts/forge-lint +0 -315
@@ -516,6 +516,75 @@ const EVAL_HEALTH_CHAIN_NUMBERS = [16, 27, 28]; // eval:diff zettel → eval:dif
516
516
  // for a dedicated/idle host.
517
517
  const PREFLIGHT_CONCURRENCY = Number(process.env.PREFLIGHT_CONCURRENCY) || 4;
518
518
 
519
+ // gh#2195 — gate 4 flake-retry, scoped to the solo phase only.
520
+ //
521
+ // gh#2195's own diagnosis found two mechanisms behind "test:unit:serial
522
+ // flakes under host load": a real wall-clock timing race in one test (fixed
523
+ // separately, PR #2290) and genuine 45s timeouts under contention on the
524
+ // same shared, multi-tenant host an operator or agent session runs
525
+ // everything else on. There is no separate "release pre-flight runner" with
526
+ // its own core count to recalibrate worker-cap.mjs against — Phase 1 above
527
+ // already runs gate 4 solo, with no sibling PRE-FLIGHT gate competing, so
528
+ // what is left is contention from OTHER, unrelated concurrent processes on
529
+ // that same host (other agents' builds, other `npm run check` runs) that no
530
+ // single process's static worker-cap formula can see or size against.
531
+ //
532
+ // A file that fails once under that kind of contention and then passes
533
+ // clean on an immediate, unmodified re-run was never proving a code
534
+ // regression — gate 4 exists to prove one, and a transient scheduling stall
535
+ // isn't that. A file that fails twice in a row almost certainly is a real
536
+ // regression (a genuine bug reproduces deterministically; host contention
537
+ // does not), so this retries AT MOST ONCE, and only the files gate 4 itself
538
+ // named as failed — never the whole suite, never a second retry. Scoped to
539
+ // gate 4's solo phase alone: every other gate, and every OTHER invocation of
540
+ // `npm run test:unit:serial` (CI, local dev, `npm run check`), stays
541
+ // single-shot and strict — this never masks a real regression anywhere else.
542
+ const FAILED_TEST_FILE_RE = /^ FAIL\s+(\S+)/gm;
543
+
544
+ // Pure — extracts the deduped list of failed test file paths from vitest's
545
+ // default-reporter output. Its "Failed Tests" section prints a leading
546
+ // ` FAIL <file> > ...` line per failing test — the same line is emitted for
547
+ // an assertion failure and for a per-test timeout alike.
548
+ function extractFailedTestFiles(text) {
549
+ const files = new Set();
550
+ for (const m of (text || '').matchAll(FAILED_TEST_FILE_RE)) files.add(m[1]);
551
+ return [...files];
552
+ }
553
+
554
+ // Runs the solo gate; on failure, extracts which file(s) it named as failed
555
+ // and — only if at least one was parseable — retries gate 4 scoped to
556
+ // exactly those files, once. An unparseable failure (e.g. vitest itself
557
+ // crashed before printing a summary) fails closed: no retry, same behavior
558
+ // as before this change.
559
+ async function runSoloGateWithRetry(soloGate, args) {
560
+ const first = await runGateCaptured(soloGate, args);
561
+ printGateResult(first);
562
+ if (first.ok) return first;
563
+
564
+ const failedFiles = extractFailedTestFiles(`${first.stdout || ''}\n${first.stderr || ''}`);
565
+ if (failedFiles.length === 0) {
566
+ console.error('\n Gate 4 failed with no parseable failed-file list — not retrying (fail closed, gh#2195).');
567
+ return first;
568
+ }
569
+
570
+ console.log(`\n Gate 4 failed on ${failedFiles.length} file(s) — retrying once, scoped to just those files (host-contention tolerance, gh#2195):`);
571
+ for (const f of failedFiles) console.log(` - ${f}`);
572
+ const retryGate = {
573
+ n: soloGate.n,
574
+ cmd: `${soloGate.cmd} -- ${failedFiles.map((f) => `"${f}"`).join(' ')}`,
575
+ what: `${soloGate.what} — retry of ${failedFiles.length} flaky file(s)`,
576
+ };
577
+ const retry = await runGateCaptured(retryGate, args);
578
+ printGateResult(retry);
579
+
580
+ if (retry.ok) {
581
+ console.log(`\n ✓ gate 4 passed on retry — treating the first failure as host contention, not a regression (gh#2195).`);
582
+ return { ...retry, note: `passed on retry after 1 failure (gh#2195 host-contention tolerance); first attempt failed: ${failedFiles.join(', ')}` };
583
+ }
584
+ console.error('\n Gate 4 failed again on retry — a real regression, not a flake. Aborting.');
585
+ return { ...retry, note: `failed again on retry (gh#2195); first attempt failed: ${failedFiles.join(', ')}` };
586
+ }
587
+
519
588
  // Resolves one gate's actual command string — the same versionArg/
520
589
  // targetVersionArg/resume branching step3PreFlight has always applied.
521
590
  // Returns null for a versionArg gate deferred by an in-flight resume (Step
@@ -656,8 +725,7 @@ async function step3PreFlight(args) {
656
725
  const poolGates = GATE_ROSTER.filter((g) => g.n !== SOLO_GATE_NUMBER && !chainNumbers.has(g.n));
657
726
 
658
727
  console.log(`\n-- Phase 1/2: gate ${SOLO_GATE_NUMBER} solo (test:unit:serial) --`);
659
- const soloResult = await runGateCaptured(soloGate, args);
660
- printGateResult(soloResult);
728
+ const soloResult = await runSoloGateWithRetry(soloGate, args);
661
729
  if (!soloResult.ok) {
662
730
  console.error('\n Pre-flight aborted at Phase 1 — fix the gate failure before proceeding.');
663
731
  console.error(' See references/gates-catalog.md for failure-mode → recovery.');
@@ -799,10 +867,11 @@ function step4PromoteAndBump(args) {
799
867
  sh('npm install --package-lock-only --no-audit --no-fund', args);
800
868
  sh('npm run check:lockstep', args);
801
869
 
802
- // Step 4d.5 — regenerate the derived genui catalog (gh#617): its catalogId
803
- // embeds the lockstep version (`adia.base@X.Y.Z`), so the bump above just
804
- // invalidated base.json/adia-pack.json. The v0.8.26 cut skipped this and
805
- // check:genui-catalog failed in CI one push later. Step 5 stages both files.
870
+ // Step 4d.5 — regenerate the derived genui catalog (gh#617; gh#2211/ADR-0093
871
+ // repartitioned): each catalog's catalogId embeds the lockstep version
872
+ // (`adia.<scope>@X.Y.Z`), so the bump above just invalidated all five.
873
+ // The v0.8.26 cut skipped this and check:genui-catalog failed in CI one
874
+ // push later. Step 5 stages all five files.
806
875
  console.log('\n Step 4d.5 — regenerate derived genui catalog (catalogId carries the lockstep version):');
807
876
  sh('node scripts/build/derive-genui-catalog.mjs', args);
808
877
 
@@ -907,6 +976,39 @@ function step4PromoteAndBump(args) {
907
976
  }
908
977
  }
909
978
 
979
+ // Pure so the selftest can lock the parse without a real git tree (gh#2473,
980
+ // same "pure so selftest can lock the transform" shape as bump.mjs's
981
+ // bumpPinnedRef). Takes `git status --porcelain` output, returns the tracked
982
+ // paths Step 5.6's guard should block on — worktree status (porcelain
983
+ // column Y, index 1) 'M' or 'D'; 'A'/'?' (new/untracked) are out of scope,
984
+ // see the guard's own comment. Handles the two porcelain path shapes a
985
+ // plain `line.slice(3)` mishandles: C-style-quoted paths (git quotes any
986
+ // path containing a space, or a rename's `->` separator surrounded by
987
+ // spaces) and rename/copy entries (`R old -> new`, `C old -> new`),
988
+ // where the file that actually needs staging is the NEW path.
989
+ function unstagedTrackedLeftovers(porcelainOutput) {
990
+ const leftover = [];
991
+ for (const line of porcelainOutput.split('\n')) {
992
+ if (line.length <= 3 || !/[MD]/.test(line[1])) continue;
993
+ let rest = line.slice(3).trim();
994
+ // Rename/copy: "old -> new" (both sides may independently be quoted) —
995
+ // the worktree-dirty path to report is the NEW (post-rename) side.
996
+ const arrow = rest.indexOf(' -> ');
997
+ if (arrow !== -1) rest = rest.slice(arrow + 4).trim();
998
+ // git C-quotes a path containing a space, a quote, or a control char as
999
+ // a double-quoted, backslash-escaped string — strip the wrapper and
1000
+ // undo the escaping rather than reporting the literal quoted form.
1001
+ if (rest.length >= 2 && rest[0] === '"' && rest[rest.length - 1] === '"') {
1002
+ rest = rest
1003
+ .slice(1, -1)
1004
+ .replace(/\\"/g, '"')
1005
+ .replace(/\\\\/g, '\\');
1006
+ }
1007
+ leftover.push(rest);
1008
+ }
1009
+ return leftover;
1010
+ }
1011
+
910
1012
  // ── Step 5 — Commit ──────────────────────────────────────────────
911
1013
  function step5Commit(args) {
912
1014
  if (args.mode === 'handoff') {
@@ -956,14 +1058,18 @@ function step5Commit(args) {
956
1058
  `docs/ops/releases/v${args.version}.md`,
957
1059
  // gh#617: Step 4d.5 regenerates these post-bump (catalogId embeds the
958
1060
  // lockstep version); unstaged, check:genui-catalog fails in CI (v0.8.26).
959
- // catalog-data.js is the same derivation's third output (the browser-safe
960
- // ES-module projection of base.json/adia-pack.json) check-genui-catalog.mjs
961
- // verifies its freshness too, but this list never carried it (found live
962
- // during the v0.8.33 cut: base.json/adia-pack.json staged and committed
963
- // fine, catalog-data.js sat modified-uncommitted, CI caught the drift a
964
- // fresh checkout exposes that a local working tree doesn't).
965
- 'packages/genui/adia-catalog/base.json',
966
- 'packages/genui/adia-catalog/adia-pack.json',
1061
+ // gh#2211 (ADR-0093): the old base/pack pair retired in favor of five
1062
+ // opt-out-scoped catalogs. catalog-data.js is the same derivation's sixth
1063
+ // output (the browser-safe ES-module projection of all five)
1064
+ // check-genui-catalog.mjs verifies its freshness too, but this list never
1065
+ // carried it (found live during the v0.8.33 cut: the JSON catalogs staged
1066
+ // and committed fine, catalog-data.js sat modified-uncommitted, CI caught
1067
+ // the drift a fresh checkout exposes that a local working tree doesn't).
1068
+ 'packages/genui/adia-catalog/adia.core.json',
1069
+ 'packages/genui/adia-catalog/adia.navigation.json',
1070
+ 'packages/genui/adia-catalog/adia.data.json',
1071
+ 'packages/genui/adia-catalog/adia.agent.json',
1072
+ 'packages/genui/adia-catalog/adia.shells.json',
967
1073
  'packages/genui/adia-catalog/catalog-data.js',
968
1074
  // gh#1361: Step 4d.5b regenerates the catalog tier index post-bump (it
969
1075
  // derives from the catalog derive-genui-catalog.mjs just refreshed) and,
@@ -1015,12 +1121,54 @@ function step5Commit(args) {
1015
1121
  // Codex `agents/openai.yaml` glob above is.
1016
1122
  files.push(`${dir}/plugin.yaml`, `${dir}/__init__.py`, `${dir}/prompts/*.md`);
1017
1123
  }
1018
- if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`, `${dir}/hermes-mcp.yaml`);
1124
+ // gh#2473: bump.mjs's PINNED_REFS also rewrites the factory README's
1125
+ // OWN prose echo of the generation-MCP pin (`server pinned:
1126
+ // @adia-ai/mcp@X` — see bump.mjs's A2UI_MCP_PIN comment,
1127
+ // check:plugin-count-claims' "factory README generation-MCP pin"
1128
+ // equality check) — same invariant-8 scope as .mcp.json, but this
1129
+ // list only ever staged .mcp.json + hermes-mcp.yaml, so the README
1130
+ // edit rode along unstaged/uncommitted every cut until Step 5.6
1131
+ // (below) started catching it.
1132
+ if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`, `${dir}/hermes-mcp.yaml`, `${dir}/README.md`);
1019
1133
  return files;
1020
1134
  }),
1021
1135
  ];
1022
1136
  sh(`git add ${releaseFiles.join(' ')}`, args);
1023
1137
  sh('git diff --cached --stat | tail -3', args);
1138
+
1139
+ // Step 5.6 — unstaged-tracked-files guard (gh#2473). Numbered 5.6, not
1140
+ // 5.5: cut-procedure.md's own §Step 5.5 already names a DIFFERENT,
1141
+ // pre-existing manual freshness trip-wire (components.mjs --verify +
1142
+ // check:chunks-fresh + check:embeddings-fresh) that this script doesn't
1143
+ // automate — reusing "5.5" here would collide with that doc section for
1144
+ // an operator reading both. This guard slots before §Step 5.7 (the
1145
+ // release PR), the next free number in the doc's own sequence.
1146
+ //
1147
+ // The release commit's file allowlist above has drifted from what
1148
+ // bump.mjs / cut-hygiene actually edit more than once (gh#1198, gh#1899,
1149
+ // gh#1954, gh#2473 itself) — a step upstream starts writing a new file
1150
+ // and nobody remembers to add it here, so it rides through Step 5
1151
+ // uncommitted and either ships a half-bumped release commit or gets
1152
+ // discovered only when a later gate (or a human diff) trips over it.
1153
+ // Fail loud, right here, rather than trusting the allowlist is
1154
+ // exhaustive: after staging everything this list knows about, anything
1155
+ // STILL modified-in-the-worktree on a tracked path means the allowlist
1156
+ // missed a file this cut's bump/hygiene pass touched.
1157
+ if (args.dry) {
1158
+ console.log(" [dry] git status --porcelain (Step 5.6 guard: no unstaged tracked changes remain — Step 5's allowlist covered everything this cut touched)");
1159
+ } else {
1160
+ const leftover = unstagedTrackedLeftovers(shQuiet('git status --porcelain', args));
1161
+ if (leftover.length > 0) {
1162
+ console.error(`\nERROR: ${leftover.length} tracked file(s) modified by this cut are still unstaged after Step 5's allowlist ran:`);
1163
+ for (const f of leftover) console.error(` ✗ ${f}`);
1164
+ console.error('\n bump.mjs (or a cut-hygiene step) edited these but Step 5\'s `releaseFiles`');
1165
+ console.error(' allowlist does not know about them — add the missing path(s) and re-run,');
1166
+ console.error(' or `git add` + `git commit --amend` them onto the release commit by hand.');
1167
+ process.exit(1);
1168
+ }
1169
+ console.log(" ✓ no unstaged tracked changes remain — Step 5's allowlist covered everything this cut touched");
1170
+ }
1171
+
1024
1172
  if (!args.commitMessageFile) {
1025
1173
  console.error('ERROR: --commit-message-file required for cut/from-scratch mode');
1026
1174
  process.exit(2);
@@ -1403,9 +1551,120 @@ async function main() {
1403
1551
  // must never proceed to tag/push/publish on its own). A fixture temp dir
1404
1552
  // supplies the operator-authored content files (--commit-message-file /
1405
1553
  // --gh-notes-file) so nothing here depends on real repo state.
1406
- function selftest() {
1554
+ async function selftest() {
1407
1555
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'release-pack-selftest-'));
1408
1556
  try {
1557
+ // gh#2195 — gate-4 retry: extractFailedTestFiles() must parse vitest's
1558
+ // default-reporter ` FAIL <file> > ...` lines, and
1559
+ // runSoloGateWithRetry() must retry exactly once, scoped to exactly
1560
+ // those files, accepting a clean retry as a host-contention flake but
1561
+ // never masking a failure that reproduces, and never retrying at all
1562
+ // when the failure has no parseable failed-file list.
1563
+ {
1564
+ const sample = [
1565
+ ' FAIL packages/a/b.test.js > describe > one',
1566
+ ' FAIL packages/a/b.test.js > describe > two',
1567
+ ' FAIL packages/c/d.test.js > describe > three',
1568
+ ].join('\n');
1569
+ const files = extractFailedTestFiles(sample);
1570
+ if (files.length !== 2 || !files.includes('packages/a/b.test.js') || !files.includes('packages/c/d.test.js')) {
1571
+ console.error(`selftest FAIL: extractFailedTestFiles() expected 2 deduped files, got: ${JSON.stringify(files)}`);
1572
+ process.exit(1);
1573
+ }
1574
+
1575
+ const flakyMarker = path.join(tmp, 'flaky-invocation-count');
1576
+ const flakyScript = path.join(tmp, 'flaky-once.mjs');
1577
+ fs.writeFileSync(flakyScript, `
1578
+ import { writeFileSync } from 'node:fs';
1579
+ const isRetry = process.argv.slice(2).includes('--');
1580
+ if (!isRetry) {
1581
+ writeFileSync(${JSON.stringify(flakyMarker)}, '1');
1582
+ console.log(' FAIL packages/fake/flaky.test.js > describe > flaky test');
1583
+ console.error('Error: Test timed out in 45000ms.');
1584
+ process.exit(1);
1585
+ }
1586
+ console.log(' retry ran clean');
1587
+ process.exit(0);
1588
+ `);
1589
+ const flakyGate = { n: 4, cmd: `node "${flakyScript}"`, what: 'selftest flaky gate' };
1590
+ const flakyResult = await runSoloGateWithRetry(flakyGate, {});
1591
+ if (!flakyResult.ok || !flakyResult.note || !flakyResult.note.includes('packages/fake/flaky.test.js')) {
1592
+ console.error(`selftest FAIL: runSoloGateWithRetry() must accept a clean retry as a flake; got: ${JSON.stringify(flakyResult)}`);
1593
+ process.exit(1);
1594
+ }
1595
+
1596
+ const alwaysFailsScript = path.join(tmp, 'always-fails.mjs');
1597
+ fs.writeFileSync(alwaysFailsScript, `
1598
+ console.log(' FAIL packages/fake/broken.test.js > describe > broken test');
1599
+ process.exit(1);
1600
+ `);
1601
+ const alwaysFailsGate = { n: 4, cmd: `node "${alwaysFailsScript}"`, what: 'selftest always-fails gate' };
1602
+ const alwaysFailsResult = await runSoloGateWithRetry(alwaysFailsGate, {});
1603
+ if (alwaysFailsResult.ok) {
1604
+ console.error('selftest FAIL: runSoloGateWithRetry() must NOT mask a failure that reproduces on retry');
1605
+ process.exit(1);
1606
+ }
1607
+
1608
+ const sentinel = path.join(tmp, 'unparseable-retried');
1609
+ const unparseableScript = path.join(tmp, 'unparseable-fails.mjs');
1610
+ fs.writeFileSync(unparseableScript, `
1611
+ import { existsSync, writeFileSync } from 'node:fs';
1612
+ const sentinel = ${JSON.stringify(sentinel)};
1613
+ writeFileSync(sentinel, existsSync(sentinel) ? 'RETRIED' : 'FIRST-RUN');
1614
+ console.error('Segmentation fault (core dumped)');
1615
+ process.exit(139);
1616
+ `);
1617
+ const unparseableGate = { n: 4, cmd: `node "${unparseableScript}"`, what: 'selftest unparseable-crash gate' };
1618
+ const unparseableResult = await runSoloGateWithRetry(unparseableGate, {});
1619
+ if (unparseableResult.ok) {
1620
+ console.error('selftest FAIL: an unparseable gate-4 failure must never be treated as passing');
1621
+ process.exit(1);
1622
+ }
1623
+ if (fs.readFileSync(sentinel, 'utf8') !== 'FIRST-RUN') {
1624
+ console.error('selftest FAIL: an unparseable failure (no FAIL lines) must fail closed — no retry');
1625
+ process.exit(1);
1626
+ }
1627
+ }
1628
+
1629
+ // gh#2473 — Step 5.6's guard: unstagedTrackedLeftovers() must find a
1630
+ // plain modified/deleted tracked file, unquote a C-quoted path
1631
+ // (spaces), and report the NEW side of a rename/copy — all from a
1632
+ // fixture porcelain string, independent of `--dry` and independent of
1633
+ // any real git tree (the `--mode cut --dry` subprocess run below can
1634
+ // only ever prove the code path executes and prints a fixed success
1635
+ // string; it can't reach the `else` branch at all, since `sh()`'s dry
1636
+ // short-circuit never runs `git status`). This exercises the exact
1637
+ // logic Step 5.6 runs for real.
1638
+ {
1639
+ const porcelain = [
1640
+ ' M packages/plugins/adia-ui-factory/README.md',
1641
+ ' D packages/plugins/adia-ui-factory/hermes-mcp.yaml',
1642
+ 'M packages/already/staged.json', // staged-only (X=M, Y=space) — NOT a leftover
1643
+ '?? packages/new/untracked.json', // untracked — out of scope, NOT a leftover
1644
+ ' M "packages/has space/quoted file.md"',
1645
+ 'RM packages/old-name.md -> packages/new-name.md', // renamed AND further worktree-modified (Y=M) — a leftover
1646
+ 'R packages/clean-rename-old.md -> packages/clean-rename-new.md', // renamed, no further worktree change (Y=space) — NOT a leftover
1647
+ ].join('\n');
1648
+ const leftover = unstagedTrackedLeftovers(porcelain);
1649
+ const want = [
1650
+ 'packages/plugins/adia-ui-factory/README.md',
1651
+ 'packages/plugins/adia-ui-factory/hermes-mcp.yaml',
1652
+ 'packages/has space/quoted file.md',
1653
+ 'packages/new-name.md',
1654
+ ];
1655
+ const gotJson = JSON.stringify(leftover);
1656
+ const wantMissing = want.filter((w) => !leftover.includes(w));
1657
+ const wantExtra = leftover.filter((l) => !want.includes(l));
1658
+ if (wantMissing.length > 0 || wantExtra.length > 0 || leftover.length !== want.length) {
1659
+ console.error(`selftest FAIL: unstagedTrackedLeftovers() expected ${JSON.stringify(want)}, got ${gotJson}`);
1660
+ process.exit(1);
1661
+ }
1662
+ if (unstagedTrackedLeftovers('').length !== 0) {
1663
+ console.error('selftest FAIL: unstagedTrackedLeftovers() must return [] on empty porcelain output (the clean-tree case Step 5.6 hits on every normal cut)');
1664
+ process.exit(1);
1665
+ }
1666
+ }
1667
+
1409
1668
  const commitFile = path.join(tmp, 'commit.txt');
1410
1669
  const notesFile = path.join(tmp, 'notes.md');
1411
1670
  fs.writeFileSync(commitFile, 'selftest fixture commit message\n');
@@ -1462,6 +1721,16 @@ function selftest() {
1462
1721
  'packages/plugins/adia-ui-factory/__init__.py',
1463
1722
  'packages/plugins/adia-ui-factory/hermes-mcp.yaml',
1464
1723
  'packages/plugins/adia-ui-factory/prompts/*.md',
1724
+ // gh#2473 — Step 5's staging pathspec must carry the factory README's
1725
+ // own prose echo of the generation-MCP pin (bump.mjs's A2UI_MCP_PIN
1726
+ // README target), or it rides unstaged/uncommitted past the release
1727
+ // commit the same way .mcp.json itself did before gh#1198.
1728
+ 'packages/plugins/adia-ui-factory/README.md',
1729
+ // gh#2473 — Step 5.6's unstaged-tracked-files guard must actually run
1730
+ // (as a no-op) on a clean --dry plan. Its real leftover-detection
1731
+ // logic is unit-tested directly above via unstagedTrackedLeftovers();
1732
+ // this marker only proves the --dry code path reaches its console.log.
1733
+ "no unstaged tracked changes remain — Step 5's allowlist covered everything this cut touched",
1465
1734
  ];
1466
1735
  for (const marker of need) {
1467
1736
  if (!out.includes(marker)) {
@@ -1597,7 +1866,10 @@ function fileURLToPathCompat(url) {
1597
1866
  const topArgv = process.argv.slice(2);
1598
1867
  if (topArgv[0] === 'selftest') {
1599
1868
  rl.close();
1600
- selftest();
1869
+ selftest().catch((e) => {
1870
+ console.error(e);
1871
+ process.exit(1);
1872
+ });
1601
1873
  } else {
1602
1874
  main().catch((e) => {
1603
1875
  // sh()/shQuiet() failures already printed the failing command + its
@@ -23,8 +23,8 @@ closable: { type: Boolean, default: true, reflect: true }
23
23
  | --- | --- |
24
24
  | `modal-ui[closable]` (default `true`) | `modal-ui[permanent]` (default `false`) |
25
25
  | `skeleton-ui[animate]` | `skeleton-ui[static]` |
26
- | `stream-ui[cursor]` | `stream-ui[hideCursor]` |
27
- | `chart-ui[average]` | `chart-ui[hideAverage]` |
26
+ | `stream-ui[cursor]` | `stream-ui[noCursor]` |
27
+ | `chart-ui[average]` | `chart-ui[noAverage]` |
28
28
  | `toggle-group-ui[multiple]` | `toggle-group-ui[single]` |
29
29
  | `swiper-ui[pause-on-hover]` | `swiper-ui[noPauseOnHover]` |
30
30
 
@@ -66,8 +66,8 @@ AdiaUI conventions require `default: false` on Boolean props in the standard sha
66
66
  | --- | --- | --- |
67
67
  | Modal can be dismissed | `closable` | `permanent` |
68
68
  | Skeleton animates | `animate` | `static` |
69
- | Stream shows blinking cursor | `cursor` | `hideCursor` |
70
- | Chart shows average line | `average` | `hideAverage` |
69
+ | Stream shows blinking cursor | `cursor` | `noCursor` |
70
+ | Chart shows average line | `average` | `noAverage` |
71
71
  | Toggle group allows multi-select | `multiple` | `single` |
72
72
  | Swiper pauses on hover | `pause-on-hover` | `noPauseOnHover` |
73
73
 
@@ -253,9 +253,22 @@ Reserve `error` for validation state (`[error]` on form inputs matches ARIA patt
253
253
  <toggle-group-ui single>...</toggle-group-ui>
254
254
  ```
255
255
 
256
+ **[historical, 2026-08-31, ADR-0056 amendment]** `<toggle-group-ui>` /
257
+ `<toggle-option-ui>` were cut outright in `0.8.43` (gh#1617), before the
258
+ migration this ADR's Decision 2 anticipated ever ran — moot, not
259
+ falsified. `segmented-ui`/`segment-ui` absorbed the role
260
+ (`segmented.yaml`'s `multiple` prop description names the absorption
261
+ directly, gh#1369/#1363 C1). The example above stays as written because it
262
+ illustrates the Boolean-false naming rule against a real historical prop
263
+ name, not because `<toggle-group-ui>` still exists — `segmented-ui`
264
+ deliberately does NOT follow this same polarity (`[multiple]` is
265
+ positive-polarity by design, matching `select-ui[multiple]`; there is no
266
+ `segmented-ui[single]` opt-out), so it is not a drop-in replacement
267
+ example for this rule.
268
+
256
269
  ## Selection-item state — a declared, reflected `selected` prop, never a private `data-*` stamp
257
270
 
258
- A **selection-item primitive** (one selectable option inside a selection-group parent — `segment-ui` in `segmented-ui`, `toggle-option-ui` in `toggle-group-ui`) exposes its current state as a declared, reflected `selected: Boolean` prop, documented as parent-managed, and styled via `[selected]`:
271
+ A **selection-item primitive** (one selectable option inside a selection-group parent — `segment-ui` in `segmented-ui`; historically also `toggle-option-ui` in `toggle-group-ui`, cut in `0.8.43`/gh#1617 — see the dated note below) exposes its current state as a declared, reflected `selected: Boolean` prop, documented as parent-managed, and styled via `[selected]`:
259
272
 
260
273
  ```javascript
261
274
  // child — the declared API surface
@@ -286,7 +299,7 @@ Rules:
286
299
  - **The parent's `value` is the single source of truth**; the parent writes `selected` on its children (`segmented.class.js:146,149` sets/removes the attribute; `toggle-group.class.js:104-111` assigns `opt.selected`). Authors and generated markup drive the group's `value`, never `[selected]` on a child (`toggle-group.yaml`'s rules block says so in so many words).
287
300
  - **No private `data-*` stamp for API-conceptual state.** `data-selected` hides the state from the yaml SoT, the catalog, and generative authoring — a consumer or the A2UI grammar cannot express "this option is selected" against a stamp that no schema declares. `toggle-group-ui` used to stamp `data-selected` + style `:scope[data-selected]`; it converged onto `segment-ui`'s mechanism (`toggle-option.yaml:38-46`, `toggle-group.css:14`, `toggle-group.test.js:64` asserts the stamp is gone).
288
301
  - **Parent-stamped ARIA stays as-is — ARIA is wiring, not API.** `segment-ui` derives `aria-checked` from `selected` (`segment.class.js:42`); `toggle-option-ui` derives `aria-pressed` (`toggle-group.class.js:65`). The reflected prop is the API; the ARIA attribute follows it.
289
- - **Reference implementation:** `segment-ui` (`segment.yaml:32-36`, `segment.css:7`); `toggle-option-ui` is the converged second instance. A new selection-item primitive copies this shape extending one of the two by analogy is what produced the divergence in the first place (gh#1303).
302
+ - **Reference implementation:** `segment-ui` (`segment.yaml:32-36`, `segment.css:7`) is the current, live reference. **[historical, 2026-08-31, ADR-0056 amendment]** `toggle-option-ui`/`toggle-group-ui` were the converged second instance at ratification time, but both were cut outright in `0.8.43` (gh#1617), before any migration ran — `segmented-ui`/`segment-ui` absorbed the role. A new selection-item primitive copies `segment-ui`'s shape directly; there is no second live instance to extend by analogy anymore (the divergence gh#1303 describes was between `segment-ui` and the now-deleted `toggle-option-ui`).
290
303
 
291
304
  Migration note: removing `data-selected` was a breaking change for external CSS that targeted it, so it shipped in two halves — the additive `selected` prop in `0.8.39`, the stamp removal on the `0.8.40` breaking wave with a migration-guide entry (`packages/web-components/CHANGELOG.md`; the ADR text names the wave `0.9.0` — it shipped as `0.8.40`).
292
305
 
@@ -31,7 +31,7 @@ These rules are the distilled lessons from a 5-iteration audit. Each one corresp
31
31
 
32
32
  ### API / Attributes
33
33
 
34
- 1. **Boolean defaults are `false`.** If the expected default is "on," rename: `closable` → `permanent`, `animate` → `static`, `cursor` → `hideCursor`, `average` → `hideAverage`, `pause-on-hover` → `noPauseOnHover`.
34
+ 1. **Boolean defaults are `false`.** If the expected default is "on," rename: `closable` → `permanent`, `animate` → `static`, `cursor` → `noCursor`, `average` → `noAverage`, `pause-on-hover` → `noPauseOnHover`. `no-*` is the canonical negation prefix (ADR-0063 decision 1) — a new rename lands on `no-*` directly, never `hide-*`.
35
35
 
36
36
  2. **No magic-value sentinels in numeric props.** Indeterminate = `null`, not `-1`. Consumers branch on `value == null`, which is explicit.
37
37
 
@@ -239,7 +239,7 @@ Every Boolean prop on a component must default to `false`. If the expected defau
239
239
  | --------------------------------- | ------------------------------ |
240
240
  | `<drawer-ui permanent>` | `<drawer-ui closable>` |
241
241
  | `<chart-ui static>` | `<chart-ui animate>` |
242
- | `<cursor-ui hide-cursor>` | `<cursor-ui cursor>` |
242
+ | `<cursor-ui no-cursor>` | `<cursor-ui cursor>` |
243
243
  | `<carousel-ui no-pause-on-hover>` | `<carousel-ui pause-on-hover>` |
244
244
 
245
245
  Default behavior is the absent attribute. Attributes exist to opt OUT or carry a value — never to opt INTO the expected default.
@@ -16,7 +16,7 @@ Composite authors: read §§1–5 BEFORE Phase 3 sketch. Anyone adding an async
16
16
  4. [minmax(min, 1fr) inside repeat() fighting container queries](#4-minmaxmin-1fr-inside-repeat-fighting-container-queries)
17
17
  5. [Nested `<!-- ... -->` inside design-plan canonical-sketch fenced blocks](#5-nested----inside-design-plan-canonical-sketch-fenced-blocks)
18
18
  6. [Async load/render function completing out of order](#6-async-loadrender-function-completing-out-of-order--a-guard-at-the-checkpoint-isnt-enough)
19
- 7. [Minting a wrapper-shaped component before its registry.js entry lands](#7-minting-a-wrapper-shaped-component-before-its-registryjs-entry-lands--site-a2ui-silently-deletes-the-node-not-just-mis-types-it)
19
+ 7. [Minting a wrapper-shaped component before its registry.js entry lands](#7-minting-a-wrapper-shaped-component-before-its-registryjs-entry-lands--the-transpiler-silently-deletes-the-node-not-just-mis-types-it)
20
20
 
21
21
  ---
22
22
 
@@ -123,15 +123,15 @@ async #loadContent(route) {
123
123
 
124
124
  ---
125
125
 
126
- ## 7. Minting a wrapper-shaped component before its registry.js entry lands — site-a2ui silently deletes the node, not just mis-types it
126
+ ## 7. Minting a wrapper-shaped component before its registry.js entry lands — the transpiler silently deletes the node, not just mis-types it
127
127
 
128
- **Pattern**: a tag is gated first and only, for `*-ui` tags, by `packages/gen-ui/a2ui/registry.js`'s hand-maintained `registry` map — inverted into `reverseRegistry` at `transpiler-maps.js`'s module init, consulted first thing in `compose/transpiler/transpiler.js:149-150`. `registry.js` is hand-edited, not generated by `node scripts/build/components.mjs` (that script writes sidecars/prop-catalog data, consumed only for prop-extraction fidelity on tags the transpiler ALREADY resolved — `transpiler-maps.js:22-26`); a runtime `registerType()` call doesn't rescue a stale row either — `reverseRegistry` is a one-time init snapshot, not live. Regenerate a site-a2ui row for a demo using a component minted in the SAME change, before its `registry.js` line lands, and the tag falls through to `transpiler.js`'s "Unknown → Column" branch (line 180-183) — same mechanism as gh#535's toolbar-group breakage, which at least rendered visibly-wrong. A NEW component is usually wrapper-shaped (one child, author-defined attributes like `anchor="bottom"` the transpiler doesn't map to any real A2UI prop). That shape trips a SECOND, separate rule right after — "single-child container chains flatten" (`transpiler.js:282-285`): a retyped Column with exactly one child and zero recognized props is discarded outright, and its child is spliced directly into the PARENT's children in its place. The wrapper's own id and node are never pushed to the tree at all — not visible-but-wrong, just gone. The row is then internally self-consistent (content hash matches source) so `check:chunks-fresh`/`verify:site-a2ui` both report clean.
128
+ **Pattern**: a tag is gated first and only, for `*-ui` tags, by `packages/gen-ui/a2ui/registry.js`'s hand-maintained `registry` map — inverted into `reverseRegistry` at `transpiler-maps.js`'s module init, consulted first thing in `compose/transpiler/transpiler.js:149-150`. `registry.js` is hand-edited, not generated by `node scripts/build/components.mjs` (that script writes sidecars/prop-catalog data, consumed only for prop-extraction fidelity on tags the transpiler ALREADY resolved — `transpiler-maps.js:22-26`); a runtime `registerType()` call doesn't rescue a stale row either — `reverseRegistry` is a one-time init snapshot, not live. Transpile a demo using a component minted in the SAME change, before its `registry.js` line lands (e.g. the chunk harvester, `node scripts/build/harvest-chunks.mjs`, or any other engine-transpiler consumer), and the tag falls through to `transpiler.js`'s "Unknown → Column" branch (line 180-183) — same mechanism as gh#535's toolbar-group breakage, which at least rendered visibly-wrong. A NEW component is usually wrapper-shaped (one child, author-defined attributes like `anchor="bottom"` the transpiler doesn't map to any real A2UI prop). That shape trips a SECOND, separate rule right after — "single-child container chains flatten" (`transpiler.js:282-285`): a retyped Column with exactly one child and zero recognized props is discarded outright, and its child is spliced directly into the PARENT's children in its place. The wrapper's own id and node are never pushed to the tree at all — not visible-but-wrong, just gone. The row is then internally self-consistent (content hash matches source) so `check:chunks-fresh` reports clean.
129
129
 
130
- **Example**: minting `anchor-bar-ui` (gh#495, PR #569) and regenerating the `bulk-action-toolbar` pattern's site-a2ui row before the worktree's `registry.js` entry for it existed. Git-verified on the pre-fix commit (`ebf71832d`): `site-a2ui/pages/site__patterns__bulk-action-toolbar.a2ui.json` contains zero occurrences of `pat-bulk-float-bar` (the anchor-bar-ui's own authored id) anywhere — not retyped-and-visible, genuinely absent — while its single child (the toolbar content) survived, reparented one level up. `verify:site-a2ui` reported 321/321 clean.
130
+ **Example (historical — the illustrating consumer has since retired):** minting `anchor-bar-ui` (gh#495, PR #569) and regenerating the `bulk-action-toolbar` pattern's site-a2ui row (site-a2ui itself retired 2026-08-31, ADR-0072 Decision 2 / gh#2410 — the underlying registry-gating hazard below is unchanged, only that particular consumer is gone) before the worktree's `registry.js` entry for it existed. Git-verified on the pre-fix commit (`ebf71832d`): the converted artifact contained zero occurrences of `pat-bulk-float-bar` (the anchor-bar-ui's own authored id) anywhere — not retyped-and-visible, genuinely absent — while its single child (the toolbar content) survived, reparented one level up. The site-a2ui freshness gate of the day reported clean regardless, for the exact reason the Detector below still explains.
131
131
 
132
- **Detector**: none generic — `verify:site-a2ui`'s freshness check can't catch this (the row IS fresh relative to its source, it transpiled correctly against a registry that was itself incomplete). The only catch is rendering the actual route in a browser and confirming the new tag's node count is nonzero, or re-running `node scripts/build/site-a2ui.mjs --page <route>` (or `--stale`) after `registry.js` is updated and diffing the row for the new component name. A non-wrapper-shaped new component (multiple children, or attributes that happen to map to real props) is lower-risk here — it survives as a visible-but-wrong Column, the gh#535 class, which at least has a visual tell.
132
+ **Detector**: none generic — a same-source freshness check can't catch this (the artifact IS fresh relative to its source, it transpiled correctly against a registry that was itself incomplete). The only catch is rendering the actual consuming surface and confirming the new tag's node count is nonzero, or re-running the transpile after `registry.js` is updated and diffing the output for the new component name. A non-wrapper-shaped new component (multiple children, or attributes that happen to map to real props) is lower-risk here — it survives as a visible-but-wrong Column, the gh#535 class, which at least has a visual tell.
133
133
 
134
- **Fix**: the `registry.js` entry is what gates resolution — land it (not just run `components.mjs`, which is necessary for prop fidelity but not sufficient to avoid the retype) before regenerating any site-a2ui row that uses the new tag. When gating a dispatched agent's PR that did this out of order, re-run `node scripts/build/site-a2ui.mjs --page <route>` on the merged tree and browser-probe the tag actually appears — never trust `verify:site-a2ui`'s green alone for a route touching a component minted in the same change.
134
+ **Fix**: the `registry.js` entry is what gates resolution — land it (not just run `components.mjs`, which is necessary for prop fidelity but not sufficient to avoid the retype) before transpiling anything that uses the new tag. When gating a dispatched agent's PR that did this out of order, re-run the transpile on the merged tree and confirm the tag actually appears in the output — never trust a freshness gate's green alone for a surface touching a component minted in the same change.
135
135
 
136
136
  **Generalizes to**: any hand-maintained resolution map (not build-generated) that a later regeneration step reads through — regenerating before the map is updated produces an internally-consistent-but-wrong artifact that passes a same-source freshness check; if the misresolved shape also happens to trip a downstream simplification/collapse rule, the failure escalates from "renders wrong" to "renders nothing," with no visual tell at all.
137
137
 
@@ -20,10 +20,24 @@ posture flip.
20
20
 
21
21
  ## Where legibility floors live
22
22
 
23
- A `--*-min-width: 20ch`-class floor (the gh#781/gh#1633 pattern on
24
- `input-ui`/`select-ui`/`combobox-ui`) belongs on `:scope[inline]` only, never
25
- on the plain `:scope`. In the fill state the container owns the width, all
26
- the way downno exception, no re-added floor. Two floor classes are
23
+ **[retired 2026-08-28, ADR-0095]** The `--*-min-width: 20ch`-class floor
24
+ described below is no longer permitted anywhere, including on
25
+ `:scope[inline]`. A primitive never floors its own inline-size independent
26
+ of its own geometry the only permitted floor is
27
+ `min-inline-size: var(--<component>-height)` (a square minimum). ADR-0095
28
+ Decision Class 1 removes the 20ch-class field-width floor for
29
+ `input-ui`/`select-ui`/`combobox-ui` (plus `--input-label-min-width`,
30
+ `--date-range-picker-trigger-min-width`) and replaces it with the square
31
+ floor, generalizing the `button-ui` keep-square pattern already named below
32
+ to the field-width components.
33
+
34
+ *(Historical, pre-ADR-0095 rule — superseded by the retirement above, kept
35
+ only for context on what the old convention was.)* A
36
+ `--*-min-width: 20ch`-class floor (the gh#781/gh#1633 pattern on
37
+ `input-ui`/`select-ui`/`combobox-ui`) used to belong on `:scope[inline]`
38
+ only, never on the plain `:scope`. In the fill state the container owns the
39
+ width, all the way down — no exception, no re-added floor. Two floor
40
+ classes are
27
41
  explicitly NOT sizing-posture floors and stay in both states: inner
28
42
  `min-width: 0` flex unclampers (the ellipsis-reset machinery) and
29
43
  `min-width: var(--*-height)` squareness floors (`button-ui`'s keep-square
@@ -35,9 +49,10 @@ posture, and also stay in both states.
35
49
 
36
50
  - Plain `:scope` (no `[inline]`): block-level, `width: auto` in block flow,
37
51
  `width: 100%` in flex/grid parents. No `min-width` legibility floor here.
38
- - `:scope[inline]`: inline-level display, shrink-wrap sizing, and — only if
39
- the component is a floor-carrier (input/select/combobox-class) — the
40
- `--*-min-width: 20ch`-class floor moves here.
52
+ - `:scope[inline]`: inline-level display, shrink-wrap sizing. Per ADR-0095,
53
+ no `--*-min-width: 20ch`-class floor moves here any more — a floor-carrier
54
+ component uses the square minimum (`min-inline-size:
55
+ var(--<component>-height)`) instead, in both states.
41
56
  - A component that wants to own its line but still hug has no attribute for
42
57
  that state (ADR-0077 Decision item 6, accepted consequence) — tell the
43
58
  consumer to use explicit sizing (`width: fit-content` or an explicit
@@ -154,6 +154,11 @@ ADR-0053/0054 don't cover. Any new attribute follows these:
154
154
  opt-out. `chart-legend[static]` retires to `[interactive]` (inverted
155
155
  default). Applies to single-purpose toggles only — `noodles`'s
156
156
  `editable`/`readonly` pair is two orthogonal booleans and stays.
157
+ - **One spelling survives a documented alias; the alias retires
158
+ everywhere it appeared** — never a deprecation window. `alert[dismissible]`
159
+ retired (`[closable]` survives, the implemented prop); `card`'s style
160
+ enum's duplicate `outline` member retired (`outlined` survives, the
161
+ ADR-0044 style-axis token).
157
162
  - **Two sanctioned multi-value encodings, never a hand-rolled delimited
158
163
  string:** (a) `multiple` boolean + a structured option model (`select`'s
159
164
  `options`/`<option>` shape); (b) a native array-typed JS property,