geneseed 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
@@ -14,7 +14,7 @@
14
14
  * THE SECOND thing the walk shows only as one name: `_diff_collect` runs a whole GLOBAL EMIT
15
15
  * into a temp dir. `build.HOSTS[host]["emit_global"]` is a `build.*` reference like any other
16
16
  * in the table, and behind it is the generator. That call is why this file imports
17
- * `bin/geneseed.mjs` — the same in-process route `js/generate.mjs` established for `build`,
17
+ * `bin/build-driver.mjs` — the same in-process route `js/build/generate.mjs` established for `build`,
18
18
  * under the same transitive `child_process` ban.
19
19
  *
20
20
  * WHAT NO CELL CAN VARY, stated rather than left implicit:
@@ -22,30 +22,33 @@
22
22
  * * **The diff algorithm.** A cell can produce ONE edited file and see one hunk. It cannot
23
23
  * produce the shapes where `difflib`'s choices differ from any other diff's — ties, the
24
24
  * recursion's alignment, and above all `autojunk`, which only engages at 200 lines and
25
- * changes the hunks on every real harness file. Gated as a corpus in
26
- * `tests/test_pure_function_parity.py`, measured in both directions (28 of the 176 cases
27
- * there disagree when `autojunk` is switched off, so the corpus is not describing a
28
- * constant).
25
+ * changes the hunks on every real harness file. Gated as a corpus —
26
+ * `tests/fixtures/pure_probe.mjs`'s `unified_diff` entry, replayed by
27
+ * `tests/snapshot/pure_snapshot.test.mjs` against `tests/__snapshots__/primitives/` —
28
+ * measured in both directions (28 of the 176 cases there disagree when `autojunk` is
29
+ * switched off, so the corpus is not describing a constant).
29
30
  * * **The timestamp.** `_write_improvements` names the file `improvements-%Y%m%d-%H%M%S.md`
30
31
  * and stamps the report with `datetime.now()`, so the two implementations cannot write the
31
32
  * same bytes across a second boundary — and a cell runs them in sequence, so the boundary
32
- * is a coin flip. `harness_golden._STAMPS` normalises both spellings out of the snapshot,
33
- * which costs the FORMAT assertion and pays it back in
34
- * `test_the_improvements_filename_is_stamped_the_same_way_by_both` — absolute, per
35
- * implementation, because a comparison made tolerant of a value owes one somewhere else.
33
+ * is a coin flip. The cell harness normalises both spellings out of the snapshot, which
34
+ * costs the FORMAT assertion and pays it back in `tests/unit/hook_cli.test.mjs`'s
35
+ * `the improvements filename is stamped the declared way` — absolute, and asserted
36
+ * directly rather than by comparison, because a check made tolerant of a value owes one
37
+ * somewhere else.
36
38
  */
37
39
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
38
40
  import os from 'node:os';
39
41
  import path from 'node:path';
40
42
 
41
- import { emitGlobalInto } from '../bin/geneseed.mjs';
42
- import { GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir } from './hosts.mjs';
43
+ import { emitGlobalInto } from '../../bin/build-driver.mjs';
44
+ import { GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir } from '../hosts/hosts.mjs';
43
45
  import {
44
46
  EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
45
47
  readJsonMaybe, readMaybe, themeOfDir,
46
- } from './installs.mjs';
47
- import { unifiedDiff, pySplitLines } from './lib/udiff.mjs';
48
- import { pyPathStr, pyPrint, pyPrintErr, readText, writeText } from './lib/fs.mjs';
48
+ } from '../hosts/installs.mjs';
49
+ import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
50
+ import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
51
+ import { toPlatformPath } from '../lib/paths.mjs';
49
52
 
50
53
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
51
54
 
@@ -100,7 +103,7 @@ export function manifestScope(d) {
100
103
  *
101
104
  * The emit prints its own summary line, which is not part of `diff`'s output; the Python
102
105
  * swallows it and lets stderr through, so a WARN from the emit still reaches the user. Both
103
- * halves matter and only the first needs code here — `pyPrint` and every driver funnel go
106
+ * halves matter and only the first needs code here — `printOut` and every driver funnel go
104
107
  * through `process.stdout.write`, so replacing that one method is the whole redirect.
105
108
  *
106
109
  * `finally`, because the emit can throw: a swallow that leaked would silence every later line
@@ -183,13 +186,13 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
183
186
  const ta = readText(a);
184
187
  const tb = readText(b);
185
188
  if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
186
- const diff = unifiedDiff(pySplitLines(tb), pySplitLines(ta), {
189
+ const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
187
190
  fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
188
191
  });
189
192
  files.push({ rel, status: 'edited', diff });
190
193
  }
191
194
  } else if (isFile(a)) {
192
- const body = pySplitLines(readText(a));
195
+ const body = splitLines(readText(a));
193
196
  files.push({
194
197
  rel,
195
198
  status: 'added',
@@ -334,8 +337,8 @@ export function flushExportNotes() {
334
337
  .sort();
335
338
  } catch { return; }
336
339
  if (fresh.length === 0) return;
337
- for (const p of fresh) pyPrint(`[geneseed] improvements file saved: ${pyPathStr(p)}\n`);
338
- pyPrint('[geneseed] the deployed harness carried local edits — hand the file to '
340
+ for (const p of fresh) printOut(`[geneseed] improvements file saved: ${toPlatformPath(p)}\n`);
341
+ printOut('[geneseed] the deployed harness carried local edits — hand the file to '
339
342
  + 'your agent to back-port them into src/.\n');
340
343
  }
341
344
 
@@ -344,14 +347,14 @@ export function cmdDiff(args) {
344
347
  const { target, theme, files } = diffCollect({ target: args.target, theme: args.theme });
345
348
  if (files === null) {
346
349
  if (existsSync(path.join(target, GLOBAL_MANIFEST)) && manifestScope(target) === 'project') {
347
- pyPrintErr(`[diff] ${target} is a PROJECT install — diff only compares GLOBAL `
350
+ printErr(`[diff] ${target} is a PROJECT install — diff only compares GLOBAL `
348
351
  + 'installs against a fresh render (a project layer is diffed against '
349
352
  + 'its own repo, which this command does not do). Pass --target at a '
350
353
  + 'global config dir (opencode-global/claude-global/bob-global/'
351
354
  + 'copilot-global), or omit '
352
355
  + '--target to use the default.\n');
353
356
  } else {
354
- pyPrintErr(`[diff] no global Geneseed install at ${target} (no ${GLOBAL_MANIFEST}). `
357
+ printErr(`[diff] no global Geneseed install at ${target} (no ${GLOBAL_MANIFEST}). `
355
358
  + 'Pass --target, or run `--emit opencode-global` first.\n');
356
359
  }
357
360
  return 1;
@@ -359,31 +362,31 @@ export function cmdDiff(args) {
359
362
  const edited = files.filter((f) => f.status === 'edited');
360
363
  const added = files.filter((f) => f.status === 'added');
361
364
  const missing = files.filter((f) => f.status === 'missing');
362
- pyPrint(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
363
- pyPrint(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
365
+ printOut(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
366
+ printOut(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
364
367
  + `${missing.length} missing-from-deployed\n`);
365
- for (const f of edited) pyPrint(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
366
- for (const f of added) pyPrint(` + ${f.rel} (only in deployed — your addition)\n`);
367
- for (const f of missing) pyPrint(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
368
+ for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
369
+ for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
370
+ for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
368
371
  if (args.out) {
369
372
  if (files.length) {
370
373
  // Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
371
374
  // path". `parse()` gives a flag `true` and an option a string, so the two arrive
372
375
  // distinguishable here without a third kind of entry in the VERBS table.
373
376
  const outPath = args.out === true ? null : args.out;
374
- pyPrint(`[diff] improvements file written: ${
377
+ printOut(`[diff] improvements file written: ${
375
378
  writeImprovements(target, theme, files, outPath)}\n`);
376
379
  } else {
377
- pyPrint('[diff] no differences — nothing written.\n');
380
+ printOut('[diff] no differences — nothing written.\n');
378
381
  }
379
382
  }
380
383
  if (args.full) {
381
384
  for (const f of edited) {
382
- pyPrint(`\n--- ${f.rel} (source -> deployed) ---\n`);
383
- pyPrint(`${f.diff.join('\n')}\n`);
385
+ printOut(`\n--- ${f.rel} (source -> deployed) ---\n`);
386
+ printOut(`${f.diff.join('\n')}\n`);
384
387
  }
385
388
  } else if (edited.length && !args.out) {
386
- pyPrint('\nRun with --full to see the line-level diffs, or --out FILE to export them.\n');
389
+ printOut('\nRun with --full to see the line-level diffs, or --out FILE to export them.\n');
387
390
  }
388
391
  return 0;
389
392
  }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `geneseed doctor` — validate the build.
3
+ *
4
+ * Sixteen `*Problems` checks over one theme (or every theme) — and `[doctor] ok — …` when all
5
+ * sixteen find nothing. The checks themselves live beside this file, in `checks-build.mjs`,
6
+ * `checks-repo.mjs` and `checks-authoring.mjs`, over the primitives in `scan.mjs`; what is left
7
+ * here is the driver that chooses the themes, runs the checks and prints the verdict.
8
+ *
9
+ * WHY THE GATE LOOKS THE WAY IT DOES, and it is worth stating here rather than only in the
10
+ * spec: **delete any single check and a clean run is byte-identical**, so a comparison of two
11
+ * runs cannot tell a full doctor from a stub that prints the OK line.
12
+ * `tests/unit/harness.test.mjs` and `tests/unit/authoring_gates.test.mjs` therefore plant ONE
13
+ * FAULT PER CHECK, in a private copy of the checkout — see `copyCheckout` in
14
+ * `tests/helpers/cli_golden.mjs` for why a verb that reads `ROOT` needed a fixture kind that
15
+ * did not exist until the port.
16
+ *
17
+ * WHY THIS IS THE VERB THAT SPAWNS. `bin/geneseed-cli.mjs` is under a blanket transitive
18
+ * `child_process` ban, and its own docblock predicted the day a verb that genuinely spawns
19
+ * would land: this is it. `authoringProblems` runs `node --check` over the OpenCode plugins,
20
+ * and there is no in-process equivalent — `vm.Script` compiles as a SCRIPT and every plugin is
21
+ * ESM (`import { promises as fs } from "node:fs"`), which `node --check` accepts through
22
+ * module-syntax detection and `vm.Script` rejects with a SyntaxError of its own. So the ban is
23
+ * an ALLOW-LIST of exactly the shape the hook entry already carries for `$GENESEED_LLM`: one
24
+ * binding, one call site, and a test that names the argv. What the ban actually protects —
25
+ * that this tool never shells back to the generator — is asserted directly instead.
26
+ *
27
+ * WHAT `doctorCollect` DOES NOT REACH, MEASURED RATHER THAN ASSUMED. It takes `groups` and
28
+ * `cmdDoctor` does not pass it: that parameter is the WEB's, and `apiDoctor` in
29
+ * `js/web/api.mjs` is its only caller. `ran` exists for it — four lines of accumulator that
30
+ * keep the Doctor page's per-check cards a parameter rather than a rewrite.
31
+ */
32
+ import path from 'node:path';
33
+ import { emitGlobalInto, emitProjectInto, main as driverMain } from '../../bin/build-driver.mjs';
34
+ import { ROOT } from '../build/source.mjs';
35
+ import { resolvePath } from '../hosts/hosts.mjs';
36
+ import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
37
+ import { validateIsVendored } from '../hosts/native.mjs';
38
+ import { printOut } from '../lib/fs.mjs';
39
+ import { authoringProblems } from './checks-authoring.mjs';
40
+ import {
41
+ checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
42
+ } from './checks-build.mjs';
43
+ import { moduleMapProblems, shimProblems } from './checks-repo.mjs';
44
+ import { isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
45
+
46
+ /**
47
+ * `_harness_build._themes_to_check` — which themes doctor validates.
48
+ *
49
+ * An explicit `--theme` wins. Otherwise, unless `--all` forces the maintainer sweep, scope to
50
+ * the theme THIS host installed so a one-theme user is not buried under the same problem
51
+ * echoed across all fourteen. Falls back to the full sweep when nothing is installed (a fresh
52
+ * clone) or the detected theme is unknown, so a maintainer in a clean checkout still gets
53
+ * full coverage. Pure, and gated as such: `--all` is a 14-theme sweep no cell can afford.
54
+ */
55
+ export function themesToCheck(theme, allThemes, detected, available) {
56
+ if (theme) return [theme];
57
+ if (!allThemes && detected && available.includes(detected)) return [detected];
58
+ return [...available].sort();
59
+ }
60
+
61
+ /**
62
+ * `contextlib.redirect_stdout(io.StringIO())` — swallow an emit's log, and ONLY stdout.
63
+ *
64
+ * The Python redirects stdout and leaves stderr alone, so a WARN from the emit still reaches
65
+ * the user. Reproducing that exactly is the point: swallowing both would hide
66
+ * `warnBobGlobalOverProject` and swallowing neither would put ~200 lines of emit summary into
67
+ * doctor's output. `withPlatformNewlines` inside `emitGlobalInto` wraps whatever `write` is
68
+ * installed at call time, so it translates into this buffer and the bytes never leave.
69
+ */
70
+ function swallowStdout(fn) {
71
+ const real = process.stdout.write.bind(process.stdout);
72
+ process.stdout.write = () => true;
73
+ try { return fn(); } finally { process.stdout.write = real; }
74
+ }
75
+
76
+ /**
77
+ * `_harness_build._global_emit_problems` — validate the opencode-global emit, in BOTH
78
+ * footprints.
79
+ *
80
+ * The RECOMMENDED install, and otherwise a doctor blind spot: the files build and ./Harness
81
+ * were checked and the global layout never was. The two footprints produce genuinely
82
+ * different AGENT.md bodies — lean swaps the inlined laws for a digest plus a pointer and
83
+ * ships a standalone laws file the full emit does not write — so checking one leaves the
84
+ * other's links and tokens unvalidated, and lean being the default made that the shape most
85
+ * installs actually run.
86
+ */
87
+ export function globalEmitProblems(themeName) {
88
+ const problems = [];
89
+ for (const footprint of ['lean', 'full']) {
90
+ withTempDir((tmp) => {
91
+ const cfgDir = path.join(tmp, 'cfg');
92
+ try {
93
+ swallowStdout(() => emitGlobalInto('opencode', {
94
+ theme: themeName, out: path.join(tmp, 'bundle'), cfgDir, footprint,
95
+ }));
96
+ } catch (e) {
97
+ if (e && e.exitCode !== undefined) {
98
+ problems.push(`[${themeName} global/${footprint}] build failed`);
99
+ return;
100
+ }
101
+ throw e;
102
+ }
103
+ problems.push(...checkBuild(`${themeName} global/${footprint}`, cfgDir));
104
+ });
105
+ }
106
+ return problems;
107
+ }
108
+
109
+ /**
110
+ * `_harness_build._claude_bob_emit_problems` — validate the claude/bob/copilot PER-REPO
111
+ * emits, which were never checked before.
112
+ *
113
+ * That is exactly why the CLAUDE.md/AGENTS.md skill-table dead links shipped unnoticed: the
114
+ * emits that render straight into a repo were outside doctor's sweep. Scanned with
115
+ * `validateIsVendored` rather than `isVendoredPath`, because the native per-repo layer nests
116
+ * skills one level deeper than a bundle does.
117
+ */
118
+ export function claudeBobEmitProblems(themeName) {
119
+ const problems = [];
120
+ for (const label of ['claude', 'bob', 'copilot']) {
121
+ withTempDir((tmp) => {
122
+ const root = path.join(tmp, 'root');
123
+ try {
124
+ swallowStdout(() => emitProjectInto(label, {
125
+ theme: themeName, out: path.join(root, 'bundle'), root,
126
+ }));
127
+ } catch (e) {
128
+ if (e && e.exitCode !== undefined) {
129
+ problems.push(`[${themeName} ${label}] build failed`);
130
+ return;
131
+ }
132
+ throw e;
133
+ }
134
+ problems.push(...checkBuild(`${themeName} ${label}`, root, validateIsVendored));
135
+ });
136
+ }
137
+ return problems;
138
+ }
139
+
140
+ /**
141
+ * `_harness_build._doctor_collect` — run every check; return `[themes, problems]`.
142
+ *
143
+ * ONE PARAMETER OF THE PYTHON IS NOT HERE, and the absence is on purpose. `on_progress` is
144
+ * the TUI's — it drags ~140 lines of curses drawing behind it and nothing in this tree can
145
+ * supply one. `groups` DID land, with the caller that needed it: it is the structured
146
+ * `{check, label, problems}` view the Doctor page renders, and `js/web/api.mjs`'s `apiDoctor`
147
+ * passes an array. `cmdDoctor` passes nothing and gets the flat list, so the return contract
148
+ * is the same either way.
149
+ *
150
+ * THE PER-THEME BUILD IS THE DRIVER, IN-PROCESS. The Python shells to `build.py` and reads a
151
+ * return code; here that is `driverMain`, the route P5e established and the only one the
152
+ * `child_process` allow-list leaves. `cwd=ROOT` does not travel in-process and does not have
153
+ * to: `--out` is an absolute path, which is the one input `resolveOut` would have read the
154
+ * working directory for.
155
+ */
156
+ export function doctorCollect({
157
+ theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
158
+ } = {}) {
159
+ const available = themeFiles().map(stemOf);
160
+ if (!available.length) return [[], ['[doctor] no themes found']];
161
+
162
+ // `_ran` — the label is the one place each check is NAMED, and since P6b a caller can
163
+ // pass an array to collect them. `/api/doctor` renders one card per entry, which is the
164
+ // only reason the structured view exists; `cmd_doctor` passes nothing and gets the flat
165
+ // list, so the return contract is unchanged either way. `on_progress` is still P7's.
166
+ const ran = (check, label, probs) => {
167
+ if (groups !== null) groups.push({ check, label, problems: sortedProblems(probs) });
168
+ return probs;
169
+ };
170
+
171
+ // Only probe the deployed install when we actually need it (no theme / not --all).
172
+ const detected = (theme || allThemes) ? null : (installedDefaults().theme ?? null);
173
+ const themes = themesToCheck(theme, allThemes, detected, available);
174
+ // Sampled HERE, before the emit loop below — every emit rewrites the hook shim, so a check
175
+ // placed after them could only ever observe the freshly repaired file and would be dead
176
+ // code that always reports clean. Reported in its usual slot further down.
177
+ const shimProbs = shimProblems();
178
+ let problems = [];
179
+ withTempDir((tmp) => {
180
+ for (const themeName of themes) {
181
+ const out = path.join(tmp, themeName);
182
+ // capture_output=True: BOTH streams, unlike the emits below, which redirect stdout
183
+ // only. A failing build's stderr is not doctor's output either — the return code is.
184
+ const rc = captureBoth(() => driverMain(['--theme', themeName, '--out', out]));
185
+ if (rc !== 0) {
186
+ problems = problems.concat(ran('build', `Build scan (${themeName})`,
187
+ [`[${themeName}] build failed`]));
188
+ continue;
189
+ }
190
+ problems = problems.concat(ran('build', `Build scan (${themeName})`,
191
+ checkBuild(themeName, out)));
192
+ problems = problems.concat(ran('global', `Global install (${themeName})`,
193
+ globalEmitProblems(themeName)));
194
+ problems = problems.concat(ran('claude_bob',
195
+ `Claude/Bob/Copilot per-repo emit (${themeName})`, claudeBobEmitProblems(themeName)));
196
+ }
197
+ });
198
+ problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
199
+ problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
200
+ problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
201
+ problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
202
+ problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
203
+ // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
204
+ // `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
205
+ // parser edited without regenerating the table. P2 made the table the OWNED document
206
+ // (`js/cli-table.json`), so there is no generator to fall behind and no second file to hash
207
+ // — the digest was a claim about a file this migration deletes. What replaces it is
208
+ // `tests/unit/cli_table.test.mjs`, which holds the owned table against the two entry points
209
+ // that dispatch from it: no second file to hash, and no generator that can fall behind.
210
+ if (!noBundle) {
211
+ // `Path(bundle).expanduser().resolve()`, which `resolvePath` already IS — the default is
212
+ // deliberately NOT resolved, because the Python's `ROOT / "Harness"` is not either and
213
+ // the resolved spelling is what the messages print.
214
+ const b = bundle ? resolvePath(bundle) : path.join(ROOT, 'Harness');
215
+ problems = problems.concat(ran('bundle', 'Committed bundle drift', renderedProblems(b)));
216
+ }
217
+ return [themes, sortedUnique(problems)];
218
+ }
219
+
220
+ /** `run(..., capture_output=True)` — both streams into the void, the return code out. */
221
+ function captureBoth(fn) {
222
+ const out = process.stdout.write.bind(process.stdout);
223
+ const err = process.stderr.write.bind(process.stderr);
224
+ process.stdout.write = () => true;
225
+ process.stderr.write = () => true;
226
+ try { return fn(); } finally { process.stdout.write = out; process.stderr.write = err; }
227
+ }
228
+
229
+ /**
230
+ * `_harness_build.cmd_doctor` — the CLI face.
231
+ *
232
+ * With `--theme`, one theme. With none it scopes to the INSTALLED theme, so a one-theme
233
+ * install is not buried under the same issue repeated across every theme; `--all` is the full
234
+ * maintainer sweep. The cross-theme parity check runs in every mode.
235
+ */
236
+ export function cmdDoctor(args) {
237
+ const [themes, collected] = doctorCollect({
238
+ theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
239
+ noBundle: Boolean(args.noBundle),
240
+ });
241
+ if (!themes.length) {
242
+ printOut(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
243
+ return 1;
244
+ }
245
+ // ⚠ NOTES ARE PRINTED AND NOT COUNTED. The only producer is the pack-off citation report in
246
+ // `constitutionProblems`, and the state it describes is one the install's owner chose: a
247
+ // build that leaves body prose citing a pack it did not render. Silence there is how prose
248
+ // and boundary drift apart, and a non-zero exit there is doctor failing a legal
249
+ // configuration. Nothing in the recorded CLI corpus produces one — the checkout's own
250
+ // `harness.config.json` names no packs — so the byte-compared output is unmoved.
251
+ const notes = collected.filter(isDoctorNote);
252
+ const problems = collected.filter((p) => !isDoctorNote(p));
253
+ const scoped = !args.theme && !args.all && themes.length === 1;
254
+ const note = scoped
255
+ ? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
256
+ for (const n of notes) printOut(`${n}\n`);
257
+ if (problems.length) {
258
+ printOut(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
259
+ for (const p of problems) printOut(` - ${p}\n`);
260
+ if (problems.some((p) => p.includes('dead link'))) {
261
+ printOut(' tip: dead links to skills mean your source is incomplete — run '
262
+ + '`./geneseed update` (or re-sync src/), then re-check.\n');
263
+ }
264
+ if (problems.some((p) => p.startsWith('[themes]') && p.includes('missing key'))) {
265
+ // ⚠ THE TIP USED TO NAME `./geneseed build --sync-themes`, AND THAT COMMAND ERRORS.
266
+ // It was argued here on the premise that "`build` forwards its extra arguments to the
267
+ // generator" — which was true of the reference and is not true of `cmdBuild`
268
+ // (`js/build/generate.mjs`), which forwards `--theme` and nothing else. So the front door
269
+ // answered `unrecognized arguments: --sync-themes` to anyone who followed doctor's own
270
+ // advice, for as long as the premise went unchecked. The flag belongs to the GENERATOR,
271
+ // whose binary is `geneseed-build` — which is what `README.md` and `SETUP.md` have said
272
+ // all along. A hint is a command; if it is not runnable it is decoration.
273
+ printOut(' tip: a theme is missing a key another theme defines — run '
274
+ + '`geneseed-build --sync-themes` to fill it from _TEMPLATE.json, '
275
+ + 'then restyle the added key(s) and re-check.\n');
276
+ }
277
+ if (note) printOut(`${note}\n`);
278
+ return 1;
279
+ }
280
+ printOut('[doctor] ok — ' + `${themes.length} theme(s) clean: no unresolved tokens, no dead `
281
+ + 'links, nothing escapes the bundle; themes in parity; specs carry purpose '
282
+ + 'lines; rendered bundle in sync\n');
283
+ if (note) printOut(`${note}\n`);
284
+ return 0;
285
+ }
286
+
@@ -1,35 +1,32 @@
1
1
  /**
2
- * `rituals/_harness_exclude.py` — the sovereign-repo exclusion WRITER.
2
+ * The sovereign-repo exclusion WRITER.
3
3
  *
4
- * THE READER WAS ALREADY HERE. `js/hooks.mjs` has carried `sovereignBypass`, `EXCLUDES_FILE`
4
+ * THE READER WAS ALREADY HERE. `js/hosts/hooks.mjs` has carried `sovereignBypass`, `EXCLUDES_FILE`
5
5
  * and `EXCLUDE_DIRS` since P5a: every hook call reads `excludes.json` and stands down when
6
6
  * cwd is under an excluded folder. This module is the other half — `harness exclude
7
7
  * add|remove|list`, which maintains that file across every global install and wires the
8
8
  * host-native per-repo suppression into the excluded repo.
9
9
  *
10
- * THE PYTHON WRITER STAYS, AND THAT IS A DECISION RATHER THAN AN OMISSION.
11
- * `rituals/_web_actions.py:371-390` calls `excludes_snapshot()`, `exclude_add` and
12
- * `exclude_remove` "verbatim: this endpoint owns no exclusion logic of its own", and the web
13
- * server is P6. So until that phase there are two writers for one user-owned file. What
14
- * makes that survivable is what makes it survivable for the machine-wide hook shim: the two
15
- * implementations are held byte-equal by `tests/harness_golden.py`, which runs every cell
16
- * against both and compares stdout, stderr, the exit code and every file left behind. What
17
- * it does NOT cover is two processes writing at the same instant — `_write_excludes` is
18
- * temp-plus-rename on both sides, so a concurrent pair loses one edit rather than tearing
19
- * the file, which is the same guarantee the single-writer version had against itself.
10
+ * THE WEB ENDPOINT IS THIS MODULE — there is exactly ONE writer. `js/web/actions.mjs`
11
+ * dispatches straight to `excludeAdd`/`excludeRemove` here, and `apiExcludes` in
12
+ * `js/web/api.mjs` is `excludesSnapshot()` unmodified, so the CLI and the console are one
13
+ * writer over one user-owned file. It used to be two, with a Python endpoint calling the
14
+ * same three names, and the surviving hazard is the one that outlived the split: two
15
+ * PROCESSES writing at the same instant. `writeExcludes` is temp-plus-rename, so a
16
+ * concurrent pair loses one edit rather than tearing the file.
20
17
  *
21
18
  * Every path a hook takes on this file is READ-only, so nothing here is on the PreToolUse
22
- * latency budget — which is why it lives beside the CLI and not in `js/hooks.mjs`.
19
+ * latency budget — which is why it lives beside the CLI and not in `js/hosts/hooks.mjs`.
23
20
  */
24
21
  import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync }
25
22
  from 'node:fs';
26
23
  import path from 'node:path';
27
- import { EXCLUDES_FILE, EXCLUDES_STUB, BOB_RULES_STUB } from './emit.mjs';
28
- import { wireClaudeExcludes, unwireClaudeExcludes } from './settings.mjs';
29
- import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
30
- import {
31
- readText, writeText, parseJson, jsonDumpsIndent, normcase, pyPathStr, pyPrint, pyPrintErr,
32
- } from './lib/fs.mjs';
24
+ import { EXCLUDES_FILE, EXCLUDES_STUB, BOB_RULES_STUB } from '../build/stubs.mjs';
25
+ import { wireClaudeExcludes, unwireClaudeExcludes } from '../hosts/settings.mjs';
26
+ import { GLOBAL_MANIFEST, HOSTS, resolvePath } from '../hosts/hosts.mjs';
27
+ import { readText, writeText, printOut, printErr } from '../lib/fs.mjs';
28
+ import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
29
+ import { normcase, toPlatformPath } from '../lib/paths.mjs';
33
30
 
34
31
  const get = (o, k) => (o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
35
32
  const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
@@ -78,19 +75,19 @@ function writeExcludes(cfg, data) {
78
75
  }
79
76
 
80
77
  /** `_canon` — `Path(p).expanduser().resolve()`. */
81
- const canon = (p) => pyResolve(p);
78
+ const canon = (p) => resolvePath(p);
82
79
 
83
80
  /**
84
81
  * `_same` — is this stored entry the same folder as `repo`?
85
82
  *
86
- * `pyPathStr` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
83
+ * `toPlatformPath` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
87
84
  * `Path()` does not, so a hand-edited entry carrying `..` matches here and would not match
88
85
  * there. Getting that backwards makes `exclude remove` unwire a repo the reference left
89
86
  * alone.
90
87
  */
91
88
  function same(stored, repo) {
92
89
  if (typeof stored !== 'string' || !stored) return false;
93
- return normcase(pyPathStr(stored)) === normcase(repo);
90
+ return normcase(toPlatformPath(stored)) === normcase(repo);
94
91
  }
95
92
 
96
93
  /**
@@ -112,7 +109,7 @@ export function excludeAdd(target) {
112
109
  const priorWired = (isDict(prior) ? get(prior, 'wired') : null) || {};
113
110
  const wired = {};
114
111
  if (host === 'claude' && isDir(repo)) {
115
- const entry = pyResolve(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
112
+ const entry = resolvePath(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
116
113
  const added = wireClaudeExcludes(
117
114
  path.join(repo, '.claude', 'settings.local.json'), [entry]);
118
115
  if (added.length) {
@@ -200,7 +197,7 @@ export function excludeRemove(target) {
200
197
  // Node's own wording. No cell can reach this branch (`unlink` on a file that
201
198
  // `isFile` just confirmed, `rmdir` on a directory just measured empty), so a
202
199
  // translation table here would be an unobservable guess. Same class as P5b's
203
- // untested `die()` newline; `js/settings.mjs`'s `pyOsError` is the same stub for
200
+ // untested `die()` newline; `js/hosts/settings.mjs`'s `asOsError` is the same stub for
204
201
  // the same reason.
205
202
  messages.push(`${host}: could not remove ${stub} (${e.message})`);
206
203
  }
@@ -213,8 +210,8 @@ export function excludeRemove(target) {
213
210
  }
214
211
 
215
212
  /**
216
- * `excludes_snapshot` — the union view across installs, shared by the CLI `list` and (on the
217
- * Python side) the web endpoint. Also flags installs whose lists diverge: a global emit made
213
+ * `excludes_snapshot` — the union view across installs, shared by the CLI `list` and by the
214
+ * web endpoint (`apiExcludes` in `js/web/api.mjs` is this function, unmodified). Also flags installs whose lists diverge: a global emit made
218
215
  * after the exclusions were added starts with an empty stub.
219
216
  */
220
217
  export function excludesSnapshot() {
@@ -224,7 +221,7 @@ export function excludesSnapshot() {
224
221
  for (const e of readExcludes(cfg).excludes) {
225
222
  const p = isDict(e) ? get(e, 'path') : e;
226
223
  if (typeof p !== 'string' || !p) continue;
227
- const key = normcase(pyPathStr(p));
224
+ const key = normcase(toPlatformPath(p));
228
225
  if (!byPath.has(key)) {
229
226
  byPath.set(key, { path: p, hosts: [],
230
227
  wired: (isDict(e) ? get(e, 'wired') : null) || {} });
@@ -242,11 +239,11 @@ export function cmdExclude(args) {
242
239
  if (args.action === 'list') {
243
240
  const snap = excludesSnapshot();
244
241
  if (!snap.installs.length) {
245
- pyPrint('[geneseed] no global install found.\n');
242
+ printOut('[geneseed] no global install found.\n');
246
243
  return 1;
247
244
  }
248
245
  if (!snap.excludes.length) {
249
- pyPrint('[geneseed] no folders excluded.\n');
246
+ printOut('[geneseed] no folders excluded.\n');
250
247
  return 0;
251
248
  }
252
249
  const allHosts = new Set(snap.installs.map((i) => i.host));
@@ -254,18 +251,18 @@ export function cmdExclude(args) {
254
251
  const missing = [...allHosts].filter((h) => !rec.hosts.includes(h)).sort();
255
252
  const flag = missing.length
256
253
  ? ` (MISSING from: ${missing.join(', ')} — re-run \`harness exclude add\`)` : '';
257
- pyPrint(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
254
+ printOut(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
258
255
  }
259
256
  return 0;
260
257
  }
261
258
  if (!args.path) {
262
- pyPrintErr('[geneseed] exclude add/remove needs a folder path.\n');
259
+ printErr('[geneseed] exclude add/remove needs a folder path.\n');
263
260
  return 2;
264
261
  }
265
262
  const res = args.action === 'add' ? excludeAdd(args.path) : excludeRemove(args.path);
266
- for (const m of res.messages) pyPrintErr(`[geneseed] ${m}\n`);
263
+ for (const m of res.messages) printErr(`[geneseed] ${m}\n`);
267
264
  const verb = args.action === 'add' ? 'excluded' : 're-included';
268
- pyPrint(res.ok ? `[geneseed] ${res.path} ${verb}.\n`
265
+ printOut(res.ok ? `[geneseed] ${res.path} ${verb}.\n`
269
266
  : `[geneseed] nothing done for ${res.path}.\n`);
270
267
  return res.ok ? 0 : 1;
271
268
  }