geneseed 2.1.0 → 3.1.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 (187) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/DESIGN.md +112 -61
  3. package/QUICKSTART.md +1 -1
  4. package/README.md +24 -14
  5. package/SETUP.md +83 -17
  6. package/SHIPPED.md +5 -4
  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/plugins/geneseed-context.js +7 -7
  16. package/adapters/opencode/plugins/geneseed-guard.js +6 -6
  17. package/adapters/opencode/workflows/dispatch.js +1 -1
  18. package/bin/geneseed.mjs +72 -6
  19. package/docs/extending.md +164 -55
  20. package/docs/limits.md +4 -4
  21. package/docs/token-footprint.md +15 -4
  22. package/docs/web/authoring.md +1 -1
  23. package/docs/web/claude-hooks.md +2 -2
  24. package/docs/web/collaboration.md +3 -3
  25. package/docs/web/first-session.md +2 -2
  26. package/docs/web/footprint.md +14 -7
  27. package/docs/web/foreman-mode.md +2 -2
  28. package/docs/web/harnesses.md +5 -3
  29. package/docs/web/install-quick.md +1 -1
  30. package/docs/web/model.md +3 -3
  31. package/docs/web/plugin-context.md +2 -2
  32. package/docs/web/plugin-guard.md +3 -3
  33. package/docs/web/plugin-ponytail.md +1 -1
  34. package/docs/web/rules.md +23 -3
  35. package/docs/web/setup-choices.md +20 -7
  36. package/docs/web/themes-author.md +13 -1
  37. package/docs/web-ui.md +36 -12
  38. package/harness.config.json +1 -1
  39. package/js/catalog.mjs +29 -4
  40. package/js/checkout.mjs +24 -1
  41. package/js/cli-table.json +8 -6
  42. package/js/diff.mjs +8 -3
  43. package/js/doctor.mjs +307 -4
  44. package/js/emit.mjs +48 -11
  45. package/js/generate.mjs +39 -5
  46. package/js/hooks.mjs +8 -7
  47. package/js/installs.mjs +101 -4
  48. package/js/inventory.mjs +150 -21
  49. package/js/migrate.mjs +10 -4
  50. package/js/render.mjs +110 -7
  51. package/js/settings.mjs +218 -27
  52. package/js/setup.mjs +95 -5
  53. package/js/status.mjs +45 -4
  54. package/js/tui.mjs +44 -1
  55. package/js/uninstall.mjs +25 -6
  56. package/js/web/actions.mjs +56 -5
  57. package/js/web/api.mjs +102 -16
  58. package/js/web/docs.mjs +33 -5
  59. package/js/web/jobs.mjs +12 -3
  60. package/js/web/server.mjs +6 -0
  61. package/package.json +1 -1
  62. package/src/AGENT.md.tmpl +75 -65
  63. package/src/agents/_template.md +4 -4
  64. package/src/agents/advocate.md +4 -4
  65. package/src/agents/architect.md +2 -2
  66. package/src/agents/developer.md +5 -5
  67. package/src/agents/docs.md +2 -2
  68. package/src/agents/empiricist.md +3 -3
  69. package/src/agents/explorer.md +3 -3
  70. package/src/agents/framer.md +3 -3
  71. package/src/agents/historian.md +3 -3
  72. package/src/agents/operator.md +3 -3
  73. package/src/agents/pragmatist.md +3 -3
  74. package/src/agents/reviewer.md +2 -2
  75. package/src/agents/security.md +2 -2
  76. package/src/agents/skeptic.md +4 -4
  77. package/src/agents/steward.md +3 -3
  78. package/src/agents/tester.md +2 -2
  79. package/src/agents/user-advocate.md +3 -3
  80. package/src/agents/visionary.md +3 -3
  81. package/src/doctrines/README.md +41 -0
  82. package/src/doctrines/craft.md +51 -0
  83. package/src/doctrines/ops.md +61 -0
  84. package/src/doctrines/process.md +87 -0
  85. package/src/doctrines/rigor.md +52 -0
  86. package/src/laws/universal.md +17 -336
  87. package/src/memory/README.md +2 -2
  88. package/src/modes/README.md +2 -2
  89. package/src/modes/foreman.md +1 -1
  90. package/src/notebook/README.md +2 -2
  91. package/src/ontology/universal.md +89 -0
  92. package/src/postures/README.md +3 -3
  93. package/src/postures/artisan.md +3 -3
  94. package/src/postures/assistant.md +1 -1
  95. package/src/postures/expert.md +1 -1
  96. package/src/postures/mentor.md +1 -1
  97. package/src/postures/peer.md +2 -2
  98. package/src/skills/_template.md +2 -2
  99. package/src/skills/brainstorm.md +2 -2
  100. package/src/skills/clarify.md +2 -2
  101. package/src/skills/codebase-design.md +1 -1
  102. package/src/skills/commit.md +5 -5
  103. package/src/skills/council.md +4 -4
  104. package/src/skills/crash-course.md +1 -1
  105. package/src/skills/daydream/SKILL.md +1 -1
  106. package/src/skills/debug.md +2 -2
  107. package/src/skills/decode.md +1 -1
  108. package/src/skills/develop.md +6 -6
  109. package/src/skills/document-project.md +4 -4
  110. package/src/skills/domain-modeling.md +1 -1
  111. package/src/skills/drill.md +1 -1
  112. package/src/skills/feynman.md +1 -1
  113. package/src/skills/forge-mcp.md +3 -3
  114. package/src/skills/fresh-eyes.md +4 -4
  115. package/src/skills/frontend-design.md +1 -1
  116. package/src/skills/gap-detector.md +1 -1
  117. package/src/skills/geneseed-code-review.md +1 -1
  118. package/src/skills/geneseed.md +4 -4
  119. package/src/skills/git-archaeology.md +1 -1
  120. package/src/skills/git-rescue.md +3 -3
  121. package/src/skills/handoff.md +4 -4
  122. package/src/skills/herdr.md +1 -1
  123. package/src/skills/ingest.md +3 -3
  124. package/src/skills/learning-path.md +1 -1
  125. package/src/skills/migrate.md +5 -5
  126. package/src/skills/opencode-theme.md +1 -1
  127. package/src/skills/parallel-agents.md +2 -2
  128. package/src/skills/pipeline.md +2 -2
  129. package/src/skills/plan.md +2 -2
  130. package/src/skills/ponytail.md +1 -1
  131. package/src/skills/profile.md +1 -1
  132. package/src/skills/prose.md +2 -2
  133. package/src/skills/prototype.md +2 -2
  134. package/src/skills/refactor.md +2 -2
  135. package/src/skills/release.md +4 -4
  136. package/src/skills/repo-map.md +5 -5
  137. package/src/skills/research.md +6 -6
  138. package/src/skills/review-response.md +3 -3
  139. package/src/skills/roast-me.md +1 -1
  140. package/src/skills/rule.md +5 -5
  141. package/src/skills/ship.md +3 -3
  142. package/src/skills/tdd.md +2 -2
  143. package/src/skills/tickets.md +1 -1
  144. package/src/skills/wayfinder.md +1 -1
  145. package/src/skills/wiki.md +4 -4
  146. package/src/skills/workflow.md +2 -2
  147. package/themes/README.md +17 -7
  148. package/themes/_TEMPLATE.json +37 -36
  149. package/themes/biker.json +37 -36
  150. package/themes/commentator.json +37 -36
  151. package/themes/cyberpunk.json +37 -36
  152. package/themes/gamer.json +37 -36
  153. package/themes/imperial.json +37 -36
  154. package/themes/joker.json +37 -36
  155. package/themes/marvin.json +37 -36
  156. package/themes/mean.json +37 -36
  157. package/themes/military.json +37 -36
  158. package/themes/neutral.json +37 -36
  159. package/themes/pirate.json +37 -36
  160. package/themes/sports.json +37 -36
  161. package/themes/verstappen.json +37 -36
  162. package/themes/wizard.json +37 -36
  163. package/web/dist/assets/{About-BYXlmbyo.js → About-_MnJcwb_.js} +1 -1
  164. package/web/dist/assets/{Activity-us5MPf4u.js → Activity-DpjqO-FR.js} +1 -1
  165. package/web/dist/assets/{ActivityDetail-CiaQXUk2.js → ActivityDetail-Bvrjv8oW.js} +1 -1
  166. package/web/dist/assets/{Diff-Bh0qbZ2s.js → Diff-DZ0IehBV.js} +1 -1
  167. package/web/dist/assets/{Doctor-BIlQME45.js → Doctor-cu4krooO.js} +1 -1
  168. package/web/dist/assets/{Graph-4dYD1dPG.js → Graph-DUOPY65j.js} +1 -1
  169. package/web/dist/assets/{Harnesses-PzYZ3LOC.js → Harnesses-B5Osg-BF.js} +1 -1
  170. package/web/dist/assets/Laws-D8DJgTBp.js +3 -0
  171. package/web/dist/assets/{Library-CjMbksJW.js → Library-8y69I3_z.js} +1 -1
  172. package/web/dist/assets/{Markdown-Bk81c3aZ.js → Markdown-Ds5GS1OD.js} +1 -1
  173. package/web/dist/assets/{Profile-Curui_-j.js → Profile-7Jxa7ZUX.js} +1 -1
  174. package/web/dist/assets/{Rules-Bm283wdv.js → Rules-UZ0jWkjg.js} +1 -1
  175. package/web/dist/assets/{Skills-gruqYuhl.js → Skills-Bgkl0xLd.js} +1 -1
  176. package/web/dist/assets/{StatusBadge-EiM_FPqv.js → StatusBadge-Cx4caF_2.js} +1 -1
  177. package/web/dist/assets/{Themes-DU99j4Hr.js → Themes-De8az7Sv.js} +1 -1
  178. package/web/dist/assets/{activity-0rvQ6Vra.js → activity-VDAdVQ3q.js} +1 -1
  179. package/web/dist/assets/index-1pPSz7Kl.css +1 -0
  180. package/web/dist/assets/{index-0b2hobOo.js → index-BKrjBRV4.js} +1 -1
  181. package/web/dist/assets/index-C-gb-q-r.js +12 -0
  182. package/web/dist/assets/{index-IiGFmCW_.js → index-wNiNzFFQ.js} +1 -1
  183. package/web/dist/index.html +2 -2
  184. package/web/src/pages/Laws.jsx +401 -93
  185. package/web/dist/assets/Laws-DtZweXal.js +0 -1
  186. package/web/dist/assets/index-D5VY3TY_.js +0 -12
  187. package/web/dist/assets/index-DUCsDv7z.css +0 -1
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,30 +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', XXXVIII: 'verify', XXXIX: 'comms',
49
- XL: 'comms',
69
+ I: 'security', II: 'process', III: 'verify', IV: 'security', V: 'verify',
70
+ VI: 'security', VII: 'security', VIII: 'craft', IX: 'security',
50
71
  };
51
72
 
52
73
  /** `_harness_tui.ENTITY_STATUSES` — the lifecycle statuses `registry.json` may carry. */
53
74
  export const ENTITY_STATUSES = ['experimental', 'approved', 'deprecated'];
54
75
 
55
- /** `_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
+ */
56
85
  export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context', 'comms'];
57
86
 
58
87
  /** `_harness_tui.SKILL_CLASS` — each skill's category, keyed by file stem. */
@@ -106,6 +135,68 @@ export function parseLaws(text) {
106
135
  return laws;
107
136
  }
108
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
+
109
200
  /**
110
201
  * `_harness_tui.load_registry` — the `entities` map from `registry.json`, or `{}`.
111
202
  *
@@ -144,33 +235,71 @@ export function entityStatus(registry, key) {
144
235
  }
145
236
 
146
237
  /**
147
- * `_harness_tui._tui_inventory` — render-accurate inventory: every agent and skill with
148
- * its one-line purpose, full rendered spec, source path and lifecycle badge, and every law
149
- * 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.
150
252
  */
151
- export function tuiInventory(themeName) {
253
+ export function tuiInventory(themeName, doctrines = null) {
152
254
  const { items } = renderAll(makeCfg(), themeName);
153
255
  const registry = loadRegistry();
154
256
  const agents = [];
155
257
  const skills = [];
156
258
  let laws = [];
259
+ let ontology = [];
260
+ const packs = new Map();
157
261
  for (const { text, src } of items) {
158
262
  if (text === null) continue;
159
263
  const parts = path.relative(SRC, src).split(path.sep);
160
- if (parts.length === 2 && parts[1].endsWith('.md') && !parts[1].startsWith('_')) {
161
- 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')) {
162
267
  const entry = { name, desc: firstBlockquote(text), body: text,
163
268
  source: pyResolve(src), status: entityStatus(registry, `${parts[0]}/${name}`) };
164
269
  if (parts[0] === 'agents') agents.push(entry);
165
- else if (parts[0] === 'skills') {
270
+ else {
166
271
  entry.klass = has(SKILL_CLASS, name) ? SKILL_CLASS[name] : 'build';
167
272
  skills.push(entry);
168
273
  }
169
274
  }
170
- 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);
171
290
  }
172
291
  const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
173
292
  agents.sort(byName);
174
293
  skills.sort(byName);
175
- 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 };
176
305
  }
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;
package/js/render.mjs CHANGED
@@ -29,13 +29,20 @@
29
29
  import { readdirSync, statSync, existsSync } from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import { readText, parseJson, normcase, comparePaths } from './lib/fs.mjs';
32
+ // `js/checkout.mjs` imports nothing but node builtins, so this direction cannot cycle.
33
+ import { PACK_ORDER } from './checkout.mjs';
32
34
 
33
35
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
34
36
  export const STRUCTURE = {
35
37
  HARNESS: 'Geneseed', CHARTER: 'Charter', CONTEXT: 'Context',
36
38
  SCRIPT: 'Script', SCRIPTS: 'Scripts',
37
39
  DIR_LAWS: 'laws', DIR_AGENTS: 'agents', DIR_SKILLS: 'skills', DIR_MEMORY: 'memory',
38
- DIR_NOTEBOOK: 'notebook',
40
+ DIR_NOTEBOOK: 'notebook', DIR_ONTOLOGY: 'ontology', DIR_DOCTRINES: 'doctrines',
41
+ // Ontology section names. Theme-INDEPENDENT on purpose: a citation reads
42
+ // `({{ONTOLOGY}}: {{ONT_TELOS}})` — token on both sides, so the heading and every
43
+ // reference to it move together and a rename can never desync them.
44
+ ONT_TELOS: 'Telos', ONT_EVIDENCE: 'Evidence', ONT_DECISIONS: 'Decisions',
45
+ ONT_CONDUCT: 'Conduct',
39
46
  };
40
47
 
41
48
  /**
@@ -46,11 +53,15 @@ export const STRUCTURE = {
46
53
  export const SRC_DIR_TOKENS = {
47
54
  laws: 'DIR_LAWS', agents: 'DIR_AGENTS', skills: 'DIR_SKILLS',
48
55
  memory: 'DIR_MEMORY', notebook: 'DIR_NOTEBOOK',
56
+ ontology: 'DIR_ONTOLOGY', doctrines: 'DIR_DOCTRINES',
49
57
  };
50
58
 
51
59
  const TEXT_SUFFIXES = new Set(['.md', '.tmpl', '.json', '.txt', '.yml', '.yaml']);
52
60
 
53
- const TOKEN_RE = /\{\{([A-Z_]+)\}\}/g;
61
+ // Digits are legal after the first character: `DOC_<PACK>_<n>` (the doctrine rule titles) are
62
+ // the first token names in the project to carry one, and without `0-9` here `{{DOC_CRAFT_1}}`
63
+ // silently never substitutes. A leading digit stays illegal, so `{{1}}` is still not a token.
64
+ const TOKEN_RE = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
54
65
  const INCLUDE_RE = /^[ \t]*<!--[ \t]*INCLUDE:[ \t]*(?<path>[^ \t]+)[ \t]*-->[ \t]*$/gm;
55
66
  const CATALOG_BLOCK_RE =
56
67
  /[ \t]*<!-- CATALOG:begin -->\n(?<table>[\s\S]*?)[ \t]*<!-- CATALOG:else -->\n(?<pointer>[\s\S]*?)[ \t]*<!-- CATALOG:end -->\n/g;
@@ -152,8 +163,18 @@ function splitAtLawHeadings(text) {
152
163
  return out;
153
164
  }
154
165
 
155
- /** `_build_render._terse_laws`. */
156
- export function terseLaws(text, theme, lawsPrefix = '') {
166
+ /**
167
+ * `_terse_laws` minus its pointer paragraph — heading + first sentence, per `### ` block.
168
+ *
169
+ * Split out rather than parameterised. The pointer was baked into the function body, and
170
+ * the doctrines axis needs the same truncation under a DIFFERENT closing paragraph (one
171
+ * that names the pack catalogue, not `laws/universal.md`, and drops the
172
+ * secrets/deletion/git-history clause, which is the invariants' domain and not a pack's).
173
+ * An extraction leaves `terseLaws` byte-identical for the laws path — the recorded lean
174
+ * AGENT.md does not move — where a fourth parameter would have put a branch inside the one
175
+ * function every frozen lean render already goes through.
176
+ */
177
+ function terseBlocks(text) {
157
178
  const blocks = splitAtLawHeadings(text);
158
179
  const out = [blocks[0].trimEnd()];
159
180
  for (const b of blocks.slice(1)) {
@@ -169,6 +190,11 @@ export function terseLaws(text, theme, lawsPrefix = '') {
169
190
  const m = /^([\s\S]+?[.!?])(?:\s|$)/.exec(body);
170
191
  out.push(`${heading}\n${m ? m[1].trim() : body}`);
171
192
  }
193
+ return out.join('\n\n');
194
+ }
195
+
196
+ /** `_build_render._terse_laws`. */
197
+ export function terseLaws(text, theme, lawsPrefix = '') {
172
198
  const law = theme.LAW ?? 'Law';
173
199
  const lawsDir = theme.DIR_LAWS ?? 'laws';
174
200
  const pointer =
@@ -176,7 +202,7 @@ export function terseLaws(text, theme, lawsPrefix = '') {
176
202
  + `complete, binding text of every ${law} is in \`${lawsPrefix}${lawsDir}/universal.md\`; `
177
203
  + `read it whenever a ${law}'s application is unclear, and before any act touching `
178
204
  + `secrets, deletion, git history, scope, or untrusted content.`;
179
- return out.join('\n\n') + '\n\n' + pointer;
205
+ return terseBlocks(text) + '\n\n' + pointer;
180
206
  }
181
207
 
182
208
  /** `_build_render.render_file`. */
@@ -213,6 +239,75 @@ function registerBody(cfg, theme, dir, selected, fallback) {
213
239
  return '';
214
240
  }
215
241
 
242
+ /**
243
+ * The doctrine packs this build actually renders, in `PACK_ORDER` — and the two gates
244
+ * that stand between `cfg.doctrines` and a silently wrong AGENT.md.
245
+ *
246
+ * GATE 1 (stray pack): a `.md` under `src/doctrines/` that `PACK_ORDER` does not name is a
247
+ * refusal, not a skip. Discovery sorts alphabetically and `PACK_ORDER` does not, so the two
248
+ * cannot be the same list; without this gate a fifth pack file would render into no install
249
+ * and no test would say so.
250
+ *
251
+ * GATE 2 (missing file): a pack named in `cfg.doctrines` with no file is a refusal too. The
252
+ * CLI validates `--doctrines` against discovery, but `makeCfg({doctrines})` is a public
253
+ * entry point and an emit that quietly dropped a pack the user asked for is exactly the
254
+ * failure the `Active packs:` marker would then attest to.
255
+ *
256
+ * Both throw with `exitCode`, the marker `bin/geneseed.mjs`'s `main` turns into a status.
257
+ */
258
+ function activeDoctrines(cfg) {
259
+ const dir = path.join(cfg.src, 'doctrines');
260
+ const onDisk = existsSync(dir)
261
+ ? readdirSync(dir)
262
+ .filter((f) => f.endsWith('.md') && path.basename(f, '.md').toLowerCase() !== 'readme')
263
+ .map((f) => path.basename(f, '.md'))
264
+ : [];
265
+ const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
266
+ if (stray.length) {
267
+ const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
268
+ + 'but are missing from PACK_ORDER (js/checkout.mjs) — add them there, or they render '
269
+ + 'into no install at all');
270
+ e.exitCode = 1;
271
+ throw e;
272
+ }
273
+ const wanted = cfg.doctrines ?? PACK_ORDER;
274
+ for (const name of wanted) {
275
+ if (!onDisk.includes(name)) {
276
+ const e = new Error(`doctrine pack '${name}' is selected but src/doctrines/${name}.md `
277
+ + `does not exist (packs on disk: ${onDisk.join(', ') || 'none'})`);
278
+ e.exitCode = 1;
279
+ throw e;
280
+ }
281
+ }
282
+ return PACK_ORDER.filter((p) => wanted.includes(p));
283
+ }
284
+
285
+ /**
286
+ * `{{DOCTRINES_BODY}}` — the active packs concatenated, the multi-select answer to
287
+ * `registerBody`'s single-select one.
288
+ *
289
+ * `registerBody` reads ONE file because a posture and a mode are scalars; a doctrine set is
290
+ * 0-4 files, so this loops instead of wrapping it. Under `lean` each pack gets the same
291
+ * heading + first-sentence treatment the invariants get, and the whole section — not each
292
+ * pack — closes with one pointer at the full catalogue, which ships in every bundle whether
293
+ * or not a pack is active.
294
+ */
295
+ function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
296
+ if (!active.length) return '';
297
+ const parts = active.map((name) => {
298
+ const body = substitute(readText(path.join(cfg.src, 'doctrines', `${name}.md`)), theme).trim();
299
+ return footprint === 'lean' ? terseBlocks(body) : body;
300
+ });
301
+ if (footprint === 'lean') {
302
+ const doctrine = theme.DOCTRINE ?? 'Doctrine';
303
+ const dir = theme.DIR_DOCTRINES ?? 'doctrines';
304
+ parts.push(`> Each ${doctrine} above is given in brief — the rule, not its reasoning. `
305
+ + `The complete text of every pack, active or not, is in \`${lawsPrefix}${dir}/\`; read `
306
+ + `the pack file whenever a ${doctrine}'s application is unclear.`);
307
+ }
308
+ return parts.join('\n\n');
309
+ }
310
+
216
311
  /**
217
312
  * `_build_render.effective_theme`.
218
313
  *
@@ -223,10 +318,18 @@ function registerBody(cfg, theme, dir, selected, fallback) {
223
318
  * it reaches nothing at all unless it travels. The constant stays as the default for the
224
319
  * parity harnesses, which drive this module directly.
225
320
  */
226
- export function effectiveTheme(cfg, themeName) {
321
+ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix = '' } = {}) {
227
322
  const theme = { ...loadTheme(cfg, themeName), ...(cfg.structure ?? STRUCTURE) };
228
323
  theme.POSTURE_BODY = registerBody(cfg, theme, 'postures', cfg.posture ?? 'peer', 'peer');
229
324
  theme.MODE_BODY = registerBody(cfg, theme, 'modes', cfg.mode ?? 'direct', 'direct');
325
+ // The doctrines pair is threaded, not read off `cfg`: `footprint` is a per-RENDER value
326
+ // (`renderAll`'s option, and the same cfg renders both ways in one process), so parking it
327
+ // on cfg would make the terse/full choice sticky across calls. An options object rather
328
+ // than two positional params keeps the existing two-arg call sites — every test, and
329
+ // `renderAll` — compiling unchanged, and defaults to the full text if a caller forgets.
330
+ const active = activeDoctrines(cfg);
331
+ theme.DOCTRINES_BODY = doctrinesBody(cfg, theme, active, footprint, lawsPrefix);
332
+ theme.DOCTRINES_LIST = active.length ? active.join(', ') : 'none';
230
333
  return theme;
231
334
  }
232
335
 
@@ -271,7 +374,7 @@ export function destRel(rel) {
271
374
  export function renderAll(cfg, themeName, {
272
375
  footprint = 'full', lawsPrefix = '', nativeCatalog = false,
273
376
  } = {}) {
274
- const theme = effectiveTheme(cfg, themeName);
377
+ const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
275
378
  const items = [];
276
379
  for (const file of sortedSourceFiles(cfg.src)) {
277
380
  const rel = path.relative(cfg.src, file);