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/generate.mjs CHANGED
@@ -33,11 +33,11 @@ import { existsSync, mkdirSync, statSync } from 'node:fs';
33
33
  import path from 'node:path';
34
34
 
35
35
  import { main as driverMain } from '../bin/geneseed.mjs';
36
- import { makeCfg } from './checkout.mjs';
36
+ import { makeCfg, PACK_ORDER } from './checkout.mjs';
37
37
  import { opencodeConfigDir, pyResolve } from './hosts.mjs';
38
38
  import {
39
- EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, footprintOfDir, installState,
40
- installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
39
+ EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
40
+ footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
41
41
  } from './installs.mjs';
42
42
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from './opencode.mjs';
43
43
  import { renderAll } from './render.mjs';
@@ -101,9 +101,28 @@ export function cmdBuild(args) {
101
101
  * because omitting it when it matched the generator's default silently coupled the answer to
102
102
  * whatever that default happened to be — the moment the default moved, picking the old one
103
103
  * produced the new one. `posture` and `mode` still elide at theirs, and neither has moved.
104
+ *
105
+ * ⚠ `doctrines` DOES NOT ELIDE, AND THAT IS THE SAFETY DECISION, NOT A STYLE ONE. It used to
106
+ * elide on the full set, by analogy with posture and mode — but those two compare against a
107
+ * FROZEN LITERAL (`'peer'`, `'direct'`), while the default a missing `--doctrines` actually
108
+ * lands on is `configDefaults()`, which reads `harness.config.json`. With `{"doctrines":
109
+ * ["craft"]}` in that file, an install carrying all four packs re-emitted by `upgrade` was
110
+ * silently narrowed to one — and the narrowing takes the commit/push consent gate off an
111
+ * install whose owner never asked for that. Same trap as `footprint`, one axis over, with a
112
+ * boundary behind it. A caller that knows the selection now always says so.
113
+ *
114
+ * `allPacks` survives the elision's removal because it is still what CANONICALISES the value:
115
+ * the flag is emitted in the checkout's pack order, not the order handed in, and this function
116
+ * is pure and has no discovery of its own.
117
+ *
118
+ * Two states: `null` means "the caller expressed no opinion" and emits no flag at all, so the
119
+ * generator falls back to `harness.config.json` — the only route left to that fallback, and it
120
+ * is reached only where nothing was read back off an install; `[]` is a deliberate empty
121
+ * selection and emits `--doctrines none`, which is the spelling the driver's `parseDoctrines`
122
+ * accepts (an empty `--doctrines ` is a usage error there, so it can never be produced here).
104
123
  */
105
124
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
106
- posture = 'peer', mode = 'direct') {
125
+ posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER) {
107
126
  const argv = ['--theme', theme, '--emit', emit];
108
127
  if (!['opencode-global', 'claude-global', 'bob-global', 'copilot-global'].includes(emit)) {
109
128
  if (out) argv.push('--out', out);
@@ -112,6 +131,13 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
112
131
  if (footprint) argv.push('--footprint', footprint);
113
132
  if (posture && posture !== 'peer') argv.push('--posture', posture);
114
133
  if (mode && mode !== 'direct') argv.push('--mode', mode);
134
+ if (Array.isArray(doctrines)) {
135
+ // Emitted in `allPacks` order, not the order handed in: a caller may legitimately pass an
136
+ // unsorted list, and the flag has to be canonical for the same reason the driver
137
+ // re-normalises on parse — the `Active packs:` marker is compared against itself.
138
+ const picked = allPacks.filter((p) => doctrines.includes(p));
139
+ argv.push('--doctrines', picked.length ? picked.join(',') : 'none');
140
+ }
115
141
  return argv;
116
142
  }
117
143
 
@@ -167,7 +193,15 @@ export function cmdRebuildAll() {
167
193
  const posture = postureOfDir(root) || defaultPosture();
168
194
  const mode = modeOfDir(root) || defaultMode();
169
195
  const out = scope === 'global' ? null : root;
170
- const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode);
196
+ // SIX values now, not five. A rebuild that defaulted the pack selection would re-emit an
197
+ // install into a constitution its owner did not choose — and in one direction that is not
198
+ // merely surprising: dropping the process pack takes the commit/push consent RULES out of
199
+ // AGENT.md while `claudeHookGroups` keeps the gate wired, or the reverse. ⚠ AND THE
200
+ // FALLBACK IS NOT `harness.config.json`: a pre-migration carrier has no marker, and
201
+ // resolving that silence out of a machine-wide config narrows every such install on its
202
+ // first upgrade. `doctrinesForBuild` resolves unknown to ALL packs — see its docblock.
203
+ const doctrines = doctrinesForBuild(root);
204
+ const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines);
171
205
  const label = `${host}:${scope} (${root})`;
172
206
  pyPrint(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
173
207
  + `posture=${posture} mode=${mode}\n`);
package/js/hooks.mjs CHANGED
@@ -214,7 +214,7 @@ const out = pyPrint;
214
214
  const err = pyPrintErr;
215
215
 
216
216
  // ======================================================================================
217
- // context — project-context discovery (Rule XVIII)
217
+ // context — project-context discovery
218
218
  // ======================================================================================
219
219
 
220
220
  // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
@@ -494,7 +494,7 @@ export function cmdContext(args) {
494
494
  }
495
495
 
496
496
  const lines = [
497
- `=== PROJECT CONTEXT \u2014 binding for this repo per Rule XVIII (via ${source}) ===`,
497
+ `=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`,
498
498
  '',
499
499
  ];
500
500
  for (const entry of eager) {
@@ -525,7 +525,7 @@ export function cmdContext(args) {
525
525
  }
526
526
 
527
527
  // ======================================================================================
528
- // git-gate — Law XX's tool-boundary backstop
528
+ // git-gate — Doctrine process 5's tool-boundary backstop
529
529
  // ======================================================================================
530
530
 
531
531
  // A `git` verb ANYWHERE in the command trips the gate, including the chained
@@ -555,12 +555,13 @@ export function cmdGitGate(args) {
555
555
  return 0;
556
556
  }
557
557
  if (typeof command !== 'string' || !GIT_GATE_RE.test(command)) return 0;
558
- out(askDecision('Geneseed Law XX \u2014 every git commit/push needs explicit approval'));
558
+ out(askDecision('Geneseed Doctrine process 5 \u2014 every git commit/push needs explicit '
559
+ + 'approval'));
559
560
  return 0;
560
561
  }
561
562
 
562
563
  // ======================================================================================
563
- // rule-gate — Law VI's tool-boundary backstop
564
+ // rule-gate — Doctrine process 1's tool-boundary backstop
564
565
  // ======================================================================================
565
566
 
566
567
  /**
@@ -604,8 +605,8 @@ export function cmdRuleGate(args) {
604
605
  if (typeof p !== 'string' || !p) return 0;
605
606
  const target = ruleGateTarget(p, args.root);
606
607
  if (!target) return 0;
607
- out(askDecision(`Geneseed Law VI \u2014 writing to ${target}: a standing rule, or a fact to `
608
- + "remember? That choice is the user's. Run the rule skill first."));
608
+ out(askDecision(`Geneseed Doctrine process 1 \u2014 writing to ${target}: a standing rule, or a `
609
+ + "fact to remember? That choice is the user's. Run the rule skill first."));
609
610
  return 0;
610
611
  }
611
612
 
package/js/installs.mjs CHANGED
@@ -35,7 +35,7 @@
35
35
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
- import { CONFIG, THEMES, discoverNames } from './checkout.mjs';
38
+ import { CONFIG, PACK_ORDER, THEMES, discoverNames } from './checkout.mjs';
39
39
  import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
40
40
  import { registryRoots } from './registry.mjs';
41
41
  import { comparePaths, pyPrintErr, pyRepr, readText } from './lib/fs.mjs';
@@ -91,6 +91,17 @@ export const defaultTheme = () => configuredDefault('theme', 'neutral');
91
91
  export const defaultPosture = () => configuredDefault('posture', 'peer');
92
92
  export const defaultMode = () => configuredDefault('mode', 'direct');
93
93
 
94
+ // There is deliberately no `defaultDoctrines()` beside the three above, and its absence is
95
+ // the invariant. `theme`, `posture` and `mode` may fall back to `harness.config.json` when an
96
+ // install does not say, because the worst a wrong answer does there is cosmetic. The pack
97
+ // selection is not like them: it decides whether the commit/push consent gate is wired, so
98
+ // resolving "this install does not say" out of a MACHINE-WIDE config file narrows every
99
+ // pre-2.3 install on its first upgrade — `{"doctrines":["craft"]}` and the gate is gone from
100
+ // carriers that never asked. Unknown resolves to ALL packs; see `doctrinesForBuild` below.
101
+ // The config value keeps exactly one legitimate reader: `configDefaults()` in
102
+ // `bin/geneseed.mjs`, which answers `geneseed build` — the case where there is no install to
103
+ // ask in the first place.
104
+
94
105
  // ---- theme, footprint, posture, mode, read back off a deployed tree -----------------------
95
106
 
96
107
  /**
@@ -159,7 +170,8 @@ const FOOTPRINTS = ['lean', 'full'];
159
170
  *
160
171
  * `status` reads no footprint row at all; the WARN is why this function is ported there, and
161
172
  * `diff` and `rebuild-all` read the RETURN value as well. An unrecognised marker is reported
162
- * rather than swallowed (Rule VII), and a compared stderr is what makes that observable —
173
+ * rather than swallowed (Rule V, *Surface Failures* — VII before the three-tier renumber, and
174
+ * VII now names *Least Privilege*), and a compared stderr is what makes that observable —
163
175
  * `status/a-bogus-footprint-marker-warns-on-stderr` is the cell.
164
176
  */
165
177
  export function footprintOfDir(d) {
@@ -208,6 +220,76 @@ function leadOfDir(d, names) {
208
220
  export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
209
221
  export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
210
222
 
223
+ /** The `Active packs:` marker line the template emits, in one of the five carriers. */
224
+ const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
225
+
226
+ /**
227
+ * The doctrine packs a DEPLOYED install carries — the third register read back off disk,
228
+ * beside `postureOfDir` and `modeOfDir`, and the first that is a SET.
229
+ *
230
+ * MARKERED IN PROSE, like posture and mode and unlike theme/footprint: `src/AGENT.md.tmpl`
231
+ * writes a literal `Active packs: <list>` line under the doctrines section, so no extra dotfile
232
+ * has to survive a copy. The label is NOT token-substituted, so a themed bundle still answers.
233
+ *
234
+ * ⚠ THREE RETURN STATES, AND THE NULL IS THE SAFETY ONE.
235
+ * - `null` — no marker in any carrier. That is a pre-migration install, or a carrier that
236
+ * never had one, and it means "unknown", NOT "no packs". Every consumer must read it as
237
+ * the process pack being ACTIVE, so the commit/push consent gate stays installed. Write
238
+ * `doctrinesOfDir(root)` into an explicit `=== null` / `??` test — never
239
+ * `doctrinesOfDir(root)?.includes('process')`, which is falsy on `null` and would strip
240
+ * the gate from every existing install on its next upgrade.
241
+ * - `[]` — the marker is present and reads `none`. A deliberate empty selection (invariants
242
+ * and ontology only) and a real configuration: the gate comes OFF.
243
+ * - a list — the packs named, re-ordered through `PACK_ORDER`, because this value is
244
+ * compared against a marker the build writes in that order.
245
+ *
246
+ * A marker line this checkout cannot read WHOLE falls back to `null` rather than to whatever
247
+ * part of it did parse, for the same reason: a corrupt line is unknown, and unknown fails
248
+ * closed. ⚠ "Whole" is the strict word and it is what the first cut of this got wrong. The
249
+ * regex is single-line, so a marker a wrapper had folded — `Active packs: craft, rigor,\nops,
250
+ * process` — matched its first half, every name in that half was known, and the function
251
+ * answered the NARROWED set `['craft','rigor']` with no complaint. That is fail-OPEN against
252
+ * this docblock's own promise: the process pack disappears from an install that has it, and
253
+ * the gate goes with it. So every comma-separated field must be non-empty AND a pack this
254
+ * checkout ships; one that is not condemns the whole line to `null`. A trailing comma is
255
+ * exactly the empty field a fold leaves behind, which is what makes this catch it.
256
+ */
257
+ export function doctrinesOfDir(d) {
258
+ for (const carrier of CARRIERS) {
259
+ const text = readMaybe(path.join(d, carrier));
260
+ if (text === null) continue;
261
+ const m = ACTIVE_PACKS_RE.exec(text);
262
+ if (!m) continue;
263
+ if (m[1] === 'none') return [];
264
+ const named = m[1].split(',').map((s) => s.trim());
265
+ if (!named.every((n) => PACK_ORDER.includes(n))) return null;
266
+ return PACK_ORDER.filter((p) => named.includes(p));
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * `doctrinesOfDir` with the `null` RESOLVED — the one spelling every build-side consumer uses.
273
+ *
274
+ * ⚠ UNKNOWN RESOLVES TO ALL PACKS, NEVER TO A CONFIG VALUE. `doctrinesOfDir`'s docblock puts
275
+ * the rule on the reader; this function is what makes it true of the readers, because the
276
+ * previous shape (`doctrinesOfDir(root) ?? defaultDoctrines()`) left every consumer free to
277
+ * pick its own fallback and all five of them picked `harness.config.json`. With
278
+ * `{"doctrines":["craft"]}` in that file, an `upgrade`/`rebuild-all`/`migrate` re-emitted every
279
+ * pre-2.3 install at ONE pack and took the commit/push consent gate off installs whose owners
280
+ * had never been asked — the exact outcome the fail-closed reading exists to prevent, and it
281
+ * got WIDER when the reader was hardened, because more inputs now answer `null`.
282
+ *
283
+ * A config value is a legitimate default only where there is NO INSTALL TO ASK: a fresh
284
+ * `geneseed build`, which `configDefaults()` in `bin/geneseed.mjs` answers. Anything holding a
285
+ * directory has an install to ask, so it calls this and never the config.
286
+ *
287
+ * `[]` still passes through untouched — a marker that reads `none` is an answer, not a silence.
288
+ */
289
+ export function doctrinesForBuild(d) {
290
+ return doctrinesOfDir(d) ?? [...PACK_ORDER];
291
+ }
292
+
211
293
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
212
294
 
213
295
  /** `_harness_mcp._claude_read_manifest`. */
@@ -333,7 +415,7 @@ export function installTargets() {
333
415
  // ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
334
416
 
335
417
  /**
336
- * `_harness_setup._installed_defaults` — all five keys, as of P5i.
418
+ * `_harness_setup._installed_defaults` — all six keys.
337
419
  *
338
420
  * It carried only `theme` and `emit` for four phases because those are the two `status`
339
421
  * prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
@@ -342,9 +424,20 @@ export function installTargets() {
342
424
  * `inst.posture` / `inst.mode` / `inst.footprint`, so a three-key answer would have silently
343
425
  * pre-selected the CONFIGURED default over the DEPLOYED one on three of its five questions.
344
426
  * Nothing in `status` or `doctor` moves: both read the keys they already read.
427
+ *
428
+ * `doctrines` joined last, for the same reason and one phase later: the wizard's pack question
429
+ * shipped before the marker reader existed, so it defaulted to `all` for everyone and re-asked
430
+ * an installer who had already narrowed their selection. ⚠ THIS KEY IS THE ONE WITH THREE
431
+ * STATES, and the `=== null` tests below preserve all three: `null` is "no carrier on this
432
+ * machine said", `[]` is a carrier that said `none`, a list is a carrier that named packs.
433
+ * Only a WIZARD may read it — it is a pre-selection shown to a human who then answers. No
434
+ * build-side consumer resolves a pack set from here; that is `doctrinesForBuild(dir)`, which
435
+ * asks one named install rather than whichever candidate this walk happens to reach first.
345
436
  */
346
437
  export function installedDefaults() {
347
- const found = { theme: null, posture: null, mode: null, emit: null, footprint: null };
438
+ const found = {
439
+ theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
440
+ };
348
441
  const candidates = [];
349
442
  for (const { host, configDir } of HOSTS) {
350
443
  try { candidates.push([configDir(), `${host}-global`]); } catch { /* as the Python */ }
@@ -373,6 +466,10 @@ export function installedDefaults() {
373
466
  if (found.footprint === null && isFile(path.join(base, '.geneseed-footprint'))) {
374
467
  found.footprint = footprintOfDir(base);
375
468
  }
469
+ // `doctrinesOfDir` and not `doctrinesForBuild`: the resolved form answers `[...PACK_ORDER]`
470
+ // for a carrier with no marker, which would end this walk at the FIRST candidate and hide
471
+ // a later carrier that does say. The null has to survive to the caller here.
472
+ if (found.doctrines === null) found.doctrines = doctrinesOfDir(base);
376
473
  }
377
474
  return found;
378
475
  }
package/js/inventory.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import path from 'node:path';
23
23
 
24
- import { ROOT, SRC, makeCfg } from './checkout.mjs';
24
+ import { ROOT, SRC, makeCfg, PACK_ORDER } from './checkout.mjs';
25
25
  import { firstBlockquote } from './native.mjs';
26
26
  import { renderAll } from './render.mjs';
27
27
  import { pyResolve } from './hosts.mjs';
@@ -29,29 +29,59 @@ import { parseJson, readText } from './lib/fs.mjs';
29
29
 
30
30
  /**
31
31
  * `_harness_tui.LAW_HEADING_RE` — a heading in the RENDERED laws file, e.g.
32
- * `### Rule XVIII — Load the Project Context`.
32
+ * `### Rule IV — Deletion Is Deliberate`.
33
33
  *
34
34
  * Not `js/doctor.mjs`'s `LAW_HEADING_RE`, which matches the `{{LAW}}` token in the
35
35
  * unrendered source. Two patterns, two inputs, two names in Python as well.
36
36
  */
37
37
  export const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
38
38
 
39
+ /**
40
+ * A heading in a RENDERED doctrine pack, e.g. `### Doctrine craft 1 — Automate Repetition`.
41
+ *
42
+ * THE TIER NOUN IS THEMED AND THE ADDRESS IS NOT, exactly as the invariants work: `{{LAW}}`
43
+ * plus a Roman numeral there, `{{DOCTRINE}}` plus `<pack> <n>` here. So `\S+` for the noun —
44
+ * ⚠ which is why a theme's `DOCTRINE` value must be ONE WORD, and why `doctor` asserts it in
45
+ * all fourteen. A two-word noun does not error; it silently stops matching, and a pack quietly
46
+ * parses to zero rules.
47
+ *
48
+ * It cannot collide with `LAW_HEADING_RE` in either direction and the reason is case:
49
+ * `([a-z]+)` refuses `Rule I`'s uppercase numeral, and `([IVXLCDM]+)` refuses a lowercase pack
50
+ * id. Measured across all fourteen themes — zero cross-matches either way.
51
+ */
52
+ export const DOCTRINE_HEADING_RE = /^###\s+\S+\s+([a-z]+)\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
53
+
54
+ /**
55
+ * A section heading in the RENDERED ontology, e.g. `#### Telos`.
56
+ *
57
+ * FOUR HASHES, and that is the whole reason the ontology can share `universal.md`'s basename
58
+ * with the laws file without either parser seeing the other's headings. The section names come
59
+ * from `STRUCTURE` rather than from a theme, so the id below is stable across all fourteen
60
+ * voices and is safe to put in a deep link.
61
+ */
62
+ export const ONTOLOGY_HEADING_RE = /^####\s+(.+?)\s*$/;
63
+
64
+ /** The lead line of a pack file — `**Craft** — how code is written.` */
65
+ const PACK_LEAD_RE = /^\*\*(.+?)\*\*\s+[—-]\s+(.+?)\s*$/;
66
+
39
67
  /** `_harness_tui.LAW_CLASS` — each rule's class, keyed by Roman numeral. */
40
68
  export const LAW_CLASS = {
41
- I: 'security', II: 'process', III: 'verify', IV: 'security', V: 'craft',
42
- VI: 'context', VII: 'verify', VIII: 'comms', IX: 'comms', X: 'comms',
43
- XI: 'craft', XII: 'craft', XIII: 'craft', XIV: 'process', XV: 'process',
44
- XVI: 'context', XVII: 'context', XVIII: 'context', XIX: 'context', XX: 'security',
45
- XXI: 'process', XXII: 'security', XXIII: 'security', XXIV: 'craft', XXV: 'craft',
46
- XXVI: 'craft', XXVII: 'verify', XXVIII: 'process', XXIX: 'comms', XXX: 'comms',
47
- XXXI: 'comms', XXXII: 'craft', XXXIII: 'craft', XXXIV: 'verify', XXXV: 'verify',
48
- XXXVI: 'security', XXXVII: 'process',
69
+ I: 'security', II: 'process', III: 'verify', IV: 'security', V: 'verify',
70
+ VI: 'security', VII: 'security', VIII: 'craft', IX: 'security',
49
71
  };
50
72
 
51
73
  /** `_harness_tui.ENTITY_STATUSES` — the lifecycle statuses `registry.json` may carry. */
52
74
  export const ENTITY_STATUSES = ['experimental', 'approved', 'deprecated'];
53
75
 
54
- /** `_harness_tui.LAW_CLASSES` — the six governance classes a law may carry. */
76
+ /**
77
+ * `_harness_tui.LAW_CLASSES` — the six governance classes a law may carry.
78
+ *
79
+ * ⚠ SIX, THOUGH ONLY FOUR ARE IN USE. When the corpus became nine invariants, `context` and
80
+ * `comms` stopped having a member — that material moved to the ontology and the doctrine packs,
81
+ * neither of which is classed here. The list stays six because it is the VOCABULARY, not a
82
+ * census: `doctor` quotes it verbatim in the message a bad class earns, and two recorded cells
83
+ * in `tests/helpers/matrix/cli.*.json` hold that message byte for byte.
84
+ */
55
85
  export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context', 'comms'];
56
86
 
57
87
  /** `_harness_tui.SKILL_CLASS` — each skill's category, keyed by file stem. */
@@ -105,6 +135,68 @@ export function parseLaws(text) {
105
135
  return laws;
106
136
  }
107
137
 
138
+ /**
139
+ * One rendered doctrine pack file split into `{pack, n, title, klass, body}` rows.
140
+ *
141
+ * Same accumulator as `parseLaws`, and deliberately so: text before the first heading — the
142
+ * pack's own lead line — is dropped on the floor because `cur` is still null, and each body is
143
+ * trimmed at its ends only.
144
+ *
145
+ * `klass` IS THE PACK ID, not one of `LAW_CLASSES`. A doctrine rule is grouped by the pack it
146
+ * belongs to and there is no second taxonomy over it; the six-class chip is the invariants'.
147
+ * The collision with the class names `craft` and `process` is real and harmless — the two
148
+ * lists are separate end to end and nothing ever unions them.
149
+ *
150
+ * `pack` is read from the HEADING and not from the filename, so a rule filed under the wrong
151
+ * pack is visible rather than silently renamed by its container. `doctor` is what compares the
152
+ * two; this function just reports what the text says.
153
+ */
154
+ export function parseDoctrines(text) {
155
+ const rules = [];
156
+ let cur = null;
157
+ for (const line of text.split('\n')) {
158
+ const m = DOCTRINE_HEADING_RE.exec(line);
159
+ if (m) {
160
+ if (cur) rules.push(cur);
161
+ cur = { pack: m[1], n: Number(m[2]), title: m[3], klass: m[1], body: '' };
162
+ } else if (cur !== null) {
163
+ cur.body += `${line}\n`;
164
+ }
165
+ }
166
+ if (cur) rules.push(cur);
167
+ for (const rule of rules) rule.body = rule.body.trim();
168
+ return rules;
169
+ }
170
+
171
+ /**
172
+ * The rendered ontology split into `{id, title, body}` sections.
173
+ *
174
+ * `id` is the slugified title, and it is an ADDRESS: `#/item/law/ont:telos` is how the console
175
+ * deep-links a section, so it has to survive a theme change. It does, because the four titles
176
+ * come from `STRUCTURE` in `js/render.mjs` and not from any theme file — the same reason a
177
+ * citation can spell itself `({{ONTOLOGY}}: {{ONT_TELOS}})` with a token on both sides.
178
+ *
179
+ * The preamble above the first `####` is dropped, as in the two parsers above. It is the
180
+ * paragraph that states the tier order, and it renders into AGENT.md whole — the catalogue
181
+ * lists sections, and the preamble is not one.
182
+ */
183
+ export function parseOntology(text) {
184
+ const sections = [];
185
+ let cur = null;
186
+ for (const line of text.split('\n')) {
187
+ const m = ONTOLOGY_HEADING_RE.exec(line);
188
+ if (m) {
189
+ if (cur) sections.push(cur);
190
+ cur = { id: m[1].toLowerCase().replace(/\s+/g, '-'), title: m[1], body: '' };
191
+ } else if (cur !== null) {
192
+ cur.body += `${line}\n`;
193
+ }
194
+ }
195
+ if (cur) sections.push(cur);
196
+ for (const s of sections) s.body = s.body.trim();
197
+ return sections;
198
+ }
199
+
108
200
  /**
109
201
  * `_harness_tui.load_registry` — the `entities` map from `registry.json`, or `{}`.
110
202
  *
@@ -143,33 +235,71 @@ export function entityStatus(registry, key) {
143
235
  }
144
236
 
145
237
  /**
146
- * `_harness_tui._tui_inventory` — render-accurate inventory: every agent and skill with
147
- * its one-line purpose, full rendered spec, source path and lifecycle badge, and every law
148
- * with its title, body and governance class.
238
+ * `_harness_tui._tui_inventory` — render-accurate inventory of all three constitutional tiers,
239
+ * plus every agent and skill with its one-line purpose, rendered spec, source and badge.
240
+ *
241
+ * THE WHOLE CATALOGUE, NOT THE BUILT SUBSET. All four pack files ship in every bundle
242
+ * (`OWNED_SRC_DIRS`), which is what lets a cross-pack citation resolve when its pack is not
243
+ * built into AGENT.md — and it is also what lets the console show an inactive pack greyed out
244
+ * with the command that would enable it. So the packs are always all parsed; `doctrines` only
245
+ * decides which of them carry `active: true`. Passing `null` — the default, and what `catalog`,
246
+ * `status` and the graph do — means "no install to ask", and every pack reads active, matching
247
+ * `makeCfg()`'s own default and `doctrinesForBuild`'s resolution of unknown.
248
+ *
249
+ * `laws` KEEPS ITS NAME and means the invariants. Renaming it to `invariants` would churn
250
+ * `status`, `catalog`, `tui`, the web API, its `SECTIONS` id, three test fixtures and two
251
+ * recorded corpora to say the same thing in a different word.
149
252
  */
150
- export function tuiInventory(themeName) {
253
+ export function tuiInventory(themeName, doctrines = null) {
151
254
  const { items } = renderAll(makeCfg(), themeName);
152
255
  const registry = loadRegistry();
153
256
  const agents = [];
154
257
  const skills = [];
155
258
  let laws = [];
259
+ let ontology = [];
260
+ const packs = new Map();
156
261
  for (const { text, src } of items) {
157
262
  if (text === null) continue;
158
263
  const parts = path.relative(SRC, src).split(path.sep);
159
- if (parts.length === 2 && parts[1].endsWith('.md') && !parts[1].startsWith('_')) {
160
- const name = parts[1].slice(0, -3);
264
+ const two = parts.length === 2 && parts[1].endsWith('.md') && !parts[1].startsWith('_');
265
+ const name = two ? parts[1].slice(0, -3) : null;
266
+ if (two && (parts[0] === 'agents' || parts[0] === 'skills')) {
161
267
  const entry = { name, desc: firstBlockquote(text), body: text,
162
268
  source: pyResolve(src), status: entityStatus(registry, `${parts[0]}/${name}`) };
163
269
  if (parts[0] === 'agents') agents.push(entry);
164
- else if (parts[0] === 'skills') {
270
+ else {
165
271
  entry.klass = has(SKILL_CLASS, name) ? SKILL_CLASS[name] : 'build';
166
272
  skills.push(entry);
167
273
  }
168
274
  }
169
- if (parts[parts.length - 1] === 'universal.md') laws = parseLaws(text);
275
+ // Every `.md` in the dir is read; `PACK_ORDER` below decides which of them is a pack. The
276
+ // dir also holds a `README.md`, which passes the `_`-scaffold filter, and a second
277
+ // membership test here would be a second owner of the same rule — one that has to agree
278
+ // with the one that actually publishes the list.
279
+ if (two && parts[0] === 'doctrines') {
280
+ const lead = text.split('\n').map((l) => PACK_LEAD_RE.exec(l)).find(Boolean);
281
+ packs.set(name, { pack: name, title: lead ? lead[1] : name, desc: lead ? lead[2] : '',
282
+ source: pyResolve(src), rules: parseDoctrines(text) });
283
+ }
284
+ // Scoped to the laws dir, not matched on the basename: `src/ontology/universal.md`
285
+ // shares the name, sorts after `laws/`, and would clobber the parse with `[]` —
286
+ // `LAW_HEADING_RE` wants `###` and the ontology uses `####`. Same spelling as the
287
+ // already-scoped sites at `js/render.mjs`'s `rel === 'laws/universal.md'`.
288
+ if (parts.join('/') === 'laws/universal.md') laws = parseLaws(text);
289
+ if (parts.join('/') === 'ontology/universal.md') ontology = parseOntology(text);
170
290
  }
171
291
  const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
172
292
  agents.sort(byName);
173
293
  skills.sort(byName);
174
- return { agents, skills, laws, theme: themeName };
294
+ // ⚠ `PACK_ORDER` IS THE SINGLE OWNER OF THE SET, and of its order. Filtering the order by
295
+ // what was found — rather than publishing what was found — is what keeps `README.md` and any
296
+ // other stray `.md` in that directory out of the catalogue, and it is the same membership
297
+ // rule `js/render.mjs` refuses the build over, so a fifth pack cannot be silently dropped
298
+ // here either. Order because `sortedSourceFiles` walks alphabetically, which puts `ops`
299
+ // second: the narrative order is the reading order, and it is what the marker line, the
300
+ // wizard and the rendered section all use.
301
+ const on = Array.isArray(doctrines) ? doctrines : PACK_ORDER;
302
+ const built = PACK_ORDER.filter((p) => packs.has(p))
303
+ .map((p) => ({ ...packs.get(p), active: on.includes(p) }));
304
+ return { agents, skills, laws, ontology, doctrines: built, theme: themeName };
175
305
  }
package/js/lib/fs.mjs CHANGED
@@ -281,10 +281,14 @@ export const normcase = process.platform === 'win32'
281
281
  * flat cmp : the reverse again
282
282
  *
283
283
  * Nothing in the emitted tree could see it: `js/render.mjs` sorts a list whose members share
284
- * a directory depth, and `golden.py` compares CONTENT rather than order. It became visible
284
+ * a directory depth, and `golden.py` compared CONTENT rather than order. It became visible
285
285
  * the first time a full path list was PRINTED \u2014 `geneseed validate -v` \u2014 against a real
286
286
  * skills tree that happens to contain both `skills/geneseed/` and `skills/geneseed-code-review/`.
287
- * `tests/test_maintainer_tools_parity.py` is the gate that found it and the one that holds it.
287
+ * `tests/test_maintainer_tools_parity.py` found it and died with the reference it compared
288
+ * against. What HOLDS it now is `tests/unit/maintainer_tools.test.mjs`, and specifically its
289
+ * `theme files are visited in THIS platform's Path collation order` test: it re-derives Python's
290
+ * `sorted(Path)` order for the running platform instead of replaying a recording, so it cannot
291
+ * agree with a drift \u2014 and it is the measured killer of this function's mutation (M3).
288
292
  */
289
293
  export function comparePaths(a, b) {
290
294
  const A = normcase(a).split(/[\\/]/);
package/js/mcp.mjs CHANGED
@@ -75,8 +75,9 @@ export const MCP_PRESETS = {
75
75
  label: 'GitLab',
76
76
  desc: 'GitLab repo / MR / issue / CI tools via @zereight/mcp-gitlab (npx, no '
77
77
  + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
78
- + 'and GITLAB_API_URL before use. Add a second entry (gitlab-2) for another '
79
- + 'instance.',
78
+ + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
79
+ + 'copy this block by hand under a new name and point it at the other URL — '
80
+ + 'SETUP.md has the pattern.',
80
81
  block: {
81
82
  type: 'local',
82
83
  command: ['npx', '-y', '@zereight/mcp-gitlab'],
@@ -85,19 +86,6 @@ export const MCP_PRESETS = {
85
86
  enabled: true,
86
87
  },
87
88
  },
88
- 'gitlab-2': {
89
- label: 'GitLab (2nd instance)',
90
- desc: 'A second GitLab instance (e.g. a self-hosted server) via the same '
91
- + '@zereight/mcp-gitlab command. Point GITLAB_API_URL at the other instance '
92
- + "and give it that instance's own token.",
93
- block: {
94
- type: 'local',
95
- command: ['npx', '-y', '@zereight/mcp-gitlab'],
96
- environment: { GITLAB_PERSONAL_ACCESS_TOKEN: '',
97
- GITLAB_API_URL: 'https://gitlab.example.com/api/v4' },
98
- enabled: true,
99
- },
100
- },
101
89
  filesystem: {
102
90
  label: 'Filesystem',
103
91
  desc: 'Read/write files under explicitly allowed directories via '
package/js/migrate.mjs CHANGED
@@ -35,8 +35,8 @@ import path from 'node:path';
35
35
  import { main as driverMain } from '../bin/geneseed.mjs';
36
36
  import { ROOT } from './checkout.mjs';
37
37
  import {
38
- EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, footprintOfDir, installState,
39
- installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
38
+ EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
39
+ footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
40
40
  } from './installs.mjs';
41
41
  import { DEFAULT_EMIT, setupBuildArgs } from './generate.mjs';
42
42
  import {
@@ -206,7 +206,12 @@ export function cmdMigrate(args = {}) {
206
206
  try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
207
207
  };
208
208
 
209
- // Step 3 — re-emit, in each install's OWN five values. Same reads as `cmdRebuildAll`.
209
+ // Step 3 — re-emit, in each install's OWN six values. Same reads as `cmdRebuildAll`, and
210
+ // the pack selection is the sixth: leaving it out re-emitted a narrowed install at whatever
211
+ // `harness.config.json` said, which WIDENS a constitution its owner had cut down (and, the
212
+ // other way, can strip the consent gate). `null` — a pre-marker carrier, which on a MIGRATION
213
+ // is the common case and not the rare one — resolves to ALL packs through
214
+ // `doctrinesForBuild`, never to `harness.config.json`, which is what makes it fail-closed.
210
215
  for (const r of rows) {
211
216
  const marker = r.marker && (EMIT_HOST_SCOPE.get(r.marker) ?? ['', ''])[0] === r.host
212
217
  ? r.marker : '';
@@ -214,7 +219,8 @@ export function cmdMigrate(args = {}) {
214
219
  const theme = themeOfDir(r.root) || defaultTheme();
215
220
  const out = r.scope === 'global' ? null : r.root;
216
221
  const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
217
- postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode());
222
+ postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
223
+ doctrinesForBuild(r.root));
218
224
  const label = `${r.host}:${r.scope} (${r.root})`;
219
225
  pyPrint(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
220
226
  let rc = 1;