geneseed 3.20.0 → 4.1.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 (125) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
  9. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  10. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  11. package/adapters/opencode/plugins/geneseed-guard.js +324 -175
  12. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  13. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  14. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  15. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  16. package/bin/geneseed-hook.mjs +36 -5
  17. package/docs/README.md +3 -1
  18. package/docs/candidate-hosts.md +0 -8
  19. package/docs/concepts/enforcement-table.md +66 -0
  20. package/docs/concepts/hook-gates.md +2 -2
  21. package/docs/concepts/hooks.md +6 -6
  22. package/docs/concepts/host-matrix.md +4 -3
  23. package/docs/concepts/loop-branch.md +6 -1
  24. package/docs/concepts/rules.md +1 -1
  25. package/docs/design-history.md +59 -0
  26. package/docs/extending.md +11 -3
  27. package/docs/guides/install-opencode.md +1 -1
  28. package/docs/guides/loop-running.md +1 -1
  29. package/docs/guides/mcp-filesystem.md +2 -2
  30. package/docs/guides/mcp-gitlab.md +2 -2
  31. package/docs/guides/mcp-markitdown.md +3 -3
  32. package/docs/guides/mcp.md +7 -7
  33. package/docs/guides/project-context.md +1 -1
  34. package/docs/reference/env-context.md +3 -2
  35. package/docs/reference/environment.md +2 -1
  36. package/docs/reference/opencode-plugins.md +9 -0
  37. package/docs/reference/plugin-guard.md +3 -3
  38. package/docs/reference/troubleshoot-hooks.md +34 -0
  39. package/docs/understand/design-choices-engineering.md +29 -0
  40. package/docs/understand/design-choices-gates.md +23 -0
  41. package/docs/understand/design-choices-hosts.md +21 -0
  42. package/docs/understand/design-choices-install.md +19 -0
  43. package/docs/understand/design-choices-shape.md +21 -0
  44. package/docs/understand/design-choices.md +34 -0
  45. package/docs/understand/enforced-vs-asked.md +5 -4
  46. package/docs/understand/machine-bob.md +1 -1
  47. package/docs/understand/machine-opencode.md +2 -2
  48. package/harness.config.json +1 -1
  49. package/js/build/driver.mjs +42 -16
  50. package/js/build/emit-claude.mjs +24 -8
  51. package/js/build/emit-opencode.mjs +33 -15
  52. package/js/build/generate.mjs +38 -9
  53. package/js/build/source.mjs +39 -0
  54. package/js/cli-table.json +7 -7
  55. package/js/hosts/gitref.mjs +3 -3
  56. package/js/hosts/hooks-context.mjs +11 -30
  57. package/js/hosts/hooks-learn.mjs +89 -8
  58. package/js/hosts/hooks.mjs +212 -18
  59. package/js/hosts/hosts.mjs +217 -13
  60. package/js/hosts/installs.mjs +259 -46
  61. package/js/hosts/mcp.mjs +23 -152
  62. package/js/hosts/native.mjs +48 -12
  63. package/js/hosts/opencode.mjs +48 -18
  64. package/js/hosts/settings.mjs +366 -42
  65. package/js/hosts/shim.mjs +37 -0
  66. package/js/inspect/checks-authoring.mjs +81 -1
  67. package/js/inspect/checks-constitution.mjs +2 -1
  68. package/js/inspect/checks-repo.mjs +68 -4
  69. package/js/inspect/doctor.mjs +40 -9
  70. package/js/inspect/scan.mjs +5 -4
  71. package/js/inspect/status.mjs +62 -12
  72. package/js/lib/text.mjs +23 -0
  73. package/js/loop/catalog.mjs +7 -2
  74. package/js/maintain/migrate.mjs +42 -5
  75. package/js/maintain/uninstall.mjs +150 -38
  76. package/js/maintain/update.mjs +22 -2
  77. package/js/web/actions.mjs +43 -87
  78. package/js/web/api.mjs +13 -4
  79. package/js/web/handler.mjs +2 -2
  80. package/js/web/routes.mjs +1 -2
  81. package/js/web/state.mjs +28 -15
  82. package/package.json +1 -1
  83. package/src/agents/_template.md +3 -2
  84. package/src/agents/researcher.md +2 -1
  85. package/src/agents/reviewer.md +9 -4
  86. package/src/agents/tester.md +3 -3
  87. package/src/bricks/_template.md +1 -1
  88. package/src/doctrines/ops.md +9 -7
  89. package/src/doctrines/rigor.md +8 -7
  90. package/src/laws/universal.md +1 -1
  91. package/src/skills/_template.md +3 -3
  92. package/src/skills/brainstorm/builder-check.md +16 -0
  93. package/src/skills/brainstorm.md +19 -11
  94. package/src/skills/daydream/SKILL.md +1 -1
  95. package/src/skills/daydream/VENDOR.md +4 -0
  96. package/src/skills/debug/ci.md +4 -4
  97. package/src/skills/debug.md +4 -4
  98. package/src/skills/develop.md +6 -4
  99. package/src/skills/fresh-eyes.md +1 -1
  100. package/src/skills/geneseed-code-review.md +8 -2
  101. package/src/skills/handoff.md +1 -1
  102. package/src/skills/herdr.md +1 -1
  103. package/src/skills/parallel-agents.md +4 -4
  104. package/src/skills/pipeline.md +3 -3
  105. package/src/skills/plan.md +18 -6
  106. package/src/skills/review-response.md +5 -2
  107. package/src/skills/ship.md +2 -1
  108. package/src/skills/worktree.md +2 -0
  109. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-CqVt2u9s.js} +1 -1
  110. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Bui5Gomc.js} +1 -1
  111. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-VlIx1-At.js} +2 -2
  112. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-6cYYkVeA.js} +1 -1
  113. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DgNY8QKU.js} +1 -1
  114. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-DZwZOUZJ.js} +1 -1
  115. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-DXpFbKOG.js} +1 -1
  116. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-Bh7ztekk.js} +1 -1
  117. package/web/dist/assets/{Library-BraUKPyQ.js → Library-DBLX-1SD.js} +1 -1
  118. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-CvFMZ7_P.js} +1 -1
  119. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-FPTUH1CB.js} +1 -1
  120. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-DcVA502F.js} +1 -1
  121. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-DtyR4h75.js} +1 -1
  122. package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
  123. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-CNSPVZxX.js} +1 -1
  124. package/web/dist/assets/{index-CPi22b7y.js → index-BmjcLIOr.js} +2 -2
  125. package/web/dist/index.html +2 -2
package/js/hosts/mcp.mjs CHANGED
@@ -1,22 +1,15 @@
1
1
  /**
2
- * MCP server presets, state and config I/O — `rituals/_harness_mcp.py`'s MCP block.
2
+ * MCP server state and config reading — `rituals/_harness_mcp.py`'s MCP block, minus its writers.
3
3
  *
4
- * WHAT THE PLAN SAID AND WHAT READING FOUND. The P6f plan scored `api_mcp` + `api_mcp_toggle`
5
- * at "~90 new, already in js/: `_mcp_load`/`readJsonc` (P6c), `settings.mjs`'s atomic write".
6
- * Measured against `js/`: `readJsonc` had crossed and NOTHING ELSE HAD. `js/web/api.mjs`
7
- * carries a five-line `mcpLoad` that is the no-host, comment-tolerant reader P6c needed for
8
- * `geneseed-wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
9
- * `_mcp_servers_key`, `_mcp_preset_block`, `_mcp_apply`, `_mcp_state`, `_mcp_set_enabled`,
10
- * `_mcp_load`'s host fork, `_mcp_commented`, `_mcp_save`, `_mcp_config_for`,
11
- * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
12
- * new. The endpoints on top of them are the small half.
4
+ * READ-ONLY BY DECISION (2026-10). Which MCP servers an install wires depends on the user and on
5
+ * the host, so Geneseed shows what each config already holds and where that file lives, and the
6
+ * user edits it. The three presets (`MCP_PRESETS`), the block shaper, the applier, the
7
+ * enable/disable flip and the atomic saver went with the console's Add button and toggle;
8
+ * `docs/guides/mcp*.md` carry the blocks a user copies by hand. Nothing here writes.
13
9
  *
14
- * THE ROUND TRIP IS THE HAZARD, and it is why every read here goes through `parseJson`.
15
- * `_mcp_save` rewrites the WHOLE config, not the servers map — and for a Claude global
16
- * install that config is `~/.claude.json`, which holds projects and history far beyond MCP
17
- * wiring. A `JSON.parse` would turn every `"temperature": 1.0` in somebody's real file into
18
- * `1` on the way back out. `readJsonc` already learned this; the strict-JSON fork has to
19
- * learn it separately, because it does not go through `readJsonc` at all.
10
+ * THE READ IS STILL HOST-SHAPED. OpenCode nests servers under `mcp` with an `enabled` flag and
11
+ * may be `.jsonc`; the Claude-shaped three use `mcpServers`, are strict JSON, and have no flag —
12
+ * present IS enabled. `uninstall` reads through `mcpLoad` too.
20
13
  *
21
14
  * A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict` (js/lib/json.mjs) excludes
22
15
  * the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
@@ -31,7 +24,7 @@ import { installState, installTargets } from './installs.mjs';
31
24
  import { printOut, printErr } from '../lib/fs.mjs';
32
25
  import { isDict } from '../lib/json.mjs';
33
26
  import { padEndToWidth } from '../lib/text.mjs';
34
- import { atomicWriteJson, loadJsonObject, opencodeTarget } from './settings.mjs';
27
+ import { loadJsonObject, opencodeTarget } from './settings.mjs';
35
28
 
36
29
 
37
30
  /** `dict.get(key, default)`. */
@@ -40,52 +33,6 @@ const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[k
40
33
  /** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
41
34
  const FLAGLESS = CLAUDE_STYLE;
42
35
 
43
- /**
44
- * `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
45
- *
46
- * The `desc` strings go on the wire verbatim (`api_mcp` returns them), so they are the one
47
- * place in this file where a byte matters more than the shape. Insertion order is the
48
- * listing order.
49
- */
50
- export const MCP_PRESETS = {
51
- markitdown: {
52
- label: 'MarkItDown',
53
- desc: 'PDF / Office / HTML -> Markdown for the ingest skill. Runs via `uvx` '
54
- + '(needs uv; zero-install, fetches on first call). No uv? Switch the command '
55
- + 'to ["markitdown-mcp"] after `pipx install markitdown-mcp`. Exposes one '
56
- + 'tool: convert_to_markdown(uri).',
57
- block: { type: 'local', command: ['uvx', 'markitdown-mcp'], enabled: true },
58
- },
59
- gitlab: {
60
- label: 'GitLab',
61
- desc: 'GitLab repo / MR / issue / CI tools via @zereight/mcp-gitlab (npx, no '
62
- + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
63
- + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
64
- + 'copy this block by hand under a new name and point it at the other URL — '
65
- + 'docs/guides/mcp-gitlab.md has the pattern.',
66
- block: {
67
- type: 'local',
68
- command: ['npx', '-y', '@zereight/mcp-gitlab'],
69
- environment: { GITLAB_PERSONAL_ACCESS_TOKEN: '',
70
- GITLAB_API_URL: 'https://gitlab.com/api/v4' },
71
- enabled: true,
72
- },
73
- },
74
- filesystem: {
75
- label: 'Filesystem',
76
- desc: 'Read/write files under explicitly allowed directories via '
77
- + '@modelcontextprotocol/server-filesystem (npx, no install). Replace the '
78
- + 'trailing path arg(s) with only the dir(s) the agent may touch '
79
- + '(least-privilege).',
80
- block: {
81
- type: 'local',
82
- command: ['npx', '-y', '@modelcontextprotocol/server-filesystem',
83
- '/path/to/allowed/dir'],
84
- enabled: true,
85
- },
86
- },
87
- };
88
-
89
36
  /** `_mcp_servers_key` — OpenCode nests servers under `mcp`, the other three `mcpServers`. */
90
37
  const serversKey = (host) => (FLAGLESS.includes(host) ? 'mcpServers' : 'mcp');
91
38
 
@@ -95,50 +42,11 @@ const serversOf = (config, host) => {
95
42
  return isDict(s) ? s : {};
96
43
  };
97
44
 
98
- /**
99
- * `_mcp_preset_block` — the block to WRITE for a preset, shaped for `host`.
100
- *
101
- * OpenCode takes it verbatim. The flagless three split the command list into `command`
102
- * (head) + `args` (tail) and rename `environment` to `env`.
103
- */
104
- export function mcpPresetBlock(name, host = 'opencode') {
105
- const block = { ...MCP_PRESETS[name].block };
106
- if (!FLAGLESS.includes(host)) return block;
107
- const cmd = [...(block.command || [])];
108
- const out = { command: cmd.length ? cmd[0] : '', args: cmd.slice(1) };
109
- const env = block.environment;
110
- if (env) out.env = { ...env };
111
- return out;
112
- }
113
-
114
- /**
115
- * `_mcp_apply` — a COPY of `config` with server `name` set to `block`, or removed when
116
- * `block` is null. Never touches another key; drops an emptied servers map.
117
- *
118
- * The key ORDER is behaviour, not cosmetics: `setdefault` appends `$schema` at the end of a
119
- * fresh dict and leaves an existing one where it is, and `_mcp_save` writes the result out
120
- * as the file the user then reads. `{...config}` plus a guarded assignment is the same rule.
121
- */
122
- export function mcpApply(config, name, block, host = 'opencode') {
123
- const cfg = { ...config };
124
- const key = serversKey(host);
125
- if (host === 'opencode' && !Object.hasOwn(cfg, '$schema')) {
126
- cfg.$schema = 'https://opencode.ai/config.json';
127
- }
128
- const servers = { ...serversOf(cfg, host) };
129
- if (block === null) delete servers[name];
130
- else servers[name] = block;
131
- if (Object.keys(servers).length) cfg[key] = servers;
132
- else delete cfg[key];
133
- return cfg;
134
- }
135
-
136
45
  /**
137
46
  * `_mcp_state` — 'enabled' | 'disabled' | 'absent'.
138
47
  *
139
48
  * OpenCode: a present server with no explicit `enabled` counts as enabled (its default).
140
- * The flagless three have no such key, so present IS enabled and 'disabled' never occurs —
141
- * toggling one off removes its entry instead.
49
+ * The flagless three have no such key, so present IS enabled and 'disabled' never occurs.
142
50
  */
143
51
  export function mcpState(config, name, host = 'opencode') {
144
52
  const server = serversOf(config, host)[name];
@@ -147,25 +55,13 @@ export function mcpState(config, name, host = 'opencode') {
147
55
  return dget(server, 'enabled', true) ? 'enabled' : 'disabled';
148
56
  }
149
57
 
150
- /** `_mcp_set_enabled` — flip a present server's enabled-ness. No-op when it is absent. */
151
- export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
152
- const server = serversOf(config, host)[name];
153
- if (!isDict(server)) return config;
154
- if (FLAGLESS.includes(host)) {
155
- return enabled ? config : mcpApply(config, name, null, host);
156
- }
157
- return mcpApply(config, name, { ...server, enabled }, host);
158
- }
159
-
160
58
  /**
161
59
  * `_mcp_load` — a config as a dict; `{}` when missing, unreadable, unparseable or not an
162
60
  * object.
163
61
  *
164
62
  * The host fork is the whole function. OpenCode is read comment-tolerantly because a
165
63
  * hand-maintained `opencode.jsonc` carries `//`; the other three are read STRICTLY, because
166
- * their configs are plain JSON by contract, and a file that does not parse as such — for a
167
- * Claude global install, one that holds projects and history — is not one to rewrite from a
168
- * best-effort reading. (The comment stripper's trailing-comma pass IS string-aware; an older
64
+ * their configs are plain JSON by contract. (The comment stripper's trailing-comma pass IS string-aware; an older
169
65
  * version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
170
66
  */
171
67
  export function mcpLoad(p, host = 'opencode') {
@@ -179,11 +75,6 @@ export function mcpCommented(p) {
179
75
  return loadJsonObject(p).hadComments;
180
76
  }
181
77
 
182
- /** `_mcp_save` — pretty JSON, written ATOMICALLY. The one writer, under its MCP name. */
183
- export function mcpSave(p, config) {
184
- atomicWriteJson(p, config);
185
- }
186
-
187
78
  /**
188
79
  * `_mcp_config_for` — the file an install writes its MCP servers into, or null for a host
189
80
  * that carries no MCP wiring at that scope.
@@ -222,19 +113,9 @@ export function mcpInstallTargets() {
222
113
  return out;
223
114
  }
224
115
 
225
- /** `_mcp_known_names` — the presets first, then any server already in THIS config. */
226
- export function mcpKnownNames(config, host = 'opencode') {
227
- const names = Object.keys(MCP_PRESETS);
228
- const present = isDict(config) ? Object.keys(serversOf(config, host)) : [];
229
- return names.concat(present.filter((n) => !Object.hasOwn(MCP_PRESETS, n)));
230
- }
231
-
232
- /** `_mcp_meta` — (label, description); the bare name and a generic note for an unknown one. */
233
- export function mcpMeta(name) {
234
- const p = MCP_PRESETS[name];
235
- if (p) return [p.label, p.desc];
236
- return [name, 'User-defined MCP server (not a Harness preset). It lives in this '
237
- + "config already — 'e' enables/disables it, Enter removes it."];
116
+ /** The servers THIS config already has, in file order. Nothing it could have. */
117
+ export function mcpServerNames(config, host = 'opencode') {
118
+ return isDict(config) ? Object.keys(serversOf(config, host)) : [];
238
119
  }
239
120
 
240
121
  export { isDict };
@@ -242,22 +123,11 @@ export { isDict };
242
123
  // ---- the verb ----------------------------------------------------------------------------
243
124
 
244
125
  /**
245
- * `geneseed mcp` — the CLI FACE this library never had.
246
- *
247
- * Everything above crossed with the web console rather than with a subcommand: the reference
248
- * kept it as a preset/state library whose only command was the uninstall path, and the one
249
- * way to see which servers an install had wired was to start a server and look. That is the
250
- * gap this verb closes, and it closes it by CALLING the library — the same `mcpInstallTargets`
251
- * / `mcpLoad` / `mcpKnownNames` / `mcpState` / `mcpMeta` sequence the console's read endpoint
252
- * runs, rendered as text instead of as JSON.
126
+ * `geneseed mcp` — the servers each active install has wired, and the file they live in.
253
127
  *
254
- * ponytail: READ-ONLY, on purpose, and this is the ceiling. Wiring a server from here needs
255
- * the console's toggle orchestration, whose load-bearing part is not the toggle at all — it
256
- * is the refusal to rewrite a config it could not parse strictly, because for a global
257
- * Claude install that file holds projects and history and a rewrite from an empty object
258
- * would erase them. A second copy of that guard is a second place for it to be wrong. When a
259
- * terminal toggle is wanted, move the console's non-HTTP core down into this module and have
260
- * both faces call it; do not grow a parallel one here.
128
+ * The same `mcpInstallTargets` / `mcpLoad` / `mcpServerNames` / `mcpState` sequence the
129
+ * console's read endpoint runs, rendered as text instead of as JSON. Read-only like the
130
+ * console: there is nothing here to add or toggle a server with.
261
131
  */
262
132
  export function cmdMcp() {
263
133
  const targets = mcpInstallTargets()
@@ -272,9 +142,10 @@ export function cmdMcp() {
272
142
  if (mcpCommented(p)) {
273
143
  printOut(' this config carries comments — edit it by hand so they survive.\n');
274
144
  }
275
- for (const name of mcpKnownNames(cfg, host)) {
276
- const [lbl] = mcpMeta(name);
277
- printOut(` ${padEndToWidth(mcpState(cfg, name, host), 8)} ${padEndToWidth(name, 12)} ${lbl}\n`);
145
+ const names = mcpServerNames(cfg, host);
146
+ if (!names.length) printOut(' no MCP server configured\n');
147
+ for (const name of names) {
148
+ printOut(` ${padEndToWidth(mcpState(cfg, name, host), 8)} ${name}\n`);
278
149
  }
279
150
  }
280
151
  return 0;
@@ -150,7 +150,7 @@ function triggerOf(text) {
150
150
  * failure, so the cure is a shorter first trigger sentence in the source, never a wider cap.
151
151
  */
152
152
  const DESCRIPTION_CAP = 320;
153
- function skillDescription(text) {
153
+ export function skillDescription(text) {
154
154
  return withWhen(firstBlockquote(text), triggerOf(text));
155
155
  }
156
156
 
@@ -245,7 +245,7 @@ function isReadonly(text) {
245
245
  * themselves, and the ones gated on an environment the model cannot see (a herdr pane).
246
246
  */
247
247
  const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
248
- function isUserInvokedOnly(text) {
248
+ export function isUserInvokedOnly(text) {
249
249
  return USER_INVOKED_RE.test(text);
250
250
  }
251
251
 
@@ -359,7 +359,11 @@ function claudeAgentFrontmatter(stem, text, overrides) {
359
359
  if (isReadonly(text)) {
360
360
  const list = ['Write', 'Edit', 'NotebookEdit'];
361
361
  if (!text.includes(WEBFETCH_MARKER)) list.push('WebFetch');
362
- if (!text.includes(BASH_MARKER)) list.push('Bash');
362
+ // Bash AND PowerShell: docs `tools-reference.md` says denying Bash turns PowerShell off
363
+ // only for settings rules and `--disallowedTools` — agent frontmatter is not named there,
364
+ // so a "read-only" agent on Windows could still run shell commands via PowerShell
365
+ // (Claude verdict R1). The one opt-in marker lifts both together.
366
+ if (!text.includes(BASH_MARKER)) list.push('Bash', 'PowerShell');
363
367
  fm.push(`disallowedTools: ${list.join(', ')}`);
364
368
  }
365
369
  return fm;
@@ -505,8 +509,10 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
505
509
  * `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
506
510
  * `oldOwned`/`cfg`/`manifestExisted` when absent.
507
511
  *
508
- * Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
509
- * paths in write order.
512
+ * Returns `{ nAgents, nSkills, written, userOnlySkills }` — Python's 3-tuple, with `written` as
513
+ * absolute paths in write order, plus `userOnlySkills` (the claimed stems carrying the
514
+ * user-only marker; Task 4/O-1's input to OpenCode's `permission.skill` wiring — see the
515
+ * comment above the skill-frontmatter branch).
510
516
  *
511
517
  * That order is NOT observable, and this comment used to claim it was ("it lands in
512
518
  * `owned`, which the prune diffs against"). Measured when a mutation reversing it stayed
@@ -519,6 +525,15 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
519
525
  export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
520
526
  host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
521
527
  theme = null, src = undefined, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
528
+ // Bob (host-compat R1): its skills page documents only `name`/`description` — no
529
+ // `disable-model-invocation` — and giving it the Claude-dialect alias SKILL.md (the
530
+ // `host === 'claude'` branch below) would be an unverified key PLUS a visible duplicate
531
+ // in the model's catalogue (exactly the OpenCode problem this file already dodges for
532
+ // aliases). `bobDialect` suppresses both for Bob only, while `host: 'claude'` keeps every
533
+ // OTHER Claude-dialect choice (agent frontmatter, `warnClaudeIgnored`) — Bob still renders
534
+ // those verbatim. The caller (`emit-claude.mjs`) routes the suppressed names through
535
+ // `writeAliasCommands(..., alsoUserOnly: true)` into `.bob/commands/*.md` instead (O2).
536
+ bobDialect = false,
522
537
  } = {}) {
523
538
  const ov = overrides || {};
524
539
  if (host === 'claude') warnClaudeIgnored(ov);
@@ -527,6 +542,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
527
542
  const written = [];
528
543
  const sideFiles = []; // [stem, dest, text], written after the loop
529
544
  const claimedSkills = new Set(); // stems whose own SKILL.md claim held
545
+ const userOnlySkills = []; // claimed stems carrying the user-only marker
530
546
 
531
547
  const write = (dest, text) => {
532
548
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -590,6 +606,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
590
606
  let fm;
591
607
  let dest;
592
608
  let kind;
609
+ let userOnly = false;
593
610
  if (folder === 'agents') {
594
611
  fm = host === 'claude' ? claudeAgentFrontmatter(stem, text, ov)
595
612
  : opencodeAgentFrontmatter(stem, text, ov, theme);
@@ -597,26 +614,45 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
597
614
  kind = 'agent';
598
615
  } else {
599
616
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
600
- // The user-only key is emitted for every host too — Claude Code and Bob honour it,
601
- // OpenCode ignores an unknown key — so the identity holds. ALIASES are the one
602
- // exception: OpenCode has no user-only skill, so an alias skill there would land in the
617
+ // The user-only key is emitted for Claude Code, which honours it natively
618
+ // (`disable-model-invocation`). OpenCode has no frontmatter equivalent, so the
619
+ // SAME marker also drives `userOnlySkills` below, which the OpenCode emit turns into a
620
+ // `permission.skill: {name: "deny"}` row in `opencode.json` (opencode-verdict.md O-1) —
621
+ // the model loses the skill from its catalogue, `/name` still runs it (O-1's second
622
+ // finding: the command path reads the UNFILTERED skill list, never the permission one).
623
+ // Bob's skills page documents no such key (host-compat R1) — see `bobDialect` above,
624
+ // which skips the SKILL.md for a user-only skill on Bob entirely.
625
+ // ALIASES are the one case every non-Claude dialect shares: no alias skill via
626
+ // frontmatter exists for them either, so an alias skill there would land in the
603
627
  // model's catalogue as a duplicate; it gets a command instead (`writeAliasCommands`).
604
628
  fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
605
- if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
629
+ userOnly = isUserInvokedOnly(text);
630
+ if (userOnly && !bobDialect) fm.push('disable-model-invocation: true');
606
631
  body = stripSkillBodyLinks(pointSideFiles(body, stem, skillDirOf ? skillDirOf(stem)
607
632
  : SKILL_DIR_PLACEHOLDER));
608
633
  dest = path.join(skillsDir, stem, 'SKILL.md');
609
634
  kind = 'skill';
610
635
  }
636
+ // Bob, user-only: no SKILL.md at all — the command written by the caller is the only
637
+ // copy, so the model's `use_skill` catalogue never carries it (see `bobDialect` above).
638
+ // The `continue` is BEFORE `claimedSkills.add(stem)` below, so a user-only skill's own
639
+ // side files (none exist today, but the layout allows them) are skipped too — there is
640
+ // no `skills/<stem>/` folder on Bob for one to sit beside.
641
+ if (kind === 'skill' && userOnly && bobDialect) continue;
611
642
  if (!claim(dest)) continue;
612
643
  write(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
613
- if (kind === 'skill') claimedSkills.add(stem);
644
+ if (kind === 'skill') {
645
+ claimedSkills.add(stem);
646
+ // Only a CLAIMED user-only skill is Geneseed's to deny: a pre-existing file the claim
647
+ // declined belongs to the user, and `userOnly` was computed from OUR text, not theirs.
648
+ if (userOnly) userOnlySkills.push(stem);
649
+ }
614
650
  // An old name keeps answering `/name`: user-only, so the model's catalogue lists the
615
651
  // skill once. Read off the already-filtered items, so excluding the target drops its
616
652
  // aliases; through `claim`, so manifest, prune and uninstall own them; not counted.
617
653
  // Only once the target's own claim held: a user's file at the target gets no alias
618
654
  // pointing at a skill this install never wrote.
619
- if (kind === 'skill' && host === 'claude') {
655
+ if (kind === 'skill' && host === 'claude' && !bobDialect) {
620
656
  // The host announces the ALIAS's folder as the base directory, so a side-file pointer
621
657
  // climbs back to the target's.
622
658
  const aliasBody = skillDirOf ? body : stripSkillBodyLinks(
@@ -634,7 +670,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
634
670
  for (const [stem, dest, text] of sideFiles) {
635
671
  if (claimedSkills.has(stem) && claim(dest)) write(dest, stripSkillBodyLinks(text));
636
672
  }
637
- return { nAgents, nSkills, written };
673
+ return { nAgents, nSkills, written, userOnlySkills };
638
674
  }
639
675
 
640
676
  /**
@@ -28,7 +28,10 @@ import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
28
28
  import { comparePaths } from '../lib/paths.mjs';
29
29
  import { sourceReleaseVersion } from '../build/version.mjs';
30
30
  import { aliasesOf } from '../build/source.mjs';
31
- import { firstBlockquote, stripSkillBodyLinks, pointSideFiles, pushOverrideLines } from './native.mjs';
31
+ import {
32
+ firstBlockquote, isUserInvokedOnly, skillDescription, stripSkillBodyLinks, pointSideFiles,
33
+ pushOverrideLines,
34
+ } from './native.mjs';
32
35
 
33
36
  // Re-exported for `js/web/api.mjs` and the tests, which import it from here.
34
37
  export { sourceReleaseVersion };
@@ -111,7 +114,7 @@ export const COMMAND_SET = {
111
114
  };
112
115
 
113
116
  /** Mirrors `_build_emit.PONYTAIL_COMMAND_BODY`. */
114
- const PONYTAIL_COMMAND_BODY =
117
+ export const PONYTAIL_COMMAND_BODY =
115
118
  'Ponytail level requested: $ARGUMENTS\n\n'
116
119
  + 'The geneseed-ponytail plugin records this level and, from your next turn, appends '
117
120
  + 'the matching "laziest solution that works" ruleset to your system prompt — honour '
@@ -169,10 +172,19 @@ export function themeJson(theme) {
169
172
  return { $schema: 'https://opencode.ai/theme.json', theme: t };
170
173
  }
171
174
 
172
- /** `_build_emit._write_theme` — `<themes_dir>/geneseed-<theme>.json`. */
173
- export function writeTheme(themesDir, themeName, theme) {
174
- mkdirSync(themesDir, { recursive: true });
175
+ /**
176
+ * `_build_emit._write_theme` — `<themes_dir>/geneseed-<theme>.json`.
177
+ *
178
+ * `claim` — the same `claimer` closure the native layer and command writers share (see
179
+ * `writePrimaryAgent`'s docblock). Without it a user's own same-named theme file was
180
+ * overwritten and recorded in `owned`, so uninstall deleted it next. Default: claim
181
+ * everything, for a caller with no manifest to consult. Returns `null`, not the dest,
182
+ * when the claim is refused — so the caller does not push a non-write into `owned`.
183
+ */
184
+ export function writeTheme(themesDir, themeName, theme, claim = (_dest) => true) {
175
185
  const dest = path.join(themesDir, `geneseed-${themeName}.json`);
186
+ if (!claim(dest)) return null;
187
+ mkdirSync(themesDir, { recursive: true });
176
188
  writeText(dest, `${jsonDumpsIndent(themeJson(theme))}\n`);
177
189
  return dest;
178
190
  }
@@ -215,8 +227,8 @@ export function colorThemeFiles(cfg) {
215
227
  .sort(comparePaths);
216
228
  }
217
229
 
218
- /** `_build_emit._write_color_themes` — each curated palette in both flavours. */
219
- export function writeColorThemes(cfg, themesDir) {
230
+ /** `_build_emit._write_color_themes` — each curated palette in both flavours. `claim` — see `writeTheme`. */
231
+ export function writeColorThemes(cfg, themesDir, claim = (_dest) => true) {
220
232
  mkdirSync(themesDir, { recursive: true });
221
233
  const written = [];
222
234
  for (const src of colorThemeFiles(cfg)) {
@@ -224,6 +236,7 @@ export function writeColorThemes(cfg, themesDir) {
224
236
  const palette = spec.palette;
225
237
  for (const [flavour, transparent] of [['solid', false], ['transparent', true]]) {
226
238
  const dest = path.join(themesDir, `geneseed-${spec.name}-${flavour}.json`);
239
+ if (!claim(dest)) continue;
227
240
  writeText(dest, `${jsonDumpsIndent(colorThemeJson(palette, transparent))}\n`);
228
241
  written.push(dest);
229
242
  }
@@ -336,9 +349,17 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
336
349
  * second copy of its target. UNCONDITIONAL, unlike the `GENESEED_COMMANDS` set — an old name
337
350
  * that only works behind an opt-in is a name that broke. Read off the already-filtered
338
351
  * `items`, so excluding the target drops its aliases.
352
+ *
353
+ * `alsoUserOnly` (Bob host-compat O2/R1): Bob's skills page documents no model-invocation
354
+ * key either, so a genuinely user-only skill (not just an alias) needs the same treatment —
355
+ * `writeNativeLayer({ bobDialect: true })` already skipped its SKILL.md, and this is the one
356
+ * place left to give `/<stem>` a home. Default `false`: OpenCode's own call site leaves this
357
+ * off, because a real user-only skill there is handled by `permission.skill` deny instead
358
+ * (Task 4) — it keeps its SKILL.md and does not need a second, command-shaped copy.
339
359
  */
340
360
  export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
341
- skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
361
+ skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/'),
362
+ alsoUserOnly = false) {
342
363
  const written = [];
343
364
  for (const { text, src } of items) {
344
365
  if (text === null) continue;
@@ -346,11 +367,13 @@ export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => tr
346
367
  if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
347
368
  const stem = sp[1].slice(0, -3);
348
369
  const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
349
- for (const alias of aliasesOf(text)) {
350
- const dest = path.join(commandDir, `${alias}.md`);
370
+ const names = alsoUserOnly && isUserInvokedOnly(text) ? [...aliasesOf(text), stem] : aliasesOf(text);
371
+ for (const name of names) {
372
+ const dest = path.join(commandDir, `${name}.md`);
351
373
  if (!claim(dest)) continue;
352
374
  mkdirSync(path.dirname(dest), { recursive: true });
353
- writeText(dest, `---\ndescription: ${jsonDumps(`Alias of ${sp[1].slice(0, -3)}.`)}\n---\n\n${body}`);
375
+ const desc = name === stem ? skillDescription(text) : `Alias of ${stem}.`;
376
+ writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
354
377
  written.push(dest);
355
378
  }
356
379
  }
@@ -367,8 +390,13 @@ export function writePonytailCommand(commandDir, claim = (_dest) => true) {
367
390
  return dest;
368
391
  }
369
392
 
370
- /** `_copy_plugins` / `_copy_workflows` — maintained files, copied verbatim (copy2, so LF). */
371
- function copyJsDir(srcDir, dst, owned, prefix) {
393
+ /**
394
+ * `_copy_plugins` / `_copy_workflows` — maintained files, copied verbatim (copy2, so LF).
395
+ *
396
+ * `claim` — see `writeTheme`. A file the claim refuses is skipped: not copied, not counted,
397
+ * not pushed into `owned`, exactly as a declined native-layer write is.
398
+ */
399
+ function copyJsDir(srcDir, dst, owned, prefix, claim = (_dest) => true) {
372
400
  let n = 0;
373
401
  if (existsSync(srcDir) && statSync(srcDir).isDirectory()) {
374
402
  mkdirSync(dst, { recursive: true });
@@ -377,7 +405,9 @@ function copyJsDir(srcDir, dst, owned, prefix) {
377
405
  .map((name) => path.join(srcDir, name))
378
406
  .sort(comparePaths);
379
407
  for (const js of files) {
380
- copyFile(js, path.join(dst, path.basename(js)));
408
+ const dest = path.join(dst, path.basename(js));
409
+ if (!claim(dest)) continue;
410
+ copyFile(js, dest);
381
411
  if (owned !== null && owned !== undefined) owned.push(`${prefix}/${path.basename(js)}`);
382
412
  n += 1;
383
413
  }
@@ -386,11 +416,11 @@ function copyJsDir(srcDir, dst, owned, prefix) {
386
416
  }
387
417
 
388
418
  /** `_build_emit._copy_plugins`. */
389
- export function copyPlugins(cfg, dst, owned = null) {
390
- return copyJsDir(cfg.pluginSrc, dst, owned, 'plugins');
419
+ export function copyPlugins(cfg, dst, owned = null, claim = (_dest) => true) {
420
+ return copyJsDir(cfg.pluginSrc, dst, owned, 'plugins', claim);
391
421
  }
392
422
 
393
423
  /** `_build_emit._copy_workflows`. */
394
- export function copyWorkflows(cfg, dst, owned = null) {
395
- return copyJsDir(cfg.workflowSrc, dst, owned, 'workflows');
424
+ export function copyWorkflows(cfg, dst, owned = null, claim = (_dest) => true) {
425
+ return copyJsDir(cfg.workflowSrc, dst, owned, 'workflows', claim);
396
426
  }