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
@@ -20,7 +20,7 @@
20
20
  * `js_cfg()` (_build_core.py:199) always sends `structure` and `capabilityLinkRe`, because
21
21
  * the Python originals are module-level names that TESTS MUTATE — `_OWNED` membership
22
22
  * asked one level out. This driver has no Python module to mutate, so it sends neither and
23
- * `js/render.mjs:215`'s `cfg.structure ?? STRUCTURE` and `js/emit.mjs:680`'s
23
+ * `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE` and `js/build/bundle.mjs`'s
24
24
  * `cfg.capabilityLinkRe ? ... : <default>` take their right-hand branches for the first
25
25
  * time. Those two fallbacks have been dead code since they were written — always
26
26
  * overridden by the driver that always supplies them. P4 is what makes them live.
@@ -36,33 +36,36 @@ import {
36
36
  } from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import { fileURLToPath } from 'node:url';
39
- import {
40
- build, emitOpencodeRender, emitOpencodeGlobalRender, emitClaudeRender, phaseLog,
41
- } from '../js/emit.mjs';
42
- import { settingsIntegrityCheck } from '../js/settings.mjs';
43
- import { writeText, parseJson, jsonDumpsIndent, withPyNewlines } from '../js/lib/fs.mjs';
39
+ import { build, phaseLog } from '../js/build/bundle.mjs';
40
+ import { emitClaudeRender } from '../js/build/emit-claude.mjs';
41
+ import { emitOpencodeRender, emitOpencodeGlobalRender } from '../js/build/emit-opencode.mjs';
42
+ import { settingsIntegrityCheck } from '../js/hosts/settings.mjs';
43
+ import { writeText, withPlatformNewlines } from '../js/lib/fs.mjs';
44
+ import { parseJson, jsonDumpsIndent } from '../js/lib/json.mjs';
44
45
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
45
46
  // find a global install, and a resolver that decides WHERE a driver writes is the last thing
46
47
  // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
47
48
  import {
48
- GLOBAL_MANIFEST, pyResolve, opencodeConfigDir, claudeConfigDir, bobConfigDir,
49
+ GLOBAL_MANIFEST, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
49
50
  copilotConfigDir,
50
- } from '../js/hosts.mjs';
51
+ } from '../js/hosts/hosts.mjs';
51
52
 
52
53
  // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
53
54
  // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
54
55
  // same cfg. golden.py's 259 cells all build one, which is what made the move safe.
55
- import { ROOT, CONFIG, THEMES, discoverNames, makeCfg, PACK_ORDER } from '../js/checkout.mjs';
56
+ import {
57
+ ROOT, CONFIG, discoverNames, makeCfg, PACK_ORDER, knownRuleIds,
58
+ } from '../js/build/source.mjs';
56
59
 
57
60
  // P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
58
61
  // registry to find every install it must re-emit, and a CLI verb may not reach into a driver
59
- // for a reader. See js/registry.mjs.
60
- import { registryRecord, registryRoots } from '../js/registry.mjs';
62
+ // for a reader. See js/inspect/registry.mjs.
63
+ import { registryRecord, registryRoots } from '../js/inspect/registry.mjs';
61
64
 
62
65
  // P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
63
66
  // of textual surgery over committed files with a corpus of its own, and it is the half of the
64
67
  // maintainer pair that needs nothing this driver is banned from having.
65
- import { syncThemes } from '../js/themes.mjs';
68
+ import { syncThemes } from '../js/build/themes.mjs';
66
69
 
67
70
  /** The nine `--emit` choices, in build.py:337-338's order. */
68
71
  const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
@@ -93,24 +96,24 @@ const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: fals
93
96
  /** `_build_render.resolve_out` — absolute, or relative to the CURRENT WORKING DIRECTORY
94
97
  * (not to ROOT), so the harness renders straight into any repository.
95
98
  *
96
- * EXPORTED IN P5i because `js/setup.mjs` became its second caller: `_setup_summary_lines`
99
+ * EXPORTED IN P5i because `js/maintain/setup.mjs` became its second caller: `_setup_summary_lines`
97
100
  * names the AGENT.md a bundle emit just wrote, and it names it by the same resolution the
98
101
  * emit used. Copying the one line would be a second owner of the rule that a bare `Harness`
99
102
  * follows the invocation and not the checkout — which is what
100
103
  * `build/the-bundle-follows-cwd-not-the-checkout` exists to gate.
101
104
  *
102
- * `pyResolve` AND NOT `path.resolve`, and the difference is the whole reason `pyResolve`
105
+ * `resolvePath` AND NOT `path.resolve`, and the difference is the whole reason `resolvePath`
103
106
  * exists: the reference ends in `.resolve()`, which canonicalises — 8.3 short names expanded
104
107
  * on Windows, symlinks followed on POSIX, the filesystem's own casing — where `path.resolve`
105
- * only normalises `.`/`..`. Nine `pyResolve` call sites had the rule and this one did not.
108
+ * only normalises `.`/`..`. Nine `resolvePath` call sites had the rule and this one did not.
106
109
  * It is invisible on a machine whose paths are already canonical, which is every machine
107
110
  * this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
108
111
  * two CLIs printed the same directory under two different names — a 70-byte difference in a
109
112
  * line compared BYTE for byte by `test_the_two_entry_points_agree_on_stdout_BYTES`. The
110
- * argument to `pyResolve` is made absolute first so its `expanduser` cannot fire: the
113
+ * argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
111
114
  * reference does not expand `~` here, and a bare `~` is a legal directory name. */
112
115
  export function resolveOut(raw) {
113
- return pyResolve(path.resolve(process.cwd(), raw));
116
+ return resolvePath(path.resolve(process.cwd(), raw));
114
117
  }
115
118
 
116
119
  /**
@@ -124,6 +127,7 @@ export function resolveOut(raw) {
124
127
  function configDefaults() {
125
128
  const d = {
126
129
  theme: 'neutral', posture: 'peer', mode: 'direct', doctrines: [...PACK_ORDER],
130
+ excludeRules: [],
127
131
  };
128
132
  if (!existsSync(CONFIG)) return d;
129
133
  try {
@@ -146,6 +150,19 @@ function configDefaults() {
146
150
  + 'list of known pack names — using all packs.\n');
147
151
  }
148
152
  }
153
+ // The second axis, read the same forgiving way and defaulting the OTHER direction: a
154
+ // bad value excludes NOTHING, because the safe answer for a list of things to take
155
+ // away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
156
+ if (data.excludeRules !== undefined) {
157
+ if (Array.isArray(data.excludeRules)
158
+ && data.excludeRules.every((x) => typeof x === 'string')) {
159
+ d.excludeRules = [...new Set(data.excludeRules.map((x) => x.replace(/[ \t]+/, '.')))]
160
+ .sort();
161
+ } else {
162
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
163
+ + 'not a list of rule addresses — excluding nothing.\n');
164
+ }
165
+ }
149
166
  }
150
167
  } catch {
151
168
  process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
@@ -162,7 +179,7 @@ function configDefaults() {
162
179
  */
163
180
  const VALUED = {
164
181
  '--theme': 'theme', '--posture': 'posture', '--mode': 'mode',
165
- '--doctrines': 'doctrines',
182
+ '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
166
183
  '--out': 'out', '--target': 'out', '--emit': 'emit',
167
184
  '--footprint': 'footprint', '--root': 'root',
168
185
  };
@@ -202,8 +219,39 @@ function choicesFor() {
202
219
  * carrier, and a marker whose contents depend on argument order is one that compares unequal
203
220
  * to itself. Validation is against DISCOVERY, not `PACK_ORDER`, so the refusal names the
204
221
  * packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
205
- * different fault and `js/render.mjs` refuses the whole build for it.
222
+ * different fault and `js/build/render.mjs` refuses the whole build for it.
223
+ */
224
+ /**
225
+ * `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
226
+ *
227
+ * BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
228
+ * every citation in the corpus spell a rule `process 7`; the console's deep link and the
229
+ * catalogue spell it `process.7`. A flag that took only one of them would be wrong for
230
+ * whichever half of the documentation the reader had open, so it takes either and normalises
231
+ * to the dotted form the code compares.
232
+ *
233
+ * ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
234
+ * refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
235
+ * a typo that would otherwise take away nothing in silence, which is the failure this flag
236
+ * can least afford: the user asked for a rule to STOP binding and it would keep binding.
237
+ * Sorted, deduped, for the same marker-stability reason `--doctrines` is.
206
238
  */
239
+ function parseExcludeRules(value) {
240
+ const s = value.trim();
241
+ if (s === 'none' || s === '') return [];
242
+ const known = knownRuleIds();
243
+ const out = [];
244
+ for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
245
+ const id = raw.replace(/[ \t]+/, '.');
246
+ if (!known.includes(id)) {
247
+ die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
248
+ + `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
249
+ }
250
+ out.push(id);
251
+ }
252
+ return [...new Set(out)].sort();
253
+ }
254
+
207
255
  function parseDoctrines(value) {
208
256
  const s = value.trim();
209
257
  const known = discoverNames('doctrines', PACK_ORDER[0]);
@@ -264,6 +312,7 @@ function parseArgs(argv, defaults) {
264
312
  const args = {
265
313
  theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
266
314
  doctrines: defaults.doctrines,
315
+ excludeRules: defaults.excludeRules,
267
316
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
268
317
  syncThemes: false, validateOnly: false, verbose: false,
269
318
  };
@@ -296,6 +345,7 @@ function parseArgs(argv, defaults) {
296
345
  // Only when the flag was actually PASSED is this a string; left at its default it is
297
346
  // already the array `configDefaults` built, and re-parsing an array would stringify it.
298
347
  if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
348
+ if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
299
349
  return args;
300
350
  }
301
351
 
@@ -308,11 +358,11 @@ function parseArgs(argv, defaults) {
308
358
  * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
309
359
  * `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
310
360
  *
311
- * `withPyNewlines` because the refusal and `-h` paths WRITE: called from `bin/geneseed.mjs`
361
+ * `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
312
362
  * they sit inside `main`'s funnel, and called from the CLI binary they would not.
313
363
  */
314
364
  export function parseDriverArgs(argv) {
315
- return withPyNewlines(() => parseArgs(argv, configDefaults()));
365
+ return withPlatformNewlines(() => parseArgs(argv, configDefaults()));
316
366
  }
317
367
 
318
368
  function choice(flag, value, allowed) {
@@ -392,7 +442,7 @@ function die(code, msg) {
392
442
  * re-emit, which is P10's migration arriving five phases early.
393
443
  *
394
444
  * EXPORTED SINCE P6i, because the emitter is no longer its only caller.
395
- * `js/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
445
+ * `js/maintain/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
396
446
  * Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
397
447
  * correctly. There is exactly one right answer on this side and it is this function, so the
398
448
  * second caller imports it rather than restating two lines that must never drift: a
@@ -415,14 +465,14 @@ export function hookRunnerEntry() {
415
465
  * through `_build_core._claude_config_dir`, an `_OWNED` name precisely because the suite
416
466
  * redirects it at a sandbox, and a redirect that stops at a subprocess half-works in
417
467
  * silence. The child must therefore never derive it — but this file is the PARENT, so
418
- * deriving it is exactly this file's job, and `js/emit.mjs` receives the answer.
468
+ * deriving it is exactly this file's job, and `the host emit` receives the answer.
419
469
  *
420
470
  * POSIX spelling, per the original: `claudeMdExcludes` entries are glob patterns, where a
421
471
  * backslash is an escape, so the Windows-native form risks never matching.
422
472
  */
423
473
  function preambleExclude(claudeMd) {
424
474
  if (path.basename(claudeMd) !== 'CLAUDE.md') return null;
425
- return pyResolve(path.join(claudeConfigDir(), 'CLAUDE.md')).split(path.sep).join('/');
475
+ return resolvePath(path.join(claudeConfigDir(), 'CLAUDE.md')).split(path.sep).join('/');
426
476
  }
427
477
 
428
478
  /**
@@ -517,10 +567,6 @@ function pruneOwned(oc, oldOwned, owned) {
517
567
  }
518
568
  }
519
569
 
520
- function isDir(p) {
521
- try { return statSync(p).isDirectory(); } catch { return false; }
522
- }
523
-
524
570
  /**
525
571
  * `_build_global._project_survivors` — registered per-repo PROJECT installs of `emitName`.
526
572
  *
@@ -687,7 +733,7 @@ function emitOpencodeGlobal(cfg, args, out) {
687
733
  * `_build_global._emit_claude_core` — the shared engine behind six emits.
688
734
  *
689
735
  * Only the two Copilot ones run through it so far, and that is a deliberate split rather
690
- * than an arbitrary stopping point: `js/emit.mjs:1166` gates the entire settings/hook path
736
+ * than an arbitrary stopping point: `js/build/emit-claude.mjs's wire` gates the entire settings/hook path
691
737
  * behind `if (!isCopilot)`, so a Copilot emit never reaches `hookPrefix` and therefore never
692
738
  * needs `hookOpts` — the pair of machine-absolute values (`sys.executable` and the harness
693
739
  * entry point) that a Node driver has no way to produce. Copilot crossing first isolates
@@ -958,7 +1004,7 @@ export function emitProjectInto(host, { theme, out, root, footprint = 'full', do
958
1004
  // same reason: a drift reader that renders `expected` at the DEFAULT pack set reports the
959
1005
  // whole doctrines section as edited on every narrowed install. `null` means "no opinion" and
960
1006
  // `makeCfg` renders all four, which is what every validation caller wants.
961
- return withPyNewlines(() => emit(makeCfg(doctrines ? { doctrines } : {}),
1007
+ return withPlatformNewlines(() => emit(makeCfg(doctrines ? { doctrines } : {}),
962
1008
  { theme, footprint, root }, out));
963
1009
  }
964
1010
 
@@ -971,7 +1017,7 @@ export function emitProjectInto(host, { theme, out, root, footprint = 'full', do
971
1017
  * exactly as `run` inherits it below.
972
1018
  */
973
1019
  export function buildInto({ theme, out, footprint = 'lean' }) {
974
- return withPyNewlines(
1020
+ return withPlatformNewlines(
975
1021
  () => build(makeCfg(), theme, out, { footprint, nativeCatalog: false }),
976
1022
  );
977
1023
  }
@@ -993,7 +1039,7 @@ export function emitGlobalInto(host, {
993
1039
  // rendering at `peer`/`direct`; the drift readers pass what the deployment actually says,
994
1040
  // because rendering `expected` at the defaults reports AGENT.md as edited on every install
995
1041
  // that chose a register — the same scar the footprint left in `diffCollect`, one axis over.
996
- return withPyNewlines(
1042
+ return withPlatformNewlines(
997
1043
  () => emit(
998
1044
  // THE PACK SELECTION RIDES THE SAME RAIL, with one difference: `doctrinesOfDir` answers
999
1045
  // `null` for "no marker / undetectable", and that must render at ALL packs rather than
@@ -1013,16 +1059,16 @@ export function emitGlobalInto(host, {
1013
1059
  * `_harness_build.cmd_build` is `run([sys.executable, BUILD, *extra]).returncode` — Python
1014
1060
  * needs a second process because `build.py` is a different PROGRAM. Here it is a module in
1015
1061
  * the same one, and `bin/geneseed-cli.mjs` is under a transitive `child_process` ban, so
1016
- * `js/generate.mjs` calls this directly. The export is what makes the auto-run below need a
1062
+ * `js/build/generate.mjs` calls this directly. The export is what makes the auto-run below need a
1017
1063
  * guard: without one, importing this file to reach `main` would RUN the generator with the
1018
1064
  * CLI's argv.
1019
1065
  */
1020
1066
  export function main(argv) {
1021
- // `withPyNewlines` is what makes this driver's bytes Python's bytes on Windows, for every
1067
+ // `withPlatformNewlines` is what makes this driver's bytes Python's bytes on Windows, for every
1022
1068
  // print site in the whole render tree at once — see its docblock for why a funnel and not
1023
- // 25 calls to `pyPrint`. It wraps the `catch` as well as `run`, because `die` writes its
1069
+ // 25 calls to `printOut`. It wraps the `catch` as well as `run`, because `die` writes its
1024
1070
  // line on the way out.
1025
- return withPyNewlines(() => {
1071
+ return withPlatformNewlines(() => {
1026
1072
  try {
1027
1073
  return run(argv);
1028
1074
  } catch (e) {
@@ -1043,12 +1089,12 @@ function run(argv) {
1043
1089
  // different places because the transitive `child_process` ban splits them.
1044
1090
  //
1045
1091
  // `--sync-themes` is HERE, because it needs nothing this driver may not have: it reads
1046
- // `themes/`, rewrites the files textually, and prints. `js/themes.mjs` carries it.
1092
+ // `themes/`, rewrites the files textually, and prints. `js/build/themes.mjs` carries it.
1047
1093
  // Non-zero when files were CHANGED (0 == already in sync), so CI can run it as a drift
1048
1094
  // check — build.py:393-397's mapping, and a refusal (exit 2) if the template is unreadable.
1049
1095
  if (args.syncThemes) return syncThemes() ? 1 : 0;
1050
1096
  // `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
1051
- // `js/doctor.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
1097
+ // `js/inspect/checks-authoring.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
1052
1098
  // is under a transitive ban on reaching any such module, gated twice — by a source grep in
1053
1099
  // `tests/test_node_cli_parity.py` and by an import walk in `tests/test_hook_cli_parity.py`.
1054
1100
  // So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
@@ -1123,10 +1169,10 @@ function run(argv) {
1123
1169
  }
1124
1170
 
1125
1171
  /**
1126
- * Run only when this file IS the entry point, not when `js/generate.mjs` imports it.
1172
+ * Run only when this file IS the entry point, not when `js/build/generate.mjs` imports it.
1127
1173
  *
1128
1174
  * `import.meta.main` would say this in one word and is Node 24; the spec's `engines` floor
1129
- * is still open and this machine runs v22, the same reason `js/emit.mjs` avoids it. So the
1175
+ * is still open and this machine runs v22, which is why the comparison is spelled out rather than using it. So the
1130
1176
  * check is the manual one, through `realpathSync` because `process.argv[1]` is whatever the
1131
1177
  * caller typed and `import.meta.url` is always the resolved file — an `npx`-generated shim
1132
1178
  * or a `geneseed link` symlink makes those differ as strings while naming the same file.
@@ -1134,9 +1180,9 @@ function run(argv) {
1134
1180
  * BOTH ways of getting this wrong are gated, which is why it is a guard and not a comment,
1135
1181
  * and both were measured rather than argued:
1136
1182
  *
1137
- * * stuck FALSE — `node bin/geneseed.mjs` becomes a silent no-op: 259 golden cells.
1183
+ * * stuck FALSE — `node bin/build-driver.mjs` becomes a silent no-op: 259 golden cells.
1138
1184
  * * stuck TRUE — importing this file runs the generator on the IMPORTER's argv, so it is
1139
- * not only `build` that breaks. `bin/geneseed-cli.mjs` reaches `js/generate.mjs` at
1185
+ * not only `build` that breaks. `bin/geneseed-cli.mjs` reaches `js/build/generate.mjs` at
1140
1186
  * module load, so every CLI verb dies in the driver's flag parser: **62 of the 166
1141
1187
  * acceptance cells**, `exclude` and `status` and `version` among them.
1142
1188
  */
@@ -4,24 +4,24 @@
4
4
  * invokes. `bin/geneseed-hook.mjs` is the twin of the four it does.
5
5
  *
6
6
  * WHY A THIRD BINARY. P5c's whole question, and the three candidates were: grow the hook
7
- * entry, put verbs in `bin/geneseed.mjs`, or this. Each of the other two costs something
7
+ * entry, put verbs in `bin/build-driver.mjs`, or this. Each of the other two costs something
8
8
  * specific.
9
9
  *
10
10
  * * GROWING `bin/geneseed-hook.mjs` weakens the gate the design now rests on.
11
11
  * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` asserts EQUALITY between
12
- * that file's table and the hooks `js/settings.mjs` wires, and P5b made it load-bearing:
12
+ * that file's table and the hooks `js/hosts/settings.mjs` wires, and P5b made it load-bearing:
13
13
  * the shim is machine-wide (`~/.geneseed/bin/geneseed-hook[.cmd]`, no per-install
14
14
  * component) and last-writer-wins, so a Node emit owns the hooks of installs Python
15
15
  * wrote, and the verb sets matching is what makes that safe. Relaxing it to a subset
16
16
  * relation to admit `exclude` would be the single most damaging edit available here.
17
17
  * Two more reasons point the same way, and the second is not in that file's own
18
- * argument: `exclude`'s writer imports `js/settings.mjs` and `js/emit.mjs`, ~2,100 lines
18
+ * argument: `exclude`'s writer imports `js/hosts/settings.mjs` and `the emit modules`, ~2,100 lines
19
19
  * that would then load on EVERY PreToolUse call — the spec's hook-latency budget says
20
20
  * the shim must exec a minimal entry, not the CLI. And the entry's refusal list, which
21
21
  * names every verb it does not carry, is what makes "silently accepted `doctor` and did
22
22
  * nothing" impossible; every Geneseed hook exits 0 and signals through stdout, so a
23
23
  * no-op and a success are one observation.
24
- * * `bin/geneseed.mjs` IS `build.py`'s `main()` — a different Python program from
24
+ * * `bin/build-driver.mjs` IS `build.py`'s `main()` — a different Python program from
25
25
  * `rituals/harness.py`, not a different half of the same one. It parses generator FLAGS
26
26
  * with a partition asserted over them (`test_the_node_driver_classifies_every_emit`),
27
27
  * `tests/golden.py` drives it 259 times per run, and it is under a hard `child_process`
@@ -31,7 +31,7 @@
31
31
  *
32
32
  * What this costs is what the P5b handoff predicted: a second acceptance matrix. It is paid
33
33
  * in `tests/harness_golden.py`, which now carries a `bin` per cell and takes `--new-cli`
34
- * beside `--new` — and it would have been paid identically by the `bin/geneseed.mjs` option,
34
+ * beside `--new` — and it would have been paid identically by the `bin/build-driver.mjs` option,
35
35
  * so it is not a discriminator between the two.
36
36
  *
37
37
  * THE VERB SET REFUSES THE REST BY NAME, exactly as the hook entry does. It carries 22 of the
@@ -46,24 +46,25 @@
46
46
  * for the file as a whole is that its transitive imports now reach two modules that spawn,
47
47
  * which `_ALLOWED_SPAWNS` in `tests/test_hook_cli_parity.py` declares argv by argv.
48
48
  */
49
- import { cmdCatalog } from '../js/catalog.mjs';
50
- import { cliSpec, printHelp } from '../js/cli.mjs';
51
- import { cmdDiff } from '../js/diff.mjs';
52
- import { cmdDoctor, cmdValidate } from '../js/doctor.mjs';
53
- import { parseDriverArgs } from './geneseed.mjs';
54
- import { cmdExclude } from '../js/excludes.mjs';
55
- import { cmdBuild, cmdPrompt, cmdRebuildAll, cmdTheme } from '../js/generate.mjs';
56
- import { cmdMigrate } from '../js/migrate.mjs';
57
- import { pyInt } from '../js/lib/fs.mjs';
58
- import { cmdLink, cmdUnlink } from '../js/link.mjs';
59
- import { cmdMcp } from '../js/mcp.mjs';
60
- import { cmdMemory } from '../js/memory.mjs';
61
- import { cmdHome, cmdMenu } from '../js/menu.mjs';
62
- import { cmdTui } from '../js/tui.mjs';
63
- import { cmdSetup } from '../js/setup.mjs';
64
- import { cmdStatus, cmdVersion } from '../js/status.mjs';
65
- import { cmdUninstall } from '../js/uninstall.mjs';
66
- import { cmdBootstrap, cmdSyncSelf, cmdUpgrade } from '../js/update.mjs';
49
+ import { cmdCatalog } from '../js/build/catalog.mjs';
50
+ import { cliSpec, printHelp } from '../js/ui/cli.mjs';
51
+ import { cmdDiff } from '../js/inspect/diff.mjs';
52
+ import { cmdDoctor } from '../js/inspect/doctor.mjs';
53
+ import { cmdValidate } from '../js/inspect/validate.mjs';
54
+ import { parseDriverArgs } from './build-driver.mjs';
55
+ import { cmdExclude } from '../js/inspect/excludes.mjs';
56
+ import { cmdBuild, cmdPrompt, cmdRebuildAll, cmdTheme } from '../js/build/generate.mjs';
57
+ import { cmdMigrate } from '../js/maintain/migrate.mjs';
58
+ import { parseIntStrict } from '../js/lib/text.mjs';
59
+ import { cmdLink, cmdUnlink } from '../js/hosts/link.mjs';
60
+ import { cmdMcp } from '../js/hosts/mcp.mjs';
61
+ import { cmdMemory } from '../js/maintain/memory.mjs';
62
+ import { cmdHome, cmdMenu } from '../js/ui/menu.mjs';
63
+ import { cmdTui } from '../js/ui/tui.mjs';
64
+ import { cmdSetup } from '../js/maintain/setup.mjs';
65
+ import { cmdStatus, cmdVersion } from '../js/inspect/status.mjs';
66
+ import { cmdUninstall } from '../js/maintain/uninstall.mjs';
67
+ import { cmdBootstrap, cmdSyncSelf, cmdUpgrade } from '../js/maintain/update.mjs';
67
68
  import { cmdWeb } from '../js/web/server.mjs';
68
69
 
69
70
  /**
@@ -74,7 +75,7 @@ import { cmdWeb } from '../js/web/server.mjs';
74
75
  * `add_argument` calls, gated against the subparsers by NAME only. When
75
76
  * `_web_docs._cli_reference` stopped walking the parser and started reading the CLI table,
76
77
  * keeping that would have made THREE transcriptions of one parser — the parser, the file, and
77
- * here. So the argument surface comes from `js/cli.mjs`'s `cliSpec()`, derived from the same
78
+ * here. So the argument surface comes from `js/ui/cli.mjs`'s `cliSpec()`, derived from the same
78
79
  * file the docs page serves, and what is left in a row is the one thing a data file cannot
79
80
  * hold: the FUNCTION.
80
81
  *
@@ -150,14 +151,14 @@ const VERBS = {
150
151
  // Points at `cmdSyncSelf` rather than at `cmdUpgrade`, and that is not tidiness:
151
152
  // `sync-self` DROPS its `ref` before `upgrade` ever sees it, where `upgrade` both WARNS
152
153
  // about one and re-reads it as a theme — so the two verbs answer `sync-self cyberpunk`
153
- // and `sync-self v1.2.3` differently. See `js/update.mjs`'s `syncSelf` for both halves.
154
+ // and `sync-self v1.2.3` differently. See `js/maintain/update.mjs`'s `syncSelf` for both halves.
154
155
  fn: cmdSyncSelf,
155
156
  },
156
157
  bootstrap: {
157
158
  fn: cmdBootstrap,
158
159
  },
159
160
  // P7a. Both are DISPATCHERS whose off-TTY arm is the whole of what a cell can reach, and
160
- // `menu`'s on-TTY arm falls back rather than opening a panel — `js/menu.mjs`'s header
161
+ // `menu`'s on-TTY arm falls back rather than opening a panel — `js/ui/menu.mjs`'s header
161
162
  // argues both.
162
163
  menu: {
163
164
  fn: cmdMenu,
@@ -168,7 +169,7 @@ const VERBS = {
168
169
  // P7b, and it is the twenty-fifth and last. `cmd_tui`'s FIRST arm is `if not sys.stdin.
169
170
  // isatty()`, so off a TTY — which is every cell there is — the verb is one line and an
170
171
  // exit code, and that arm crosses byte for byte. The panel behind it is P7c's;
171
- // `js/tui.mjs`'s header argues why this entry falls back to `cmd_tui`'s own
172
+ // `js/ui/tui.mjs`'s header argues why this entry falls back to `cmd_tui`'s own
172
173
  // panel-unavailable line rather than inventing a second full-screen UI, and
173
174
  // `tests/test_tui_boundary.py` asserts that the arm it declares is genuinely unreachable
174
175
  // here rather than merely untested.
@@ -201,7 +202,7 @@ const VERBS = {
201
202
  };
202
203
 
203
204
  function die(code, msg) {
204
- // CRLF on Windows, for the same reason `js/hooks.mjs`'s funnels translate: argparse writes
205
+ // CRLF on Windows, for the same reason `js/hosts/hooks.mjs`'s funnels translate: argparse writes
205
206
  // this line through `sys.stderr`, which does.
206
207
  process.stderr.write(`geneseed: error: ${msg}${process.platform === 'win32' ? '\r\n' : '\n'}`);
207
208
  return code;
@@ -211,7 +212,7 @@ function die(code, msg) {
211
212
  * `argparse`'s surface for the verbs here, and only theirs: ordered positionals, some
212
213
  * optional, some with `choices`.
213
214
  *
214
- * `spec` comes from `js/cli.mjs`'s `cliSpec()` since P10c — derived from `js/cli-table.json`,
215
+ * `spec` comes from `js/ui/cli.mjs`'s `cliSpec()` since P10c — derived from `js/cli-table.json`,
215
216
  * which P2 made the owned document. This function's rules are unchanged; what
216
217
  * changed is that the table they run over is no longer written out by hand beside them. Every
217
218
  * argparse feature named below is a row of that derivation.
@@ -274,7 +275,7 @@ function parse(spec, argv) {
274
275
  // `type=int`. argparse's own wording, minus the usage block it prints around it — the
275
276
  // same rule the rest of this parser follows. The VALUE stays a string in `args`; the
276
277
  // command converts, and this is what guarantees the conversion cannot fail there.
277
- if ((spec.ints ?? []).includes(flag) && pyInt(value) === null) {
278
+ if ((spec.ints ?? []).includes(flag) && parseIntStrict(value) === null) {
278
279
  return { error: `argument ${flag}: invalid int value: '${value}'` };
279
280
  }
280
281
  args[dest] = value;
@@ -347,7 +348,7 @@ async function main(argv) {
347
348
  //
348
349
  // So a row would have meant amending two gates and recording a corpus half blind. It takes
349
350
  // the generator's own parser (see `parseDriverArgs`) because its flags are the generator's,
350
- // and it is on THIS binary — not on `bin/geneseed.mjs` — because it runs the doctor, which
351
+ // and it is on THIS binary — not on `bin/build-driver.mjs` — because it runs the doctor, which
351
352
  // starts a process the driver is banned from reaching.
352
353
  if (verb === 'validate') {
353
354
  try {
@@ -397,7 +398,7 @@ async function main(argv) {
397
398
  return await spec.fn(parsed.args);
398
399
  } catch (e) {
399
400
  // `e.exitCode` is the generator's existing marker for a DELIBERATE refusal that has
400
- // already explained itself on stderr — `js/emit.mjs:1289` reads the same flag, and it is
401
+ // already explained itself on stderr — `js/build/emit-claude.mjs` reads the same flag, and it is
401
402
  // `sys.exit(<message>)` on the Python side. Anything without it is a crash and keeps its
402
403
  // stack: Python prints a traceback for an unhandled exception, and turning one into a
403
404
  // tidy one-liner here would hide a bug behind a refusal's clothing.
@@ -3,7 +3,7 @@
3
3
  * The hook entry point — a Node twin of `rituals/harness.py` for the four verbs the
4
4
  * emitted `settings.json` actually invokes.
5
5
  *
6
- * WHY IT IS A SECOND BINARY AND NOT A SUBCOMMAND OF `bin/geneseed.mjs`. Three reasons,
6
+ * WHY IT IS A SECOND BINARY AND NOT A SUBCOMMAND OF `bin/build-driver.mjs`. Three reasons,
7
7
  * and each alone would be enough:
8
8
  *
9
9
  * * The generator driver is under a hard `child_process` ban
@@ -14,11 +14,11 @@
14
14
  * * The hook shim bakes ONE entry and forwards `%*` to it. `harness.py` is that entry on
15
15
  * the Python side; a Node twin needs its own, invoked as `<node> <this> context
16
16
  * --root "<cfg>"`.
17
- * * `bin/geneseed.mjs` parses generator FLAGS (`--emit`, `--theme`, `--footprint`), and
17
+ * * `bin/build-driver.mjs` parses generator FLAGS (`--emit`, `--theme`, `--footprint`), and
18
18
  * `test_the_node_driver_classifies_every_emit` asserts a partition over them. Verbs
19
19
  * are a different shape and would sit awkwardly inside that.
20
20
  *
21
- * WHAT BAKES THIS FILE, SINCE P5b. `bin/geneseed.mjs` writes `<node> <checkout>/bin/
21
+ * WHAT BAKES THIS FILE, SINCE P5b. `bin/build-driver.mjs` writes `<node> <checkout>/bin/
22
22
  * geneseed-hook.mjs` into the machine-wide shim, so an install this driver emitted needs no
23
23
  * Python for its hooks. The exit-4 refusal it used to raise when no interpreter was
24
24
  * discoverable is gone with the discovery that fed it.
@@ -37,8 +37,8 @@
37
37
  * every Geneseed hook returns 0 and signals through stdout — so "did nothing" and "worked"
38
38
  * are the same observation.
39
39
  */
40
- import { cmdContext, cmdGitGate, cmdRuleGate, cmdLearn } from '../js/hooks.mjs';
41
- import { printHelp } from '../js/cli.mjs';
40
+ import { cmdContext, cmdGitGate, cmdRuleGate, cmdLearn } from '../js/hosts/hooks.mjs';
41
+ import { printHelp } from '../js/ui/cli.mjs';
42
42
 
43
43
  const VERBS = {
44
44
  context: { fn: cmdContext, flags: { '--root': 'root' } },
@@ -53,7 +53,7 @@ const VERBS = {
53
53
  };
54
54
 
55
55
  function die(code, msg) {
56
- // CRLF on Windows, for the same reason `js/hooks.mjs`'s funnels translate: argparse
56
+ // CRLF on Windows, for the same reason `js/hosts/hooks.mjs`'s funnels translate: argparse
57
57
  // writes this line through `sys.stderr`, which does.
58
58
  process.stderr.write(`geneseed-hook: error: ${msg}${process.platform === 'win32' ? '\r\n' : '\n'}`);
59
59
  return code;
@@ -115,7 +115,7 @@ function main(argv) {
115
115
  // `<verb> --help`, for the same reason and by the same owner as `bin/geneseed-cli.mjs`:
116
116
  // argparse holds `-h` at the parser, so this entry's `parse` calls it an unrecognized
117
117
  // argument. The four hook verbs are four of the reference's 26 and had the same gap.
118
- // `js/cli.mjs` reads the CLI table lazily, inside its functions, so importing it costs this
118
+ // `js/ui/cli.mjs` reads the CLI table lazily, inside its functions, so importing it costs this
119
119
  // entry a module parse and no file read on the hook path.
120
120
  if (argv.slice(1).some((t) => t === '-h' || t === '--help')) {
121
121
  const rc = printHelp('geneseed-hook', verb);