geneseed 3.13.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 (131) hide show
  1. package/CHANGELOG.md +93 -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 +45 -10
  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 +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -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 {
@@ -40,7 +41,7 @@ import { existsSync, mkdirSync } from 'node:fs';
40
41
  * Six emits route through here — claude, bob and openclaude at both scopes — so this one
41
42
  * job is what puts two thirds of the matrix on the seam.
42
43
  *
43
- * `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
44
+ * `claudeMdText` is the one product of this render that is not a file it writes. WIRE
44
45
  * needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
45
46
  * re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
46
47
  * there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
@@ -174,8 +175,6 @@ export function emitClaudeRender(cfg, job) {
174
175
  stats: { nAgents, nSkills },
175
176
  memStatus,
176
177
  nbStatus,
177
- hasAgentText: agentText !== null,
178
- claudeMdText,
179
178
  managed,
180
179
  };
181
180
  }
@@ -264,7 +263,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
264
263
  // travels from `cfg` rather than being re-read off the deployment: this is the emit that
265
264
  // DECIDES the install's packs, so the marker on disk is still the previous build's.
266
265
  const [, managedHooks] = mergeClaudeSettings(
267
- settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
266
+ settingsPath, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
268
267
  doctrines, excludeRules, host, cfgDir,
269
268
  );
270
269
  managed.settings_hooks = managedHooks;
@@ -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';
@@ -171,7 +173,7 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
171
173
  * A pre-marker install still has a (host, scope), which is what the walk found it by, so it
172
174
  * is rebuilt in its own mode rather than defaulted to OpenCode.
173
175
  */
174
- export const DEFAULT_EMIT = new Map([
176
+ const DEFAULT_EMIT = new Map([
175
177
  ['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
176
178
  ['claude global', 'claude-global'], ['claude project', 'claude'],
177
179
  ['bob global', 'bob-global'], ['bob project', 'bob'],
@@ -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
@@ -483,7 +487,10 @@ export function cmdTheme(args) {
483
487
  written.push(dest);
484
488
  }
485
489
  printOut(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
486
- printOut(`[theme] select in OpenCode with: /theme ${full}`
487
- + (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
490
+ // The hint names what was WRITTEN: `--transparent-only` writes no `${full}.json`, and a
491
+ // hint selecting it would point OpenCode at a theme that does not exist.
492
+ const [first, ...rest] = written.map((p) => path.basename(p, '.json'));
493
+ printOut(`[theme] select in OpenCode with: /theme ${first}`
494
+ + rest.map((n) => ` (or /theme ${n})`).join('') + '\n');
488
495
  return 0;
489
496
  }
@@ -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
@@ -34,7 +34,7 @@ import { readText } from '../lib/fs.mjs';
34
34
  import { parseJson } from '../lib/json.mjs';
35
35
  import { normcase, comparePaths } from '../lib/paths.mjs';
36
36
  // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
- import { PACK_ORDER, readPackText, resolveRuleIds } from './source.mjs';
37
+ import { PACK_ORDER, readPackText, resolveRuleIds, ruleCanon } from './source.mjs';
38
38
  import { DEFAULT_PRESET } from '../loop/score.mjs';
39
39
 
40
40
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
@@ -86,7 +86,7 @@ const CATALOG_BLOCK_RE =
86
86
  // truncation — the objection `docs/token-footprint.md` raised against terse-ing the
87
87
  // Ontology ("four orphan sentences") is answered by authoring the short form, not
88
88
  // generating it. Same marker grammar as CATALOG so the two stay learnable as one idea.
89
- const LEAN_BLOCK_RE =
89
+ export const LEAN_BLOCK_RE =
90
90
  /[ \t]*<!-- LEAN:begin -->\n(?<full>[\s\S]*?)[ \t]*<!-- LEAN:else -->\n(?<lean>[\s\S]*?)[ \t]*<!-- LEAN:end -->\n/g;
91
91
 
92
92
  /**
@@ -184,7 +184,7 @@ function resolveLean(text, footprint) {
184
184
  * match is a corner both engines implement, and the leading '' Python produces when the
185
185
  * text starts with a heading is load-bearing (it becomes `blocks[0]`, the preamble).
186
186
  */
187
- function splitAtLawHeadings(text) {
187
+ export function splitAtLawHeadings(text) {
188
188
  const out = [];
189
189
  let start = 0;
190
190
  for (let i = 0; i < text.length; i++) {
@@ -310,12 +310,8 @@ function activeDoctrines(cfg) {
310
310
  // interpret. Dropped here rather than in `doctrinesBody` so `DOCTRINES_LIST` — the
311
311
  // `Active packs:` marker a later reader parses back — agrees with what was rendered.
312
312
  const excluded = new Set(cfg.excludeRules ?? []);
313
- const survives = (pack) => {
314
- const ids = [...readPackText(path.join(dir, `${pack}.md`))
315
- .matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)]
316
- .map((m) => `${m[1]}.${Number(m[2])}`);
317
- return ids.some((id) => !excluded.has(id));
318
- };
313
+ const { rules } = ruleCanon(cfg.src);
314
+ const survives = (pack) => rules.some((r) => r.pack === pack && !excluded.has(`${pack}.${r.n}`));
319
315
  return PACK_ORDER.filter((p) => wanted.includes(p)).filter(survives);
320
316
  }
321
317
 
@@ -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');
@@ -222,7 +222,8 @@ export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process', 'comms'];
222
222
  * The ONE enumerator, for the same arithmetic reason `PACK_ORDER` is one array: the CLI
223
223
  * flag, the console's trust boundary and the doctor all have to close `--exclude-rules`
224
224
  * against the SAME set, and a second copy is a set that drifts by one rule the day a pack
225
- * grows. Read from the UNRENDERED source (`### {{DOCTRINE}} <pack> <n>`) rather than from a
225
+ * grows. Read off `ruleCanon` — the same declarations, in the same order, that the render
226
+ * numbers its headings from and the doctor validates citations against — rather than from a
226
227
  * built tree, because an address is a property of the source and the rendered heading is
227
228
  * themed — fourteen spellings of a thing that must compare equal to itself.
228
229
  *
@@ -232,16 +233,7 @@ export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process', 'comms'];
232
233
  * were ever out of order, and disagree in the direction that reads as correct.
233
234
  */
234
235
  export function knownRuleIds() {
235
- const out = [];
236
- for (const pack of PACK_ORDER) {
237
- const file = path.join(SRC, 'doctrines', `${pack}.md`);
238
- let text = '';
239
- try { text = readPackText(file); } catch { continue; }
240
- for (const m of text.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)) {
241
- if (m[1] === pack) out.push(`${pack}.${Number(m[2])}`);
242
- }
243
- }
244
- return out;
236
+ return ruleCanon(SRC).rules.filter((r) => r.kind === 'DOCTRINE').map((r) => `${r.pack}.${r.n}`);
245
237
  }
246
238
 
247
239
  /**
@@ -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);