@adia-ai/adia-ui-forge 0.8.7 → 0.8.9

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adia-forge",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Maintain the adia-ui (@adia-ai) framework itself \u2014 author primitives and shells, run the A2UI generation pipeline and its corpus, review gen-UI quality, sweep QA, cut releases, deploy. The maintainer counterpart to adia-factory (the consumer/app-author plugin).",
5
5
  "author": {
6
6
  "name": "Kim",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog — adia-forge
2
2
 
3
+ ## [0.8.9] — 2026-07-19
4
+
5
+ ### Fixed
6
+ - **`release-pack.mjs` now actually creates `release/vX.Y.Z`** before staging — `step5Commit()` previously committed on whatever branch was checked out (the v0.8.8 cut landed its release commit on local `main`, caught and recovered by hand). Idempotent (checkout if the branch exists — a resumed run — create with `-b` otherwise); `step1ReBaseline()` now asserts the starting branch is `main` or the release branch itself for cut modes. Guarded: checking out an *existing* release branch on a dirty tree now fails with a real diagnostic (which files, what to do) instead of an opaque git error.
7
+ - **Step 9's settle-wait polls the registry itself**, not one proxy workflow — the old code waited for `publish-a2ui-validator.yml` alone as a stand-in for all 11 publishes; a straggler on any other package made the immediately-following fail-closed registry check abort on a pure timing false alarm (hit live on the v0.8.8 cut). Now polls all 11 packages + `latest` directly, every 15s up to ~10-18min, per the skill's own doctrine ("the registry is the verify target, never a workflow's green check"). The existing fail-closed check remains as a final confirmation.
8
+ - **`pr-bridge.mjs` refuses to merge a DRAFT PR** — a real incident: it attempted to merge an author-marked draft, which GitHub's API rejects, and the old code (no `isDraft` check anywhere) crashed uncaught. `bridgeDecision()` now treats `isDraft` as an unconditional stop; the `gh pr merge` call and the top-level `main().catch` are both wrapped so no rejection reason — draft or otherwise — can crash invisibly under the script's own documented `grep -E "pr-bridge" | tail -N` usage pattern.
9
+
10
+ ### Maintenance
11
+ - **`.claude-plugin/plugin.json` version bump** — moves in lockstep with package.json (the `/plugin update` cache key).
12
+ - **`skills/` touched in this release window** (2 file(s), e.g. `scripts/pr-bridge.mjs`) — carried by the entries above.
13
+
14
+ ## [0.8.8] — 2026-07-19
15
+
16
+ ### Added
17
+ - **`pr-bridge.mjs`** (adia-release `skills/adia-release/scripts/`): the cut's two-phase PR bridge, mechanized — pushes the release branch, opens/reuses the PR, polls CI, and admin-merges ONLY on all-non-fail-soft-checks-green + zero unresolved review threads + no changes-requested + MERGEABLE; anything else waits or stops with full evidence (`--dry` previews the decision). Independently reviewed and hardened pre-ship: never merges on an empty gating-check set ("nothing failed" ≠ "something passed"); operator-supplied args go through `execFileSync` arg arrays (no shell); the merge-landing proof never skips silently. 14-case selftest; registered in CI's Plugin bin selftests; battle-tested on 8 real merges before first release use. cut-procedure §5.7 documents both the mechanized and manual forms.
18
+
19
+ ### Changed
20
+ - **`package-paths.mjs` is now the single source of the 11-package roster** (`PACKAGE_ROSTER` + `PACKAGE_NAMES`/`PACKAGE_DIRS`): the lockstep list was re-declared in 8 scripts (bump, tag-lockstep, dispatch-publish, release-pack, and the repo-side check-release/check-lockstep/check-release-docs/generate-release-notes), each with its own projection — the same list-drift class the gate-roster incident proved out. Skill-side home is deliberate: cache-executed skill scripts can't import repo modules; repo scripts import the skill module by its stable in-repo path.
21
+ - **`gate-roster.mjs` grew gate 18** (`check-cut-hygiene --version X.Y.Z`, via the new `versionArg` field release-pack substitutes) and **gate 4 now runs `test:unit:serial`** (`vitest --no-file-parallelism` — gates-catalog §Cat 8's documented source of truth IS now the gate). References updated same-change: cut-procedure §3.1 (18-gate table), gates-catalog Cat 1/7/8/9.
22
+
23
+ ### Fixed
24
+ - **`bump.mjs` moves every cross-file version reference in the same pass**: the factory `.mcp.json` `@adia-ai/a2ui-mcp@X.Y.Z` pin (invariant 8 — hand-fixed on BOTH the 0.8.6 and 0.8.7 cuts; `check:lockstep` now guards it too) and the root README "Current version" claim (`REPO_PINNED_REFS` — gate 18 exact-matches it, so a hand-set value fails the next cut by construction). All pins pre-flight-validated alongside `validateAllAtFrom`, so a doomed bump writes NOTHING (reviewer finding: the mid-loop check fired only after two files were already written).
25
+ - **adia-release docs**: SKILL.md Mechanization + cut-procedure §5.7/§3.1 + gates-catalog reflect pr-bridge, the 18-gate roster, the umbrella tag's `info` classification (the repo-side `check-release.mjs` fix that killed the Step-7 false-stop which forced manual handoffs on 0.8.6/0.8.7), and the deleted superseded `check-changelog-coverage` gate.
26
+
27
+ ### Maintenance
28
+ - **`.claude-plugin/plugin.json` version bump** — moves in lockstep with package.json (the `/plugin update` cache key).
29
+
3
30
  ## [0.8.7] — 2026-07-19
4
31
 
5
32
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/adia-ui-forge",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Maintain the adia-ui (@adia-ai) framework itself \u2014 author primitives and shells, run the A2UI generation pipeline and its corpus, review gen-UI quality, sweep QA, cut releases, deploy. The maintainer counterpart to adia-factory (the consumer/app-author plugin).",
5
5
  "keywords": [
6
6
  "adia-ui",
@@ -95,7 +95,7 @@ assumed pass, or "published" without the actual `npm view` output.
95
95
 
96
96
  `release-pack.mjs` walks the cycle in two phases per invariant 3 — `--mode cut` / `--mode from-scratch` promote `[Unreleased]` (BOTH modes now — a peer's hand-authored section must be promoted whichever mode cut it, and a loud guard blocks the bump if any of the 11 packages still carries non-empty `[Unreleased]` content after promotion), run Step 4f (pre-tag coverage `--fix` with the authoritative F-N1 matcher), and stop at the release commit (PR → merge); `--mode handoff` tags/publishes/deploys from post-merge main. **`--go` is the standard invocation for an operator-initiated release** — the single authorization covers the cycle; evidence blocks print as a log; granular `--yes`/`--push`/`--publish` remain for cautious manual runs (`--push` still refuses to auto-fire past any F-N1 finding). `--mode batch` is not implemented — it hard-rejects with a pointer to [`recovery-paths.md`](references/recovery-paths.md) §Scenario 2 rather than silently misbehaving; a real batch tags each version at its own release-merge SHA, which this single-version orchestrator has no model for. Pre-flight (Step 3) runs the full 17-gate roster from the single shared list in `scripts/gate-roster.mjs` — the same list [`cut-procedure.md`](references/cut-procedure.md) §3.1 transcribes, so a subset run requires editing that one file, not two.
97
97
 
98
- The pieces are callable alone: `bump.mjs` (version fields), `promote-unreleased.mjs` / `insert-stub.mjs` (CHANGELOG blocks), `tag-lockstep.mjs` (12 tags, `--delete` to move), `dispatch-publish.mjs` (publish workflows; `--verify-triggered` re-dispatches only missing runs; `--after` enforces batch ordering — dispatch output states what WAS done, never a to-do phrased as if it hadn't fired). All fail fast outside a monorepo root (`assert-monorepo-root.mjs`); fork-configurable via `--host` / `--scope`. All 6 CLI scripts (plus `scripts/release/check-release.mjs`) have a `selftest` mode wired into CI's Plugin bin selftests step; the 3 helper modules (`assert-monorepo-root`, `gate-roster`, `package-paths`) are exercised through the CLIs' selftests, not their own.
98
+ The pieces are callable alone: `bump.mjs` (version fields + the factory `.mcp.json` a2ui-mcp pin, invariant 8), `promote-unreleased.mjs` / `insert-stub.mjs` (CHANGELOG blocks), `tag-lockstep.mjs` (12 tags, `--delete` to move), `dispatch-publish.mjs` (publish workflows; `--verify-triggered` re-dispatches only missing runs; `--after` enforces batch ordering — dispatch output states what WAS done, never a to-do phrased as if it hadn't fired), and `pr-bridge.mjs` (the two-phase bridge: pushes the release branch, opens the PR, waits out CI, and merges ONLY on all-non-fail-soft-checks-green + zero unresolved threads + no changes-requested; any other state stops with evidence — §5.7 mechanized, `--dry` to preview the decision). All fail fast outside a monorepo root (`assert-monorepo-root.mjs`); fork-configurable via `--host` / `--scope`. All 6 CLI scripts (plus `scripts/release/check-release.mjs`) have a `selftest` mode wired into CI's Plugin bin selftests step; the 3 helper modules (`assert-monorepo-root`, `gate-roster`, `package-paths`) are exercised through the CLIs' selftests, not their own.
99
99
 
100
100
  Site deploy (Step 10 of the handoff) dispatches `deploy-site.yml` (its own pre-flight verify → snapshot → hardened rsync → post-deploy verify → auto-rollback), never a raw local `rsync` — H1 (forge-campaign gh#268 audit): the script had drifted into bypassing that pipeline even though this doc already taught the workflow-dispatch path. The dispatch rides the cycle's single authorization (`--go`, or `--publish` on a manual run); the workflow's own GitHub environment gate is the human stop for the deploy itself.
101
101
 
@@ -78,13 +78,13 @@ npm run build:bundles # dist CSS+JS bundles
78
78
  ```bash
79
79
  node scripts/build/components.mjs --verify # 1 yaml ↔ sidecar ↔ .d.ts
80
80
  npm run verify:traits # 2 trait coverage
81
- npm run check:lockstep # 3 version coherence
82
- npm run test:unit # 4 vitest
81
+ npm run check:lockstep # 3 version coherence (+ factory .mcp.json a2ui-mcp pin, invariant 8)
82
+ npm run test:unit:serial # 4 vitest, serial — the source of truth (§Cat 8; parallel flakes under load)
83
83
  npm run typecheck # 5 tsc --noEmit
84
84
  npm run check:demo-shells # 6 demo imports cover composes:
85
85
  npm run check:lightningcss-build # 7 CSS minifies
86
86
  npm run check:css-bundles-fresh # 8 dist CSS matches source
87
- npm run check:js-bundles-fresh # 9 dist JS matches source
87
+ npm run check:js-bundles-fresh # 9 dist JS matches source + semantic content (everything-import, ≥100 -ui tags, icons-manifest ×2)
88
88
  npm run smoke:engines # 10 gen-UI engines
89
89
  npm run smoke:register-engine # 11 register-engine 11/11
90
90
  npm run verify:corpus # 12 corpus 0 errors
@@ -93,6 +93,7 @@ npm run check:embeddings-fresh # 14 embeddings vs chunk index
93
93
  npm run check:links # 15 intra-repo links
94
94
  npm run eval:diff -- --engine zettel # 16 eval floors
95
95
  npm run dogfood:status # 17 P0/P1 dogfood floor
96
+ node scripts/release/check-cut-hygiene.mjs --version <mode-dependent> # 18 README CDN-pin + version currency — CUT modes pre-flight at the PREVIOUS version (the README claim is exact-match and only moves at the Step-4 bump; Step 4g re-proves at the cut version), HANDOFF pre-flights at the CUT version (post-merge, the claim already moved)
96
97
  ```
97
98
 
98
99
  Any red → route via [`gates-catalog.md`](gates-catalog.md); fix at the source, re-run the narrowest gate, then re-run the full sequence. The canonical miss: a cut that ran 7 of the gates shipped a stale-embeddings defect that surfaced a day later and cost a tag-move recovery.
@@ -157,7 +158,18 @@ Drift here means a regen output was left out of the allowlist — stage it and r
157
158
 
158
159
  ## §Step 5.7 — Release PR: push the branch, merge, re-baseline
159
160
 
160
- The release commit reaches `main` through the standard PR flow:
161
+ The release commit reaches `main` through the standard PR flow. Mechanized
162
+ form (preferred — `pr-bridge.mjs` pushes, opens the PR, polls, merges ONLY
163
+ when every non-fail-soft check is green AND zero review threads are
164
+ unresolved AND no review requests changes; any other state stops with the
165
+ evidence, never force-merges):
166
+
167
+ ```bash
168
+ node "${CLAUDE_PLUGIN_ROOT}/skills/adia-release/scripts/pr-bridge.mjs" \
169
+ --branch "release/vX.Y.Z" --title "release: vX.Y.Z lockstep" --body-file <path>
170
+ ```
171
+
172
+ Manual form (the same flow the bridge mechanizes):
161
173
 
162
174
  ```bash
163
175
  git -C "$REPO" push -u origin "release/vX.Y.Z"
@@ -25,12 +25,9 @@ Row layout per gate: **What** · **Typical failure** · **Recovery**.
25
25
  - **Cosmetic failure:** "diff `packages/<pkg>/components/` touched but CHANGELOG `[X.Y.Z]` doesn't mention 'components'" — the entry names the component (`table-ui`) but not the literal path keyword. Regex miss, change IS documented.
26
26
  - **Real failure:** a touched directory has NO matching CHANGELOG entry at all — the cycle missed documenting a change.
27
27
  - **Recovery:** cosmetic → add the path keyword to a relevant entry (`table.yaml` → `components/table/table.yaml`); real → author the missing entry. Either way: land it as a new commit through the PR flow (the release commit is already merged — `--amend` is not possible post-invariant-3), delete + re-create the tags at the new post-merge SHA, re-run. Full discipline: [`changelog-discipline.md`](changelog-discipline.md) §F-N1 enrichment.
28
- - The umbrella tag's "doesn't match `<pkg>-v<version>`" **error is expected ignore it**; only per-package warns need action.
28
+ - The umbrella tag `vX.Y.Z` classifies **`info`** (since 2026-07-19 — it used to score `error`, which false-stopped release-pack's Step 7 on every cut); only per-package findings need action.
29
29
 
30
- ### `npm run check:changelog-coverage`
31
-
32
- - **What:** every published package has a `[vX.Y.Z]` CHANGELOG block for the current `package.json` version.
33
- - **Recovery:** add the block (stub if genuinely no source change — but classify by the diff first, see [`changelog-discipline.md`](changelog-discipline.md) §Detecting source-changing packages).
30
+ (The former `check:changelog-coverage` gate was deleted 2026-07-19 — its drifted 9-package matcher could report "clean" where the authoritative gate fails. Pre-tag coverage is `check-release.mjs --pending-version X.Y.Z [--fix]`, Step 4f.)
34
31
 
35
32
  ---
36
33
 
@@ -141,14 +138,7 @@ The two `audit:*` probes are soft-fail pre-cut (exit-1 only on criticals); findi
141
138
 
142
139
  - **What:** the `dist/` CDN bundles (`web-components.min.{css,js}`, `everything.min.js`, per-shell `*.min.js`, `icons-manifest.js`) match source. Stale bundles mean jsdelivr/unpkg serve yesterday's build. **Blocking in CI** (2026-06-08).
143
140
  - **Recovery:** `npm run build -w @adia-ai/llm` **FIRST** (its `index.js` is a gitignored tsc artifact `build:bundle-js` resolves — a fresh worktree can't bundle without it), then `npm run build:bundles` (or `build:bundle-css` / `build:bundle-js` individually). Stage both `dist/` trees.
144
- - **Manual spot-checks** (complement the gate; the v0.6.29–31 cycles shipped a shells-only `everything.min.js` that hard-crashed CDN consumers):
145
-
146
- ```bash
147
- grep "import '@adia-ai/web-components'" packages/web-modules/everything.js # must be present
148
- grep -oE '"[a-z][a-z-]*-ui"' packages/web-modules/dist/everything.min.js | sort -u | wc -l # ≥100
149
- node -e "const m=require('./packages/web-components/dist/icons-manifest.js'); \
150
- console.log('regular:', m.default.regular.length, '| cdn:', m.default.cdn)" # non-empty + cdn key
151
- ```
141
+ - **Semantic content checks — mechanized 2026-07-19** (formerly manual spot-checks; the v0.6.29–31 cycles shipped a shells-only `everything.min.js` that hard-crashed CDN consumers): `bundle-js.mjs` now enforces, on every build AND `--verify` run, that (1) `everything.js` still imports `@adia-ai/web-components`, (2) `everything.min.js` carries ≥100 distinct `-ui` tags, and (3) `icons-manifest.js` exists populated in BOTH dist trees. A fresh-but-semantically-wrong bundle fails the gate itself — no separate manual step remains.
152
142
 
153
143
  ### `npm run audit:chunk-reconcile`
154
144
 
@@ -162,7 +152,7 @@ The two `audit:*` probes are soft-fail pre-cut (exit-1 only on criticals); findi
162
152
 
163
153
  - **What:** the vitest suite (~1000+ tests).
164
154
  - **Stale-test failure:** the test asserts a behavior a peer deliberately changed (CHANGELOG-documented) without updating the assertion. Tell it apart by reading assertion vs CHANGELOG vs source: if the code matches the CHANGELOG's described behavior, the test is stale — update the assertion (see [`recovery-paths.md`](recovery-paths.md) §Scenario 5). Real regression → fix the regression.
165
- - **Parallel-contention flake:** heavy web-modules composite suites can fail under full parallelism on a loaded machine (`signals: drain loop exceeded 100 iterations`). Load-dependent, not a code defect. **Gate on `npx vitest run --no-file-parallelism`** (sequential is the source of truth); don't treat a one-off parallel flake as a blocker, and don't raise the drain guard.
155
+ - **Parallel-contention flake:** heavy web-modules composite suites can fail under full parallelism on a loaded machine (`signals: drain loop exceeded 100 iterations`). Load-dependent, not a code defect. Sequential is the source of truth, and **the roster gate now runs it directly** (`npm run test:unit:serial` = `vitest run --no-file-parallelism`, gate 4 since 2026-07-19) a parallel `test:unit` flake outside the roster still isn't a blocker, and don't raise the drain guard.
166
156
 
167
157
  ### `npm run eval:diff -- --engine zettel`
168
158
 
@@ -179,6 +169,11 @@ Siblings: `typecheck` (`tsc --noEmit`) · `smoke:engines` (gen-UI engines + retr
179
169
 
180
170
  ## §Category 9 — Misc release safety
181
171
 
172
+ ### `node scripts/release/check-cut-hygiene.mjs --version X.Y.Z`
173
+
174
+ - **What:** roster gate 18 (wired 2026-07-19; release-pack fills `--version` — the roster's one `versionArg` entry). The objective half of the v0.7.13-retro hygiene checker: published README CDN pins / "Current version" claims must not sit below the cut's minor (`doc-currency`, error-level); merged release branches + extra worktrees print as `limbo` warns (advisory without `--strict`). The judgment half of §4e — content *currency* of what the READMEs say — stays yours.
175
+ - **Recovery:** update the stale README pin/claim, stage it into the release commit. A `limbo` warn routes to branch/worktree cleanup, never blocks the cut.
176
+
182
177
  ### `npm run dogfood:status`
183
178
 
184
179
  - **What:** aggregator over the component dogfood audits; classifies findings P0–P3 and regenerates the tracker at `qa/findings/dogfood-tracker.md`. Exit-1 when P0+P1 > 0; P2/P3 are advisory.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // bump.mjs — bump 9 @adia-ai/* package.json from --from to --to.
2
+ // bump.mjs — bump the 11 lockstep @adia-ai/* package.json from --from to --to.
3
3
  //
4
4
  // Idempotent. Stops on the first mismatch (a package not at --from) so
5
5
  // you can investigate before any mutation lands.
@@ -20,32 +20,55 @@
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
22
  import process from 'node:process';
23
+ import { PACKAGE_DIRS, PACKAGE_ROSTER } from './package-paths.mjs';
23
24
 
24
- const PACKAGES = [
25
- 'packages/web-components',
26
- 'packages/web-modules',
27
- 'packages/llm',
28
- 'packages/a2ui/compose',
29
- 'packages/a2ui/corpus',
30
- 'packages/a2ui/mcp',
31
- 'packages/a2ui/retrieval',
32
- 'packages/a2ui/runtime',
33
- 'packages/a2ui/validator',
34
- // Plugins joined the lockstep 2026-07-15 (see check-lockstep.mjs's POLICY
35
- // note). Each also carries a .claude-plugin/plugin.json whose version MUST
36
- // move with package.json — plugin.json is the /plugin-update cache key
37
- // (verify:plugins gates the sync).
38
- 'packages/plugins/adia-ui-factory',
39
- 'packages/plugins/adia-ui-forge',
40
- ];
25
+ // Single-sourced roster (H3, package-paths.mjs). Each plugin also carries a
26
+ // .claude-plugin/plugin.json whose version MUST move with package.json —
27
+ // plugin.json is the /plugin-update cache key (verify:plugins gates the sync).
28
+ const PACKAGES = PACKAGE_DIRS;
41
29
 
42
30
  // Sibling manifests bumped in the same pass as package.json (path relative to
43
- // the package dir). Today: the Claude Code plugin manifests.
44
- const SIBLING_MANIFESTS = {
45
- 'packages/plugins/adia-ui-factory': '.claude-plugin/plugin.json',
46
- 'packages/plugins/adia-ui-forge': '.claude-plugin/plugin.json',
31
+ // the package dir) derived from the roster's plugin flag.
32
+ const SIBLING_MANIFESTS = Object.fromEntries(
33
+ PACKAGE_ROSTER.filter((p) => p.plugin).map((p) => [p.dir, '.claude-plugin/plugin.json']),
34
+ );
35
+
36
+ // Cross-package EXACT pins bumped in the same pass — a different shape from a
37
+ // package.json "version" field, so SIBLING_MANIFESTS' regex can't reach them.
38
+ // The factory plugin's .mcp.json pins @adia-ai/a2ui-mcp to the exact lockstep
39
+ // version (SKILL invariant 8 — the consumer never floats). bump.mjs left this
40
+ // untouched through the 0.8.6 + 0.8.7 cuts, forcing a manual edit + amend each
41
+ // time; check-lockstep's mcp-pin guard now catches a drift, this moves it.
42
+ const PINNED_REFS = {
43
+ 'packages/plugins/adia-ui-factory': {
44
+ file: '.mcp.json',
45
+ label: '@adia-ai/a2ui-mcp pin',
46
+ // `@adia-ai/a2ui-mcp@<from>` (not a prefix of a longer version) → `@<to>`
47
+ pattern: (from) => new RegExp(`(@adia-ai/a2ui-mcp@)${from.replace(/\./g, '\\.')}(?![\\d.])`),
48
+ replace: (to) => `$1${to}`,
49
+ },
47
50
  };
48
51
 
52
+ // Repo-level version references (path relative to REPO ROOT, not a package) —
53
+ // same mechanism, different anchor. The root README's "Current version" claim
54
+ // is gate 18's EXACT-match check (check-cut-hygiene doc-currency): hand-set it
55
+ // once and the next cut fails the gate — so the bump moves it, like the
56
+ // .mcp.json pin (H2 follow-through, 2026-07-19: found 0.7.13 claimed while
57
+ // 0.8.7 was live — a full minor of README drift shipped to npm).
58
+ const REPO_PINNED_REFS = [
59
+ {
60
+ file: 'README.md',
61
+ label: '"Current version" claim',
62
+ pattern: (from) => new RegExp(`([Cc]urrent version[^\\d]*v?)${from.replace(/\./g, '\\.')}(?![\\d.])`),
63
+ replace: (to) => `$1${to}`,
64
+ },
65
+ ];
66
+
67
+ // Pure so the selftest can lock the transform without touching a real file.
68
+ function bumpPinnedRef(text, cfg, from, to) {
69
+ return text.replace(cfg.pattern(from), cfg.replace(to));
70
+ }
71
+
49
72
  function parseArgs(argv) {
50
73
  const args = { from: null, to: null, dry: false, repo: process.cwd() };
51
74
  for (let i = 0; i < argv.length; i++) {
@@ -84,6 +107,52 @@ function loadPackageJsons(repo) {
84
107
  return result;
85
108
  }
86
109
 
110
+ // Pre-flight the cross-package pins the same way validateAllAtFrom pre-flights
111
+ // versions: a pin not at --from means invariant-8 drift, and catching it HERE —
112
+ // before bumpAll writes anything — keeps the failure mutation-free. (Reviewer
113
+ // finding on the first PINNED_REFS cut: the mid-loop pin check fired only after
114
+ // factory's package.json + plugin.json were already written, leaving a
115
+ // half-bumped tree whose re-run then failed validateAllAtFrom.)
116
+ function loadPinnedRefs(repo) {
117
+ const out = [];
118
+ for (const [pkg, cfg] of Object.entries(PINNED_REFS)) {
119
+ const p = path.join(repo, pkg, cfg.file);
120
+ if (!fs.existsSync(p)) {
121
+ console.error(`error: missing ${p}`);
122
+ process.exit(2);
123
+ }
124
+ out.push({ pkg, cfg, path: p, txt: fs.readFileSync(p, 'utf8') });
125
+ }
126
+ for (const cfg of REPO_PINNED_REFS) {
127
+ const p = path.join(repo, cfg.file);
128
+ if (!fs.existsSync(p)) {
129
+ console.error(`error: missing ${p}`);
130
+ process.exit(2);
131
+ }
132
+ out.push({ pkg: '(repo)', cfg, path: p, txt: fs.readFileSync(p, 'utf8') });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ // Repo-level pins aren't tied to a package iteration in bumpAll — apply them
138
+ // after it, from the same pre-validated set (validatePinsAtFrom guarantees the
139
+ // pattern matches, so this cannot fail mid-write).
140
+ function applyRepoPins(pins, from, to, dry) {
141
+ for (const x of pins.filter((x) => x.pkg === '(repo)')) {
142
+ const out = bumpPinnedRef(x.txt, x.cfg, from, to);
143
+ if (!dry) fs.writeFileSync(x.path, out);
144
+ console.log(` ${dry ? '[dry] ' : ''}bumped ${x.cfg.file} ${x.cfg.label}: ${from} → ${to}`);
145
+ }
146
+ }
147
+
148
+ function validatePinsAtFrom(pins, from) {
149
+ const bad = pins.filter((x) => !x.cfg.pattern(from).test(x.txt));
150
+ for (const x of bad) {
151
+ console.error(`error: ${x.pkg}/${x.cfg.file} — ${x.cfg.label} '@${from}' not found (invariant-8 drift? fix the pin first: \`npm run check:lockstep:fix\`). Nothing written.`);
152
+ }
153
+ return bad.length === 0;
154
+ }
155
+
87
156
  function validateAllAtFrom(packages, from) {
88
157
  const wrong = packages.filter((p) => p.version !== from);
89
158
  if (wrong.length > 0) {
@@ -121,6 +190,18 @@ function bumpAll(packages, from, to, dry) {
121
190
  if (!dry) fs.writeFileSync(sibPath, sibOut);
122
191
  console.log(` ${dry ? '[dry] ' : ''}bumped ${p.pkg}/${sibling}: ${from} → ${to}`);
123
192
  }
193
+ const pin = PINNED_REFS[p.pkg];
194
+ if (pin) {
195
+ const pinPath = path.join(path.dirname(p.path), pin.file);
196
+ const pinTxt = fs.readFileSync(pinPath, 'utf8');
197
+ const pinOut = bumpPinnedRef(pinTxt, pin, from, to);
198
+ if (pinOut === pinTxt) {
199
+ console.error(`error: ${p.pkg}/${pin.file} — ${pin.label} '@${from}' not found (invariant 8 drift? set the pin to @${from} and re-run, or use \`npm run check:lockstep:fix\`)`);
200
+ return -1;
201
+ }
202
+ if (!dry) fs.writeFileSync(pinPath, pinOut);
203
+ console.log(` ${dry ? '[dry] ' : ''}bumped ${p.pkg}/${pin.file} ${pin.label}: @${from} → @${to}`);
204
+ }
124
205
  }
125
206
  return bumped;
126
207
  }
@@ -128,8 +209,11 @@ function bumpAll(packages, from, to, dry) {
128
209
  function main() {
129
210
  const args = parseArgs(process.argv.slice(2));
130
211
  const packages = loadPackageJsons(args.repo);
212
+ const pins = loadPinnedRefs(args.repo);
131
213
  if (!validateAllAtFrom(packages, args.from)) process.exit(1);
214
+ if (!validatePinsAtFrom(pins, args.from)) process.exit(1);
132
215
  const bumped = bumpAll(packages, args.from, args.to, args.dry);
216
+ if (bumped >= 0) applyRepoPins(pins, args.from, args.to, args.dry);
133
217
  if (bumped < 0) process.exit(3);
134
218
  console.log(`\n[bump] ${bumped}/${PACKAGES.length} ${args.dry ? '(dry — no files written)' : 'bumped'}`);
135
219
  if (!args.dry) {
@@ -166,6 +250,43 @@ function selftest() {
166
250
  if (bumpAll(corrupted, '0.8.4', '0.8.5', true) !== -1) {
167
251
  console.error('selftest FAIL: corrupted package.json (no version match) not caught'); process.exit(1);
168
252
  }
253
+
254
+ // PINNED_REFS — the .mcp.json a2ui-mcp pin (invariant 8) moves in lockstep.
255
+ const pinCfg = PINNED_REFS['packages/plugins/adia-ui-factory'];
256
+ const pinBefore = '{"args":["-y","@adia-ai/a2ui-mcp@0.8.6"]}';
257
+ const pinAfter = bumpPinnedRef(pinBefore, pinCfg, '0.8.6', '0.8.7');
258
+ if (!pinAfter.includes('@adia-ai/a2ui-mcp@0.8.7') || pinAfter.includes('@0.8.6')) {
259
+ console.error('selftest FAIL: PINNED_REFS did not bump the .mcp.json a2ui-mcp pin'); process.exit(1);
260
+ }
261
+ // Must NOT prefix-match a longer version (0.8.6 inside 0.8.60).
262
+ if (bumpPinnedRef('@adia-ai/a2ui-mcp@0.8.60', pinCfg, '0.8.6', '0.8.7') !== '@adia-ai/a2ui-mcp@0.8.60') {
263
+ console.error('selftest FAIL: PINNED_REFS pattern prefix-matched a longer version'); process.exit(1);
264
+ }
265
+
266
+ // validatePinsAtFrom — the mutation-free pre-flight (reviewer finding: the
267
+ // mid-loop pin check fired only after two files were already written).
268
+ const goodPin = [{ pkg: 'packages/plugins/adia-ui-factory', cfg: pinCfg, path: '/nonexistent/.mcp.json', txt: pinBefore }];
269
+ if (!validatePinsAtFrom(goodPin, '0.8.6')) {
270
+ console.error('selftest FAIL: validatePinsAtFrom rejected a pin at --from'); process.exit(1);
271
+ }
272
+ if (validatePinsAtFrom(goodPin, '0.8.5')) {
273
+ console.error('selftest FAIL: validatePinsAtFrom accepted a drifted pin (should pre-flight-fail before any write)'); process.exit(1);
274
+ }
275
+
276
+ // REPO_PINNED_REFS — the root README "Current version" claim moves per-cut
277
+ // (gate 18's doc-currency check is EXACT-match, so a static value fails the
278
+ // next cut by construction).
279
+ const readmeCfg = REPO_PINNED_REFS[0];
280
+ const claimBefore = 'lockstep. Current version: **`v0.8.7`** — see CHANGELOG';
281
+ const claimAfter = bumpPinnedRef(claimBefore, readmeCfg, '0.8.7', '0.8.8');
282
+ if (!claimAfter.includes('v0.8.8') || claimAfter.includes('0.8.7')) {
283
+ console.error('selftest FAIL: REPO_PINNED_REFS did not move the Current-version claim'); process.exit(1);
284
+ }
285
+ // Must not touch an already-moved or unrelated version string.
286
+ if (bumpPinnedRef('Current version: v0.8.70', readmeCfg, '0.8.7', '0.8.8') !== 'Current version: v0.8.70') {
287
+ console.error('selftest FAIL: Current-version pattern prefix-matched a longer version'); process.exit(1);
288
+ }
289
+
169
290
  console.log('selftest OK');
170
291
  }
171
292
 
@@ -20,25 +20,14 @@
20
20
  import { execSync } from 'node:child_process';
21
21
  import process from 'node:process';
22
22
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
23
+ import { PACKAGE_NAMES } from './package-paths.mjs';
23
24
 
24
25
  // Instance data — fork-configurable. The npm scope the 11 packages publish under.
25
26
  // Default preserves @adia-ai behavior; override via --scope or $ADIA_NPM_SCOPE.
26
27
  const DEFAULT_SCOPE = '@adia-ai';
27
28
 
28
- const PACKAGES = [
29
- 'web-components',
30
- 'web-modules',
31
- 'llm',
32
- 'a2ui-runtime',
33
- 'a2ui-compose',
34
- 'a2ui-corpus',
35
- 'a2ui-mcp',
36
- 'a2ui-retrieval',
37
- 'a2ui-validator',
38
- // Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note).
39
- 'adia-ui-factory',
40
- 'adia-ui-forge',
41
- ];
29
+ // Single-sourced roster (H3, package-paths.mjs); order = dispatch order.
30
+ const PACKAGES = PACKAGE_NAMES;
42
31
 
43
32
  function parseArgs(argv) {
44
33
  const args = {
@@ -23,7 +23,10 @@ export const GATE_ROSTER = [
23
23
  { n: 1, cmd: 'node scripts/build/components.mjs --verify', what: 'yaml ↔ sidecar ↔ .d.ts' },
24
24
  { n: 2, cmd: 'npm run verify:traits', what: 'trait coverage' },
25
25
  { n: 3, cmd: 'npm run check:lockstep', what: 'version coherence' },
26
- { n: 4, cmd: 'npm run test:unit', what: 'vitest' },
26
+ // Serial on purpose: heavy web-modules suites flake under full parallelism
27
+ // ("drain loop exceeded 100 iterations") — gates-catalog §Cat 8 names the
28
+ // sequential run as the source of truth; the gate now IS that run (H5).
29
+ { n: 4, cmd: 'npm run test:unit:serial', what: 'vitest (serial — the source of truth)' },
27
30
  { n: 5, cmd: 'npm run typecheck', what: 'tsc --noEmit' },
28
31
  { n: 6, cmd: 'npm run check:demo-shells', what: 'demo imports cover composes:' },
29
32
  { n: 7, cmd: 'npm run check:lightningcss-build', what: 'CSS minifies' },
@@ -37,6 +40,11 @@ export const GATE_ROSTER = [
37
40
  { n: 15, cmd: 'npm run check:links', what: 'intra-repo links' },
38
41
  { n: 16, cmd: 'npm run eval:diff -- --engine zettel', what: 'eval floors' },
39
42
  { n: 17, cmd: 'npm run dogfood:status', what: 'P0/P1 dogfood floor' },
43
+ // versionArg: the runner appends ` --version X.Y.Z` (release-pack knows the
44
+ // cut version; the roster stays a static list). Objective half of the
45
+ // v0.7.13-retro hygiene checker — README CDN pins / "Current version"
46
+ // claims vs the cut version; branch/worktree limbo prints as warns (H2).
47
+ { n: 18, cmd: 'node scripts/release/check-cut-hygiene.mjs', what: 'README CDN-pin + version currency', versionArg: true },
40
48
  ];
41
49
 
42
50
  // -- CLI ------------------------------------------------------------------
@@ -23,6 +23,44 @@
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
 
26
+ // ─── THE package roster — single source of truth (H3, 2026-07-19) ─────────
27
+ //
28
+ // The 11-package lockstep list was re-declared in ~8 files (bump, tag-lockstep,
29
+ // dispatch-publish, release-pack, check-release, check-lockstep,
30
+ // check-release-docs, generate-release-notes), each carrying its own
31
+ // name/path projection — adding the 12th package meant 8 hand-edits, and the
32
+ // gate-roster.mjs incident (release-pack silently running 12 of 17 gates)
33
+ // proved what list-drift does. Same fix, same pattern: ONE list here, every
34
+ // consumer projects from it.
35
+ //
36
+ // This module lives in the SKILL scripts dir deliberately: skill scripts must
37
+ // work from the installed plugin cache (CLAUDE_PLUGIN_ROOT), where repo
38
+ // modules aren't importable — but repo scripts (scripts/release/*) always run
39
+ // in-repo and CAN import this by its stable in-repo path. The asymmetry
40
+ // decides the home.
41
+ //
42
+ // Order is the canonical publish/tag order (release-pack's historical order);
43
+ // consumers needing a different ordering (check-lockstep sorts by path for
44
+ // stable output) re-sort locally.
45
+ export const PACKAGE_ROSTER = [
46
+ { name: 'web-components', dir: 'packages/web-components' },
47
+ { name: 'web-modules', dir: 'packages/web-modules' },
48
+ { name: 'llm', dir: 'packages/llm' },
49
+ { name: 'a2ui-runtime', dir: 'packages/a2ui/runtime' },
50
+ { name: 'a2ui-compose', dir: 'packages/a2ui/compose' },
51
+ { name: 'a2ui-corpus', dir: 'packages/a2ui/corpus' },
52
+ { name: 'a2ui-mcp', dir: 'packages/a2ui/mcp' },
53
+ { name: 'a2ui-retrieval', dir: 'packages/a2ui/retrieval' },
54
+ { name: 'a2ui-validator', dir: 'packages/a2ui/validator' },
55
+ // Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note);
56
+ // each carries a .claude-plugin/plugin.json that bumps with package.json.
57
+ { name: 'adia-ui-factory', dir: 'packages/plugins/adia-ui-factory', plugin: true },
58
+ { name: 'adia-ui-forge', dir: 'packages/plugins/adia-ui-forge', plugin: true },
59
+ ];
60
+
61
+ export const PACKAGE_NAMES = PACKAGE_ROSTER.map((p) => p.name);
62
+ export const PACKAGE_DIRS = PACKAGE_ROSTER.map((p) => p.dir);
63
+
26
64
  // Resolve a package arg to its on-disk directory under packages/, accepting
27
65
  // BOTH the name form (`a2ui-corpus`) and the path form (`a2ui/corpus`).
28
66
  // Returns the resolved relative directory (e.g. `a2ui/corpus`); does not
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ // pr-bridge.mjs — babysit a release PR from push to merged main (the cut's
3
+ // two-phase bridge, mechanized).
4
+ //
5
+ // The cut modes of release-pack.mjs stop at the release commit (invariant 3:
6
+ // release commits land via PR, never a direct push to main). The bridge from
7
+ // there to `--mode handoff` — push the branch, open the PR, wait out CI,
8
+ // merge, re-baseline main — was the release cycle's last hand-carried leg
9
+ // (the operator babysat it on every cut). This script mechanizes EXACTLY the
10
+ // documented house flow (cut-procedure.md §5.7), no policy expansion:
11
+ //
12
+ // MERGES only when ALL of:
13
+ // - every non-fail-soft check has landed SUCCESS (jobs whose name carries
14
+ // the repo's "(fail-soft)" marker are advisory — reported, never gating,
15
+ // matching §5.7's "required checks (fail-soft jobs excluded)")
16
+ // - zero unresolved review threads (admin merge bypasses required review
17
+ // but NOT unresolved threads — repo-verified behavior)
18
+ // - no CHANGES_REQUESTED review decision
19
+ // - GitHub reports the PR MERGEABLE
20
+ // WAITS while anything is still pending/queued/UNKNOWN (up to --timeout).
21
+ // STOPS (exit 1, full evidence, no mutation) on anything else: a failed
22
+ // check, an unresolved thread, changes requested, a merge conflict, or
23
+ // timeout. It never force-merges past a red or contested state — a stop
24
+ // here is a human's turn, by design.
25
+ //
26
+ // Usage:
27
+ // node pr-bridge.mjs --branch release/v0.8.8 \
28
+ // --title "release: v0.8.8 lockstep" --body-file /tmp/pr-body.md
29
+ // node pr-bridge.mjs --branch <existing-pr-branch> # reuses the open PR
30
+ // node pr-bridge.mjs selftest
31
+ //
32
+ // Flags:
33
+ // --poll-interval <s> seconds between polls (default 30)
34
+ // --timeout <s> max seconds to wait for pending checks (default 2400)
35
+ // --dry report the current decision for the PR and exit;
36
+ // no push, no create, no merge
37
+ //
38
+ // Exit: 0 merged + main re-baselined · 1 stopped (evidence printed) · 2 bad args.
39
+ //
40
+ // Part of the adia-release skill. Registered in CI's "Plugin bin selftests".
41
+
42
+ import { execSync, execFileSync } from 'node:child_process';
43
+ import fs from 'node:fs';
44
+ import process from 'node:process';
45
+ import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
46
+
47
+ const REPO = process.cwd();
48
+
49
+ function parseArgs(argv) {
50
+ const args = {
51
+ branch: null, title: null, bodyFile: null,
52
+ pollInterval: 30, timeout: 2400, dry: false,
53
+ };
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const k = argv[i];
56
+ if (k === '--branch') args.branch = argv[++i];
57
+ else if (k === '--title') args.title = argv[++i];
58
+ else if (k === '--body-file') args.bodyFile = argv[++i];
59
+ else if (k === '--poll-interval') args.pollInterval = Number(argv[++i]);
60
+ else if (k === '--timeout') args.timeout = Number(argv[++i]);
61
+ else if (k === '--dry') args.dry = true;
62
+ else if (k === '-h' || k === '--help') { help(); process.exit(0); }
63
+ }
64
+ if (!args.branch) {
65
+ console.error('error: --branch is required (see --help)');
66
+ process.exit(2);
67
+ }
68
+ if (!Number.isFinite(args.pollInterval) || args.pollInterval < 5 ||
69
+ !Number.isFinite(args.timeout) || args.timeout < args.pollInterval) {
70
+ console.error('error: --poll-interval must be ≥5s and --timeout ≥ --poll-interval');
71
+ process.exit(2);
72
+ }
73
+ return args;
74
+ }
75
+
76
+ function help() {
77
+ console.log(`Usage:
78
+ node pr-bridge.mjs --branch release/vX.Y.Z --title "release: vX.Y.Z lockstep" --body-file <path>
79
+ node pr-bridge.mjs --branch <branch-with-open-pr> # reuse the PR
80
+ node pr-bridge.mjs --branch <branch> --dry # decision only
81
+ Flags: --poll-interval <s=30> --timeout <s=2400> --dry`);
82
+ }
83
+
84
+ function sh(cmd) {
85
+ return execSync(cmd, { cwd: REPO, encoding: 'utf8' }).trim();
86
+ }
87
+
88
+ // No-shell variant for every call that carries an operator-supplied value
89
+ // (branch, title, body path) — reviewer finding #2: string interpolation left
90
+ // `$(…)`/backticks live inside double quotes and broke on metachar branch
91
+ // names. execFileSync + arg array = no shell, no expansion, no quoting bugs.
92
+ function shFile(cmd, argv) {
93
+ return execFileSync(cmd, argv, { cwd: REPO, encoding: 'utf8' }).trim();
94
+ }
95
+
96
+ // ── The decision predicate — pure, selftest-locked ────────────────
97
+ // state: {
98
+ // checks: [{ name, status, conclusion }] normalized check runs + contexts
99
+ // unresolvedThreads: number
100
+ // reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
101
+ // mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
102
+ // }
103
+ // → { action: 'merge' | 'wait' | 'stop', reasons: [string] }
104
+ //
105
+ // Gating checks = every check whose name does NOT carry the "(fail-soft)"
106
+ // marker (the repo's own advisory-job convention). NEUTRAL and SKIPPED count
107
+ // as pass; anything not landed counts as pending; every other conclusion is
108
+ // a failure. CHANGES_REQUESTED, unresolved threads, and CONFLICTING always
109
+ // stop — no flag overrides them.
110
+ export const FAIL_SOFT_RE = /fail-soft/i;
111
+ const PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
112
+ const PENDING = new Set(['PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED', 'EXPECTED', '']);
113
+
114
+ export function bridgeDecision(state) {
115
+ const reasons = [];
116
+ let pending = false;
117
+ let gatingLanded = 0;
118
+
119
+ for (const c of state.checks) {
120
+ if (FAIL_SOFT_RE.test(c.name)) continue; // advisory by convention
121
+ const landed = (c.status || '').toUpperCase() === 'COMPLETED' || c.conclusion;
122
+ const concl = (c.conclusion || c.status || '').toUpperCase();
123
+ if (!landed || PENDING.has(concl)) { pending = true; continue; }
124
+ if (!PASS.has(concl)) reasons.push(`check '${c.name}' landed ${concl}`);
125
+ else gatingLanded++;
126
+ }
127
+ // gh#(pr-bridge draft guard): a draft PR is the AUTHOR's own "not ready"
128
+ // signal — GitHub's API refuses to merge one anyway (GraphQL: "Pull
129
+ // Request is still a draft"), and the old code had no isDraft check, so
130
+ // bridgeDecision said MERGE, the gh pr merge call threw, and the whole
131
+ // script crashed uncaught. A caller piping through `grep -E "pr-bridge" |
132
+ // tail -N` (this script's own documented usage pattern) sees only the
133
+ // last N lines BEFORE the crash and no error — reads as quiet success.
134
+ // Unconditional stop (review nit: placed after the per-check loop above,
135
+ // not literally first — but every reasons.push() here is unconditional and
136
+ // the "stop" decision below fires on ANY non-empty reasons list, so a
137
+ // draft overrides an otherwise-perfect merge regardless of ordering):
138
+ // never attempt the merge call on a draft, no matter how green everything
139
+ // else is.
140
+ if (state.isDraft) reasons.push('PR is a DRAFT — the author has not marked it ready for review; never auto-merge a draft');
141
+ if (state.reviewDecision === 'CHANGES_REQUESTED') reasons.push('a review requests changes');
142
+ if (state.unresolvedThreads > 0) reasons.push(`${state.unresolvedThreads} unresolved review thread(s) — admin merge cannot bypass these`);
143
+ if (state.mergeable === 'CONFLICTING') reasons.push('PR is CONFLICTING with the base branch');
144
+
145
+ if (reasons.length > 0) return { action: 'stop', reasons };
146
+ if (pending || state.mergeable === 'UNKNOWN') return { action: 'wait', reasons: ['checks or mergeability still pending'] };
147
+ // Never merge on an empty gating set (reviewer finding #1): "nothing failed"
148
+ // must not pass for "something passed" — a fresh PR's rollup can be briefly
149
+ // empty (or carry only advisory fail-soft jobs) while CI spins up, and this
150
+ // window would otherwise admin-merge with ZERO verified checks. WAIT (not
151
+ // stop): the checks appear within moments, or the timeout reports it.
152
+ if (gatingLanded === 0) {
153
+ return { action: 'wait', reasons: ['no gating check has landed SUCCESS yet — refusing to merge on an empty check set'] };
154
+ }
155
+ return { action: 'merge', reasons: [] };
156
+ }
157
+
158
+ // ── GitHub reads ──────────────────────────────────────────────────
159
+ function readPrState(prNumber) {
160
+ const view = JSON.parse(sh(
161
+ `gh pr view ${prNumber} --json state,isDraft,mergeable,reviewDecision,statusCheckRollup,headRefName,baseRefName`,
162
+ ));
163
+ const checks = (view.statusCheckRollup || []).map((c) => ({
164
+ name: c.name || c.context || '?',
165
+ status: c.status || c.state || '',
166
+ conclusion: c.conclusion || (c.state === 'SUCCESS' ? 'SUCCESS' : c.state === 'FAILURE' || c.state === 'ERROR' ? 'FAILURE' : ''),
167
+ }));
168
+ const threads = JSON.parse(sh(
169
+ `gh api graphql -f query='query($n:Int!){repository(owner:"${OWNER}",name:"${NAME}"){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved path line}}}}}' -F n=${prNumber}`,
170
+ )).data.repository.pullRequest.reviewThreads.nodes;
171
+ return {
172
+ state: view.state,
173
+ isDraft: view.isDraft,
174
+ headRefName: view.headRefName,
175
+ baseRefName: view.baseRefName,
176
+ checks,
177
+ unresolvedThreads: threads.filter((t) => !t.isResolved).length,
178
+ unresolvedList: threads.filter((t) => !t.isResolved).map((t) => `${t.path}:${t.line}`),
179
+ reviewDecision: view.reviewDecision || null,
180
+ mergeable: view.mergeable || 'UNKNOWN',
181
+ };
182
+ }
183
+
184
+ function printEvidence(st) {
185
+ console.log(` checks (${st.checks.length}):`);
186
+ for (const c of st.checks) {
187
+ const soft = FAIL_SOFT_RE.test(c.name) ? ' [advisory]' : '';
188
+ console.log(` ${c.name}: ${c.status} ${c.conclusion}${soft}`);
189
+ }
190
+ console.log(` unresolved threads: ${st.unresolvedThreads}${st.unresolvedThreads ? ' — ' + st.unresolvedList.join(', ') : ''}`);
191
+ console.log(` reviewDecision: ${st.reviewDecision || '(none)'} · mergeable: ${st.mergeable}`);
192
+ }
193
+
194
+ let OWNER = 'adiahealth';
195
+ let NAME = 'gen-ui-kit';
196
+
197
+ // ── Main flow ─────────────────────────────────────────────────────
198
+ async function main() {
199
+ const args = parseArgs(process.argv.slice(2));
200
+ assertMonorepoRoot(REPO);
201
+ const slug = JSON.parse(sh('gh repo view --json nameWithOwner')).nameWithOwner;
202
+ [OWNER, NAME] = slug.split('/');
203
+
204
+ // Locate or create the PR for the branch.
205
+ const existing = JSON.parse(
206
+ shFile('gh', ['pr', 'list', '--head', args.branch, '--state', 'open', '--json', 'number,title']));
207
+ let prNumber;
208
+ if (existing.length > 0) {
209
+ prNumber = existing[0].number;
210
+ console.log(`[pr-bridge] reusing open PR #${prNumber} for ${args.branch} ("${existing[0].title}")`);
211
+ } else if (args.dry) {
212
+ console.error(`[pr-bridge] --dry: no open PR for ${args.branch}; nothing to report`);
213
+ process.exit(1);
214
+ } else {
215
+ if (!args.title || !args.bodyFile) {
216
+ console.error('error: no open PR for this branch — --title and --body-file are required to create one');
217
+ process.exit(2);
218
+ }
219
+ if (!fs.existsSync(args.bodyFile)) {
220
+ console.error(`error: body file not found: ${args.bodyFile}`);
221
+ process.exit(2);
222
+ }
223
+ // Push the branch (idempotent if already pushed), then create.
224
+ shFile('git', ['push', '-u', 'origin', args.branch]);
225
+ const url = shFile('gh', ['pr', 'create', '--base', 'main', '--head', args.branch, '--title', args.title, '--body-file', args.bodyFile]);
226
+ prNumber = Number(url.split('/').pop());
227
+ console.log(`[pr-bridge] opened PR #${prNumber}: ${url}`);
228
+ }
229
+
230
+ // Poll until the predicate resolves.
231
+ const deadline = Date.now() + args.timeout * 1000;
232
+ for (;;) {
233
+ const st = readPrState(prNumber);
234
+ if (st.state === 'MERGED') {
235
+ console.log(`[pr-bridge] PR #${prNumber} is already merged`);
236
+ break;
237
+ }
238
+ if (st.state !== 'OPEN') {
239
+ console.error(`[pr-bridge] STOP — PR #${prNumber} is ${st.state}`);
240
+ process.exit(1);
241
+ }
242
+ const d = bridgeDecision(st);
243
+ console.log(`\n[pr-bridge] ${new Date().toISOString()} — decision: ${d.action.toUpperCase()}`);
244
+ printEvidence(st);
245
+
246
+ if (args.dry) {
247
+ console.log(`[pr-bridge] --dry: would ${d.action}${d.reasons.length ? ` (${d.reasons.join('; ')})` : ''}`);
248
+ process.exit(d.action === 'stop' ? 1 : 0);
249
+ }
250
+ if (d.action === 'stop') {
251
+ console.error(`\n[pr-bridge] STOP — not merging PR #${prNumber}:`);
252
+ for (const r of d.reasons) console.error(` ✗ ${r}`);
253
+ console.error(' This state needs a human (or a fix pushed to the branch); pr-bridge never merges past it.');
254
+ process.exit(1);
255
+ }
256
+ if (d.action === 'merge') {
257
+ console.log(`\n[pr-bridge] all gates green — merging PR #${prNumber} (admin merge per house flow §5.7)`);
258
+ // Defense in depth: the isDraft check above should catch this, but
259
+ // GitHub's own merge call is the actual authority — never let its
260
+ // rejection crash uncaught (a caller filtering output for the
261
+ // "pr-bridge" prefix, this script's own documented usage, would see
262
+ // the run go quiet mid-merge with no clear failure, easy to misread
263
+ // as success). Report loudly and exit 1 instead of throwing.
264
+ try {
265
+ sh(`gh pr merge ${prNumber} --admin --merge --delete-branch`);
266
+ } catch (e) {
267
+ console.error(`\n[pr-bridge] STOP — gh pr merge rejected PR #${prNumber}: ${e.message || e}`);
268
+ console.error(' Nothing else mutated by this run; investigate and merge manually if appropriate.');
269
+ process.exit(1);
270
+ }
271
+ break;
272
+ }
273
+ if (Date.now() > deadline) {
274
+ console.error(`\n[pr-bridge] STOP — timeout after ${args.timeout}s with checks still pending. Re-run to resume waiting.`);
275
+ process.exit(1);
276
+ }
277
+ await new Promise((r) => setTimeout(r, args.pollInterval * 1000));
278
+ }
279
+
280
+ // Re-baseline main and prove the merge landed.
281
+ sh('git checkout main');
282
+ sh('git pull origin main');
283
+ const mergedSha = JSON.parse(sh(`gh pr view ${prNumber} --json mergeCommit`)).mergeCommit?.oid;
284
+ if (mergedSha) {
285
+ sh(`git merge-base --is-ancestor ${mergedSha} HEAD`); // throws if not
286
+ console.log(`[pr-bridge] main re-baselined at ${sh('git rev-parse --short HEAD')} (merge ${mergedSha.slice(0, 9)} confirmed in history)`);
287
+ } else {
288
+ // Reviewer finding #4: never skip the landing proof SILENTLY.
289
+ console.log(`[pr-bridge] note: GitHub has not populated mergeCommit yet — landing proof skipped (main was pulled; verify with \`git log\` if it matters)`);
290
+ }
291
+ // Local branch cleanup (remote side handled by --delete-branch).
292
+ try { shFile('git', ['branch', '-D', args.branch]); } catch { /* already gone */ }
293
+ console.log(`[pr-bridge] done — PR #${prNumber} merged, main current. Resume with --mode handoff.`);
294
+ }
295
+
296
+ // ── Selftest — the predicate is the load-bearing part ─────────────
297
+ function selftest() {
298
+ const ck = (name, status, conclusion) => ({ name, status, conclusion });
299
+ const base = { unresolvedThreads: 0, reviewDecision: 'REVIEW_REQUIRED', mergeable: 'MERGEABLE', isDraft: false };
300
+ const cases = [
301
+ ['all green → merge',
302
+ { ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS'), ck('CodeRabbit', '', 'SUCCESS')] }, 'merge'],
303
+ ['DRAFT PR with all-green checks → stop, never attempt gh pr merge (real incident: PR #362)',
304
+ { ...base, isDraft: true, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS'), ck('CodeRabbit', '', 'SUCCESS')] }, 'stop'],
305
+ ['fail-soft failure is advisory → merge',
306
+ { ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS'), ck('Audit health (fail-soft)', 'COMPLETED', 'FAILURE')] }, 'merge'],
307
+ ['EMPTY check set → wait, never blind-merge (reviewer finding #1)',
308
+ { ...base, checks: [] }, 'wait'],
309
+ ['fail-soft-only set → wait, zero gating checks verified (reviewer finding #1)',
310
+ { ...base, checks: [ck('Audit health (fail-soft)', 'COMPLETED', 'SUCCESS')] }, 'wait'],
311
+ ['gating check failed → stop',
312
+ { ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'FAILURE')] }, 'stop'],
313
+ ['gating check pending → wait',
314
+ { ...base, checks: [ck('Static checks + MCP smoke', 'IN_PROGRESS', '')] }, 'wait'],
315
+ ['status-context pending → wait',
316
+ { ...base, checks: [ck('CodeRabbit', 'PENDING', '')] }, 'wait'],
317
+ ['unresolved thread → stop even when green',
318
+ { ...base, unresolvedThreads: 2, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'stop'],
319
+ ['changes requested → stop even when green',
320
+ { ...base, reviewDecision: 'CHANGES_REQUESTED', checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'stop'],
321
+ ['conflicting → stop',
322
+ { ...base, mergeable: 'CONFLICTING', checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'stop'],
323
+ ['mergeable UNKNOWN with green checks → wait (never merge blind)',
324
+ { ...base, mergeable: 'UNKNOWN', checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'SUCCESS')] }, 'wait'],
325
+ ['SKIPPED + NEUTRAL count as pass → merge',
326
+ { ...base, checks: [ck('a', 'COMPLETED', 'SKIPPED'), ck('b', 'COMPLETED', 'NEUTRAL')] }, 'merge'],
327
+ ['CANCELLED is a failure → stop',
328
+ { ...base, checks: [ck('Static checks + MCP smoke', 'COMPLETED', 'CANCELLED')] }, 'stop'],
329
+ ['failure + pending → stop wins (report now, not after the wait)',
330
+ { ...base, checks: [ck('a', 'COMPLETED', 'FAILURE'), ck('b', 'IN_PROGRESS', '')] }, 'stop'],
331
+ ];
332
+ let failed = 0;
333
+ for (const [label, state, want] of cases) {
334
+ const got = bridgeDecision(state).action;
335
+ const ok = got === want;
336
+ if (!ok) failed++;
337
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}: expected ${want}, got ${got}`);
338
+ }
339
+ console.log(failed === 0 ? '\nselftest OK' : `\nselftest: ${failed} failure(s)`);
340
+ process.exit(failed === 0 ? 0 : 1);
341
+ }
342
+
343
+ const argv = process.argv.slice(2);
344
+ if (argv[0] === 'selftest') selftest();
345
+ else main().catch((e) => {
346
+ // Reviewer finding (PR #364): every OTHER error path (readPrState()'s gh
347
+ // pr view/graphql calls on every poll, git push/gh pr create, the
348
+ // re-baseline sequence) throws up to this top-level handler, which had NO
349
+ // "[pr-bridge]" prefix — invisible under the exact `grep -E "pr-bridge" |
350
+ // tail -N` filter this PR exists to fix. Same class, different call site.
351
+ console.error(`[pr-bridge] STOP — ${e.message || e}`);
352
+ process.exit(1);
353
+ });
@@ -68,7 +68,7 @@ import os from 'node:os';
68
68
  import path from 'node:path';
69
69
  import process from 'node:process';
70
70
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
71
- import { resolvePackageChangelog } from './package-paths.mjs';
71
+ import { resolvePackageChangelog, PACKAGE_NAMES, PACKAGE_ROSTER } from './package-paths.mjs';
72
72
  import { GATE_ROSTER } from './gate-roster.mjs';
73
73
 
74
74
  const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
@@ -80,11 +80,8 @@ const REPO = process.cwd();
80
80
  const DEFAULT_DEPLOY_HOST = 'ui-kit.exe.xyz';
81
81
  const DEFAULT_NPM_SCOPE = '@adia-ai';
82
82
 
83
- const PACKAGES = ['web-components', 'web-modules', 'llm', 'a2ui-runtime',
84
- 'a2ui-compose', 'a2ui-corpus', 'a2ui-mcp', 'a2ui-retrieval',
85
- 'a2ui-validator',
86
- // Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note).
87
- 'adia-ui-factory', 'adia-ui-forge'];
83
+ // Single-sourced roster (H3, package-paths.mjs); order = publish order.
84
+ const PACKAGES = PACKAGE_NAMES;
88
85
 
89
86
  function parseArgs(argv) {
90
87
  const args = {
@@ -232,6 +229,21 @@ function shCapture(cmd, args) {
232
229
  // ── Step 1 — Re-baseline ──────────────────────────────────────────
233
230
  function step1ReBaseline(args) {
234
231
  console.log('\n=== Step 1 — Re-baseline ===');
232
+ // cut-procedure.md §Step 1 requires `git branch --show-current` == main
233
+ // before a cut starts — cutting on any other branch pushes a stale main ref
234
+ // while tags point at the wrong tip (recovery-paths.md §Scenario 8). The doc
235
+ // asserted this; nothing here ever checked it (gh#358). The one legitimate
236
+ // exception is resuming this SAME cut after step5Commit already created and
237
+ // checked out release/vX.Y.Z — allow only that exact branch name back in.
238
+ if ((args.mode === 'cut' || args.mode === 'from-scratch') && !args.dry) {
239
+ const branch = shQuiet('git branch --show-current', args).trim();
240
+ const releaseBranch = `release/v${args.version}`;
241
+ if (branch !== 'main' && branch !== releaseBranch) {
242
+ console.error(`ERROR: on branch '${branch}' — cut modes start from 'main' (or resume on '${releaseBranch}'). Switch branches before proceeding.`);
243
+ process.exit(1);
244
+ }
245
+ console.log(` branch: ${branch} (ok)`);
246
+ }
235
247
  console.log('git status --short:');
236
248
  sh('git status --short', args);
237
249
  console.log('\norigin/main..HEAD (unpushed):');
@@ -253,11 +265,19 @@ function step3PreFlight(args) {
253
265
  }
254
266
  console.log(`\n=== Step 3 — Pre-flight gates (${GATE_ROSTER.length}-gate roster — gate-roster.mjs) ===`);
255
267
  for (const g of GATE_ROSTER) {
256
- console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${g.cmd} # ${g.what}`);
268
+ // versionArg gates take a MODE-DEPENDENT version (both directions bit
269
+ // the first 0.8.8 run): cut modes run pre-flight PRE-BUMP, where the
270
+ // README "Current version" claim still equals the PREVIOUS version (the
271
+ // bump moves it in Step 4; Step 4g then re-proves at the cut version) —
272
+ // while handoff runs POST-MERGE, where the claim already equals the CUT
273
+ // version and validating at previous fails by construction.
274
+ const hygieneVersion = args.mode === 'handoff' ? args.version : args.previous;
275
+ const cmd = g.versionArg ? `${g.cmd} --version ${hygieneVersion}` : g.cmd;
276
+ console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${cmd} # ${g.what}`);
257
277
  try {
258
- sh(g.cmd, args, { stdio: 'inherit' });
278
+ sh(cmd, args, { stdio: 'inherit' });
259
279
  } catch (e) {
260
- console.error(`\n ✗ FAILED (${g.n}/${GATE_ROSTER.length}): ${g.cmd}`);
280
+ console.error(`\n ✗ FAILED (${g.n}/${GATE_ROSTER.length}): ${cmd}`);
261
281
  console.error(' Pre-flight aborted. Fix the gate failure before proceeding.');
262
282
  console.error(' See references/gates-catalog.md for failure-mode → recovery.');
263
283
  process.exit(1);
@@ -339,6 +359,15 @@ function step4PromoteAndBump(args) {
339
359
  // delete/retag rounds discovering the same gaps only after tagging.
340
360
  console.log('\n Step 4f — pre-tag coverage (+auto-fix) with the authoritative F-N1 matcher:');
341
361
  sh(`node scripts/release/check-release.mjs --pending-version ${args.version} --fix`, args);
362
+
363
+ // Step 4g — re-run the versionArg gates at the CUT version, post-bump: the
364
+ // pre-flight ran them against the previous version (the README claim can't
365
+ // equal the cut version before bump.mjs moves it); this run proves the bump
366
+ // moved every cross-file reference to the new version.
367
+ console.log('\n Step 4g — cut-hygiene at the cut version (post-bump proof):');
368
+ for (const g of GATE_ROSTER.filter((g) => g.versionArg)) {
369
+ sh(`${g.cmd} --version ${args.version}`, args);
370
+ }
342
371
  }
343
372
 
344
373
  // ── Step 5 — Commit ──────────────────────────────────────────────
@@ -348,21 +377,45 @@ function step5Commit(args) {
348
377
  return;
349
378
  }
350
379
  console.log('\n=== Step 5 — Stage + Commit ===');
380
+ // cut-procedure.md §Step 5: "Branch FIRST, then stage" — this step never
381
+ // actually created release/vX.Y.Z, so the commit landed on whatever branch
382
+ // was checked out (main, on the v0.8.8 cut — gh#358). Idempotent: if the
383
+ // branch already exists (a resumed run), check it out rather than failing;
384
+ // step1ReBaseline's branch assertion is what stops a genuinely wrong branch
385
+ // from reaching this point.
386
+ const releaseBranch = `release/v${args.version}`;
387
+ const currentBranch = args.dry ? null : shQuiet('git branch --show-current', args).trim();
388
+ if (currentBranch !== releaseBranch) {
389
+ const exists = !args.dry && shQuiet(`git branch --list ${releaseBranch}`, args).trim().length > 0;
390
+ // Reviewer finding (PR #359): checking out an EXISTING release branch (a
391
+ // resumed run) on a dirty tree fails with a raw, opaque git error — Step 4
392
+ // (promote/bump/lockfile) runs immediately before this and dirties exactly
393
+ // the files a release branch differs from main in. A fresh `-b` create
394
+ // from clean main can't hit this, so the check only guards the resume path.
395
+ if (exists && !args.dry) {
396
+ const dirty = shQuiet('git status --porcelain', args).trim();
397
+ if (dirty) {
398
+ console.error(`ERROR: '${releaseBranch}' already exists, but the working tree is dirty — checking out would fail or silently carry these changes onto the resumed branch:\n${dirty}\n Commit, stash, or discard first, then re-run.`);
399
+ process.exit(1);
400
+ }
401
+ }
402
+ sh(`git checkout ${exists ? '' : '-b '}${releaseBranch}`, args);
403
+ }
351
404
  sh('git reset HEAD >/dev/null 2>&1 || true', args);
352
405
  const releaseFiles = [
353
406
  'package-lock.json',
354
- ...PACKAGES.flatMap((p) => {
355
- // Path map covers all three package families — the plugins joined the
356
- // lockstep 2026-07-15 and live under packages/plugins/ with a sibling
357
- // .claude-plugin/plugin.json that bump.mjs also moves (the
358
- // /plugin-update cache key); factory additionally pins a2ui-mcp in
359
- // .mcp.json (SKILL invariant 8).
360
- const dir = p.startsWith('a2ui-') ? `packages/a2ui/${p.replace('a2ui-', '')}`
361
- : p.startsWith('adia-ui-') ? `packages/plugins/${p}`
362
- : `packages/${p}`;
407
+ // Root README's "Current version" claim — bump.mjs moves it per-cut
408
+ // (REPO_PINNED_REFS); gate 18 (check-cut-hygiene doc-currency) exact-matches
409
+ // it against the cut version, so it must ride the release commit.
410
+ 'README.md',
411
+ ...PACKAGE_ROSTER.flatMap(({ name, dir, plugin }) => {
412
+ // Roster-driven (H3): each package stages its manifest + CHANGELOG;
413
+ // plugins also stage .claude-plugin/plugin.json (the /plugin-update
414
+ // cache key, moved by bump.mjs); factory additionally pins a2ui-mcp in
415
+ // .mcp.json (SKILL invariant 8 — bump.mjs moves the pin too).
363
416
  const files = [`${dir}/package.json`, `${dir}/CHANGELOG.md`];
364
- if (p.startsWith('adia-ui-')) files.push(`${dir}/.claude-plugin/plugin.json`);
365
- if (p === 'adia-ui-factory') files.push(`${dir}/.mcp.json`);
417
+ if (plugin) files.push(`${dir}/.claude-plugin/plugin.json`);
418
+ if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`);
366
419
  return files;
367
420
  }),
368
421
  ];
@@ -498,11 +551,34 @@ async function step9Publish(args) {
498
551
  // Give GH a moment to index the runs created by the per-tag push in Step 8.
499
552
  if (!args.dry) sh('sleep 6', args);
500
553
  sh(`node ${SCRIPT_DIR}/dispatch-publish.mjs --version ${args.version} --scope ${args.scope} --verify-triggered`, args);
501
- console.log('\n Waiting for publishes to settle...');
502
- sh(`until [ "$(gh run list --workflow=publish-a2ui-validator.yml --limit 1 --json status -q '.[0].status')" = "completed" ]; do sleep 5; done`, args);
503
- console.log(' publishes settled');
554
+ // gh#354: this used to poll ONE workflow (publish-a2ui-validator.yml) as a
555
+ // sample-of-one proxy for all 11 publishes settling a straggler on any
556
+ // OTHER package's workflow made the fail-closed check below abort on a pure
557
+ // timing false alarm (hit live on the v0.8.8 cut: a2ui-retrieval published
558
+ // successfully ~1 minute after validator did, after the old proxy had
559
+ // already declared "settled" and the registry read caught it mid-flight).
560
+ // Poll the REGISTRY itself instead — the skill's own doctrine (SKILL.md:
561
+ // "the registry is the verify target, never a workflow's green check")
562
+ // applies here as much as it does to the final check.
563
+ console.log(`\n Waiting for the registry to confirm all ${PACKAGES.length} packages at ${args.version} (polling every 15s, up to 10min)...`);
564
+ sh(
565
+ `for i in $(seq 1 40); do ` +
566
+ `fail=0; ` +
567
+ `for pkg in ${PACKAGES.join(' ')}; do ` +
568
+ `v="$(npm view "${args.scope}/$pkg" version 2>/dev/null || true)"; ` +
569
+ `[ "$v" = "${args.version}" ] || fail=1; ` +
570
+ `done; ` +
571
+ `latest="$(npm view ${args.scope}/web-components dist-tags.latest 2>/dev/null || true)"; ` +
572
+ `[ "$latest" = "${args.version}" ] || fail=1; ` +
573
+ `[ "$fail" = 0 ] && { echo " ✓ registry confirms all ${PACKAGES.length} + latest, after $i check(s)"; exit 0; }; ` +
574
+ `sleep 15; ` +
575
+ `done; ` +
576
+ `echo " registry did not converge after 10min — the fail-closed check below will report exactly which package(s) are still stale"`,
577
+ args,
578
+ );
504
579
  // Fail-closed: the registry is the verify target (SKILL.md) — a missing or
505
- // stale package aborts the cut here, before GH releases + site deploy.
580
+ // stale package aborts the cut here, before GH releases + site deploy. This
581
+ // is now a final confirmation of the poll above, not the first real read.
506
582
  console.log(`\n Post-publish registry verification, all ${PACKAGES.length} at ${args.version} + npm latest (fail-closed):`);
507
583
  sh(
508
584
  `fail=0; ` +
@@ -579,9 +655,11 @@ async function main() {
579
655
  console.log(`
580
656
  ✓ release commit ready — STOPPING per invariant 3 (release commits land via PR).
581
657
 
582
- Next (manual, or your CI-babysitting flow):
583
- git push -u origin <release/v${args.version} branch>
584
- gh pr create … # release PR → CI → merge
658
+ Next — mechanized bridge (pushes, opens the PR, waits out CI, merges only
659
+ green + zero unresolved threads, re-baselines main; stops on anything else):
660
+ node ${SCRIPT_DIR}/pr-bridge.mjs --branch release/v${args.version} \\
661
+ --title "release: v${args.version} lockstep" --body-file <path>
662
+ (or the manual flow: git push -u origin release/v${args.version} && gh pr create … && merge)
585
663
  Then resume from post-merge main:
586
664
  git checkout main && git pull origin main
587
665
  node release-pack.mjs --mode handoff --version ${args.version} --date ${args.date} \\
@@ -632,6 +710,8 @@ function selftest() {
632
710
  `${GATE_ROSTER.length}-gate roster`,
633
711
  `✓ all ${GATE_ROSTER.length}/${GATE_ROSTER.length} gates passed`,
634
712
  'STOPPING per invariant 3',
713
+ // gh#358 — the release branch must actually be created, not just documented.
714
+ 'git checkout -b release/v9.9.9',
635
715
  ];
636
716
  for (const marker of need) {
637
717
  if (!out.includes(marker)) {
@@ -15,22 +15,11 @@
15
15
  import { execSync } from 'node:child_process';
16
16
  import process from 'node:process';
17
17
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
18
+ import { PACKAGE_NAMES } from './package-paths.mjs';
18
19
 
19
- const PER_PACKAGE_NAMES = [
20
- 'web-components',
21
- 'web-modules',
22
- 'llm',
23
- 'a2ui-runtime',
24
- 'a2ui-compose',
25
- 'a2ui-corpus',
26
- 'a2ui-mcp',
27
- 'a2ui-retrieval',
28
- 'a2ui-validator',
29
- // Plugins joined the lockstep 2026-07-15 (check-lockstep.mjs POLICY note);
30
- // their `adia-ui-*-v*` tags trigger publish-adia-ui-{factory,forge}.yml.
31
- 'adia-ui-factory',
32
- 'adia-ui-forge',
33
- ];
20
+ // The roster is single-sourced in package-paths.mjs (H3) — the `adia-ui-*-v*`
21
+ // tags trigger publish-adia-ui-{factory,forge}.yml like every other package.
22
+ const PER_PACKAGE_NAMES = PACKAGE_NAMES;
34
23
 
35
24
  function parseArgs(argv) {
36
25
  const args = { version: null, at: null, deleteMode: false, dry: false, repo: process.cwd() };