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
@@ -1,14 +1,10 @@
1
1
  /**
2
- * The generator driver — flag parsing, the nine emit targets, and the per-host emit orchestration.
2
+ * The generator driver — the nine emit targets and the per-host emit orchestration (flag parsing
3
+ * is `args.mjs`).
3
4
  * `bin/build-driver.mjs` (`geneseed-build`) is only its entry; this module is what the CLI verbs
4
5
  * (`build`, `setup`, `migrate`, `doctor`, `diff`, `validate`, the web console) import. It lived in
5
6
  * `bin/` until 2026-09, which made nine `js/` modules import upward from a binary.
6
7
  *
7
- * P4 adds a driver; it does not replace one. `build.py` is ALSO the `import build` facade
8
- * that 19 `rituals/` modules read 55 distinct names from, and ~11 sites spawn
9
- * `[sys.executable, build.py, ...]`. None of those flip here: the Python CLI has to
10
- * survive this phase intact, and every gate that drives it keeps driving it.
11
- *
12
8
  * WHAT THIS FILE ORIGINATES, AND WHY THAT IS THE PHASE'S WHOLE POINT.
13
9
  * Every phase since P2d has asked one question at the process boundary: which values does
14
10
  * the CHILD resolve, and which does the parent decide and send? P3c's answer for `cfgDir`
@@ -39,13 +35,14 @@ import {
39
35
  unlinkSync,
40
36
  } from 'node:fs';
41
37
  import path from 'node:path';
42
- import { parseArgs as nodeParseArgs } from 'node:util';
43
38
  import { build, phaseLog } from './bundle.mjs';
44
39
  import { emitClaudeRender } from './emit-claude.mjs';
45
40
  import { emitOpencodeRender, emitOpencodeGlobalRender } from './emit-opencode.mjs';
46
41
  import { settingsIntegrityCheck } from '../hosts/settings.mjs';
42
+ import { hookRunnerEntry } from '../hosts/shim.mjs';
47
43
  import { writeText, withPlatformNewlines, isFile } from '../lib/fs.mjs';
48
44
  import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
45
+ import { relPosix } from '../lib/text.mjs';
49
46
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
50
47
  // find a global install, and a resolver that decides WHERE a driver writes is the last thing
51
48
  // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
@@ -58,25 +55,22 @@ import {
58
55
  // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
59
56
  // same cfg. golden.py's 259 cells all build one, which is what made the move safe.
60
57
  import {
61
- ROOT, CONFIG, discoverNames, makeCfg, PACK_ORDER, knownRuleIds,
58
+ ROOT, makeCfg,
62
59
  } from './source.mjs';
63
60
 
64
61
  // P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
65
62
  // registry to find every install it must re-emit, and a CLI verb may not reach into a driver
66
63
  // for a reader. See js/inspect/registry.mjs.
67
64
  import { registryRecord, registryRoots } from '../inspect/registry.mjs';
68
- // The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
69
- // what lets it sit inside this driver's total `child_process` ban.
70
- import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
71
65
 
72
66
  // P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
73
67
  // of textual surgery over committed files with a corpus of its own, and it is the half of the
74
68
  // maintainer pair that needs nothing this driver is banned from having.
75
69
  import { syncThemes } from './themes.mjs';
70
+ import { configDefaults, die, parseArgs } from './args.mjs';
71
+
72
+ export { parseDriverArgs } from './args.mjs';
76
73
 
77
- /** The nine `--emit` choices: a plain bundle, then a project and a global emit per host. */
78
- const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
79
- 'bob', 'bob-global', 'openclaude', 'openclaude-global'];
80
74
 
81
75
  /** `_build_emit.PRIMARY_AGENT_SRC`. */
82
76
  const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
@@ -97,406 +91,14 @@ const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orc
97
91
  * It is invisible on a machine whose paths are already canonical, which is every machine
98
92
  * this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
99
93
  * two CLIs printed the same directory under two different names — a 70-byte difference in a
100
- * 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
101
96
  * argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
102
97
  * reference does not expand `~` here, and a bare `~` is a legal directory name. */
103
98
  export function resolveOut(raw) {
104
99
  return resolvePath(path.resolve(process.cwd(), raw));
105
100
  }
106
101
 
107
- /**
108
- * build.py:307-321 — the three defaults that come from `harness.config.json`.
109
- *
110
- * The corrupt-file branch is reproduced including its imprecision: the warning says only
111
- * "using theme 'neutral'", but the branch also resets posture and mode. Matching the
112
- * Python text matters more than fixing it here, because the two CLIs' stderr is compared
113
- * byte-for-byte by `tests/golden.py` (:316).
114
- */
115
- function configDefaults() {
116
- const d = {
117
- theme: 'neutral', posture: 'peer', mode: 'direct', trust: DEFAULT_PRESET,
118
- doctrines: [...PACK_ORDER], excludeRules: [],
119
- };
120
- if (!existsSync(CONFIG)) return d;
121
- try {
122
- const data = JSON.parse(readFileSync(CONFIG, 'utf8'));
123
- if (data && typeof data === 'object' && !Array.isArray(data)) {
124
- if (data.theme !== undefined) d.theme = data.theme;
125
- if (data.posture !== undefined) d.posture = data.posture;
126
- if (data.mode !== undefined) d.mode = data.mode;
127
- // `doctrines` is the only config key that is a LIST, so it is the only one that can be
128
- // the right type and still nonsense. A bad value warns and falls back to all packs
129
- // rather than dying: this file is hand-editable, and a typo here must not brick every
130
- // rebuild of an install whose AGENT.md is otherwise fine. `[]` is a legitimate value
131
- // (no packs) and must survive the check — hence an explicit Array test, not truthiness.
132
- if (data.doctrines !== undefined) {
133
- const known = discoverNames('doctrines', PACK_ORDER[0]);
134
- if (Array.isArray(data.doctrines) && data.doctrines.every((x) => known.includes(x))) {
135
- d.doctrines = PACK_ORDER.filter((p) => data.doctrines.includes(p));
136
- } else {
137
- process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "doctrines" is not a `
138
- + 'list of known pack names — using all packs.\n');
139
- }
140
- }
141
- // The second axis, read the same forgiving way and defaulting the OTHER direction: a
142
- // bad value excludes NOTHING, because the safe answer for a list of things to take
143
- // away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
144
- if (data.excludeRules !== undefined) {
145
- if (Array.isArray(data.excludeRules)
146
- && data.excludeRules.every((x) => typeof x === 'string')) {
147
- d.excludeRules = [...new Set(data.excludeRules.map((x) => x.replace(/[ \t]+/, '.')))]
148
- .sort();
149
- } else {
150
- process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
151
- + 'not a list of rule addresses — excluding nothing.\n');
152
- }
153
- }
154
- }
155
- } catch {
156
- process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
157
- + "using theme 'neutral'.\n");
158
- }
159
- return d;
160
- }
161
-
162
- /**
163
- * The parser's tables, at MODULE scope so `usage` reads the same two objects the loop below
164
- * dispatches on. They were local to `parseArgs` until `-h` arrived; a help text listing its
165
- * own private copy of the flags is a second source that drifts silently the first time a
166
- * flag is added to one and not the other, and nothing downstream would say so.
167
- */
168
- const VALUED = {
169
- '--theme': 'theme', '--posture': 'posture', '--mode': 'mode', '--trust': 'trust',
170
- '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
171
- '--out': 'out', '--target': 'out', '--emit': 'emit',
172
- '--footprint': 'footprint', '--root': 'root', '--config-dir': 'cfgDir',
173
- };
174
- const FLAGS = {
175
- '--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
176
- '-v': 'verbose', '--verbose': 'verbose',
177
- };
178
-
179
- /**
180
- * `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
181
- * second time — a hand-written twin is exactly the anti-drift failure this module's docblock
182
- * already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
183
- * value` form is captured as one token instead of leaking `value` as an orphan positional);
184
- * every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
185
- * `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
186
- */
187
- const PARSE_OPTIONS = Object.fromEntries([
188
- ...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
189
- ...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
190
- ]);
191
- PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
192
-
193
- /** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
194
- const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
195
- const FLAG_BY_NAME = Object.fromEntries(
196
- Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
197
- );
198
-
199
- /**
200
- * The five flags `choice` validates, as ONE table for the same anti-drift reason.
201
- *
202
- * A function and not a constant because two of the five read the checkout: `discoverNames`
203
- * scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
204
- * standing in and cannot be frozen at import.
205
- *
206
- * `--doctrines` is DELIBERATELY not a sixth row. Every consumer of this table treats an
207
- * entry as one value drawn from a closed list — `choice` refuses anything else, and
208
- * `usage`'s metavar prints the list as the whole legal surface. `--doctrines` takes a comma
209
- * LIST plus the sentinel `none`, so a row here would have refused `craft,rigor` outright and
210
- * advertised a metavar that lies. It is validated by `parseDoctrines` instead and prints the
211
- * plain `DOCTRINES` metavar, exactly as `--theme` (also open-ended) already does.
212
- */
213
- function choicesFor() {
214
- return {
215
- '--emit': EMITS,
216
- '--footprint': ['lean', 'full'],
217
- '--posture': discoverNames('postures', 'peer'),
218
- '--mode': discoverNames('modes', 'direct'),
219
- '--trust': Object.keys(PRESETS),
220
- };
221
- }
222
-
223
- /**
224
- * `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
225
- *
226
- * Normalised into `PACK_ORDER` rather than kept in the order typed, which also dedupes: the
227
- * rendered `Active packs:` line is a MARKER a later reader parses back out of a deployed
228
- * carrier, and a marker whose contents depend on argument order is one that compares unequal
229
- * to itself. Validation is against DISCOVERY, not `PACK_ORDER`, so the refusal names the
230
- * packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
231
- * different fault and `js/build/render.mjs` refuses the whole build for it.
232
- */
233
- /**
234
- * `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
235
- *
236
- * BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
237
- * every citation in the corpus spell a rule `process 7`; the console's deep link and the
238
- * catalogue spell it `process.7`. A flag that took only one of them would be wrong for
239
- * whichever half of the documentation the reader had open, so it takes either and normalises
240
- * to the dotted form the code compares.
241
- *
242
- * ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
243
- * refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
244
- * a typo that would otherwise take away nothing in silence, which is the failure this flag
245
- * can least afford: the user asked for a rule to STOP binding and it would keep binding.
246
- * Sorted, deduped, for the same marker-stability reason `--doctrines` is.
247
- */
248
- function parseExcludeRules(value) {
249
- const s = value.trim();
250
- if (s === 'none' || s === '') return [];
251
- const known = knownRuleIds();
252
- const out = [];
253
- for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
254
- const id = raw.replace(/[ \t]+/, '.');
255
- if (!known.includes(id)) {
256
- die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
257
- + `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
258
- }
259
- out.push(id);
260
- }
261
- return [...new Set(out)].sort();
262
- }
263
-
264
- function parseDoctrines(value) {
265
- const s = value.trim();
266
- const known = discoverNames('doctrines', PACK_ORDER[0]);
267
- if (s === 'none') return [];
268
- const names = s.split(',').map((x) => x.trim()).filter(Boolean);
269
- if (!names.length) {
270
- die(2, "argument --doctrines: expected a comma-separated list of packs, or 'none'");
271
- }
272
- for (const n of names) {
273
- if (!known.includes(n)) {
274
- die(2, `argument --doctrines: invalid choice: '${n}' (choose from `
275
- + `${known.map((c) => `'${c}'`).join(', ')}, or 'none')`);
276
- }
277
- }
278
- return PACK_ORDER.filter((p) => names.includes(p));
279
- }
280
-
281
- /**
282
- * `-h/--help` — the flag argparse handed the reference for free and this hand-rolled parser
283
- * did not, so the two entry points disagreed: the reference printed usage and exited 0 while
284
- * `geneseed-build --help` died with `unrecognized arguments: --help` and exit 2.
285
- *
286
- * NO CELL COULD SEE IT. `golden.py`'s `_argv` builds every cell out of the render flags and
287
- * never emits `--help`, so the byte comparison only ever ran inputs both implementations
288
- * were built for — a flag missing from one side is invisible to a gate that never passes it.
289
- * `test_the_help_text_names_every_flag_the_reference_takes` is the gate, and it reads
290
- * `build.py`'s `add_argument` calls rather than this file, so it fails on drift from EITHER
291
- * side rather than agreeing with whichever one it was copied from.
292
- *
293
- * The text is deliberately NOT argparse's byte-for-byte. Reproducing its wrapping would put
294
- * a hand-written copy of the reference's output in a file no gate compares, which is the
295
- * drift this whole docblock is about; the surface here is the npx one, in the same class as
296
- * the `--sync-themes` and `--validate-only` refusals that already answer differently.
297
- */
298
- function usage() {
299
- const choices = choicesFor();
300
- const metavar = (flag) => (choices[flag]
301
- ? `{${choices[flag].join(',')}}`
302
- : VALUED[flag].toUpperCase());
303
- const valued = Object.keys(VALUED);
304
- const bare = Object.keys(FLAGS);
305
- return [
306
- `usage: geneseed-build [-h] ${[...valued.map((f) => `[${f} ${metavar(f)}]`),
307
- ...bare.map((f) => `[${f}]`)].join(' ')}`,
308
- '',
309
- 'Render the Geneseed harness for a theme.',
310
- '',
311
- 'options:',
312
- ' -h, --help',
313
- ...valued.map((f) => ` ${f} ${metavar(f)}`),
314
- ...bare.map((f) => ` ${f}`),
315
- '',
316
- ].join('\n');
317
- }
318
-
319
- /**
320
- * argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
321
- * SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
322
- * decision this function made before P6 (Task 4) still made HERE, unmoved.
323
- *
324
- * `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
325
- * walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
326
- * flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
327
- * argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
328
- * string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
329
- * being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
330
- * accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
331
- * anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
332
- * behaviour, not a preserved one.
333
- *
334
- * `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
335
- * `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
336
- * help text for a cluster the original parser refused outright as one unrecognized argument.
337
- * Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
338
- * ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
339
- * privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
340
- * for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
341
- * would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
342
- * this one) refuses the whole typed token, `-bogus`, once.
343
- */
344
- function parseArgs(argv, defaults) {
345
- const args = {
346
- theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
347
- trust: defaults.trust,
348
- doctrines: defaults.doctrines,
349
- excludeRules: defaults.excludeRules,
350
- out: 'Harness', emit: 'files', footprint: 'lean', root: null,
351
- syncThemes: false, validateOnly: false, verbose: false,
352
- };
353
- const { tokens } = nodeParseArgs({
354
- args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
355
- });
356
- for (const tok of tokens) {
357
- if (tok.kind === 'option' && tok.value === undefined
358
- && (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
359
- process.stdout.write(usage());
360
- // The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
361
- // `main`'s catch is what turns either into this process's exit status. A bare
362
- // `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
363
- // loop with it, and skips the stdout flush the text was just written to.
364
- const e = new Error('help');
365
- e.exitCode = 0;
366
- throw e;
367
- }
368
- if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
369
- if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
370
- args[VALUED_BY_NAME[tok.name]] = tok.value;
371
- continue;
372
- }
373
- // `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
374
- // flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
375
- // keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
376
- // `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
377
- // given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
378
- if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
379
- args[FLAG_BY_NAME[tok.name]] = true;
380
- continue;
381
- }
382
- // Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
383
- // always treats as a terminator but this parser never did) — refuses with the literal text
384
- // typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
385
- die(2, `unrecognized arguments: ${argv[tok.index]}`);
386
- }
387
- const choices = choicesFor();
388
- for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
389
- // Only when the flag was actually PASSED is this a string; left at its default it is
390
- // already the array `configDefaults` built, and re-parsing an array would stringify it.
391
- if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
392
- if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
393
- return args;
394
- }
395
-
396
- /**
397
- * The generator's OWN flag surface, for the one consumer that is not this driver.
398
- *
399
- * `geneseed validate` takes `--theme/--emit/--out/--root/--footprint/-v` — the reference's
400
- * `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
401
- * hands the CLI this parser rather than a second one beside it. That is not tidiness: a
402
- * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
403
- * `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
404
- *
405
- * `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
406
- * they sit inside `main`'s funnel, and called from the CLI binary they would not.
407
- */
408
- export function parseDriverArgs(argv) {
409
- return withPlatformNewlines(() => parseArgs(argv, configDefaults()));
410
- }
411
-
412
- function choice(flag, value, allowed) {
413
- if (!allowed.includes(value)) {
414
- die(2, `argument ${flag}: invalid choice: '${value}' `
415
- + `(choose from ${allowed.map((c) => `'${c}'`).join(', ')})`);
416
- }
417
- }
418
-
419
- /**
420
- * argparse's error exit — a THROW since P5f, where it used to be `process.exit(code)`.
421
- *
422
- * `harness rebuild-all` re-emits every active install and its whole contract is "continue
423
- * past a failure so one broken install never blocks the rest". The Python gets that for free:
424
- * each rebuild is a SUBPROCESS, and a child that exits 2 hands back a return code. Here the
425
- * driver is a module in the same process, so `process.exit` would take the loop, the CLI and
426
- * every remaining install with it — the one place where importing rather than spawning is not
427
- * transparent, and it turns a per-install failure into a total one.
428
- *
429
- * The marker is `exitCode`, the SAME one `assertSourceComplete` and `effectiveTheme` already
430
- * use, and the first draft of this used a second name so that only `die` would be converted
431
- * — on the argument that an incomplete source should keep its stack. That draft was wrong,
432
- * and `rebuild-all/one-broken-install-does-not-stop-the-rest` is what said so: a
433
- * `.geneseed-theme` naming a theme that does not exist makes `effectiveTheme` refuse, and
434
- * with only `die` converted the refusal propagated out of the loop and the remaining
435
- * installs were never rebuilt. `main` is standing in for a PROCESS, and a process boundary
436
- * turns every deliberate refusal into an exit code — the narrower rule was a distinction
437
- * with no principle behind it.
438
- *
439
- * One behaviour improves as a side effect, and it is worth naming rather than discovering
440
- * later: `process.exit` terminates without flushing a pending stdout write, so a refusal on
441
- * a slow pipe could lose output it had already produced. Returning through `main` lets Node
442
- * drain normally.
443
- */
444
- function die(code, msg) {
445
- process.stderr.write(`geneseed: error: ${msg}\n`);
446
- const e = new Error(msg);
447
- e.exitCode = code;
448
- throw e;
449
- }
450
-
451
- /**
452
- * `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
453
- * P5b is NOT Python's.
454
- *
455
- * The Python original returns `sys.executable` and `<checkout>/rituals/harness.py`: the
456
- * interpreter running `build.py`, and the harness it will hand `%*` to. This driver returns
457
- * `process.execPath` and `<checkout>/bin/geneseed-hook.mjs`, because the four verbs the
458
- * emitted hooks invoke — context, git-gate, rule-gate, learn — are now Node, and an install
459
- * this driver emits therefore needs no Python at all for its hooks.
460
- *
461
- * WHAT THIS RETIRED, AND WHY IT IS A DELETION. Until this phase the function was
462
- * `hookOptsOrDie`: it scanned PATH for an interpreter and refused the four Claude-shaped
463
- * emits with exit 4 when it found none, because writing a shim that names a nonexistent
464
- * interpreter silently disables every hook in the install. Both halves are gone. `runner` is
465
- * `process.execPath` — the node already running this file, which by construction exists —
466
- * so there is nothing left to discover and no way for discovery to fail. P4e kept the
467
- * unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
468
- * partition it belongs to; nothing asserts a partition over this one, so keeping it would
469
- * be keeping code no test can reach for no stated reason.
470
- *
471
- * THE SHIM IS MACHINE-WIDE, AND THAT IS THE DECISION THIS PHASE ACTUALLY TOOK.
472
- * `hookShimPath()` is `$GENESEED_HOME`-or-`~/.geneseed` + `bin/geneseed-hook[.cmd]`, with no
473
- * per-install component: every emit of every install on the machine rewrites the same file,
474
- * and every install's hooks execute it. While both drivers baked Python that was invisible,
475
- * because last-writer-wins wrote the same thing. It is observable now — a machine whose last
476
- * emit ran through this file has EVERY install's hooks running under Node, including
477
- * installs the Python driver wrote, and the reverse.
478
- *
479
- * That is correct exactly while the two entry points answer the same verbs the same way, so
480
- * the gate that used to be a formality is now load-bearing:
481
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
482
- * and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
483
- * lacks is a dead hook on every install on the machine, not just this one. The alternative
484
- * considered and rejected was a per-driver shim path: the path is baked into every already
485
- * emitted hook command, so changing it makes every existing install's hooks stale until
486
- * re-emit, which is P10's migration arriving five phases early.
487
- *
488
- * EXPORTED SINCE P6i, because the emitter is no longer its only caller.
489
- * `js/maintain/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
490
- * Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
491
- * correctly. There is exactly one right answer on this side and it is this function, so the
492
- * second caller imports it rather than restating two lines that must never drift: a
493
- * reactivate whose shim path differed from the emitter's would wire hooks pointing at a file
494
- * the emitter never writes.
495
- */
496
- export function hookRunnerEntry() {
497
- return { runner: process.execPath, entry: path.join(ROOT, 'bin', 'geneseed-hook.mjs') };
498
- }
499
-
500
102
  /**
501
103
  * `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
502
104
  * to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
@@ -549,10 +151,10 @@ function warnBobGlobalOverProject() {
549
151
  * would be written into `opencode.json`'s instruction path.
550
152
  */
551
153
  function relUnder(out, root) {
552
- const rel = path.relative(root, out);
154
+ const rel = relPosix(root, out);
553
155
  if (rel === '' || rel === '.') return '';
554
156
  if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
555
- return rel.split(path.sep).join('/');
157
+ return rel;
556
158
  }
557
159
 
558
160
  /**
@@ -655,7 +257,7 @@ function projectSurvivors(emitName) {
655
257
  * RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
656
258
  * either side of them: reading the previous manifest (PRE), pruning what this layer no
657
259
  * longer owns, writing the manifest atomically, and the summary line. All five stages now
658
- * 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:
659
261
  * RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
660
262
  * settings file of its own).
661
263
  */
@@ -730,7 +332,7 @@ function emitOpencodeGlobal(cfg, args, out) {
730
332
  const rendered = emitOpencodeGlobalRender(
731
333
  { ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
732
334
  {
733
- theme: args.theme, cfgDir, out: out === null ? null : out,
335
+ theme: args.theme, cfgDir, out,
734
336
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively('opencode'),
735
337
  oldOwned, agentPath,
736
338
  });
@@ -780,10 +382,10 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
780
382
  // measured `null` instead of an assumed one.
781
383
  const rendered = emitClaudeRender(cfg, {
782
384
  theme: args.theme, cfgDir, claudeMd, scope, host,
783
- out: out === null ? null : out,
385
+ out,
784
386
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively(host),
785
387
  oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd, host),
786
- ...(hookOpts ? { hookOpts } : {}),
388
+ hookOpts,
787
389
  });
788
390
  const { owned, stats, memStatus, nbStatus, managed } = rendered;
789
391
 
@@ -828,123 +430,91 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
828
430
  }
829
431
 
830
432
  /**
831
- * `_build_global.emit_claude_global` — into Claude Code's global config dir (~/.claude).
832
- *
833
- * `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse:
834
- * the shape stayed after P5b deleted the refusal so that a future value which CAN fail is
835
- * decided while nothing has been written, rather than behind a half-rendered config dir.
836
- */
837
- function emitClaudeGlobal(cfg, args, out) {
838
- const cfgDir = args.cfgDir ?? claudeConfigDir();
839
- const hookOpts = hookRunnerEntry();
840
- const r = emitClaudeCore(cfg, args, {
841
- cfgDir, claudeMd: path.join(cfgDir, 'CLAUDE.md'), scope: 'global', host: 'claude', out,
842
- hookOpts,
843
- });
844
- // "Hooks call the harness by absolute path", not `harness.py` — and this one was already
845
- // WRONG on this side rather than merely about to be: P5b made this driver bake `<node>
846
- // bin/geneseed-hook.mjs` into the hooks it writes, so the sentence named a file its own
847
- // emit does not use. The two drivers bake different entries and printed the same claim;
848
- // the neutral wording is true of both, and stays true when only one is left. It is frozen
849
- // in the `claude-global` cells' recorded stdout, which is why it moves here rather than in
850
- // the deletion phase, which may not move a recorded byte.
851
- process.stdout.write(`[geneseed] claude-global -> ${cfgDir}: ${r.nAgents} subagents, `
852
- + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
853
- + `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
854
- + '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
855
- + 'GENESEED_HARNESS only to relocate memory.\n');
856
- return cfgDir;
857
- }
858
-
859
- /** `_build_global.emit_claude` — per-repo: CLAUDE.md at the root + a `.claude/` layer. */
860
- function emitClaude(cfg, args, out) {
861
- const root = args.root ? resolveOut(args.root) : out;
862
- const hookOpts = hookRunnerEntry();
863
- const r = emitClaudeCore(cfg, args, {
864
- cfgDir: path.join(root, '.claude'), claudeMd: path.join(root, 'CLAUDE.md'),
865
- scope: 'project', host: 'claude', out, hookOpts,
866
- });
867
- process.stdout.write(`[geneseed] claude (folder) -> ${root}: CLAUDE.md + .claude/ `
868
- + `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
869
- + `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
870
- }
871
-
872
- /**
873
- * OpenClaude global — `~/.openclaude` (or `$OPENCLAUDE_CONFIG_DIR`). The Claude emit with
874
- * another config dir: OpenClaude is a Claude Code fork that reads neither `~/.claude` nor
875
- * `CLAUDE_CONFIG_DIR`, so a Claude install is invisible to it and it needs its own.
433
+ * The six Claude-shaped emits — Claude, Bob and OpenClaude, each per-repo and global — as one
434
+ * body and a table. They differ only in where the config dir is, what the instruction carrier
435
+ * is called and where it sits, and the one summary line each prints; everything else is
436
+ * `emitClaudeCore`.
437
+ *
438
+ * `configDir` set = a GLOBAL emit: the target is `args.cfgDir` (the `--config-dir` / `diff`
439
+ * override) or the host's resolved dir, the carrier lives inside it, and the dir is returned
440
+ * for the caller to record. Unset = PER-REPO: the target is `<root>/<layer>`, and the carrier
441
+ * sits at the root unless `carrierInLayer` says otherwise.
442
+ *
443
+ * `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse: the
444
+ * shape stayed after P5b deleted the refusal so that a future value which CAN fail is decided
445
+ * while nothing has been written, rather than behind a half-rendered config dir. `before` runs
446
+ * after it and before the emit — Bob's global warning is about a state this emit is at the
447
+ * point of creating, so printing it afterwards would describe it as already chosen.
876
448
  */
877
- function emitOpenclaudeGlobal(cfg, args, out) {
878
- const cfgDir = args.cfgDir ?? openclaudeConfigDir();
879
- const hookOpts = hookRunnerEntry();
880
- const r = emitClaudeCore(cfg, args, {
881
- cfgDir, claudeMd: path.join(cfgDir, 'CLAUDE.md'), scope: 'global', host: 'openclaude', out,
882
- hookOpts,
883
- });
884
- process.stdout.write(`[geneseed] openclaude-global -> ${cfgDir}: ${r.nAgents} subagents, `
885
- + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
886
- + `${r.memStatus}, ${r.nbStatus}. MCP servers go in .openclaude.json.\n`);
887
- return cfgDir;
888
- }
889
-
890
- /**
891
- * OpenClaude per-repo — everything under `.openclaude/`, the preamble included. OpenClaude
892
- * reads the root CLAUDE.md only when the repo has no AGENTS.md, but `.openclaude/CLAUDE.md`
893
- * always; keeping the root untouched also lets a Claude Code install share the repo.
894
- */
895
- function emitOpenclaude(cfg, args, out) {
896
- const root = args.root ? resolveOut(args.root) : out;
897
- const cfgDir = path.join(root, '.openclaude');
898
- const hookOpts = hookRunnerEntry();
899
- const r = emitClaudeCore(cfg, args, {
900
- cfgDir, claudeMd: path.join(cfgDir, 'CLAUDE.md'), scope: 'project', host: 'openclaude', out,
901
- hookOpts,
902
- });
903
- process.stdout.write(`[geneseed] openclaude (folder) -> ${root}: .openclaude/ `
904
- + `(CLAUDE.md, ${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
905
- + `settings.local.json), ${r.memStatus}, ${r.nbStatus}.\n`);
906
- }
907
-
908
- /**
909
- * `_build_global.emit_bob_global` — into Bob's global config dir (~/.bob).
910
- *
911
- * The warning fires BEFORE the emit, matching the Python order: it is about a state this
912
- * emit is at the point of creating, so printing it afterwards would describe the situation
913
- * as though the operator had already chosen it.
914
- */
915
- function emitBobGlobal(cfg, args, out) {
916
- const cfgDir = args.cfgDir ?? bobConfigDir();
917
- const hookOpts = hookRunnerEntry();
918
- warnBobGlobalOverProject();
919
- const r = emitClaudeCore(cfg, args, {
920
- cfgDir, claudeMd: path.join(cfgDir, 'AGENTS.md'), scope: 'global', host: 'bob', out,
921
- hookOpts,
922
- });
923
- process.stdout.write(`[geneseed] bob-global -> ${cfgDir}: ${r.nAgents} subagents, `
924
- + `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
925
- + 'AGENTS.md is not auto-loaded, so none is written), '
926
- + `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`);
927
- return cfgDir;
928
- }
449
+ const CLAUDE_SHAPED = {
450
+ 'claude-global': {
451
+ host: 'claude', configDir: claudeConfigDir, carrier: 'CLAUDE.md',
452
+ // "Hooks call the harness by absolute path": the hooks bake `<node> bin/geneseed-hook.mjs`.
453
+ summary: (at, r) => `[geneseed] claude-global -> ${at}: ${r.nAgents} subagents, `
454
+ + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
455
+ + `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
456
+ + '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
457
+ + 'GENESEED_HARNESS only to relocate memory.\n',
458
+ },
459
+ // Per-repo: CLAUDE.md at the root + a `.claude/` layer.
460
+ claude: {
461
+ host: 'claude', layer: '.claude', carrier: 'CLAUDE.md',
462
+ summary: (at, r) => `[geneseed] claude (folder) -> ${at}: CLAUDE.md + .claude/ `
463
+ + `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
464
+ + `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`,
465
+ },
466
+ // `~/.openclaude` (or `$OPENCLAUDE_CONFIG_DIR`). OpenClaude is a Claude Code fork that reads
467
+ // neither `~/.claude` nor `CLAUDE_CONFIG_DIR`, so a Claude install is invisible to it.
468
+ 'openclaude-global': {
469
+ host: 'openclaude', configDir: openclaudeConfigDir, carrier: 'CLAUDE.md',
470
+ summary: (at, r) => `[geneseed] openclaude-global -> ${at}: ${r.nAgents} subagents, `
471
+ + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
472
+ + `${r.memStatus}, ${r.nbStatus}. MCP servers go in .openclaude.json.\n`,
473
+ },
474
+ // Everything under `.openclaude/`, the preamble included. OpenClaude reads the root CLAUDE.md
475
+ // only when the repo has no AGENTS.md, but `.openclaude/CLAUDE.md` always; keeping the root
476
+ // untouched also lets a Claude Code install share the repo.
477
+ openclaude: {
478
+ host: 'openclaude', layer: '.openclaude', carrier: 'CLAUDE.md', carrierInLayer: true,
479
+ summary: (at, r) => `[geneseed] openclaude (folder) -> ${at}: .openclaude/ `
480
+ + `(CLAUDE.md, ${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
481
+ + `settings.local.json), ${r.memStatus}, ${r.nbStatus}.\n`,
482
+ },
483
+ 'bob-global': {
484
+ host: 'bob', configDir: bobConfigDir, carrier: 'AGENTS.md', before: warnBobGlobalOverProject,
485
+ summary: (at, r) => `[geneseed] bob-global -> ${at}: ${r.nAgents} subagents, `
486
+ + `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
487
+ + 'AGENTS.md is not auto-loaded, so none is written), '
488
+ + `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`,
489
+ },
490
+ // Per-repo: AGENTS.md at the root + a `.bob/` layer.
491
+ bob: {
492
+ host: 'bob', layer: '.bob', carrier: 'AGENTS.md',
493
+ summary: (at, r) => `[geneseed] bob (folder) -> ${at}: AGENTS.md + .bob/ `
494
+ + `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
495
+ + `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`,
496
+ },
497
+ };
929
498
 
930
- /** `_build_global.emit_bob` — per-repo: AGENTS.md at the root + a `.bob/` layer. */
931
- function emitBob(cfg, args, out) {
932
- const root = args.root ? resolveOut(args.root) : out;
499
+ /** One row of `CLAUDE_SHAPED` as an emit — the `(cfg, args, out)` shape every dispatch takes. */
500
+ const claudeShaped = (name) => (cfg, args, out) => {
501
+ const { host, configDir, layer, carrier, carrierInLayer, before, summary } = CLAUDE_SHAPED[name];
502
+ const root = configDir ? null : (args.root ? resolveOut(args.root) : out);
503
+ const cfgDir = configDir ? (args.cfgDir ?? configDir()) : path.join(root, layer);
504
+ const claudeMd = path.join(configDir || carrierInLayer ? cfgDir : root, carrier);
933
505
  const hookOpts = hookRunnerEntry();
506
+ before?.();
934
507
  const r = emitClaudeCore(cfg, args, {
935
- cfgDir: path.join(root, '.bob'), claudeMd: path.join(root, 'AGENTS.md'),
936
- scope: 'project', host: 'bob', out, hookOpts,
508
+ cfgDir, claudeMd, scope: configDir ? 'global' : 'project', host, out, hookOpts,
937
509
  });
938
- process.stdout.write(`[geneseed] bob (folder) -> ${root}: AGENTS.md + .bob/ `
939
- + `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
940
- + `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
941
- }
510
+ process.stdout.write(summary(root ?? cfgDir, r));
511
+ return configDir ? cfgDir : undefined;
512
+ };
942
513
 
943
514
  /**
944
515
  * build.py:437-466 — the POST stage, which writes markers and records the install and
945
- * wires NOTHING. `test_the_post_emit_stage_wires_nothing` classifies that stage on the
946
- * Python side; this is the same stage on this side, and it stays after the dispatch for
947
- * 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.
948
518
  *
949
519
  * `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
950
520
  * `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
@@ -984,9 +554,9 @@ function writeMarkers(markerDir, emit, footprint) {
984
554
  */
985
555
  const GLOBAL_EMITS = {
986
556
  opencode: emitOpencodeGlobal,
987
- claude: emitClaudeGlobal,
988
- bob: emitBobGlobal,
989
- openclaude: emitOpenclaudeGlobal,
557
+ claude: claudeShaped('claude-global'),
558
+ bob: claudeShaped('bob-global'),
559
+ openclaude: claudeShaped('openclaude-global'),
990
560
  };
991
561
 
992
562
  /**
@@ -1008,21 +578,43 @@ const GLOBAL_EMITS = {
1008
578
  * three-positional call leaves in place, inherited here rather than re-decided.
1009
579
  */
1010
580
  const PROJECT_EMITS = {
1011
- claude: emitClaude, bob: emitBob, openclaude: emitOpenclaude,
581
+ claude: claudeShaped('claude'), bob: claudeShaped('bob'), openclaude: claudeShaped('openclaude'),
1012
582
  // P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
1013
583
  // `--emit` choice into its sandbox and not only the three doctor already scans. Its call
1014
- // shape is `emitClaude`'s exactly — `(cfg, args, out)` — so the row is the whole change.
584
+ // shape is the Claude-shaped emits' exactly — `(cfg, args, out)` — so the row is the whole change.
1015
585
  opencode: emitOpencode,
1016
586
  };
1017
587
 
1018
- export function emitProjectInto(host, { theme, out, root, footprint = 'full', doctrines = null }) {
588
+ /**
589
+ * The five render axes every `*Into` takes, folded into one `cfg` — the drift readers pass what
590
+ * the deployment says, `validate` passes its flags.
591
+ *
592
+ * EACH IS OMITTED WHEN NULL, never passed through, because `null` is "no opinion" and only
593
+ * `makeCfg`'s own defaults are the fail-closed answers: `peer`/`direct`/the default preset, ALL
594
+ * packs (`doctrinesOfDir` answers `null` for "no marker", which must not render as `[]`), and
595
+ * NO exclusions (a list of rules taken away defaults to taking nothing away). The posture and
596
+ * mode readers return `null` for "undetectable" too, which an explicit `posture: null` would
597
+ * carry straight past the parameter default.
598
+ */
599
+ function axesCfg({ posture, mode, trust, doctrines, excludeRules }) {
600
+ return makeCfg({
601
+ ...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
602
+ ...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
603
+ });
604
+ }
605
+
606
+ export function emitProjectInto(host, {
607
+ theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
608
+ doctrines = null, excludeRules = null,
609
+ }) {
1019
610
  const emit = PROJECT_EMITS[host];
1020
- // `doctrines` rides the same rail posture and mode ride into `emitGlobalInto`, and for the
1021
- // same reason: a drift reader that renders `expected` at the DEFAULT pack set reports the
1022
- // whole doctrines section as edited on every narrowed install. `null` means "no opinion" and
1023
- // `makeCfg` renders all four, which is what every validation caller wants.
1024
- return withPlatformNewlines(() => emit(makeCfg(doctrines ? { doctrines } : {}),
1025
- { theme, footprint, root }, out));
611
+ // The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
612
+ // that renders `expected` at the DEFAULT pack set reports the whole doctrines section as
613
+ // edited on every narrowed install, and `validate --doctrines craft` that ignored its flag
614
+ // checked an all-packs render nobody asked for.
615
+ return withPlatformNewlines(() => emit(
616
+ axesCfg({ posture, mode, trust, doctrines, excludeRules }), { theme, footprint, root }, out,
617
+ ));
1026
618
  }
1027
619
 
1028
620
  /**
@@ -1033,14 +625,17 @@ export function emitProjectInto(host, { theme, out, root, footprint = 'full', do
1033
625
  * `nativeCatalog: false` is that three-positional call's signature default, inherited here
1034
626
  * exactly as `run` inherits it below.
1035
627
  */
1036
- export function buildInto({ theme, out, footprint = 'lean' }) {
1037
- return withPlatformNewlines(
1038
- () => build(makeCfg(), theme, out, { footprint, nativeCatalog: false }),
1039
- );
628
+ export function buildInto({
629
+ theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
630
+ excludeRules = null,
631
+ }) {
632
+ return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules }),
633
+ theme, out, { footprint, nativeCatalog: false }));
1040
634
  }
1041
635
 
1042
636
  export function emitGlobalInto(host, {
1043
637
  theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
638
+ excludeRules = null,
1044
639
  }) {
1045
640
  // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
1046
641
  // raising, because the host comes from a marker file a user can edit.
@@ -1050,25 +645,15 @@ export function emitGlobalInto(host, {
1050
645
  // emit's STDOUT and lets its stderr through (the Python's `redirect_stdout` does exactly
1051
646
  // that), so an untranslated WARN from `warnBobGlobalOverProject` would reach the user's
1052
647
  // terminal with the wrong bytes on the one stream the caller deliberately does not hide.
1053
- // POSTURE AND MODE TRAVEL WITH THE RENDER, and the `|| default` is load-bearing:
1054
- // `postureOfDir` returns null for "no install / undetectable", which `makeCfg`'s parameter
1055
- // default would NOT catch. Callers that pass nothing keep the reference's behaviour of
1056
- // rendering at `peer`/`direct`; the drift readers pass what the deployment actually says,
1057
- // because rendering `expected` at the defaults reports AGENT.md as edited on every install
1058
- // that chose a register — the same scar the footprint left in `diffCollect`, one axis over.
1059
- return withPlatformNewlines(
1060
- () => emit(
1061
- // THE PACK SELECTION RIDES THE SAME RAIL, with one difference: `doctrinesOfDir` answers
1062
- // `null` for "no marker / undetectable", and that must render at ALL packs rather than
1063
- // at `[]`, so the key is OMITTED instead of passed through — `makeCfg`'s own default is
1064
- // the fail-closed answer and an explicit `doctrines: null` would not reach it.
1065
- makeCfg({
1066
- posture: posture || 'peer', mode: mode || 'direct', trust: trust || DEFAULT_PRESET,
1067
- ...(doctrines ? { doctrines } : {}),
1068
- }),
1069
- { theme, footprint, root: null, cfgDir }, out,
1070
- ),
1071
- );
648
+ // ALL FIVE AXES TRAVEL WITH THE RENDER (see `axesCfg`). Callers that pass nothing keep the
649
+ // reference's behaviour of rendering at `peer`/`direct`; the drift readers pass what the
650
+ // deployment actually says, because rendering `expected` at the defaults reports AGENT.md as
651
+ // edited on every install that chose a register, narrowed its packs or excluded a rule —
652
+ // the same scar the footprint left in `diffCollect`, one axis over each time.
653
+ return withPlatformNewlines(() => emit(
654
+ axesCfg({ posture, mode, trust, doctrines, excludeRules }),
655
+ { theme, footprint, root: null, cfgDir }, out,
656
+ ));
1072
657
  }
1073
658
 
1074
659
  /**
@@ -1113,8 +698,8 @@ function run(argv) {
1113
698
  if (args.syncThemes) return syncThemes() ? 1 : 0;
1114
699
  // `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
1115
700
  // `js/inspect/checks-authoring.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
1116
- // is under a transitive ban on reaching any such module, gated twice — by a source grep in
1117
- // `tests/test_node_cli_parity.py` and by an import walk in `tests/test_hook_cli_parity.py`.
701
+ // is under a transitive ban on reaching any such module, gated by an import walk in
702
+ // `tests/unit/hook_cli.test.mjs` ("the generator driver still reaches no child-process module").
1118
703
  // So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
1119
704
  // points at it rather than silently building for real into the caller's `--out`.
1120
705
  if (args.validateOnly) {