@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/README.md +5 -2
- package/artifacts/skills/common.md +6 -0
- package/artifacts/skills/thatch-code-review.md +20 -7
- package/artifacts/skills/thatch-fact-extractor.md +2 -1
- package/artifacts/skills/thatch-review-acceptance.md +17 -1
- package/artifacts/skills/thatch-review-context.md +2 -0
- package/artifacts/skills/thatch-review-highlights.md +111 -0
- package/artifacts/skills/thatch-review-synthesizer.md +29 -4
- package/artifacts/skills/ticket-description.md +42 -16
- package/docs/dev/README.md +43 -14
- package/docs/dev/gotchas.md +29 -7
- package/docs/dev/mcp-parity.md +29 -9
- package/docs/dev/setup-and-hooks.md +14 -3
- package/docs/dev/skills.md +8 -7
- package/docs/qa/use-cases/README.md +2 -2
- package/docs/qa/use-cases/UC-003-extraction-nudge.md +35 -13
- package/docs/qa/use-cases/UC-005-setup-install.md +2 -2
- package/docs/qa/use-cases/UC-014-skill-install-drift.md +3 -3
- package/docs/qa/use-cases/UC-020-extraction-escalation.md +23 -9
- package/docs/user/README.md +24 -16
- package/package.json +1 -1
- package/src/extraction.ts +41 -0
- package/src/index.ts +317 -29
- package/src/prompts.ts +28 -4
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +20 -14
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
|
|
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 —
|
|
126
|
-
// triggers another
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
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\` —
|
|
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
|
-
`
|
|
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\` —
|
|
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
package/src/tool-defs.ts
CHANGED
|
@@ -352,29 +352,35 @@ const markCheckedDef: ToolDef = {
|
|
|
352
352
|
};
|
|
353
353
|
|
|
354
354
|
/**
|
|
355
|
-
*
|
|
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
|
-
*
|
|
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
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
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
|
|
372
|
-
"
|
|
373
|
-
"
|
|
374
|
-
"
|
|
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]
|
|
383
|
+
return "[acknowledged]";
|
|
378
384
|
},
|
|
379
385
|
};
|
|
380
386
|
|