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
@@ -38,11 +38,10 @@
38
38
  * have run, and inventing one writes a shim that names an interpreter the user may not have.
39
39
  * P5b settled this shape for the hook shim — "Node bakes node" — and P3a settled the gate:
40
40
  * the shim is byte-comparable once runner and entry are ARGUMENTS. So this bakes
41
- * `process.execPath` + `bin/geneseed-cli.mjs`, `tests/harness_golden.py` normalises the
42
- * shim's argv line the way `js/web/jobs.mjs` normalises the job runner's, and each side's
43
- * real content is asserted ABSOLUTELY in `tests/test_win_user_path.py`. A shim written by
44
- * the Node CLI reaches sixteen verbs rather than twenty-four; that is the cost of the port
45
- * being a port, and it disappears when P7 lands `tui`/`menu`/`home`.
41
+ * `process.execPath` + `bin/geneseed-cli.mjs`, the cell harness normalises the shim's argv
42
+ * line the way `js/web/jobs.mjs` normalises the job runner's, and the real content is
43
+ * asserted ABSOLUTELY in `tests/unit/win_user_path.test.mjs`. The shim reaches every verb in
44
+ * the `VERBS` table, `tui`/`menu`/`home` included since P7 — the gap the port opened is closed.
46
45
  *
47
46
  * THE DOUBLE CARRIAGE RETURN IS NOT A TYPO. The reference builds the shim with explicit
48
47
  * `\r\n` and writes it with `Path.write_text`, whose default newline handling then turns
@@ -67,9 +66,10 @@
67
66
  * and the verb takes its "removed the shim, said nothing about PATH" branch. A real arm,
68
67
  * reached honestly, with the registry untouched.
69
68
  *
70
- * THE SUCCESS ARM OF `winUserPath` IS THEREFORE UNGATED, DELIBERATELY, IN BOTH
71
- * IMPLEMENTATIONS — declared here rather than faked, because the only way to gate it is to
72
- * edit the machine running the gate.
69
+ * THE SUCCESS ARM OF `winUserPath` IS THEREFORE UNGATED, DELIBERATELY — declared here and
70
+ * in `tests/unit/win_user_path.test.mjs`'s coverage row rather than faked, because the only
71
+ * way to gate it is to edit the machine running the gate. What stands in for it is
72
+ * source-level: that file asserts the spawn's shape without running it.
73
73
  */
74
74
  import {
75
75
  chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync,
@@ -78,9 +78,10 @@ import { spawnSync } from 'node:child_process';
78
78
  import path from 'node:path';
79
79
  import os from 'node:os';
80
80
 
81
- import { ROOT } from './checkout.mjs';
81
+ import { ROOT } from '../build/source.mjs';
82
82
  import { readMaybe } from './installs.mjs';
83
- import { pyPathStr, pyPrint, pyPrintErr, writeText } from './lib/fs.mjs';
83
+ import { printOut, printErr, writeText } from '../lib/fs.mjs';
84
+ import { toPlatformPath } from '../lib/paths.mjs';
84
85
 
85
86
  const IS_WIN = process.platform === 'win32';
86
87
 
@@ -154,28 +155,28 @@ export function cmdLink(args) {
154
155
  writeText(shim, '@echo off\r\n'
155
156
  + `"${process.execPath}" "${path.join(here, 'bin', 'geneseed-cli.mjs')}" %*\r\n`);
156
157
  } catch (e) {
157
- pyPrintErr(`geneseed: could not write ${shim} (${pyOsError(e)})\n`);
158
+ printErr(`geneseed: could not write ${shim} (${asOsError(e)})\n`);
158
159
  return 1;
159
160
  }
160
- pyPrint(`geneseed: wrote shim ${shim}\n`);
161
+ printOut(`geneseed: wrote shim ${shim}\n`);
161
162
  const on_path = (process.env.PATH || '').toLowerCase().includes(bindir.toLowerCase());
162
163
  if (on_path || winUserPath('add', bindir)) {
163
- pyPrint(`geneseed: '${bindir}' is on your user PATH — open a NEW terminal, then run \`geneseed\`.\n`);
164
+ printOut(`geneseed: '${bindir}' is on your user PATH — open a NEW terminal, then run \`geneseed\`.\n`);
164
165
  } else {
165
- pyPrint(`geneseed: add '${bindir}' to your PATH manually, then run \`geneseed\` from anywhere.\n`);
166
+ printOut(`geneseed: add '${bindir}' to your PATH manually, then run \`geneseed\` from anywhere.\n`);
166
167
  }
167
168
  return 0;
168
169
  }
169
170
  // Unix: write a shim into a bin dir (default ~/.local/bin, no sudo). See the shim
170
171
  // comment below for why this is no longer a symlink to `ROOT/geneseed`.
171
- // `Path(args.dir)` — and `pyPathStr` is that conversion, not decoration. The reference
172
+ // `Path(args.dir)` — and `toPlatformPath` is that conversion, not decoration. The reference
172
173
  // builds a `Path` here and prints `str(target_dir)` twice (the PATH notice and the `export
173
174
  // PATH=` line) as well as comparing it against `PATH.split(os.pathsep)`, so an argument
174
175
  // that is not already normalised diverges in three places at once. A trailing slash is the
175
176
  // cheapest one: `str(Path('/x/bin/'))` is `/x/bin` and the raw string is not.
176
177
  // `harness_golden`'s `link/an-explicit-dir-argument-is-used-instead-of-the-default` passes
177
178
  // one, and it is the cell that found this.
178
- let targetDir = args.dir ? pyPathStr(args.dir) : null;
179
+ let targetDir = args.dir ? toPlatformPath(args.dir) : null;
179
180
  if (targetDir === null) {
180
181
  const local = path.join(os.homedir(), '.local', 'bin');
181
182
  try {
@@ -202,16 +203,16 @@ export function cmdLink(args) {
202
203
  + `exec "${process.execPath}" "${entry}" "$@"\n`);
203
204
  chmodSync(dest, 0o755);
204
205
  } catch (e) {
205
- pyPrintErr(`geneseed: could not write ${dest} (${pyOsError(e)}) — pick a writable dir: `
206
+ printErr(`geneseed: could not write ${dest} (${asOsError(e)}) — pick a writable dir: `
206
207
  + 'geneseed link <dir>\n');
207
208
  return 1;
208
209
  }
209
- pyPrint(`geneseed: linked ${dest} -> ${entry}\n`);
210
+ printOut(`geneseed: linked ${dest} -> ${entry}\n`);
210
211
  if ((process.env.PATH || '').split(path.delimiter).includes(targetDir)) {
211
- pyPrint(`geneseed: '${targetDir}' is on PATH — run 'geneseed' from anywhere.\n`);
212
+ printOut(`geneseed: '${targetDir}' is on PATH — run 'geneseed' from anywhere.\n`);
212
213
  } else {
213
- pyPrint(`geneseed: NOTE '${targetDir}' is not on your PATH. Add it, e.g.:\n`);
214
- pyPrint(` echo 'export PATH="${targetDir}:$PATH"' >> ~/.zshrc # or ~/.bashrc\n`);
214
+ printOut(`geneseed: NOTE '${targetDir}' is not on your PATH. Add it, e.g.:\n`);
215
+ printOut(` echo 'export PATH="${targetDir}:$PATH"' >> ~/.zshrc # or ~/.bashrc\n`);
215
216
  }
216
217
  return 0;
217
218
  }
@@ -226,15 +227,15 @@ export function cmdUnlink() {
226
227
  try {
227
228
  rmSync(shim);
228
229
  removed = true;
229
- pyPrint(`geneseed: removed ${shim}\n`);
230
+ printOut(`geneseed: removed ${shim}\n`);
230
231
  } catch (e) {
231
- pyPrintErr(`geneseed: could not remove ${shim} (${pyOsError(e)})\n`);
232
+ printErr(`geneseed: could not remove ${shim} (${asOsError(e)})\n`);
232
233
  }
233
234
  }
234
235
  if (winUserPath('remove', bindir)) {
235
- pyPrint(`geneseed: removed '${bindir}' from your user PATH (open a new terminal).\n`);
236
+ printOut(`geneseed: removed '${bindir}' from your user PATH (open a new terminal).\n`);
236
237
  }
237
- if (!removed) pyPrint('geneseed: no linked launcher found.\n');
238
+ if (!removed) printOut('geneseed: no linked launcher found.\n');
238
239
  return 0;
239
240
  }
240
241
  let removed = false;
@@ -256,12 +257,12 @@ export function cmdUnlink() {
256
257
  if (ours) {
257
258
  try {
258
259
  rmSync(f);
259
- pyPrint(`geneseed: removed ${f}\n`);
260
+ printOut(`geneseed: removed ${f}\n`);
260
261
  removed = true;
261
262
  } catch { /* `except OSError: pass` */ }
262
263
  }
263
264
  }
264
- if (!removed) pyPrint('geneseed: no linked launcher found on PATH\n');
265
+ if (!removed) printOut('geneseed: no linked launcher found on PATH\n');
265
266
  return 0;
266
267
  }
267
268
 
@@ -281,6 +282,6 @@ function isSymlink(p) {
281
282
  * in a different order. Not byte-reproducible and not worth pretending otherwise: both cells
282
283
  * that can reach it assert the arm, not the errno text.
283
284
  */
284
- function pyOsError(e) {
285
+ function asOsError(e) {
285
286
  return e && e.message ? e.message : String(e);
286
287
  }
@@ -24,7 +24,7 @@
24
24
  * `1` on the way back out. `readJsonc` already learned this; the strict-JSON fork has to
25
25
  * learn it separately, because it does not go through `readJsonc` at all.
26
26
  *
27
- * A PyNumber IS AN OBJECT TO `typeof`, which is why `isDict` is spelled out rather than
27
+ * A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict` is spelled out rather than
28
28
  * written as `typeof v === 'object'`. Python's `isinstance(x, dict)` is false for a float;
29
29
  * the JS twin has to be false for its wrapper.
30
30
  */
@@ -32,10 +32,11 @@ import { existsSync, mkdirSync, renameSync } from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
34
 
35
- import { bobConfigDir, copilotConfigDir, pyResolve } from './hosts.mjs';
35
+ import { bobConfigDir, copilotConfigDir, resolvePath } from './hosts.mjs';
36
36
  import { installState, installTargets } from './installs.mjs';
37
- import { jsonDumpsIndent, parseJson, pyLjust, pyPrint, pyPrintErr, readText, writeText }
38
- from './lib/fs.mjs';
37
+ import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
38
+ import { jsonDumpsIndent, parseJson } from '../lib/json.mjs';
39
+ import { padEndToWidth } from '../lib/text.mjs';
39
40
  import { opencodeTarget, readJsonc } from './settings.mjs';
40
41
 
41
42
  /**
@@ -240,7 +241,7 @@ export function mcpConfigFor(host, scope, root) {
240
241
  if (host === 'opencode') return opencodeTarget(path.join(root, 'opencode.json'));
241
242
  if (host === 'claude') {
242
243
  return scope === 'project' ? path.join(root, '.mcp.json')
243
- : pyResolve(path.join(os.homedir(), '.claude.json'));
244
+ : resolvePath(path.join(os.homedir(), '.claude.json'));
244
245
  }
245
246
  if (host === 'bob') {
246
247
  return scope === 'project' ? path.join(root, '.bob', 'settings.json')
@@ -305,18 +306,18 @@ export function cmdMcp() {
305
306
  const targets = mcpInstallTargets()
306
307
  .filter(([, , host, scope, root]) => installState(root, host, scope) === 'active');
307
308
  if (!targets.length) {
308
- pyPrintErr('[mcp] no active Geneseed install found. Run `geneseed setup` first.\n');
309
+ printErr('[mcp] no active Geneseed install found. Run `geneseed setup` first.\n');
309
310
  return 1;
310
311
  }
311
312
  for (const [label, p, host] of targets) {
312
313
  const cfg = mcpLoad(p, host);
313
- pyPrint(`[mcp] ${host} ${label}: ${p}${existsSync(p) ? '' : ' (not written yet)'}\n`);
314
+ printOut(`[mcp] ${host} ${label}: ${p}${existsSync(p) ? '' : ' (not written yet)'}\n`);
314
315
  if (mcpCommented(p)) {
315
- pyPrint(' this config carries comments — edit it by hand so they survive.\n');
316
+ printOut(' this config carries comments — edit it by hand so they survive.\n');
316
317
  }
317
318
  for (const name of mcpKnownNames(cfg, host)) {
318
319
  const [lbl] = mcpMeta(name);
319
- pyPrint(` ${pyLjust(mcpState(cfg, name, host), 8)} ${pyLjust(name, 12)} ${lbl}\n`);
320
+ printOut(` ${padEndToWidth(mcpState(cfg, name, host), 8)} ${padEndToWidth(name, 12)} ${lbl}\n`);
320
321
  }
321
322
  }
322
323
  return 0;
@@ -6,7 +6,7 @@
6
6
  * one dialect per host (OpenCode / Claude Code / Copilot), vendored skill folders copied
7
7
  * through verbatim, authoring templates shipped flat.
8
8
  *
9
- * WHY THIS IS THE HARD HALF. `js/render.mjs` is a pure function of `src/` and `themes/`;
9
+ * WHY THIS IS THE HARD HALF. `js/build/render.mjs` is a pure function of `src/` and `themes/`;
10
10
  * this file is not, in three separate ways, and each one is a place a port can be subtly
11
11
  * wrong while looking right:
12
12
  *
@@ -19,9 +19,9 @@
19
19
  * (`<name>.agent.md`) that drags a link rewrite along with it.
20
20
  *
21
21
  * None of the three is reachable from the golden harness's happy path: golden emits into
22
- * a fresh tree with an empty overrides stub. `tests/test_native_layer_parity.py` drives
23
- * both implementations over the cells golden cannot construct, and byte-compares the
24
- * written trees, the returned values AND the warning stream.
22
+ * a fresh tree with an empty overrides stub. `tests/unit/user_files.test.mjs` drives
23
+ * claim-on-create over the states a clean fixture cannot produce, and asserts the written
24
+ * tree, the return value AND the warning stream.
25
25
  *
26
26
  * NOTHING HERE MAY WRITE TO STDOUT. Python's warnings all go to stderr, and the emitted
27
27
  * hook gates signal by printing JSON on stdout — a stray byte from a library on that path
@@ -30,8 +30,8 @@
30
30
  */
31
31
  import { existsSync, mkdirSync } from 'node:fs';
32
32
  import path from 'node:path';
33
- import { writeText, copyFile, jsonDumps, parseJson, pyStr, pyRepr, pyTruthy, readText }
34
- from './lib/fs.mjs';
33
+ import { writeText, copyFile, readText } from '../lib/fs.mjs';
34
+ import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy } from '../lib/json.mjs';
35
35
 
36
36
  /** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
37
37
  export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report']);
@@ -125,7 +125,7 @@ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
125
125
  * comment is stripped.
126
126
  *
127
127
  * `.replace` with a `g` regex, matching `re.sub`'s replace-all; the Python's pattern is
128
- * non-global-looking but `re.sub` always is. `split('\n')` rather than `pySplitLines`: the
128
+ * non-global-looking but `re.sub` always is. `split('\n')` rather than `splitLines`: the
129
129
  * text has been through `readText`, and the reason `firstBlockquote` gives for the same
130
130
  * choice applies verbatim to the same files.
131
131
  */
@@ -133,12 +133,12 @@ export function descBlockProblem(text) {
133
133
  const nonblank = text.replace(HTML_COMMENT_RE, '').split('\n').filter((ln) => ln.trim());
134
134
  if (!nonblank.length) return 'file is empty (after stripping authoring comments)';
135
135
  if (!nonblank[0].replace(/^\s+/, '').startsWith('#')) {
136
- return `first content line is not a title ('# ...'): ${pyRepr(nonblank[0].trim())}`;
136
+ return `first content line is not a title ('# ...'): ${formatRepr(nonblank[0].trim())}`;
137
137
  }
138
138
  if (nonblank.length < 2) return 'has a title but no purpose blockquote after it';
139
139
  const second = nonblank[1].trim();
140
140
  if (!second.startsWith('>')) {
141
- return `first block after the title is not a '>' blockquote: ${pyRepr(second)}`;
141
+ return `first block after the title is not a '>' blockquote: ${formatRepr(second)}`;
142
142
  }
143
143
  if (!second.replace(/^>+/, '').trim()) return 'purpose blockquote is empty';
144
144
  return '';
@@ -167,7 +167,7 @@ function agentColorMap(theme) {
167
167
  if (VALID_AGENT_COLOR_SLOTS.has(v)) {
168
168
  cleaned[k] = v;
169
169
  } else {
170
- warn(`[geneseed] WARN: AGENT_COLORS[${pyRepr(k)}] = ${pyRepr(v)} is not a valid `
170
+ warn(`[geneseed] WARN: AGENT_COLORS[${formatRepr(k)}] = ${formatRepr(v)} is not a valid `
171
171
  + `OpenCode theme slot (${[...VALID_AGENT_COLOR_SLOTS].sort().join(', ')}) — `
172
172
  + `falling back to 'secondary'`);
173
173
  cleaned[k] = 'secondary';
@@ -216,23 +216,25 @@ function agentOverride(overrides, stem) {
216
216
  * `model`/`variant` tests are truthiness and the `temperature`/`steps` tests are
217
217
  * `is not None` — a configured `temperature: 0` must survive, an empty model must not.
218
218
  *
219
- * Python spells these four ifs out twice, in `_opencode_agent_frontmatter` and in
220
- * `_write_primary_agent`. One owner here rather than a second copy: the two Python
221
- * bodies are identical today, and the parity gate drives BOTH call sites, so if they
222
- * ever diverge the gate says so instead of the copies silently rotting apart.
219
+ * Python spelled these four ifs out twice, in `_opencode_agent_frontmatter` and in
220
+ * `_write_primary_agent`. One owner here rather than a second copy, and the reason is
221
+ * sharper now than parity was: the two call sites are `opencodeAgentFrontmatter` below and
222
+ * `writePrimaryAgent` in `js/hosts/opencode.mjs`, and the second is behind
223
+ * `GENESEED_PRIMARY` — so only `tests/unit/agent_overrides.test.mjs` reaches it. Sharing the
224
+ * body is what stops the opt-in path rotting away from the default one unobserved.
223
225
  */
224
226
  export function pushOverrideLines(fm, ov) {
225
- if (pyTruthy(ov.model)) fm.push(`model: ${pyStr(ov.model)}`);
226
- if (given(ov.temperature)) fm.push(`temperature: ${pyStr(ov.temperature)}`);
227
- if (pyTruthy(ov.variant)) fm.push(`variant: ${pyStr(ov.variant)}`);
228
- if (given(ov.steps)) fm.push(`steps: ${pyStr(ov.steps)}`);
227
+ if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
228
+ if (given(ov.temperature)) fm.push(`temperature: ${formatValue(ov.temperature)}`);
229
+ if (isTruthy(ov.variant)) fm.push(`variant: ${formatValue(ov.variant)}`);
230
+ if (given(ov.steps)) fm.push(`steps: ${formatValue(ov.steps)}`);
229
231
  }
230
232
 
231
233
  /** `_build_emit._claude_agent_frontmatter`. */
232
234
  function claudeAgentFrontmatter(stem, text, overrides) {
233
235
  const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
234
236
  const ov = agentOverride(overrides, stem);
235
- if (pyTruthy(ov.model)) fm.push(`model: ${pyStr(ov.model)}`);
237
+ if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
236
238
  if (isReadonly(text)) {
237
239
  const denied = ['Write', 'Edit', 'NotebookEdit', 'WebFetch'];
238
240
  if (!text.includes('<!-- bash: allow -->')) denied.push('Bash');
@@ -245,7 +247,7 @@ function claudeAgentFrontmatter(stem, text, overrides) {
245
247
  function copilotAgentFrontmatter(stem, text, overrides) {
246
248
  const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
247
249
  const ov = agentOverride(overrides, stem);
248
- if (pyTruthy(ov.model)) fm.push(`model: ${pyStr(ov.model)}`);
250
+ if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
249
251
  if (isReadonly(text)) {
250
252
  const allowed = ['read', 'search', 'todo', 'agent'];
251
253
  if (text.includes('<!-- bash: allow -->')) allowed.push('execute');
@@ -271,7 +273,7 @@ function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
271
273
  export function loadAgentOverrides(base) {
272
274
  let data;
273
275
  try {
274
- // parseJson, not JSON.parse: the override values are rendered with `pyStr`, which
276
+ // parseJson, not JSON.parse: the override values are rendered with `formatValue`, which
275
277
  // needs the int/float distinction the raw literal carries and `JSON.parse` discards.
276
278
  data = parseJson(readText(path.join(base, 'agent-overrides.json')));
277
279
  } catch {
@@ -297,7 +299,7 @@ function relPosix(base, target) {
297
299
  * `items` are `render_all`'s, as `{ rel, text, src }` — `text` null for a binary that is
298
300
  * copied rather than rendered. `src` is the absolute source path; `opts.src` is the
299
301
  * source root it is relativised against (Python reads `_build_core.SRC` directly, which
300
- * is the module-level single owner; here it travels explicitly, as in `js/render.mjs`).
302
+ * is the module-level single owner; here it travels explicitly, as in `js/build/render.mjs`).
301
303
  *
302
304
  * Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
303
305
  * paths in write order.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The OpenCode-native extras, in Node — everything `emit_opencode` writes beyond the
3
- * agents and skills that `js/native.mjs` already owns.
3
+ * agents and skills that `js/hosts/native.mjs` already owns.
4
4
  *
5
5
  * A translation of the second half of `_build_emit.py`: the branded and curated colour
6
6
  * themes, the opt-in primary agent, the opt-in slash-command layer plus the always-on
@@ -15,17 +15,18 @@
15
15
  * indent is what switches Python's separators to the ones `JSON.stringify` already uses.
16
16
  *
17
17
  * TWO STREAMS, AND THE SPLIT IS NOT TIDY. `_warn_if_overrides_stale` prints to STDOUT
18
- * while every warning in `js/native.mjs` goes to stderr. That is the Python's own
18
+ * while every warning in `js/hosts/native.mjs` goes to stderr. That is the Python's own
19
19
  * inconsistency and it is reproduced, not corrected: the parity gate compares both
20
- * streams, so "fixing" it here would be a divergence. It is safe because this is the
20
+ * specification, and `tests/unit/agent_overrides.test.mjs` pins the stale notice to stdout.
21
+ * It is safe because this is the
21
22
  * generator's CLI, which prints progress to stdout by design — unlike a hook path, where
22
23
  * a stray stdout byte silently disarms a blocking gate.
23
24
  */
24
25
  import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
25
26
  import path from 'node:path';
26
- import {
27
- writeText, readText, copyFile, jsonDumps, jsonDumpsIndent, parseJson, comparePaths,
28
- } from './lib/fs.mjs';
27
+ import { writeText, readText, copyFile } from '../lib/fs.mjs';
28
+ import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
29
+ import { comparePaths } from '../lib/paths.mjs';
29
30
  import { descOf, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
30
31
 
31
32
  /** ANSI colour-name -> integer (0-7). Mirrors `_build_emit._ANSI`. */
@@ -98,7 +99,11 @@ function truthyEnv(name) {
98
99
  return ['1', 'on', 'true', 'yes'].includes((process.env[name] || '').toLowerCase());
99
100
  }
100
101
 
101
- /** `_build_core.source_release_version`. Belongs to core; travels in `cfg` until core lands. */
102
+ /**
103
+ * `_build_core.source_release_version`. It reads `cfg.config`, so it takes `cfg` rather than
104
+ * importing core — and it stayed HERE when core landed as `js/build/source.mjs`, which is why
105
+ * `js/build/version.mjs` reaches back into the OpenCode module for it.
106
+ */
102
107
  export function sourceReleaseVersion(cfg) {
103
108
  try {
104
109
  const data = parseJson(readText(cfg.config));