geneseed 3.1.1 → 3.1.3

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 (106) hide show
  1. package/CHANGELOG.md +123 -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/docs/web/exclusions.md +1 -1
  12. package/docs/web/footprint.md +1 -1
  13. package/docs/web/foreman-mode.md +2 -2
  14. package/docs/web/themes-author.md +1 -1
  15. package/docs/web-ui.md +41 -29
  16. package/harness.config.json +1 -1
  17. package/js/README.md +203 -0
  18. package/js/build/bundle.mjs +231 -0
  19. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  20. package/js/build/emit-claude.mjs +287 -0
  21. package/js/build/emit-common.mjs +255 -0
  22. package/js/build/emit-opencode.mjs +214 -0
  23. package/js/{generate.mjs → build/generate.mjs} +27 -28
  24. package/js/{render.mjs → build/render.mjs} +13 -9
  25. package/js/{checkout.mjs → build/source.mjs} +9 -9
  26. package/js/build/stubs.mjs +256 -0
  27. package/js/{themes.mjs → build/themes.mjs} +24 -24
  28. package/js/build/version.mjs +155 -0
  29. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  30. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  31. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  32. package/js/{link.mjs → hosts/link.mjs} +29 -28
  33. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  34. package/js/{native.mjs → hosts/native.mjs} +24 -22
  35. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  36. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  37. package/js/inspect/checks-authoring.mjs +761 -0
  38. package/js/inspect/checks-build.mjs +231 -0
  39. package/js/inspect/checks-repo.mjs +308 -0
  40. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  41. package/js/inspect/doctor.mjs +286 -0
  42. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  43. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  44. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  45. package/js/inspect/scan.mjs +125 -0
  46. package/js/{status.mjs → inspect/status.mjs} +27 -25
  47. package/js/inspect/validate.mjs +199 -0
  48. package/js/lib/fs.mjs +37 -759
  49. package/js/lib/json.mjs +344 -0
  50. package/js/lib/paths.mjs +242 -0
  51. package/js/lib/proc.mjs +3 -3
  52. package/js/lib/text.mjs +181 -0
  53. package/js/lib/udiff.mjs +1 -1
  54. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  55. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  56. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  57. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  58. package/js/{update.mjs → maintain/update.mjs} +51 -51
  59. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  60. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  61. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  62. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  63. package/js/web/actions.mjs +84 -85
  64. package/js/web/activity.mjs +22 -21
  65. package/js/web/api.mjs +154 -51
  66. package/js/web/daemon.mjs +319 -0
  67. package/js/web/docs.mjs +24 -21
  68. package/js/web/graph.mjs +0 -0
  69. package/js/web/handler.mjs +442 -0
  70. package/js/web/jobs.mjs +21 -19
  71. package/js/web/routes.mjs +245 -0
  72. package/js/web/server.mjs +56 -987
  73. package/package.json +11 -3
  74. package/src/doctrines/README.md +1 -1
  75. package/themes/opencode/README.md +1 -1
  76. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
  77. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
  78. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
  79. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
  80. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
  81. package/web/dist/assets/Harness-BIWei7N5.js +1 -0
  82. package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
  83. package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
  84. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
  85. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
  86. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
  87. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
  88. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
  89. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
  90. package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
  91. package/web/dist/assets/index-DC3-GlCY.js +1 -0
  92. package/web/dist/assets/index-DP035wPa.js +12 -0
  93. package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +5 -19
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1750
  99. package/js/emit.mjs +0 -1411
  100. package/web/dist/assets/About-DyK7yRy7.js +0 -1
  101. package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
  102. package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
  103. package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
  104. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  105. package/web/dist/assets/index-CH_Z725C.js +0 -12
  106. package/web/dist/assets/index-jrY4m9jP.js +0 -1
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The OpenCode emits — a project's `.opencode/` and the global config directory.
3
+ *
4
+ * Split out of `emit.mjs` beside `emit-claude.mjs`, over the shared writers in
5
+ * `emit-common.mjs`. The two are siblings on purpose: an emit that is right for one host and
6
+ * wrong for the other is the defect these have historically carried, and it reads far more
7
+ * plainly as two files with the same shape than as two sections of one.
8
+ */
9
+ import path from 'node:path';
10
+ import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
+ import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
+ import {
13
+ copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
14
+ writePonytailCommand, writePrimaryAgent, writeTheme,
15
+ } from '../hosts/opencode.mjs';
16
+ import { mergeOpencodeJson } from '../hosts/settings.mjs';
17
+ import { readText, writeText } from '../lib/fs.mjs';
18
+ import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
19
+ import {
20
+ globalMemory, globalNotebook, isFile, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ } from './emit-common.mjs';
22
+ import { renderAll } from './render.mjs';
23
+ import {
24
+ ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub, ensureRulesStub,
25
+ ensureWikiStub,
26
+ } from './stubs.mjs';
27
+ import { writeVersion } from './version.mjs';
28
+ import { mkdirSync } from 'node:fs';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // emit_opencode — the RENDER stage only
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * RENDER and WIRE for `_build_emit.emit_opencode` — everything through the
36
+ * `mergeOpencodeJson` call at the bottom. PRUNE and MANIFEST are the driver's, in
37
+ * `bin/build-driver.mjs`.
38
+ *
39
+ * `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
40
+ * manifest is the DRIVER's stage: it reads the file, hands the prior claim in, and prunes
41
+ * and rewrites it after this returns. One owner of the file, and this function is not it.
42
+ *
43
+ * `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']` from
44
+ * `js/hosts/hosts.mjs`, resolved by the driver before the call; passing the decision rather
45
+ * than re-reading the registry keeps one owner of it.
46
+ */
47
+ export function emitOpencodeRender(cfg, job) {
48
+ phaseLog('RENDER');
49
+ const {
50
+ theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
51
+ } = job;
52
+ const oc = path.join(root, '.opencode');
53
+
54
+ build(cfg, _theme, out, { footprint, nativeCatalog });
55
+
56
+ // OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
57
+ // plain names (the portable build keeps them). A deliberate de-link, not a fix.
58
+ const agentMd = path.join(out, 'AGENT.md');
59
+ if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
60
+
61
+ const owned = [];
62
+ const { theme, items } = renderAll(cfg, _theme);
63
+
64
+ ensureAgentOverridesStub(cfg, out);
65
+ const overrides = loadAgentOverrides(out);
66
+
67
+ const { nAgents, nSkills, written } = writeNativeLayer(
68
+ items, path.join(oc, 'agents'), path.join(oc, 'skills'), overrides,
69
+ { host: 'opencode', oldOwned, cfg: oc, manifestExisted, theme, src: cfg.src });
70
+ for (const p of written) owned.push(relPosix(oc, p));
71
+
72
+ const primary = writePrimaryAgent(cfg, path.join(oc, 'agents'), overrides);
73
+ if (primary) owned.push(relPosix(oc, primary));
74
+
75
+ const commands = writeCommandLayer(cfg, items, path.join(oc, 'command'));
76
+ commands.push(writePonytailCommand(path.join(oc, 'command'))); // always-on /ponytail
77
+ for (const p of commands) owned.push(relPosix(oc, p));
78
+
79
+ owned.push(relPosix(oc, writeTheme(path.join(oc, 'themes'), _theme, theme)));
80
+ for (const p of writeColorThemes(cfg, path.join(oc, 'themes'))) owned.push(relPosix(oc, p));
81
+
82
+ const nPlugins = copyPlugins(cfg, path.join(oc, 'plugins'), owned);
83
+ const nWorkflows = copyWorkflows(cfg, path.join(oc, 'workflows'), owned);
84
+
85
+ // WIRE — the one file of this emit the user co-owns. `agentPath` arrives decided
86
+ // (`_rel_under` is the Python side by design), and only the BASENAME is consumed
87
+ // downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
88
+ phaseLog('WIRE');
89
+ const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath,
90
+ cfg.doctrines, cfg.excludeRules));
91
+
92
+ return {
93
+ owned,
94
+ stats: {
95
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
96
+ },
97
+ cfgName,
98
+ };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // emit_opencode_global — the ninth, and the last emit to cross
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * BOTH halves of `_build_global.emit_opencode_global`, the "everything global, zero
107
+ * per-repo" OpenCode deployment. PRUNE and MANIFEST are the driver's; there is no VERIFY,
108
+ * because this emit writes no settings file.
109
+ *
110
+ * ASSEMBLY, NOT TRANSLATION. Every unit it needs was already here — 58 of the 65 functions
111
+ * in its Python closure, counted with `ast` rather than by name-mapping, which is how the
112
+ * scout that produced this list first reported `_posture_body` and `_mode_body` as missing
113
+ * when both are `registerBody` in `js/build/render.mjs`. What is new is the assembly and the
114
+ * three values that had to be decided on the Python side.
115
+ *
116
+ * `cfgDir` IS THE FIRST OF THEM AND THE REASON THE OTHERS WERE ASKED ABOUT.
117
+ * `_build_core._opencode_config_dir` is an `_OWNED` name — the suite and `harness diff`
118
+ * both redirect it at a sandbox — so resolving it here would render 135 files into the
119
+ * developer's real `~/.config/opencode`. Python's entry point resolves it once, before
120
+ * anything else, and sends the answer. Fourth phase running (`STRUCTURE` at P2d,
121
+ * `capabilityLinkRe` at P2e, `_PREAMBLE_CONFIG_DIR` at P3b): send the decision, never the
122
+ * resolver.
123
+ *
124
+ * `out` IS NOT THE TARGET, and with the default nobody can tell. `emit_opencode_global`
125
+ * writes into `<cfg>` and takes `out` ONLY as the legacy bundle to migrate a memory or
126
+ * notebook store from; the CLI's `--out` defaults to a directory this emit never writes a
127
+ * byte into. A port that derived the legacy source from `<cfg>` would pass every cell that
128
+ * leaves them coincident — the `claude/bundle-in-subfolder` finding at a third call site.
129
+ *
130
+ * `agentPath` travels for the same reason `emitOpencodeRender`'s does: it is WIRE's one
131
+ * input, and `Path.as_posix()` is the Python side's spelling of it.
132
+ */
133
+ export function emitOpencodeGlobalRender(cfg, job) {
134
+ phaseLog('RENDER');
135
+ const {
136
+ theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
137
+ } = job;
138
+
139
+ // No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
140
+ // pointer's relative `laws/universal.md` resolves with no prefix.
141
+ const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
142
+ assertSourceComplete(cfg, 'opencode-global');
143
+ mkdirSync(cfgDir, { recursive: true });
144
+
145
+ const owned = [];
146
+ const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
147
+ if (agentText !== null) {
148
+ // OpenCode loads agents/skills natively, so drop AGENT.md's per-row spec links to
149
+ // plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
150
+ // store are siblings, so `memory/` resolves from AGENT.md's own location and stays
151
+ // hermetic — no absolute path a doctor check would flag as an escape.
152
+ writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
153
+ owned.push('AGENT.md');
154
+ }
155
+
156
+ ensureAgentOverridesStub(cfg, cfgDir);
157
+ const overrides = loadAgentOverrides(cfgDir);
158
+
159
+ // `manifestExisted` is deliberately not passed — the Python does not pass it either, so
160
+ // the pre-manifest header line is unreachable from this emit on both sides.
161
+ const { nAgents, nSkills, written } = writeNativeLayer(
162
+ items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'), overrides,
163
+ { host: 'opencode', oldOwned, cfg: cfgDir, theme, src: cfg.src });
164
+ for (const p of written) owned.push(relPosix(cfgDir, p));
165
+
166
+ const primary = writePrimaryAgent(cfg, path.join(cfgDir, 'agents'), overrides);
167
+ if (primary) owned.push(relPosix(cfgDir, primary));
168
+
169
+ const commands = writeCommandLayer(cfg, items, path.join(cfgDir, 'command'));
170
+ commands.push(writePonytailCommand(path.join(cfgDir, 'command'))); // always-on /ponytail
171
+ for (const p of commands) owned.push(relPosix(cfgDir, p));
172
+
173
+ owned.push(relPosix(cfgDir, writeTheme(path.join(cfgDir, 'themes'), themeName, theme)));
174
+ for (const p of writeColorThemes(cfg, path.join(cfgDir, 'themes'))) {
175
+ owned.push(relPosix(cfgDir, p));
176
+ }
177
+
178
+ const nPlugins = copyPlugins(cfg, path.join(cfgDir, 'plugins'), owned);
179
+ const nWorkflows = copyWorkflows(cfg, path.join(cfgDir, 'workflows'), owned);
180
+
181
+ const memStatus = globalMemory(cfgDir, items, out, cfg.src);
182
+ ensureMemoryIndex(path.join(cfgDir, 'memory'));
183
+ const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
184
+ ensureNotebookIndex(path.join(cfgDir, 'notebook'));
185
+ ensureWikiStub(cfgDir);
186
+ ensureRulesStub(cfgDir);
187
+ ensureProfileStub(cfgDir);
188
+ ensureExcludesStub(cfgDir);
189
+
190
+ writeVersion(cfg, cfgDir);
191
+ owned.push(VERSION_MARKER);
192
+
193
+ shipLeanLaws(items, theme, cfgDir, owned, footprint);
194
+
195
+ // WIRE — the one file of this emit the user co-owns, and the last render is now behind
196
+ // us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same file
197
+ // name). Nothing walks the SOURCE for that split now: `tests/unit/emit_phase_order.test.mjs`
198
+ // reads the runtime `GENESEED_PHASE_LOG` trace instead, so the `phaseLog('WIRE')` below is
199
+ // what the gate sees — and deleting it is mutation M30.
200
+ phaseLog('WIRE');
201
+ const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
202
+ agentPath, cfg.doctrines, cfg.excludeRules));
203
+
204
+ return {
205
+ owned,
206
+ stats: {
207
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
208
+ },
209
+ memStatus,
210
+ nbStatus,
211
+ cfgName,
212
+ };
213
+ }
214
+
@@ -1,19 +1,19 @@
1
1
  /**
2
- * `rituals/_harness_build.py`'s three cheap verbs — `build`, `prompt` and `theme`.
2
+ * The three cheap generator verbs — `build`, `prompt` and `theme`.
3
3
  *
4
4
  * These are the harness's GENERATOR FACE: the subcommands whose whole job is to drive the
5
- * thing `bin/geneseed.mjs` already is. That is why they are the cheapest slice left and why
5
+ * thing `bin/build-driver.mjs` already is. That is why they are the cheapest slice left and why
6
6
  * they are the last one that can be measured by a closure walk alone — `cmd_build`'s body is
7
7
  * three lines because the work is a `subprocess`, and a name walk cannot see through one.
8
8
  *
9
9
  * WHY `build` IMPORTS THE DRIVER RATHER THAN SPAWNING IT. The Python is
10
10
  * `run([sys.executable, BUILD, *extra]).returncode` — a passthrough to a second process.
11
- * Reproducing that shape here would mean spawning `node bin/geneseed.mjs`, and
11
+ * Reproducing that shape here would mean spawning `node bin/build-driver.mjs`, and
12
12
  * `test_the_cli_reaches_no_child_process_module` forbids it: the ban is transitive over this
13
13
  * entry's imports, and P5c's argument for keeping it is that `web`, `upgrade` and `setup`
14
14
  * will genuinely need to spawn, so the ban should outlive them rather than be dismantled by
15
15
  * a verb that does not. It is also the wrong shape on its own terms. Python needs a second
16
- * process because `build.py` is a different PROGRAM; `bin/geneseed.mjs` is a module in this
16
+ * process because `build.py` is a different PROGRAM; `bin/build-driver.mjs` is a module in this
17
17
  * one, and a Node CLI spawning a Node driver to do work it could do in-process is exactly
18
18
  * the passthrough the whole port has been removing. So the driver's `main` is exported and
19
19
  * called directly, and the observable contract — the tree, the streams, the exit code — is
@@ -32,19 +32,18 @@
32
32
  import { existsSync, mkdirSync, statSync } from 'node:fs';
33
33
  import path from 'node:path';
34
34
 
35
- import { main as driverMain } from '../bin/geneseed.mjs';
36
- import { makeCfg, PACK_ORDER } from './checkout.mjs';
37
- import { opencodeConfigDir, pyResolve } from './hosts.mjs';
35
+ import { main as driverMain } from '../../bin/build-driver.mjs';
36
+ import { makeCfg, PACK_ORDER } from './source.mjs';
37
+ import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
38
38
  import {
39
39
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
40
40
  excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
41
41
  readMaybe, themeOfDir,
42
- } from './installs.mjs';
43
- import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from './opencode.mjs';
42
+ } from '../hosts/installs.mjs';
43
+ import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
44
44
  import { renderAll } from './render.mjs';
45
- import {
46
- jsonDumpsIndent, parseJson, pyPrint, pyPrintErr, pyRepr, readText, writeText,
47
- } from './lib/fs.mjs';
45
+ import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
46
+ import { jsonDumpsIndent, parseJson, formatRepr } from '../lib/json.mjs';
48
47
 
49
48
  /** `_harness_build._HEX_RE`. Anchored at BOTH ends — `#123` and `#1122334` both fail. */
50
49
  const HEX_RE = /^#[0-9a-fA-F]{6}$/;
@@ -52,18 +51,18 @@ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
52
51
  /**
53
52
  * `raise SystemExit(msg)` — the message on stderr, exit 1.
54
53
  *
55
- * The convention `js/emit.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
54
+ * The convention `js/build/bundle.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
56
55
  * honours: write at the raise site, mark the throw as a DELIBERATE refusal with `exitCode`,
57
56
  * and let anything unmarked keep its stack. Python prints a traceback for an unhandled
58
57
  * exception, so dressing a crash as a one-liner would hide a bug.
59
58
  */
60
59
  function sysExit(msg) {
61
- // `pyPrintErr`, NOT a local newline translation. The first draft inlined the `\n` ->
60
+ // `printErr`, NOT a local newline translation. The first draft inlined the `\n` ->
62
61
  // `\r\n` rule here and every cell passed, because `harness_golden` reads stderr through
63
62
  // `subprocess`'s universal-newline decode and cannot see the difference — the same
64
63
  // transport hole P5b measured. A second copy of a translation is exactly what P5d's
65
- // `pyStrPath`/`pyPathStr` split was, and it is ungated in precisely the same way.
66
- pyPrintErr(`${msg}\n`);
64
+ // `pyStrPath`/`toPlatformPath` split was, and it is ungated in precisely the same way.
65
+ printErr(`${msg}\n`);
67
66
  const e = new Error(msg);
68
67
  e.exitCode = 1;
69
68
  throw e;
@@ -188,7 +187,7 @@ export const DEFAULT_EMIT = new Map([
188
187
  export function cmdRebuildAll() {
189
188
  const targets = installTargets().filter(([h, s, r]) => installState(r, h, s) === 'active');
190
189
  if (!targets.length) {
191
- pyPrint('[rebuild-all] no active installs detected.\n');
190
+ printOut('[rebuild-all] no active installs detected.\n');
192
191
  return 0;
193
192
  }
194
193
  const failures = [];
@@ -218,20 +217,20 @@ export function cmdRebuildAll() {
218
217
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
219
218
  PACK_ORDER, excludeRules);
220
219
  const label = `${host}:${scope} (${root})`;
221
- pyPrint(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
220
+ printOut(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
222
221
  + `posture=${posture} mode=${mode}\n`);
223
222
  const rc = driverMain(argv);
224
223
  if (rc !== 0) {
225
224
  failures.push(label);
226
- pyPrintErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
225
+ printErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
227
226
  }
228
227
  }
229
228
  if (failures.length) {
230
- pyPrintErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
229
+ printErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
231
230
  + `${failures.join(', ')}\n`);
232
231
  return 1;
233
232
  }
234
- pyPrint(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
233
+ printOut(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
235
234
  return 0;
236
235
  }
237
236
 
@@ -311,9 +310,9 @@ export function cmdPrompt(args) {
311
310
  if (dest) mkdirSync(dest, { recursive: true });
312
311
  writeText(args.out, text);
313
312
  // The RAW argument, not the resolved path — `f"[prompt] wrote {args.out}"`.
314
- pyPrint(`[prompt] wrote ${args.out} (${themeName})\n`);
313
+ printOut(`[prompt] wrote ${args.out} (${themeName})\n`);
315
314
  } else {
316
- pyPrint(text);
315
+ printOut(text);
317
316
  }
318
317
  return 0;
319
318
  }
@@ -324,8 +323,8 @@ export function cmdPrompt(args) {
324
323
 
325
324
  /** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
326
325
  export function resolveThemesDir(args) {
327
- // `Path(args.dir).expanduser().resolve()` — `pyResolve` already expands a leading `~`.
328
- if (args.dir) return pyResolve(args.dir);
326
+ // `Path(args.dir).expanduser().resolve()` — `resolvePath` already expands a leading `~`.
327
+ if (args.dir) return resolvePath(args.dir);
329
328
  const repo = path.join(process.cwd(), '.opencode');
330
329
  if (!args.globalDir && isDir(repo)) return path.join(repo, 'themes');
331
330
  return path.join(opencodeConfigDir(), 'themes');
@@ -383,7 +382,7 @@ export function loadUserPalette(args, cfg) {
383
382
  }
384
383
  // `f"{r}={pal[r]!r}"` — Python's repr, so a string is single-quoted and an int is bare.
385
384
  const bad = [...pal].filter(([, v]) => !(typeof v === 'string' && HEX_RE.test(v)))
386
- .map(([r, v]) => `${r}=${pyRepr(v)}`);
385
+ .map(([r, v]) => `${r}=${formatRepr(v)}`);
387
386
  if (bad.length) sysExit(`[theme] non-#rrggbb value(s): ${bad.join(', ')}`);
388
387
  return pal;
389
388
  }
@@ -421,8 +420,8 @@ export function cmdTheme(args) {
421
420
  writeText(dest, `${jsonDumpsIndent(colorThemeJson(Object.fromEntries(pal), transparent))}\n`);
422
421
  written.push(dest);
423
422
  }
424
- pyPrint(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
425
- pyPrint(`[theme] select in OpenCode with: /theme ${full}`
423
+ printOut(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
424
+ printOut(`[theme] select in OpenCode with: /theme ${full}`
426
425
  + (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
427
426
  return 0;
428
427
  }
@@ -10,11 +10,13 @@
10
10
  * RENDER* -> WIRE* -> PRUNE -> MANIFEST -> VERIFY (see `_build_global._emit_claude_core`
11
11
  * and tests/test_emit_phase_order.py). RENDER produces files Geneseed owns wholesale;
12
12
  * WIRE reconciles files the user co-owns (settings.json, opencode.json, the CLAUDE.md
13
- * managed block) and stays in Python for now, in `_build_settings.py`. Node takes RENDER
13
+ * managed block) and lives in `js/hosts/settings.mjs`, driven from the two emit modules.
14
+ * THIS module is only RENDER
14
15
  * because it is the only stage whose output is a pure function of the source tree.
15
16
  *
16
17
  * PARITY IS THE CONTRACT, NOT THE INTENT. Every function here has a named Python
17
- * counterpart and must produce byte-identical output for it; tests/test_render_parity.py
18
+ * counterpart and had to produce byte-identical output for it. The reference is gone, so
19
+ * what holds the line is `tests/golden.mjs --against`, replaying recorded emit cells;
18
20
  * drives both over every theme x footprint x posture x mode and byte-compares the
19
21
  * written trees. Where Python and JS semantics differ silently — path sort order,
20
22
  * `.suffix`, `splitlines`, universal newlines — the difference is spelled out at the
@@ -28,9 +30,11 @@
28
30
  */
29
31
  import { readdirSync, statSync, existsSync } from 'node:fs';
30
32
  import path from 'node:path';
31
- import { readText, parseJson, normcase, comparePaths } from './lib/fs.mjs';
32
- // `js/checkout.mjs` imports nothing but node builtins, so this direction cannot cycle.
33
- import { PACK_ORDER } from './checkout.mjs';
33
+ import { readText } from '../lib/fs.mjs';
34
+ import { parseJson } from '../lib/json.mjs';
35
+ import { normcase, comparePaths } from '../lib/paths.mjs';
36
+ // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
+ import { PACK_ORDER } from './source.mjs';
34
38
 
35
39
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
36
40
  export const STRUCTURE = {
@@ -47,7 +51,7 @@ export const STRUCTURE = {
47
51
 
48
52
  /**
49
53
  * Source top-level dirs whose OUTPUT name is themed. Mirrors `SRC_DIR_TOKENS`.
50
- * Exported because `js/emit.mjs`'s `build` resolves the same tokens to decide which
54
+ * Exported because `js/build/bundle.mjs`'s `build` resolves the same tokens to decide which
51
55
  * dirs it owns and wipes — one owner, so the two cannot disagree about a folder name.
52
56
  */
53
57
  export const SRC_DIR_TOKENS = {
@@ -101,7 +105,7 @@ export function loadTheme(cfg, name) {
101
105
  // `sys.exit(<str>)` — the message on stderr, exit 1 — spelled the way
102
106
  // `assertSourceComplete` spells it: written at the raise site, and the throw carries
103
107
  // only the marker. ⚠ It was once described as an unreachable nicety under
104
- // `bin/geneseed.mjs`, on the grounds that `--theme` is validated against `choices` before
108
+ // `bin/build-driver.mjs`, on the grounds that `--theme` is validated against `choices` before
105
109
  // anything renders — it is not: `--theme` takes a value and has no `choices` entry, so THIS
106
110
  // is the refusal, on every path. It is also reachable from `geneseed status`, which takes
107
111
  // its theme from a `.geneseed-theme`
@@ -253,7 +257,7 @@ function registerBody(cfg, theme, dir, selected, fallback) {
253
257
  * entry point and an emit that quietly dropped a pack the user asked for is exactly the
254
258
  * failure the `Active packs:` marker would then attest to.
255
259
  *
256
- * Both throw with `exitCode`, the marker `bin/geneseed.mjs`'s `main` turns into a status.
260
+ * Both throw with `exitCode`, the marker `bin/build-driver.mjs`'s `main` turns into a status.
257
261
  */
258
262
  function activeDoctrines(cfg) {
259
263
  const dir = path.join(cfg.src, 'doctrines');
@@ -265,7 +269,7 @@ function activeDoctrines(cfg) {
265
269
  const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
266
270
  if (stray.length) {
267
271
  const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
268
- + 'but are missing from PACK_ORDER (js/checkout.mjs) — add them there, or they render '
272
+ + 'but are missing from PACK_ORDER (js/build/source.mjs) — add them there, or they render '
269
273
  + 'into no install at all');
270
274
  e.exitCode = 1;
271
275
  throw e;
@@ -2,8 +2,8 @@
2
2
  * The checkout this code is running FROM — `_build_core`'s ROOT and the paths under it,
3
3
  * plus the `cfg` every render needs.
4
4
  *
5
- * The inverse of `js/hosts.mjs`. That module answers "where does the output go"; this one
6
- * answers "where is the source". Both were `bin/geneseed.mjs`'s, and both leave it for the
5
+ * The inverse of `js/hosts/hosts.mjs`. That module answers "where does the output go"; this one
6
+ * answers "where is the source". Both were `bin/build-driver.mjs`'s, and both leave it for the
7
7
  * same arithmetic reason: `bin/geneseed-cli.mjs` needs them too, and the value that decides
8
8
  * which tree gets rendered is the last thing that should exist twice.
9
9
  *
@@ -12,13 +12,13 @@
12
12
  * `build.render_all(theme)`. So the CLI has to build the same `cfg` the driver builds, and
13
13
  * until this move only the driver could.
14
14
  *
15
- * THE TWO ABSENT KEYS ARE STILL ABSENT, and the reason is bin/geneseed.mjs's, unchanged:
15
+ * THE TWO ABSENT KEYS ARE STILL ABSENT, and the reason is bin/build-driver.mjs's, unchanged:
16
16
  * `_build_core.js_cfg()` always sends `structure` and `capabilityLinkRe` because the Python
17
17
  * originals are module-level names TESTS MUTATE (`_OWNED` membership asked one level out).
18
18
  * A Node process has no Python module to mutate, so it sends neither and
19
- * `js/render.mjs`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch.
19
+ * `js/build/render.mjs`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch.
20
20
  *
21
- * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/geneseed.mjs`
21
+ * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/build-driver.mjs`
22
22
  * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
23
23
  * from these paths. A depth error or a renamed key fails 259 cells, not zero.
24
24
  */
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url';
37
37
  * which is what makes them comparable and what makes them unfenceable. See the
38
38
  * status/version section of `tests/harness_golden.py`.
39
39
  */
40
- export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
40
+ export const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
41
41
  export const SRC = path.join(ROOT, 'src');
42
42
  export const CONFIG = path.join(ROOT, 'harness.config.json');
43
43
  export const THEMES = path.join(ROOT, 'themes');
@@ -48,8 +48,8 @@ export const WORKFLOW_SRC = path.join(ROOT, 'adapters', 'opencode', 'workflows')
48
48
  * `_build_render.posture_names()` / `mode_names()` — discovered, never hardcoded, so a new
49
49
  * posture file appears in both CLIs' choices with no code change.
50
50
  *
51
- * P5f moved this here from `bin/geneseed.mjs`, which had the only caller until
52
- * `js/installs.mjs` needed the same two lists: `_posture_of_dir` scans a deployed carrier for
51
+ * P5f moved this here from `bin/build-driver.mjs`, which had the only caller until
52
+ * `js/hosts/installs.mjs` needed the same two lists: `_posture_of_dir` scans a deployed carrier for
53
53
  * `**<Name>**` and has to be scanning for the SAME names the driver's `--posture` accepts.
54
54
  * Two discoveries of one directory is precisely the shape that lets a new posture file be
55
55
  * buildable and undetectable.
@@ -72,7 +72,7 @@ export function discoverNames(dir, first) {
72
72
  * DELIBERATELY NOT `discoverNames('doctrines', 'craft')`. That helper `.sort()`s, so it
73
73
  * answers `craft, ops, process, rigor`; the constitution's order is a reading order —
74
74
  * write it (craft), prove it (rigor), run it (ops), ship it (process) — and no sort
75
- * produces it. Discovery still runs, but as a GATE rather than as the order: `js/render.mjs`
75
+ * produces it. Discovery still runs, but as a GATE rather than as the order: `js/build/render.mjs`
76
76
  * refuses to build when a pack file exists under `src/doctrines/` that is missing from this
77
77
  * array, so a fifth pack cannot be silently dropped from every install.
78
78
  *