@adia-ai/adia-ui-forge 0.8.30 → 0.8.32

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-ui-kit-forge",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
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,22 @@
1
1
  # Changelog — adia-ui-kit-forge
2
2
 
3
+ ## [0.8.32] — 2026-08-09
4
+
5
+ ### Fixed
6
+ - **adia-release `release-pack.mjs` deploy-settle wait tracks the dispatched run, requires success, and names the blocking run (gh#762).** The handoff's "waiting for the deploy workflow to settle" loop polled the NEWEST run's status in silence — it could settle on the previous completed run before the dispatch registered, accepted failure/cancelled as settled, and gave no signal while a forgotten run WAITING at the `production-site` gate held the `deploy-site` concurrency group (56h on Aug 5–8). Now: the newest run id is captured before dispatch, the handoff waits for the NEW run to appear, polls that run by id, requires `conclusion=success` (anything else fails the handoff), and each poll reports the gate-waiting blocker's id, wait-start, and URL ("BLOCKED BEHIND run X — approve or cancel it"). Pairs with the repo-level `reap-stale-deploy-gate.yml` auto-cancel (root CHANGELOG).
7
+ - **adia-release `release-pack.mjs` from-scratch guard rails (gh#765).** Three fixes from the v0.8.29 cut's failure modes: (1) a parse-time guard — `--mode from-scratch` whose `--substantive-packages` ∪ `--stub-packages` doesn't cover the 13-package roster now fails immediately, naming the ride-alongs that would be missing a `[VERSION]` CHANGELOG section, instead of dying at Step 4e after the ~15-min pre-flight (mirrors the handoff `--gh-notes-file` guard); (2) `sh()`/`shQuiet()` failures now print the failing command, exit status, and the inner command's captured stdout/stderr instead of a bare execSync stack; (3) a half-cut tree (all versions bumped + CHANGELOGs promoted by an aborted Step-4 run) is detected before pre-flight and the run RESUMES — hygiene gates validate at the cut version and Step 4 skips promote/stub/bump — instead of false-failing gate 24; a mixed partial bump hard-stops with a recovery pointer.
8
+ - **adia-release `dispatch-publish.mjs --verify-triggered` is conclusion-aware and registry-gated (gh#763).** The verify used to count ANY run for the tag as "triggered" — after the v0.8.29 runner-starvation wave stamped 5 publish runs `cancelled`, it would have reported all 13 packages fine and refused to re-dispatch the dead ones. Now: a run satisfies the check only on `conclusion: success`; in-progress/queued runs get a bounded wait (6 × 20s rechecks); cancelled/failed/timed-out runs and missing runs are re-dispatch candidates. Every re-dispatch is additionally gated behind `npm view <pkg>@<version>` — a version already on the registry is published regardless of run conclusion (the v0.8.29 compose run published, then got stamped cancelled by the 10m job clock), so re-dispatching over it would only 403. Registry remains the final arbiter; workflow conclusions are explicitly not publish truth. Selftest extended with negative controls for both the run-classification and the registry-gate decision tables.
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** (13 file(s), e.g. `adia-author/SKILL.md`) — carried by the entries above.
13
+
14
+ ## [0.8.31] — 2026-08-07
15
+
16
+ ### Maintenance
17
+ - **Lockstep version bump only.** No source changes in this package; bumped to maintain the lockstep version coherence enforced by `scripts/release/check-lockstep.mjs`. Substantive v0.8.31 work shipped in post-0.8.30 fix wave: double-submit boundaries, list-item render gate, segmented allow-empty + adapter kebab-case booleans, MCP multi-session + idle TTL, feedback signal:none, swatch auto-contrast, tabs strip-gap spacing + vertical clearance. See `packages/web-components/CHANGELOG.md#0831--2026-08-07` for details.
18
+ - **`.claude-plugin/plugin.json` version bump** — moves in lockstep with package.json (the `/plugin update` cache key).
19
+
3
20
  ## [0.8.30] — 2026-08-07
4
21
 
5
22
  ### Maintenance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/adia-ui-forge",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
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",
@@ -41,15 +41,13 @@ wins any tie with this skill. Monorepo source read while authoring (yaml, CSS,
41
41
  | Contract / token / lifecycle drift audit on an existing component | [token-contract.md](references/token-contract.md) + [anti-patterns.md](references/anti-patterns.md) |
42
42
  | Demo for a composite/module — any `packages/web-modules/**/*.{examples,contents}.html` | [composite-demo-protocol.md](references/composite-demo-protocol.md) — NOT the primitive or promotion paths; they lack the canonical-survey discipline |
43
43
  | Convention question ("is this idiomatic?") | [code-style.md](references/code-style.md) — cite the rule, don't expand it inline |
44
+ | Trait detail page (`site/pages/traits/<name>/`) | [trait-pages.md](references/trait-pages.md) — the ADR-0019 required-section template (traits are this skill's charter; adia-site-docs does not own `traits/`) |
44
45
 
45
- Depth references, loaded only when an entry reference cross-links them:
46
- [api-contract.md](references/api-contract.md) ·
47
- [css-patterns.md](references/css-patterns.md) ·
48
- [lifecycle-patterns.md](references/lifecycle-patterns.md) ·
49
- [yaml-contract.md](references/yaml-contract.md) ·
50
- [canonical-pattern-index.md](references/canonical-pattern-index.md) ·
51
- [common-gotchas.md](references/common-gotchas.md) ·
52
- [worked-example.md](references/worked-example.md).
46
+ Full retrieval map one line per reference file, grouped by axis:
47
+ [references/INDEX.md](references/INDEX.md). Depth references (api-contract,
48
+ css-patterns, lifecycle-patterns, yaml-contract, canonical-pattern-index,
49
+ common-gotchas, worked-example) load only when an entry reference or the
50
+ INDEX cross-links them.
53
51
 
54
52
  The `demo-postwrite-pattern-gate` hook enforces the demo `Pattern source:`
55
53
  citation mechanically on every web-modules demo write; the
@@ -0,0 +1,41 @@
1
+ # adia-author references — retrieval map
2
+
3
+ One line per file: `path — the question it answers`. Load this first; load
4
+ files on demand from it. Axes are topical (no severity ranking applies).
5
+ The skill body's task-shape table routes entry asks; this map covers every
6
+ file, including the depth references those entries cross-link.
7
+
8
+ ## Authoring lifecycle
9
+
10
+ - [primitive-audit.md](primitive-audit.md) — does the primitive I'm about to build already exist? The mandatory §0 affordance-by-affordance audit before ANY new component.
11
+ - [authoring-cycle.md](authoring-cycle.md) — what are the 5 steps to author a new primitive or modify an existing one, and which rules are non-negotiable?
12
+ - [worked-example.md](worked-example.md) — what does a finished primitive look like end to end? Two annotated builds: `<badge-ui>` (presentation) and `<counter-ui>` (form-associated).
13
+
14
+ ## Contracts (API · yaml · tokens · CSS · lifecycle)
15
+
16
+ - [api-contract.md](api-contract.md) — how do I declare `static properties` (type, default, reflect, attribute) and avoid the `attr:` silent-typo trap?
17
+ - [yaml-contract.md](yaml-contract.md) — what fields does the `<name>.yaml` SoT take, and how does it feed the generated `.a2ui.json` sidecar?
18
+ - [token-contract.md](token-contract.md) — is this component's CSS honoring the two-block `@scope` token contract, and why does it misrender under a theme?
19
+ - [css-patterns.md](css-patterns.md) — how do I structure a component stylesheet: two-block `@scope`, variants vs modes, token layering?
20
+ - [lifecycle-patterns.md](lifecycle-patterns.md) — how do I keep `connected()`/`disconnected()` symmetric for listeners, timers, observers, and popovers?
21
+
22
+ ## Shells & modules
23
+
24
+ - [shell-patterns.md](shell-patterns.md) — how do I author a shell or a bespoke cluster child (`<admin-*>` `<chat-*>` `<editor-*>` `<simple-*>`) per ADR-0023?
25
+ - [module-promotion.md](module-promotion.md) — when and how do I lift a UI block duplicated across consumers into a shared `packages/web-modules/` element (the 5-phase arc)?
26
+
27
+ ## Demos & doc surfaces
28
+
29
+ - [composite-demo-protocol.md](composite-demo-protocol.md) — what discipline governs any `packages/web-modules/**` demo edit, beyond what the `Pattern source:` gate can see?
30
+ - [canonical-pattern-index.md](canonical-pattern-index.md) — which canonical `.contents.html` files should I survey for a given UI type? Auto-generated; regenerate via `scripts/build-canonical-pattern-index.mjs`.
31
+ - [trait-pages.md](trait-pages.md) — which sections, in which order, must a `site/pages/traits/<name>/` detail page carry (ADR-0019 template)?
32
+
33
+ ## Conventions & failure catalog
34
+
35
+ - [code-style.md](code-style.md) — is this idiomatic AdiaUI? The conventions (`<*-ui>` elements, tokens, layout primitives) and the working feature each protects.
36
+ - [anti-patterns.md](anti-patterns.md) — what named mistake shapes (AP-##) has the coherence audit already caught, so I don't repeat them?
37
+ - [common-gotchas.md](common-gotchas.md) — which authoring traps render broken without console errors: CSS/composition layering (§§1–5), async-lifecycle races (§6), regeneration ordering (§7)?
38
+
39
+ ## Historical
40
+
41
+ - [llm-bridge.md](llm-bridge.md) — superseded 2026-07-18 pointer: `@adia-ai/llm` internals belong to `adia-llm-internals`; kept only to redirect.
@@ -597,6 +597,30 @@ disconnected() {
597
597
 
598
598
  ---
599
599
 
600
+ ## Registration / Composition
601
+
602
+ ### AP-R1 · Cross-primitive `import` inside a primitive's `.js`
603
+
604
+ ```javascript
605
+ // ❌ input.js
606
+ import '../button/button.js';
607
+ import '../icon/icon.js';
608
+ ```
609
+
610
+ Each primitive's `.js` registers ONLY its own `customElements.define()`. Composition is the **consumer's** responsibility, never the producer's: demo shells, consumer apps, and barrel-using packages import every primitive a fragment uses explicitly, or take the full barrel (`import '@adia-ai/web-components'`).
611
+
612
+ Three load-bearing arguments:
613
+
614
+ 1. **The composition graph is dynamic.** `<input-ui>` stamps `<button-ui>` + `<icon-ui>` only when `type="number"`; static ES modules have no conditional import, so a producer-side import makes every plain-text-input consumer pay the stepper's bundle cost. Bundle size would couple to the compose-graph instead of the usage graph.
615
+ 2. **The full barrel exists.** Consumers who don't want to think about composition import the barrel; piecemeal importers have opted into caring about bundle size, so explicit is the right tradeoff for them.
616
+ 3. **The silent failure has a one-liner detector.** An unregistered composed tag renders as an inert `HTMLUnknownElement` with no console error; probe with `customElements.get('button-ui')` (the `smoke:consumers` sweep spot-checks registration).
617
+
618
+ If you author a primitive that programmatically creates another (`document.createElement('button-ui')`, or via `innerHTML`/template strings), verify its demo shell `<x>.html` imports each composed primitive explicitly.
619
+
620
+ Source: ADR-0027.
621
+
622
+ ---
623
+
600
624
  ## Rapid-fire anti-patterns (mined from incident history)
601
625
 
602
626
  One line each; every entry is a shipped bug or a CRITICAL gate. Same axes as above, too small for a full entry.
@@ -203,3 +203,18 @@ render() {
203
203
  ```
204
204
 
205
205
  Rule of thumb: if CSS can do the work via an attribute selector (`:scope[disabled] { ... }`), prefer CSS. Reserve `render()` for propagating state into child inputs, recalculating positions, or reflecting data changes that attribute selectors can't express.
206
+
207
+ ## Popover `placement` defaults — picked by popover-to-trigger width ratio
208
+
209
+ Every popover-bearing primitive exposes a consumer-overridable `placement` attribute (declare it in the yaml SoT, with the default documented in the prop description). The DEFAULT is selected by the popover's natural width relative to its trigger — never by component identity:
210
+
211
+ | Case | Default |
212
+ |---|---|
213
+ | Popover ≈ trigger width (≤ ~1.5×; listboxes / action menus, anything that `matchWidth`s the trigger) | `bottom-start` |
214
+ | Popover materially wider than trigger (calendar grids, date/time/color pickers, filter panels) | `bottom` (centered) |
215
+ | Trigger sits at a container's right edge **by construction** (toolbar spillover, right-pinned "more") | `bottom-end` |
216
+ | Non-bottom-axis surfaces | component-specific: `tooltip-ui` → `top`, `nav-group-ui` collapsed flyout → `right` |
217
+
218
+ Rationale: `bottom-start` on a wide popover under a right-anchored trigger fires `anchor.js`'s right-edge overflow recovery, snapping the panel far past the trigger's left edge (the v0.6.35 date-range-picker incident — ~800px panel under a ~280px button). Centered `bottom` shifts at most half the overflow distance. Classification is done once at authoring time; borderline cases resolve by what the popover *wants*: if it `matchWidth`s the trigger it is trigger-width regardless of absolute size. "The consumer placed their button on the right of the layout" is a call-site `placement="bottom-end"` override, never a default. The overflow-recovery logic in `anchor.js` is the safety net and stays unchanged — the default's job is to make recovery rarely fire.
219
+
220
+ Source: ADR-0034.
@@ -355,6 +355,25 @@ When a card-like container (drawer-ui, pane-ui, full-height cards) needs a heade
355
355
 
356
356
  **Gotcha:** the sticky background must be opaque. If the header is transparent, content scrolls visibly underneath. Match the sticky element's `background` to the panel's `--*-bg` token.
357
357
 
358
+ ## Cascade layers — precedence is declared once, never fought per-rule
359
+
360
+ `styles/index.css` declares the single ordered list, before any layered rule:
361
+
362
+ ```css
363
+ @layer reset, tokens, elements, components, utilities, context, overrides;
364
+ ```
365
+
366
+ The precedence law: a later layer beats an earlier layer **regardless of selector specificity**; within a layer, normal specificity applies. Component `@scope` rules live inside `@layer components` — `@scope` sets proximity *inside* the layer, `@layer` sets inter-group order; they compose, they are not substitutes.
367
+
368
+ Consequences for component authors:
369
+
370
+ - **Consumer overrides always win** — unlayered consumer CSS, or `@layer overrides`, beats everything by layer order. Never add `:where()` wraps, matched-specificity selectors, or `!important` to "let the consumer win"; the layer order already guarantees it.
371
+ - **Utilities beat component defaults by layer order.** The global attribute API (`api/*`) sits in `utilities`, so a component cannot out-specific it — a component repurposing a global attribute (e.g. `color=` on a filled control) must opt out explicitly (see the `[color]`/`[weight]` trap below).
372
+ - **`@layer` governs rule precedence only.** The `var()` token chains (token indirection), genuine element-default `:where()`s, and allowlisted a11y `!important`s serve other mechanisms and are not retired by layers.
373
+ - `npm run check:cascade-layers` gates the model: canonical order declared once, `!important` ≤ allowlist, no precedence-`:where()` in layered files.
374
+
375
+ Source: ADR-0038.
376
+
358
377
  ## Raw-CSS traps (mined from incident history)
359
378
 
360
379
  One line each; every entry is a shipped bug.
@@ -291,6 +291,26 @@ disconnected() {
291
291
 
292
292
  Especially important for components that are attached/detached frequently (modals, toasts, drawers) — each incarnation leaks its old subtree into memory if refs aren't released.
293
293
 
294
+ ## Adopt-or-stamp — slot content is the source of truth
295
+
296
+ Every slot-bearing primitive that exposes both an attribute and a slot for the same logical content (`text=` + `<span slot="text">`) follows the `#stampMark` / `#wasStamped` policy (working reference: `list-item-ui`, `packages/web-components/components/list/list.js`):
297
+
298
+ 1. `connected()` either **adopts** the consumer-authored slotted child or **stamps** a new one, tagging it `data-{component}-stamped`.
299
+ 2. `render()` may only mutate or remove elements carrying the stamp. An unstamped slotted child is consumer-owned — leave it alone, even when the matching attribute is empty.
300
+ 3. The attribute stays a convenience shorthand that produces a stamped element; both forms render identically and are interchangeable.
301
+
302
+ The failure this prevents: a `render()` that overwrites `textContent` from the attribute, or hides/removes the slot element when the attribute is empty, silently destroys consumer-authored rich content (links, `<strong>`, inline icons). A 13-page audit found 73 slot uses where the slotted form is the only way to author the intended content (the destructive render() confirmed in 3 primitives) before the policy landed — from the page author's seat it looks like "the docs are broken", from the component author's seat like "the consumer didn't pass the attribute"; both are wrong-blame. Source: ADR-0010.
303
+
304
+ ### `slot="caret"` — the one disclosure-indicator slot
305
+
306
+ The expand/collapse (and dropdown) directional-indicator slot is named `caret` on every primitive that exposes composition slots (`tree-item-ui`, `pane-ui`, `accordion-item-ui`, `nav-group-ui`, `select-ui`). `chevron` is retired — a hard rename with no compat alias, and `--*-chevron-*` tokens became `--*-caret-*` (Phosphor names the glyphs `caret-right` / `caret-down`; there is no `chevron-*` icon).
307
+
308
+ - The mechanism is adopt-or-stamp (above): honor a declarative `<… slot="caret">` child if present, else stamp `<icon-ui slot="caret" name="caret-right">`. The same adopt-or-stamp rule applies to **every** composition slot the stamp builds (e.g. `slot="actions"`).
309
+ - Rotation is driven by host open-state CSS (`:scope[open]` / `[collapsed]` / `[expanded]`) — never by swapping the icon name.
310
+ - Internal-render components with no composition slots (`agent-trace`, `agent-reasoning`) keep `[data-*-caret]` styling hooks — the convention governs slots, not internal hooks.
311
+
312
+ Source: ADR-0036.
313
+
294
314
  ## Anti-patterns to watch for
295
315
 
296
316
  - **Inline arrow + `removeEventListener`** — the listener isn't really being removed. Silent leak.
@@ -89,7 +89,7 @@ Every component's `.css` file must follow the **two-block @scope** pattern:
89
89
 
90
90
  If you find a raw value elsewhere, either:
91
91
 
92
- - Fix it by adding a proper token at `packages/web-components/styles/tokens.css`
92
+ - Fix it by adding a proper token in the owning `styles/` dimension×layer file (see "Where foundation styles live" below — `tokens.css` is a compat barrel, not a home)
93
93
  - Or document the exemption in `.claude/docs/specs/component-token-contract.md`
94
94
 
95
95
  ## Chrome token palette
@@ -107,6 +107,20 @@ Added in v0.5.0 — use these for UI chrome:
107
107
 
108
108
  For charts, stat colors, category markers — use `--a-data-0` through `--a-data-9`. Do NOT hardcode chart colors.
109
109
 
110
+ ## Where foundation styles live — dimension × layer
111
+
112
+ Every file under `packages/web-components/styles/` answers two questions — which *dimension* (color · type · space · size · radius · motion · elevation) and which *layer* (primitive → semantic → role → element → attribute-API → context → reset) — and declares its cell with a machine-readable header tag `adia:<layer>/<dimension>`. When adding or moving a declaration:
113
+
114
+ - Spatial / size / radius / motion / elevation primitives → `styles/foundation/` (one file per dimension)
115
+ - Type scale (L1/L2), roles (L3), native-element defaults → `styles/type/`
116
+ - Color layers → `styles/colors/` (parameters · primitives-* · surfaces · scrims · semantics/*)
117
+ - The global attribute API (`[size]` `[density]` `[gap]` `[padding]` `[margin]` …) → `styles/api/{sizing,text,layout}.css` — the ONLY home for global `[attr]` selectors
118
+ - `tokens.css`, `typography.css`, `colors/semantics.css`, etc. remain **compat barrels** at their public paths — they only `@import`; never add declarations to them
119
+
120
+ Enforced by `scripts/release/check-foundation-layer-placement.mjs` (wired into `npm run check`): header tags present, primitive files free of attribute selectors, the attribute API confined to `api/`, no orphan files. Import order inside `colors/semantics/` is load-bearing (`aliases.css` last) and asserted in file headers.
121
+
122
+ Source: ADR-0035.
123
+
110
124
  ## When to update this reference
111
125
 
112
126
  If you add a new token category (like `--a-chrome-*` was added), update both this file and `.claude/docs/specs/component-token-contract.md`. The spec doc is the live source of truth; this file is the practitioner's checklist.
@@ -0,0 +1,38 @@
1
+ # Trait Detail Pages — the required template
2
+
3
+ Every trait detail page under `site/pages/traits/<name>/` follows one binding section template. The 41-page Phase B sweep proved what a loose contract produces: bimodal quality (portfolio-grade pages next to abstract-box demos) and zero pages teaching restraint. Navigate-by-template is the contract. Source: ADR-0019.
4
+
5
+ Trait pages are this skill's charter (traits live in `packages/web-components/traits/`); `adia-site-docs` owns `site/pages/{architecture,getting-started,guides,patterns,reference}` — not `traits/`.
6
+
7
+ ## Required sections, in order
8
+
9
+ | # | Section (`data-property=`) | Required? | Purpose |
10
+ |---|---|---|---|
11
+ | 1 | `<header>` | always | `<h1>` trait name + `<tag-ui>trait</tag-ui>` + category tag matching `_catalog.json` SoT |
12
+ | 2 | `hero` | always | "In context" demo with real product chrome — no abstract boxes |
13
+ | 3 | `minimal` | always | Single-tag declarative usage; `<h2>` reads "Bare minimum" |
14
+ | 4 | `declarative` | always | `<X traits="trait-name">` code-ui block |
15
+ | 5 | `config` | conditional | Only when the trait declares `config` attrs in `defineTrait()`; live sliders + readouts |
16
+ | 6 | `reduced-motion` | motion-only | Toggleable `prefers-reduced-motion: reduce` degradation demo. Required for any trait touching transform / opacity / animation |
17
+ | 7 | `accessibility` | always | 1–3 bullets on pointer-only, touch-confused, AT-silent reality |
18
+ | 8 | `performance` | always | 1 sentence on rAF cost, observer count, listener cost |
19
+ | 9 | `when-not-to-use` | always | 2–4 opinionated bullets on overuse, audience-mismatch, perf cost |
20
+ | 10 | `api` | always | Auto-stamped from `_api-table.js` |
21
+ | 11 | `composes` | always | ≥ 1 trait demonstrated in a WORKING composition; ≥ 4 traits listed |
22
+
23
+ Trait-specific extras (`state`, `lifecycle`, scenario `usage` recipes) slot between `config` (5) and `accessibility` (7).
24
+
25
+ ## Naming and sourcing rules
26
+
27
+ - `data-property` values are lowercase hyphen-separated tokens; canonical set as in the table. **`minimal`, not `bare`.**
28
+ - `data-artifact-label` values are lowercase descriptive prose ("settle log") OR code-shaped strings describing the demo axis (`speed=120 (slow, dramatic)`). HTML-entity escapes inside attribute values are correct.
29
+ - Header category tag MUST match the trait's `category` in `packages/web-components/traits/_catalog.json` — catalog SoT, never page-author judgment.
30
+ - Imports in code-ui blocks use the full package name `@adia-ai/web-components/traits` — never internal aliases like `@traits/index.js`.
31
+ - Inline-style tokens use canonical names: `--a-border` (dashed borders), `--a-radius-md` / `--a-radius-lg`, `--a-accent` / `--a-accent-fg`.
32
+
33
+ ## The two teeth
34
+
35
+ - **`when-not-to-use` is the highest-impact section.** A page shipping without it defaults to "this trait is good, please use it" — wrong for every trait once overuse, density, or a11y tradeoffs enter. The restraint mandate is the pedagogy differentiator (Motion / GSAP / React Spring all whiff on it). Fewer than 2 bullets requires an articulated why.
36
+ - **Composes must SHOW.** Listing trait names with prose-only descriptions does not satisfy the contract — at least one composition runs as a working demo.
37
+
38
+ No mechanical gate enforces this template yet (ADR-0019's verify-trait-pages.mjs was deferred and does not exist) — the 11-section contract is enforced at PR review. Source: ADR-0019.
@@ -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 roster package 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 24-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 + 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.
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 missing AND dead — cancelled/failed/timed-out — runs, each gated behind an npm-registry check so an already-published version is never re-dispatched (gh#763); `--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
 
@@ -148,20 +148,36 @@ tag-creation/tag-push/publish-dispatch commands until it passes, and
148
148
  shipped tarballs with unpromoted `[Unreleased]` CHANGELOG headers because
149
149
  this class of check ran only after tagging).
150
150
 
151
- **4-resume. Resuming a cut that died mid-Step-4** (v0.8.10 hit this live):
152
- `release-pack.mjs` always re-runs the FULL sequence from Step 1, and
153
- `promote-unreleased.mjs` hard-errors on already-promoted packages ("already
154
- has ## [X.Y.Z]") — so once ANY promotion has landed, re-invoking the
155
- orchestrator can never get past Step 4. That error is the idempotency guard
156
- working, not a corrupted state. Do NOT hand-edit CHANGELOGs back to
157
- `[Unreleased]` to appease it. Instead, verify the promotion state with the
158
- authoritative gate `node scripts/release/check-release-docs.mjs --version
159
- X.Y.Z` covers every lockstep package (including the nested `a2ui/*` and
160
- `plugins/*` paths a shallow `packages/*` glob misses) and fails on any
161
- leftover `[Unreleased]` content or missing `[X.Y.Z]` heading; it will still
162
- flag the not-yet-generated `docs/releases/vX.Y.Z.md`, which 4e below creates.
163
- Then run the remaining steps as the standalone pieces the Mechanization
164
- section already names, in this order:
151
+ **4-resume. Resuming a cut that died mid-Step-4** (v0.8.10 and v0.8.29 hit
152
+ this live). Two shapes, told apart by the lockstep versions — `release-pack.mjs`
153
+ checks them itself at startup (gh#765):
154
+
155
+ - **Complete bump (all 13 `package.json` versions at the cut version):**
156
+ just RE-RUN the same `release-pack.mjs` command. It detects the half-cut
157
+ tree before the pre-flight, prints a `[resume]` banner, validates the
158
+ hygiene gates at the CUT version (no more gate-24 false-fail at the
159
+ previous version), and skips promote/stub/bump in Step 4 re-running only
160
+ the idempotent substeps (lockfile, catalog, 4e notes, 4f coverage, 4g
161
+ hygiene) and continuing to Step 5. The `promote-unreleased.mjs`
162
+ "already has ## [X.Y.Z]" hard-error can no longer be reached on this path.
163
+ Do NOT hand-edit CHANGELOGs back to `[Unreleased]` first the resumed run
164
+ expects the promoted state and re-proves it with the
165
+ unpromoted-`[Unreleased]` guard.
166
+ - **Mixed versions (some packages bumped, some not — a mid-bump abort):**
167
+ the orchestrator hard-stops listing the stragglers and will neither resume
168
+ nor roll back. Recover by hand: restore the tree (`git status` /
169
+ `git checkout -- <files>`, or finish the bump with `bump.mjs` directly),
170
+ then re-run. To verify the promotion state along the way, the
171
+ authoritative gate is `node scripts/release/check-release-docs.mjs
172
+ --version X.Y.Z` — it covers every lockstep package (including the nested
173
+ `a2ui/*` and `plugins/*` paths a shallow `packages/*` glob misses) and
174
+ fails on any leftover `[Unreleased]` content or missing `[X.Y.Z]` heading;
175
+ it will still flag the not-yet-generated `docs/releases/vX.Y.Z.md`, which
176
+ 4e creates.
177
+
178
+ The fully manual fallback (both shapes, if the orchestrator itself is
179
+ suspect) remains the standalone pieces the Mechanization section names, in
180
+ this order:
165
181
 
166
182
  ```bash
167
183
  node "${CLAUDE_PLUGIN_ROOT}/skills/adia-release/scripts/bump.mjs" --from X.Y.Z-1 --to X.Y.Z # 4b
@@ -300,7 +316,7 @@ Log the current registry snapshot (per-package versions + `dist-tags.latest`) be
300
316
 
301
317
  ```bash
302
318
  node "${CLAUDE_PLUGIN_ROOT}/skills/adia-release/scripts/dispatch-publish.mjs" \
303
- --version X.Y.Z --verify-triggered # re-dispatches only missing runs
319
+ --version X.Y.Z --verify-triggered # re-dispatches missing/dead runs; registry-gated (gh#763)
304
320
  ```
305
321
 
306
322
  Wait for the workflows to settle, then verify against the **registry**, never the workflow's green check:
@@ -71,7 +71,7 @@
71
71
 
72
72
  ```bash
73
73
  node "${CLAUDE_PLUGIN_ROOT}/skills/adia-release/scripts/dispatch-publish.mjs" \
74
- --version X.Y.Z --verify-triggered # re-dispatches ONLY packages with no run; idempotent
74
+ --version X.Y.Z --verify-triggered # re-dispatches packages with no run OR a dead (cancelled/failed/timed-out) run; registry-gated; idempotent
75
75
  ```
76
76
 
77
77
  For a batch, preserve npm-latest ordering (`--after <prev>`). Verify against the registry, not the workflows. **Prevention:** push tags one-at-a-time ([`cut-procedure.md`](cut-procedure.md) §Step 8); `` `${CLAUDE_PLUGIN_ROOT}/skills/adia-release/scripts/release-pack.mjs` `` does this automatically and follows with `--verify-triggered`.
@@ -17,7 +17,7 @@
17
17
  // Part of the adia-release skill. Mechanizes the dispatch loop
18
18
  // + handles the npm-latest ordering rule for batch pushes.
19
19
 
20
- import { execSync } from 'node:child_process';
20
+ import { execFileSync } from 'node:child_process';
21
21
  import process from 'node:process';
22
22
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
23
23
  import { PACKAGE_NAMES } from './package-paths.mjs';
@@ -49,10 +49,14 @@ function parseArgs(argv) {
49
49
  console.log('--after Verify npm latest is at the given version before dispatching this one.');
50
50
  console.log(' Used in batch push to ensure publish ordering.');
51
51
  console.log('--verify-triggered Instead of dispatching, check whether each per-package publish-<pkg>.yml');
52
- console.log(' ALREADY has a run for the <pkg>-vX.Y.Z tag (i.e. push-on-tag fired), and');
53
- console.log(' re-dispatch ONLY the misses. This is the recovery for the batch-tag-push');
54
- console.log(' skip (recovery-paths.md §Scenario 7) a single `git push <12 tags>` can');
55
- console.log(' trigger ZERO publish workflows. Idempotent: re-dispatches nothing if all 11 fired.');
52
+ console.log(' has a SUCCESSFUL run for the <pkg>-vX.Y.Z tag, and re-dispatch the misses.');
53
+ console.log(' Conclusion-aware (gh#763): a cancelled/failed/timed-out run is a miss, not');
54
+ console.log(' a pass; in-progress/queued runs get a bounded wait. Every re-dispatch is');
55
+ console.log(' gated behind an npm-registry check if <pkg>@<version> is already on the');
56
+ console.log(' registry the publish succeeded regardless of run conclusion (a run can');
57
+ console.log(' publish, then be stamped cancelled by the job clock), and re-dispatching');
58
+ console.log(' would only 403. Covers the batch-tag-push skip (recovery-paths.md');
59
+ console.log(' §Scenario 7) AND dead runs from runner starvation. Idempotent.');
56
60
  console.log(`--scope npm scope the packages publish under (default: ${DEFAULT_SCOPE};`);
57
61
  console.log(' or set $ADIA_NPM_SCOPE). The --after npm-latest check uses this scope.');
58
62
  process.exit(0);
@@ -65,18 +69,23 @@ function parseArgs(argv) {
65
69
  return args;
66
70
  }
67
71
 
68
- function run(cmd, dry) {
72
+ // All gh/npm invocations go through execFileSync with ARGUMENT ARRAYS —
73
+ // never a shell string. --version/--scope are CLI-attacker-controlled;
74
+ // interpolating them into a shell command is command injection (PR #774
75
+ // review). `run` logs a printable form in dry mode and execs the array.
76
+ function run(bin, argv, dry, opts = {}) {
77
+ const printable = `${bin} ${argv.join(' ')}`;
69
78
  if (dry) {
70
- console.log(` [dry] ${cmd}`);
79
+ console.log(` [dry] ${printable}`);
71
80
  return '';
72
81
  }
73
- return execSync(cmd, { encoding: 'utf8' });
82
+ return execFileSync(bin, argv, { encoding: 'utf8', ...opts });
74
83
  }
75
84
 
76
85
  function checkAfter(afterVersion, scope) {
77
86
  console.log(`Checking npm latest is at ${afterVersion} before dispatching...`);
78
87
  try {
79
- const latest = execSync(`npm view ${scope}/web-components dist-tags.latest`, { encoding: 'utf8' }).trim();
88
+ const latest = run('npm', ['view', `${scope}/web-components`, 'dist-tags.latest'], false).trim();
80
89
  if (latest !== afterVersion) {
81
90
  console.error(`error: npm ${scope}/web-components latest is '${latest}', expected '${afterVersion}'`);
82
91
  console.error(' This means the previous batch hasn\'t completed publishing.');
@@ -92,9 +101,8 @@ function checkAfter(afterVersion, scope) {
92
101
 
93
102
  function dispatch(pkg, version, dry) {
94
103
  const ref = `${pkg}-v${version}`;
95
- const cmd = `gh workflow run "publish-${pkg}.yml" --ref "${ref}"`;
96
104
  try {
97
- const out = run(cmd, dry);
105
+ const out = run('gh', ['workflow', 'run', `publish-${pkg}.yml`, '--ref', ref], dry);
98
106
  if (!dry && out) {
99
107
  const url = out.split('\n').find((l) => l.startsWith('http')) || '';
100
108
  console.log(` ✓ dispatched ${pkg} (${url.trim()})`);
@@ -114,53 +122,163 @@ async function sleep(ms) {
114
122
  return new Promise((res) => setTimeout(res, ms));
115
123
  }
116
124
 
117
- // Has publish-<pkg>.yml already run for the <pkg>-vX.Y.Z tag? Tag-triggered runs
125
+ // Classify a run's {status, conclusion} into what --verify-triggered should do
126
+ // with it. Pure — selftest exercises it with negative controls.
127
+ // 'success' — completed + success: the publish run is satisfied.
128
+ // 'pending' — queued / in_progress / waiting / requested: recheck (bounded wait).
129
+ // 'dead' — completed but NOT success (cancelled / failure / timed_out /
130
+ // skipped / anything unknown): re-dispatch candidate. gh#763: the
131
+ // v0.8.29 starvation wave stamped runs `cancelled` — one of them
132
+ // had already published to npm, which is why every re-dispatch is
133
+ // additionally gated behind the registry check below.
134
+ function classifyRun(status, conclusion) {
135
+ if (status !== 'completed') return 'pending';
136
+ return conclusion === 'success' ? 'success' : 'dead';
137
+ }
138
+
139
+ // Latest run of publish-<pkg>.yml for the <pkg>-vX.Y.Z tag. Tag-triggered runs
118
140
  // report the tag in the `headBranch` field, so we filter the run list by it.
119
- // Returns true if at least one run exists for that tag, false otherwise.
141
+ // Returns 'none' | 'success' | 'pending' | 'dead' | 'unknown' existence
142
+ // alone is NOT satisfaction (gh#763: a cancelled run used to count as
143
+ // "triggered"), and a FAILED QUERY is NOT verified absence (PR #774 review:
144
+ // fail-open here read a gh rate limit as "no run" and re-dispatched over a
145
+ // possibly in-flight publish).
120
146
  function triggeredForTag(pkg, version, dry) {
121
147
  const tag = `${pkg}-v${version}`;
122
- const cmd = `gh run list --workflow="publish-${pkg}.yml" --branch "${tag}" --limit 1 --json databaseId -q '.[0].databaseId // empty'`;
148
+ const argv = ['run', 'list', `--workflow=publish-${pkg}.yml`, '--branch', tag, '--limit', '1', '--json', 'status,conclusion', '-q', '.[0] // empty'];
123
149
  if (dry) {
124
- console.log(` [dry] ${cmd}`);
125
- return false; // dry preview assumes not-triggered so the re-dispatch path is shown
150
+ console.log(` [dry] gh ${argv.join(' ')}`);
151
+ return 'none'; // dry preview assumes not-triggered so the re-dispatch path is shown
126
152
  }
127
153
  try {
128
- const out = execSync(cmd, { encoding: 'utf8' }).trim();
129
- return out.length > 0;
154
+ const out = execFileSync('gh', argv, { encoding: 'utf8' }).trim();
155
+ if (!out) return 'none'; // gh answered: genuinely zero runs for the tag
156
+ const run = JSON.parse(out);
157
+ return classifyRun(run.status, run.conclusion);
130
158
  } catch {
131
- // gh error (auth / rate limit / missing workflow) — treat as "unknown".
132
- // Conservative: report not-triggered so the miss is re-dispatched rather than
133
- // silently skipped. A redundant re-dispatch is harmless (publish is idempotent
134
- // per the workflow's own guard); a skipped publish is the failure we're fixing.
135
- return false;
159
+ // gh error (auth / rate limit / missing workflow) — we could not observe
160
+ // the runs. Fail CLOSED: 'unknown' stops the loop with a nonzero result
161
+ // instead of dispatching blind.
162
+ return 'unknown';
136
163
  }
137
164
  }
138
165
 
139
- // The batch-tag-push-skip recovery: verify each per-package publish workflow
140
- // actually triggered off its pushed tag, and re-dispatch ONLY the misses.
141
- // Idempotent re-dispatches nothing if all 11 fired.
142
- function verifyTriggeredAndRedispatch(version, dry) {
143
- console.log(`\nVerifying publish-on-tag triggered for all ${PACKAGES.length} packages (v${version}):`);
166
+ // Registry truth: is <scope>/<pkg>@<version> already published? A workflow
167
+ // run's conclusion is NOT publish truth the v0.8.29 compose run published
168
+ // successfully, then got stamped `cancelled` by the 10m job clock. If the
169
+ // version is on the registry, the publish succeeded, full stop; re-dispatching
170
+ // would only hit npm's publish-over-published 403.
171
+ // Returns true (published) | false (npm answered: version absent, E404) |
172
+ // null (query FAILED — network/auth; absence NOT verified, fail closed:
173
+ // PR #774 review — a transient npm failure must not re-open the 403).
174
+ function onRegistry(pkg, version, scope, dry) {
175
+ const argv = ['view', `${scope}/${pkg}@${version}`, 'version'];
176
+ if (dry) {
177
+ console.log(` [dry] npm ${argv.join(' ')}`);
178
+ return false; // dry preview assumes not-published so the re-dispatch path is shown
179
+ }
180
+ try {
181
+ return execFileSync('npm', argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() === version;
182
+ } catch (e) {
183
+ const stderr = `${e.stderr || ''}`;
184
+ if (stderr.includes('E404') || stderr.includes('404 Not Found')) return false; // definitive: not published
185
+ return null; // npm/network error — absence unverified
186
+ }
187
+ }
188
+
189
+ // What to do with a package whose latest run is not a clean success, given
190
+ // registry truth (`published`: true | false | null-unverified). Pure —
191
+ // selftest proves the 403-avoidance and fail-closed invariants here.
192
+ // published === true → 'satisfied' — npm has the version; the publish
193
+ // succeeded whatever the run conclusion says. NEVER
194
+ // re-dispatch over an already-published version (403).
195
+ // either query failed→ 'unknown' — run state or registry state was NOT
196
+ // observed (rate limit, network). Fail closed: stop
197
+ // with a nonzero result, never dispatch blind.
198
+ // pending, absent → 'wait' — a run may still be publishing; do not
199
+ // race it with a second dispatch.
200
+ // dead/none, absent → 'redispatch' — no run, or a cancelled/failed/
201
+ // timed-out one, and the registry lacks the version.
202
+ function decidePackage(state, published) {
203
+ if (published === true) return 'satisfied';
204
+ if (state === 'unknown' || published === null) return 'unknown';
205
+ if (state === 'pending') return 'wait';
206
+ return 'redispatch';
207
+ }
208
+
209
+ // Bounded wait for in-progress/queued runs before judging them: a healthy
210
+ // publish run finishes in ~30s; 6 × 20s covers a slow queue without stalling
211
+ // the recovery loop forever.
212
+ const PENDING_RECHECKS = 6;
213
+ const PENDING_RECHECK_SECONDS = 20;
214
+
215
+ // The publish-run recovery: verify each per-package publish workflow has a
216
+ // SUCCESSFUL run for its tag (batch-tag-push skip AND dead runs — cancelled /
217
+ // failed / timed_out, e.g. runner starvation), wait out in-progress runs, and
218
+ // re-dispatch only true misses — never over a version the registry already has.
219
+ // Idempotent — re-dispatches nothing if all publishes landed.
220
+ async function verifyTriggeredAndRedispatch(version, scope, dry) {
221
+ console.log(`\nVerifying publish-on-tag succeeded for all ${PACKAGES.length} packages (v${version}):`);
222
+ let pending = PACKAGES;
223
+ const unresolved = new Map(); // pkg → state, after the bounded wait
224
+ for (let attempt = 0; attempt <= PENDING_RECHECKS && pending.length > 0; attempt++) {
225
+ if (attempt > 0) {
226
+ console.log(` … ${pending.length} run(s) still pending — recheck ${attempt}/${PENDING_RECHECKS} in ${PENDING_RECHECK_SECONDS}s`);
227
+ await sleep(PENDING_RECHECK_SECONDS * 1000);
228
+ }
229
+ const stillPending = [];
230
+ for (const pkg of pending) {
231
+ const state = triggeredForTag(pkg, version, dry);
232
+ if (state === 'success') {
233
+ console.log(` ✓ ${pkg} — successful publish run for ${pkg}-v${version}`);
234
+ } else if (state === 'pending' && attempt < PENDING_RECHECKS && !dry) {
235
+ stillPending.push(pkg);
236
+ } else {
237
+ unresolved.set(pkg, state);
238
+ }
239
+ }
240
+ pending = stillPending;
241
+ }
242
+
243
+ // Registry gate: for every non-success, npm is the arbiter — already
244
+ // published → satisfied regardless of run conclusion; absent → re-dispatch.
144
245
  const misses = [];
145
- for (const pkg of PACKAGES) {
146
- if (triggeredForTag(pkg, version, dry)) {
147
- console.log(` ✓ ${pkg} publish run found for ${pkg}-v${version}`);
246
+ for (const [pkg, state] of unresolved) {
247
+ const decision = decidePackage(state, onRegistry(pkg, version, scope, dry));
248
+ if (decision === 'satisfied') {
249
+ console.log(` ✓ ${pkg} — run state '${state}' but ${scope}/${pkg}@${version} IS on the registry → published, no re-dispatch`);
250
+ } else if (decision === 'unknown') {
251
+ console.log(` ! ${pkg} — run or registry state could NOT be observed (gh/npm query failed) → NOT dispatching blind; fix gh/npm access and re-run --verify-triggered`);
252
+ misses.push({ pkg, redispatch: false });
253
+ } else if (decision === 'wait') {
254
+ console.log(` ? ${pkg} — run still pending after bounded wait and not on the registry → NOT re-dispatching over an in-flight run; re-run --verify-triggered once it settles`);
255
+ misses.push({ pkg, redispatch: false });
148
256
  } else {
149
- console.log(` ✗ ${pkg} NO publish run for ${pkg}-v${version} (batch-push skip) will re-dispatch`);
150
- misses.push(pkg);
257
+ const why = state === 'none' ? 'NO publish run (batch-push skip)' : 'run cancelled/failed/timed-out';
258
+ console.log(` ✗ ${pkg} — ${why} for ${pkg}-v${version}, not on the registry → will re-dispatch`);
259
+ misses.push({ pkg, redispatch: true });
151
260
  }
152
261
  }
153
262
  if (misses.length === 0) {
154
- console.log(`\n[verify-triggered] all ${PACKAGES.length} publish workflows triggered — nothing to re-dispatch.`);
263
+ console.log(`\n[verify-triggered] all ${PACKAGES.length} publishes verified (successful run or on-registry) — nothing to re-dispatch.`);
155
264
  return 0;
156
265
  }
157
- console.log(`\n[verify-triggered] ${misses.length} miss(es) re-dispatching via workflow_dispatch:`);
266
+ const toDispatch = misses.filter((m) => m.redispatch);
158
267
  let redispatched = 0;
159
- for (const pkg of misses) {
160
- if (dispatch(pkg, version, dry)) redispatched++;
268
+ if (toDispatch.length > 0) {
269
+ console.log(`\n[verify-triggered] ${toDispatch.length} miss(es) re-dispatching via workflow_dispatch:`);
270
+ for (const { pkg } of toDispatch) {
271
+ if (dispatch(pkg, version, dry)) redispatched++;
272
+ }
273
+ console.log(`\n[verify-triggered] re-dispatched ${redispatched}/${toDispatch.length} ${dry ? '(dry)' : ''}`);
274
+ if (!dry) {
275
+ console.log(' Registry is the final arbiter — confirm each with:');
276
+ console.log(` npm view ${scope}/<pkg>@${version} version`);
277
+ }
161
278
  }
162
- console.log(`\n[verify-triggered] re-dispatched ${redispatched}/${misses.length} ${dry ? '(dry)' : ''}`);
163
- return redispatched === misses.length ? 0 : 1;
279
+ const unresolvedCount = misses.length - toDispatch.length;
280
+ if (unresolvedCount > 0) console.log(`\n[verify-triggered] ${unresolvedCount} package(s) unresolved (run still pending, or gh/npm query failed) — re-run --verify-triggered once they settle / access is restored.`);
281
+ return redispatched === toDispatch.length && unresolvedCount === 0 ? 0 : 1;
164
282
  }
165
283
 
166
284
  async function main() {
@@ -169,10 +287,11 @@ async function main() {
169
287
  assertMonorepoRoot(process.cwd());
170
288
  if (args.after) checkAfter(args.after, args.scope);
171
289
 
172
- // Recovery mode — verify each publish workflow triggered off its pushed tag,
173
- // re-dispatch only the misses (the batch-tag-push skip). Skips the blind dispatch.
290
+ // Recovery mode — verify each publish workflow SUCCEEDED for its pushed tag
291
+ // (or the version is on the registry), re-dispatch only true misses (batch-
292
+ // tag-push skip + dead runs, gh#763). Skips the blind dispatch.
174
293
  if (args.verifyTriggered) {
175
- const rc = verifyTriggeredAndRedispatch(args.version, args.dry);
294
+ const rc = await verifyTriggeredAndRedispatch(args.version, args.scope, args.dry);
176
295
  process.exit(rc);
177
296
  }
178
297
 
@@ -211,31 +330,75 @@ async function main() {
211
330
  if (succeeded !== PACKAGES.length) process.exit(1);
212
331
  }
213
332
 
214
- // dry mode never shells out (run()/dispatch() short-circuit before
215
- // execSync), so this exercises the real functions without touching gh/npm.
216
- function selftest() {
333
+ // dry mode never shells out (run()/dispatch()/triggeredForTag()/onRegistry()
334
+ // short-circuit before execFileSync), so this exercises the real functions
335
+ // without touching gh/npm.
336
+ async function selftest() {
337
+ const fail = (msg) => { console.error(`selftest FAIL: ${msg}`); process.exit(1); };
338
+
217
339
  let allOk = true;
218
340
  for (const pkg of PACKAGES) {
219
341
  if (!dispatch(pkg, '9.9.9', true /* dry */)) allOk = false;
220
342
  }
221
- if (!allOk) { console.error('selftest FAIL: dry dispatch() reported a failure for at least one package'); process.exit(1); }
343
+ if (!allOk) fail('dry dispatch() reported a failure for at least one package');
344
+
345
+ // classifyRun() — the gh#763 core: conclusion decides, existence does not.
346
+ if (classifyRun('completed', 'success') !== 'success') fail("classifyRun(completed,success) must be 'success'");
347
+ // Negative controls: the exact states the v0.8.29 starvation wave produced
348
+ // must NOT satisfy the verify — a cancelled run counting as "triggered" is
349
+ // the defect this script no longer has.
350
+ for (const dead of ['cancelled', 'failure', 'timed_out', 'skipped', null]) {
351
+ if (classifyRun('completed', dead) !== 'dead') fail(`classifyRun(completed,${dead}) must be 'dead', never satisfied`);
352
+ }
353
+ for (const status of ['queued', 'in_progress', 'waiting', 'requested']) {
354
+ if (classifyRun(status, null) !== 'pending') fail(`classifyRun(${status}) must be 'pending' (bounded recheck)`);
355
+ }
356
+
357
+ // decidePackage() — the registry gate. Negative controls: the v0.8.29
358
+ // a2ui-compose shape (run cancelled, version PUBLISHED) must never
359
+ // re-dispatch — that's the publish-over-published 403.
360
+ if (decidePackage('dead', true) !== 'satisfied') fail('decidePackage(dead, published) must be satisfied — never re-dispatch over a published version');
361
+ if (decidePackage('none', true) !== 'satisfied') fail('decidePackage(none, published) must be satisfied');
362
+ if (decidePackage('pending', true) !== 'satisfied') fail('decidePackage(pending, published) must be satisfied');
363
+ if (decidePackage('dead', false) !== 'redispatch') fail('decidePackage(dead, absent) must re-dispatch');
364
+ if (decidePackage('none', false) !== 'redispatch') fail('decidePackage(none, absent) must re-dispatch');
365
+ if (decidePackage('pending', false) !== 'wait') fail('decidePackage(pending, absent) must wait, never race an in-flight run');
366
+ // Fail-closed negative controls (PR #774 review): a FAILED query is not
367
+ // verified absence — 'unknown' must never dispatch. A gh rate limit could
368
+ // hide an in-flight run; a transient npm failure could hide an
369
+ // already-published version (the 403 again).
370
+ for (const state of ['unknown', 'none', 'dead', 'pending']) {
371
+ if (decidePackage(state, null) !== 'unknown') fail(`decidePackage(${state}, registry-unverified) must be 'unknown' — never dispatch blind`);
372
+ }
373
+ for (const published of [false, null]) {
374
+ if (decidePackage('unknown', published) !== 'unknown') fail(`decidePackage(run-unknown, ${published}) must be 'unknown' — never dispatch blind`);
375
+ }
376
+ // Positive registry truth still wins even when the run query failed:
377
+ if (decidePackage('unknown', true) !== 'satisfied') fail('decidePackage(unknown, published) must be satisfied — registry is the arbiter');
378
+
379
+ // triggeredForTag() in dry mode must conservatively report 'none' — the
380
+ // whole point is re-dispatching misses; silently assuming "triggered"
381
+ // would let a real miss through.
382
+ if (triggeredForTag('web-components', '9.9.9', true) !== 'none') {
383
+ fail("dry triggeredForTag() must report 'none' (conservative default)");
384
+ }
222
385
 
223
- // triggeredForTag() in dry mode must conservatively report "not yet
224
- // triggered" verifyTriggeredAndRedispatch's whole point is to re-dispatch
225
- // misses; silently assuming "triggered" would let a real miss through.
226
- if (triggeredForTag('web-components', '9.9.9', true) !== false) {
227
- console.error('selftest FAIL: dry triggeredForTag() must report false (conservative default)'); process.exit(1);
386
+ // onRegistry() in dry mode must report not-published — assuming published
387
+ // would suppress the re-dispatch preview AND, inverted, the real registry
388
+ // gate is what prevents the publish-over-published 403.
389
+ if (onRegistry('web-components', '9.9.9', '@adia-ai', true) !== false) {
390
+ fail('dry onRegistry() must report false (conservative default)');
228
391
  }
229
392
 
230
- const rc = verifyTriggeredAndRedispatch('9.9.9', true);
231
- if (rc !== 0) { console.error(`selftest FAIL: dry verifyTriggeredAndRedispatch() should report rc=0, got ${rc}`); process.exit(1); }
393
+ const rc = await verifyTriggeredAndRedispatch('9.9.9', '@adia-ai', true);
394
+ if (rc !== 0) fail(`dry verifyTriggeredAndRedispatch() should report rc=0, got ${rc}`);
232
395
 
233
396
  console.log('selftest OK');
234
397
  }
235
398
 
236
399
  const topArgv = process.argv.slice(2);
237
400
  if (topArgv[0] === 'selftest') {
238
- selftest();
401
+ selftest().catch((e) => { console.error(`selftest FAIL: ${e.message}`); process.exit(1); });
239
402
  } else {
240
403
  main().catch((e) => {
241
404
  console.error(`error: ${e.message}`);
@@ -147,6 +147,52 @@ function parseArgs(argv) {
147
147
  // there: on the v0.8.10 cut the missing flag surfaced only after tags and
148
148
  // npm publish were already irreversible (Step 10 is the LAST step). An arg
149
149
  // error must never cost half a shipped release.
150
+ // From-scratch needs FULL roster CHANGELOG coverage at Step 4 — every
151
+ // package gets its [VERSION] section from either promote-unreleased
152
+ // (--substantive-packages) or insert-stub (--stub-packages). Validate at
153
+ // PARSE time, not there: on the v0.8.29 cut the missing --stub-packages
154
+ // surfaced only at Step 4e's generate-release-notes ("no [X.Y.Z] CHANGELOG
155
+ // section in: <8 ride-alongs>") — AFTER the ~15-min pre-flight, promote,
156
+ // and bump had all run, and each abort left a half-cut tree (gh#765).
157
+ // Mirrors the handoff --gh-notes-file guard below. Mode cut is exempt: a
158
+ // peer may have pre-staged the [VERSION] sections by hand.
159
+ // EITHER package flag arms the guard (CodeRabbit, PR #772): keying on
160
+ // --substantive-packages alone let a stub-only invocation bypass both the
161
+ // coverage check and the --substantive/--xref pairing check below — and a
162
+ // stub list without that pairing makes Step 4 silently skip insert-stub,
163
+ // the exact Step-4e death this guard exists to prevent. (A ZERO-flag
164
+ // from-scratch can't be judged here — it may be a half-cut resume whose
165
+ // sections already exist; main() rules on it after detectHalfCut.)
166
+ if (args.mode === 'from-scratch' && (args.substantivePackages || args.stubPackages)) {
167
+ // Flags accept both name form (a2ui-corpus) and path form (a2ui/corpus);
168
+ // normalize to name form for the roster set-difference.
169
+ const toName = (p) => (PACKAGE_NAMES.includes(p) ? p : p.replaceAll('/', '-'));
170
+ const covered = new Set([
171
+ ...(args.substantivePackages ?? []).map(toName),
172
+ ...(args.stubPackages ?? []).map(toName),
173
+ ]);
174
+ const missing = PACKAGE_NAMES.filter((p) => !covered.has(p));
175
+ if (missing.length > 0) {
176
+ console.error(`error: --mode from-scratch covers only ${covered.size} of the ${PACKAGE_NAMES.length} lockstep`);
177
+ console.error(' packages — Step 4e (generate-release-notes) would die on a missing');
178
+ console.error(` [${args.version}] CHANGELOG section in the ${missing.length} ride-along(s):`);
179
+ console.error(` ${missing.join(', ')}`);
180
+ console.error(' Add them to --stub-packages (pure ride-alongs get the lockstep stub;');
181
+ console.error(' insert-stub also needs --substantive and --xref) or to');
182
+ console.error(' --substantive-packages if they carry real [Unreleased] content.');
183
+ console.error(' Failing now, BEFORE the ~15-min pre-flight (v0.8.29 burned three');
184
+ console.error(' full re-runs discovering this at Step 4e — gh#765).');
185
+ process.exit(2);
186
+ }
187
+ // insert-stub silently no-ops without --substantive/--xref (Step 4's
188
+ // guard condition) — same Step 4e death, so same parse-time fail.
189
+ if (args.stubPackages && (!args.substantive || !args.xref)) {
190
+ console.error('error: --stub-packages requires --substantive and --xref (insert-stub.mjs');
191
+ console.error(' needs both for the stub block; without them Step 4 silently skips');
192
+ console.error(' stub insertion and Step 4e dies on the missing CHANGELOG sections).');
193
+ process.exit(2);
194
+ }
195
+ }
150
196
  if (args.mode === 'handoff') {
151
197
  if (!args.ghNotesFile) {
152
198
  console.error('error: --mode handoff requires --gh-notes-file (Step 10 creates the 11 GH');
@@ -210,12 +256,39 @@ async function checkpoint(label, evidence, autoConfirmed) {
210
256
  }
211
257
  }
212
258
 
259
+ // A failed inner command must surface WHAT it said, not a bare execSync
260
+ // stack: the v0.8.29 cut died at Step 4e with only `Error: Command failed`
261
+ // — the generator's real message ("no [X.Y.Z] CHANGELOG section in: …") was
262
+ // invisible until the command was re-run by hand (gh#765). Print the failing
263
+ // command + exit status + any CAPTURED stdout/stderr (piped calls; inherit
264
+ // calls already streamed it live), then rethrow a marked, stack-free error
265
+ // so main()'s catch doesn't re-dump a useless execSync trace over it.
266
+ function shFailure(cmd, e) {
267
+ const status = e.status ?? (e.signal ? `signal ${e.signal}` : '?');
268
+ console.error(`\n✗ command failed (exit ${status}): ${cmd}`);
269
+ const captured = [e.stdout, e.stderr]
270
+ .map((s) => (s || '').toString().trim())
271
+ .filter(Boolean);
272
+ if (captured.length > 0) {
273
+ console.error(' ── the command\'s own output ──');
274
+ console.error(captured.join('\n').split('\n').map((l) => ` ${l}`).join('\n'));
275
+ console.error(' ──');
276
+ }
277
+ const err = new Error(`command failed (exit ${status}): ${cmd}`);
278
+ err.reported = true; // main()'s catch prints e.message only, no stack
279
+ return err;
280
+ }
281
+
213
282
  function sh(cmd, args, opts = {}) {
214
283
  if (args.dry) {
215
284
  console.log(` [dry] ${cmd}`);
216
285
  return '';
217
286
  }
218
- return execSync(cmd, { cwd: REPO, encoding: 'utf8', stdio: opts.stdio || 'inherit' });
287
+ try {
288
+ return execSync(cmd, { cwd: REPO, encoding: 'utf8', stdio: opts.stdio || 'inherit' });
289
+ } catch (e) {
290
+ throw shFailure(cmd, e);
291
+ }
219
292
  }
220
293
 
221
294
  function shQuiet(cmd, args) {
@@ -223,7 +296,11 @@ function shQuiet(cmd, args) {
223
296
  console.log(` [dry] ${cmd}`);
224
297
  return '';
225
298
  }
226
- return execSync(cmd, { cwd: REPO, encoding: 'utf8' });
299
+ try {
300
+ return execSync(cmd, { cwd: REPO, encoding: 'utf8' });
301
+ } catch (e) {
302
+ throw shFailure(cmd, e);
303
+ }
227
304
  }
228
305
 
229
306
  /** Run a command for its EXIT STATUS only — true on 0, false on anything
@@ -266,6 +343,56 @@ function shCapture(cmd, args) {
266
343
  }
267
344
  }
268
345
 
346
+ // ── Half-cut detection (gh#765) ──────────────────────────────────
347
+ // An aborted Step-4 run (the v0.8.29 cut aborted three times at Step 4e)
348
+ // leaves a half-cut tree: package versions bumped to the cut version +
349
+ // CHANGELOGs promoted. A plain re-run then FALSE-FAILS the pre-flight —
350
+ // gate 24 (cut-hygiene) validates version currency at args.previous, which
351
+ // the half-done bump has already moved past.
352
+ //
353
+ // DETECT-AND-RESUME, not rollback — the smaller and safer of the two:
354
+ // resume is pure detection at the next invocation's start, so it works no
355
+ // matter HOW the previous run died (crash, SIGINT, kill -9 — a rollback
356
+ // handler never runs for those), and it never touches the working tree,
357
+ // so it cannot discard operator hand-edits the way a `git checkout --`
358
+ // rollback could. The already-correct Step-4 work is kept, not redone.
359
+ //
360
+ // Marker: the lockstep bump is the last tree-wide mutation before the
361
+ // abort point, so ALL roster package.json versions at args.version ⇒ the
362
+ // promote+bump half completed — resume. A MIX of cut/previous versions is
363
+ // a mid-bump abort this orchestrator cannot reason about: hard-stop with
364
+ // the manual recovery pointer. CHANGELOG promotion is then re-proved by
365
+ // Step 4's existing unpromoted-[Unreleased] guard, and the remaining Step-4
366
+ // substeps (lockfile, catalog, 4e notes, 4f fix, 4g hygiene) are idempotent
367
+ // regenerations that simply run again.
368
+ function detectHalfCut(args) {
369
+ if (args.mode === 'handoff' || args.dry) return false;
370
+ const versions = PACKAGE_ROSTER.map(({ name, dir }) => {
371
+ const p = path.join(REPO, dir, 'package.json');
372
+ return { name, version: JSON.parse(fs.readFileSync(p, 'utf8')).version };
373
+ });
374
+ const bumped = versions.filter((v) => v.version === args.version);
375
+ if (bumped.length === 0) return false; // fresh cut — nothing bumped yet
376
+ if (bumped.length < versions.length) {
377
+ console.error(`ERROR: mixed lockstep versions — ${bumped.length}/${versions.length} packages are already`);
378
+ console.error(` at ${args.version}, the rest are not (a mid-bump abort?):`);
379
+ for (const v of versions.filter((x) => x.version !== args.version)) {
380
+ console.error(` ✗ ${v.name}: ${v.version}`);
381
+ }
382
+ console.error(' This orchestrator can neither resume nor safely roll back a partial');
383
+ console.error(' bump. Restore the tree first (git status / git checkout -- <files>,');
384
+ console.error(' or re-run bump.mjs by hand), then re-run.');
385
+ process.exit(1);
386
+ }
387
+ console.log(`
388
+ [resume] half-cut tree detected: all ${versions.length} lockstep packages already at ${args.version}
389
+ (an earlier run's Step 4 promote+bump completed before it aborted — gh#765).
390
+ Resuming: pre-flight hygiene validates at ${args.version} instead of
391
+ ${args.previous}, and Step 4 skips promote/stub/bump, re-running only its
392
+ idempotent substeps (lockfile, catalog, notes, coverage, hygiene).`);
393
+ return true;
394
+ }
395
+
269
396
  // ── Step 1 — Re-baseline ──────────────────────────────────────────
270
397
  function step1ReBaseline(args) {
271
398
  console.log('\n=== Step 1 — Re-baseline ===');
@@ -310,8 +437,11 @@ function step3PreFlight(args) {
310
437
  // README "Current version" claim still equals the PREVIOUS version (the
311
438
  // bump moves it in Step 4; Step 4g then re-proves at the cut version) —
312
439
  // while handoff runs POST-MERGE, where the claim already equals the CUT
313
- // version and validating at previous fails by construction.
314
- const hygieneVersion = args.mode === 'handoff' ? args.version : args.previous;
440
+ // version and validating at previous fails by construction. A RESUMED
441
+ // half-cut run (gh#765) is post-bump too: the earlier aborted run already
442
+ // moved every reference to the cut version, so validating at previous
443
+ // false-fails gate 24 exactly like handoff would.
444
+ const hygieneVersion = (args.mode === 'handoff' || args.resume) ? args.version : args.previous;
315
445
  const cmd = g.versionArg ? `${g.cmd} --version ${hygieneVersion}` : g.cmd;
316
446
  console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${cmd} # ${g.what}`);
317
447
  try {
@@ -371,16 +501,25 @@ function step4PromoteAndBump(args) {
371
501
  // optional per window, so `root` joins the promote list exactly when its
372
502
  // [Unreleased] carries real content. A repo-level-only window (0.8.7 was
373
503
  // one) promotes root with zero substantive packages.
374
- const promoteTargets = [...(args.substantivePackages ?? [])];
375
- if (unpromotedUnreleased(REPO, 'root')) promoteTargets.push('root');
376
- if (promoteTargets.length > 0) {
377
- const cmd = `node ${SCRIPT_DIR}/promote-unreleased.mjs --version ${args.version} --date ${args.date} --packages ${promoteTargets.join(',')}`;
378
- sh(cmd, args);
379
- }
504
+ // A resumed half-cut run (gh#765, detected in main) already promoted,
505
+ // stubbed, and bumped — re-running promote/insert-stub would double-insert
506
+ // sections and bump.mjs --from would no longer match. Skip those three;
507
+ // the unpromoted-[Unreleased] guard below still re-proves the promotion
508
+ // actually completed, and every later substep is an idempotent regenerate.
509
+ if (!args.resume) {
510
+ const promoteTargets = [...(args.substantivePackages ?? [])];
511
+ if (unpromotedUnreleased(REPO, 'root')) promoteTargets.push('root');
512
+ if (promoteTargets.length > 0) {
513
+ const cmd = `node ${SCRIPT_DIR}/promote-unreleased.mjs --version ${args.version} --date ${args.date} --packages ${promoteTargets.join(',')}`;
514
+ sh(cmd, args);
515
+ }
380
516
 
381
- if (args.stubPackages && args.substantive && args.xref) {
382
- const cmd = `node ${SCRIPT_DIR}/insert-stub.mjs --version ${args.version} --date ${args.date} --previous-version ${args.previous} --substantive "${args.substantive}" --xref "${args.xref}" --packages ${args.stubPackages.join(',')}`;
383
- sh(cmd, args);
517
+ if (args.stubPackages && args.substantive && args.xref) {
518
+ const cmd = `node ${SCRIPT_DIR}/insert-stub.mjs --version ${args.version} --date ${args.date} --previous-version ${args.previous} --substantive "${args.substantive}" --xref "${args.xref}" --packages ${args.stubPackages.join(',')}`;
519
+ sh(cmd, args);
520
+ }
521
+ } else {
522
+ console.log(' [resume] promote/stub already applied by the aborted run — skipping');
384
523
  }
385
524
 
386
525
  // Loud guard: after promotion, NO package in the lockstep set (including
@@ -404,7 +543,11 @@ function step4PromoteAndBump(args) {
404
543
  console.log(` ✓ no unpromoted [Unreleased] content across all ${PACKAGES.length} packages + root`);
405
544
  }
406
545
 
407
- sh(`node ${SCRIPT_DIR}/bump.mjs --from ${args.previous} --to ${args.version}`, args);
546
+ if (!args.resume) {
547
+ sh(`node ${SCRIPT_DIR}/bump.mjs --from ${args.previous} --to ${args.version}`, args);
548
+ } else {
549
+ console.log(` [resume] versions already at ${args.version} — skipping bump`);
550
+ }
408
551
  sh('npm install --package-lock-only --no-audit --no-fund', args);
409
552
  sh('npm run check:lockstep', args);
410
553
 
@@ -751,11 +894,58 @@ async function step10GhAndSite(args) {
751
894
  `builds from main, runs pre-flight verify → snapshot → hardened rsync →\n` +
752
895
  `post-deploy verify → auto-rollback. NOT a raw rsync (the bypass this replaced).`;
753
896
  await checkpoint('Deploy site — before dispatching deploy-site.yml', evidence, args.publish);
754
- sh(`gh workflow run "Deploy site (${args.host})" --repo adiahealth/gen-ui-kit --ref main`, args);
755
- console.log(' Waiting for the deploy workflow to settle...');
756
- if (!args.dry) sh('sleep 10', args);
757
- sh(`until [ "$(gh run list --workflow=deploy-site.yml --limit 1 --json status -q '.[0].status')" = "completed" ]; do sleep 5; done`, args);
758
- console.log(' ✓ deploy-site.yml settled');
897
+ // gh#762 + PR #775 review: dispatch and settle-wait are ONE shell block so
898
+ // the tracked run id survives between steps. Three fixes over the old
899
+ // "poll the newest run for status=completed" shape:
900
+ // 1. It could settle on the PREVIOUS completed run before the dispatch
901
+ // registered so capture the newest id BEFORE dispatching and wait
902
+ // for a NEW id to appear.
903
+ // 2. It accepted failure/cancelled as settled — so poll the tracked run
904
+ // by id and require conclusion=success, else fail the handoff.
905
+ // 3. It waited in silence while a forgotten run at the production-site
906
+ // gate held the deploy-site concurrency group for 56h — so each poll
907
+ // names the gate-waiting blocker (id, since, URL).
908
+ // reap-stale-deploy-gate.yml auto-cancels gate-waiters older than 24h, but
909
+ // a live handoff should not wait hours to find out.
910
+ console.log(' Dispatching and waiting for the deploy workflow to settle...');
911
+ sh(String.raw`
912
+ prev_id=$(gh run list --workflow=deploy-site.yml --repo adiahealth/gen-ui-kit --limit 1 --json databaseId -q '.[0].databaseId')
913
+ gh workflow run "Deploy site (${args.host})" --repo adiahealth/gen-ui-kit --ref main
914
+ run_id=""
915
+ tries=0
916
+ while [ "$tries" -lt 60 ]; do
917
+ sleep 5
918
+ run_id=$(gh run list --workflow=deploy-site.yml --repo adiahealth/gen-ui-kit --limit 1 --json databaseId -q '.[0].databaseId')
919
+ if [ -n "$run_id" ] && [ "$run_id" != "$prev_id" ]; then break; fi
920
+ run_id=""
921
+ tries=$((tries + 1))
922
+ done
923
+ if [ -z "$run_id" ]; then
924
+ echo "ERROR: the dispatched deploy-site run never appeared within 5 min (newest is still $prev_id) — inspect the Actions tab before retrying" >&2
925
+ exit 1
926
+ fi
927
+ echo " Tracking dispatched run $run_id..."
928
+ while :; do
929
+ status=$(gh run view "$run_id" --repo adiahealth/gen-ui-kit --json status -q .status)
930
+ [ "$status" = "completed" ] && break
931
+ blocker=$(gh run list --workflow=deploy-site.yml --repo adiahealth/gen-ui-kit --limit 10 \
932
+ --json databaseId,status,createdAt,url \
933
+ -q '[.[] | select(.status == "waiting")] | last | if . == null then "" else "run \(.databaseId) — waiting at the production-site gate since \(.createdAt): \(.url)" end')
934
+ if [ "$status" = "waiting" ]; then
935
+ echo " … run $run_id is at the production-site approval gate: ${'$'}{blocker:-approve it in the Actions UI}"
936
+ elif [ -n "$blocker" ]; then
937
+ echo " … run $run_id is '$status' — BLOCKED BEHIND $blocker (gh#762: approve or cancel that run to release the deploy-site concurrency group)"
938
+ else
939
+ echo " … run $run_id: $status"
940
+ fi
941
+ sleep 15
942
+ done
943
+ conclusion=$(gh run view "$run_id" --repo adiahealth/gen-ui-kit --json conclusion -q .conclusion)
944
+ if [ "$conclusion" != "success" ]; then
945
+ echo "ERROR: deploy-site run $run_id completed with conclusion '$conclusion' — the site deploy did NOT land; investigate the run before re-dispatching" >&2
946
+ exit 1
947
+ fi
948
+ echo " ✓ deploy-site.yml run $run_id settled: success"`, args);
759
949
  // Verify a deployed CONTENT FILE, never an SPA route — the docs site
760
950
  // returns HTTP 200 + a blank shell for ANY unmatched route (v0.7.4 trap).
761
951
  sh(`curl -s -o /dev/null -w "EXE host.css: HTTP %{http_code}\\n" https://${args.host}/packages/web-components/styles/host.css`, args);
@@ -767,6 +957,23 @@ async function main() {
767
957
  // Fail-fast guard: refuse to git/npm/gh/curl against a non-monorepo directory.
768
958
  assertMonorepoRoot(REPO);
769
959
  console.log(`adia-release release-pack — v${args.version} (${args.mode})${args.dry ? ' [DRY]' : ''}`);
960
+ // gh#765: an aborted Step-4 run leaves bumped versions + promoted
961
+ // CHANGELOGs; detect BEFORE pre-flight so gate 24 validates at the right
962
+ // version and Step 4 skips the already-done promote/stub/bump.
963
+ args.resume = detectHalfCut(args);
964
+ // The zero-flag from-scratch shape parseArgs deferred (CodeRabbit, PR #772):
965
+ // with NEITHER package flag, no CHANGELOG gets a [VERSION] section and
966
+ // Step 4e is a guaranteed death — unless this is a half-cut RESUME, where
967
+ // the aborted run already wrote the sections. Only detectHalfCut can tell
968
+ // the two apart, so the ruling lives here — still BEFORE the pre-flight.
969
+ if (args.mode === 'from-scratch' && !args.resume && !args.substantivePackages && !args.stubPackages) {
970
+ console.error('error: --mode from-scratch needs --substantive-packages and/or --stub-packages');
971
+ console.error(` covering all ${PACKAGE_NAMES.length} lockstep packages — with neither, no CHANGELOG gets`);
972
+ console.error(` a [${args.version}] section and Step 4e (generate-release-notes) dies after`);
973
+ console.error(' the ~15-min pre-flight (gh#765). A resumed half-cut tree is exempt,');
974
+ console.error(' but this tree is not half-cut (versions are not at the cut version).');
975
+ process.exit(2);
976
+ }
770
977
  step1ReBaseline(args);
771
978
  step3PreFlight(args);
772
979
  step4PromoteAndBump(args);
@@ -859,6 +1066,83 @@ function selftest() {
859
1066
  process.exit(1);
860
1067
  }
861
1068
 
1069
+ // gh#765 — from-scratch with substantive-but-no-stub packages must die at
1070
+ // PARSE time, naming the ride-alongs, BEFORE any pre-flight gate runs
1071
+ // (v0.8.29 burned three ~15-min pre-flight re-runs discovering it at
1072
+ // Step 4e). Mirror of the handoff --gh-notes-file guard below.
1073
+ let guardFailed = false;
1074
+ let guardOut = '';
1075
+ try {
1076
+ execSync(
1077
+ `node "${scriptPath}" --mode from-scratch --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
1078
+ `--commit-message-file "${commitFile}" --substantive-packages web-components --dry`,
1079
+ { cwd: REPO, encoding: 'utf8' },
1080
+ );
1081
+ } catch (e) {
1082
+ guardFailed = true;
1083
+ guardOut = (e.stdout || '') + (e.stderr || '');
1084
+ }
1085
+ if (!guardFailed || !guardOut.includes('ride-along') || !guardOut.includes('a2ui-validator')) {
1086
+ console.error('selftest FAIL: from-scratch without --stub-packages must hard-reject at parse time, naming the ride-alongs');
1087
+ process.exit(1);
1088
+ }
1089
+ if (guardOut.includes('Step 3') || guardOut.includes('gate roster')) {
1090
+ console.error('selftest FAIL: the ride-along guard must fire BEFORE the pre-flight, not after');
1091
+ process.exit(1);
1092
+ }
1093
+
1094
+ // CodeRabbit (PR #772): the guard must arm on EITHER flag — a stub-only
1095
+ // invocation used to bypass it entirely. Three shapes: stub-only partial
1096
+ // coverage rejects naming ride-alongs; stub-only full coverage without
1097
+ // the --substantive/--xref pairing rejects (insert-stub would silently
1098
+ // no-op); zero-flag from-scratch on a NON-half-cut tree rejects in main
1099
+ // after detectHalfCut, still before the pre-flight.
1100
+ const rejectShapes = [
1101
+ { flags: '--substantive x --xref y --stub-packages llm', marker: 'ride-along' },
1102
+ { flags: `--stub-packages ${PACKAGE_NAMES.join(',')}`, marker: '--substantive and --xref' },
1103
+ { flags: '', marker: 'needs --substantive-packages and/or --stub-packages' },
1104
+ ];
1105
+ for (const { flags, marker } of rejectShapes) {
1106
+ let failed = false;
1107
+ let out = '';
1108
+ try {
1109
+ execSync(
1110
+ `node "${scriptPath}" --mode from-scratch --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
1111
+ `--commit-message-file "${commitFile}" ${flags} --dry`,
1112
+ { cwd: REPO, encoding: 'utf8' },
1113
+ );
1114
+ } catch (e) {
1115
+ failed = true;
1116
+ out = (e.stdout || '') + (e.stderr || '');
1117
+ }
1118
+ if (!failed || !out.includes(marker) || out.includes('gate roster')) {
1119
+ console.error(`selftest FAIL: from-scratch shape "${flags || '(no package flags)'}" must reject pre-flight with "${marker}"`);
1120
+ process.exit(1);
1121
+ }
1122
+ }
1123
+
1124
+ // …and a fully covered from-scratch roster (substantive ∪ stub = all
1125
+ // packages) must pass the guard and assemble its dry plan normally.
1126
+ const stubs = PACKAGE_NAMES.filter((p) => p !== 'web-components').join(',');
1127
+ let coveredOut;
1128
+ try {
1129
+ coveredOut = execSync(
1130
+ `node "${scriptPath}" --mode from-scratch --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
1131
+ `--commit-message-file "${commitFile}" --gh-notes-file "${notesFile}" ` +
1132
+ `--substantive "selftest" --xref "CHANGELOG.md#999" ` +
1133
+ `--substantive-packages web-components --stub-packages ${stubs} --dry --yes`,
1134
+ { cwd: REPO, encoding: 'utf8' },
1135
+ );
1136
+ } catch (e) {
1137
+ console.error('selftest FAIL: fully covered from-scratch --dry must pass the ride-along guard');
1138
+ console.error((e.stdout || '') + (e.stderr || ''));
1139
+ process.exit(1);
1140
+ }
1141
+ if (!coveredOut.includes('STOPPING per invariant 3')) {
1142
+ console.error('selftest FAIL: covered from-scratch dry plan missing the invariant-3 stop');
1143
+ process.exit(1);
1144
+ }
1145
+
862
1146
  // Handoff without --gh-notes-file must die at PARSE time (v0.8.10: the old
863
1147
  // Step-10-only check fired after tags+publish were irreversible).
864
1148
  let handoffFailed = false;
@@ -892,7 +1176,10 @@ if (topArgv[0] === 'selftest') {
892
1176
  selftest();
893
1177
  } else {
894
1178
  main().catch((e) => {
895
- console.error(e);
1179
+ // sh()/shQuiet() failures already printed the failing command + its
1180
+ // captured output (gh#765) — a full stack re-dump would bury it again.
1181
+ if (e && e.reported) console.error(e.message);
1182
+ else console.error(e);
896
1183
  rl.close();
897
1184
  process.exit(1);
898
1185
  });