@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.
@@ -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).
@@ -18,6 +18,8 @@ resolution fit together.
18
18
  hooks, and skills for Cursor
19
19
  - `--global` flag --- installs to user-scoped config dirs instead of
20
20
  project-local
21
+ - `--skills-only` flag --- refreshes just the skill files (same host and
22
+ scope rules), skipping MCP config, instructions, hooks, and commands
21
23
  - **Marker system** --- idempotent replacement of the thatch instruction block
22
24
  in `CLAUDE.md` or `AGENTS.md` via start/end markers
23
25
  - **`checkSetup`** --- detects installed, not-installed, or markers-broken at
@@ -35,6 +37,11 @@ resolution fit together.
35
37
 
36
38
  At least one of `--claude` or `--cursor` is required. Both can be passed
37
39
  together. `--global` applies to whichever host or hosts are selected.
40
+ `--skills-only` narrows the run to the skill install (`setupSkillsOnly`
41
+ in `src/setup.ts`): it reuses the same path resolution and other-scope
42
+ probe as the full setup paths but only calls `installSkills`, so it is
43
+ safe to run in an already-set-up host when only the skill files need
44
+ refreshing.
38
45
 
39
46
  ### Claude Code setup (`setupClaudeCode` in `src/setup.ts`)
40
47
 
@@ -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)
@@ -67,32 +67,26 @@ here first. These are the things that have already cost time.
67
67
  - **Hook failures are logged with a `[thatch]` prefix and never swallowed.** Two
68
68
  hooks were dead for weeks before failures were made visible. If you add a
69
69
  hook, log on failure.
70
- - **`chat.message` has two priority tiers**: extraction nudge first (returns
71
- early), then the prompt-aware recall nudge. Don't run both in one turn.
72
- - **The extraction nudge peeks, never flushes.** The buffer is NOT drained
73
- on nudge delivery (`extraction.peek()` in `triggerExtraction`, src/runtime.ts). It persists until the
74
- agent writes a memory or calls `thatch_extraction_done`. Ignored nudges
75
- accumulate; the `missedNudges` counter escalates the tone (polite at 0-1
76
- misses, insistent at 2, ALL-CAPS at 3+). The counter resets when the
77
- buffer drains. The primary opencode path doesn't use the nudge at all:
78
- `triggerExtraction` creates a child session directly, and the `extracting`
79
- set suppresses the nudge in `chat.message` while a direct-extraction child
80
- is active. The peek-not-drain semantics still hold for the fallback nudge
81
- path (MCP hosts and any `triggerExtraction` failure).
82
- - **A child sub-agent's `thatch_memory_remember` drains the parent's buffer**
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
- session** — `triggerExtraction` calls `client.session.create` with a
86
- `parentID`, the primary opencode path; (b) the **agent-initiated background
87
- task** — the model dispatches the fact-extractor via the `task` tool after
88
- receiving the nudge, the fallback path still used by MCP hosts. Both use
89
- the same `childToParent`/`parentSnapshots`/`consumeSnapshot` plumbing.
90
- Without this, either path would write memories in the child but never
91
- clear the parent's queue — the nudge would replay every turn.
92
- `thatch_extraction_done` is the belt-and-suspenders explicit
93
- acknowledgment: it drains the buffer without requiring a memory write
94
- (covers cases where the sub-agent errors out or the host doesn't expose
95
- parent-child session relationships).
70
+ - **There is no model-facing extraction nudge on opencode.** The old
71
+ `chat.message` extraction nudge (and its `missedNudges` escalation) drove
72
+ a model-driven handshake that raced its own state machine: parent acks
73
+ landed before the child's payload fetch, no-claim completions wiped
74
+ in-flight accepted sets, and mis-targeted session ids made every
75
+ transition a silent no-op - so the same buffered count re-fired forever
76
+ (the September 2026 dispatch-loop report). Extraction is now plugin-driven
77
+ end-to-end: `triggerExtraction` at session idle creates the child and
78
+ interpolates the session ID itself; the child's payload fetch records the
79
+ claim; completions are claim-scoped; the 15-minute stale reaper bounds
80
+ crashed extractors. The nudge survives on MCP hosts only, where no plugin
81
+ can create sessions and the file-backed queue is durable until the
82
+ extractor completes.
83
+ - **A child's `thatch_memory_remember` completes only its claimed delivery**
84
+ via the `childToParent` Map and the claim recorded by its payload fetch.
85
+ A child that never fetched has no claim, and its memory write (or idle
86
+ signal, or ack) consumes nothing - the entries stay held/pending for the
87
+ real extractor. `thatch_extraction_done` is the explicit no-save
88
+ completion: claim-scoped like everything else, so a mis-ordered or
89
+ mis-targeted ack can never drop another extractor's in-flight delivery.
96
90
  - **The no-save drain runs in the child-idle handler regardless of writes.**
97
91
  When the extraction child goes idle after a no-save run (nothing worth
98
92
  extracting), the parent's snapshot entries must still be drained from the
@@ -78,28 +78,27 @@ and the nudge (fallback):
78
78
  2. When the parent session goes idle (`session.status` idle event) with pending
79
79
  buffer entries, the plugin calls `triggerExtraction`: creates a child session
80
80
  via `client.session.create` (with `parentID`), then prompts it via
81
- `client.session.promptAsync` (or `prompt`) with the extraction payload. The
82
- `extracting` set suppresses the nudge in `chat.message` while the child runs.
81
+ `client.session.promptAsync` (or `prompt`) with the extraction prompt (the
82
+ parent's session ID is interpolated into the prompt by the plugin). The
83
+ `extracting` set gates re-triggering while the child runs.
83
84
  3. The child session runs the `thatch-fact-extractor` skill, writes memories via
84
- `thatch_memory_remember`. A memory write in the child drains the parent's
85
- buffer snapshot (via the `childToParent` Map and `consumeSnapshot`) and
86
- resets the missed-nudge counter.
87
- 4. When the child goes idle, the handler drains any remaining snapshot entries
88
- (covers no-save runs), deletes the child session, and fires a toast via
85
+ `thatch_memory_remember`. The child's payload fetch records its claim; its
86
+ memory write (or `extraction_done`) completes that claim - consuming only
87
+ the entries it received.
88
+ 4. When the child goes idle, the handler finalizes the claim-scoped
89
+ completion, deletes the child session, and fires a toast via
89
90
  `client.tui.showToast`.
90
91
 
91
- **Nudge (fallback path):**
92
+ **Failure recovery (no model-facing nudge exists on opencode):**
92
93
 
93
94
  1. If `triggerExtraction` throws (session creation or prompt fails), the catch
94
- block clears the `extracting` flag. On the next `chat.message`, the buffer is
95
- **peeked** (not drained) and a synthetic text part carrying the JSON payload
96
- is injected into the conversation. The buffer persists until the agent writes
97
- a memory or calls `thatch_extraction_done`, so ignored nudges repeat and
98
- escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter.
99
- 2. The agent sees the nudge, loads the `thatch-fact-extractor` skill, and saves
100
- durable facts via `thatch_memory_remember`. A memory write in the session
101
- (or a child sub-agent via the `childToParent` Map) or `thatch_extraction_done`
102
- drains the buffer and resets the missed-nudge counter.
95
+ block clears the `extracting` flag. The pending buffer is untouched, and
96
+ the next idle re-triggers extraction - the plugin retries without any
97
+ model cooperation.
98
+ 2. If the child errors or is deleted before completing, what it held is
99
+ requeued to pending. If its completion signal never comes at all, the
100
+ 15-minute stale reaper returns accepted entries to pending. Either way
101
+ the next idle re-extracts - no interaction is silently lost.
103
102
 
104
103
  Claude Code and Cursor use a **file-backed** queue (`src/extract-queue.ts`)
105
104
  because hooks fire one-shot with no cross-call state:
@@ -25,7 +25,7 @@ code (`src/opencode/v1.ts`):
25
25
  | `experimental.session.compacting` | `{ sessionID }` | `{ context: string[] }` | Marks the session as compacting, pushes re-familiarization context. |
26
26
  | `experimental.compaction.autocontinue` | `{ sessionID }` | `{ enabled: boolean }` | Clears the compacting flag so `chat.message` nudges resume post-compaction. |
27
27
  | `tool.execute.after` | `{ tool, sessionID, callID, args }` | `{ title, output, metadata }` | Buffers non-`thatch_`, non-`skill`, non-`task` tool calls into the in-memory extraction ring buffer (max 20). In child sessions, also tracks new/updated/deleted counts via `childMetrics` for the extraction toast. |
28
- | `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting, while `extracting` is set, or for task-dispatched sub-agent sessions (children that are not extraction children). |
28
+ | `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Prompt-aware recall nudge (plus prediction/behavior auto-fire). No extraction nudge - extraction is plugin-driven at session idle. Suppressed while compacting or for task-dispatched sub-agent sessions (children that are not extraction children). |
29
29
  | `event` | `{ event: { type, properties } }` | — | Dispatches on `event.type`: `session.created` (top-level) → `client.session.prompt` with reminder + hygiene heartbeat; `session.created` (child with `parentID`) → records `childToParent` + snapshots parent's pending buffer. `session.status` idle (parent, pending interactions) → `triggerExtraction` (direct child-session extraction); idle (child) → complete/drain, `showToast` with metrics, delete child. `session.error` (child) → requeue parent's accepted entries. `session.deleted` → requeue if child, drop accepted if parent, clean maps. `session.compacted` → clear compacting flag. |
30
30
  | `dispose` | — | — | Closes the DB. |
31
31
 
package/docs/user/cli.md CHANGED
@@ -148,7 +148,8 @@ thatch buffer-batch # append tool batch to queue (Claude Code hook)
148
148
  thatch buffer-tool # append single tool to queue (Cursor hook)
149
149
  thatch flush-tools [--json] # peek queue + print nudges (hook)
150
150
  thatch flush-predictions [--json] # prediction-only nudge (hook)
151
- thatch setup --claude [--cursor] [--global] # install config + hooks + skills
151
+ thatch setup --claude [--cursor] [--global] # install config + hooks + skills
152
+ thatch setup --claude --skills-only # refresh just the skill files
152
153
  ```
153
154
 
154
155
  ## Environment variables
@@ -60,6 +60,16 @@ Skills follow the install scope: project-local installs them to the
60
60
  repo's `.claude/skills/` so they version with the project; `--global`
61
61
  installs them to `~/.claude/skills/` (or `$CLAUDE_CONFIG_DIR/skills/`).
62
62
 
63
+ To refresh just the skill files without rewriting anything else, add
64
+ `--skills-only`:
65
+
66
+ ```bash
67
+ thatch setup --claude --skills-only
68
+ ```
69
+
70
+ This skips the MCP config, instructions, hooks, and commands, and
71
+ reports the same skills directory and install counts as a full run.
72
+
63
73
  `bun` must be on PATH (the thatch binary runs under bun). Setup is
64
74
  idempotent. Re-running it updates drifted content without clobbering
65
75
  unrelated config, and reports what it did: the skills directory plus
@@ -84,6 +94,9 @@ thatch setup --cursor --global # user-scoped (~/.cursor/)
84
94
  Skills follow the install scope: project-local installs them to the
85
95
  repo's `.cursor/skills/`; `--global` installs them to `~/.cursor/skills/`.
86
96
 
97
+ As with Claude Code, `--skills-only` refreshes just the skill files and
98
+ skips the MCP config, instructions, and hooks.
99
+
87
100
  Cursor uses a flat hooks format (`{version, hooks:{event:[{command}]}}`)
88
101
  and `--json` output for hook commands.
89
102
 
@@ -16,6 +16,12 @@ baseline, then polls the GitHub API through the `gh` CLI once a
16
16
  minute. When the target changes in a way the watcher cares about, the
17
17
  plugin prompts the agent with a notification.
18
18
 
19
+ Register before you push. CI notifications fire when a check run goes
20
+ from running to completed, so a watcher created after a fast pipeline
21
+ has already finished will never fire for that commit. If every check on
22
+ the head is already done when the watcher is created, the response says
23
+ so and reports the results directly.
24
+
19
25
  PR watched events:
20
26
 
21
27
  - `pr_comment` - new top-level comments on the PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/chat.ts CHANGED
@@ -212,7 +212,7 @@ export type ChatHostKind = "opencode" | "mcp";
212
212
  // Display names appear inside wake prompts and chat_list output, so they are
213
213
  // capped tight. Bodies are capped so a runaway sender cannot turn a nudge
214
214
  // into a context bomb; the reader fetches full content via chat_read anyway.
215
- const MAX_BODY_LEN = 10_000;
215
+ export const MAX_BODY_LEN = 10_000;
216
216
 
217
217
  // Assigned names are lowercase slugs of a pool name with a numeric counter
218
218
  // suffix ("al-go-rithm-00001"), which satisfies NAME_CHARSET. The charset
@@ -56,10 +56,25 @@ export function appendBatch(sessionID: string, toolCalls: BatchToolCall[]): void
56
56
  const additions: ToolInteraction[] = [];
57
57
  for (const tc of toolCalls) {
58
58
  const lower = tc.tool_name.toLowerCase();
59
- if (lower === "mcp__thatch__memory_remember" || lower === "mcp__thatch__extraction_done") {
59
+ if (lower === "mcp__thatch__memory_remember") {
60
+ // The session that writes a memory has processed its queued
61
+ // interactions itself - drain. This is the parent's self-serve path.
60
62
  resetMissedCount(sessionID);
61
63
  consumeQueue(sessionID);
62
64
  existing.length = 0;
65
+ } else if (lower === "mcp__thatch__extraction_done") {
66
+ // An extraction_done ack must NOT drain the queue. When the PARENT
67
+ // acks after dispatching (per the nudge), a drain here deletes the
68
+ // queue before the sub-agent's get_extraction_payload fetch - the
69
+ // accept-before-fetch race, which on this path is a hard delete
70
+ // (silent interaction loss). The queue is consumed instead by the
71
+ // extractor's completion: extraction_done with the parent's
72
+ // session_id (drainExtractionQueue in mcp.ts), or the parent's own
73
+ // memory_remember above. A no-drain ack only resets the escalation
74
+ // counter - the queue persists on disk (durable across
75
+ // interruption), and an extractor that dies without completing
76
+ // leaves it in place for the next nudge to re-dispatch.
77
+ resetMissedCount(sessionID);
63
78
  }
64
79
  if (lower.startsWith("mcp__thatch__")) continue;
65
80
  if (lower === "skill" || lower === "task" || lower === "agent") continue;
package/src/extraction.ts CHANGED
@@ -342,9 +342,10 @@ export class ExtractionPipeline {
342
342
  * Requeues accepted entries that have waited longer than maxAgeMs for a
343
343
  * completion signal that never came: the extractor sub-agent crashed
344
344
  * without acking, or the completion ack was lost. Without this, accepted
345
- * entries linger unprocessed AND un-nudged - silent loss. Returns the session ids that had entries
346
- * requeued; the regular nudge path picks them up from pending on the
347
- * session's next message.
345
+ * entries linger unprocessed with nothing to re-extract them - silent
346
+ * loss. Returns the session ids that had entries requeued; on opencode
347
+ * the idle trigger re-extracts them, on MCP hosts the next flush-tools
348
+ * nudge picks them up.
348
349
  */
349
350
  requeueStaleAccepted(maxAgeMs = ACCEPTED_STALE_MS): string[] {
350
351
  const stale = [...this.#acceptedAt.entries()]
@@ -529,8 +529,9 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
529
529
  // returns the created SessionInfo directly.
530
530
  const result = await session.create({ location: { directory: worktree }, title: input.title });
531
531
  const id = result?.id;
532
- // A shape mismatch must fail into the runtime's extraction fallback
533
- // (the nudge path), not silently corrupt the child-session maps.
532
+ // A shape mismatch must fail loudly: the runtime logs it and retries
533
+ // extraction at the next idle. It must not silently corrupt the
534
+ // child-session maps.
534
535
  if (!id) throw new Error(`v2 session.create returned no id: ${JSON.stringify(result)?.slice(0, 200)}`);
535
536
  // Register for event forwarding: the child's events carry the project
536
537
  // directory, which the pump's directory filter would otherwise drop.
@@ -539,7 +540,7 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
539
540
  },
540
541
  // No delete on the v2 SessionDomain: extraction child sessions are not
541
542
  // cleaned up on v2 (documented gap; the bookkeeping maps still keep the
542
- // nudge path consistent). Two user-visible consequences: the session
543
+ // claim/completion lifecycle consistent). Two user-visible consequences: the session
543
544
  // picker accumulates one thatch-extraction entry per extraction, and
544
545
  // "continue last session" (-c) logic that picks the newest top-level
545
546
  // session will land in an extraction child after any session that