@jeffober/thatch 0.2.1 → 0.2.3

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.
@@ -15,19 +15,23 @@ directly for conversation-derived knowledge.
15
15
 
16
16
  - Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call for later
17
17
  extraction
18
- - Two paths: **direct extraction** via a child session (opencode, primary) and
19
- **nudge-based extraction** (all hosts, fallback for opencode, primary for
20
- MCP)
21
- - The extraction nudge escalates: polite (0–1 missed), insistent (2),
22
- all-caps shouting (3+)
18
+ - Two paths: **direct extraction** via a plugin-created child session (the
19
+ only opencode path) and **nudge-based extraction** (MCP hosts only)
20
+ - The MCP extraction nudge escalates: polite (0–1 missed), insistent (2),
21
+ all-caps shouting (3+). There is deliberately no opencode nudge: the
22
+ model-driven handshake it required raced its own state machine (acks
23
+ before fetches, no-claim completions wiping in-flight sets, mis-targeted
24
+ session ids making every transition a silent no-op) and was removed —
25
+ see "Key invariants"
23
26
  - `thatch_get_extraction_payload` fetches queued interactions as JSON,
24
- keeping the full payload out of the main session's context window
25
- - `thatch_extraction_done` acknowledges and quiets the nudge
27
+ keeping the full payload out of the main session's context window; the
28
+ fetch is also the **delivery record** (it claims the entries for that
29
+ fetcher)
30
+ - `thatch_extraction_done` completes only the calling child's claimed
31
+ delivery — never the whole accepted set
26
32
  - AMQP-style buffer lifecycle for opencode: pending → accepted → completed,
27
33
  with requeue on failure
28
34
  - File-backed JSONL queue for MCP hosts (no cross-call state)
29
- - Parent-child session drain: the child drains the parent's snapshot,
30
- preserving interleaved-turn entries added after the snapshot was taken
31
35
 
32
36
  ## How it works
33
37
 
@@ -71,57 +75,70 @@ calls are buffered for later extraction.
71
75
  - Filters the same tools as opencode (`mcp__thatch__memory_remember`,
72
76
  `mcp__thatch__extraction_done`, `mcp__thatch__*`, `skill`, `task`, `agent`)
73
77
 
74
- ### Direct extraction (opencode, primary path)
78
+ ### Direct extraction (opencode, the only opencode path)
75
79
 
76
80
  When a parent session goes idle (`session.status` idle event) with pending
77
- buffer interactions:
81
+ buffer interactions — after `requeueStaleAccepted()` has returned any
82
+ timed-out accepted entries to pending:
78
83
 
79
84
  1. `triggerExtraction` adds the parent ID to the `extracting` set — this
80
- suppresses the nudge in `chat.message`
85
+ gates re-triggering while a child runs
81
86
  2. Peeks the buffer to count pending interactions
82
87
  3. Creates a child session via
83
88
  `client.session.create({ parentID, title: "thatch-extraction" })`
84
89
  4. The `session.created` event fires, populating `childToParent` and
85
- `parentSnapshots` (a snapshot of the full pending buffer at dispatch time)
90
+ `parentSnapshots` (a snapshot of the full pending buffer at dispatch
91
+ time — journal-recovery data only; claims subsumed the snapshot drain)
86
92
  5. Adds the child ID to the `extractionChildren` set
87
- 6. Prompts the child with `extractionDirectPrompt(count, sessionID)`
93
+ 6. Prompts the child with `extractionDirectPrompt(count, sessionID)` — the
94
+ plugin interpolates the parent's session ID into the prompt, so no
95
+ model ever has to copy one
88
96
  7. If background sub-agents are enabled
89
97
  (`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`): `promptAsync`. Otherwise:
90
98
  fire-and-forget `prompt`
91
99
  8. On prompt failure: `cleanupChild` removes the child from all maps and
92
- deletes the child session
100
+ deletes the child session. The entries stay pending; the next idle
101
+ retries (there is no nudge fallback)
93
102
 
94
103
  The child session then:
95
104
 
96
- - Calls `thatch_get_extraction_payload` to fetch the queued interactions
105
+ - Calls `thatch_get_extraction_payload` — the fetch **claims** the entries
106
+ for this fetcher (claim = accept + delivery record)
97
107
  - Runs the [thatch-fact-extractor](../skills.md) skill
98
108
  - Writes memories via `thatch_memory_remember`
99
109
  - Goes idle
100
110
 
101
111
  On child idle (`session.status` idle event, extraction child):
102
112
 
103
- 1. Drains the parent's snapshot from the pending buffer via
104
- `consumeSnapshot` — removes only entries captured at dispatch time by
105
- reference identity, preserving interleaved-turn entries
113
+ 1. `completeClaimed(childID)` — consumes only the entries this child
114
+ claimed via its fetch. A no-claim idle is a no-op: a child that never
115
+ fetched processed nothing, and its idle signal must not drop entries
116
+ another extractor holds
106
117
  2. Fires a toast with extraction metrics (only if memories were actually
107
118
  written)
108
- 3. `completeAccepted(parentID)`, resets `missedNudges`
109
- 4. Cleans up all maps
110
- 5. `consume(childID)` — drains the child's own buffer
111
- 6. Deletes the child session
119
+ 3. Cleans up all maps
120
+ 4. `consume(childID)` — drains the child's own buffer
121
+ 5. Deletes the child session
112
122
 
113
- ### Nudge-based extraction (fallback for opencode, primary for MCP)
123
+ If the extraction child is deleted before completing,
124
+ `requeueClaimed`/`requeueAccepted` returns what it held to pending. If its
125
+ completion signal never comes at all, `requeueStaleAccepted` (15 min)
126
+ returns the accepted entries to pending — the next idle re-extracts them.
114
127
 
115
- If direct extraction was never triggered or threw an error (session not in
116
- the `extracting` set) **and** the buffer has pending interactions:
128
+ ### Nudge-based extraction (MCP hosts only)
129
+
130
+ Claude Code and Cursor have no SDK client to create child sessions and no
131
+ plugin lifecycle (each hook invocation is a fresh process), so extraction
132
+ there is still model-driven. When the buffer has pending interactions, the
133
+ next user prompt (`UserPromptSubmit`/`beforeSubmitPrompt`) gets an
134
+ extraction nudge telling the agent to spawn a sub-agent for the
135
+ fact-extractor skill.
117
136
 
118
- - The next user message (`chat.message` for opencode,
119
- `UserPromptSubmit`/`beforeSubmitPrompt` for MCP) gets an extraction nudge
120
137
  - The nudge carries the session ID and fetch tool name — not the full
121
138
  payload — so the sub-agent calls `thatch_get_extraction_payload` to
122
139
  retrieve the interactions as a tool response
123
140
 
124
- **Nudge escalation** (via the `missedNudges` counter):
141
+ **Nudge escalation** (via the file-backed missed counter):
125
142
 
126
143
  | Missed count | Tone |
127
144
  |--------------|------|
@@ -129,26 +146,29 @@ the `extracting` set) **and** the buffer has pending interactions:
129
146
  | 2 | Insistent |
130
147
  | 3+ | All-caps shouting |
131
148
 
132
- The buffer persists until the agent writes a memory or calls
133
- `thatch_extraction_done`. Ignored nudges repeat and escalate.
134
-
135
- - **opencode**: in-memory `missedNudges` map
136
- - **MCP hosts**: file-backed `.count` file per session
149
+ The file-backed queue persists until the extractor completes
150
+ (`extraction_done` with the parent's `session_id`) or the parent writes a
151
+ memory itself. The parent's dispatch-time ack resets the escalation
152
+ counter but does NOT drain the queue — a drain at ack time deleted the
153
+ queue before the sub-agent fetched (the accept-before-fetch loss; the
154
+ opencode path removed the handshake for the same failure class).
137
155
 
138
156
  ### Buffer lifecycle (opencode, AMQP-style)
139
157
 
140
158
  Buffered interactions move through four states in `ExtractionPipeline`
141
159
  (`src/extraction.ts`):
142
160
 
143
- - **pending** — interactions in the ring buffer. The nudge fires on
144
- `chat.message`.
145
- - **accepted** — moved from pending by `accept()`. The nudge quiets, but
146
- entries are not dropped yet.
147
- - **completed** — dropped by `completeAccepted()`. Triggered by a
148
- `memory_remember` or `extraction_done` call in the child, or the child
149
- going idle.
150
- - **requeued** — moved back to pending by `requeueAccepted()`. Triggered by
151
- the child session erroring or being deleted before completing.
161
+ - **pending** — interactions in the ring buffer. The idle trigger
162
+ (`triggerExtraction`) fires on them; there is no model-facing nudge.
163
+ - **accepted** — moved from pending by `accept()` (a parent's legacy ack,
164
+ or a fetcher's claim). Entries are held, not dropped, and are still
165
+ served by the payload provider.
166
+ - **completed** — dropped by `completeClaimed()` (the fetcher's completion
167
+ signal) or `completeAccepted()` (the parent session being deleted —
168
+ nothing exists to replay into).
169
+ - **requeued** — moved back to pending by `requeueClaimed()` /
170
+ `requeueAccepted()` (child error or deletion before completing) or
171
+ `requeueStaleAccepted()` (15-minute completion timeout).
152
172
 
153
173
  Key methods:
154
174
 
@@ -157,36 +177,42 @@ Key methods:
157
177
  | `push()` | Add interaction to pending buffer (capped at 20) |
158
178
  | `peek()` | Read without clearing |
159
179
  | `consume()` | Delete the session's pending buffer (called on memory write) |
160
- | `accept()` | Move pending to accepted — quiet the nudge, hold entries |
161
- | `completeAccepted()` | Drop accepted entries — extractor finished |
162
- | `requeueAccepted()` | Move accepted back to pending — extractor died |
163
- | `consumeSnapshot()` | Remove only entries that were in the snapshot (by reference identity). Used for child-parent drain. |
164
-
165
- ### Parent-child session drain
166
-
167
- When a child session writes a memory via `thatch_memory_remember`:
168
-
169
- 1. `tool.execute.after` detects the memory write
170
- 2. If in a child session (`childToParent.has(sessionID)`): track metrics,
171
- `completeAccepted(parentID)`, drain the parent's snapshot via
172
- `consumeSnapshot`, reset the parent's `missedNudges`
173
- 3. `consume(sessionID)` — drains the child's own buffer
174
-
175
- `consumeSnapshot` is **snapshot-aware**: it removes only entries that were
176
- in the parent's buffer at dispatch time (by reference identity).
177
- Interleaved-turn entries — added after the snapshot was taken, while the
178
- child was extracting — survive. This prevents data loss when the parent
179
- continues working while the child extracts.
180
+ | `accept()` | Move pending to accepted — hold entries (non-destructive) |
181
+ | `claim()` | Accept + record WHICH fetcher received the entries — the delivery record |
182
+ | `completeClaimed()` | Drop only the completing fetcher's claimed entries |
183
+ | `completeAccepted()` | Drop accepted entries — used on parent deletion |
184
+ | `requeueClaimed()` / `requeueAccepted()` | Move held entries back to pending — extractor died |
185
+ | `requeueStaleAccepted()` | Requeue accepted entries held longer than 15 min |
186
+
187
+ ### Parent-child delivery (claim semantics)
188
+
189
+ The payload fetch is the delivery record:
190
+
191
+ 1. `thatch_get_extraction_payload` with a fetcher identity calls
192
+ `claim(parentID, fetcherID)`: the pending buffer is accepted and the
193
+ fetcher's claim records exactly which entries it received
194
+ 2. The fetcher's completion signal (`extraction_done` or
195
+ `memory_remember` from that child) calls `completeClaimed(fetcherID)` —
196
+ consuming only the claimed entries, by reference identity
197
+ 3. A completion from a fetcher with no claim is a **no-op** — it processed
198
+ nothing, so it must not drop entries another extractor holds (a sibling
199
+ sub-agent going idle, or a mis-ordered ack, once wiped the whole
200
+ accepted set this way — the accept-then-racing-completion loss)
201
+ 4. Entries that arrived after the fetch stay pending; the next idle
202
+ re-extracts them
180
203
 
181
204
  ### MCP file-backed queue drain
182
205
 
183
206
  - `drainExtractionQueue(sessionID)` calls `resetMissedCount` +
184
207
  `consumeQueue` (deletes the JSONL file)
185
208
  - Triggered by: `thatch_extraction_done` called with the parent's
186
- `session_id`, or `thatch_memory_remember` called
187
- - `appendBatch` in `extract-queue.ts` self-detects
188
- `memory_remember`/`extraction_done` calls and resets the counter +
189
- consumes the queue inline
209
+ `session_id` (the extractor's completion), or `thatch_memory_remember`
210
+ called by the parent itself
211
+ - `appendBatch` in `extract-queue.ts` self-detects `memory_remember` and
212
+ resets the counter + consumes the queue inline. It detects
213
+ `extraction_done` too, but only resets the counter — draining at the
214
+ parent's dispatch-time ack deleted the queue before the sub-agent
215
+ fetched (the accept-before-fetch loss)
190
216
 
191
217
  ### The `extraction_done` tool
192
218
 
@@ -197,6 +223,10 @@ continues working while the child extracts.
197
223
  - The real state transitions happen in the host's post-tool hook
198
224
  (`tool.execute.after` for opencode, `PostToolBatch`/`appendBatch` for MCP)
199
225
  - The tool exists primarily so the model has a recognizable name to key on
226
+ - On opencode there is no parent ack any more: a parent-side
227
+ `extraction_done` is tolerated as a non-destructive accept (entries
228
+ held, not dropped) for backward compatibility with an in-flight older
229
+ nudge
200
230
 
201
231
  ### The `get_extraction_payload` tool
202
232
 
@@ -226,14 +256,15 @@ continues working while the child extracts.
226
256
  - [Memory store](memory-store.md) — extraction writes memories via
227
257
  `thatch_memory_remember`
228
258
  - [Nudge pipeline](nudge-pipeline.md) — the extraction nudge is tier 1
229
- (highest priority, returns early)
259
+ (highest priority, returns early) on MCP hosts; opencode's
260
+ `chat.message` runs the recall tier only
230
261
  - [Session lifecycle](session-lifecycle.md) — direct extraction is triggered
231
262
  by `session.status` idle; child lifecycle is managed by the event handler
232
263
  - [Skills](../skills.md) — the thatch-fact-extractor skill is dispatched to
233
264
  child sessions
234
265
  - [Multi-host](multi-host.md) — in-memory ring buffer for opencode,
235
266
  file-backed queue for MCP hosts
236
- - [Compaction recovery](compaction-recovery.md) — extraction nudge is
267
+ - [Compaction recovery](compaction-recovery.md) — nudges are
237
268
  suppressed during compaction (tools are blocked)
238
269
 
239
270
  ## Source files
@@ -242,25 +273,33 @@ continues working while the child extracts.
242
273
  |------|----------------|
243
274
  | `src/extraction.ts` | In-memory ring buffer (`ExtractionPipeline`), shared payload builders (`buildExtractionPayload`, `deriveTitle`, `summarizeArgs`) |
244
275
  | `src/extract-queue.ts` | File-backed JSONL queue for MCP hosts |
245
- | `src/runtime.ts` | opencode hooks: `tool.execute.after`, `session.status` idle, `session.created`, `session.error`, `session.deleted`, `chat.message` (nudge tier 1) -- shared by the v1/v2 adapters |
276
+ | `src/runtime.ts` | opencode hooks: `tool.execute.after`, `session.status` idle (direct extraction trigger), `session.created`, `session.error`, `session.deleted`, `chat.message` (recall/prediction/behavior nudges; no extraction nudge) -- shared by the v1/v2 adapters |
246
277
  | `bin/thatch` | `buffer-batch`, `buffer-tool`, `flush-tools` subcommands |
247
278
  | `src/prompts.ts` | `extractionNudge` (with escalation), `extractionDirectPrompt` |
248
279
 
249
280
  ## Key invariants
250
281
 
251
- 1. **Tool filtering is absolute.** `thatch_*`, `skill`, and `task` tools are
252
- never buffered. Buffering them would echo the store into itself or create
253
- a feedback loop.
254
- 2. **The nudge peeks, never flushes.** The buffer persists until a memory
255
- write or `extraction_done`. Ignored nudges repeat and escalate.
256
- 3. **`consumeSnapshot` is snapshot-aware.** It removes only entries captured
257
- at dispatch time (by reference identity), preserving interleaved-turn
258
- entries added while the child was extracting.
282
+ 1. **Tool filtering is absolute.** `thatch_*`, `skill`, `task`, and
283
+ `subagent` tools are never buffered. Buffering them would echo the store
284
+ into itself or create a feedback loop.
285
+ 2. **Extraction never depends on model cooperation on opencode.** The
286
+ plugin creates the extraction child, interpolates the session ID, and
287
+ drives the lifecycle from events. The old model-driven handshake (model
288
+ dispatches, parent acks, child copies the ID) raced its own state
289
+ machine — the September 2026 dispatch-loop report (accept-before-fetch
290
+ loss + never-terminating re-nudge) — and was removed. The opencode
291
+ `chat.message` hook computes no extraction nudge.
292
+ 3. **Completion is claim-scoped.** Only a fetcher that recorded a claim
293
+ (via `get_extraction_payload`) can consume entries, and only the entries
294
+ it received. No-claim completions are no-ops; no transition drops
295
+ entries another extractor holds. The stale reaper bounds any orphan
296
+ linger at 15 minutes.
259
297
  4. **`tool.execute.after` is a plugin hook, not a bus event.** The event bus
260
298
  has no such event. Moving the buffering logic into the `event` handler
261
299
  silently never fires it.
262
300
  5. **MCP host hooks must be silent.** `PostToolBatch`/`postToolUse` produce
263
301
  no stdout — only `flush-tools` prints. Any stdout delays the agent loop.
264
- 6. **The no-save drain runs unconditionally.** The child-idle handler drains
265
- the remaining snapshot regardless of whether the child wrote memories,
266
- covering no-save extraction runs.
302
+ 6. **The MCP queue is durable until the extractor completes.** The
303
+ parent's dispatch-time ack must not drain it (see the invariant above);
304
+ the drain happens at the extractor's completion or the parent's own
305
+ memory write.
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- Runs on every user message. Four priority tiers share one embedding
5
+ Runs on every user message. Priority tiers share one embedding
6
6
  computation. The pipeline injects *synthetic parts*---text the model sees in
7
7
  the conversation but the user does not see in the TUI. This is the inverse of
8
8
  toast notifications, which are TUI-visible but model-invisible.
@@ -10,16 +10,18 @@ toast notifications, which are TUI-visible but model-invisible.
10
10
  The nudge pipeline is thatch's core innovation. It is not a passive
11
11
  store-and-retrieve system. It is a nudge layer that changes agent behavior by
12
12
  injecting context at the right moment. See [extraction.md](extraction.md) for
13
- the extraction nudge, [prediction-engine.md](prediction-engine.md) for the
13
+ the extraction pipeline (plugin-driven on opencode; nudged on MCP hosts only),
14
+ [prediction-engine.md](prediction-engine.md) for the
14
15
  prediction auto-fire, and [behavior-engine.md](behavior-engine.md) for the
15
16
  behavior auto-fire.
16
17
 
17
18
  ## What it does
18
19
 
19
- On every user message, thatch runs through four priority tiers:
20
+ On every user message, thatch runs through the priority tiers:
20
21
 
21
- 1. **Extraction nudge**---if tool interactions are buffered, tell the agent to
22
- extract memories.
22
+ 1. **Extraction nudge** (MCP hosts only)---if tool interactions are queued,
23
+ tell the agent to dispatch an extractor. There is deliberately no
24
+ opencode extraction nudge: extraction is plugin-driven at session idle.
23
25
  2. **Recall nudge**---semantically search stored memories for context
24
26
  relevant to the user's prompt.
25
27
  3. **Prediction nudge**---surface user decision-model predictions relevant to
@@ -28,14 +30,16 @@ On every user message, thatch runs through four priority tiers:
28
30
  current situation.
29
31
 
30
32
  Tiers 2--4 fire independently and share one embedding computation. Tier 1
31
- returns early, skipping tiers 2--4.
33
+ (extraction) exists only on the MCP path.
32
34
 
33
35
  ## How it works
34
36
 
35
- Two host paths deliver the same four tiers through different mechanisms. The
36
- opencode path runs in-process with direct event hooks. The MCP path runs via
37
- external CLI hook processes that communicate with the long-lived MCP server
38
- through the sideband socket.
37
+ Two host paths deliver the tiers through different mechanisms. The opencode
38
+ path runs in-process with direct event hooks and has THREE tiers (recall,
39
+ prediction, behavior - extraction is plugin-driven at session idle, not
40
+ nudged). The MCP path runs via external CLI hook processes that communicate
41
+ with the long-lived MCP server through the sideband socket and keeps all
42
+ four.
39
43
 
40
44
  ### opencode path (`src/runtime.ts`, `onChatMessage` - v1 `chat.message` hook, v2 `session.hook("prompt")`)
41
45
 
@@ -53,28 +57,25 @@ See [compaction-recovery.md](compaction-recovery.md).
53
57
 
54
58
  If the session is a child in `childToParent` but not in `extractionChildren`
55
59
  (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
60
+ returns early. Dispatched sub-agents may have restricted tool lists
57
61
  that exclude the thatch tools, and even where the tools exist the child is
58
62
  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
63
+ error rounds or noise. See
61
64
  [session-lifecycle.md](session-lifecycle.md) for the `extractionChildren`
62
65
  distinction.
63
66
 
64
- #### Tier 1---Extraction nudge (fallback path)
67
+ #### Tier 1---Extraction nudge: REMOVED from opencode
65
68
 
66
- Fires only when direct extraction was never triggered or threw (the session is
67
- not in the `extracting` set) **and** the in-memory buffer has pending
68
- interactions.
69
-
70
- - Injects `extractionNudge(count, missed, "thatch_memory_remember", sessionID)`
71
- as a synthetic text part.
72
- - Increments the `missedNudges` counter for escalation: polite at 0, insistent
73
- at 2, ALL-CAPS shouting at 3+.
74
- - Returns early. Tiers 2--4 are skipped.
75
-
76
- See [extraction.md](extraction.md) for the full extraction pipeline, including
77
- the accept/complete/requeue lifecycle and the `extracting` set.
69
+ There is deliberately no extraction nudge on the opencode path. The old
70
+ model-driven handshake (nudge → model dispatches a sub-agent → parent acks
71
+ with `thatch_extraction_done` → child fetches by a model-copied session ID)
72
+ raced its own state machine: the parent's ack accepted the buffer before
73
+ the child fetched, a no-claim completion wiped the accepted set, and
74
+ mis-targeted IDs made every transition a silent no-op - so the same queued
75
+ count re-fired forever while buffered interactions were silently lost (the
76
+ September 2026 dispatch-loop report, both defects). Extraction on opencode
77
+ is now plugin-driven end-to-end (`triggerExtraction` at session idle); see
78
+ [extraction.md](extraction.md). The MCP path keeps its nudge - see below.
78
79
 
79
80
  #### Tier 2---Recall nudge
80
81
 
@@ -127,8 +128,7 @@ and the self-discipline rule data model.
127
128
  #### Tier independence
128
129
 
129
130
  Tiers 2, 3, and 4 all fire independently. Any subset may inject. All share
130
- one embedding computation. The embedding is computed once, after the
131
- extraction tier returns early, and is reused across three cosine scans against
131
+ one embedding computation, reused across three cosine scans against
132
132
  different tables.
133
133
 
134
134
  ### MCP path (`bin/thatch`, `flush-tools` subcommand)
@@ -156,10 +156,14 @@ Three priority tiers, first match wins:
156
156
  #### Tier 1---Extraction nudge
157
157
 
158
158
  - `peekQueue(sessionID)` peeks the file-backed JSONL queue without draining
159
- it. The queue persists until the agent calls `memory_remember`, which
160
- triggers `consumeQueue`.
159
+ it. The queue persists until the extractor completes
160
+ (`extraction_done` with the parent's session id) or the agent calls
161
+ `memory_remember`, which triggers `consumeQueue`. The parent's
162
+ dispatch-time ack resets the escalation counter but does NOT drain - a
163
+ drain at ack time deleted the queue before the sub-agent fetched (the
164
+ accept-before-fetch loss).
161
165
  - If interactions are pending: increments the missed count, prints
162
- `extractionNudge(count, missed, "mcp__thatch__memory_remember", sessionID)`.
166
+ `extractionNudge(count, missed, sessionID)`.
163
167
  - Breaks. Tiers 2--3 are skipped.
164
168
 
165
169
  See [extraction.md](extraction.md) for the file-backed queue and the
@@ -253,9 +257,10 @@ TUI display. The opencode framework's history serializer filters on
253
257
 
254
258
  ## Interactions with other features
255
259
 
256
- - **Extraction pipeline** ([extraction.md](extraction.md)): tier 1 is the
257
- extraction nudge. The `extracting` set suppresses tier 1 when direct
258
- extraction is active.
260
+ - **Extraction pipeline** ([extraction.md](extraction.md)): the MCP tier 1
261
+ is the extraction nudge. On opencode there is no extraction
262
+ nudge - extraction is plugin-driven at session idle, so `chat.message`
263
+ runs the recall/prediction/behavior tiers only.
259
264
  - **Memory store** ([memory-store.md](memory-store.md)): tier 2 searches
260
265
  stored memories via `db.search()`. This path records no telemetry---the
261
266
  agent has not read the memories yet.
@@ -272,21 +277,21 @@ TUI display. The opencode framework's history serializer filters on
272
277
  the nudge pipeline is suppressed during compaction. The compaction guard
273
278
  clears stale flags when compaction fails.
274
279
  - **Session lifecycle** ([session-lifecycle.md](session-lifecycle.md)):
275
- direct extraction (`triggerExtraction`) suppresses tier 1 by adding the
276
- session to the `extracting` set.
280
+ direct extraction (`triggerExtraction`) is triggered by session idle;
281
+ there is no extraction tier to suppress on opencode.
277
282
 
278
283
  ## Source files
279
284
 
280
285
  | File | Role |
281
286
  |------|------|
282
- | `src/runtime.ts` | opencode: per-message nudges (all 4 tiers), compaction guard, extraction fallback |
283
- | `bin/thatch` | MCP: `flush-tools` and `flush-predictions` subcommands |
287
+ | `src/runtime.ts` | opencode: per-message nudges (recall/prediction/behavior tiers; no extraction nudge), compaction guard |
288
+ | `bin/thatch` | MCP: `flush-tools` and `flush-predictions` subcommands (all 4 tiers) |
284
289
  | `src/sideband.ts` | MCP: sideband client helpers (`sidebandMatch`, `sidebandPredictions`, `sidebandBehaviors`) |
285
290
  | `src/prompts.ts` | All nudge formatting functions (`recallNudge`, `claudeRecallNudge`, `predictionNudge`, `behaviorNudge`, `extractionNudge`, `claudeWriteNudge`) |
286
291
 
287
292
  ## Key invariants
288
293
 
289
- - Tier 1 (extraction) returns early. Tiers 2-4 are skipped when tier 1 fires.
294
+ - Tier 1 (extraction, MCP only) returns early. Tiers 2-4 are skipped when tier 1 fires.
290
295
  - Tiers 2-4 share one embedding computation. One embed, three cosine scans against different tables.
291
296
  - Each tier is wrapped in its own try/catch. A failure in one tier does not block the others.
292
297
  - The recall nudge uses `db.search()`, not `db.recall()`, to avoid inflating telemetry. The agent has not read the memories yet.
@@ -27,7 +27,10 @@ Subscribes to all session bus events. Dispatches based on `event.type`.
27
27
  2. Snapshot the parent's current pending buffer: `parentSnapshots.set(childID, [...extraction.peek(parentID)])`
28
28
  3. Return early -- child sessions don't get the session-start reminder
29
29
 
30
- This snapshot is used by `consumeSnapshot` for snapshot-aware drain: when the child writes memories, only the snapshot entries are removed from the parent's buffer, preserving interleaved-turn entries that were added after the snapshot was taken.
30
+ The snapshot is journal-recovery data only (restored child bookkeeping
31
+ after a reload); live delivery is claim-based: the child's payload fetch
32
+ records exactly which entries it received, and its completion signal
33
+ consumes only those.
31
34
 
32
35
  ### session.created without parentID (top-level session)
33
36
 
@@ -39,20 +42,22 @@ This snapshot is used by `consumeSnapshot` for snapshot-aware drain: when the ch
39
42
 
40
43
  When a child created by `triggerExtraction` goes idle:
41
44
 
42
- 1. Drain the parent's snapshot from pending buffer via `consumeSnapshot` (if still present -- a no-save run's entries need draining so they don't replay; a save run already drained them via `tool.execute.after`)
43
- 2. Never drains the entire buffer -- interleaved-turn entries survive
44
- 3. Fire a toast with extraction metrics (new/updated/deleted counts) -- only if memories were actually written
45
- 4. `completeAccepted(parentID)`, reset `missedNudges`
46
- 5. Clean up all maps: `extracting`, `childToParent`, `parentSnapshots`, `childMetrics`, `extractionChildren`
47
- 6. `consume(childID)` -- drain child's own buffer
48
- 7. Delete the child session via `client.session.delete`
45
+ 1. `completeClaimed(childID)` -- consume only the entries this child
46
+ claimed via its payload fetch. A no-claim idle is a no-op: a child that
47
+ never fetched processed nothing, and its idle signal must not drop
48
+ entries another extractor holds. Held entries with no completer are
49
+ bounded by the 15-minute stale reaper
50
+ 2. Fire a toast with extraction metrics (new/updated/deleted counts) -- only if memories were actually written
51
+ 3. Clean up all maps: `extracting`, `childToParent`, `parentSnapshots`, `childMetrics`, `extractionChildren`
52
+ 4. `consume(childID)` -- drain child's own buffer
53
+ 5. Delete the child session via `client.session.delete`
49
54
 
50
55
  ### session.status idle -- task-dispatched sub-agent
51
56
 
52
57
  When a task-dispatched sub-agent (not created by `triggerExtraction`) goes idle:
53
58
 
54
- - `completeAccepted(parentID)` -- complete the parent's accepted entries (from the nudge-path `extraction_done` accept)
55
- - Reset `missedNudges`
59
+ - `completeClaimed(childID)` -- complete only what THIS child claimed via
60
+ its payload fetch (a no-claim idle consumes nothing)
56
61
  - Does NOT drain the buffer or delete the session -- the task tool that dispatched the sub-agent needs to read its output
57
62
 
58
63
  ### session.status idle -- parent session
@@ -60,8 +65,9 @@ When a task-dispatched sub-agent (not created by `triggerExtraction`) goes idle:
60
65
  When a parent session (no parentID) goes idle:
61
66
 
62
67
  - Wrap-up resolution first (below): a pending `/thatch/compact` or `/thatch/exit` resolves here, and a greenlit action returns early
68
+ - `requeueStaleAccepted()` first: accepted entries whose completion signal never came (15 min) return to pending
63
69
  - If not compacting, not already extracting, and buffer has pending interactions: `triggerExtraction(sessionID)`
64
- - On failure: log error, clear `extracting` flag (nudge path takes over as fallback on next chat.message)
70
+ - On failure: log error, clear `extracting` flag (the next idle retries -- there is no model-facing nudge fallback)
65
71
 
66
72
  ### Wrap-up commands (`/thatch/compact`, `/thatch/exit`)
67
73
 
@@ -84,12 +90,21 @@ first-ever install is invisible until the next server start.
84
90
 
85
91
  ### session.error (child session)
86
92
 
87
- - `requeueAccepted(parentID)` -- move parent's accepted entries back to pending (the extractor never processed them)
93
+ - `requeueClaimed(childID)` if it fetched (the delivery was never
94
+ processed), else `requeueAccepted(parentID)` -- the error is a terminal
95
+ death signal, so a claim-less child falls back to whole-set requeue for
96
+ immediate recovery
97
+ - Clear `extracting` for the parent (the next idle re-triggers)
88
98
  - Clean up all maps for the child
89
99
 
90
100
  ### session.deleted -- child
91
101
 
92
- - `requeueAccepted(parentID)` -- entries go back to pending (never processed)
102
+ - Extraction child: `requeueClaimed(childID)`, else
103
+ `requeueAccepted(parentID)` (never processed)
104
+ - Task-kind child: `requeueClaimed(childID)` only -- task children are
105
+ deleted ROUTINELY, so a claim-less task child's deletion must not requeue
106
+ the whole accepted set (it would yank an in-flight extractor's payload
107
+ back to pending for duplicate extraction)
93
108
  - Clean up all maps for the child
94
109
 
95
110
  ### session.deleted -- parent
@@ -106,21 +121,20 @@ first-ever install is invisible until the next server start.
106
121
  ### Internal state maps
107
122
 
108
123
  - **childToParent**: `Map<childID, parentID>` -- maps child sessions to their parents
109
- - **parentSnapshots**: `Map<childID, Interaction[]>` -- snapshot of parent's buffer at child creation time
124
+ - **parentSnapshots**: `Map<childID, Interaction[]>` -- snapshot of parent's buffer at child creation time (journal recovery only; live delivery is claim-based)
110
125
  - **childMetrics**: `Map<childID, {new, updated, deleted}>` -- extraction metrics per child
111
- - **extracting**: `Set<parentID>` -- parent IDs with an active direct-extraction child (suppresses nudge)
126
+ - **extracting**: `Set<parentID>` -- parent IDs with an active direct-extraction child (gates re-triggering)
112
127
  - **extractionChildren**: `Set<childID>` -- distinguishes extraction children from task-dispatched sub-agents
113
128
  - **compacting**: `Set<sessionID>` -- sessions currently being compacted (suppresses nudges)
114
129
  - **pendingWrapUp**: `Map<sessionID, {token, kind}>` -- wrap-up commands awaiting their greenlight check; armed by `command.execute.before`, resolved on the next idle, cleared on session deletion
115
- - **missedNudges**: `Map<sessionID, number>` -- extraction nudge escalation counter
116
130
 
117
131
  ## Interactions with other features
118
132
 
119
133
  - Extraction pipeline ([extraction.md](extraction.md)): direct extraction is triggered by `session.status idle`; child lifecycle managed here
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
134
+ - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `compacting` set suppresses all nudges; task-dispatched sub-agents (in `childToParent`, not in `extractionChildren`) suppress all tiers
121
135
  - Hygiene ([hygiene.md](hygiene.md)): hygiene report runs at `session.created` for top-level sessions
122
136
  - Compaction recovery ([compaction-recovery.md](compaction-recovery.md)): `session.compacted` event clears the compacting flag
123
- - Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which triggers drain via `tool.execute.after`
137
+ - Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which completes their claimed delivery via `tool.execute.after`
124
138
 
125
139
  ## Source files
126
140
 
@@ -130,7 +144,7 @@ first-ever install is invisible until the next server start.
130
144
  ## Key invariants
131
145
 
132
146
  - Child sessions don't get the session-start reminder (early return in `session.created` with parentID).
133
- - `consumeSnapshot` is snapshot-aware: removes only entries captured at dispatch time, preserving interleaved-turn entries.
147
+ - Delivery is claim-based: the payload fetch records what the fetcher received; only the fetcher's completion consumes it. A no-claim completion is a no-op, never a whole-set drop.
134
148
  - Extraction children are deleted after going idle; task-dispatched sub-agents are NOT deleted (task tool reads output).
135
- - Child errors requeue the parent's accepted entries (never processed). Child deletion also requeues.
149
+ - Child errors requeue what the child held (never processed). Child deletion also requeues.
136
150
  - Parent deletion completes accepted entries (takes them with it).
@@ -73,6 +73,15 @@ gh setup fails registration with a real error instead of producing a
73
73
  watcher that never fires. Watch creation is also gated on a cached
74
74
  `gh --version` availability check.
75
75
 
76
+ CI events (`pr_ci`, `branch_ci`) fire only on a running -> completed
77
+ check-run transition, so a watcher registered after a fast pipeline has
78
+ already finished holds completed runs in its baseline and can never
79
+ notify for that head. When every check run on the head is already
80
+ completed at registration, the tool response says so, with the
81
+ conclusion breakdown (`describeBaselineCheckRuns`), so the caller reads
82
+ the result instead of waiting on a notification that cannot come. The
83
+ rule for catching a push's CI: register the watcher before pushing.
84
+
76
85
  ### Polling
77
86
 
78
87
  A `setInterval` loop (unref'd - it never keeps the process alive)