geneseed 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/CHANGELOG.md +217 -5
  2. package/DESIGN.md +149 -1326
  3. package/QUICKSTART.md +1 -1
  4. package/README.md +30 -22
  5. package/SETUP.md +98 -24
  6. package/SHIPPED.md +6 -5
  7. package/adapters/bob/README.md +3 -3
  8. package/adapters/claude-code/README.md +6 -4
  9. package/adapters/claude-code/skills/geneseed/SKILL.md +10 -6
  10. package/adapters/copilot/README.md +5 -4
  11. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +11 -6
  12. package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
  13. package/adapters/opencode/README.md +15 -9
  14. package/adapters/opencode/agents/orchestrator.md +1 -1
  15. package/adapters/opencode/opencode.json +0 -9
  16. package/adapters/opencode/plugins/geneseed-context.js +7 -7
  17. package/adapters/opencode/plugins/geneseed-guard.js +6 -6
  18. package/adapters/opencode/workflows/dispatch.js +1 -1
  19. package/bin/geneseed.mjs +74 -8
  20. package/docs/declined.md +53 -2
  21. package/docs/design-history.md +1290 -0
  22. package/docs/extending.md +672 -0
  23. package/docs/limits.md +141 -82
  24. package/docs/token-footprint.md +15 -4
  25. package/docs/web/agents.md +1 -1
  26. package/docs/web/authoring.md +1 -1
  27. package/docs/web/claude-hooks.md +2 -2
  28. package/docs/web/collaboration.md +3 -3
  29. package/docs/web/first-session.md +2 -2
  30. package/docs/web/footprint.md +14 -7
  31. package/docs/web/foreman-mode.md +2 -2
  32. package/docs/web/harnesses.md +5 -3
  33. package/docs/web/install-quick.md +1 -1
  34. package/docs/web/model.md +3 -3
  35. package/docs/web/plugin-context.md +2 -2
  36. package/docs/web/plugin-guard.md +3 -3
  37. package/docs/web/plugin-ponytail.md +1 -1
  38. package/docs/web/rules.md +23 -3
  39. package/docs/web/setup-choices.md +20 -7
  40. package/docs/web/themes-author.md +13 -1
  41. package/docs/web-ui.md +36 -12
  42. package/geneseed +0 -0
  43. package/harness.config.json +1 -1
  44. package/js/catalog.mjs +29 -4
  45. package/js/checkout.mjs +24 -1
  46. package/js/cli-table.json +9 -7
  47. package/js/diff.mjs +8 -3
  48. package/js/doctor.mjs +374 -26
  49. package/js/emit.mjs +48 -11
  50. package/js/generate.mjs +39 -5
  51. package/js/hooks.mjs +8 -7
  52. package/js/installs.mjs +101 -4
  53. package/js/inventory.mjs +150 -20
  54. package/js/lib/fs.mjs +6 -2
  55. package/js/mcp.mjs +3 -15
  56. package/js/migrate.mjs +10 -4
  57. package/js/render.mjs +116 -11
  58. package/js/settings.mjs +297 -28
  59. package/js/setup.mjs +95 -5
  60. package/js/status.mjs +45 -4
  61. package/js/themes.mjs +1 -1
  62. package/js/tui.mjs +46 -3
  63. package/js/uninstall.mjs +30 -11
  64. package/js/web/actions.mjs +76 -9
  65. package/js/web/api.mjs +95 -16
  66. package/js/web/docs.mjs +33 -5
  67. package/js/web/jobs.mjs +12 -3
  68. package/js/web/server.mjs +59 -2
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +75 -65
  71. package/src/agents/_template.md +4 -4
  72. package/src/agents/advocate.md +4 -4
  73. package/src/agents/architect.md +2 -2
  74. package/src/agents/developer.md +5 -5
  75. package/src/agents/docs.md +2 -2
  76. package/src/agents/empiricist.md +3 -3
  77. package/src/agents/explorer.md +3 -3
  78. package/src/agents/framer.md +3 -3
  79. package/src/agents/historian.md +3 -3
  80. package/src/agents/operator.md +3 -3
  81. package/src/agents/pragmatist.md +3 -3
  82. package/src/agents/reviewer.md +2 -2
  83. package/src/agents/security.md +2 -2
  84. package/src/agents/skeptic.md +4 -4
  85. package/src/agents/steward.md +3 -3
  86. package/src/agents/tester.md +2 -2
  87. package/src/agents/user-advocate.md +3 -3
  88. package/src/agents/visionary.md +3 -3
  89. package/src/doctrines/README.md +41 -0
  90. package/src/doctrines/craft.md +51 -0
  91. package/src/doctrines/ops.md +61 -0
  92. package/src/doctrines/process.md +87 -0
  93. package/src/doctrines/rigor.md +52 -0
  94. package/src/laws/universal.md +17 -301
  95. package/src/memory/README.md +2 -2
  96. package/src/modes/README.md +2 -2
  97. package/src/modes/foreman.md +1 -1
  98. package/src/notebook/README.md +2 -2
  99. package/src/ontology/universal.md +89 -0
  100. package/src/postures/README.md +3 -3
  101. package/src/postures/artisan.md +3 -3
  102. package/src/postures/assistant.md +1 -1
  103. package/src/postures/expert.md +1 -1
  104. package/src/postures/mentor.md +1 -1
  105. package/src/postures/peer.md +2 -2
  106. package/src/skills/_template.md +2 -2
  107. package/src/skills/brainstorm.md +2 -2
  108. package/src/skills/clarify.md +2 -2
  109. package/src/skills/codebase-design.md +1 -1
  110. package/src/skills/commit.md +5 -5
  111. package/src/skills/council.md +4 -4
  112. package/src/skills/crash-course.md +1 -1
  113. package/src/skills/daydream/SKILL.md +1 -1
  114. package/src/skills/debug.md +2 -2
  115. package/src/skills/decode.md +1 -1
  116. package/src/skills/develop.md +6 -6
  117. package/src/skills/document-project.md +4 -4
  118. package/src/skills/domain-modeling.md +1 -1
  119. package/src/skills/drill.md +1 -1
  120. package/src/skills/feynman.md +1 -1
  121. package/src/skills/forge-mcp.md +3 -3
  122. package/src/skills/fresh-eyes.md +4 -4
  123. package/src/skills/frontend-design.md +1 -1
  124. package/src/skills/gap-detector.md +1 -1
  125. package/src/skills/geneseed-code-review.md +1 -1
  126. package/src/skills/geneseed.md +4 -4
  127. package/src/skills/git-archaeology.md +1 -1
  128. package/src/skills/git-rescue.md +3 -3
  129. package/src/skills/handoff.md +4 -4
  130. package/src/skills/herdr.md +1 -1
  131. package/src/skills/ingest.md +3 -3
  132. package/src/skills/learning-path.md +1 -1
  133. package/src/skills/migrate.md +5 -5
  134. package/src/skills/opencode-theme.md +1 -1
  135. package/src/skills/parallel-agents.md +2 -2
  136. package/src/skills/pipeline.md +2 -2
  137. package/src/skills/plan.md +2 -2
  138. package/src/skills/ponytail.md +1 -1
  139. package/src/skills/profile.md +1 -1
  140. package/src/skills/prose.md +2 -2
  141. package/src/skills/prototype.md +2 -2
  142. package/src/skills/refactor.md +2 -2
  143. package/src/skills/release.md +4 -4
  144. package/src/skills/repo-map.md +5 -5
  145. package/src/skills/research.md +6 -6
  146. package/src/skills/review-response.md +3 -3
  147. package/src/skills/roast-me.md +1 -1
  148. package/src/skills/rule.md +5 -5
  149. package/src/skills/ship.md +3 -3
  150. package/src/skills/tdd.md +2 -2
  151. package/src/skills/tickets.md +1 -1
  152. package/src/skills/wayfinder.md +1 -1
  153. package/src/skills/wiki.md +4 -4
  154. package/src/skills/workflow.md +2 -2
  155. package/themes/README.md +17 -7
  156. package/themes/_TEMPLATE.json +37 -33
  157. package/themes/biker.json +37 -33
  158. package/themes/commentator.json +37 -33
  159. package/themes/cyberpunk.json +37 -33
  160. package/themes/gamer.json +37 -33
  161. package/themes/imperial.json +37 -33
  162. package/themes/joker.json +37 -33
  163. package/themes/marvin.json +37 -33
  164. package/themes/mean.json +37 -33
  165. package/themes/military.json +37 -33
  166. package/themes/neutral.json +37 -33
  167. package/themes/pirate.json +37 -33
  168. package/themes/sports.json +37 -33
  169. package/themes/verstappen.json +37 -33
  170. package/themes/wizard.json +37 -33
  171. package/web/dist/assets/{About-DX1-VTBP.js → About-C0Mk0igT.js} +1 -1
  172. package/web/dist/assets/{Activity-Dp9NaXOn.js → Activity-C4aJJVXz.js} +1 -1
  173. package/web/dist/assets/{ActivityDetail-HpOBtrXS.js → ActivityDetail-DaQ85za0.js} +1 -1
  174. package/web/dist/assets/{Diff-Ct4G3Se8.js → Diff-B3tlO2Yj.js} +1 -1
  175. package/web/dist/assets/{Doctor-DGjkZFuA.js → Doctor-BNC-5Dmo.js} +1 -1
  176. package/web/dist/assets/{Graph-b8o-S6kv.js → Graph-us0T6ZcV.js} +1 -1
  177. package/web/dist/assets/Harnesses-DAMgyfM_.js +1 -0
  178. package/web/dist/assets/Laws-rOxegJ75.js +1 -0
  179. package/web/dist/assets/{Library-BEZscyaQ.js → Library-KmwrHA1J.js} +1 -1
  180. package/web/dist/assets/{Markdown-k_LSLBGq.js → Markdown-0eQOVGSF.js} +1 -1
  181. package/web/dist/assets/{Profile-DydukTxP.js → Profile-ZU7WV6Xl.js} +1 -1
  182. package/web/dist/assets/{Rules-CIRG4Ldt.js → Rules-CtDoPhhf.js} +1 -1
  183. package/web/dist/assets/{Skills-Buoi1IBr.js → Skills-BhoOxdkD.js} +1 -1
  184. package/web/dist/assets/{StatusBadge-CE_AJttl.js → StatusBadge-CGvYVCwa.js} +1 -1
  185. package/web/dist/assets/{Themes-CI4o3gJC.js → Themes-Dr7OOkD7.js} +1 -1
  186. package/web/dist/assets/{activity-BCW7e6Cy.js → activity-B8aq3qtl.js} +1 -1
  187. package/web/dist/assets/index-2wqPDxuN.css +1 -0
  188. package/web/dist/assets/{index-B1PIx7RM.js → index-AhMzkdbI.js} +1 -1
  189. package/web/dist/assets/index-CRftEtBX.js +12 -0
  190. package/web/dist/assets/{index-Ck2iCdaC.js → index-D0lPN_qW.js} +1 -1
  191. package/web/dist/index.html +2 -2
  192. package/web/src/pages/Laws.jsx +282 -88
  193. package/web/dist/assets/Harnesses-gr3-aHBF.js +0 -1
  194. package/web/dist/assets/Laws-DTwF5tTC.js +0 -1
  195. package/web/dist/assets/index-BnTQUSUz.js +0 -12
  196. package/web/dist/assets/index-DUCsDv7z.css +0 -1
package/js/render.mjs CHANGED
@@ -29,13 +29,20 @@
29
29
  import { readdirSync, statSync, existsSync } from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import { readText, parseJson, normcase, comparePaths } from './lib/fs.mjs';
32
+ // `js/checkout.mjs` imports nothing but node builtins, so this direction cannot cycle.
33
+ import { PACK_ORDER } from './checkout.mjs';
32
34
 
33
35
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
34
36
  export const STRUCTURE = {
35
37
  HARNESS: 'Geneseed', CHARTER: 'Charter', CONTEXT: 'Context',
36
38
  SCRIPT: 'Script', SCRIPTS: 'Scripts',
37
39
  DIR_LAWS: 'laws', DIR_AGENTS: 'agents', DIR_SKILLS: 'skills', DIR_MEMORY: 'memory',
38
- DIR_NOTEBOOK: 'notebook',
40
+ DIR_NOTEBOOK: 'notebook', DIR_ONTOLOGY: 'ontology', DIR_DOCTRINES: 'doctrines',
41
+ // Ontology section names. Theme-INDEPENDENT on purpose: a citation reads
42
+ // `({{ONTOLOGY}}: {{ONT_TELOS}})` — token on both sides, so the heading and every
43
+ // reference to it move together and a rename can never desync them.
44
+ ONT_TELOS: 'Telos', ONT_EVIDENCE: 'Evidence', ONT_DECISIONS: 'Decisions',
45
+ ONT_CONDUCT: 'Conduct',
39
46
  };
40
47
 
41
48
  /**
@@ -46,11 +53,15 @@ export const STRUCTURE = {
46
53
  export const SRC_DIR_TOKENS = {
47
54
  laws: 'DIR_LAWS', agents: 'DIR_AGENTS', skills: 'DIR_SKILLS',
48
55
  memory: 'DIR_MEMORY', notebook: 'DIR_NOTEBOOK',
56
+ ontology: 'DIR_ONTOLOGY', doctrines: 'DIR_DOCTRINES',
49
57
  };
50
58
 
51
59
  const TEXT_SUFFIXES = new Set(['.md', '.tmpl', '.json', '.txt', '.yml', '.yaml']);
52
60
 
53
- const TOKEN_RE = /\{\{([A-Z_]+)\}\}/g;
61
+ // Digits are legal after the first character: `DOC_<PACK>_<n>` (the doctrine rule titles) are
62
+ // the first token names in the project to carry one, and without `0-9` here `{{DOC_CRAFT_1}}`
63
+ // silently never substitutes. A leading digit stays illegal, so `{{1}}` is still not a token.
64
+ const TOKEN_RE = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
54
65
  const INCLUDE_RE = /^[ \t]*<!--[ \t]*INCLUDE:[ \t]*(?<path>[^ \t]+)[ \t]*-->[ \t]*$/gm;
55
66
  const CATALOG_BLOCK_RE =
56
67
  /[ \t]*<!-- CATALOG:begin -->\n(?<table>[\s\S]*?)[ \t]*<!-- CATALOG:else -->\n(?<pointer>[\s\S]*?)[ \t]*<!-- CATALOG:end -->\n/g;
@@ -89,9 +100,11 @@ export function loadTheme(cfg, name) {
89
100
  .map((f) => f.slice(0, -5)).sort().join(', ');
90
101
  // `sys.exit(<str>)` — the message on stderr, exit 1 — spelled the way
91
102
  // `assertSourceComplete` spells it: written at the raise site, and the throw carries
92
- // only the marker. It reads as an unreachable nicety under `bin/geneseed.mjs`, whose
93
- // `--theme` is validated against `choices` before anything renders. It is NOT
94
- // unreachable under `harness status`, which takes its theme from a `.geneseed-theme`
103
+ // only the marker. ⚠ It was once described as an unreachable nicety under
104
+ // `bin/geneseed.mjs`, on the grounds that `--theme` is validated against `choices` before
105
+ // anything renders — it is not: `--theme` takes a value and has no `choices` entry, so THIS
106
+ // is the refusal, on every path. It is also reachable from `geneseed status`, which takes
107
+ // its theme from a `.geneseed-theme`
95
108
  // marker — an unvalidated file in a user's install. A bare `throw` there printed a Node
96
109
  // stack trace where Python printed one line, and the acceptance matrix is what said so.
97
110
  process.stderr.write(`[geneseed] unknown theme '${name}'. available: ${available}\n`);
@@ -113,7 +126,7 @@ export function substitute(text, theme) {
113
126
  const v = theme[key];
114
127
  // Python renders `str(theme[key])`, which agrees with JS only for strings —
115
128
  // `str(True)`/`String(true)` and `str({...})`/`String({...})` do not. Every one of
116
- // the 145 tokens the source tree actually uses resolves to a string today
129
+ // the 148 tokens the source tree actually uses resolves to a string today
117
130
  // (AGENT_COLORS is a dict but is never a token), so a non-string here means the
118
131
  // source or a theme grew a case nobody has decided the semantics for. Fail loudly
119
132
  // rather than emit `[object Object]` into somebody's AGENT.md.
@@ -150,8 +163,18 @@ function splitAtLawHeadings(text) {
150
163
  return out;
151
164
  }
152
165
 
153
- /** `_build_render._terse_laws`. */
154
- export function terseLaws(text, theme, lawsPrefix = '') {
166
+ /**
167
+ * `_terse_laws` minus its pointer paragraph — heading + first sentence, per `### ` block.
168
+ *
169
+ * Split out rather than parameterised. The pointer was baked into the function body, and
170
+ * the doctrines axis needs the same truncation under a DIFFERENT closing paragraph (one
171
+ * that names the pack catalogue, not `laws/universal.md`, and drops the
172
+ * secrets/deletion/git-history clause, which is the invariants' domain and not a pack's).
173
+ * An extraction leaves `terseLaws` byte-identical for the laws path — the recorded lean
174
+ * AGENT.md does not move — where a fourth parameter would have put a branch inside the one
175
+ * function every frozen lean render already goes through.
176
+ */
177
+ function terseBlocks(text) {
155
178
  const blocks = splitAtLawHeadings(text);
156
179
  const out = [blocks[0].trimEnd()];
157
180
  for (const b of blocks.slice(1)) {
@@ -167,6 +190,11 @@ export function terseLaws(text, theme, lawsPrefix = '') {
167
190
  const m = /^([\s\S]+?[.!?])(?:\s|$)/.exec(body);
168
191
  out.push(`${heading}\n${m ? m[1].trim() : body}`);
169
192
  }
193
+ return out.join('\n\n');
194
+ }
195
+
196
+ /** `_build_render._terse_laws`. */
197
+ export function terseLaws(text, theme, lawsPrefix = '') {
170
198
  const law = theme.LAW ?? 'Law';
171
199
  const lawsDir = theme.DIR_LAWS ?? 'laws';
172
200
  const pointer =
@@ -174,7 +202,7 @@ export function terseLaws(text, theme, lawsPrefix = '') {
174
202
  + `complete, binding text of every ${law} is in \`${lawsPrefix}${lawsDir}/universal.md\`; `
175
203
  + `read it whenever a ${law}'s application is unclear, and before any act touching `
176
204
  + `secrets, deletion, git history, scope, or untrusted content.`;
177
- return out.join('\n\n') + '\n\n' + pointer;
205
+ return terseBlocks(text) + '\n\n' + pointer;
178
206
  }
179
207
 
180
208
  /** `_build_render.render_file`. */
@@ -211,6 +239,75 @@ function registerBody(cfg, theme, dir, selected, fallback) {
211
239
  return '';
212
240
  }
213
241
 
242
+ /**
243
+ * The doctrine packs this build actually renders, in `PACK_ORDER` — and the two gates
244
+ * that stand between `cfg.doctrines` and a silently wrong AGENT.md.
245
+ *
246
+ * GATE 1 (stray pack): a `.md` under `src/doctrines/` that `PACK_ORDER` does not name is a
247
+ * refusal, not a skip. Discovery sorts alphabetically and `PACK_ORDER` does not, so the two
248
+ * cannot be the same list; without this gate a fifth pack file would render into no install
249
+ * and no test would say so.
250
+ *
251
+ * GATE 2 (missing file): a pack named in `cfg.doctrines` with no file is a refusal too. The
252
+ * CLI validates `--doctrines` against discovery, but `makeCfg({doctrines})` is a public
253
+ * entry point and an emit that quietly dropped a pack the user asked for is exactly the
254
+ * failure the `Active packs:` marker would then attest to.
255
+ *
256
+ * Both throw with `exitCode`, the marker `bin/geneseed.mjs`'s `main` turns into a status.
257
+ */
258
+ function activeDoctrines(cfg) {
259
+ const dir = path.join(cfg.src, 'doctrines');
260
+ const onDisk = existsSync(dir)
261
+ ? readdirSync(dir)
262
+ .filter((f) => f.endsWith('.md') && path.basename(f, '.md').toLowerCase() !== 'readme')
263
+ .map((f) => path.basename(f, '.md'))
264
+ : [];
265
+ const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
266
+ if (stray.length) {
267
+ const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
268
+ + 'but are missing from PACK_ORDER (js/checkout.mjs) — add them there, or they render '
269
+ + 'into no install at all');
270
+ e.exitCode = 1;
271
+ throw e;
272
+ }
273
+ const wanted = cfg.doctrines ?? PACK_ORDER;
274
+ for (const name of wanted) {
275
+ if (!onDisk.includes(name)) {
276
+ const e = new Error(`doctrine pack '${name}' is selected but src/doctrines/${name}.md `
277
+ + `does not exist (packs on disk: ${onDisk.join(', ') || 'none'})`);
278
+ e.exitCode = 1;
279
+ throw e;
280
+ }
281
+ }
282
+ return PACK_ORDER.filter((p) => wanted.includes(p));
283
+ }
284
+
285
+ /**
286
+ * `{{DOCTRINES_BODY}}` — the active packs concatenated, the multi-select answer to
287
+ * `registerBody`'s single-select one.
288
+ *
289
+ * `registerBody` reads ONE file because a posture and a mode are scalars; a doctrine set is
290
+ * 0-4 files, so this loops instead of wrapping it. Under `lean` each pack gets the same
291
+ * heading + first-sentence treatment the invariants get, and the whole section — not each
292
+ * pack — closes with one pointer at the full catalogue, which ships in every bundle whether
293
+ * or not a pack is active.
294
+ */
295
+ function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
296
+ if (!active.length) return '';
297
+ const parts = active.map((name) => {
298
+ const body = substitute(readText(path.join(cfg.src, 'doctrines', `${name}.md`)), theme).trim();
299
+ return footprint === 'lean' ? terseBlocks(body) : body;
300
+ });
301
+ if (footprint === 'lean') {
302
+ const doctrine = theme.DOCTRINE ?? 'Doctrine';
303
+ const dir = theme.DIR_DOCTRINES ?? 'doctrines';
304
+ parts.push(`> Each ${doctrine} above is given in brief — the rule, not its reasoning. `
305
+ + `The complete text of every pack, active or not, is in \`${lawsPrefix}${dir}/\`; read `
306
+ + `the pack file whenever a ${doctrine}'s application is unclear.`);
307
+ }
308
+ return parts.join('\n\n');
309
+ }
310
+
214
311
  /**
215
312
  * `_build_render.effective_theme`.
216
313
  *
@@ -221,10 +318,18 @@ function registerBody(cfg, theme, dir, selected, fallback) {
221
318
  * it reaches nothing at all unless it travels. The constant stays as the default for the
222
319
  * parity harnesses, which drive this module directly.
223
320
  */
224
- export function effectiveTheme(cfg, themeName) {
321
+ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix = '' } = {}) {
225
322
  const theme = { ...loadTheme(cfg, themeName), ...(cfg.structure ?? STRUCTURE) };
226
323
  theme.POSTURE_BODY = registerBody(cfg, theme, 'postures', cfg.posture ?? 'peer', 'peer');
227
324
  theme.MODE_BODY = registerBody(cfg, theme, 'modes', cfg.mode ?? 'direct', 'direct');
325
+ // The doctrines pair is threaded, not read off `cfg`: `footprint` is a per-RENDER value
326
+ // (`renderAll`'s option, and the same cfg renders both ways in one process), so parking it
327
+ // on cfg would make the terse/full choice sticky across calls. An options object rather
328
+ // than two positional params keeps the existing two-arg call sites — every test, and
329
+ // `renderAll` — compiling unchanged, and defaults to the full text if a caller forgets.
330
+ const active = activeDoctrines(cfg);
331
+ theme.DOCTRINES_BODY = doctrinesBody(cfg, theme, active, footprint, lawsPrefix);
332
+ theme.DOCTRINES_LIST = active.length ? active.join(', ') : 'none';
228
333
  return theme;
229
334
  }
230
335
 
@@ -269,7 +374,7 @@ export function destRel(rel) {
269
374
  export function renderAll(cfg, themeName, {
270
375
  footprint = 'full', lawsPrefix = '', nativeCatalog = false,
271
376
  } = {}) {
272
- const theme = effectiveTheme(cfg, themeName);
377
+ const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
273
378
  const items = [];
274
379
  for (const file of sortedSourceFiles(cfg.src)) {
275
380
  const rel = path.relative(cfg.src, file);
package/js/settings.mjs CHANGED
@@ -33,7 +33,8 @@
33
33
  * much larger change than the defect.
34
34
  */
35
35
  import {
36
- chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
36
+ chmodSync, existsSync, mkdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync,
37
+ writeFileSync,
37
38
  } from 'node:fs';
38
39
  import path from 'node:path';
39
40
  import os from 'node:os';
@@ -66,17 +67,120 @@ function isDict(v) {
66
67
  return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
67
68
  }
68
69
 
69
- /** `_build_settings._default_permission`. */
70
- export function defaultPermission() {
71
- return {
72
- bash: {
73
- 'rm -rf *': 'ask',
74
- 'git commit*': 'ask',
75
- 'git push*': 'ask',
76
- 'git push --force*': 'ask',
77
- 'git push -f*': 'ask',
78
- },
79
- };
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];
80
184
  }
81
185
 
82
186
  /**
@@ -169,16 +273,27 @@ export function readJsonc(text) {
169
273
  }
170
274
  }
171
275
 
172
- /** `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice. */
173
- export function warnCommentedJsonc(target, agentPath, includePermission,
174
- 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) {
175
288
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
176
289
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
177
290
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
178
- if (includePermission) {
179
- process.stdout.write(`[${prefix}] and, for Geneseed's default ask-gates, a `
180
- + '"permission" key:\n');
181
- 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')) {
182
297
  process.stdout.write(`[${prefix}] ${line}\n`);
183
298
  }
184
299
  }
@@ -207,7 +322,7 @@ export function atomicWriteJson(p, config) {
207
322
  * re-implements the inverse itself rather than inherit this one's permission/lsp side
208
323
  * effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
209
324
  * `_build_global.emit_opencode_global`). */
210
- export function mergeOpencodeJson(p, agentPath) {
325
+ export function mergeOpencodeJson(p, agentPath, doctrines = null) {
211
326
  const target = opencodeTarget(p);
212
327
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
213
328
  let hadComments = false;
@@ -238,13 +353,58 @@ export function mergeOpencodeJson(p, agentPath) {
238
353
  const addInstr = indexOfEq(instr, agentPath) < 0;
239
354
  if (addInstr) instr.push(agentPath);
240
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.
241
360
  const addPerm = !has(config, 'permission');
242
- 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
+ }
243
391
  const addLsp = !has(config, 'lsp');
244
392
  if (addLsp) config.lsp = true;
245
- if (!addInstr && !addPerm && !addLsp) return target;
393
+ if (!addInstr && !permChanged && !addLsp) return target;
246
394
  if (path.extname(target) === '.jsonc' && hadComments) {
247
- 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);
248
408
  return target;
249
409
  }
250
410
  try {
@@ -294,6 +454,19 @@ export const SHIM_MARK = 'geneseed-hook';
294
454
  */
295
455
  export const SHIM_ARGV = new Set(['$@', '%*']);
296
456
 
457
+ /**
458
+ * Every quoted token in a shim body that names a path which is not there — empty is healthy.
459
+ *
460
+ * ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
461
+ * `"$@"` defect above to come back: `js/doctor.mjs` turns this into the `[shim] … does not
462
+ * exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
463
+ * protecting from a checkout that will not outlive the emit.
464
+ */
465
+ export function shimDeadPaths(body) {
466
+ return [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1])
467
+ .filter((q) => !SHIM_ARGV.has(q) && !existsSync(q));
468
+ }
469
+
297
470
  /** `_SHIM_REL`, with the platform as an argument.
298
471
  *
299
472
  * Python computes it at IMPORT time from `sys.platform`, which is what makes the other
@@ -401,6 +574,50 @@ export function writeHookShim(p, body, platform = process.platform) {
401
574
  }
402
575
  }
403
576
 
577
+ /** The OS temp root, resolved once. `.native` because a Windows `TEMP` can be an 8.3 alias —
578
+ * the same trap `tests/helpers/sandbox.mjs` documents, reached from the other side. */
579
+ const TMP_REAL = (() => {
580
+ try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
581
+ })();
582
+
583
+ /** Is `child` inside `parent`? `path.relative` compares case-insensitively on win32. */
584
+ function isUnder(child, parent) {
585
+ const rel = path.relative(parent, child);
586
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
587
+ }
588
+
589
+ /**
590
+ * Will the checkout holding `entry` outlive the emit that is baking it into the shim?
591
+ *
592
+ * TWO SHAPES, and both are in `docs/extending.md` §5.3 because both have really happened on this
593
+ * repo: a copied checkout under the OS temp root — the `copyCheckout` fixture, which builds its
594
+ * copy from `git ls-files` and therefore carries no `.git` at all, so only the temp root
595
+ * identifies it — and a git WORKTREE, whose `.git` is a FILE pointing at the main checkout
596
+ * rather than a directory. A plain checkout and an npm install are neither, and keep the
597
+ * ownership they have always had.
598
+ *
599
+ * `tmpRoot` IS A PARAMETER for the reason `shimRel`'s `platform` is: with the real temp root
600
+ * baked in, the second rule is unreachable in a test — anything a test can build under `mkdtemp`
601
+ * has already answered true on the first — so the worktree arm could only ever be exercised by
602
+ * whichever kind of checkout the run happened to sit in.
603
+ */
604
+ export function ephemeralCheckout(entry, tmpRoot = TMP_REAL) {
605
+ let real = entry;
606
+ try { real = realpathSync.native(entry); } catch { /* not there yet — test the literal */ }
607
+ if (isUnder(real, tmpRoot)) return true;
608
+ // `<checkout>/bin/geneseed-hook.mjs` — the one shape `hookRunnerEntry` produces.
609
+ try { return statSync(path.join(path.dirname(path.dirname(real)), '.git')).isFile(); } catch {
610
+ return false;
611
+ }
612
+ }
613
+
614
+ /** Does the shim already on disk still name paths that all exist? */
615
+ function shimIsLive(p) {
616
+ try { return statSync(p).isFile() && shimDeadPaths(readText(p)).length === 0; } catch {
617
+ return false;
618
+ }
619
+ }
620
+
404
621
  /**
405
622
  * `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
406
623
  * with, falling back to the pre-shim direct form when the shim cannot be written.
@@ -422,6 +639,26 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
422
639
  + 'bakes them, and there is no correct value to guess from inside Node');
423
640
  }
424
641
  const p = hookShimPath(platform);
642
+ // ⚠ A DISPOSABLE CHECKOUT DOES NOT GET TO CLAIM THE MACHINE-WIDE SHIM. The shim has no
643
+ // per-install component, so the last checkout to emit owns EVERY install's hooks — and when
644
+ // that checkout is a test sandbox or a git worktree, deleting it kills hooks machine-wide with
645
+ // nothing to report it: hooks signal through stdout and return 0 on every path, and this file
646
+ // is excluded from the byte corpora by name. Measured twice in one session, from
647
+ // `tests/unit/harness.test.mjs`'s copied-checkout fixture; `docs/extending.md` §5.3 carries
648
+ // the worktree half.
649
+ //
650
+ // KEEP, NOT REFUSE, and that distinction is what makes this safe to add here rather than at
651
+ // each call site. Returning the existing shim path leaves the emitted hook command
652
+ // byte-identical to what a normal emit writes; taking the fallback below instead would move a
653
+ // hook command in every recorded bundle. The install being emitted then runs the DURABLE
654
+ // checkout's entry — which is exactly what last-writer-wins already handed every other install
655
+ // on the machine, so nothing is lost by it.
656
+ //
657
+ // ONLY A SHIM THAT STILL RESOLVES IS PROTECTED. An absent or already-dead one is no worse for
658
+ // being rewritten from here, and a sandboxed `GENESEED_HOME` (every emit test, every cell) has
659
+ // none — so the suite's own emits are unaffected and this cannot go green by silently
660
+ // skipping the write.
661
+ if (ephemeralCheckout(entry) && shimIsLive(p)) return `"${p}"`;
425
662
  const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
426
663
  if (shim !== null) return `"${shim}"`;
427
664
  process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
@@ -430,8 +667,38 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
430
667
  return `"${runner}" "${entry}"`;
431
668
  }
432
669
 
433
- /** `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event. */
434
- 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) {
435
702
  const run = hookPrefix(hookOpts);
436
703
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
437
704
  // --root carries the install's own dir so a GLOBAL hook can stand down when a project
@@ -442,7 +709,8 @@ export function claudeHookGroups(cfg, hookOpts) {
442
709
  const learn = `${run} learn ${mem} || exit 0`;
443
710
  return {
444
711
  PreToolUse: [
445
- { matcher: 'Bash', hooks: [{ type: 'command', command: gate }] },
712
+ ...(processPackOn(doctrines)
713
+ ? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
446
714
  {
447
715
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
448
716
  hooks: [{ type: 'command', command: ruleGate }],
@@ -472,7 +740,8 @@ export function claudeHookGroups(cfg, hookOpts) {
472
740
  *
473
741
  * `scope` is accepted and unused, exactly as in the Python.
474
742
  */
475
- export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {}) {
743
+ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
744
+ doctrines = null) {
476
745
  const prior = (priorHooks || []).filter(isDict);
477
746
  let config = {};
478
747
  let hadComments = false;
@@ -492,7 +761,7 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
492
761
  }
493
762
  let hooks = get(config, 'hooks');
494
763
  if (!isDict(hooks)) hooks = {};
495
- const canonical = claudeHookGroups(path.dirname(p), hookOpts);
764
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
496
765
  const canonFlat = [];
497
766
  for (const [event, groups] of Object.entries(canonical)) {
498
767
  for (const g of groups) canonFlat.push({ event, group: g });