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
@@ -1,15 +1,17 @@
1
1
  /**
2
- * `rituals/_harness_tui.py`'s CATALOG half — the taxonomy tables, the rendered-laws
2
+ * The CATALOG surface — the taxonomy tables, the rendered-laws
3
3
  * parser, the entity registry, and the render-accurate inventory the TUI, the `status`
4
4
  * panel and the web console all read.
5
5
  *
6
6
  * WHY THIS FILE EXISTS RATHER THAN A THIRD COPY OF THE TABLES. `LAW_CLASS`, `SKILL_CLASS`,
7
- * `LAW_CLASSES` and `ENTITY_STATUSES` crossed in P5g, inside `js/doctor.mjs`, because
8
- * doctor's authoring gates are what validate them — and `js/status.mjs`'s docblock said
7
+ * `LAW_CLASSES` and `ENTITY_STATUSES` crossed in P5g, inside `js/inspect/doctor.mjs`, because
8
+ * doctor's authoring gates are what validate them — and `js/inspect/status.mjs`'s docblock said
9
9
  * flatly that "the taxonomy P7 owns" was not ported, which was true of `_parse_laws` and
10
10
  * `entity_status` and had stopped being true of the tables. P6c needs all of it, so the
11
- * tables move HERE, to the module that mirrors where Python keeps them, and `js/doctor.mjs`
12
- * imports them. A copy of a value under test silently stops being the value under test.
11
+ * tables move HERE, to the module that mirrors where Python keeps them, and the doctor's
12
+ * check modules import them — `checks-authoring.mjs` takes `LAW_CLASS`, `LAW_CLASSES` and
13
+ * `SKILL_CLASS`, `checks-repo.mjs` takes `ENTITY_STATUSES`. A copy of a value under test
14
+ * silently stops being the value under test.
13
15
  *
14
16
  * AND `inventoryCounts` FOLDS BACK INTO `tuiInventory`. P5d shipped a counting-only twin
15
17
  * with a docblock warning that the two must not become two classifiers; this is the phase
@@ -21,17 +23,18 @@
21
23
  */
22
24
  import path from 'node:path';
23
25
 
24
- import { ROOT, SRC, makeCfg, PACK_ORDER } from './checkout.mjs';
25
- import { firstBlockquote } from './native.mjs';
26
- import { renderAll } from './render.mjs';
27
- import { pyResolve } from './hosts.mjs';
28
- import { parseJson, readText } from './lib/fs.mjs';
26
+ import { ROOT, SRC, makeCfg, PACK_ORDER } from '../build/source.mjs';
27
+ import { firstBlockquote } from '../hosts/native.mjs';
28
+ import { renderAll } from '../build/render.mjs';
29
+ import { resolvePath } from '../hosts/hosts.mjs';
30
+ import { readText } from '../lib/fs.mjs';
31
+ import { parseJson } from '../lib/json.mjs';
29
32
 
30
33
  /**
31
34
  * `_harness_tui.LAW_HEADING_RE` — a heading in the RENDERED laws file, e.g.
32
35
  * `### Rule IV — Deletion Is Deliberate`.
33
36
  *
34
- * Not `js/doctor.mjs`'s `LAW_HEADING_RE`, which matches the `{{LAW}}` token in the
37
+ * Not `js/inspect/checks-authoring.mjs`'s `LAW_HEADING_RE`, which matches the `{{LAW}}` token in the
35
38
  * unrendered source. Two patterns, two inputs, two names in Python as well.
36
39
  */
37
40
  export const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
@@ -173,7 +176,7 @@ export function parseDoctrines(text) {
173
176
  *
174
177
  * `id` is the slugified title, and it is an ADDRESS: `#/item/law/ont:telos` is how the console
175
178
  * deep-links a section, so it has to survive a theme change. It does, because the four titles
176
- * come from `STRUCTURE` in `js/render.mjs` and not from any theme file — the same reason a
179
+ * come from `STRUCTURE` in `js/build/render.mjs` and not from any theme file — the same reason a
177
180
  * citation can spell itself `({{ONTOLOGY}}: {{ONT_TELOS}})` with a token on both sides.
178
181
  *
179
182
  * The preamble above the first `####` is dropped, as in the two parsers above. It is the
@@ -202,7 +205,7 @@ export function parseOntology(text) {
202
205
  *
203
206
  * Deliberately forgiving on BOTH failure modes, as the reference is: a missing file and a
204
207
  * corrupt one both yield `{}`, which reads every badge as "unknown" rather than breaking
205
- * the browser. `registryProblems` in `js/doctor.mjs` is the loud reader of the same file
208
+ * the browser. `registryProblems` in `js/inspect/checks-repo.mjs` is the loud reader of the same file
206
209
  * and stays separate — the two answer different questions.
207
210
  */
208
211
  export function loadRegistry() {
@@ -265,7 +268,7 @@ export function tuiInventory(themeName, doctrines = null, excluded = []) {
265
268
  const name = two ? parts[1].slice(0, -3) : null;
266
269
  if (two && (parts[0] === 'agents' || parts[0] === 'skills')) {
267
270
  const entry = { name, desc: firstBlockquote(text), body: text,
268
- source: pyResolve(src), status: entityStatus(registry, `${parts[0]}/${name}`) };
271
+ source: resolvePath(src), status: entityStatus(registry, `${parts[0]}/${name}`) };
269
272
  if (parts[0] === 'agents') agents.push(entry);
270
273
  else {
271
274
  entry.klass = has(SKILL_CLASS, name) ? SKILL_CLASS[name] : 'build';
@@ -279,12 +282,12 @@ export function tuiInventory(themeName, doctrines = null, excluded = []) {
279
282
  if (two && parts[0] === 'doctrines') {
280
283
  const lead = text.split('\n').map((l) => PACK_LEAD_RE.exec(l)).find(Boolean);
281
284
  packs.set(name, { pack: name, title: lead ? lead[1] : name, desc: lead ? lead[2] : '',
282
- source: pyResolve(src), rules: parseDoctrines(text) });
285
+ source: resolvePath(src), rules: parseDoctrines(text) });
283
286
  }
284
287
  // Scoped to the laws dir, not matched on the basename: `src/ontology/universal.md`
285
288
  // shares the name, sorts after `laws/`, and would clobber the parse with `[]` —
286
289
  // `LAW_HEADING_RE` wants `###` and the ontology uses `####`. Same spelling as the
287
- // already-scoped sites at `js/render.mjs`'s `rel === 'laws/universal.md'`.
290
+ // already-scoped sites at `js/build/render.mjs`'s `rel === 'laws/universal.md'`.
288
291
  if (parts.join('/') === 'laws/universal.md') laws = parseLaws(text);
289
292
  if (parts.join('/') === 'ontology/universal.md') ontology = parseOntology(text);
290
293
  }
@@ -294,7 +297,7 @@ export function tuiInventory(themeName, doctrines = null, excluded = []) {
294
297
  // ⚠ `PACK_ORDER` IS THE SINGLE OWNER OF THE SET, and of its order. Filtering the order by
295
298
  // what was found — rather than publishing what was found — is what keeps `README.md` and any
296
299
  // other stray `.md` in that directory out of the catalogue, and it is the same membership
297
- // rule `js/render.mjs` refuses the build over, so a fifth pack cannot be silently dropped
300
+ // rule `js/build/render.mjs` refuses the build over, so a fifth pack cannot be silently dropped
298
301
  // here either. Order because `sortedSourceFiles` walks alphabetically, which puts `ops`
299
302
  // second: the narrative order is the reading order, and it is what the marker line, the
300
303
  // wizard and the rendered section all use.
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * `_install_registry.py` — the persistent list of folders a harness was deployed into.
3
3
  *
4
- * MOVED OUT OF `bin/geneseed.mjs` IN P5f, for the third time the same arithmetic has decided
4
+ * MOVED OUT OF `bin/build-driver.mjs` IN P5f, for the third time the same arithmetic has decided
5
5
  * a module boundary in this port (P5c took the host resolvers, P5d the checkout paths). The
6
6
  * driver WRITES the registry, one row per per-repo emit; `harness rebuild-all` READS it to
7
7
  * find every install it must re-emit, and `bin/geneseed-cli.mjs` may not reach into a driver
8
- * for a reader. `js/generate.mjs` importing `main` from `bin/geneseed.mjs` is not a
8
+ * for a reader. `js/build/generate.mjs` importing `main` from `bin/build-driver.mjs` is not a
9
9
  * precedent for it either: `harness build` IS the driver, and a registry reader is not.
10
10
  *
11
- * The move is licensed the same way the two before it were: `tests/golden.py` drives
12
- * `bin/geneseed.mjs` over 259 cells with `$XDG_CONFIG_HOME` redirected into the sandbox and
11
+ * The move is licensed the same way the two before it were: `tests/golden.mjs` drives
12
+ * `bin/build-driver.mjs` over 259 cells with `$XDG_CONFIG_HOME` redirected into the sandbox and
13
13
  * compares `installs.json` byte-for-byte, so a changed path or a dropped prune fails cells
14
14
  * rather than passing quietly.
15
15
  */
@@ -17,8 +17,10 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync
17
17
  import path from 'node:path';
18
18
  import os from 'node:os';
19
19
 
20
- import { expanduser } from './hosts.mjs';
21
- import { writeText, parseJson, jsonDumpsIndent, pyPathStr } from './lib/fs.mjs';
20
+ import { expanduser } from '../hosts/hosts.mjs';
21
+ import { writeText } from '../lib/fs.mjs';
22
+ import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
23
+ import { toPlatformPath } from '../lib/paths.mjs';
22
24
 
23
25
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
24
26
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
@@ -53,7 +55,7 @@ export function registrySave(items) {
53
55
  *
54
56
  * `realpathSync` rather than `path.resolve`, because Python's `Path.resolve()` returns the
55
57
  * filesystem's own casing and follows links; `path.resolve` only makes a path absolute. The
56
- * registry is compared byte-for-byte by `tests/golden.py` (XDG is redirected into the
58
+ * registry is compared byte-for-byte by `tests/golden.mjs` (XDG is redirected into the
57
59
  * sandbox), so a differently-cased entry is a failing cell.
58
60
  */
59
61
  export function registryRecord(dir) {
@@ -80,20 +82,20 @@ export function registryRoots() {
80
82
  const kept = [];
81
83
  const seen = new Set();
82
84
  for (const s of original) {
83
- // `Path(s).expanduser()`, and `pyPathStr` is the `str()` around it — which is where the
85
+ // `Path(s).expanduser()`, and `toPlatformPath` is the `str()` around it — which is where the
84
86
  // NORMALISATION happens. Python turns `C:/x/other` into `C:\x\other` before it is
85
87
  // compared, kept, returned and printed, so a registry entry spelled with forward slashes
86
88
  // (routine: every tool that writes one from Git Bash) makes Python rewrite the file and
87
89
  // print a backslash path while a raw `expanduser` prints the entry verbatim and leaves
88
90
  // installs.json alone. Invisible to every cell before P5h, because `rebuild-all`'s
89
91
  // registry cells seed native spellings; `uninstall`'s inventory prints the root.
90
- // `expanduser` now REFUSES a `~user` entry (js/hosts.mjs's docblock) instead of passing
92
+ // `expanduser` now REFUSES a `~user` entry (js/hosts/hosts.mjs's docblock) instead of passing
91
93
  // it through. This loop scans STORED registry rows, not a fresh CLI argument — a single
92
94
  // bad row (hand-edited, or written before this refusal existed) must not abort the scan
93
95
  // for every other install, so it is dropped exactly like any other dead row rather than
94
96
  // propagated. `rebuild-all`'s contract is "one broken install never blocks the rest".
95
97
  let root;
96
- try { root = pyPathStr(expanduser(s)); } catch { continue; }
98
+ try { root = toPlatformPath(expanduser(s)); } catch { continue; }
97
99
  let key;
98
100
  try { key = existsSync(root) ? realpathSync.native(root) : root; } catch { key = root; }
99
101
  if (seen.has(key)) continue;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `_harness_core`'s scanning primitives — what every doctor check reads the tree with.
3
+ *
4
+ * Split out of `doctor.mjs` because all three check modules need them and none of the three
5
+ * owns them. Nothing here decides anything: these walk, sort, strip and test, and the
6
+ * judgement lives in `checks-build.mjs`, `checks-repo.mjs` and `checks-authoring.mjs`.
7
+ *
8
+ * `NOTE` and `isDoctorNote` sit here for the same reason — a check in any of the three
9
+ * marks an advisory line with the prefix, and the driver in `doctor.mjs` reads it back.
10
+ */
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { SRC } from '../build/source.mjs';
14
+ import { comparePaths, normcase } from '../lib/paths.mjs';
15
+ import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
16
+
17
+ // --------------------------------------------------------------------------------------
18
+ // _harness_core's scanning primitives
19
+ // --------------------------------------------------------------------------------------
20
+
21
+ /** `_harness_core.TOKEN_RE` / `LINK_RE` / `ABS_LINK_RE`. `g` where `findall` is used. */
22
+ // Must stay in step with `js/build/render.mjs`'s TOKEN_RE: a token the renderer substitutes but this
23
+ // scan cannot see is an unresolved-token gate that silently stops gating. Digits are legal after
24
+ // the first character for `DOC_<PACK>_<n>`; a leading digit is still not a token.
25
+ export const TOKEN_RE = /\{\{[A-Z_][A-Z0-9_]*\}\}/g;
26
+ export const LINK_RE = /\]\((?!https?:\/\/|#)([^)]+)\)/g;
27
+ export const ABS_LINK_RE = /^([A-Za-z]:[\\/]|\/|~)/;
28
+ const FENCE_RE = /```[\s\S]*?```/g;
29
+ const INLINE_CODE_RE = /`[^`]*`/g;
30
+ const COMMENT_RE = /<!--[\s\S]*?-->/g;
31
+
32
+ /** `_harness_core.strip_code` — fenced blocks, inline code and HTML comments, in that order. */
33
+ export function stripCode(text) {
34
+ return text.replace(FENCE_RE, '').replace(COMMENT_RE, '').replace(INLINE_CODE_RE, '');
35
+ }
36
+
37
+ /**
38
+ * `_harness_core._within` — `child.relative_to(parent)` without the exception.
39
+ *
40
+ * Segment-wise and through `normcase`, because that is what `PurePath` comparison does: a
41
+ * bundle at `C:\Temp\X` contains `c:\temp\x\a.md` on Windows and does not on Linux. A
42
+ * `startsWith` on the raw strings would additionally call `/tmp/bundle2` a child of
43
+ * `/tmp/bundle`, which is the classic version of this bug.
44
+ */
45
+ export function within(child, parent) {
46
+ const c = normcase(child).split(/[\\/]/);
47
+ const p = normcase(parent).split(/[\\/]/);
48
+ return p.length <= c.length && p.every((seg, i) => c[i] === seg);
49
+ }
50
+
51
+ export const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
52
+ export const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
53
+
54
+ /** `sorted(d.glob(pat))`, or `[]` for a directory that is not there. */
55
+ export function globSorted(dir, filter) {
56
+ let names;
57
+ try { names = readdirSync(dir); } catch { return []; }
58
+ return names.filter(filter).map((n) => path.join(dir, n)).sort(comparePaths);
59
+ }
60
+
61
+ /** `Path.rglob('*')` — every entry under `dir`, sorted as `sorted()` sorts paths. */
62
+ export function rglob(dir) {
63
+ const out = [];
64
+ const walk = (d) => {
65
+ let entries;
66
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
67
+ for (const e of entries.map((x) => x.name).sort(comparePaths)) {
68
+ const p = path.join(d, e);
69
+ out.push(p);
70
+ if (isDir(p)) walk(p);
71
+ }
72
+ };
73
+ walk(dir);
74
+ return out.sort(comparePaths);
75
+ }
76
+
77
+ // `withDiscardableStderr` was defined here while `renderedProblems` was its only caller.
78
+ // `js/hosts/hooks.mjs` became the second and third when the hook started CATCHING `expanduser`'s
79
+ // refusal, so it moved beside `printErr` in `js/lib/fs.mjs` — the writes it intercepts —
80
+ // and its docblock carries the "not for silencing noise" warning with it.
81
+
82
+ /** `Path.stem` — the basename with its last suffix removed. */
83
+ export const stemOf = (p) => path.basename(p, path.extname(p));
84
+
85
+ /** `sorted(set)` over strings, the ordering every problem list is emitted in. */
86
+ export const sortedUnique = (xs) => [...new Set(xs)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
87
+ /** `sorted(probs)` — SORTED, not deduped, which is what `_ran` fills a group with. */
88
+ export const sortedProblems = (xs) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
89
+
90
+ /**
91
+ * `k in d` for a Python dict — OWN keys only.
92
+ *
93
+ * Every membership test in this file reads a key that came out of a JSON document or a source
94
+ * filename, and a bare `k in obj` would answer true for `constructor`, `toString` and the rest
95
+ * of `Object.prototype`. A theme defining a `toString` voice token, or a skill file named
96
+ * `valueOf.md`, would then be reported as missing by the reference and not by this port — a
97
+ * divergence no cell can reach and no reviewer would look for.
98
+ */
99
+ export const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);
100
+
101
+ /** `_harness_build._src_stems` — spec stems under `src/<folder>`, minus `_`-scaffolds. */
102
+ export function srcStems(folder) {
103
+ const d = path.join(SRC, folder);
104
+ if (!isDir(d)) return new Set();
105
+ return new Set(globSorted(d, (n) => n.endsWith('.md') && !n.startsWith('_')).map(stemOf));
106
+ }
107
+
108
+ /**
109
+ * A line reported by the doctor that is NOT a failure — `[note] …` rather than `[authoring] …`.
110
+ *
111
+ * ⚠ ONE PRODUCER, AND IT IS THE ONLY THING THAT NEEDED A SECOND CHANNEL. A build with a
112
+ * doctrine pack off is LEGAL, and it leaves body prose citing rules that pack owns. That state
113
+ * has to be visible — a silent dangling citation is how prose and boundary drift apart — but
114
+ * reporting it as a problem would make `doctor` exit 1 over a configuration its owner chose.
115
+ * So `cmdDoctor` prints notes and does not count them.
116
+ */
117
+ export const NOTE = '[note] ';
118
+ export const isDoctorNote = (p) => p.startsWith(NOTE);
119
+
120
+ /** `tempfile.TemporaryDirectory()` — created, handed to `fn`, removed whatever happens. */
121
+ export function withTempDir(fn, prefix = 'geneseed-doctor-') {
122
+ const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
123
+ try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
124
+ }
125
+
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * WHERE THE DETECTORS WENT. `themeOfDir`, `footprintOfDir`, `installedDefaults`,
32
32
  * `manifestIsClaude` and the configured defaults were written here in P5d because `status`
33
- * was their only caller. `js/installs.mjs` owns them since P5f, when `diff` and `rebuild-all`
33
+ * was their only caller. `js/hosts/installs.mjs` owns them since P5f, when `diff` and `rebuild-all`
34
34
  * became the second and third. Nothing about them changed in the move — the 166-cell
35
35
  * acceptance matrix is what licensed attempting it, `status`'s own eleven cells among them.
36
36
  *
@@ -41,10 +41,11 @@
41
41
  * know), `accentFor`'s cyan fallback (an unknown theme is refused upstream by
42
42
  * `effectiveTheme`), and the whole ANSI half of `statusLines` (`_color_enabled` is
43
43
  * `sys.stdout.isatty()`, and every harness captures stdout through a pipe). All three are
44
- * PURE FUNCTIONS of their arguments, so all three are gated as a corpus in
45
- * `tests/test_pure_function_parity.py` instead — which is a third answer to the colour
46
- * question the P5c handoff posed as a choice between shipping it ungated and not shipping
47
- * it.
44
+ * PURE FUNCTIONS of their arguments, so all three are gated as a corpus instead:
45
+ * `tests/fixtures/pure_probe.mjs` calls them and `tests/snapshot/pure_snapshot.test.mjs`
46
+ * replays that against `tests/__snapshots__/primitives/`. That was a third answer to the
47
+ * colour question, which had been posed as a choice between shipping it ungated and not
48
+ * shipping it.
48
49
  *
49
50
  * `manifestIsClaude` was a FOURTH until wave 2 of the P0/P1 review: it is only reached for
50
51
  * a candidate with no known host, and `ROOT/"Harness"` was ordered ahead of the sandbox's
@@ -56,13 +57,13 @@
56
57
  import { existsSync, readdirSync } from 'node:fs';
57
58
  import path from 'node:path';
58
59
 
59
- import { THEMES, ROOT, makeCfg } from './checkout.mjs';
60
+ import { THEMES, ROOT, makeCfg } from '../build/source.mjs';
60
61
  import { tuiInventory } from './inventory.mjs';
61
- import { readVersion, sourceFingerprint } from './emit.mjs';
62
+ import { readVersion, sourceFingerprint } from '../build/version.mjs';
62
63
  import {
63
- claudeConfigDir, bobConfigDir, copilotConfigDir, opencodeConfigDir, pyResolve,
64
+ claudeConfigDir, bobConfigDir, copilotConfigDir, opencodeConfigDir, resolvePath,
64
65
  resolveMemoryDir,
65
- } from './hosts.mjs';
66
+ } from '../hosts/hosts.mjs';
66
67
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
67
68
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
68
69
  // three verbs now read them. `defaultTheme`, `manifestIsClaude` and `installedDefaults` are
@@ -70,8 +71,9 @@ import {
70
71
  // and a corpus that followed the code to its new file would stop testing the caller's view.
71
72
  import {
72
73
  defaultTheme, installedDefaults, manifestIsClaude, readJsonMaybe, readMaybe,
73
- } from './installs.mjs';
74
- import { pyLen, pyLjust, pyPrint } from './lib/fs.mjs';
74
+ } from '../hosts/installs.mjs';
75
+ import { printOut } from '../lib/fs.mjs';
76
+ import { codePointLength, padEndToWidth } from '../lib/text.mjs';
75
77
 
76
78
  export { defaultTheme, manifestIsClaude };
77
79
 
@@ -91,9 +93,9 @@ export function versionVerdict(installed, current) {
91
93
  export function cmdVersion(args) {
92
94
  const cfg = makeCfg();
93
95
  const current = sourceFingerprint(cfg);
94
- // `Path(args.target).expanduser().resolve()` — `pyResolve` already expands, so the tilde
96
+ // `Path(args.target).expanduser().resolve()` — `resolvePath` already expands, so the tilde
95
97
  // is handled once rather than twice.
96
- let target = args.target ? pyResolve(args.target) : opencodeConfigDir();
98
+ let target = args.target ? resolvePath(args.target) : opencodeConfigDir();
97
99
  let installed = readVersion(target);
98
100
  if (installed === null) {
99
101
  // A claude/bob/copilot-only machine must not report "no install detected". The bundle
@@ -104,10 +106,10 @@ export function cmdVersion(args) {
104
106
  if (v) { installed = v; target = base; break; }
105
107
  }
106
108
  }
107
- pyPrint(`[version] source: ${current} (${ROOT})\n`);
108
- pyPrint(`[version] installed: ${installed || '(none found)'}`
109
+ printOut(`[version] source: ${current} (${ROOT})\n`);
110
+ printOut(`[version] installed: ${installed || '(none found)'}`
109
111
  + (installed ? ` (${target})` : '') + '\n');
110
- pyPrint(`[version] ${versionVerdict(installed, current)}\n`);
112
+ printOut(`[version] ${versionVerdict(installed, current)}\n`);
111
113
  return 0;
112
114
  }
113
115
 
@@ -121,7 +123,7 @@ export function cmdVersion(args) {
121
123
  * and porting them to produce three integers would have been absurd — with a docblock
122
124
  * warning that the two must not become two classifiers. `api_catalog` consumes exactly the
123
125
  * fields the count threw away, so P6c is the phase that would have made them two. It did
124
- * not: `tuiInventory` in `js/inventory.mjs` is the one walk, this is three `length`s off
126
+ * not: `tuiInventory` in `js/inspect/inventory.mjs` is the one walk, this is three `length`s off
125
127
  * it, and the eleven `status` cells and the catalog cells now gate the same classifier
126
128
  * from opposite ends.
127
129
  */
@@ -294,29 +296,29 @@ export function statusLines(d, color = false) {
294
296
  `${d.agent_md} (${d.agent_md_present ? 'present' : 'MISSING'})`]);
295
297
  }
296
298
 
297
- const labelW = Math.max(...rows.map(([k]) => pyLen(k)));
298
- const body = rows.map(([k, v]) => ` ${pyLjust(k, labelW)} ${v}`);
299
+ const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
300
+ const body = rows.map(([k, v]) => ` ${padEndToWidth(k, labelW)} ${v}`);
299
301
  const verdict = ` ${mark} ${d.version_verdict}`;
300
302
  const title = ` ${badge} Geneseed ${emdash} status `;
301
- const width = Math.max(...body.map(pyLen), pyLen(verdict), pyLen(title) + 2);
303
+ const width = Math.max(...body.map(codePointLength), codePointLength(verdict), codePointLength(title) + 2);
302
304
 
303
305
  const ac = ANSI_CODES[d.accent] ?? '36';
304
306
  const c = (s, code) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
305
307
 
306
308
  const top = color
307
- ? c(TL + H, ac) + c(title, `${ac};1`) + c(H.repeat(width - pyLen(title) - 1) + TR, ac)
308
- : TL + H + title + H.repeat(width - pyLen(title) - 1) + TR;
309
+ ? c(TL + H, ac) + c(title, `${ac};1`) + c(H.repeat(width - codePointLength(title) - 1) + TR, ac)
310
+ : TL + H + title + H.repeat(width - codePointLength(title) - 1) + TR;
309
311
  const edge = c(V, ac);
310
312
  const lines = [top];
311
- for (const b of body) lines.push(edge + pyLjust(b, width) + edge);
313
+ for (const b of body) lines.push(edge + padEndToWidth(b, width) + edge);
312
314
  lines.push(c(LT + H.repeat(width) + RT, ac));
313
- lines.push(edge + c(pyLjust(verdict, width), vcode) + edge);
315
+ lines.push(edge + c(padEndToWidth(verdict, width), vcode) + edge);
314
316
  lines.push(c(BL + H.repeat(width) + BR, ac));
315
317
  return lines;
316
318
  }
317
319
 
318
320
  /** `_harness_status.cmd_status`. */
319
321
  export function cmdStatus() {
320
- for (const line of statusLines(statusData(), colorEnabled())) pyPrint(`${line}\n`);
322
+ for (const line of statusLines(statusData(), colorEnabled())) printOut(`${line}\n`);
321
323
  return 0;
322
324
  }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `validate` — the generator's old `--validate-only`, on this binary because it runs the
3
+ * doctor's scans over a sandbox it renders first.
4
+ *
5
+ * Split out of `doctor.mjs`, where it already sat behind a banner of its own. It is a
6
+ * separate VERB with a separate exit contract, and it is the one verb no recorded cell ever
7
+ * reached — see its row in `tests/ported.json`.
8
+ */
9
+ import path from 'node:path';
10
+ import { buildInto, emitGlobalInto, emitProjectInto } from '../../bin/build-driver.mjs';
11
+ import { resolvePath } from '../hosts/hosts.mjs';
12
+ import { validateIsVendored } from '../hosts/native.mjs';
13
+ import { printOut, printErr, readText } from '../lib/fs.mjs';
14
+ import { comparePaths } from '../lib/paths.mjs';
15
+ import { stripWhitespace } from '../lib/text.mjs';
16
+ import { linkProblems } from './checks-build.mjs';
17
+ import { cmdDoctor } from './doctor.mjs';
18
+ import { TOKEN_RE, isDir, isFile, rglob, sortedUnique, withTempDir, within } from './scan.mjs';
19
+
20
+ // --------------------------------------------------------------------------------------
21
+ // validate — the generator's `--validate-only`, on this binary because it runs the doctor
22
+ // --------------------------------------------------------------------------------------
23
+
24
+ /**
25
+ * `build._validate_sandbox_problems` — the unresolved-token / dead-link / non-hermetic-link
26
+ * scan over an already-rendered sandbox tree.
27
+ *
28
+ * A NEAR-TWIN OF `checkBuild` ABOVE, AND DELIBERATELY NOT IT, because the reference is two
29
+ * functions and they differ in three observable ways: the token list is `sorted(set(...))`
30
+ * here and bare `set(...)` there, the message carries no `[theme]` prefix (the caller adds
31
+ * `[emit]` instead), and an unreadable file is SKIPPED rather than raised. Python duplicates
32
+ * the loop because `build.py` cannot import the harness tree; here the two live in one file
33
+ * and the two now sit one file apart (`checkBuild` is in `checks-build.mjs`); the
34
+ * duplication is eight lines — every primitive underneath (`rglob`, `stripCode`,
35
+ * `linkProblems`, `readText`) is the shared one, which is the half that could actually drift.
36
+ */
37
+ export function validateSandboxProblems(sandbox) {
38
+ const out = resolvePath(sandbox);
39
+ const problems = [];
40
+ for (const md of rglob(out)) {
41
+ if (!md.endsWith('.md') || !isFile(md)) continue;
42
+ const rel = path.relative(out, md);
43
+ if (validateIsVendored(rel)) continue;
44
+ let text;
45
+ // `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing here.
46
+ try { text = readText(md); } catch { continue; }
47
+ for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
48
+ problems.push(`unresolved token ${tok} in ${rel}`);
49
+ }
50
+ problems.push(...linkProblems(md, text, out, rel));
51
+ }
52
+ return problems;
53
+ }
54
+
55
+ /**
56
+ * `subprocess.run(..., capture_output=True, text=True)` around an IN-PROCESS call.
57
+ *
58
+ * The reference shells to `harness.py doctor`; this calls `cmdDoctor` directly, and the
59
+ * difference has to be undone in one place: a child's `capture_output` hands the parent
60
+ * UNIVERSAL-NEWLINE text, so `r.stdout` holds `\n` on Windows even though the child wrote
61
+ * `\r\n`. `printOut` inside `cmdDoctor` writes `\r\n`. Without the fold, the `.strip()` below
62
+ * would leave every interior line CRLF and the re-print would double the translation.
63
+ */
64
+ function captureStreams(fn) {
65
+ const chunks = { out: [], err: [] };
66
+ const realOut = process.stdout.write.bind(process.stdout);
67
+ const realErr = process.stderr.write.bind(process.stderr);
68
+ process.stdout.write = (c) => { chunks.out.push(String(c)); return true; };
69
+ process.stderr.write = (c) => { chunks.err.push(String(c)); return true; };
70
+ let code;
71
+ try {
72
+ code = fn();
73
+ } finally {
74
+ process.stdout.write = realOut;
75
+ process.stderr.write = realErr;
76
+ }
77
+ const decode = (xs) => xs.join('').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
78
+ return { code, out: decode(chunks.out), err: decode(chunks.err) };
79
+ }
80
+
81
+ /**
82
+ * `build._validate_only` — render + emit the requested target into a throwaway sandbox, run
83
+ * every validation a real build would gate on, print what WOULD have been written, and return
84
+ * the exit code. Nothing under the sandbox survives, and no marker, manifest or registry row
85
+ * is ever written for real.
86
+ *
87
+ * WHY IT IS HERE AND NOT ON THE GENERATOR DRIVER, which is where its flag lives. The
88
+ * source-tree half of the check IS the doctor, and this module starts a process (`node --check`
89
+ * over the OpenCode plugins). `bin/build-driver.mjs` is under a transitive ban on reaching any
90
+ * module that can, gated by `tests/unit/hook_cli.test.mjs`, which walks the driver's TRANSITIVE
91
+ * imports rather than grepping its own source — a source grep walks straight past an
92
+ * `import … from './x.mjs'` that reaches child_process. Siting the tool on the CLI
93
+ * binary, which already carries the doctor, crosses it with neither gate amended and without
94
+ * the dynamic `import()` that would evade the walk.
95
+ *
96
+ * THE MARKER GUARANTEE COMES FREE, and that is why `emitProjectInto`/`emitGlobalInto`/
97
+ * `buildInto` are used rather than `driverMain(['--emit', …])`: the driver writes
98
+ * `.geneseed-emit`/`.geneseed-footprint` and records an install-registry row AFTER the
99
+ * dispatch, so routing through it would register a temp directory that is deleted a
100
+ * millisecond later. The reference calls the emit functions directly for the same reason.
101
+ *
102
+ * THE EMIT'S OWN STDOUT IS NOT SWALLOWED. `globalEmitProblems` above wraps its call in
103
+ * `swallowStdout` and this deliberately does not: `_validate_only` has no `redirect_stdout`,
104
+ * so the ~200 lines of emit summary ARE part of what a dry run shows the operator.
105
+ *
106
+ * NO CELL REACHES THIS. The recorded corpus's argv never emitted `--validate-only`, so the
107
+ * flag was ungated across implementations for the whole port. What holds it now is
108
+ * `tests/unit/generate.test.mjs`, which drives `cmdValidate` directly and asserts its exit
109
+ * contract absolutely — this verb is deliberately outside the verb table, so nothing else does.
110
+ */
111
+ export function cmdValidate(args) {
112
+ const problems = [];
113
+ const scan = withTempDir((tmpRaw) => {
114
+ // `.resolve()` mirrors `_check_build`'s: on Windows a temp dir can come back in 8.3
115
+ // short form (`RUNNER~1`) while every link TARGET below is resolved long-form, and
116
+ // `within` then rejects every relative link rather than only the escaping ones.
117
+ const tmp = resolvePath(tmpRaw);
118
+ // With a distinct `--root` the per-repo emits split their output — the bundle under
119
+ // `out`, the native layer under `root` — so the sandbox bundle nests INSIDE the sandbox
120
+ // root and the scan covers both layers.
121
+ const root = args.root ? path.join(tmp, 'root') : path.join(tmp, 'out');
122
+ const sandbox = args.root ? path.join(root, 'bundle') : root;
123
+ const cfgDir = path.join(tmp, 'cfg');
124
+ const emit = args.emit;
125
+ const opts = { theme: args.theme, footprint: args.footprint };
126
+ let scanDirs;
127
+ try {
128
+ if (emit.endsWith('-global')) {
129
+ emitGlobalInto(emit.slice(0, -'-global'.length), { ...opts, out: sandbox, cfgDir });
130
+ scanDirs = [cfgDir];
131
+ } else if (emit === 'files') {
132
+ buildInto({ ...opts, out: sandbox });
133
+ scanDirs = [sandbox];
134
+ } else {
135
+ emitProjectInto(emit, { ...opts, out: sandbox, root });
136
+ scanDirs = [root];
137
+ }
138
+ } catch (e) {
139
+ // `except SystemExit as e` — a DELIBERATE refusal from the render (an unknown theme,
140
+ // an incomplete source), interpolated as `{e}`.
141
+ //
142
+ // THE EXIT CODE AND NOT THE MESSAGE, measured rather than assumed: the reference's
143
+ // render half is ALREADY this port. `_build_render.build` calls `_build_core.run_node`,
144
+ // which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
145
+ // that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
146
+ // sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
147
+ // says `unknown theme 'x'` where the reference says `1`. The corpus in
148
+ // `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
149
+ if (e && e.exitCode !== undefined) {
150
+ printOut(`[validate-only] render/emit FAILED for theme '${args.theme}' `
151
+ + `emit '${emit}': ${e.exitCode}\n`);
152
+ return null;
153
+ }
154
+ throw e;
155
+ }
156
+
157
+ const written = scanDirs.filter(isDir).flatMap((d) => rglob(d)).filter(isFile)
158
+ .sort(comparePaths);
159
+ printOut(`[validate-only] theme=${args.theme} emit=${emit} `
160
+ + `footprint=${args.footprint}\n`);
161
+ printOut(`[validate-only] would write ${written.length} file(s) under ${args.out}`
162
+ + (args.root ? ` (root ${args.root})` : '')
163
+ + ' — nothing was actually written (sandboxed).\n');
164
+ if (args.verbose) {
165
+ let base = scanDirs[0];
166
+ for (const p of written) {
167
+ for (const d of scanDirs) {
168
+ if (within(p, d)) { base = d; break; }
169
+ }
170
+ printOut(` would write: ${path.relative(base, p)}\n`);
171
+ }
172
+ }
173
+ for (const d of scanDirs) {
174
+ for (const p of validateSandboxProblems(d)) problems.push(`[${emit}] ${p}`);
175
+ }
176
+ return scanDirs;
177
+ }, 'geneseed-validate-');
178
+ if (scan === null) return 1;
179
+
180
+ // The source-tree-wide checks (theme parity, authoring gates, AGENT.md table parity,
181
+ // colour themes) do not depend on --out/--root/--emit at all and already live fully tested
182
+ // in the doctor, so they run through it rather than being re-derived. The reference SHELLS
183
+ // to `harness.py doctor --theme T --no-bundle`; one process is what this port has.
184
+ const doctor = captureStreams(() => cmdDoctor({ theme: args.theme, noBundle: true }));
185
+ if (stripWhitespace(doctor.out)) printOut(`${stripWhitespace(doctor.out)}\n`);
186
+ if (stripWhitespace(doctor.err)) printErr(`${stripWhitespace(doctor.err)}\n`);
187
+ if (doctor.code !== 0) {
188
+ problems.push(`[doctor] source-tree validation failed for theme '${args.theme}' `
189
+ + '(see output above)');
190
+ }
191
+
192
+ if (problems.length) {
193
+ printOut(`[validate-only] ${problems.length} problem(s):\n`);
194
+ for (const p of problems) printOut(` - ${p}\n`);
195
+ return 1;
196
+ }
197
+ printOut('[validate-only] ok — would render and emit cleanly, no problems found.\n');
198
+ return 0;
199
+ }