geneseed 4.0.0 → 4.2.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 +66 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/README.md +0 -21
- 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 +64 -1
- 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/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/guides/uninstall.md +2 -0
- package/docs/guides/upgrade.md +2 -0
- package/docs/reference/opencode-plugins.md +9 -0
- package/docs/token-footprint.md +1 -1
- package/docs/understand/take-it-out.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +6 -0
- package/js/build/driver.mjs +47 -11
- package/js/build/emit-claude.mjs +22 -10
- package/js/build/emit-common.mjs +9 -3
- package/js/build/emit-opencode.mjs +18 -11
- package/js/build/source.mjs +1 -0
- package/js/build/version.mjs +10 -3
- package/js/cli-table.json +16 -1
- package/js/hosts/hooks.mjs +4 -0
- package/js/hosts/installs.mjs +1 -1
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/native.mjs +38 -6
- package/js/hosts/opencode.mjs +1 -1
- package/js/hosts/owned.mjs +159 -0
- package/js/hosts/settings.mjs +4 -0
- package/js/hosts/usage.mjs +62 -0
- package/js/inspect/checks-repo.mjs +76 -1
- package/js/inspect/cost.mjs +83 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -1
- package/js/maintain/uninstall.mjs +77 -42
- package/js/web/actions.mjs +15 -77
- package/js/web/api.mjs +74 -4
- package/js/web/gates-api.mjs +44 -0
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +9 -4
- package/js/web/update-check.mjs +61 -0
- package/js/web/usage-api.mjs +95 -0
- package/package.json +1 -1
- package/registry.json +18 -25
- package/src/AGENT.md.tmpl +0 -1
- package/src/agents/_template.md +2 -1
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/doctrines/rigor.md +8 -7
- 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 +15 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +13 -3
- package/src/skills/handoff.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/security-audit.md +7 -1
- package/src/skills/ship.md +2 -1
- package/src/skills/wayfinder.md +25 -6
- package/src/skills/worktree.md +7 -5
- package/themes/_TEMPLATE.json +0 -1
- package/themes/biker.json +0 -1
- package/themes/commentator.json +0 -1
- package/themes/cyberpunk.json +0 -1
- package/themes/gamer.json +0 -1
- package/themes/imperial.json +0 -1
- package/themes/joker.json +0 -1
- package/themes/marvin.json +0 -1
- package/themes/mean.json +0 -1
- package/themes/military.json +0 -1
- package/themes/neutral.json +0 -1
- package/themes/pirate.json +0 -1
- package/themes/sports.json +0 -1
- package/themes/verstappen.json +0 -1
- package/themes/wizard.json +0 -1
- package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
- package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
- package/web/dist/assets/Docs-CD20mrVv.js +5 -0
- package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
- package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
- package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
- package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
- package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
- package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
- package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
- package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
- package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
- package/web/dist/assets/api-Bp5eW_6m.js +1 -0
- package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
- package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
- package/web/dist/assets/index-DoD_g2CM.js +2 -0
- package/web/dist/index.html +3 -3
- package/src/skills/herdr.md +0 -154
- package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
- package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
- package/web/dist/assets/api-CxgfYu18.js +0 -1
- package/web/dist/assets/index-CiXwChvf.js +0 -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
|
@@ -33,6 +33,7 @@ import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
|
33
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
|
+
import { claimLeaf, ownedState } from './owned.mjs';
|
|
36
37
|
|
|
37
38
|
/** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
|
|
38
39
|
const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
|
|
@@ -242,7 +243,7 @@ function isReadonly(text) {
|
|
|
242
243
|
* description leaves the model's always-on catalogue and the model never self-triggers it.
|
|
243
244
|
* Same precedent as `isReadonly` — a phrase in the spec, not a second manifest to keep in
|
|
244
245
|
* step. Which skills carry it is an authoring decision: the teaching skills a human runs on
|
|
245
|
-
* themselves
|
|
246
|
+
* themselves.
|
|
246
247
|
*/
|
|
247
248
|
const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
|
|
248
249
|
export function isUserInvokedOnly(text) {
|
|
@@ -471,14 +472,42 @@ function warnClaudeIgnored(overrides) {
|
|
|
471
472
|
* emit, shared by all of them, so the pre-manifest header still prints once.
|
|
472
473
|
*
|
|
473
474
|
* `oldOwned === null` (or no `cfg`) is a caller doing no ownership at all: claim everything.
|
|
475
|
+
*
|
|
476
|
+
* The leaf guard (`claimLeaf`/`safeLeaf`) runs whenever `cfg != null`, independently of
|
|
477
|
+
* `oldOwned` — it is not about which prior manifest owns `dest`, it is about what sits AT
|
|
478
|
+
* `dest` on disk, and a caller with no `cfg` (`null` or `undefined` — they behave alike
|
|
479
|
+
* throughout this closure) has no root to contain against at all. See `js/hosts/owned.mjs`.
|
|
480
|
+
*
|
|
481
|
+
* `claim.editRefused` (Task 4, fix round 1): the rels this closure refused because the user
|
|
482
|
+
* had edited them — the one piece of its own decision `pruneOwned` cannot re-derive from
|
|
483
|
+
* `owned` alone. A refused edit drops its rel OUT of `owned` (the file was not written), which
|
|
484
|
+
* makes it look exactly like a rel this build stopped shipping — `pruneOwned` would read it as
|
|
485
|
+
* stale, re-run `ownedState`, see `'modified'` again, and print ITS OWN "kept your edited...
|
|
486
|
+
* (no longer shipped)" right under this function's "kept your edited... delete it to get
|
|
487
|
+
* Geneseed's copy back" — two warnings about one rel that is in fact still shipped. The caller
|
|
488
|
+
* reads this set back off the closure and hands it to `pruneOwned` so that rel is excluded from
|
|
489
|
+
* the stale set entirely, silently, rather than re-evaluated.
|
|
474
490
|
*/
|
|
475
|
-
export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
491
|
+
export function claimer(oldOwned, cfg, manifestExisted = true, oldHashes = {}) {
|
|
476
492
|
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
477
493
|
let headerPrinted = false;
|
|
478
|
-
|
|
479
|
-
|
|
494
|
+
const editRefused = new Set();
|
|
495
|
+
const claim = (dest) => {
|
|
496
|
+
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest))) return false;
|
|
497
|
+
if (oldSet === null || cfg == null || !existsSync(dest)) return true;
|
|
480
498
|
const rel = relPosix(cfg, dest);
|
|
481
|
-
if (oldSet.has(rel))
|
|
499
|
+
if (oldSet.has(rel)) {
|
|
500
|
+
// `'unknown'` (no hash recorded — a pre-Task-3 manifest, or a rel `ownedHashes` never
|
|
501
|
+
// hashed) and `'unmodified'` both fall through to the ordinary overwrite below: the
|
|
502
|
+
// constraint that a manifest without `hashes` keeps today's behaviour exactly is this
|
|
503
|
+
// branch doing nothing extra, not a special case.
|
|
504
|
+
if (ownedState(cfg, rel, oldHashes) === 'modified') {
|
|
505
|
+
warn(`[geneseed] kept your edited ${rel} — delete it to get Geneseed's copy back`);
|
|
506
|
+
editRefused.add(rel);
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
482
511
|
if (!manifestExisted && !headerPrinted) {
|
|
483
512
|
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
484
513
|
+ 'are treated as yours');
|
|
@@ -488,6 +517,8 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
|
488
517
|
+ 'clobbering it');
|
|
489
518
|
return false;
|
|
490
519
|
};
|
|
520
|
+
claim.editRefused = editRefused;
|
|
521
|
+
return claim;
|
|
491
522
|
}
|
|
492
523
|
|
|
493
524
|
/**
|
|
@@ -524,7 +555,8 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
|
524
555
|
*/
|
|
525
556
|
export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
|
|
526
557
|
host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
|
|
527
|
-
theme = null, src = undefined, skillDirOf = null,
|
|
558
|
+
theme = null, src = undefined, skillDirOf = null, oldHashes = {},
|
|
559
|
+
claim = claimer(oldOwned, cfg, manifestExisted, oldHashes),
|
|
528
560
|
// Bob (host-compat R1): its skills page documents only `name`/`description` — no
|
|
529
561
|
// `disable-model-invocation` — and giving it the Claude-dialect alias SKILL.md (the
|
|
530
562
|
// `host === 'claude'` branch below) would be an unverified key PLUS a visible duplicate
|
package/js/hosts/opencode.mjs
CHANGED
|
@@ -114,7 +114,7 @@ export const COMMAND_SET = {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
/** Mirrors `_build_emit.PONYTAIL_COMMAND_BODY`. */
|
|
117
|
-
const PONYTAIL_COMMAND_BODY =
|
|
117
|
+
export const PONYTAIL_COMMAND_BODY =
|
|
118
118
|
'Ponytail level requested: $ARGUMENTS\n\n'
|
|
119
119
|
+ 'The geneseed-ponytail plugin records this level and, from your next turn, appends '
|
|
120
120
|
+ 'the matching "laziest solution that works" ruleset to your system prompt — honour '
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest rel -> path on disk decisions — the small set of rules that turn an `owned` list
|
|
3
|
+
* entry (a POSIX-spelled relative path, read straight off a `.geneseed-manifest.json` that
|
|
4
|
+
* sits inside the install) into something safe to act on.
|
|
5
|
+
*
|
|
6
|
+
* `ownedWithin` is the first tenant: both `uninstall.mjs`'s owned-file walk and the generator
|
|
7
|
+
* driver's prune read a manifest's `owned` list and must agree on the one security boundary
|
|
8
|
+
* neither may skip — a manifest is a file on disk, and per-repo ones live inside the very repo
|
|
9
|
+
* a build runs over, so a cloned repo's manifest is as untrusted as any other file it ships.
|
|
10
|
+
*
|
|
11
|
+
* `safeLeaf` is the second: `ownedWithin` only proves a REL resolves inside the root, which
|
|
12
|
+
* says nothing about what sits at that path. A per-repo manifest can ship a rel that resolves
|
|
13
|
+
* inside the root AND is itself a symlink or a hard link out — `claimer` runs `safeLeaf` on
|
|
14
|
+
* every destination before it ever claims one.
|
|
15
|
+
*
|
|
16
|
+
* `claimLeaf` is `safeLeaf` plus the one sentence every refusal must print, because `claimer`
|
|
17
|
+
* is not the only writer whose path lands in `owned`: `writeVersion`, `shipLeanLaws`, and a
|
|
18
|
+
* handful of direct `writeText` calls in `emit-claude.mjs`/`emit-opencode.mjs` (the Bob rules
|
|
19
|
+
* stub, the project `.gitignore`, the OpenCode-global `AGENT.md`) push a rel into `owned`
|
|
20
|
+
* too, and the spec's intent — never write through a linked owned file — covers all of them,
|
|
21
|
+
* not just the ones that happen to go through a `claim()` closure.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { resolvePath } from './hosts.mjs';
|
|
28
|
+
import { within } from '../lib/paths.mjs';
|
|
29
|
+
import { isFile } from '../lib/fs.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
33
|
+
* `owned` goes through (uninstall, both deactivates, build prune (`pruneOwned` in js/build/driver.mjs)).
|
|
34
|
+
*
|
|
35
|
+
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
36
|
+
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
37
|
+
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
38
|
+
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
39
|
+
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
|
|
40
|
+
* on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
|
|
41
|
+
* besides, which is why the guard runs on the resolved path rather than the lexical one.
|
|
42
|
+
*/
|
|
43
|
+
export function ownedWithin(base, rels) {
|
|
44
|
+
const rroot = resolvePath(base);
|
|
45
|
+
return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when `dest` is safe for `claimer` to (over)write: not itself a link, not a second name
|
|
50
|
+
* for a file with other names, and — once symlinks on its existing ancestor resolve — still
|
|
51
|
+
* inside `root`. False for the shape the leaf guard exists to catch: a cloned repo's manifest
|
|
52
|
+
* naming a rel that resolves inside the config root (so `ownedWithin` lets it through) at a
|
|
53
|
+
* path that is ITSELF a symlink or hard link pointing somewhere else, such as
|
|
54
|
+
* `.opencode/skills/x/SKILL.md` symlinked to `~/.bashrc` with a manifest entry claiming it.
|
|
55
|
+
*
|
|
56
|
+
* `lstatSync(dest, { throwIfNoEntry: false })`, never `existsSync` first — a DANGLING symlink
|
|
57
|
+
* (the attack's own shape: the real target need not exist yet for the write to land on it the
|
|
58
|
+
* moment something else creates it there) reads as ABSENT to `existsSync` and as a symlink to
|
|
59
|
+
* `lstat`, so `existsSync` is exactly the check that misses this case.
|
|
60
|
+
*
|
|
61
|
+
* `nlink` is checked only for a regular file. A directory's `nlink` is routinely > 1 — every
|
|
62
|
+
* child subdirectory's own `..` entry adds one — so gating on `isFile()` is what keeps an
|
|
63
|
+
* ordinary nested skill folder from tripping the hard-link branch on its own directories.
|
|
64
|
+
*
|
|
65
|
+
* A symlinked ROOT — `~/.claude` as a dotfiles repo's own symlink, or a `CLAUDE_CONFIG_DIR`
|
|
66
|
+
* env override pointing at one (`js/hosts/link.mjs` links the `geneseed` CLI binary onto
|
|
67
|
+
* PATH, a different concern entirely, and never a config root) — is NOT this shape:
|
|
68
|
+
* `resolvePath` follows the root link on both `root` and `dest`, so a leaf reached the
|
|
69
|
+
* ordinary way through a linked root still resolves inside it and stays allowed — only the
|
|
70
|
+
* LEAF itself being a link is refused.
|
|
71
|
+
*/
|
|
72
|
+
export function safeLeaf(root, dest) {
|
|
73
|
+
const rroot = resolvePath(root);
|
|
74
|
+
const st = lstatSync(dest, { throwIfNoEntry: false });
|
|
75
|
+
if (st) {
|
|
76
|
+
if (st.isSymbolicLink()) return false;
|
|
77
|
+
if (st.isFile() && st.nlink > 1) return false;
|
|
78
|
+
}
|
|
79
|
+
return within(resolvePath(dest), rroot);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `safeLeaf(root, dest)`, and on false the warning line every refusal prints — ONE sentence,
|
|
84
|
+
* so `claimer` and every direct owned-path writer below it say the same thing rather than
|
|
85
|
+
* each inventing its own wording. `rel` is the manifest spelling (POSIX, root-relative);
|
|
86
|
+
* callers that already have it (every one of them, since it is what they are about to push
|
|
87
|
+
* into `owned`) pass it straight through instead of re-deriving it from `dest`.
|
|
88
|
+
*/
|
|
89
|
+
export function claimLeaf(root, dest, rel) {
|
|
90
|
+
if (safeLeaf(root, dest)) return true;
|
|
91
|
+
process.stderr.write(`[geneseed] refused ${rel}: it is a link — Geneseed writes only `
|
|
92
|
+
+ 'regular files it owns\n');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* sha256 hex of the bytes on disk at `p`, with every `\r\n` folded to `\n` first. The fold is
|
|
98
|
+
* the point: a committed install is checked out by git with whatever line endings that clone's
|
|
99
|
+
* `core.autocrlf` / `.gitattributes` / OS asks for, and a teammate on the other OS gets every
|
|
100
|
+
* owned file's endings flipped without touching a byte of content. Hashed raw, that read as an
|
|
101
|
+
* edit to every file and the next rebuild froze the harness (measured: 95 false "kept your
|
|
102
|
+
* edited", owned 106 -> 11). A lone `\r` is not folded — git never writes one, so it is content.
|
|
103
|
+
* `latin1` maps each byte to one char and back, so the fold is byte-exact on any file, text or not.
|
|
104
|
+
*/
|
|
105
|
+
export function fileHash(p) {
|
|
106
|
+
const folded = readFileSync(p).toString('latin1').replaceAll('\r\n', '\n');
|
|
107
|
+
return createHash('sha256').update(folded, 'latin1').digest('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One sha256 per entry of `owned` that is both inside `root` (through `ownedWithin`, the same
|
|
112
|
+
* containment boundary every other owned-rel walk goes through) and a file that still exists —
|
|
113
|
+
* a rel a prune is about to delete, or one a crafted manifest named outside root, is skipped
|
|
114
|
+
* rather than hashed. Keys land in the same sorted order the manifest writers already give
|
|
115
|
+
* `owned` itself (`[...owned].sort()`), so the hash map a re-emit produces for an unchanged
|
|
116
|
+
* tree is byte-identical to the one before it — `golden.mjs --idempotent`'s whole point.
|
|
117
|
+
*/
|
|
118
|
+
export function ownedHashes(root, owned) {
|
|
119
|
+
const hashes = {};
|
|
120
|
+
for (const rel of ownedWithin(root, owned).sort()) {
|
|
121
|
+
const p = path.join(root, rel);
|
|
122
|
+
if (isFile(p)) hashes[rel] = fileHash(p);
|
|
123
|
+
}
|
|
124
|
+
return hashes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `/^[0-9a-f]{64}$/` — the only shape a hash this module wrote ever takes. */
|
|
128
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `doc.hashes` read back defensively: a manifest is a file on disk, as untrusted as any other
|
|
132
|
+
* (`ownedWithin`'s own docblock), so a hand-edited or corrupt `hashes` must read as "nothing
|
|
133
|
+
* recorded" rather than crash or poison a comparison with a value that was never really a
|
|
134
|
+
* sha256. ANY malformed entry zeroes the whole map rather than only dropping that one key —
|
|
135
|
+
* a map that is wrong in one place is not trustworthy in the rest either.
|
|
136
|
+
*/
|
|
137
|
+
export function hashesOf(doc) {
|
|
138
|
+
const h = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.hashes : undefined;
|
|
139
|
+
if (!h || typeof h !== 'object' || Array.isArray(h)) return {};
|
|
140
|
+
for (const v of Object.values(h)) {
|
|
141
|
+
if (typeof v !== 'string' || !HEX64.test(v)) return {};
|
|
142
|
+
}
|
|
143
|
+
return { ...h };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where one owned file stands against its recorded hash — the table later tasks (rebuild,
|
|
148
|
+
* prune, uninstall) will branch on to stop clobbering a user's edit to a Geneseed-owned file.
|
|
149
|
+
* `'unknown'` is deliberately distinct from `'unmodified'`: an older manifest with no `hashes`
|
|
150
|
+
* entry for this rel (today's installs, pre-Task-3) must not be read as "proven untouched" —
|
|
151
|
+
* see the constraint that a manifest without `hashes` keeps today's behaviour exactly.
|
|
152
|
+
*/
|
|
153
|
+
export function ownedState(root, rel, hashes) {
|
|
154
|
+
if (!Object.hasOwn(hashes, rel)) return 'unknown';
|
|
155
|
+
const expected = hashes[rel];
|
|
156
|
+
const p = path.join(root, rel);
|
|
157
|
+
if (!isFile(p)) return 'missing';
|
|
158
|
+
return fileHash(p) === expected ? 'unmodified' : 'modified';
|
|
159
|
+
}
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -802,6 +802,10 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
802
802
|
matcher: 'Write|Edit|NotebookEdit',
|
|
803
803
|
hooks: [{ type: 'command', command: ruleGate, ...onFailureBlock }],
|
|
804
804
|
},
|
|
805
|
+
// Usage counting (js/hosts/usage.mjs): `tool-gate` records a Skill or subagent call and
|
|
806
|
+
// judges nothing else about it. `Task` is the subagent tool's older name. No
|
|
807
|
+
// `onFailure: 'block'`: a counter that cannot start must never stop a skill.
|
|
808
|
+
{ matcher: 'Skill|Agent|Task', hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"` }] },
|
|
805
809
|
],
|
|
806
810
|
// One matcher-less group, not a `startup|clear` / `resume|compact` split (Claude verdict
|
|
807
811
|
// I4): every SessionStart source — `startup`, `resume`, `clear`, `compact` and `fork` —
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH SKILLS AND AGENTS ACTUALLY FIRE — the writer behind the Overview's "What fires" chart.
|
|
3
|
+
*
|
|
4
|
+
* One line per Skill or subagent call, appended to `<install>/notebook/usage.jsonl`:
|
|
5
|
+
* `{"ts":"2026-10-13","kind":"skill","name":"develop"}`. Counts only — never the prompt, the
|
|
6
|
+
* arguments or a path. The day, not the instant: a count per day is all the chart draws, and a
|
|
7
|
+
* coarser stamp is less of a record of someone's working hours.
|
|
8
|
+
*
|
|
9
|
+
* ON THE HOOK PATH, so it is cheap and silent: it imports only node builtins, prints nothing (a
|
|
10
|
+
* printed byte would turn a gate permissive), and swallows every error — a counter that fails
|
|
11
|
+
* must never block, slow or change a tool call. It rides `tool-gate`, the verb Bob already runs
|
|
12
|
+
* on every tool and Claude/OpenClaude route their `Skill|Agent|Task` matcher to, so no host pays
|
|
13
|
+
* an extra process for it. The OpenCode guard plugin carries the same rules inline (a plugin
|
|
14
|
+
* cannot import from here); `tests/unit/usage.test.mjs` holds both to one table.
|
|
15
|
+
*
|
|
16
|
+
* Off when `GENESEED_NO_USAGE` is set or the install holds a `.usage-off` marker.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const USAGE_LEDGER = path.join('notebook', 'usage.jsonl');
|
|
22
|
+
export const USAGE_OFF = '.usage-off';
|
|
23
|
+
|
|
24
|
+
const NAME_RE = /^[A-Za-z0-9][\w.:@/-]{0,99}$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `{kind, name}` for a skill or subagent call, else null. Reads the Claude shape (`tool_name` /
|
|
28
|
+
* `tool_input`: `Skill` → `skill`, `Agent` or the older `Task` → `subagent_type`), OpenCode's
|
|
29
|
+
* (`skill` → `name`, `task` → `subagent_type`) and, best-effort, Bob's (`tool` / `input`, any
|
|
30
|
+
* tool whose name contains "skill"): Bob's skill tool name is unverified, no Bob runs here.
|
|
31
|
+
*/
|
|
32
|
+
export function usageOf(payload) {
|
|
33
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
34
|
+
const tool = String(payload.tool_name || payload.tool || '').toLowerCase();
|
|
35
|
+
const ti = payload.tool_input || payload.input || payload.args || {};
|
|
36
|
+
let kind = null;
|
|
37
|
+
let name = null;
|
|
38
|
+
if (!tool.startsWith('mcp') && tool.includes('skill')) {
|
|
39
|
+
kind = 'skill';
|
|
40
|
+
name = ti.skill ?? ti.name ?? ti.skill_name;
|
|
41
|
+
} else if (tool === 'agent' || tool === 'task') {
|
|
42
|
+
kind = 'agent';
|
|
43
|
+
name = ti.subagent_type ?? ti.agent;
|
|
44
|
+
}
|
|
45
|
+
if (!kind || typeof name !== 'string') return null;
|
|
46
|
+
name = name.trim().replace(/^\/+/, '');
|
|
47
|
+
return NAME_RE.test(name) ? { kind, name } : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Append one line for `payload` under `root`, if it is a counted call. Never throws. */
|
|
51
|
+
export function recordUse(root, payload, now = new Date()) {
|
|
52
|
+
try {
|
|
53
|
+
if (!root || process.env.GENESEED_NO_USAGE) return;
|
|
54
|
+
const use = usageOf(payload);
|
|
55
|
+
if (!use) return;
|
|
56
|
+
if (existsSync(path.join(root, USAGE_OFF))) return;
|
|
57
|
+
const dir = path.join(root, 'notebook');
|
|
58
|
+
if (!existsSync(dir)) return;
|
|
59
|
+
appendFileSync(path.join(root, USAGE_LEDGER),
|
|
60
|
+
`${JSON.stringify({ ts: now.toISOString().slice(0, 10), ...use })}\n`);
|
|
61
|
+
} catch { /* a counter never fails a tool call */ }
|
|
62
|
+
}
|