@jeffober/thatch 0.2.0 → 0.2.1

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.
@@ -72,6 +72,10 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
72
72
  // here, and the pump lets their events through.
73
73
  const childSessions = new Set<string>();
74
74
 
75
+ // Tool executions currently running against this instance's runtime (see
76
+ // the counter in the tool execute wrapper below).
77
+ let inflightTools = 0;
78
+
75
79
  const capabilities = buildCapabilities(context, worktree, childSessions);
76
80
  const runtime = await createRuntime({ capabilities, directory, worktree });
77
81
  // Seed the forwarding set from rehydrated child bookkeeping: after a
@@ -97,9 +101,33 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
97
101
  description: def.description,
98
102
  input: z.toJSONSchema(z.object(def.args)),
99
103
  execute: async (input: unknown, toolContext: V2ToolContext) => {
100
- const host: HostToolContext | undefined = trimHostContext(toolContext);
101
- const result: string = await def.execute(input as Record<string, unknown>, runtime.coreContext, host);
102
- return { content: result } as Tool.Result;
104
+ // In-flight counter (read by the cleanup below): v2 tears the old
105
+ // activation down while a call it registered may still be running,
106
+ // so cleanup waits briefly for stragglers before disposing the
107
+ // runtime whose db they use.
108
+ inflightTools++;
109
+ try {
110
+ const host: HostToolContext | undefined = trimHostContext(toolContext);
111
+ const result: string = await def.execute(input as Record<string, unknown>, runtime.coreContext, host);
112
+ return { content: result } as Tool.Result;
113
+ } catch (err) {
114
+ // A db closed mid-call by a concurrent reload's dispose surfaces
115
+ // here even after the wait (the call may have started racing the
116
+ // cleanup itself). Fail the call with an explicit retry hint -
117
+ // the reloaded instance is live by then, so a retry succeeds.
118
+ // (Hard failures are never retried by models - every prior
119
+ // session that hit one just abandoned the call.)
120
+ if (err instanceof Error && /closed database|connection closed/i.test(err.message)) {
121
+ return {
122
+ content:
123
+ "thatch: the plugin was reloaded mid-call and this instance's database handle " +
124
+ "was closed underneath it. The reloaded instance is live - retry the same call.",
125
+ } as Tool.Result;
126
+ }
127
+ throw err;
128
+ } finally {
129
+ inflightTools--;
130
+ }
103
131
  },
104
132
  });
105
133
  }
@@ -296,11 +324,21 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
296
324
  // second setup after a skipped or partial cleanup would double every
297
325
  // poller, pump, and nudge. The pump await is safe: its body is fully
298
326
  // try/caught and abort ends the loop, so it settles promptly.
327
+ //
328
+ // Before disposing the runtime, briefly drain in-flight tool executions:
329
+ // v2 tears this activation down mid-turn, and an execute whose db access
330
+ // would race the close either completes here or (past the bound) hits the
331
+ // lazy reopen + retry hint in the wrapper. The bound keeps a hung tool
332
+ // from holding the reload hostage.
299
333
  let disposed = false;
300
334
  return async () => {
301
335
  if (disposed) return;
302
336
  disposed = true;
303
337
  controller.abort();
338
+ const drainDeadline = Date.now() + 5_000;
339
+ while (inflightTools > 0 && Date.now() < drainDeadline) {
340
+ await new Promise((resolve) => setTimeout(resolve, 25));
341
+ }
304
342
  await pump;
305
343
  registerTools.dispose();
306
344
  registerToolHook.dispose();
@@ -394,7 +432,13 @@ export function translateEvent(located: { type: string; data?: any }): { type: s
394
432
  const data = located.data ?? {};
395
433
  switch (located.type) {
396
434
  case "session.created":
397
- return [{ type: "session.created", properties: { info: { id: data.sessionID, parentID: data.parentID } } }];
435
+ // v2 publishes the v1-compat payload { sessionID, info: SessionInfo };
436
+ // the parent link lives at info.parentID (the task tool dispatches
437
+ // sub-agents with parentID), not at the payload top level. Without
438
+ // this, childToParent never populates on v2 and every child-session
439
+ // behavior (buffer drain, nudge suppression, child cleanup) silently
440
+ // only works on v1.
441
+ return [{ type: "session.created", properties: { info: { id: data.sessionID, parentID: data.info?.parentID ?? data.parentID } } }];
398
442
  case "session.deleted":
399
443
  return [{ type: "session.deleted", properties: { info: { id: data.sessionID } } }];
400
444
  case "session.execution.started":
package/src/prompts.ts CHANGED
@@ -10,6 +10,29 @@ export function predictionVerb(evidenceCount: number): string {
10
10
  return evidenceCount === 0 ? "you may prefer" : "you tend to";
11
11
  }
12
12
 
13
+ /**
14
+ * Shared nudge-line formatter for the prediction and behavior surfaces.
15
+ * The model supplies the matcher and statement text, and both frequently
16
+ * arrive already carrying the render prefixes: matchers phrased as
17
+ * "When ..." (a natural situation phrasing, or copied from an earlier
18
+ * nudge) and statements copied with the evidence verb ("you tend to ...").
19
+ * The unconditional prefixes doubled that text in every rendered nudge,
20
+ * so each prefix is skipped when the supplied text already carries it.
21
+ */
22
+ export function formatWhenLine(
23
+ confidence: number,
24
+ evidenceCount: number,
25
+ matcher: string,
26
+ statement: string,
27
+ verb: string,
28
+ ): string {
29
+ const matcherText = /^when\b/i.test(matcher) ? matcher : `When ${matcher}`;
30
+ const statementText = new RegExp(`^${verb}\b`, "i").test(statement) || /^you (?:tend to|may prefer)\b/i.test(statement)
31
+ ? statement
32
+ : `${verb} ${statement}`;
33
+ return `[${confidence.toFixed(2)} conf, ${evidenceCount} tests] ${matcherText}: ${statementText}`;
34
+ }
35
+
13
36
  /**
14
37
  * Cross-session chat section of the opencode system prompt. Omitted when the
15
38
  * user disabled chat via config (chat.enabled: false), so a disabled feature
@@ -73,6 +96,49 @@ ought to trigger it and widen the wording to the broadest surface of the
73
96
  class - a matcher that only names this incident never fires for the next
74
97
  one.`;
75
98
 
99
+ /**
100
+ * The artifact-pointer bullet of the "How to Write" section. Names no
101
+ * tools, so it is shared verbatim by both prompt variants (the
102
+ * class-lesson convention above).
103
+ */
104
+ const artifactPointerBullet = `- Artifact memories should carry their pointers: file paths, ticket IDs,
105
+ package names. Verification is cheap later only if the memory says where
106
+ to check. A pointer is provenance for durable knowledge, not knowledge
107
+ itself - a bare "where X lives" is exactly what the durability test
108
+ excludes.`;
109
+
110
+ /**
111
+ * "Memories are pointers, not truth" instruction body, shared by all three
112
+ * prompt variants. Takes a ToolNamer because the section names memory tools
113
+ * (thatch_memory_remember on opencode, bare on MCP hosts, whose
114
+ * instructions establish the bare-name convention). Staleness is a
115
+ * property of the claim's relationship to a mutable world, not of the
116
+ * memory's age.
117
+ */
118
+ export function memoryPointersSection(tool: ToolNamer): string {
119
+ return `## Memories Are Pointers, Not Truth
120
+
121
+ ${tool("memory_recall")} surfaces a point-in-time record: a semantic guide
122
+ associating "what I'm looking for" with "where I should look", written when
123
+ the knowledge was current. Memories speed up research - they do not replace
124
+ it, and they are not a source of truth.
125
+
126
+ - **Policy memories** ("the user always wants X", "check CI after merging")
127
+ are self-executing: they are the ground truth for behavior.
128
+ - **Artifact memories** ("feature X lives in <file> and uses <technique>")
129
+ are pointers to checkable claims about code, docs, tickets, or git.
130
+ Follow the pointer and assert from the source, not from the memory.
131
+
132
+ Before asserting an artifact claim from memory, re-derive it from the
133
+ current source. Confirming the reference still exists verifies little: the
134
+ file can survive while the behavior changed (scope drift, priority shifts,
135
+ implementation details that proved too complex in practice).
136
+ The source wins. When reality has moved on, correct the record with
137
+ ${tool("memory_remember")} (overwrite: true) or ${tool("memory_forget")} -
138
+ silently; memory maintenance is not user-facing. For a deep staleness check
139
+ of a specific memory, load the thatch-memory-verify skill.`;
140
+ }
141
+
76
142
  export function systemPrompt(repo: string, chatEnabled = true): string {
77
143
  return `# Persistence
78
144
 
@@ -106,13 +172,16 @@ WIP, PR status). Unscoped memories are project-wide and always included in searc
106
172
 
107
173
  ## Session Startup
108
174
 
109
- 1. thatch_memory_recall "user preferences and personality"
110
- 2. thatch_memory_recall "project architecture and conventions"
111
- 3. If on a non-main branch, thatch_memory_recall with the branch name
175
+ 1. thatch_memory_recall({ query: "user preferences and personality" })
176
+ 2. thatch_memory_recall({ query: "project architecture and conventions" })
177
+ 3. If on a non-main branch, thatch_memory_recall with the branch name as the query
112
178
  4. thatch_memory_list and thatch_store_list to see what's available
113
179
 
114
180
  Before diving into code, thatch_memory_recall with a query relevant to the area
115
- you're working in. Prior sessions may have already investigated it.
181
+ you're working in. Prior sessions may have already investigated it. All thatch
182
+ tools take a single object argument: never a bare positional string. Call them
183
+ directly, or through the Code Mode execute runtime when the harness only
184
+ exposes tools through code.
116
185
 
117
186
  ## Skills
118
187
 
@@ -134,7 +203,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
134
203
  - \`thatch-ticket-description\` - draft a ticket, issue, or work item (Linear or Jira) with clear sections and bold/italic emphasis for scanning.
135
204
  - \`thatch-split-overlarge-pr\` - split already-completed work from an overlarge PR into human-reviewable, release-safe PRs targeting main.
136
205
  - \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
137
- - \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
206
+ - \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory looks stale or when preparing a knowledge export.
138
207
  - \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
139
208
  - \`thatch-clear-writing\` - prose rules for any human-facing text not covered by a dedicated writing skill: PR and ticket comments, documentation, plans, reports, and chat replies. Load before drafting any of those.
140
209
 
@@ -162,15 +231,7 @@ whether you've discovered knowledge worth persisting. Use thatch_memory_recall
162
231
  to check for duplicates, then thatch_memory_remember for new findings. Then
163
232
  deliver your response.
164
233
 
165
- ## Memory Verification
166
-
167
- When thatch_memory_show or thatch_memory_recall returns a memory with an old
168
- updated_at date (more than a few weeks), consider loading the
169
- thatch-memory-verify skill to fact-check its code-related claims against the
170
- current codebase before relying on them. This is not mandatory for every old
171
- memory. Use judgment: a memory about a stable architectural decision may be
172
- fine for months, while a memory about file locations or function names is
173
- more likely to drift.
234
+ ${memoryPointersSection(opencodeToolName)}
174
235
 
175
236
  ## Background Task Completions
176
237
 
@@ -187,6 +248,12 @@ the same prompt. If the re-dispatch also fails or times out, note the gap and
187
248
  move on. Do not retry more than once - repeated failures indicate an
188
249
  intractable prompt, not a transient hang.
189
250
 
251
+ When the fact-extractor sub-agent completes, its "Extraction complete."
252
+ report is a background-task completion, not an extraction nudge: the buffer
253
+ is drained and nothing follows from it. Never re-dispatch an extractor
254
+ because a completion arrived - a new extraction nudge is the only dispatch
255
+ signal.
256
+
190
257
  ## Watchers
191
258
 
192
259
  Use thatch_watch_create to watch a GitHub PR for events (comments, review
@@ -304,6 +371,7 @@ config in memory.
304
371
  - One topic per memory. Write for a future instance with zero current context.
305
372
  - Confidence 1-2: single signal. 5-6: moderate. 9: explicitly stated.
306
373
  10: hard constraint.
374
+ ${artifactPointerBullet}
307
375
 
308
376
  ## What NOT to Store
309
377
 
@@ -422,13 +490,14 @@ WIP, PR status). Unscoped memories are project-wide and always included in searc
422
490
 
423
491
  ## Session Startup
424
492
 
425
- 1. \`memory_recall\` "user preferences and personality" (global store)
426
- 2. \`memory_recall\` "project architecture and conventions" (project store)
427
- 3. If on a non-main branch, \`memory_recall\` with the branch name
493
+ 1. \`memory_recall\` with \`{"query": "user preferences and personality"}\` (global store)
494
+ 2. \`memory_recall\` with \`{"query": "project architecture and conventions"}\` (project store)
495
+ 3. If on a non-main branch, \`memory_recall\` with the branch name as the query
428
496
  4. \`memory_list\` and \`store_list\` to see what's available
429
497
 
430
498
  Before diving into code, \`memory_recall\` with a query relevant to the area
431
- you're working in. Prior sessions may have already investigated it.
499
+ you're working in. Prior sessions may have already investigated it. All thatch
500
+ tools take a single JSON object argument: never a bare positional string.
432
501
 
433
502
  ## Skills
434
503
 
@@ -450,7 +519,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
450
519
  - \`thatch-ticket-description\` - draft a ticket, issue, or work item (Linear or Jira) with clear sections and bold/italic emphasis for scanning.
451
520
  - \`thatch-split-overlarge-pr\` - split already-completed work from an overlarge PR into human-reviewable, release-safe PRs targeting main.
452
521
  - \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
453
- - \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
522
+ - \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory looks stale or when preparing a knowledge export.
454
523
  - \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
455
524
  - \`thatch-clear-writing\` - prose rules for any human-facing text not covered by a dedicated writing skill: PR and ticket comments, documentation, plans, reports, and chat replies. Load before drafting any of those.
456
525
 
@@ -478,14 +547,7 @@ whether you've discovered knowledge worth persisting. Use memory_recall to
478
547
  check for duplicates, then memory_remember for new findings. Then deliver
479
548
  your response.
480
549
 
481
- ## Memory Verification
482
-
483
- When memory_show or memory_recall returns a memory with an old updated_at
484
- date (more than a few weeks), consider loading the thatch-memory-verify skill
485
- to fact-check its code-related claims against the current codebase before
486
- relying on them. This is not mandatory for every old memory. Use judgment: a
487
- memory about a stable architectural decision may be fine for months, while a
488
- memory about file locations or function names is more likely to drift.
550
+ ${memoryPointersSection(bareToolName)}
489
551
 
490
552
  ## User Decision Model
491
553
 
@@ -571,6 +633,7 @@ hand-editable; do not store thatch config in memory.
571
633
  - One topic per memory. Write for a future instance with zero current context.
572
634
  - Confidence 1-2: single signal. 5-6: moderate. 9: explicitly stated.
573
635
  10: hard constraint.
636
+ ${artifactPointerBullet}
574
637
 
575
638
  ## What NOT to Store
576
639
 
@@ -683,6 +746,13 @@ export type ToolNamer = (name: string) => string;
683
746
  export const opencodeToolName: ToolNamer = (name) => `thatch_${name}`;
684
747
  export const mcpToolName: ToolNamer = (name) => `mcp__thatch__${name}`;
685
748
 
749
+ /**
750
+ * The bare name itself. Used by prompt sections embedded in instructions
751
+ * that establish the bare-name convention (mcpInstructions and twins): the
752
+ * model maps bare names to the host spelling at call time.
753
+ */
754
+ export const bareToolName: ToolNamer = (name) => name;
755
+
686
756
  /**
687
757
  * The extraction instruction body. sessionIdArg is either ` with session_id
688
758
  * "X"` (the nudge path: the sub-agent's session differs from the parent's)
@@ -866,7 +936,11 @@ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
866
936
  const verb = n === 1 ? "relates" : "relate";
867
937
  const labels = matches.slice(0, 2).map((m) => `"${m.label}"`).join(", ");
868
938
  const etc = n > 2 ? ", etc." : "";
869
- return `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). Use ${toolName} before responding.`;
939
+ return (
940
+ `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). ` +
941
+ `Use ${toolName}({ query: "..." }) before responding - the argument is ` +
942
+ `a single object with a "query" key, never a bare positional string.`
943
+ );
870
944
  }
871
945
 
872
946
  // ---------------------------------------------------------------------------
@@ -879,7 +953,7 @@ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
879
953
  export function predictionNudge(items: PredictionNudgeItem[]): string {
880
954
  const lines = items.map((p) => {
881
955
  const verb = predictionVerb(p.evidence_count);
882
- return `- [${p.confidence.toFixed(2)} conf, ${p.evidence_count} tests] When ${p.matcher_description}: ${verb} ${p.statement}`;
956
+ return `- ${formatWhenLine(p.confidence, p.evidence_count, p.matcher_description, p.statement, verb)}`;
883
957
  });
884
958
  return `[thatch] User decision model\n${lines.join("\n")}`;
885
959
  }
@@ -893,7 +967,7 @@ export function predictionNudge(items: PredictionNudgeItem[]): string {
893
967
  export function behaviorNudge(items: BehaviorNudgeItem[]): string {
894
968
  const lines = items.map((b) => {
895
969
  const verb = b.evidence_count === 0 ? "consider" : "do";
896
- return `- [${b.confidence.toFixed(2)} conf, ${b.evidence_count} tests] When ${b.matcher_description}: ${verb} ${b.statement}`;
970
+ return `- ${formatWhenLine(b.confidence, b.evidence_count, b.matcher_description, b.statement, verb)}`;
897
971
  });
898
972
  return `[thatch] Situational behaviors\n${lines.join("\n")}`;
899
973
  }