geneseed 3.3.0 → 3.5.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 (231) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/DESIGN.md +11 -14
  3. package/QUICKSTART.md +3 -1
  4. package/README.md +53 -44
  5. package/SETUP.md +25 -27
  6. package/SHIPPED.md +26 -32
  7. package/adapters/bob/README.md +12 -9
  8. package/adapters/copilot/README.md +28 -26
  9. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
  10. package/adapters/opencode/README.md +8 -4
  11. package/adapters/opencode/plugins/geneseed-context.js +5 -3
  12. package/adapters/opencode/plugins/geneseed-workflow.js +30 -3
  13. package/adapters/opencode/workflows/_runtime.js +97 -7
  14. package/bin/build-driver.mjs +5 -1248
  15. package/bin/geneseed-cli.mjs +11 -6
  16. package/bin/geneseed-hook.mjs +4 -2
  17. package/docs/candidate-hosts.md +1 -1
  18. package/docs/design-history.md +37 -37
  19. package/docs/extending.md +49 -27
  20. package/docs/token-footprint.md +52 -26
  21. package/docs/web/agents.md +1 -1
  22. package/docs/web/claude-hooks.md +3 -2
  23. package/docs/web/collaboration.md +2 -2
  24. package/docs/web/first-session.md +2 -2
  25. package/docs/web/footprint.md +2 -2
  26. package/docs/web/harnesses.md +1 -1
  27. package/docs/web/model.md +1 -1
  28. package/docs/web/plugin-workflow.md +1 -0
  29. package/docs/web/rules.md +3 -3
  30. package/docs/web/setup-choices.md +2 -2
  31. package/docs/web/skills.md +1 -1
  32. package/docs/web/themes-author.md +1 -1
  33. package/docs/web-ui.md +18 -10
  34. package/harness.config.json +1 -1
  35. package/js/README.md +6 -5
  36. package/js/build/driver.mjs +1191 -0
  37. package/js/build/emit-claude.mjs +17 -10
  38. package/js/build/emit-common.mjs +4 -4
  39. package/js/build/generate.mjs +1 -1
  40. package/js/build/render.mjs +48 -44
  41. package/js/build/stubs.mjs +1 -1
  42. package/js/build/themes.mjs +4 -4
  43. package/js/build/version.mjs +12 -0
  44. package/js/cli-table.json +21 -6
  45. package/js/hosts/hooks.mjs +89 -74
  46. package/js/hosts/hosts.mjs +19 -2
  47. package/js/hosts/mcp.mjs +14 -20
  48. package/js/hosts/native.mjs +85 -25
  49. package/js/hosts/opencode.mjs +14 -0
  50. package/js/hosts/settings.mjs +52 -104
  51. package/js/inspect/checks-authoring.mjs +117 -3
  52. package/js/inspect/diff.mjs +1 -1
  53. package/js/inspect/doctor.mjs +1 -1
  54. package/js/inspect/excludes.mjs +1 -1
  55. package/js/inspect/inventory.mjs +12 -7
  56. package/js/inspect/scan.mjs +3 -14
  57. package/js/inspect/status.mjs +2 -2
  58. package/js/inspect/validate.mjs +1 -1
  59. package/js/lib/fs.mjs +23 -8
  60. package/js/lib/json.mjs +7 -1
  61. package/js/lib/proc.mjs +6 -4
  62. package/js/maintain/migrate.mjs +1 -1
  63. package/js/maintain/setup.mjs +1 -1
  64. package/js/maintain/uninstall.mjs +17 -15
  65. package/js/ui/cli.mjs +19 -0
  66. package/js/ui/menu.mjs +1 -1
  67. package/js/web/actions.mjs +4 -4
  68. package/js/web/activity.mjs +4 -4
  69. package/js/web/api.mjs +14 -11
  70. package/js/web/daemon.mjs +1 -1
  71. package/js/web/docs.mjs +12 -12
  72. package/js/web/handler.mjs +12 -5
  73. package/js/web/jobs.mjs +1 -1
  74. package/package.json +1 -1
  75. package/registry.json +86 -79
  76. package/src/AGENT.md.tmpl +83 -48
  77. package/src/agents/architect.md +4 -1
  78. package/src/agents/developer.md +2 -2
  79. package/src/agents/explorer.md +2 -0
  80. package/src/agents/operator.md +13 -0
  81. package/src/agents/pragmatist.md +12 -0
  82. package/src/agents/researcher.md +72 -0
  83. package/src/agents/reviewer.md +5 -2
  84. package/src/agents/security.md +7 -1
  85. package/src/agents/skeptic.md +2 -1
  86. package/src/agents/tester.md +5 -1
  87. package/src/agents/user-advocate.md +13 -0
  88. package/src/doctrines/README.md +26 -3
  89. package/src/doctrines/craft.md +78 -5
  90. package/src/doctrines/ops.md +62 -0
  91. package/src/doctrines/process.md +91 -4
  92. package/src/doctrines/rigor.md +41 -1
  93. package/src/laws/universal.md +27 -21
  94. package/src/memory/README.md +4 -2
  95. package/src/modes/foreman.md +5 -5
  96. package/src/notebook/README.md +2 -1
  97. package/src/ontology/universal.md +71 -67
  98. package/src/postures/peer.md +3 -0
  99. package/src/skills/_self-improvement.md +19 -0
  100. package/src/skills/_template.md +1 -12
  101. package/src/skills/brainstorm.md +37 -19
  102. package/src/skills/bruno-collection-generator.md +2 -13
  103. package/src/skills/bruno-test-writer.md +3 -14
  104. package/src/skills/ci-fix.md +49 -0
  105. package/src/skills/codebase-design.md +1 -12
  106. package/src/skills/commit.md +1 -12
  107. package/src/skills/consolidate-memory.md +50 -0
  108. package/src/skills/council.md +1 -12
  109. package/src/skills/daydream/LICENSE +21 -0
  110. package/src/skills/daydream/VENDOR.md +3 -1
  111. package/src/skills/debug.md +1 -12
  112. package/src/skills/deps-audit.md +46 -0
  113. package/src/skills/develop.md +38 -20
  114. package/src/skills/document-project.md +1 -12
  115. package/src/skills/domain-modeling.md +1 -12
  116. package/src/skills/forge-mcp.md +1 -12
  117. package/src/skills/fresh-eyes.md +1 -12
  118. package/src/skills/frontend-design.md +1 -12
  119. package/src/skills/geneseed-code-review.md +7 -14
  120. package/src/skills/geneseed.md +1 -12
  121. package/src/skills/git-archaeology.md +1 -12
  122. package/src/skills/git-rescue.md +1 -12
  123. package/src/skills/handoff.md +1 -12
  124. package/src/skills/herdr.md +1 -12
  125. package/src/skills/ingest.md +1 -12
  126. package/src/skills/migrate.md +1 -12
  127. package/src/skills/opencode-theme.md +1 -12
  128. package/src/skills/parallel-agents.md +4 -15
  129. package/src/skills/pipeline.md +6 -17
  130. package/src/skills/plan.md +1 -12
  131. package/src/skills/ponytail.md +1 -12
  132. package/src/skills/profile.md +1 -12
  133. package/src/skills/prose.md +1 -12
  134. package/src/skills/prototype.md +1 -12
  135. package/src/skills/quiz.md +43 -0
  136. package/src/skills/react-view-transitions/VENDOR.md +7 -1
  137. package/src/skills/refactor.md +1 -12
  138. package/src/skills/release.md +1 -12
  139. package/src/skills/repo-map.md +1 -12
  140. package/src/skills/research.md +5 -13
  141. package/src/skills/review-response.md +1 -12
  142. package/src/skills/roast-me.md +1 -12
  143. package/src/skills/rule.md +1 -12
  144. package/src/skills/security-audit.md +51 -0
  145. package/src/skills/ship.md +1 -12
  146. package/src/skills/skill-forge.md +59 -0
  147. package/src/skills/teach.md +50 -0
  148. package/src/skills/tickets.md +1 -12
  149. package/src/skills/wayfinder.md +1 -12
  150. package/src/skills/wiki.md +1 -12
  151. package/src/skills/workflow.md +82 -26
  152. package/src/skills/worktree.md +52 -0
  153. package/themes/_TEMPLATE.json +15 -12
  154. package/themes/biker.json +14 -11
  155. package/themes/commentator.json +14 -11
  156. package/themes/cyberpunk.json +14 -11
  157. package/themes/gamer.json +14 -11
  158. package/themes/imperial.json +14 -11
  159. package/themes/joker.json +14 -11
  160. package/themes/marvin.json +14 -11
  161. package/themes/mean.json +14 -11
  162. package/themes/military.json +14 -11
  163. package/themes/neutral.json +15 -12
  164. package/themes/opencode/README.md +2 -2
  165. package/themes/pirate.json +14 -11
  166. package/themes/sports.json +14 -11
  167. package/themes/verstappen.json +14 -11
  168. package/themes/wizard.json +14 -11
  169. package/web/dist/assets/Activity-Flgf4kHy.js +1 -0
  170. package/web/dist/assets/ActivityDetail-DIpr85nh.js +1 -0
  171. package/web/dist/assets/CatalogRow-CjyLjaLb.js +1 -0
  172. package/web/dist/assets/Diff-Deisdha8.js +2 -0
  173. package/web/dist/assets/Docs-DGUdY4l4.js +2 -0
  174. package/web/dist/assets/Doctor-F74lJ69P.js +1 -0
  175. package/web/dist/assets/FilterInput-BakJLvaV.js +1 -0
  176. package/web/dist/assets/GreenhouseView-D072lBiD.js +2 -0
  177. package/web/dist/assets/Harness-DNRP2Va7.js +1 -0
  178. package/web/dist/assets/JournalView-DWFqZ0Iw.js +1 -0
  179. package/web/dist/assets/Laws-BN6g3HU7.js +3 -0
  180. package/web/dist/assets/Library-CDOv4emM.js +1 -0
  181. package/web/dist/assets/Markdown-BQAvN8xx.js +38 -0
  182. package/web/dist/assets/OperatorHudView-CwNIUVX8.js +2 -0
  183. package/web/dist/assets/Profile-CpP9BTne.js +1 -0
  184. package/web/dist/assets/Rules-DFXsUCB7.js +1 -0
  185. package/web/dist/assets/Settings-BqZVUtJ5.js +1 -0
  186. package/web/dist/assets/Skills-Cu6kBgfF.js +1 -0
  187. package/web/dist/assets/StatusBadge-Cg6j-NJL.js +1 -0
  188. package/web/dist/assets/activity-iWaWVtsR.js +1 -0
  189. package/web/dist/assets/drawable-BgfrEBer.js +1 -0
  190. package/web/dist/assets/index-CAqrWhKl.js +2 -0
  191. package/web/dist/assets/index-Ruh93A3A.css +1 -0
  192. package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +1 -0
  193. package/web/dist/assets/lawCats-BLgxl151.js +1 -0
  194. package/web/dist/assets/react-DbkvxWG5.js +8 -0
  195. package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +1 -0
  196. package/web/dist/index.html +4 -3
  197. package/web/src/pages/Laws.jsx +22 -8
  198. package/src/skills/clarify.md +0 -28
  199. package/src/skills/crash-course.md +0 -29
  200. package/src/skills/decode.md +0 -29
  201. package/src/skills/drill.md +0 -29
  202. package/src/skills/feynman.md +0 -27
  203. package/src/skills/gap-detector.md +0 -27
  204. package/src/skills/learning-path.md +0 -27
  205. package/src/skills/react-view-transitions/README.md +0 -42
  206. package/src/skills/tdd.md +0 -31
  207. package/web/dist/assets/Activity-BfVcLrda.js +0 -1
  208. package/web/dist/assets/ActivityDetail-CQXccGPl.js +0 -1
  209. package/web/dist/assets/CatalogRow-DALEQImZ.js +0 -1
  210. package/web/dist/assets/Diff-BIjxXx46.js +0 -5
  211. package/web/dist/assets/Doctor-CJLbgsrw.js +0 -1
  212. package/web/dist/assets/FilterInput-CkUCk_lY.js +0 -1
  213. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +0 -2
  214. package/web/dist/assets/Harness-Dm5CCU-v.js +0 -1
  215. package/web/dist/assets/JournalView-Cpu2eI4s.js +0 -1
  216. package/web/dist/assets/Laws-DDDVFuQz.js +0 -3
  217. package/web/dist/assets/Library-Dao3T9na.js +0 -1
  218. package/web/dist/assets/Markdown-C__nuWX5.js +0 -46
  219. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +0 -2
  220. package/web/dist/assets/Profile-BlXtUlh2.js +0 -1
  221. package/web/dist/assets/Rules-DIre6gxR.js +0 -1
  222. package/web/dist/assets/Skills-rY13ZPhM.js +0 -1
  223. package/web/dist/assets/StatusBadge-wW_RS_my.js +0 -1
  224. package/web/dist/assets/activity-DpYPKNrA.js +0 -1
  225. package/web/dist/assets/index-BEZvPvpF.js +0 -1
  226. package/web/dist/assets/index-DNQE5ydy.js +0 -10
  227. package/web/dist/assets/index-DzdQjvM1.css +0 -1
  228. package/web/dist/assets/index-PmQGRS1X.js +0 -2
  229. package/web/dist/assets/jobBuckets-DfTUJUxR.js +0 -1
  230. package/web/dist/assets/lawCats-DUS9UTH4.js +0 -1
  231. package/web/dist/assets/react-DOpiTn5v.js +0 -32
@@ -8,11 +8,9 @@
8
8
  *
9
9
  * ONE VERB, TWO DIALECTS. `--host` names the host that will read the verdict. Claude Code
10
10
  * (the default) reads `hookSpecificOutput.permissionDecision: "ask"` and shows the user a
11
- * prompt. GitHub Copilot's `toolCall` hook has no ask tier — its stdout contract is
12
- * `{"block": true|false}` — so on `--host copilot` the two Laws that admit no judgement call
13
- * (I, IV) BLOCK and everything that was an ask (process 1, process 5, a gate error) is a
14
- * warning on stderr, which Copilot logs. The same stance the OpenCode guard plugin takes
15
- * where its `tool.execute.before` can only allow or throw. `--host bob` is Bob's own
11
+ * prompt. GitHub Copilot's `preToolUse` reads the same decision TOP-LEVEL —
12
+ * `{"permissionDecision": "ask", "permissionDecisionReason": …}` — so `--host copilot` asks
13
+ * exactly where Claude asks, in Copilot's envelope. `--host bob` is Bob's own
16
14
  * protocol: PreToolUse ignores stdout and refuses only on EXIT CODE 2, so the two Laws
17
15
  * exit 2 with the reason on stderr and the rest is a stderr line with exit 0; SessionStart
18
16
  * context is plain stdout, as on Claude. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
@@ -43,7 +41,7 @@
43
41
  * and every file left behind. Absolute rather than compared is what let it survive the deletion
44
42
  * of the implementation it was once compared against.
45
43
  */
46
- import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync,
44
+ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync,
47
45
  appendFileSync, writeFileSync }
48
46
  from 'node:fs';
49
47
  import path from 'node:path';
@@ -56,7 +54,9 @@ import { NO_WINDOW } from '../lib/proc.mjs';
56
54
  // module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
57
55
  // spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
58
56
  // with it went this file's private `opencodeConfigDir`, whose only caller it was.
59
- import { resolveMemoryDir, expanduser, sovereignBypass } from './hosts.mjs';
57
+ import {
58
+ GATE_LEDGER, resolveMemoryDir, resolvePath, sovereignBypass,
59
+ } from './hosts.mjs';
60
60
 
61
61
  const ROOT = path.resolve(import.meta.dirname, '../..');
62
62
 
@@ -88,32 +88,8 @@ const ROOT = path.resolve(import.meta.dirname, '../..');
88
88
  // call. For the two GATES that containment is not enough, and `guardGate` below is the
89
89
  // contract: these verbs exit 0 and signal on stdout, so "the gate blew up" and "the gate
90
90
  // found nothing" would be the SAME observation to Claude — a crash would be a silent allow.
91
- // A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` just below
92
- // is the THIRD copy of a primitive `js/hosts/hosts.mjs` also exports, and is the next item
93
- // in this series.
94
-
95
- /**
96
- * `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
97
- *
98
- * `realpathSync` throws on a path that does not exist, which is routine here (an
99
- * `excludes.json` may name a folder that was deleted), so the existing prefix is
100
- * canonicalised and the rest appended verbatim — `resolve(strict=False)`'s behaviour.
101
- */
102
- function resolvePath(p) {
103
- let cur = path.resolve(expanduser(p));
104
- const tail = [];
105
- for (;;) {
106
- try {
107
- return tail.length ? path.join(realpathSync.native(cur), ...tail)
108
- : realpathSync.native(cur);
109
- } catch {
110
- const parent = path.dirname(cur);
111
- if (parent === cur) return path.resolve(expanduser(p));
112
- tail.unshift(path.basename(cur));
113
- cur = parent;
114
- }
115
- }
116
- }
91
+ // A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` comes from
92
+ // `js/hosts/hosts.mjs`, which this module already imports — it had kept a private copy.
117
93
 
118
94
  // NOT imported from `js/lib/fs.mjs` (the owner everywhere else) — hot path, no new import cost.
119
95
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
@@ -242,6 +218,19 @@ const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR']
242
218
  const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
243
219
  'target', '.venv', '__pycache__', '.opencode', '.harness']);
244
220
 
221
+ // The root instruction file a host already loads by itself. Injecting it again pays the
222
+ // whole harness twice per session — and was doing exactly that until 2026-09: every hooked
223
+ // host had its own root in EAGER_ROOT. Other tools' roots stay eager (a repo carrying a
224
+ // hand-written AGENTS.md is worth showing Claude Code); only the host's OWN is dropped.
225
+ // The OpenCode context plugin carries the same rule for AGENT.md/AGENTS.md/CLAUDE.md.
226
+ const NATIVE_ROOT = { claude: ['CLAUDE.md'], bob: ['AGENTS.md'], copilot: ['AGENTS.md'] };
227
+
228
+ // Eager injection budget: a root README of 40k chars was going out whole, every session and
229
+ // every compaction, and cost more than the harness itself. Per-file cut at the last line
230
+ // break under the cap; files that would push the total over the budget are listed lazy.
231
+ const EAGER_FILE_BYTES = 16 * 1024;
232
+ const EAGER_TOTAL_BYTES = 48 * 1024;
233
+
245
234
  const CLAUDE_MARKERS = ['.claude', '.bob'];
246
235
  const GENESEED_MANIFEST = '.geneseed-manifest.json';
247
236
 
@@ -310,12 +299,14 @@ function rglobMd(dir, acc = []) {
310
299
  * Root entry docs are eager; other root markdown, the doc trees and monorepo package
311
300
  * READMEs are lazy.
312
301
  */
313
- export function discoverContext(root) {
302
+ export function discoverContext(root, host = HOST) {
314
303
  const eager = new Map();
315
304
  const lazy = new Map();
305
+ const native = NATIVE_ROOT[host] || [];
316
306
  for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
317
307
  if (!isFile(full)) continue;
318
308
  const name = path.basename(full);
309
+ if (native.includes(name)) continue;
319
310
  if (EAGER_ROOT.includes(name)) eager.set(full, null);
320
311
  else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
321
312
  }
@@ -473,25 +464,42 @@ export function cmdContext(args) {
473
464
  `=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`,
474
465
  '',
475
466
  ];
467
+ let spent = 0;
468
+ const demoted = [];
476
469
  for (const entry of eager) {
477
470
  const p = entry.path === undefined ? '' : entry.path;
478
471
  const desc = entry.description === undefined ? '' : entry.description;
479
472
  const target = path.isAbsolute(p) ? p : path.join(root, p);
480
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`);
473
+ let text;
481
474
  try {
482
- lines.push(readText(target).replace(/\n+$/, ''));
475
+ text = readText(target).replace(/\n+$/, '');
483
476
  } catch (e) {
484
- lines.push(`[context] MISSING eager file: ${asOsError(e, target)}`);
477
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
478
+ `[context] MISSING eager file: ${asOsError(e, target)}`, '');
479
+ continue;
485
480
  }
486
- lines.push('');
481
+ if (text.length > EAGER_FILE_BYTES) {
482
+ const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
483
+ text = `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
484
+ + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB \u2014 read ${disp(p, root)} `
485
+ + 'on demand for the rest';
486
+ }
487
+ if (spent + text.length > EAGER_TOTAL_BYTES) {
488
+ demoted.push(entry);
489
+ continue;
490
+ }
491
+ spent += text.length;
492
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
487
493
  }
488
494
 
489
- if (lazy.length) {
495
+ if (lazy.length || demoted.length) {
490
496
  lines.push('--- Lazy entries (load only when the task needs them) ---');
491
- for (const entry of lazy) {
497
+ for (const entry of [...lazy, ...demoted]) {
492
498
  const p = entry.path === undefined ? '' : entry.path;
493
499
  const desc = entry.description === undefined ? '' : entry.description;
494
- lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}`);
500
+ const over = demoted.includes(entry)
501
+ ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
502
+ lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
495
503
  }
496
504
  lines.push('');
497
505
  }
@@ -544,9 +552,14 @@ function askDecision(reason) {
544
552
  let HOST = 'claude';
545
553
  const setHost = (args) => { HOST = (args && args.host) || 'claude'; };
546
554
 
547
- /** Copilot's `toolCall` verdict: `{"block": true}` plus the reason it displays. */
548
- function blockDecision(reason) {
549
- return `${jsonDumpsCompact({ block: true, reason })}\n`;
555
+ /**
556
+ * Copilot's `preToolUse` verdict: the same `ask` Claude reads, but top-level rather than under
557
+ * `hookSpecificOutput` (docs.github.com/en/copilot/reference/hooks-configuration). An older
558
+ * emit wired a `toolCall` event answered with `{"block": true}`; Copilot no longer has that
559
+ * event, so those gates never ran.
560
+ */
561
+ function copilotAskDecision(reason) {
562
+ return `${jsonDumpsCompact({ permissionDecision: 'ask', permissionDecisionReason: reason })}\n`;
550
563
  }
551
564
 
552
565
  /**
@@ -557,18 +570,15 @@ function blockDecision(reason) {
557
570
  const BOB_DENY_EXIT = 2;
558
571
 
559
572
  /**
560
- * On a host with no ask tier, which rules are worth a hard block. Laws I and IV: a
573
+ * On Bob, which has no ask tier, which rules are worth a hard block. Laws I and IV: a
561
574
  * credential in a tracked file and a history-discarding git act are wrong in every context,
562
575
  * so refusing them costs nothing. Process 1 and process 5 are the USER's calls — a hard
563
- * block would make Copilot unable to commit at all — so they become a warning the host
576
+ * block would make Bob unable to commit at all — so they become a warning the host
564
577
  * logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
565
578
  * a lockout, not a safeguard, and there is no prompt through which the user could clear it.
566
579
  */
567
580
  const BLOCK_RULES = new Set(['law-1', 'law-4']);
568
581
 
569
- /** Where the gate ledger lives inside an install: the notebook is the agent's own, gitignored space. */
570
- export const GATE_LEDGER = path.join('notebook', 'gates.jsonl');
571
-
572
582
  /**
573
583
  * THE GATE LEDGER — one JSON line per ask, nothing per defer.
574
584
  *
@@ -603,27 +613,23 @@ function ledger(root, verb, rule) {
603
613
 
604
614
  /**
605
615
  * Ask, and record that we asked. Every ask site goes through here so none can skip the
606
- * ledger. On `--host copilot` the ask becomes a block or a warning (see `BLOCK_RULES`); the
616
+ * ledger. On `--host bob` the ask becomes a block or a warning (see `BLOCK_RULES`); the
607
617
  * ledger records the block, never the warning — a warning asked nothing of anyone.
608
618
  */
609
619
  function ask(args, verb, rule, reason) {
610
- if (HOST === 'copilot' || HOST === 'bob') {
620
+ if (HOST === 'bob') {
611
621
  if (!BLOCK_RULES.has(rule)) {
612
- // Neither host has an ask tier; a stderr line is the nudge, and the call goes through.
622
+ // No ask tier; a stderr line is the nudge, and the call goes through.
613
623
  err(`[geneseed] ${reason}\n`);
614
624
  return 0;
615
625
  }
616
626
  ledger(args && args.root, verb, rule);
617
- if (HOST === 'bob') {
618
- // stdout is not read on Bob's PreToolUse; the reason goes where Bob's hook log looks.
619
- err(`[geneseed] BLOCKED: ${reason}\n`);
620
- return BOB_DENY_EXIT;
621
- }
622
- out(blockDecision(reason));
623
- return 0;
627
+ // stdout is not read on Bob's PreToolUse; the reason goes where Bob's hook log looks.
628
+ err(`[geneseed] BLOCKED: ${reason}\n`);
629
+ return BOB_DENY_EXIT;
624
630
  }
625
631
  ledger(args && args.root, verb, rule);
626
- out(askDecision(reason));
632
+ out(HOST === 'copilot' ? copilotAskDecision(reason) : askDecision(reason));
627
633
  return 0;
628
634
  }
629
635
 
@@ -651,10 +657,11 @@ export function guardGate(fn, verb = 'gate') {
651
657
  /**
652
658
  * The hook payload on stdin, or null for a well-formed absence. Not-JSON THROWS.
653
659
  *
654
- * Copilot spells the same payload in camelCase — `toolName`/`toolInput` where Claude sends
655
- * `tool_name`/`tool_input` — and fires `toolCall` both before AND after the tool runs
656
- * (`phase`). The field names are folded to Claude's so every gate reads one shape; the
657
- * `after` phase is a well-formed absence, since there is nothing left to gate.
660
+ * Copilot spells the same payload in camelCase — `toolName`/`toolArgs` where Claude sends
661
+ * `tool_name`/`tool_input` — and `toolArgs` may arrive as a JSON STRING rather than an object.
662
+ * Both are folded to Claude's shape so every gate reads one. (`toolInput` and the `after`
663
+ * phase are the retired `toolCall` event's spelling, kept so an install not yet re-emitted
664
+ * still reads right.) A `toolArgs` string that is not JSON throws: fail closed.
658
665
  */
659
666
  function readPayload() {
660
667
  const raw = readStdin();
@@ -667,8 +674,12 @@ function readPayload() {
667
674
  }
668
675
  if (!payload || typeof payload !== 'object') return null;
669
676
  if (payload.phase === 'after') return null;
670
- if (payload.tool_input === undefined && payload.toolInput !== undefined) {
671
- payload.tool_input = payload.toolInput;
677
+ if (payload.tool_input === undefined) {
678
+ let ta = payload.toolArgs !== undefined ? payload.toolArgs : payload.toolInput;
679
+ if (typeof ta === 'string') {
680
+ try { ta = JSON.parse(ta); } catch { throw new Error('the payload\'s toolArgs is not JSON'); }
681
+ }
682
+ if (ta !== undefined) payload.tool_input = ta;
672
683
  }
673
684
  return payload;
674
685
  }
@@ -685,7 +696,9 @@ function gitDecide(args, payload) {
685
696
  return ask(args, 'git-gate', 'law-4', 'Geneseed Law IV \u2014 a history-rewriting or '
686
697
  + 'discarding git act needs confirmation bound to this specific command');
687
698
  }
688
- if (!GIT_GATE_RE.test(command)) return 0;
699
+ // `--no-consent`: the process pack (or process 5 alone) is off, so the commit/push ask has
700
+ // no rule behind it — but Law IV above is universal and has already run.
701
+ if (args.noConsent || !GIT_GATE_RE.test(command)) return 0;
689
702
  return ask(args, 'git-gate', 'process-5', 'Geneseed Doctrine process 5 \u2014 every git '
690
703
  + 'commit/push needs explicit approval');
691
704
  }
@@ -739,9 +752,10 @@ function ruleDecide(args, payload) {
739
752
  const ti = (payload && payload.tool_input) || {};
740
753
  const p = ti.file_path || ti.path || '';
741
754
  if (typeof p !== 'string' || !p) return 0;
742
- // Write carries `content`, Edit carries `new_string`; either can plant a credential.
743
- const body = typeof ti.content === 'string' ? ti.content
744
- : typeof ti.new_string === 'string' ? ti.new_string : '';
755
+ // Write carries `content`, Edit carries `new_string` (Copilot: `file_text`, `new_str`);
756
+ // any of them can plant a credential.
757
+ const body = [ti.content, ti.new_string, ti.file_text, ti.new_str]
758
+ .find((v) => typeof v === 'string') || '';
745
759
  if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
746
760
  return ask(args, 'rule-gate', 'law-1', `Geneseed Law I — ${p} would carry a `
747
761
  + 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
@@ -760,9 +774,9 @@ export const cmdRuleGate = guardGate(ruleGate, 'rule-gate');
760
774
  // ======================================================================================
761
775
 
762
776
  /**
763
- * Copilot's settings carry ONE command per hook event, not a list of matcher groups, so
764
- * its `toolCall` hook cannot name `git-gate` for Bash and `rule-gate` for Write the way
765
- * Claude's `PreToolUse` does. This verb is the two fused, dispatched on the payload rather
777
+ * Copilot's and Bob's PreToolUse entries carry no matcher over documented tool names, so
778
+ * neither can name `git-gate` for Bash and `rule-gate` for Write the way Claude's
779
+ * `PreToolUse` does. This verb is the two fused, dispatched on the payload rather
766
780
  * than on a matcher: a `command` field is a shell call and gets the git checks, a path
767
781
  * field is a write and gets the rule checks. Nothing here decides anything the two named
768
782
  * gates do not — it only removes the need for a matcher the host does not have.
@@ -905,7 +919,8 @@ export function readNotes(raw) {
905
919
  if (s[0] === '{') {
906
920
  let payload;
907
921
  try { payload = JSON.parse(s); } catch { return raw; }
908
- const tp = payload && payload.transcript_path;
922
+ // Copilot's agentStop/preCompact spell it `transcriptPath`.
923
+ const tp = payload && (payload.transcript_path || payload.transcriptPath);
909
924
  if (tp) return flattenTranscript(tp);
910
925
  return raw;
911
926
  }
@@ -26,6 +26,9 @@ export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
26
26
  /** The user's own per-install exclusion list; `js/build/stubs.mjs` seeds it, this file reads it. */
27
27
  export const EXCLUDES_FILE = 'excludes.json';
28
28
 
29
+ /** The gate ledger inside an install: the hooks append to it, `status` counts it. */
30
+ export const GATE_LEDGER = path.join('notebook', 'gates.jsonl');
31
+
29
32
  /**
30
33
  * `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
31
34
  * takes effect without a re-emit. Every failure mode degrades to false: a hook must never
@@ -248,11 +251,25 @@ export const HOSTS = [
248
251
  * Unknown host -> false, which is the shape that KEEPS the tables. The value arrives from a
249
252
  * user-editable `.geneseed-emit` marker, so an unrecognised one must degrade to the portable
250
253
  * bundle rather than raise.
254
+ *
255
+ * SPLIT PER KIND (2026-09): Bob reads `.bob/skills/<name>/SKILL.md` natively — verified
256
+ * against its docs in the Bob injection review — but has no markdown agents directory, so a
257
+ * single boolean was wrong both ways: `false` shipped the §4 Skills table on top of a
258
+ * catalogue Bob already had (~1.7k tokens twice), `true` would have stripped the §3 Agents
259
+ * table that is Bob's ONLY agent catalogue. Each CATALOG block in AGENT.md names its kind and
260
+ * resolves against its own flag. Copilot stays unverified and keeps both tables. Mirrored in
261
+ * `bin/build-driver.mjs` (which cannot import this module's spawning neighbours).
251
262
  */
252
- const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: false };
263
+ const NATIVE_CATALOG = {
264
+ opencode: { skills: true, agents: true },
265
+ claude: { skills: true, agents: true },
266
+ bob: { skills: true, agents: false },
267
+ copilot: { skills: false, agents: false },
268
+ };
253
269
 
270
+ /** `{ skills, agents }` for a known host, `false` for an unknown one. */
254
271
  export function hostCatalogsNatively(host) {
255
- return Boolean(NATIVE_CATALOG[host]);
272
+ return NATIVE_CATALOG[host] || false;
256
273
  }
257
274
 
258
275
  /** `_harness_learn.MEMORY_DIR_NAMES` — the neutral name and the imperial theme's. */
package/js/hosts/mcp.mjs CHANGED
@@ -24,9 +24,9 @@
24
24
  * `1` on the way back out. `readJsonc` already learned this; the strict-JSON fork has to
25
25
  * learn it separately, because it does not go through `readJsonc` at all.
26
26
  *
27
- * A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict` is spelled out rather than
28
- * written as `typeof v === 'object'`. Python's `isinstance(x, dict)` is false for a float;
29
- * the JS twin has to be false for its wrapper.
27
+ * A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict` (js/lib/json.mjs) excludes
28
+ * the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
29
+ * false for a float; the JS twin has to be false for its wrapper.
30
30
  */
31
31
  import { existsSync, mkdirSync, renameSync } from 'node:fs';
32
32
  import os from 'node:os';
@@ -35,20 +35,10 @@ import path from 'node:path';
35
35
  import { bobConfigDir, copilotConfigDir, resolvePath } from './hosts.mjs';
36
36
  import { installState, installTargets } from './installs.mjs';
37
37
  import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
38
- import { jsonDumpsIndent, parseJson } from '../lib/json.mjs';
38
+ import { isDict, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
39
39
  import { padEndToWidth } from '../lib/text.mjs';
40
40
  import { opencodeTarget, readJsonc } from './settings.mjs';
41
41
 
42
- /**
43
- * `isinstance(x, dict)` for a value that came out of `json.loads`.
44
- *
45
- * A JSON value is null, a boolean, a string, a NUMBER, an array or an object — and
46
- * `parseJson` hands numbers back wrapped, so `typeof v === 'object'` says yes to a float.
47
- * `constructor === Object` is the plain-object test, and every object `JSON.parse` builds
48
- * is one.
49
- */
50
- const isDict = (v) => v !== null && v !== undefined && typeof v === 'object'
51
- && v.constructor === Object;
52
42
 
53
43
  /** `dict.get(key, default)`. */
54
44
  const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
@@ -184,8 +174,10 @@ export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
184
174
  *
185
175
  * The host fork is the whole function. OpenCode is read comment-tolerantly because a
186
176
  * hand-maintained `opencode.jsonc` carries `//`; the other three are read STRICTLY, because
187
- * the comment stripper's trailing-comma pass is not string-aware and would silently drop a
188
- * comma from any string value containing `,]` — in a file that holds projects and history.
177
+ * their configs are plain JSON by contract, and a file that does not parse as such — for a
178
+ * Claude global install, one that holds projects and history — is not one to rewrite from a
179
+ * best-effort reading. (The comment stripper's trailing-comma pass IS string-aware; an older
180
+ * version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
189
181
  */
190
182
  export function mcpLoad(p, host = 'opencode') {
191
183
  if (!existsSync(p)) return {};
@@ -244,10 +236,12 @@ export function mcpConfigFor(host, scope, root) {
244
236
  : resolvePath(path.join(os.homedir(), '.claude.json'));
245
237
  }
246
238
  if (host === 'bob') {
247
- // Nested at global scope, as Bob's hooks doc places the global file (and as the emit
248
- // now writes it); the project file is flat.
249
- return scope === 'project' ? path.join(root, '.bob', 'settings.json')
250
- : path.join(bobConfigDir(), 'settings', 'settings.json');
239
+ // Bob reads MCP servers from their OWN file, never from settings.json: `.bob/mcp.json` in
240
+ // the project, `~/.bob/settings/mcp.json` globally, each `{"mcpServers": …}`
241
+ // (bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob). Servers written into settings.json,
242
+ // as an older version did, never loaded.
243
+ return scope === 'project' ? path.join(root, '.bob', 'mcp.json')
244
+ : path.join(bobConfigDir(), 'settings', 'mcp.json');
251
245
  }
252
246
  if (host === 'copilot') {
253
247
  // The Copilot CLI reads MCP servers from ~/.copilot/mcp-config.json only — no per-repo
@@ -31,7 +31,7 @@
31
31
  import { existsSync, mkdirSync } from 'node:fs';
32
32
  import path from 'node:path';
33
33
  import { writeText, copyFile, readText } from '../lib/fs.mjs';
34
- import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy } from '../lib/json.mjs';
34
+ import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
35
35
 
36
36
  /** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
37
37
  export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report']);
@@ -112,6 +112,57 @@ export function descOf(text) {
112
112
  return firstBlockquote(text);
113
113
  }
114
114
 
115
+ /**
116
+ * The `**Trigger:**` paragraph of a skill, flattened to one line — markdown links reduced
117
+ * to their text, HTML comments dropped, whitespace collapsed. '' when the skill has none.
118
+ *
119
+ * Why it exists: on Claude Code (and Bob) the frontmatter `description:` is the ONLY text
120
+ * the model matches a task against before deciding to load a skill. The purpose line says
121
+ * what a skill does; the trigger says WHEN — and a catalogue of purposes alone left the
122
+ * model choosing `tdd` from "Drive implementation with tests" without ever seeing "before
123
+ * writing implementation code". The AGENT.md §4 table carried the triggers, but native
124
+ * hosts collapse that table to a pointer (`hostCatalogsNatively`), so the trigger reached
125
+ * no host at all. `skillDescription` below concatenates the two.
126
+ */
127
+ function triggerOf(text) {
128
+ const lines = text.split('\n');
129
+ const start = lines.findIndex((ln) => /^\*\*Trigger:\*\*/.test(ln.trim()));
130
+ if (start < 0) return '';
131
+ const para = [];
132
+ for (let i = start; i < lines.length; i += 1) {
133
+ const s = lines[i].trim();
134
+ if (i > start && (s === '' || s.startsWith('#') || s.startsWith('**'))) break;
135
+ para.push(s);
136
+ }
137
+ return para.join(' ')
138
+ .replace(/^\*\*Trigger:\*\*\s*/, '')
139
+ .replace(HTML_COMMENT_RE, '')
140
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
141
+ .replace(/\s+/g, ' ')
142
+ .trim();
143
+ }
144
+
145
+ /**
146
+ * Purpose + the trigger's FIRST sentence, the emitted `description:` of a skill. Every
147
+ * description is always-on context on a host that catalogues natively: at the earlier
148
+ * 900-char cap the catalogue weighed 22k chars (~5.6k tokens) across 51 skills, three times
149
+ * the figure the footprint page quoted. The first sentence says WHEN; the later sentences
150
+ * disambiguate, and the body — read as soon as the skill is chosen — still carries them.
151
+ * Cut at the last sentence end inside the cap, never mid-word.
152
+ */
153
+ const DESCRIPTION_CAP = 320;
154
+ function skillDescription(text) {
155
+ const desc = descOf(text);
156
+ const trig = triggerOf(text);
157
+ if (!trig) return desc;
158
+ const first = trig.match(/^.*?[.;](?=\s|$)/);
159
+ const full = `${desc} Use when: ${first ? first[0] : trig}`;
160
+ if (full.length <= DESCRIPTION_CAP) return full;
161
+ const cut = full.slice(0, DESCRIPTION_CAP);
162
+ const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '));
163
+ return end > desc.length ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
164
+ }
165
+
115
166
  /** `_build_render._HTML_COMMENT_RE`. */
116
167
  const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
117
168
 
@@ -159,7 +210,7 @@ function isReadonly(text) {
159
210
  * themselves, and the ones gated on an environment the model cannot see (a herdr pane).
160
211
  */
161
212
  const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
162
- export function isUserInvokedOnly(text) {
213
+ function isUserInvokedOnly(text) {
163
214
  return USER_INVOKED_RE.test(text);
164
215
  }
165
216
 
@@ -171,8 +222,8 @@ export function stripSkillBodyLinks(body) {
171
222
  /** `_build_emit._agent_color_map` — validated, always a valid OpenCode slot. */
172
223
  function agentColorMap(theme) {
173
224
  let raw = null;
174
- if (isPlainObject(theme)) raw = theme.AGENT_COLORS;
175
- if (!isPlainObject(raw)) raw = AGENT_COLORS;
225
+ if (isDict(theme)) raw = theme.AGENT_COLORS;
226
+ if (!isDict(raw)) raw = AGENT_COLORS;
176
227
  const cleaned = {};
177
228
  // Insertion order matters: it is the order the warnings below are printed in, and the
178
229
  // parity gate compares the warning stream. Python dicts and JS objects agree on it for
@@ -197,10 +248,6 @@ function agentColor(stem, theme) {
197
248
  return Object.hasOwn(colors, stem) ? colors[stem] : colors._default;
198
249
  }
199
250
 
200
- function isPlainObject(v) {
201
- return typeof v === 'object' && v !== null && !Array.isArray(v);
202
- }
203
-
204
251
  /** `is not None` — the test that lets a configured `temperature: 0` survive. */
205
252
  function given(v) {
206
253
  return v !== undefined && v !== null;
@@ -244,22 +291,28 @@ export function pushOverrideLines(fm, ov) {
244
291
  if (given(ov.steps)) fm.push(`steps: ${formatValue(ov.steps)}`);
245
292
  }
246
293
 
294
+ /** The two opt-in markers a read-only spec may carry. Bare substring tests, like `isReadonly`. */
295
+ const BASH_MARKER = '<!-- bash: allow -->';
296
+ const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
297
+
247
298
  /**
248
299
  * The readonly-tool line `claudeAgentFrontmatter`/`copilotAgentFrontmatter` each emit — a
249
300
  * DENYLIST for Claude, an ALLOWLIST for Copilot — differing only in the key, the base list,
250
- * the one extra entry the `<!-- bash: allow -->` marker toggles, and whether the value is
251
- * bracketed. `extraWhenBashAllowed` is `false` for Claude (Bash is denied UNLESS the marker
252
- * allows it) and `true` for Copilot (`execute` is allowed ONLY IF the marker is present) —
253
- * the same marker, read in the opposite sense a denylist and an allowlist require.
301
+ * the gated entries the markers toggle, and whether the value is bracketed. Each `gated`
302
+ * row is `[tool, marker, listedWhenMarked]`: `false` for Claude (the tool is denied UNLESS
303
+ * the marker allows it) and `true` for Copilot (the tool is allowed ONLY IF the marker is
304
+ * present) — the same marker, read in the opposite sense a denylist and an allowlist
305
+ * require. Row order is emit order: WebFetch before Bash keeps the unmarked Claude line
306
+ * byte-identical to what it was before the webfetch marker existed.
254
307
  */
255
308
  const READONLY_TOOLS = {
256
309
  claude: {
257
- key: 'disallowedTools', tools: ['Write', 'Edit', 'NotebookEdit', 'WebFetch'],
258
- extra: 'Bash', extraWhenBashAllowed: false, brackets: false,
310
+ key: 'disallowedTools', tools: ['Write', 'Edit', 'NotebookEdit'], brackets: false,
311
+ gated: [['WebFetch', WEBFETCH_MARKER, false], ['Bash', BASH_MARKER, false]],
259
312
  },
260
313
  copilot: {
261
- key: 'tools', tools: ['read', 'search', 'todo', 'agent'],
262
- extra: 'execute', extraWhenBashAllowed: true, brackets: true,
314
+ key: 'tools', tools: ['read', 'search', 'todo', 'agent'], brackets: true,
315
+ gated: [['fetch', WEBFETCH_MARKER, true], ['execute', BASH_MARKER, true]],
263
316
  },
264
317
  };
265
318
 
@@ -269,11 +322,11 @@ function claudeCopilotAgentFrontmatter(host, stem, text, overrides) {
269
322
  const ov = agentOverride(overrides, stem);
270
323
  if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
271
324
  if (isReadonly(text)) {
272
- const {
273
- key, tools, extra, extraWhenBashAllowed, brackets,
274
- } = READONLY_TOOLS[host];
325
+ const { key, tools, brackets, gated } = READONLY_TOOLS[host];
275
326
  const list = [...tools];
276
- if (text.includes('<!-- bash: allow -->') === extraWhenBashAllowed) list.push(extra);
327
+ for (const [tool, marker, listedWhenMarked] of gated) {
328
+ if (text.includes(marker) === listedWhenMarked) list.push(tool);
329
+ }
277
330
  fm.push(`${key}: ${brackets ? `[${list.join(', ')}]` : list.join(', ')}`);
278
331
  }
279
332
  return fm;
@@ -294,8 +347,9 @@ function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
294
347
  fm.push(`color: ${agentColor(stem, theme)}`);
295
348
  pushOverrideLines(fm, agentOverride(overrides, stem));
296
349
  if (isReadonly(text)) {
297
- fm.push('permission:', ' edit: deny', ' webfetch: deny');
298
- if (text.includes('<!-- bash: allow -->')) fm.push(' bash:', ' "*": ask');
350
+ fm.push('permission:', ' edit: deny',
351
+ ` webfetch: ${text.includes(WEBFETCH_MARKER) ? 'allow' : 'deny'}`);
352
+ if (text.includes(BASH_MARKER)) fm.push(' bash:', ' "*": ask');
299
353
  else fm.push(' bash: deny');
300
354
  }
301
355
  return fm;
@@ -316,8 +370,8 @@ export function loadAgentOverrides(base) {
316
370
  // than reproduced — matching it means detecting a decode error Node does not report.
317
371
  return {};
318
372
  }
319
- const agents = isPlainObject(data) ? data.agents : null;
320
- return isPlainObject(agents) ? agents : {};
373
+ const agents = isDict(data) ? data.agents : null;
374
+ return isDict(agents) ? agents : {};
321
375
  }
322
376
 
323
377
  /** `Path.relative_to(base).as_posix()`. */
@@ -403,6 +457,12 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
403
457
  if (targetDir === undefined) continue;
404
458
 
405
459
  if (fname.startsWith('_')) {
460
+ // NOT the agent template: every host loads each `.md` in its agents dir as an agent,
461
+ // so shipping it there registered a phantom `_template` agent, and its authoring steps
462
+ // (theme tokens, README badge) are the source repo's, not an install's. Nothing in an
463
+ // installed harness points at it; the skill template, which AGENT.md and skill-forge
464
+ // do point at, still ships.
465
+ if (folder === 'agents') continue;
406
466
  // Authoring templates are shipped verbatim and FLAT — not wrapped as a native
407
467
  // skill — so an author following the `_template.md` note has the scaffold on disk.
408
468
  const dest = path.join(targetDir, fname);
@@ -435,7 +495,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
435
495
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
436
496
  // The user-only key is emitted for every host too — Claude Code and Bob honour it,
437
497
  // OpenCode and Copilot ignore an unknown key — so the identity holds.
438
- fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
498
+ fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
439
499
  if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
440
500
  body = stripSkillBodyLinks(body);
441
501
  dest = path.join(skillsDir, stem, 'SKILL.md');
@@ -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. */