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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,42 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.1.0] — 2026-10-13
12
+
13
+ Minor: the planning, review, debugging and multi-agent skills take the lessons of Superpowers 7.0, and the host canary.
14
+
15
+ ### Changed
16
+ - **brainstorm** asks which build choices (platform, language, key libraries) you want to own; one the agent makes goes in the decision list as its own call. It sizes the work: a quick task is just done, a small change keeps the confirmed list in chat as its spec, and only a project gets a spec file. A fresh subagent then reads that spec as the person who will build it and asks what they would have to guess, replacing the agent rereading its own spec.
17
+ - **plan**: a step records decisions (files, signatures, the spec's values, the test, the check command), not code. A step is marked done only once its check has run green, with the command on the line. A decision taken without asking gets a `Ruling:` line that is reported at the end, and the mid-run "stop for review" clause is gone. The plan file names its task and its spec, and the spec wins a conflict.
18
+ - **debug**: compare against the nearest working twin, trace a bad value back to where it was born, cite evidence as `file:line`, and probe a secret's presence, never its value. The CI branch diffs against the last green run first and replaces guessed sleeps with bounded waits.
19
+ - **Code review**: where the spec says nothing, a crash, hang or data loss on an input it implies is still blocking. Findings end with a *Set aside* list the caller rules on, and `ship` rules on it before pushing. The base is `origin/<default branch>` after a fetch. The reviewer never moves HEAD and never dispatches another reviewer.
20
+ - **pipeline / parallel-agents**: the developer↔tester loop gets two repair rounds, then one fresh developer, then stops (it allowed five, against `bound-the-loop`). An ambiguity is decided and recorded rather than stalling, small edits of one kind run as one unit, no dispatched agent dispatches its own, and an empty diff counts as a failure. `worktree` never forces a refused removal.
21
+ - The `cover-and-verify` doctrine and `develop` run the whole suite once at the end and name every failure, including ones the change did not cause; per step, only the affected tests still run.
22
+
23
+ ### Fixed
24
+ - The daydream skill's cost line showed a mangled price on Claude Code, which replaces a dollar sign followed by a digit with the skill's arguments.
25
+ - On OpenCode 2, the Activity view now names each tool call. OpenCode 2 sends a tool's name only when its input starts streaming, not with the call itself, so every tool card read "undefined".
26
+
27
+ ### Added
28
+ - A host canary, `node tests/host_canary.mjs`. It installs the real OpenCode 1, OpenCode 2, Claude Code and OpenClaude from npm and points each at a throwaway home holding the Geneseed install. It fails when a host refuses one of our OpenCode plugins, reports one of our Claude settings as invalid, or never runs our session-start hook. It never calls a model and needs no account. It runs in CI every Monday against the latest releases and on every pull request that changes what Geneseed installs, so the next host release that breaks the contract is caught within a week rather than by a user. IBM Bob is not covered, because Bob Shell is not published on npm.
29
+
30
+ ## [4.0.2] — 2026-10-12
31
+
32
+ Patch: none of the OpenCode plugins loaded on OpenCode 2, and MCP is read-only.
33
+
34
+ ### Fixed
35
+ - The seven OpenCode plugins now load on OpenCode 2. OpenCode 2 rewrote the plugin API and refused every Geneseed plugin with *"Plugin must export a default definition with an id and an effect or setup function"*. It skipped them without crashing, so the context load, learn, notify, ponytail, activity and the `workflow` tool were all gone, and the guard's safety gates were silently off. Each plugin now carries both APIs in one file and works on OpenCode 1.3.4 or newer and on OpenCode 2. Plugins no longer load on OpenCode older than 1.3.4. On OpenCode 2 a guard block shows up as a failed tool call that gives the reason, and the Activity view shows no todo list or files-touched count, because OpenCode 2 sends no event for either. Run `geneseed rebuild-all` after updating to install the new plugin files.
36
+
37
+ ### Changed
38
+ - MCP is read-only in the web console and in `geneseed mcp`. Each active install lists only the servers its config already holds, enabled or disabled, with its config path and an **Open folder** button. The **Add** button, the on/off switch and the three built-in presets are gone. Which servers to wire depends on you and on your host, so you edit the config yourself; [MCP servers](docs/guides/mcp.md) still has the blocks for MarkItDown, GitLab and Filesystem. Geneseed no longer writes any MCP config, and the `POST /api/mcp` endpoint is removed.
39
+
40
+ ## [4.0.1] — 2026-10-12
41
+
42
+ Patch: the web console could not tell apart two project installs of the same host (#244).
43
+
44
+ ### Fixed
45
+ - Two project installs of one host (two repos on Bob, say) are now separate rows in the web console. Both used to get the id `bob:project`, so the Installs table showed both as selected and the topbar harness selector always switched back to the first. The id now carries the install root, and the selector names a project install by its folder. All four hosts had the bug and all four are covered by a test. Nothing is re-emitted; run `geneseed web restart` after updating.
46
+
11
47
  ## [4.0.0] — 2026-10-11
12
48
 
13
49
  Major: a host-compatibility pass against the current IBM Bob, Claude Code, OpenCode and OpenClaude docs (#240), the host opportunities and a root-scan guard (#241), and author notes kept out of what the agent reads (#242). It is a major because three behaviours change visibly: Claude Code gates now block when they cannot run, OpenCode no longer falls back to `~/.claude/CLAUDE.md`, and the silent *process 8* migration is gone.
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.0.0**.
23
+ > string. Current label: **4.1.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -64,6 +64,41 @@ export const MyPlugin = async ({ client, $, directory, worktree }) => ({
64
64
  **without** triggering a reply with `client.session.prompt({ path, body: { noReply: true, parts } })`.
65
65
  - **`$`** — a shell executor for running commands.
66
66
 
67
+ ### OpenCode 2 — a different plugin API
68
+
69
+ OpenCode 2 (the `@opencode/cli` package; V1 is still `opencode-ai`) still auto-loads every
70
+ `.js`/`.ts` in `plugins/` (or `plugin/`), but it accepts **only** a default-exported
71
+ definition: `export default { id, setup(ctx) }`. A V1 plugin fails to load with *"Plugin
72
+ must export a default definition with an id and an effect or setup function"*. Each one
73
+ is marked failed on its own and OpenCode carries on, so nothing warns you.
74
+
75
+ V1 (>= 1.3.4) also accepts an object default, `{ id, server }`, and each loader ignores
76
+ the other's key. So every Geneseed plugin exports **one object for both majors**:
77
+ `export default { id: "<file stem>", server: <V1 factory>, setup: <V2 setup> }`. On V1
78
+ older than 1.3.4 the plugins no longer load.
79
+
80
+ `setup(ctx)` gets domains instead of hooks returned in a map. Hooks register by name
81
+ (`ctx.tool.hook("execute.before")`, `ctx.session.hook("context" | "prompt" |
82
+ "compaction")`), tools through `ctx.tool.transform`, and events come from
83
+ `ctx.event.subscribe()` as `{ type, created, data }` (not `{ type, properties }`). What
84
+ changed for each plugin:
85
+
86
+ | V1 | V2 |
87
+ | --- | --- |
88
+ | `session.idle` | not published by core (deprecated). The turn ends with `session.execution.succeeded` / `failed` / `interrupted` |
89
+ | `experimental.chat.messages.transform` / `chat.system.transform` | `session.hook("context")`: mutable `system` parts and `LLM.Message` instances, on every request |
90
+ | `command.execute.before` | none. A markdown command's expanded template arrives through `session.hook("prompt")` |
91
+ | `client.session.messages` | `ctx.session.context({ sessionID })`: `user` / `assistant` messages after the last compaction |
92
+ | throwaway session + `prompt` to distil | `ctx.generate.text({ prompt, model })`: one stateless call |
93
+ | a hook throws to block a tool | a throw is a **defect that fails the whole step**. The guard renames the call instead (see `geneseed-guard.js`) |
94
+ | `@opencode-ai/plugin` `tool()` helper | `@opencode/plugin`; a plain JSON-schema `input` needs no helper |
95
+ | `todo.updated`, session `summary` diffs | no V2 event. The activity cards show no todos or files on V2 |
96
+ | tool part carries its name | `session.tool.called` has no name; it rides on `session.tool.input.started` |
97
+ | tools `bash`, `apply_patch`, `task` | renamed `shell`, `patch`, `subagent` (the old ids are aliases). The guard's substring classes cover both spellings |
98
+
99
+ None of this is checked by a mock alone: `node tests/host_canary.mjs` starts the real V1 and V2
100
+ and fails on the host's own `failed to load plugin` log line.
101
+
67
102
  ## 4. Subagents, skills & commands
68
103
 
69
104
  Subdir names are **plural** canonically (`agents/`, `skills/`, `commands/`,
@@ -425,6 +425,109 @@ export const GeneseedActivity = async () => {
425
425
 
426
426
  // ponytail: OpenCode treats every export as a plugin and rejects non-functions; hang
427
427
  // the test helpers off the factory instead — reachable via import, invisible to the loader.
428
- Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag })
428
+ Object.assign(GeneseedActivity, { sidOf, nextStatus, applyEvent, acctTotals, errStr, snip, safeName, activityDir, enabledFromFlag, toV1Events })
429
429
 
430
- export default GeneseedActivity
430
+ // --- OpenCode 2 --------------------------------------------------------------
431
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
432
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
433
+ //
434
+ // V2's event stream is a different vocabulary (`{ type, created, data }`, the turn as an
435
+ // execution, a message as steps, tool calls as called/success/failed), but every fact the
436
+ // cards show is still in it. Rather than a second reducer, `toV1Events` translates each
437
+ // V2 event into the V1 event(s) the handler above already reduces, so both majors write
438
+ // the same files. Not carried: todos (V2 publishes no todo event) and files-touched (the
439
+ // V1 session summary has no V2 event).
440
+
441
+ // A V2 SessionError ({ type, message }) in the V1 error union errStr reads.
442
+ const v1Error = (e) => (e ? { name: e.type, data: { message: e.message || e.type } } : undefined)
443
+
444
+ // V2 event -> the V1 events it means ([] for one the cards ignore). `tools` remembers each
445
+ // running call's name and start (callID -> { name, start }) so its end can say both.
446
+ // Pure but for `tools`; exported for tests.
447
+ function toV1Events(ev, tools) {
448
+ const d = ev?.data ?? {}
449
+ const sessionID = d.sessionID
450
+ if (!sessionID) return []
451
+ const t = typeof ev.created === "number" ? ev.created : Date.now()
452
+ const msg = (info) => ({ type: "message.updated", properties: { info: { role: "assistant", sessionID, ...info } } })
453
+ const part = (p) => ({ type: "message.part.updated", properties: { part: { sessionID, ...p } } })
454
+ switch (ev.type) {
455
+ case "session.created":
456
+ return [{ type: "session.created", properties: { info: {
457
+ id: sessionID, title: d.title, parentID: d.parentID, directory: d.location?.directory } } }]
458
+ case "session.renamed":
459
+ return [{ type: "session.updated", properties: { info: { id: sessionID, title: d.title } } }]
460
+ case "session.deleted":
461
+ return [{ type: "session.deleted", properties: { sessionID } }]
462
+ case "session.execution.started":
463
+ return [{ type: "session.status", properties: { sessionID, status: { type: "busy" } } }]
464
+ case "session.execution.succeeded":
465
+ return [{ type: "session.idle", properties: { sessionID } }]
466
+ case "session.execution.failed":
467
+ return [{ type: "session.error", properties: { sessionID, error: v1Error(d.error) } },
468
+ { type: "session.idle", properties: { sessionID } }]
469
+ case "session.execution.interrupted":
470
+ return [{ type: "session.status", properties: { sessionID, status: { type: "idle" } } }]
471
+ case "session.retry.scheduled":
472
+ return [{ type: "session.status", properties: { sessionID, status: { type: "retry" } } }]
473
+ case "session.step.started":
474
+ return [msg({ id: d.assistantMessageID, mode: d.agent, modelID: d.model?.id, time: { created: t } })]
475
+ case "session.step.ended":
476
+ return [msg({ id: d.assistantMessageID, cost: d.cost, tokens: d.tokens, time: { completed: t } })]
477
+ case "session.step.failed":
478
+ return [msg({ id: d.assistantMessageID, cost: d.cost, tokens: d.tokens, error: v1Error(d.error), time: { completed: t } })]
479
+ // The name rides only on `input.started`; `called` carries the input but no name.
480
+ case "session.tool.input.started":
481
+ tools.set(d.id, { name: d.name, start: undefined })
482
+ return []
483
+ case "session.tool.called": {
484
+ const name = tools.get(d.id)?.name ?? "tool"
485
+ tools.set(d.id, { name, start: t })
486
+ return [part({ type: "tool", callID: d.id, tool: name, messageID: d.assistantMessageID,
487
+ state: { status: "running", title: name, time: { start: t } } })]
488
+ }
489
+ case "session.tool.success":
490
+ case "session.tool.failed": {
491
+ const call = tools.get(d.id) ?? { name: "tool", start: undefined }
492
+ tools.delete(d.id)
493
+ const failed = ev.type === "session.tool.failed"
494
+ return [part({ type: "tool", callID: d.id, tool: call.name, messageID: d.assistantMessageID,
495
+ state: { status: failed ? "error" : "completed", title: call.name,
496
+ error: failed ? d.error?.message || d.error?.type : undefined, time: { start: call.start, end: t } } })]
497
+ }
498
+ case "session.reasoning.started":
499
+ case "session.reasoning.ended":
500
+ return [part({ type: "reasoning", id: `r:${d.assistantMessageID}:${d.ordinal}`, text: d.text ?? "" })]
501
+ case "session.text.ended":
502
+ return [part({ type: "text", id: `t:${d.assistantMessageID}:${d.ordinal}`, messageID: d.assistantMessageID, text: d.text })]
503
+ case "permission.asked":
504
+ return [{ type: "permission.updated", properties: { sessionID, id: d.id, title: d.message || d.action } }]
505
+ case "permission.replied":
506
+ return [{ type: "permission.replied", properties: { sessionID, permissionID: d.requestID } }]
507
+ default:
508
+ return []
509
+ }
510
+ }
511
+
512
+ async function setupV2(ctx) {
513
+ const hooks = await GeneseedActivity()
514
+ const tools = new Map()
515
+ // The user's prompt, as V1's chat.message hook received it.
516
+ await ctx.session.hook("prompt", async (event) => {
517
+ try {
518
+ await hooks["chat.message"]({ sessionID: event?.sessionID, messageID: event?.messageID },
519
+ { parts: [{ type: "text", text: event?.prompt?.text }] })
520
+ } catch (err) { log(`prompt hook failed: ${err?.message ?? err}`) }
521
+ })
522
+ const controller = new AbortController()
523
+ void (async () => {
524
+ for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
525
+ for (const event of toV1Events(ev, tools)) {
526
+ try { await hooks.event({ event }) } catch (err) { log(`event ${event.type} failed: ${err?.message ?? err}`) }
527
+ }
528
+ }
529
+ })().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
530
+ return () => controller.abort()
531
+ }
532
+
533
+ export default { id: "geneseed-activity", server: GeneseedActivity, setup: setupV2 }
@@ -989,4 +989,99 @@ export const GeneseedContext = async (ctx) => {
989
989
  }
990
990
  }
991
991
 
992
- export default GeneseedContext
992
+ // ---- OpenCode 2 ----------------------------------------------------------------
993
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
994
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
995
+ //
996
+ // V2 has no `messages.transform`: `session.hook("context")` is its stable per-request
997
+ // equivalent (the outgoing system + messages, mutable), so the invisible delivery needs
998
+ // no fallback detection there. Messages are `LLM.Message` class instances, not V1's
999
+ // `{info, parts}`. A V2 hook that throws is a defect that fails the whole request,
1000
+ // so every callback below swallows its own errors.
1001
+
1002
+ // Build a user message the request will accept: reuse the class of a message already in
1003
+ // it (LLM.Message validates on construction), else a plain object of the same shape.
1004
+ function v2UserMessage(messages, text) {
1005
+ const content = [{ type: "text", text }]
1006
+ const Ctor = messages.find((m) => m && m.constructor && m.constructor !== Object)?.constructor
1007
+ if (Ctor) { try { return new Ctor({ role: "user", content }) } catch {} }
1008
+ return { role: "user", content }
1009
+ }
1010
+ const v2HasMarker = (messages) => messages.some((m) =>
1011
+ (m?.content ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
1012
+ const v2Model = (ref) => (ref?.providerID && ref?.id ? `${ref.providerID}/${ref.id}` : null)
1013
+
1014
+ async function setupV2(ctx) {
1015
+ const root = repoRoot({ worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory })
1016
+ const blockCache = { text: null, at: 0 }
1017
+ // Same scope rule as V1's skipSession: child/subagent sessions inherit the parent's
1018
+ // context, geneseed-* sessions stay clean. A failed lookup is not cached.
1019
+ const sessionSkip = new Map()
1020
+ async function skipSession(sid) {
1021
+ if (!sid) return false
1022
+ const hit = sessionSkip.get(sid)
1023
+ if (hit !== undefined) return hit
1024
+ try {
1025
+ const info = await ctx.session.get({ sessionID: sid })
1026
+ const skip = Boolean(info?.parentID) || String(info?.title ?? "").startsWith("geneseed-")
1027
+ sessionSkip.set(sid, skip)
1028
+ return skip
1029
+ } catch {
1030
+ return false
1031
+ }
1032
+ }
1033
+
1034
+ if (!VISIBLE) {
1035
+ await ctx.session.hook("context", async (event) => {
1036
+ if (INJECT_OFF) return
1037
+ try {
1038
+ if (await sovereignBypass(root)) return
1039
+ if (!Array.isArray(event?.messages) || v2HasMarker(event.messages)) return
1040
+ if (await skipSession(event.sessionID)) return
1041
+ const cached = await cachedBlockText(root, blockCache)
1042
+ if (!cached) return
1043
+ event.messages.unshift(v2UserMessage(event.messages, withModel(cached, v2Model(event.model) || envModelStr())))
1044
+ log("context hook: injected context invisibly")
1045
+ } catch (err) {
1046
+ log(`context hook error: ${err?.message ?? err}`)
1047
+ }
1048
+ })
1049
+ return
1050
+ }
1051
+
1052
+ // Visible delivery (GENESEED_CONTEXT_VISIBLE): one synthetic message when a session is
1053
+ // created, re-pushed into the compaction request so it outlives the summary.
1054
+ await ctx.session.hook("compaction", async (event) => {
1055
+ if (INJECT_OFF) return
1056
+ try {
1057
+ if (await sovereignBypass(root)) return
1058
+ if (!Array.isArray(event?.messages) || await skipSession(event.sessionID)) return
1059
+ const { block } = await resolveBlock(root)
1060
+ if (block) event.messages.unshift(v2UserMessage(event.messages, block.text))
1061
+ } catch (err) {
1062
+ log(`compaction error: ${err?.message ?? err}`)
1063
+ }
1064
+ })
1065
+ const controller = new AbortController()
1066
+ void (async () => {
1067
+ for await (const ev of ctx.event.subscribe({ signal: controller.signal })) {
1068
+ if (ev?.type !== "session.created" || INJECT_OFF) continue
1069
+ const d = ev.data ?? {}
1070
+ try {
1071
+ if (d.parentID || String(d.title ?? "").startsWith("geneseed-")) continue
1072
+ if (await sovereignBypass(root)) continue
1073
+ const { block } = await resolveBlock(root)
1074
+ if (!block) continue
1075
+ await ctx.session.synthetic({
1076
+ sessionID: d.sessionID, text: withModel(block.text, v2Model(d.model) || envModelStr()),
1077
+ description: "Geneseed project context", resume: false,
1078
+ })
1079
+ } catch (err) {
1080
+ log(`visible injection error: ${err?.message ?? err}`)
1081
+ }
1082
+ }
1083
+ })().catch((err) => log(`event stream ended: ${err?.message ?? err}`))
1084
+ return () => controller.abort()
1085
+ }
1086
+
1087
+ export default { id: "geneseed-context", server: GeneseedContext, setup: setupV2 }
@@ -633,4 +633,36 @@ export const GeneseedGuard = async (ctx) => {
633
633
  }
634
634
  }
635
635
 
636
- export default GeneseedGuard
636
+ // ---- OpenCode 2 ----------------------------------------------------------------
637
+ // `export default { id, server, setup }` serves V1 (>= 1.3.4) and V2 from one file — why, and
638
+ // each V1 hook's V2 counterpart: adapters/opencode/HOW-OPENCODE-LOADS.md.
639
+ //
640
+ // V2 runs the same gates through `tool.hook("execute.before")`, but cannot block the
641
+ // V1 way: a V2 hook that throws is a defect, which fails the whole agent step instead
642
+ // of the one call (only a `Tool.Error` settles a call cleanly, and a hook cannot raise
643
+ // one). So a deny RENAMES the call: core then finds no tool by that name and settles it
644
+ // with a `Tool.Error` whose text carries the reason — the call never runs, the model sees
645
+ // why, the session continues. The input is emptied as well, for code mode, which runs the
646
+ // original tool whatever the event's name says; every tool this guard gates requires
647
+ // arguments, so `{}` fails validation there before anything executes.
648
+ //
649
+ // `permission.evaluate` (V2's deny hook) was not used: it sees only the action and a
650
+ // resource list (the shell tool's is per sub-command), not the full input the content
651
+ // scan and the patch-path gates need.
652
+ async function setupV2(ctx) {
653
+ const hooks = await GeneseedGuard({
654
+ worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory,
655
+ })
656
+ await ctx.tool.hook("execute.before", async (event) => {
657
+ try {
658
+ await hooks["tool.execute.before"]({ tool: event.tool }, { args: event.input })
659
+ } catch (err) {
660
+ const why = String(err?.message || "")
661
+ if (!why.startsWith("[geneseed-guard]")) return
662
+ event.tool = why
663
+ event.input = {}
664
+ }
665
+ })
666
+ }
667
+
668
+ export default { id: "geneseed-guard", server: GeneseedGuard, setup: setupV2 }
@@ -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 }