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.
- package/CHANGELOG.md +71 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
- package/adapters/opencode/plugins/geneseed-activity.js +105 -2
- package/adapters/opencode/plugins/geneseed-context.js +96 -1
- package/adapters/opencode/plugins/geneseed-guard.js +324 -175
- package/adapters/opencode/plugins/geneseed-learn.js +127 -14
- package/adapters/opencode/plugins/geneseed-notify.js +46 -1
- package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
- package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/guides/mcp-filesystem.md +2 -2
- package/docs/guides/mcp-gitlab.md +2 -2
- package/docs/guides/mcp-markitdown.md +3 -3
- package/docs/guides/mcp.md +7 -7
- package/docs/guides/project-context.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/opencode-plugins.md +9 -0
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/cli-table.json +7 -7
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +11 -30
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +48 -18
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +43 -87
- package/js/web/api.mjs +13 -4
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +1 -2
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +3 -2
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/bricks/_template.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/doctrines/rigor.md +8 -7
- package/src/laws/universal.md +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/brainstorm/builder-check.md +16 -0
- package/src/skills/brainstorm.md +19 -11
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/daydream/VENDOR.md +4 -0
- package/src/skills/debug/ci.md +4 -4
- package/src/skills/debug.md +4 -4
- package/src/skills/develop.md +6 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +8 -2
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/parallel-agents.md +4 -4
- package/src/skills/pipeline.md +3 -3
- package/src/skills/plan.md +18 -6
- package/src/skills/review-response.md +5 -2
- package/src/skills/ship.md +2 -1
- package/src/skills/worktree.md +2 -0
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-CqVt2u9s.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Bui5Gomc.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-VlIx1-At.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-6cYYkVeA.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DgNY8QKU.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-DZwZOUZJ.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-DXpFbKOG.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-Bh7ztekk.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-DBLX-1SD.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-CvFMZ7_P.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-FPTUH1CB.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-DcVA502F.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-DtyR4h75.js} +1 -1
- package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-CNSPVZxX.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-BmjcLIOr.js} +2 -2
- package/web/dist/index.html +2 -2
package/js/hosts/mcp.mjs
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP server
|
|
2
|
+
* MCP server state and config reading — `rituals/_harness_mcp.py`'s MCP block, minus its writers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
226
|
-
export function
|
|
227
|
-
|
|
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
|
|
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
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* is
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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;
|
package/js/hosts/native.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
601
|
-
// OpenCode
|
|
602
|
-
//
|
|
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
|
-
|
|
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')
|
|
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
|
/**
|
package/js/hosts/opencode.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
371
|
-
|
|
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
|
-
|
|
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
|
}
|