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
@@ -42,35 +42,15 @@ import {
42
42
  } from 'node:fs';
43
43
  import path from 'node:path';
44
44
  import os from 'node:os';
45
- import { printErr, readText, writeText } from '../lib/fs.mjs';
45
+ import { readText, writeText, isOsError } from '../lib/fs.mjs';
46
46
  import {
47
47
  jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
48
- indexOfDeepEqual,
48
+ indexOfDeepEqual, get, has, isDict,
49
49
  } from '../lib/json.mjs';
50
+ import { expanduser } from './hosts.mjs';
51
+ import { stripWhitespace, stripWhitespaceEnd } from '../lib/text.mjs';
50
52
 
51
- export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
52
-
53
- /** `dict.get(key)` with Python's semantics — OWN properties only.
54
- *
55
- * `loaded[event]` where `event` came out of a manifest hands back `Object.prototype`'s
56
- * member for `constructor`, `toString` or `valueOf`, where Python's `.get` returns None.
57
- * The same hazard `js/hosts/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
58
- function get(obj, key) {
59
- return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
60
- }
61
-
62
- function has(obj, key) {
63
- return Object.prototype.hasOwnProperty.call(obj, key);
64
- }
65
-
66
- /** `except OSError` — an fs failure carries a `code`; anything else is a bug, so rethrow. */
67
- function isOsError(e) {
68
- return Boolean(e) && typeof e.code === 'string';
69
- }
70
-
71
- function isDict(v) {
72
- return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
73
- }
53
+ const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
74
54
 
75
55
  /**
76
56
  * Is the `process` doctrine pack active for the install being emitted?
@@ -114,7 +94,7 @@ const consentRuleOn = (d, excluded = []) =>
114
94
  * Law IV's territory — an always-on invariant — not the process pack's, and the guard
115
95
  * plugin's own force-push arm is unconditional for the same reason.
116
96
  */
117
- export function defaultPermission(doctrines = null, excluded = []) {
97
+ function defaultPermission(doctrines = null, excluded = []) {
118
98
  const bash = { 'rm -rf *': 'ask' };
119
99
  if (consentRuleOn(doctrines, excluded)) {
120
100
  bash['git commit*'] = 'ask';
@@ -205,22 +185,10 @@ function reconcileOpencodePermission(perm, want) {
205
185
  return [changed, stranded, null];
206
186
  }
207
187
 
208
- /**
209
- * `Path.with_suffix(suffix)` for the one shape this module needs.
210
- *
211
- * `PurePath.suffix` and `path.extname` disagree on leading-dot and trailing-dot names
212
- * (`.bashrc`, `x.`); the only caller passes `…/opencode.json`, and the emit's own callers
213
- * pass a literal. Spelled off `path.extname` with the empty-extension case handled so a
214
- * future caller with a suffix-less path appends rather than truncates.
215
- */
216
- function withSuffix(p, suffix) {
217
- const ext = path.extname(p);
218
- return (ext ? p.slice(0, p.length - ext.length) : p) + suffix;
219
- }
220
-
221
- /** `_build_settings._opencode_target`. */
188
+ /** `_build_settings._opencode_target`. Only caller of a `.json` -> `.jsonc` suffix swap. */
222
189
  export function opencodeTarget(jsonPath) {
223
- const jsonc = withSuffix(jsonPath, '.jsonc');
190
+ const ext = path.extname(jsonPath);
191
+ const jsonc = (ext ? jsonPath.slice(0, jsonPath.length - ext.length) : jsonPath) + '.jsonc';
224
192
  return existsSync(jsonc) ? jsonc : jsonPath;
225
193
  }
226
194
 
@@ -305,7 +273,7 @@ export function readJsonc(text) {
305
273
  * already has a `permission` key to add one is not saying so. Any other truthy value is read as
306
274
  * `'add'`, which is what the boolean callers this replaced meant.
307
275
  */
308
- export function warnCommentedJsonc(target, agentPath, permission,
276
+ function warnCommentedJsonc(target, agentPath, permission,
309
277
  includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = []) {
310
278
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
311
279
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
@@ -354,7 +322,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
354
322
  raw = readText(target);
355
323
  } catch (e) {
356
324
  if (!isOsError(e)) throw e;
357
- process.stderr.write(`[geneseed] WARN: could not read ${target} (${asOsError(e)}) `
325
+ process.stderr.write(`[geneseed] WARN: could not read ${target} (${e.message}) `
358
326
  + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
359
327
  + "hand once it's readable again.\n");
360
328
  return target;
@@ -433,25 +401,18 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
433
401
  atomicWriteJson(target, config);
434
402
  } catch (e) {
435
403
  if (!isOsError(e)) throw e;
436
- process.stderr.write(`[geneseed] WARN: could not write ${target} (${asOsError(e)}) — `
404
+ process.stderr.write(`[geneseed] WARN: could not write ${target} (${e.message}) — `
437
405
  + `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
438
406
  + 'to its "instructions" array by hand.\n');
439
407
  }
440
408
  return target;
441
409
  }
442
410
 
443
- /**
444
- * `str(OSError)` — what Python interpolates for `except OSError as e: f"({e})"`.
445
- *
446
- * Python renders `[Errno 13] Permission denied: 'C:\\x\\y'`; Node's message is
447
- * `EACCES: permission denied, open 'C:\x\y'`. The two cannot be made to agree, so the
448
- * parity gate does not drive a cell through a real fs failure — it exercises the branch
449
- * with an injectable failure instead and compares everything except the OS's own wording.
450
- * Named here so the gap is a decision rather than a surprise at GA.
451
- */
452
- function asOsError(e) {
453
- return e.message;
454
- }
411
+ // `except OSError as e: f"({e})"` is inlined as `e.message` at each call site above and below.
412
+ // Python renders `[Errno 13] Permission denied: 'C:\\x\\y'`; Node's message is
413
+ // `EACCES: permission denied, open 'C:\x\y'`. The two cannot be made to agree, so the parity
414
+ // gate does not drive a cell through a real fs failure — it exercises the branch with an
415
+ // injectable failure instead and compares everything except the OS's own wording.
455
416
 
456
417
  // ---- the hook shim ----------------------------------------------------------------
457
418
  // `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
@@ -499,32 +460,18 @@ export function shimRel(platform = process.platform) {
499
460
  return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
500
461
  }
501
462
 
502
- /** `_build_settings._shim_home`. */
503
- export function shimHome() {
504
- const env = process.env.GENESEED_HOME;
505
- return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
506
- }
507
-
508
463
  /**
509
- * `Path(x).expanduser()` — private twin of `js/hosts/hosts.mjs`'s `expanduser`, same guard.
464
+ * `_build_settings._shim_home`.
510
465
  *
511
- * `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
512
- * has the identical failure mode (a hook shim written under a literal `~user` directory) and
513
- * gets the identical fix — refuse rather than pass through. See that file's docblock for why
514
- * the refusal lives IN the primitive rather than at `shimHome`'s caller.
466
+ * `expanduser` is `js/hosts/hosts.mjs`'s — GENESEED_HOME is user-set, same as any config-dir
467
+ * env var there, so a `~user` value here has the identical failure mode (a hook shim written
468
+ * under a literal `~user` directory) and gets the identical refusal. This module used to carry
469
+ * a private "same guard" twin; the two had drifted (this one's refusal message dropped the
470
+ * parenthetical explaining WHY), which is exactly the kind of divergence one owner prevents.
515
471
  */
516
- function expanduser(p) {
517
- if (p === '~') return os.homedir();
518
- if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
519
- if (p.startsWith('~')) {
520
- const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
521
- + 'absolute path, or "~" / "~/…" for your own home directory';
522
- printErr(`${msg}\n`);
523
- const e = new Error(msg);
524
- e.exitCode = 1;
525
- throw e;
526
- }
527
- return p;
472
+ export function shimHome() {
473
+ const env = process.env.GENESEED_HOME;
474
+ return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
528
475
  }
529
476
 
530
477
  /** `_build_settings._hook_shim_path`. */
@@ -720,11 +667,34 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
720
667
  * belongs in — the user's call — not how work is run, so it is not the process pack's to
721
668
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
722
669
  */
723
- export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = []) {
670
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude') {
724
671
  const run = hookPrefix(hookOpts);
725
672
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
726
673
  // --root carries the install's own dir so a GLOBAL hook can stand down when a project
727
674
  // install of the same host sits at/above cwd (project-bypasses-global).
675
+ if (host === 'bob') {
676
+ // BOB'S OWN HOOK CONTRACT, per its hooks doc and 2.0.2 changelog (docs/reviews/
677
+ // bob-global-injection-2026-09.md): FIVE events — SessionStart, UserPromptSubmit,
678
+ // PreToolUse, PostToolUse, Stop — Claude's names, but NOT Claude's protocol. stdout is
679
+ // read as context on SessionStart only; on PreToolUse it is ignored and the one way to
680
+ // refuse a call is EXIT CODE 2. The groups this emit wrote before were Claude's whole
681
+ // set: `SubagentStop`/`PreCompact` are not Bob events, and the gates answered with a
682
+ // stdout JSON Bob never reads — permissive on every call. `--host bob` makes the gates
683
+ // speak exit codes (js/hosts/hooks.mjs). No matcher on PreToolUse: Bob's tool names
684
+ // are undocumented (its IDE and Shell differ), so `tool-gate` reads the payload's shape
685
+ // instead. Which tool payload fields Bob sends is equally undocumented; the gates read
686
+ // Claude's (`command`, `file_path`/`path`, `content`/`new_string`) and defer on anything
687
+ // else. Unverified live: no Bob install on the authoring machine.
688
+ const b = ' --host bob';
689
+ return {
690
+ SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
691
+ // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
692
+ // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
693
+ // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries.
694
+ PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
695
+ Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
696
+ };
697
+ }
728
698
  const context = `${run} context --root "${cfg}" || exit 0`;
729
699
  const gate = `${run} git-gate --root "${cfg}"`;
730
700
  const ruleGate = `${run} rule-gate --root "${cfg}"`;
@@ -740,7 +710,10 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
740
710
  ],
741
711
  SessionStart: [
742
712
  { matcher: 'startup|clear', hooks: [{ type: 'command', command: context }] },
743
- { matcher: 'resume', hooks: [{ type: 'command', command: context }] },
713
+ // `compact` too: auto-compaction keeps the instruction file (the host re-reads it) but
714
+ // summarises away the eagerly injected context, memory index and notebook TOC. Re-seed
715
+ // them the way a resume does — the static AGENT.md is NOT re-printed.
716
+ { matcher: 'resume|compact', hooks: [{ type: 'command', command: context }] },
744
717
  ],
745
718
  // `|| exit 0` (not `|| true`): hooks run under cmd.exe on native Windows, where `true`
746
719
  // is not a command and the swallow-failures intent would invert into a 9009 error.
@@ -748,9 +721,151 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
748
721
  // Same command as Stop: `learn` reads the payload's hook_event_name and routes a
749
722
  // SubagentStop to the per-agent lesson path.
750
723
  SubagentStop: [{ hooks: [{ type: 'command', command: learn }] }],
724
+ // And once more BEFORE compaction. `learn` distils the tail of the transcript (the newest
725
+ // MAX_NOTES_CHARS), so per-turn Stop is a sliding window over the session — and
726
+ // compaction is the one moment that window is about to be summarised away for good.
727
+ // The payload carries `transcript_path` like Stop's; the SessionStart `compact` matcher
728
+ // re-seeds context AFTER, this captures memory BEFORE. Same `|| exit 0`: never block.
729
+ PreCompact: [{ hooks: [{ type: 'command', command: learn }] }],
751
730
  };
752
731
  }
753
732
 
733
+ /**
734
+ * Geneseed's Copilot hooks, keyed by Copilot's event names.
735
+ *
736
+ * COPILOT'S SETTINGS ARE NOT CLAUDE'S. `~/.copilot/settings.json` carries `hooks` as a map of
737
+ * event → ONE `{command, shell?, timeout?}` object, not event → list of matcher groups, so
738
+ * nothing in `claudeHookGroups`/`mergeClaudeSettings` can be reused for it: there is no
739
+ * array to append to, and a second Geneseed group per event is not expressible. Two hooks:
740
+ *
741
+ * * `sessionStart` → `context --host copilot`: the same project-context injection Claude
742
+ * gets, in Copilot's `{"additionalContext"}` envelope.
743
+ * * `toolCall` → `tool-gate --host copilot`: both gates behind the one command the event
744
+ * allows, block-only for Laws I/IV, warn for the rest (js/hosts/hooks.mjs's header).
745
+ *
746
+ * No `sessionEnd`/`agentStop` → `learn`: Copilot's payloads for those carry no
747
+ * `transcript_path`, so `learn` would have nothing to distil. The memory write-back stays on
748
+ * the preamble for this host, as before.
749
+ *
750
+ * `|| exit 0` on the non-gate, as in `claudeHookGroups` and for the same reason.
751
+ */
752
+ export function copilotHooks(cfg, hookOpts) {
753
+ const run = hookPrefix(hookOpts);
754
+ return {
755
+ sessionStart: { command: `${run} context --root "${cfg}" --host copilot || exit 0` },
756
+ toolCall: { command: `${run} tool-gate --root "${cfg}" --host copilot` },
757
+ };
758
+ }
759
+
760
+ /**
761
+ * Wire Copilot's hooks into `p` — returns the complete current claim set, `[{event, hook}]`.
762
+ *
763
+ * ONE SLOT PER EVENT MEANS A FOREIGN HOOK CANNOT BE JOINED, ONLY DISPLACED — and displacing
764
+ * a hook the user wrote is not Geneseed's to do. A slot is taken when it holds a hook that
765
+ * is neither canonical nor a claim recorded by a previous emit; that event is skipped with a
766
+ * warning and never claimed. A recorded stale claim (a moved shim) is replaced, as the Claude
767
+ * merge prunes stale groups. Comments in the file mean it is not rewritten, as everywhere.
768
+ */
769
+ export function mergeCopilotSettings(p, priorHooks = null, hookOpts = {}) {
770
+ const prior = (priorHooks || []).filter(isDict);
771
+ let config = {};
772
+ let hadComments = false;
773
+ if (existsSync(p)) {
774
+ try {
775
+ const [loaded, hc] = readJsonc(readText(p));
776
+ hadComments = hc;
777
+ if (isDict(loaded)) config = loaded;
778
+ } catch (e) {
779
+ if (!isOsError(e)) throw e;
780
+ }
781
+ }
782
+ let hooks = get(config, 'hooks');
783
+ if (!isDict(hooks)) hooks = {};
784
+ const canonical = copilotHooks(path.dirname(p), hookOpts);
785
+ const claims = [];
786
+ let changed = false;
787
+ for (const [event, hook] of Object.entries(canonical)) {
788
+ const cur = get(hooks, event);
789
+ const recorded = prior.find((r) => get(r, 'event') === event);
790
+ if (isDict(cur) && deepEquals(cur, hook)) { claims.push({ event, hook }); continue; }
791
+ if (isDict(cur) && !(recorded && deepEquals(recorded.hook, cur))) {
792
+ process.stderr.write(`[geneseed] ${path.basename(p)}: hooks.${event} is already set and `
793
+ + 'is not Geneseed\'s — left alone, so this event is not wired.\n');
794
+ continue;
795
+ }
796
+ hooks[event] = hook;
797
+ claims.push({ event, hook });
798
+ changed = true;
799
+ }
800
+ // A recorded claim whose event is no longer canonical: unwire it, or it fires forever.
801
+ for (const rec of prior) {
802
+ const event = get(rec, 'event');
803
+ if (event in canonical) continue;
804
+ if (deepEquals(get(hooks, event), get(rec, 'hook'))) { delete hooks[event]; changed = true; }
805
+ }
806
+ if (!changed) return [p, claims];
807
+ if (hadComments) {
808
+ process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
809
+ + "(your edits are kept). Add Geneseed's hooks by hand from adapters/copilot/README.md.\n");
810
+ return [p, prior];
811
+ }
812
+ if (Object.keys(hooks).length) config.hooks = hooks;
813
+ else delete config.hooks;
814
+ atomicWriteJson(p, config);
815
+ return [p, claims];
816
+ }
817
+
818
+ /** Remove exactly the recorded Copilot hooks from `p`. True when the file was rewritten. */
819
+ export function unwireCopilotSettings(p, recorded) {
820
+ if (!existsSync(p) || !recorded || !recorded.length) return false;
821
+ let loaded;
822
+ let hadComments;
823
+ try {
824
+ [loaded, hadComments] = readJsonc(readText(p));
825
+ } catch (e) {
826
+ if (!isOsError(e)) throw e;
827
+ return false;
828
+ }
829
+ if (hadComments || !isDict(loaded)) return false;
830
+ const hooks = get(loaded, 'hooks');
831
+ if (!isDict(hooks)) return false;
832
+ for (const rec of recorded) {
833
+ const event = get(rec, 'event');
834
+ if (deepEquals(get(hooks, event), get(rec, 'hook'))) delete hooks[event];
835
+ }
836
+ if (!Object.keys(hooks).length) delete loaded.hooks;
837
+ try {
838
+ atomicWriteJson(p, loaded);
839
+ } catch (e) {
840
+ if (!isOsError(e)) throw e;
841
+ return false;
842
+ }
843
+ return true;
844
+ }
845
+
846
+ /**
847
+ * The Copilot twin of `settingsIntegrityCheck`: every recorded hook present (or absent) in
848
+ * the file it was claimed against. Same contract — problems as strings, each also WARNed on
849
+ * stderr, empty when clean — so a merge that did not stick is loud at emit time rather than
850
+ * a hook that quietly never fires.
851
+ */
852
+ export function copilotIntegrityCheck(p, recorded, expect = 'present') {
853
+ const problems = [];
854
+ const hooks = existsSync(p) ? (() => {
855
+ try { return get(readJsonc(readText(p))[0], 'hooks'); } catch { return null; }
856
+ })() : null;
857
+ for (const rec of (recorded || []).filter(isDict)) {
858
+ const hit = deepEquals(get(hooks, rec.event), rec.hook);
859
+ if (expect === 'present' && !hit) {
860
+ problems.push(`${p}: recorded Copilot hook missing — event=${rec.event}`);
861
+ } else if (expect === 'absent' && hit) {
862
+ problems.push(`${p}: recorded Copilot hook still present after unwire — event=${rec.event}`);
863
+ }
864
+ }
865
+ for (const x of problems) process.stderr.write(`[geneseed] WARN: ${x}\n`);
866
+ return problems;
867
+ }
868
+
754
869
  /**
755
870
  * `_build_settings._merge_claude_settings` — returns `[target, managed]`.
756
871
  *
@@ -763,7 +878,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
763
878
  * `scope` is accepted and unused, exactly as in the Python.
764
879
  */
765
880
  export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
766
- doctrines = null, excluded = []) {
881
+ doctrines = null, excluded = [], host = 'claude') {
767
882
  const prior = (priorHooks || []).filter(isDict);
768
883
  let config = {};
769
884
  let hadComments = false;
@@ -783,7 +898,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
783
898
  }
784
899
  let hooks = get(config, 'hooks');
785
900
  if (!isDict(hooks)) hooks = {};
786
- const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded);
901
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded, host);
787
902
  const canonFlat = [];
788
903
  for (const [event, groups] of Object.entries(canonical)) {
789
904
  for (const g of groups) canonFlat.push({ event, group: g });
@@ -824,7 +939,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
824
939
  return [p, prior];
825
940
  }
826
941
  // The `else` is DEAD on both sides and reproduced rather than dropped: `canonical` always
827
- // carries four events, so by the time control reaches here `hooks` is never empty. A
942
+ // carries at least three events, so by the time control reaches here `hooks` is never empty. A
828
943
  // mutation keeping the empty block instead of deleting it is the one of thirty-three that
829
944
  // stays green, and it stays green because there is nothing to detect — recorded the way
830
945
  // `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
@@ -918,7 +1033,7 @@ export function wireClaudeExcludes(p, excludes) {
918
1033
  atomicWriteJson(p, config);
919
1034
  } catch (e) {
920
1035
  if (!isOsError(e)) throw e;
921
- process.stderr.write(`[geneseed] WARN: could not write ${p} (${asOsError(e)}) — `
1036
+ process.stderr.write(`[geneseed] WARN: could not write ${p} (${e.message}) — `
922
1037
  + 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
923
1038
  + `${jsonDumpsCompact(added)}\n`);
924
1039
  return [];
@@ -973,7 +1088,7 @@ export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
973
1088
  // 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
974
1089
  // call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
975
1090
  // exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
976
- export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
1091
+ const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
977
1092
 
978
1093
  /**
979
1094
  * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
@@ -1028,7 +1143,7 @@ export function autostartStale(text, root) {
1028
1143
  }
1029
1144
 
1030
1145
  /** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
1031
- export function settingsHookGroups(loaded) {
1146
+ function settingsHookGroups(loaded) {
1032
1147
  const hooks = get(loaded, 'hooks');
1033
1148
  if (!isDict(hooks)) return [];
1034
1149
  const out = [];
@@ -1068,7 +1183,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1068
1183
  [loaded] = readJsonc(readText(p));
1069
1184
  } catch (e) {
1070
1185
  if (!isOsError(e)) throw e;
1071
- problems.push(`${p}: could not read the file (${asOsError(e)})`);
1186
+ problems.push(`${p}: could not read the file (${e.message})`);
1072
1187
  return flush();
1073
1188
  }
1074
1189
  if (!isDict(loaded)) {
@@ -1128,12 +1243,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1128
1243
  return flush();
1129
1244
  }
1130
1245
 
1131
- const BLOCK_BEGIN = '<!-- BEGIN {id} -->';
1132
- const BLOCK_END = '<!-- END {id} -->';
1133
-
1134
- function delimiters(blockId) {
1135
- return [BLOCK_BEGIN.replace('{id}', blockId), BLOCK_END.replace('{id}', blockId)];
1136
- }
1246
+ const delimiters = (blockId) => [`<!-- BEGIN ${blockId} -->`, `<!-- END ${blockId} -->`];
1137
1247
 
1138
1248
  /**
1139
1249
  * `_build_settings._managed_block_write` — 'created' | 'updated' | 'merged'.
@@ -1141,7 +1251,7 @@ function delimiters(blockId) {
1141
1251
  */
1142
1252
  export function managedBlockWrite(p, content, blockId = 'GENESEED') {
1143
1253
  const [begin, end] = delimiters(blockId);
1144
- const block = `${begin}\n${stripEnd(content)}\n${end}\n`;
1254
+ const block = `${begin}\n${stripWhitespaceEnd(content)}\n${end}\n`;
1145
1255
  if (!existsSync(p)) {
1146
1256
  mkdirSync(path.dirname(p), { recursive: true });
1147
1257
  writeText(p, block);
@@ -1171,7 +1281,7 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
1171
1281
  if (!existing.includes(begin) || !existing.includes(end)) return;
1172
1282
  const pre = existing.split(begin)[0];
1173
1283
  const post = existing.slice(existing.indexOf(end) + end.length);
1174
- const rest = stripEnds(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
1284
+ const rest = stripWhitespace(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
1175
1285
  if (rest) writeText(p, `${rest}\n`);
1176
1286
  else unlinkSync(p);
1177
1287
  }
@@ -1186,25 +1296,6 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
1186
1296
  return stripNewlines(inner.slice(0, inner.indexOf(end)));
1187
1297
  }
1188
1298
 
1189
- // `str.strip()` and friends. JS's `trim()` is NOT `strip()`, and the difference runs both
1190
- // ways: `trim()` eats U+FEFF, which Python does not strip, and Python strips U+0085 and
1191
- // U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
1192
- // Geneseed shares with the user, so the set is spelled out rather than approximated — and
1193
- // spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
1194
- const WHITESPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
1195
- + '\u2028\u2029\u202f\u205f\u3000]';
1196
-
1197
- const RSTRIP_RE = new RegExp(`${WHITESPACE}+$`);
1198
- const LSTRIP_RE = new RegExp(`^${WHITESPACE}+`);
1199
-
1200
- function stripEnd(s) {
1201
- return s.replace(RSTRIP_RE, '');
1202
- }
1203
-
1204
- function stripEnds(s) {
1205
- return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
1206
- }
1207
-
1208
1299
  function lstripNewlines(s) {
1209
1300
  return s.replace(/^\n+/, '');
1210
1301
  }
@@ -411,7 +411,8 @@ export function constitutionProblems() {
411
411
  try { text = readText(path.join(dir, `${pack}.md`)); } catch { continue; }
412
412
  for (const [, key] of text.matchAll(DOC_TOKEN_RE)) wanted.add(key);
413
413
  }
414
- const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'].map((r) => `LEX_${r}`);
414
+ const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI']
415
+ .map((r) => `LEX_${r}`);
415
416
  // `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
416
417
  // stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
417
418
  const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
@@ -443,11 +444,11 @@ export function constitutionProblems() {
443
444
  const stale = lex.filter((k) => !lexWant.includes(k));
444
445
  const missing = lexWant.filter((k) => !lex.includes(k));
445
446
  for (const k of stale) {
446
- problems.push(`[authoring] ${name} still defines {${k}} — the invariants are I..IX and `
447
- + 'nothing renders this key, so it is a title for a rule that no longer exists');
447
+ problems.push(`[authoring] ${name} still defines {${k}} — no law heading carries this `
448
+ + 'numeral and nothing renders this key, so it is a title for a rule that no longer exists');
448
449
  }
449
450
  for (const k of missing) {
450
- problems.push(`[authoring] ${name} has no {${k}} — one of the nine invariants would `
451
+ problems.push(`[authoring] ${name} has no {${k}} — one of the invariants would `
451
452
  + 'render with an empty title');
452
453
  }
453
454
  // M10 — ⚠ BOTH HEADING PARSERS MATCH THE TIER NOUN WITH `\S+`. A two-word value does not
@@ -217,7 +217,7 @@ export function renderedProblems(bundle) {
217
217
  // The OpenCode emits de-link AGENT.md's per-row table entries after rendering, so the
218
218
  // bundle on disk legitimately differs from `renderAll`'s output for this one file.
219
219
  const text = host === 'opencode' && outRel === 'AGENT.md'
220
- ? stripCapabilityLinks(cfg, rendered) : rendered;
220
+ ? stripCapabilityLinks(rendered) : rendered;
221
221
  if (readText(dest) !== text) {
222
222
  problems.push(`[rendered] ${name}/${outRel} stale (differs from a fresh render) `
223
223
  + '— rebuild');
@@ -47,11 +47,9 @@ import {
47
47
  readJsonMaybe, readMaybe, themeOfDir,
48
48
  } from '../hosts/installs.mjs';
49
49
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
50
- import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
+ import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
51
51
  import { toPlatformPath } from '../lib/paths.mjs';
52
52
 
53
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
54
-
55
53
  /**
56
54
  * `_harness_core._T0` — this process's start, in epoch seconds.
57
55
  *