geneseed 3.1.3 → 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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  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 +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  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 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -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
@@ -81,17 +91,17 @@ function suffixOf(name) {
81
91
  return 0 < i && i < name.length - 1 ? name.slice(i) : '';
82
92
  }
83
93
 
84
- /** `Path.rglob("*")` restricted to files, then `sorted()`. */
94
+ /**
95
+ * `Path.rglob("*")` restricted to files, then `sorted()`.
96
+ *
97
+ * `recursive: true, withFileTypes: true` replaces the hand-rolled walk — the order it
98
+ * yields is not the walk's order, but the explicit `.sort(comparePaths)` below is what
99
+ * every caller actually observes, so a different collection order changes nothing.
100
+ */
85
101
  function sortedSourceFiles(src) {
86
- const out = [];
87
- (function walk(dir) {
88
- for (const name of readdirSync(dir)) {
89
- const full = path.join(dir, name);
90
- if (name === '__pycache__') continue; // `"__pycache__" in path.parts`
91
- if (statSync(full).isDirectory()) walk(full); // rglob yields dirs; render_all skips them
92
- else out.push(full);
93
- }
94
- })(src);
102
+ const out = readdirSync(src, { recursive: true, withFileTypes: true })
103
+ .filter((d) => d.isFile() && !d.parentPath.split(path.sep).includes('__pycache__'))
104
+ .map((d) => path.join(d.parentPath, d.name));
95
105
  return out.sort(comparePaths); // `sorted()` — case-folded on Windows only
96
106
  }
97
107
 
@@ -126,7 +136,7 @@ export function loadTheme(cfg, name) {
126
136
  /** `_build_render.substitute`. */
127
137
  export function substitute(text, theme) {
128
138
  return text.replace(TOKEN_RE, (whole, key) => {
129
- if (!Object.prototype.hasOwnProperty.call(theme, key)) return whole; // visible for debugging
139
+ if (!Object.hasOwn(theme, key)) return whole; // visible for debugging
130
140
  const v = theme[key];
131
141
  // Python renders `str(theme[key])`, which agrees with JS only for strings —
132
142
  // `str(True)`/`String(true)` and `str({...})`/`String({...})` do not. Every one of
@@ -143,10 +153,15 @@ export function substitute(text, theme) {
143
153
  }
144
154
 
145
155
  /** `_build_render._resolve_catalogs`. */
146
- export function resolveCatalogs(text, nativeCatalog) {
156
+ 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;
@@ -397,7 +411,7 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
397
411
  /** `_build_render.themed_rel` — rename the top-level folder of an output path per theme. */
398
412
  export function themedRel(rel, theme) {
399
413
  const parts = rel.split(path.sep);
400
- if (parts.length && Object.prototype.hasOwnProperty.call(SRC_DIR_TOKENS, parts[0])) {
414
+ if (parts.length && Object.hasOwn(SRC_DIR_TOKENS, parts[0])) {
401
415
  parts[0] = theme[SRC_DIR_TOKENS[parts[0]]] ?? parts[0];
402
416
  }
403
417
  return parts.join(path.sep);
@@ -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 };
@@ -12,19 +12,40 @@
12
12
  * `build.render_all(theme)`. So the CLI has to build the same `cfg` the driver builds, and
13
13
  * until this move only the driver could.
14
14
  *
15
- * THE TWO ABSENT KEYS ARE STILL ABSENT, and the reason is bin/build-driver.mjs's, unchanged:
16
- * `_build_core.js_cfg()` always sends `structure` and `capabilityLinkRe` because the Python
17
- * originals are module-level names TESTS MUTATE (`_OWNED` membership asked one level out).
18
- * A Node process has no Python module to mutate, so it sends neither and
15
+ * THE ABSENT KEY IS STILL ABSENT, and the reason is bin/build-driver.mjs's, unchanged:
16
+ * `_build_core.js_cfg()` always sent `structure` and `capabilityLinkRe` because the Python
17
+ * originals were module-level names TESTS MUTATE (`_OWNED` membership asked one level out).
18
+ * A Node process has no Python module to mutate, so it sends no `structure`, and
19
19
  * `js/build/render.mjs`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch.
20
+ * `capabilityLinkRe`'s equivalent override in `stripCapabilityLinks` had no producer either
21
+ * and was deleted outright in the over-engineering cleanup, `cfg` param and all.
20
22
  *
21
23
  * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/build-driver.mjs`
22
24
  * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
23
25
  * from these paths. A depth error or a renamed key fails 259 cells, not zero.
24
26
  */
25
- import { readdirSync, readFileSync } from 'node:fs';
27
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
26
28
  import path from 'node:path';
27
- import { fileURLToPath } from 'node:url';
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
+ }
28
49
 
29
50
  /**
30
51
  * `_build_core.ROOT` — the checkout, from this file's own location (`js/..`).
@@ -37,7 +58,7 @@ import { fileURLToPath } from 'node:url';
37
58
  * which is what makes them comparable and what makes them unfenceable. See the
38
59
  * status/version section of `tests/harness_golden.py`.
39
60
  */
40
- export const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
61
+ export const ROOT = path.resolve(import.meta.dirname, '../..');
41
62
  export const SRC = path.join(ROOT, 'src');
42
63
  export const CONFIG = path.join(ROOT, 'harness.config.json');
43
64
  export const THEMES = path.join(ROOT, 'themes');
@@ -103,7 +124,7 @@ export function knownRuleIds() {
103
124
  for (const pack of PACK_ORDER) {
104
125
  const file = path.join(SRC, 'doctrines', `${pack}.md`);
105
126
  let text = '';
106
- try { text = readFileSync(file, 'utf8'); } catch { continue; }
127
+ try { text = readPackText(file); } catch { continue; }
107
128
  for (const m of text.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)) {
108
129
  if (m[1] === pack) out.push(`${pack}.${Number(m[2])}`);
109
130
  }
@@ -198,59 +198,51 @@ agent-overrides.json
198
198
  // Seeded stubs — write-once, never overwrite
199
199
  // ---------------------------------------------------------------------------
200
200
 
201
- /** `_build_render.ensure_context_stub`. */
201
+ /**
202
+ * `_build_render`'s eight `ensure_*_stub` functions all wrote this same guard out by hand:
203
+ * write `t` to `p` only when nothing is there yet. The STUB BODIES above are what a recorded
204
+ * corpus compares byte for byte — this only touches the write-once CHECK every one of them
205
+ * repeated, never the text.
206
+ */
207
+ const seed = (p, t) => { if (!existsSync(p)) writeText(p, t); };
208
+
209
+ /** A stub whose destination is a fixed `out`-relative name and a fixed body. */
210
+ const stubWriter = (file, stub) => (out) => seed(path.join(out, file), stub);
211
+
212
+ /** `_build_render.ensure_context_stub` — the one stub whose body is computed, not literal. */
202
213
  export function ensureContextStub(out) {
203
- const dest = path.join(out, 'context.json');
204
- if (!existsSync(dest)) {
205
- writeText(dest, `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
206
- }
214
+ seed(path.join(out, 'context.json'), `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
207
215
  }
208
216
 
209
217
  /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
210
218
  export function ensureWikiStub(out) {
211
- const dest = path.join(out, 'wiki.jsonc');
212
- if (!existsSync(dest) && !existsSync(path.join(out, 'wiki.json'))) writeText(dest, WIKI_STUB);
219
+ if (!existsSync(path.join(out, 'wiki.json'))) seed(path.join(out, 'wiki.jsonc'), WIKI_STUB);
213
220
  }
214
221
 
215
222
  /** `_build_render.ensure_rules_stub`. */
216
- export function ensureRulesStub(out) {
217
- const dest = path.join(out, RULES_FILE);
218
- if (!existsSync(dest)) writeText(dest, RULES_STUB);
219
- }
223
+ export const ensureRulesStub = stubWriter(RULES_FILE, RULES_STUB);
220
224
 
221
225
  /** `_build_render.ensure_profile_stub`. */
222
- export function ensureProfileStub(out) {
223
- const dest = path.join(out, PROFILE_FILE);
224
- if (!existsSync(dest)) writeText(dest, PROFILE_STUB);
225
- }
226
+ export const ensureProfileStub = stubWriter(PROFILE_FILE, PROFILE_STUB);
226
227
 
227
228
  /**
228
229
  * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
229
230
  * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
230
231
  * `emitClaudeRender` rather than with the bundle stubs beside it.
231
232
  */
232
- export function ensureExcludesStub(out) {
233
- const dest = path.join(out, EXCLUDES_FILE);
234
- if (!existsSync(dest)) writeText(dest, EXCLUDES_STUB);
235
- }
233
+ export const ensureExcludesStub = stubWriter(EXCLUDES_FILE, EXCLUDES_STUB);
236
234
 
237
235
  /** `_build_render.ensure_bundle_gitignore`. */
238
- export function ensureBundleGitignore(out) {
239
- const dest = path.join(out, '.gitignore');
240
- if (!existsSync(dest)) writeText(dest, BUNDLE_GITIGNORE);
241
- }
236
+ export const ensureBundleGitignore = stubWriter('.gitignore', BUNDLE_GITIGNORE);
237
+
238
+ /** A stub INSIDE an already-existing store dir — a memory/notebook index, only ever seeded
239
+ * once the store itself exists (`ensureMemoryIndex`/`ensureNotebookIndex` are both called
240
+ * right after the store dir is created, but never invent the dir themselves). */
241
+ const storeIndexWriter = (file, header) => (dir) => { if (isDir(dir)) seed(path.join(dir, file), header); };
242
242
 
243
243
  /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
244
- export function ensureMemoryIndex(memDir) {
245
- if (!isDir(memDir)) return;
246
- const idx = path.join(memDir, 'MEMORY.md');
247
- if (!existsSync(idx)) writeText(idx, '# Memory Index\n');
248
- }
244
+ export const ensureMemoryIndex = storeIndexWriter('MEMORY.md', '# Memory Index\n');
249
245
 
250
246
  /** `_build_render.ensure_notebook_index`. */
251
- export function ensureNotebookIndex(nbDir) {
252
- if (!isDir(nbDir)) return;
253
- const idx = path.join(nbDir, 'NOTEBOOK.md');
254
- if (!existsSync(idx)) writeText(idx, '# Notebook Index\n');
255
- }
247
+ export const ensureNotebookIndex = storeIndexWriter('NOTEBOOK.md', '# Notebook Index\n');
256
248
 
@@ -66,7 +66,7 @@ import { stripWhitespaceStart, stripWhitespaceEnd, stripWhitespace } from '../li
66
66
  * for containers — a `===` here would return `null` every time and silently route every
67
67
  * theme through the re-dump fallback, i.e. rewrite all 14 files in full.
68
68
  */
69
- export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
69
+ function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
70
70
  const lines = splitLines(raw);
71
71
  if (!lines.length || stripWhitespace(lines[0]) !== '{') return null;
72
72
 
@@ -145,9 +145,7 @@ export function writeVersion(cfg, out) {
145
145
  // Mutating it away stays green here for a reason no cell can remove — freezing the
146
146
  // clock across a process boundary is not something the harness can do — which is
147
147
  // exactly why the choice is spelled out instead of relying on the comparison.
148
- const d = new Date();
149
- const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
150
- + `${String(d.getDate()).padStart(2, '0')}`;
148
+ const today = new Date().toLocaleDateString('sv-SE');
151
149
  writeText(path.join(out, VERSION_MARKER),
152
150
  `${fp} (built ${today}) [release ${release}]\n`);
153
151
  return fp;
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)",