geneseed 4.0.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 (63) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
  4. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  5. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  6. package/adapters/opencode/plugins/geneseed-guard.js +33 -1
  7. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  8. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  9. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  10. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  11. package/docs/guides/mcp-filesystem.md +2 -2
  12. package/docs/guides/mcp-gitlab.md +2 -2
  13. package/docs/guides/mcp-markitdown.md +3 -3
  14. package/docs/guides/mcp.md +7 -7
  15. package/docs/guides/project-context.md +1 -1
  16. package/docs/reference/opencode-plugins.md +9 -0
  17. package/harness.config.json +1 -1
  18. package/js/cli-table.json +1 -1
  19. package/js/hosts/mcp.mjs +23 -152
  20. package/js/hosts/opencode.mjs +1 -1
  21. package/js/web/actions.mjs +15 -77
  22. package/js/web/api.mjs +8 -1
  23. package/js/web/handler.mjs +2 -2
  24. package/js/web/routes.mjs +1 -2
  25. package/package.json +1 -1
  26. package/src/agents/_template.md +2 -1
  27. package/src/agents/researcher.md +2 -1
  28. package/src/agents/reviewer.md +9 -4
  29. package/src/agents/tester.md +3 -3
  30. package/src/doctrines/rigor.md +8 -7
  31. package/src/skills/brainstorm/builder-check.md +16 -0
  32. package/src/skills/brainstorm.md +19 -11
  33. package/src/skills/daydream/SKILL.md +1 -1
  34. package/src/skills/daydream/VENDOR.md +4 -0
  35. package/src/skills/debug/ci.md +4 -4
  36. package/src/skills/debug.md +4 -4
  37. package/src/skills/develop.md +6 -4
  38. package/src/skills/fresh-eyes.md +1 -1
  39. package/src/skills/geneseed-code-review.md +8 -2
  40. package/src/skills/handoff.md +1 -1
  41. package/src/skills/parallel-agents.md +4 -4
  42. package/src/skills/pipeline.md +3 -3
  43. package/src/skills/plan.md +18 -6
  44. package/src/skills/review-response.md +5 -2
  45. package/src/skills/ship.md +2 -1
  46. package/src/skills/worktree.md +2 -0
  47. package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-CqVt2u9s.js} +1 -1
  48. package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-Bui5Gomc.js} +1 -1
  49. package/web/dist/assets/{Dashboard-DpHza68-.js → Dashboard-VlIx1-At.js} +2 -2
  50. package/web/dist/assets/{Docs-Bw_-UK0O.js → Docs-6cYYkVeA.js} +1 -1
  51. package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-DgNY8QKU.js} +1 -1
  52. package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-DZwZOUZJ.js} +1 -1
  53. package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-DXpFbKOG.js} +1 -1
  54. package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-Bh7ztekk.js} +1 -1
  55. package/web/dist/assets/{Library-CK9HZzjV.js → Library-DBLX-1SD.js} +1 -1
  56. package/web/dist/assets/{Loops-BP98SMOH.js → Loops-CvFMZ7_P.js} +1 -1
  57. package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-FPTUH1CB.js} +1 -1
  58. package/web/dist/assets/{Personal-Bk89f13a.js → Personal-DcVA502F.js} +1 -1
  59. package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-DtyR4h75.js} +1 -1
  60. package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
  61. package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-CNSPVZxX.js} +1 -1
  62. package/web/dist/assets/{index-CiXwChvf.js → index-BmjcLIOr.js} +2 -2
  63. package/web/dist/index.html +2 -2
@@ -116,23 +116,10 @@ async function writeTrace(rootDir, runId, lines) {
116
116
  } catch (e) { dlog(`trace write failed: ${e?.message || e}`); return "(trace write failed)" }
117
117
  }
118
118
 
119
- export const GeneseedWorkflow = async (ctx) => {
120
- const { client, directory, worktree } = ctx
119
+ // The `workflow` tool's body, shared by the V1 and V2 registrations: `client` is the V1
120
+ // SDK client, or on V2 the facade `v2Client` builds over the plugin context.
121
+ function makeExecute({ client, directory, worktree }) {
121
122
  const rootDir = worktree || directory
122
- let toolHelper
123
- try { ({ tool: toolHelper } = await import("@opencode-ai/plugin")) }
124
- catch { dlog("@opencode-ai/plugin tool helper unavailable — registering a raw tool def") }
125
-
126
- // One-line load banner (always on) so a restart visibly confirms the plugin loaded
127
- // and the `workflow` tool is registered — and WARNS if this OpenCode build lacks the
128
- // custom-tool helper (the silent "tool never appears" failure).
129
- try {
130
- const names = await listWorkflows()
131
- const warn = toolHelper ? ""
132
- : " — WARN: @opencode-ai/plugin 'tool' helper missing; the workflow tool may NOT register on this OpenCode version"
133
- console.error(`[geneseed-workflow] loaded — tool 'workflow' registered, ${names.length} workflow(s) [${names.join(", ") || "none"}] from ${WORKFLOWS_DIR}${warn}`)
134
- } catch (e) { console.error(`[geneseed-workflow] loaded but could not list workflows: ${e?.message || e}`) }
135
-
136
123
  // Normalize the `args` parameter. OpenCode's tool layer can deliver a structured
137
124
  // object param as a JSON STRING (or drop it) when the schema is untyped — so coerce
138
125
  // a string back to an object and reject non-object shapes to `{}`. Without this, the
@@ -185,22 +172,52 @@ export const GeneseedWorkflow = async (ctx) => {
185
172
  return summarize(name, result, stats, tracePath)
186
173
  }
187
174
 
175
+ return execute
176
+ }
177
+
178
+ // The `workflow` tool as the model sees it, on both majors.
179
+ const TOOL_DESCRIPTION =
180
+ "Run a saved, code-driven Geneseed workflow that orchestrates subagents " +
181
+ "deterministically (parallel fan-out, find→verify pipeline, phased handoff). " +
182
+ "Call with no name to list available workflows."
183
+ const NAME_DESCRIPTION = "saved workflow name; omit to list available"
184
+ const ARGS_DESCRIPTION = "inputs passed to the workflow script as `args` (object, or a JSON object string)"
185
+
186
+ // One-line load banner (always on) so a restart visibly confirms the plugin loaded and the
187
+ // `workflow` tool is registered. tests/host_canary.mjs reads it as V1's proof of load.
188
+ async function loadBanner(warn = "") {
189
+ try {
190
+ const names = await listWorkflows()
191
+ console.error(`[geneseed-workflow] loaded — tool 'workflow' registered, ${names.length} workflow(s) [${names.join(", ") || "none"}] from ${WORKFLOWS_DIR}${warn}`)
192
+ } catch (e) { console.error(`[geneseed-workflow] loaded but could not list workflows: ${e?.message || e}`) }
193
+ }
194
+
195
+ export const GeneseedWorkflow = async (ctx) => {
196
+ const { client, directory, worktree } = ctx
197
+ let toolHelper
198
+ try { ({ tool: toolHelper } = await import("@opencode-ai/plugin")) }
199
+ catch { dlog("@opencode-ai/plugin tool helper unavailable — registering a raw tool def") }
200
+
201
+ // Warn when this OpenCode build lacks the custom-tool helper (the silent "tool never
202
+ // appears" failure).
203
+ await loadBanner(toolHelper ? ""
204
+ : " — WARN: @opencode-ai/plugin 'tool' helper missing; the workflow tool may NOT register on this OpenCode version")
205
+
206
+ const execute = makeExecute({ client, directory, worktree })
207
+
188
208
  const def = {
189
- description:
190
- "Run a saved, code-driven Geneseed workflow that orchestrates subagents " +
191
- "deterministically (parallel fan-out, find→verify pipeline, phased handoff). " +
192
- "Call with no name to list available workflows.",
209
+ description: TOOL_DESCRIPTION,
193
210
  args: toolHelper
194
211
  ? {
195
- name: toolHelper.schema.string().optional().describe("saved workflow name; omit to list available"),
212
+ name: toolHelper.schema.string().optional().describe(NAME_DESCRIPTION),
196
213
  // Keep this `any()` — it's the only param shape known to register cleanly on
197
214
  // this OpenCode build (record()/object schemas wedged tool registration → every
198
215
  // session message stuck "queued"). The provider may still deliver the object as
199
216
  // a JSON string or empty; normalizeArgs() in execute() recovers both cases.
200
- args: toolHelper.schema.any().optional().describe("inputs passed to the workflow script as `args` (object, or a JSON object string)"),
217
+ args: toolHelper.schema.any().optional().describe(ARGS_DESCRIPTION),
201
218
  }
202
219
  : {
203
- name: { type: "string", description: "saved workflow name; omit to list available" },
220
+ name: { type: "string", description: NAME_DESCRIPTION },
204
221
  args: { type: "object", description: "inputs passed to the workflow script as `args`" },
205
222
  },
206
223
  async execute(argv) { return execute(argv) },
@@ -209,4 +226,87 @@ export const GeneseedWorkflow = async (ctx) => {
209
226
  return { tool: { workflow: toolHelper ? toolHelper(def) : def } }
210
227
  }
211
228
 
212
- export default GeneseedWorkflow
229
+ // ---- OpenCode 2 ----------------------------------------------------------------
230
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
231
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
232
+ //
233
+ // V2 registers tools through `tool.transform` with a plain JSON-schema `input` (no
234
+ // `@opencode-ai/plugin` helper — that package is `@opencode/plugin` there, and not
235
+ // needed). The runtime (`_runtime.js`) and every saved workflow speak the V1 SDK client,
236
+ // so `v2Client` presents that client's few calls over the V2 session domain instead of
237
+ // forking the runtime:
238
+ // create → session.create (a worktree directory becomes the session's location)
239
+ // prompt → switchAgent/switchModel when asked, then session.prompt — which only
240
+ // admits the input on V2 — then session.wait for the loop to go idle, and the
241
+ // last assistant message read back from session.context
242
+ // messages → session.context, reshaped to V1's `{ info, parts }`
243
+ // delete → session.remove
244
+ function v2Message(m) {
245
+ if (m?.type === "assistant") {
246
+ return {
247
+ info: { role: "assistant", tokens: m.tokens },
248
+ parts: (m.content || []).filter((p) => p?.type === "text").map((p) => ({ type: "text", text: p.text })),
249
+ }
250
+ }
251
+ return { info: { role: m?.type }, parts: typeof m?.text === "string" ? [{ type: "text", text: m.text }] : [] }
252
+ }
253
+ function v2ModelRef(model) {
254
+ if (typeof model === "string" && model.includes("/")) {
255
+ const [providerID, ...rest] = model.split("/")
256
+ return { providerID, id: rest.join("/") }
257
+ }
258
+ const id = model?.id ?? model?.modelID
259
+ return model?.providerID && id ? { providerID: model.providerID, id } : null
260
+ }
261
+ function v2Client(ctx) {
262
+ const context = async (sid) => (await ctx.session.context({ sessionID: sid })) || []
263
+ return {
264
+ session: {
265
+ create: async ({ body, query } = {}) => ctx.session.create({
266
+ ...(body?.title ? { title: body.title } : {}),
267
+ ...(query?.directory ? { location: { directory: query.directory } } : {}),
268
+ }),
269
+ prompt: async ({ path: p, body } = {}) => {
270
+ const sessionID = p?.id
271
+ if (body?.agent) await ctx.session.switchAgent({ sessionID, agent: body.agent })
272
+ const model = v2ModelRef(body?.model)
273
+ if (model) await ctx.session.switchModel({ sessionID, model })
274
+ const text = (body?.parts || []).filter((x) => x?.type === "text").map((x) => x.text).join("\n")
275
+ await ctx.session.prompt({ sessionID, text })
276
+ await ctx.session.wait({ sessionID })
277
+ const last = (await context(sessionID)).filter((m) => m?.type === "assistant").pop()
278
+ return last ? v2Message(last) : null
279
+ },
280
+ messages: async ({ path: p } = {}) => (await context(p?.id)).map(v2Message),
281
+ delete: async ({ path: p } = {}) => ctx.session.remove({ sessionID: p?.id }),
282
+ },
283
+ }
284
+ }
285
+
286
+ async function setupV2(ctx) {
287
+ const directory = ctx?.location?.directory
288
+ const worktree = ctx?.location?.project?.directory
289
+ const execute = makeExecute({ client: v2Client(ctx), directory, worktree })
290
+ await loadBanner()
291
+ await ctx.tool.transform((editor) => {
292
+ editor.add({
293
+ name: "workflow",
294
+ description: TOOL_DESCRIPTION,
295
+ input: {
296
+ type: "object",
297
+ properties: {
298
+ name: { type: "string", description: NAME_DESCRIPTION },
299
+ args: { description: ARGS_DESCRIPTION },
300
+ },
301
+ },
302
+ // A rejected promise here is a defect that fails the whole agent step, not the call:
303
+ // every failure comes back as text, as the V1 tool's do.
304
+ execute: async (input) => {
305
+ try { return { content: await execute(input) } }
306
+ catch (e) { return { content: `Workflow failed: ${e?.message || e}` } }
307
+ },
308
+ })
309
+ })
310
+ }
311
+
312
+ export default { id: "geneseed-workflow", server: GeneseedWorkflow, setup: setupV2 }
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "Set up"
7
7
  description: "File access limited to the folders you list, and the placeholder path to replace."
8
8
  ---
9
- One of the [MCP server](mcp.md) presets. Where each host keeps the config, and how to check the server connected, are on that page.
9
+ One of the [MCP servers](mcp.md) Geneseed documents. Where each host keeps the config, and how to check the server connected, are on that page.
10
10
 
11
11
  [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) gives the agent file access limited to the folders you list, through `npx`. **The allowed folders are command-line arguments.** The server refuses any path outside them, so grant the narrowest set that works, not `$HOME` or `/`.
12
12
 
@@ -34,7 +34,7 @@ One of the [MCP server](mcp.md) presets. Where each host keeps the config, and h
34
34
  ```
35
35
  <!--/harness-->
36
36
 
37
- > **Replace the placeholder.** The preset ships with the literal path `/path/to/allowed/dir`. If you enable the server without changing it, the server runs but can reach nothing. This is the classic "the filesystem MCP sees nothing" problem.
37
+ > **Replace the placeholder.** The blocks above end with a sample path. If you register the server without changing it, the server runs but can reach nothing. This is the classic "the filesystem MCP sees nothing" problem.
38
38
 
39
39
  ---
40
40
 
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "Set up"
7
7
  description: "Merge requests, issues and CI from the agent: token scopes, the config block, a second instance."
8
8
  ---
9
- One of the [MCP server](mcp.md) presets. Where each host keeps the config, and how to check the server connected, are on that page.
9
+ One of the [MCP servers](mcp.md) Geneseed documents. Where each host keeps the config, and how to check the server connected, are on that page.
10
10
 
11
11
  > **Never commit a real token.** The presets ship with an empty `GITLAB_PERSONAL_ACCESS_TOKEN` and a sample filesystem path. Fill them in your own config, never in a tracked file.
12
12
 
@@ -59,7 +59,7 @@ You can also register it with `claude mcp add`.
59
59
 
60
60
  Keep the `/api/v4` suffix on the URL.
61
61
 
62
- **A second instance** (gitlab.com plus a self-hosted server, for example) is a copy of the same block under a new name, such as `gitlab-acme`, with its own `GITLAB_API_URL` and token. The preset covers the first instance only. You add the copy by hand.
62
+ **A second instance** (gitlab.com plus a self-hosted server, for example) is a copy of the same block under a new name, such as `gitlab-acme`, with its own `GITLAB_API_URL` and token. You add the copy by hand, like the first.
63
63
 
64
64
  ---
65
65
 
@@ -6,13 +6,13 @@ kind: "concept"
6
6
  section: "Set up"
7
7
  description: "Convert PDF and Office files to markdown: uvx or pipx, OCR extras, corporate TLS."
8
8
  ---
9
- One of the [MCP server](mcp.md) presets. Where each host keeps the config, and how to check the server connected, are on that page.
9
+ One of the [MCP servers](mcp.md) Geneseed documents. Where each host keeps the config, and how to check the server connected, are on that page.
10
10
 
11
11
  [MarkItDown](https://github.com/microsoft/markitdown) (Microsoft) converts PDF, Word, Excel, PowerPoint and HTML to markdown. It exposes one tool, `convert_to_markdown(uri)`, which accepts `file:`, `http:`, `https:` and `data:` URIs.
12
12
 
13
13
  **1. Make the command resolve.**
14
14
 
15
- - **`uvx` (recommended, nothing to install).** If you have [uv](https://docs.astral.sh/uv/), `uvx markitdown-mcp` fetches and caches the server on first use. This is the command the preset uses.
15
+ - **`uvx` (recommended, nothing to install).** If you have [uv](https://docs.astral.sh/uv/), `uvx markitdown-mcp` fetches and caches the server on first use. This is the command the blocks in [MCP servers](mcp.md) use.
16
16
  - **`pipx`.** Without uv, run `pipx install markitdown-mcp`, then use `markitdown-mcp` alone as the command (OpenCode: `["markitdown-mcp"]`; Claude: `"command": "markitdown-mcp"`, no args).
17
17
 
18
18
  ```
@@ -29,7 +29,7 @@ export UV_SYSTEM_CERTS=true # older uv: UV_NATIVE_TLS=true
29
29
  # fallback: export SSL_CERT_FILE=/path/to/corporate-root-ca.pem
30
30
  ```
31
31
 
32
- **3. Register it**, with the console toggle or one of the blocks in [MCP servers](mcp.md). Then restart your agent.
32
+ **3. Register it** by adding one of the blocks in [MCP servers](mcp.md) to your config. Then restart your agent.
33
33
 
34
34
  ---
35
35
 
@@ -4,15 +4,15 @@ order: 16
4
4
  title: "MCP servers"
5
5
  kind: "concept"
6
6
  section: "Set up"
7
- description: "Where MCP config lives for each host, the three presets, and how to check a server connected."
7
+ description: "Where MCP config lives for each host, three servers worth adding, and how to check a server connected."
8
8
  ---
9
- MCP (Model Context Protocol) servers give your agent extra tools: converting a PDF, opening a merge request, reading files outside the repo. Geneseed ships three ready-made server **presets** (MarkItDown, GitLab and Filesystem) that you can switch on without writing JSON by hand.
9
+ MCP (Model Context Protocol) servers give your agent extra tools: converting a PDF, opening a merge request, reading files outside the repo. Geneseed documents three servers worth adding (MarkItDown, GitLab and Filesystem), with the config block for each host. You add them to your config yourself: which servers you need depends on you and on your host.
10
10
 
11
11
  > **You already know this:** an MCP entry is a line in a Procfile. It names a command the agent starts on demand. Writing the line does not install the program behind it.
12
12
 
13
- Each preset is a *local* server, a command your agent launches when it needs the server. Registering a preset only points the agent at that command. **You** install the tool behind it (or let `uvx` or `npx` fetch it) and you supply any credentials.
13
+ Each of the three is a *local* server, a command your agent launches when it needs the server. Registering one only points the agent at that command. **You** install the tool behind it (or let `uvx` or `npx` fetch it) and you supply any credentials.
14
14
 
15
- > **Never commit a real token.** The presets ship with an empty `GITLAB_PERSONAL_ACCESS_TOKEN` and a sample filesystem path. Fill them in your own config, never in a tracked file.
15
+ > **Never commit a real token.** The blocks here carry an empty `GITLAB_PERSONAL_ACCESS_TOKEN` and a sample filesystem path. Fill them in your own config, never in a tracked file.
16
16
 
17
17
  ## Where the config lives
18
18
 
@@ -35,11 +35,11 @@ The same MarkItDown server in both shapes:
35
35
  { "mcpServers": { "markitdown": { "command": "uvx", "args": ["markitdown-mcp"] } } }
36
36
  ```
37
37
 
38
- **Toggle instead of editing.** In the web console (`geneseed web`), **Settings → MCP servers** adds, enables, disables or removes any preset in the right file, already in the right shape. Not sure which file that is? The **Harnesses** page shows the config path for each install, and `geneseed mcp` prints the same paths and lists the servers each install has wired, without starting anything.
38
+ **See what is wired.** Geneseed never writes these files. The web console (`geneseed web`) shows, for each active install, its config path and the servers it holds, enabled or disabled, with a button that opens the folder so you can edit the file. `geneseed mcp` prints the same in a terminal, without starting anything.
39
39
 
40
- The reference OpenCode config ships MarkItDown enabled and the GitLab and Filesystem entries disabled, so a merge never switches on a server that has no credentials yet. Fill in the blanks, then switch the server on.
40
+ The reference OpenCode config ships MarkItDown enabled and the GitLab and Filesystem entries disabled, so a merge never switches on a server that has no credentials yet. Fill in the blanks, then set `enabled` to `true`.
41
41
 
42
- ## The presets
42
+ ## Three servers worth adding
43
43
 
44
44
  - [MarkItDown](mcp-markitdown.md): converts PDF, Word, Excel, PowerPoint and HTML to markdown.
45
45
  - [GitLab](mcp-gitlab.md): repository, merge request, issue and CI tools over the GitLab API.
@@ -99,7 +99,7 @@ A line is simply left out when its source is missing.
99
99
 
100
100
  ## How OpenCode delivers it
101
101
 
102
- By default, the plugin delivers the context invisibly. It prepends the context to each request through OpenCode's experimental `experimental.chat.messages.transform` hook, so nothing appears in the conversation. Because it is re-sent with every request, the context also survives compaction. If your OpenCode build lacks that hook, the plugin notices and falls back to posting a visible `PROJECT CONTEXT` message.
102
+ By default, the plugin delivers the context invisibly. It prepends the context to each request through OpenCode's experimental `experimental.chat.messages.transform` hook, so nothing appears in the conversation. Because it is re-sent with every request, the context also survives compaction. If your OpenCode build lacks that hook, the plugin notices and falls back to posting a visible `PROJECT CONTEXT` message. On OpenCode 2 the same invisible delivery goes through its stable per-request `context` hook, so no fallback is needed.
103
103
 
104
104
  - `GENESEED_CONTEXT_VISIBLE=1` always shows the visible block, so you can see what the agent received.
105
105
  - `GENESEED_CONTEXT_INJECT=off` turns injection off. The agent then relies on the instruction in `AGENT.md` to read the docs itself.
@@ -14,6 +14,15 @@ Every plugin installs in the same step as the rest of the harness: `geneseed set
14
14
 
15
15
  Every plugin swallows its own errors — none can block or crash a session. Each plugin's page lists the variables that tune it.
16
16
 
17
+ ## OpenCode 2
18
+
19
+ The same files work on OpenCode 1 (1.3.4 or newer) and OpenCode 2. OpenCode 2 rewrote the plugin API, and each plugin carries both halves. You don't need to configure anything. Two differences show on OpenCode 2:
20
+
21
+ - **A guard block shows up as a failed tool call.** The call never runs. The model gets an error that starts with `[geneseed-guard] blocked:` and gives the reason.
22
+ - **The Activity view shows no todo list or files-touched count.** OpenCode 2 publishes no event for either.
23
+
24
+ If a plugin seems silent on OpenCode 2, check that `~/.config/opencode/plugins/` holds the current files. Copies from before this change fail to load with *"Plugin must export a default definition with an id and an effect or setup function"*. Running `geneseed rebuild-all` replaces them.
25
+
17
26
  ## The plugins
18
27
 
19
28
  - [geneseed-context](plugin-context.md): Puts your project docs, rules, profile, memory index and wiki in front of the agent.
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.0.0"
5
+ "version": "4.1.0"
6
6
  }
package/js/cli-table.json CHANGED
@@ -901,7 +901,7 @@
901
901
  },
902
902
  {
903
903
  "name": "mcp",
904
- "help": "list the MCP servers wired into each active install, and the presets available to add",
904
+ "help": "list the MCP servers wired into each active install, read-only",
905
905
  "description": "",
906
906
  "positionals": [],
907
907
  "options": [],
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;
@@ -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 '