@jeffober/thatch 0.2.2 → 0.2.4

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.
package/bin/thatch CHANGED
@@ -8,7 +8,7 @@ import { detectRepo } from "../src/git";
8
8
  import { BgeEmbeddingModel } from "../src/embeddings";
9
9
  import { hygieneReport } from "../src/hygiene";
10
10
  import { claudeSessionStartReminder } from "../src/prompts";
11
- import { setupClaudeCode, setupCursor, registerClaudeMcpServer } from "../src/setup";
11
+ import { setupClaudeCode, setupCursor, setupSkillsOnly, registerClaudeMcpServer } from "../src/setup";
12
12
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
13
13
  import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, sortChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
14
14
  import { chatEnabled, loadConfig } from "../src/config";
@@ -38,8 +38,10 @@ function usage(): never {
38
38
  thatch flush-tools [--json] Peek the queue + print an extraction nudge (hook only)
39
39
  thatch chat-notify Print a chat follow-up message when mail is unread (Cursor stop hook)
40
40
  thatch flush-predictions [--json] Query predictions for the prompt (hook only)
41
- thatch setup --claude [--cursor] [--local|--global]
42
- Install config + instructions + hooks + skills
41
+ thatch setup --claude [--cursor] [--local|--global] [--skills-only]
42
+ Install config + instructions + hooks + skills.
43
+ --skills-only refreshes just the skill files
44
+ (skips MCP config, instructions, hooks, commands)
43
45
  thatch session list -s <id> [--after T] [--before T]
44
46
  JSONL timeline of a session's parts (one line per part)
45
47
  thatch session get --id <part_id|msg_id>
@@ -839,6 +841,10 @@ switch (command) {
839
841
  const isLocal = args.includes("--local");
840
842
  const isClaude = args.includes("--claude");
841
843
  const isCursor = args.includes("--cursor");
844
+ // --skills-only narrows the run to the skill install: same host and
845
+ // scope rules, but MCP config, instructions, hooks, and commands are
846
+ // left untouched.
847
+ const skillsOnly = args.includes("--skills-only");
842
848
 
843
849
  if (isGlobal && isLocal) {
844
850
  console.error("Choose one scope: --local or --global.");
@@ -875,52 +881,73 @@ switch (command) {
875
881
  };
876
882
 
877
883
  if (isClaude) {
878
- const result = setupClaudeCode(thatchBin, isGlobal, dir);
884
+ if (skillsOnly) {
885
+ const result = setupSkillsOnly("claude", isGlobal, dir);
879
886
 
880
- console.log(`Thatch setup complete for Claude Code (${isGlobal ? "global" : "project-local"}).`);
881
- console.log();
882
- if (result.mcpConfig) {
883
- console.log(` MCP config: ${result.mcpConfig}`);
884
- }
885
- console.log(` Instructions: ${result.claudeMd}`);
886
- console.log(` Hooks: ${result.settings}`);
887
- printSkills(result);
888
- if (result.commands.length > 0) {
889
- console.log(` Commands: ${result.commands.length} written (${result.commands.map((p) => p.split("/").pop()).join(", ")})`);
890
- }
891
- console.log();
892
- if (result.mcpAddCommand) {
893
- // Global setup cannot write ~/.claude.json directly, so run the
894
- // registration on the user's behalf. Idempotent: an existing
895
- // registration is left alone; a missing or failing claude CLI
896
- // falls back to printing the manual command.
897
- const reg = await registerClaudeMcpServer(thatchBin);
898
- if (reg.status === "registered") {
899
- console.log(` MCP server: ${reg.detail}`);
900
- } else if (reg.status === "already-registered") {
901
- console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
902
- } else {
903
- console.log("To register the MCP server globally, run:");
904
- console.log(` ${result.mcpAddCommand}`);
905
- console.log(` (${reg.detail})`);
887
+ console.log(`Thatch skills updated for Claude Code (${isGlobal ? "global" : "project-local"}).`);
888
+ console.log();
889
+ printSkills(result);
890
+ console.log();
891
+ console.log("Restart Claude Code for the changes to take effect.");
892
+ } else {
893
+ const result = setupClaudeCode(thatchBin, isGlobal, dir);
894
+
895
+ console.log(`Thatch setup complete for Claude Code (${isGlobal ? "global" : "project-local"}).`);
896
+ console.log();
897
+ if (result.mcpConfig) {
898
+ console.log(` MCP config: ${result.mcpConfig}`);
899
+ }
900
+ console.log(` Instructions: ${result.claudeMd}`);
901
+ console.log(` Hooks: ${result.settings}`);
902
+ printSkills(result);
903
+ if (result.commands.length > 0) {
904
+ console.log(` Commands: ${result.commands.length} written (${result.commands.map((p) => p.split("/").pop()).join(", ")})`);
906
905
  }
907
906
  console.log();
907
+ if (result.mcpAddCommand) {
908
+ // Global setup cannot write ~/.claude.json directly, so run the
909
+ // registration on the user's behalf. Idempotent: an existing
910
+ // registration is left alone; a missing or failing claude CLI
911
+ // falls back to printing the manual command.
912
+ const reg = await registerClaudeMcpServer(thatchBin);
913
+ if (reg.status === "registered") {
914
+ console.log(` MCP server: ${reg.detail}`);
915
+ } else if (reg.status === "already-registered") {
916
+ console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
917
+ } else {
918
+ console.log("To register the MCP server globally, run:");
919
+ console.log(` ${result.mcpAddCommand}`);
920
+ console.log(` (${reg.detail})`);
921
+ }
922
+ console.log();
923
+ }
924
+ console.log("Restart Claude Code for the changes to take effect.");
908
925
  }
909
- console.log("Restart Claude Code for the changes to take effect.");
910
926
  }
911
927
 
912
928
  if (isCursor) {
913
- const result = setupCursor(thatchBin, isGlobal, dir);
929
+ if (skillsOnly) {
930
+ const result = setupSkillsOnly("cursor", isGlobal, dir);
914
931
 
915
- console.log();
916
- console.log(`Thatch setup complete for Cursor (${isGlobal ? "global" : "project-local"}).`);
917
- console.log();
918
- console.log(` MCP config: ${result.mcpConfig}`);
919
- console.log(` Instructions: ${result.agentsMd}`);
920
- console.log(` Hooks: ${result.hooks}`);
921
- printSkills(result);
922
- console.log();
923
- console.log("Restart Cursor for the changes to take effect.");
932
+ console.log();
933
+ console.log(`Thatch skills updated for Cursor (${isGlobal ? "global" : "project-local"}).`);
934
+ console.log();
935
+ printSkills(result);
936
+ console.log();
937
+ console.log("Restart Cursor for the changes to take effect.");
938
+ } else {
939
+ const result = setupCursor(thatchBin, isGlobal, dir);
940
+
941
+ console.log();
942
+ console.log(`Thatch setup complete for Cursor (${isGlobal ? "global" : "project-local"}).`);
943
+ console.log();
944
+ console.log(` MCP config: ${result.mcpConfig}`);
945
+ console.log(` Instructions: ${result.agentsMd}`);
946
+ console.log(` Hooks: ${result.hooks}`);
947
+ printSkills(result);
948
+ console.log();
949
+ console.log("Restart Cursor for the changes to take effect.");
950
+ }
924
951
  }
925
952
  break;
926
953
  }
@@ -1054,15 +1081,19 @@ switch (command) {
1054
1081
  }
1055
1082
 
1056
1083
  // Tier 1: extraction nudge. Peek (don't drain) - the queue persists
1057
- // until the agent calls memory_remember, which triggers consumeQueue.
1058
- // If the agent ignores the nudge, the payload grows on the next flush.
1059
- // The nudge carries the session ID, not the full payload — the
1060
- // sub-agent fetches it via get_extraction_payload.
1084
+ // until the extractor completes (extraction_done with the parent's
1085
+ // session_id) or the parent writes a memory itself. The parent's
1086
+ // dispatch-time ack resets the escalation counter but does NOT drain:
1087
+ // a drain at ack time deleted the queue before the sub-agent fetched
1088
+ // (the accept-before-fetch loss). If the agent ignores the nudge, the
1089
+ // payload grows on the next flush. The nudge carries the session ID,
1090
+ // not the full payload - the sub-agent fetches it via
1091
+ // get_extraction_payload.
1061
1092
  const interactions = sessionID ? peekQueue(sessionID) : [];
1062
1093
  if (interactions.length > 0) {
1063
1094
  const missed = sessionID ? getMissedCount(sessionID) : 0;
1064
1095
  if (sessionID) incrementMissedCount(sessionID);
1065
- const output = extractionNudge(interactions.length, missed, "mcp__thatch__memory_remember", sessionID ?? "");
1096
+ const output = extractionNudge(interactions.length, missed, sessionID ?? "");
1066
1097
  emit(output, versionWarning, isJson, chatLine);
1067
1098
  break;
1068
1099
  }
@@ -127,7 +127,7 @@ methods. The v1 hook shapes:
127
127
  | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
128
128
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
129
129
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
130
- | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
130
+ | `chat.message` | Recall nudge: embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges fire independently in separate try/catch blocks with separate synthetic parts. There is deliberately NO extraction nudge here - extraction is plugin-driven at session idle (see the extraction feature doc); the old model-driven handshake raced its own state machine and was removed. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
131
131
  | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher and chat delivery gates; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events and pending chat mail (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, cancels the session's watchers, and unregisters it from the chat directory (the crash path — a dead process fires no event — is covered by heartbeat staleness). `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
132
132
  | `dispose` | Stops the version checker, the watcher poller, and the chat poller, closes the DB. |
133
133
 
@@ -171,11 +171,11 @@ Two of these hooks were dead for weeks because failures were invisible.
171
171
  check for durable knowledge (via `thatch_memory_recall` for dedup, then
172
172
  `thatch_memory_remember`) before composing a final response after
173
173
  substantial work. No plugin hook fires between generation and response
174
- delivery, so the only viable path is prompt instruction. The existing
175
- `chat.message` extraction nudge stays as a fallback for the false-negative
176
- case (agent forgot to save, next user turn arrives with buffer still
177
- pending). This is an experiment — model reliability on meta-instructions
178
- is uncertain.
174
+ delivery, so the only viable path is prompt instruction. If the agent
175
+ forgets to save, extraction still sweeps the buffered tool interactions
176
+ at the session's next idle (plugin-driven; there is no model-facing
177
+ extraction nudge any more). This is an experiment — model reliability on
178
+ meta-instructions is uncertain.
179
179
  10. **Background completion narration is suppressed, not prevented.** When a
180
180
  background sub-agent completes, opencode injects a `<task_result>` block
181
181
  into the parent session and triggers a full model generation. Thatch
@@ -243,27 +243,23 @@ extraction cycle
243
243
  → tool.execute.after buffers non-thatch, non-skill, non-task tool calls
244
244
  per session (max 20); for child sessions, also tracks new/updated/deleted
245
245
  metrics
246
- → direct extraction (primary path, opencode-only):
246
+ → direct extraction (the only opencode path):
247
247
  parent goes idle (session.status idle) with pending tool interactions
248
- → triggerExtraction adds parentID to the `extracting` set (suppresses
249
- nudge in chat.message), creates a child session via
250
- client.session.create, and prompts it with the extraction payload
248
+ → stale accepted entries requeue (15-min bound), then triggerExtraction
249
+ adds parentID to the `extracting` set, creates a child session via
250
+ client.session.create, and prompts it with extractionDirectPrompt
251
+ (the parent's session ID is interpolated by the plugin)
252
+ → child fetches the payload via thatch_get_extraction_payload - the
253
+ fetch records its claim (the delivery record)
251
254
  → child runs the fact-extractor skill, writes memories via
252
- thatch_memory_remember → consumeSnapshot drains the parent's buffer
253
- (snapshot-aware: removes only entries captured at dispatch time by
254
- reference identity, preserving interleaved-turn entries)
255
- → child goes idle → event handler drains remaining snapshot entries,
256
- fires a toast with childMetrics, deletes the child session
257
- → nudge path (fallback): if triggerExtraction throws (create or prompt
258
- fails), the `extracting` set clears and the next chat.message sees
259
- pending entries with no extracting flag → peeks the buffer and injects
260
- a nudge part with session ID and fetch tool name; missed nudges escalate (polite →
261
- insistent → ALL-CAPS) via the missedNudges counter
262
- → drain: thatch_memory_remember (or thatch_extraction_done) clears the
263
- buffer and resets the missed-nudge counter
264
- → agent dispatches a sub-agent that calls thatch_get_extraction_payload
265
- to fetch the queued interactions, loads thatch-fact-extractor skill,
266
- saves facts via thatch_memory_remember
255
+ thatch_memory_remember - each write (or extraction_done) completes
256
+ the child's claimed delivery, consuming only what it received
257
+ → child goes idle → event handler finalizes, fires a toast with
258
+ childMetrics, deletes the child session
259
+ → failure recovery (no model-facing nudge exists): a child error or
260
+ deletion requeues what it held to pending; a completion signal that
261
+ never comes is bounded by the 15-minute stale reaper; either way the
262
+ next idle re-triggers extraction from the same pending buffer
267
263
  → MCP path (Claude Code/Cursor): unchanged — no SDK client, no child
268
264
  sessions; extract-queue.ts + flush-tools drives the nudge via hooks
269
265
 
@@ -85,6 +85,7 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
85
85
  | `THATCH_RECALL_THRESHOLD` | Cosine cutoff for recall nudge | `0.55` |
86
86
  | `THATCH_PREDICTION_THRESHOLD` | Cutoff for prediction auto-fire | `0.60` |
87
87
  | `THATCH_BEHAVIOR_THRESHOLD` | Cutoff for behavior auto-fire | `0.60` |
88
+ | `THATCH_CHILD_RECONCILE_MS` | Reload window before a restored extraction child is judged finished (test knob) | `10000` |
88
89
  | `CLAUDE_PROJECT_DIR` | Claude Code project dir | `process.cwd()` |
89
90
  | `CURSOR_PROJECT_DIR` | Cursor project dir | falls through |
90
91
  | `CLAUDE_CONFIG_DIR` | Claude config dir | `~/.claude` |
@@ -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.