@adia-ai/adia-ui-forge 0.8.59 → 0.8.60

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.
@@ -71,6 +71,11 @@ import process from 'node:process';
71
71
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
72
72
  import { resolvePackageChangelog, PACKAGE_ROSTER } from './package-paths.mjs';
73
73
  import { GATE_ROSTER } from './gate-roster.mjs';
74
+ // gh#3361: Step 5's releaseFiles derives its PINNED_REFS-covered entries
75
+ // from bump.mjs's own tables (below, pinnedRefFiles()) instead of
76
+ // hand-duplicating them — bump.mjs guards its own main()/selftest() behind
77
+ // an entry-point check specifically so this import is side-effect-free.
78
+ import { PINNED_REFS, REPO_PINNED_REFS } from './bump.mjs';
74
79
 
75
80
  const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
76
81
  const REPO = process.cwd();
@@ -118,6 +123,7 @@ function parseArgs(argv) {
118
123
  host: process.env.ADIA_DEPLOY_HOST || DEFAULT_DEPLOY_HOST,
119
124
  scope: process.env.ADIA_NPM_SCOPE || DEFAULT_NPM_SCOPE,
120
125
  dry: false, yes: false, push: false, publish: false, skipGates: false, skipSite: false,
126
+ fromStep10: false,
121
127
  };
122
128
  for (let i = 0; i < argv.length; i++) {
123
129
  const k = argv[i];
@@ -148,6 +154,15 @@ function parseArgs(argv) {
148
154
  else if (k === '--go') { args.yes = true; args.push = true; args.publish = true; }
149
155
  else if (k === '--skip-gates') args.skipGates = true;
150
156
  else if (k === '--skip-site') args.skipSite = true;
157
+ // gh#3342: resume a handoff that already tagged/pushed/published — a
158
+ // straight re-run of `--mode handoff` re-gates (Step 3 never runs in
159
+ // handoff mode, but Step 6 would re-tag at HEAD, which may no longer be
160
+ // the tagged commit if anything merged since) and Step 9 would
161
+ // re-dispatch publishes that already succeeded (idempotent, but a
162
+ // needless wait). `--from-step10` skips straight to Step 10 (GH
163
+ // releases + site deploy) — the exact "resume from Step 10 only" shape
164
+ // the v0.8.59 cut needed by hand (a one-off script, never checked in).
165
+ else if (k === '--from-step10') args.fromStep10 = true;
151
166
  else if (k === '-h' || k === '--help') { help(); process.exit(0); }
152
167
  }
153
168
  if (!args.version || !args.date || !args.previous || !args.mode) {
@@ -235,6 +250,10 @@ function parseArgs(argv) {
235
250
  console.error(`error: GH notes file not found or not a regular file: ${args.ghNotesFile}`);
236
251
  process.exit(2);
237
252
  }
253
+ } else if (args.fromStep10) {
254
+ console.error('error: --from-step10 only applies to --mode handoff (it resumes handoff\'s own');
255
+ console.error(' tag→publish→deploy sequence at its last step) — got --mode ' + args.mode);
256
+ process.exit(2);
238
257
  }
239
258
  return args;
240
259
  }
@@ -259,8 +278,13 @@ Flags: --go (single-authorization: the operator's cut-start go covers the
259
278
  --push (auto-confirm checkpoint 2 — push tags)
260
279
  --publish (auto-confirm checkpoint 3 — dispatch publishes)
261
280
  --skip-site (skip EXE deploy)
281
+ --from-step10 (--mode handoff only: resume at Step 10 — GH releases +
282
+ site deploy — skipping the pre-flight gates and re-tagging;
283
+ use once Steps 1-9 are independently confirmed done. See
284
+ recovery-paths.md §Scenario 9.)
262
285
  Instance: --host <deploy-host> (default ${DEFAULT_DEPLOY_HOST}; or $ADIA_DEPLOY_HOST)
263
- --scope <@org> (npm scope; default ${DEFAULT_NPM_SCOPE}; or $ADIA_NPM_SCOPE)`);
286
+ --scope <@org> (npm scope; default ${DEFAULT_NPM_SCOPE}; or $ADIA_NPM_SCOPE)
287
+ $REGISTRY_POLL_MINUTES (Step 9 registry-poll window; default 30)`);
264
288
  }
265
289
 
266
290
  const rl = createInterface({ input: stdin, output: stdout });
@@ -740,6 +764,27 @@ async function step3PreFlight(args) {
740
764
  return;
741
765
  }
742
766
  console.log(`\n=== Step 3 — Pre-flight gates (${GATE_ROSTER.length}-gate roster — gate-roster.mjs) ===`);
767
+
768
+ // gh#3342: gate 4 (test:unit:serial) runs package-root `*.test.js` files
769
+ // for llm/agent/persona against their BUILT tsc output — agent.test.js's
770
+ // own docstring: "run against the BUILT output ... npm run build
771
+ // -w @adia-ai/agent first"; persona.test.js imports './index.js' directly;
772
+ // llm/core carries a dedicated dist-check.test.js that asserts the emitted
773
+ // artifacts exist and explicitly does NOT build them itself ("callers
774
+ // should run npm run build first"). All three packages' emitted .js/.d.ts
775
+ // are gitignored (packages/llm/agent/.gitignore: "Generated by npm run
776
+ // build ... not tracked in git"), so a fresh cut clone has none of them.
777
+ // cut-procedure.md §3.0's harvest preamble names `npm run build
778
+ // -w @adia-ai/llm` but that whole block is CONDITIONAL on source content
779
+ // having changed in the window, and never named agent/persona at all — a
780
+ // cut with no §3.0 trigger skipped the build outright and gate 4 failed
781
+ // on missing dist (the v0.8.59 cut). Unlike §3.0's regen outputs, these
782
+ // are plain build artifacts, not content-conditional — build them
783
+ // unconditionally, every cut, before gate 4 ever runs. Runs before the
784
+ // `--dry` early-return below so a dry preview shows it too.
785
+ console.log('\n-- Precondition: tsc build (llm, agent, persona — gate 4 needs their built output) --');
786
+ sh('npm run build -w @adia-ai/llm -w @adia-ai/agent -w @adia-ai/persona', args);
787
+
743
788
  if (args.dry) {
744
789
  step3PreFlightDry(args);
745
790
  return;
@@ -973,6 +1018,31 @@ function step4PromoteAndBump(args) {
973
1018
  console.log(' ✓ Hermes/Pi plugin manifests fresh after regeneration');
974
1019
  }
975
1020
 
1021
+ // Step 4d.8 — rebuild the dist CSS+JS bundles (gh#3342): bump.mjs's
1022
+ // web-components PINNED_REFS entry moves icons-cdn.js's hand-typed
1023
+ // PACKAGE_VERSION literal every cut (gh#3240/invariant 9), and that
1024
+ // literal is embedded verbatim in three committed dist bundles
1025
+ // (web-components.min.js, everything.min.js, chat-shell.min.js) —
1026
+ // same "derived artifact embeds the lockstep version" class as 4d.5-4d.7
1027
+ // above. Gate 9 (check:js-bundles-fresh) runs pre-bump in Step 3 and only
1028
+ // proves freshness against the PREVIOUS version; relying on the
1029
+ // derived-resync bot to rebuild post-merge left gate 9 red in the
1030
+ // handoff pre-flight (AGENTS.md: committed dist/ bundles are PR-blocking,
1031
+ // Class C — never a derived-resync-only artifact). Rebuild here, under
1032
+ // the same npm-ci layout Step 1 already asserted for gate 9, and verify:
1033
+ console.log('\n Step 4d.8 — rebuild dist CSS+JS bundles (icons-cdn.js pin embeds the lockstep bump):');
1034
+ sh('npm run build:bundles', args);
1035
+ if (args.dry) {
1036
+ console.log(' [dry] npm run check:css-bundles-fresh && npm run check:js-bundles-fresh (post-rebuild proof)');
1037
+ } else if (!shOk('npm run check:css-bundles-fresh') || !shOk('npm run check:js-bundles-fresh')) {
1038
+ console.error('\nERROR: dist bundles still stale after rebuild — build:bundles did not produce');
1039
+ console.error(' output matching check:css-bundles-fresh/check:js-bundles-fresh\'s expectations.');
1040
+ console.error(' Run both by hand to diagnose before re-running the cut.');
1041
+ process.exit(1);
1042
+ } else {
1043
+ console.log(' ✓ dist CSS+JS bundles fresh after rebuild');
1044
+ }
1045
+
976
1046
  // Step 4e — generate docs/ops/releases/vX.Y.Z.md (gh#399: neither this step nor
977
1047
  // its staging into the release commit existed before — check-cut-hygiene's
978
1048
  // "notes present" claim (Step 4g, below) went unverified until
@@ -1002,6 +1072,27 @@ function step4PromoteAndBump(args) {
1002
1072
  }
1003
1073
  }
1004
1074
 
1075
+ // gh#3361: derive Step 5's PINNED_REFS-covered allowlist entries directly
1076
+ // from bump.mjs's own tables, instead of hand-listing the same paths a
1077
+ // second time — the drift class this closes structurally (gh#1198, gh#1899,
1078
+ // gh#1954, gh#2473, gh#3342): every one of those was bump.mjs gaining a new
1079
+ // pinned file that release-pack.mjs's Step 5 didn't independently know
1080
+ // about. A package-keyed entry becomes `<pkgDir>/<file>`; a repo-level entry
1081
+ // (REPO_PINNED_REFS, e.g. the root README's "Current version" claim) is
1082
+ // already repo-root-relative. Pure — the selftest below asserts the
1083
+ // derivation against a fixture table, and separately that it agrees with
1084
+ // the REAL imported PINNED_REFS/REPO_PINNED_REFS for every entry currently
1085
+ // known (so an entry added to bump.mjs without a corresponding
1086
+ // release-pack.mjs edit still shows up here for free).
1087
+ function pinnedRefFiles(pinnedRefs, repoPinnedRefs) {
1088
+ const files = [];
1089
+ for (const [pkgDir, cfgs] of Object.entries(pinnedRefs)) {
1090
+ for (const cfg of cfgs) files.push(`${pkgDir}/${cfg.file}`);
1091
+ }
1092
+ for (const cfg of repoPinnedRefs) files.push(cfg.file);
1093
+ return files;
1094
+ }
1095
+
1005
1096
  // Pure so the selftest can lock the parse without a real git tree (gh#2473,
1006
1097
  // same "pure so selftest can lock the transform" shape as bump.mjs's
1007
1098
  // bumpPinnedRef). Takes `git status --porcelain` output, returns the tracked
@@ -1069,10 +1160,10 @@ function step5Commit(args) {
1069
1160
  sh('git reset HEAD >/dev/null 2>&1 || true', args);
1070
1161
  const releaseFiles = [
1071
1162
  'package-lock.json',
1072
- // Root README's "Current version" claim bump.mjs moves it per-cut
1073
- // (REPO_PINNED_REFS); gate 18 (check-cut-hygiene doc-currency) exact-matches
1074
- // it against the cut version, so it must ride the release commit.
1075
- 'README.md',
1163
+ // Root README's "Current version" claim (gate 18, check-cut-hygiene
1164
+ // doc-currency, exact-matches it against the cut version) comes from
1165
+ // pinnedRefFiles(REPO_PINNED_REFS) below bump.mjs's own REPO_PINNED_REFS
1166
+ // table names this exact file (gh#3361).
1076
1167
  // Root CHANGELOG — the gh#453 promotion machinery renames its
1077
1168
  // [Unreleased] heading at Step 4, but this roster predated it, so the
1078
1169
  // v0.8.20 cut promoted the heading and then shipped WITHOUT it (the
@@ -1097,6 +1188,17 @@ function step5Commit(args) {
1097
1188
  'packages/genui/adia-catalog/adia.agent.json',
1098
1189
  'packages/genui/adia-catalog/adia.shells.json',
1099
1190
  'packages/genui/adia-catalog/catalog-data.js',
1191
+ // gh#3342/gh#3361: packages/web-components/core/icons-cdn.js (bump.mjs's
1192
+ // PACKAGE_VERSION pin, fifth instance of this drift class — gh#1198,
1193
+ // gh#1899, gh#1954, gh#2473) comes from pinnedRefFiles(PINNED_REFS)
1194
+ // below, not a hand-typed entry — bump.mjs's own PINNED_REFS table names
1195
+ // it, so it can't drift out of this list again.
1196
+ // gh#3342: Step 4d.8 rebuilds the dist CSS+JS bundles post-bump (the
1197
+ // icons-cdn.js PACKAGE_VERSION pin above is embedded in three of them) —
1198
+ // directory pathspecs, `git add` stages whatever the rebuild actually
1199
+ // changed, same shape as the chunk-corpus/skills-glob entries below.
1200
+ 'packages/web-components/dist',
1201
+ 'packages/web-modules/dist',
1100
1202
  // gh#1361: Step 4d.5b regenerates the catalog tier index post-bump (it
1101
1203
  // derives from the catalog derive-genui-catalog.mjs just refreshed) and,
1102
1204
  // when that regen goes stale against the chunk harvester's recorded
@@ -1147,19 +1249,25 @@ function step5Commit(args) {
1147
1249
  // Codex `agents/openai.yaml` glob above is.
1148
1250
  files.push(`${dir}/plugin.yaml`, `${dir}/__init__.py`, `${dir}/prompts/*.md`);
1149
1251
  }
1150
- // gh#2473: bump.mjs's PINNED_REFS also rewrites the factory README's
1151
- // OWN prose echo of the generation-MCP pin (`server pinned:
1152
- // @adia-ai/mcp@X` see bump.mjs's A2UI_MCP_PIN comment,
1153
- // check:plugin-count-claims' "factory README generation-MCP pin"
1154
- // equality check) — same invariant-8 scope as .mcp.json, but this
1155
- // list only ever staged .mcp.json + hermes-mcp.yaml, so the README
1156
- // edit rode along unstaged/uncommitted every cut until Step 5.6
1157
- // (below) started catching it.
1158
- if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`, `${dir}/hermes-mcp.yaml`, `${dir}/README.md`);
1252
+ // gh#1954: Step 4d.7 also regenerates the factory's Hermes/Pi
1253
+ // hermes-mcp.yaml fragment post-bump not PINNED_REFS-covered (it's
1254
+ // a manifest-generator output, not a version-pin literal), so it
1255
+ // stays hand-listed here. factory's `.mcp.json` + `README.md`
1256
+ // generation-MCP pins (gh#1198, gh#2473 — same invariant-8 scope) come
1257
+ // from pinnedRefFiles(PINNED_REFS) below instead (gh#3361).
1258
+ if (name === 'adia-ui-factory') files.push(`${dir}/hermes-mcp.yaml`);
1159
1259
  return files;
1160
1260
  }),
1261
+ // gh#3361: every PINNED_REFS/REPO_PINNED_REFS-covered file, derived
1262
+ // straight from bump.mjs's own tables — see pinnedRefFiles()'s header
1263
+ // comment for what this closes. Deduped below against entries this list
1264
+ // already carries for OTHER reasons (e.g. every roster package's own
1265
+ // package.json is already staged above; web-modules' peer-pin entry
1266
+ // targets that same package.json).
1267
+ ...pinnedRefFiles(PINNED_REFS, REPO_PINNED_REFS),
1161
1268
  ];
1162
- sh(`git add ${releaseFiles.join(' ')}`, args);
1269
+ const dedupedReleaseFiles = [...new Set(releaseFiles)];
1270
+ sh(`git add ${dedupedReleaseFiles.join(' ')}`, args);
1163
1271
  sh('git diff --cached --stat | tail -3', args);
1164
1272
 
1165
1273
  // Step 5.6 — unstaged-tracked-files guard (gh#2473). Numbered 5.6, not
@@ -1317,6 +1425,77 @@ function registrySnapshot(args) {
1317
1425
  return `Registry state BEFORE publish (target = ${args.version}):\n${lines.join('\n')}`;
1318
1426
  }
1319
1427
 
1428
+ async function sleepMs(ms) {
1429
+ return new Promise((res) => setTimeout(res, ms));
1430
+ }
1431
+
1432
+ // Registry truth for the Step 9 poll — which packages (plus, as the sentinel
1433
+ // entry 'dist-tags.latest', the tag itself) have NOT yet converged on the
1434
+ // target version. [] means fully converged.
1435
+ function registryStalePackages(args, scope, version) {
1436
+ const stale = [];
1437
+ for (const pkg of PACKAGES) {
1438
+ let v;
1439
+ try { v = execSync(`npm view "${scope}/${pkg}" version`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
1440
+ catch { v = null; }
1441
+ if (v !== version) stale.push(pkg);
1442
+ }
1443
+ let latest;
1444
+ try { latest = execSync(`npm view ${scope}/web-components dist-tags.latest`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
1445
+ catch { latest = null; }
1446
+ if (latest !== version) stale.push('dist-tags.latest');
1447
+ return stale;
1448
+ }
1449
+
1450
+ // gh#3342: npm now stages a large publish ASYNCHRONOUSLY — the tarball can
1451
+ // take up to ~25 minutes to actually become visible on `npm view`, well past
1452
+ // this poll's former 10-minute bound (the v0.8.59 cut hit exactly this). A
1453
+ // re-dispatch of the publish workflow while a package is in that state fails
1454
+ // its own `npm publish` step with npm error E409 ("Cannot publish over
1455
+ // previously staged version") — that error IS the staged-not-lost signal
1456
+ // (npm rejected the duplicate BECAUSE the real publish already landed
1457
+ // server-side), never a real failure. Check the package's latest
1458
+ // publish-<pkg>.yml run: a FAILED conclusion whose log matches the E409
1459
+ // signature confirms staged-not-lost; anything else (no failed run, or a
1460
+ // failure that isn't E409) is a genuine miss. Never throws — a gh query
1461
+ // failure just means "not confirmed E409", the conservative default so a
1462
+ // genuine miss is never masked.
1463
+ function publishRunFailedWithE409(pkg) {
1464
+ try {
1465
+ const runId = execSync(
1466
+ `gh run list --workflow=publish-${pkg}.yml --limit 1 --json databaseId,conclusion -q '.[0] | select(.conclusion=="failure") | .databaseId'`,
1467
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
1468
+ ).trim();
1469
+ if (!runId) return false;
1470
+ const log = execSync(`gh run view ${runId} --log-failed`, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
1471
+ return /E409|previously staged/i.test(log);
1472
+ } catch {
1473
+ return false;
1474
+ }
1475
+ }
1476
+
1477
+ // Poll the registry every 15s up to `minutes`, logging progress the same way
1478
+ // the original bash loop did. Returns the still-stale list (empty = fully
1479
+ // converged) — never throws; the caller decides what a non-empty result means.
1480
+ async function pollRegistry(args, scope, version, minutes, label) {
1481
+ if (args.dry) {
1482
+ console.log(` [dry] ${label} poll skipped (up to ${minutes}min, every 15s)`);
1483
+ return [];
1484
+ }
1485
+ const checks = Math.max(1, Math.round((minutes * 60) / 15));
1486
+ let stale = PACKAGES.length ? [...PACKAGES, 'dist-tags.latest'] : [];
1487
+ for (let i = 1; i <= checks; i++) {
1488
+ stale = registryStalePackages(args, scope, version);
1489
+ if (stale.length === 0) {
1490
+ console.log(` ✓ ${label} confirms all ${PACKAGES.length} + latest, after ${i} check(s)`);
1491
+ return [];
1492
+ }
1493
+ if (i < checks) await sleepMs(15000);
1494
+ }
1495
+ console.log(` ${label} did not converge after ${minutes}min — still stale: ${stale.join(', ')}`);
1496
+ return stale;
1497
+ }
1498
+
1320
1499
  async function step9Publish(args) {
1321
1500
  console.log('\n=== Step 9 — Verify-triggered + re-dispatch misses ===');
1322
1501
  const evidence = registrySnapshot(args);
@@ -1334,39 +1513,105 @@ async function step9Publish(args) {
1334
1513
  // Poll the REGISTRY itself instead — the skill's own doctrine (SKILL.md:
1335
1514
  // "the registry is the verify target, never a workflow's green check")
1336
1515
  // applies here as much as it does to the final check.
1337
- console.log(`\n Waiting for the registry to confirm all ${PACKAGES.length} packages at ${args.version} (polling every 15s, up to 10min)...`);
1338
- sh(
1339
- `for i in $(seq 1 40); do ` +
1340
- `fail=0; ` +
1341
- `for pkg in ${PACKAGES.join(' ')}; do ` +
1342
- `v="$(npm view "${args.scope}/$pkg" version 2>/dev/null || true)"; ` +
1343
- `[ "$v" = "${args.version}" ] || fail=1; ` +
1344
- `done; ` +
1345
- `latest="$(npm view ${args.scope}/web-components dist-tags.latest 2>/dev/null || true)"; ` +
1346
- `[ "$latest" = "${args.version}" ] || fail=1; ` +
1347
- `[ "$fail" = 0 ] && { echo " ✓ registry confirms all ${PACKAGES.length} + latest, after $i check(s)"; exit 0; }; ` +
1348
- `sleep 15; ` +
1349
- `done; ` +
1350
- `echo " registry did not converge after 10min — the fail-closed check below will report exactly which package(s) are still stale"`,
1351
- args,
1352
- );
1516
+ //
1517
+ // gh#3342: the poll window moved from a flat 10 minutes to
1518
+ // REGISTRY_POLL_MINUTES (default 30) npm stages a large publish
1519
+ // ASYNCHRONOUSLY and the tarball can take up to ~25 minutes to become
1520
+ // visible on `npm view`, past the old bound (the v0.8.59 cut hit this and
1521
+ // exited 1 with every publish run actually green).
1522
+ const pollMinutes = Number(process.env.REGISTRY_POLL_MINUTES) || 30;
1523
+ console.log(`\n Waiting for the registry to confirm all ${PACKAGES.length} packages at ${args.version} (polling every 15s, up to ${pollMinutes}min)...`);
1524
+ let stale = await pollRegistry(args, args.scope, args.version, pollMinutes, 'registry');
1525
+
1526
+ // gh#3342: a still-stale package here may be genuinely missing, OR it may
1527
+ // be staged-but-not-yet-visible — distinguish via its latest publish run.
1528
+ // A run that failed specifically on npm's E409 ("Cannot publish over
1529
+ // previously staged version") proves the package IS staged: npm rejected
1530
+ // the (redundant) publish attempt BECAUSE the real one already landed
1531
+ // server-side. That is the staged-not-lost signal this ticket exists to
1532
+ // handle — never a hard failure, and never itself grounds to re-dispatch
1533
+ // again (a THIRD publish attempt would only 409 again). Give
1534
+ // E409-confirmed packages one more extended, isolated poll; a package
1535
+ // with no E409 signal is a genuine miss and skips straight to the
1536
+ // fail-closed report below.
1537
+ if (stale.length > 0 && !args.dry) {
1538
+ const staleePkgs = stale.filter((p) => p !== 'dist-tags.latest');
1539
+ const e409 = staleePkgs.filter((pkg) => publishRunFailedWithE409(pkg));
1540
+ if (e409.length > 0) {
1541
+ console.log(`\n ${e409.length} stale package(s) confirmed E409 (staged, not lost) — extending the poll another ${pollMinutes}min: ${e409.join(', ')}`);
1542
+ console.log(' (a genuinely missing package would show no failed run, or a failure that is not E409 — those are NOT extended)');
1543
+ // The fail-closed block below re-queries the registry fresh regardless
1544
+ // of this poll's own result — this extended wait exists to let a
1545
+ // genuinely staged package converge before that final read, not to
1546
+ // compute the verdict itself.
1547
+ await pollRegistry(args, args.scope, args.version, pollMinutes, 'extended registry poll');
1548
+ }
1549
+ }
1550
+
1353
1551
  // Fail-closed: the registry is the verify target (SKILL.md) — a missing or
1354
1552
  // stale package aborts the cut here, before GH releases + site deploy. This
1355
- // is now a final confirmation of the poll above, not the first real read.
1553
+ // is now a final confirmation of the poll(s) above, not the first real read.
1356
1554
  console.log(`\n Post-publish registry verification, all ${PACKAGES.length} at ${args.version} + npm latest (fail-closed):`);
1357
- sh(
1358
- `fail=0; ` +
1359
- `for pkg in ${PACKAGES.join(' ')}; do ` +
1360
- `v="$(npm view "${args.scope}/$pkg" version 2>/dev/null || true)"; ` +
1361
- `echo " $pkg: \${v:-MISSING}"; ` +
1362
- `[ "$v" = "${args.version}" ] || fail=1; ` +
1363
- `done; ` +
1364
- `latest="$(npm view ${args.scope}/web-components dist-tags.latest 2>/dev/null || true)"; ` +
1365
- `echo " latest: \${latest:-MISSING}"; ` +
1366
- `[ "$latest" = "${args.version}" ] || fail=1; ` +
1367
- `[ "$fail" = 0 ] || { echo "ERROR: registry does not confirm ${args.version} across all ${PACKAGES.length} packages — fix before GH releases/deploy"; exit 1; }`,
1368
- args,
1369
- );
1555
+ if (args.dry) {
1556
+ console.log(' [dry] registry re-verify skipped');
1557
+ return;
1558
+ }
1559
+ const final = registryStalePackages(args, args.scope, args.version);
1560
+ for (const pkg of PACKAGES) {
1561
+ console.log(` ${pkg}: ${final.includes(pkg) ? 'MISSING' : args.version}`);
1562
+ }
1563
+ console.log(` latest: ${final.includes('dist-tags.latest') ? 'MISSING' : args.version}`);
1564
+ if (final.length > 0) {
1565
+ console.error(`ERROR: registry does not confirm ${args.version} across all ${PACKAGES.length} packages — fix before GH releases/deploy`);
1566
+ console.error(` still stale: ${final.join(', ')}`);
1567
+ process.exit(1);
1568
+ }
1569
+ }
1570
+
1571
+ // Precondition for `--from-step10` (critic finding on PR #3364): jumping
1572
+ // straight to Step 10 without checking the release tags exist is a silent
1573
+ // trap — `gh release create <tag>` on a tag that doesn't exist yet mints a
1574
+ // NEW lightweight tag at whatever HEAD happens to be, not the real tagged
1575
+ // release commit. Refuse the resume outright unless every tag Step 6 would
1576
+ // have created (umbrella + all per-package) already exists on origin and
1577
+ // points at HEAD.
1578
+ function assertReleaseTagsAtHead(args) {
1579
+ console.log('\n Precondition — verifying release tags already exist and point at HEAD before resuming at Step 10:');
1580
+ const headSha = shQuiet('git rev-parse HEAD', args).trim();
1581
+ const tags = [`v${args.version}`, ...PACKAGES.map((p) => `${p}-v${args.version}`)];
1582
+ if (args.dry) {
1583
+ console.log(` [dry] git fetch origin ${tags.map((t) => `tag ${t}`).join(' ')}`);
1584
+ console.log(` [dry] verify each of ${tags.length} tags resolves to HEAD (${headSha || '<dry HEAD>'})`);
1585
+ return;
1586
+ }
1587
+ sh(`git fetch origin ${tags.map((t) => `tag ${t}`).join(' ')}`, args);
1588
+ const missing = [];
1589
+ const mismatched = [];
1590
+ for (const tag of tags) {
1591
+ let tagSha;
1592
+ try {
1593
+ tagSha = execSync(`git rev-parse "${tag}^{commit}"`, { cwd: REPO, encoding: 'utf8' }).trim();
1594
+ } catch {
1595
+ missing.push(tag);
1596
+ continue;
1597
+ }
1598
+ if (tagSha !== headSha) mismatched.push(`${tag} -> ${tagSha}`);
1599
+ }
1600
+ if (missing.length > 0 || mismatched.length > 0) {
1601
+ console.error('\nERROR: --from-step10 refuses to resume — the release tags this handoff needs are not all present at HEAD.');
1602
+ if (missing.length > 0) {
1603
+ console.error(` Missing entirely (Step 6 creates these, Step 8 pushes them to origin): ${missing.join(', ')}`);
1604
+ }
1605
+ if (mismatched.length > 0) {
1606
+ console.error(` Point somewhere other than HEAD (${headSha}): ${mismatched.join(', ')}`);
1607
+ }
1608
+ console.error(' Resuming Step 10 here would let `gh release create` mint NEW lightweight tags at the');
1609
+ console.error(' current HEAD instead of using the real tagged release commit — silently wrong.');
1610
+ console.error(' Fix: confirm the original handoff run actually reached Step 8 (tags pushed to origin)');
1611
+ console.error(' and that HEAD has not moved since, or re-run the full handoff instead of --from-step10.');
1612
+ process.exit(1);
1613
+ }
1614
+ console.log(` ✓ all ${tags.length} release tags exist on origin and point at HEAD (${headSha})`);
1370
1615
  }
1371
1616
 
1372
1617
  // ── Step 10 — GH releases + site deploy ──────────────────────────
@@ -1519,6 +1764,22 @@ async function main() {
1519
1764
  console.error(` node ${repoCopy} <same args>\n`);
1520
1765
  }
1521
1766
  }
1767
+ // gh#3342: --from-step10 resumes a handoff that already tagged, pushed,
1768
+ // and published — skip straight to Step 10 (GH releases + site deploy)
1769
+ // rather than a plain re-run, which would re-run the full pre-flight gate
1770
+ // roster (Step 3, ~15min) AND re-tag at HEAD (Step 6, wrong if anything
1771
+ // merged since the original tag). Every step this skips is either
1772
+ // idempotent-but-wasteful (3, 9) or actively wrong to repeat (6, if HEAD
1773
+ // moved) — a real operational need the v0.8.59 cut hit and worked around
1774
+ // with a one-off, never-checked-in script.
1775
+ if (args.mode === 'handoff' && args.fromStep10) {
1776
+ console.log('\n[resume --from-step10] skipping Steps 1/3/4/5/6/7/8/9 (already tagged/pushed/published) — jumping straight to Step 10.');
1777
+ assertReleaseTagsAtHead(args);
1778
+ await step10GhAndSite(args);
1779
+ rl.close();
1780
+ console.log('\n✓ release-pack complete (resumed at Step 10). Verify against the npm registry + GH releases, then author the release notes.');
1781
+ return;
1782
+ }
1522
1783
  // gh#765: an aborted Step-4 run leaves bumped versions + promoted
1523
1784
  // CHANGELOGs; detect BEFORE pre-flight so gate 24 validates at the right
1524
1785
  // version and Step 4 skips the already-done promote/stub/bump.
@@ -1652,6 +1913,50 @@ async function selftest() {
1652
1913
  }
1653
1914
  }
1654
1915
 
1916
+ // gh#3361 — pinnedRefFiles() must derive `<pkgDir>/<file>` for a
1917
+ // package-keyed entry and the bare `file` for a repo-level entry, from a
1918
+ // FIXTURE table (independent of bump.mjs's real, evolving content).
1919
+ {
1920
+ const fixturePinned = {
1921
+ 'packages/plugins/fake-plugin': [{ file: '.mcp.json' }, { file: 'README.md' }],
1922
+ 'packages/fake-pkg': [{ file: 'core/pinned.js' }],
1923
+ };
1924
+ const fixtureRepoPinned = [{ file: 'README.md' }];
1925
+ const got = pinnedRefFiles(fixturePinned, fixtureRepoPinned);
1926
+ const want = [
1927
+ 'packages/plugins/fake-plugin/.mcp.json',
1928
+ 'packages/plugins/fake-plugin/README.md',
1929
+ 'packages/fake-pkg/core/pinned.js',
1930
+ 'README.md',
1931
+ ];
1932
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
1933
+ console.error(`selftest FAIL: pinnedRefFiles() expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
1934
+ process.exit(1);
1935
+ }
1936
+ }
1937
+ // gh#3361 — the structural guarantee this ticket exists for: every path
1938
+ // bump.mjs's REAL, imported PINNED_REFS/REPO_PINNED_REFS tables name is
1939
+ // present in Step 5's assembled releaseFiles (the `--mode cut --dry`
1940
+ // subprocess run below), proven by literally re-deriving the expected
1941
+ // set from the SAME imported tables the subprocess itself reads — a
1942
+ // future PINNED_REFS entry with no matching releaseFiles line (the
1943
+ // gh#1198/1899/1954/2473/3342 drift shape) fails HERE, not three cuts
1944
+ // later at Step 5.6.
1945
+ {
1946
+ const expectedPinned = pinnedRefFiles(PINNED_REFS, REPO_PINNED_REFS);
1947
+ if (expectedPinned.length === 0) {
1948
+ console.error('selftest FAIL: bump.mjs\'s real PINNED_REFS/REPO_PINNED_REFS produced zero derived paths — the import is broken');
1949
+ process.exit(1);
1950
+ }
1951
+ // The fixture set itself must never silently reduce to nothing new —
1952
+ // proves gh#3342's icons-cdn.js entry specifically survived the
1953
+ // gh#3361 refactor (it's the exact file that motivated this ticket).
1954
+ if (!expectedPinned.includes('packages/web-components/core/icons-cdn.js')) {
1955
+ console.error(`selftest FAIL: pinnedRefFiles(PINNED_REFS, REPO_PINNED_REFS) is missing packages/web-components/core/icons-cdn.js — got ${JSON.stringify(expectedPinned)}`);
1956
+ process.exit(1);
1957
+ }
1958
+ }
1959
+
1655
1960
  // gh#2473 — Step 5.6's guard: unstagedTrackedLeftovers() must find a
1656
1961
  // plain modified/deleted tracked file, unquote a C-quoted path
1657
1962
  // (spaces), and report the NEW side of a rename/copy — all from a
@@ -1715,6 +2020,11 @@ async function selftest() {
1715
2020
  `${GATE_ROSTER.length}-gate roster`,
1716
2021
  `✓ all ${GATE_ROSTER.length}/${GATE_ROSTER.length} gates passed`,
1717
2022
  'STOPPING per invariant 3',
2023
+ // gh#3342 — gate 4 needs llm/agent/persona's BUILT (tsc) output, not
2024
+ // just conditionally regenerated content; the precondition must run
2025
+ // even in a --dry preview, before the gate roster itself.
2026
+ 'Precondition: tsc build (llm, agent, persona',
2027
+ '[dry] npm run build -w @adia-ai/llm -w @adia-ai/agent -w @adia-ai/persona',
1718
2028
  // gh#358 — the release branch must actually be created, not just documented.
1719
2029
  'git checkout -b release/v9.9.9',
1720
2030
  // gh#1197 — Step 5's staging pathspec must carry gate 28's committed
@@ -1752,6 +2062,19 @@ async function selftest() {
1752
2062
  // README target), or it rides unstaged/uncommitted past the release
1753
2063
  // commit the same way .mcp.json itself did before gh#1198.
1754
2064
  'packages/plugins/adia-ui-factory/README.md',
2065
+ // gh#3342 — fifth instance of the same allowlist-drift class
2066
+ // (gh#1198, gh#1899, gh#1954, gh#2473): bump.mjs's web-components
2067
+ // PINNED_REFS entry moves icons-cdn.js's PACKAGE_VERSION literal
2068
+ // every cut; the v0.8.59 cut hit Step 5.6's guard on this exact file.
2069
+ 'packages/web-components/core/icons-cdn.js',
2070
+ // gh#3342 — Step 4d.8 rebuilds the dist bundles post-bump (the
2071
+ // icons-cdn.js pin above is embedded in three of them) and Step 5
2072
+ // must stage the result, or gate 9 (check:js-bundles-fresh) fails in
2073
+ // the handoff pre-flight the way the v0.8.59 cut's did.
2074
+ 'Step 4d.8 — rebuild dist CSS+JS bundles',
2075
+ '[dry] npm run build:bundles',
2076
+ 'packages/web-components/dist',
2077
+ 'packages/web-modules/dist',
1755
2078
  // gh#2473 — Step 5.6's unstaged-tracked-files guard must actually run
1756
2079
  // (as a no-op) on a clean --dry plan. Its real leftover-detection
1757
2080
  // logic is unit-tested directly above via unstagedTrackedLeftovers();
@@ -1908,6 +2231,62 @@ async function selftest() {
1908
2231
  process.exit(1);
1909
2232
  }
1910
2233
 
2234
+ // gh#3364 critic finding — `--from-step10` must (a) only apply to
2235
+ // --mode handoff, at parse time, and (b) refuse to resume without first
2236
+ // verifying the release tags already exist and point at HEAD (otherwise
2237
+ // `gh release create` on a tag that was never made mints a NEW
2238
+ // lightweight tag at whatever HEAD happens to be — silently wrong).
2239
+ let fromStep10ModeFailed = false;
2240
+ let fromStep10ModeOut = '';
2241
+ try {
2242
+ execSync(
2243
+ `node "${scriptPath}" --mode cut --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 --from-step10 --dry`,
2244
+ { cwd: REPO, encoding: 'utf8' },
2245
+ );
2246
+ } catch (e) {
2247
+ fromStep10ModeFailed = true;
2248
+ fromStep10ModeOut = (e.stdout || '') + (e.stderr || '');
2249
+ }
2250
+ if (!fromStep10ModeFailed || !fromStep10ModeOut.includes('--from-step10 only applies to --mode handoff')) {
2251
+ console.error('selftest FAIL: --from-step10 with a non-handoff mode must hard-reject at parse time');
2252
+ process.exit(1);
2253
+ }
2254
+
2255
+ let resumeOut;
2256
+ try {
2257
+ resumeOut = execSync(
2258
+ `node "${scriptPath}" --mode handoff --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
2259
+ `--gh-notes-file "${notesFile}" --from-step10 --skip-site --dry`,
2260
+ { cwd: REPO, encoding: 'utf8' },
2261
+ );
2262
+ } catch (e) {
2263
+ console.error('selftest FAIL: --mode handoff --from-step10 --dry plan assembly exited non-zero');
2264
+ console.error((e.stdout || '') + (e.stderr || ''));
2265
+ process.exit(1);
2266
+ }
2267
+ const resumeNeed = [
2268
+ '[resume --from-step10] skipping Steps 1/3/4/5/6/7/8/9',
2269
+ 'Precondition — verifying release tags already exist and point at HEAD',
2270
+ '[dry] git fetch origin',
2271
+ '=== Step 10 — GH releases + Site deploy',
2272
+ ];
2273
+ for (const marker of resumeNeed) {
2274
+ if (!resumeOut.includes(marker)) {
2275
+ console.error(`selftest FAIL: expected marker not found in --from-step10 dry plan: "${marker}"`);
2276
+ process.exit(1);
2277
+ }
2278
+ }
2279
+ // "=== Step 1" is a prefix of "=== Step 10" — every marker below must
2280
+ // carry enough of its own header to avoid a false positive against the
2281
+ // Step 10 banner this resume path is SUPPOSED to reach.
2282
+ const resumeMustSkip = ['=== Step 1 — Re-baseline', '=== Step 3 — Pre-flight', '=== Step 6 — Tag at HEAD', '=== Step 7 — F-N1', '=== Step 8 — Push', '=== Step 9 — Verify-triggered'];
2283
+ for (const marker of resumeMustSkip) {
2284
+ if (resumeOut.includes(marker)) {
2285
+ console.error(`selftest FAIL: --from-step10 must skip Steps 1-9 — found "${marker}" in the dry plan`);
2286
+ process.exit(1);
2287
+ }
2288
+ }
2289
+
1911
2290
  console.log('selftest OK');
1912
2291
  } finally {
1913
2292
  fs.rmSync(tmp, { recursive: true, force: true });