geneseed 3.1.0 → 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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -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 +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.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,25 +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
- claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, installKind, installState,
53
+ claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
+ installState,
54
55
  registeredTargets, readMaybe, DISABLED_STASH,
55
- } from './installs.mjs';
56
+ } from '../hosts/installs.mjs';
56
57
  import {
57
- GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, pyResolve,
58
- } from './hosts.mjs';
59
- 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';
60
61
  import {
61
62
  managedBlockRead, managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
62
63
  opencodeTarget, readJsonc, settingsIntegrityCheck, wireClaudeExcludes,
63
64
  unwireClaudeExcludes, unwireClaudeSettings,
64
- } from './settings.mjs';
65
- import {
66
- comparePaths, indexOfEq, jsonDumps, jsonDumpsIndent, pyEq, pyIsAbsolute, pyPrint,
67
- pyPrintErr, readText, within, writeText,
68
- } 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';
69
69
 
70
70
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
71
71
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
@@ -172,7 +172,7 @@ function unlinkOwned(base, owned, label = '') {
172
172
  // `f"{rel} ({e})"`. `str(OSError)` and an `Error.message` word the same fault
173
173
  // differently, and no cell can reach this: it needs a file that exists, is a file, and
174
174
  // cannot be unlinked. Recorded rather than papered over — the same standing item
175
- // `js/excludes.mjs` carries for its `could not remove <stub>` branch.
175
+ // `js/inspect/excludes.mjs` carries for its `could not remove <stub>` branch.
176
176
  failed.push(`${label}${rel} (${e && e.message ? e.message : e})`);
177
177
  }
178
178
  }
@@ -181,12 +181,12 @@ function unlinkOwned(base, owned, label = '') {
181
181
 
182
182
  /** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
183
183
  function warnSurvivors(failed) {
184
- pyPrintErr('[uninstall] WARN: could not remove '
184
+ printErr('[uninstall] WARN: could not remove '
185
185
  + `${failed.length} owned file(s): ${failed.join(', ')}\n`);
186
186
  }
187
187
 
188
188
  function warnMarkersKept() {
189
- pyPrintErr('[uninstall] WARN: the manifest and markers were KEPT so '
189
+ printErr('[uninstall] WARN: the manifest and markers were KEPT so '
190
190
  + '`geneseed uninstall` can be retried once the file(s) are '
191
191
  + 'unlocked/removable.\n');
192
192
  }
@@ -215,7 +215,7 @@ export function unmergeOpencodeJson(p, entry) {
215
215
  const instr = cfg.instructions;
216
216
  if (!Array.isArray(instr) || !instr.includes(entry)) return false;
217
217
  if (path.extname(target) === '.jsonc' && hadComments) {
218
- pyPrint(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
218
+ printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
219
219
  + `this from its "instructions" by hand: ${jsonDumps(entry)}\n`);
220
220
  return false;
221
221
  }
@@ -264,7 +264,7 @@ function settingsFile(cfg, managed) {
264
264
  /** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
265
265
  function claudeMdPath(cfg, managed) {
266
266
  const rel = ((managed && managed.claude_md) || {}).rel || 'CLAUDE.md';
267
- return pyResolve(path.join(cfg, rel));
267
+ return resolvePath(path.join(cfg, rel));
268
268
  }
269
269
 
270
270
  /** A manifest's `managed` map, or `{}` — the Python's `isinstance(..., dict)` guard. */
@@ -360,7 +360,7 @@ function mcpLoadOpencode(p) {
360
360
  *
361
361
  * A global install wires the ABSOLUTE posix path; a project install wires the relative
362
362
  * `…/AGENT.md` the emit recorded, read back off the live config so a bundle sub-dir layout
363
- * 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
364
364
  * ROOTLESS `/repo/AGENT.md`, which Python keeps and `path.isAbsolute` would have skipped.
365
365
  */
366
366
  export function installAgentEntry(root, kind) {
@@ -382,7 +382,7 @@ export function installAgentEntry(root, kind) {
382
382
  export function installAgentEntryOf(instr) {
383
383
  if (Array.isArray(instr)) {
384
384
  for (const e of instr) {
385
- 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;
386
386
  }
387
387
  }
388
388
  return 'AGENT.md';
@@ -508,7 +508,7 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
508
508
  if (survivors.length || failed.length) {
509
509
  if (survivors.length && !failed.length) {
510
510
  // With `failed` set the reversal already warned twice — no third overlapping WARN.
511
- pyPrintErr('[uninstall] WARN: could not fully remove the install — still present: '
511
+ printErr('[uninstall] WARN: could not fully remove the install — still present: '
512
512
  + `${survivors.join(', ')}. The install marker was KEPT so you can retry `
513
513
  + '`geneseed uninstall` once the file(s) are unlocked/removable.\n');
514
514
  }
@@ -544,7 +544,7 @@ export function projectQualifies(root, host) {
544
544
  const cfg = path.join(root, spec.projectMarker);
545
545
  if (!isDir(cfg)) return false;
546
546
  try {
547
- if (pyResolve(cfg) === pyResolve(spec.configDir())) return false;
547
+ if (resolvePath(cfg) === resolvePath(spec.configDir())) return false;
548
548
  } catch { /* as the Python's bare `except Exception: pass` */ }
549
549
  if (isFile(path.join(cfg, GLOBAL_MANIFEST))) return true;
550
550
  const hs = emitHostScopeOf(root);
@@ -568,7 +568,7 @@ export function uninstallResolve(targetArg) {
568
568
  const globalHit = (p) => {
569
569
  for (const spec of HOSTS) {
570
570
  try {
571
- if (p === pyResolve(spec.configDir())) return [spec.host, 'global', p];
571
+ if (p === resolvePath(spec.configDir())) return [spec.host, 'global', p];
572
572
  } catch { continue; }
573
573
  }
574
574
  return null;
@@ -580,7 +580,7 @@ export function uninstallResolve(targetArg) {
580
580
  return null;
581
581
  };
582
582
  if (targetArg) {
583
- const p = pyResolve(expanduser(targetArg));
583
+ const p = resolvePath(expanduser(targetArg));
584
584
  const hit = globalHit(p);
585
585
  if (hit) return hit;
586
586
  for (const spec of HOSTS) {
@@ -594,7 +594,7 @@ export function uninstallResolve(targetArg) {
594
594
  // `.resolve()` so the cwd fallback matches every other branch and the registry, which
595
595
  // stores resolved paths — a short-form (8.3) cwd, as Windows CI hands back for %TEMP%,
596
596
  // would otherwise return a root that LOOKS different from the identical directory.
597
- const hit = projectHit(pyResolve(process.cwd()));
597
+ const hit = projectHit(resolvePath(process.cwd()));
598
598
  if (hit) return hit;
599
599
  return ['opencode', 'global', opencodeConfigDir()];
600
600
  }
@@ -608,11 +608,11 @@ export function uninstallResolve(targetArg) {
608
608
  * rediscovered from, and the just-removed root is excluded.
609
609
  */
610
610
  export function survivingProjectInstalls(removedRoot) {
611
- const rroot = pyResolve(removedRoot);
611
+ const rroot = resolvePath(removedRoot);
612
612
  const out = [];
613
613
  for (const [host, scope, root] of registeredTargets()) {
614
614
  if (scope !== 'project') continue;
615
- 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 */ }
616
616
  out.push([host, scope, root]);
617
617
  }
618
618
  return out;
@@ -622,10 +622,10 @@ export function survivingProjectInstalls(removedRoot) {
622
622
  export function printSurvivingProjectInventory(removedRoot) {
623
623
  const survivors = survivingProjectInstalls(removedRoot);
624
624
  if (!survivors.length) return;
625
- pyPrint(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
625
+ printOut(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
626
626
  + 'does not affect them (each is self-contained):\n');
627
627
  for (const [host, scope, root] of survivors) {
628
- pyPrint(` - ${root} (${host}:${scope}) — remove with: `
628
+ printOut(` - ${root} (${host}:${scope}) — remove with: `
629
629
  + `geneseed uninstall --target "${root}" --yes\n`);
630
630
  }
631
631
  }
@@ -639,8 +639,8 @@ export function printSurvivingProjectInventory(removedRoot) {
639
639
  export function printOtherHostHits(root, removedHost) {
640
640
  for (const spec of HOSTS) {
641
641
  if (spec.host !== removedHost && projectQualifies(root, spec.host)) {
642
- pyPrint(`[uninstall] also found ${spec.host}:project here — run \`harness `
643
- + 'uninstall\` again to remove it.\n');
642
+ printOut(`[uninstall] also found ${spec.host}:project here — run \`harness `
643
+ + 'uninstall` again to remove it.\n');
644
644
  }
645
645
  }
646
646
  }
@@ -648,7 +648,7 @@ export function printOtherHostHits(root, removedHost) {
648
648
  /**
649
649
  * `_harness_setup._confirm`, and the only interactive read this verb makes.
650
650
  *
651
- * 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
652
652
  * `_harness_setup` beside the `_ask` it is built on, and `setup` is its second caller. The
653
653
  * body stayed here for one phase because a helper with one caller is not shared yet; the
654
654
  * rule this port uses is that the SECOND owner is what moves it, and a byte gate is what
@@ -673,8 +673,8 @@ export function cmdUninstall(args) {
673
673
  if (hit === null) {
674
674
  // `uninstallResolve(null)` never returns null — it falls back to the OpenCode global
675
675
  // default — so reaching here means `--target` was given and was not recognised.
676
- const targetDesc = pyResolve(expanduser(args.target));
677
- 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`
678
678
  + '[uninstall] pass --target <repo> for a project install (.opencode/.claude/'
679
679
  + '.bob/.github) or --target <config dir> for a global one.\n');
680
680
  return 1;
@@ -683,60 +683,60 @@ export function cmdUninstall(args) {
683
683
  if (installState(root, host, scope) === 'absent') {
684
684
  const where = scope === 'project' && host !== 'opencode'
685
685
  ? ` under ${hostSpec(host).projectMarker}/` : '';
686
- pyPrintErr(`[uninstall] no ${host}:${scope} Geneseed install at ${root} `
686
+ printErr(`[uninstall] no ${host}:${scope} Geneseed install at ${root} `
687
687
  + `(no ${GLOBAL_MANIFEST}${where}).\n`);
688
688
  return 1;
689
689
  }
690
690
  const data = installDataDir(root, host, scope);
691
691
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
692
- pyPrint(`[uninstall] target: ${root} (${host}:${scope})\n`);
692
+ printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
693
693
  if (host === 'copilot') {
694
- pyPrint('[uninstall] removes: agents/, skills/, markers, and the '
694
+ printOut('[uninstall] removes: agents/, skills/, markers, and the '
695
695
  + `${hostSpec(host).agentFile} managed block (Copilot has no `
696
696
  + 'settings.json/hooks to unwire; your own .github files are kept).\n');
697
697
  } else if (host === 'claude' || host === 'bob') {
698
- pyPrint('[uninstall] removes: agents/, skills/, markers, the '
698
+ printOut('[uninstall] removes: agents/, skills/, markers, the '
699
699
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
700
700
  + 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
701
701
  } else if (scope === 'global') {
702
- pyPrint('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
702
+ printOut('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
703
703
  + 'opencode.json instructions entry.\n');
704
704
  } else {
705
- pyPrint('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
705
+ printOut('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
706
706
  + 'opencode.json instructions entry.\n');
707
707
  }
708
- 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)'
709
709
  + (stores.length ? ' — --archive-memory sets both aside.' : '.') + '\n');
710
710
  if (stores.length && args.archiveMemory) {
711
- pyPrint(`[uninstall] ${stores.join(' + ')}: will be ARCHIVED to a sibling `
711
+ printOut(`[uninstall] ${stores.join(' + ')}: will be ARCHIVED to a sibling `
712
712
  + 'archived-<name>/<timestamp>/ (never deleted)\n');
713
713
  }
714
714
  if (!args.yes) {
715
715
  if (!process.stdin.isTTY) {
716
- pyPrintErr('[uninstall] refusing to proceed without --yes (non-interactive).\n');
716
+ printErr('[uninstall] refusing to proceed without --yes (non-interactive).\n');
717
717
  return 1;
718
718
  }
719
719
  if (!confirm('Proceed with uninstall?', false)) {
720
- pyPrint('[uninstall] cancelled — nothing removed.\n');
720
+ printOut('[uninstall] cancelled — nothing removed.\n');
721
721
  return 0;
722
722
  }
723
723
  }
724
724
  const memory = args.archiveMemory ? 'archive' : 'keep';
725
725
  const s = installUninstall(root, host, scope, memory);
726
726
  if (!(s.ok ?? true)) {
727
- pyPrintErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
727
+ printErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
728
728
  return 1;
729
729
  }
730
730
  const archived = s.archived || [];
731
731
  const mem = archived.length ? `archived -> ${archived.join(', ')}` : 'kept in place';
732
732
  const cfgfile = host === 'opencode' ? 'opencode.json' : 'settings.json';
733
733
  if (s.incomplete && s.incomplete.length) {
734
- pyPrint(`[uninstall] INCOMPLETE — removed ${s.removed} file(s), but `
734
+ printOut(`[uninstall] INCOMPLETE — removed ${s.removed} file(s), but `
735
735
  + `${s.incomplete.length} item(s) survived (see the WARN above); `
736
736
  + 'the install marker was kept — retry `geneseed uninstall` once they\'re '
737
737
  + `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
738
738
  } else {
739
- pyPrint(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
739
+ printOut(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
740
740
  + `needed; memory/notebook ${mem}. Start a new session to apply.\n`);
741
741
  }
742
742
  if (scope === 'global') printSurvivingProjectInventory(root);
@@ -835,8 +835,8 @@ function installMoveList(root, kind) {
835
835
  } else {
836
836
  rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
837
837
  }
838
- const rroot = pyResolve(root);
839
- 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));
840
840
  }
841
841
 
842
842
  /**
@@ -862,9 +862,9 @@ export function installReaddEntry(target, entry) {
862
862
  const [cfg, hadComments] = readJsonc(raw);
863
863
  if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) return false;
864
864
  const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
865
- if (indexOfEq(instr, entry) >= 0) return false;
865
+ if (indexOfDeepEqual(instr, entry) >= 0) return false;
866
866
  if (path.extname(target) === '.jsonc' && hadComments) {
867
- 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 `
868
868
  + `to its "instructions" by hand: ${jsonDumps(entry)}\n`);
869
869
  return false;
870
870
  }
@@ -1032,10 +1032,14 @@ function remergeClaudeHooks(cfg, root = cfg) {
1032
1032
  // config dir (`.github/copilot-instructions.md`), and on a global install the two are the
1033
1033
  // same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
1034
1034
  const doctrines = doctrinesOfDir(root) ?? doctrinesOfDir(cfg);
1035
+ // Same two-carrier read for the second axis, and its own default: an absent line means
1036
+ // NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
1037
+ const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1038
+ : excludedRulesOfDir(cfg);
1035
1039
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1036
- managed.settings_hooks ?? null, hookRunnerEntry(), doctrines);
1040
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded);
1037
1041
  // `and data` — a manifest that did not parse is not one to write back.
1038
- 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) {
1039
1043
  managed.settings_hooks = claims;
1040
1044
  data.managed = managed;
1041
1045
  const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
@@ -1058,9 +1062,9 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1058
1062
  const man = claudeReadManifest(cfg);
1059
1063
  const managed = managedOf(man);
1060
1064
  const stash = path.join(cfg, DISABLED_STASH, host);
1061
- const rroot = pyResolve(cfg);
1065
+ const rroot = resolvePath(cfg);
1062
1066
  const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
1063
- && within(pyResolve(path.resolve(rroot, r)), rroot));
1067
+ && within(resolvePath(path.resolve(rroot, r)), rroot));
1064
1068
  const done = [];
1065
1069
  for (const rel of rels) {
1066
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