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.
Files changed (115) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/adapters/claude-code/README.md +0 -21
  5. package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
  6. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  7. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  8. package/adapters/opencode/plugins/geneseed-guard.js +64 -1
  9. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  10. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  11. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  12. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  13. package/docs/guides/mcp-filesystem.md +2 -2
  14. package/docs/guides/mcp-gitlab.md +2 -2
  15. package/docs/guides/mcp-markitdown.md +3 -3
  16. package/docs/guides/mcp.md +7 -7
  17. package/docs/guides/project-context.md +1 -1
  18. package/docs/guides/uninstall.md +2 -0
  19. package/docs/guides/upgrade.md +2 -0
  20. package/docs/reference/opencode-plugins.md +9 -0
  21. package/docs/token-footprint.md +1 -1
  22. package/docs/understand/take-it-out.md +1 -1
  23. package/harness.config.json +1 -1
  24. package/js/README.md +6 -0
  25. package/js/build/driver.mjs +47 -11
  26. package/js/build/emit-claude.mjs +22 -10
  27. package/js/build/emit-common.mjs +9 -3
  28. package/js/build/emit-opencode.mjs +18 -11
  29. package/js/build/source.mjs +1 -0
  30. package/js/build/version.mjs +10 -3
  31. package/js/cli-table.json +16 -1
  32. package/js/hosts/hooks.mjs +4 -0
  33. package/js/hosts/installs.mjs +1 -1
  34. package/js/hosts/mcp.mjs +23 -152
  35. package/js/hosts/native.mjs +38 -6
  36. package/js/hosts/opencode.mjs +1 -1
  37. package/js/hosts/owned.mjs +159 -0
  38. package/js/hosts/settings.mjs +4 -0
  39. package/js/hosts/usage.mjs +62 -0
  40. package/js/inspect/checks-repo.mjs +76 -1
  41. package/js/inspect/cost.mjs +83 -0
  42. package/js/inspect/doctor.mjs +2 -1
  43. package/js/inspect/inventory.mjs +1 -1
  44. package/js/maintain/uninstall.mjs +77 -42
  45. package/js/web/actions.mjs +15 -77
  46. package/js/web/api.mjs +74 -4
  47. package/js/web/gates-api.mjs +44 -0
  48. package/js/web/handler.mjs +2 -2
  49. package/js/web/routes.mjs +9 -4
  50. package/js/web/update-check.mjs +61 -0
  51. package/js/web/usage-api.mjs +95 -0
  52. package/package.json +1 -1
  53. package/registry.json +18 -25
  54. package/src/AGENT.md.tmpl +0 -1
  55. package/src/agents/_template.md +2 -1
  56. package/src/agents/researcher.md +2 -1
  57. package/src/agents/reviewer.md +9 -4
  58. package/src/agents/tester.md +3 -3
  59. package/src/doctrines/rigor.md +8 -7
  60. package/src/skills/brainstorm/builder-check.md +16 -0
  61. package/src/skills/brainstorm.md +19 -11
  62. package/src/skills/daydream/SKILL.md +1 -1
  63. package/src/skills/daydream/VENDOR.md +4 -0
  64. package/src/skills/debug/ci.md +4 -4
  65. package/src/skills/debug.md +4 -4
  66. package/src/skills/develop.md +15 -4
  67. package/src/skills/fresh-eyes.md +1 -1
  68. package/src/skills/geneseed-code-review.md +13 -3
  69. package/src/skills/handoff.md +1 -1
  70. package/src/skills/parallel-agents.md +4 -4
  71. package/src/skills/pipeline.md +3 -3
  72. package/src/skills/plan.md +18 -6
  73. package/src/skills/review-response.md +5 -2
  74. package/src/skills/security-audit.md +7 -1
  75. package/src/skills/ship.md +2 -1
  76. package/src/skills/wayfinder.md +25 -6
  77. package/src/skills/worktree.md +7 -5
  78. package/themes/_TEMPLATE.json +0 -1
  79. package/themes/biker.json +0 -1
  80. package/themes/commentator.json +0 -1
  81. package/themes/cyberpunk.json +0 -1
  82. package/themes/gamer.json +0 -1
  83. package/themes/imperial.json +0 -1
  84. package/themes/joker.json +0 -1
  85. package/themes/marvin.json +0 -1
  86. package/themes/mean.json +0 -1
  87. package/themes/military.json +0 -1
  88. package/themes/neutral.json +0 -1
  89. package/themes/pirate.json +0 -1
  90. package/themes/sports.json +0 -1
  91. package/themes/verstappen.json +0 -1
  92. package/themes/wizard.json +0 -1
  93. package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
  94. package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
  95. package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
  96. package/web/dist/assets/Docs-CD20mrVv.js +5 -0
  97. package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
  98. package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
  99. package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
  100. package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
  101. package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
  102. package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
  103. package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
  104. package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
  105. package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
  106. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  107. package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
  108. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  109. package/web/dist/assets/index-DoD_g2CM.js +2 -0
  110. package/web/dist/index.html +3 -3
  111. package/src/skills/herdr.md +0 -154
  112. package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
  113. package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
  114. package/web/dist/assets/api-CxgfYu18.js +0 -1
  115. package/web/dist/assets/index-CiXwChvf.js +0 -2
@@ -301,6 +301,23 @@ async function writeMemories(output, memDir, existing) {
301
301
  return written
302
302
  }
303
303
 
304
+ // The distil's reply into the store: a child session's one agent lesson, else memory
305
+ // files plus their MEMORY.md lines. Shared by the V1 and V2 runs.
306
+ async function storeDistil(output, { isChild, memDir, agentName, existing }) {
307
+ if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
308
+ if (isChild) {
309
+ const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
310
+ if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
311
+ const f = await appendAgentLesson(memDir, agentName, lesson)
312
+ console.error(`[geneseed-learn] agent lesson -> ${f}`)
313
+ return
314
+ }
315
+ const written = await writeMemories(output, memDir, existing)
316
+ if (written.length) {
317
+ console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
318
+ }
319
+ }
320
+
304
321
  export const GeneseedLearn = async (ctx) => {
305
322
  const { client } = ctx ?? {}
306
323
  const ours = new Set() // throwaway distil sessions — never mine our own output
@@ -376,18 +393,7 @@ export const GeneseedLearn = async (ctx) => {
376
393
  if (newId) await client.session.delete({ path: { id: newId } }).catch(() => {})
377
394
  }
378
395
 
379
- if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
380
- if (isChild) {
381
- const lesson = output.trim().split("\n")[0].replace(/^[-*]\s*/, "").trim()
382
- if (lesson.length < 10 || lesson.length > 300) return // junk / truncation guard
383
- const f = await appendAgentLesson(memDir, agentName, lesson)
384
- console.error(`[geneseed-learn] agent lesson -> ${f}`)
385
- return
386
- }
387
- const written = await writeMemories(output, memDir, existing)
388
- if (written.length) {
389
- console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
390
- }
396
+ await storeDistil(output, { isChild, memDir, agentName, existing })
391
397
  } catch (err) {
392
398
  // Never break the session over a memory write.
393
399
  console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
@@ -426,6 +432,113 @@ export const GeneseedLearn = async (ctx) => {
426
432
 
427
433
  // OpenCode treats every export as a plugin and rejects non-functions; hang the test
428
434
  // helpers off the factory instead — reachable via import, invisible to the loader.
429
- Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson })
435
+ Object.assign(GeneseedLearn, { writeMemories, resolveAgentName, appendAgentLesson, flattenV2 })
436
+
437
+ // ---- OpenCode 2 ----------------------------------------------------------------
438
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
439
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
440
+ //
441
+ // What differs on V2:
442
+ // - The debounce re-arms on `session.execution.succeeded`: V2 core never publishes
443
+ // `session.idle` (deprecated in its schema), and the execution pair is its turn
444
+ // boundary. Events are `{ type, data }`, not `{ type, properties }`.
445
+ // - The transcript is `session.context()` — the messages after the last compaction —
446
+ // typed `user` (text) / `assistant` (content parts, `model: { providerID, id }`).
447
+ // - The distil is one stateless `generate.text({ prompt, model })` call with the
448
+ // session's own model: no throwaway session to create, guard against and delete.
449
+ function flattenV2(messages) {
450
+ const lines = []
451
+ let model = null
452
+ for (const m of messages || []) {
453
+ if (m?.type === "user") {
454
+ const text = String(m.text ?? "").trim()
455
+ if (text) lines.push(`user: ${text}`)
456
+ } else if (m?.type === "assistant") {
457
+ const text = (m.content || [])
458
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
459
+ .map((p) => p.text).join("\n").trim()
460
+ if (text) lines.push(`assistant: ${text}`)
461
+ if (m.model?.providerID && m.model?.id) model = m.model
462
+ }
463
+ }
464
+ return { notes: lines.join("\n\n"), model }
465
+ }
466
+
467
+ async function setupV2(ctx) {
468
+ const root = ctx?.location?.project?.directory || ctx?.location?.directory || process.cwd()
469
+ const timers = new Map()
470
+ const inFlight = new Set()
471
+ let warnedNoDir = false
472
+
473
+ async function runDistill(sid) {
474
+ if (await sovereignBypass(root)) return
475
+ if (inFlight.has(sid)) return
476
+ inFlight.add(sid)
477
+ try {
478
+ const meta = await ctx.session.get({ sessionID: sid }).catch(() => null)
479
+ if (String(meta?.title ?? "").startsWith("geneseed-")) return
480
+ const isChild = !!meta?.parentID
481
+ const agentName = isChild ? resolveAgentName(meta) : null
482
+ if (isChild && !agentName) return
483
+
484
+ const memDir = await resolveMemoryDir()
485
+ if (!memDir) {
486
+ if (!warnedNoDir) {
487
+ warnedNoDir = true
488
+ console.error("[geneseed-learn] no memory dir — set $GENESEED_HARNESS or $GENESEED_MEMORY.")
489
+ }
490
+ return
491
+ }
492
+
493
+ let { notes, model } = flattenV2(await ctx.session.context({ sessionID: sid }))
494
+ if (notes.length < MIN_NOTES_CHARS) return
495
+ notes = notes.slice(-MAX_NOTES_CHARS)
496
+ if (!model) {
497
+ const env = envModel()
498
+ model = env ? { providerID: env.providerID, id: env.modelID } : null
499
+ }
500
+ if (!model) {
501
+ console.error("[geneseed-learn] could not determine a model — set $GENESEED_MODEL=provider/model.")
502
+ return
503
+ }
504
+
505
+ const existing = await existingSlugs(memDir)
506
+ const prompt = isChild
507
+ ? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
508
+ : buildPrompt(notes, existing)
509
+ const reply = await ctx.generate.text({
510
+ prompt, model: { providerID: model.providerID, id: model.id, ...(model.variant ? { variant: model.variant } : {}) },
511
+ })
512
+ await storeDistil(String(reply?.text ?? ""), { isChild, memDir, agentName, existing })
513
+ } catch (err) {
514
+ console.error(`[geneseed-learn] skipped: ${err?.message ?? err}`)
515
+ } finally {
516
+ inFlight.delete(sid)
517
+ }
518
+ }
519
+
520
+ const controller = new AbortController()
521
+ void (async () => {
522
+ for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
523
+ const sid = ev?.data?.sessionID
524
+ if (!sid) continue
525
+ if (ev.type === "session.deleted") {
526
+ const t = timers.get(sid)
527
+ if (t) clearTimeout(t)
528
+ timers.delete(sid)
529
+ continue
530
+ }
531
+ if (ev.type !== "session.execution.succeeded") continue
532
+ const prev = timers.get(sid)
533
+ if (prev) clearTimeout(prev)
534
+ timers.set(sid, setTimeout(() => { timers.delete(sid); runDistill(sid) }, DEBOUNCE_MS))
535
+ }
536
+ })().catch((err) => console.error(`[geneseed-learn] event stream ended: ${err?.message ?? err}`))
537
+ return () => {
538
+ controller.abort()
539
+ for (const t of timers.values()) clearTimeout(t)
540
+ timers.clear()
541
+ }
542
+ }
430
543
 
431
- export default GeneseedLearn
544
+ export default { id: "geneseed-learn", server: GeneseedLearn, setup: setupV2 }
@@ -157,4 +157,49 @@ export const GeneseedNotify = async ({ client }) => {
157
157
  // off the factory instead — reachable via `import`, invisible to the loader.
158
158
  Object.assign(GeneseedNotify, { lastUserMs, shouldNotify, notifyCommand })
159
159
 
160
- export default GeneseedNotify
160
+ // ---- OpenCode 2 ----------------------------------------------------------------
161
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
162
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
163
+ //
164
+ // V2 core never publishes `session.idle` (deprecated in its schema). The turn boundary is
165
+ // the execution pair instead: `session.execution.started` stamps when the agent began,
166
+ // `session.execution.succeeded`/`failed` ends it — so the turn length comes straight off
167
+ // the events, no transcript read. An interrupted execution is the user's own doing (or a
168
+ // shutdown) and is not announced. Events are `{ type, created, data }`, not V1's
169
+ // `{ type, properties }`.
170
+ async function setupV2(ctx) {
171
+ if (OFF) return
172
+ const started = new Map()
173
+ const controller = new AbortController()
174
+ void (async () => {
175
+ for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
176
+ const sid = ev?.data?.sessionID
177
+ if (!sid) continue
178
+ if (ev.type === "session.execution.started") { started.set(sid, ev.created); continue }
179
+ if (ev.type === "session.execution.interrupted" || ev.type === "session.deleted") { started.delete(sid); continue }
180
+ if (ev.type !== "session.execution.succeeded" && ev.type !== "session.execution.failed") continue
181
+ const begin = started.get(sid) ?? null
182
+ started.delete(sid)
183
+ try {
184
+ let title = "", parentID
185
+ try {
186
+ const info = await ctx.session.get({ sessionID: sid })
187
+ title = info?.title ?? ""
188
+ parentID = info?.parentID
189
+ } catch {}
190
+ const now = typeof ev.created === "number" ? ev.created : Date.now()
191
+ if (!shouldNotify({ now, lastUserMs: begin, parentID, title, minMs: MIN_MS })) {
192
+ log(`skipped ${sid} (child/throwaway or turn under ${MIN_MS}ms)`)
193
+ continue
194
+ }
195
+ deliver(TITLE, title ? `Done: ${title}` : "Agent finished — your turn.")
196
+ log(`notified for ${sid}`)
197
+ } catch (err) {
198
+ log(`skipped: ${err?.message ?? err}`)
199
+ }
200
+ }
201
+ })().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
202
+ return () => controller.abort()
203
+ }
204
+
205
+ export default { id: "geneseed-notify", server: GeneseedNotify, setup: setupV2 }
@@ -143,6 +143,48 @@ export const GeneseedPonytail = async () => ({
143
143
  // any that isn't a function — a bare helper export crashes startup ("null is not an
144
144
  // object") or logs "not a function". Hang the test helpers off the factory instead;
145
145
  // they ride along for `import`, invisible to the loader. Upgrade path: none needed.
146
- Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions })
146
+ Object.assign(GeneseedPonytail, { normalizeMode, defaultMode, ponytailInstructions, levelFromPrompt })
147
147
 
148
- export default GeneseedPonytail
148
+ // --- OpenCode 2 --------------------------------------------------------------
149
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
150
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
151
+ //
152
+ // V2 has neither hook. `session.hook("context")` carries the outgoing system parts (the
153
+ // ruleset is pushed there), and there is no `command.execute.before`: a markdown command
154
+ // expands its template into the prompt, so the switch is read off the prompt instead —
155
+ // the first line of PONYTAIL_COMMAND_BODY (js/hosts/opencode.mjs), `Ponytail level
156
+ // requested: <args>`. Change that line and this pattern together.
157
+ const PONYTAIL_PROMPT_RE = /^Ponytail level requested:[ \t]*(.*)$/m
158
+
159
+ // The level a `/ponytail` prompt asks for: undefined when the text is not one, null
160
+ // when its argument is no known level, else the level (empty argument = `full`).
161
+ function levelFromPrompt(text) {
162
+ const m = PONYTAIL_PROMPT_RE.exec(String(text ?? ""))
163
+ if (!m || m.index !== 0) return undefined
164
+ const arg = m[1].trim()
165
+ return normalizeMode(arg) || (arg ? null : "full")
166
+ }
167
+
168
+ async function setupV2(ctx) {
169
+ await ctx.session.hook("prompt", (event) => {
170
+ try {
171
+ const mode = levelFromPrompt(event?.prompt?.text)
172
+ if (mode === undefined) return
173
+ if (!mode) { log("ignored unknown level"); return }
174
+ writeMode(mode)
175
+ log(`ponytail ${mode}`)
176
+ } catch (err) {
177
+ log(`prompt hook error: ${err?.message ?? err}`)
178
+ }
179
+ })
180
+ await ctx.session.hook("context", (event) => {
181
+ try {
182
+ const text = ponytailInstructions(readMode())
183
+ if (text && Array.isArray(event?.system)) event.system.push({ type: "text", text })
184
+ } catch (err) {
185
+ log(`context hook error: ${err?.message ?? err}`)
186
+ }
187
+ })
188
+ }
189
+
190
+ export default { id: "geneseed-ponytail", server: GeneseedPonytail, setup: setupV2 }
@@ -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.
@@ -28,6 +28,8 @@ Each install keeps a manifest, a list of the files Geneseed wrote. **Uninstall d
28
28
  - **OpenCode per repo:** `AGENT.md`, `.opencode/`, `laws/`, `agents/`, `skills/`, and the `instructions` entry in `opencode.json`.
29
29
  - **Claude Code, Bob, OpenClaude:** `agents/`, `skills/`, the marker files, Geneseed's managed block in the instructions file (`CLAUDE.md` or `AGENTS.md`), and Geneseed's own hooks and exclusions in `settings.json`. Your own settings keys and hooks stay.
30
30
 
31
+ **A file Geneseed wrote that you have since edited is kept.** The manifest records each file's fingerprint, so uninstall can tell your edit apart from Geneseed's copy. It leaves the edited file in place, names it (`kept 1 file(s) you edited: ...`), and finishes the uninstall anyway: the file is yours now, and no later rebuild touches it. To delete edited files too, pass `--force`. Changing only the line endings, as a git checkout on another system does, is not an edit.
32
+
31
33
  It also removes the install's row from the registry of installs. Start a new session in your tool afterwards so it stops loading the old files.
32
34
 
33
35
  ## What it keeps
@@ -48,6 +48,8 @@ A rebuild only replaces files the install's manifest says Geneseed owns. It leav
48
48
 
49
49
  The agent can refine its own deployed agent and skill files as it works, and so can you. A rebuild would overwrite those edits, so **setup, re-theme and upgrade export them first**. When the files they are about to overwrite differ from a fresh render, the differences are written to a markdown **improvements file** in `improvements/`, inside the install's own folder (for the OpenCode global install, `~/.config/opencode/improvements/`). The manifest does not list that folder, so rebuilds never overwrite it and uninstall never removes it.
50
50
 
51
+ A rebuild also refuses to overwrite a file you edited. The manifest records a fingerprint of every file Geneseed wrote; when a file no longer matches it, the rebuild leaves it alone and prints `kept your edited <file> — delete it to get Geneseed's copy back`. From then on the file is yours and later rebuilds skip it. A file the new version no longer ships is kept the same way, with `(no longer shipped)`. Line-ending changes from a git checkout do not count as edits. Installs made before fingerprints existed are overwritten as before.
52
+
51
53
  To see the drift yourself, or to export it on demand:
52
54
 
53
55
  ```
@@ -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.
@@ -93,7 +93,7 @@ until 2026-09 it did, and the whole harness was paid twice per session.
93
93
  `<!-- invocation: user -->` renders `disable-model-invocation: true`; Claude
94
94
  Code and Bob then drop its description from the always-on skill metadata and
95
95
  only `/name` opens it. The three teaching skills (`teach`, `quiz`, `learn-mode`)
96
- and `herdr` carry it today.
96
+ carry it today.
97
97
  - **Aliases cost 0 always-on.** A merged skill's old names (`<!-- aliases: -->`)
98
98
  ship as user-only stubs on Claude Code, Bob and OpenClaude, and as `/name` commands
99
99
  on OpenCode — never in the model's catalogue, so a rename or merge is free.
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "On your machine"
7
7
  description: "What geneseed uninstall removes, and what it keeps for you."
8
8
  ---
9
- `geneseed uninstall` removes only what Geneseed recorded writing: the rules file or its managed block, `agents/`, `skills/`, plugins, and Geneseed's own hook and config entries. It keeps `memory/` and `notebook/` in place, along with the files it seeded for you to edit (`user-rules.md`, `PROFILE.md`, `geneseed-wiki.jsonc`). Add `--archive-memory` to move memory and notebook aside instead. It still deletes nothing. See [Uninstall](../guides/uninstall.md).
9
+ `geneseed uninstall` removes only what Geneseed recorded writing: the rules file or its managed block, `agents/`, `skills/`, plugins, and Geneseed's own hook and config entries. It keeps `memory/` and `notebook/` in place, along with the files it seeded for you to edit (`user-rules.md`, `PROFILE.md`, `geneseed-wiki.jsonc`). Add `--archive-memory` to move memory and notebook aside instead. It still deletes nothing. A file Geneseed wrote that you have since edited stays too, and becomes yours; add `--force` to delete edited files as well. See [Uninstall](../guides/uninstall.md).
10
10
 
11
11
  ---
12
12
 
@@ -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.2.0"
6
6
  }