geneseed 3.2.0 → 3.4.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 (171) hide show
  1. package/CHANGELOG.md +150 -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 +52 -25
  9. package/adapters/opencode/README.md +11 -3
  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 +10 -8
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web/agents.md +1 -1
  18. package/docs/web/first-session.md +1 -1
  19. package/docs/web/skills.md +1 -1
  20. package/docs/web-ui.md +5 -13
  21. package/harness.config.json +1 -1
  22. package/js/README.md +5 -6
  23. package/js/build/emit-claude.mjs +22 -7
  24. package/js/build/render.mjs +37 -19
  25. package/js/build/source.mjs +22 -2
  26. package/js/cli-table.json +84 -0
  27. package/js/hosts/hooks.mjs +240 -78
  28. package/js/hosts/hosts.mjs +56 -2
  29. package/js/hosts/link.mjs +1 -1
  30. package/js/hosts/mcp.mjs +3 -1
  31. package/js/hosts/native.mjs +86 -15
  32. package/js/hosts/opencode.mjs +14 -0
  33. package/js/hosts/settings.mjs +173 -5
  34. package/js/inspect/excludes.mjs +1 -1
  35. package/js/inspect/inventory.mjs +13 -8
  36. package/js/inspect/status.mjs +50 -2
  37. package/js/maintain/uninstall.mjs +38 -11
  38. package/js/maintain/update.mjs +2 -2
  39. package/js/web/actions.mjs +8 -8
  40. package/js/web/api.mjs +5 -10
  41. package/js/web/docs.mjs +9 -31
  42. package/js/web/handler.mjs +20 -30
  43. package/js/web/jobs.mjs +5 -26
  44. package/js/web/routes.mjs +18 -84
  45. package/js/web/server.mjs +3 -3
  46. package/package.json +1 -1
  47. package/registry.json +99 -78
  48. package/src/AGENT.md.tmpl +69 -28
  49. package/src/agents/architect.md +4 -1
  50. package/src/agents/explorer.md +3 -1
  51. package/src/agents/operator.md +13 -0
  52. package/src/agents/pragmatist.md +12 -0
  53. package/src/agents/researcher.md +72 -0
  54. package/src/agents/security.md +7 -1
  55. package/src/agents/skeptic.md +2 -1
  56. package/src/agents/tester.md +5 -1
  57. package/src/agents/user-advocate.md +13 -0
  58. package/src/doctrines/README.md +3 -3
  59. package/src/doctrines/rigor.md +14 -1
  60. package/src/laws/universal.md +98 -20
  61. package/src/ontology/universal.md +38 -13
  62. package/src/skills/_self-improvement.md +19 -0
  63. package/src/skills/_template.md +6 -13
  64. package/src/skills/brainstorm.md +37 -19
  65. package/src/skills/bruno-collection-generator.md +52 -0
  66. package/src/skills/bruno-test-writer.md +51 -0
  67. package/src/skills/ci-fix.md +49 -0
  68. package/src/skills/codebase-design.md +1 -12
  69. package/src/skills/commit.md +1 -12
  70. package/src/skills/consolidate-memory.md +50 -0
  71. package/src/skills/council.md +1 -12
  72. package/src/skills/debug.md +1 -12
  73. package/src/skills/deps-audit.md +46 -0
  74. package/src/skills/develop.md +38 -20
  75. package/src/skills/document-project.md +1 -12
  76. package/src/skills/domain-modeling.md +1 -12
  77. package/src/skills/forge-mcp.md +2 -13
  78. package/src/skills/fresh-eyes.md +1 -12
  79. package/src/skills/frontend-design.md +1 -12
  80. package/src/skills/geneseed-code-review.md +1 -12
  81. package/src/skills/geneseed.md +1 -12
  82. package/src/skills/git-archaeology.md +1 -12
  83. package/src/skills/git-rescue.md +1 -12
  84. package/src/skills/handoff.md +1 -12
  85. package/src/skills/herdr.md +2 -12
  86. package/src/skills/ingest.md +1 -12
  87. package/src/skills/migrate.md +1 -12
  88. package/src/skills/opencode-theme.md +2 -12
  89. package/src/skills/parallel-agents.md +1 -12
  90. package/src/skills/pipeline.md +2 -13
  91. package/src/skills/plan.md +1 -12
  92. package/src/skills/ponytail.md +1 -12
  93. package/src/skills/profile.md +1 -12
  94. package/src/skills/prose.md +1 -12
  95. package/src/skills/prototype.md +1 -12
  96. package/src/skills/quiz.md +43 -0
  97. package/src/skills/refactor.md +1 -12
  98. package/src/skills/release.md +1 -12
  99. package/src/skills/repo-map.md +1 -12
  100. package/src/skills/research.md +6 -14
  101. package/src/skills/review-response.md +1 -12
  102. package/src/skills/roast-me.md +1 -12
  103. package/src/skills/rule.md +1 -12
  104. package/src/skills/security-audit.md +51 -0
  105. package/src/skills/ship.md +1 -12
  106. package/src/skills/skill-forge.md +59 -0
  107. package/src/skills/teach.md +50 -0
  108. package/src/skills/tickets.md +1 -12
  109. package/src/skills/wayfinder.md +1 -12
  110. package/src/skills/wiki.md +1 -12
  111. package/src/skills/workflow.md +74 -26
  112. package/src/skills/worktree.md +52 -0
  113. package/themes/_TEMPLATE.json +12 -8
  114. package/themes/biker.json +12 -8
  115. package/themes/commentator.json +12 -8
  116. package/themes/cyberpunk.json +12 -8
  117. package/themes/gamer.json +12 -8
  118. package/themes/imperial.json +12 -8
  119. package/themes/joker.json +12 -8
  120. package/themes/marvin.json +12 -8
  121. package/themes/mean.json +12 -8
  122. package/themes/military.json +12 -8
  123. package/themes/neutral.json +12 -8
  124. package/themes/pirate.json +12 -8
  125. package/themes/sports.json +12 -8
  126. package/themes/verstappen.json +12 -8
  127. package/themes/wizard.json +12 -8
  128. package/web/dist/assets/Activity-DMGhhjQc.js +1 -0
  129. package/web/dist/assets/ActivityDetail-CeQcDA7e.js +1 -0
  130. package/web/dist/assets/{CatalogRow-DqS4e5XM.js → CatalogRow-CnSbDPyg.js} +1 -1
  131. package/web/dist/assets/{Diff-CIrGbiqm.js → Diff-D_ksMBD1.js} +1 -1
  132. package/web/dist/assets/{Doctor-CpI53oHY.js → Doctor-Brb3_oCT.js} +1 -1
  133. package/web/dist/assets/FilterInput-DbRWPhtT.js +1 -0
  134. package/web/dist/assets/GreenhouseView-F3VNFr73.js +2 -0
  135. package/web/dist/assets/{Harness-vRYs11kX.js → Harness-DJSwfYeF.js} +1 -1
  136. package/web/dist/assets/JournalView-BLRYRnB3.js +1 -0
  137. package/web/dist/assets/Laws-CKYns9qe.js +3 -0
  138. package/web/dist/assets/Library-BK0YloSv.js +1 -0
  139. package/web/dist/assets/{Markdown-kgpg9EQM.js → Markdown-BzTRzc7a.js} +1 -1
  140. package/web/dist/assets/OperatorHudView-D3OQSGp5.js +2 -0
  141. package/web/dist/assets/{Profile-CHr-PPf6.js → Profile-CPA2u_0g.js} +1 -1
  142. package/web/dist/assets/{Rules-CsC6hfqk.js → Rules-Lukel-Ru.js} +1 -1
  143. package/web/dist/assets/Skills-BKRLHH7m.js +1 -0
  144. package/web/dist/assets/{StatusBadge-gRrak0nO.js → StatusBadge-DI3weRKd.js} +1 -1
  145. package/web/dist/assets/{activity-B0-FXZCj.js → activity-C_H2Bx1P.js} +1 -1
  146. package/web/dist/assets/{index-Bx3I7cRW.js → index-B7_Wga23.js} +1 -1
  147. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  148. package/web/dist/assets/{index-DJOhOwwR.js → index-N8dTrVro.js} +1 -1
  149. package/web/dist/assets/index-Py_IQjgK.js +10 -0
  150. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  151. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  152. package/web/dist/index.html +2 -2
  153. package/web/src/pages/Laws.jsx +82 -64
  154. package/js/web/graph.mjs +0 -0
  155. package/src/skills/clarify.md +0 -28
  156. package/src/skills/crash-course.md +0 -28
  157. package/src/skills/decode.md +0 -28
  158. package/src/skills/drill.md +0 -28
  159. package/src/skills/feynman.md +0 -26
  160. package/src/skills/gap-detector.md +0 -27
  161. package/src/skills/learning-path.md +0 -26
  162. package/src/skills/tdd.md +0 -31
  163. package/web/dist/assets/Activity-BTCG2gMl.js +0 -1
  164. package/web/dist/assets/ActivityDetail-julsmPDB.js +0 -1
  165. package/web/dist/assets/Graph-CS4tA7YS.js +0 -1
  166. package/web/dist/assets/Laws-BjbHJrJt.js +0 -3
  167. package/web/dist/assets/Library-CjC7BAWN.js +0 -1
  168. package/web/dist/assets/Skills-BvatJGfe.js +0 -1
  169. package/web/dist/assets/index-BnRWQbZb.js +0 -12
  170. package/web/dist/assets/index-D0f9tJlA.css +0 -1
  171. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -78,6 +78,20 @@ const AGENT_OVERRIDES_STUB = {
78
78
  + 'Host-specific; git-ignored. A future TUI screen edits this — rebuild to apply.'
79
79
  ),
80
80
  agents: {},
81
+ // Inert: `loadAgentOverrides` reads `agents` only. This is the starting point the README
82
+ // used to describe in prose — the seats whose work is read-and-summarise, where a smaller
83
+ // model loses little and a council convenes 3–6 of them at once.
84
+ _recommended: {
85
+ _comment: (
86
+ 'Cheap-tier seats: copy the ones you want into "agents" with your host\'s cheap '
87
+ + 'model id (OpenCode: "anthropic/claude-haiku-4-5"; Claude Code / Copilot: "haiku"). '
88
+ + 'Read-and-summarise work — a council convenes several at once, so this is where '
89
+ + 'the savings compound. Nothing applies until you copy it.'
90
+ ),
91
+ 'cheap-tier': ['explorer', 'researcher', 'tester', 'advocate', 'skeptic', 'pragmatist',
92
+ 'steward', 'visionary', 'user-advocate', 'framer', 'empiricist', 'operator',
93
+ 'historian'],
94
+ },
81
95
  };
82
96
 
83
97
  /** Mirrors `_build_emit.COMMAND_SET` — the hot skills worth a one-keystroke trigger. */
@@ -667,11 +667,34 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
667
667
  * belongs in — the user's call — not how work is run, so it is not the process pack's to
668
668
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
669
669
  */
670
- export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = []) {
670
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude') {
671
671
  const run = hookPrefix(hookOpts);
672
672
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
673
673
  // --root carries the install's own dir so a GLOBAL hook can stand down when a project
674
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
+ }
675
698
  const context = `${run} context --root "${cfg}" || exit 0`;
676
699
  const gate = `${run} git-gate --root "${cfg}"`;
677
700
  const ruleGate = `${run} rule-gate --root "${cfg}"`;
@@ -687,7 +710,10 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
687
710
  ],
688
711
  SessionStart: [
689
712
  { matcher: 'startup|clear', hooks: [{ type: 'command', command: context }] },
690
- { 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 }] },
691
717
  ],
692
718
  // `|| exit 0` (not `|| true`): hooks run under cmd.exe on native Windows, where `true`
693
719
  // is not a command and the swallow-failures intent would invert into a 9009 error.
@@ -695,9 +721,151 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
695
721
  // Same command as Stop: `learn` reads the payload's hook_event_name and routes a
696
722
  // SubagentStop to the per-agent lesson path.
697
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 }] }],
730
+ };
731
+ }
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` },
698
757
  };
699
758
  }
700
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
+
701
869
  /**
702
870
  * `_build_settings._merge_claude_settings` — returns `[target, managed]`.
703
871
  *
@@ -710,7 +878,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
710
878
  * `scope` is accepted and unused, exactly as in the Python.
711
879
  */
712
880
  export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
713
- doctrines = null, excluded = []) {
881
+ doctrines = null, excluded = [], host = 'claude') {
714
882
  const prior = (priorHooks || []).filter(isDict);
715
883
  let config = {};
716
884
  let hadComments = false;
@@ -730,7 +898,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
730
898
  }
731
899
  let hooks = get(config, 'hooks');
732
900
  if (!isDict(hooks)) hooks = {};
733
- const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded);
901
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded, host);
734
902
  const canonFlat = [];
735
903
  for (const [event, groups] of Object.entries(canonical)) {
736
904
  for (const g of groups) canonFlat.push({ event, group: g });
@@ -771,7 +939,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
771
939
  return [p, prior];
772
940
  }
773
941
  // The `else` is DEAD on both sides and reproduced rather than dropped: `canonical` always
774
- // 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
775
943
  // mutation keeping the empty block instead of deleting it is the one of thirty-three that
776
944
  // stays green, and it stays green because there is nothing to detect — recorded the way
777
945
  // `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The sovereign-repo exclusion WRITER.
3
3
  *
4
- * THE READER WAS ALREADY HERE. `js/hosts/hooks.mjs` has carried `sovereignBypass`, `EXCLUDES_FILE`
4
+ * THE READER WAS ALREADY HERE. `js/hosts/hosts.mjs` (since 2026-09; `hooks.mjs` before) has carried `sovereignBypass`, `EXCLUDES_FILE`
5
5
  * and `EXCLUDE_DIRS` since P5a: every hook call reads `excludes.json` and stands down when
6
6
  * cwd is under an excluded folder. This module is the other half — `harness exclude
7
7
  * add|remove|list`, which maintains that file across every global install and wires the
@@ -90,21 +90,26 @@ export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context',
90
90
 
91
91
  /** `_harness_tui.SKILL_CLASS` — each skill's category, keyed by file stem. */
92
92
  export const SKILL_CLASS = {
93
- brainstorm: 'design', clarify: 'design', plan: 'design', council: 'design',
93
+ brainstorm: 'design', plan: 'design', council: 'design',
94
94
  workflow: 'design', 'parallel-agents': 'design', pipeline: 'design',
95
95
  'codebase-design': 'design', 'domain-modeling': 'design', wayfinder: 'design',
96
96
  tickets: 'design',
97
- tdd: 'build', develop: 'build', refactor: 'build', debug: 'build', migrate: 'build',
97
+ develop: 'build', refactor: 'build', debug: 'build', migrate: 'build',
98
98
  'frontend-design': 'build', 'opencode-theme': 'build', prototype: 'build',
99
- 'forge-mcp': 'build',
100
- 'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'gap-detector': 'review',
99
+ 'forge-mcp': 'build', 'bruno-collection-generator': 'build', 'bruno-test-writer': 'build',
100
+ worktree: 'build', 'ci-fix': 'build', 'deps-audit': 'build',
101
+ 'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
101
102
  'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
102
103
  commit: 'ship', ship: 'ship', release: 'ship', handoff: 'ship', 'git-rescue': 'ship',
103
- 'repo-map': 'understand', 'git-archaeology': 'understand', decode: 'understand',
104
+ 'repo-map': 'understand', 'git-archaeology': 'understand',
104
105
  research: 'understand', ingest: 'understand', 'document-project': 'understand',
105
- wiki: 'understand', prose: 'understand', geneseed: 'understand', rule: 'understand',
106
- profile: 'understand', herdr: 'understand',
107
- 'crash-course': 'learn', drill: 'learn', feynman: 'learn', 'learning-path': 'learn',
106
+ prose: 'understand',
107
+ // `harness` — skills about the agent's own apparatus (its harness, memory, rules,
108
+ // knowledge base, terminal) rather than about the user's code. They sat under
109
+ // `understand` before, which made that class a grab-bag.
110
+ wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
111
+ herdr: 'harness', 'skill-forge': 'harness', 'consolidate-memory': 'harness',
112
+ teach: 'learn', quiz: 'learn',
108
113
  };
109
114
 
110
115
  /** `k in dict` — own keys only, where `k in obj` also finds `Object.prototype` members. */
@@ -54,7 +54,7 @@
54
54
  * is inside the sandbox. It is reachable now; no cell seeds a manifest without an emit
55
55
  * marker, so it is still gated only by the corpus, and that is a gap rather than a wall.
56
56
  */
57
- import { existsSync, readdirSync } from 'node:fs';
57
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
58
58
  import path from 'node:path';
59
59
 
60
60
  import { THEMES, ROOT, makeCfg } from '../build/source.mjs';
@@ -62,7 +62,7 @@ import { tuiInventory } from './inventory.mjs';
62
62
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
63
63
  import {
64
64
  claudeConfigDir, bobConfigDir, copilotConfigDir, opencodeConfigDir, resolvePath,
65
- resolveMemoryDir,
65
+ resolveMemoryDir, sovereignBypass,
66
66
  } from '../hosts/hosts.mjs';
67
67
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
68
68
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
@@ -175,6 +175,41 @@ export function accentFor(theme) {
175
175
 
176
176
  // ---- the panel ----------------------------------------------------------------------------
177
177
 
178
+ /**
179
+ * The gates, as seen from THIS cwd across every install on the machine.
180
+ *
181
+ * Two facts the panel could not show before. `standing_down` names each install whose
182
+ * `excludes.json` silences all three gates for the current directory — a bypass that until
183
+ * now had no visible surface anywhere, so a user could not tell an armed gate from one they
184
+ * had switched off months ago. `asks` counts the gate ledger (`notebook/gates.jsonl`, written
185
+ * by `js/hosts/hooks.mjs` on every ask) by rule; it is the only evidence of whether the gates
186
+ * do anything, and the input to the decision of whether to build more of them. Unreadable
187
+ * ledgers and malformed lines are skipped, not reported: this is a display read.
188
+ */
189
+ export function gateSummary(cfgDirs) {
190
+ const standingDown = [];
191
+ const asks = {};
192
+ let total = 0;
193
+ for (const cfg of cfgDirs) {
194
+ if (sovereignBypass(cfg)) standingDown.push(String(cfg));
195
+ let raw;
196
+ try {
197
+ raw = readFileSync(path.join(cfg, 'notebook', 'gates.jsonl'), 'utf8');
198
+ } catch {
199
+ continue;
200
+ }
201
+ for (const line of raw.split('\n')) {
202
+ if (!line.trim()) continue;
203
+ let entry;
204
+ try { entry = JSON.parse(line); } catch { continue; }
205
+ const rule = entry && typeof entry.rule === 'string' ? entry.rule : 'unknown';
206
+ asks[rule] = (asks[rule] || 0) + 1;
207
+ total += 1;
208
+ }
209
+ }
210
+ return { standing_down: standingDown, asks, total };
211
+ }
212
+
178
213
  /** `_harness_status._status_data`. */
179
214
  export function statusData() {
180
215
  const inst = installedDefaults();
@@ -226,6 +261,7 @@ export function statusData() {
226
261
  version_verdict: versionVerdict(installedFp, sourceFp),
227
262
  agent_md: agentMd ? String(agentMd) : null,
228
263
  agent_md_present: Boolean(agentMd && existsSync(agentMd)),
264
+ gates: gateSummary([...(cfgDir ? [cfgDir] : []), ...otherCfg]),
229
265
  };
230
266
  }
231
267
 
@@ -295,6 +331,18 @@ export function statusLines(d, color = false) {
295
331
  rows.push(['AGENT.md',
296
332
  `${d.agent_md} (${d.agent_md_present ? 'present' : 'MISSING'})`]);
297
333
  }
334
+ // Conditional for the same recording reason as `constitution` above: the 30 recorded
335
+ // panels carry no `gates` key and must render unchanged.
336
+ if (d.gates) {
337
+ const g = d.gates;
338
+ const armed = g.standing_down.length
339
+ ? `STANDING DOWN for this cwd (excludes.json in ${g.standing_down.join(', ')})`
340
+ : 'armed';
341
+ const byRule = Object.entries(g.asks).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
342
+ .map(([k, v]) => `${k} ${v}`).join(', ');
343
+ rows.push(['gates', `${armed} ${DOT} ${g.total} ask${g.total === 1 ? '' : 's'}`
344
+ + (byRule ? ` (${byRule})` : '')]);
345
+ }
298
346
 
299
347
  const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
300
348
  const body = rows.map(([k, v]) => ` ${padEndToWidth(k, labelW)} ${v}`);
@@ -59,9 +59,10 @@ import {
59
59
  } from '../hosts/hosts.mjs';
60
60
  import { mcpCommented } from '../hosts/mcp.mjs';
61
61
  import {
62
- managedBlockRead, managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
63
- opencodeTarget, readJsonc, settingsIntegrityCheck, wireClaudeExcludes,
64
- unwireClaudeExcludes, unwireClaudeSettings,
62
+ copilotIntegrityCheck, managedBlockRead, managedBlockRemove, managedBlockWrite,
63
+ mergeClaudeSettings, mergeCopilotSettings, opencodeTarget, readJsonc,
64
+ settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
65
+ unwireCopilotSettings,
65
66
  } from '../hosts/settings.mjs';
66
67
  import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
67
68
  import { indexOfDeepEqual, jsonDumps, jsonDumpsIndent, deepEquals } from '../lib/json.mjs';
@@ -294,8 +295,12 @@ export function claudeUninstall(cfg, archiveMemory) {
294
295
  if (failed.length) warnSurvivors(failed);
295
296
  const hooks = managed.settings_hooks || [];
296
297
  const sf = settingsFile(cfg, managed);
297
- const unwired = unwireClaudeSettings(sf, hooks);
298
+ // Two shapes, two unwires: a Copilot install records `copilot_hooks` (event → one hook)
299
+ // and never `settings_hooks`, so exactly one of these does anything.
300
+ const unwired = unwireClaudeSettings(sf, hooks)
301
+ | unwireCopilotSettings(sf, managed.copilot_hooks || []);
298
302
  unwireClaudeExcludes(sf, managed.settings_excludes || []);
303
+ copilotIntegrityCheck(sf, managed.copilot_hooks || [], 'absent');
299
304
  // The unwire is VERIFIED, not assumed: a commented settings file is never rewritten, so a
300
305
  // supposedly-uninstalled repo could keep firing Geneseed's hooks. Loud, never fatal.
301
306
  settingsIntegrityCheck(sf, managed, 'absent');
@@ -692,9 +697,10 @@ export function cmdUninstall(args) {
692
697
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
693
698
  printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
694
699
  if (host === 'copilot') {
695
- printOut('[uninstall] removes: agents/, skills/, markers, and the '
696
- + `${hostSpec(host).agentFile} managed block (Copilot has no `
697
- + 'settings.json/hooks to unwire; your own .github files are kept).\n');
700
+ printOut('[uninstall] removes: agents/, skills/, markers, the '
701
+ + `${hostSpec(host).agentFile} managed block, and Geneseed's `
702
+ + '~/.copilot/settings.json hooks on a global install (your own keys/hooks and '
703
+ + '.github files are kept).\n');
698
704
  } else if (host === 'claude' || host === 'bob') {
699
705
  printOut('[uninstall] removes: agents/, skills/, markers, the '
700
706
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
@@ -1049,9 +1055,27 @@ function cleanHostStash(cfg, host = 'claude') {
1049
1055
  * file and swallows its own OSError, exactly as the reference does — a failed bookkeeping
1050
1056
  * write must not fail the reactivate that has already moved every file back.
1051
1057
  */
1052
- function remergeClaudeHooks(cfg, root = cfg) {
1058
+ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1053
1059
  const data = claudeReadManifest(cfg);
1054
1060
  const managed = managedOf(data);
1061
+ // A Copilot install: its own merge, its own claim key, and none of the pack/exclude axes
1062
+ // below — the Copilot gate is not pack-conditional (see `copilotHooks`). Without this
1063
+ // branch a Copilot reactivate would write CLAUDE-shaped hook groups into
1064
+ // ~/.copilot/settings.json, which Copilot cannot read.
1065
+ if (Array.isArray(managed.copilot_hooks)) {
1066
+ const [, claims] = mergeCopilotSettings(settingsFile(cfg, managed), managed.copilot_hooks,
1067
+ hookRunnerEntry());
1068
+ if (!deepEquals(claims, managed.copilot_hooks) && Object.keys(data).length > 0) {
1069
+ managed.copilot_hooks = claims;
1070
+ data.managed = managed;
1071
+ const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
1072
+ try {
1073
+ writeText(tmp, `${jsonDumpsIndent(data)}\n`);
1074
+ renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
1075
+ } catch { /* except OSError: pass */ }
1076
+ }
1077
+ return;
1078
+ }
1055
1079
  // `hookOpts` is the one argument `mergeClaudeSettings` will not default, and it is right
1056
1080
  // not to: there is no computable fallback for the runner/entry the shim bakes. The
1057
1081
  // emitter's own originator is imported rather than restated — a reactivate that wired a
@@ -1072,8 +1096,9 @@ function remergeClaudeHooks(cfg, root = cfg) {
1072
1096
  // NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
1073
1097
  const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1074
1098
  : excludedRulesOfDir(cfg);
1099
+ // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1075
1100
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1076
- managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded);
1101
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host);
1077
1102
  // `and data` — a manifest that did not parse is not one to write back.
1078
1103
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1079
1104
  managed.settings_hooks = claims;
@@ -1107,10 +1132,12 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1107
1132
  return { ok: false, failed, rolled_back: done.length };
1108
1133
  }
1109
1134
  unwireClaudeSettings(settingsFile(cfg, managed), managed.settings_hooks || []);
1135
+ unwireCopilotSettings(settingsFile(cfg, managed), managed.copilot_hooks || []);
1110
1136
  unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1111
1137
  // The same integrity check the uninstall path runs: a deactivate that silently failed to
1112
1138
  // unwire would leave hooks firing in a repo the user believes is off.
1113
1139
  settingsIntegrityCheck(settingsFile(cfg, managed), managed, 'absent');
1140
+ copilotIntegrityCheck(settingsFile(cfg, managed), managed.copilot_hooks || [], 'absent');
1114
1141
  const cm = claudeMdPath(cfg, managed);
1115
1142
  const block = managedBlockRead(cm);
1116
1143
  mkdirSync(stash, { recursive: true }); // presence == disabled, even if nothing moved
@@ -1147,7 +1174,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1147
1174
  && isFile(path.join(cfg, 'rules', 'geneseed.md'));
1148
1175
  }
1149
1176
  if (relive) {
1150
- remergeClaudeHooks(cfg, root); // ensure the hooks are present (and the claims exact)
1177
+ remergeClaudeHooks(cfg, root, host); // ensure the hooks are present (and the claims exact)
1151
1178
  cleanHostStash(cfg, host);
1152
1179
  return { ok: true,
1153
1180
  note: 'install was re-created while disabled; discarded the stashed snapshot' };
@@ -1165,7 +1192,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1165
1192
  if (existsSync(blockFile)) {
1166
1193
  managedBlockWrite(claudeMdPath(cfg, managed), readText(blockFile));
1167
1194
  }
1168
- remergeClaudeHooks(cfg, root);
1195
+ remergeClaudeHooks(cfg, root, host);
1169
1196
  wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1170
1197
  cleanHostStash(cfg, host);
1171
1198
  return { ok: true, kind: host, moved };
@@ -33,8 +33,8 @@
33
33
  *
34
34
  * ALSO HERE, SINCE P8b: `syncSelf`/`cmdSyncSelf` and the bootstrap step runner
35
35
  * (`cmdBootstrap`, `bootstrapPlain`) at the bottom of this file. Still NOT here: `main`, the
36
- * launchers' self-heal entry. `NOT_PORTED_ACTIONS` in `js/web/jobs.mjs` is empty now — every
37
- * action row crosses. `parseOrigin` is ported in full here even though only `upgrade`
36
+ * launchers' self-heal entry. Every web action row crosses — see `js/web/jobs.mjs`.
37
+ * `parseOrigin` is ported in full here even though only `upgrade`
38
38
  * needs its fallback, because P8c's docs `about` kind needs the slug half and splitting one
39
39
  * pure function across two phases is how a second classifier gets written.
40
40
  */
@@ -10,10 +10,10 @@
10
10
  *
11
11
  * ---------------------------------------------------------------------------------------
12
12
  * `apiPickFolder` CROSSED 2026-08-27 — a user override of the permanent decline this section
13
- * used to record. `js/web/routes.mjs`'s `DECLINED_POST` still carries the history of that
14
- * decision (why a folder dialog looked permanently un-portable) and is now empty because of
15
- * it. See `apiPickFolder`'s own docblock, below, for the argv, the async-spawn reasoning, and
16
- * why it dispatches inline from `js/web/handler.mjs` rather than through `POST_ROUTES`.
13
+ * used to record (why a folder dialog looked permanently un-portable lives in git history,
14
+ * under `DECLINED_POST` in `js/web/routes.mjs`). See `apiPickFolder`'s own docblock, below,
15
+ * for the argv, the async-spawn reasoning, and why it dispatches inline from
16
+ * `js/web/handler.mjs` rather than through `POST_ROUTES`.
17
17
  *
18
18
  * ---------------------------------------------------------------------------------------
19
19
  * `apiInstallToggle` is a thin endpoint over an engine that lives elsewhere on purpose.
@@ -223,10 +223,10 @@ function runPicker(cmd, args, done, resultOf) {
223
223
 
224
224
  /**
225
225
  * `/api/pick-folder` — the OS-NATIVE folder chooser, opened ON THE DAEMON HOST. `done` is
226
- * called with `{path}` on OK, `{cancelled: true}` on cancel, or `{error}` — the shape
227
- * `web/src/api/installs.js`'s `pickFolder()` already expects, unchanged since the endpoint was
228
- * DECLINED (see this file's header and `js/web/routes.mjs`'s `DECLINED_POST` for that history,
229
- * overridden by the user 2026-08-27).
226
+ * called with `{path}` on OK, `{cancelled: true}` on cancel, or `{error}` — the shape the
227
+ * client's `pickFolder()` (in `web/src/api/index.js`) already expects, unchanged since the
228
+ * endpoint was DECLINED (see this file's header for that history, overridden by the user
229
+ * 2026-08-27).
230
230
  *
231
231
  * ASYNC, NEVER `spawnSync`: a folder dialog stays open until a human answers it — seconds to
232
232
  * minutes — and a synchronous spawn would block this single-threaded server's event loop for
package/js/web/api.mjs CHANGED
@@ -37,7 +37,6 @@ import { stripWhitespace, percentDecode } from '../lib/text.mjs';
37
37
  import { readJsonc } from '../hosts/settings.mjs';
38
38
  import { apiActivity, apiActivityDetail } from './activity.mjs';
39
39
  import { apiMcp, apiRules } from './actions.mjs';
40
- import { apiGraph } from './graph.mjs';
41
40
 
42
41
  /** A requested catalog section or item that does not exist. */
43
42
  export class NotFound extends Error {}
@@ -342,7 +341,7 @@ export const WIKI_FILE_CAP = 5000;
342
341
  * (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
343
342
  * wraps the whole GET in a blanket `catch` → a JSON 500, so an unguarded refusal would turn
344
343
  * ONE bad line in a file the user hand-edits into a dead Knowledge section. That is the same
345
- * argument `sovereignBypass` makes in `js/hosts/hooks.mjs` — contain the refusal per entry
344
+ * argument `sovereignBypass` makes in `js/hosts/hosts.mjs` — contain the refusal per entry
346
345
  * and take the site's OWN degrade — and each of the three already has one: `[]`, `continue`,
347
346
  * `continue`-into-404.
348
347
  *
@@ -498,7 +497,6 @@ function flatName(name) {
498
497
  }
499
498
  }
500
499
 
501
- /** `js/web/graph.mjs` is a second reader of this pattern. */
502
500
  export const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
503
501
 
504
502
  /**
@@ -874,8 +872,8 @@ const RECENT_LIMIT = 8;
874
872
  /**
875
873
  * THE NEWEST FILE-BACKED ENTRIES ACROSS THE HARNESS — the "freshly grown" card's whole payload.
876
874
  *
877
- * A GET added after the reference surface was frozen (`GET_BEYOND_REF` in `routes.mjs`), and
878
- * the first one. It exists because NOTHING else in this API carries a per-entry date: every
875
+ * The first GET added after the Python→Node port froze its reference surface. It exists
876
+ * because NOTHING else in this API carries a per-entry date: every
879
877
  * catalog row is `{name, title, desc, source}` and the only two timestamps on the whole surface are
880
878
  * `overview.build_time` and `doctor.checked_at`. A client cannot stat a path, so "what changed
881
879
  * lately" was unanswerable without a server that looks.
@@ -954,13 +952,11 @@ export const PREFIX_ROUTES = [
954
952
  /**
955
953
  * A literal path to `api_X(state)`, table-keyed rather than a chain of `if`s — the reason
956
954
  * it is a table is `tests/unit/web_server.test.mjs`'s cross-check, which requires this
957
- * table plus `PREFIX_ROUTES`, `PORTED_INLINE` and `NOT_PORTED` to equal `REF_GET`, a frozen
958
- * record of every GET this daemon has ever needed to answer.
955
+ * table plus `PREFIX_ROUTES` and `GET_INLINE` to equal a surface list written out in the
956
+ * test, so a route cannot appear or vanish unenumerated.
959
957
  */
960
958
  export const STATE_ROUTES = {
961
959
  '/api/overview': apiOverview,
962
- // Added after the reference surface was frozen — declared in `GET_BEYOND_REF`, which
963
- // keeps the partition gate's set equality honest instead of forging `REF_GET`.
964
960
  '/api/recent': apiRecent,
965
961
  '/api/themes': apiThemes,
966
962
  '/api/setup': apiSetup,
@@ -969,7 +965,6 @@ export const STATE_ROUTES = {
969
965
  '/api/excludes': apiExcludes,
970
966
  '/api/profile': apiProfile,
971
967
  '/api/diff': apiDiff,
972
- '/api/graph': apiGraph,
973
968
  '/api/activity': apiActivity,
974
969
  // `/api/rules` must exist as a PAIR with its POST: `apiRulesMutate` splices `parseRules`'
975
970
  // line indices, and the fingerprint every mutation must send back is what this GET