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/doctor.mjs CHANGED
@@ -33,7 +33,9 @@ import { spawnSync } from 'node:child_process';
33
33
  import os from 'node:os';
34
34
  import path from 'node:path';
35
35
 
36
- import { CONFIG, PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC, makeCfg } from './checkout.mjs';
36
+ import {
37
+ CONFIG, PACK_ORDER, PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC, makeCfg,
38
+ } from './checkout.mjs';
37
39
  import {
38
40
  buildInto, main as driverMain, emitGlobalInto, emitProjectInto,
39
41
  } from '../bin/geneseed.mjs';
@@ -61,7 +63,10 @@ import {
61
63
  // --------------------------------------------------------------------------------------
62
64
 
63
65
  /** `_harness_core.TOKEN_RE` / `LINK_RE` / `ABS_LINK_RE`. `g` where `findall` is used. */
64
- const TOKEN_RE = /\{\{[A-Z_]+\}\}/g;
66
+ // Must stay in step with `js/render.mjs`'s TOKEN_RE: a token the renderer substitutes but this
67
+ // scan cannot see is an unresolved-token gate that silently stops gating. Digits are legal after
68
+ // the first character for `DOC_<PACK>_<n>`; a leading digit is still not a token.
69
+ const TOKEN_RE = /\{\{[A-Z_][A-Z0-9_]*\}\}/g;
65
70
  const LINK_RE = /\]\((?!https?:\/\/|#)([^)]+)\)/g;
66
71
  const ABS_LINK_RE = /^([A-Za-z]:[\\/]|\/|~)/;
67
72
  const FENCE_RE = /```[\s\S]*?```/g;
@@ -660,6 +665,63 @@ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
660
665
  return problems;
661
666
  }
662
667
 
668
+ /** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>'],`. */
669
+ const DOCTRINE_META_ROW =
670
+ /^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*,?\s*\]\s*,?[^\S\n]*$/gm;
671
+
672
+ /**
673
+ * `lawMetaProblems`' doctrine half — the console's per-rule Principle column stays complete.
674
+ *
675
+ * SAME DEFECT, ONE TIER OVER. `DOCTRINE_META` holds copy that exists nowhere else in the tree,
676
+ * and a rule with no row falls back to an empty principle: it renders with a blank description
677
+ * while every count upstream reads correct. Two invariants shipped exactly that way before
678
+ * `lawMetaProblems` existed, and 23 new rules is a much larger surface to lose one in.
679
+ *
680
+ * ⚠ THE CLASS ARM IS DIFFERENT FROM THE INVARIANTS'. An invariant's class is checked against
681
+ * `LAW_CLASSES`, a vocabulary of six; a doctrine rule has no second taxonomy — its class IS its
682
+ * pack — so the check is an EQUALITY against the pack in its own key. That catches the copy
683
+ * error a six-way vocabulary cannot: a row pasted from the pack above it, keeping the class of
684
+ * the pack it came from, which would colour the row and label its chip with the wrong pack.
685
+ */
686
+ export function doctrineMetaProblems(addrs) {
687
+ const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
688
+ let text;
689
+ try { text = readText(page); } catch (e) {
690
+ return [`[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
691
+ }
692
+ const block = /^const DOCTRINE_META = \{$([\s\S]*?)^\}$/m.exec(text);
693
+ if (!block) {
694
+ return ['[authoring] DOCTRINE_META literal not found in web/src/pages/Laws.jsx — the '
695
+ + "console's doctrine rows would have no gate on their Principle column"];
696
+ }
697
+ const meta = new Map();
698
+ for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6]]);
699
+ const problems = [];
700
+ const seen = new Set();
701
+ for (const addr of addrs) {
702
+ seen.add(addr);
703
+ if (!meta.has(addr)) {
704
+ problems.push(`[authoring] doctrine rule ${addr} has no row in DOCTRINE_META `
705
+ + '(web/src/pages/Laws.jsx) — the console would render it with a blank Principle');
706
+ continue;
707
+ }
708
+ const [klass, principle] = meta.get(addr);
709
+ if (!principle.trim()) {
710
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] has an empty principle line — that `
711
+ + 'rule would render with a blank description');
712
+ }
713
+ const pack = addr.split('.')[0];
714
+ if (klass !== pack) {
715
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] carries pack '${klass}' — a doctrine `
716
+ + `rule's class IS its pack, so this must read '${pack}'`);
717
+ }
718
+ }
719
+ for (const addr of [...meta.keys()].filter((k) => !seen.has(k)).sort()) {
720
+ problems.push(`[authoring] DOCTRINE_META lists ${addr} but no pack file defines it`);
721
+ }
722
+ return problems;
723
+ }
724
+
663
725
  /**
664
726
  * `_harness_build._prose_mirror_problems` — the human-readable count mirrors, the ones the
665
727
  * badge regex never sees.
@@ -760,6 +822,237 @@ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '
760
822
  const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
761
823
  const LAW_HEADING_RE = /^### \{\{LAW\}\} ([IVXLCDM]+)\b/gm;
762
824
 
825
+ // The doctrine twins of `LAW_HEADING_RE`, over the UNRENDERED source — `js/inventory.mjs`'s
826
+ // pair of the same names read the rendered form. A citation is the heading minus its `### `,
827
+ // so one regex cannot serve both: a heading is an anchored line, a citation is anywhere.
828
+ const DOCTRINE_HEADING_RE = /^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm;
829
+ const DOCTRINE_CITE_RE = /\{\{DOCTRINE\}\}\s+([a-z]+)\s+(\d+)/g;
830
+ // `\{\{DOCTRINE\}\}` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is legal
831
+ // everywhere, including in an invariant that points at the tier as a whole.
832
+ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE\}\}/;
833
+ const DOC_TOKEN_RE = /\{\{(DOC_[A-Z0-9_]+)\}\}/g;
834
+
835
+ /**
836
+ * A line reported by the doctor that is NOT a failure — `[note] …` rather than `[authoring] …`.
837
+ *
838
+ * ⚠ ONE PRODUCER, AND IT IS THE ONLY THING THAT NEEDED A SECOND CHANNEL. A build with a
839
+ * doctrine pack off is LEGAL, and it leaves body prose citing rules that pack owns. That state
840
+ * has to be visible — a silent dangling citation is how prose and boundary drift apart — but
841
+ * reporting it as a problem would make `doctor` exit 1 over a configuration its owner chose.
842
+ * So `cmdDoctor` prints notes and does not count them.
843
+ */
844
+ const NOTE = '[note] ';
845
+ export const isDoctorNote = (p) => p.startsWith(NOTE);
846
+
847
+ /**
848
+ * The three-tier constitution's own authoring gates — the doctrine twin of the `LAW_CLASS` /
849
+ * `LAW_META` family, plus the two purity rules the tiering rests on.
850
+ *
851
+ * IT LIVES BESIDE `countTableProblems` RATHER THAN INSIDE IT, and is called from
852
+ * `authoringProblems` beside `registry`/`secret`/`vendorPin`. `countTableProblems` opens by
853
+ * reading `AGENT.md.tmpl` and HARD-RETURNS if that read fails, so an arm placed inside it
854
+ * would go quiet on exactly the tree that is most broken. Nothing here needs the template's
855
+ * tables, and `proseMirrorProblems` — the frozen recording — is untouched either way.
856
+ *
857
+ * ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
858
+ * presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
859
+ * fourteen is in parity, and a key whose VALUE breaks a parser is not its business at all. So
860
+ * `DOC_*` coverage, the `LEX_I..LEX_IX` equality and the single-word tier noun each need a gate
861
+ * that reads the SOURCE and asks what the source requires.
862
+ */
863
+ export function constitutionProblems() {
864
+ const problems = [];
865
+ const dir = path.join(SRC, 'doctrines');
866
+
867
+ // ---- the packs themselves: present, contiguous from 1, and filed where they say they are.
868
+ const rules = new Map(); // 'craft.1' -> title-less marker
869
+ for (const pack of PACK_ORDER) {
870
+ const file = path.join(dir, `${pack}.md`);
871
+ let text;
872
+ try { text = readText(file); } catch (e) {
873
+ problems.push(`[authoring] doctrines/${pack}.md is named in PACK_ORDER but unreadable: `
874
+ + `${e.message} — the build ships the whole catalogue, so a missing pack file breaks `
875
+ + 'every citation into it, active or not');
876
+ continue;
877
+ }
878
+ const found = [...text.matchAll(DOCTRINE_HEADING_RE)];
879
+ if (!found.length) {
880
+ problems.push(`[authoring] doctrines/${pack}.md carries no '### {{DOCTRINE}} ${pack} <n>' `
881
+ + 'heading — the pack would render as an empty section');
882
+ continue;
883
+ }
884
+ found.forEach(([, named, n], i) => {
885
+ if (named !== pack) {
886
+ problems.push(`[authoring] doctrines/${pack}.md holds a rule addressed to '${named} `
887
+ + `${n}' — a rule's pack is read from its heading, so this one is unreachable at `
888
+ + `'${pack}.${n}' and shadows whatever ${named}.md numbers ${n}`);
889
+ }
890
+ if (Number(n) !== i + 1) {
891
+ problems.push(`[authoring] doctrines/${pack}.md rule ${n} is at position ${i + 1} — `
892
+ + 'pack ids must run contiguously from 1, or a citation addresses a gap');
893
+ }
894
+ rules.set(`${named}.${Number(n)}`, true);
895
+ });
896
+ }
897
+
898
+ // The console's Principle column, keyed off the rules just parsed — so the gate is driven by
899
+ // the SOURCE and a rule added to a pack file is caught the same day, not when someone
900
+ // notices a blank description in the browser.
901
+ problems.push(...doctrineMetaProblems([...rules.keys()]));
902
+
903
+ // ---- every `{{DOCTRINE}} <pack> <n>` anywhere in src/ resolves to a rule that exists.
904
+ //
905
+ // The whole tree, not the template: a skill footer citing `process 9` is as broken as a
906
+ // template one, and it is the shape 339 rewired citations could have left behind.
907
+ const cited = new Map(); // 'process.5' -> [rel, …]
908
+ for (const p of rglob(SRC)) {
909
+ if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
910
+ let text;
911
+ try { text = readText(p); } catch { continue; }
912
+ const rel = path.relative(SRC, p).split(path.sep).join('/');
913
+ // ⚠ AN ALWAYS-ON TIER MAY NEVER CITE A TOGGLEABLE ONE (D2). A `--doctrines craft` build
914
+ // ships invariants that point at rules its AGENT.md does not contain; the ontology is
915
+ // worse still, being the tier that states the order the others sit in. Doctrine->doctrine
916
+ // citations are fine and stay: the full catalogue ships in every bundle.
917
+ if ((rel.startsWith('ontology/') || rel.startsWith('laws/')) && DOCTRINE_TOKEN_RE.test(text)) {
918
+ problems.push(`[authoring] ${rel} cites {{DOCTRINE}} — an always-on tier may never `
919
+ + 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
920
+ + 'that is not there. State the point directly instead');
921
+ }
922
+ for (const [, pack, n] of text.matchAll(DOCTRINE_CITE_RE)) {
923
+ const key = `${pack}.${Number(n)}`;
924
+ if (!cited.has(key)) cited.set(key, []);
925
+ cited.get(key).push(rel);
926
+ }
927
+ }
928
+ for (const [key, where] of [...cited].sort()) {
929
+ if (rules.has(key)) continue;
930
+ problems.push(`[authoring] ${where[0]} cites {{DOCTRINE}} ${key.replace('.', ' ')}, which `
931
+ + `no pack file defines${where.length > 1 ? ` (and ${where.length - 1} more)` : ''}`);
932
+ }
933
+
934
+ // ---- the vocabulary every theme owes the source.
935
+ const wanted = new Set();
936
+ for (const pack of PACK_ORDER) {
937
+ let text;
938
+ try { text = readText(path.join(dir, `${pack}.md`)); } catch { continue; }
939
+ for (const [, key] of text.matchAll(DOC_TOKEN_RE)) wanted.add(key);
940
+ }
941
+ const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'].map((r) => `LEX_${r}`);
942
+ // `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
943
+ // stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
944
+ const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
945
+ for (const f of files) {
946
+ const name = path.basename(f);
947
+ let theme;
948
+ try { theme = parseJson(readText(f)); } catch (e) {
949
+ problems.push(`[authoring] ${name} unreadable: ${e.message}`);
950
+ continue;
951
+ }
952
+ if (!theme || typeof theme !== 'object' || Array.isArray(theme)) continue;
953
+ const keys = Object.keys(theme);
954
+ for (const key of [...wanted].sort()) {
955
+ if (!Object.hasOwn(theme, key)) {
956
+ problems.push(`[authoring] ${name} has no {${key}} — a doctrine file names it, and `
957
+ + 'theme parity cannot see a key that is missing from every theme at once');
958
+ }
959
+ }
960
+ for (const key of keys.filter((k) => k.startsWith('DOC_')).sort()) {
961
+ if (!wanted.has(key)) {
962
+ problems.push(`[authoring] ${name} defines {${key}}, which no doctrine file uses — a `
963
+ + 'dead voice key ships a title for a rule that does not exist');
964
+ }
965
+ }
966
+ // I1 — an EQUALITY, not a presence check. The renumber left `LEX_XXII`, `LEX_XXIII`,
967
+ // `LEX_XXIV` and `LEX_XXXVI` behind in all fifteen files, and parity was silent because
968
+ // they were absent from nowhere.
969
+ const lex = keys.filter((k) => k.startsWith('LEX_')).sort();
970
+ const stale = lex.filter((k) => !lexWant.includes(k));
971
+ const missing = lexWant.filter((k) => !lex.includes(k));
972
+ for (const k of stale) {
973
+ problems.push(`[authoring] ${name} still defines {${k}} — the invariants are I..IX and `
974
+ + 'nothing renders this key, so it is a title for a rule that no longer exists');
975
+ }
976
+ for (const k of missing) {
977
+ problems.push(`[authoring] ${name} has no {${k}} — one of the nine invariants would `
978
+ + 'render with an empty title');
979
+ }
980
+ // M10 — ⚠ BOTH HEADING PARSERS MATCH THE TIER NOUN WITH `\S+`. A two-word value does not
981
+ // error; the heading stops matching and the tier silently parses to nothing. `_TEMPLATE`'s
982
+ // values are descriptive placeholders, so only real voices are held to this.
983
+ if (name === '_TEMPLATE.json') continue;
984
+ for (const key of ['LAW', 'DOCTRINE']) {
985
+ const v = theme[key];
986
+ if (typeof v === 'string' && /\s/.test(v)) {
987
+ problems.push(`[authoring] ${name} spells {${key}} as ${pyRepr(v)} — the heading `
988
+ + 'parsers match the tier noun with \\S+, so a value with a space makes every '
989
+ + `${key === 'LAW' ? 'invariant' : 'doctrine rule'} parse to nothing, in silence`);
990
+ }
991
+ }
992
+ }
993
+
994
+ // ---- every `§N` in src/ addresses a section the template actually declares.
995
+ //
996
+ // ⚠ THIS GATE IS NARROW AND SAYS SO. Inserting `## 2. Doctrines` pushed every later section
997
+ // down one, and the sweep that fixed the template's own cross-references stopped at the
998
+ // template — four satellites under `src/skills/` and `src/agents/` kept pointing at the
999
+ // section that used to be there, and they RENDER INTO EVERY BUNDLE. A range check would not
1000
+ // have caught those: §7 still existed, it had just stopped being the Wiki.
1001
+ //
1002
+ // What it does catch is the other half of a renumber — a pointer past the end, which is what
1003
+ // REMOVING a section leaves behind — and it is nearly free. The half it cannot catch is a
1004
+ // judgement about intent, and `docs/extending.md` carries the manual sweep for it. Naming
1005
+ // that limit here is the point: a reader who assumes this gate is total will skip the sweep.
1006
+ const declared = new Set();
1007
+ // Read here rather than borrowing `countTableProblems`' copy: that function hard-returns when
1008
+ // the template is unreadable, and this gate must not inherit a silence it did not choose.
1009
+ let tmpl = '';
1010
+ try { tmpl = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch { tmpl = ''; }
1011
+ for (const [, n] of tmpl.matchAll(/^## (\d+)\.\s/gm)) declared.add(Number(n));
1012
+ if (declared.size) {
1013
+ for (const p of rglob(SRC)) {
1014
+ if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
1015
+ let text;
1016
+ try { text = readText(p); } catch { continue; }
1017
+ const rel = path.relative(SRC, p).split(path.sep).join('/');
1018
+ for (const [, n] of text.matchAll(/§(\d+)/g)) {
1019
+ if (declared.has(Number(n))) continue;
1020
+ problems.push(`[authoring] ${rel} cites AGENT.md §${n}, and the template declares no `
1021
+ + `such section ${pyRepr([...declared].sort((a, b) => a - b).map(String))} — a `
1022
+ + 'renumber moved it, and this pointer renders into every bundle');
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ // ---- the build default names packs that exist.
1028
+ let cfgDoctrines = null;
1029
+ try {
1030
+ const cfg = parseJson(readText(CONFIG));
1031
+ if (cfg && typeof cfg === 'object' && Array.isArray(cfg.doctrines)) cfgDoctrines = cfg.doctrines;
1032
+ } catch { /* no config, or unreadable — `configDefaults()` is forgiving here too */ }
1033
+ if (cfgDoctrines) {
1034
+ for (const pack of cfgDoctrines) {
1035
+ if (!PACK_ORDER.includes(pack)) {
1036
+ problems.push(`[authoring] harness.config.json names doctrine pack ${pyRepr(pack)}, `
1037
+ + `which this checkout does not ship ${pyRepr([...PACK_ORDER])} — every build reading `
1038
+ + 'that default would fail');
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ // ---- D5, a NOTE and not a problem: what a narrowed default leaves pointing at the shelf.
1044
+ const on = cfgDoctrines ?? [...PACK_ORDER];
1045
+ const dangling = [...cited].filter(([k]) => rules.has(k) && !on.includes(k.split('.')[0]));
1046
+ if (dangling.length) {
1047
+ const packs = [...new Set(dangling.map(([k]) => k.split('.')[0]))].sort();
1048
+ problems.push(`${NOTE}harness.config.json builds ${pyRepr(on)}, and ${dangling.length} `
1049
+ + `citation(s) in src/ point into ${packs.join(', ')}. Those rules still SHIP — every `
1050
+ + 'pack file is in the bundle — so the reference resolves on disk; it is the rendered '
1051
+ + 'AGENT.md that will not contain them. Legal, and recorded here so it is not silent');
1052
+ }
1053
+ return problems;
1054
+ }
1055
+
763
1056
  /**
764
1057
  * `_harness_build._count_table_problems` — the hand-authored AGENT.md tables and the README
765
1058
  * badges, against `src/`.
@@ -987,6 +1280,7 @@ export function authoringProblems() {
987
1280
  problems.push(...registryProblems());
988
1281
  problems.push(...secretProblems());
989
1282
  problems.push(...vendorPinProblems());
1283
+ problems.push(...constitutionProblems());
990
1284
  problems.push(...countTableProblems());
991
1285
  return problems;
992
1286
  }
@@ -1188,17 +1482,26 @@ function captureBoth(fn) {
1188
1482
  * maintainer sweep. The cross-theme parity check runs in every mode.
1189
1483
  */
1190
1484
  export function cmdDoctor(args) {
1191
- const [themes, problems] = doctorCollect({
1485
+ const [themes, collected] = doctorCollect({
1192
1486
  theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
1193
1487
  noBundle: Boolean(args.noBundle),
1194
1488
  });
1195
1489
  if (!themes.length) {
1196
- pyPrint(`${problems.length ? problems[0] : '[doctor] no themes found'}\n`);
1490
+ pyPrint(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
1197
1491
  return 1;
1198
1492
  }
1493
+ // ⚠ NOTES ARE PRINTED AND NOT COUNTED. The only producer is the pack-off citation report in
1494
+ // `constitutionProblems`, and the state it describes is one the install's owner chose: a
1495
+ // build that leaves body prose citing a pack it did not render. Silence there is how prose
1496
+ // and boundary drift apart, and a non-zero exit there is doctor failing a legal
1497
+ // configuration. Nothing in the recorded CLI corpus produces one — the checkout's own
1498
+ // `harness.config.json` names no packs — so the byte-compared output is unmoved.
1499
+ const notes = collected.filter(isDoctorNote);
1500
+ const problems = collected.filter((p) => !isDoctorNote(p));
1199
1501
  const scoped = !args.theme && !args.all && themes.length === 1;
1200
1502
  const note = scoped
1201
1503
  ? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
1504
+ for (const n of notes) pyPrint(`${n}\n`);
1202
1505
  if (problems.length) {
1203
1506
  pyPrint(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
1204
1507
  for (const p of problems) pyPrint(` - ${p}\n`);
package/js/emit.mjs CHANGED
@@ -87,8 +87,29 @@ import {
87
87
  } from './lib/fs.mjs';
88
88
  /** `_build_render.SRC_DIRS_MARKER`. */
89
89
  const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
90
- /** `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run. */
91
- const OWNED_SRC_DIRS = ['laws', 'agents', 'skills'];
90
+ /**
91
+ * `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run.
92
+ *
93
+ * `ontology` and `doctrines` are appended, not inserted: this array's iteration order is the
94
+ * key order of the `.geneseed-srcdirs.json` marker it fills, and appending keeps the three
95
+ * original keys where an existing install's marker already has them.
96
+ *
97
+ * They belong here for the same reason `laws` does — they are GENERATED, so an install must
98
+ * not keep a copy the source no longer produces. Without them a build writes the dirs on
99
+ * every run and never wipes them, which turns the doctrines toggle into a one-way switch:
100
+ * disable a pack, rebuild, and its rendered file sits in the install forever, contradicting
101
+ * the `Active packs:` line in the AGENT.md right beside it.
102
+ */
103
+ const OWNED_SRC_DIRS = ['laws', 'agents', 'skills', 'ontology', 'doctrines'];
104
+
105
+ /**
106
+ * The constitution dirs a LEAN emit re-ships in full text — see `shipLeanLaws`.
107
+ *
108
+ * Not all of `OWNED_SRC_DIRS`: `agents` and `skills` are already written as whole files by
109
+ * the native layer, and only the three constitution dirs are inlined into AGENT.md in a
110
+ * form (terse, or pack-filtered) that loses text the reader may still need.
111
+ */
112
+ const LEAN_FULL_TEXT_DIRS = ['laws', 'ontology', 'doctrines'];
92
113
 
93
114
  /**
94
115
  * `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
@@ -824,7 +845,8 @@ export function emitOpencodeRender(cfg, job) {
824
845
  // (`_rel_under` is the Python side by design), and only the BASENAME is consumed
825
846
  // downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
826
847
  phaseLog('WIRE');
827
- const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath));
848
+ const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath,
849
+ cfg.doctrines));
828
850
 
829
851
  return {
830
852
  owned,
@@ -929,16 +951,27 @@ function globalNotebook(cfgDir, items, legacy, srcRoot) {
929
951
  * output besides the files is what it APPENDS to `owned` — which travels back in the
930
952
  * payload for the manifest and the prune. It reads nothing any wiring stage writes, so
931
953
  * its new position is not merely legal, it has no dependency on the old one.
954
+ *
955
+ * NO LONGER LEAN-ONLY, and the name is kept because the lean case is still the whole reason
956
+ * it exists. `doctrines` ships at BOTH footprints, because the two footprints lose different
957
+ * things: lean loses text (every rule is truncated to its first sentence, so the full text has
958
+ * to be on disk), while FULL loses whole packs (only the active ones are inlined, so the
959
+ * inactive ones have to be on disk). AGENT.md tells the agent it may read a rule in a pack
960
+ * that is not active, and `src/doctrines/README.md` rests cross-pack citations on exactly
961
+ * that — a claim the emit has to make true, not merely the `files` bundle.
932
962
  */
933
- function shipLeanLaws(items, theme, cfgDir, owned) {
934
- const lawsDir = theme.DIR_LAWS ?? 'laws';
963
+ function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
964
+ // Themed names, resolved through the same token table `build`'s wipe resolves, so the dir
965
+ // a lean emit re-ships and the dir it prunes can never be two different spellings.
966
+ const wanted = footprint === 'lean' ? LEAN_FULL_TEXT_DIRS : ['doctrines'];
967
+ const dirs = new Set(wanted.map((d) => theme[SRC_DIR_TOKENS[d]] ?? d));
935
968
  for (const { rel, text } of items) {
936
969
  const parts = rel.split('/');
937
970
  // `parts.length` mirrors Python's `parts and parts[0]`, where `Path("").parts` really
938
971
  // is empty. `String.split` never returns an empty array, so this half can never be
939
972
  // false here — kept for the mirror, and its mutation stays green because there is
940
973
  // nothing to detect, not because the gate cannot see it.
941
- if (text !== null && parts.length && parts[0] === lawsDir) {
974
+ if (text !== null && parts.length && dirs.has(parts[0])) {
942
975
  const dest = path.join(cfgDir, ...parts);
943
976
  mkdirSync(path.dirname(dest), { recursive: true });
944
977
  writeText(dest, text);
@@ -1039,7 +1072,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
1039
1072
  writeVersion(cfg, cfgDir);
1040
1073
  owned.push(VERSION_MARKER);
1041
1074
 
1042
- if (footprint === 'lean') shipLeanLaws(items, theme, cfgDir, owned);
1075
+ shipLeanLaws(items, theme, cfgDir, owned, footprint);
1043
1076
 
1044
1077
  // WIRE — the one file of this emit the user co-owns, and the last render is now behind
1045
1078
  // us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same
@@ -1047,7 +1080,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
1047
1080
  // that split is what `tests/test_emit_phase_order.py` walks, and it walks the Python.
1048
1081
  phaseLog('WIRE');
1049
1082
  const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
1050
- agentPath));
1083
+ agentPath, cfg.doctrines));
1051
1084
 
1052
1085
  return {
1053
1086
  owned,
@@ -1170,7 +1203,7 @@ export function emitClaudeRender(cfg, job) {
1170
1203
  writeVersion(cfg, cfgDir);
1171
1204
  owned.push(VERSION_MARKER);
1172
1205
 
1173
- if (footprint === 'lean') shipLeanLaws(items, theme, cfgDir, owned);
1206
+ shipLeanLaws(items, theme, cfgDir, owned, footprint);
1174
1207
 
1175
1208
  // The last render of the emit, and the only one whose product is not written here.
1176
1209
  // Bob at GLOBAL scope gets no managed block at all, so nothing is rendered for it —
@@ -1183,7 +1216,7 @@ export function emitClaudeRender(cfg, job) {
1183
1216
 
1184
1217
  // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
1185
1218
  // functions rather than two spawns; the seam between them is a call, not a process.
1186
- const managed = claudeWire(job, claudeMdText, agentText !== null);
1219
+ const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines);
1187
1220
 
1188
1221
  return {
1189
1222
  owned,
@@ -1218,7 +1251,7 @@ export function emitClaudeRender(cfg, job) {
1218
1251
  * not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
1219
1252
  * always a string, so `''` is the only falsy value either language sees.
1220
1253
  */
1221
- function claudeWire(job, claudeMdText, hasAgentText) {
1254
+ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null) {
1222
1255
  phaseLog('WIRE');
1223
1256
  const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
1224
1257
  const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
@@ -1274,8 +1307,12 @@ function claudeWire(job, claudeMdText, hasAgentText) {
1274
1307
  // The merge prunes recorded groups that are no longer canonical and returns the
1275
1308
  // COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
1276
1309
  // stale claims.
1310
+ // `doctrines` decides whether the git-gate group is part of the canonical claim set. It
1311
+ // travels from `cfg` rather than being re-read off the deployment: this is the emit that
1312
+ // DECIDES the install's packs, so the marker on disk is still the previous build's.
1277
1313
  const [, managedHooks] = mergeClaudeSettings(
1278
1314
  settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
1315
+ doctrines,
1279
1316
  );
1280
1317
  managed.settings_hooks = managedHooks;
1281
1318
 
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