@jeffober/thatch 0.1.21 → 0.1.24

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/src/extraction.ts CHANGED
@@ -91,6 +91,7 @@ export function buildExtractionPayload(
91
91
  */
92
92
  export class ExtractionPipeline {
93
93
  #buffers = new Map<string, ToolInteraction[]>();
94
+ #accepted = new Map<string, ToolInteraction[]>();
94
95
  #maxBuffer = 20;
95
96
 
96
97
  /** Record a tool execution for later extraction. */
@@ -110,6 +111,46 @@ export class ExtractionPipeline {
110
111
  this.#buffers.delete(sessionID);
111
112
  }
112
113
 
114
+ /**
115
+ * Accept the session's buffer: move it to a holding area so the nudge
116
+ * stops replaying while a dispatched extractor works. AMQP-style accept —
117
+ * the entries are not dropped until a completion signal arrives (the
118
+ * extractor writes a memory, calls extraction_done, or goes idle). If the
119
+ * extractor dies first, requeueAccepted puts them back.
120
+ */
121
+ accept(sessionID: string): void {
122
+ const buf = this.#buffers.get(sessionID) ?? [];
123
+ if (buf.length === 0) return;
124
+ this.#buffers.delete(sessionID);
125
+ const existing = this.#accepted.get(sessionID) ?? [];
126
+ this.#accepted.set(sessionID, [...existing, ...buf]);
127
+ }
128
+
129
+ /** Drop accepted entries: the extractor finished (save or no-save). */
130
+ completeAccepted(sessionID: string): void {
131
+ this.#accepted.delete(sessionID);
132
+ }
133
+
134
+ /**
135
+ * Move accepted entries back to pending: the extractor errored or its
136
+ * session was deleted before completing, so the facts were never
137
+ * processed. Prepended so they replay in original order. Not capped at
138
+ * maxBuffer — a silent drop here would defeat the point of requeueing;
139
+ * the next push trims back to the cap.
140
+ */
141
+ requeueAccepted(sessionID: string): void {
142
+ const accepted = this.#accepted.get(sessionID);
143
+ if (!accepted || accepted.length === 0) return;
144
+ this.#accepted.delete(sessionID);
145
+ const buf = this.#buffers.get(sessionID) ?? [];
146
+ this.#buffers.set(sessionID, [...accepted, ...buf]);
147
+ }
148
+
149
+ /** Returns the session's accepted (held) interactions. Test/introspection. */
150
+ peekAccepted(sessionID: string): ToolInteraction[] {
151
+ return this.#accepted.get(sessionID) ?? [];
152
+ }
153
+
113
154
  /**
114
155
  * Remove entries that were in the snapshot (by reference identity), keeping
115
156
  * any that arrived after the snapshot was taken. Used by the child-parent
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  sessionStartReminder,
11
11
  recallNudge,
12
12
  extractionNudge,
13
+ extractionDirectPrompt,
13
14
  predictionNudge,
14
15
  type NudgeMatch,
15
16
  } from "./prompts";
@@ -81,6 +82,32 @@ export const server: Plugin = async ({ client, worktree }) => {
81
82
  // any tool calls the parent made concurrently with the sub-agent.
82
83
  const parentSnapshots = new Map<string, ToolInteraction[]>();
83
84
 
85
+ // Parent sessions with an active direct-extraction child. When the parent
86
+ // goes idle with pending tool interactions, the plugin creates a child
87
+ // session and prompts it directly (via the SDK client) instead of injecting
88
+ // a nudge into the next user message. This set suppresses the nudge path
89
+ // while the child runs, and prevents re-triggering if the parent goes idle
90
+ // again before the child finishes. Cleared when the child goes idle, errors,
91
+ // or is deleted. If direct extraction fails, the set is cleared so the nudge
92
+ // path takes over as a fallback on the next chat.message.
93
+ const extracting = new Set<string>();
94
+
95
+ // Child session IDs created by triggerExtraction (direct extraction only).
96
+ // The child idle handler uses this to distinguish plugin-created extraction
97
+ // children from task-dispatched sub-agents (code review specialists, the
98
+ // nudge-path fact-extractor, any model-dispatched task). Only extraction
99
+ // children get the full cleanup: buffer drain, session deletion, toast.
100
+ // Non-extraction children get the old behavior (completeAccepted +
101
+ // missedNudges.reset) so their sessions are not deleted out from under the
102
+ // task tool that dispatched them.
103
+ const extractionChildren = new Set<string>();
104
+
105
+ // Per-child-session extraction metrics for the toast notification. When the
106
+ // child session goes idle, these counts are formatted into a toast that
107
+ // shows the user thatch is working without polluting the conversation.
108
+ // Keyed by child session ID. Cleaned up on child idle, error, or deletion.
109
+ const childMetrics = new Map<string, { new: number; updated: number; deleted: number }>();
110
+
84
111
  // Skills always install to the global opencode config — installing into the
85
112
  // worktree would mutate the user's repo (untracked files in git status).
86
113
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -96,6 +123,83 @@ export const server: Plugin = async ({ client, worktree }) => {
96
123
  const sys = systemPrompt(repo);
97
124
  const compact = compactionContext(repo);
98
125
 
126
+ // Direct extraction: create a child session linked to the parent and prompt
127
+ // it with the extraction payload. The child runs the fact-extractor skill,
128
+ // writes memories, and goes idle. The existing childToParent / snapshot
129
+ // drain machinery handles buffer cleanup. The nudge path is a fallback if
130
+ // this throws.
131
+ //
132
+ // Does NOT call extraction.accept — entries stay in pending so consumeSnapshot
133
+ // can drain them by reference identity when the child writes a memory. The
134
+ // extracting set (not accept) suppresses the nudge in chat.message.
135
+ //
136
+ // The child ID is added to extractionChildren so the idle handler can
137
+ // distinguish plugin-created extraction children from task-dispatched
138
+ // sub-agents. Only extraction children get the full cleanup (buffer drain,
139
+ // session deletion, toast).
140
+ async function triggerExtraction(parentID: string): Promise<void> {
141
+ extracting.add(parentID);
142
+
143
+ const batch = extraction.peek(parentID);
144
+ const payload = extraction.buildPayload(batch, repo);
145
+ const promptText = extractionDirectPrompt(batch.length, payload);
146
+
147
+ const result = await client.session.create({
148
+ body: { parentID, title: "thatch-extraction" },
149
+ });
150
+ // session.created event fires here, setting childToParent and
151
+ // parentSnapshots (snapshot of the full pending buffer, since we
152
+ // have not called accept).
153
+ const childId = result.data!.id;
154
+ extractionChildren.add(childId);
155
+
156
+ // Clean up the child session and all map entries if prompting fails.
157
+ // Without this, the child exists on the server but was never prompted,
158
+ // so it never goes idle and the maps leak.
159
+ const cleanupChild = () => {
160
+ extracting.delete(parentID);
161
+ extractionChildren.delete(childId);
162
+ childToParent.delete(childId);
163
+ parentSnapshots.delete(childId);
164
+ childMetrics.delete(childId);
165
+ try { client.session.delete({ path: { id: childId } }); } catch {}
166
+ };
167
+
168
+ const bgEnabled =
169
+ process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === "true" ||
170
+ process.env.OPENCODE_EXPERIMENTAL === "true";
171
+
172
+ if (bgEnabled) {
173
+ try {
174
+ await client.session.promptAsync({
175
+ path: { id: childId },
176
+ body: {
177
+ parts: [{ type: "text", text: promptText }],
178
+ },
179
+ });
180
+ } catch (err) {
181
+ console.error(`[thatch] extraction promptAsync failed: ${err}`);
182
+ cleanupChild();
183
+ throw err;
184
+ }
185
+ } else {
186
+ // Fire and forget — the parent is already idle, so blocking the event
187
+ // handler would only delay other event processing. The child runs to
188
+ // completion and its idle event triggers cleanup.
189
+ client.session
190
+ .prompt({
191
+ path: { id: childId },
192
+ body: {
193
+ parts: [{ type: "text", text: promptText }],
194
+ },
195
+ })
196
+ .catch((err: unknown) => {
197
+ console.error(`[thatch] extraction child failed: ${err}`);
198
+ cleanupChild();
199
+ });
200
+ }
201
+ }
202
+
99
203
  return {
100
204
  tool: createTools(db, model, repo),
101
205
 
@@ -118,57 +222,92 @@ export const server: Plugin = async ({ client, worktree }) => {
118
222
  compacting.delete(input.sessionID);
119
223
  },
120
224
 
121
- // 3. Tool buffering — feeds the extraction nudge. Excluded tools:
225
+ // 3. Tool buffering — feeds the extraction pipeline (direct extraction
226
+ // via SDK or nudge fallback). Excluded tools:
122
227
  // - thatch_*: extracting facts from memory ops would echo the store
123
228
  // - skill/task: meta-tools that orchestrate agent behavior (loading
124
229
  // skills, dispatching sub-agents). Buffering them creates a feedback
125
- // loop — the nudge triggers a skill load, which gets buffered, which
126
- // triggers another nudge on the next turn.
230
+ // loop — extraction triggers a skill load, which gets buffered, which
231
+ // triggers another extraction on the next turn.
127
232
  // Memory writes consume the buffer and reset the missed-nudge counter.
128
233
  // The buffer is NOT drained on nudge delivery — it persists until the
129
234
  // agent writes a memory, so ignored nudges accumulate and the payload
130
235
  // grows with each missed cycle.
131
236
  //
132
- // A memory_remember call in a child session (sub-agent) also drains
133
- // the parent's buffer — but only the entries that existed at dispatch
134
- // time (the snapshot). Entries from interleaved turns survive so their
135
- // facts aren't silently dropped. Without this, dispatching the
136
- // fact-extractor as a background task writes memories in the child but
137
- // never clears the parent's queue — the nudge replays every turn.
237
+ // Buffer lifecycle is AMQP-style accept/complete, so a failed
238
+ // extractor does not silently drop unprocessed interactions:
239
+ // - thatch_extraction_done in the parent ACCEPTS the buffer: it moves
240
+ // to a holding area and the nudge quiets, but entries are not
241
+ // dropped yet.
242
+ // - COMPLETE drops the held entries. Signals: a memory_remember or
243
+ // extraction_done call in a child session of that parent (the
244
+ // extractor confirming it processed them), or the child going idle
245
+ // (a no-save run that never acks still counts as processed).
246
+ // - REQUEUE returns the held entries to pending. Signals: the child
247
+ // session errors or is deleted before completing.
138
248
  //
139
- // thatch_extraction_done is an explicit acknowledgment tool the model
140
- // calls in the parent session after dispatching. Covers the case where
141
- // the sub-agent errors out or the host doesn't expose parent-child
142
- // session relationships.
249
+ // A memory_remember call in a child session (sub-agent) also drains
250
+ // the parent's pending buffer — but only the entries that existed at
251
+ // dispatch time (the snapshot). Entries from interleaved turns survive
252
+ // so their facts aren't silently dropped.
143
253
  "tool.execute.after": async (input, output) => {
144
254
  if (input.tool === "thatch_memory_remember") {
145
255
  extraction.consume(input.sessionID);
146
256
  missedNudges.delete(input.sessionID);
147
- // If this is a child session, drain only the parent's snapshot entries
148
- // (those present at dispatch time). Interleaved-turn entries survive.
149
- // If no snapshot was recorded (e.g. session.created event never
150
- // arrived), fall back to draining the entire parent buffer.
257
+ // Track extraction metrics for toast display. Counted for any
258
+ // child session (both direct-extraction and nudge-path sub-agents)
259
+ // since childToParent covers both. The toast only fires for
260
+ // extraction children (extractionChildren set) on idle.
151
261
  const parentID = childToParent.get(input.sessionID);
152
262
  if (parentID) {
263
+ const metrics = childMetrics.get(input.sessionID) ?? { new: 0, updated: 0, deleted: 0 };
264
+ if (input.args?.overwrite) metrics.updated++;
265
+ else metrics.new++;
266
+ childMetrics.set(input.sessionID, metrics);
267
+ // Complete the parent's accepted entries (the extractor confirmed
268
+ // it is alive and saving) and drain the parent's snapshot entries
269
+ // from the pending buffer. If no snapshot was recorded (unreachable
270
+ // when childToParent has the entry, since both are set together in
271
+ // session.created), skip the drain rather than dropping the entire
272
+ // buffer — interleaved-turn entries that arrived while the child
273
+ // was running must survive for the next extraction cycle.
274
+ extraction.completeAccepted(parentID);
153
275
  const snapshot = parentSnapshots.get(input.sessionID);
154
276
  if (snapshot) {
155
277
  extraction.consumeSnapshot(parentID, snapshot);
156
278
  parentSnapshots.delete(input.sessionID);
157
- } else {
158
- extraction.consume(parentID);
159
279
  }
160
280
  missedNudges.delete(parentID);
161
281
  }
162
282
  return;
163
283
  }
164
- // Explicit acknowledgment tool drains the buffer without requiring a
165
- // memory write. The model calls this after dispatching the
166
- // fact-extractor to a sub-agent.
284
+ // thatch_extraction_done has two roles depending on the session:
285
+ // - parent, after dispatching the fact-extractor: ACCEPT the buffer
286
+ // (quiet the nudge, hold the entries for completion).
287
+ // - child extractor, at the end of its run: COMPLETE the parent's
288
+ // accepted entries, including no-save runs that write no memory,
289
+ // and drop the child's own buffer (its work is done).
167
290
  if (input.tool === "thatch_extraction_done") {
168
- extraction.consume(input.sessionID);
291
+ const parentID = childToParent.get(input.sessionID);
292
+ if (parentID) {
293
+ extraction.completeAccepted(parentID);
294
+ missedNudges.delete(parentID);
295
+ extraction.consume(input.sessionID);
296
+ } else {
297
+ extraction.accept(input.sessionID);
298
+ }
169
299
  missedNudges.delete(input.sessionID);
170
300
  return;
171
301
  }
302
+ // Track memory deletions in child sessions for the toast metrics.
303
+ // Same scoping as the remember handler above — only child sessions,
304
+ // not the parent or manual memory writes from the user's session.
305
+ if (input.tool === "thatch_memory_forget" && childToParent.has(input.sessionID)) {
306
+ const metrics = childMetrics.get(input.sessionID) ?? { new: 0, updated: 0, deleted: 0 };
307
+ metrics.deleted++;
308
+ childMetrics.set(input.sessionID, metrics);
309
+ return;
310
+ }
172
311
  if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
173
312
  extraction.push({
174
313
  tool: input.tool,
@@ -207,7 +346,11 @@ export const server: Plugin = async ({ client, worktree }) => {
207
346
  if (isCompactionMsg) return;
208
347
  compacting.delete(input.sessionID);
209
348
  }
210
- if (extraction.pending(input.sessionID)) {
349
+ // Extraction nudge (fallback path). Skipped when the extracting set
350
+ // is active — that means a direct-extraction child session is running
351
+ // and the plugin is handling extraction via the SDK. The nudge fires
352
+ // here only when direct extraction was never triggered or threw.
353
+ if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
211
354
  const batch = extraction.peek(input.sessionID);
212
355
  const payload = extraction.buildPayload(batch, repo);
213
356
  const missed = missedNudges.get(input.sessionID) ?? 0;
@@ -255,6 +398,17 @@ export const server: Plugin = async ({ client, worktree }) => {
255
398
  text: recallNudge(matches),
256
399
  synthetic: true,
257
400
  });
401
+ try {
402
+ await client.tui.showToast({
403
+ body: {
404
+ message: `\u{1F4AD} recalled ${matches.length} memor${matches.length === 1 ? "y" : "ies"}`,
405
+ variant: "info",
406
+ duration: 3000,
407
+ },
408
+ });
409
+ } catch {
410
+ // TUI may not be connected. Best-effort.
411
+ }
258
412
  }
259
413
  } catch (err) {
260
414
  console.error(`[thatch] recall nudge failed: ${err}`);
@@ -273,6 +427,17 @@ export const server: Plugin = async ({ client, worktree }) => {
273
427
  text: predictionNudge(predItems),
274
428
  synthetic: true,
275
429
  });
430
+ try {
431
+ await client.tui.showToast({
432
+ body: {
433
+ message: `\u{1F4AD} ${predItems.length} prediction${predItems.length === 1 ? "" : "s"} surfaced`,
434
+ variant: "info",
435
+ duration: 3000,
436
+ },
437
+ });
438
+ } catch {
439
+ // TUI may not be connected. Best-effort.
440
+ }
276
441
  }
277
442
  } catch (err) {
278
443
  console.error(`[thatch] prediction nudge failed: ${err}`);
@@ -283,25 +448,148 @@ export const server: Plugin = async ({ client, worktree }) => {
283
448
  },
284
449
 
285
450
  // 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
286
- // best-effort: a failure there must not cost the reminder itself.
451
+ // best-effort: a failure there must not cost the reminder itself.
287
452
  //
288
453
  // Also tracks parent-child session relationships for cross-session buffer
289
454
  // drain. session.created with a parentID records the mapping AND
290
- // snapshots the parent's current buffer — so the child's later
455
+ // snapshots the parent's current buffer so the child's later
291
456
  // memory_remember drains only those snapshot entries, not the parent's
292
457
  // entire buffer (which may have grown from interleaved turns).
293
- // session.deleted cleans both maps to avoid unbounded growth.
458
+ //
459
+ // Child lifecycle events: idle means the extractor finished; error or
460
+ // deletion before completion means the facts were never extracted, so
461
+ // entries go back to pending. Only extraction children (created by
462
+ // triggerExtraction, tracked in extractionChildren) get the full cleanup
463
+ // (buffer drain, session deletion, toast). Task-dispatched sub-agents
464
+ // get the old behavior (completeAccepted + missedNudges.reset) so their
465
+ // sessions are not deleted out from under the task tool.
294
466
  event: async ({ event }) => {
295
467
  if (event.type === "session.created") {
296
468
  const info = event.properties.info;
297
469
  if (info.parentID) {
298
470
  childToParent.set(info.id, info.parentID);
299
471
  parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
472
+ // Child sessions don't need the session-start reminder — only
473
+ // top-level sessions do. Returning here prevents extraction
474
+ // children from receiving the hygiene report and tool overview.
475
+ return;
476
+ }
477
+ }
478
+ if (event.type === "session.error") {
479
+ const childID = event.properties.sessionID;
480
+ const parentID = childID ? childToParent.get(childID) : undefined;
481
+ if (parentID && childID) {
482
+ extraction.requeueAccepted(parentID);
483
+ extracting.delete(parentID);
484
+ childToParent.delete(childID);
485
+ parentSnapshots.delete(childID);
486
+ childMetrics.delete(childID);
487
+ extractionChildren.delete(childID);
488
+ }
489
+ return;
490
+ }
491
+ if (event.type === "session.status" && event.properties.status?.type === "idle") {
492
+ const sessionID = event.properties.sessionID;
493
+ const parentID = sessionID ? childToParent.get(sessionID) : undefined;
494
+ if (parentID && sessionID) {
495
+ // A child session went idle. Two cases:
496
+ // - Extraction child (created by triggerExtraction, tracked in
497
+ // extractionChildren): drain the parent's snapshot, fire a toast
498
+ // with metrics, delete the child session, clean up all maps.
499
+ // - Task-dispatched sub-agent (code review specialist, nudge-path
500
+ // fact-extractor, any model-dispatched task): complete the
501
+ // parent's accepted entries and reset missedNudges. Do NOT drain
502
+ // the buffer or delete the session — the task tool that
503
+ // dispatched the sub-agent needs to read its output.
504
+ if (extractionChildren.has(sessionID)) {
505
+ // Drain the parent's snapshot entries from the pending buffer.
506
+ // If the child wrote memories, consumeSnapshot already ran in
507
+ // tool.execute.after and the snapshot is gone — nothing to
508
+ // drain. If the child did a no-save run, the snapshot entries
509
+ // are still in the buffer and need to be drained here so they
510
+ // don't replay as a nudge on the next chat.message. Never drain
511
+ // the entire buffer — interleaved-turn entries must survive.
512
+ const snapshot = parentSnapshots.get(sessionID);
513
+ if (snapshot) {
514
+ extraction.consumeSnapshot(parentID, snapshot);
515
+ }
516
+
517
+ // Fire a toast with the extraction metrics. Only show a toast
518
+ // when memories were actually written — no toast for no-save
519
+ // runs to avoid notification fatigue.
520
+ const metrics = childMetrics.get(sessionID);
521
+ const parts: string[] = [];
522
+ if (metrics) {
523
+ if (metrics.new > 0) parts.push(`new: ${metrics.new}`);
524
+ if (metrics.updated > 0) parts.push(`updated: ${metrics.updated}`);
525
+ if (metrics.deleted > 0) parts.push(`deleted: ${metrics.deleted}`);
526
+ }
527
+ if (parts.length > 0) {
528
+ try {
529
+ await client.tui.showToast({
530
+ body: { message: `\u{1F4AD} ${parts.join(", ")}`, variant: "success", duration: 4000 },
531
+ });
532
+ } catch {
533
+ // TUI may not be connected (e.g. headless mode).
534
+ }
535
+ }
536
+
537
+ extraction.completeAccepted(parentID);
538
+ missedNudges.delete(parentID);
539
+ extracting.delete(parentID);
540
+ childToParent.delete(sessionID);
541
+ parentSnapshots.delete(sessionID);
542
+ childMetrics.delete(sessionID);
543
+ extractionChildren.delete(sessionID);
544
+ extraction.consume(sessionID);
545
+ // Delete the child session to avoid clutter.
546
+ try {
547
+ await client.session.delete({ path: { id: sessionID } });
548
+ } catch {
549
+ // Best-effort — the child is idle and harmless if not deleted.
550
+ }
551
+ } else {
552
+ // Task-dispatched sub-agent went idle. Complete the parent's
553
+ // accepted entries (from the nudge-path extraction_done accept)
554
+ // and reset missedNudges. Retained for the nudge fallback path.
555
+ extraction.completeAccepted(parentID);
556
+ missedNudges.delete(parentID);
557
+ }
558
+ return;
300
559
  }
560
+ // Parent went idle — trigger direct extraction if there are pending
561
+ // tool interactions and no extraction is already running. Falls
562
+ // back to the nudge path on the next chat.message if this throws.
563
+ if (
564
+ sessionID &&
565
+ !compacting.has(sessionID) &&
566
+ !extracting.has(sessionID) &&
567
+ extraction.pending(sessionID)
568
+ ) {
569
+ try {
570
+ await triggerExtraction(sessionID);
571
+ } catch (err) {
572
+ console.error(`[thatch] direct extraction trigger failed: ${err}`);
573
+ extracting.delete(sessionID);
574
+ }
575
+ }
576
+ return;
301
577
  }
302
578
  if (event.type === "session.deleted") {
303
- childToParent.delete(event.properties.info.id);
304
- parentSnapshots.delete(event.properties.info.id);
579
+ const id = event.properties.info.id;
580
+ // A child deleted before completing never processed its payload.
581
+ const parentID = childToParent.get(id);
582
+ if (parentID) {
583
+ extraction.requeueAccepted(parentID);
584
+ extracting.delete(parentID);
585
+ }
586
+ childToParent.delete(id);
587
+ parentSnapshots.delete(id);
588
+ childMetrics.delete(id);
589
+ extractionChildren.delete(id);
590
+ // A deleted parent takes its accepted entries with it.
591
+ extraction.completeAccepted(id);
592
+ extracting.delete(id);
305
593
  return;
306
594
  }
307
595
 
package/src/prompts.ts CHANGED
@@ -37,7 +37,7 @@ you're working in. Prior sessions may have already investigated it.
37
37
  Thatch ships skills for code review, project investigation, and memory
38
38
  workflows. The host auto-discovers them, but reach for them proactively:
39
39
 
40
- - \`thatch-code-review\` — full multi-agent code review (dispatches 6 specialists + synthesizer). Requires sub-agent support.
40
+ - \`thatch-code-review\` — full multi-agent code review (dispatches 7 specialists + synthesizer). Requires sub-agent support.
41
41
  - \`thatch-workflow-research\` — research code workflows affected by a change or planned change, before reviewing or planning.
42
42
  - \`thatch-change-walkthrough\` — produces a teaching walkthrough of a diff with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + change overlay mirroring the same numbers) and calibrated prose rules. Load the skill for the format and prose rules, not just for the research method.
43
43
  - \`thatch-code-walkthrough\` — produces a teaching walkthrough for a feature/workflow as it stands today with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + Key files) and calibrated prose rules. Also use proactively to draft high-level docs for new or undocumented features (suitable for pkg/README.md or docs/features/<name>.md). Load the skill for the format and prose rules, not just for the research method.
@@ -76,6 +76,11 @@ that thread. If the user's last message has been fully addressed, stop and
76
76
  wait. Do not treat the completion as a trigger to advance work the user has
77
77
  not greenlit.
78
78
 
79
+ If a background task was cancelled after timing out, re-dispatch it once with
80
+ the same prompt. If the re-dispatch also fails or times out, note the gap and
81
+ move on. Do not retry more than once — repeated failures indicate an
82
+ intractable prompt, not a transient hang.
83
+
79
84
  ## User Decision Model
80
85
 
81
86
  A statistical model of the user's decision-making preferences is maintained by
@@ -238,7 +243,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
238
243
  - \`thatch-change-walkthrough\` — produces a teaching walkthrough of a diff with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + change overlay mirroring the same numbers) and calibrated prose rules. Load the skill for the format and prose rules, not just for the research method.
239
244
  - \`thatch-code-walkthrough\` — produces a teaching walkthrough for a feature/workflow as it stands today with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + Key files) and calibrated prose rules. Also use proactively to draft high-level docs for new or undocumented features (suitable for pkg/README.md or docs/features/<name>.md). Load the skill for the format and prose rules, not just for the research method.
240
245
  - \`thatch-review-context\` — gather project context (PRs, tickets, TODOs, deferred work) before a review.
241
- - \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` — six specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
246
+ - \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` / \`-highlights\` — seven specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
242
247
  - \`thatch-project-primer\` — investigate a new project and write foundational memories.
243
248
  - \`thatch-session-reflection\` — record what you learned at end of session.
244
249
  - \`thatch-fact-extractor\` — extract durable facts from recent tool interactions (auto-triggered by the extraction pipeline).
@@ -426,10 +431,29 @@ export function extractionNudge(
426
431
  return `[thatch] ${dispatch} to run the thatch-fact-extractor skill ` +
427
432
  `on ${count} queued tool interaction${plural}. ` +
428
433
  `${acknowledge} ` +
429
- `Then answer the user. ` +
434
+ `This is a system nudge, not user input. After dispatching and acknowledging, ` +
435
+ `stop: do not advance pending work on this nudge's account. If you are ` +
436
+ `waiting for the user to respond, continue waiting. ` +
430
437
  `Use ${toolName} to save durable facts from this payload:\n${payload}`;
431
438
  }
432
439
 
440
+ /**
441
+ * Prompt text sent directly to a child session the plugin created for
442
+ * extraction. Unlike extractionNudge (which tells the model to dispatch a
443
+ * sub-agent and call extraction_done), this prompt is already inside the
444
+ * child — the model just runs the fact-extractor skill and saves facts.
445
+ * The payload format is identical to the nudge path so the skill receives
446
+ * the same contract either way.
447
+ */
448
+ export function extractionDirectPrompt(count: number, payload: string): string {
449
+ const plural = count === 1 ? "" : "s";
450
+ return `Run the thatch-fact-extractor skill to extract durable facts from ` +
451
+ `${count} queued tool interaction${plural}. ` +
452
+ `Use thatch_memory_remember to save any durable facts you find. ` +
453
+ `Call thatch_extraction_done when finished, even if nothing was worth saving.\n` +
454
+ `Payload:\n${payload}`;
455
+ }
456
+
433
457
  /**
434
458
  * Backwards-compatible wrapper for the Claude Code/Cursor CLI path.
435
459
  * Maintained for any external callers; bin/thatch should use extractionNudge
@@ -487,7 +511,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
487
511
  - \`thatch-change-walkthrough\` — produces a teaching walkthrough of a diff with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + change overlay mirroring the same numbers) and calibrated prose rules. Load the skill for the format and prose rules, not just for the research method.
488
512
  - \`thatch-code-walkthrough\` — produces a teaching walkthrough for a feature/workflow as it stands today with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + Key files) and calibrated prose rules. Also use proactively to draft high-level docs for new or undocumented features (suitable for pkg/README.md or docs/features/<name>.md). Load the skill for the format and prose rules, not just for the research method.
489
513
  - \`thatch-review-context\` — gather project context (PRs, tickets, TODOs, deferred work) before a review.
490
- - \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` — six specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
514
+ - \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` / \`-highlights\` — seven specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
491
515
  - \`thatch-project-primer\` — investigate a new project and write foundational memories.
492
516
  - \`thatch-session-reflection\` — record what you learned at end of session.
493
517
  - \`thatch-fact-extractor\` — extract durable facts from recent tool interactions (auto-triggered by the extraction pipeline).
package/src/skills.ts CHANGED
@@ -39,6 +39,7 @@ function loadSharedSkills(): SkillDef[] {
39
39
  "thatch-review-no-slop",
40
40
  "thatch-review-breadcrumbs",
41
41
  "thatch-review-mark-and-sweep",
42
+ "thatch-review-highlights",
42
43
  "thatch-review-synthesizer",
43
44
  "thatch-review-context",
44
45
  "thatch-workflow-research",
package/src/tool-defs.ts CHANGED
@@ -352,29 +352,35 @@ const markCheckedDef: ToolDef = {
352
352
  };
353
353
 
354
354
  /**
355
- * Acknowledge that the extraction nudge has been handled. Drains the
356
- * extraction buffer so the nudge does not replay on the next turn.
355
+ * Extraction-buffer acknowledgment, with AMQP-style accept/complete roles.
357
356
  *
358
- * The actual draining happens in the host's post-tool hook
357
+ * Called in a PARENT session after dispatching the fact-extractor, it accepts
358
+ * the buffer: entries move to a holding area and the nudge quiets, but they
359
+ * are not dropped until the extractor completes. Called in a CHILD extractor
360
+ * at the end of its run, it completes the parent's accepted entries —
361
+ * including no-save runs that write no memory. If the child errors or is
362
+ * deleted before either signal, the host requeues the entries so the facts
363
+ * are not lost.
364
+ *
365
+ * The actual state changes happen in the host's post-tool hook
359
366
  * (tool.execute.after for opencode, PostToolBatch/appendBatch for MCP) —
360
367
  * this tool's execute function is a no-op confirmation. The tool exists so
361
- * the model can call it in the parent session after dispatching the
362
- * fact-extractor to a sub-agent, giving the hook a recognizable tool name
363
- * to key on. Without it, the buffer only drains when a memory_remember
364
- * call lands in the same session (or a tracked child session), which fails
365
- * if the sub-agent errors out or the host doesn't expose parent-child
366
- * session relationships.
368
+ * the model has a recognizable tool name to key on. In the MCP path the
369
+ * file-backed queue is consumed on this call (or on any memory_remember),
370
+ * which is durable across interruption because the queue persists on disk
371
+ * until then.
367
372
  */
368
373
  const extractionDoneDef: ToolDef = {
369
374
  name: "extraction_done",
370
375
  description:
371
- "Acknowledge that the extraction nudge has been dispatched to a sub-agent. " +
372
- "Drains the extraction buffer so the nudge does not replay. " +
373
- "Call this in the parent session after dispatching a background task " +
374
- "to run the thatch-fact-extractor skill.",
376
+ "Acknowledge extraction-buffer work. In a parent session, call after " +
377
+ "dispatching the fact-extractor to a sub-agent: accepts the buffer " +
378
+ "(quiets the nudge) while keeping entries until the extractor completes. " +
379
+ "In the fact-extractor sub-agent, call at the end of the run to mark the " +
380
+ "entries complete, even when nothing was worth saving.",
375
381
  args: {},
376
382
  async execute() {
377
- return "[acknowledged] extraction buffer drained";
383
+ return "[acknowledged]";
378
384
  },
379
385
  };
380
386