geneseed 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -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,18 +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
- footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
41
- } from './installs.mjs';
42
- import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from './opencode.mjs';
40
+ excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
41
+ readMaybe, themeOfDir,
42
+ } from '../hosts/installs.mjs';
43
+ import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
43
44
  import { renderAll } from './render.mjs';
44
- import {
45
- jsonDumpsIndent, parseJson, pyPrint, pyPrintErr, pyRepr, readText, writeText,
46
- } from './lib/fs.mjs';
45
+ import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
46
+ import { jsonDumpsIndent, parseJson, formatRepr } from '../lib/json.mjs';
47
47
 
48
48
  /** `_harness_build._HEX_RE`. Anchored at BOTH ends — `#123` and `#1122334` both fail. */
49
49
  const HEX_RE = /^#[0-9a-fA-F]{6}$/;
@@ -51,18 +51,18 @@ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
51
51
  /**
52
52
  * `raise SystemExit(msg)` — the message on stderr, exit 1.
53
53
  *
54
- * 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`
55
55
  * honours: write at the raise site, mark the throw as a DELIBERATE refusal with `exitCode`,
56
56
  * and let anything unmarked keep its stack. Python prints a traceback for an unhandled
57
57
  * exception, so dressing a crash as a one-liner would hide a bug.
58
58
  */
59
59
  function sysExit(msg) {
60
- // `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` ->
61
61
  // `\r\n` rule here and every cell passed, because `harness_golden` reads stderr through
62
62
  // `subprocess`'s universal-newline decode and cannot see the difference — the same
63
63
  // transport hole P5b measured. A second copy of a translation is exactly what P5d's
64
- // `pyStrPath`/`pyPathStr` split was, and it is ungated in precisely the same way.
65
- pyPrintErr(`${msg}\n`);
64
+ // `pyStrPath`/`toPlatformPath` split was, and it is ungated in precisely the same way.
65
+ printErr(`${msg}\n`);
66
66
  const e = new Error(msg);
67
67
  e.exitCode = 1;
68
68
  throw e;
@@ -122,7 +122,8 @@ export function cmdBuild(args) {
122
122
  * accepts (an empty `--doctrines ` is a usage error there, so it can never be produced here).
123
123
  */
124
124
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
125
- posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER) {
125
+ posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
126
+ excludeRules = null) {
126
127
  const argv = ['--theme', theme, '--emit', emit];
127
128
  if (!['opencode-global', 'claude-global', 'bob-global', 'copilot-global'].includes(emit)) {
128
129
  if (out) argv.push('--out', out);
@@ -138,6 +139,13 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
138
139
  const picked = allPacks.filter((p) => doctrines.includes(p));
139
140
  argv.push('--doctrines', picked.length ? picked.join(',') : 'none');
140
141
  }
142
+ // The second axis, emitted the same way and for the same reason — but ALWAYS when the
143
+ // caller passed a list, empty included. An omitted flag reads as "no opinion" and the
144
+ // driver's config fallback fills it in; `none` is the caller saying "exclude nothing",
145
+ // which is exactly what a rebuild that just re-enabled the last rule has to say.
146
+ if (Array.isArray(excludeRules)) {
147
+ argv.push('--exclude-rules', excludeRules.length ? excludeRules.join(',') : 'none');
148
+ }
141
149
  return argv;
142
150
  }
143
151
 
@@ -179,7 +187,7 @@ export const DEFAULT_EMIT = new Map([
179
187
  export function cmdRebuildAll() {
180
188
  const targets = installTargets().filter(([h, s, r]) => installState(r, h, s) === 'active');
181
189
  if (!targets.length) {
182
- pyPrint('[rebuild-all] no active installs detected.\n');
190
+ printOut('[rebuild-all] no active installs detected.\n');
183
191
  return 0;
184
192
  }
185
193
  const failures = [];
@@ -201,22 +209,28 @@ export function cmdRebuildAll() {
201
209
  // resolving that silence out of a machine-wide config narrows every such install on its
202
210
  // first upgrade. `doctrinesForBuild` resolves unknown to ALL packs — see its docblock.
203
211
  const doctrines = doctrinesForBuild(root);
204
- const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines);
212
+ // SEVEN. The per-rule axis is preserved for the same reason and with sharper teeth: an
213
+ // upgrade that dropped it would quietly hand back every rule its owner switched off, and
214
+ // `process 5` is one of them — the consent gate would return to a harness that had been
215
+ // built without it. No `doctrinesForBuild` rescue is needed here; see `apiInstallCmd`.
216
+ const excludeRules = excludedRulesOfDir(root);
217
+ const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
218
+ PACK_ORDER, excludeRules);
205
219
  const label = `${host}:${scope} (${root})`;
206
- pyPrint(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
220
+ printOut(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
207
221
  + `posture=${posture} mode=${mode}\n`);
208
222
  const rc = driverMain(argv);
209
223
  if (rc !== 0) {
210
224
  failures.push(label);
211
- pyPrintErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
225
+ printErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
212
226
  }
213
227
  }
214
228
  if (failures.length) {
215
- pyPrintErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
229
+ printErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
216
230
  + `${failures.join(', ')}\n`);
217
231
  return 1;
218
232
  }
219
- pyPrint(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
233
+ printOut(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
220
234
  return 0;
221
235
  }
222
236
 
@@ -296,9 +310,9 @@ export function cmdPrompt(args) {
296
310
  if (dest) mkdirSync(dest, { recursive: true });
297
311
  writeText(args.out, text);
298
312
  // The RAW argument, not the resolved path — `f"[prompt] wrote {args.out}"`.
299
- pyPrint(`[prompt] wrote ${args.out} (${themeName})\n`);
313
+ printOut(`[prompt] wrote ${args.out} (${themeName})\n`);
300
314
  } else {
301
- pyPrint(text);
315
+ printOut(text);
302
316
  }
303
317
  return 0;
304
318
  }
@@ -309,8 +323,8 @@ export function cmdPrompt(args) {
309
323
 
310
324
  /** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
311
325
  export function resolveThemesDir(args) {
312
- // `Path(args.dir).expanduser().resolve()` — `pyResolve` already expands a leading `~`.
313
- 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);
314
328
  const repo = path.join(process.cwd(), '.opencode');
315
329
  if (!args.globalDir && isDir(repo)) return path.join(repo, 'themes');
316
330
  return path.join(opencodeConfigDir(), 'themes');
@@ -368,7 +382,7 @@ export function loadUserPalette(args, cfg) {
368
382
  }
369
383
  // `f"{r}={pal[r]!r}"` — Python's repr, so a string is single-quoted and an int is bare.
370
384
  const bad = [...pal].filter(([, v]) => !(typeof v === 'string' && HEX_RE.test(v)))
371
- .map(([r, v]) => `${r}=${pyRepr(v)}`);
385
+ .map(([r, v]) => `${r}=${formatRepr(v)}`);
372
386
  if (bad.length) sysExit(`[theme] non-#rrggbb value(s): ${bad.join(', ')}`);
373
387
  return pal;
374
388
  }
@@ -406,8 +420,8 @@ export function cmdTheme(args) {
406
420
  writeText(dest, `${jsonDumpsIndent(colorThemeJson(Object.fromEntries(pal), transparent))}\n`);
407
421
  written.push(dest);
408
422
  }
409
- pyPrint(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
410
- 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}`
411
425
  + (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
412
426
  return 0;
413
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;
@@ -279,7 +283,20 @@ function activeDoctrines(cfg) {
279
283
  throw e;
280
284
  }
281
285
  }
282
- return PACK_ORDER.filter((p) => wanted.includes(p));
286
+ // ⚠ A PACK WHOSE EVERY RULE IS EXCLUDED IS NOT A PACK, it is a heading over nothing. The
287
+ // console only ever offers per-RULE switches, so "turn this whole pack off" arrives here as
288
+ // seven exclusions rather than as a missing pack name — and rendering `**Observance** — how
289
+ // a task is run.` with no rules under it would be the one shape the reader cannot
290
+ // interpret. Dropped here rather than in `doctrinesBody` so `DOCTRINES_LIST` — the
291
+ // `Active packs:` marker a later reader parses back — agrees with what was rendered.
292
+ const excluded = new Set(cfg.excludeRules ?? []);
293
+ const survives = (pack) => {
294
+ const ids = [...readText(path.join(dir, `${pack}.md`))
295
+ .matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)]
296
+ .map((m) => `${m[1]}.${Number(m[2])}`);
297
+ return ids.some((id) => !excluded.has(id));
298
+ };
299
+ return PACK_ORDER.filter((p) => wanted.includes(p)).filter(survives);
283
300
  }
284
301
 
285
302
  /**
@@ -292,10 +309,40 @@ function activeDoctrines(cfg) {
292
309
  * pack — closes with one pointer at the full catalogue, which ships in every bundle whether
293
310
  * or not a pack is active.
294
311
  */
312
+ /** `### {{DOCTRINE}} <pack> <n> —` in the UNRENDERED source, which is where the address is. */
313
+ const SRC_RULE_HEADING_RE = /^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/;
314
+
315
+ /**
316
+ * Drop the individual rules an install excluded, leaving the pack's lead line and the rest.
317
+ *
318
+ * ⚠ FILTERED BEFORE SUBSTITUTION, ON THE SOURCE. The rendered heading spells the tier noun in
319
+ * the theme's own voice — `Doctrina process 7` on imperial, `Custom process 7` on pirate — so
320
+ * matching after substitution means matching fourteen spellings, and a theme whose noun
321
+ * happened not to match would silently exclude nothing at all. The source spelling is one
322
+ * literal and is the same in every voice.
323
+ *
324
+ * The pack's lead line (`**{{PACK_CRAFT}}** — how code is written.`) sits before the first
325
+ * heading and always survives: `splitAtLawHeadings` returns it as the leading chunk, and a
326
+ * pack whose every rule is excluded is not rendered at all — `activeDoctrines` drops it from
327
+ * the selection, so a header with nothing under it cannot occur.
328
+ */
329
+ function dropExcludedRules(text, pack, excluded) {
330
+ if (!excluded.size) return text;
331
+ return splitAtLawHeadings(text)
332
+ .filter((block) => {
333
+ const m = SRC_RULE_HEADING_RE.exec(block);
334
+ return !m || !excluded.has(`${m[1]}.${Number(m[2])}`);
335
+ })
336
+ .join('');
337
+ }
338
+
295
339
  function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
296
340
  if (!active.length) return '';
341
+ const excluded = new Set(cfg.excludeRules ?? []);
297
342
  const parts = active.map((name) => {
298
- const body = substitute(readText(path.join(cfg.src, 'doctrines', `${name}.md`)), theme).trim();
343
+ const raw = dropExcludedRules(readText(path.join(cfg.src, 'doctrines', `${name}.md`)),
344
+ name, excluded);
345
+ const body = substitute(raw, theme).trim();
299
346
  return footprint === 'lean' ? terseBlocks(body) : body;
300
347
  });
301
348
  if (footprint === 'lean') {
@@ -330,6 +377,20 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
330
377
  const active = activeDoctrines(cfg);
331
378
  theme.DOCTRINES_BODY = doctrinesBody(cfg, theme, active, footprint, lawsPrefix);
332
379
  theme.DOCTRINES_LIST = active.length ? active.join(', ') : 'none';
380
+ // ⚠ EMITTED ONLY WHEN SOMETHING IS EXCLUDED, and that is not cosmetic. An unconditional
381
+ // second marker line would put a new line into EVERY carrier — 261 golden cells, both
382
+ // footprints, nine emit modes — to say "nothing was excluded", which is what the line's
383
+ // absence already says. Keeping the default build byte-identical is what lets this ship
384
+ // without re-blessing anything, and it is why `excludedRulesOfDir` reads a MISSING line as
385
+ // `[]` rather than as unknown: absence is a real answer here, unlike the pack list.
386
+ //
387
+ // Rendered `process 7`, not `process.7`: the dotted form is the console's address, the
388
+ // spaced form is how every citation in the corpus already spells a rule, and this line is
389
+ // read by people first and by `excludedRulesOfDir` second.
390
+ const dropped = [...(cfg.excludeRules ?? [])].sort();
391
+ theme.EXCLUDED_RULES_LINE = dropped.length
392
+ ? `\nExcluded rules: ${dropped.map((id) => id.replace('.', ' ')).join(', ')}`
393
+ : '';
333
394
  return theme;
334
395
  }
335
396