@jeffober/thatch 0.2.5 → 0.2.7

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.
Files changed (53) hide show
  1. package/README.md +48 -25
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +11 -0
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +25 -15
  6. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  7. package/artifacts/skills/thatch-refine-personal.md +5 -0
  8. package/artifacts/skills/thatch-refine.md +7 -1
  9. package/artifacts/skills/thatch-review-clarity.md +101 -0
  10. package/artifacts/skills/thatch-review-context.md +55 -2
  11. package/artifacts/skills/thatch-review-followup.md +4 -4
  12. package/artifacts/skills/thatch-review-response.md +1 -1
  13. package/artifacts/skills/thatch-review-synthesizer.md +22 -2
  14. package/bin/thatch +103 -33
  15. package/docs/dev/README.md +11 -8
  16. package/docs/dev/features/README.md +1 -1
  17. package/docs/dev/features/behavior-engine.md +10 -0
  18. package/docs/dev/features/cli.md +23 -7
  19. package/docs/dev/features/commands.md +40 -9
  20. package/docs/dev/features/cross-session-chat.md +99 -27
  21. package/docs/dev/features/database.md +4 -4
  22. package/docs/dev/features/nudge-pipeline.md +14 -0
  23. package/docs/dev/features/opencode-plugin.md +41 -15
  24. package/docs/dev/features/prediction-engine.md +27 -1
  25. package/docs/dev/features/qa-system.md +19 -3
  26. package/docs/dev/features/session-lifecycle.md +6 -6
  27. package/docs/dev/features/session-tabs.md +11 -5
  28. package/docs/dev/gotchas.md +21 -0
  29. package/docs/dev/mcp-parity.md +3 -3
  30. package/docs/dev/skills.md +12 -8
  31. package/docs/user/README.md +22 -14
  32. package/docs/user/behavior-engine.md +7 -1
  33. package/docs/user/cli.md +28 -15
  34. package/docs/user/code-review.md +46 -4
  35. package/docs/user/commands.md +27 -7
  36. package/docs/user/cross-session-chat.md +48 -20
  37. package/docs/user/prediction-engine.md +12 -1
  38. package/docs/user/skills.md +9 -6
  39. package/package.json +1 -1
  40. package/src/capabilities.ts +20 -6
  41. package/src/chat.ts +56 -24
  42. package/src/commands.ts +109 -18
  43. package/src/db.ts +23 -2
  44. package/src/mcp.ts +6 -2
  45. package/src/opencode/tui-plugin.ts +29 -9
  46. package/src/opencode/v2.ts +247 -66
  47. package/src/prompts.ts +55 -6
  48. package/src/runtime.ts +122 -17
  49. package/src/scoring-engine.ts +40 -9
  50. package/src/session-tab-shared.ts +55 -4
  51. package/src/skills.ts +5 -9
  52. package/src/tab-state.ts +125 -0
  53. package/src/tool-defs.ts +55 -22
@@ -5,11 +5,11 @@ import type { ToolContext as V2ToolContext } from "@opencode/plugin/promise/tool
5
5
  import type { ThatchDB } from "../db";
6
6
  import type { HostCapabilities, PromptPart, ToastInput } from "../capabilities";
7
7
  import { createRuntime } from "../runtime";
8
- import { wrapUpCommandContent } from "../commands";
8
+ import { wrapUpCommandContent, wrapUpCommandDescription } from "../commands";
9
9
  import { deriveTitle } from "../extraction";
10
10
  import { detectRepo, detectWorktreeKind } from "../git";
11
11
  import { chatEnabled, loadConfig } from "../config";
12
- import { SESSION_TAB_RPC, TAB_CLOSED_EVENT, TAB_OPENED_EVENT, buildSubordinatePrompt, tabClosedEventType, tabOpenedEventType } from "../session-tab-shared";
12
+ import { EXIT_TAB_CLOSED_EVENT, SESSION_TAB_RPC, TAB_CLOSED_EVENT, TAB_OPENED_EVENT, TOAST_EVENT, buildSubordinatePrompt, tabClosedEventType, tabOpenedEventType } from "../session-tab-shared";
13
13
  import { TOOL_DEFS, trimHostContext, type HostToolContext, type SessionTabCloseInput, type SessionTabHost, type SessionTabSpawnInput, type SessionTabSpawnResult } from "../tool-defs";
14
14
 
15
15
  // The opencode v2 adapter (opencode 2.x, plugin API @opencode/plugin 2.x).
@@ -29,9 +29,9 @@ import { TOOL_DEFS, trimHostContext, type HostToolContext, type SessionTabCloseI
29
29
  // array; the runtime's plain strings convert to {type: "text", text}
30
30
  // parts at the boundary.
31
31
  // - per-message nudges: the prompt hook computes the turn's injections;
32
- // the generate hook appends them to the outbound request's last user
33
- // message `content` array (v2 wire Messages have no `parts` field).
34
- // Injections stay out of the stored prompt text.
32
+ // the context hook appends them to the outbound request's user message
33
+ // that matches the entry's prompt text (v2 wire Messages have no
34
+ // `parts` field). Injections stay out of the stored prompt text.
35
35
  // - noReply deliveries (chat echoes, session-start reminder): v2's prompt
36
36
  // endpoint cannot suppress the model turn, so the runtime gates them off
37
37
  // via capabilities.noReplyDelivery.
@@ -46,18 +46,52 @@ import { TOOL_DEFS, trimHostContext, type HostToolContext, type SessionTabCloseI
46
46
  // - TUI reach: the ONLY server-to-TUI channel is the rpc event bridge - the
47
47
  // session-tab rpc definition is registered at setup and its events reach
48
48
  // the TUI CLI plugin (the package's ./tui entrypoint), which drives the
49
- // tab strip (ui.tabs). Toast/command/exit pushes still have no surface.
50
- // - toasts, tui commands, session delete/list endpoints:
49
+ // tab strip (ui.tabs) and shows toasts (ui.toast). Keymap-command pushes
50
+ // still have no surface.
51
+ // - wrap-up actions: compact rides session.compact on the promise domain
52
+ // (upstream #52385, newer than the pinned dev types, so runtime-guarded);
53
+ // exit records the session's watcher deaths synchronously in the runtime
54
+ // and then emits the bridge's exit-tab-closed event, which the TUI plugin
55
+ // turns into closing the session's OWN tab (see exitHost in
56
+ // buildCapabilities).
57
+ // - toasts ride the bridge's toast event (TUI plugin's ui.toast.show);
58
+ // extraction-child cleanup rides session.remove (upstream #52387, also
59
+ // runtime-guarded). tui commands, session list endpoints:
51
60
  // no v2 surface reachable from a plugin - degrades as no-op/null.
52
61
  // fetchStatuses returns {} because the wake gate treats an unknown session
53
62
  // as idle; the event-fed status map inside the runtime does the gating.
54
63
  // Session MESSAGES are readable via session.context (mapped into the v1
55
- // shape), so the wrap-up greenlight check works; the compaction TRIGGER
56
- // does not (no session.compact on the promise domain).
64
+ // shape), so the wrap-up greenlight check works.
57
65
 
58
66
  type V2Context = Plugin.Context;
59
67
  type V2Cleanup = Plugin.Cleanup;
60
68
 
69
+ // Both ends of the prompt-text binding must reconstruct text the same way:
70
+ // the prompt-session adapter joins outbound prompt parts with this helper,
71
+ // and the context hook joins a wire message's text parts with it before
72
+ // comparing against the stored prompt text. A separator mismatch would
73
+ // silently fail the equality and drop the turn's nudges.
74
+ function joinTextParts(parts: Array<{ text: string }>): string {
75
+ return parts.map((p) => p.text).join("\n\n");
76
+ }
77
+
78
+ // Reconstructs a wire message's text for the prompt-text binding. A string
79
+ // content is the common case; multi-part messages join with joinTextParts
80
+ // so the read side agrees with the write side's separator.
81
+ function messageText(message: { content: unknown }): string {
82
+ if (typeof message.content === "string") return message.content;
83
+ const parts = (Array.isArray(message.content) ? message.content : []).filter(
84
+ (p: any) => p?.type === "text",
85
+ );
86
+ return joinTextParts(parts);
87
+ }
88
+
89
+ // The wrap-up exit's tab-close emitter, late-bound in setup (see the
90
+ // tuiBridge holder there). One shape, referenced at the holder, the
91
+ // buildCapabilities seam, and the assignment - a local alias so the payload
92
+ // shape has a single drift point in this file.
93
+ type ExitTabCloseEmitter = (data: { sessionID: string }) => Promise<unknown>;
94
+
61
95
  // Raw setup function: the dual entry (src/index.ts) owns the plugin id and
62
96
  // the merged default export; this adapter only implements the v2 setup.
63
97
  export async function setup(context: V2Context): Promise<V2Cleanup | void> {
@@ -77,14 +111,26 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
77
111
  // differs from this instance's directory whenever opencode is launched
78
112
  // below the project root. Their events would never pass the directory
79
113
  // filter, so they are forwarded by ID: sessionCreate records every child
80
- // here, and the pump lets their events through.
114
+ // here, and the pump lets their events through. Ids are never removed
115
+ // (children are deleted, but their id never recurs and the set dies with
116
+ // this activation - only a long-lived instance grows it).
81
117
  const childSessions = new Set<string>();
82
118
 
83
119
  // Tool executions currently running against this instance's runtime (see
84
120
  // the counter in the tool execute wrapper below).
85
121
  let inflightTools = 0;
86
122
 
87
- const capabilities = buildCapabilities(context, worktree, childSessions);
123
+ // TUI-bridge holder: the wrap-up exit emits the exit-tab-closed rpc event
124
+ // (the TUI plugin closes the session's own tab) and every runtime toast
125
+ // rides the toast event - both through this late-bound holder, because
126
+ // capabilities are built before the rpc registration exists. Assigned when
127
+ // the registration wires (below); undefined forever on an SDK floor
128
+ // without the rpc surface, which both emitters' guards degrade.
129
+ const tuiBridge: {
130
+ emitExitTabClose?: ExitTabCloseEmitter;
131
+ emitToast?: (toast: ToastInput) => Promise<unknown>;
132
+ } = {};
133
+ const capabilities = buildCapabilities(context, worktree, childSessions, tuiBridge);
88
134
  const runtime = await createRuntime({ capabilities, directory, worktree, v2Tools: true });
89
135
  // Seed the forwarding set from rehydrated child bookkeeping: after a
90
136
  // reload, an in-flight extraction child's maps come back from the journal,
@@ -111,13 +157,27 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
111
157
  if (tabRegistration) {
112
158
  const emitTabOpened = (data: { sessionID: string; directory: string }) =>
113
159
  tabRegistration.events.emit(TAB_OPENED_EVENT, data);
160
+ // The session_tab_close tool's confirmed-close emitter: the pump
161
+ // translates this event into the runtime's death path, so it is the
162
+ // tool's only route to that bookkeeping.
163
+ const emitTabClosed = (data: { sessionID: string; chatName: string | null }) =>
164
+ tabRegistration.events.emit(TAB_CLOSED_EVENT, data);
165
+ // The wrap-up exit's own emitter: TUI-only (the runtime records the
166
+ // exit's deaths synchronously before the action fires, so the pump
167
+ // deliberately never translates this one).
168
+ tuiBridge.emitExitTabClose = (data) => tabRegistration.events.emit(EXIT_TAB_CLOSED_EVENT, data);
169
+ // Every runtime toast rides this emitter (alerts, extraction metrics,
170
+ // chat registration, blocked wrap-ups) - TUI-only by nature. The spread
171
+ // widens the interface into a plain object type: the rpc emit's
172
+ // schema-inferred input wants an index signature, which interfaces
173
+ // never carry.
174
+ tuiBridge.emitToast = (toast) => tabRegistration.events.emit(TOAST_EVENT, { ...toast });
114
175
  runtime.coreContext.sessionTabHost = buildSessionTabHost({
115
176
  context,
116
177
  directory,
117
178
  db: runtime.coreContext.db,
118
179
  emitTabOpened,
119
- emitTabClosed: (data: { sessionID: string; chatName: string | null }) =>
120
- tabRegistration.events.emit(TAB_CLOSED_EVENT, data),
180
+ emitTabClosed,
121
181
  debug: runtime.debug,
122
182
  });
123
183
  } else {
@@ -199,12 +259,34 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
199
259
  // (the v1 contract); v2's system parts are { type: "text", text }
200
260
  // objects, so convert at the boundary.
201
261
  // - per-turn nudges: append the prompt hook's stored injections to the
202
- // OUTBOUND request's last user message. The stored user message stays
203
- // clean (v1 delivered nudges as TUI-hidden synthetic parts; v2's prompt
204
- // text IS the stored message), and the request is rebuilt per round
205
- // trip, so injecting here reproduces v1's persistent-synthetic-part
206
- // semantics: the model sees the nudges on every call of the turn.
207
- const pendingInjections = new Map<string, string[]>();
262
+ // OUTBOUND request's user message whose text matches the entry's
263
+ // stored prompt (see the binding check below). The stored user message
264
+ // stays clean (v1 delivered nudges as TUI-hidden synthetic parts; v2's
265
+ // prompt text IS the stored message), and the request is rebuilt per
266
+ // round trip, so injecting here reproduces v1's
267
+ // persistent-synthetic-part semantics: the model sees the nudges on
268
+ // every call of the turn.
269
+ // Per-turn nudge injections, keyed by session, then by the prompt text
270
+ // they were computed from. Each entry is bound to that text: the context
271
+ // hook injects an entry only when the request contains a user message
272
+ // whose text equals it. A bare sessionID-keyed entry that outlives its
273
+ // turn (the prompt hook missed or failed on a later message) replays a
274
+ // stale nudge block onto a message it was never computed for - deleted
275
+ // predictions surfaced as current with their old confidence labels (the
276
+ // October 2026 staleness report).
277
+ //
278
+ // The per-session set holds a small bounded number of entries (oldest
279
+ // evicted) so a prompt queued mid-turn keeps its own fresh entry instead
280
+ // of evicting the running turn's: the running turn's remaining rounds
281
+ // still match their own prompt, and the queued prompt's turn matches
282
+ // its own. A prompt whose nudge computation comes up empty clears the
283
+ // session's whole set - an entry left behind would match its own prompt
284
+ // text in the session HISTORY of later requests and replay a stale block
285
+ // there - so a queued prompt with no nudges drops the running turn's
286
+ // remaining-round block. That cost is smaller than the staleness it
287
+ // prevents; opencode-plugin.md discloses it.
288
+ const PENDING_INJECTION_CAP = 3;
289
+ const pendingInjections = new Map<string, Map<string, { promptText: string; injections: string[] }>>();
208
290
  const registerSystem = await context.session.hook(
209
291
  "context",
210
292
  async (request: { sessionID: string; system: unknown[]; messages?: any[] }) => {
@@ -213,16 +295,51 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
213
295
  for (const part of pushed) {
214
296
  request.system.push(typeof part === "string" ? { type: "text", text: part } : part);
215
297
  }
216
- const injections = pendingInjections.get(request.sessionID);
217
- if (!injections?.length) return;
218
- const lastUser = [...(request.messages ?? [])].reverse().find((m) => m?.role === "user");
219
- if (!lastUser) return;
298
+ const pending = pendingInjections.get(request.sessionID);
299
+ if (!pending?.size) return;
300
+ // Binding check: a stored v2 user message IS its prompt text (the
301
+ // prompt hook's injections are computed from it), so text equality
302
+ // identifies the message an entry belongs to. Wire message ids are
303
+ // optional on the v2 Message schema and cannot be relied on here.
304
+ //
305
+ // The match searches the request's user messages for the LAST one
306
+ // whose joined text equals the entry's prompt - it is not simply the
307
+ // request's last user message. Sibling context hooks (opencode's plan
308
+ // plugin) splice their own user-role reminder into the request
309
+ // mid-turn: BEFORE the prompt when the request's last message is
310
+ // user-role, but AT THE TAIL when it is not (mid-turn rounds), which
311
+ // would make the reminder the last user message, fail the equality,
312
+ // and silently drop every nudge for the turn. Injecting into the
313
+ // prompt's own message is v1's persistent-synthetic-part semantics.
314
+ //
315
+ // Entries are tried newest first: when several are pending for the
316
+ // session, the latest prompt-hook run wins a text match.
317
+ //
318
+ // Text equality is the tightest binding available here, not an
319
+ // airtight one: an identical prompt re-submitted after a missed
320
+ // prompt hook still matches and replays. The next prompt-hook run
321
+ // that fires is the recovery.
322
+ let target: any;
323
+ let matched: { promptText: string; injections: string[] } | undefined;
324
+ for (const entry of [...pending.values()].reverse()) {
325
+ target = [...(request.messages ?? [])]
326
+ .reverse()
327
+ .find((m) => m?.role === "user" && messageText(m) === entry.promptText);
328
+ if (target) {
329
+ matched = entry;
330
+ break;
331
+ }
332
+ }
333
+ if (!matched) {
334
+ runtime.debug("v2:hook:context", `session=${request.sessionID} no user message matches a stored prompt; skipping injections`);
335
+ return;
336
+ }
220
337
  // v2's wire Message carries parts in `content: Array<ContentPart>` -
221
338
  // it has no `parts` field, so injecting there would write a stray
222
339
  // property the provider formatter never reads (silent nudge loss).
223
- const content = Array.isArray(lastUser.content) ? lastUser.content : (lastUser.content = []);
224
- for (const text of injections) content.push({ type: "text", text });
225
- runtime.debug("v2:hook:context", `session=${request.sessionID} injected=${injections.length} messages=${request.messages?.length}`);
340
+ const content = Array.isArray(target.content) ? target.content : (target.content = []);
341
+ for (const text of matched.injections) content.push({ type: "text", text });
342
+ runtime.debug("v2:hook:context", `session=${request.sessionID} injected=${matched.injections.length} messages=${request.messages?.length}`);
226
343
  },
227
344
  );
228
345
 
@@ -241,12 +358,29 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
241
358
  );
242
359
  } catch (err) {
243
360
  console.error(`[thatch] v2 prompt hook failed: ${err}`);
361
+ // The hook owns no fresh entry for this prompt; a retained one
362
+ // belongs to a superseded prompt and would replay on an identical
363
+ // re-submission. Drop the session's whole set.
364
+ pendingInjections.delete(request.sessionID);
244
365
  return;
245
366
  }
246
367
  const injections = parts.slice(1).map((part) => part.text);
247
368
  if (injections.length > 0) runtime.debug("v2:hook:prompt", `session=${request.sessionID} injections=${injections.length}`);
248
- if (injections.length > 0) pendingInjections.set(request.sessionID, injections);
249
- else pendingInjections.delete(request.sessionID);
369
+ if (injections.length > 0) {
370
+ const entries = pendingInjections.get(request.sessionID) ?? new Map();
371
+ if (!entries.has(request.prompt.text) && entries.size >= PENDING_INJECTION_CAP) {
372
+ // Bounded set: evict the oldest entry (insertion order).
373
+ entries.delete(entries.keys().next().value!);
374
+ }
375
+ entries.set(request.prompt.text, { promptText: request.prompt.text, injections });
376
+ pendingInjections.set(request.sessionID, entries);
377
+ } else {
378
+ // A prompt that produced no nudges clears the session's whole set:
379
+ // an entry left behind would match its own prompt text in the
380
+ // session history of later requests and replay a stale block there.
381
+ // See the pendingInjections comment above for the disclosed cost.
382
+ pendingInjections.delete(request.sessionID);
383
+ }
250
384
  },
251
385
  );
252
386
 
@@ -278,10 +412,9 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
278
412
  for (const kind of ["compact", "exit"] as const) {
279
413
  editor.add({
280
414
  name: `thatch/${kind}`,
281
- description:
282
- kind === "compact"
283
- ? "Flush thatch persistence, check for loose ends, then compact if clear"
284
- : "Flush thatch persistence, check for loose ends, then exit opencode if clear",
415
+ // Shared with the v1 command file's frontmatter so the two hosts'
416
+ // menu descriptions cannot drift.
417
+ description: wrapUpCommandDescription(kind),
285
418
  // The user's typed arguments ride invocation.prompt (a
286
419
  // PromptInput.Prompt with a text field). v1's host expanded
287
420
  // $ARGUMENTS from the command file; v2's session.prompt does no
@@ -699,14 +832,41 @@ function buildSessionTabHost(input: {
699
832
  };
700
833
  }
701
834
 
835
+ // Runtime guard for promise-domain members newer than the pinned dev types
836
+ // (compact - upstream #52385; remove - #52387): a typed call fails tsc
837
+ // against the pin, and an unguarded cast crashes on an older host. Returns
838
+ // the member as a callable, or undefined on the floor - the CALLER owns the
839
+ // degrade (a log for user-requested actions like compact; silence for
840
+ // best-effort ones like the child cleanup). The rpc registration guard in
841
+ // setup is the same idiom at domain granularity and stays inline: it gates
842
+ // a whole registration, not one call.
843
+ export function optionalDomainMethod(
844
+ domain: object,
845
+ method: string,
846
+ ): ((input: unknown) => Promise<unknown>) | undefined {
847
+ const fn = (domain as Record<string, unknown>)[method];
848
+ return typeof fn === "function" ? (fn as (input: unknown) => Promise<unknown>) : undefined;
849
+ }
850
+
702
851
  // The HostCapabilities implementation over the v2 promise context. Every
703
852
  // operation the v2 surface lacks degrades as a no-op or null - the shared
704
853
  // runtime's callers treat those results as best-effort and log, never crash.
705
- function buildCapabilities(context: V2Context, worktree: string, childSessions: Set<string>): HostCapabilities {
854
+ function buildCapabilities(
855
+ context: V2Context,
856
+ worktree: string,
857
+ childSessions: Set<string>,
858
+ tuiBridge: {
859
+ emitExitTabClose?: ExitTabCloseEmitter;
860
+ emitToast?: (toast: ToastInput) => Promise<unknown>;
861
+ },
862
+ ): HostCapabilities {
706
863
  // Typed against the SDK's own SessionDomain: the shape errors the
707
864
  // original cast (as unknown as {...}) suppressed are exactly what the
708
- // review caught (message.list and compact do not exist here; get takes
709
- // {sessionID}). Let the typecheck keep holding that line.
865
+ // review caught (message.list does not exist here; get takes
866
+ // {sessionID}). compact and remove DO exist on newer SDK builds but not
867
+ // on the pinned dev types - both calls go through optionalDomainMethod
868
+ // (above). Let the typecheck keep holding that line for the pinned
869
+ // surface.
710
870
  const session = context.session;
711
871
 
712
872
  return {
@@ -731,14 +891,20 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
731
891
  childSessions.add(id);
732
892
  return { id };
733
893
  },
734
- // No delete on the v2 SessionDomain: extraction child sessions are not
735
- // cleaned up on v2 (documented gap; the bookkeeping maps still keep the
736
- // claim/completion lifecycle consistent). Two user-visible consequences: the session
737
- // picker accumulates one thatch-extraction entry per extraction, and
738
- // "continue last session" (-c) logic that picks the newest top-level
739
- // session will land in an extraction child after any session that
740
- // triggered extraction, because the child is top-level and newer.
741
- sessionDelete: async () => {},
894
+ // Extraction child cleanup: upstream exposed session.remove on the
895
+ // promise domain (#52387), the same era as compact - guarded the same
896
+ // way (optionalDomainMethod; the pinned dev types predate the surface).
897
+ // An older host keeps the documented gap: the session picker accumulates
898
+ // one thatch-extraction entry per extraction, and "continue last session"
899
+ // (-c) logic that picks the newest top-level session lands in an
900
+ // extraction child after any session that triggered extraction,
901
+ // because the child is top-level and newer.
902
+ sessionDelete: async (id: string) => {
903
+ const remove = optionalDomainMethod(session, "remove");
904
+ // Best-effort by the caller's contract: the floor degrades silently.
905
+ if (!remove) return;
906
+ await remove({ sessionID: id });
907
+ },
742
908
  promptSession: async (sessionID, body, _mode) => {
743
909
  // v2's prompt endpoint takes text, not parts, and has no synthetic /
744
910
  // noReply semantics. Nudge injections ride the prompt hook.
@@ -749,7 +915,7 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
749
915
  // prompt); v2's prompt enqueues the message and returns while the
750
916
  // model turn runs asynchronously, so the extraction background flag
751
917
  // degrades: both modes enqueue the same way.
752
- const text = body.parts.map((part) => part.text).join("\n\n");
918
+ const text = joinTextParts(body.parts);
753
919
  if (!body.noReply && body.parts.length > 0 && body.parts.every((part) => part.synthetic)) {
754
920
  await session.synthetic({ sessionID, text });
755
921
  return;
@@ -764,36 +930,51 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
764
930
  },
765
931
  sessionList: async () => null,
766
932
  sessionMessages: async (id) => {
767
- // The v2 promise domain has no message-list endpoint (no
768
- // `message` accessor and no compact/remove in the SessionDomain Pick).
933
+ // The pinned promise domain has no message-list endpoint (no `message`
934
+ // accessor; compact/remove exist on newer SDK builds but not here).
769
935
  // `session.context` IS exposed and returns the session's message list
770
936
  // (Array<SessionMessageInfo>); map into the v1 shape the wrap-up
771
937
  // greenlight check reads.
772
938
  const result = await session.context({ sessionID: id });
773
939
  return mapSessionContextMessages(result);
774
940
  },
775
- showToast: async (_toast: ToastInput) => {
776
- // No toast publish path reachable from the promise context (the
777
- // tui.toast.show event has no producer surface here). A `tui`
778
- // companion plugin entrypoint (ui.toast.show) is the known lift
779
- // candidate - not yet wired. Degrades.
941
+ showToast: async (toast: ToastInput) => {
942
+ // The toast rpc event is the only TUI feedback channel on v2 - the
943
+ // TUI plugin's ui.toast.show. Best-effort by contract (the runtime's
944
+ // call sites catch-and-ignore): a TUI-less environment (headless run,
945
+ // still connecting) or an rpc-less SDK floor simply never shows it,
946
+ // and that is the correct silent degrade, not an error.
947
+ const emit = tuiBridge.emitToast;
948
+ if (!emit) return;
949
+ await emit(toast);
780
950
  },
781
- compactSession: async () => {
782
- // No compaction trigger is reachable from the promise context: the
783
- // SessionDomain Pick has no session.compact, and the built-in
784
- // /compact is a TUI palette action calling the server endpoint
785
- // directly (the server command registry only knows config/plugin-
786
- // registered names). The compact wrap-up still runs its checklist
787
- // and flush; only the automatic compaction itself degrades.
788
- console.error("[thatch] v2: compaction trigger unavailable; wrap-up checklist ran, compact skipped");
951
+ compactSession: async (sessionID: string) => {
952
+ // Upstream exposed session.compact on the promise SessionDomain
953
+ // (#52385) - the same core compaction the built-in /compact runs.
954
+ // The pinned dev types predate that surface, so the call goes through
955
+ // optionalDomainMethod: an older host degrades to a logged no-op (the
956
+ // wrap-up checklist and flush still ran).
957
+ const compact = optionalDomainMethod(session, "compact");
958
+ if (!compact) {
959
+ console.error("[thatch] v2: compaction trigger unavailable; wrap-up checklist ran, compact skipped");
960
+ return;
961
+ }
962
+ await compact({ sessionID });
789
963
  },
790
- exitHost: async () => {
791
- // No TUI surface on v2; the exit wrap-up's checklist and flush run,
792
- // the exit itself does not. When upstream restores a publish path
793
- // (or the `tui` companion entrypoint's ui.tabs.close is wired),
794
- // the right v2 target closes THIS session's tab, not app.exit - on
795
- // a shared daemon app.exit would take down every tab. Tracked in
796
- // anomalyco/opencode#50984.
964
+ exitHost: async (sessionID: string) => {
965
+ // Tab-scoped close (the close anomalyco/opencode#50984 asks for):
966
+ // the daemon hosts every tab, so app.exit would take down unrelated
967
+ // sessions. This emit is TUI-only - the runtime records the
968
+ // session's watcher deaths synchronously BEFORE calling here, and
969
+ // the pump deliberately never translates this event (a second
970
+ // death pass would erase the durable death row the first one
971
+ // wrote).
972
+ const emit = tuiBridge.emitExitTabClose;
973
+ if (!emit) {
974
+ console.error("[thatch] v2: rpc surface unavailable; wrap-up checklist ran, tab close skipped");
975
+ return;
976
+ }
977
+ await emit({ sessionID });
797
978
  },
798
979
  };
799
980
  }
package/src/prompts.ts CHANGED
@@ -1,5 +1,31 @@
1
1
  import type { PredictionNudgeItem, BehaviorNudgeItem } from "./db";
2
2
 
3
+ /**
4
+ * The review specialist roster, in the coordinator's dispatch order. This is
5
+ * the single source of truth for the roster and its count in code: both host
6
+ * instruction skill lists (the opencode system prompt and the Claude
7
+ * Code/Cursor instructions), the review-mode command couching in
8
+ * src/commands.ts, and the shared-skills install list in src/skills.ts all
9
+ * derive from it, so a roster change lands in code everywhere at once.
10
+ * Prose copies cannot interpolate and remain hand-maintained: the
11
+ * coordinator and followup skills' count phrases are pinned by a
12
+ * setup.test.ts alarm, and the rest (the coordinator's Step 6 name list,
13
+ * the synthesizer's specialist list, the user-doc tables) need a grep of
14
+ * the artifacts and docs when the roster changes.
15
+ */
16
+ export const REVIEW_SPECIALISTS: readonly string[] = [
17
+ "thatch-review-pedantic",
18
+ "thatch-review-acceptance",
19
+ "thatch-review-state-flow",
20
+ "thatch-review-economy",
21
+ "thatch-review-no-slop",
22
+ "thatch-review-clarity",
23
+ "thatch-review-breadcrumbs",
24
+ "thatch-review-mark-and-sweep",
25
+ "thatch-review-highlights",
26
+ "thatch-review-technique",
27
+ ];
28
+
3
29
  /**
4
30
  * Shared verb selection for prediction formatting. 0-evidence predictions
5
31
  * use the hedged "you may prefer"; predictions with any evidence use the
@@ -188,7 +214,7 @@ exposes tools through code.
188
214
  Thatch ships skills for code review, project investigation, and memory
189
215
  workflows. The host auto-discovers them, but reach for them proactively:
190
216
 
191
- - \`thatch-code-review\` - full multi-agent code review (dispatches 8 specialists + synthesizer). Requires sub-agent support.
217
+ - \`thatch-code-review\` - full multi-agent code review (dispatches ${REVIEW_SPECIALISTS.length} specialist sub-agents, then synthesizes). Requires sub-agent support.
192
218
  - \`thatch-coordination\` - coordinate subordinate LLM sessions: dispatch with thatch_session_tab (opencode v2), track each in your task list, supervise over cross-session chat, verify before declaring done.
193
219
  - \`thatch-review-followup\` - alternate entrypoint for follow-up review rounds. Verifies whether the author's responses and code changes since your last review adequately addressed prior findings, offers to reply on resolved items, then optionally re-runs the full structured review.
194
220
  - \`thatch-code-archaeology\` - investigate an existing feature, debug an unfamiliar area, or begin a new ticket. Explores the code base from multiple angles (data model, state flow, git history, sibling features) before proposing changes. The research skill; pair with \`thatch-coding-workflow\`.
@@ -551,7 +577,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
551
577
  - \`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.
552
578
  - \`thatch-review-context\` - gather project context (PRs, tickets, TODOs, deferred work) before a review.
553
579
  - \`thatch-review-followup\` - alternate entrypoint for follow-up review rounds. Verifies whether the author's responses and code changes since your last review adequately addressed prior findings, offers to reply on resolved items, then optionally re-runs the full structured review.
554
- - \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-economy\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` / \`-highlights\` / \`-technique\` - nine specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
580
+ - ${REVIEW_SPECIALISTS.map((name) => "`" + name + "`").join(" / ")} - ${REVIEW_SPECIALISTS.length} specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
555
581
  - \`thatch-project-primer\` - investigate a new project and write foundational memories.
556
582
  - \`thatch-session-reflection\` - record what you learned at end of session.
557
583
  - \`thatch-fact-extractor\` - extract durable facts from recent tool interactions (auto-triggered by the extraction pipeline).
@@ -992,12 +1018,32 @@ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
992
1018
  // update on user response).
993
1019
  // ---------------------------------------------------------------------------
994
1020
 
995
- export function predictionNudge(items: PredictionNudgeItem[]): string {
1021
+ // Shared tail of the prediction and behavior nudges. The re-check tool name
1022
+ // is resolved by the caller's ToolNamer: the same renderers serve the
1023
+ // opencode plugin path (thatch_*) and the MCP flush-tools path
1024
+ // (mcp__thatch__*), where hosts spell tools differently.
1025
+ function snapshotFooter(store: string, recheckTool: string): string {
1026
+ const cap = store[0].toUpperCase() + store.slice(1);
1027
+ return (
1028
+ `Snapshot: these labels reflect the ${store} store as of this message's submission. ` +
1029
+ `${cap} updated after submission (including during this turn) do not refresh it; ` +
1030
+ `re-check with ${recheckTool} before relying on them.`
1031
+ );
1032
+ }
1033
+
1034
+ export function predictionNudge(items: PredictionNudgeItem[], tool: ToolNamer = opencodeToolName): string {
996
1035
  const lines = items.map((p) => {
997
1036
  const verb = predictionVerb(p.evidence_count);
998
1037
  return `- ${formatWhenLine(p.confidence, p.evidence_count, p.matcher_description, p.statement, verb)}`;
999
1038
  });
1000
- return `[thatch] User decision model\n${lines.join("\n")}`;
1039
+ // Snapshot basis (the Oct 2026 staleness report): the block is computed
1040
+ // once when the message is submitted and stays attached to it for the
1041
+ // whole turn. Predictions mutated after submission (by this turn's own
1042
+ // tool calls, or a concurrent session) do not refresh it - the labels
1043
+ // below can lag the store. Point the consumer at the live tool instead
1044
+ // of implying a per-round refresh.
1045
+ const footer = snapshotFooter("prediction", tool("prediction_query"));
1046
+ return `[thatch] User decision model\n${lines.join("\n")}\n${footer}`;
1001
1047
  }
1002
1048
 
1003
1049
  // ---------------------------------------------------------------------------
@@ -1006,12 +1052,15 @@ export function predictionNudge(items: PredictionNudgeItem[]): string {
1006
1052
  // how to act (follow relevant rules, ham/spam each match via behavior_feedback).
1007
1053
  // ---------------------------------------------------------------------------
1008
1054
 
1009
- export function behaviorNudge(items: BehaviorNudgeItem[]): string {
1055
+ export function behaviorNudge(items: BehaviorNudgeItem[], tool: ToolNamer = opencodeToolName): string {
1010
1056
  const lines = items.map((b) => {
1011
1057
  const verb = b.evidence_count === 0 ? "consider" : "do";
1012
1058
  return `- ${formatWhenLine(b.confidence, b.evidence_count, b.matcher_description, b.statement, verb)}`;
1013
1059
  });
1014
- return `[thatch] Situational behaviors\n${lines.join("\n")}`;
1060
+ // Same snapshot basis as the prediction nudge: computed at submission,
1061
+ // not refreshed by mid-turn behavior feedback.
1062
+ const footer = snapshotFooter("behavior", tool("behavior_list"));
1063
+ return `[thatch] Situational behaviors\n${lines.join("\n")}\n${footer}`;
1015
1064
  }
1016
1065
 
1017
1066
  /**