geneseed 3.14.0 → 3.15.0

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 (88) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/harness.config.json +1 -1
  12. package/js/README.md +9 -5
  13. package/js/build/args.mjs +4 -3
  14. package/js/build/bundle.mjs +10 -10
  15. package/js/build/driver.mjs +8 -7
  16. package/js/build/emit-claude.mjs +3 -2
  17. package/js/build/emit-common.mjs +2 -14
  18. package/js/build/emit-opencode.mjs +12 -8
  19. package/js/build/generate.mjs +15 -11
  20. package/js/build/render.mjs +1 -1
  21. package/js/build/source.mjs +6 -6
  22. package/js/build/stubs.mjs +6 -5
  23. package/js/build/version.mjs +21 -2
  24. package/js/hosts/hooks-context.mjs +2 -1
  25. package/js/hosts/hooks-prims.mjs +2 -2
  26. package/js/hosts/hosts.mjs +24 -26
  27. package/js/hosts/installs.mjs +33 -14
  28. package/js/hosts/link.mjs +75 -26
  29. package/js/hosts/memory-files.mjs +13 -4
  30. package/js/hosts/native.mjs +106 -48
  31. package/js/hosts/opencode.mjs +44 -36
  32. package/js/hosts/shim.mjs +4 -2
  33. package/js/inspect/diff.mjs +5 -6
  34. package/js/inspect/excludes.mjs +10 -5
  35. package/js/inspect/status.mjs +18 -21
  36. package/js/inspect/validate.mjs +2 -2
  37. package/js/lib/fs.mjs +12 -11
  38. package/js/lib/json.mjs +4 -4
  39. package/js/lib/paths.mjs +27 -33
  40. package/js/lib/proc.mjs +3 -3
  41. package/js/lib/prompt.mjs +132 -0
  42. package/js/lib/text.mjs +18 -4
  43. package/js/lib/udiff.mjs +3 -2
  44. package/js/maintain/memory.mjs +5 -6
  45. package/js/maintain/migrate.mjs +4 -3
  46. package/js/maintain/setup.mjs +9 -131
  47. package/js/maintain/uninstall.mjs +10 -9
  48. package/js/maintain/update.mjs +115 -34
  49. package/js/ui/anim.mjs +8 -12
  50. package/js/ui/cli.mjs +29 -35
  51. package/js/ui/menu.mjs +7 -13
  52. package/js/ui/tui.mjs +14 -123
  53. package/js/web/actions.mjs +82 -386
  54. package/js/web/activity.mjs +1 -1
  55. package/js/web/api.mjs +33 -717
  56. package/js/web/catalog.mjs +527 -0
  57. package/js/web/daemon.mjs +21 -3
  58. package/js/web/handler.mjs +51 -27
  59. package/js/web/jobs.mjs +24 -2
  60. package/js/web/routes.mjs +61 -6
  61. package/js/web/server.mjs +4 -4
  62. package/js/web/state.mjs +138 -0
  63. package/js/web/user-files.mjs +339 -0
  64. package/package.json +1 -1
  65. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  66. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  67. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  68. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  69. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  70. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  71. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  72. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  73. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  74. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  75. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  76. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  77. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  78. package/web/dist/assets/api-CxgfYu18.js +1 -0
  79. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  80. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  83. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  84. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  85. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  86. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  87. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  88. package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/js/README.md CHANGED
@@ -129,6 +129,7 @@ into files users have committed, so change the test that states it in the same c
129
129
  | `text.mjs` | String primitives: `codePointLength`, `WHITESPACE`, `parseIntStrict`, `percentDecode` |
130
130
  | `udiff.mjs` | `geneseed diff`'s own difflib clone — the alignment IS the printed hunks — plus `splitLines` |
131
131
  | `proc.mjs` | One constant: the `windowsHide` flag every spawn in `js/` must reuse |
132
+ | `prompt.mjs` | The line-mode stdin readers — `ask`, `promptLine`, `confirm`, `askChoice` — read a byte at a time so they stop where Enter stops. Shared by `setup`, `uninstall` and `web` without dragging in the wizard |
132
133
  | `node-floor.mjs` | The first import of all three binaries: below Node 22.3, one sentence on stderr and exit 1 (0 in the hook) instead of a stack trace |
133
134
 
134
135
  **Before editing:** parse with `parseJson`, never `JSON.parse` — the latter collapses int `1` and
@@ -202,10 +203,13 @@ the deployed harness as JSON.
202
203
  | module | owns |
203
204
  |---|---|
204
205
  | `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
205
- | `routes.mjs` | Route DECLARATION only: the `POST_ROUTES` table and the ported/unported/declined sets |
206
- | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards, static SPA fallback |
207
- | `api.mjs` | `WebState` plus every GET read endpoint; owns `STATE_ROUTES`, `PREFIX_ROUTES`, `NotFound` |
208
- | `actions.mjs` | The mutating endpoints — rules, profile, memory, MCP, excludes — plus install/deploy argv |
206
+ | `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
207
+ | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards and body cap (before the body is read), static SPA fallback |
208
+ | `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
209
+ | `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
210
+ | `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
211
+ | `user-files.mjs` | The fingerprint-guarded editors for `user-rules.md` (incl. memory promotion) and `PROFILE.md`, read and write |
212
+ | `actions.mjs` | The other mutating endpoints — memory delete, MCP, excludes, installs, view, restore — plus install/deploy argv |
209
213
  | `daemon.mjs` | Detached web start/stop/status/restart, the `.geneseed-web.json` record, and `openUrl` |
210
214
  | `jobs.mjs` | `JobManager` and the action→argv table. The only module here that spawns job children |
211
215
  | `docs.mjs` | The Docs tab: the `docs/<folder>/` page registry, `?harness=` filtering, link rewriting, the six page kinds |
@@ -232,4 +236,4 @@ and inline lists are held against a surface list written out in
232
236
  | a hook verb | `js/hosts/hooks.mjs` (a gate) or its own `js/hosts/hooks-<verb>.mjs` | `claudeHookGroups` in `settings.mjs`, the `VERBS` row (with its `load`) in `bin/geneseed-hook.mjs`. Keep the import graph tiny |
233
237
  | a console doc page | `docs/<understand|guides|concepts|reference>/<id>.md` — the stem is the id | `docs/extending.md` §4c. Only `kind: "concept"` gets `{N_*}` substitution |
234
238
  | an OpenCode plugin | `adapters/opencode/plugins/geneseed-<name>.js` | `docs/extending.md` §4a — the `geneseed-` prefix is mechanically load-bearing |
235
- | to change a `js/lib/` primitive | `tests/fixtures/pure_probe.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
239
+ | to change a `js/lib/` primitive | `tests/unit/lib_primitives.test.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
package/js/build/args.mjs CHANGED
@@ -24,8 +24,8 @@ const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global'
24
24
  *
25
25
  * The corrupt-file branch is reproduced including its imprecision: the warning says only
26
26
  * "using theme 'neutral'", but the branch also resets posture and mode. Matching the
27
- * Python text matters more than fixing it here, because the two CLIs' stderr is compared
28
- * byte-for-byte by `tests/golden.py` (:316).
27
+ * Python text mattered more than fixing it here, because the two CLIs' stderr was compared
28
+ * byte-for-byte by the former Python golden harness; nothing pins the wording now.
29
29
  */
30
30
  export function configDefaults() {
31
31
  const d = {
@@ -325,7 +325,8 @@ export function parseArgs(argv, defaults) {
325
325
  * `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
326
326
  * hands the CLI this parser rather than a second one beside it. That is not tidiness: a
327
327
  * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
328
- * `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
328
+ * `-h`, and `tests/unit/node_driver.test.mjs`'s '--help names every flag the parser takes' gates only
329
+ * this one.
329
330
  *
330
331
  * `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
331
332
  * they sit inside `main`'s funnel, and called from the CLI binary they would not.
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import path from 'node:path';
14
14
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
- import { copyFile, readText, writeText } from '../lib/fs.mjs';
15
+ import { copyFile, isDir, isFile, readText, writeText } from '../lib/fs.mjs';
16
16
  import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
- import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE, isDir, isFile } from './emit-common.mjs';
17
+ import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE } from './emit-common.mjs';
18
18
  import { SRC_DIR_TOKENS, renderAll } from './render.mjs';
19
19
  import {
20
20
  ensureBundleGitignore, ensureContextStub, ensureMemoryIndex, ensureNotebookIndex,
@@ -114,16 +114,9 @@ function safePriorDirName(out, priorName) {
114
114
  // build
115
115
  // ---------------------------------------------------------------------------
116
116
 
117
- /**
118
- * `_build_render.build` — render the bundle into `out`.
119
- *
120
- * `out` arrives as the STRING Python already resolved, and is echoed into the progress
121
- * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
122
- * Windows, and re-deriving it here would differ by separator in every message.
123
- */
124
117
  /**
125
118
  * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
126
- * `tests/test_emit_phase_order.py`.
119
+ * `tests/unit/emit_phase_order.test.mjs`.
127
120
  *
128
121
  * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
129
122
  * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
@@ -156,6 +149,13 @@ export function phaseLog(phase) {
156
149
  process.stderr.write(`[geneseed:phase] ${phase}\n`);
157
150
  }
158
151
 
152
+ /**
153
+ * `_build_render.build` — render the bundle into `out`.
154
+ *
155
+ * `out` arrives as the STRING Python already resolved, and is echoed into the progress
156
+ * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
157
+ * Windows, and re-deriving it here would differ by separator in every message.
158
+ */
159
159
  export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
160
160
  phaseLog('RENDER');
161
161
  const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
@@ -42,6 +42,7 @@ import { settingsIntegrityCheck } from '../hosts/settings.mjs';
42
42
  import { hookRunnerEntry } from '../hosts/shim.mjs';
43
43
  import { writeText, withPlatformNewlines, isFile } from '../lib/fs.mjs';
44
44
  import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
45
+ import { relPosix } from '../lib/text.mjs';
45
46
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
46
47
  // find a global install, and a resolver that decides WHERE a driver writes is the last thing
47
48
  // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
@@ -90,7 +91,8 @@ const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orc
90
91
  * It is invisible on a machine whose paths are already canonical, which is every machine
91
92
  * this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
92
93
  * two CLIs printed the same directory under two different names — a 70-byte difference in a
93
- * line compared BYTE for byte by `test_the_two_entry_points_agree_on_stdout_BYTES`. The
94
+ * line the former Python suite compared BYTE for byte; `tests/unit/resolve_out.test.mjs` pins
95
+ * it now. The
94
96
  * argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
95
97
  * reference does not expand `~` here, and a bare `~` is a legal directory name. */
96
98
  export function resolveOut(raw) {
@@ -149,10 +151,10 @@ function warnBobGlobalOverProject() {
149
151
  * would be written into `opencode.json`'s instruction path.
150
152
  */
151
153
  function relUnder(out, root) {
152
- const rel = path.relative(root, out);
154
+ const rel = relPosix(root, out);
153
155
  if (rel === '' || rel === '.') return '';
154
156
  if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
155
- return rel.split(path.sep).join('/');
157
+ return rel;
156
158
  }
157
159
 
158
160
  /**
@@ -255,7 +257,7 @@ function projectSurvivors(emitName) {
255
257
  * RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
256
258
  * either side of them: reading the previous manifest (PRE), pruning what this layer no
257
259
  * longer owns, writing the manifest atomically, and the summary line. All five stages now
258
- * run in one process, in the order `test_emit_phase_order.py` pins for the Python twin:
260
+ * run in one process, in the order `tests/unit/emit_phase_order.test.mjs` pins:
259
261
  * RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
260
262
  * settings file of its own).
261
263
  */
@@ -511,9 +513,8 @@ const claudeShaped = (name) => (cfg, args, out) => {
511
513
 
512
514
  /**
513
515
  * build.py:437-466 — the POST stage, which writes markers and records the install and
514
- * wires NOTHING. `test_the_post_emit_stage_wires_nothing` classifies that stage on the
515
- * Python side; this is the same stage on this side, and it stays after the dispatch for
516
- * the same reason.
516
+ * wires NOTHING. `tests/unit/emit_phase_order.test.mjs`'s 'the post-emit marker stage wires
517
+ * nothing' classifies it, and that is why it stays after the dispatch.
517
518
  *
518
519
  * `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
519
520
  * `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
@@ -14,10 +14,11 @@ import {
14
14
  unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
15
15
  } from '../hosts/settings.mjs';
16
16
  import { writeText } from '../lib/fs.mjs';
17
- import { isTruthy } from '../lib/json.mjs';
17
+ import { get, isDict, isTruthy } from '../lib/json.mjs';
18
+ import { relPosix } from '../lib/text.mjs';
18
19
  import { assertSourceComplete, phaseLog } from './bundle.mjs';
19
20
  import {
20
- get, globalMemory, globalNotebook, isDict, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
21
22
  } from './emit-common.mjs';
22
23
  import { renderAll, renderFile } from './render.mjs';
23
24
  import {
@@ -10,18 +10,11 @@
10
10
  * `emit-claude.mjs` do that; `bundle.mjs` renders the bundle they read from.
11
11
  */
12
12
  import path from 'node:path';
13
- import { copyFile, writeText, isFile, isDir } from '../lib/fs.mjs';
14
- import { get, isDict } from '../lib/json.mjs';
13
+ import { copyFile, writeText, isDir } from '../lib/fs.mjs';
14
+ import { relPosix } from '../lib/text.mjs';
15
15
  import { SRC_DIR_TOKENS, destRel } from './render.mjs';
16
16
  import { mkdirSync, readdirSync, cpSync } from 'node:fs';
17
17
 
18
- // `isFile`/`isDir`/`get`/`isDict` are owned by `js/lib` now (single owner across `hosts/`,
19
- // `build/`, `inspect/` and `web/` — layering forbids `build/` reaching into `inspect/scan.mjs`
20
- // for them, so `js/lib` is the one home both sides can reach). Re-exported here so this
21
- // module's own existing callers (`emit-claude.mjs`, `emit-opencode.mjs`, `version.mjs`,
22
- // `bundle.mjs`) keep importing them from `./emit-common.mjs` unchanged.
23
- export { isFile, isDir, get, isDict };
24
-
25
18
  /** `_build_render.SRC_DIRS_MARKER`. */
26
19
  export const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
27
20
  /**
@@ -79,11 +72,6 @@ export function rglobFiles(root) {
79
72
  .map((d) => path.join(d.parentPath, d.name));
80
73
  }
81
74
 
82
- /** `p.relative_to(base).as_posix()`. */
83
- export function relPosix(base, p) {
84
- return path.relative(base, p).split(path.sep).join('/');
85
- }
86
-
87
75
  /**
88
76
  * `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
89
77
  *
@@ -8,16 +8,17 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
- import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
+ import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
12
  import {
13
13
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
14
14
  writePonytailCommand, writePrimaryAgent, writeTheme,
15
15
  } from '../hosts/opencode.mjs';
16
16
  import { mergeOpencodeJson } from '../hosts/settings.mjs';
17
- import { readText, writeText } from '../lib/fs.mjs';
17
+ import { isFile, readText, writeText } from '../lib/fs.mjs';
18
+ import { relPosix } from '../lib/text.mjs';
18
19
  import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
19
20
  import {
20
- globalMemory, globalNotebook, isFile, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
21
22
  } from './emit-common.mjs';
22
23
  import { renderAll } from './render.mjs';
23
24
  import {
@@ -56,17 +57,20 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
56
57
  } = opts;
57
58
  ensureAgentOverridesStub(cfg, overridesDir);
58
59
  const overrides = loadAgentOverrides(overridesDir);
60
+ // One claim for every writer below whose path lands in `owned` — see `claimer`.
61
+ const claim = claimer(oldOwned, dir, manifestExisted);
59
62
 
60
63
  const { nAgents, nSkills, written } = writeNativeLayer(
61
64
  items, path.join(dir, 'agents'), path.join(dir, 'skills'), overrides,
62
- { host: 'opencode', oldOwned, cfg: dir, manifestExisted, theme, src: cfg.src });
65
+ { host: 'opencode', theme, src: cfg.src, claim });
63
66
  for (const p of written) owned.push(relPosix(dir, p));
64
67
 
65
- const primary = writePrimaryAgent(cfg, path.join(dir, 'agents'), overrides);
68
+ const primary = writePrimaryAgent(cfg, path.join(dir, 'agents'), overrides, claim);
66
69
  if (primary) owned.push(relPosix(dir, primary));
67
70
 
68
- const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'));
69
- commands.push(writePonytailCommand(path.join(dir, 'command'))); // always-on /ponytail
71
+ const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'), claim);
72
+ const ponytail = writePonytailCommand(path.join(dir, 'command'), claim); // always-on /ponytail
73
+ if (ponytail) commands.push(ponytail);
70
74
  for (const p of commands) owned.push(relPosix(dir, p));
71
75
 
72
76
  owned.push(relPosix(dir, writeTheme(path.join(dir, 'themes'), themeName, theme)));
@@ -97,7 +101,7 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
97
101
  * manifest is the DRIVER's stage: it reads the file, hands the prior claim in, and prunes
98
102
  * and rewrites it after this returns. One owner of the file, and this function is not it.
99
103
  *
100
- * `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']` from
104
+ * `nativeCatalog` likewise arrives decided. It is `hostCatalogsNatively('opencode')` from
101
105
  * `js/hosts/hosts.mjs`, resolved by the driver before the call; passing the decision rather
102
106
  * than re-reading the registry keeps one owner of it.
103
107
  */
@@ -9,25 +9,27 @@
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
11
  * Reproducing that shape here would mean spawning `node bin/build-driver.mjs`, and
12
- * `test_the_cli_reaches_no_child_process_module` forbids it: the ban is transitive over this
13
- * entry's imports, and P5c's argument for keeping it is that `web`, `upgrade` and `setup`
14
- * will genuinely need to spawn, so the ban should outlive them rather than be dismantled by
15
- * a verb that does not. It is also the wrong shape on its own terms. Python needs a second
12
+ * `tests/unit/hook_cli.test.mjs`'s 'the CLI entry reaches child_process only where it is
13
+ * declared' forbids it: the check is transitive over this entry's imports and allow-lists only
14
+ * the modules that genuinely start processes — `web`, `upgrade`, `setup` — and P5c's argument
15
+ * for it is that the ban should outlive them rather than be dismantled by a verb that does not.
16
+ * It is also the wrong shape on its own terms. Python needs a second
16
17
  * process because `build.py` is a different PROGRAM; `bin/build-driver.mjs` is a module in this
17
18
  * one, and a Node CLI spawning a Node driver to do work it could do in-process is exactly
18
19
  * the passthrough the whole port has been removing. So the driver's `main` is exported and
19
20
  * called directly, and the observable contract — the tree, the streams, the exit code — is
20
- * what `build/*` in `tests/harness_golden.py` compares.
21
+ * what the `build/*` cells of `tests/golden.mjs --cli` assert.
21
22
  *
22
23
  * WHAT NO CELL CAN REACH HERE, stated rather than left implicit. `fenceFor`'s real arm is
23
24
  * unreachable from the matrix: measured over the whole rendered tree, the longest backtick
24
25
  * run in any source text is 3, so `max(4, longest + 1)` picks 4 for all 96 files, and a port
25
26
  * that hardcoded four backticks is byte-identical in every prompt cell. Reaching the other
26
27
  * arm needs a file in `src/`, and `src/` is the tree no fixture can redirect (P5d). It is
27
- * therefore gated as a pure function over a corpus in `tests/snapshot/pure_snapshot.test.mjs`, with the
28
- * unreachability measured in BOTH directions rather than asserted: one test proves the corpus
29
- * leaves the floor, and one re-renders every theme × posture × mode of the live tree and proves
30
- * `src/` still does not. The 96 above is one render — the sweep is 13 440 files.
28
+ * the former Python suite gated it as a pure function over a corpus, with the unreachability
29
+ * measured in BOTH directions rather than asserted: one test proved the corpus left the floor,
30
+ * and one re-rendered every theme × posture × mode of the live tree and proved `src/` still did
31
+ * not. The 96 above is one render — the sweep was 13 440 files. That suite is gone and no Node
32
+ * test calls `fenceFor`, so the real arm is pinned by nothing today.
31
33
  */
32
34
  import { existsSync, mkdirSync, statSync } from 'node:fs';
33
35
  import path from 'node:path';
@@ -202,7 +204,7 @@ export function installProfile(host, scope, root) {
202
204
  const posture = postureOfDir(root) || defaultPosture();
203
205
  const mode = modeOfDir(root) || defaultMode();
204
206
  // Read off the deployed loop skill; no config fallback — `DEFAULT_PRESET` is the engine's.
205
- const trust = trustOfDir(root) || DEFAULT_PRESET;
207
+ const trust = trustOfDir(root, host) || DEFAULT_PRESET;
206
208
  // A rebuild that defaulted the pack selection would re-emit an install into a constitution
207
209
  // its owner did not choose — and in one direction that is not merely surprising: dropping
208
210
  // the process pack takes the commit/push consent RULES out of AGENT.md while
@@ -360,7 +362,9 @@ export function buildPrompt(cfg, themeName) {
360
362
  * Windows. At ~10,000 newlines in a 400 KB document that is the largest byte difference this
361
363
  * port could ship, and `harness_golden` can only see half of it — the FILE half is compared
362
364
  * directly, the stdout half is folded by `subprocess`'s universal-newline decode, which is
363
- * why `test_the_two_entry_points_agree_on_stdout_BYTES` grew a `prompt` row.
365
+ * why the former Python suite's stdout-bytes comparison grew a `prompt` row — kept today as
366
+ * a row of `tests/unit/hook_cli.test.mjs`'s 'every newline the entry points write is the
367
+ * platform's'.
364
368
  */
365
369
  export function cmdPrompt(args) {
366
370
  // `args.theme or "neutral"` — a literal in the Python, NOT the config's theme, so this
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * WHY THIS HALF AND NOT MORE. The emits split into five stages —
10
10
  * RENDER* -> WIRE* -> PRUNE -> MANIFEST -> VERIFY (see `_build_global._emit_claude_core`
11
- * and tests/test_emit_phase_order.py). RENDER produces files Geneseed owns wholesale;
11
+ * and tests/unit/emit_phase_order.test.mjs). RENDER produces files Geneseed owns wholesale;
12
12
  * WIRE reconciles files the user co-owns (settings.json, opencode.json, the CLAUDE.md
13
13
  * managed block) and lives in `js/hosts/settings.mjs`, driven from the two emit modules.
14
14
  * THIS module is only RENDER
@@ -20,9 +20,9 @@
20
20
  * `capabilityLinkRe`'s equivalent override in `stripCapabilityLinks` had no producer either
21
21
  * and was deleted outright in the over-engineering cleanup, `cfg` param and all.
22
22
  *
23
- * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/build-driver.mjs`
24
- * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
25
- * from these paths. A depth error or a renamed key fails 259 cells, not zero.
23
+ * The move is safe for the reason P5c's was: `tests/golden.mjs` drives `bin/build-driver.mjs`
24
+ * over every emit configuration and requires each to render, and every one of them builds a
25
+ * cfg from these paths. A depth error or a renamed key fails every cell, not zero.
26
26
  */
27
27
  import { readdirSync, readFileSync, statSync } from 'node:fs';
28
28
  import path from 'node:path';
@@ -38,7 +38,7 @@ import { DEFAULT_PRESET } from '../loop/score.mjs';
38
38
  * on raw or folded text, so one behaviour serves both.
39
39
  */
40
40
  const _packTexts = new Map();
41
- export function readRawText(file) {
41
+ function readRawText(file) {
42
42
  const key = path.resolve(file);
43
43
  const mtime = statSync(file).mtimeMs;
44
44
  const hit = _packTexts.get(key);
@@ -160,8 +160,8 @@ export function resolveRuleIds(text, src = SRC) {
160
160
  * `_OWNED` redirect the suite uses is an in-process write, and no env var moves it
161
161
  * (`$GENESEED_ROOT` is `harness context`'s doc-discovery root — a different name for a
162
162
  * different job). Both implementations therefore answer from their own file's location,
163
- * which is what makes them comparable and what makes them unfenceable. See the
164
- * status/version section of `tests/harness_golden.py`.
163
+ * which is what made them comparable and what makes them unfenceable. See
164
+ * `js/inspect/status.mjs`'s header for what that leaves unreachable from any cell.
165
165
  */
166
166
  export const ROOT = path.resolve(import.meta.dirname, '../..');
167
167
  export const SRC = path.join(ROOT, 'src');
@@ -9,8 +9,9 @@
9
9
  */
10
10
  import path from 'node:path';
11
11
  import { writeText } from '../lib/fs.mjs';
12
+ import { isDir } from '../lib/fs.mjs';
12
13
  import { jsonDumpsIndent } from '../lib/json.mjs';
13
- import { isDir } from './emit-common.mjs';
14
+ import { EXCLUDES_FILE } from '../hosts/hosts.mjs';
14
15
  import { existsSync } from 'node:fs';
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -102,8 +103,8 @@ usually one promoted from a recurring memory. Review it by that date, then
102
103
  graduate it (remove the marker) or demote it back to memory.
103
104
  `;
104
105
 
105
- /** `_build_render.EXCLUDES_FILE`. */
106
- export const EXCLUDES_FILE = 'excludes.json';
106
+ // Owned by `js/hosts/hosts.mjs` (the hook reads it there); re-exported for `js/inspect/excludes.mjs`.
107
+ export { EXCLUDES_FILE };
107
108
 
108
109
  /**
109
110
  * `_build_render.EXCLUDES_STUB` — one long line, exactly as Python spells it.
@@ -232,8 +233,8 @@ export const ensureProfileStub = stubWriter(PROFILE_FILE, PROFILE_STUB);
232
233
 
233
234
  /**
234
235
  * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
235
- * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
236
- * `emitClaudeRender` rather than with the bundle stubs beside it.
236
+ * overwritten. Called by every GLOBAL emit — the Claude-shaped ones and opencode-global —
237
+ * since that is where the hook looks for it.
237
238
  */
238
239
  export const ensureExcludesStub = stubWriter(EXCLUDES_FILE, EXCLUDES_STUB);
239
240
 
@@ -8,9 +8,11 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
- import { sourceReleaseVersion } from '../hosts/opencode.mjs';
12
11
  import { readText, writeText } from '../lib/fs.mjs';
13
- import { isDir, relPosix, rglobFiles } from './emit-common.mjs';
12
+ import { parseJson } from '../lib/json.mjs';
13
+ import { isDir } from '../lib/fs.mjs';
14
+ import { relPosix } from '../lib/text.mjs';
15
+ import { rglobFiles } from './emit-common.mjs';
14
16
  import { createHash } from 'node:crypto';
15
17
  import { readFileSync } from 'node:fs';
16
18
 
@@ -132,6 +134,23 @@ export function versionIsNewer(a, b) {
132
134
  return false;
133
135
  }
134
136
 
137
+ /**
138
+ * `_build_core.source_release_version` — the release this checkout would install. It reads
139
+ * `cfg.config`, so it takes `cfg`. It lived in `js/hosts/opencode.mjs` for no better reason
140
+ * than that the first caller was there; every reader since asks a version question, so it
141
+ * lives with them. `js/hosts/opencode.mjs` re-exports it for its older importers.
142
+ */
143
+ export function sourceReleaseVersion(cfg) {
144
+ try {
145
+ const data = parseJson(readText(cfg.config));
146
+ const v = data && typeof data === 'object' && !Array.isArray(data) ? data.version : null;
147
+ if (typeof v === 'string' && v) return v;
148
+ } catch {
149
+ // OSError / JSONDecodeError — degrade to "not newer" rather than raising.
150
+ }
151
+ return '0.0.0';
152
+ }
153
+
135
154
  /** `_build_render._warn_if_downgrade` — STDOUT, matching the Python. */
136
155
  function warnIfDowngrade(cfg, out) {
137
156
  const deployed = readReleaseVersion(out);
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { readText, printOut as out, printErr as err, withDiscardableStderr } from '../lib/fs.mjs';
10
10
  import { normcase, toPlatformPath } from '../lib/paths.mjs';
11
+ import { relPosix } from '../lib/text.mjs';
11
12
  import { resolvePath, sovereignBypass } from './hosts.mjs';
12
13
  import { isFile, isDir, selfAndParents, sortPaths, listDir } from './hooks-prims.mjs';
13
14
 
@@ -177,7 +178,7 @@ function disp(pathStr, root) {
177
178
  !== path.parse(path.resolve(root)).root.toLowerCase()) {
178
179
  return pathStr;
179
180
  }
180
- return path.relative(root, pathStr).split(path.sep).join('/');
181
+ return relPosix(root, pathStr);
181
182
  }
182
183
 
183
184
 
@@ -23,8 +23,8 @@ import { comparePaths } from '../lib/paths.mjs';
23
23
  // none of that reached the hook, because a corpus finds what it is pointed at and nothing
24
24
  // named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
25
25
  // `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
26
- // down for a repo Python still gates. `context/bypass-does-not-match-a-dotdot-entry` is
27
- // the cell.
26
+ // down for a repo Python still gates. (Since the Python is gone, exclude entries resolve `..`
27
+ // and symlinks on every host — `context/sovereign-bypass-matches-a-dotdot-entry` is the cell.)
28
28
  //
29
29
  // So the line is not "duplicate rather than couple" — it is "one owner for anything that
30
30
  // reproduces a language primitive, wherever it is used". `fs.mjs` carries no
@@ -66,8 +66,12 @@ export function sovereignBypass(root) {
66
66
  // `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
67
67
  // and this caller catches: the reference prints nothing here, so replaying the
68
68
  // message would be a stderr divergence on every hook call for one bad entry.
69
+ //
70
+ // `resolvePath`, the same resolution as `cwd` above: symlinks and junctions are followed on
71
+ // BOTH sides, so an entry spelled through a link matches the real directory the session
72
+ // stands in (and the OpenCode plugins' `norm` applies the identical rule).
69
73
  base = withDiscardableStderr(
70
- () => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
74
+ () => normcase(resolvePath(raw.trim())).replace(/[\\/]+$/, ''));
71
75
  } catch {
72
76
  // PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
73
77
  // must not decide the whole function — `expanduser` refuses a `~user` form, and the
@@ -208,14 +212,13 @@ export function openclaudeConfigDir() {
208
212
  }
209
213
 
210
214
  /**
211
- * `_build_global.HOSTS`, reduced to the two columns a non-emitting caller needs, and IN ITS
212
- * ORDER — opencode, claude, bob, openclaude.
215
+ * `_build_global.HOSTS` — one row per host, IN ITS ORDER: opencode, claude, bob, openclaude.
216
+ * A new host is one row here; `EMIT_HOST_SCOPE` (`js/hosts/installs.mjs`) and `CLAUDE_STYLE`
217
+ * below are derived from it.
213
218
  *
214
219
  * An array rather than an object because the order is observable output, not an
215
220
  * implementation detail: `harness exclude add` walks it and prints one message per host, so
216
- * a reordering is a diff in stderr. (`emit_global` and `native_catalog` stay out;
217
- * `bin/build-driver.mjs` receives those decisions from its own dispatch rather than reading a
218
- * table.)
221
+ * a reordering is a diff in stderr.
219
222
  *
220
223
  * `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
221
224
  * install of host H", and `_claude_cfg` asks "which subdir holds a project install's
@@ -232,13 +235,16 @@ export function openclaudeConfigDir() {
232
235
  * `family` joined in 2026-10: `'claude'` for every host emitted through the Claude-shaped
233
236
  * engine — one manifest shape, one `settings.json` hook wiring, one strict-JSON `mcpServers`
234
237
  * config, one reversal. The list had been spelled out by hand in ten places, one of which
235
- * ignored the named constant right above it; a fifth host is now one row here.
238
+ * ignored the named constant right above it.
239
+ *
240
+ * `catalog` — does the host list every skill and agent to the model by itself? See
241
+ * `hostCatalogsNatively` below for what it decides and why it is split per kind.
236
242
  */
237
243
  export const HOSTS = [
238
- { host: 'opencode', family: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
239
- { host: 'claude', family: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
240
- { host: 'bob', family: 'claude', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
241
- { host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
244
+ { host: 'opencode', family: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md', catalog: { skills: true, agents: true } },
245
+ { host: 'claude', family: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true } },
246
+ { host: 'bob', family: 'claude', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md', catalog: { skills: true, agents: false } },
247
+ { host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true } },
242
248
  ];
243
249
 
244
250
  /** The Claude-STYLE hosts (`family: 'claude'`), in `HOSTS` order. Test with `.includes(host)`. */
@@ -248,12 +254,11 @@ export const CLAUDE_STYLE = HOSTS.filter((h) => h.family === 'claude').map((h) =
248
254
  * `_build_global.host_catalogs_natively` — does `host` list every skill and agent to the
249
255
  * model by itself?
250
256
  *
251
- * The third column of `HOSTS`, and the reason the docblock above no longer says
252
- * `native_catalog` stays out: `doctor`'s `_rendered_problems` has to know, because a host
257
+ * `HOSTS`' `catalog` column. `doctor`'s `_rendered_problems` has to know, because a host
253
258
  * that catalogues natively gets AGENT.md's tables collapsed to a pointer and comparing the
254
259
  * portable shape against its bundle reports AGENT.md stale on every run forever. It is a
255
- * column and not a set literal so that the capability is still declared in exactly one place
256
- * — the Python's own reason for the helper.
260
+ * column and not a set literal so that the capability is declared in exactly one place
261
+ * — the Python's own reason for the helper. The driver reads it through this function too.
257
262
  *
258
263
  * Unknown host -> false, which is the shape that KEEPS the tables. The value arrives from a
259
264
  * user-editable `.geneseed-emit` marker, so an unrecognised one must degrade to the portable
@@ -264,19 +269,12 @@ export const CLAUDE_STYLE = HOSTS.filter((h) => h.family === 'claude').map((h) =
264
269
  * single boolean was wrong both ways: `false` shipped the §4 Skills table on top of a
265
270
  * catalogue Bob already had (~1.7k tokens twice), `true` would have stripped the §3 Agents
266
271
  * table that is Bob's ONLY agent catalogue. Each CATALOG block in AGENT.md names its kind and
267
- * resolves against its own flag. Mirrored in
268
- * `bin/build-driver.mjs` (which cannot import this module's spawning neighbours).
272
+ * resolves against its own flag.
273
+ *
274
+ * Returns `{ skills, agents }` for a known host, `false` for an unknown one.
269
275
  */
270
- const NATIVE_CATALOG = {
271
- opencode: { skills: true, agents: true },
272
- claude: { skills: true, agents: true },
273
- bob: { skills: true, agents: false },
274
- openclaude: { skills: true, agents: true },
275
- };
276
-
277
- /** `{ skills, agents }` for a known host, `false` for an unknown one. */
278
276
  export function hostCatalogsNatively(host) {
279
- return NATIVE_CATALOG[host] || false;
277
+ return HOSTS.find((h) => h.host === host)?.catalog || false;
280
278
  }
281
279
 
282
280
  /** `_harness_learn.MEMORY_DIR_NAMES` — the neutral name and the imperial theme's. */