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/uninstall.mjs CHANGED
@@ -50,7 +50,7 @@ import path from 'node:path';
50
50
  import { hookRunnerEntry } from '../bin/geneseed.mjs';
51
51
  import { confirm } from './setup.mjs';
52
52
  import {
53
- claudeCfg, claudeReadManifest, emitHostScopeOf, installKind, installState,
53
+ claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, installKind, installState,
54
54
  registeredTargets, readMaybe, DISABLED_STASH,
55
55
  } from './installs.mjs';
56
56
  import {
@@ -1013,15 +1013,27 @@ function cleanHostStash(cfg, host = 'claude') {
1013
1013
  * file and swallows its own OSError, exactly as the reference does — a failed bookkeeping
1014
1014
  * write must not fail the reactivate that has already moved every file back.
1015
1015
  */
1016
- function remergeClaudeHooks(cfg) {
1016
+ function remergeClaudeHooks(cfg, root = cfg) {
1017
1017
  const data = claudeReadManifest(cfg);
1018
1018
  const managed = managedOf(data);
1019
1019
  // `hookOpts` is the one argument `mergeClaudeSettings` will not default, and it is right
1020
1020
  // not to: there is no computable fallback for the runner/entry the shim bakes. The
1021
1021
  // emitter's own originator is imported rather than restated — a reactivate that wired a
1022
1022
  // different shim path would point every re-added hook at a file the emitter never writes.
1023
+ // The pack selection is read back off the reactivated carrier, not defaulted: a reactivate
1024
+ // that re-wired the git-gate into an install whose owner had turned the process pack off
1025
+ // would put the boundary back at odds with the prompt. `null` (no marker) keeps the gate.
1026
+ //
1027
+ // ⚠ THE CARRIER IS NOT ALWAYS UNDER `cfg`, WHICH IS WHY `root` IS A SECOND PARAMETER. On a
1028
+ // PROJECT install `cfg` is `<repo>/.claude` and the carrier is `<repo>/CLAUDE.md`, so
1029
+ // reading `cfg` alone answered `null` for every project reactivate and re-wired the gate
1030
+ // unconditionally (fail-closed, so not a hole — but wrong, and it made the toggle one-way
1031
+ // for project installs). `root` first, `cfg` second: copilot keeps its carrier INSIDE the
1032
+ // config dir (`.github/copilot-instructions.md`), and on a global install the two are the
1033
+ // same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
1034
+ const doctrines = doctrinesOfDir(root) ?? doctrinesOfDir(cfg);
1023
1035
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1024
- managed.settings_hooks ?? null, hookRunnerEntry());
1036
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines);
1025
1037
  // `and data` — a manifest that did not parse is not one to write back.
1026
1038
  if (!pyEq(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1027
1039
  managed.settings_hooks = claims;
@@ -1106,7 +1118,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1106
1118
  && isFile(path.join(cfg, 'rules', 'geneseed.md'));
1107
1119
  }
1108
1120
  if (relive) {
1109
- remergeClaudeHooks(cfg); // ensure the hooks are present (and the claims exact)
1121
+ remergeClaudeHooks(cfg, root); // ensure the hooks are present (and the claims exact)
1110
1122
  cleanHostStash(cfg, host);
1111
1123
  return { ok: true,
1112
1124
  note: 'install was re-created while disabled; discarded the stashed snapshot' };
@@ -1126,12 +1138,19 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1126
1138
  moved += 1;
1127
1139
  }
1128
1140
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
1129
- remergeClaudeHooks(cfg);
1130
1141
  const managed = claudeReadManifest(cfg).managed || {};
1131
- wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1142
+ // ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
1143
+ // `remergeClaudeHooks` reads the install's pack selection back off the carrier, and on a
1144
+ // Claude-style host that selection lives inside the MANAGED BLOCK — which the deactivate
1145
+ // stashed. Re-merging first therefore asked a CLAUDE.md with its block cut out, got `null`
1146
+ // ("unknown"), and fail-closed straight back to the consent gate: every reactivate re-wired a
1147
+ // git-gate the owner had turned off, in both scopes. The two writes touch different files
1148
+ // (`CLAUDE.md` vs `settings*.json`), so nothing else depends on which goes first.
1132
1149
  if (existsSync(blockFile)) {
1133
1150
  managedBlockWrite(claudeMdPath(cfg, managed), readText(blockFile));
1134
1151
  }
1152
+ remergeClaudeHooks(cfg, root);
1153
+ wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1135
1154
  cleanHostStash(cfg, host);
1136
1155
  return { ok: true, kind: host, moved };
1137
1156
  }
@@ -104,7 +104,7 @@ import os from 'node:os';
104
104
  import path from 'node:path';
105
105
 
106
106
  import { emitGlobalInto } from '../../bin/geneseed.mjs';
107
- import { ROOT, discoverNames } from '../checkout.mjs';
107
+ import { ROOT, discoverNames, PACK_ORDER } from '../checkout.mjs';
108
108
  import { withStdoutSwallowed } from '../diff.mjs';
109
109
  import { excludeAdd, excludeRemove } from '../excludes.mjs';
110
110
  import { setupBuildArgs } from '../generate.mjs';
@@ -114,8 +114,8 @@ import {
114
114
  pyResolve,
115
115
  } from '../hosts.mjs';
116
116
  import {
117
- EMIT_HOST_SCOPE, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
118
- readMaybe,
117
+ EMIT_HOST_SCOPE, doctrinesForBuild, footprintOfDir, installState, installTargets, modeOfDir,
118
+ postureOfDir, readMaybe,
119
119
  } from '../installs.mjs';
120
120
  import {
121
121
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
@@ -140,6 +140,36 @@ const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, ke
140
140
  /** `str(x or "")` — Python's truthiness, then Python's `str`. */
141
141
  const strOr = (v) => (pyTruthy(v) ? pyStr(v) : '');
142
142
 
143
+ /**
144
+ * A request body's `doctrines` -> a normalised pack list, or `null` for "said nothing usable".
145
+ *
146
+ * THE SAME TRUST BOUNDARY THE THEME CROSSES, and it needs more care than posture or mode do
147
+ * because this value is a LIST: `discoverNames(...).includes(x)` closes a scalar in one call,
148
+ * but a list has to be closed member by member or one bogus element rides in beside three good
149
+ * ones and reaches an argv. Every name is checked against DISCOVERY — what this checkout
150
+ * actually ships — and the result is re-ordered through `PACK_ORDER`, because the
151
+ * `Active packs:` line the build writes is a marker that a later reader parses back out and a
152
+ * marker whose contents depend on request-body order compares unequal to itself.
153
+ *
154
+ * `null` rather than a substituted default on every rejection, so the caller decides what
155
+ * "unspecified" means for its own endpoint — a rebuild keeps the deployment's answer, a fresh
156
+ * deploy takes the configured one. That mirrors the bogus-theme fallback right beside it.
157
+ *
158
+ * Accepts an array (what the console sends) or a comma string (what a curl by hand sends), and
159
+ * `none`/`[]` both mean the deliberate empty selection — a real configuration, not a rejection.
160
+ */
161
+ function bodyDoctrines(body) {
162
+ const raw = bget(body, 'doctrines');
163
+ if (Array.isArray(raw) && !raw.length) return [];
164
+ const names = Array.isArray(raw) ? raw
165
+ : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
166
+ if (names === null || !names.length) return null;
167
+ if (names.length === 1 && names[0] === 'none') return [];
168
+ const known = discoverNames('doctrines', PACK_ORDER[0]);
169
+ if (!names.every((n) => typeof n === 'string' && known.includes(n))) return null;
170
+ return PACK_ORDER.filter((p) => names.includes(p));
171
+ }
172
+
143
173
  /** `str.split()` with no argument — runs of PYTHON whitespace, no empties. */
144
174
  const pyWords = (s) => s.split(new RegExp(`[${PY_SPACE}]+`)).filter(Boolean);
145
175
 
@@ -702,6 +732,13 @@ export function apiRestore(state, files) {
702
732
  footprint: state.footprint,
703
733
  posture: state.posture,
704
734
  mode: state.mode,
735
+ // THE PACK SELECTION IS THE FIFTH, and it is the one that makes this verb a BOUNDARY
736
+ // question rather than a cosmetic one. `expected` rendered at all four packs, and this
737
+ // verb COPIES OUT OF IT: restoring AGENT.md onto an install built `--doctrines craft`
738
+ // wrote a carrier stating doctrine process 5 while `claudeHookGroups` had wired no gate
739
+ // behind it — prompt and boundary disagreeing by a WRITE, the write-side twin of the
740
+ // read-side hole `diffCollect` had (and the reason the panel offered the file at all).
741
+ doctrines: doctrinesForBuild(target),
705
742
  }));
706
743
  for (const raw of (pyTruthy(files) ? files : [])) {
707
744
  const rel = pyStripSpace(pyStr(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
@@ -781,8 +818,15 @@ export function apiInstallCmd(state, body) {
781
818
  const bmode = bget(body, 'mode');
782
819
  const mode = discoverNames('modes', 'direct').includes(bmode)
783
820
  ? bmode : (modeOfDir(root) || 'direct');
821
+ // Unspecified means "keep what this install already has", exactly as theme, footprint,
822
+ // posture and mode above do — a rebuild through the console is not a place to silently
823
+ // re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
824
+ // install) MUST NOT LAND ON `harness.config.json`: `doctrinesOfDir` answers `null` there,
825
+ // `null` elided the flag, and the generator's config fallback then narrowed the install and
826
+ // took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
827
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
784
828
  const out = scope === 'global' ? null : String(root);
785
- const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode);
829
+ const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines);
786
830
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
787
831
  }
788
832
 
@@ -878,7 +922,14 @@ export function apiDeployCmd(state, body) {
878
922
  const pos = discoverNames('postures', 'peer').includes(bpos) ? bpos : 'peer';
879
923
  const bmode = bget(body, 'mode');
880
924
  const mode = discoverNames('modes', 'direct').includes(bmode) ? bmode : 'direct';
925
+ // Same resolution as `apiInstallCmd` above, and for the same reason: the console's Deploy
926
+ // form sends host/path/theme/footprint/posture/mode and NO pack selection, and nothing stops
927
+ // it landing on a directory that already holds an install. Taking `bodyDoctrines` alone left
928
+ // the flag off, the generator fell back to `harness.config.json`, and deploying onto an
929
+ // existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
930
+ // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
931
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
881
932
  // project-scope emit name == host name (opencode / claude / bob / copilot)
882
- const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode);
933
+ const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines);
883
934
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
884
935
  }
package/js/web/api.mjs CHANGED
@@ -61,7 +61,7 @@ import { doctorCollect } from '../doctor.mjs';
61
61
  import { excludesSnapshot } from '../excludes.mjs';
62
62
  import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, pyResolve } from '../hosts.mjs';
63
63
  import {
64
- footprintOfDir, installState, installTargets, installedDefaults, modeOfDir,
64
+ doctrinesForBuild, footprintOfDir, installState, installTargets, installedDefaults, modeOfDir,
65
65
  postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
66
66
  } from '../installs.mjs';
67
67
  import { frontmatter } from '../hooks.mjs';
@@ -273,16 +273,22 @@ export function specEntries(root, nested) {
273
273
  * `_web_core._deployed_inventory` — the agents and skills actually installed at
274
274
  * `state.target`, not a fresh render of `src/`.
275
275
  *
276
- * Laws still come from the render: once deployed they live inside AGENT.md rather than as
277
- * separate files, so the deployed arm replaces two of the three and not all three. A
278
- * deployed spec file carries neither a category nor a lifecycle status, so each is tagged
279
- * BY NAME from the same two sources the source render uses — and an entity the registry
280
- * does not know reads "personal" for both, which is the honest answer rather than a
281
- * borrowed one: filing it under the `build` fallback would claim a Geneseed taxonomy slot
282
- * it was never given.
276
+ * All three constitutional tiers still come from the render: once deployed they live inside
277
+ * AGENT.md rather than as separate files, so the deployed arm replaces the two ENTITY rosters
278
+ * and none of the constitution. A deployed spec file carries neither a category nor a
279
+ * lifecycle status, so each is tagged BY NAME from the same two sources the source render
280
+ * uses — and an entity the registry does not know reads "personal" for both, which is the
281
+ * honest answer rather than a borrowed one: filing it under the `build` fallback would claim
282
+ * a Geneseed taxonomy slot it was never given.
283
+ *
284
+ * ⚠ THE PACK SELECTION IS THE ONE THING THE RENDER CANNOT KNOW. `renderAll` emits all four
285
+ * pack files whatever `cfg.doctrines` says — measured: `makeCfg({doctrines:['craft']})` yields
286
+ * the same 105 items — because the filtering happens when AGENT.md's §2 is assembled, not in
287
+ * the walk. So the catalogue is always whole, and which packs this INSTALL built in comes from
288
+ * its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
283
289
  */
284
290
  export function deployedInventory(state) {
285
- const render = tuiInventory(state.theme);
291
+ const render = tuiInventory(state.theme, doctrinesForBuild(state.target));
286
292
  const registry = loadRegistry();
287
293
  const skills = specEntries(path.join(state.target, 'skills'), true);
288
294
  for (const e of skills) {
@@ -292,7 +298,12 @@ export function deployedInventory(state) {
292
298
  }
293
299
  const agents = specEntries(path.join(state.target, 'agents'), false);
294
300
  for (const e of agents) e.status = entityStatus(registry, `agents/${e.name}`);
295
- return { agents, skills, laws: render.laws, theme: state.theme };
301
+ return { agents,
302
+ skills,
303
+ laws: render.laws,
304
+ ontology: render.ontology,
305
+ doctrines: render.doctrines,
306
+ theme: state.theme };
296
307
  }
297
308
 
298
309
  /** `WebState.inventory` — the deployed record when there is one, else the source render. */
@@ -592,9 +603,53 @@ function configManifest(name, p) {
592
603
  return null;
593
604
  }
594
605
 
595
- /** `_web_core.SECTIONS` — a closed list; anything else is a 404. */
606
+ /**
607
+ * `_web_core.SECTIONS` — a closed list; anything else is a 404.
608
+ *
609
+ * ⚠ `laws` STAYS ONE SECTION FOR ALL THREE TIERS, and that is a decision rather than an
610
+ * oversight. The console has ONE Constitution entry, not three, so a reader keeps the "read it
611
+ * top to bottom" property; the id stays `laws` because `tests/helpers/cli_golden.mjs`
612
+ * hard-requires `web/src/pages/Laws.jsx` and because a route rename buys nothing and costs a
613
+ * redirect. The tier lives on the ITEM (`tier: 'ontology' | 'invariant' | 'doctrine'`), which
614
+ * is where a consumer can act on it.
615
+ */
596
616
  export const SECTIONS = ['agents', 'skills', 'laws', 'memory', 'notebook', 'wiki', 'config'];
597
617
 
618
+ /**
619
+ * The `laws` section's items — the whole constitution, in constitutional order, one flat list.
620
+ *
621
+ * FLAT AND NOT NESTED, because every other section is `{section, items}` and a second shape
622
+ * for one section is a second client path for one roster. The pack's grouping metadata rides
623
+ * on each doctrine row (`pack`, `packTitle`, `packDesc`, `active`), so a consumer groups by
624
+ * `pack` and has everything a header needs without a second fetch.
625
+ *
626
+ * Names are ADDRESSES and the three shapes cannot collide: `ont:<slug>` carries a colon no
627
+ * numeral has, an invariant is `[IVXLCDM]+`, a doctrine rule is `<pack>.<n>`. `apiItem`
628
+ * resolves all three off the same list.
629
+ */
630
+ function constitutionItems(inv) {
631
+ return [
632
+ ...(inv.ontology ?? []).map((e) => ({ name: `ont:${e.id}`, title: e.title, desc: '',
633
+ tier: 'ontology' })),
634
+ ...inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`, desc: '',
635
+ klass: e.klass ?? 'craft', tier: 'invariant' })),
636
+ ...(inv.doctrines ?? []).flatMap((p) => p.rules.map((r) => ({
637
+ name: `${r.pack}.${r.n}`,
638
+ title: `Doctrine ${r.pack} ${r.n} — ${r.title}`,
639
+ desc: '',
640
+ klass: r.klass ?? p.pack,
641
+ tier: 'doctrine',
642
+ pack: p.pack,
643
+ packTitle: p.title,
644
+ packDesc: p.desc,
645
+ // ⚠ EVERY RULE IS LISTED WHETHER OR NOT ITS PACK IS BUILT IN. An inactive pack that
646
+ // vanished from the payload would be indistinguishable from one that never shipped, and
647
+ // the console could not then offer the command that turns it back on.
648
+ active: p.active,
649
+ }))),
650
+ ];
651
+ }
652
+
598
653
  /** `_web_catalog.api_catalog`. */
599
654
  export function apiCatalog(state, section) {
600
655
  if (!SECTIONS.includes(section)) throw new NotFound(section);
@@ -607,8 +662,7 @@ export function apiCatalog(state, section) {
607
662
  items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
608
663
  source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
609
664
  } else if (section === 'laws') {
610
- items = inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`,
611
- desc: '', klass: e.klass ?? 'craft' }));
665
+ items = constitutionItems(inv);
612
666
  } else if (section === 'memory') {
613
667
  items = memoryItems(state);
614
668
  } else if (section === 'notebook') {
@@ -634,10 +688,21 @@ export function apiItem(state, type, name) {
634
688
  return out;
635
689
  }
636
690
  if (type === 'law') {
637
- const e = inv.laws.find((x) => x.num === name);
691
+ // ONE ARM FOR THREE TIERS, resolved off the same list the catalogue publishes — so a name
692
+ // that appears in `/api/catalog/laws` always opens, and one that does not always 404s.
693
+ // Building the list twice is what would let the two drift.
694
+ const e = constitutionItems(inv).find((x) => x.name === name);
638
695
  if (!e) throw new NotFound(name);
639
- return { type, name, title: `Rule ${e.num} — ${e.title}`, desc: '', body: e.body,
640
- links: [], klass: e.klass ?? 'craft' };
696
+ const body = e.tier === 'ontology'
697
+ ? (inv.ontology.find((s) => `ont:${s.id}` === name)?.body ?? '')
698
+ : (e.tier === 'invariant'
699
+ ? (inv.laws.find((x) => x.num === name)?.body ?? '')
700
+ : (inv.doctrines.flatMap((p) => p.rules)
701
+ .find((r) => `${r.pack}.${r.n}` === name)?.body ?? ''));
702
+ // `links: []` — a constitution body is not link-resolved. It cites its siblings by
703
+ // ADDRESS (`Rule IV`, `Doctrine ops 1`, `Ontology: Telos`), not by path, and there is no
704
+ // file behind those to resolve to.
705
+ return { ...e, type, name, body, links: [] };
641
706
  }
642
707
  if (type === 'memory' || type === 'notebook') {
643
708
  flatName(name);
@@ -789,13 +854,34 @@ export function apiOverview(state) {
789
854
  accent: accentFor(state.theme),
790
855
  emit: state.emit,
791
856
  footprint: install ? install.footprint : state.footprint,
857
+ // ⚠ NO PACK ROSTER HERE, DELIBERATELY. One was added when the toggle lived in Settings,
858
+ // which has no other source for the pack names. The control moved to the Constitution
859
+ // page, which already holds the whole catalogue — `/api/catalog/laws` carries `pack`,
860
+ // `packTitle`, `packDesc` and `active` on every doctrine row — so a second copy of the
861
+ // roster on this endpoint would be API surface with no reader, and two places for the
862
+ // same fact to disagree. The SUMMARY stays, under `counts.doctrines`: the dashboard
863
+ // tiles need the fraction and must not fetch a catalogue to draw it.
792
864
  install,
793
865
  target: state.target,
794
866
  deployed: deployed(state),
795
867
  counts: {
796
868
  agents: inv.agents.length,
797
869
  skills: inv.skills.length,
870
+ // ⚠ `laws` STAYS THE INVARIANT COUNT. The rail badge reads it, `docCounts` mirrors it as
871
+ // `{N_LAWS}`, and the doctor's frozen `proseMirrorProblems` compares README and
872
+ // SHIPPED.md prose against the same number. Widening it to "the whole constitution"
873
+ // would silently move all four at once.
798
874
  laws: inv.laws.length,
875
+ ontology: (inv.ontology ?? []).length,
876
+ // The tier's own summary, kept out of the section counts because it is not a section:
877
+ // `active`/`total` is the fraction the dashboard shows, `rules` counts only the packs
878
+ // this install built in — what it is bound by, not what its bundle carries.
879
+ doctrines: {
880
+ active: (inv.doctrines ?? []).filter((p) => p.active).length,
881
+ total: (inv.doctrines ?? []).length,
882
+ rules: (inv.doctrines ?? []).filter((p) => p.active)
883
+ .reduce((n, p) => n + p.rules.length, 0),
884
+ },
799
885
  memory: memoryItems(state).length,
800
886
  notebook: notebookItems(state).length,
801
887
  wiki: wikiItems(state).length,
package/js/web/docs.mjs CHANGED
@@ -130,8 +130,15 @@ export function docGroups() {
130
130
  * the one-line description. A key of `null` marks a term no theme renames.
131
131
  */
132
132
  const GLOSSARY_KEYS = [
133
- ['Rule (Law)', 'LAW', 'the governance rules the agent obeys'],
134
- ['Rules (Laws)', 'LAWS', 'the body of governance rules'],
133
+ // The three tiers, in constitutional order and before the entities — a reader who does not
134
+ // know what an Ontology is here cannot read the rest of the glossary's first rows.
135
+ ['Ontology', 'ONTOLOGY', 'the always-on worldview under the rules — telos, evidence, '
136
+ + 'decisions, conduct; it holds the Pact'],
137
+ ['Rule (Law)', 'LAW', 'one of the nine always-on invariants: what is never done'],
138
+ ['Rules (Laws)', 'LAWS', 'the body of always-on invariants'],
139
+ ['Doctrine', 'DOCTRINE', 'one practice rule, cited `<pack> <n>` — how work is done here'],
140
+ ['Doctrines', 'DOCTRINES', 'the practice packs, chosen at build time; inactive packs still '
141
+ + 'ship in the bundle'],
135
142
  ['Agent', 'AGENT', 'a capability specialist'],
136
143
  ['Agents', 'AGENTS', 'the roster of specialists'],
137
144
  ['Skill', 'SKILL', 'a repeatable workflow'],
@@ -139,7 +146,10 @@ const GLOSSARY_KEYS = [
139
146
  ['Memory', 'MEMORY', 'durable, one-fact-per-file knowledge'],
140
147
  ['Notebook', 'NOTEBOOK', "the agent's sovereign space"],
141
148
  ['Wiki', 'WIKI', 'the machine-wide knowledge base'],
142
- ['Pact', 'PACT', 'the two-way collaboration contract'],
149
+ // Still a token, still themed, but it names a CONCEPT INSIDE the ontology's Telos now
150
+ // rather than a peer of the Rules — `src/postures/peer.md` cites it and the section that
151
+ // used to carry it is the Ontology.
152
+ ['Pact', 'PACT', "the two-way collaboration contract, stated in the Ontology's Telos"],
143
153
  ['Posture', null, 'the relationship register the agent works in '
144
154
  + '(peer, mentor, expert, assistant, artisan)'],
145
155
  ['Mode', null, 'how work gets executed — direct (the agent works every '
@@ -156,8 +166,14 @@ const GLOSSARY_KEYS = [
156
166
 
157
167
  // ---- counts substituted into concept bodies -------------------------------------------
158
168
 
159
- /** `_web_docs._doc_counts`. */
160
- function docCounts(state) {
169
+ /**
170
+ * `_web_docs._doc_counts`.
171
+ *
172
+ * Exported so the tier tokens can be gated on the day they are ADDED rather than on the day a
173
+ * page first spends one. Only one page carries `{N_LAWS}` today; a test that could only reach
174
+ * these through a rendered body would leave four of the seven untested until Task 6.2.
175
+ */
176
+ export function docCounts(state) {
161
177
  const inv = state.inventory;
162
178
  const plugins = path.join(ROOT, 'adapters', 'opencode', 'plugins');
163
179
  let n = 0;
@@ -165,11 +181,23 @@ function docCounts(state) {
165
181
  n = readdirSync(plugins).filter((f) => f.startsWith('geneseed-') && f.endsWith('.js'))
166
182
  .length;
167
183
  } catch { n = 0; }
184
+ // `{N_LAWS}` IS THE INVARIANT COUNT AND NOTHING ELSE. The doctor's frozen
185
+ // `proseMirrorProblems` holds README and SHIPPED.md prose to the same number, so a token
186
+ // that quietly meant "the whole constitution" would put the docs and the gate at odds with
187
+ // no way to re-bless either. The other three tiers get their own tokens.
188
+ const packs = inv.doctrines ?? [];
189
+ const on = packs.filter((p) => p.active);
168
190
  return {
169
191
  '{N_LAWS}': (inv.laws || []).length,
170
192
  '{N_AGENTS}': (inv.agents || []).length,
171
193
  '{N_SKILLS}': (inv.skills || []).length,
172
194
  '{N_PLUGINS}': n,
195
+ '{N_ONTOLOGY}': (inv.ontology ?? []).length,
196
+ '{N_PACKS}': packs.length,
197
+ '{N_PACKS_ACTIVE}': on.length,
198
+ // Rules in the packs this install BUILT IN — the number a reader of these pages is
199
+ // actually bound by. The catalogue always ships whole, so it is never the smaller claim.
200
+ '{N_DOCTRINE_RULES}': on.reduce((t, p) => t + p.rules.length, 0),
173
201
  };
174
202
  }
175
203
 
package/js/web/jobs.mjs CHANGED
@@ -107,7 +107,7 @@ import { statSync } from 'node:fs';
107
107
  import { StringDecoder } from 'node:string_decoder';
108
108
  import path from 'node:path';
109
109
 
110
- import { ROOT } from '../checkout.mjs';
110
+ import { ROOT, PACK_ORDER } from '../checkout.mjs';
111
111
  import { setupBuildArgs } from '../generate.mjs';
112
112
  import { isDict } from '../mcp.mjs';
113
113
  import { jsonDumpsCompact, parseJson, pyTruthy, readText, writeText } from '../lib/fs.mjs';
@@ -444,11 +444,20 @@ export const NOT_PORTED_ACTIONS = new Set();
444
444
  */
445
445
  export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
446
446
 
447
+ /**
448
+ * ⚠ `doctrines` DEFAULTS TO THE FULL SET, and it is the one axis here whose default is not the
449
+ * generator's own. `theme`/`emit`/`footprint`/`posture`/`mode` fall back to a frozen literal
450
+ * that costs nothing when it is wrong; a missing `--doctrines` falls through to
451
+ * `harness.config.json`, and a `{"doctrines":["craft"]}` there re-emits the deployed install
452
+ * without its commit/push consent gate. So a caller that forgets the axis gets ALL packs —
453
+ * unknown resolves to the full set, never to a config value (`doctrinesForBuild`).
454
+ */
447
455
  function actionTable({
448
456
  theme = 'neutral', emit = 'opencode-global', footprint = 'full',
449
- posture = 'peer', mode = 'direct',
457
+ posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER],
450
458
  } = {}) {
451
- const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode);
459
+ const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode,
460
+ doctrines);
452
461
  return {
453
462
  doctor: [[NODE(), CLI(), 'doctor']],
454
463
  build: [[NODE(), GEN(), ...buildArgv]],
package/js/web/server.mjs CHANGED
@@ -60,6 +60,7 @@ import { promptLine } from '../setup.mjs';
60
60
  // which resolves because everything either side needs of the other is a hoisted function
61
61
  // declaration, not a value read at module-evaluation time.
62
62
  import { preflight } from '../update.mjs';
63
+ import { doctrinesForBuild } from '../installs.mjs';
63
64
  import { NotFound, PREFIX_ROUTES, STATE_ROUTES, webState } from './api.mjs';
64
65
  import {
65
66
  apiDeployCmd, apiExcludesMutate, apiInstallCmd, apiInstallToggle, apiMcpToggle,
@@ -549,8 +550,13 @@ export function makeHandler(state, jm, token, dist, holder = null) {
549
550
  // install, so a re-theme preserves lean/full, the register and the operating mode.
550
551
  const [theme, emit] = action === 'build'
551
552
  ? buildOverride(state, body) : [state.theme, state.emit];
553
+ // The pack selection follows the install like footprint/posture/mode, and is read here
554
+ // rather than left to the table's default: a console Build must re-emit the constitution
555
+ // the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
556
+ // packs — never to `harness.config.json`, which would drop the consent gate.
552
557
  const cmds = actionCommands(action, {
553
558
  theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
559
+ doctrines: doctrinesForBuild(state.target),
554
560
  });
555
561
  if (!pyTruthy(cmds)) {
556
562
  return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "2.1.0",
4
+ "version": "3.1.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",