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
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * These are the commands the emitted `settings.json` actually invokes on a user's machine,
5
5
  * every session: a SessionStart injector, two PreToolUse gates, and a Stop/SubagentStop
6
- * distiller. Since P5b they are also what the emitted hooks name: `bin/geneseed.mjs` bakes
6
+ * distiller. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
7
7
  * `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
8
8
  * driver emits has no Python in its hook path at all — which is what let the interpreter
9
9
  * discovery and its exit-4 refusal be deleted rather than merely bypassed.
@@ -14,7 +14,7 @@
14
14
  * ~11,600 lines and the generator behind them. This module loads none of that, and that is
15
15
  * deliberate rather than incidental: the Python originals already refuse to import `build`
16
16
  * for the marker filenames they need ("literals keep the hook dependency-free"), and
17
- * `bin/geneseed.mjs` could not be imported anyway, since its last statement runs the
17
+ * `bin/build-driver.mjs` could not be imported anyway, since its last statement runs the
18
18
  * generator. What that rule does NOT license is a second copy of a shared resolver under a
19
19
  * different name — see the primitives section below, where P5d found one that had silently
20
20
  * diverged from its twin.
@@ -26,24 +26,27 @@
26
26
  * failure has to be diagnosable, and its hook command carries `|| exit 0` for exactly that
27
27
  * reason.
28
28
  *
29
- * The gate is `tests/harness_golden.py`, which runs each verb through both CLIs in a
30
- * seeded sandbox and compares stdout, stderr, the exit code and every file left behind.
29
+ * The gate is `tests/helpers/cli_golden.mjs` replaying `tests/helpers/matrix/cli.{posix,win32}.json`:
30
+ * each verb run in a seeded sandbox, with ABSOLUTE assertions on stdout, stderr, the exit code
31
+ * and every file left behind. Absolute rather than compared is what let it survive the deletion
32
+ * of the implementation it was once compared against.
31
33
  */
32
34
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync }
33
35
  from 'node:fs';
34
36
  import path from 'node:path';
35
37
  import { spawnSync } from 'node:child_process';
36
38
  import { fileURLToPath } from 'node:url';
37
- import { readText, writeText, jsonDumpsCompact, normcase, comparePaths, pyPathStr, pyPrint,
38
- pyPrintErr, withDiscardableStderr } from './lib/fs.mjs';
39
- import { NO_WINDOW } from './lib/proc.mjs';
39
+ import { readText, writeText, printOut, printErr, withDiscardableStderr } from '../lib/fs.mjs';
40
+ import { jsonDumpsCompact } from '../lib/json.mjs';
41
+ import { normcase, comparePaths, toPlatformPath } from '../lib/paths.mjs';
42
+ import { NO_WINDOW } from '../lib/proc.mjs';
40
43
  // `harness status` reports the memory store, and `bin/geneseed-cli.mjs` cannot import THIS
41
44
  // module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
42
- // spawns. So the resolver has one owner in `js/hosts.mjs` rather than a second copy — and
45
+ // spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
43
46
  // with it went this file's private `opencodeConfigDir`, whose only caller it was.
44
47
  import { resolveMemoryDir, expanduser } from './hosts.mjs';
45
48
 
46
- const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
49
+ const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
47
50
 
48
51
  // ---- Python path and text primitives -------------------------------------------------
49
52
  // Small, and each one is here because a JS-idiomatic equivalent differs observably.
@@ -52,8 +55,8 @@ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
52
55
  // coupling a hook to the emitter", with `readText`/`writeText`/`normcase` as the stated
53
56
  // exceptions. That is the wrong line, and `pyStrPath` is what proved it: it was a second
54
57
  // implementation of `str(Path(p))` under a different NAME from `js/lib/fs.mjs`'s
55
- // `pyPathStr`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
56
- // where `PurePath` keeps it, fixed `pyPathStr`, and gated it with a 25-path corpus — and
58
+ // `toPlatformPath`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
59
+ // where `PurePath` keeps it, fixed `toPlatformPath`, and gated it with a 25-path corpus — and
57
60
  // none of that reached this file, because a corpus finds what it is pointed at and nothing
58
61
  // named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
59
62
  // `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
@@ -63,16 +66,16 @@ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
63
66
  // So the line is not "duplicate rather than couple" — it is "one owner for anything that
64
67
  // reproduces a language primitive, wherever it is used". `fs.mjs` carries no
65
68
  // `child_process` and costs nothing at hook latency. What stays local is what is genuinely
66
- // only the hook's (`fnmatch`, the transcript readers, `pyOsError`'s errno table).
69
+ // only the hook's (`fnmatch`, the transcript readers, `asOsError`'s errno table).
67
70
  //
68
71
  // AND `expanduser` WAS THE SECOND VIOLATION, closed here. This file carried its own, which
69
- // returned a `~user` path UNCHANGED while `js/hosts.mjs`'s single owner REFUSES the form —
72
+ // returned a `~user` path UNCHANGED while `js/hosts/hosts.mjs`'s single owner REFUSES the form —
70
73
  // an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
71
74
  // was quietly exempt from because it had a private copy. It now imports the owner. The
72
75
  // refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
73
76
  // call: a hook that CRASHES reports success, because these gates exit 0 and signal on
74
77
  // stdout, so "the gate blew up" and "the gate found nothing" are the same observation to
75
- // Claude. `pyResolve` just below is the THIRD copy of a primitive `js/hosts.mjs` also
78
+ // Claude. `resolvePath` just below is the THIRD copy of a primitive `js/hosts/hosts.mjs` also
76
79
  // exports, and is the next item in this series.
77
80
 
78
81
  /**
@@ -82,7 +85,7 @@ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
82
85
  * `excludes.json` may name a folder that was deleted), so the existing prefix is
83
86
  * canonicalised and the rest appended verbatim — `resolve(strict=False)`'s behaviour.
84
87
  */
85
- function pyResolve(p) {
88
+ function resolvePath(p) {
86
89
  let cur = path.resolve(expanduser(p));
87
90
  const tail = [];
88
91
  for (;;) {
@@ -121,7 +124,7 @@ function selfAndParents(p) {
121
124
  * the other. The trailing empty is dropped the way Python drops it (`"a\n"` is one line,
122
125
  * `""` is none).
123
126
  */
124
- function pySplitlines(text) {
127
+ function splitLines(text) {
125
128
  if (!text) return [];
126
129
  const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
127
130
  if (parts.length && parts[parts.length - 1] === '') parts.pop();
@@ -129,7 +132,7 @@ function pySplitlines(text) {
129
132
  }
130
133
 
131
134
  /** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
132
- function pyWords(s) {
135
+ function splitWords(s) {
133
136
  return s.split(/\s+/).filter(Boolean);
134
137
  }
135
138
 
@@ -156,7 +159,7 @@ const _ERRNO = {
156
159
  EPERM: [1, 'Operation not permitted'],
157
160
  };
158
161
 
159
- function pyOsError(e, filename) {
162
+ function asOsError(e, filename) {
160
163
  const hit = _ERRNO[e && e.code];
161
164
  if (!hit) return String((e && e.message) || e);
162
165
  // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
@@ -208,10 +211,10 @@ function readStdin() {
208
211
 
209
212
  // The two output funnels, with Python's newline translation reproduced. They moved to
210
213
  // `js/lib/fs.mjs` in P5c — beside `writeText`, which is the same rule for FILES — when
211
- // `js/excludes.mjs` became the second caller; see that docblock for why the translation
214
+ // `js/inspect/excludes.mjs` became the second caller; see that docblock for why the translation
212
215
  // exists and which gate can see it.
213
- const out = pyPrint;
214
- const err = pyPrintErr;
216
+ const out = printOut;
217
+ const err = printErr;
215
218
 
216
219
  // ======================================================================================
217
220
  // context — project-context discovery
@@ -243,7 +246,7 @@ export function globalHookStandingDown(hookRoot, cwd) {
243
246
  if (isFile(path.join(cand, GENESEED_MANIFEST))) {
244
247
  // Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
245
248
  // the same install there and two different ones on Linux.
246
- return normcase(pyResolve(cand)) !== normcase(pyResolve(hookRoot));
249
+ return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
247
250
  }
248
251
  }
249
252
  return false;
@@ -265,7 +268,7 @@ export function sovereignBypass(root) {
265
268
  }
266
269
  let cwd;
267
270
  try {
268
- cwd = normcase(pyResolve(process.cwd()));
271
+ cwd = normcase(resolvePath(process.cwd()));
269
272
  } catch {
270
273
  return false;
271
274
  }
@@ -281,7 +284,7 @@ export function sovereignBypass(root) {
281
284
  // and this caller catches: the reference prints nothing here, so replaying the
282
285
  // message would be a stderr divergence on every hook call for one bad entry.
283
286
  base = withDiscardableStderr(
284
- () => normcase(pyPathStr(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
287
+ () => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
285
288
  } catch {
286
289
  // PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
287
290
  // must not decide the whole function — `expanduser` refuses a `~user` form, and the
@@ -381,7 +384,7 @@ export function resolveContextSets(root) {
381
384
  if (env && isFile(env)) {
382
385
  // `Path(env)`, and the label is printed — so the separators are folded exactly the
383
386
  // way `str(Path(...))` folds them, or the source line differs by every slash in it.
384
- manifest = pyPathStr(env);
387
+ manifest = toPlatformPath(env);
385
388
  } else {
386
389
  for (const cand of [path.join(root, '.harness', 'context.json'),
387
390
  path.join(root, 'context.json')]) {
@@ -398,7 +401,7 @@ export function resolveContextSets(root) {
398
401
  try {
399
402
  raw = readText(manifest);
400
403
  } catch (e) {
401
- err(`[context] could not read ${manifest}: ${pyOsError(e, manifest)}\n`);
404
+ err(`[context] could not read ${manifest}: ${asOsError(e, manifest)}\n`);
402
405
  return [[], [], String(manifest)];
403
406
  }
404
407
  let entries;
@@ -453,7 +456,7 @@ export function resolveContextSets(root) {
453
456
  }
454
457
  continue;
455
458
  }
456
- const abs = path.isAbsolute(raw) ? raw : pyResolve(path.join(root, raw));
459
+ const abs = path.isAbsolute(raw) ? raw : resolvePath(path.join(root, raw));
457
460
  put(abs, load, desc);
458
461
  }
459
462
 
@@ -468,8 +471,8 @@ export function resolveContextSets(root) {
468
471
  export function cmdContext(args) {
469
472
  // Discovery runs against the project root the hook was launched from — Claude runs
470
473
  // SessionStart hooks with cwd = repo root — not the harness package dir.
471
- // The only two `pyResolve` calls in this file whose argument can be a `~user` path the
472
- // user typed — `$GENESEED_ROOT` and `--root` — and `pyResolve` expands, so both can now
474
+ // The only two `resolvePath` calls in this file whose argument can be a `~user` path the
475
+ // user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
473
476
  // throw. Every other caller either builds its argument by `path.join` from an absolute
474
477
  // root (so no leading tilde survives) or already sits inside a `try`. Refused input
475
478
  // injects nothing, which is the same degrade as "nothing to load" below.
@@ -477,8 +480,8 @@ export function cmdContext(args) {
477
480
  let hookRoot;
478
481
  try {
479
482
  ({ root, hookRoot } = withDiscardableStderr(() => ({
480
- root: pyResolve(process.env.GENESEED_ROOT || process.cwd()),
481
- hookRoot: args.root ? pyResolve(args.root) : null,
483
+ root: resolvePath(process.env.GENESEED_ROOT || process.cwd()),
484
+ hookRoot: args.root ? resolvePath(args.root) : null,
482
485
  })));
483
486
  } catch {
484
487
  return 0;
@@ -505,7 +508,7 @@ export function cmdContext(args) {
505
508
  try {
506
509
  lines.push(readText(target).replace(/\n+$/, ''));
507
510
  } catch (e) {
508
- lines.push(`[context] MISSING eager file: ${pyOsError(e, target)}`);
511
+ lines.push(`[context] MISSING eager file: ${asOsError(e, target)}`);
509
512
  }
510
513
  lines.push('');
511
514
  }
@@ -583,9 +586,9 @@ export function ruleGateTarget(p, root) {
583
586
  if (name === 'memory.md') return 'the memory index';
584
587
  if (!root || !name.endsWith('.md')) return null;
585
588
  try {
586
- const store = normcase(pyResolve(path.join(root, 'memory')));
589
+ const store = normcase(resolvePath(path.join(root, 'memory')));
587
590
  // `.parents`, so the store dir itself is not one of its own ancestors.
588
- return selfAndParents(pyResolve(p)).slice(1).some((d) => normcase(d) === store)
591
+ return selfAndParents(resolvePath(p)).slice(1).some((d) => normcase(d) === store)
589
592
  ? 'memory' : null;
590
593
  } catch {
591
594
  return null;
@@ -660,11 +663,11 @@ export function appendAgentLesson(memDir, agent, lesson) {
660
663
  mkdirSync(d, { recursive: true });
661
664
  const f = path.join(d, `${agent}.md`);
662
665
  let bullets = [];
663
- if (existsSync(f)) bullets = pySplitlines(readText(f)).filter((l) => l.startsWith('- '));
666
+ if (existsSync(f)) bullets = splitLines(readText(f)).filter((l) => l.startsWith('- '));
664
667
  const now = new Date();
665
668
  const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
666
669
  + `${String(now.getDate()).padStart(2, '0')}`;
667
- bullets.push(`- ${day}: ${pyWords(lesson).join(' ')}`);
670
+ bullets.push(`- ${day}: ${splitWords(lesson).join(' ')}`);
668
671
  bullets = bullets.slice(-MAX_AGENT_BULLETS);
669
672
  writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
670
673
  return f;
@@ -703,7 +706,7 @@ function flattenTranscript(p) {
703
706
  return '';
704
707
  }
705
708
  const acc = [];
706
- for (let line of pySplitlines(raw)) {
709
+ for (let line of splitLines(raw)) {
707
710
  line = line.trim();
708
711
  if (!line) continue;
709
712
  let obj;
@@ -747,7 +750,7 @@ export function readNotes(raw) {
747
750
  return raw;
748
751
  }
749
752
 
750
- // `resolveMemoryDir` moved to `js/hosts.mjs` in P5d — see the import at the top of this
753
+ // `resolveMemoryDir` moved to `js/hosts/hosts.mjs` in P5d — see the import at the top of this
751
754
  // file. Re-exported here so the hook entry's own import list does not change.
752
755
  export { resolveMemoryDir };
753
756
 
@@ -764,7 +767,7 @@ export function frontmatter(md) {
764
767
  const m = FRONTMATTER_RE.exec(md);
765
768
  if (!m) return [new Map(), md];
766
769
  const fm = new Map();
767
- for (const line of pySplitlines(m[1])) {
770
+ for (const line of splitLines(m[1])) {
768
771
  const at = line.indexOf(':');
769
772
  if (at >= 0) {
770
773
  // `str.strip('"')` strips the character from BOTH ends, however many there are.
@@ -853,7 +856,7 @@ export function memoryDropIndex(memDir, name) {
853
856
  const idx = path.join(memDir, 'MEMORY.md');
854
857
  let lines;
855
858
  try {
856
- lines = pySplitlines(readText(idx));
859
+ lines = splitLines(readText(idx));
857
860
  } catch {
858
861
  return; // the reference's `except OSError: return`
859
862
  }
@@ -882,7 +885,7 @@ export function consolidateMemory(memDir) {
882
885
  const index = path.join(memDir, 'MEMORY.md');
883
886
  const oldSlugs = new Set();
884
887
  if (existsSync(index)) {
885
- for (const line of pySplitlines(readText(index))) {
888
+ for (const line of splitLines(readText(index))) {
886
889
  const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
887
890
  if (m) oldSlugs.add(m[1]);
888
891
  }
@@ -912,7 +915,7 @@ export function consolidateMemory(memDir) {
912
915
  * would truncate a long reply into a silently-half-parsed set of memory files.
913
916
  */
914
917
  function runLlm(llm, prompt) {
915
- const argv = pyWords(llm);
918
+ const argv = splitWords(llm);
916
919
  // `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
917
920
  // which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
918
921
  // otherwise pops a console for as long as it takes to answer.
@@ -958,7 +961,7 @@ function learnAgentLesson(meta, notes, args) {
958
961
  const proc = runLlm(llm, prompt);
959
962
  const text = proc.stdout.trim();
960
963
  if (text && text.toUpperCase() !== 'NOTHING') {
961
- const lesson = pySplitlines(text)[0].replace(/^[-*]+/, '').trim();
964
+ const lesson = splitLines(text)[0].replace(/^[-*]+/, '').trim();
962
965
  const n = Array.from(lesson).length; // code points, as `len()` counts them
963
966
  if (n >= 10 && n <= 300) {
964
967
  err(`[learn] agent lesson -> ${appendAgentLesson(memDir, agent, lesson)}\n`);
@@ -985,7 +988,7 @@ export function cmdLearn(args) {
985
988
 
986
989
  // The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
987
990
  // folder the global install must not learn.
988
- if (args.memory && sovereignBypass(path.dirname(pyPathStr(args.memory)))) return 0;
991
+ if (args.memory && sovereignBypass(path.dirname(toPlatformPath(args.memory)))) return 0;
989
992
 
990
993
  const raw = args.file ? readText(args.file) : readStdin();
991
994
  let notes = readNotes(raw);
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * The four host config dirs, and the two path primitives they are built on.
3
3
  *
4
- * EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/geneseed.mjs`
4
+ * EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/build-driver.mjs`
5
5
  * owned these; `bin/geneseed-cli.mjs` needs the same four to find a global install, and a
6
6
  * resolver that decides WHERE a driver writes is the last thing that should exist twice.
7
- * `js/hooks.mjs` carries a third copy of `opencodeConfigDir` on purpose — the hook path is
8
- * deliberately dependency-free and says so — but the CLI has no such reason, so this is one
9
- * owner for both drivers rather than a second exception.
10
- *
11
- * The move is a pure one, which is why it could be made at all: `tests/golden.py` runs
12
- * `bin/geneseed.mjs` over 259 cells and compares the tree byte-for-byte, and roughly half
13
- * of those resolve a global config dir. A move that changed one of these by a character
14
- * fails ~126 cells, not zero.
7
+ * `js/hosts/hooks.mjs` USED to carry a third copy of `opencodeConfigDir`, on the rule that the
8
+ * hook path stays dependency-free; P5d deleted it when `resolveMemoryDir` moved here and the
9
+ * hook became an importer instead. This module is now the single owner for both drivers AND
10
+ * for the hook.
11
+ *
12
+ * The move is a pure one. What holds it is no longer a byte corpus — `tests/golden.mjs`'s two
13
+ * modes compare a run against another run of the SAME generator, so they would follow a
14
+ * changed resolver rather than catch it — but `tests/unit/node_driver.test.mjs`'s relocation
15
+ * table, which asserts each host's global target absolutely.
15
16
  */
16
17
  import { realpathSync, statSync } from 'node:fs';
17
18
  import path from 'node:path';
18
19
  import os from 'node:os';
19
- import { pyPathStr, pyPrintErr, withDiscardableStderr } from './lib/fs.mjs';
20
+ import { printErr, withDiscardableStderr } from '../lib/fs.mjs';
21
+ import { toPlatformPath } from '../lib/paths.mjs';
20
22
 
21
23
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
22
24
 
@@ -24,8 +26,8 @@ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return
24
26
  export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
25
27
 
26
28
  /**
27
- * `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/emit.mjs` and
28
- * `js/diff.mjs` each had a private const of their own and `js/uninstall.mjs` would have made
29
+ * `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
30
+ * `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
29
31
  * a third copy of a value whose whole job is being the same string everywhere. Same
30
32
  * arithmetic that put `GLOBAL_MANIFEST` above it.
31
33
  */
@@ -66,7 +68,7 @@ export function expanduser(p) {
66
68
  const msg = `refusing '${p}': a '~user' path is not expanded by this port (Python's `
67
69
  + "ntpath/posixpath would guess a home directory for 'user' — this port does not); "
68
70
  + 'pass an absolute path, or "~" / "~/…" for your own home directory';
69
- pyPrintErr(`${msg}\n`);
71
+ printErr(`${msg}\n`);
70
72
  const e = new Error(msg);
71
73
  e.exitCode = 1;
72
74
  throw e;
@@ -83,7 +85,7 @@ export function expanduser(p) {
83
85
  * the part that exists and appends the rest verbatim, so that is what this reproduces.
84
86
  * It matters because the resolved directory is printed on stdout and compared byte-for-byte.
85
87
  */
86
- export function pyResolve(p) {
88
+ export function resolvePath(p) {
87
89
  let cur = path.resolve(expanduser(p));
88
90
  const tail = [];
89
91
  for (;;) {
@@ -100,7 +102,7 @@ export function pyResolve(p) {
100
102
 
101
103
  /**
102
104
  * `_build_core._opencode_config_dir` — and the four resolvers like it are the reason
103
- * `bin/geneseed.mjs` exists rather than a child doing the work.
105
+ * `bin/build-driver.mjs` exists rather than a child doing the work.
104
106
  *
105
107
  * P3c's rule was "the child must never resolve this": a render child that did would write
106
108
  * 135 files into the developer's real `~/.config/opencode`. A driver is the PARENT, so the
@@ -109,10 +111,10 @@ export function pyResolve(p) {
109
111
  */
110
112
  export function opencodeConfigDir() {
111
113
  const env = process.env.OPENCODE_CONFIG_DIR;
112
- if (env) return pyResolve(env);
114
+ if (env) return resolvePath(env);
113
115
  const xdg = process.env.XDG_CONFIG_HOME;
114
116
  const base = xdg ? expanduser(xdg) : path.join(os.homedir(), '.config');
115
- return pyResolve(path.join(base, 'opencode'));
117
+ return resolvePath(path.join(base, 'opencode'));
116
118
  }
117
119
 
118
120
  /**
@@ -125,8 +127,8 @@ export function opencodeConfigDir() {
125
127
  */
126
128
  export function copilotConfigDir() {
127
129
  const env = process.env.COPILOT_CONFIG_DIR;
128
- if (env) return pyResolve(env);
129
- return pyResolve(path.join(os.homedir(), '.copilot'));
130
+ if (env) return resolvePath(env);
131
+ return resolvePath(path.join(os.homedir(), '.copilot'));
130
132
  }
131
133
 
132
134
  /**
@@ -141,14 +143,14 @@ export function copilotConfigDir() {
141
143
  * as an omission.
142
144
  */
143
145
  export function claudeConfigDir() {
144
- return pyResolve(path.join(os.homedir(), '.claude'));
146
+ return resolvePath(path.join(os.homedir(), '.claude'));
145
147
  }
146
148
 
147
149
  /** `_build_core._bob_config_dir` — `~/.bob`, relocatable via `$BOB_CONFIG_DIR`. */
148
150
  export function bobConfigDir() {
149
151
  const env = process.env.BOB_CONFIG_DIR;
150
- if (env) return pyResolve(env);
151
- return pyResolve(path.join(os.homedir(), '.bob'));
152
+ if (env) return resolvePath(env);
153
+ return resolvePath(path.join(os.homedir(), '.bob'));
152
154
  }
153
155
 
154
156
  /**
@@ -158,14 +160,14 @@ export function bobConfigDir() {
158
160
  * An array rather than an object because the order is observable output, not an
159
161
  * implementation detail: `harness exclude add` walks it and prints one message per host, so
160
162
  * a reordering is a diff in stderr. (`emit_global` and `native_catalog` stay out;
161
- * `bin/geneseed.mjs` receives those decisions from its own dispatch rather than reading a
163
+ * `bin/build-driver.mjs` receives those decisions from its own dispatch rather than reading a
162
164
  * table.)
163
165
  *
164
166
  * `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
165
167
  * install of host H", and `_claude_cfg` asks "which subdir holds a project install's
166
168
  * manifest" — both are the same `.opencode`/`.claude`/`.bob`/`.github` value the Python reads
167
169
  * out of `build.HOSTS[host]["project_marker"]`, so it belongs beside the config dir rather
168
- * than in a second table in `js/installs.mjs`.
170
+ * than in a second table in `js/hosts/installs.mjs`.
169
171
  *
170
172
  * `agentFile` joined in P5h, and for one caller: `cmd_uninstall` names the managed block's
171
173
  * carrier in its "removes:" preamble (`the CLAUDE.md managed block`, `the AGENTS.md managed
@@ -213,7 +215,7 @@ const MEMORY_DIR_NAMES = ['memory', 'anamnesis'];
213
215
  * The last two matter for the recommended opencode-global install, whose store lives in
214
216
  * `~/.config/opencode` rather than beside any repo. null => stdout-only.
215
217
  *
216
- * HERE rather than in `js/hooks.mjs`, where it was written, because P5d gave it a second
218
+ * HERE rather than in `js/hosts/hooks.mjs`, where it was written, because P5d gave it a second
217
219
  * caller that cannot import that file: `bin/geneseed-cli.mjs` is under a transitive
218
220
  * `child_process` ban and `learn` spawns the model CLI. This module is the one both can
219
221
  * reach, and it already owns the `opencodeConfigDir` the last fallback needs — so the move
@@ -221,18 +223,18 @@ const MEMORY_DIR_NAMES = ['memory', 'anamnesis'];
221
223
  */
222
224
  export function resolveMemoryDir(explicit) {
223
225
  if (explicit) {
224
- const p = pyPathStr(explicit);
226
+ const p = toPlatformPath(explicit);
225
227
  return isDir(p) ? p : null;
226
228
  }
227
229
  const env = process.env.GENESEED_MEMORY;
228
- if (env && isDir(env)) return pyPathStr(env);
230
+ if (env && isDir(env)) return toPlatformPath(env);
229
231
  const cwd = process.cwd();
230
232
  const bases = [cwd, path.join(cwd, 'Harness')];
231
233
  const gh = process.env.GENESEED_HARNESS;
232
234
  if (gh) {
233
235
  // PER BASE, and the walk continues. `$GENESEED_HARNESS` is the FOURTH user-controlled
234
236
  // tilde input and the one the `~user` refusal was never swept onto: `expanduser` throws
235
- // on `~someone/x`, and this resolver is reached by `geneseed status` (js/status.mjs's
237
+ // on `~someone/x`, and this resolver is reached by `geneseed status` (js/inspect/status.mjs's
236
238
  // `resolveMemoryDir(null)`) and by the `learn` hook, whose entry point
237
239
  // (`bin/geneseed-hook.mjs`) has NO top-level try — so an unguarded throw here is a stack
238
240
  // trace on a hook path and a non-zero exit, the exact failure the other three guards
@@ -2,9 +2,9 @@
2
2
  * How a DEPLOYED install is recognised — `_harness_setup`'s detection half and
3
3
  * `_harness_mcp`'s registry-reading half, which are one subject in two Python files.
4
4
  *
5
- * EXTRACTED IN P5f, and by the same arithmetic that produced `js/hosts.mjs` and
6
- * `js/checkout.mjs`: this is the third verb-set to need it. P5d wrote `themeOfDir` and
7
- * `footprintOfDir` inside `js/status.mjs` because `status` was the only caller; `diff`
5
+ * EXTRACTED IN P5f, and by the same arithmetic that produced `js/hosts/hosts.mjs` and
6
+ * `js/build/source.mjs`: this is the third verb-set to need it. P5d wrote `themeOfDir` and
7
+ * `footprintOfDir` inside `js/inspect/status.mjs` because `status` was the only caller; `diff`
8
8
  * renders its 'expected' copy in the theme and footprint the deployment actually uses, and
9
9
  * `rebuild-all` re-emits every install in its own theme, emit, footprint, posture and mode.
10
10
  * Three callers of one detector is where a private helper becomes a module.
@@ -32,13 +32,15 @@
32
32
  * web cells recorded it. Both candidates are gone (the argument is in the Python), and the
33
33
  * claim is now true rather than aspirational.
34
34
  */
35
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
35
+ import { existsSync, readdirSync, statSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
- import { CONFIG, PACK_ORDER, THEMES, discoverNames } from './checkout.mjs';
39
- import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
40
- import { registryRoots } from './registry.mjs';
41
- import { comparePaths, pyPrintErr, pyRepr, readText } from './lib/fs.mjs';
38
+ import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
39
+ import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
+ import { registryRoots } from '../inspect/registry.mjs';
41
+ import { printErr, readText } from '../lib/fs.mjs';
42
+ import { formatRepr } from '../lib/json.mjs';
43
+ import { comparePaths } from '../lib/paths.mjs';
42
44
 
43
45
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
44
46
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
@@ -99,7 +101,7 @@ export const defaultMode = () => configuredDefault('mode', 'direct');
99
101
  // pre-2.3 install on its first upgrade — `{"doctrines":["craft"]}` and the gate is gone from
100
102
  // carriers that never asked. Unknown resolves to ALL packs; see `doctrinesForBuild` below.
101
103
  // The config value keeps exactly one legitimate reader: `configDefaults()` in
102
- // `bin/geneseed.mjs`, which answers `geneseed build` — the case where there is no install to
104
+ // `bin/build-driver.mjs`, which answers `geneseed build` — the case where there is no install to
103
105
  // ask in the first place.
104
106
 
105
107
  // ---- theme, footprint, posture, mode, read back off a deployed tree -----------------------
@@ -180,7 +182,7 @@ export function footprintOfDir(d) {
180
182
  const v = (readMaybe(marker) ?? '').trim();
181
183
  if (FOOTPRINTS.includes(v)) return v;
182
184
  if (v) {
183
- pyPrintErr(`[geneseed] WARN: ${marker} holds unknown footprint ${pyRepr(v)} — `
185
+ printErr(`[geneseed] WARN: ${marker} holds unknown footprint ${formatRepr(v)} — `
184
186
  + `reading it as 'full'. Known: ${FOOTPRINTS.join(', ')}.\n`);
185
187
  }
186
188
  }
@@ -202,7 +204,7 @@ export function footprintOfDir(d) {
202
204
  * lowercasing. Every shipped name is already lowercase, which is exactly why the difference
203
205
  * would go unnoticed — the corpus in `tests/test_pure_function_parity.py` is what covers it.
204
206
  */
205
- export function pyCapitalize(s) {
207
+ export function capitalize(s) {
206
208
  return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
207
209
  }
208
210
 
@@ -211,7 +213,7 @@ function leadOfDir(d, names) {
211
213
  const text = readMaybe(path.join(d, carrier));
212
214
  if (text === null) continue;
213
215
  for (const name of names) {
214
- if (text.includes(`**${pyCapitalize(name)}**`)) return name;
216
+ if (text.includes(`**${capitalize(name)}**`)) return name;
215
217
  }
216
218
  }
217
219
  return null;
@@ -281,7 +283,7 @@ export function doctrinesOfDir(d) {
281
283
  * got WIDER when the reader was hardened, because more inputs now answer `null`.
282
284
  *
283
285
  * A config value is a legitimate default only where there is NO INSTALL TO ASK: a fresh
284
- * `geneseed build`, which `configDefaults()` in `bin/geneseed.mjs` answers. Anything holding a
286
+ * `geneseed build`, which `configDefaults()` in `bin/build-driver.mjs` answers. Anything holding a
285
287
  * directory has an install to ask, so it calls this and never the config.
286
288
  *
287
289
  * `[]` still passes through untouched — a marker that reads `none` is an answer, not a silence.
@@ -290,6 +292,47 @@ export function doctrinesForBuild(d) {
290
292
  return doctrinesOfDir(d) ?? [...PACK_ORDER];
291
293
  }
292
294
 
295
+ /** `Excluded rules: process 7, craft 3` — the marker's optional second line. */
296
+ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
297
+
298
+ /**
299
+ * The individual doctrine rules a DEPLOYED install left out — the second axis, read back.
300
+ *
301
+ * ⚠ ITS NULL SEMANTICS ARE THE OPPOSITE OF `doctrinesOfDir`'S, AND THAT IS THE POINT. A
302
+ * missing `Active packs:` line means "this install does not say", which has to resolve to
303
+ * ALL packs or a pre-migration install loses its consent gate on the next upgrade. A missing
304
+ * `Excluded rules:` line means "nothing was excluded" — because the line is only ever written
305
+ * when something is, so its absence is a STATEMENT rather than a silence. Both answers fail
306
+ * closed: unknown packs bind the most, unknown exclusions take away the least. They only look
307
+ * inconsistent until you ask which direction is safe for each.
308
+ *
309
+ * That is also what makes this backward compatible by construction: every install that exists
310
+ * today has no such line, reads as `[]`, and behaves exactly as it does now.
311
+ *
312
+ * A malformed entry condemns the whole line to `[]` rather than to the part that parsed —
313
+ * same rule as the pack list, same reason, opposite default. Half an exclusion list would
314
+ * take away rules nobody named.
315
+ */
316
+ export function excludedRulesOfDir(d) {
317
+ for (const carrier of CARRIERS) {
318
+ const text = readMaybe(path.join(d, carrier));
319
+ if (text === null) continue;
320
+ const m = EXCLUDED_RULES_RE.exec(text);
321
+ if (!m) continue;
322
+ if (m[1] === 'none') return [];
323
+ const named = m[1].split(',').map((s) => s.trim());
324
+ // `<pack> <n>` on the page, `<pack>.<n>` in the code — the same asymmetry the rendered
325
+ // line documents. A name this checkout does not ship is a corrupt line, not a hint.
326
+ const ids = named.map((s) => {
327
+ const parts = s.split(/[ \t]+/);
328
+ return parts.length === 2 && PACK_ORDER.includes(parts[0]) && /^\d+$/.test(parts[1])
329
+ ? `${parts[0]}.${Number(parts[1])}` : null;
330
+ });
331
+ return ids.every(Boolean) ? [...new Set(ids)].sort() : [];
332
+ }
333
+ return [];
334
+ }
335
+
293
336
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
294
337
 
295
338
  /** `_harness_mcp._claude_read_manifest`. */
@@ -377,7 +420,7 @@ export function registeredTargets() {
377
420
  * De-duplicated on the (host, RESOLVED path) pair, which is three behaviours in one rule: a
378
421
  * cwd carrying both `.opencode/` and `.claude/` yields two correctly-typed rows, a cwd that
379
422
  * IS a global config dir never doubles its own host's row, and a registered root that is also
380
- * the cwd never doubles either. `pyResolve` rather than `path.resolve` because Python's
423
+ * the cwd never doubles either. `resolvePath` rather than `path.resolve` because Python's
381
424
  * `Path.resolve()` returns the filesystem's own casing — on Windows two spellings of the same
382
425
  * directory differ as strings and not as installs, and this Set is keyed by string.
383
426
  */
@@ -392,12 +435,12 @@ export function installTargets() {
392
435
  if (scope !== 'global') {
393
436
  try {
394
437
  const spec = HOSTS.find((h) => h.host === host);
395
- if (pyResolve(path.join(root, spec.projectMarker)) === pyResolve(spec.configDir())) {
438
+ if (resolvePath(path.join(root, spec.projectMarker)) === resolvePath(spec.configDir())) {
396
439
  return;
397
440
  }
398
441
  } catch { /* as the Python's bare `except Exception: pass` */ }
399
442
  }
400
- const key = `${host}\0${pyResolve(root)}`;
443
+ const key = `${host}\0${resolvePath(root)}`;
401
444
  if (!seen.has(key)) { seen.add(key); out.push([host, scope, root]); }
402
445
  };
403
446
 
@@ -437,6 +480,7 @@ export function installTargets() {
437
480
  export function installedDefaults() {
438
481
  const found = {
439
482
  theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
483
+ excludeRules: null,
440
484
  };
441
485
  const candidates = [];
442
486
  for (const { host, configDir } of HOSTS) {
@@ -470,6 +514,14 @@ export function installedDefaults() {
470
514
  // for a carrier with no marker, which would end this walk at the FIRST candidate and hide
471
515
  // a later carrier that does say. The null has to survive to the caller here.
472
516
  if (found.doctrines === null) found.doctrines = doctrinesOfDir(base);
517
+ // ⚠ THE PER-RULE AXIS HAS ONLY TWO STATES, so the walk cannot use `=== null` to mean
518
+ // "keep looking" the way the line above does — `excludedRulesOfDir` answers `[]` for a
519
+ // carrier that excludes nothing AND for one with no marker at all, and the empty list is a
520
+ // real answer. An EMPTY answer therefore keeps the walk going and a non-empty one stops it:
521
+ // the first carrier on this machine that actually names an exclusion is the one that
522
+ // pre-selects. The cost of that choice is that "excludes nothing" and "no carrier" are
523
+ // indistinguishable here, which is exactly what the marker itself already says.
524
+ if (!found.excludeRules?.length) found.excludeRules = excludedRulesOfDir(base);
473
525
  }
474
526
  return found;
475
527
  }