geneseed 2.0.0 → 3.0.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 (196) hide show
  1. package/CHANGELOG.md +217 -5
  2. package/DESIGN.md +149 -1326
  3. package/QUICKSTART.md +1 -1
  4. package/README.md +30 -22
  5. package/SETUP.md +98 -24
  6. package/SHIPPED.md +6 -5
  7. package/adapters/bob/README.md +3 -3
  8. package/adapters/claude-code/README.md +6 -4
  9. package/adapters/claude-code/skills/geneseed/SKILL.md +10 -6
  10. package/adapters/copilot/README.md +5 -4
  11. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +11 -6
  12. package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
  13. package/adapters/opencode/README.md +15 -9
  14. package/adapters/opencode/agents/orchestrator.md +1 -1
  15. package/adapters/opencode/opencode.json +0 -9
  16. package/adapters/opencode/plugins/geneseed-context.js +7 -7
  17. package/adapters/opencode/plugins/geneseed-guard.js +6 -6
  18. package/adapters/opencode/workflows/dispatch.js +1 -1
  19. package/bin/geneseed.mjs +74 -8
  20. package/docs/declined.md +53 -2
  21. package/docs/design-history.md +1290 -0
  22. package/docs/extending.md +672 -0
  23. package/docs/limits.md +141 -82
  24. package/docs/token-footprint.md +15 -4
  25. package/docs/web/agents.md +1 -1
  26. package/docs/web/authoring.md +1 -1
  27. package/docs/web/claude-hooks.md +2 -2
  28. package/docs/web/collaboration.md +3 -3
  29. package/docs/web/first-session.md +2 -2
  30. package/docs/web/footprint.md +14 -7
  31. package/docs/web/foreman-mode.md +2 -2
  32. package/docs/web/harnesses.md +5 -3
  33. package/docs/web/install-quick.md +1 -1
  34. package/docs/web/model.md +3 -3
  35. package/docs/web/plugin-context.md +2 -2
  36. package/docs/web/plugin-guard.md +3 -3
  37. package/docs/web/plugin-ponytail.md +1 -1
  38. package/docs/web/rules.md +23 -3
  39. package/docs/web/setup-choices.md +20 -7
  40. package/docs/web/themes-author.md +13 -1
  41. package/docs/web-ui.md +36 -12
  42. package/geneseed +0 -0
  43. package/harness.config.json +1 -1
  44. package/js/catalog.mjs +29 -4
  45. package/js/checkout.mjs +24 -1
  46. package/js/cli-table.json +9 -7
  47. package/js/diff.mjs +8 -3
  48. package/js/doctor.mjs +374 -26
  49. package/js/emit.mjs +48 -11
  50. package/js/generate.mjs +39 -5
  51. package/js/hooks.mjs +8 -7
  52. package/js/installs.mjs +101 -4
  53. package/js/inventory.mjs +150 -20
  54. package/js/lib/fs.mjs +6 -2
  55. package/js/mcp.mjs +3 -15
  56. package/js/migrate.mjs +10 -4
  57. package/js/render.mjs +116 -11
  58. package/js/settings.mjs +297 -28
  59. package/js/setup.mjs +95 -5
  60. package/js/status.mjs +45 -4
  61. package/js/themes.mjs +1 -1
  62. package/js/tui.mjs +46 -3
  63. package/js/uninstall.mjs +30 -11
  64. package/js/web/actions.mjs +76 -9
  65. package/js/web/api.mjs +95 -16
  66. package/js/web/docs.mjs +33 -5
  67. package/js/web/jobs.mjs +12 -3
  68. package/js/web/server.mjs +59 -2
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +75 -65
  71. package/src/agents/_template.md +4 -4
  72. package/src/agents/advocate.md +4 -4
  73. package/src/agents/architect.md +2 -2
  74. package/src/agents/developer.md +5 -5
  75. package/src/agents/docs.md +2 -2
  76. package/src/agents/empiricist.md +3 -3
  77. package/src/agents/explorer.md +3 -3
  78. package/src/agents/framer.md +3 -3
  79. package/src/agents/historian.md +3 -3
  80. package/src/agents/operator.md +3 -3
  81. package/src/agents/pragmatist.md +3 -3
  82. package/src/agents/reviewer.md +2 -2
  83. package/src/agents/security.md +2 -2
  84. package/src/agents/skeptic.md +4 -4
  85. package/src/agents/steward.md +3 -3
  86. package/src/agents/tester.md +2 -2
  87. package/src/agents/user-advocate.md +3 -3
  88. package/src/agents/visionary.md +3 -3
  89. package/src/doctrines/README.md +41 -0
  90. package/src/doctrines/craft.md +51 -0
  91. package/src/doctrines/ops.md +61 -0
  92. package/src/doctrines/process.md +87 -0
  93. package/src/doctrines/rigor.md +52 -0
  94. package/src/laws/universal.md +17 -301
  95. package/src/memory/README.md +2 -2
  96. package/src/modes/README.md +2 -2
  97. package/src/modes/foreman.md +1 -1
  98. package/src/notebook/README.md +2 -2
  99. package/src/ontology/universal.md +89 -0
  100. package/src/postures/README.md +3 -3
  101. package/src/postures/artisan.md +3 -3
  102. package/src/postures/assistant.md +1 -1
  103. package/src/postures/expert.md +1 -1
  104. package/src/postures/mentor.md +1 -1
  105. package/src/postures/peer.md +2 -2
  106. package/src/skills/_template.md +2 -2
  107. package/src/skills/brainstorm.md +2 -2
  108. package/src/skills/clarify.md +2 -2
  109. package/src/skills/codebase-design.md +1 -1
  110. package/src/skills/commit.md +5 -5
  111. package/src/skills/council.md +4 -4
  112. package/src/skills/crash-course.md +1 -1
  113. package/src/skills/daydream/SKILL.md +1 -1
  114. package/src/skills/debug.md +2 -2
  115. package/src/skills/decode.md +1 -1
  116. package/src/skills/develop.md +6 -6
  117. package/src/skills/document-project.md +4 -4
  118. package/src/skills/domain-modeling.md +1 -1
  119. package/src/skills/drill.md +1 -1
  120. package/src/skills/feynman.md +1 -1
  121. package/src/skills/forge-mcp.md +3 -3
  122. package/src/skills/fresh-eyes.md +4 -4
  123. package/src/skills/frontend-design.md +1 -1
  124. package/src/skills/gap-detector.md +1 -1
  125. package/src/skills/geneseed-code-review.md +1 -1
  126. package/src/skills/geneseed.md +4 -4
  127. package/src/skills/git-archaeology.md +1 -1
  128. package/src/skills/git-rescue.md +3 -3
  129. package/src/skills/handoff.md +4 -4
  130. package/src/skills/herdr.md +1 -1
  131. package/src/skills/ingest.md +3 -3
  132. package/src/skills/learning-path.md +1 -1
  133. package/src/skills/migrate.md +5 -5
  134. package/src/skills/opencode-theme.md +1 -1
  135. package/src/skills/parallel-agents.md +2 -2
  136. package/src/skills/pipeline.md +2 -2
  137. package/src/skills/plan.md +2 -2
  138. package/src/skills/ponytail.md +1 -1
  139. package/src/skills/profile.md +1 -1
  140. package/src/skills/prose.md +2 -2
  141. package/src/skills/prototype.md +2 -2
  142. package/src/skills/refactor.md +2 -2
  143. package/src/skills/release.md +4 -4
  144. package/src/skills/repo-map.md +5 -5
  145. package/src/skills/research.md +6 -6
  146. package/src/skills/review-response.md +3 -3
  147. package/src/skills/roast-me.md +1 -1
  148. package/src/skills/rule.md +5 -5
  149. package/src/skills/ship.md +3 -3
  150. package/src/skills/tdd.md +2 -2
  151. package/src/skills/tickets.md +1 -1
  152. package/src/skills/wayfinder.md +1 -1
  153. package/src/skills/wiki.md +4 -4
  154. package/src/skills/workflow.md +2 -2
  155. package/themes/README.md +17 -7
  156. package/themes/_TEMPLATE.json +37 -33
  157. package/themes/biker.json +37 -33
  158. package/themes/commentator.json +37 -33
  159. package/themes/cyberpunk.json +37 -33
  160. package/themes/gamer.json +37 -33
  161. package/themes/imperial.json +37 -33
  162. package/themes/joker.json +37 -33
  163. package/themes/marvin.json +37 -33
  164. package/themes/mean.json +37 -33
  165. package/themes/military.json +37 -33
  166. package/themes/neutral.json +37 -33
  167. package/themes/pirate.json +37 -33
  168. package/themes/sports.json +37 -33
  169. package/themes/verstappen.json +37 -33
  170. package/themes/wizard.json +37 -33
  171. package/web/dist/assets/{About-DX1-VTBP.js → About-C0Mk0igT.js} +1 -1
  172. package/web/dist/assets/{Activity-Dp9NaXOn.js → Activity-C4aJJVXz.js} +1 -1
  173. package/web/dist/assets/{ActivityDetail-HpOBtrXS.js → ActivityDetail-DaQ85za0.js} +1 -1
  174. package/web/dist/assets/{Diff-Ct4G3Se8.js → Diff-B3tlO2Yj.js} +1 -1
  175. package/web/dist/assets/{Doctor-DGjkZFuA.js → Doctor-BNC-5Dmo.js} +1 -1
  176. package/web/dist/assets/{Graph-b8o-S6kv.js → Graph-us0T6ZcV.js} +1 -1
  177. package/web/dist/assets/Harnesses-DAMgyfM_.js +1 -0
  178. package/web/dist/assets/Laws-rOxegJ75.js +1 -0
  179. package/web/dist/assets/{Library-BEZscyaQ.js → Library-KmwrHA1J.js} +1 -1
  180. package/web/dist/assets/{Markdown-k_LSLBGq.js → Markdown-0eQOVGSF.js} +1 -1
  181. package/web/dist/assets/{Profile-DydukTxP.js → Profile-ZU7WV6Xl.js} +1 -1
  182. package/web/dist/assets/{Rules-CIRG4Ldt.js → Rules-CtDoPhhf.js} +1 -1
  183. package/web/dist/assets/{Skills-Buoi1IBr.js → Skills-BhoOxdkD.js} +1 -1
  184. package/web/dist/assets/{StatusBadge-CE_AJttl.js → StatusBadge-CGvYVCwa.js} +1 -1
  185. package/web/dist/assets/{Themes-CI4o3gJC.js → Themes-Dr7OOkD7.js} +1 -1
  186. package/web/dist/assets/{activity-BCW7e6Cy.js → activity-B8aq3qtl.js} +1 -1
  187. package/web/dist/assets/index-2wqPDxuN.css +1 -0
  188. package/web/dist/assets/{index-B1PIx7RM.js → index-AhMzkdbI.js} +1 -1
  189. package/web/dist/assets/index-CRftEtBX.js +12 -0
  190. package/web/dist/assets/{index-Ck2iCdaC.js → index-D0lPN_qW.js} +1 -1
  191. package/web/dist/index.html +2 -2
  192. package/web/src/pages/Laws.jsx +282 -88
  193. package/web/dist/assets/Harnesses-gr3-aHBF.js +0 -1
  194. package/web/dist/assets/Laws-DTwF5tTC.js +0 -1
  195. package/web/dist/assets/index-BnTQUSUz.js +0 -12
  196. package/web/dist/assets/index-DUCsDv7z.css +0 -1
package/js/setup.mjs CHANGED
@@ -46,7 +46,7 @@ import path from 'node:path';
46
46
 
47
47
  import { main as driverMain, resolveOut } from '../bin/geneseed.mjs';
48
48
  import { playLine } from './anim.mjs';
49
- import { discoverNames } from './checkout.mjs';
49
+ import { discoverNames, PACK_ORDER } from './checkout.mjs';
50
50
  import { exportImprovements } from './diff.mjs';
51
51
  import { cmdDoctor } from './doctor.mjs';
52
52
  import { setupBuildArgs } from './generate.mjs';
@@ -211,6 +211,23 @@ const MODE_BLURBS = {
211
211
  foreman: 'triages tasks, spawns pipelines for substantial work',
212
212
  };
213
213
 
214
+ /**
215
+ * One line per doctrine pack, written for someone who has never read `src/doctrines/README.md`
216
+ * — this is the only description of a pack an installer ever sees.
217
+ *
218
+ * `process` says what turning it OFF costs, and it is the only one that has to. The other three
219
+ * trade practices for context; process 5 is the commit/push consent gate, so dropping the pack
220
+ * drops the rule that every commit and every push is asked for. A blurb that read "planning and
221
+ * context economy" would have sold that away in a menu.
222
+ */
223
+ const DOCTRINE_BLURBS = {
224
+ craft: 'how code is written — reuse first, house conventions, docs, the smallest diff',
225
+ rigor: 'how work is proven — idempotence, honest tests, coverage, gates that can fail',
226
+ ops: 'how the machine is driven — tool discovery, non-blocking commands, teardown',
227
+ process: 'how a session runs — planning, context economy, docs first, and the consent gate '
228
+ + 'on every commit and push (drop this pack and that gate goes with it)',
229
+ };
230
+
214
231
  /**
215
232
  * `_harness_setup._theme_options`.
216
233
  *
@@ -243,6 +260,20 @@ export function modeOptions() {
243
260
  return discoverNames('modes', 'direct').map((n) => [n, MODE_BLURBS[n] ?? '']);
244
261
  }
245
262
 
263
+ /**
264
+ * The doctrine packs, in `PACK_ORDER` — the third option table, and the one that is a SET.
265
+ *
266
+ * The order is NARRATIVE and comes from `PACK_ORDER`; `discoverNames` only says which packs
267
+ * this checkout actually ships. Ordering by discovery instead would read `craft, ops, process,
268
+ * rigor` — it `.sort()`s — and the menu a human answers would stop matching the `Active packs:`
269
+ * line the build writes, which is normalised into `PACK_ORDER` at the driver. Same intersection
270
+ * the driver's `parseDoctrines` and the config loader take, for the same reason.
271
+ */
272
+ export function doctrineOptions() {
273
+ const known = discoverNames('doctrines', PACK_ORDER[0]);
274
+ return PACK_ORDER.filter((n) => known.includes(n)).map((n) => [n, DOCTRINE_BLURBS[n] ?? '']);
275
+ }
276
+
246
277
  /**
247
278
  * `_harness_setup.EMIT_OPTIONS`.
248
279
  *
@@ -272,12 +303,60 @@ const FOOTPRINT_OPTIONS = [
272
303
  // --------------------------------------------------------------------------------------
273
304
 
274
305
  /**
275
- * `_harness_setup._collect_setup_lines` — the five questions, the plan, the confirm.
306
+ * The Doctrine-packs question — one `all`/`choose` gate, then a y/n per pack.
307
+ *
308
+ * TWO LEVELS RATHER THAN FOUR QUESTIONS, because the overwhelmingly common answer is "all of
309
+ * them" and a wizard that asks four yes/nos to get there taxes every install for the rare one.
310
+ * The gate's default is `all`, so an installer who holds Enter through the whole wizard gets
311
+ * the full set — which is what every pre-packs install already had.
312
+ *
313
+ * `askChoice` and not `confirm` for the per-pack question: `confirm` reads the first character
314
+ * and prints no description, so the pack's blurb — the only place a reader is told what the
315
+ * pack costs — would have nowhere to go. Two named options give it a line.
316
+ *
317
+ * Answering `no` to all four is legal and yields `[]`, which `setupBuildArgs` spells
318
+ * `--doctrines none`. That is a real configuration (invariants and ontology only), not a
319
+ * mistake to guard against, so it is not second-guessed here.
320
+ *
321
+ * `deployed` is what the machine's carriers already say (`installedDefaults().doctrines`), and
322
+ * it drives BOTH levels: a narrowed install opens on `choose` with its own packs pre-ticked,
323
+ * so holding Enter through the wizard KEEPS the selection instead of silently widening it back
324
+ * to all four. `null` — no carrier said — still opens on `all`, which is the same resolution
325
+ * `doctrinesForBuild` gives unknown, so the wizard and the build agree about silence.
326
+ *
327
+ * ⚠ It never returns `null`, at either level, and `tests/unit/setup.test.mjs` pins that: this
328
+ * is the one call site allowed to reach `setupBuildArgs` without a `doctrinesForBuild`
329
+ * fallback, and that exemption is only sound while the function is total.
330
+ */
331
+ function askDoctrines(deployed = null) {
332
+ const opts = doctrineOptions();
333
+ const all = opts.map(([n]) => n);
334
+ const known = Array.isArray(deployed) ? all.filter((n) => deployed.includes(n)) : null;
335
+ const narrowed = known !== null && known.length !== all.length;
336
+ const pick = askChoice('Doctrine packs', [
337
+ ['all', `${all.join(' + ')}${narrowed ? '' : ' (default)'}`],
338
+ ['choose', `pick packs${narrowed ? ` (installed: ${known.join(', ') || 'none'})` : ''}`],
339
+ ], narrowed ? 'choose' : 'all');
340
+ if (pick !== 'choose') return all;
341
+ return all.filter((name) => askChoice(`Include ${name} — ${DOCTRINE_BLURBS[name] ?? name}`,
342
+ [['yes', `build ${name} into AGENT.md`], ['no', `leave ${name} out`]],
343
+ known === null || known.includes(name) ? 'yes' : 'no') === 'yes');
344
+ }
345
+
346
+ /**
347
+ * `_harness_setup._collect_setup_lines` — the six questions, the plan, the confirm.
276
348
  *
277
349
  * Returns the selection, or null when the confirm is declined. Every default is the
278
350
  * DEPLOYED value first (`installedDefaults`) and the CONFIGURED one second, which is why
279
351
  * P5i is the phase that had to give `installedDefaults` its posture, mode and footprint keys
280
352
  * back: three of the five questions read them.
353
+ *
354
+ * Doctrines reads the same walk, and joined it last because the `Active packs:` marker reader
355
+ * did not exist when the question shipped. Until it did, every re-run of the wizard offered
356
+ * `all` to an installer who had deliberately narrowed their packs — the one register where
357
+ * holding Enter SILENTLY WIDENED a selection instead of keeping it. `inst.doctrines` is passed
358
+ * whole, `null` included, because `askDoctrines` distinguishes "no carrier said" from "a
359
+ * carrier said none" and the two open the question differently.
281
360
  */
282
361
  export function collectSetupLines() {
283
362
  pyPrint('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
@@ -285,6 +364,7 @@ export function collectSetupLines() {
285
364
  const theme = askChoice('Theme', themeOptions(), inst.theme || defaultTheme());
286
365
  const posture = askChoice('Posture', postureOptions(), inst.posture || defaultPosture());
287
366
  const mode = askChoice('Mode', modeOptions(), inst.mode || defaultMode());
367
+ const doctrines = askDoctrines(inst.doctrines);
288
368
  const emit = askChoice('Install mode', EMIT_OPTIONS, inst.emit || 'opencode-global');
289
369
  const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS.map(([k, d]) => [k, d]),
290
370
  inst.footprint || 'lean');
@@ -299,10 +379,15 @@ export function collectSetupLines() {
299
379
  } else if (emit === 'files') {
300
380
  out = ask('Output dir for the bundle', 'Harness');
301
381
  }
382
+ // The full pack list is passed as the elision default rather than left to `setupBuildArgs`'s
383
+ // own: the menu the user just answered was built from `doctrineOptions()`, so "they picked
384
+ // everything" must be judged against that same list, not against a second one.
385
+ const allPacks = doctrineOptions().map(([n]) => n);
302
386
  pyPrint(`\nAbout to run: geneseed build ${
303
- setupBuildArgs(theme, emit, out, root, footprint, posture, mode).join(' ')}\n`);
387
+ setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks)
388
+ .join(' ')}\n`);
304
389
  if (!confirm('Proceed?', true)) return null;
305
- return { theme, posture, mode, emit, out, root, footprint };
390
+ return { theme, posture, mode, doctrines, emit, out, root, footprint };
306
391
  }
307
392
 
308
393
  // --------------------------------------------------------------------------------------
@@ -441,6 +526,7 @@ export function setupLines() {
441
526
  }
442
527
  const {
443
528
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
529
+ doctrines = null,
444
530
  } = sel;
445
531
  if (emit === 'opencode-global') {
446
532
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -455,7 +541,11 @@ export function setupLines() {
455
541
  pyPrint(`! could not export local edits (${e && e.message}) — continuing.\n`);
456
542
  }
457
543
  }
458
- const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode);
544
+ // Same list the question was built from — see `collectSetupLines`. The build that actually
545
+ // runs must carry the pack selection the plan line just previewed, or the wizard would print
546
+ // one command and run another.
547
+ const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
548
+ doctrineOptions().map(([n]) => n));
459
549
  pyPrint(`Running: geneseed build ${argv.join(' ')}\n`);
460
550
  const rc = driverMain(argv);
461
551
  if (rc !== 0) {
package/js/status.mjs CHANGED
@@ -125,9 +125,24 @@ export function cmdVersion(args) {
125
125
  * it, and the eleven `status` cells and the catalog cells now gate the same classifier
126
126
  * from opposite ends.
127
127
  */
128
- export function inventoryCounts(themeName) {
129
- const inv = tuiInventory(themeName);
130
- return { agents: inv.agents.length, skills: inv.skills.length, laws: inv.laws.length };
128
+ export function inventoryCounts(themeName, doctrines = null) {
129
+ const inv = tuiInventory(themeName, doctrines);
130
+ const on = inv.doctrines.filter((p) => p.active);
131
+ return {
132
+ agents: inv.agents.length,
133
+ skills: inv.skills.length,
134
+ laws: inv.laws.length,
135
+ ontology: inv.ontology.length,
136
+ // ⚠ THREE NUMBERS AND NOT ONE, because a pack-off install is a real configuration and
137
+ // "23 rules" would be a lie in it. `packs`/`packsTotal` is the fraction the console and
138
+ // the panel both print; `doctrineRules` counts only the ACTIVE packs' rules — what this
139
+ // install is actually bound by, not what its bundle happens to carry. The catalogue is
140
+ // always whole (every pack file ships), so the total is recoverable from `tuiInventory`
141
+ // by anything that needs it and is not duplicated here.
142
+ packs: on.length,
143
+ packsTotal: inv.doctrines.length,
144
+ doctrineRules: on.reduce((n, p) => n + p.rules.length, 0),
145
+ };
131
146
  }
132
147
 
133
148
  /**
@@ -163,7 +178,10 @@ export function statusData() {
163
178
  const inst = installedDefaults();
164
179
  const theme = inst.theme || defaultTheme();
165
180
  const mdir = resolveMemoryDir(null);
166
- const inv = inventoryCounts(theme);
181
+ // `inst.doctrines` is what the machine's carriers say — `null` when none of them does, which
182
+ // `tuiInventory` reads as every pack, the same resolution `doctrinesForBuild` gives unknown.
183
+ // This is a DISPLAY read: nothing here decides what a build emits.
184
+ const inv = inventoryCounts(theme, inst.doctrines);
167
185
  let cfgDir = null;
168
186
  try { cfgDir = opencodeConfigDir(); } catch { cfgDir = null; }
169
187
  const sourceFp = sourceFingerprint(makeCfg());
@@ -189,6 +207,15 @@ export function statusData() {
189
207
  agents: inv.agents,
190
208
  skills: inv.skills,
191
209
  laws: inv.laws,
210
+ // ⚠ NAMED ONE BY ONE, AND THAT IS THE TRAP THIS FUNCTION SETS TWICE. `inv` is already
211
+ // `inventoryCounts`' reduction, and this literal names its keys again — so a count added
212
+ // to the inventory and not to BOTH places is dropped here in silence, with nothing to
213
+ // fail. There is deliberately no spread: the panel's payload is a stated contract, and
214
+ // `js/web/api.mjs` spreads THIS, so anything reaching that endpoint passes through here.
215
+ ontology: inv.ontology,
216
+ packs: inv.packs,
217
+ packs_total: inv.packsTotal,
218
+ doctrine_rules: inv.doctrineRules,
192
219
  memory_dir: mdir ? String(mdir) : null,
193
220
  facts: mdir ? memoryFactCount(mdir) : 0,
194
221
  source_fp: sourceFp,
@@ -248,6 +275,20 @@ export function statusLines(d, color = false) {
248
275
  + `(${d.facts} fact${d.facts === 1 ? '' : 's'})`],
249
276
  ['version', `${d.installed_fp || '(none)'} ${DOT} source ${d.source_fp}`],
250
277
  ];
278
+ // ⚠ A ROW, NOT A WIDER `components` LINE, AND THE REASON IS THAT THE CORPUS CANNOT BE
279
+ // RE-BLESSED. `statusLines` has 30 recorded cases per platform in
280
+ // `tests/__snapshots__/primitives/`, every one of them holding the box's exact bytes —
281
+ // including `17 agents · 47 skills · 37 laws` — and the recorder was Python and is gone.
282
+ // Touching `components` moves all 30; adding a row would too, since the box width is
283
+ // measured over `rows`. So the row is CONDITIONAL on a key the recorded arguments do not
284
+ // carry, exactly as `agent_md` below already is, and the recording renders unchanged.
285
+ //
286
+ // It carries the two tiers `components` cannot: `components` counts the invariants as
287
+ // `laws`, which is still true, and says nothing about the ontology or the packs.
288
+ if (d.packs_total !== undefined) {
289
+ rows.push(['constitution', `${d.ontology} ontology sections ${DOT} ${d.doctrine_rules} `
290
+ + `doctrine rules in ${d.packs}/${d.packs_total} packs`]);
291
+ }
251
292
  if (d.agent_md) {
252
293
  rows.push(['AGENT.md',
253
294
  `${d.agent_md} (${d.agent_md_present ? 'present' : 'MISSING'})`]);
package/js/themes.mjs CHANGED
@@ -59,7 +59,7 @@ import {
59
59
  * - the three `strip` spellings are Python's whitespace set, not `String.prototype.trim`'s;
60
60
  * - `jsonDumpsCompact(v, {ensureAscii: false})` is `json.dumps(v, ensure_ascii=False)`,
61
61
  * including the `', '`/`': '` separators — which matter for exactly ONE key,
62
- * `AGENT_COLORS`, the only non-string among `_TEMPLATE.json`'s 137 values. `ensureAscii`
62
+ * `AGENT_COLORS`, the only non-string among `_TEMPLATE.json`'s 140 values. `ensureAscii`
63
63
  * is false because the reference passes `ensure_ascii=False`, and 33 template values
64
64
  * carry non-ASCII text that would otherwise be escaped into the committed file;
65
65
  * - `pyEq`, because the safety net compares two `json.loads` results and `===` is identity
package/js/tui.mjs CHANGED
@@ -246,6 +246,11 @@ const ICONS = {
246
246
  agent: ['🤖', '◆', '@'],
247
247
  skill: ['✨', '✦', '*'],
248
248
  law: ['📜', '§', '#'],
249
+ // The other two tiers. Safe to add: `icon` has 32 recorded cases and none of them names a
250
+ // key that did not exist, so a new entry changes no recorded answer — unlike `glyphs`,
251
+ // whose recorded results are the whole object and which is therefore left alone.
252
+ ontology: ['🧭', '◉', 'O'],
253
+ doctrine: ['📐', '⌘', 'D'],
249
254
  library: ['📚', '❒', 'L'],
250
255
  notebook: ['📓', '✎', 'N'],
251
256
  wiki: ['📘', '▥', 'W'],
@@ -387,14 +392,47 @@ export function themeFlair(theme) {
387
392
  /**
388
393
  * `_tui_entries` — the ordered (kind, label, data) rows for the left list. `head` is a
389
394
  * section divider and is not selectable.
395
+ *
396
+ * ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS. This function has two
397
+ * recorded cases per platform in `tests/__snapshots__/primitives/`, replayed under
398
+ * `deepStrictEqual`, and their argument is a synthetic inventory carrying exactly
399
+ * `{agents, skills, laws, theme}` — no recorder survives to re-bless them. Reading
400
+ * `inv.doctrines.length` there THROWS; emitting a head unconditionally changes the recorded
401
+ * result array. Keyed on presence, the recording renders byte-identical and a real inventory
402
+ * gets all three tiers.
403
+ *
404
+ * The order is CONSTITUTIONAL and not alphabetical: ontology, then invariants, then doctrines
405
+ * — the order they are read in, the order AGENT.md renders them, and the order the console's
406
+ * three bands appear in. `Rule` and `Doctrine` are hardcoded English here, as `Rule` already
407
+ * was: this function receives an inventory, not a theme's nouns.
390
408
  */
391
409
  export function tuiEntries(inv) {
392
410
  const rows = [['head', `AGENTS (${inv.agents.length})`, null]];
393
411
  for (const e of inv.agents) rows.push(['agent', e.name, e]);
394
412
  rows.push(['head', `SKILLS (${inv.skills.length})`, null]);
395
413
  for (const e of inv.skills) rows.push(['skill', e.name, e]);
414
+ if (inv.ontology?.length) {
415
+ rows.push(['head', `ONTOLOGY (${inv.ontology.length})`, null]);
416
+ for (const e of inv.ontology) rows.push(['ontology', e.title, e]);
417
+ }
396
418
  rows.push(['head', `LAWS (${inv.laws.length})`, null]);
397
419
  for (const e of inv.laws) rows.push(['law', `Rule ${e.num} — ${e.title}`, e]);
420
+ if (inv.doctrines?.length) {
421
+ // The head counts the ACTIVE packs' rules and names the fraction, because a pack that is
422
+ // not built in is still listed — greyed rather than absent, so "off" never looks like
423
+ // "gone" — and a head reading `DOCTRINES (23)` over an install bound by six would be the
424
+ // one number in this panel that lies.
425
+ const on = inv.doctrines.filter((p) => p.active);
426
+ const n = on.reduce((a, p) => a + p.rules.length, 0);
427
+ rows.push(['head',
428
+ `DOCTRINES (${n} in ${on.length}/${inv.doctrines.length} packs)`, null]);
429
+ for (const p of inv.doctrines) {
430
+ for (const r of p.rules) {
431
+ rows.push(['doctrine', `Doctrine ${r.pack} ${r.n} — ${r.title}`,
432
+ { ...r, active: p.active, source: p.source }]);
433
+ }
434
+ }
435
+ }
398
436
  return rows;
399
437
  }
400
438
 
@@ -420,7 +458,12 @@ export function tuiEntries(inv) {
420
458
  * the fix is one `or []` at the call site, and it belongs in the same change as the port.
421
459
  */
422
460
  export function detailLines(kind, label, data) {
423
- if (kind === 'law') return [label, ''].concat(data ? pySplitLines(data.body) : []);
461
+ // The three constitutional tiers share one treatment — the label, a blank, then the body —
462
+ // because all three are a titled rule rather than a rendered spec. New kinds only: the nine
463
+ // recorded cases name kinds that already existed, so no recorded answer moves.
464
+ if (kind === 'law' || kind === 'doctrine' || kind === 'ontology') {
465
+ return [label, ''].concat(data ? pySplitLines(data.body) : []);
466
+ }
424
467
  if ((kind === 'agent' || kind === 'skill') && data) return pySplitLines(data.body);
425
468
  return null;
426
469
  }
@@ -433,12 +476,12 @@ export function detailLines(kind, label, data) {
433
476
  */
434
477
  export function cmdTui() {
435
478
  if (!process.stdin.isTTY) {
436
- pyPrint('[tui] not an interactive terminal. Use `harness setup`, `doctor`, or `build`.\n');
479
+ pyPrint('[tui] not an interactive terminal. Use `geneseed setup`, `geneseed doctor`, or `geneseed build`.\n');
437
480
  return 1;
438
481
  }
439
482
  // The pointer to the Python panel is gone for `js/menu.mjs`'s reason: the
440
483
  // panel it named is the Python being deleted. What is left is true and stays true.
441
484
  pyPrint('[tui] full-screen panel unavailable (this entry carries the TUI\'s layout '
442
- + 'half, not its screens). Use `harness setup`, `doctor`, or `build`.\n');
485
+ + 'half, not its screens). Use `geneseed setup`, `geneseed doctor`, or `geneseed build`.\n');
443
486
  return 1;
444
487
  }
package/js/uninstall.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `harness uninstall` — the first verb in this port that DELETES.
2
+ * `geneseed uninstall` — the first verb in this port that DELETES.
3
3
  *
4
4
  * Thirteen subcommands crossed before this one and every one of them either writes or
5
5
  * reports. `uninstall` removes a deployed install: the manifest's owned files, the empty
@@ -50,7 +50,7 @@ import path from 'node:path';
50
50
  import { hookRunnerEntry } from '../bin/geneseed.mjs';
51
51
  import { confirm } from './setup.mjs';
52
52
  import {
53
- claudeCfg, claudeReadManifest, emitHostScopeOf, installKind, installState,
53
+ claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, installKind, installState,
54
54
  registeredTargets, readMaybe, DISABLED_STASH,
55
55
  } from './installs.mjs';
56
56
  import {
@@ -187,7 +187,7 @@ function warnSurvivors(failed) {
187
187
 
188
188
  function warnMarkersKept() {
189
189
  pyPrintErr('[uninstall] WARN: the manifest and markers were KEPT so '
190
- + '`harness uninstall` can be retried once the file(s) are '
190
+ + '`geneseed uninstall` can be retried once the file(s) are '
191
191
  + 'unlocked/removable.\n');
192
192
  }
193
193
 
@@ -510,7 +510,7 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
510
510
  // With `failed` set the reversal already warned twice — no third overlapping WARN.
511
511
  pyPrintErr('[uninstall] WARN: could not fully remove the install — still present: '
512
512
  + `${survivors.join(', ')}. The install marker was KEPT so you can retry `
513
- + '`harness uninstall` once the file(s) are unlocked/removable.\n');
513
+ + '`geneseed uninstall` once the file(s) are unlocked/removable.\n');
514
514
  }
515
515
  const out = {
516
516
  ok: true,
@@ -626,7 +626,7 @@ export function printSurvivingProjectInventory(removedRoot) {
626
626
  + 'does not affect them (each is self-contained):\n');
627
627
  for (const [host, scope, root] of survivors) {
628
628
  pyPrint(` - ${root} (${host}:${scope}) — remove with: `
629
- + `harness uninstall --target "${root}" --yes\n`);
629
+ + `geneseed uninstall --target "${root}" --yes\n`);
630
630
  }
631
631
  }
632
632
 
@@ -733,7 +733,7 @@ export function cmdUninstall(args) {
733
733
  if (s.incomplete && s.incomplete.length) {
734
734
  pyPrint(`[uninstall] INCOMPLETE — removed ${s.removed} file(s), but `
735
735
  + `${s.incomplete.length} item(s) survived (see the WARN above); `
736
- + 'the install marker was kept — retry `harness uninstall` once they\'re '
736
+ + 'the install marker was kept — retry `geneseed uninstall` once they\'re '
737
737
  + `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
738
738
  } else {
739
739
  pyPrint(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
@@ -1013,15 +1013,27 @@ function cleanHostStash(cfg, host = 'claude') {
1013
1013
  * file and swallows its own OSError, exactly as the reference does — a failed bookkeeping
1014
1014
  * write must not fail the reactivate that has already moved every file back.
1015
1015
  */
1016
- function remergeClaudeHooks(cfg) {
1016
+ function remergeClaudeHooks(cfg, root = cfg) {
1017
1017
  const data = claudeReadManifest(cfg);
1018
1018
  const managed = managedOf(data);
1019
1019
  // `hookOpts` is the one argument `mergeClaudeSettings` will not default, and it is right
1020
1020
  // not to: there is no computable fallback for the runner/entry the shim bakes. The
1021
1021
  // emitter's own originator is imported rather than restated — a reactivate that wired a
1022
1022
  // different shim path would point every re-added hook at a file the emitter never writes.
1023
+ // The pack selection is read back off the reactivated carrier, not defaulted: a reactivate
1024
+ // that re-wired the git-gate into an install whose owner had turned the process pack off
1025
+ // would put the boundary back at odds with the prompt. `null` (no marker) keeps the gate.
1026
+ //
1027
+ // ⚠ THE CARRIER IS NOT ALWAYS UNDER `cfg`, WHICH IS WHY `root` IS A SECOND PARAMETER. On a
1028
+ // PROJECT install `cfg` is `<repo>/.claude` and the carrier is `<repo>/CLAUDE.md`, so
1029
+ // reading `cfg` alone answered `null` for every project reactivate and re-wired the gate
1030
+ // unconditionally (fail-closed, so not a hole — but wrong, and it made the toggle one-way
1031
+ // for project installs). `root` first, `cfg` second: copilot keeps its carrier INSIDE the
1032
+ // config dir (`.github/copilot-instructions.md`), and on a global install the two are the
1033
+ // same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
1034
+ const doctrines = doctrinesOfDir(root) ?? doctrinesOfDir(cfg);
1023
1035
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1024
- managed.settings_hooks ?? null, hookRunnerEntry());
1036
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines);
1025
1037
  // `and data` — a manifest that did not parse is not one to write back.
1026
1038
  if (!pyEq(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1027
1039
  managed.settings_hooks = claims;
@@ -1106,7 +1118,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1106
1118
  && isFile(path.join(cfg, 'rules', 'geneseed.md'));
1107
1119
  }
1108
1120
  if (relive) {
1109
- remergeClaudeHooks(cfg); // ensure the hooks are present (and the claims exact)
1121
+ remergeClaudeHooks(cfg, root); // ensure the hooks are present (and the claims exact)
1110
1122
  cleanHostStash(cfg, host);
1111
1123
  return { ok: true,
1112
1124
  note: 'install was re-created while disabled; discarded the stashed snapshot' };
@@ -1126,12 +1138,19 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1126
1138
  moved += 1;
1127
1139
  }
1128
1140
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
1129
- remergeClaudeHooks(cfg);
1130
1141
  const managed = claudeReadManifest(cfg).managed || {};
1131
- wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1142
+ // ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
1143
+ // `remergeClaudeHooks` reads the install's pack selection back off the carrier, and on a
1144
+ // Claude-style host that selection lives inside the MANAGED BLOCK — which the deactivate
1145
+ // stashed. Re-merging first therefore asked a CLAUDE.md with its block cut out, got `null`
1146
+ // ("unknown"), and fail-closed straight back to the consent gate: every reactivate re-wired a
1147
+ // git-gate the owner had turned off, in both scopes. The two writes touch different files
1148
+ // (`CLAUDE.md` vs `settings*.json`), so nothing else depends on which goes first.
1132
1149
  if (existsSync(blockFile)) {
1133
1150
  managedBlockWrite(claudeMdPath(cfg, managed), readText(blockFile));
1134
1151
  }
1152
+ remergeClaudeHooks(cfg, root);
1153
+ wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1135
1154
  cleanHostStash(cfg, host);
1136
1155
  return { ok: true, kind: host, moved };
1137
1156
  }
@@ -104,7 +104,7 @@ import os from 'node:os';
104
104
  import path from 'node:path';
105
105
 
106
106
  import { emitGlobalInto } from '../../bin/geneseed.mjs';
107
- import { ROOT, discoverNames } from '../checkout.mjs';
107
+ import { ROOT, discoverNames, PACK_ORDER } from '../checkout.mjs';
108
108
  import { withStdoutSwallowed } from '../diff.mjs';
109
109
  import { excludeAdd, excludeRemove } from '../excludes.mjs';
110
110
  import { setupBuildArgs } from '../generate.mjs';
@@ -114,8 +114,8 @@ import {
114
114
  pyResolve,
115
115
  } from '../hosts.mjs';
116
116
  import {
117
- EMIT_HOST_SCOPE, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
118
- readMaybe,
117
+ EMIT_HOST_SCOPE, doctrinesForBuild, footprintOfDir, installState, installTargets, modeOfDir,
118
+ postureOfDir, readMaybe,
119
119
  } from '../installs.mjs';
120
120
  import {
121
121
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
@@ -140,6 +140,36 @@ const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, ke
140
140
  /** `str(x or "")` — Python's truthiness, then Python's `str`. */
141
141
  const strOr = (v) => (pyTruthy(v) ? pyStr(v) : '');
142
142
 
143
+ /**
144
+ * A request body's `doctrines` -> a normalised pack list, or `null` for "said nothing usable".
145
+ *
146
+ * THE SAME TRUST BOUNDARY THE THEME CROSSES, and it needs more care than posture or mode do
147
+ * because this value is a LIST: `discoverNames(...).includes(x)` closes a scalar in one call,
148
+ * but a list has to be closed member by member or one bogus element rides in beside three good
149
+ * ones and reaches an argv. Every name is checked against DISCOVERY — what this checkout
150
+ * actually ships — and the result is re-ordered through `PACK_ORDER`, because the
151
+ * `Active packs:` line the build writes is a marker that a later reader parses back out and a
152
+ * marker whose contents depend on request-body order compares unequal to itself.
153
+ *
154
+ * `null` rather than a substituted default on every rejection, so the caller decides what
155
+ * "unspecified" means for its own endpoint — a rebuild keeps the deployment's answer, a fresh
156
+ * deploy takes the configured one. That mirrors the bogus-theme fallback right beside it.
157
+ *
158
+ * Accepts an array (what the console sends) or a comma string (what a curl by hand sends), and
159
+ * `none`/`[]` both mean the deliberate empty selection — a real configuration, not a rejection.
160
+ */
161
+ function bodyDoctrines(body) {
162
+ const raw = bget(body, 'doctrines');
163
+ if (Array.isArray(raw) && !raw.length) return [];
164
+ const names = Array.isArray(raw) ? raw
165
+ : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
166
+ if (names === null || !names.length) return null;
167
+ if (names.length === 1 && names[0] === 'none') return [];
168
+ const known = discoverNames('doctrines', PACK_ORDER[0]);
169
+ if (!names.every((n) => typeof n === 'string' && known.includes(n))) return null;
170
+ return PACK_ORDER.filter((p) => names.includes(p));
171
+ }
172
+
143
173
  /** `str.split()` with no argument — runs of PYTHON whitespace, no empties. */
144
174
  const pyWords = (s) => s.split(new RegExp(`[${PY_SPACE}]+`)).filter(Boolean);
145
175
 
@@ -534,14 +564,30 @@ export function apiMcp() {
534
564
  * `,]`. A file that will not parse is refused rather than clobbered: it may be
535
565
  * `~/.claude.json`, which holds projects and history far beyond MCP wiring.
536
566
  */
537
- export function apiMcpToggle(state, body) {
538
- const name = strOr(bget(body, 'name'));
539
- const want = pyTruthy(bget(body, 'enabled'));
540
- const pathArg = strOr(bget(body, 'path'));
567
+ /**
568
+ * The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
569
+ *
570
+ * LIFTED OUT OF `apiMcpToggle` BECAUSE IT NOW HAS A SECOND CALLER, not for tidiness. `/api/reveal`
571
+ * hands a path to the desktop's file manager, and the allowlist is the whole security of that
572
+ * endpoint exactly as it is of the toggle: an unlisted path must 404 before anything is opened.
573
+ * Two copies of a security check drift, and the copy that drifts is the one nobody re-read.
574
+ *
575
+ * It is rebuilt per call rather than cached: installs come and go while the daemon runs, and a
576
+ * stale allowlist is wrong in both directions — refusing a real target, or naming a dead one.
577
+ */
578
+ export function mcpTargetPaths() {
541
579
  const known = new Map();
542
580
  for (const [, cfgPath, host] of mcpInstallTargets()) {
543
581
  known.set(String(cfgPath), [cfgPath, host]);
544
582
  }
583
+ return known;
584
+ }
585
+
586
+ export function apiMcpToggle(state, body) {
587
+ const name = strOr(bget(body, 'name'));
588
+ const want = pyTruthy(bget(body, 'enabled'));
589
+ const pathArg = strOr(bget(body, 'path'));
590
+ const known = mcpTargetPaths();
545
591
  const hit = known.get(pathArg);
546
592
  if (hit === undefined || !name) {
547
593
  throw new NotFound(`mcp target ${pathArg || '(none)'}`);
@@ -686,6 +732,13 @@ export function apiRestore(state, files) {
686
732
  footprint: state.footprint,
687
733
  posture: state.posture,
688
734
  mode: state.mode,
735
+ // THE PACK SELECTION IS THE FIFTH, and it is the one that makes this verb a BOUNDARY
736
+ // question rather than a cosmetic one. `expected` rendered at all four packs, and this
737
+ // verb COPIES OUT OF IT: restoring AGENT.md onto an install built `--doctrines craft`
738
+ // wrote a carrier stating doctrine process 5 while `claudeHookGroups` had wired no gate
739
+ // behind it — prompt and boundary disagreeing by a WRITE, the write-side twin of the
740
+ // read-side hole `diffCollect` had (and the reason the panel offered the file at all).
741
+ doctrines: doctrinesForBuild(target),
689
742
  }));
690
743
  for (const raw of (pyTruthy(files) ? files : [])) {
691
744
  const rel = pyStripSpace(pyStr(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
@@ -765,8 +818,15 @@ export function apiInstallCmd(state, body) {
765
818
  const bmode = bget(body, 'mode');
766
819
  const mode = discoverNames('modes', 'direct').includes(bmode)
767
820
  ? bmode : (modeOfDir(root) || 'direct');
821
+ // Unspecified means "keep what this install already has", exactly as theme, footprint,
822
+ // posture and mode above do — a rebuild through the console is not a place to silently
823
+ // re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
824
+ // install) MUST NOT LAND ON `harness.config.json`: `doctrinesOfDir` answers `null` there,
825
+ // `null` elided the flag, and the generator's config fallback then narrowed the install and
826
+ // took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
827
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
768
828
  const out = scope === 'global' ? null : String(root);
769
- const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode);
829
+ const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines);
770
830
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
771
831
  }
772
832
 
@@ -862,7 +922,14 @@ export function apiDeployCmd(state, body) {
862
922
  const pos = discoverNames('postures', 'peer').includes(bpos) ? bpos : 'peer';
863
923
  const bmode = bget(body, 'mode');
864
924
  const mode = discoverNames('modes', 'direct').includes(bmode) ? bmode : 'direct';
925
+ // Same resolution as `apiInstallCmd` above, and for the same reason: the console's Deploy
926
+ // form sends host/path/theme/footprint/posture/mode and NO pack selection, and nothing stops
927
+ // it landing on a directory that already holds an install. Taking `bodyDoctrines` alone left
928
+ // the flag off, the generator fell back to `harness.config.json`, and deploying onto an
929
+ // existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
930
+ // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
931
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
865
932
  // project-scope emit name == host name (opencode / claude / bob / copilot)
866
- const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode);
933
+ const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines);
867
934
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
868
935
  }