geneseed 3.13.0 → 3.14.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 (93) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-learn.js +10 -1
  4. package/bin/geneseed-cli.mjs +10 -14
  5. package/bin/geneseed-hook.mjs +22 -15
  6. package/docs/extending.md +5 -5
  7. package/harness.config.json +1 -1
  8. package/js/README.md +19 -10
  9. package/js/build/args.mjs +374 -0
  10. package/js/build/driver.mjs +137 -553
  11. package/js/build/emit-claude.mjs +2 -4
  12. package/js/build/generate.mjs +6 -3
  13. package/js/build/render.mjs +5 -9
  14. package/js/build/source.mjs +3 -11
  15. package/js/hosts/hooks-context.mjs +431 -0
  16. package/js/hosts/hooks-learn.mjs +275 -0
  17. package/js/hosts/hooks-prims.mjs +108 -0
  18. package/js/hosts/hooks.mjs +50 -977
  19. package/js/hosts/hosts.mjs +12 -4
  20. package/js/hosts/installs.mjs +5 -6
  21. package/js/hosts/mcp.mjs +12 -48
  22. package/js/hosts/memory-files.mjs +181 -0
  23. package/js/hosts/settings.mjs +135 -414
  24. package/js/hosts/shim.mjs +373 -0
  25. package/js/inspect/checks-authoring.mjs +21 -828
  26. package/js/inspect/checks-build.mjs +4 -4
  27. package/js/inspect/checks-constitution.mjs +493 -0
  28. package/js/inspect/checks-counts.mjs +242 -0
  29. package/js/inspect/checks-repo.mjs +17 -8
  30. package/js/inspect/diff.mjs +4 -1
  31. package/js/inspect/doctor.mjs +2 -2
  32. package/js/inspect/inventory.mjs +4 -4
  33. package/js/inspect/scan.mjs +1 -1
  34. package/js/inspect/status.mjs +1 -1
  35. package/js/inspect/validate.mjs +48 -35
  36. package/js/loop/catalog.mjs +5 -3
  37. package/js/loop/cli.mjs +26 -12
  38. package/js/loop/graph.mjs +1 -1
  39. package/js/loop/registry.mjs +10 -15
  40. package/js/loop/score.mjs +1 -1
  41. package/js/loop/state.mjs +35 -11
  42. package/js/maintain/memory.mjs +1 -1
  43. package/js/maintain/migrate.mjs +18 -30
  44. package/js/maintain/uninstall.mjs +47 -88
  45. package/js/ui/cli.mjs +12 -12
  46. package/js/web/actions.mjs +29 -19
  47. package/js/web/api.mjs +21 -21
  48. package/js/web/docs.mjs +29 -72
  49. package/package.json +1 -1
  50. package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
  51. package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
  52. package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
  53. package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
  54. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  55. package/web/dist/assets/GreenhouseView-6KDjX8da.js +2 -0
  56. package/web/dist/assets/Installs-MWuhNjSm.js +2 -0
  57. package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
  58. package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
  59. package/web/dist/assets/Library-DOkO0TtB.js +1 -0
  60. package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
  61. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  62. package/web/dist/assets/OperatorHudView-BPP1dtLz.js +2 -0
  63. package/web/dist/assets/Personal-26VPYkK0.js +1 -0
  64. package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
  65. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  66. package/web/dist/assets/{drawable-C_x5xncq.js → drawable-zVVDbsyw.js} +1 -1
  67. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  68. package/web/dist/assets/index-KHt3wE_K.js +2 -0
  69. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  70. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  71. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  72. package/web/dist/index.html +4 -5
  73. package/web/src/pages/Laws.jsx +0 -1
  74. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  75. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  76. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  77. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  78. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  79. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  80. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  81. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  82. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  83. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  84. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  85. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  86. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  87. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  88. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  89. package/web/dist/assets/index-CMPgezFm.js +0 -2
  90. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  91. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  92. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  93. 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,11 +35,11 @@ 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';
49
45
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
@@ -58,25 +54,22 @@ import {
58
54
  // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
59
55
  // same cfg. golden.py's 259 cells all build one, which is what made the move safe.
60
56
  import {
61
- ROOT, CONFIG, discoverNames, makeCfg, PACK_ORDER, knownRuleIds,
57
+ ROOT, makeCfg,
62
58
  } from './source.mjs';
63
59
 
64
60
  // P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
65
61
  // registry to find every install it must re-emit, and a CLI verb may not reach into a driver
66
62
  // for a reader. See js/inspect/registry.mjs.
67
63
  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
64
 
72
65
  // P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
73
66
  // of textual surgery over committed files with a corpus of its own, and it is the half of the
74
67
  // maintainer pair that needs nothing this driver is banned from having.
75
68
  import { syncThemes } from './themes.mjs';
69
+ import { configDefaults, die, parseArgs } from './args.mjs';
70
+
71
+ export { parseDriverArgs } from './args.mjs';
76
72
 
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
73
 
81
74
  /** `_build_emit.PRIMARY_AGENT_SRC`. */
82
75
  const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
@@ -104,399 +97,6 @@ export function resolveOut(raw) {
104
97
  return resolvePath(path.resolve(process.cwd(), raw));
105
98
  }
106
99
 
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
100
  /**
501
101
  * `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
502
102
  * to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
@@ -730,7 +330,7 @@ function emitOpencodeGlobal(cfg, args, out) {
730
330
  const rendered = emitOpencodeGlobalRender(
731
331
  { ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
732
332
  {
733
- theme: args.theme, cfgDir, out: out === null ? null : out,
333
+ theme: args.theme, cfgDir, out,
734
334
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively('opencode'),
735
335
  oldOwned, agentPath,
736
336
  });
@@ -780,10 +380,10 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
780
380
  // measured `null` instead of an assumed one.
781
381
  const rendered = emitClaudeRender(cfg, {
782
382
  theme: args.theme, cfgDir, claudeMd, scope, host,
783
- out: out === null ? null : out,
383
+ out,
784
384
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively(host),
785
385
  oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd, host),
786
- ...(hookOpts ? { hookOpts } : {}),
386
+ hookOpts,
787
387
  });
788
388
  const { owned, stats, memStatus, nbStatus, managed } = rendered;
789
389
 
@@ -828,117 +428,86 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
828
428
  }
829
429
 
830
430
  /**
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.
431
+ * The six Claude-shaped emits — Claude, Bob and OpenClaude, each per-repo and global — as one
432
+ * body and a table. They differ only in where the config dir is, what the instruction carrier
433
+ * is called and where it sits, and the one summary line each prints; everything else is
434
+ * `emitClaudeCore`.
435
+ *
436
+ * `configDir` set = a GLOBAL emit: the target is `args.cfgDir` (the `--config-dir` / `diff`
437
+ * override) or the host's resolved dir, the carrier lives inside it, and the dir is returned
438
+ * for the caller to record. Unset = PER-REPO: the target is `<root>/<layer>`, and the carrier
439
+ * sits at the root unless `carrierInLayer` says otherwise.
440
+ *
441
+ * `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse: the
442
+ * shape stayed after P5b deleted the refusal so that a future value which CAN fail is decided
443
+ * while nothing has been written, rather than behind a half-rendered config dir. `before` runs
444
+ * after it and before the emit — Bob's global warning is about a state this emit is at the
445
+ * point of creating, so printing it afterwards would describe it as already chosen.
876
446
  */
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
- }
447
+ const CLAUDE_SHAPED = {
448
+ 'claude-global': {
449
+ host: 'claude', configDir: claudeConfigDir, carrier: 'CLAUDE.md',
450
+ // "Hooks call the harness by absolute path": the hooks bake `<node> bin/geneseed-hook.mjs`.
451
+ summary: (at, r) => `[geneseed] claude-global -> ${at}: ${r.nAgents} subagents, `
452
+ + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
453
+ + `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
454
+ + '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
455
+ + 'GENESEED_HARNESS only to relocate memory.\n',
456
+ },
457
+ // Per-repo: CLAUDE.md at the root + a `.claude/` layer.
458
+ claude: {
459
+ host: 'claude', layer: '.claude', carrier: 'CLAUDE.md',
460
+ summary: (at, r) => `[geneseed] claude (folder) -> ${at}: CLAUDE.md + .claude/ `
461
+ + `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
462
+ + `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`,
463
+ },
464
+ // `~/.openclaude` (or `$OPENCLAUDE_CONFIG_DIR`). OpenClaude is a Claude Code fork that reads
465
+ // neither `~/.claude` nor `CLAUDE_CONFIG_DIR`, so a Claude install is invisible to it.
466
+ 'openclaude-global': {
467
+ host: 'openclaude', configDir: openclaudeConfigDir, carrier: 'CLAUDE.md',
468
+ summary: (at, r) => `[geneseed] openclaude-global -> ${at}: ${r.nAgents} subagents, `
469
+ + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
470
+ + `${r.memStatus}, ${r.nbStatus}. MCP servers go in .openclaude.json.\n`,
471
+ },
472
+ // Everything under `.openclaude/`, the preamble included. OpenClaude reads the root CLAUDE.md
473
+ // only when the repo has no AGENTS.md, but `.openclaude/CLAUDE.md` always; keeping the root
474
+ // untouched also lets a Claude Code install share the repo.
475
+ openclaude: {
476
+ host: 'openclaude', layer: '.openclaude', carrier: 'CLAUDE.md', carrierInLayer: true,
477
+ summary: (at, r) => `[geneseed] openclaude (folder) -> ${at}: .openclaude/ `
478
+ + `(CLAUDE.md, ${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
479
+ + `settings.local.json), ${r.memStatus}, ${r.nbStatus}.\n`,
480
+ },
481
+ 'bob-global': {
482
+ host: 'bob', configDir: bobConfigDir, carrier: 'AGENTS.md', before: warnBobGlobalOverProject,
483
+ summary: (at, r) => `[geneseed] bob-global -> ${at}: ${r.nAgents} subagents, `
484
+ + `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
485
+ + 'AGENTS.md is not auto-loaded, so none is written), '
486
+ + `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`,
487
+ },
488
+ // Per-repo: AGENTS.md at the root + a `.bob/` layer.
489
+ bob: {
490
+ host: 'bob', layer: '.bob', carrier: 'AGENTS.md',
491
+ summary: (at, r) => `[geneseed] bob (folder) -> ${at}: AGENTS.md + .bob/ `
492
+ + `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
493
+ + `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`,
494
+ },
495
+ };
929
496
 
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;
497
+ /** One row of `CLAUDE_SHAPED` as an emit — the `(cfg, args, out)` shape every dispatch takes. */
498
+ const claudeShaped = (name) => (cfg, args, out) => {
499
+ const { host, configDir, layer, carrier, carrierInLayer, before, summary } = CLAUDE_SHAPED[name];
500
+ const root = configDir ? null : (args.root ? resolveOut(args.root) : out);
501
+ const cfgDir = configDir ? (args.cfgDir ?? configDir()) : path.join(root, layer);
502
+ const claudeMd = path.join(configDir || carrierInLayer ? cfgDir : root, carrier);
933
503
  const hookOpts = hookRunnerEntry();
504
+ before?.();
934
505
  const r = emitClaudeCore(cfg, args, {
935
- cfgDir: path.join(root, '.bob'), claudeMd: path.join(root, 'AGENTS.md'),
936
- scope: 'project', host: 'bob', out, hookOpts,
506
+ cfgDir, claudeMd, scope: configDir ? 'global' : 'project', host, out, hookOpts,
937
507
  });
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
- }
508
+ process.stdout.write(summary(root ?? cfgDir, r));
509
+ return configDir ? cfgDir : undefined;
510
+ };
942
511
 
943
512
  /**
944
513
  * build.py:437-466 — the POST stage, which writes markers and records the install and
@@ -984,9 +553,9 @@ function writeMarkers(markerDir, emit, footprint) {
984
553
  */
985
554
  const GLOBAL_EMITS = {
986
555
  opencode: emitOpencodeGlobal,
987
- claude: emitClaudeGlobal,
988
- bob: emitBobGlobal,
989
- openclaude: emitOpenclaudeGlobal,
556
+ claude: claudeShaped('claude-global'),
557
+ bob: claudeShaped('bob-global'),
558
+ openclaude: claudeShaped('openclaude-global'),
990
559
  };
991
560
 
992
561
  /**
@@ -1008,21 +577,43 @@ const GLOBAL_EMITS = {
1008
577
  * three-positional call leaves in place, inherited here rather than re-decided.
1009
578
  */
1010
579
  const PROJECT_EMITS = {
1011
- claude: emitClaude, bob: emitBob, openclaude: emitOpenclaude,
580
+ claude: claudeShaped('claude'), bob: claudeShaped('bob'), openclaude: claudeShaped('openclaude'),
1012
581
  // P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
1013
582
  // `--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.
583
+ // shape is the Claude-shaped emits' exactly — `(cfg, args, out)` — so the row is the whole change.
1015
584
  opencode: emitOpencode,
1016
585
  };
1017
586
 
1018
- export function emitProjectInto(host, { theme, out, root, footprint = 'full', doctrines = null }) {
587
+ /**
588
+ * The five render axes every `*Into` takes, folded into one `cfg` — the drift readers pass what
589
+ * the deployment says, `validate` passes its flags.
590
+ *
591
+ * EACH IS OMITTED WHEN NULL, never passed through, because `null` is "no opinion" and only
592
+ * `makeCfg`'s own defaults are the fail-closed answers: `peer`/`direct`/the default preset, ALL
593
+ * packs (`doctrinesOfDir` answers `null` for "no marker", which must not render as `[]`), and
594
+ * NO exclusions (a list of rules taken away defaults to taking nothing away). The posture and
595
+ * mode readers return `null` for "undetectable" too, which an explicit `posture: null` would
596
+ * carry straight past the parameter default.
597
+ */
598
+ function axesCfg({ posture, mode, trust, doctrines, excludeRules }) {
599
+ return makeCfg({
600
+ ...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
601
+ ...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
602
+ });
603
+ }
604
+
605
+ export function emitProjectInto(host, {
606
+ theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
607
+ doctrines = null, excludeRules = null,
608
+ }) {
1019
609
  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));
610
+ // The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
611
+ // that renders `expected` at the DEFAULT pack set reports the whole doctrines section as
612
+ // edited on every narrowed install, and `validate --doctrines craft` that ignored its flag
613
+ // checked an all-packs render nobody asked for.
614
+ return withPlatformNewlines(() => emit(
615
+ axesCfg({ posture, mode, trust, doctrines, excludeRules }), { theme, footprint, root }, out,
616
+ ));
1026
617
  }
1027
618
 
1028
619
  /**
@@ -1033,14 +624,17 @@ export function emitProjectInto(host, { theme, out, root, footprint = 'full', do
1033
624
  * `nativeCatalog: false` is that three-positional call's signature default, inherited here
1034
625
  * exactly as `run` inherits it below.
1035
626
  */
1036
- export function buildInto({ theme, out, footprint = 'lean' }) {
1037
- return withPlatformNewlines(
1038
- () => build(makeCfg(), theme, out, { footprint, nativeCatalog: false }),
1039
- );
627
+ export function buildInto({
628
+ theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
629
+ excludeRules = null,
630
+ }) {
631
+ return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules }),
632
+ theme, out, { footprint, nativeCatalog: false }));
1040
633
  }
1041
634
 
1042
635
  export function emitGlobalInto(host, {
1043
636
  theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
637
+ excludeRules = null,
1044
638
  }) {
1045
639
  // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
1046
640
  // raising, because the host comes from a marker file a user can edit.
@@ -1050,25 +644,15 @@ export function emitGlobalInto(host, {
1050
644
  // emit's STDOUT and lets its stderr through (the Python's `redirect_stdout` does exactly
1051
645
  // that), so an untranslated WARN from `warnBobGlobalOverProject` would reach the user's
1052
646
  // 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
- );
647
+ // ALL FIVE AXES TRAVEL WITH THE RENDER (see `axesCfg`). Callers that pass nothing keep the
648
+ // reference's behaviour of rendering at `peer`/`direct`; the drift readers pass what the
649
+ // deployment actually says, because rendering `expected` at the defaults reports AGENT.md as
650
+ // edited on every install that chose a register, narrowed its packs or excluded a rule —
651
+ // the same scar the footprint left in `diffCollect`, one axis over each time.
652
+ return withPlatformNewlines(() => emit(
653
+ axesCfg({ posture, mode, trust, doctrines, excludeRules }),
654
+ { theme, footprint, root: null, cfgDir }, out,
655
+ ));
1072
656
  }
1073
657
 
1074
658
  /**
@@ -1113,8 +697,8 @@ function run(argv) {
1113
697
  if (args.syncThemes) return syncThemes() ? 1 : 0;
1114
698
  // `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
1115
699
  // `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`.
700
+ // is under a transitive ban on reaching any such module, gated by an import walk in
701
+ // `tests/unit/hook_cli.test.mjs` ("the generator driver still reaches no child-process module").
1118
702
  // So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
1119
703
  // points at it rather than silently building for real into the caller's `--out`.
1120
704
  if (args.validateOnly) {