geneseed 3.1.1 → 3.1.2

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 (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
@@ -35,11 +35,11 @@
35
35
  * `move` where it has `unlink`, plus an inverse; splitting them would have duplicated the
36
36
  * owned-file walk and the ancestor prune a fourth time.
37
37
  *
38
- * NO SPAWN. `bin/geneseed-cli.mjs`'s `child_process` allow-list names `js/doctor.mjs` and one
39
- * argv, and this module does not extend it: every operation here is a filesystem call. That
40
- * matters more than usual for this verb — shelling the uninstall out to a Python interpreter
41
- * would be byte-identical in every cell of the matrix, and the allow-list plus
42
- * `test_uninstall_removes_an_install_with_no_python_on_path` are what refute it.
38
+ * NO SPAWN. `ALLOWED_SPAWNS` in `tests/unit/hook_cli.test.mjs` is the table of every module
39
+ * allowed to reach `child_process` and the argv each may use; this module has no row and
40
+ * does not want one, because every operation here is a filesystem call. That matters more
41
+ * than usual for this verb: shelling the uninstall out to an interpreter would have been
42
+ * byte-identical in every cell of the matrix, so only the allow-list refutes it.
43
43
  */
44
44
  import {
45
45
  existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync,
@@ -47,26 +47,25 @@ import {
47
47
  } from 'node:fs';
48
48
  import path from 'node:path';
49
49
 
50
- import { hookRunnerEntry } from '../bin/geneseed.mjs';
50
+ import { hookRunnerEntry } from '../../bin/build-driver.mjs';
51
51
  import { confirm } from './setup.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
54
  installState,
55
55
  registeredTargets, readMaybe, DISABLED_STASH,
56
- } from './installs.mjs';
56
+ } from '../hosts/installs.mjs';
57
57
  import {
58
- GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, pyResolve,
59
- } from './hosts.mjs';
60
- import { mcpCommented } from './mcp.mjs';
58
+ GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
59
+ } from '../hosts/hosts.mjs';
60
+ import { mcpCommented } from '../hosts/mcp.mjs';
61
61
  import {
62
62
  managedBlockRead, managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
63
63
  opencodeTarget, readJsonc, settingsIntegrityCheck, wireClaudeExcludes,
64
64
  unwireClaudeExcludes, unwireClaudeSettings,
65
- } from './settings.mjs';
66
- import {
67
- comparePaths, indexOfEq, jsonDumps, jsonDumpsIndent, pyEq, pyIsAbsolute, pyPrint,
68
- pyPrintErr, readText, within, writeText,
69
- } from './lib/fs.mjs';
65
+ } from '../hosts/settings.mjs';
66
+ import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
67
+ import { indexOfDeepEqual, jsonDumps, jsonDumpsIndent, deepEquals } from '../lib/json.mjs';
68
+ import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
70
69
 
71
70
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
72
71
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
@@ -173,7 +172,7 @@ function unlinkOwned(base, owned, label = '') {
173
172
  // `f"{rel} ({e})"`. `str(OSError)` and an `Error.message` word the same fault
174
173
  // differently, and no cell can reach this: it needs a file that exists, is a file, and
175
174
  // cannot be unlinked. Recorded rather than papered over — the same standing item
176
- // `js/excludes.mjs` carries for its `could not remove <stub>` branch.
175
+ // `js/inspect/excludes.mjs` carries for its `could not remove <stub>` branch.
177
176
  failed.push(`${label}${rel} (${e && e.message ? e.message : e})`);
178
177
  }
179
178
  }
@@ -182,12 +181,12 @@ function unlinkOwned(base, owned, label = '') {
182
181
 
183
182
  /** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
184
183
  function warnSurvivors(failed) {
185
- pyPrintErr('[uninstall] WARN: could not remove '
184
+ printErr('[uninstall] WARN: could not remove '
186
185
  + `${failed.length} owned file(s): ${failed.join(', ')}\n`);
187
186
  }
188
187
 
189
188
  function warnMarkersKept() {
190
- pyPrintErr('[uninstall] WARN: the manifest and markers were KEPT so '
189
+ printErr('[uninstall] WARN: the manifest and markers were KEPT so '
191
190
  + '`geneseed uninstall` can be retried once the file(s) are '
192
191
  + 'unlocked/removable.\n');
193
192
  }
@@ -216,7 +215,7 @@ export function unmergeOpencodeJson(p, entry) {
216
215
  const instr = cfg.instructions;
217
216
  if (!Array.isArray(instr) || !instr.includes(entry)) return false;
218
217
  if (path.extname(target) === '.jsonc' && hadComments) {
219
- pyPrint(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
218
+ printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
220
219
  + `this from its "instructions" by hand: ${jsonDumps(entry)}\n`);
221
220
  return false;
222
221
  }
@@ -265,7 +264,7 @@ function settingsFile(cfg, managed) {
265
264
  /** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
266
265
  function claudeMdPath(cfg, managed) {
267
266
  const rel = ((managed && managed.claude_md) || {}).rel || 'CLAUDE.md';
268
- return pyResolve(path.join(cfg, rel));
267
+ return resolvePath(path.join(cfg, rel));
269
268
  }
270
269
 
271
270
  /** A manifest's `managed` map, or `{}` — the Python's `isinstance(..., dict)` guard. */
@@ -361,7 +360,7 @@ function mcpLoadOpencode(p) {
361
360
  *
362
361
  * A global install wires the ABSOLUTE posix path; a project install wires the relative
363
362
  * `…/AGENT.md` the emit recorded, read back off the live config so a bundle sub-dir layout
364
- * round-trips. `pyIsAbsolute` and not `path.isAbsolute`: they disagree on Windows for a
363
+ * round-trips. `isAbsolutePath` and not `path.isAbsolute`: they disagree on Windows for a
365
364
  * ROOTLESS `/repo/AGENT.md`, which Python keeps and `path.isAbsolute` would have skipped.
366
365
  */
367
366
  export function installAgentEntry(root, kind) {
@@ -383,7 +382,7 @@ export function installAgentEntry(root, kind) {
383
382
  export function installAgentEntryOf(instr) {
384
383
  if (Array.isArray(instr)) {
385
384
  for (const e of instr) {
386
- if (typeof e === 'string' && !pyIsAbsolute(e) && path.basename(e) === 'AGENT.md') return e;
385
+ if (typeof e === 'string' && !isAbsolutePath(e) && path.basename(e) === 'AGENT.md') return e;
387
386
  }
388
387
  }
389
388
  return 'AGENT.md';
@@ -509,7 +508,7 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
509
508
  if (survivors.length || failed.length) {
510
509
  if (survivors.length && !failed.length) {
511
510
  // With `failed` set the reversal already warned twice — no third overlapping WARN.
512
- pyPrintErr('[uninstall] WARN: could not fully remove the install — still present: '
511
+ printErr('[uninstall] WARN: could not fully remove the install — still present: '
513
512
  + `${survivors.join(', ')}. The install marker was KEPT so you can retry `
514
513
  + '`geneseed uninstall` once the file(s) are unlocked/removable.\n');
515
514
  }
@@ -545,7 +544,7 @@ export function projectQualifies(root, host) {
545
544
  const cfg = path.join(root, spec.projectMarker);
546
545
  if (!isDir(cfg)) return false;
547
546
  try {
548
- if (pyResolve(cfg) === pyResolve(spec.configDir())) return false;
547
+ if (resolvePath(cfg) === resolvePath(spec.configDir())) return false;
549
548
  } catch { /* as the Python's bare `except Exception: pass` */ }
550
549
  if (isFile(path.join(cfg, GLOBAL_MANIFEST))) return true;
551
550
  const hs = emitHostScopeOf(root);
@@ -569,7 +568,7 @@ export function uninstallResolve(targetArg) {
569
568
  const globalHit = (p) => {
570
569
  for (const spec of HOSTS) {
571
570
  try {
572
- if (p === pyResolve(spec.configDir())) return [spec.host, 'global', p];
571
+ if (p === resolvePath(spec.configDir())) return [spec.host, 'global', p];
573
572
  } catch { continue; }
574
573
  }
575
574
  return null;
@@ -581,7 +580,7 @@ export function uninstallResolve(targetArg) {
581
580
  return null;
582
581
  };
583
582
  if (targetArg) {
584
- const p = pyResolve(expanduser(targetArg));
583
+ const p = resolvePath(expanduser(targetArg));
585
584
  const hit = globalHit(p);
586
585
  if (hit) return hit;
587
586
  for (const spec of HOSTS) {
@@ -595,7 +594,7 @@ export function uninstallResolve(targetArg) {
595
594
  // `.resolve()` so the cwd fallback matches every other branch and the registry, which
596
595
  // stores resolved paths — a short-form (8.3) cwd, as Windows CI hands back for %TEMP%,
597
596
  // would otherwise return a root that LOOKS different from the identical directory.
598
- const hit = projectHit(pyResolve(process.cwd()));
597
+ const hit = projectHit(resolvePath(process.cwd()));
599
598
  if (hit) return hit;
600
599
  return ['opencode', 'global', opencodeConfigDir()];
601
600
  }
@@ -609,11 +608,11 @@ export function uninstallResolve(targetArg) {
609
608
  * rediscovered from, and the just-removed root is excluded.
610
609
  */
611
610
  export function survivingProjectInstalls(removedRoot) {
612
- const rroot = pyResolve(removedRoot);
611
+ const rroot = resolvePath(removedRoot);
613
612
  const out = [];
614
613
  for (const [host, scope, root] of registeredTargets()) {
615
614
  if (scope !== 'project') continue;
616
- try { if (pyResolve(root) === rroot) continue; } catch { /* as the Python's except OSError */ }
615
+ try { if (resolvePath(root) === rroot) continue; } catch { /* as the Python's except OSError */ }
617
616
  out.push([host, scope, root]);
618
617
  }
619
618
  return out;
@@ -623,10 +622,10 @@ export function survivingProjectInstalls(removedRoot) {
623
622
  export function printSurvivingProjectInventory(removedRoot) {
624
623
  const survivors = survivingProjectInstalls(removedRoot);
625
624
  if (!survivors.length) return;
626
- pyPrint(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
625
+ printOut(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
627
626
  + 'does not affect them (each is self-contained):\n');
628
627
  for (const [host, scope, root] of survivors) {
629
- pyPrint(` - ${root} (${host}:${scope}) — remove with: `
628
+ printOut(` - ${root} (${host}:${scope}) — remove with: `
630
629
  + `geneseed uninstall --target "${root}" --yes\n`);
631
630
  }
632
631
  }
@@ -640,8 +639,8 @@ export function printSurvivingProjectInventory(removedRoot) {
640
639
  export function printOtherHostHits(root, removedHost) {
641
640
  for (const spec of HOSTS) {
642
641
  if (spec.host !== removedHost && projectQualifies(root, spec.host)) {
643
- pyPrint(`[uninstall] also found ${spec.host}:project here — run \`harness `
644
- + 'uninstall\` again to remove it.\n');
642
+ printOut(`[uninstall] also found ${spec.host}:project here — run \`harness `
643
+ + 'uninstall` again to remove it.\n');
645
644
  }
646
645
  }
647
646
  }
@@ -649,7 +648,7 @@ export function printOtherHostHits(root, removedHost) {
649
648
  /**
650
649
  * `_harness_setup._confirm`, and the only interactive read this verb makes.
651
650
  *
652
- * MOVED TO `js/setup.mjs` IN P5i, where the Python's own owner is: `_confirm` lives in
651
+ * MOVED TO `js/maintain/setup.mjs` IN P5i, where the Python's own owner is: `_confirm` lives in
653
652
  * `_harness_setup` beside the `_ask` it is built on, and `setup` is its second caller. The
654
653
  * body stayed here for one phase because a helper with one caller is not shared yet; the
655
654
  * rule this port uses is that the SECOND owner is what moves it, and a byte gate is what
@@ -674,8 +673,8 @@ export function cmdUninstall(args) {
674
673
  if (hit === null) {
675
674
  // `uninstallResolve(null)` never returns null — it falls back to the OpenCode global
676
675
  // default — so reaching here means `--target` was given and was not recognised.
677
- const targetDesc = pyResolve(expanduser(args.target));
678
- pyPrintErr(`[uninstall] no Geneseed install detected at ${targetDesc}.\n`
676
+ const targetDesc = resolvePath(expanduser(args.target));
677
+ printErr(`[uninstall] no Geneseed install detected at ${targetDesc}.\n`
679
678
  + '[uninstall] pass --target <repo> for a project install (.opencode/.claude/'
680
679
  + '.bob/.github) or --target <config dir> for a global one.\n');
681
680
  return 1;
@@ -684,60 +683,60 @@ export function cmdUninstall(args) {
684
683
  if (installState(root, host, scope) === 'absent') {
685
684
  const where = scope === 'project' && host !== 'opencode'
686
685
  ? ` under ${hostSpec(host).projectMarker}/` : '';
687
- pyPrintErr(`[uninstall] no ${host}:${scope} Geneseed install at ${root} `
686
+ printErr(`[uninstall] no ${host}:${scope} Geneseed install at ${root} `
688
687
  + `(no ${GLOBAL_MANIFEST}${where}).\n`);
689
688
  return 1;
690
689
  }
691
690
  const data = installDataDir(root, host, scope);
692
691
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
693
- pyPrint(`[uninstall] target: ${root} (${host}:${scope})\n`);
692
+ printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
694
693
  if (host === 'copilot') {
695
- pyPrint('[uninstall] removes: agents/, skills/, markers, and the '
694
+ printOut('[uninstall] removes: agents/, skills/, markers, and the '
696
695
  + `${hostSpec(host).agentFile} managed block (Copilot has no `
697
696
  + 'settings.json/hooks to unwire; your own .github files are kept).\n');
698
697
  } else if (host === 'claude' || host === 'bob') {
699
- pyPrint('[uninstall] removes: agents/, skills/, markers, the '
698
+ printOut('[uninstall] removes: agents/, skills/, markers, the '
700
699
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
701
700
  + 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
702
701
  } else if (scope === 'global') {
703
- pyPrint('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
702
+ printOut('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
704
703
  + 'opencode.json instructions entry.\n');
705
704
  } else {
706
- pyPrint('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
705
+ printOut('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
707
706
  + 'opencode.json instructions entry.\n');
708
707
  }
709
- pyPrint('[uninstall] memory/ and notebook/ are kept in place (never deleted here)'
708
+ printOut('[uninstall] memory/ and notebook/ are kept in place (never deleted here)'
710
709
  + (stores.length ? ' — --archive-memory sets both aside.' : '.') + '\n');
711
710
  if (stores.length && args.archiveMemory) {
712
- pyPrint(`[uninstall] ${stores.join(' + ')}: will be ARCHIVED to a sibling `
711
+ printOut(`[uninstall] ${stores.join(' + ')}: will be ARCHIVED to a sibling `
713
712
  + 'archived-<name>/<timestamp>/ (never deleted)\n');
714
713
  }
715
714
  if (!args.yes) {
716
715
  if (!process.stdin.isTTY) {
717
- pyPrintErr('[uninstall] refusing to proceed without --yes (non-interactive).\n');
716
+ printErr('[uninstall] refusing to proceed without --yes (non-interactive).\n');
718
717
  return 1;
719
718
  }
720
719
  if (!confirm('Proceed with uninstall?', false)) {
721
- pyPrint('[uninstall] cancelled — nothing removed.\n');
720
+ printOut('[uninstall] cancelled — nothing removed.\n');
722
721
  return 0;
723
722
  }
724
723
  }
725
724
  const memory = args.archiveMemory ? 'archive' : 'keep';
726
725
  const s = installUninstall(root, host, scope, memory);
727
726
  if (!(s.ok ?? true)) {
728
- pyPrintErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
727
+ printErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
729
728
  return 1;
730
729
  }
731
730
  const archived = s.archived || [];
732
731
  const mem = archived.length ? `archived -> ${archived.join(', ')}` : 'kept in place';
733
732
  const cfgfile = host === 'opencode' ? 'opencode.json' : 'settings.json';
734
733
  if (s.incomplete && s.incomplete.length) {
735
- pyPrint(`[uninstall] INCOMPLETE — removed ${s.removed} file(s), but `
734
+ printOut(`[uninstall] INCOMPLETE — removed ${s.removed} file(s), but `
736
735
  + `${s.incomplete.length} item(s) survived (see the WARN above); `
737
736
  + 'the install marker was kept — retry `geneseed uninstall` once they\'re '
738
737
  + `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
739
738
  } else {
740
- pyPrint(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
739
+ printOut(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
741
740
  + `needed; memory/notebook ${mem}. Start a new session to apply.\n`);
742
741
  }
743
742
  if (scope === 'global') printSurvivingProjectInventory(root);
@@ -836,8 +835,8 @@ function installMoveList(root, kind) {
836
835
  } else {
837
836
  rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
838
837
  }
839
- const rroot = pyResolve(root);
840
- return rels.filter((r) => r && within(pyResolve(path.resolve(rroot, r)), rroot));
838
+ const rroot = resolvePath(root);
839
+ return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
841
840
  }
842
841
 
843
842
  /**
@@ -863,9 +862,9 @@ export function installReaddEntry(target, entry) {
863
862
  const [cfg, hadComments] = readJsonc(raw);
864
863
  if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) return false;
865
864
  const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
866
- if (indexOfEq(instr, entry) >= 0) return false;
865
+ if (indexOfDeepEqual(instr, entry) >= 0) return false;
867
866
  if (path.extname(target) === '.jsonc' && hadComments) {
868
- pyPrint(`[activate] ${path.basename(target)} has comments — not rewriting it. Add this `
867
+ printOut(`[activate] ${path.basename(target)} has comments — not rewriting it. Add this `
869
868
  + `to its "instructions" by hand: ${jsonDumps(entry)}\n`);
870
869
  return false;
871
870
  }
@@ -1040,7 +1039,7 @@ function remergeClaudeHooks(cfg, root = cfg) {
1040
1039
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1041
1040
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded);
1042
1041
  // `and data` — a manifest that did not parse is not one to write back.
1043
- if (!pyEq(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1042
+ if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1044
1043
  managed.settings_hooks = claims;
1045
1044
  data.managed = managed;
1046
1045
  const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
@@ -1063,9 +1062,9 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1063
1062
  const man = claudeReadManifest(cfg);
1064
1063
  const managed = managedOf(man);
1065
1064
  const stash = path.join(cfg, DISABLED_STASH, host);
1066
- const rroot = pyResolve(cfg);
1065
+ const rroot = resolvePath(cfg);
1067
1066
  const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
1068
- && within(pyResolve(path.resolve(rroot, r)), rroot));
1067
+ && within(resolvePath(path.resolve(rroot, r)), rroot));
1069
1068
  const done = [];
1070
1069
  for (const rel of rels) {
1071
1070
  const src = path.join(cfg, rel);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `rituals/_update.py` — self-update: `git pull` the install's own origin, validate, rebuild.
2
+ * Self-update: `git pull` the install's own origin, validate, rebuild.
3
3
  *
4
4
  * The verb this whole port exists for. Every other subcommand can be Python for one more
5
5
  * release; `upgrade` is the one operation a "no Python needed" install cannot delegate,
@@ -31,9 +31,10 @@
31
31
  * `tests/test_pure_function_parity.py`; the heartbeat and the hard deadline are gated by
32
32
  * neither and are declared in the P8a handoff.
33
33
  *
34
- * NOT HERE, DELIBERATELY: `sync_self`, `main` (the launchers' self-heal entry) and
35
- * `cmd_bootstrap` are P8b's, and `update`'s row in `js/web/jobs.mjs`' `NOT_PORTED_ACTIONS`
36
- * is P8c's one-line payment. `parseOrigin` is ported in full here even though only `upgrade`
34
+ * ALSO HERE, SINCE P8b: `syncSelf`/`cmdSyncSelf` and the bootstrap step runner
35
+ * (`cmdBootstrap`, `bootstrapPlain`) at the bottom of this file. Still NOT here: `main`, the
36
+ * launchers' self-heal entry. `NOT_PORTED_ACTIONS` in `js/web/jobs.mjs` is empty now — every
37
+ * action row crosses. `parseOrigin` is ported in full here even though only `upgrade`
37
38
  * needs its fallback, because P8c's docs `about` kind needs the slug half and splitting one
38
39
  * pure function across two phases is how a second classifier gets written.
39
40
  */
@@ -42,33 +43,32 @@ import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
42
43
  import os from 'node:os';
43
44
  import path from 'node:path';
44
45
 
45
- import { ROOT } from './checkout.mjs';
46
- import { exportImprovements, flushExportNotes } from './diff.mjs';
47
- import { opencodeConfigDir } from './hosts.mjs';
48
- import {
49
- PY_SPACE, appendText, copyFile, pyInt, pyPathStr, pyPrint, pyPrintErr, pyStripSpace, pyWhich,
50
- readText, writeText,
51
- } from './lib/fs.mjs';
52
- import { NO_WINDOW } from './lib/proc.mjs';
53
- import { restartDaemon } from './web/server.mjs';
46
+ import { ROOT } from '../build/source.mjs';
47
+ import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
48
+ import { opencodeConfigDir } from '../hosts/hosts.mjs';
49
+ import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
+ import { toPlatformPath, which } from '../lib/paths.mjs';
51
+ import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
+ import { NO_WINDOW } from '../lib/proc.mjs';
53
+ import { restartDaemon } from '../web/daemon.mjs';
54
54
 
55
55
  /**
56
56
  * `_CREDS_RE` / `_redact_url_creds` — strip a `user[:token]@` userinfo out of any URL in
57
57
  * `text` so a tokened remote never reaches a log line or an HTTP response.
58
58
  *
59
- * `PY_SPACE` rather than `\s`: the class is NEGATED, so the two definitions disagree about
59
+ * `WHITESPACE` rather than `\s`: the class is NEGATED, so the two definitions disagree about
60
60
  * whether a `\uFEFF` or a `\u001c` ends the userinfo. Unreachable through a git remote and
61
61
  * spelled correctly anyway, for the reason `js/lib/fs.mjs` gives — an approximated
62
62
  * primitive is the one that is wrong the day something reaches it.
63
63
  */
64
- const CREDS_RE = new RegExp(`(://)[^/@${PY_SPACE}]+@`, 'g');
64
+ const CREDS_RE = new RegExp(`(://)[^/@${WHITESPACE}]+@`, 'g');
65
65
 
66
66
  export function redactUrlCreds(text) {
67
67
  return (text || '').replace(CREDS_RE, '$1');
68
68
  }
69
69
 
70
- // `_NO_WINDOW` was defined here and PRIVATE to this module, which is how `js/doctor.mjs`
71
- // and `js/hooks.mjs` came to spawn without it — see `js/lib/proc.mjs` for the flag, the
70
+ // `_NO_WINDOW` was defined here and PRIVATE to this module, which is how `js/inspect/doctor.mjs`
71
+ // and `js/hosts/hooks.mjs` came to spawn without it — see `js/lib/proc.mjs` for the flag, the
72
72
  // capturing-only rule behind it, and why a miss here is invisible to every byte gate.
73
73
 
74
74
  /** `subprocess.run(..., capture_output=True, encoding="utf-8")` reads in UNIVERSAL NEWLINES. */
@@ -84,7 +84,7 @@ export const DEFAULT_ORIGIN = { url: 'https://github.com/Arylmera/Geneseed',
84
84
  * which-guarded, no-window, stripped + credential-redacted, NEVER raises. Returns
85
85
  * `[rc, out, err]`; `rc` is null when git is absent or the spawn failed.
86
86
  *
87
- * `pyWhich` and not a bare `'git'`: the reference answers `(None, "", "")` — which
87
+ * `which` and not a bare `'git'`: the reference answers `(None, "", "")` — which
88
88
  * `preflight` reports as "git is not installed or not on PATH" — when the lookup misses,
89
89
  * and a port that let the OS resolve the name would get an ENOENT instead and have to guess
90
90
  * which of the two it was.
@@ -94,7 +94,7 @@ export const DEFAULT_ORIGIN = { url: 'https://github.com/Arylmera/Geneseed',
94
94
  * "clean enough".
95
95
  */
96
96
  export function gitRun(args, { timeout = 10, network = false } = {}) {
97
- const exe = pyWhich('git');
97
+ const exe = which('git');
98
98
  if (!exe) return [null, '', ''];
99
99
  const cmd = ['-C', String(ROOT)];
100
100
  let env;
@@ -112,8 +112,8 @@ export function gitRun(args, { timeout = 10, network = false } = {}) {
112
112
  // the arm the reference reaches for every other failure.
113
113
  if (r.error || r.status === null) return [null, '', ''];
114
114
  return [r.status,
115
- redactUrlCreds(pyStripSpace(universal(r.stdout))),
116
- redactUrlCreds(pyStripSpace(universal(r.stderr)))];
115
+ redactUrlCreds(stripWhitespace(universal(r.stdout))),
116
+ redactUrlCreds(stripWhitespace(universal(r.stderr)))];
117
117
  }
118
118
 
119
119
  /**
@@ -130,7 +130,7 @@ function urlsplitHostPath(o) {
130
130
  // A scheme is `[A-Za-z][A-Za-z0-9+.-]*` before the first `:` — and only then. `urlsplit`
131
131
  // leaves `foo:bar` alone when `foo` is not a legal scheme, which is what keeps an scp-form
132
132
  // remote out of this branch (the caller checks for that shape first anyway).
133
- const m = /^([A-Za-z][A-Za-z0-9+.\-]*):(.*)$/s.exec(rest);
133
+ const m = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/s.exec(rest);
134
134
  if (m) rest = m[2];
135
135
  let netloc = '';
136
136
  if (rest.startsWith('//')) {
@@ -153,7 +153,7 @@ function urlsplitHostPath(o) {
153
153
  * Userinfo and port dropped from the url; slug set only for a two-segment github.com path.
154
154
  */
155
155
  export function parseOrigin(origin) {
156
- const o = pyStripSpace(origin || '');
156
+ const o = stripWhitespace(origin || '');
157
157
  let host = '';
158
158
  let p = '';
159
159
  if (!o.includes('://') && o.includes('@') && o.split('@').slice(1).join('@').includes(':')) {
@@ -220,9 +220,9 @@ export function preflight() {
220
220
  return pre('ready');
221
221
  }
222
222
 
223
- /** `_fetch_timeout()`. `pyInt` is null where `int()` raises, which is the `except` arm. */
223
+ /** `_fetch_timeout()`. `parseIntStrict` is null where `int()` raises, which is the `except` arm. */
224
224
  export function fetchTimeout() {
225
- const n = pyInt(process.env.GENESEED_NET_TIMEOUT ?? '120');
225
+ const n = parseIntStrict(process.env.GENESEED_NET_TIMEOUT ?? '120');
226
226
  if (n === null) return 120;
227
227
  return Math.max(30, n);
228
228
  }
@@ -235,8 +235,8 @@ export function fetchTimeout() {
235
235
  * (`rev-list --count` prints ASCII). Reproducing the crash would be reproducing a bug; the
236
236
  * divergence is confined to input git cannot emit and is recorded rather than matched.
237
237
  */
238
- export function pyCount(s) {
239
- const t = pyStripSpace(s || '');
238
+ export function countOccurrences(s) {
239
+ const t = stripWhitespace(s || '');
240
240
  return /^[0-9]+$/.test(t) ? Number(t) : 0;
241
241
  }
242
242
 
@@ -269,7 +269,7 @@ export function killTree(child) {
269
269
  export function fetchPhases(rawLines, last = '') {
270
270
  const out = [];
271
271
  for (const raw of rawLines) {
272
- const line = redactUrlCreds(pyStripSpace(raw));
272
+ const line = redactUrlCreds(stripWhitespace(raw));
273
273
  if (!line) continue;
274
274
  const phase = line.split(':')[0];
275
275
  if (phase !== last) {
@@ -292,7 +292,7 @@ export function fetchPhases(rawLines, last = '') {
292
292
  * since P6h.
293
293
  */
294
294
  export async function fetchStreaming(log = null) {
295
- const exe = pyWhich('git');
295
+ const exe = which('git');
296
296
  if (!exe) return [null, 'git is not installed or not on PATH'];
297
297
  const timeout = fetchTimeout();
298
298
  const args = ['-C', String(ROOT),
@@ -323,7 +323,7 @@ export async function fetchStreaming(log = null) {
323
323
  const [emit, next] = fetchPhases(parts, last);
324
324
  last = next;
325
325
  for (const p of parts) {
326
- const s = redactUrlCreds(pyStripSpace(p));
326
+ const s = redactUrlCreds(stripWhitespace(p));
327
327
  if (s) lines.push(s);
328
328
  }
329
329
  if (log) for (const l of emit) log(`[geneseed] ${l}`);
@@ -373,8 +373,8 @@ export async function measureUpstream(log = null) {
373
373
  if (rc !== 0) return ['fetch_failed', 0, err];
374
374
  const [, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
375
375
  const [, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
376
- const ahead = pyCount(aheadRaw);
377
- const behind = pyCount(behindRaw);
376
+ const ahead = countOccurrences(aheadRaw);
377
+ const behind = countOccurrences(behindRaw);
378
378
  if (ahead > 0) {
379
379
  const [mrc] = gitRun(['merge-base', 'HEAD', '@{u}']);
380
380
  return [mrc === 0 ? 'diverged' : 'unrelated', 0, ''];
@@ -412,8 +412,8 @@ export function runDoctor(cand) {
412
412
  // `stderr=subprocess.STDOUT` — the reference gives the child ONE handle, so its two
413
413
  // streams are interleaved in write order and arrive as `proc.stdout`. `spawnSync` has no
414
414
  // way to hand two descriptors the same pipe, so the two are concatenated instead: equal
415
- // whenever the child writes to only one of them, which `doctor` does (`js/doctor.mjs`
416
- // reports every problem through `pyPrint`), and the reason the two doctor cells below are
415
+ // whenever the child writes to only one of them, which `doctor` does (`js/inspect/doctor.mjs`
416
+ // reports every problem through `printOut`), and the reason the two doctor cells below are
417
417
  // the gate on this rather than a comment. If a future check reports on stderr the
418
418
  // ORDERING here is what would differ, not the content.
419
419
  return [r.status === 0, universal(r.stdout) + universal(r.stderr)];
@@ -454,7 +454,7 @@ export function logfile() {
454
454
  // `Path(env)`, stringified with the platform separator when `upgrade` prints it — the same
455
455
  // rule `GENESEED_OUT` needed. No cell can set this (`cell_env` clears every `GENESEED_*`
456
456
  // and no upgrade cell re-adds it), so it is spelled from the rule rather than from a gate.
457
- if (env) return pyPathStr(env);
457
+ if (env) return toPlatformPath(env);
458
458
  return path.join(os.homedir(), '.geneseed-install.log');
459
459
  }
460
460
 
@@ -471,7 +471,7 @@ export class Log {
471
471
  }
472
472
 
473
473
  call(msg) {
474
- pyPrint(`${msg}\n`);
474
+ printOut(`${msg}\n`);
475
475
  if (this.path !== null) {
476
476
  try { appendText(this.path, `${msg}\n`); } catch { /* OSError: pass */ }
477
477
  }
@@ -485,7 +485,7 @@ export function resolveEmit(cfg, out) {
485
485
  for (const marker of [path.join(String(cfg), '.geneseed-emit'),
486
486
  path.join(String(out), '.geneseed-emit')]) {
487
487
  try {
488
- const val = pyStripSpace(readText(marker));
488
+ const val = stripWhitespace(readText(marker));
489
489
  if (val) return val;
490
490
  } catch { /* OSError: pass */ }
491
491
  }
@@ -497,7 +497,7 @@ export function markerTheme(cfg, out) {
497
497
  for (const marker of [path.join(String(cfg), '.geneseed-theme'),
498
498
  path.join(String(out), '.geneseed-theme')]) {
499
499
  try {
500
- const val = pyStripSpace(readText(marker));
500
+ const val = stripWhitespace(readText(marker));
501
501
  if (val) return val;
502
502
  } catch { /* OSError: pass */ }
503
503
  }
@@ -570,11 +570,11 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
570
570
  else if (emit === 'opencode-global') buildArgs.push('--emit', 'opencode-global');
571
571
  log(`[geneseed] rebuilding bundle -> ${out} (theme: ${theme || 'config default'}, emit: ${emit}) ...`);
572
572
  await drainStdout();
573
- // THE GENERATOR, RE-EXECUTED. `js/generate.mjs`'s `cmdBuild` calls `driverMain` in-process
573
+ // THE GENERATOR, RE-EXECUTED. `js/build/generate.mjs`'s `cmdBuild` calls `driverMain` in-process
574
574
  // and is right to — nothing has changed under it. Here the source on disk was replaced
575
575
  // three statements ago, so this must be a new process reading the new files.
576
576
  const proc = spawnSync(process.execPath,
577
- [path.join(String(here), 'bin', 'geneseed.mjs'), ...buildArgs],
577
+ [path.join(String(here), 'bin', 'build-driver.mjs'), ...buildArgs],
578
578
  { cwd: String(here), stdio: 'inherit' });
579
579
  if (proc.status !== 0) return proc.status === null ? 1 : proc.status;
580
580
  // If a web daemon is running, bounce it so the new source and the freshly rebuilt web/dist
@@ -623,13 +623,13 @@ export async function upgrade(ref = null, themeArg = null) {
623
623
  + 'current branch (tag/branch pinning was removed with the zip path).');
624
624
  }
625
625
  const here = String(ROOT);
626
- // `Path(os.environ.get(...) or ...)`, and `pyPathStr` is the `Path(str)` half of it: the
626
+ // `Path(os.environ.get(...) or ...)`, and `toPlatformPath` is the `Path(str)` half of it: the
627
627
  // reference stringifies with the PLATFORM separator, so a `GENESEED_OUT` spelled
628
628
  // `C:/x/bundle` — which is how a JSON config, a shell script and this harness's own
629
629
  // `{sb/}` placeholder all spell it — reaches the log as `C:\x\bundle`. Six cells differed
630
630
  // on exactly that and nothing else.
631
- const out = pyPathStr(process.env.GENESEED_OUT || path.join(path.dirname(here), 'Harness'));
632
- const rootDir = pyPathStr(process.env.GENESEED_ROOT || path.dirname(out));
631
+ const out = toPlatformPath(process.env.GENESEED_OUT || path.join(path.dirname(here), 'Harness'));
632
+ const rootDir = toPlatformPath(process.env.GENESEED_ROOT || path.dirname(out));
633
633
  const cfg = opencodeConfigDir();
634
634
  const emit = resolveEmit(cfg, out);
635
635
 
@@ -647,7 +647,7 @@ export async function upgrade(ref = null, themeArg = null) {
647
647
  return p.kind === 'info' ? 3 : 1;
648
648
  }
649
649
 
650
- log(`[geneseed] fetching from origin (git: ${pyWhich('git') || 'git'}, `
650
+ log(`[geneseed] fetching from origin (git: ${which('git') || 'git'}, `
651
651
  + `timeout: ${fetchTimeout()}s) ...`);
652
652
  const [code, behind, err] = await measureUpstream(log);
653
653
  if (code === 'fetch_failed') {
@@ -715,11 +715,11 @@ export async function cmdUpgrade(args) {
715
715
  try {
716
716
  const [ipath] = exportImprovements();
717
717
  if (ipath) {
718
- pyPrint(`[upgrade] deployed harness carries local edits — saved to ${ipath}\n`);
719
- pyPrint('[upgrade] hand that file to your agent to back-port them into src/.\n');
718
+ printOut(`[upgrade] deployed harness carries local edits — saved to ${ipath}\n`);
719
+ printOut('[upgrade] hand that file to your agent to back-port them into src/.\n');
720
720
  }
721
721
  } catch (e) {
722
- pyPrintErr(`[upgrade] ⚠️ could not export local edits (${e && e.message ? e.message : e}) — `
722
+ printErr(`[upgrade] ⚠️ could not export local edits (${e && e.message ? e.message : e}) — `
723
723
  + 'run `geneseed diff --out FILE` before upgrading to keep them.\n');
724
724
  }
725
725
  let { ref } = args;
@@ -862,17 +862,17 @@ async function bootstrapPlain() {
862
862
  let failed = false;
863
863
  for (let i = 0; i < steps.length; i += 1) {
864
864
  const [title, cmd, step] = steps[i];
865
- pyPrint(`[geneseed] step ${i + 1}/${steps.length}: ${title} ...\n`);
866
- // eslint-disable-next-line no-await-in-loop
865
+ printOut(`[geneseed] step ${i + 1}/${steps.length}: ${title} ...\n`);
866
+
867
867
  const rc = await step();
868
868
  if (rc !== 0 && rc !== 3) {
869
869
  failed = true;
870
870
  for (const ln of diagnoseFailedStep(i + 1, steps.length, title, cmd, rc, '')) {
871
- pyPrint(`${ln}\n`);
871
+ printOut(`${ln}\n`);
872
872
  }
873
873
  }
874
874
  }
875
- if (!failed) pyPrint('[geneseed] ✓ update complete.\n');
875
+ if (!failed) printOut('[geneseed] ✓ update complete.\n');
876
876
  return failed;
877
877
  }
878
878