@adia-ai/adia-ui-forge 0.8.31 → 0.8.33

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.
@@ -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,21 @@ 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;
445
+ // A RESUMED run's versionArg gates (cut-hygiene) demand artifacts Step 4
446
+ // has not recreated yet — docs/releases/vX.Y.Z.md exists only after 4e,
447
+ // which runs AFTER this pre-flight. Step 4g re-runs these same gates at
448
+ // the cut version once the notes exist, so skipping them here loses no
449
+ // coverage — running them here false-fails by construction (the v0.8.32
450
+ // cut hit exactly this: 23/24 green, gate 24 dead on the missing notes).
451
+ if (g.versionArg && args.resume) {
452
+ console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${g.cmd} # ${g.what} — [resume] deferred to Step 4g (notes not regenerated yet)`);
453
+ continue;
454
+ }
315
455
  const cmd = g.versionArg ? `${g.cmd} --version ${hygieneVersion}` : g.cmd;
316
456
  console.log(`\n ${g.n}/${GATE_ROSTER.length}. ${cmd} # ${g.what}`);
317
457
  try {
@@ -371,16 +511,48 @@ function step4PromoteAndBump(args) {
371
511
  // optional per window, so `root` joins the promote list exactly when its
372
512
  // [Unreleased] carries real content. A repo-level-only window (0.8.7 was
373
513
  // 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
- }
514
+ // A resumed half-cut run (gh#765, detected in main) already promoted,
515
+ // stubbed, and bumped — re-running promote/insert-stub would double-insert
516
+ // sections and bump.mjs --from would no longer match. Skip those three;
517
+ // the unpromoted-[Unreleased] guard below still re-proves the promotion
518
+ // actually completed, and every later substep is an idempotent regenerate.
519
+ if (!args.resume) {
520
+ const promoteTargets = [...(args.substantivePackages ?? [])];
521
+ if (unpromotedUnreleased(REPO, 'root')) promoteTargets.push('root');
522
+ if (promoteTargets.length > 0) {
523
+ const cmd = `node ${SCRIPT_DIR}/promote-unreleased.mjs --version ${args.version} --date ${args.date} --packages ${promoteTargets.join(',')}`;
524
+ sh(cmd, args);
525
+ }
380
526
 
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);
527
+ if (args.stubPackages && args.substantive && args.xref) {
528
+ 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(',')}`;
529
+ sh(cmd, args);
530
+ }
531
+ } else {
532
+ // The bump-complete marker proves promote+bump ran — it CANNOT see
533
+ // whether insert-stub ran: a stub package's [Unreleased] is empty, so
534
+ // the unpromoted guard below passes with the [X.Y.Z] section entirely
535
+ // absent, and the death arrives later at 4e's generate-release-notes
536
+ // ("no [X.Y.Z] CHANGELOG section in: …"). The v0.8.32 cut hit exactly
537
+ // this (the aborted run predated stub insertion). Complete the missing
538
+ // stubs here instead of skipping blindly — insert-stub hard-errors on
539
+ // existing sections, so filter to the genuinely missing ones.
540
+ if (args.stubPackages && args.substantive && args.xref) {
541
+ const missingStubs = args.stubPackages.filter((pkg) => {
542
+ const p = resolvePackageChangelog(REPO, pkg);
543
+ return fs.existsSync(p) && !fs.readFileSync(p, 'utf8').includes(`## [${args.version}]`);
544
+ });
545
+ if (missingStubs.length > 0) {
546
+ console.log(` [resume] ${missingStubs.length} stub section(s) missing from the aborted run — completing: ${missingStubs.join(', ')}`);
547
+ sh(`node ${SCRIPT_DIR}/insert-stub.mjs --version ${args.version} --date ${args.date} --previous-version ${args.previous} --substantive "${args.substantive}" --xref "${args.xref}" --packages ${missingStubs.join(',')}`, args);
548
+ } else {
549
+ console.log(' [resume] promote/stub already applied by the aborted run — skipping');
550
+ }
551
+ } else {
552
+ console.log(' [resume] promote/stub already applied by the aborted run — skipping');
553
+ console.log(' (no --stub-packages/--substantive/--xref given; if 4e fails on');
554
+ console.log(' missing [X.Y.Z] sections, re-run with the stub flags to complete them)');
555
+ }
384
556
  }
385
557
 
386
558
  // Loud guard: after promotion, NO package in the lockstep set (including
@@ -404,7 +576,11 @@ function step4PromoteAndBump(args) {
404
576
  console.log(` ✓ no unpromoted [Unreleased] content across all ${PACKAGES.length} packages + root`);
405
577
  }
406
578
 
407
- sh(`node ${SCRIPT_DIR}/bump.mjs --from ${args.previous} --to ${args.version}`, args);
579
+ if (!args.resume) {
580
+ sh(`node ${SCRIPT_DIR}/bump.mjs --from ${args.previous} --to ${args.version}`, args);
581
+ } else {
582
+ console.log(` [resume] versions already at ${args.version} — skipping bump`);
583
+ }
408
584
  sh('npm install --package-lock-only --no-audit --no-fund', args);
409
585
  sh('npm run check:lockstep', args);
410
586
 
@@ -493,8 +669,15 @@ function step5Commit(args) {
493
669
  `docs/releases/v${args.version}.md`,
494
670
  // gh#617: Step 4d.5 regenerates these post-bump (catalogId embeds the
495
671
  // lockstep version); unstaged, check:genui-catalog fails in CI (v0.8.26).
672
+ // catalog-data.js is the same derivation's third output (the browser-safe
673
+ // ES-module projection of base.json/adia-pack.json) — check-genui-catalog.mjs
674
+ // verifies its freshness too, but this list never carried it (found live
675
+ // during the v0.8.33 cut: base.json/adia-pack.json staged and committed
676
+ // fine, catalog-data.js sat modified-uncommitted, CI caught the drift a
677
+ // fresh checkout exposes that a local working tree doesn't).
496
678
  'packages/genui/adia-catalog/base.json',
497
679
  'packages/genui/adia-catalog/adia-pack.json',
680
+ 'packages/genui/adia-catalog/catalog-data.js',
498
681
  ...PACKAGE_ROSTER.flatMap(({ name, dir, plugin }) => {
499
682
  // Roster-driven (H3): each package stages its manifest + CHANGELOG;
500
683
  // plugins also stage .claude-plugin/plugin.json (the /plugin-update
@@ -751,11 +934,58 @@ async function step10GhAndSite(args) {
751
934
  `builds from main, runs pre-flight verify → snapshot → hardened rsync →\n` +
752
935
  `post-deploy verify → auto-rollback. NOT a raw rsync (the bypass this replaced).`;
753
936
  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');
937
+ // gh#762 + PR #775 review: dispatch and settle-wait are ONE shell block so
938
+ // the tracked run id survives between steps. Three fixes over the old
939
+ // "poll the newest run for status=completed" shape:
940
+ // 1. It could settle on the PREVIOUS completed run before the dispatch
941
+ // registered so capture the newest id BEFORE dispatching and wait
942
+ // for a NEW id to appear.
943
+ // 2. It accepted failure/cancelled as settled — so poll the tracked run
944
+ // by id and require conclusion=success, else fail the handoff.
945
+ // 3. It waited in silence while a forgotten run at the production-site
946
+ // gate held the deploy-site concurrency group for 56h — so each poll
947
+ // names the gate-waiting blocker (id, since, URL).
948
+ // reap-stale-deploy-gate.yml auto-cancels gate-waiters older than 24h, but
949
+ // a live handoff should not wait hours to find out.
950
+ console.log(' Dispatching and waiting for the deploy workflow to settle...');
951
+ sh(String.raw`
952
+ prev_id=$(gh run list --workflow=deploy-site.yml --repo adiahealth/gen-ui-kit --limit 1 --json databaseId -q '.[0].databaseId')
953
+ gh workflow run "Deploy site (${args.host})" --repo adiahealth/gen-ui-kit --ref main
954
+ run_id=""
955
+ tries=0
956
+ while [ "$tries" -lt 60 ]; do
957
+ sleep 5
958
+ run_id=$(gh run list --workflow=deploy-site.yml --repo adiahealth/gen-ui-kit --limit 1 --json databaseId -q '.[0].databaseId')
959
+ if [ -n "$run_id" ] && [ "$run_id" != "$prev_id" ]; then break; fi
960
+ run_id=""
961
+ tries=$((tries + 1))
962
+ done
963
+ if [ -z "$run_id" ]; then
964
+ echo "ERROR: the dispatched deploy-site run never appeared within 5 min (newest is still $prev_id) — inspect the Actions tab before retrying" >&2
965
+ exit 1
966
+ fi
967
+ echo " Tracking dispatched run $run_id..."
968
+ while :; do
969
+ status=$(gh run view "$run_id" --repo adiahealth/gen-ui-kit --json status -q .status)
970
+ [ "$status" = "completed" ] && break
971
+ blocker=$(gh run list --workflow=deploy-site.yml --repo adiahealth/gen-ui-kit --limit 10 \
972
+ --json databaseId,status,createdAt,url \
973
+ -q '[.[] | select(.status == "waiting")] | last | if . == null then "" else "run \(.databaseId) — waiting at the production-site gate since \(.createdAt): \(.url)" end')
974
+ if [ "$status" = "waiting" ]; then
975
+ echo " … run $run_id is at the production-site approval gate: ${'$'}{blocker:-approve it in the Actions UI}"
976
+ elif [ -n "$blocker" ]; then
977
+ echo " … run $run_id is '$status' — BLOCKED BEHIND $blocker (gh#762: approve or cancel that run to release the deploy-site concurrency group)"
978
+ else
979
+ echo " … run $run_id: $status"
980
+ fi
981
+ sleep 15
982
+ done
983
+ conclusion=$(gh run view "$run_id" --repo adiahealth/gen-ui-kit --json conclusion -q .conclusion)
984
+ if [ "$conclusion" != "success" ]; then
985
+ echo "ERROR: deploy-site run $run_id completed with conclusion '$conclusion' — the site deploy did NOT land; investigate the run before re-dispatching" >&2
986
+ exit 1
987
+ fi
988
+ echo " ✓ deploy-site.yml run $run_id settled: success"`, args);
759
989
  // Verify a deployed CONTENT FILE, never an SPA route — the docs site
760
990
  // returns HTTP 200 + a blank shell for ANY unmatched route (v0.7.4 trap).
761
991
  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 +997,41 @@ async function main() {
767
997
  // Fail-fast guard: refuse to git/npm/gh/curl against a non-monorepo directory.
768
998
  assertMonorepoRoot(REPO);
769
999
  console.log(`adia-release release-pack — v${args.version} (${args.mode})${args.dry ? ' [DRY]' : ''}`);
1000
+ // Stale-plugin-cache self-check (v0.8.32 cut, first failure): the INSTALLED
1001
+ // plugin cache only refreshes on a version bump, so running this script from
1002
+ // the cache mid-cycle can execute a version that predates its own guards
1003
+ // (SKILL.md §Plugin cache). If the repo carries its own copy and the bytes
1004
+ // differ, warn loudly — the repo copy is almost always the newer one.
1005
+ const repoCopy = path.join(REPO, 'packages/plugins/adia-ui-forge/skills/adia-release/scripts/release-pack.mjs');
1006
+ const selfPath = new URL(import.meta.url).pathname;
1007
+ if (fs.existsSync(repoCopy) && path.resolve(selfPath) !== path.resolve(repoCopy)) {
1008
+ const self = fs.readFileSync(selfPath, 'utf8');
1009
+ const repo = fs.readFileSync(repoCopy, 'utf8');
1010
+ if (self !== repo) {
1011
+ console.error('\n⚠ STALE-COPY WARNING: this release-pack.mjs differs from the repo\'s own copy at');
1012
+ console.error(` ${repoCopy}`);
1013
+ console.error(' The installed plugin cache lags the repo between cuts (SKILL.md §Plugin cache);');
1014
+ console.error(' the repo copy is almost certainly newer — prefer running it:');
1015
+ console.error(` node ${repoCopy} <same args>\n`);
1016
+ }
1017
+ }
1018
+ // gh#765: an aborted Step-4 run leaves bumped versions + promoted
1019
+ // CHANGELOGs; detect BEFORE pre-flight so gate 24 validates at the right
1020
+ // version and Step 4 skips the already-done promote/stub/bump.
1021
+ args.resume = detectHalfCut(args);
1022
+ // The zero-flag from-scratch shape parseArgs deferred (CodeRabbit, PR #772):
1023
+ // with NEITHER package flag, no CHANGELOG gets a [VERSION] section and
1024
+ // Step 4e is a guaranteed death — unless this is a half-cut RESUME, where
1025
+ // the aborted run already wrote the sections. Only detectHalfCut can tell
1026
+ // the two apart, so the ruling lives here — still BEFORE the pre-flight.
1027
+ if (args.mode === 'from-scratch' && !args.resume && !args.substantivePackages && !args.stubPackages) {
1028
+ console.error('error: --mode from-scratch needs --substantive-packages and/or --stub-packages');
1029
+ console.error(` covering all ${PACKAGE_NAMES.length} lockstep packages — with neither, no CHANGELOG gets`);
1030
+ console.error(` a [${args.version}] section and Step 4e (generate-release-notes) dies after`);
1031
+ console.error(' the ~15-min pre-flight (gh#765). A resumed half-cut tree is exempt,');
1032
+ console.error(' but this tree is not half-cut (versions are not at the cut version).');
1033
+ process.exit(2);
1034
+ }
770
1035
  step1ReBaseline(args);
771
1036
  step3PreFlight(args);
772
1037
  step4PromoteAndBump(args);
@@ -859,6 +1124,83 @@ function selftest() {
859
1124
  process.exit(1);
860
1125
  }
861
1126
 
1127
+ // gh#765 — from-scratch with substantive-but-no-stub packages must die at
1128
+ // PARSE time, naming the ride-alongs, BEFORE any pre-flight gate runs
1129
+ // (v0.8.29 burned three ~15-min pre-flight re-runs discovering it at
1130
+ // Step 4e). Mirror of the handoff --gh-notes-file guard below.
1131
+ let guardFailed = false;
1132
+ let guardOut = '';
1133
+ try {
1134
+ execSync(
1135
+ `node "${scriptPath}" --mode from-scratch --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
1136
+ `--commit-message-file "${commitFile}" --substantive-packages web-components --dry`,
1137
+ { cwd: REPO, encoding: 'utf8' },
1138
+ );
1139
+ } catch (e) {
1140
+ guardFailed = true;
1141
+ guardOut = (e.stdout || '') + (e.stderr || '');
1142
+ }
1143
+ if (!guardFailed || !guardOut.includes('ride-along') || !guardOut.includes('a2ui-validator')) {
1144
+ console.error('selftest FAIL: from-scratch without --stub-packages must hard-reject at parse time, naming the ride-alongs');
1145
+ process.exit(1);
1146
+ }
1147
+ if (guardOut.includes('Step 3') || guardOut.includes('gate roster')) {
1148
+ console.error('selftest FAIL: the ride-along guard must fire BEFORE the pre-flight, not after');
1149
+ process.exit(1);
1150
+ }
1151
+
1152
+ // CodeRabbit (PR #772): the guard must arm on EITHER flag — a stub-only
1153
+ // invocation used to bypass it entirely. Three shapes: stub-only partial
1154
+ // coverage rejects naming ride-alongs; stub-only full coverage without
1155
+ // the --substantive/--xref pairing rejects (insert-stub would silently
1156
+ // no-op); zero-flag from-scratch on a NON-half-cut tree rejects in main
1157
+ // after detectHalfCut, still before the pre-flight.
1158
+ const rejectShapes = [
1159
+ { flags: '--substantive x --xref y --stub-packages llm', marker: 'ride-along' },
1160
+ { flags: `--stub-packages ${PACKAGE_NAMES.join(',')}`, marker: '--substantive and --xref' },
1161
+ { flags: '', marker: 'needs --substantive-packages and/or --stub-packages' },
1162
+ ];
1163
+ for (const { flags, marker } of rejectShapes) {
1164
+ let failed = false;
1165
+ let out = '';
1166
+ try {
1167
+ execSync(
1168
+ `node "${scriptPath}" --mode from-scratch --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
1169
+ `--commit-message-file "${commitFile}" ${flags} --dry`,
1170
+ { cwd: REPO, encoding: 'utf8' },
1171
+ );
1172
+ } catch (e) {
1173
+ failed = true;
1174
+ out = (e.stdout || '') + (e.stderr || '');
1175
+ }
1176
+ if (!failed || !out.includes(marker) || out.includes('gate roster')) {
1177
+ console.error(`selftest FAIL: from-scratch shape "${flags || '(no package flags)'}" must reject pre-flight with "${marker}"`);
1178
+ process.exit(1);
1179
+ }
1180
+ }
1181
+
1182
+ // …and a fully covered from-scratch roster (substantive ∪ stub = all
1183
+ // packages) must pass the guard and assemble its dry plan normally.
1184
+ const stubs = PACKAGE_NAMES.filter((p) => p !== 'web-components').join(',');
1185
+ let coveredOut;
1186
+ try {
1187
+ coveredOut = execSync(
1188
+ `node "${scriptPath}" --mode from-scratch --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
1189
+ `--commit-message-file "${commitFile}" --gh-notes-file "${notesFile}" ` +
1190
+ `--substantive "selftest" --xref "CHANGELOG.md#999" ` +
1191
+ `--substantive-packages web-components --stub-packages ${stubs} --dry --yes`,
1192
+ { cwd: REPO, encoding: 'utf8' },
1193
+ );
1194
+ } catch (e) {
1195
+ console.error('selftest FAIL: fully covered from-scratch --dry must pass the ride-along guard');
1196
+ console.error((e.stdout || '') + (e.stderr || ''));
1197
+ process.exit(1);
1198
+ }
1199
+ if (!coveredOut.includes('STOPPING per invariant 3')) {
1200
+ console.error('selftest FAIL: covered from-scratch dry plan missing the invariant-3 stop');
1201
+ process.exit(1);
1202
+ }
1203
+
862
1204
  // Handoff without --gh-notes-file must die at PARSE time (v0.8.10: the old
863
1205
  // Step-10-only check fired after tags+publish were irreversible).
864
1206
  let handoffFailed = false;
@@ -892,7 +1234,10 @@ if (topArgv[0] === 'selftest') {
892
1234
  selftest();
893
1235
  } else {
894
1236
  main().catch((e) => {
895
- console.error(e);
1237
+ // sh()/shQuiet() failures already printed the failing command + its
1238
+ // captured output (gh#765) — a full stack re-dump would bury it again.
1239
+ if (e && e.reported) console.error(e.message);
1240
+ else console.error(e);
896
1241
  rl.close();
897
1242
  process.exit(1);
898
1243
  });