@jeffober/thatch 0.1.48 → 0.2.1

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 (50) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/bin/thatch +31 -16
  5. package/docs/dev/README.md +39 -13
  6. package/docs/dev/features/behavior-engine.md +3 -2
  7. package/docs/dev/features/cicd.md +1 -1
  8. package/docs/dev/features/compaction-recovery.md +1 -1
  9. package/docs/dev/features/cross-session-chat.md +12 -3
  10. package/docs/dev/features/extraction.md +10 -2
  11. package/docs/dev/features/hygiene.md +3 -1
  12. package/docs/dev/features/memory-store.md +1 -1
  13. package/docs/dev/features/multi-host.md +4 -2
  14. package/docs/dev/features/notifications.md +121 -4
  15. package/docs/dev/features/nudge-pipeline.md +16 -2
  16. package/docs/dev/features/opencode-plugin.md +133 -0
  17. package/docs/dev/features/qa-system.md +43 -0
  18. package/docs/dev/features/repo-identity.md +1 -1
  19. package/docs/dev/features/session-lifecycle.md +6 -3
  20. package/docs/dev/features/watchers.md +12 -5
  21. package/docs/dev/gotchas.md +94 -4
  22. package/docs/dev/mcp-parity.md +4 -3
  23. package/docs/dev/setup-and-hooks.md +7 -2
  24. package/docs/plans/llm-alerts.md +287 -0
  25. package/docs/plans/prediction-consolidation.md +3 -3
  26. package/docs/user/README.md +8 -4
  27. package/docs/user/memory.md +1 -1
  28. package/docs/user/notifications.md +63 -4
  29. package/docs/user/setup.md +4 -0
  30. package/docs/user/watchers.md +15 -7
  31. package/package.json +3 -1
  32. package/src/alerts.ts +360 -0
  33. package/src/capabilities.ts +174 -0
  34. package/src/chat.ts +80 -5
  35. package/src/commands.ts +44 -5
  36. package/src/config.ts +32 -1
  37. package/src/db.ts +157 -12
  38. package/src/embeddings.ts +43 -0
  39. package/src/extraction.ts +210 -0
  40. package/src/index.ts +77 -1339
  41. package/src/opencode/v1.ts +54 -0
  42. package/src/opencode/v2.ts +605 -0
  43. package/src/os-args.ts +99 -0
  44. package/src/prompts.ts +181 -31
  45. package/src/runtime.ts +2004 -0
  46. package/src/seed-behaviors.ts +1 -1
  47. package/src/session-db.ts +32 -0
  48. package/src/tool-defs.ts +340 -46
  49. package/src/tools.ts +16 -35
  50. package/src/watchers.ts +109 -14
@@ -5,19 +5,28 @@ hand-editable user config file. Both are plain child processes and file I/O,
5
5
  so the feature works identically on every host -- opencode, Claude Code, and
6
6
  Cursor -- with no host-specific plumbing and no new npm dependencies.
7
7
 
8
+ Two delivery paths share the dispatcher and the config file:
9
+
10
+ - **Agent-initiated**: the LLM calls `notify_user`. Works on every host.
11
+ - **Automatic alerts** (opencode-only): the plugin runtime watches session
12
+ events and notifies when the LLM pauses for human input or finishes a
13
+ round of real work. See the Alerts section below and
14
+ `docs/plans/llm-alerts.md` for the design decisions.
15
+
8
16
  ## Source files
9
17
 
10
18
  | File | Role |
11
19
  |------|------|
12
- | `src/config.ts` | Zod section schemas, config file load/save/merge, platform defaults. |
20
+ | `src/config.ts` | Zod section schemas, config file load/save/merge, platform defaults, `alertMode`. |
13
21
  | `src/notify.ts` | Per-platform dispatch for banner + voice, with an injectable spawner. |
14
22
  | `src/tool-defs.ts` | `config_get`, `config_set`, `notify_user` tool definitions (shared registry, non-opencodeOnly). |
23
+ | `src/alerts.ts` | Alert state machine: pause/done/error decisions from session events, per-session dedup (no timer-based debounce - one alert per busy->idle transition, per request id for pauses). |
15
24
 
16
25
  ## Config design
17
26
 
18
27
  The config is one JSON file at `~/.config/thatch/config.json`, placed next to
19
28
  `thatch.db` by deriving the path from `THATCH_DB_PATH` (or the XDG default the
20
- same way `index.ts` and `mcp.ts` derive the DB path). Tests pass an explicit
29
+ same way `runtime.ts` and `mcp.ts` derive the DB path). Tests pass an explicit
21
30
  dbPath to keep writes inside a tempdir.
22
31
 
23
32
  Key decisions:
@@ -81,13 +90,110 @@ Details that matter:
81
90
  reporting stays honest. There is no debounce; polling agents notify once at
82
91
  the end.
83
92
 
93
+ ## Alerts: the automatic notification path (opencode only)
94
+
95
+ `src/alerts.ts` holds an in-memory state machine per session. The plugin
96
+ runtime (both the v1 and v2 adapters feed it the same event stream) drives it
97
+ from the shared `onEvent` hook; delivery reuses `sendNotification` with the
98
+ `alerts` config section choosing the channel per event (`alertMode`, default
99
+ `banner`). Delivery is fire-and-forget (a spoken alert awaits the sentence
100
+ for seconds and must never stall the event pump); the machine owns the
101
+ per-event `none` gate and the per-delivery config re-read is what makes
102
+ config changes apply without a restart.
103
+
104
+ Events consumed:
105
+
106
+ | Event | Machine input |
107
+ |-------|---------------|
108
+ | `question.asked` / `permission.asked` | Pause alert, deduped per request id; cleared by the matching replied/rejected event. |
109
+ | `session.status` busy | Marks the session active and resets the round. |
110
+ | `session.status` retry (v1 only) | Clears a recorded error -- opencode recovered on its own -- but preserves the real-work flag (the tool calls before the retry happened). |
111
+ | `session.error` / `session.execution.failed` | Records the error name; on v2 the adapter emits this paired with the idle event, so the verdict always follows. |
112
+ | `session.status` idle (from active) | The verdict: error alert, done alert, or silence. A recorded error delivers its verdict WITHOUT the message fetch (the fetch rides the same host API whose failure plausibly accompanied the failure). |
113
+ | `session.deleted` | Drops the state. |
114
+
115
+ The verdict combines two sources, because neither alone can answer "was
116
+ this a real round of work the user asked for?":
117
+
118
+ - **The tool side is live bookkeeping, not message archaeology.** The
119
+ runtime calls `sessionRealWork` at exactly the extraction buffer's push
120
+ site -- the buffer's filter is the one classification of "this call was
121
+ not bookkeeping" (`isMetaToolName`: `thatch_*`, `skill`, `task`,
122
+ `agent`, `subagent`; execute-wrapped thatch calls are unwrapped and never
123
+ reach the push). Message parts do not carry tool arguments, so the
124
+ execute-unwrap cannot be redone from messages. `todowrite` and `question`
125
+ reach the push (the buffer wants their facts) but are excluded from the
126
+ alert signal -- see `isNoWorkTool` in `src/alerts.ts`; they deliberately
127
+ do NOT live in `isMetaToolName`, and moving them there would silently
128
+ stop the buffer from queueing them.
129
+ - **The trigger side comes from the message list** via the pure
130
+ `deriveRoundShape` (exported from `src/alerts.ts`, fed
131
+ `HostCapabilities.sessionMessages` output). The round delimiter is v2's
132
+ own idle marker: v2 appends a `{type: "idle"}` marker message at every
133
+ turn end (`SessionMessage.Idle`), so the round is everything after the
134
+ previous marker -- the host's turn definition, immune to mid-round
135
+ steers. V1 has no markers and falls back to "newest non-assistant message
136
+ triggered the round" (a mid-round steer under-classifies there; accepted
137
+ v1 limitation).
138
+
139
+ Silence rules, in verdict order:
140
+
141
+ - **Unknown shape** -- a failed or empty message fetch stays silent.
142
+ Alerts are best-effort: a spurious banner on every broken fetch costs
143
+ more than a rare missed completion, and the same fetch failing also
144
+ breaks the wrap-up greenlight check, so the session degrades
145
+ consistently. This rule also keeps adapter-level tests (whose client
146
+ mocks have no message surface) from ever spawning a real banner.
147
+ - **Synthetic-trigger silence** -- nudges, background-task completions, and
148
+ watcher wake-ups are synthetic deliveries (v2: their own message kind; v1:
149
+ all-synthetic text parts on the user message). A round they triggered
150
+ never fires the DONE alert, even when it does real work. It is a silence
151
+ on the done verdict only: a synthetic round that ends in a real error
152
+ still banners the error. The rule exists because async thatch activity
153
+ would otherwise produce a banner every time.
154
+ - **Abort and shutdown silence** -- an error name matching
155
+ /abort|shutdown|interrupt/i means the user interrupted the turn
156
+ themselves (`MessageAbortedError`, or v2's user-reason interrupt
157
+ translation), the process is leaving (`SessionShutdownError`), or the
158
+ execution was superseded by a newer one (the catch-all for unknown
159
+ interrupt reasons). Sources: the event-level error, the message-level
160
+ error on the last assistant message, and v2's interrupted-reason
161
+ translation.
162
+ - **Error alert** -- any other recorded error (event-level, or message-level
163
+ on the last assistant message) notifies needs-attention with the error
164
+ name in the message, instead of done. Notably `SessionInactivityError`:
165
+ v2's location-activity sweeper interrupts still-executing sessions when a
166
+ project directory goes quiet for an hour -- the one banner most likely to
167
+ reach Jeff away from the terminal.
168
+ - **No real work** -- a round whose only tool calls were bookkeeping (or
169
+ that made none, or whose tool executions all crashed at the host layer)
170
+ stays silent. A round delegated entirely to subagents also stays silent:
171
+ dispatch tools are meta, and child sessions never alert.
172
+
173
+ State is plain memory, deliberately not journaled to `runtime_state`: a v2
174
+ reload mid-turn (plugin save, upgrade) loses that turn's busy->idle
175
+ transition and stays silent. Accepted by design -- the PR #16 journaling
176
+ exists because extraction and wrap-up state corrupt sessions when lost, and
177
+ an alert cannot corrupt anything.
178
+
179
+ Subagent child sessions never alert: the runtime's idle branch handles
180
+ children (extraction completion) and returns before the alert verdict, and
181
+ child errors requeue extraction instead. Pause asks from children still
182
+ notify -- a subagent blocking on a question blocks the parent too.
183
+
184
+ Deduplication across TUIs is structural: the runtime is one instance per
185
+ project directory in the shared server regardless of how many TUIs are
186
+ attached, so per-session state is exact by construction. Delivering from the
187
+ TUI side (opencode's attention API) would fire once per attached TUI and was
188
+ rejected for that reason (plus v1 has no TUI plugin surface at all).
189
+
84
190
  ## Prompt surface
85
191
 
86
192
  - `systemPrompt()` and `mcpInstructions()` list the three new tools and carry
87
193
  a short "Notifications" section: when to notify, the source-label rule, and
88
194
  the config_get-before-config_set instruction.
89
195
  - `notify_user` is not `opencodeOnly`: it needs no host capabilities, so MCP
90
- hosts get it too. Nothing was added to `src/tools.ts` or `src/index.ts`.
196
+ hosts get it too. Nothing was added to `src/tools.ts` or the plugin runtime.
91
197
 
92
198
  ## Tests
93
199
 
@@ -99,8 +205,19 @@ Details that matter:
99
205
  platform, plus tool-level tests (merge-no-wipe, echo, `mode: none` no-op,
100
206
  configured voice honored) with `THATCH_DB_PATH` pointed at a tempdir.
101
207
  - `tests/tool-defs.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
208
+ - `tests/alerts.test.ts` -- the alert state machine: `deriveRoundShape`
209
+ (idle-marker delimiter, v1 fallback, synthetic trigger detection), the
210
+ real-work flag, pause dedup and re-arm, abort/shutdown silence,
211
+ error-with-name, retry recovery, `mode: none` per event, config
212
+ round-trip (isolated tempdir).
102
213
 
103
214
  ## QA coverage
104
215
 
105
216
  `tests/qa/auto/uc-059-tool-prefixing.ts` asserts the full bare-name list; the
106
- three new names are in its `expected` array.
217
+ three new names are in its `expected` array. `uc-108-llm-alerts.ts` drives
218
+ the full alert event flow against a spy notifier, including the adapter
219
+ seam: raw v2 bus events through `translateEvent` and real-shaped
220
+ `session.context` lists through `mapSessionContextMessages` +
221
+ `deriveRoundShape` (the seam where the idle-marker bug hid). A live
222
+ banner-by-ear check remains a manual verification against a running
223
+ session.
@@ -1,5 +1,7 @@
1
1
  # Per-Message Nudge Pipeline
2
2
 
3
+ Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
4
+
3
5
  Runs on every user message. Four priority tiers share one embedding
4
6
  computation. The pipeline injects *synthetic parts*---text the model sees in
5
7
  the conversation but the user does not see in the TUI. This is the inverse of
@@ -35,7 +37,7 @@ opencode path runs in-process with direct event hooks. The MCP path runs via
35
37
  external CLI hook processes that communicate with the long-lived MCP server
36
38
  through the sideband socket.
37
39
 
38
- ### opencode path (`src/index.ts`, `chat.message` hook)
40
+ ### opencode path (`src/runtime.ts`, `onChatMessage` - v1 `chat.message` hook, v2 `session.hook("prompt")`)
39
41
 
40
42
  #### Compaction guard
41
43
 
@@ -47,6 +49,18 @@ not a compaction summary, compaction has already failed (the auto-continue
47
49
  never fired). Thatch clears the stale `compacting` flag and proceeds normally.
48
50
  See [compaction-recovery.md](compaction-recovery.md).
49
51
 
52
+ #### Task sub-agent guard
53
+
54
+ If the session is a child in `childToParent` but not in `extractionChildren`
55
+ (a task-dispatched sub-agent), all nudge tiers are skipped and the hook
56
+ returns before tier 1. Dispatched sub-agents may have restricted tool lists
57
+ that exclude the thatch tools, and even where the tools exist the child is
58
+ driven by its dispatch prompt, so a nudge only produces "No tool named"
59
+ error rounds or noise. `extraction.requeueStaleAccepted()` still runs before
60
+ the guard - it is session-independent maintenance. See
61
+ [session-lifecycle.md](session-lifecycle.md) for the `extractionChildren`
62
+ distinction.
63
+
50
64
  #### Tier 1---Extraction nudge (fallback path)
51
65
 
52
66
  Fires only when direct extraction was never triggered or threw (the session is
@@ -265,7 +279,7 @@ TUI display. The opencode framework's history serializer filters on
265
279
 
266
280
  | File | Role |
267
281
  |------|------|
268
- | `src/index.ts` | opencode: `chat.message` hook (all 4 tiers), compaction guard, extraction fallback |
282
+ | `src/runtime.ts` | opencode: per-message nudges (all 4 tiers), compaction guard, extraction fallback |
269
283
  | `bin/thatch` | MCP: `flush-tools` and `flush-predictions` subcommands |
270
284
  | `src/sideband.ts` | MCP: sideband client helpers (`sidebandMatch`, `sidebandPredictions`, `sidebandBehaviors`) |
271
285
  | `src/prompts.ts` | All nudge formatting functions (`recallNudge`, `claudeRecallNudge`, `predictionNudge`, `behaviorNudge`, `extractionNudge`, `claudeWriteNudge`) |
@@ -0,0 +1,133 @@
1
+ # Feature: OpenCode plugin dual-adapter (v1 + v2)
2
+
3
+ The opencode plugin supports both opencode lines from one npm package: the
4
+ 1.18.x plugin API and the 2.x plugin API. The user's setup (shim file, config
5
+ key, DB, skills) is identical under either host.
6
+
7
+ ## Architecture
8
+
9
+ ```text
10
+ src/index.ts dual entry -- merged default export { id, setup, server }
11
+ │ opencode v1 reads default.server; v2 reads
12
+ │ default.setup and strips excess keys. Both loaders
13
+ │ verified against tagged upstream source.
14
+ ├── (lazy) → src/opencode/v1.ts v1 adapter (@opencode-ai/plugin)
15
+ └── (lazy) → src/opencode/v2.ts v2 adapter (@opencode/plugin)
16
+ both delegate to:
17
+ src/runtime.ts shared runtime (nudges, system prompt,
18
+ session events, extraction, bookkeeping)
19
+ ↑ consumes
20
+ src/capabilities.ts HostCapabilities seam
21
+ ```
22
+
23
+ ### The isolation rule
24
+
25
+ `src/index.ts`'s static import graph must stay SDK-free. Each adapter
26
+ imports its host's SDK (`@opencode-ai/plugin` vs `@opencode/plugin`), and the
27
+ SDKs resolve only under their own host's install, so the entry reaches both
28
+ adapters exclusively through dynamic `import()`. A top-level await would make
29
+ either host's load block on (or fail from) the other host's adapter. The pure
30
+ helpers (`osProcessArgs`, `startupSessionId`, ...) live in `src/os-args.ts`,
31
+ which is safe to re-export statically.
32
+
33
+ ### The export shape
34
+
35
+ Upstream loaders decide everything:
36
+
37
+ - **v1** (`packages/opencode/src/plugin/shared.ts`, `readV1Plugin`, identical
38
+ across 1.18.0-1.18.32): reads only `mod.default`; uses `default.server` if
39
+ present and never reaches the named-export ("legacy") fallback. Excess keys
40
+ (like `setup`) are invisible to it. A default export with `id` but no
41
+ `server` throws and the host SILENTLY SKIPS the plugin (error-publish is
42
+ commented out in the upstream caller).
43
+ - **v2** (`packages/core/src/plugin/module.ts` @ v2.0.9): decodes `default`
44
+ as `{ id, effect }` or `{ id, setup }` with Effect Schema; excess keys
45
+ (like `server`) are tolerated and stripped (verified empirically at
46
+ effect@4.0.0-rc.112, the version v2 pins).
47
+
48
+ So the merged object `{ id, setup, server }` satisfies both. A module with a
49
+ named `server` export plus a v2-shaped default does NOT work on v1 -- that
50
+ shape throws in `readV1Plugin`.
51
+
52
+ ## The capability seam
53
+
54
+ `HostCapabilities` (src/capabilities.ts) is the interface of host operations
55
+ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
56
+ (src/tool-defs.ts, the per-tool-call seam); they stay separate on purpose.
57
+
58
+ | Capability | v1 source | v2 source | v2 strategy |
59
+ |---|---|---|---|
60
+ | bus events | `event` hook | `event.subscribe` (raw SSE) | client-side location filter; location-less events resolve via `session.get` (`{sessionID}` input) and drop unresolvable (mirrors v1's server-side filter); events for plugin-created extraction children pass by ID (they live in the project directory, which differs from the instance directory on below-root launches) |
61
+ | incoming message | `chat.message` hook | `session.hook("prompt")` | the hook awaits the runtime; nudge injections append to the OUTBOUND request's last user message in the generate hook (v2 wire `Message` carries parts in `content`, not `parts`) |
62
+ | system prompt | `experimental.chat.system.transform` | `session.hook("context")` | mutates the request's system array; the runtime's plain strings convert to `{type: "text", text}` SystemPart objects at the boundary |
63
+ | compaction | `experimental.session.compacting` + `.autocontinue` + `session.compacted` | `session.hook("compaction")` | flag lands; context-injection surface unverified |
64
+ | wrap-up commands | `command.execute.before` + command files | `command.transform` + `CommandEditor.add` | registered in code: `execute` arms the greenlight check (`onCommandExecuteBefore`) and delivers the same prompt body the v1 file carries, substituting the invocation's prompt text for `$ARGUMENTS` (v2's `session.prompt` does no template expansion); the runtime installs only ACTION command files on v2 and REMOVES stale wrap-up files from earlier v1 runs (a file and a registered command with the same name would collide) |
65
+ | tools | `hooks.tool` map via `tool()` | `tool.transform` + `ToolEditor.add` | zod shapes pre-converted to JSON Schema with our own zod (v2's `instanceof $ZodType` detection fails for ours and drops the schema); results wrap as `{ content }` |
66
+ | tool buffering | `tool.execute.after` hook | `tool.hook("execute.after")` | wired: feeds the same extraction buffer; `Tool.Result.content` is flattened (string passes through, content-part arrays contribute their text parts); the title is derived from the tool name and args (`deriveTitle`), since v2's `Tool.Result.output` is a typed output value, not a title |
67
+ | noReply deliveries | `promptAsync` with `noReply` | none | gated off via `HostCapabilities.noReplyDelivery` -- chat echoes and the session-start reminder are skipped on v2 (delivering them would start real model turns: a feedback loop) |
68
+ | synthetic wake deliveries | `promptAsync` with `synthetic` parts | `session.synthetic` endpoint | watcher + chat wake nudges route to v2's synthetic endpoint (TUI-hidden), matching v1 |
69
+ | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete degrades (extraction children are not cleaned up on v2 -- the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
70
+ | session status | `client.session.status` | none | returns `{}`; the wake gate treats unknown as idle and the event-fed status map does the gating |
71
+ | session messages | `client.session.messages` | `session.context` | mapped into the v1 `{info: {role}, parts}` shape, so the wrap-up greenlight check works (the promise domain has no `message` accessor) |
72
+ | session list | `client.session.list` | none | degrade (`-c` resume listing loses its data source) |
73
+ | compaction trigger | `client.tui.executeCommand("session_compact")` | none | degrade (no `session.compact` on the promise domain's Pick, and the built-in `/compact` is a TUI palette action calling the server endpoint directly - the server command registry only knows config/plugin-registered names; the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
+ | toasts | `client.tui.showToast` | none reachable | degrade (the `tui.toast.show` event has no producer surface from the promise context; a `tui` companion plugin entrypoint is the known lift candidate) |
75
+ | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
76
+ | tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
77
+ | wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
78
+ | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
79
+
80
+ ## Live verification and remaining unknowns
81
+
82
+ The v2 adapter was written against tagged source (v2.0.9) before a live
83
+ binary was available, then verified against a real v2.0.15 binary on
84
+ 2026-09-23: merged-default loading, tool schemas (via pre-converted JSON
85
+ Schema - see the gotcha), event shapes, the execution-lifecycle idle signal,
86
+ synthetic wake delivery, and command registration all work; the full QA
87
+ suite runs green against a v2 serve.
88
+
89
+ Still unverified against the live binary (static analysis only):
90
+
91
+ - Whether `session.hook("prompt")` fires for `session.synthetic` inbox
92
+ items. If it does not, `pendingInjections` from the prior real turn gets
93
+ appended to the synthetic turn's outbound request.
94
+ - Whether `SessionGenerate.messages` are fresh objects per model call within
95
+ a turn. If not, the generate hook pushes duplicate nudges on every tool
96
+ round trip.
97
+ - An npm-installed copy (not the dev checkout, which has devDependencies
98
+ present) loading under v2 - the isolation rule's real-world proof.
99
+
100
+ The app-exit gap (no server-side publish surface for `tui.command.execute`)
101
+ is upstream:
102
+ [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984).
103
+ Install opencode-v2 (brew formula `anomalyco/tap/opencode-v2`; conflicts
104
+ with the v1 formula's binary name, so it cannot coexist) and re-run the QA
105
+ live suite against it.
106
+
107
+ ## User setup compatibility
108
+
109
+ No config file moves or changes format. The DB, model cache, version-check
110
+ files, and skills dirs are host-agnostic and shared. opencode's own config
111
+ key (`plugin` vs `plugins`) is auto-migrated by v2. The only user-owned file
112
+ with a content change is the dev-session shim, and its new dual-shape content
113
+ loads under both hosts.
114
+
115
+ ## Source files
116
+
117
+ - `src/index.ts` -- dual entry, merged default export, lazy wrappers
118
+ - `src/opencode/v1.ts` -- v1 hooks adapter
119
+ - `src/opencode/v2.ts` -- v2 promise-context adapter + capability impl
120
+ - `src/capabilities.ts` -- `HostCapabilities`, `capabilitiesFromClient`
121
+ - `src/runtime.ts` -- the shared runtime (all behavioral logic)
122
+ - `src/os-args.ts` -- pure argv helpers
123
+ - `tests/opencode-v2.test.ts` -- v2 adapter contract tests (mocked context)
124
+ - `tests/plugin.test.ts` -- v1 adapter contract tests (mocked client)
125
+
126
+ ## Interactions with other features
127
+
128
+ Everything downstream of the hooks is unchanged by the dual adapter:
129
+ [extraction.md](extraction.md), [nudge-pipeline.md](nudge-pipeline.md),
130
+ [session-lifecycle.md](session-lifecycle.md),
131
+ [cross-session-chat.md](cross-session-chat.md),
132
+ [watchers.md](watchers.md), [notifications.md](notifications.md). The MCP
133
+ path ([mcp-parity.md](../mcp-parity.md)) does not touch any of this.
@@ -96,6 +96,49 @@ opencode session timeout, compaction trigger). Run them locally with
96
96
  | `qa-auto` | `bin/qa-run auto` | Run automatable only (fast, no LLM). Pass UC names as args to select a subset. |
97
97
  | `qa-live` | `bin/qa-run live` | Run live only (spawns opencode, costs tokens). Pass UC names as args to select a subset. |
98
98
 
99
+ ### The opencode version matrix (default behavior)
100
+
101
+ Every task that runs opencode-driven use cases runs one leg per
102
+ DISCOVERED opencode install, with test names suffixed
103
+ (`UC-001-memory-roundtrip [v1]`, `UC-001-memory-roundtrip [v2]`) when
104
+ there are several. There is no separate matrix command - the ordinary
105
+ tasks just do the matrix based on what is available.
106
+
107
+ Discovery (tests/qa/binaries.ts) asks the package manager where its
108
+ formulas are installed (`brew --prefix opencode`, `brew --prefix
109
+ opencode-v2`), falls back to the PATH-resolved binary, validates each
110
+ candidate by running `--version`, tags it by major (`v1`, `v2` - the
111
+ version output decides, not the formula name), and dedups candidates
112
+ that resolve to the same binary. A machine with one install runs
113
+ single-leg with unsuffixed names, so single-binary setups behave
114
+ exactly as they did before the matrix existed.
115
+
116
+ A use case opts into or restricts the matrix with `hosts: ["v1"]` /
117
+ `["v2"]` / `["v1", "v2"]` on the `UseCase`:
118
+
119
+ - Live use cases (no custom `run`) are matrixed by default - the default
120
+ `runViaOpencode` path spawns opencode by definition.
121
+ - Automatable use cases keep a single leg unless they declare `hosts` -
122
+ the fast suite is not doubled for cases that never touch opencode.
123
+ Declare `hosts` when a custom `run` spawns opencode itself (UC-098) or
124
+ drives a serve (UC-100).
125
+
126
+ `QA_HOSTS=v1` (comma-separated) narrows the legs when iterating against
127
+ one major; a value matching no discovered install registers nothing
128
+ with an explanatory line.
129
+
130
+ Legs get separate fixtures (the fixture directory is keyed by test name
131
+ suffix) because hosts cannot share one: a v2 session db in a shared
132
+ fixture fails the v1 leg's serve with "Database is not empty and has no
133
+ session table".
134
+
135
+ The v1 and v2 `run` CLIs disagree on flags (v1 has `--dir`, which v2
136
+ dropped; v2 needs `--standalone` to skip the background daemon, which a
137
+ fixture sandbox must not boot). `opencodeRunArgs(ctx, prompt)` in the
138
+ runner builds the right invocation per leg by version-detecting the
139
+ binary the leg's PATH selects; `runViaOpencode` and UC-098's custom runs
140
+ go through it.
141
+
99
142
  The `qa` task runs auto first via `&&`. Auto failures stop before live runs.
100
143
  The `qa-live` task does not use `--concurrent` — live sessions are
101
144
  resource-heavy and benefit from sequential execution.
@@ -104,7 +104,7 @@ are per-worktree -- so git reads are equivalent from either. Consumers:
104
104
 
105
105
  - `src/git.ts` -- resolution chain, cache validation, spawn-cwd fallback
106
106
  - `src/db.ts` -- `repo_paths` table, accessors, `repoPathCache` adapter
107
- - `src/index.ts` -- plugin init order (DB before detection), identity from
107
+ - `src/runtime.ts` -- plugin init order (DB before detection), identity from
108
108
  the session directory, watcher runner wrapper
109
109
  - `src/mcp.ts` -- MCP server init order
110
110
  - `bin/thatch` -- CLI `defaultStore()` and hook-line identity resolution
@@ -1,5 +1,7 @@
1
1
  # Session Lifecycle Management (opencode)
2
2
 
3
+ Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
4
+
3
5
  opencode emits bus events for session lifecycle changes. Thatch subscribes to these events to manage the extraction pipeline, send session-start reminders, handle child session cleanup, and resolve wrap-up commands.
4
6
 
5
7
  ## What it does
@@ -15,7 +17,7 @@ opencode emits bus events for session lifecycle changes. Thatch subscribes to th
15
17
 
16
18
  ## How it works
17
19
 
18
- ### Event handler (`src/index.ts`, event hook)
20
+ ### Event handler (`src/runtime.ts`, shared by both adapters)
19
21
 
20
22
  Subscribes to all session bus events. Dispatches based on `event.type`.
21
23
 
@@ -115,14 +117,15 @@ first-ever install is invisible until the next server start.
115
117
  ## Interactions with other features
116
118
 
117
119
  - Extraction pipeline ([extraction.md](extraction.md)): direct extraction is triggered by `session.status idle`; child lifecycle managed here
118
- - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers
120
+ - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers; task-dispatched sub-agents (in `childToParent`, not in `extractionChildren`) suppress all tiers
119
121
  - Hygiene ([hygiene.md](hygiene.md)): hygiene report runs at `session.created` for top-level sessions
120
122
  - Compaction recovery ([compaction-recovery.md](compaction-recovery.md)): `session.compacted` event clears the compacting flag
121
123
  - Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which triggers drain via `tool.execute.after`
122
124
 
123
125
  ## Source files
124
126
 
125
- - `src/index.ts` -- event handler (all session events), `triggerExtraction`, `cleanupChild`, internal state maps
127
+ - `src/runtime.ts` -- event handler (all session events), `triggerExtraction`, `cleanupChild`, internal state maps
128
+ - `src/opencode/v1.ts`, `src/opencode/v2.ts` -- host adapters wiring the runtime into each opencode plugin API
126
129
 
127
130
  ## Key invariants
128
131
 
@@ -1,5 +1,7 @@
1
1
  # Watchers
2
2
 
3
+ Unless noted, `client.*` names below are the opencode v1 mapping; the v2 equivalents and degrades are in [opencode-plugin.md](opencode-plugin.md).
4
+
3
5
  Watchers are the general mechanism for event-driven notifications from
4
6
  external sources: the model registers a watch on something outside the
5
7
  session, thatch polls it in the background, and the plugin prompts the
@@ -42,10 +44,15 @@ The registry lives in plugin memory, never in SQLite. Three reasons:
42
44
  in-memory; the file-backed queue exists only for the MCP path's
43
45
  separate short-lived hook processes.
44
46
 
45
- The cost: a restart loses watches. Accepted because the session that
46
- created them loses its conversational context too - a notification
47
- into a session that no longer knows why it is being watched is worse
48
- than a lost watch.
47
+ The cost used to be "a restart loses watches", accepted because the
48
+ session that created them loses its conversational context too - a
49
+ notification into a session that no longer knows why it is being
50
+ watched is worse than a lost watch. The dormant-recovery layer keeps
51
+ the best of both: the definition survives the restart (journaled),
52
+ nothing polls or delivers while its session is away, and resuming the
53
+ session re-arms the watches into a session that again knows why it is
54
+ being watched. See the `runtime_state` row in
55
+ `docs/dev/features/opencode-plugin.md` for the journal mechanics.
49
56
 
50
57
  `session.deleted` also cancels the session's watchers and pending
51
58
  events, and `dispose` stops the poller. These are cheap insurance;
@@ -221,7 +228,7 @@ src/watchers.ts, test-enforced); command watchers register the single
221
228
  - `src/watchers.ts` - registry, poller, gh CLI runner, command runner,
222
229
  diff functions
223
230
  - `src/tool-defs.ts` - the watch tool definitions (see `TOOL_DEFS`)
224
- - `src/index.ts` - registry construction, delivery closure,
231
+ - `src/runtime.ts` - registry construction, delivery closure,
225
232
  session.status tracking, session.deleted cleanup, dispose
226
233
  - `src/prompts.ts` - `watcherNotificationNudge()`, system prompt
227
234
  Watchers section
@@ -41,7 +41,7 @@ here first. These are the things that have already cost time.
41
41
  a memory, write it with `archived: true`; to unarchive, `archived: false`.
42
42
  - **Updating an archived memory requires explicit `archived` param.** If the
43
43
  entry is already archived and a `remember` call omits the `archived` param,
44
- the tool returns an error (`db.ts:395`). Pass `archived: true` to keep it
44
+ the tool returns an error (`db.ts:585`). Pass `archived: true` to keep it
45
45
  archived or `archived: false` to unarchive. This guard prevents accidental
46
46
  unarchival via an unrelated content update.
47
47
 
@@ -70,7 +70,7 @@ here first. These are the things that have already cost time.
70
70
  - **`chat.message` has two priority tiers**: extraction nudge first (returns
71
71
  early), then the prompt-aware recall nudge. Don't run both in one turn.
72
72
  - **The extraction nudge peeks, never flushes.** The buffer is NOT drained
73
- on nudge delivery (`index.ts:366` — `peek()` call). It persists until the
73
+ on nudge delivery (`extraction.peek()` in `triggerExtraction`, src/runtime.ts). It persists until the
74
74
  agent writes a memory or calls `thatch_extraction_done`. Ignored nudges
75
75
  accumulate; the `missedNudges` counter escalates the tone (polite at 0-1
76
76
  misses, insistent at 2, ALL-CAPS at 3+). The counter resets when the
@@ -80,8 +80,8 @@ here first. These are the things that have already cost time.
80
80
  is active. The peek-not-drain semantics still hold for the fallback nudge
81
81
  path (MCP hosts and any `triggerExtraction` failure).
82
82
  - **A child sub-agent's `thatch_memory_remember` drains the parent's buffer**
83
- via the `childToParent` Map (`index.ts:79` declaration, `index.ts:272`
84
- lookup). Two paths reach this machinery: (a) the **plugin-initiated child
83
+ via the `childToParent` Map in src/runtime.ts (declaration plus `.get()`
84
+ lookups in the idle and remember handlers). Two paths reach this machinery: (a) the **plugin-initiated child
85
85
  session** — `triggerExtraction` calls `client.session.create` with a
86
86
  `parentID`, the primary opencode path; (b) the **agent-initiated background
87
87
  task** — the model dispatches the fact-extractor via the `task` tool after
@@ -196,3 +196,93 @@ prose end marker stopped matching, and setup could never update the block
196
196
  again while `checkSetup` reported `markers-broken` on every session. Rule:
197
197
  never use file content as its own delimiter; agents will reword it. The
198
198
  legacy-prose detection constants exist only to migrate old installs.
199
+
200
+ ## The dual-shape plugin entry: named `server` + v2 default export silently breaks v1
201
+
202
+ opencode v1's plugin loader (`readV1Plugin`, identical across 1.18.x) reads
203
+ ONLY `mod.default`. A module that exports a named `server` plus a v2-shaped
204
+ default `{ id, setup }` (no `server` inside the default) makes v1 throw on
205
+ load - and the host swallows the error and SKIPS the plugin with no visible
206
+ message. The failure looks like "thatch tools disappeared", not like a load
207
+ error. This is why the dual entry exports a MERGED default object
208
+ (`{ id, setup, server }`) and why every shim (the user's
209
+ `~/.config/opencode/plugins/thatch.ts` and the QA runner's generated one)
210
+ must re-export the default as well as the name. A named-only shim loads on
211
+ v1 and silently disables thatch on v2 - same invisible failure, opposite
212
+ host.
213
+
214
+ ## The extraction buffer matches on tool NAME, so Code Mode execute calls re-queue pipeline traffic
215
+
216
+ The non-bufferable filter in `onToolExecuteAfter` (`src/runtime.ts`) matches
217
+ `input.tool`, but a Code Mode `execute` call that wraps
218
+ `tools.thatch_extraction_done` / `tools.thatch_memory_remember` inside its
219
+ code string arrives as tool name `execute`. Unfixed, every execute-wrapped
220
+ ack landed back in the buffer as an extractable interaction: each
221
+ extraction run's payload contained only the previous run's dispatch and ack,
222
+ the nudge always found pending interactions, and a session could loop
223
+ forever on dispatch → ack → nudge (observed live in an oink session,
224
+ September 2026 - the looped agent answered every turn with "Extractor
225
+ dispatched and acknowledged. Stopping per the nudge" and never resumed the
226
+ user's work; only a human message broke the cycle). The fix unwraps execute
227
+ calls (`unwrapExecuteThatchCalls` in `src/extraction.ts`) and runs the
228
+ wrapped tool's hook semantics instead of buffering. Lesson for sibling
229
+ hooks: when a filter reasons about tool identity, an aggregator tool
230
+ (arbitrary code execution over other tools) defeats name-based matching -
231
+ unwrap the aggregation before classifying.
232
+
233
+ ## LLMs guess tool-call shapes on the first call, so teach the shape at the mention site
234
+
235
+ A September 2026 audit of every thatch tool-call error in the opencode
236
+ session database (~19k calls, both v1 and v2 storage) found the failures
237
+ cluster on the model's FIRST thatch call - usually the startup recall round -
238
+ and fall into a short list of avoidable mistakes:
239
+
240
+ - **Bare positional string instead of an object argument** (the largest
241
+ bucket): `thatch_memory_recall({ query: "..." })` called as
242
+ `thatch_memory_recall("...")`. Root cause: the opencode system prompt's
243
+ startup section and the recall nudge both used positional-style examples
244
+ (`thatch_memory_recall "query"`), and Code Mode models mimic that shape
245
+ inside `execute`. Both now teach the object form.
246
+ - **Required discriminator params omitted**: `memory_remember` without
247
+ `label` (models pass `title`/`id`/`statement` instead, or nothing),
248
+ `prediction_update` without `signal`, `behavior_codify` without
249
+ `situation`. Fixes: `label` is optional and derived from content;
250
+ tool descriptions state the all-args-required contract.
251
+ - **Nudges leak into tool-less agent contexts**: task-dispatched sub-agents
252
+ (explore/general and friends) have restricted tool lists that exclude the
253
+ thatch tools, but `chat.message` nudges fired for them anyway, producing
254
+ guaranteed "No tool named ..." error rounds. The chat.message hook now
255
+ skips nudges for child sessions that are not extraction children.
256
+
257
+ The general lesson: any surface that names a tool to an LLM (system prompt,
258
+ nudge, skill text) is a usage-shape prior. Give the argument shape at the
259
+ mention site, because the model's first call happens before it has seen a
260
+ working example. Also note Code Mode `execute` validation failures are only
261
+ persisted in the v2 `session_message` store - they are invisible in the v1
262
+ `part` table, so error-rate analysis on old data undercounts them.
263
+
264
+ ## ThatchDB lazy reopen: new accessors must go through the private getters, and journal writes stay guarded at call sites
265
+
266
+ `ThatchDB` reopens its SQLite connection on first use after `close()`
267
+ (`src/db.ts`, the `#db`/`#predictions`/`#behaviors`/`#chat` private getters), so
268
+ an in-flight tool call that races a v2 plugin reload's `dispose()` completes
269
+ instead of throwing "Cannot use a closed database". Two rules keep this intact:
270
+
271
+ - **New facade methods need no extra wiring, but new state-holding members
272
+ do.** The engine facades (`ChatStore`, `PredictionEngine`, `BehaviorEngine`)
273
+ capture the `Database` handle at construction; the reopen rebuilds them. A
274
+ new member that captures the Database directly must get its own private
275
+ getter that ensures the handle is open first - a plain field keeps serving
276
+ the stale closed handle and the reopen silently never fires for it (this
277
+ exact bug is pinned by the "engine facades are rebuilt" test in
278
+ tests/db.test.ts).
279
+ - **Post-dispose journal writes are no-ops at their call sites, not in
280
+ db.ts.** After a reload, the RELOADED instance owns the `runtime_state`
281
+ rows, so delayed writers in `src/runtime.ts` check the `disposed` flag
282
+ BEFORE touching the db - the lazy reopen would otherwise hand a stale
283
+ writer a fresh connection. All three journal callbacks are guarded (the
284
+ extraction pipeline's finalization, the WatcherRegistry journal - a poll
285
+ cycle suspended at an await can outlive `watchers.dispose()` - and the
286
+ wrap-up arming write, whose command executes are not drained by the v2
287
+ cleanup). Do not "fix" the guard away by moving it into db.ts: the
288
+ accessor cannot tell a legitimate in-flight caller from a stale writer.
@@ -24,8 +24,9 @@ This document maps the feature parity and documents the remaining gaps.
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
25
25
  | **Wrap-up commands** (`/thatch/compact`, `/thatch/exit`) | Command markdown synced to `$XDG_CONFIG_HOME/opencode/command/thatch/` at plugin init; `command.execute.before` arms the session and the next idle resolves the greenlight token into a TUI action (`session.compact` via `executeCommand`, `app.exit` via `publish`) | No equivalent — no plugin host to arm the check or trigger the action | No equivalent | **Gapped** — needs the plugin's SDK client and TUI control routes |
26
26
  | **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
27
- | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-thatch, non-skill, non-task tool calls in-process; **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
27
+ | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-meta tool calls in-process (`isMetaToolName` in `src/extraction.ts`: thatch_*, skill, task, agent, subagent — execute calls unwrapping only thatch tools are also excluded); **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
28
28
  | **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
29
+ | **Automatic alerts** (notify on LLM pause / turn end) | Plugin event hook drives the `alerts.ts` state machine: `question.asked`/`permission.asked` → pause alert; `session.status` idle with a real-work round → done alert; unrecovered errors → needs-attention. Delivered via the shared `notify.ts` dispatcher, channel per event from the `alerts` config section | No event stream — only `thatch_notify_user` when the agent chooses | No event stream — only `thatch_notify_user` when the agent chooses | **Gapped** — MCP hosts have no plugin event hook to watch; Claude Code's `Notification`/`Stop` hooks could feed the same classifier later |
29
30
  | **Prompt-aware recall nudge** | `chat.message` hook embeds the prompt with the in-process warm model, searches `db.search()`, pushes a nudge part if matches ≥ threshold | `UserPromptSubmit` hook → `thatch flush-tools` connects to the MCP server's sideband socket; the warm server embeds + searches; hook prints the nudge or falls back to the write nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — sideband socket gives cold hook processes access to the warm MCP server's model |
30
31
  | **Prediction auto-fire** (user decision model) | `chat.message` hook scores the prompt embedding against prediction matchers; injects a `[thatch] User decision model` nudge alongside the recall nudge (separate synthetic part, same embedding call) | `UserPromptSubmit` hook → `thatch flush-tools` fires prediction query via the sideband socket's `predictions` method in parallel with the recall nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — same `scorePredictionNudge` entry point in both paths |
31
32
  | **Behavior auto-fire** (self-discipline rules) | `chat.message` hook scores the prompt embedding against behavior matchers; injects a `[thatch] Situational behaviors` nudge (separate synthetic part, same embedding call) | `UserPromptSubmit` hook → `thatch flush-tools` fires behavior query via the sideband socket's `behaviors` method in parallel with recall and predictions | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — same `scoreBehaviorNudge` entry point in both paths |
@@ -72,8 +73,8 @@ and the nudge (fallback):
72
73
 
73
74
  **Direct extraction (primary path):**
74
75
 
75
- 1. `tool.execute.after` buffers every non-thatch, non-skill, non-task tool
76
- call into a per-session in-memory array (max 20 interactions).
76
+ 1. `tool.execute.after` buffers every non-meta tool
77
+ call (`isMetaToolName` in `src/extraction.ts`) into a per-session in-memory array (max 20 interactions).
77
78
  2. When the parent session goes idle (`session.status` idle event) with pending
78
79
  buffer entries, the plugin calls `triggerExtraction`: creates a child session
79
80
  via `client.session.create` (with `parentID`), then prompts it via