geneseed 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +108 -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 +95 -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-C0Mk0igT.js} +1 -1
  164. package/web/dist/assets/{Activity-us5MPf4u.js → Activity-C4aJJVXz.js} +1 -1
  165. package/web/dist/assets/{ActivityDetail-CiaQXUk2.js → ActivityDetail-DaQ85za0.js} +1 -1
  166. package/web/dist/assets/{Diff-Bh0qbZ2s.js → Diff-B3tlO2Yj.js} +1 -1
  167. package/web/dist/assets/{Doctor-BIlQME45.js → Doctor-BNC-5Dmo.js} +1 -1
  168. package/web/dist/assets/{Graph-4dYD1dPG.js → Graph-us0T6ZcV.js} +1 -1
  169. package/web/dist/assets/{Harnesses-PzYZ3LOC.js → Harnesses-DAMgyfM_.js} +1 -1
  170. package/web/dist/assets/Laws-rOxegJ75.js +1 -0
  171. package/web/dist/assets/{Library-CjMbksJW.js → Library-KmwrHA1J.js} +1 -1
  172. package/web/dist/assets/{Markdown-Bk81c3aZ.js → Markdown-0eQOVGSF.js} +1 -1
  173. package/web/dist/assets/{Profile-Curui_-j.js → Profile-ZU7WV6Xl.js} +1 -1
  174. package/web/dist/assets/{Rules-Bm283wdv.js → Rules-CtDoPhhf.js} +1 -1
  175. package/web/dist/assets/{Skills-gruqYuhl.js → Skills-BhoOxdkD.js} +1 -1
  176. package/web/dist/assets/{StatusBadge-EiM_FPqv.js → StatusBadge-CGvYVCwa.js} +1 -1
  177. package/web/dist/assets/{Themes-DU99j4Hr.js → Themes-Dr7OOkD7.js} +1 -1
  178. package/web/dist/assets/{activity-0rvQ6Vra.js → activity-B8aq3qtl.js} +1 -1
  179. package/web/dist/assets/index-2wqPDxuN.css +1 -0
  180. package/web/dist/assets/{index-0b2hobOo.js → index-AhMzkdbI.js} +1 -1
  181. package/web/dist/assets/index-CRftEtBX.js +12 -0
  182. package/web/dist/assets/{index-IiGFmCW_.js → index-D0lPN_qW.js} +1 -1
  183. package/web/dist/index.html +2 -2
  184. package/web/src/pages/Laws.jsx +282 -91
  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/settings.mjs CHANGED
@@ -67,17 +67,120 @@ function isDict(v) {
67
67
  return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
68
68
  }
69
69
 
70
- /** `_build_settings._default_permission`. */
71
- export function defaultPermission() {
72
- return {
73
- bash: {
74
- 'rm -rf *': 'ask',
75
- 'git commit*': 'ask',
76
- 'git push*': 'ask',
77
- 'git push --force*': 'ask',
78
- 'git push -f*': 'ask',
79
- },
80
- };
70
+ /**
71
+ * Is the `process` doctrine pack active for the install being emitted?
72
+ *
73
+ * ⚠ FAIL CLOSED, and the shape of the test is the whole point. Anything that is not an
74
+ * explicit array — `null`, `undefined`, a caller that never learned about packs — means
75
+ * "unknown", and unknown keeps the consent gate wired. `doctrinesOfDir` returns exactly that
76
+ * `null` for a pre-migration install, and `d?.includes('process')` would read it as "off" and
77
+ * silently unwire the gate from every carrier on disk. An empty array is NOT unknown: it is a
78
+ * deliberate `--doctrines none`, and it turns the gate off.
79
+ */
80
+ const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
81
+
82
+ /**
83
+ * `_build_settings._default_permission`.
84
+ *
85
+ * WHAT THE PACK TOGGLE REACHES AND WHAT IT MUST NOT. `git commit*` and `git push*` are the
86
+ * BOUNDARY HALF of doctrine process 5 (consent before recording or sharing); with the process
87
+ * pack off, that rule is no longer binding — its text is not rendered into the carrier's
88
+ * doctrine section — and a boundary that keeps asking for a rule the install did not adopt is
89
+ * the one disagreement the tiering is not allowed to produce. (It does NOT follow that the
90
+ * string is gone from the bundle; see `claudeHookGroups` for what D5 ships anyway and why.)
91
+ * `rm -rf *`, `git push --force*` and `git push -f*` stay in EVERY build: they are
92
+ * Law IV's territory — an always-on invariant — not the process pack's, and the guard
93
+ * plugin's own force-push arm is unconditional for the same reason.
94
+ */
95
+ export function defaultPermission(doctrines = null) {
96
+ const bash = { 'rm -rf *': 'ask' };
97
+ if (processPackOn(doctrines)) {
98
+ bash['git commit*'] = 'ask';
99
+ bash['git push*'] = 'ask';
100
+ }
101
+ bash['git push --force*'] = 'ask';
102
+ bash['git push -f*'] = 'ask';
103
+ return { bash };
104
+ }
105
+
106
+ /**
107
+ * Every `permission.bash` key Geneseed has ever written — the OWNED set, which is not the same
108
+ * as the set `defaultPermission` returns for a given selection. The two process-pack keys are
109
+ * in it whether or not this build wants them, because reconciling an EXISTING opencode.json
110
+ * has to be able to recognise a key that is Geneseed's business at all — to add it back when
111
+ * the pack returns, and to name it when this build no longer wants it but will not remove it.
112
+ */
113
+ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', 'git push --force*', 'git push -f*'];
114
+
115
+ /**
116
+ * Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
117
+ * touch nothing else. Returns `[changed, stranded]` — whether anything moved, and the owned
118
+ * keys this build does NOT want but refuses to take away.
119
+ *
120
+ * ⚠ THIS IS THE FIX FOR A BOUNDARY FROZEN AT FIRST WRITE. `mergeOpencodeJson` used to add the
121
+ * whole block only when the key was absent, so on a file that already had one the git gate was
122
+ * whatever the FIRST emit happened to write, forever — and the direction that matters is
123
+ * turning the process pack back ON, which left the gate ABSENT while AGENT.md stated the rule.
124
+ * That direction is pure ADDITION and it is fixed here. Claude's side never had the hole;
125
+ * `mergeClaudeSettings` prunes and re-adds its managed groups on every emit, because that file
126
+ * has a MANAGED BLOCK and this one does not.
127
+ *
128
+ * ⚠ REMOVAL IS GUARDED BY OWNERSHIP, NOT BY VALUE — and this is the second thing the first cut
129
+ * got wrong. `opencode.json` is USER CO-OWNED: the WIRE stage reconciles it, it does not own
130
+ * it. Geneseed may take back an entry it wrote; it may never take back one the user wrote.
131
+ * Dropping every unwanted key that still read `'ask'` treated the VALUE as the ownership
132
+ * record, so a user who had typed their own `"git commit*": "ask"` — a perfectly ordinary
133
+ * thing to want, and the value Geneseed writes precisely because it is the obvious one — had
134
+ * it DELETED by a pack-off build.
135
+ *
136
+ * NOTHING ON DISK RECORDS PROVENANCE, and none of the candidates survived contact: the value
137
+ * is what a user would write too; the carrier's `Active packs:` marker is rewritten by this
138
+ * same emit BEFORE `mergeOpencodeJson` runs (`emitOpencodeGlobalRender` writes AGENT.md at the
139
+ * top and wires the config at the bottom), so it answers with the NEW selection and would
140
+ * licence every removal; and a per-install ownership record would be a new marker file joining
141
+ * the owned set, the reversal markers and the deletion corpus — a much larger change than the
142
+ * defect. So removal is CONSERVATIVE: an unwanted owned key that is already on disk stays, and
143
+ * is reported to the caller as `stranded` so the user is told rather than left to discover it.
144
+ *
145
+ * That leaves a gate wired for a rule this build does not make binding, which is the
146
+ * FAIL-CLOSED direction — the boundary asks for a confirmation the constitution no longer
147
+ * compels, exactly as the commit skill's own consent step does with the pack off. The unsafe
148
+ * direction, a rule with no gate behind it, cannot occur here. A FRESH file is unaffected:
149
+ * `mergeOpencodeJson` writes `defaultPermission(doctrines)` whole, so a new pack-off install
150
+ * has no git gate at all.
151
+ *
152
+ * ⚠ KEY ORDER IS NOT RECONCILED, DELIBERATELY. `defaultPermission` emits its keys in a fixed
153
+ * order, so a FRESH write is tidy; an off→on reconcile appends the two process keys after the
154
+ * force-push pair and the block reads out of order from then on. Restoring the order means
155
+ * rewriting a map whose other entries belong to the user — sorting THEIR keys to tidy OURS is
156
+ * the value-over-ownership mistake in a second suit, and JSON object order carries no meaning
157
+ * to OpenCode. So the order is left as found and only membership is reconciled.
158
+ *
159
+ * Returns a third element, `opaque`: the shape reason wiring was impossible, or `null`. The
160
+ * caller warns on it. This is the one branch where the boundary can end up with NO Geneseed
161
+ * gate at all while the constitution states the rule — the unsafe direction — so it must not
162
+ * also be the silent one.
163
+ */
164
+ function reconcileOpencodePermission(perm, want) {
165
+ // A `permission` that is not an object, or whose `bash` is not one — OpenCode also accepts a
166
+ // bare `"bash": "allow"` — is a shape this function has no owned keys inside of. Leaving it
167
+ // exactly as found is the whole point; rewriting it into a map would DELETE a blanket policy
168
+ // the user wrote, which is a bigger change than the one being reconciled.
169
+ if (!isDict(perm)) return [false, [], 'is not an object'];
170
+ if (has(perm, 'bash') && !isDict(get(perm, 'bash'))) {
171
+ return [false, [], 'has a "bash" that is not an object'];
172
+ }
173
+ const fresh = !has(perm, 'bash');
174
+ const bash = fresh ? {} : get(perm, 'bash');
175
+ let changed = false;
176
+ const stranded = [];
177
+ for (const k of OWNED_BASH) {
178
+ if (has(want.bash, k)) {
179
+ if (!has(bash, k)) { bash[k] = want.bash[k]; changed = true; }
180
+ } else if (has(bash, k)) stranded.push(k);
181
+ }
182
+ if (changed && fresh) perm.bash = bash;
183
+ return [changed, stranded, null];
81
184
  }
82
185
 
83
186
  /**
@@ -170,16 +273,27 @@ export function readJsonc(text) {
170
273
  }
171
274
  }
172
275
 
173
- /** `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice. */
174
- export function warnCommentedJsonc(target, agentPath, includePermission,
175
- includeLsp = false, prefix = 'geneseed') {
276
+ /**
277
+ * `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice.
278
+ *
279
+ * `permission` is THREE-STATE, not a boolean: `'add'` for a file with no `permission` key,
280
+ * `'reconcile'` for one that has a block this build would have wired gates into, and falsy for
281
+ * neither. A commented `.jsonc` is never rewritten — that is what "your edits are kept" buys —
282
+ * so the only thing this branch can do about a stale block is SAY SO, and telling a file that
283
+ * already has a `permission` key to add one is not saying so. Any other truthy value is read as
284
+ * `'add'`, which is what the boolean callers this replaced meant.
285
+ */
286
+ export function warnCommentedJsonc(target, agentPath, permission,
287
+ includeLsp = false, prefix = 'geneseed', doctrines = null) {
176
288
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
177
289
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
178
290
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
179
- if (includePermission) {
180
- process.stdout.write(`[${prefix}] and, for Geneseed's default ask-gates, a `
181
- + '"permission" key:\n');
182
- for (const line of jsonDumpsIndent(defaultPermission()).split('\n')) {
291
+ if (permission) {
292
+ process.stdout.write(permission === 'reconcile'
293
+ ? `[${prefix}] and its "permission".bash is missing gates this build wires — `
294
+ + 'reconcile it by hand against:\n'
295
+ : `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
296
+ for (const line of jsonDumpsIndent(defaultPermission(doctrines)).split('\n')) {
183
297
  process.stdout.write(`[${prefix}] ${line}\n`);
184
298
  }
185
299
  }
@@ -208,7 +322,7 @@ export function atomicWriteJson(p, config) {
208
322
  * re-implements the inverse itself rather than inherit this one's permission/lsp side
209
323
  * effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
210
324
  * `_build_global.emit_opencode_global`). */
211
- export function mergeOpencodeJson(p, agentPath) {
325
+ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
212
326
  const target = opencodeTarget(p);
213
327
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
214
328
  let hadComments = false;
@@ -239,13 +353,58 @@ export function mergeOpencodeJson(p, agentPath) {
239
353
  const addInstr = indexOfEq(instr, agentPath) < 0;
240
354
  if (addInstr) instr.push(agentPath);
241
355
  config.instructions = instr;
356
+ // Absent ⇒ write the whole block; present ⇒ reconcile Geneseed's own git keys. Adding is
357
+ // unconditional; removal is not this stage's to make (see `reconcileOpencodePermission`), so
358
+ // the second return value is what it declined to take away. `addPerm` stays a separate flag
359
+ // from "something changed" because the two produce DIFFERENT advice on a commented file.
242
360
  const addPerm = !has(config, 'permission');
243
- if (addPerm) config.permission = defaultPermission();
361
+ let permChanged = addPerm;
362
+ let stranded = [];
363
+ let opaque = null;
364
+ if (addPerm) config.permission = defaultPermission(doctrines);
365
+ else {
366
+ [permChanged, stranded, opaque] = reconcileOpencodePermission(get(config, 'permission'),
367
+ defaultPermission(doctrines));
368
+ }
369
+ // The shape said no. This is the direction the residue warning above is NOT — a rule stated
370
+ // in AGENT.md with nothing behind it — and it used to pass in silence, because a blanket
371
+ // `"permission": {"bash": "allow"}` makes every reconcile a no-op and the whole merge then
372
+ // short-circuits before any of the three WARNs below. Reported, never rewritten: the blanket
373
+ // policy is the user's answer and it wins (`generate.test.mjs` pins that it survives).
374
+ if (opaque) {
375
+ process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
376
+ + `${opaque}, so Geneseed cannot wire its own gates into it — including `
377
+ + `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines).bash, k))
378
+ .map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
379
+ + 'harness states those rules but nothing enforces them at this boundary.\n');
380
+ }
381
+ // Told, not silently left: these are gates for a rule this build does not make binding, kept
382
+ // because Geneseed cannot prove it was the one that wrote them. stderr, like the other two
383
+ // WARNs here — the emit's stdout is byte-compared by the golden corpus.
384
+ if (stranded.length) {
385
+ process.stderr.write(`[geneseed] WARN: ${path.basename(target)} keeps `
386
+ + `${stranded.map((k) => jsonDumps(k)).join(', ')} under "permission".bash — the `
387
+ + 'doctrine pack behind those gates is off in this build, but Geneseed cannot tell its '
388
+ + 'own entry from one you wrote, so it removes neither. Delete them by hand if you want '
389
+ + 'them gone.\n');
390
+ }
244
391
  const addLsp = !has(config, 'lsp');
245
392
  if (addLsp) config.lsp = true;
246
- if (!addInstr && !addPerm && !addLsp) return target;
393
+ if (!addInstr && !permChanged && !addLsp) return target;
247
394
  if (path.extname(target) === '.jsonc' && hadComments) {
248
- warnCommentedJsonc(target, agentPath, addPerm, addLsp);
395
+ // ⚠ THE TWO CASES GIVE OPPOSITE ADVICE, and telling a file that HAS a `permission` key to
396
+ // add one is how the first cut of this got it wrong. `add` = there is no block, here is the
397
+ // whole one; `reconcile` = there is a block and it is missing gates this build wires, and
398
+ // this branch cannot write them because rewriting would drop the user's comments.
399
+ //
400
+ // DECIDED, NOT DEFERRED: a commented `.jsonc` is never rewritten and never will be. A
401
+ // comment-preserving JSONC writer is a parser, an AST and a printer — a dependency-free
402
+ // one, since this package ships zero — to keep formatting nobody asked us to touch, in a
403
+ // file Geneseed co-owns rather than owns. The advice above is the whole remedy: the exact
404
+ // block to paste, split by which of the two situations the reader is actually in. It is
405
+ // stdout, not stderr, because on this path the paste-in IS the output.
406
+ warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
407
+ addLsp, 'geneseed', doctrines);
249
408
  return target;
250
409
  }
251
410
  try {
@@ -508,8 +667,38 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
508
667
  return `"${runner}" "${entry}"`;
509
668
  }
510
669
 
511
- /** `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event. */
512
- export function claudeHookGroups(cfg, hookOpts) {
670
+ /**
671
+ * `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event.
672
+ *
673
+ * ⚠ `doctrines` IS WHERE PROMPT AND BOUNDARY ARE KEPT IN AGREEMENT. The `git-gate` group is
674
+ * the tool-boundary half of doctrine process 5. With the process pack off, that rule is no
675
+ * longer BINDING — `src/doctrines/process.md` is not rendered into the carrier's doctrine
676
+ * section and nothing in the constitution obliges the agent to it — so a gate that stopped
677
+ * every Bash call to ask for consent to a rule the install did not adopt is the disagreement
678
+ * the three-tier split forbids. Omitting the group here is also what UNWIRES it from an
679
+ * install that had it: `mergeClaudeSettings` prunes every previously-managed group that is not
680
+ * in this return value, so flipping the pack off and rebuilding takes the hook out of
681
+ * settings.json rather than leaving it orphaned.
682
+ *
683
+ * ⚠ WHAT THIS DOES **NOT** CLAIM, because it was written here once and it was false: that the
684
+ * string `process 5` is absent from a pack-off bundle. It is not — 18 emitted files still cite
685
+ * it (`grep -rl 'process 5' <off-bundle>`), and by owner decision D5 that is deliberate: the
686
+ * whole pack CATALOGUE ships in every build (`<bundle>/doctrines/process.md` is byte-identical
687
+ * on and off), so body-prose citations from craft, from the agents and from the skills stay
688
+ * RESOLVABLE rather than dangling. A citation is not an obligation.
689
+ *
690
+ * The one place that distinction is thin is `skills/commit.md`, which carries the operative
691
+ * procedure ("get explicit consent before committing") and not merely a pointer. It is left
692
+ * unconditional ON PURPOSE: a skill that asks before committing when nothing compelled it is
693
+ * never unsafe, whereas conditionalising it would put a second copy of the pack switch in the
694
+ * prose layer for no gain in safety. Asymmetric risk, asymmetric answer — the BOUNDARY follows
695
+ * the pack, the ASKING does not have to.
696
+ *
697
+ * The `rule-gate` group stays in every build. Its subject is which durable store a write
698
+ * belongs in — the user's call — not how work is run, so it is not the process pack's to
699
+ * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
700
+ */
701
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
513
702
  const run = hookPrefix(hookOpts);
514
703
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
515
704
  // --root carries the install's own dir so a GLOBAL hook can stand down when a project
@@ -520,7 +709,8 @@ export function claudeHookGroups(cfg, hookOpts) {
520
709
  const learn = `${run} learn ${mem} || exit 0`;
521
710
  return {
522
711
  PreToolUse: [
523
- { matcher: 'Bash', hooks: [{ type: 'command', command: gate }] },
712
+ ...(processPackOn(doctrines)
713
+ ? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
524
714
  {
525
715
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
526
716
  hooks: [{ type: 'command', command: ruleGate }],
@@ -550,7 +740,8 @@ export function claudeHookGroups(cfg, hookOpts) {
550
740
  *
551
741
  * `scope` is accepted and unused, exactly as in the Python.
552
742
  */
553
- export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {}) {
743
+ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
744
+ doctrines = null) {
554
745
  const prior = (priorHooks || []).filter(isDict);
555
746
  let config = {};
556
747
  let hadComments = false;
@@ -570,7 +761,7 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
570
761
  }
571
762
  let hooks = get(config, 'hooks');
572
763
  if (!isDict(hooks)) hooks = {};
573
- const canonical = claudeHookGroups(path.dirname(p), hookOpts);
764
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
574
765
  const canonFlat = [];
575
766
  for (const [event, groups] of Object.entries(canonical)) {
576
767
  for (const g of groups) canonFlat.push({ event, group: g });
package/js/setup.mjs CHANGED
@@ -46,7 +46,7 @@ import path from 'node:path';
46
46
 
47
47
  import { main as driverMain, resolveOut } from '../bin/geneseed.mjs';
48
48
  import { playLine } from './anim.mjs';
49
- import { discoverNames } from './checkout.mjs';
49
+ import { discoverNames, PACK_ORDER } from './checkout.mjs';
50
50
  import { exportImprovements } from './diff.mjs';
51
51
  import { cmdDoctor } from './doctor.mjs';
52
52
  import { setupBuildArgs } from './generate.mjs';
@@ -211,6 +211,23 @@ const MODE_BLURBS = {
211
211
  foreman: 'triages tasks, spawns pipelines for substantial work',
212
212
  };
213
213
 
214
+ /**
215
+ * One line per doctrine pack, written for someone who has never read `src/doctrines/README.md`
216
+ * — this is the only description of a pack an installer ever sees.
217
+ *
218
+ * `process` says what turning it OFF costs, and it is the only one that has to. The other three
219
+ * trade practices for context; process 5 is the commit/push consent gate, so dropping the pack
220
+ * drops the rule that every commit and every push is asked for. A blurb that read "planning and
221
+ * context economy" would have sold that away in a menu.
222
+ */
223
+ const DOCTRINE_BLURBS = {
224
+ craft: 'how code is written — reuse first, house conventions, docs, the smallest diff',
225
+ rigor: 'how work is proven — idempotence, honest tests, coverage, gates that can fail',
226
+ ops: 'how the machine is driven — tool discovery, non-blocking commands, teardown',
227
+ process: 'how a session runs — planning, context economy, docs first, and the consent gate '
228
+ + 'on every commit and push (drop this pack and that gate goes with it)',
229
+ };
230
+
214
231
  /**
215
232
  * `_harness_setup._theme_options`.
216
233
  *
@@ -243,6 +260,20 @@ export function modeOptions() {
243
260
  return discoverNames('modes', 'direct').map((n) => [n, MODE_BLURBS[n] ?? '']);
244
261
  }
245
262
 
263
+ /**
264
+ * The doctrine packs, in `PACK_ORDER` — the third option table, and the one that is a SET.
265
+ *
266
+ * The order is NARRATIVE and comes from `PACK_ORDER`; `discoverNames` only says which packs
267
+ * this checkout actually ships. Ordering by discovery instead would read `craft, ops, process,
268
+ * rigor` — it `.sort()`s — and the menu a human answers would stop matching the `Active packs:`
269
+ * line the build writes, which is normalised into `PACK_ORDER` at the driver. Same intersection
270
+ * the driver's `parseDoctrines` and the config loader take, for the same reason.
271
+ */
272
+ export function doctrineOptions() {
273
+ const known = discoverNames('doctrines', PACK_ORDER[0]);
274
+ return PACK_ORDER.filter((n) => known.includes(n)).map((n) => [n, DOCTRINE_BLURBS[n] ?? '']);
275
+ }
276
+
246
277
  /**
247
278
  * `_harness_setup.EMIT_OPTIONS`.
248
279
  *
@@ -272,12 +303,60 @@ const FOOTPRINT_OPTIONS = [
272
303
  // --------------------------------------------------------------------------------------
273
304
 
274
305
  /**
275
- * `_harness_setup._collect_setup_lines` — the five questions, the plan, the confirm.
306
+ * The Doctrine-packs question — one `all`/`choose` gate, then a y/n per pack.
307
+ *
308
+ * TWO LEVELS RATHER THAN FOUR QUESTIONS, because the overwhelmingly common answer is "all of
309
+ * them" and a wizard that asks four yes/nos to get there taxes every install for the rare one.
310
+ * The gate's default is `all`, so an installer who holds Enter through the whole wizard gets
311
+ * the full set — which is what every pre-packs install already had.
312
+ *
313
+ * `askChoice` and not `confirm` for the per-pack question: `confirm` reads the first character
314
+ * and prints no description, so the pack's blurb — the only place a reader is told what the
315
+ * pack costs — would have nowhere to go. Two named options give it a line.
316
+ *
317
+ * Answering `no` to all four is legal and yields `[]`, which `setupBuildArgs` spells
318
+ * `--doctrines none`. That is a real configuration (invariants and ontology only), not a
319
+ * mistake to guard against, so it is not second-guessed here.
320
+ *
321
+ * `deployed` is what the machine's carriers already say (`installedDefaults().doctrines`), and
322
+ * it drives BOTH levels: a narrowed install opens on `choose` with its own packs pre-ticked,
323
+ * so holding Enter through the wizard KEEPS the selection instead of silently widening it back
324
+ * to all four. `null` — no carrier said — still opens on `all`, which is the same resolution
325
+ * `doctrinesForBuild` gives unknown, so the wizard and the build agree about silence.
326
+ *
327
+ * ⚠ It never returns `null`, at either level, and `tests/unit/setup.test.mjs` pins that: this
328
+ * is the one call site allowed to reach `setupBuildArgs` without a `doctrinesForBuild`
329
+ * fallback, and that exemption is only sound while the function is total.
330
+ */
331
+ function askDoctrines(deployed = null) {
332
+ const opts = doctrineOptions();
333
+ const all = opts.map(([n]) => n);
334
+ const known = Array.isArray(deployed) ? all.filter((n) => deployed.includes(n)) : null;
335
+ const narrowed = known !== null && known.length !== all.length;
336
+ const pick = askChoice('Doctrine packs', [
337
+ ['all', `${all.join(' + ')}${narrowed ? '' : ' (default)'}`],
338
+ ['choose', `pick packs${narrowed ? ` (installed: ${known.join(', ') || 'none'})` : ''}`],
339
+ ], narrowed ? 'choose' : 'all');
340
+ if (pick !== 'choose') return all;
341
+ return all.filter((name) => askChoice(`Include ${name} — ${DOCTRINE_BLURBS[name] ?? name}`,
342
+ [['yes', `build ${name} into AGENT.md`], ['no', `leave ${name} out`]],
343
+ known === null || known.includes(name) ? 'yes' : 'no') === 'yes');
344
+ }
345
+
346
+ /**
347
+ * `_harness_setup._collect_setup_lines` — the six questions, the plan, the confirm.
276
348
  *
277
349
  * Returns the selection, or null when the confirm is declined. Every default is the
278
350
  * DEPLOYED value first (`installedDefaults`) and the CONFIGURED one second, which is why
279
351
  * P5i is the phase that had to give `installedDefaults` its posture, mode and footprint keys
280
352
  * back: three of the five questions read them.
353
+ *
354
+ * Doctrines reads the same walk, and joined it last because the `Active packs:` marker reader
355
+ * did not exist when the question shipped. Until it did, every re-run of the wizard offered
356
+ * `all` to an installer who had deliberately narrowed their packs — the one register where
357
+ * holding Enter SILENTLY WIDENED a selection instead of keeping it. `inst.doctrines` is passed
358
+ * whole, `null` included, because `askDoctrines` distinguishes "no carrier said" from "a
359
+ * carrier said none" and the two open the question differently.
281
360
  */
282
361
  export function collectSetupLines() {
283
362
  pyPrint('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
@@ -285,6 +364,7 @@ export function collectSetupLines() {
285
364
  const theme = askChoice('Theme', themeOptions(), inst.theme || defaultTheme());
286
365
  const posture = askChoice('Posture', postureOptions(), inst.posture || defaultPosture());
287
366
  const mode = askChoice('Mode', modeOptions(), inst.mode || defaultMode());
367
+ const doctrines = askDoctrines(inst.doctrines);
288
368
  const emit = askChoice('Install mode', EMIT_OPTIONS, inst.emit || 'opencode-global');
289
369
  const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS.map(([k, d]) => [k, d]),
290
370
  inst.footprint || 'lean');
@@ -299,10 +379,15 @@ export function collectSetupLines() {
299
379
  } else if (emit === 'files') {
300
380
  out = ask('Output dir for the bundle', 'Harness');
301
381
  }
382
+ // The full pack list is passed as the elision default rather than left to `setupBuildArgs`'s
383
+ // own: the menu the user just answered was built from `doctrineOptions()`, so "they picked
384
+ // everything" must be judged against that same list, not against a second one.
385
+ const allPacks = doctrineOptions().map(([n]) => n);
302
386
  pyPrint(`\nAbout to run: geneseed build ${
303
- setupBuildArgs(theme, emit, out, root, footprint, posture, mode).join(' ')}\n`);
387
+ setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks)
388
+ .join(' ')}\n`);
304
389
  if (!confirm('Proceed?', true)) return null;
305
- return { theme, posture, mode, emit, out, root, footprint };
390
+ return { theme, posture, mode, doctrines, emit, out, root, footprint };
306
391
  }
307
392
 
308
393
  // --------------------------------------------------------------------------------------
@@ -441,6 +526,7 @@ export function setupLines() {
441
526
  }
442
527
  const {
443
528
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
529
+ doctrines = null,
444
530
  } = sel;
445
531
  if (emit === 'opencode-global') {
446
532
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -455,7 +541,11 @@ export function setupLines() {
455
541
  pyPrint(`! could not export local edits (${e && e.message}) — continuing.\n`);
456
542
  }
457
543
  }
458
- const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode);
544
+ // Same list the question was built from — see `collectSetupLines`. The build that actually
545
+ // runs must carry the pack selection the plan line just previewed, or the wizard would print
546
+ // one command and run another.
547
+ const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
548
+ doctrineOptions().map(([n]) => n));
459
549
  pyPrint(`Running: geneseed build ${argv.join(' ')}\n`);
460
550
  const rc = driverMain(argv);
461
551
  if (rc !== 0) {
package/js/status.mjs CHANGED
@@ -125,9 +125,24 @@ export function cmdVersion(args) {
125
125
  * it, and the eleven `status` cells and the catalog cells now gate the same classifier
126
126
  * from opposite ends.
127
127
  */
128
- export function inventoryCounts(themeName) {
129
- const inv = tuiInventory(themeName);
130
- return { agents: inv.agents.length, skills: inv.skills.length, laws: inv.laws.length };
128
+ export function inventoryCounts(themeName, doctrines = null) {
129
+ const inv = tuiInventory(themeName, doctrines);
130
+ const on = inv.doctrines.filter((p) => p.active);
131
+ return {
132
+ agents: inv.agents.length,
133
+ skills: inv.skills.length,
134
+ laws: inv.laws.length,
135
+ ontology: inv.ontology.length,
136
+ // ⚠ THREE NUMBERS AND NOT ONE, because a pack-off install is a real configuration and
137
+ // "23 rules" would be a lie in it. `packs`/`packsTotal` is the fraction the console and
138
+ // the panel both print; `doctrineRules` counts only the ACTIVE packs' rules — what this
139
+ // install is actually bound by, not what its bundle happens to carry. The catalogue is
140
+ // always whole (every pack file ships), so the total is recoverable from `tuiInventory`
141
+ // by anything that needs it and is not duplicated here.
142
+ packs: on.length,
143
+ packsTotal: inv.doctrines.length,
144
+ doctrineRules: on.reduce((n, p) => n + p.rules.length, 0),
145
+ };
131
146
  }
132
147
 
133
148
  /**
@@ -163,7 +178,10 @@ export function statusData() {
163
178
  const inst = installedDefaults();
164
179
  const theme = inst.theme || defaultTheme();
165
180
  const mdir = resolveMemoryDir(null);
166
- const inv = inventoryCounts(theme);
181
+ // `inst.doctrines` is what the machine's carriers say — `null` when none of them does, which
182
+ // `tuiInventory` reads as every pack, the same resolution `doctrinesForBuild` gives unknown.
183
+ // This is a DISPLAY read: nothing here decides what a build emits.
184
+ const inv = inventoryCounts(theme, inst.doctrines);
167
185
  let cfgDir = null;
168
186
  try { cfgDir = opencodeConfigDir(); } catch { cfgDir = null; }
169
187
  const sourceFp = sourceFingerprint(makeCfg());
@@ -189,6 +207,15 @@ export function statusData() {
189
207
  agents: inv.agents,
190
208
  skills: inv.skills,
191
209
  laws: inv.laws,
210
+ // ⚠ NAMED ONE BY ONE, AND THAT IS THE TRAP THIS FUNCTION SETS TWICE. `inv` is already
211
+ // `inventoryCounts`' reduction, and this literal names its keys again — so a count added
212
+ // to the inventory and not to BOTH places is dropped here in silence, with nothing to
213
+ // fail. There is deliberately no spread: the panel's payload is a stated contract, and
214
+ // `js/web/api.mjs` spreads THIS, so anything reaching that endpoint passes through here.
215
+ ontology: inv.ontology,
216
+ packs: inv.packs,
217
+ packs_total: inv.packsTotal,
218
+ doctrine_rules: inv.doctrineRules,
192
219
  memory_dir: mdir ? String(mdir) : null,
193
220
  facts: mdir ? memoryFactCount(mdir) : 0,
194
221
  source_fp: sourceFp,
@@ -248,6 +275,20 @@ export function statusLines(d, color = false) {
248
275
  + `(${d.facts} fact${d.facts === 1 ? '' : 's'})`],
249
276
  ['version', `${d.installed_fp || '(none)'} ${DOT} source ${d.source_fp}`],
250
277
  ];
278
+ // ⚠ A ROW, NOT A WIDER `components` LINE, AND THE REASON IS THAT THE CORPUS CANNOT BE
279
+ // RE-BLESSED. `statusLines` has 30 recorded cases per platform in
280
+ // `tests/__snapshots__/primitives/`, every one of them holding the box's exact bytes —
281
+ // including `17 agents · 47 skills · 37 laws` — and the recorder was Python and is gone.
282
+ // Touching `components` moves all 30; adding a row would too, since the box width is
283
+ // measured over `rows`. So the row is CONDITIONAL on a key the recorded arguments do not
284
+ // carry, exactly as `agent_md` below already is, and the recording renders unchanged.
285
+ //
286
+ // It carries the two tiers `components` cannot: `components` counts the invariants as
287
+ // `laws`, which is still true, and says nothing about the ontology or the packs.
288
+ if (d.packs_total !== undefined) {
289
+ rows.push(['constitution', `${d.ontology} ontology sections ${DOT} ${d.doctrine_rules} `
290
+ + `doctrine rules in ${d.packs}/${d.packs_total} packs`]);
291
+ }
251
292
  if (d.agent_md) {
252
293
  rows.push(['AGENT.md',
253
294
  `${d.agent_md} (${d.agent_md_present ? 'present' : 'MISSING'})`]);
package/js/tui.mjs CHANGED
@@ -246,6 +246,11 @@ const ICONS = {
246
246
  agent: ['🤖', '◆', '@'],
247
247
  skill: ['✨', '✦', '*'],
248
248
  law: ['📜', '§', '#'],
249
+ // The other two tiers. Safe to add: `icon` has 32 recorded cases and none of them names a
250
+ // key that did not exist, so a new entry changes no recorded answer — unlike `glyphs`,
251
+ // whose recorded results are the whole object and which is therefore left alone.
252
+ ontology: ['🧭', '◉', 'O'],
253
+ doctrine: ['📐', '⌘', 'D'],
249
254
  library: ['📚', '❒', 'L'],
250
255
  notebook: ['📓', '✎', 'N'],
251
256
  wiki: ['📘', '▥', 'W'],
@@ -387,14 +392,47 @@ export function themeFlair(theme) {
387
392
  /**
388
393
  * `_tui_entries` — the ordered (kind, label, data) rows for the left list. `head` is a
389
394
  * section divider and is not selectable.
395
+ *
396
+ * ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS. This function has two
397
+ * recorded cases per platform in `tests/__snapshots__/primitives/`, replayed under
398
+ * `deepStrictEqual`, and their argument is a synthetic inventory carrying exactly
399
+ * `{agents, skills, laws, theme}` — no recorder survives to re-bless them. Reading
400
+ * `inv.doctrines.length` there THROWS; emitting a head unconditionally changes the recorded
401
+ * result array. Keyed on presence, the recording renders byte-identical and a real inventory
402
+ * gets all three tiers.
403
+ *
404
+ * The order is CONSTITUTIONAL and not alphabetical: ontology, then invariants, then doctrines
405
+ * — the order they are read in, the order AGENT.md renders them, and the order the console's
406
+ * three bands appear in. `Rule` and `Doctrine` are hardcoded English here, as `Rule` already
407
+ * was: this function receives an inventory, not a theme's nouns.
390
408
  */
391
409
  export function tuiEntries(inv) {
392
410
  const rows = [['head', `AGENTS (${inv.agents.length})`, null]];
393
411
  for (const e of inv.agents) rows.push(['agent', e.name, e]);
394
412
  rows.push(['head', `SKILLS (${inv.skills.length})`, null]);
395
413
  for (const e of inv.skills) rows.push(['skill', e.name, e]);
414
+ if (inv.ontology?.length) {
415
+ rows.push(['head', `ONTOLOGY (${inv.ontology.length})`, null]);
416
+ for (const e of inv.ontology) rows.push(['ontology', e.title, e]);
417
+ }
396
418
  rows.push(['head', `LAWS (${inv.laws.length})`, null]);
397
419
  for (const e of inv.laws) rows.push(['law', `Rule ${e.num} — ${e.title}`, e]);
420
+ if (inv.doctrines?.length) {
421
+ // The head counts the ACTIVE packs' rules and names the fraction, because a pack that is
422
+ // not built in is still listed — greyed rather than absent, so "off" never looks like
423
+ // "gone" — and a head reading `DOCTRINES (23)` over an install bound by six would be the
424
+ // one number in this panel that lies.
425
+ const on = inv.doctrines.filter((p) => p.active);
426
+ const n = on.reduce((a, p) => a + p.rules.length, 0);
427
+ rows.push(['head',
428
+ `DOCTRINES (${n} in ${on.length}/${inv.doctrines.length} packs)`, null]);
429
+ for (const p of inv.doctrines) {
430
+ for (const r of p.rules) {
431
+ rows.push(['doctrine', `Doctrine ${r.pack} ${r.n} — ${r.title}`,
432
+ { ...r, active: p.active, source: p.source }]);
433
+ }
434
+ }
435
+ }
398
436
  return rows;
399
437
  }
400
438
 
@@ -420,7 +458,12 @@ export function tuiEntries(inv) {
420
458
  * the fix is one `or []` at the call site, and it belongs in the same change as the port.
421
459
  */
422
460
  export function detailLines(kind, label, data) {
423
- if (kind === 'law') return [label, ''].concat(data ? pySplitLines(data.body) : []);
461
+ // The three constitutional tiers share one treatment — the label, a blank, then the body —
462
+ // because all three are a titled rule rather than a rendered spec. New kinds only: the nine
463
+ // recorded cases name kinds that already existed, so no recorded answer moves.
464
+ if (kind === 'law' || kind === 'doctrine' || kind === 'ontology') {
465
+ return [label, ''].concat(data ? pySplitLines(data.body) : []);
466
+ }
424
467
  if ((kind === 'agent' || kind === 'skill') && data) return pySplitLines(data.body);
425
468
  return null;
426
469
  }