geneseed 3.2.0 → 3.3.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 (112) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -12
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +16 -10
  12. package/bin/geneseed-cli.mjs +55 -56
  13. package/bin/geneseed-hook.mjs +21 -14
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -6
  20. package/js/build/emit-claude.mjs +22 -7
  21. package/js/build/render.mjs +37 -19
  22. package/js/build/source.mjs +22 -2
  23. package/js/cli-table.json +84 -0
  24. package/js/hosts/hooks.mjs +240 -78
  25. package/js/hosts/hosts.mjs +56 -2
  26. package/js/hosts/link.mjs +1 -1
  27. package/js/hosts/mcp.mjs +3 -1
  28. package/js/hosts/native.mjs +17 -0
  29. package/js/hosts/settings.mjs +173 -5
  30. package/js/inspect/excludes.mjs +1 -1
  31. package/js/inspect/inventory.mjs +1 -1
  32. package/js/inspect/status.mjs +50 -2
  33. package/js/maintain/uninstall.mjs +38 -11
  34. package/js/maintain/update.mjs +2 -2
  35. package/js/web/actions.mjs +8 -8
  36. package/js/web/api.mjs +5 -10
  37. package/js/web/docs.mjs +9 -31
  38. package/js/web/handler.mjs +20 -30
  39. package/js/web/jobs.mjs +5 -26
  40. package/js/web/routes.mjs +18 -84
  41. package/js/web/server.mjs +3 -3
  42. package/package.json +1 -1
  43. package/registry.json +14 -0
  44. package/src/AGENT.md.tmpl +55 -16
  45. package/src/agents/explorer.md +1 -1
  46. package/src/doctrines/README.md +3 -3
  47. package/src/doctrines/rigor.md +13 -0
  48. package/src/laws/universal.md +98 -20
  49. package/src/ontology/universal.md +38 -13
  50. package/src/skills/_template.md +5 -1
  51. package/src/skills/bruno-collection-generator.md +63 -0
  52. package/src/skills/bruno-test-writer.md +62 -0
  53. package/src/skills/crash-course.md +1 -0
  54. package/src/skills/decode.md +1 -0
  55. package/src/skills/drill.md +1 -0
  56. package/src/skills/feynman.md +1 -0
  57. package/src/skills/forge-mcp.md +1 -1
  58. package/src/skills/herdr.md +1 -0
  59. package/src/skills/learning-path.md +1 -0
  60. package/src/skills/opencode-theme.md +1 -0
  61. package/src/skills/research.md +1 -1
  62. package/themes/_TEMPLATE.json +3 -0
  63. package/themes/biker.json +3 -0
  64. package/themes/commentator.json +3 -0
  65. package/themes/cyberpunk.json +3 -0
  66. package/themes/gamer.json +3 -0
  67. package/themes/imperial.json +3 -0
  68. package/themes/joker.json +3 -0
  69. package/themes/marvin.json +3 -0
  70. package/themes/mean.json +3 -0
  71. package/themes/military.json +3 -0
  72. package/themes/neutral.json +3 -0
  73. package/themes/pirate.json +3 -0
  74. package/themes/sports.json +3 -0
  75. package/themes/verstappen.json +3 -0
  76. package/themes/wizard.json +3 -0
  77. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  78. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  79. package/web/dist/assets/{CatalogRow-DqS4e5XM.js → CatalogRow-DALEQImZ.js} +1 -1
  80. package/web/dist/assets/{Diff-CIrGbiqm.js → Diff-BIjxXx46.js} +1 -1
  81. package/web/dist/assets/{Doctor-CpI53oHY.js → Doctor-CJLbgsrw.js} +1 -1
  82. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  83. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  84. package/web/dist/assets/{Harness-vRYs11kX.js → Harness-Dm5CCU-v.js} +1 -1
  85. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  86. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  87. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  88. package/web/dist/assets/{Markdown-kgpg9EQM.js → Markdown-C__nuWX5.js} +1 -1
  89. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  90. package/web/dist/assets/{Profile-CHr-PPf6.js → Profile-BlXtUlh2.js} +1 -1
  91. package/web/dist/assets/{Rules-CsC6hfqk.js → Rules-DIre6gxR.js} +1 -1
  92. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  93. package/web/dist/assets/{StatusBadge-gRrak0nO.js → StatusBadge-wW_RS_my.js} +1 -1
  94. package/web/dist/assets/{activity-B0-FXZCj.js → activity-DpYPKNrA.js} +1 -1
  95. package/web/dist/assets/{index-DJOhOwwR.js → index-BEZvPvpF.js} +1 -1
  96. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  97. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  98. package/web/dist/assets/{index-Bx3I7cRW.js → index-PmQGRS1X.js} +1 -1
  99. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  100. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  101. package/web/dist/index.html +2 -2
  102. package/web/src/pages/Laws.jsx +82 -64
  103. package/js/web/graph.mjs +0 -0
  104. package/web/dist/assets/Activity-BTCG2gMl.js +0 -1
  105. package/web/dist/assets/ActivityDetail-julsmPDB.js +0 -1
  106. package/web/dist/assets/Graph-CS4tA7YS.js +0 -1
  107. package/web/dist/assets/Laws-BjbHJrJt.js +0 -3
  108. package/web/dist/assets/Library-CjC7BAWN.js +0 -1
  109. package/web/dist/assets/Skills-BvatJGfe.js +0 -1
  110. package/web/dist/assets/index-BnRWQbZb.js +0 -12
  111. package/web/dist/assets/index-D0f9tJlA.css +0 -1
  112. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/docs/web-ui.md CHANGED
@@ -51,7 +51,7 @@ The left rail mirrors the harness's own shape:
51
51
  | Group | Views |
52
52
  | --- | --- |
53
53
  | *(ungrouped)* | Dashboard |
54
- | **📖 Codex** | Constitution · Rules · Profile · Skills · Agents · Library · Docs · Graph |
54
+ | **📖 Codex** | Constitution · Rules · Profile · Skills · Agents · Library · Docs |
55
55
  | **🩺 Care** | Activity · Changes · Doctor |
56
56
  | **🎨 Setup** | Harness · Settings |
57
57
 
@@ -72,9 +72,10 @@ streams the output of background jobs and keeps their history across reloads.
72
72
  ### 🧬 Harness
73
73
 
74
74
  - **Dashboard** (`#/`) — a live readout of what's deployed. Three lenses: **Status**
75
- (readiness ring, a KPI band, a genome grid, recent activity), **Lineage** (a mini
76
- cross-link graph), and **Operator** (a searchable table of every deployed capability).
77
- Headlines reflect the active theme's voice.
75
+ (readiness ring, a KPI band, a genome grid, recent activity), **Lineage** (the
76
+ source → render → deployed timeline and the genome-by-volume strand chart), and
77
+ **Operator** (a searchable table of every deployed capability). Headlines reflect the
78
+ active theme's voice.
78
79
 
79
80
  The KPI band reads **Agents · Skills · Invariants · Local edits**. That third tile is
80
81
  labelled *Invariants* and not *Constitution* on purpose: its value is the invariant count
@@ -107,15 +108,6 @@ streams the output of background jobs and keeps their history across reloads.
107
108
  **Promote to rule** control turns a recurring lesson into a trial rule in
108
109
  `user-rules.md` — provenance recorded, the source fact deleted so it isn't loaded
109
110
  twice — landing you on the Rules page.
110
- - **Graph** (`#/graph`) — the cross-link constellation across agents, skills and the
111
- **invariants only**: every `[[wikilink]]` between agents/skills, plus every `Rule N`
112
- mention that lands on a real invariant. ⚠ The reference pattern matches the tier noun
113
- followed by a **Roman numeral** — `Rule`, `Law`, or whatever the active theme calls one,
114
- which is how `Dictate III` still resolves under `imperial`. A doctrine address is
115
- `<pack> <n>` and matches nothing, so **doctrine cross-links are not drawn**; the graph is
116
- a view of the invariant backbone, not of the whole constitution. Hover to isolate a
117
- neighbourhood, scroll to zoom, drag to pan, search to highlight; orphans dim out. Click a
118
- node to open its spec.
119
111
 
120
112
  ### 📚 Learn
121
113
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.2.0"
5
+ "version": "3.3.0"
6
6
  }
package/js/README.md CHANGED
@@ -13,7 +13,7 @@ a module that does not exist, is a test failure. `geneseed doctor` reports the s
13
13
 
14
14
  ```
15
15
  bin/geneseed-cli.mjs the `geneseed` CLI — 20+ verbs, may NOT spawn
16
- bin/geneseed-hook.mjs the hook entry — 4 verbs, loads on EVERY tool call (~14 ms)
16
+ bin/geneseed-hook.mjs the hook entry — 5 verbs, loads on EVERY tool call (~14 ms)
17
17
  bin/build-driver.mjs `geneseed-build` — the generator's flags and emit targets
18
18
 
19
19
  js/build/ src/ + themes/ → rendered text → a bundle → a per-host install
@@ -66,7 +66,7 @@ user co-owns, and the hook verbs those emitted configs then run.
66
66
  | module | owns |
67
67
  |---|---|
68
68
  | `hosts.mjs` | The four host config dirs (opencode/claude/bob/copilot), plus `resolvePath`/`expanduser` |
69
- | `hooks.mjs` | The verbs the emitted hooks run every session: `context`, `git-gate`, `rule-gate`, `learn` |
69
+ | `hooks.mjs` | The verbs the emitted hooks run every session: `context`, `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Copilot), `learn`. `--host` picks the verdict dialect |
70
70
  | `settings.mjs` | Merges into user-owned `settings.json`/`opencode.json`; owns the hook shim and the managed blocks |
71
71
  | `native.mjs` | Capability specs → host-native subagents and skills. The impure half of the emit |
72
72
  | `opencode.mjs` | OpenCode-only extras: colour themes, primary agent, `/commands`, plugins, overrides stub |
@@ -168,13 +168,12 @@ the deployed harness as JSON.
168
168
  | `daemon.mjs` | Detached web start/stop/status/restart, the `.geneseed-web.json` record, and `openUrl` |
169
169
  | `jobs.mjs` | `JobManager` and the action→argv table. The only module here that spawns job children |
170
170
  | `docs.mjs` | The Docs tab: the `docs/web/` page registry, `?harness=` filtering, the five page kinds |
171
- | `graph.mjs` | `/api/graph` alone — the agent/skill/law cross-link matrix built from rendered bodies |
172
171
  | `activity.mjs` | The `activity/` session files: pid-liveness prune, list, detail, and the on/off flag |
173
172
 
174
173
  **Before editing:** `makeHandler(state, jm, token, dist, holder)`'s arity is load-bearing — three
175
- test call sites construct one directly and drive it without a socket. Five of `routes.mjs`'s
176
- exported sets are deliberately EMPTY and must stay declared: each is half of a partition test.
177
- `graph.mjs` contains a literal NUL byte, so `grep`/`ripgrep` treat it as binary and skip it.
174
+ test call sites construct one directly and drive it without a socket. `routes.mjs`'s tables
175
+ and inline lists are held against a surface list written out in
176
+ `tests/unit/web_server.test.mjs` — growing or shrinking the API edits both, in one commit.
178
177
 
179
178
  ---
180
179
 
@@ -10,8 +10,8 @@ import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
10
  import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
11
  import { ensureAgentOverridesStub } from '../hosts/opencode.mjs';
12
12
  import {
13
- managedBlockRemove, managedBlockWrite, mergeClaudeSettings, unwireClaudeExcludes,
14
- unwireClaudeSettings, wireClaudeExcludes,
13
+ managedBlockRemove, managedBlockWrite, mergeClaudeSettings, mergeCopilotSettings,
14
+ unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
15
15
  } from '../hosts/settings.mjs';
16
16
  import { writeText } from '../lib/fs.mjs';
17
17
  import { isTruthy } from '../lib/json.mjs';
@@ -225,12 +225,19 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
225
225
  // settings.local.json — the personal, untracked file — never the team-shared
226
226
  // settings.json, which would hand every teammate failing hooks pointing at this machine's
227
227
  // node and this machine's checkout. (Bob documents no local variant, so it keeps
228
- // settings.json.) Copilot has NO
229
- // settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
230
- // written, no settings_* keys recorded for the lifecycle to unwire.
228
+ // settings.json.) Copilot's settings are a different shape entirely (one command per
229
+ // event, no matcher groups) and take the `else` branch below; its PROJECT scope wires
230
+ // nothing, because the CLI documents hooks in `~/.copilot/settings.json` only and a
231
+ // machine-absolute hook committed into `.github/` would fail on every teammate's machine.
231
232
  if (!isCopilot) {
232
- const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
233
+ // Bob GLOBAL: its hooks doc puts the global file at `~/.bob/settings/settings.json`
234
+ // (nested), where an older emit wrote `~/.bob/settings.json`; the `oldSf !==
235
+ // settingsName` branch below unwires the old file, so a re-emit migrates. Bob's project
236
+ // file stays `.bob/settings.json`.
237
+ const settingsName = scope === 'project' && !isBob ? 'settings.local.json'
238
+ : isBob && scope === 'global' ? path.join('settings', 'settings.json') : 'settings.json';
233
239
  const settingsPath = path.join(cfgDir, settingsName);
240
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
234
241
  managed.settings_file = settingsName;
235
242
  // Migration: an older install wired hooks/excludes into a different file — unwire the
236
243
  // recorded claims there, or they linger (and run) forever.
@@ -247,7 +254,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
247
254
  // DECIDES the install's packs, so the marker on disk is still the previous build's.
248
255
  const [, managedHooks] = mergeClaudeSettings(
249
256
  settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
250
- doctrines, excludeRules,
257
+ doctrines, excludeRules, host,
251
258
  );
252
259
  managed.settings_hooks = managedHooks;
253
260
 
@@ -280,6 +287,14 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
280
287
  } else if (priorExcl.length) {
281
288
  managed.settings_excludes = priorExcl;
282
289
  }
290
+ } else if (scope === 'global') {
291
+ // `copilot_hooks`, not `settings_hooks`: a distinct key so the Claude-shaped unwire and
292
+ // integrity code, which walks `settings_hooks` as event→group arrays, never sees a
293
+ // Copilot record it would misread.
294
+ const settingsPath = path.join(cfgDir, 'settings.json');
295
+ managed.settings_file = 'settings.json';
296
+ const [, claims] = mergeCopilotSettings(settingsPath, get(old, 'copilot_hooks'), hookOpts);
297
+ managed.copilot_hooks = claims;
283
298
  }
284
299
  return managed;
285
300
  }
@@ -34,7 +34,7 @@ import { readText } from '../lib/fs.mjs';
34
34
  import { parseJson } from '../lib/json.mjs';
35
35
  import { normcase, comparePaths } from '../lib/paths.mjs';
36
36
  // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
- import { PACK_ORDER } from './source.mjs';
37
+ import { PACK_ORDER, readPackText } from './source.mjs';
38
38
 
39
39
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
40
40
  export const STRUCTURE = {
@@ -69,6 +69,16 @@ const TOKEN_RE = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
69
69
  const INCLUDE_RE = /^[ \t]*<!--[ \t]*INCLUDE:[ \t]*(?<path>[^ \t]+)[ \t]*-->[ \t]*$/gm;
70
70
  const CATALOG_BLOCK_RE =
71
71
  /[ \t]*<!-- CATALOG:begin -->\n(?<table>[\s\S]*?)[ \t]*<!-- CATALOG:else -->\n(?<pointer>[\s\S]*?)[ \t]*<!-- CATALOG:end -->\n/g;
72
+ // The footprint twin of CATALOG. Before it existed, `lean` could only reach the two
73
+ // include-driven corpora (`laws/universal.md` and the doctrine packs, both truncated
74
+ // mechanically to heading + first sentence); every other section of the root template was
75
+ // byte-identical across footprints, which is how ~1.5k tokens of §-level prose rode into
76
+ // every lean install. A LEAN block ships a HAND-WRITTEN condensation instead of a
77
+ // truncation — the objection `docs/token-footprint.md` raised against terse-ing the
78
+ // Ontology ("four orphan sentences") is answered by authoring the short form, not
79
+ // generating it. Same marker grammar as CATALOG so the two stay learnable as one idea.
80
+ const LEAN_BLOCK_RE =
81
+ /[ \t]*<!-- LEAN:begin -->\n(?<full>[\s\S]*?)[ \t]*<!-- LEAN:else -->\n(?<lean>[\s\S]*?)[ \t]*<!-- LEAN:end -->\n/g;
72
82
 
73
83
  /**
74
84
  * `PurePath.suffix`. Not `path.extname`: Python returns '' for a name whose only dot is
@@ -147,6 +157,11 @@ function resolveCatalogs(text, nativeCatalog) {
147
157
  return text.replace(CATALOG_BLOCK_RE, (_m, table, pointer) => (nativeCatalog ? pointer : table));
148
158
  }
149
159
 
160
+ /** LEAN blocks — full text or the hand-written condensation, by footprint. */
161
+ function resolveLean(text, footprint) {
162
+ return text.replace(LEAN_BLOCK_RE, (_m, full, lean) => (footprint === 'lean' ? lean : full));
163
+ }
164
+
150
165
  /**
151
166
  * `re.split(r"(?m)^(?=### )", text)`.
152
167
  *
@@ -168,15 +183,15 @@ function splitAtLawHeadings(text) {
168
183
  }
169
184
 
170
185
  /**
171
- * `_terse_laws` minus its pointer paragraph — heading + first sentence, per `### ` block.
186
+ * Heading + first sentence, per `### ` block. DOCTRINES ONLY, now.
172
187
  *
173
- * Split out rather than parameterised. The pointer was baked into the function body, and
174
- * the doctrines axis needs the same truncation under a DIFFERENT closing paragraph (one
175
- * that names the pack catalogue, not `laws/universal.md`, and drops the
176
- * secrets/deletion/git-history clause, which is the invariants' domain and not a pack's).
177
- * An extraction leaves `terseLaws` byte-identical for the laws path — the recorded lean
178
- * AGENT.md does not move — where a fourth parameter would have put a branch inside the one
179
- * function every frozen lean render already goes through.
188
+ * The laws used to go through this too, and it made a law's lean footprint a function of its
189
+ * punctuation: Law II shipped as one 36-character sentence and lost its stop-and-ask
190
+ * mechanism, while a law written as one colon-chained run-on shipped nearly whole. The laws
191
+ * now carry authored LEAN blocks in `src/laws/universal.md`, the way the ontology already did,
192
+ * and `resolveLean` picks the half. Doctrine rules stay machine-cut: there are ~23 of them
193
+ * across four packs, and a pack is a practice catalogue whose first sentence is written to
194
+ * stand alone.
180
195
  */
181
196
  function terseBlocks(text) {
182
197
  const blocks = splitAtLawHeadings(text);
@@ -197,16 +212,14 @@ function terseBlocks(text) {
197
212
  return out.join('\n\n');
198
213
  }
199
214
 
200
- /** `_build_render._terse_laws`. */
201
- export function terseLaws(text, theme, lawsPrefix = '') {
215
+ /** The pointer under §1 at lean. The laws' lean text itself is authored in LEAN blocks. */
216
+ export function lawsPointer(theme, lawsPrefix = '') {
202
217
  const law = theme.LAW ?? 'Law';
203
218
  const lawsDir = theme.DIR_LAWS ?? 'laws';
204
- const pointer =
205
- `> Each ${law} above is given in brief — the rule, not its reasoning. The `
219
+ return `> Each ${law} above is given in brief — the rule, not its reasoning. The `
206
220
  + `complete, binding text of every ${law} is in \`${lawsPrefix}${lawsDir}/universal.md\`; `
207
221
  + `read it whenever a ${law}'s application is unclear, and before any act touching `
208
- + `secrets, deletion, git history, scope, or untrusted content.`;
209
- return terseBlocks(text) + '\n\n' + pointer;
222
+ + 'secrets, deletion, git history, scope, or untrusted content.';
210
223
  }
211
224
 
212
225
  /** `_build_render.render_file`. */
@@ -225,12 +238,13 @@ export function renderFile(cfg, filePath, theme, footprint = 'full', lawsPrefix
225
238
  new Set(visiting).add(normcase(here)), nativeCatalog)
226
239
  .replace(/\n+$/, ''); // `.rstrip("\n")`
227
240
  if (footprint === 'lean' && rel === 'laws/universal.md') {
228
- inner = terseLaws(inner, theme, lawsPrefix);
241
+ inner = `${inner}\n\n${lawsPointer(theme, lawsPrefix)}`;
229
242
  }
230
243
  return inner;
231
244
  });
232
245
 
233
246
  text = resolveCatalogs(text, nativeCatalog);
247
+ text = resolveLean(text, footprint);
234
248
  return substitute(text, theme);
235
249
  }
236
250
 
@@ -291,7 +305,7 @@ function activeDoctrines(cfg) {
291
305
  // `Active packs:` marker a later reader parses back — agrees with what was rendered.
292
306
  const excluded = new Set(cfg.excludeRules ?? []);
293
307
  const survives = (pack) => {
294
- const ids = [...readText(path.join(dir, `${pack}.md`))
308
+ const ids = [...readPackText(path.join(dir, `${pack}.md`))
295
309
  .matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)]
296
310
  .map((m) => `${m[1]}.${Number(m[2])}`);
297
311
  return ids.some((id) => !excluded.has(id));
@@ -340,7 +354,7 @@ function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
340
354
  if (!active.length) return '';
341
355
  const excluded = new Set(cfg.excludeRules ?? []);
342
356
  const parts = active.map((name) => {
343
- const raw = dropExcludedRules(readText(path.join(cfg.src, 'doctrines', `${name}.md`)),
357
+ const raw = dropExcludedRules(readPackText(path.join(cfg.src, 'doctrines', `${name}.md`)),
344
358
  name, excluded);
345
359
  const body = substitute(raw, theme).trim();
346
360
  return footprint === 'lean' ? terseBlocks(body) : body;
@@ -440,8 +454,12 @@ export function renderAll(cfg, themeName, {
440
454
  for (const file of sortedSourceFiles(cfg.src)) {
441
455
  const rel = path.relative(cfg.src, file);
442
456
  const outRel = destRel(themedRel(rel, theme)).split(path.sep).join('/'); // as_posix()
457
+ // The on-disk `laws/` and `ontology/` are the "complete, binding text" the lean pointer
458
+ // promises. They render at full whatever the footprint; only what AGENT.md INLINES leans.
459
+ // Keyed on the SOURCE rel, not the themed one, so a DIR_* rename cannot un-exempt them.
460
+ const fp = /^(?:laws|ontology)[\\/]/.test(rel) ? 'full' : footprint;
443
461
  items.push(TEXT_SUFFIXES.has(suffixOf(path.basename(file)))
444
- ? { rel: outRel, text: renderFile(cfg, file, theme, footprint, lawsPrefix, new Set(), nativeCatalog), src: file }
462
+ ? { rel: outRel, text: renderFile(cfg, file, theme, fp, lawsPrefix, new Set(), nativeCatalog), src: file }
445
463
  : { rel: outRel, text: null, src: file });
446
464
  }
447
465
  return { theme, items };
@@ -24,9 +24,29 @@
24
24
  * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
25
25
  * from these paths. A depth error or a renamed key fails 259 cells, not zero.
26
26
  */
27
- import { readdirSync, readFileSync } from 'node:fs';
27
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
28
28
  import path from 'node:path';
29
29
 
30
+ /**
31
+ * One doctrine pack file used to be read three times per render — the survives gate and the
32
+ * body in `js/build/render.mjs`, plus `knownRuleIds` below — and the golden matrix multiplies
33
+ * that by 261 cells. Keyed on mtime, not just path, because the one long-lived process (the
34
+ * web daemon) renders previews of a `src/` the user may be editing between requests; a
35
+ * path-only memo would serve yesterday's pack forever. Newlines come back folded to LF, the
36
+ * same normalisation `readText` gave the render callers — `knownRuleIds` matched identically
37
+ * on raw or folded text, so one behaviour serves both.
38
+ */
39
+ const _packTexts = new Map();
40
+ export function readPackText(file) {
41
+ const key = path.resolve(file);
42
+ const mtime = statSync(file).mtimeMs;
43
+ const hit = _packTexts.get(key);
44
+ if (hit && hit.mtime === mtime) return hit.text;
45
+ const text = readFileSync(file, 'utf8').replace(/\r\n?/g, '\n');
46
+ _packTexts.set(key, { mtime, text });
47
+ return text;
48
+ }
49
+
30
50
  /**
31
51
  * `_build_core.ROOT` — the checkout, from this file's own location (`js/..`).
32
52
  *
@@ -104,7 +124,7 @@ export function knownRuleIds() {
104
124
  for (const pack of PACK_ORDER) {
105
125
  const file = path.join(SRC, 'doctrines', `${pack}.md`);
106
126
  let text = '';
107
- try { text = readFileSync(file, 'utf8'); } catch { continue; }
127
+ try { text = readPackText(file); } catch { continue; }
108
128
  for (const m of text.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)) {
109
129
  if (m[1] === pack) out.push(`${pack}.${Number(m[2])}`);
110
130
  }
package/js/cli-table.json CHANGED
@@ -155,6 +155,21 @@
155
155
  "is_flag": false,
156
156
  "type": null,
157
157
  "hidden": false
158
+ },
159
+ {
160
+ "names": [
161
+ "--host"
162
+ ],
163
+ "dest": "host",
164
+ "metavar": null,
165
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'copilot' has no ask tier, so Laws I/IV block and everything else is a stderr warning",
166
+ "choices": null,
167
+ "default": null,
168
+ "required": false,
169
+ "nargs": null,
170
+ "is_flag": false,
171
+ "type": null,
172
+ "hidden": false
158
173
  }
159
174
  ],
160
175
  "mutex": []
@@ -356,6 +371,21 @@
356
371
  "is_flag": false,
357
372
  "type": null,
358
373
  "hidden": false
374
+ },
375
+ {
376
+ "names": [
377
+ "--host"
378
+ ],
379
+ "dest": "host",
380
+ "metavar": null,
381
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'copilot' has no ask tier, so Laws I/IV block and everything else is a stderr warning",
382
+ "choices": null,
383
+ "default": null,
384
+ "required": false,
385
+ "nargs": null,
386
+ "is_flag": false,
387
+ "type": null,
388
+ "hidden": false
359
389
  }
360
390
  ],
361
391
  "mutex": []
@@ -604,6 +634,21 @@
604
634
  "is_flag": false,
605
635
  "type": null,
606
636
  "hidden": false
637
+ },
638
+ {
639
+ "names": [
640
+ "--host"
641
+ ],
642
+ "dest": "host",
643
+ "metavar": null,
644
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'copilot' has no ask tier, so Laws I/IV block and everything else is a stderr warning",
645
+ "choices": null,
646
+ "default": null,
647
+ "required": false,
648
+ "nargs": null,
649
+ "is_flag": false,
650
+ "type": null,
651
+ "hidden": false
607
652
  }
608
653
  ],
609
654
  "mutex": []
@@ -764,6 +809,45 @@
764
809
  ]
765
810
  ]
766
811
  },
812
+ {
813
+ "name": "tool-gate",
814
+ "help": "toolCall hook (Copilot): git-gate and rule-gate fused behind one command, dispatched on the payload",
815
+ "description": "",
816
+ "positionals": [],
817
+ "options": [
818
+ {
819
+ "names": [
820
+ "--root"
821
+ ],
822
+ "dest": "root",
823
+ "metavar": null,
824
+ "help": "the install's own dir (set by the emitted hook); enables the sovereign-repo bypass from <root>/excludes.json",
825
+ "choices": null,
826
+ "default": null,
827
+ "required": false,
828
+ "nargs": null,
829
+ "is_flag": false,
830
+ "type": null,
831
+ "hidden": false
832
+ },
833
+ {
834
+ "names": [
835
+ "--host"
836
+ ],
837
+ "dest": "host",
838
+ "metavar": null,
839
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'copilot' has no ask tier, so Laws I/IV block and everything else is a stderr warning",
840
+ "choices": null,
841
+ "default": null,
842
+ "required": false,
843
+ "nargs": null,
844
+ "is_flag": false,
845
+ "type": null,
846
+ "hidden": false
847
+ }
848
+ ],
849
+ "mutex": []
850
+ },
767
851
  {
768
852
  "name": "tui",
769
853
  "help": "print the harness at a glance (interactive terminals only)",