geneseed 3.9.0 → 3.11.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 (219) hide show
  1. package/CHANGELOG.md +89 -1
  2. package/DESIGN.md +1 -1
  3. package/README.md +52 -71
  4. package/SHIPPED.md +3 -3
  5. package/adapters/bob/README.md +18 -2
  6. package/adapters/claude-code/README.md +17 -12
  7. package/adapters/openclaude/README.md +2 -2
  8. package/adapters/opencode/README.md +4 -4
  9. package/adapters/opencode/plugins/geneseed-context.js +78 -8
  10. package/bin/build-driver.mjs +2 -0
  11. package/bin/geneseed-cli.mjs +17 -0
  12. package/bin/geneseed-hook.mjs +3 -1
  13. package/docs/README.md +42 -0
  14. package/docs/_groups.json +18 -0
  15. package/docs/candidate-hosts.md +12 -5
  16. package/docs/concepts/agents.md +44 -0
  17. package/docs/concepts/collaboration.md +62 -0
  18. package/docs/concepts/exclusions.md +55 -0
  19. package/docs/concepts/footprint.md +52 -0
  20. package/docs/concepts/foreman-mode.md +69 -0
  21. package/docs/concepts/hooks.md +101 -0
  22. package/docs/concepts/hosts.md +55 -0
  23. package/docs/concepts/lsp.md +50 -0
  24. package/docs/{web → concepts}/memory.md +2 -2
  25. package/docs/{web → concepts}/notebook.md +2 -2
  26. package/docs/concepts/rules.md +52 -0
  27. package/docs/concepts/skills.md +49 -0
  28. package/docs/concepts/themes.md +50 -0
  29. package/docs/extending.md +27 -21
  30. package/docs/guides/autostart.md +70 -0
  31. package/docs/guides/choose-your-setup.md +120 -0
  32. package/docs/guides/headless.md +22 -0
  33. package/docs/guides/install.md +161 -0
  34. package/docs/guides/mcp.md +195 -0
  35. package/docs/guides/migrate.md +41 -0
  36. package/docs/guides/project-context.md +87 -0
  37. package/docs/guides/run-anywhere.md +45 -0
  38. package/docs/guides/uninstall.md +51 -0
  39. package/docs/guides/upgrade.md +65 -0
  40. package/docs/guides/verify.md +65 -0
  41. package/docs/guides/wiki.md +69 -0
  42. package/docs/guides/worktree.md +47 -0
  43. package/docs/reference/about.md +7 -0
  44. package/docs/reference/cli.md +7 -0
  45. package/docs/reference/environment.md +99 -0
  46. package/docs/reference/glossary.md +33 -0
  47. package/docs/reference/opencode-plugins.md +131 -0
  48. package/docs/reference/troubleshooting.md +350 -0
  49. package/docs/token-footprint.md +26 -21
  50. package/docs/understand/a-day.md +77 -0
  51. package/docs/understand/enforced-vs-asked.md +64 -0
  52. package/docs/understand/harness.md +36 -0
  53. package/docs/understand/on-your-machine.md +76 -0
  54. package/docs/web-ui.md +2 -2
  55. package/harness.config.json +1 -1
  56. package/js/README.md +7 -6
  57. package/js/build/driver.mjs +26 -95
  58. package/js/build/emit-claude.mjs +76 -81
  59. package/js/build/generate.mjs +12 -2
  60. package/js/build/render.mjs +4 -0
  61. package/js/build/source.mjs +6 -0
  62. package/js/build/stubs.mjs +18 -2
  63. package/js/cli-table.json +6 -6
  64. package/js/hosts/hooks.mjs +86 -54
  65. package/js/hosts/hosts.mjs +5 -21
  66. package/js/hosts/installs.mjs +4 -6
  67. package/js/hosts/mcp.mjs +4 -14
  68. package/js/hosts/native.mjs +27 -50
  69. package/js/hosts/opencode.mjs +1 -1
  70. package/js/hosts/settings.mjs +17 -79
  71. package/js/inspect/checks-authoring.mjs +9 -4
  72. package/js/inspect/checks-repo.mjs +45 -1
  73. package/js/inspect/diff.mjs +11 -3
  74. package/js/inspect/doctor.mjs +4 -4
  75. package/js/inspect/excludes.mjs +0 -4
  76. package/js/inspect/inventory.mjs +1 -1
  77. package/js/inspect/registry.mjs +20 -3
  78. package/js/inspect/status.mjs +11 -6
  79. package/js/lib/node-floor.mjs +30 -0
  80. package/js/maintain/migrate.mjs +5 -5
  81. package/js/maintain/setup.mjs +112 -26
  82. package/js/maintain/uninstall.mjs +8 -42
  83. package/js/maintain/update.mjs +4 -15
  84. package/js/ui/cli.mjs +2 -2
  85. package/js/web/actions.mjs +4 -5
  86. package/js/web/api.mjs +7 -4
  87. package/js/web/daemon.mjs +52 -0
  88. package/js/web/docs.mjs +149 -65
  89. package/js/web/server.mjs +5 -2
  90. package/package.json +6 -5
  91. package/registry.json +7 -0
  92. package/src/AGENT.md.tmpl +73 -52
  93. package/src/laws/universal.md +3 -0
  94. package/src/ontology/universal.md +1 -1
  95. package/src/postures/mentor.md +1 -0
  96. package/src/skills/daydream/SKILL.md +10 -3
  97. package/src/skills/daydream/VENDOR.md +10 -2
  98. package/src/skills/daydream/instructions.md +2 -2
  99. package/src/skills/explain-changes/SKILL.md +1 -1
  100. package/src/skills/explain-changes/VENDOR.md +1 -1
  101. package/src/skills/geneseed.md +1 -1
  102. package/src/skills/learn-mode.md +89 -0
  103. package/src/skills/pipeline.md +2 -2
  104. package/src/skills/quiz.md +2 -1
  105. package/src/skills/react-view-transitions/SKILL.md +1 -1
  106. package/src/skills/react-view-transitions/VENDOR.md +7 -2
  107. package/src/skills/teach.md +2 -1
  108. package/src/skills/token-report/SKILL.md +2 -3
  109. package/src/skills/token-report/VENDOR.md +2 -3
  110. package/src/skills/token-report/scripts/token_report.mjs +7 -121
  111. package/src/skills/workflow.md +1 -1
  112. package/themes/_TEMPLATE.json +1 -0
  113. package/themes/biker.json +1 -0
  114. package/themes/commentator.json +1 -0
  115. package/themes/cyberpunk.json +1 -0
  116. package/themes/gamer.json +1 -0
  117. package/themes/imperial.json +1 -0
  118. package/themes/joker.json +1 -0
  119. package/themes/marvin.json +1 -0
  120. package/themes/mean.json +1 -0
  121. package/themes/military.json +1 -0
  122. package/themes/neutral.json +1 -0
  123. package/themes/pirate.json +1 -0
  124. package/themes/sports.json +1 -0
  125. package/themes/verstappen.json +1 -0
  126. package/themes/wizard.json +1 -0
  127. package/web/dist/assets/Activity-C52LEb-2.js +1 -0
  128. package/web/dist/assets/ActivityDetail-CjNLRvXg.js +1 -0
  129. package/web/dist/assets/Dashboard-B6_sSCHi.js +2 -0
  130. package/web/dist/assets/Docs-Dj7BmCSA.js +5 -0
  131. package/web/dist/assets/{GreenhouseView-Bq1QmaGO.js → GreenhouseView-BALwjQsA.js} +1 -1
  132. package/web/dist/assets/Installs-BbffDQm3.js +2 -0
  133. package/web/dist/assets/JournalView-JpAQ8Hqe.js +1 -0
  134. package/web/dist/assets/Laws-DK7eCWSm.js +3 -0
  135. package/web/dist/assets/Library-CoamHelE.js +1 -0
  136. package/web/dist/assets/{Markdown-CkFyfgNf.js → Markdown-qjM2_ofS.js} +5 -5
  137. package/web/dist/assets/{OperatorHudView-B-HVBqfY.js → OperatorHudView-DVx1HlBx.js} +1 -1
  138. package/web/dist/assets/Personal-eaXb1zcj.js +1 -0
  139. package/web/dist/assets/{Tabs-_pjUFIEI.js → Tabs-BmGtt8E2.js} +1 -1
  140. package/web/dist/assets/api-Dc8f3ca-.js +1 -0
  141. package/web/dist/assets/drawable-Du_PZrQj.js +1 -0
  142. package/web/dist/assets/index-Bz_KMRDH.css +1 -0
  143. package/web/dist/assets/index-CNFH7o55.js +2 -0
  144. package/web/dist/index.html +3 -2
  145. package/QUICKSTART.md +0 -50
  146. package/SETUP.md +0 -1183
  147. package/adapters/copilot/README.md +0 -105
  148. package/docs/web/_groups.json +0 -45
  149. package/docs/web/about.md +0 -6
  150. package/docs/web/adapters-claude-code.md +0 -8
  151. package/docs/web/adapters-opencode-loads.md +0 -8
  152. package/docs/web/adapters-opencode-spec.md +0 -8
  153. package/docs/web/adapters-opencode.md +0 -8
  154. package/docs/web/agents.md +0 -8
  155. package/docs/web/authoring.md +0 -11
  156. package/docs/web/autostart.md +0 -9
  157. package/docs/web/claude-hooks.md +0 -37
  158. package/docs/web/cli.md +0 -6
  159. package/docs/web/collaboration.md +0 -27
  160. package/docs/web/configure-wiki.md +0 -9
  161. package/docs/web/design.md +0 -7
  162. package/docs/web/env-knobs.md +0 -9
  163. package/docs/web/exclusions.md +0 -27
  164. package/docs/web/first-session.md +0 -28
  165. package/docs/web/footprint.md +0 -38
  166. package/docs/web/foreman-mode.md +0 -33
  167. package/docs/web/glossary.md +0 -6
  168. package/docs/web/harnesses.md +0 -30
  169. package/docs/web/headless.md +0 -9
  170. package/docs/web/ingest-docs.md +0 -9
  171. package/docs/web/install-paths.md +0 -9
  172. package/docs/web/install-quick.md +0 -35
  173. package/docs/web/lsp-overview.md +0 -37
  174. package/docs/web/mcp-claude-code.md +0 -10
  175. package/docs/web/mcp-filesystem.md +0 -9
  176. package/docs/web/mcp-gitlab.md +0 -9
  177. package/docs/web/mcp-markitdown.md +0 -9
  178. package/docs/web/mcp-overview.md +0 -34
  179. package/docs/web/mcp-trouble.md +0 -9
  180. package/docs/web/mcp-verify.md +0 -9
  181. package/docs/web/migrate.md +0 -39
  182. package/docs/web/model.md +0 -20
  183. package/docs/web/pipelines.md +0 -24
  184. package/docs/web/plugin-activity.md +0 -23
  185. package/docs/web/plugin-context.md +0 -26
  186. package/docs/web/plugin-guard.md +0 -24
  187. package/docs/web/plugin-learn.md +0 -27
  188. package/docs/web/plugin-notify.md +0 -24
  189. package/docs/web/plugin-ponytail.md +0 -23
  190. package/docs/web/plugin-setup.md +0 -7
  191. package/docs/web/plugin-workflow.md +0 -25
  192. package/docs/web/plugins.md +0 -17
  193. package/docs/web/project-context.md +0 -9
  194. package/docs/web/rules.md +0 -30
  195. package/docs/web/run-anywhere.md +0 -9
  196. package/docs/web/self-improve.md +0 -8
  197. package/docs/web/setup-choices.md +0 -64
  198. package/docs/web/skills.md +0 -8
  199. package/docs/web/themes-author.md +0 -20
  200. package/docs/web/themes.md +0 -9
  201. package/docs/web/token-footprint.md +0 -7
  202. package/docs/web/trouble.md +0 -38
  203. package/docs/web/upgrade.md +0 -9
  204. package/docs/web/verify.md +0 -30
  205. package/docs/web/wiki.md +0 -7
  206. package/docs/web/worktree.md +0 -10
  207. package/docs/wiki.md +0 -43
  208. package/web/dist/assets/Activity-DAzV-qQj.js +0 -1
  209. package/web/dist/assets/ActivityDetail-CwEwMtKl.js +0 -1
  210. package/web/dist/assets/Dashboard-C2ELBW-X.js +0 -2
  211. package/web/dist/assets/Docs-iZrvQP8J.js +0 -2
  212. package/web/dist/assets/Installs-BiqR8xdO.js +0 -2
  213. package/web/dist/assets/JournalView-6wilG5Zd.js +0 -1
  214. package/web/dist/assets/Laws-D8YXD_XE.js +0 -3
  215. package/web/dist/assets/Library-DwuTZ2-L.js +0 -1
  216. package/web/dist/assets/Personal-BbUTmZQR.js +0 -1
  217. package/web/dist/assets/drawable-CoIulEH2.js +0 -1
  218. package/web/dist/assets/index-BMwSp8BP.js +0 -2
  219. package/web/dist/assets/index-CDPDk2vj.css +0 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The five host config dirs, and the two path primitives they are built on.
2
+ * The four host config dirs, and the two path primitives they are built on.
3
3
  *
4
4
  * EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/build-driver.mjs`
5
5
  * owned these; `bin/geneseed-cli.mjs` needs the same four to find a global install, and a
@@ -172,20 +172,6 @@ export function opencodeConfigDir() {
172
172
  return resolvePath(path.join(base, 'opencode'));
173
173
  }
174
174
 
175
- /**
176
- * `_build_core._copilot_config_dir` — `~/.copilot`, relocatable via `$COPILOT_CONFIG_DIR`.
177
- *
178
- * Geneseed's own knob, mirroring `$BOB_CONFIG_DIR`: Copilot documents no such variable, but
179
- * tests, doctor and locked-down setups still need to re-point the target. Like every
180
- * relocation variable it is CLEARED by `golden.cell_env`, so no cell can observe whether
181
- * a driver honours it — see `test_the_relocation_var_moves_the_global_target`.
182
- */
183
- export function copilotConfigDir() {
184
- const env = process.env.COPILOT_CONFIG_DIR;
185
- if (env) return resolvePath(env);
186
- return resolvePath(path.join(os.homedir(), '.copilot'));
187
- }
188
-
189
175
  /**
190
176
  * `_build_core._claude_config_dir` — `~/.claude`, and there is NO env branch BY DESIGN.
191
177
  *
@@ -223,7 +209,7 @@ export function openclaudeConfigDir() {
223
209
 
224
210
  /**
225
211
  * `_build_global.HOSTS`, reduced to the two columns a non-emitting caller needs, and IN ITS
226
- * ORDER — opencode, claude, bob, copilot, openclaude.
212
+ * ORDER — opencode, claude, bob, openclaude.
227
213
  *
228
214
  * An array rather than an object because the order is observable output, not an
229
215
  * implementation detail: `harness exclude add` walks it and prints one message per host, so
@@ -233,21 +219,20 @@ export function openclaudeConfigDir() {
233
219
  *
234
220
  * `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
235
221
  * install of host H", and `_claude_cfg` asks "which subdir holds a project install's
236
- * manifest" — both are the same `.opencode`/`.claude`/`.bob`/`.github` value the Python reads
222
+ * manifest" — both are the same `.opencode`/`.claude`/`.bob`/`.openclaude` value the Python reads
237
223
  * out of `build.HOSTS[host]["project_marker"]`, so it belongs beside the config dir rather
238
224
  * than in a second table in `js/hosts/installs.mjs`.
239
225
  *
240
226
  * `agentFile` joined in P5h, and for one caller: `cmd_uninstall` names the managed block's
241
227
  * carrier in its "removes:" preamble (`the CLAUDE.md managed block`, `the AGENTS.md managed
242
228
  * block`). It is a column and not a literal in the message for the reason every other column
243
- * here is one — Bob and Copilot both answer `AGENTS.md` while Claude answers `CLAUDE.md`, and
229
+ * here is one — Bob answers `AGENTS.md` while Claude answers `CLAUDE.md`, and
244
230
  * a host added later must not need the message edited to stay true.
245
231
  */
246
232
  export const HOSTS = [
247
233
  { host: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
248
234
  { host: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
249
235
  { host: 'bob', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
250
- { host: 'copilot', configDir: copilotConfigDir, projectMarker: '.github', agentFile: 'AGENTS.md' },
251
236
  { host: 'openclaude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
252
237
  ];
253
238
 
@@ -271,14 +256,13 @@ export const HOSTS = [
271
256
  * single boolean was wrong both ways: `false` shipped the §4 Skills table on top of a
272
257
  * catalogue Bob already had (~1.7k tokens twice), `true` would have stripped the §3 Agents
273
258
  * table that is Bob's ONLY agent catalogue. Each CATALOG block in AGENT.md names its kind and
274
- * resolves against its own flag. Copilot stays unverified and keeps both tables. Mirrored in
259
+ * resolves against its own flag. Mirrored in
275
260
  * `bin/build-driver.mjs` (which cannot import this module's spawning neighbours).
276
261
  */
277
262
  const NATIVE_CATALOG = {
278
263
  opencode: { skills: true, agents: true },
279
264
  claude: { skills: true, agents: true },
280
265
  bob: { skills: true, agents: false },
281
- copilot: { skills: false, agents: false },
282
266
  openclaude: { skills: true, agents: true },
283
267
  };
284
268
 
@@ -137,15 +137,14 @@ function themeFromAgent(agentMd) {
137
137
  }
138
138
 
139
139
  /**
140
- * `_harness_setup.CARRIERS` — the five instruction carriers, in the Python's order.
140
+ * `_harness_setup.CARRIERS` — the four instruction carriers, in the Python's order.
141
141
  *
142
142
  * A shared table rather than three copies of the same list: `_theme_of_dir`,
143
143
  * `_posture_of_dir` and `_mode_of_dir` each walk it, and the order is observable (the first
144
144
  * carrier that answers wins). `rules/geneseed.md` is spelled with `path.join` at each use
145
145
  * because the Python writes `d / "rules" / "geneseed.md"` for that one entry.
146
146
  */
147
- const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'),
148
- 'copilot-instructions.md', 'AGENTS.md'];
147
+ const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'), 'AGENTS.md'];
149
148
 
150
149
  /**
151
150
  * The scan `themeOfDir`, `leadOfDir`, `doctrinesOfDir` and `excludedRulesOfDir` each wrote
@@ -392,7 +391,6 @@ export const EMIT_HOST_SCOPE = new Map([
392
391
  ['opencode', ['opencode', 'project']], ['opencode-global', ['opencode', 'global']],
393
392
  ['claude', ['claude', 'project']], ['claude-global', ['claude', 'global']],
394
393
  ['bob', ['bob', 'project']], ['bob-global', ['bob', 'global']],
395
- ['copilot', ['copilot', 'project']], ['copilot-global', ['copilot', 'global']],
396
394
  ['openclaude', ['openclaude', 'project']], ['openclaude-global', ['openclaude', 'global']],
397
395
  ]);
398
396
 
@@ -410,7 +408,7 @@ export const DISABLED_STASH = '.geneseed-disabled';
410
408
  * `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
411
409
  *
412
410
  * The config dir itself for a global install; `<repo>/<project_marker>` for a project one.
413
- * Host-aware so Bob and Copilot ride the whole Claude lifecycle with only the subdir
411
+ * Host-aware so Bob and OpenClaude ride the whole Claude lifecycle with only the subdir
414
412
  * differing, and the marker comes from `HOSTS` rather than a literal for that reason.
415
413
  */
416
414
  export function claudeCfg(root, scope, host = 'claude') {
@@ -435,7 +433,7 @@ export function installKind(root) {
435
433
 
436
434
  /** `_harness_mcp._install_state` — 'active' | 'disabled' | 'absent'. */
437
435
  export function installState(root, host = 'opencode', scope = 'global') {
438
- if (['claude', 'bob', 'copilot', 'openclaude'].includes(host)) return claudeState(root, scope, host);
436
+ if (['claude', 'bob', 'openclaude'].includes(host)) return claudeState(root, scope, host);
439
437
  if (isDir(path.join(root, DISABLED_STASH))) return 'disabled';
440
438
  return installKind(root) !== null ? 'active' : 'absent';
441
439
  }
package/js/hosts/mcp.mjs CHANGED
@@ -32,7 +32,7 @@ import { existsSync, mkdirSync, renameSync } from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
34
 
35
- import { bobConfigDir, copilotConfigDir, resolvePath } from './hosts.mjs';
35
+ import { bobConfigDir, resolvePath } from './hosts.mjs';
36
36
  import { installState, installTargets } from './installs.mjs';
37
37
  import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
38
38
  import { isDict, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
@@ -44,7 +44,7 @@ import { opencodeTarget, readJsonc } from './settings.mjs';
44
44
  const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
45
45
 
46
46
  /** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
47
- const FLAGLESS = ['claude', 'bob', 'copilot', 'openclaude'];
47
+ const FLAGLESS = ['claude', 'bob', 'openclaude'];
48
48
 
49
49
  /**
50
50
  * `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
@@ -68,7 +68,7 @@ export const MCP_PRESETS = {
68
68
  + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
69
69
  + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
70
70
  + 'copy this block by hand under a new name and point it at the other URL — '
71
- + 'SETUP.md has the pattern.',
71
+ + 'docs/guides/mcp.md has the pattern.',
72
72
  block: {
73
73
  type: 'local',
74
74
  command: ['npx', '-y', '@zereight/mcp-gitlab'],
@@ -105,8 +105,7 @@ const serversOf = (config, host) => {
105
105
  * `_mcp_preset_block` — the block to WRITE for a preset, shaped for `host`.
106
106
  *
107
107
  * OpenCode takes it verbatim. The flagless three split the command list into `command`
108
- * (head) + `args` (tail) and rename `environment` to `env`; Copilot additionally requires
109
- * `type` and a `tools` allowlist.
108
+ * (head) + `args` (tail) and rename `environment` to `env`.
110
109
  */
111
110
  export function mcpPresetBlock(name, host = 'opencode') {
112
111
  const block = { ...MCP_PRESETS[name].block };
@@ -115,10 +114,6 @@ export function mcpPresetBlock(name, host = 'opencode') {
115
114
  const out = { command: cmd.length ? cmd[0] : '', args: cmd.slice(1) };
116
115
  const env = block.environment;
117
116
  if (env) out.env = { ...env };
118
- if (host === 'copilot') {
119
- out.type = 'local';
120
- out.tools = ['*'];
121
- }
122
117
  return out;
123
118
  }
124
119
 
@@ -243,11 +238,6 @@ export function mcpConfigFor(host, scope, root) {
243
238
  return scope === 'project' ? path.join(root, '.bob', 'mcp.json')
244
239
  : path.join(bobConfigDir(), 'settings', 'mcp.json');
245
240
  }
246
- if (host === 'copilot') {
247
- // The Copilot CLI reads MCP servers from ~/.copilot/mcp-config.json only — no per-repo
248
- // file is documented, so a project install carries no MCP wiring.
249
- return scope === 'global' ? path.join(copilotConfigDir(), 'mcp-config.json') : null;
250
- }
251
241
  if (host === 'openclaude') {
252
242
  // Claude's `.mcp.json` per repo; globally `.openclaude.json` beside the home dir, or
253
243
  // INSIDE `$OPENCLAUDE_CONFIG_DIR` when set — OpenClaude's own `getGlobalClaudeFile` rule.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A faithful translation of `_build_emit._write_native_layer` and the frontmatter
5
5
  * builders it dispatches to. Capability specs become host-native subagents and skills:
6
- * one dialect per host (OpenCode / Claude Code / Copilot), vendored skill folders copied
6
+ * one dialect per host (OpenCode / Claude Code), vendored skill folders copied
7
7
  * through verbatim, authoring templates shipped flat.
8
8
  *
9
9
  * WHY THIS IS THE HARD HALF. `js/build/render.mjs` is a pure function of `src/` and `themes/`;
@@ -33,6 +33,9 @@ import path from 'node:path';
33
33
  import { writeText, copyFile, readText } from '../lib/fs.mjs';
34
34
  import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
35
35
 
36
+ /** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
37
+ export const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
38
+
36
39
  /** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
37
40
  export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report', 'explain-changes']);
38
41
 
@@ -58,7 +61,7 @@ export function isVendoredPath(rel) {
58
61
  * `build._validate_is_vendored` — the same question, tolerant of `skills` at ANY depth.
59
62
  *
60
63
  * The per-repo native layers nest one level deeper than a `files`/opencode-global bundle
61
- * (`.claude/skills/<name>/…`, `.bob/skills/<name>/…`, `.github/skills/<name>/…`), so
64
+ * (`.claude/skills/<name>/…`, `.bob/skills/<name>/…`, `.openclaude/skills/<name>/…`), so
62
65
  * `doctor`'s scan of those trees needs the loose form or every vendored folder's own
63
66
  * upstream cross-links read as dead links. Never the last segment — `parts[:-1]`, because a
64
67
  * FILE named `skills` is not a directory of them.
@@ -79,8 +82,6 @@ const AGENT_COLORS = {
79
82
  const VALID_AGENT_COLOR_SLOTS = new Set(
80
83
  ['primary', 'secondary', 'accent', 'success', 'warning', 'error', 'info']);
81
84
 
82
- /** `_build_emit._SIBLING_AGENT_LINK_RE` — a bare same-dir agent filename in a link. */
83
- const SIBLING_AGENT_LINK_RE = /\]\(([A-Za-z0-9_-]+)\.md\)/g;
84
85
 
85
86
  /**
86
87
  * `_build_emit._strip_skill_body_links`'s pattern: every RELATIVE markdown link to a
@@ -296,51 +297,23 @@ const BASH_MARKER = '<!-- bash: allow -->';
296
297
  const WEBFETCH_MARKER = '<!-- webfetch: allow -->';
297
298
 
298
299
  /**
299
- * The readonly-tool line `claudeAgentFrontmatter`/`copilotAgentFrontmatter` each emit — a
300
- * DENYLIST for Claude, an ALLOWLIST for Copilot — differing only in the key, the base list,
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.
300
+ * `_build_emit._claude_agent_frontmatter`. A read-only spec gets a `disallowedTools` DENYLIST;
301
+ * each opt-in marker takes its tool back off the list. Order is emit order: WebFetch before
302
+ * Bash keeps the unmarked line byte-identical to what it was before the webfetch marker existed.
307
303
  */
308
- const READONLY_TOOLS = {
309
- claude: {
310
- key: 'disallowedTools', tools: ['Write', 'Edit', 'NotebookEdit'], brackets: false,
311
- gated: [['WebFetch', WEBFETCH_MARKER, false], ['Bash', BASH_MARKER, false]],
312
- },
313
- copilot: {
314
- key: 'tools', tools: ['read', 'search', 'todo', 'agent'], brackets: true,
315
- gated: [['fetch', WEBFETCH_MARKER, true], ['execute', BASH_MARKER, true]],
316
- },
317
- };
318
-
319
- /** `_build_emit._claude_agent_frontmatter` / `._copilot_agent_frontmatter`. */
320
- function claudeCopilotAgentFrontmatter(host, stem, text, overrides) {
304
+ function claudeAgentFrontmatter(stem, text, overrides) {
321
305
  const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
322
306
  const ov = agentOverride(overrides, stem);
323
307
  if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
324
308
  if (isReadonly(text)) {
325
- const { key, tools, brackets, gated } = READONLY_TOOLS[host];
326
- const list = [...tools];
327
- for (const [tool, marker, listedWhenMarked] of gated) {
328
- if (text.includes(marker) === listedWhenMarked) list.push(tool);
329
- }
330
- fm.push(`${key}: ${brackets ? `[${list.join(', ')}]` : list.join(', ')}`);
309
+ const list = ['Write', 'Edit', 'NotebookEdit'];
310
+ if (!text.includes(WEBFETCH_MARKER)) list.push('WebFetch');
311
+ if (!text.includes(BASH_MARKER)) list.push('Bash');
312
+ fm.push(`disallowedTools: ${list.join(', ')}`);
331
313
  }
332
314
  return fm;
333
315
  }
334
316
 
335
- function claudeAgentFrontmatter(stem, text, overrides) {
336
- return claudeCopilotAgentFrontmatter('claude', stem, text, overrides);
337
- }
338
-
339
- /** `tools:` is an ALLOWLIST, not a denylist. */
340
- function copilotAgentFrontmatter(stem, text, overrides) {
341
- return claudeCopilotAgentFrontmatter('copilot', stem, text, overrides);
342
- }
343
-
344
317
  /** `_build_emit._opencode_agent_frontmatter`. */
345
318
  function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
346
319
  const fm = [`description: ${jsonDumps(descOf(text))}`, 'mode: subagent'];
@@ -387,6 +360,14 @@ function relPosix(base, target) {
387
360
  * source root it is relativised against (Python reads `_build_core.SRC` directly, which
388
361
  * is the module-level single owner; here it travels explicitly, as in `js/build/render.mjs`).
389
362
  *
363
+ * `opts.skillDirOf(name)` — set for a host that hands the model a skill's TEXT but not where
364
+ * the skill lives (Bob). A folder skill names its scripts and prompts
365
+ * `<this-skill-directory>/…`; Claude Code resolves that because it announces each skill's
366
+ * base directory on load, while on Bob the model got the text with a hole in it and went
367
+ * hunting through the filesystem. With the option, every rendered `.md` of a vendored folder
368
+ * has the placeholder replaced by the answer. Only vendored folders carry the placeholder — a
369
+ * flat skill has no siblings to point at.
370
+ *
390
371
  * Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
391
372
  * paths in write order.
392
373
  *
@@ -400,7 +381,7 @@ function relPosix(base, target) {
400
381
  */
401
382
  export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
402
383
  host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
403
- theme = null, src,
384
+ theme = null, src, skillDirOf = null,
404
385
  } = {}) {
405
386
  const ov = overrides || {};
406
387
  const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
@@ -445,8 +426,10 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
445
426
  mkdirSync(path.dirname(dest), { recursive: true });
446
427
  // The same vendored folder mixes both writers: a rendered `.md` goes through
447
428
  // writeText (and picks up CRLF on Windows), a binary through copy2 (and keeps LF).
448
- if (text !== null) writeText(dest, text);
449
- else copyFile(source, dest);
429
+ if (text !== null) {
430
+ writeText(dest, skillDirOf === null ? text
431
+ : text.replaceAll(SKILL_DIR_PLACEHOLDER, skillDirOf(sparts[1])));
432
+ } else copyFile(source, dest);
450
433
  written.push(dest);
451
434
  continue;
452
435
  }
@@ -480,12 +463,6 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
480
463
  if (host === 'claude') {
481
464
  fm = claudeAgentFrontmatter(stem, text, ov);
482
465
  dest = path.join(agentsDir, `${stem}.md`);
483
- } else if (host === 'copilot') {
484
- fm = copilotAgentFrontmatter(stem, text, ov);
485
- dest = path.join(agentsDir, `${stem}.agent.md`);
486
- // The filename change drags the links with it: a sibling `](skeptic.md)` would
487
- // point at a file this dialect never writes.
488
- body = body.replace(SIBLING_AGENT_LINK_RE, ']($1.agent.md)');
489
466
  } else {
490
467
  fm = opencodeAgentFrontmatter(stem, text, ov, theme);
491
468
  dest = path.join(agentsDir, `${stem}.md`);
@@ -494,7 +471,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
494
471
  } else {
495
472
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
496
473
  // The user-only key is emitted for every host too — Claude Code and Bob honour it,
497
- // OpenCode and Copilot ignore an unknown key — so the identity holds.
474
+ // OpenCode ignores an unknown key — so the identity holds.
498
475
  fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
499
476
  if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
500
477
  body = stripSkillBodyLinks(body);
@@ -84,7 +84,7 @@ const AGENT_OVERRIDES_STUB = {
84
84
  _recommended: {
85
85
  _comment: (
86
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"). '
87
+ + 'model id (OpenCode: "anthropic/claude-haiku-4-5"; Claude Code: "haiku"). '
88
88
  + 'Read-and-summarise work — a council convenes several at once, so this is where '
89
89
  + 'the savings compound. Nothing applies until you copy it.'
90
90
  ),
@@ -483,6 +483,18 @@ export function hookShimPath(platform = process.platform) {
483
483
  return path.join(shimHome(), ...shimRel(platform));
484
484
  }
485
485
 
486
+ /**
487
+ * The paths the machine's hook shim names that are gone — `[]` when the shim is absent or live.
488
+ *
489
+ * Cheap on purpose: `status`, the doctor and EVERY CLI run call it (the run-time warning in
490
+ * `bin/geneseed-cli.mjs`), because a dead shim disables every hook of every Claude-shaped
491
+ * install and nothing else would say so. An absent shim is not dead — a checkout that has never
492
+ * emitted owns none — and an unreadable one is reported by the doctor, not here.
493
+ */
494
+ export function shimDead() {
495
+ try { return shimDeadPaths(readText(hookShimPath())); } catch { return []; }
496
+ }
497
+
486
498
  /**
487
499
  * `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
488
500
  *
@@ -649,7 +661,7 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
649
661
  * rule is no longer BINDING — so a gate that stopped every commit to ask for consent to a rule
650
662
  * the install did not adopt is the disagreement the three-tier split forbids. The group stays
651
663
  * and gains `--no-consent`, which skips only the process-5 branch. It once DROPPED the whole
652
- * group instead, and Law IV went with it — on Claude only, since Bob and Copilot carry it
664
+ * group instead, and Law IV went with it — on Claude only, since Bob carries it
653
665
  * through `tool-gate`. The command changing is also what re-wires an existing install:
654
666
  * `mergeClaudeSettings` prunes every previously-managed group that is not in this return
655
667
  * value.
@@ -700,22 +712,6 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
700
712
  Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
701
713
  };
702
714
  }
703
- if (host === 'copilot') {
704
- // COPILOT'S OWN SCHEMA (docs.github.com/en/copilot/reference/hooks-configuration): camelCase
705
- // events, each an ARRAY of `{type: "command", command}` entries — no matcher groups, so the
706
- // array elements ARE the groups this merge appends and prunes. `preToolUse` answers with
707
- // a top-level `permissionDecision` (`--host copilot`), and `agentStop`/`preCompact` carry
708
- // `transcriptPath`, so `learn` runs here as on Claude. No matcher, so `tool-gate` reads
709
- // the payload's shape. Unverified live: no Copilot CLI on the authoring machine.
710
- const c = ' --host copilot';
711
- const entry = (command) => [{ type: 'command', command }];
712
- return {
713
- sessionStart: entry(`${run} context --root "${cfg}"${c} || exit 0`),
714
- preToolUse: entry(`${run} tool-gate --root "${cfg}"${c}`),
715
- agentStop: entry(`${run} learn ${mem} || exit 0`),
716
- preCompact: entry(`${run} learn ${mem} || exit 0`),
717
- };
718
- }
719
715
  // OpenClaude takes Claude's whole group set and verdicts unchanged; only `context` needs
720
716
  // the host, to know which root file OpenClaude already loads by itself.
721
717
  const h = host === 'openclaude' ? ' --host openclaude' : '';
@@ -753,63 +749,6 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
753
749
  };
754
750
  }
755
751
 
756
- /**
757
- * LEGACY: the single-slot Copilot shape (`hooks.<event>` = ONE object, events `sessionStart` /
758
- * `toolCall`) an emit wrote before Copilot's hooks became event → array. Copilot has no
759
- * `toolCall` event any more, so those gates never ran. Nothing writes this shape now; these
760
- * two only take an older install's recorded `copilot_hooks` claims back out.
761
- */
762
- /** Remove exactly the recorded Copilot hooks from `p`. True when the file was rewritten. */
763
- export function unwireCopilotSettings(p, recorded) {
764
- if (!existsSync(p) || !recorded || !recorded.length) return false;
765
- let loaded;
766
- let hadComments;
767
- try {
768
- [loaded, hadComments] = readJsonc(readText(p));
769
- } catch (e) {
770
- if (!isOsError(e)) throw e;
771
- return false;
772
- }
773
- if (hadComments || !isDict(loaded)) return false;
774
- const hooks = get(loaded, 'hooks');
775
- if (!isDict(hooks)) return false;
776
- for (const rec of recorded) {
777
- const event = get(rec, 'event');
778
- if (deepEquals(get(hooks, event), get(rec, 'hook'))) delete hooks[event];
779
- }
780
- if (!Object.keys(hooks).length) delete loaded.hooks;
781
- try {
782
- atomicWriteJson(p, loaded);
783
- } catch (e) {
784
- if (!isOsError(e)) throw e;
785
- return false;
786
- }
787
- return true;
788
- }
789
-
790
- /**
791
- * The Copilot twin of `settingsIntegrityCheck`: every recorded hook present (or absent) in
792
- * the file it was claimed against. Same contract — problems as strings, each also WARNed on
793
- * stderr, empty when clean — so a merge that did not stick is loud at emit time rather than
794
- * a hook that quietly never fires.
795
- */
796
- export function copilotIntegrityCheck(p, recorded, expect = 'present') {
797
- const problems = [];
798
- const hooks = existsSync(p) ? (() => {
799
- try { return get(readJsonc(readText(p))[0], 'hooks'); } catch { return null; }
800
- })() : null;
801
- for (const rec of (recorded || []).filter(isDict)) {
802
- const hit = deepEquals(get(hooks, rec.event), rec.hook);
803
- if (expect === 'present' && !hit) {
804
- problems.push(`${p}: recorded Copilot hook missing — event=${rec.event}`);
805
- } else if (expect === 'absent' && hit) {
806
- problems.push(`${p}: recorded Copilot hook still present after unwire — event=${rec.event}`);
807
- }
808
- }
809
- for (const x of problems) process.stderr.write(`[geneseed] WARN: ${x}\n`);
810
- return problems;
811
- }
812
-
813
752
  /**
814
753
  * `_build_settings._merge_claude_settings` — returns `[target, managed]`.
815
754
  *
@@ -1045,8 +984,8 @@ const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
1045
984
  *
1046
985
  * PURE — and currently reached only END TO END, by the `migrate/a-node-baked-shim-reads-as-current`
1047
986
  * cell. No unit corpus drives it directly, which is worth knowing before trusting it.
1048
- * 'none' is not a fault: a Copilot install wires no hooks, and a user's own
1049
- * settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
987
+ * 'none' is not a fault: a user's own settings.json may carry three hand-written hooks
988
+ * and no Geneseed entry. That must read as
1050
989
  * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
1051
990
  * config on any machine.
1052
991
  */
@@ -1061,7 +1000,7 @@ export function migrateShape(commands, shimBody) {
1061
1000
  /**
1062
1001
  * `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
1063
1002
  *
1064
- * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — `SETUP.md` tells the user to create them
1003
+ * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — the install guide tells the user to create them
1065
1004
  * by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
1066
1005
  * `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
1067
1006
  * already states: an entry the manifest does not claim is "possibly user-authored; left alone".
@@ -1181,8 +1120,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1181
1120
  for (const [event, group] of presentGroups) {
1182
1121
  const key = `${formatRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1183
1122
  if (recordedSet.has(key)) continue;
1184
- // A Claude group nests its commands under `hooks`; a Copilot entry IS the command.
1185
- const cmds = [...(get(group, 'hooks') || []).filter(isDict), group]
1123
+ const cmds = (get(group, 'hooks') || []).filter(isDict)
1186
1124
  .map((h) => (has(h, 'command') ? h.command : ''));
1187
1125
  if (cmds.some((c) => typeof c === 'string'
1188
1126
  && GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import path from 'node:path';
15
15
  import {
16
- CONFIG, PACK_ORDER, PLUGIN_SRC, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles,
16
+ CONFIG, DOCS, DOC_FOLDERS, PACK_ORDER, PLUGIN_SRC, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles,
17
17
  knownRuleIds, ruleCanon, titleKey,
18
18
  } from '../build/source.mjs';
19
19
  import { themeFiles } from '../hosts/installs.mjs';
@@ -22,7 +22,9 @@ import { readText } from '../lib/fs.mjs';
22
22
  import { parseJson, formatRepr } from '../lib/json.mjs';
23
23
  import { which } from '../lib/paths.mjs';
24
24
  import { NO_WINDOW } from '../lib/proc.mjs';
25
- import { registryProblems, secretProblems, vendorPinProblems } from './checks-repo.mjs';
25
+ import {
26
+ folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
27
+ } from './checks-repo.mjs';
26
28
  import { LAW_CLASS, LAW_CLASSES, SKILL_CLASS } from './inventory.mjs';
27
29
  import { NOTE, globSorted, has, isDir, isFile, rglob, srcStems } from './scan.mjs';
28
30
  import { spawnSync } from 'node:child_process';
@@ -761,7 +763,7 @@ export function countTableProblems() {
761
763
 
762
764
  // WHERE THE ONBOARDING COPY LIVES, AND WHY THIS READ MOVED. It used to open the one module
763
765
  // that held the web console's onboarding prose. That prose has since moved into
764
- // `docs/web/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
766
+ // `docs/<folder>/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
765
767
  // — so the read was still succeeding against a file the sentences had left, all three arms
766
768
  // below scored zero, and the check looked healthy while gating nothing. A templated count
767
769
  // cannot drift; what these arms still catch is a maintainer typing the NUMBER into a page
@@ -771,7 +773,9 @@ export function countTableProblems() {
771
773
  // byte moves. `web` stays fail-soft — a missing docs tree is not an authoring fault.
772
774
  let web = '';
773
775
  try {
774
- web = globSorted(path.join(ROOT, 'docs', 'web'), (n) => n.endsWith('.md'))
776
+ web = DOC_FOLDERS
777
+ .flatMap((f) => (isDir(path.join(DOCS, f))
778
+ ? globSorted(path.join(DOCS, f), (n) => n.endsWith('.md')) : []))
775
779
  .map(readText).join('\n');
776
780
  } catch { web = ''; }
777
781
  // ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing `docs/web`
@@ -901,6 +905,7 @@ export function authoringProblems() {
901
905
  problems.push(...registryProblems());
902
906
  problems.push(...secretProblems());
903
907
  problems.push(...vendorPinProblems());
908
+ problems.push(...folderSkillProblems());
904
909
  problems.push(...constitutionProblems());
905
910
  problems.push(...leanBlockProblems());
906
911
  problems.push(...countTableProblems());
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The checks over the REPO's own records — the registry, committed secrets, the hook shim
3
- * and the vendored-skill pins.
3
+ * and the vendored skill folders (their pins and their SKILL.md frontmatter).
4
4
  *
5
5
  * Same contract as `checks-build.mjs`: an array of problem strings, empty when clean, one
6
6
  * planted fault per check in the gate. This is the group that reads what the repo SAYS about
@@ -240,6 +240,50 @@ export function vendorPinProblems() {
240
240
  return problems;
241
241
  }
242
242
 
243
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
244
+
245
+ /**
246
+ * Every vendored skill folder's `SKILL.md` opens with a frontmatter whose `name` IS the
247
+ * folder and which carries a `description`.
248
+ *
249
+ * A flat skill gets that frontmatter from `writeNativeLayer`; a folder is copied through as
250
+ * written, so its own SKILL.md is all a host has to register it from. Two shipped broken:
251
+ * daydream had no frontmatter at all and react-view-transitions named itself after its
252
+ * upstream repo — the Agent Skills format requires the name to match the folder. Claude Code
253
+ * tolerated both, so nothing showed; a stricter host is free not to register them. Only the
254
+ * two keys every host reads are checked, at the top level of the block (an indented
255
+ * `name:` under `metadata:` does not count).
256
+ */
257
+ export function folderSkillProblems() {
258
+ const problems = [];
259
+ for (const name of VENDORED_SKILL_DIRS) {
260
+ const folder = path.join(SRC, 'skills', name);
261
+ if (!isDir(folder)) continue; // vendorPinProblems reports the missing folder
262
+ let text;
263
+ try { text = readText(path.join(folder, 'SKILL.md')); } catch {
264
+ problems.push(`[authoring] skills/${name}/ has no SKILL.md — no host can load it`);
265
+ continue;
266
+ }
267
+ const fm = FRONTMATTER_RE.exec(text);
268
+ if (!fm) {
269
+ problems.push(`[authoring] skills/${name}/SKILL.md opens with no frontmatter — a host `
270
+ + 'registers a skill from its name and description');
271
+ continue;
272
+ }
273
+ const declared = /^name:[ \t]*["']?([^"'\r\n]*?)["']?[ \t]*$/m.exec(fm[1]);
274
+ if (!declared) {
275
+ problems.push(`[authoring] skills/${name}/SKILL.md frontmatter has no 'name'`);
276
+ } else if (declared[1] !== name) {
277
+ problems.push(`[authoring] skills/${name}/SKILL.md is named '${declared[1]}' — the name `
278
+ + 'must match the folder');
279
+ }
280
+ if (!/^description:[ \t]*\S/m.test(fm[1])) {
281
+ problems.push(`[authoring] skills/${name}/SKILL.md frontmatter has no 'description'`);
282
+ }
283
+ }
284
+ return problems;
285
+ }
286
+
243
287
 
244
288
  /**
245
289
  * `js/README.md` describes every module under `js/`, and only modules that exist.
@@ -41,7 +41,9 @@ import os from 'node:os';
41
41
  import path from 'node:path';
42
42
 
43
43
  import { emitGlobalInto } from '../build/driver.mjs';
44
- import { GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir } from '../hosts/hosts.mjs';
44
+ import {
45
+ GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
46
+ } from '../hosts/hosts.mjs';
45
47
  import {
46
48
  EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
47
49
  readJsonMaybe, readMaybe, themeOfDir,
@@ -174,6 +176,12 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
174
176
  // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
175
177
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
176
178
  }));
179
+ // The one place a render depends on WHERE it is installed: a global Bob emit writes each
180
+ // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
181
+ // reference render names the temp tree. Mapped back to the deployed dir before comparing,
182
+ // or every such skill reads as edited. A deployment that has since MOVED still differs,
183
+ // and that is real drift — its skills point at a directory that is no longer theirs.
184
+ const deployedDir = resolvePath(dir);
177
185
  const rels = [...new Set([...ownedSet(dir), ...ownedSet(expected)])].sort();
178
186
  for (const rel of rels) {
179
187
  const a = path.join(dir, rel);
@@ -182,7 +190,7 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
182
190
  // `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
183
191
  // same bytes, and `readText` folds the line endings the way `Path.read_text` does.
184
192
  const ta = readText(a);
185
- const tb = readText(b);
193
+ const tb = readText(b).split(expected).join(deployedDir);
186
194
  if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
187
195
  const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
188
196
  fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
@@ -349,7 +357,7 @@ export function cmdDiff(args) {
349
357
  + 'installs against a fresh render (a project layer is diffed against '
350
358
  + 'its own repo, which this command does not do). Pass --target at a '
351
359
  + 'global config dir (opencode-global/claude-global/bob-global/'
352
- + 'copilot-global/openclaude-global), or omit '
360
+ + 'openclaude-global), or omit '
353
361
  + '--target to use the default.\n');
354
362
  } else {
355
363
  printErr(`[diff] no global Geneseed install at ${target} (no ${GLOBAL_MANIFEST}). `