geneseed 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/harness.config.json +1 -1
  12. package/js/README.md +9 -5
  13. package/js/build/args.mjs +4 -3
  14. package/js/build/bundle.mjs +10 -10
  15. package/js/build/driver.mjs +8 -7
  16. package/js/build/emit-claude.mjs +3 -2
  17. package/js/build/emit-common.mjs +2 -14
  18. package/js/build/emit-opencode.mjs +12 -8
  19. package/js/build/generate.mjs +15 -11
  20. package/js/build/render.mjs +1 -1
  21. package/js/build/source.mjs +6 -6
  22. package/js/build/stubs.mjs +6 -5
  23. package/js/build/version.mjs +21 -2
  24. package/js/hosts/hooks-context.mjs +2 -1
  25. package/js/hosts/hooks-prims.mjs +2 -2
  26. package/js/hosts/hosts.mjs +24 -26
  27. package/js/hosts/installs.mjs +33 -14
  28. package/js/hosts/link.mjs +75 -26
  29. package/js/hosts/memory-files.mjs +13 -4
  30. package/js/hosts/native.mjs +106 -48
  31. package/js/hosts/opencode.mjs +44 -36
  32. package/js/hosts/shim.mjs +4 -2
  33. package/js/inspect/diff.mjs +5 -6
  34. package/js/inspect/excludes.mjs +10 -5
  35. package/js/inspect/status.mjs +18 -21
  36. package/js/inspect/validate.mjs +2 -2
  37. package/js/lib/fs.mjs +12 -11
  38. package/js/lib/json.mjs +4 -4
  39. package/js/lib/paths.mjs +27 -33
  40. package/js/lib/proc.mjs +3 -3
  41. package/js/lib/prompt.mjs +132 -0
  42. package/js/lib/text.mjs +18 -4
  43. package/js/lib/udiff.mjs +3 -2
  44. package/js/maintain/memory.mjs +5 -6
  45. package/js/maintain/migrate.mjs +4 -3
  46. package/js/maintain/setup.mjs +9 -131
  47. package/js/maintain/uninstall.mjs +10 -9
  48. package/js/maintain/update.mjs +115 -34
  49. package/js/ui/anim.mjs +8 -12
  50. package/js/ui/cli.mjs +29 -35
  51. package/js/ui/menu.mjs +7 -13
  52. package/js/ui/tui.mjs +14 -123
  53. package/js/web/actions.mjs +82 -386
  54. package/js/web/activity.mjs +1 -1
  55. package/js/web/api.mjs +33 -717
  56. package/js/web/catalog.mjs +527 -0
  57. package/js/web/daemon.mjs +21 -3
  58. package/js/web/handler.mjs +51 -27
  59. package/js/web/jobs.mjs +24 -2
  60. package/js/web/routes.mjs +61 -6
  61. package/js/web/server.mjs +4 -4
  62. package/js/web/state.mjs +138 -0
  63. package/js/web/user-files.mjs +339 -0
  64. package/package.json +1 -1
  65. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  66. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  67. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  68. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  69. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  70. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  71. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  72. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  73. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  74. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  75. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  76. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  77. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  78. package/web/dist/assets/api-CxgfYu18.js +1 -0
  79. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  80. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  83. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  84. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  85. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  86. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  87. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  88. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Self-update: `git pull` the install's own origin, validate, rebuild.
3
3
  *
4
- * The verb this whole port exists for. Every other subcommand can be Python for one more
5
- * release; `upgrade` is the one operation a "no Python needed" install cannot delegate,
6
- * because a Node install that shelled out to the Python harness to update itself
7
- * would have Python as a hard dependency of the only command a user MUST be able to run.
4
+ * The one operation a user MUST always be able to run: every other verb can be broken by a bad
5
+ * release, and this is the verb that fetches the fix.
8
6
  *
9
7
  * WHY THIS MODULE SPAWNS, AND WHY THE ARGUMENT IS NOT THE JOB RUNNER'S. `js/web/jobs.mjs`
10
8
  * re-executes this program for ISOLATION — a synchronous verb in the daemon's event loop
@@ -15,21 +13,23 @@
15
13
  * the modules this process loaded before the merge — it would validate the old source, pass,
16
14
  * and then render the old source into the bundle, reporting success. That is not a slower
17
15
  * version of the right answer, it is the wrong one. So `runDoctor`, `rebuildBundle` and
18
- * `rebuildInstalls` each start a fresh interpreter over the tree as it now is, exactly as the
19
- * reference does, and `_ALLOWED_SPAWNS` in `tests/test_hook_cli_parity.py` carries the argv.
16
+ * `rebuildInstalls` each start a fresh interpreter over the tree as it now is, and
17
+ * `ALLOWED_SPAWNS` in `tests/unit/hook_cli.test.mjs` carries the argv. The same reason is why
18
+ * the two modules this file reaches lazily (`js/build/generate.mjs`, `js/web/daemon.mjs`) are
19
+ * loaded BEFORE the pull and not at their call sites: an `import()` after the merge would mix
20
+ * pulled files with the ones already cached, and a renamed export kills the upgrade mid-way.
20
21
  *
21
22
  * `git` is the fourth spawn and the plainest: there is no git library in the Node stdlib,
22
23
  * and the port will not vendor one. Every git call goes through the `gitRun` seam —
23
24
  * which-guarded, credential-redacted, and it never throws.
24
25
  *
25
- * WHAT THE GATE CANNOT SEE, stated rather than implied. `upgrade/*` in
26
- * `tests/harness_golden.py` runs each arm against a private clone of a bare origin the
27
- * fixture builds, so no cell can reach a network — which is a safety property and a blind
26
+ * WHAT THE GATE CANNOT SEE, stated rather than implied. `tests/unit/update_git.test.mjs` and
27
+ * `tests/unit/update_flow.test.mjs` run each arm against a private clone of a bare origin the
28
+ * fixture builds, so no test can reach a network — which is a safety property and a blind
28
29
  * spot at once: `git fetch --progress` over a local path prints NOTHING, so
29
30
  * `fetchStreaming`'s reader, its phase filter and its 15s heartbeat are reachable in every
30
- * cell and varied in none. The phase filter is gated as a corpus in
31
- * `tests/test_pure_function_parity.py`; the heartbeat and the hard deadline are gated by
32
- * neither and are declared in the P8a handoff.
31
+ * run and varied in none. The phase filter is gated over a recorded transcript in
32
+ * `tests/unit/update_git.test.mjs`; the heartbeat and the hard deadline are gated by nothing.
33
33
  *
34
34
  * ALSO HERE, SINCE P8b: `syncSelf`/`cmdSyncSelf` and the bootstrap step runner
35
35
  * (`cmdBootstrap`, `bootstrapPlain`) at the bottom of this file. Still NOT here: `main`, the
@@ -47,10 +47,9 @@ import { ROOT } from '../build/source.mjs';
47
47
  import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
48
48
  import { opencodeConfigDir } from '../hosts/hosts.mjs';
49
49
  import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
- import { toPlatformPath, which } from '../lib/paths.mjs';
50
+ import { comparePaths, toPlatformPath, which } from '../lib/paths.mjs';
51
51
  import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
52
  import { NO_WINDOW } from '../lib/proc.mjs';
53
- import { bounceDaemonIfRunning } from '../web/daemon.mjs';
54
53
 
55
54
  /**
56
55
  * `_CREDS_RE` / `_redact_url_creds` — strip a `user[:token]@` userinfo out of any URL in
@@ -248,8 +247,14 @@ export function countOccurrences(s) {
248
247
  export function killTree(child) {
249
248
  try {
250
249
  if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL');
251
- else spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)],
252
- { encoding: 'utf8', timeout: 15000, ...NO_WINDOW });
250
+ else {
251
+ // `which`, never a bare name (a bare name resolves against the cwd first on Windows);
252
+ // no taskkill on PATH falls through to the `child.kill()` below.
253
+ const taskkill = which('taskkill');
254
+ if (!taskkill) throw new Error('taskkill not on PATH');
255
+ spawnSync(taskkill, ['/F', '/T', '/PID', String(child.pid)],
256
+ { encoding: 'utf8', timeout: 15000, ...NO_WINDOW });
257
+ }
253
258
  } catch {
254
259
  try { child.kill(); } catch { /* already gone */ }
255
260
  }
@@ -262,7 +267,7 @@ export function killTree(child) {
262
267
  * times and universal-newline decoding turns every `\r` repaint into its own line, so
263
268
  * without this a 30-second fetch writes a thousand lines into the install log. No cell can
264
269
  * reach it — a local-path fetch prints nothing at all — so
265
- * `tests/test_pure_function_parity.py` runs it over a recorded fetch transcript instead.
270
+ * `tests/unit/update_git.test.mjs` runs it over a recorded fetch transcript instead.
266
271
  *
267
272
  * Returns `[linesToLog, lastPhase]`, splitting the reference's loop from its side effects.
268
273
  */
@@ -366,13 +371,19 @@ export async function fetchStreaming(log = null) {
366
371
 
367
372
  /**
368
373
  * `_measure_upstream(log)` — Phase B: fetch (streamed), then classify. Returns
369
- * `[code, behind, err]` with `code` ∈ {ready, fetch_failed, unrelated, diverged, uptodate}.
374
+ * `[code, behind, err]` with `code` ∈ {ready, fetch_failed, no_upstream, unrelated, diverged,
375
+ * uptodate}.
376
+ *
377
+ * `no_upstream` HERE, AFTER PREFLIGHT SAID THERE WAS ONE: the fetch itself can take it away.
378
+ * An upstream branch deleted on the remote, fetched with `fetch.prune`, leaves `@{u}` naming
379
+ * nothing — both counts then fail, and read as two zeros they said "already up to date".
370
380
  */
371
381
  export async function measureUpstream(log = null) {
372
382
  const [rc, err] = await fetchStreaming(log);
373
383
  if (rc !== 0) return ['fetch_failed', 0, err];
374
- const [, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
375
- const [, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
384
+ const [arc, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
385
+ const [brc, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
386
+ if (arc !== 0 || brc !== 0) return ['no_upstream', 0, ''];
376
387
  const ahead = countOccurrences(aheadRaw);
377
388
  const behind = countOccurrences(behindRaw);
378
389
  if (ahead > 0) {
@@ -419,16 +430,31 @@ export function runDoctor(cand) {
419
430
  return [r.status === 0, universal(r.stdout) + universal(r.stderr)];
420
431
  }
421
432
 
422
- /** `_pull_and_validate(log)` — fast-forward to `@{u}`, then doctor-gate with exact rollback. */
433
+ /**
434
+ * `_pull_and_validate(log)` — fast-forward to `@{u}`, then doctor-gate with exact rollback.
435
+ *
436
+ * `collision` ONLY WHEN GIT SAYS SO. A fast-forward can fail for other reasons — a lock file, a
437
+ * permission, a merge refused outright — and calling every one an untracked-file collision
438
+ * sent the user hunting for a file that is not there, with an info exit (3) over an error.
439
+ *
440
+ * THE ROLLBACK IS `reset --keep`, NEVER `--hard`. The doctor window lasts up to 300 s, and an
441
+ * edit made in the checkout meanwhile is a tracked edit `--hard` would destroy without a word.
442
+ * `--keep` refuses instead when such an edit touches a file the rollback must change; that
443
+ * refusal is reported as itself — the user is left on the pulled commit, with the command to
444
+ * finish the rollback — rather than as a rollback that did not happen.
445
+ */
423
446
  export function pullAndValidate(log) {
424
447
  const [rc0, old] = gitRun(['rev-parse', 'HEAD']);
425
448
  if (rc0 !== 0 || !old) return [false, 'not_git', 'could not read HEAD'];
426
449
  log('[geneseed] fast-forwarding to upstream ...');
427
450
  const [rc1, , err] = gitRun(['merge', '--ff-only', '@{u}'], { timeout: 60 });
428
451
  if (rc1 !== 0) {
429
- return [false, 'collision',
430
- 'Update blocked — a new upstream file collides with a local untracked '
431
- + 'file. Move or remove it, then update.\n' + err];
452
+ if (/would be overwritten/.test(err)) {
453
+ return [false, 'collision',
454
+ 'Update blocked — a new upstream file collides with a local untracked '
455
+ + 'file. Move or remove it, then update.\n' + err];
456
+ }
457
+ return [false, 'merge_failed', `Update failed — could not fast-forward to upstream.\n${err}`];
432
458
  }
433
459
  const [rc2, pulled] = gitRun(['log', '--oneline', '--no-decorate', '-20', `${old}..HEAD`]);
434
460
  if (rc2 === 0 && pulled) {
@@ -439,8 +465,15 @@ export function pullAndValidate(log) {
439
465
  const [passed, output] = runDoctor(ROOT);
440
466
  log(output.replace(/\n+$/, ''));
441
467
  if (!passed) {
442
- gitRun(['reset', '--hard', old], { timeout: 60 });
468
+ const [rc3, , rerr] = gitRun(['reset', '--keep', old], { timeout: 60 });
443
469
  for (const line of DOCTOR_LEGEND) log(line);
470
+ if (rc3 !== 0) {
471
+ return [false, 'doctor_fail',
472
+ 'the pulled source FAILS validation, and the rollback was REFUSED — a file it must '
473
+ + 'restore was edited while the doctor ran, and those edits were kept. You are still '
474
+ + `on the pulled commit; save your edits, then roll back with: git reset --keep ${old}\n`
475
+ + rerr];
476
+ }
444
477
  return [false, 'doctor_fail',
445
478
  'the pulled source FAILS validation — rolled back to the previous commit. '
446
479
  + 'Fix the problems listed above.'];
@@ -505,8 +538,9 @@ export function markerTheme(cfg, out) {
505
538
  }
506
539
 
507
540
  /**
508
- * `_config_theme(here)` — the theme the LOCAL `harness.config.json` asks for, captured
509
- * before SYNC overwrites it with upstream's. Fallback only; a bundle marker still wins.
541
+ * `_config_theme(here)` — the theme the checkout's `harness.config.json` asks for. Fallback
542
+ * only; a bundle marker still wins. Read before the pull, but nothing local is at stake: the
543
+ * file is tracked and `preflight` refuses a dirty tree, so the value read is the committed one.
510
544
  */
511
545
  export function configTheme(here) {
512
546
  try {
@@ -518,11 +552,16 @@ export function configTheme(here) {
518
552
  /**
519
553
  * `_migrate_stray_bundle` — move host state (context.json, memory/) from an OLD in-folder
520
554
  * bundle to the canonical sibling `out` BEFORE rebuilding, then drop the stray.
555
+ *
556
+ * "IS `out` THE STRAY ITSELF?" IS A PATH COMPARISON, NOT A STRING ONE. `GENESEED_OUT` is typed
557
+ * by a user, so on Windows `c:\x\Geneseed\harness` is the same directory as the stray and was a
558
+ * different string: the rescue was skipped as "nothing to move into", and the `rmSync` at the
559
+ * bottom then deleted the LIVE bundle — memory/ and context.json with it.
521
560
  */
522
561
  export function migrateStrayBundle(here, out, log) {
523
562
  const stray = path.join(String(here), 'Harness');
524
563
  const outStr = String(out);
525
- if (outStr === stray) return;
564
+ if (comparePaths(path.resolve(outStr), path.resolve(stray)) === 0) return;
526
565
  try { if (!statSync(stray).isDirectory()) return; } catch { return; }
527
566
  mkdirSync(outStr, { recursive: true });
528
567
  const ctx = path.join(stray, 'context.json');
@@ -559,15 +598,40 @@ async function drainStdout() {
559
598
  await new Promise((resolve) => { process.stdout.write('', resolve); });
560
599
  }
561
600
 
601
+ /**
602
+ * The flags that re-emit the install at `dir` on its OWN axes — footprint, posture, mode,
603
+ * trust, doctrine packs, excluded rules — or `[]` when no build ever wrote a marker there.
604
+ *
605
+ * WITHOUT THEM THE REBUILD RESETS THE INSTALL. The generator's defaults are not the install's:
606
+ * a missing `--footprint` is `lean`, and posture, mode, packs and excluded rules come from
607
+ * `harness.config.json`. So a `full`, mentor, narrowed or rule-excluded install was re-emitted
608
+ * as the config default — markers included — and `rebuild-all` right after reads those
609
+ * rewritten markers back through `installProfile`, so the loss stuck. `installProfile` is the
610
+ * one reader of an install's axes; this takes its answer rather than growing a second one.
611
+ *
612
+ * Only the TAIL of the argv is kept: theme and emit are `upgrade`'s to decide (a theme argument
613
+ * may override the marker), and `setupBuildArgs` opens with exactly those four tokens when it
614
+ * is given no out and no root.
615
+ */
616
+ export async function installAxes(dir, { installProfile, setupBuildArgs }) {
617
+ if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
618
+ const p = installProfile('opencode', 'global', String(dir));
619
+ return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
620
+ undefined, p.excludeRules, p.trust).slice(4);
621
+ }
622
+
562
623
  /**
563
624
  * `_rebuild_bundle` — render the bundle from the (already-updated) source and bounce a
564
- * running web daemon. Returns the build subprocess returncode (0 = ok).
625
+ * running web daemon. Returns the build subprocess returncode (0 = ok). `axes` is
626
+ * `installAxes` of the target, read before the pull; `bounce` is the daemon module's
627
+ * `bounceDaemonIfRunning`, loaded before the pull for the reason the file header gives.
565
628
  */
566
- export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
629
+ export async function rebuildBundle(here, out, theme, emit, rootDir, log, axes, bounce) {
567
630
  const buildArgs = ['--out', String(out)];
568
631
  if (theme) buildArgs.push('--theme', theme);
569
632
  if (emit === 'opencode') buildArgs.push('--emit', 'opencode', '--root', String(rootDir));
570
633
  else if (emit === 'opencode-global') buildArgs.push('--emit', 'opencode-global');
634
+ buildArgs.push(...axes);
571
635
  log(`[geneseed] rebuilding bundle -> ${out} (theme: ${theme || 'config default'}, emit: ${emit}) ...`);
572
636
  await drainStdout();
573
637
  // THE GENERATOR, RE-EXECUTED. `js/build/generate.mjs`'s `cmdBuild` calls `driverMain` in-process
@@ -580,7 +644,7 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
580
644
  // If a web daemon is running, bounce it so the new source and the freshly rebuilt web/dist
581
645
  // take effect — otherwise the open PWA keeps hitting the old code. The web-job exception
582
646
  // (`upgrade/the-web-job-marker-...` gates it) lives with the helper.
583
- await bounceDaemonIfRunning(theme, log);
647
+ await bounce(theme, log);
584
648
  return 0;
585
649
  }
586
650
 
@@ -636,6 +700,12 @@ export async function upgrade(ref = null, themeArg = null) {
636
700
  return p.kind === 'info' ? 3 : 1;
637
701
  }
638
702
 
703
+ // Loaded and read BEFORE the pull — see the file header for why the imports cannot wait, and
704
+ // `installAxes` for why the target's own axes must be carried into the rebuild.
705
+ const [generate, { bounceDaemonIfRunning }] = await Promise.all([
706
+ import('../build/generate.mjs'), import('../web/daemon.mjs')]);
707
+ const axes = await installAxes(emit === 'opencode-global' ? cfg : out, generate);
708
+
639
709
  log(`[geneseed] fetching from origin (git: ${which('git') || 'git'}, `
640
710
  + `timeout: ${fetchTimeout()}s) ...`);
641
711
  const [code, behind, err] = await measureUpstream(log);
@@ -647,6 +717,10 @@ export async function upgrade(ref = null, themeArg = null) {
647
717
  + 'Kerberos credentials). Restart it from that terminal: `geneseed web restart`.');
648
718
  return 1;
649
719
  }
720
+ if (code === 'no_upstream') {
721
+ log(`[geneseed] ${PRE_MSG.no_upstream[1]}`);
722
+ return 3;
723
+ }
650
724
  if (code === 'unrelated') {
651
725
  log('[geneseed] Upstream history was rewritten; back up local work, then re-clone '
652
726
  + 'or `git reset --hard @{u}`.');
@@ -676,7 +750,8 @@ export async function upgrade(ref = null, themeArg = null) {
676
750
  } catch (e) {
677
751
  log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
678
752
  }
679
- let rc = await rebuildBundle(here, out, theme, emit, rootDir, log);
753
+ let rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes,
754
+ bounceDaemonIfRunning);
680
755
  if (rc !== 0) {
681
756
  log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
682
757
  return 1;
@@ -886,13 +961,19 @@ function reexec(argv) {
886
961
  * `flushExportNotes()` runs BEFORE the handoff, deliberately: the re-exec replaces this process
887
962
  * and the update step's own improvements notice has scrolled past (or, under the reference's
888
963
  * curses arm, died with the alternate screen).
964
+ *
965
+ * A FAILED UPDATE STILL EXITS 1 AFTER THE HANDOFF. `setup` still runs — the user asked for it,
966
+ * and it can repair what the update could not — but its exit code used to become bootstrap's,
967
+ * so a script saw 0 over an update that never happened. `handoff` is the seam a test needs:
968
+ * the real `setup` exits 1 off a terminal, which would make the two answers indistinguishable.
889
969
  */
890
- export async function cmdBootstrap(args) {
970
+ export async function cmdBootstrap(args, { handoff = reexec } = {}) {
891
971
  const failed = await bootstrapPlain();
892
972
  flushExportNotes();
893
973
  if (!args.noSetup) {
894
- return reexec([process.execPath,
974
+ const rc = handoff([process.execPath,
895
975
  path.join(String(ROOT), 'bin', 'geneseed-cli.mjs'), 'setup']);
976
+ return failed ? 1 : rc;
896
977
  }
897
978
  // Scripted `bootstrap --no-setup` must not exit 0 over a failed update.
898
979
  return failed ? 1 : 0;
package/js/ui/anim.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * P7c, and the one leftover of that phase that is NOT the declared curses panel. P5i ported
5
5
  * `_setup_lines` and left this out with a note — "belongs to P7 with the rest of the terminal
6
- * layer" — and the rest of the terminal layer turned out to be the panel, which stays
7
- * declared (`tests/test_tui_boundary.py` says why, and asserts it). This module is the part
6
+ * layer" — and the rest of the terminal layer turned out to be the panel, which was rejected
7
+ * (`tests/unit/no_panel.test.mjs` asserts it stays gone). This module is the part
8
8
  * that has nothing to do with curses: it draws on the ORDINARY terminal, with cursor-up
9
9
  * escapes and a sleep, and its only caller is a function that already crossed.
10
10
  *
@@ -23,16 +23,12 @@
23
23
  * needed — which is fortunate, because it is a `child_process` spawn and this port is barred
24
24
  * from those. The consequence is that the reference's `except Exception: return False` arm
25
25
  * around it has no counterpart here. That arm needs `cmd.exe` to be unlaunchable on a machine
26
- * that has already reached an interactive wizard, and `anim_ok` is compared over ten
27
- * environments in `tests/test_pure_function_parity.py` — none of which can produce it on
28
- * either side.
26
+ * that has already reached an interactive wizard.
29
27
  *
30
- * WHAT GATES IT. `tests/__snapshots__/primitives/{posix,win32}.json`, replayed by
31
- * `tests/snapshot/pure_snapshot.test.mjs`: `art_for` over every key plus the prototype-chain
32
- * names, `_place`/`_tile`/`_height` over their arithmetic, `anim_ok` over its whole decision
33
- * table, and `play_line` itself over 24 environments — captured on the reference side through
34
- * a real `TextIOWrapper`, so the CRLF translation is inside the recording rather than outside
35
- * it. There is no recorder left, so a red run means these functions moved, never the corpus.
28
+ * WHAT GATES IT. Little, now: the recorded snapshot corpus that replayed `art_for`, `_place`,
29
+ * `_tile`, `_height`, `anim_ok` and `play_line` is gone with the reference.
30
+ * `tests/unit/wizard.test.mjs` asserts the wizard still plays the animation, and in the right
31
+ * place; `tests/unit/no_panel.test.mjs` that this file is the one allowed its cursor escapes.
36
32
  */
37
33
  import { printOut } from '../lib/fs.mjs';
38
34
  import { parseIntStrict, codePointLength, padEndToWidth } from '../lib/text.mjs';
@@ -235,7 +231,7 @@ export function place(row, x, width) {
235
231
  * Python turns into an empty string and `String.repeat` turns into a RangeError, hence the
236
232
  * clamp. And `phase % len(tile)` is non-negative in Python for a positive divisor while
237
233
  * JavaScript keeps the sign of the dividend, so a negative phase indexes off the front of the
238
- * string instead of wrapping to the end. `_spin` had the same trap in P7b.
234
+ * string instead of wrapping to the end.
239
235
  */
240
236
  export function tile(tileStr, width, phase = 0) {
241
237
  if (!tileStr) return '';
package/js/ui/cli.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  * copy in it.
11
11
  *
12
12
  * P10c's answer was to make the metadata DATA — generated into `cli.json` at the repo root by
13
- * `tests/gen_cli_reference.py` and read by both sides. P2 finishes the thought: the table is
13
+ * a since-deleted Python script and read by both sides. P2 finishes the thought: the table is
14
14
  * no longer GENERATED from anything, it is the OWNED document, and it lives here on a product
15
15
  * path because that is where a document npm ships belongs. `package.json`'s `files[]` already
16
16
  * carries `js/`, so it needs no row of its own, and nothing has to import out of `tests/`.
@@ -31,13 +31,9 @@
31
31
  * a file filtered for the page breaks the CLI, and a file in `cliSpec`'s shape cannot render
32
32
  * the docs. It stays exactly what the argparse walk produced.
33
33
  *
34
- * WHAT USED TO GUARD IT, AND WHAT DOES NOW. While the parser existed, `cliReferenceProblems()`
35
- * hashed `rituals/harness.py` and doctor reported drift on both binaries. That digest was over
36
- * a file this migration deletes, so it is gone; `tests/test_cli_reference.py` holds the
37
- * argparse-vs-table comparison, and it can only ever cover the rows argparse HAS: `catalog`,
38
- * `mcp` and `memory` never had a subparser and are outside it by construction. The successor
39
- * that checks the WHOLE table, and outlives the parser, is `tests/unit/cli_table.test.mjs`;
40
- * after that the table is simply the source of truth and is edited directly.
34
+ * WHAT GUARDS IT. The Python parser it was walked from is gone, and with it every comparison
35
+ * against argparse; the table is now simply the source of truth and is edited directly.
36
+ * `tests/unit/cli_table.test.mjs` checks the WHOLE table.
41
37
  *
42
38
  * THE COST, NAMED: this entry cannot parse anything without this file. `js/build/source.mjs`
43
39
  * already reads `harness.config.json` at import for every render, so a tracked data document
@@ -48,6 +44,7 @@ import { readFileSync } from 'node:fs';
48
44
  import path from 'node:path';
49
45
 
50
46
  import { ROOT } from '../build/source.mjs';
47
+ import { printOut } from '../lib/fs.mjs';
51
48
  import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
49
 
53
50
  const CLI_JSON = path.join(ROOT, 'js', 'cli-table.json');
@@ -156,8 +153,8 @@ function actionInvocation(a, positional) {
156
153
  // hyphenated word into two chunks, so it can break a line inside `back-compat` where a
157
154
  // space-only wrap cannot. Measured before deciding: every help string then in the table disagreed
158
155
  // with a greedy wrap at some width, the widest at 175 — so "the branch is dead" was simply
159
- // false, and `test_the_ports_line_breaker_is_textwrap_at_every_width` said so on the run that
160
- // first asserted it. The rules are transcribed instead.
156
+ // false, and the former Python suite's every-width comparison against `textwrap` said so on
157
+ // the run that first asserted it. The rules are transcribed instead.
161
158
  //
162
159
  // Python's classes, spelled for a `u`-flagged JavaScript regex: `\w` is letters, numbers and
163
160
  // underscore; `[^\d\W]` is that minus the DECIMAL digits, which leaves the letter-ish half
@@ -181,9 +178,8 @@ const WORDSEP = new RegExp(
181
178
  * `drop_whitespace`, `break_long_words` and `break_on_hyphens` all on, and the text already
182
179
  * whitespace-collapsed and stripped by the caller.
183
180
  *
184
- * ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer.
185
- * `rituals/_harness_tui*.py` calls `textwrap.wrap` in seven places and is declared unported;
186
- * move this when that changes, not before.
181
+ * ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer;
182
+ * move it when a second one appears, not before.
187
183
  */
188
184
  export function wrapText(text, width) {
189
185
  const chunks = text.split(WORDSEP).filter(Boolean).reverse();
@@ -310,7 +306,7 @@ function formatAction(a, positional, helpPosition, width) {
310
306
  * `parser.format_help()` for one subcommand, byte for byte with argparse.
311
307
  *
312
308
  * WHY THE PORT RENDERS THIS AT ALL. Task 10 compared the two implementations' `--help` for the
313
- * first time — `tests/harness_golden.py` has zero `--help` cells — and found the port answering
309
+ * first time — the former Python CLI matrix had zero `--help` cells — and found the port answering
314
310
  * `geneseed: error: unrecognized arguments: --help`, exit 2, on all 26 verbs. That is not the
315
311
  * declared wording divergence `parse`'s docblock reserves for argparse's ERROR text: it is the
316
312
  * absence of the surface, on the binary that survives the reference's deletion.
@@ -318,19 +314,19 @@ function formatAction(a, positional, helpPosition, width) {
318
314
  * `prog` IS AN ARGUMENT AND THAT IS THE ONE DELIBERATE DIVERGENCE. The reference names itself
319
315
  * `harness`, after the Python file this migration deletes; a user who typed `geneseed status
320
316
  * --help` is shown `usage: geneseed status`, which is also how every error this entry prints
321
- * already spells itself (`die`). `tests/__snapshots__/help/` freezes the reference's answers
322
- * and `tests/snapshot/cli_help.test.mjs` replays them through here with `prog` set to the reference's
323
- * own — so the layout is gated byte for byte and the rename is the only thing that moves.
317
+ * already spells itself (`die`). The former Python suite replayed the reference's recorded
318
+ * answers through here with `prog` set to the reference's own, so the layout was gated byte for
319
+ * byte and the rename was the only thing that moved. That corpus is gone; the layout is now
320
+ * pinned by the written-out `formatHelp` rows in `tests/unit/text_layout.test.mjs`.
324
321
  *
325
- * NOT EVERY VERB HAS A RECORDED ANSWER, AND THREE NEVER CAN. `catalog`, `mcp` and `memory` had
326
- * no subparser for the recorder to render from, so the corpus covers the verbs argparse
327
- * answered to and those three are held ABSOLUTELY instead — `tests/snapshot/cli_help.test.mjs` names
328
- * both populations (`RECORDED`, `NATIVE`) and fails a verb that falls into neither, which is
329
- * what keeps "no recorded text" from meaning "no gate".
322
+ * NOT EVERY VERB HAD A RECORDED ANSWER, AND THREE NEVER COULD. `catalog`, `mcp` and `memory` had
323
+ * no subparser for the recorder to render from, so they were held ABSOLUTELY instead — and
324
+ * `tests/unit/hook_cli.test.mjs`'s `NATIVE` list still names that population and fails a verb
325
+ * that falls into neither, which is what keeps "no recorded text" from meaning "no gate".
330
326
  *
331
327
  * The alias rides on the same argument: argparse gives `update` the parser object it built for
332
- * `upgrade`, so `harness.py update --help` prints `usage: harness upgrade`. The recorded prog
333
- * map reproduces that; the shipped call passes `geneseed update`, which is what the user typed.
328
+ * `upgrade`, so `harness.py update --help` printed `usage: harness upgrade`. The recorded prog
329
+ * map reproduced that; the shipped call passes `geneseed update`, which is what the user typed.
334
330
  *
335
331
  * `width` is argparse's `shutil.get_terminal_size().columns - 2`, passed in rather than read,
336
332
  * because a recorded help text whose wrap column came from the recorder's terminal is a corpus
@@ -407,12 +403,13 @@ export function formatHelp(cmd, prog, width) {
407
403
  *
408
404
  * `parseIntStrict`, NOT `Number.parseInt`, and the tree already owned it. `get_terminal_size` spells
409
405
  * this `int(os.environ['COLUMNS'])` inside `except (KeyError, ValueError)`, and `int` is not
410
- * `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values
411
- * (`test_the_ports_help_width_is_shutil_get_terminal_size`): `COLUMNS=1_0` is 10 to Python and
406
+ * `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values in the former
407
+ * Python suite (`tests/unit/cli_table.test.mjs`'s 'the help width is the terminal width less
408
+ * two, and COLUMNS wins' keeps a written-out part of it): `COLUMNS=1_0` is 10 to Python and
412
409
  * 1 to `parseInt`, `COLUMNS=٣` is 3 and NaN, `COLUMNS=' 80 '` is 80 and NaN. `COLUMNS=80abc`
413
410
  * is a ValueError to Python and 80 to `parseInt` — the same answer down a PIPE, where the
414
411
  * fallback is 80 either way, and a different one on any terminal that is not 80 wide, which is
415
- * why the corpus is the gate rather than a cell. A non-positive value falls through on both
412
+ * why a unit test is the gate rather than a cell. A non-positive value falls through on both
416
413
  * sides, which is Python's `if columns <= 0` and was already right here.
417
414
  *
418
415
  * `stripWhitespace` because THIS is the caller `parseIntStrict`'s docblock did not have: its other two
@@ -443,8 +440,7 @@ export function printVerbList(prog, verbs, extra = {}) {
443
440
  `A bare \`${prog}\` runs \`home\`. \`${prog} <command> --help\` describes one command.`, '',
444
441
  'commands:'];
445
442
  for (const n of names) lines.push(` ${n.padEnd(pad)}${helpOf.get(n) || ''}`.trimEnd());
446
- const text = `${lines.join('\n')}\n`;
447
- process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
443
+ printOut(`${lines.join('\n')}\n`);
448
444
  return 0;
449
445
  }
450
446
 
@@ -453,18 +449,16 @@ export function printVerbList(prog, verbs, extra = {}) {
453
449
  * describe — the caller already owns that refusal and its message.
454
450
  *
455
451
  * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
456
- * `bin/geneseed-cli.mjs` the rest of the table, and
457
- * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
452
+ * `bin/geneseed-cli.mjs` the rest of the table, and `tests/unit/hook_cli.test.mjs`'s
453
+ * "the two entry points carry disjoint verb sets" keeps them from learning each other's
458
454
  * tables — so the help each prints has to come from here rather than from a copy in each.
459
455
  *
460
- * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
461
- * translates, and this text lands in the same terminals and the same pipes as that one.
456
+ * CRLF ON WINDOWS through `printOut`, the one funnel every CLI print takes.
462
457
  */
463
458
  export function printHelp(prog, verb) {
464
459
  const cmd = cliCommand(verb);
465
460
  if (cmd === null) return null;
466
- const text = formatHelp(cmd, `${prog} ${verb}`, helpWidth());
467
- process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
461
+ printOut(formatHelp(cmd, `${prog} ${verb}`, helpWidth()));
468
462
  return 0;
469
463
  }
470
464
 
package/js/ui/menu.mjs CHANGED
@@ -22,8 +22,8 @@
22
22
  * still the right shape; only its explanation moved. The alternative that was considered and
23
23
  * refused: building a numbered line-menu out of `js/maintain/setup.mjs`'s `askChoice`. That is not a
24
24
  * port of `_main_menu` — it is a THIRD user interface, differing from the reference on every
25
- * screen, unreachable from every cell (no cell has a TTY), and it would have to be deleted
26
- * the day P7c lands the real panel. The fallback is the reference's own contract for "no
25
+ * screen, unreachable from every cell (no cell has a TTY), for a panel that was rejected
26
+ * outright rather than deferred. The fallback is the reference's own contract for "no
27
27
  * full-screen menu available here"; a new UI would not be.
28
28
  *
29
29
  * `cmd_home` is the default for a bare `geneseed`, and it crosses WHOLE. Its two arms are
@@ -32,10 +32,9 @@
32
32
  *
33
33
  * `_web_first_ok`'s FIVE REFUSALS ARE THE PAYLOAD, and only two of them are reachable from a
34
34
  * cell: a cell's stdin is a pipe, so `isTTY` is false and everything past it is dead. The
35
- * other three (SSH, the Linux display server, the browser) are gated by the corpus in
36
- * `tests/test_pure_function_parity.py`, which drives this function with `isTTY` FAKED — the
37
- * one input no cell can vary. See that file's P7a section for what it reaches and what it
38
- * declares.
35
+ * other three (SSH, the Linux display server, the browser) are gated by
36
+ * `tests/unit/web_first.test.mjs`, which drives this function through
37
+ * `tests/fixtures/pure_probe.mjs` with `isTTY` FAKED — the one input no cell can vary.
39
38
  */
40
39
  import { printOut, printErr } from '../lib/fs.mjs';
41
40
  import { which } from '../lib/paths.mjs';
@@ -93,13 +92,8 @@ export function cmdMenu() {
93
92
  // The reference's own `except Exception` arm, with a reason that is permanent here rather
94
93
  // than machine-dependent. stderr, and CRLF on Windows, because `sys.stderr.write` does.
95
94
  //
96
- // P7b CORRECTED THE REASON RATHER THAN THE ARM. This module's header said the fallback was
97
- // reached because "`import curses` fails on stock Windows Python", and P7b measured that
98
- // to be false where it matters: `rituals/_harness_core.py` installs `rituals/_winterm.py`
99
- // as `sys.modules["curses"]` when the stdlib module is missing, so the reference opens its
100
- // panel on this machine and `tests/test_tui_boundary.py` drives it doing so. What is
101
- // missing is on THIS side — there is no window implementation in the port — and that is
102
- // what the message now says.
95
+ // The reason is on THIS side: there is no window implementation, by decision, and that is
96
+ // what the message says (`tests/unit/no_panel.test.mjs` keeps it so).
103
97
  // NOTHING TO POINT AT ANY MORE. The message used to end by sending the reader at the
104
98
  // Python panel, and P2 took the pointer out rather than re-aiming it: the panel it named
105
99
  // is the Python this migration deletes, and a refusal that sends a user to a file which