@jeffober/thatch 0.2.5 → 0.2.6

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 (51) hide show
  1. package/README.md +48 -25
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  4. package/artifacts/skills/thatch-code-review.md +16 -8
  5. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  6. package/artifacts/skills/thatch-refine-personal.md +5 -0
  7. package/artifacts/skills/thatch-refine.md +7 -1
  8. package/artifacts/skills/thatch-review-context.md +55 -2
  9. package/artifacts/skills/thatch-review-followup.md +4 -4
  10. package/artifacts/skills/thatch-review-response.md +1 -1
  11. package/artifacts/skills/thatch-review-synthesizer.md +21 -1
  12. package/bin/thatch +21 -12
  13. package/docs/dev/README.md +11 -8
  14. package/docs/dev/features/README.md +1 -1
  15. package/docs/dev/features/behavior-engine.md +10 -0
  16. package/docs/dev/features/cli.md +2 -1
  17. package/docs/dev/features/commands.md +40 -9
  18. package/docs/dev/features/cross-session-chat.md +85 -21
  19. package/docs/dev/features/database.md +4 -4
  20. package/docs/dev/features/nudge-pipeline.md +14 -0
  21. package/docs/dev/features/opencode-plugin.md +41 -15
  22. package/docs/dev/features/prediction-engine.md +27 -1
  23. package/docs/dev/features/qa-system.md +19 -3
  24. package/docs/dev/features/session-lifecycle.md +6 -6
  25. package/docs/dev/features/session-tabs.md +11 -5
  26. package/docs/dev/gotchas.md +21 -0
  27. package/docs/dev/mcp-parity.md +3 -3
  28. package/docs/dev/skills.md +11 -8
  29. package/docs/user/README.md +19 -12
  30. package/docs/user/behavior-engine.md +7 -1
  31. package/docs/user/cli.md +2 -1
  32. package/docs/user/code-review.md +43 -2
  33. package/docs/user/commands.md +27 -7
  34. package/docs/user/cross-session-chat.md +47 -20
  35. package/docs/user/prediction-engine.md +12 -1
  36. package/docs/user/skills.md +8 -6
  37. package/package.json +1 -1
  38. package/src/capabilities.ts +20 -6
  39. package/src/chat.ts +39 -17
  40. package/src/commands.ts +109 -18
  41. package/src/db.ts +23 -2
  42. package/src/mcp.ts +6 -2
  43. package/src/opencode/tui-plugin.ts +29 -9
  44. package/src/opencode/v2.ts +247 -66
  45. package/src/prompts.ts +54 -6
  46. package/src/runtime.ts +122 -17
  47. package/src/scoring-engine.ts +40 -9
  48. package/src/session-tab-shared.ts +55 -4
  49. package/src/skills.ts +5 -9
  50. package/src/tab-state.ts +125 -0
  51. package/src/tool-defs.ts +42 -21
package/src/prompts.ts CHANGED
@@ -1,5 +1,30 @@
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-breadcrumbs",
23
+ "thatch-review-mark-and-sweep",
24
+ "thatch-review-highlights",
25
+ "thatch-review-technique",
26
+ ];
27
+
3
28
  /**
4
29
  * Shared verb selection for prediction formatting. 0-evidence predictions
5
30
  * use the hedged "you may prefer"; predictions with any evidence use the
@@ -188,7 +213,7 @@ exposes tools through code.
188
213
  Thatch ships skills for code review, project investigation, and memory
189
214
  workflows. The host auto-discovers them, but reach for them proactively:
190
215
 
191
- - \`thatch-code-review\` - full multi-agent code review (dispatches 8 specialists + synthesizer). Requires sub-agent support.
216
+ - \`thatch-code-review\` - full multi-agent code review (dispatches ${REVIEW_SPECIALISTS.length} specialist sub-agents, then synthesizes). Requires sub-agent support.
192
217
  - \`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
218
  - \`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
219
  - \`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 +576,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
551
576
  - \`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
577
  - \`thatch-review-context\` - gather project context (PRs, tickets, TODOs, deferred work) before a review.
553
578
  - \`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.
579
+ - ${REVIEW_SPECIALISTS.map((name) => "`" + name + "`").join(" / ")} - ${REVIEW_SPECIALISTS.length} specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
555
580
  - \`thatch-project-primer\` - investigate a new project and write foundational memories.
556
581
  - \`thatch-session-reflection\` - record what you learned at end of session.
557
582
  - \`thatch-fact-extractor\` - extract durable facts from recent tool interactions (auto-triggered by the extraction pipeline).
@@ -992,12 +1017,32 @@ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
992
1017
  // update on user response).
993
1018
  // ---------------------------------------------------------------------------
994
1019
 
995
- export function predictionNudge(items: PredictionNudgeItem[]): string {
1020
+ // Shared tail of the prediction and behavior nudges. The re-check tool name
1021
+ // is resolved by the caller's ToolNamer: the same renderers serve the
1022
+ // opencode plugin path (thatch_*) and the MCP flush-tools path
1023
+ // (mcp__thatch__*), where hosts spell tools differently.
1024
+ function snapshotFooter(store: string, recheckTool: string): string {
1025
+ const cap = store[0].toUpperCase() + store.slice(1);
1026
+ return (
1027
+ `Snapshot: these labels reflect the ${store} store as of this message's submission. ` +
1028
+ `${cap} updated after submission (including during this turn) do not refresh it; ` +
1029
+ `re-check with ${recheckTool} before relying on them.`
1030
+ );
1031
+ }
1032
+
1033
+ export function predictionNudge(items: PredictionNudgeItem[], tool: ToolNamer = opencodeToolName): string {
996
1034
  const lines = items.map((p) => {
997
1035
  const verb = predictionVerb(p.evidence_count);
998
1036
  return `- ${formatWhenLine(p.confidence, p.evidence_count, p.matcher_description, p.statement, verb)}`;
999
1037
  });
1000
- return `[thatch] User decision model\n${lines.join("\n")}`;
1038
+ // Snapshot basis (the Oct 2026 staleness report): the block is computed
1039
+ // once when the message is submitted and stays attached to it for the
1040
+ // whole turn. Predictions mutated after submission (by this turn's own
1041
+ // tool calls, or a concurrent session) do not refresh it - the labels
1042
+ // below can lag the store. Point the consumer at the live tool instead
1043
+ // of implying a per-round refresh.
1044
+ const footer = snapshotFooter("prediction", tool("prediction_query"));
1045
+ return `[thatch] User decision model\n${lines.join("\n")}\n${footer}`;
1001
1046
  }
1002
1047
 
1003
1048
  // ---------------------------------------------------------------------------
@@ -1006,12 +1051,15 @@ export function predictionNudge(items: PredictionNudgeItem[]): string {
1006
1051
  // how to act (follow relevant rules, ham/spam each match via behavior_feedback).
1007
1052
  // ---------------------------------------------------------------------------
1008
1053
 
1009
- export function behaviorNudge(items: BehaviorNudgeItem[]): string {
1054
+ export function behaviorNudge(items: BehaviorNudgeItem[], tool: ToolNamer = opencodeToolName): string {
1010
1055
  const lines = items.map((b) => {
1011
1056
  const verb = b.evidence_count === 0 ? "consider" : "do";
1012
1057
  return `- ${formatWhenLine(b.confidence, b.evidence_count, b.matcher_description, b.statement, verb)}`;
1013
1058
  });
1014
- return `[thatch] Situational behaviors\n${lines.join("\n")}`;
1059
+ // Same snapshot basis as the prediction nudge: computed at submission,
1060
+ // not refreshed by mid-turn behavior feedback.
1061
+ const footer = snapshotFooter("behavior", tool("behavior_list"));
1062
+ return `[thatch] Situational behaviors\n${lines.join("\n")}\n${footer}`;
1015
1063
  }
1016
1064
 
1017
1065
  /**
package/src/runtime.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join } from "node:path";
2
- import { createDebugLog } from "./debug";
3
+ import { createDebugLog, type DebugLog } from "./debug";
3
4
  import { ThatchDB, repoPathCache } from "./db";
5
+ import { chatOpenTabs as chatOpenTabsProvider } from "./tab-state";
4
6
  import { SharedModelPool } from "./embeddings";
5
7
  import { detectRepo, detectWorktreeKind, resolveSpawnCwd } from "./git";
6
8
  import { buildCoreContext } from "./tool-defs";
@@ -134,6 +136,47 @@ export interface ThatchRuntime {
134
136
  dispose(): Promise<void>;
135
137
  }
136
138
 
139
+ /**
140
+ * Whether a wake delivery's target directory can host the attempt right
141
+ * now. existsSync cannot say WHY a path is absent - deleted worktree,
142
+ * broken symlink, permission on a parent - and none of those states can
143
+ * host a wake attempt, so one false answer covers the class (deliberate:
144
+ * the alternative, surfacing non-ENOENT errors through the attempt, buys
145
+ * a toast back for no recovery the mail does not already get from its
146
+ * pending-and-retry cycle). Synchronous on purpose: fire-and-forget
147
+ * callers and test harnesses observe promptSession timing with no added
148
+ * microtask between the gate and the wrapped call.
149
+ */
150
+ export function directoryAvailable(path: string): boolean {
151
+ return existsSync(path);
152
+ }
153
+
154
+ /**
155
+ * The delivery guard: wraps the host's promptSession so a wake attempt
156
+ * never runs against a session whose directory has been deleted. The
157
+ * attempt itself would error, and opencode surfaces that failure to the
158
+ * user as a toast. Skipping instead leaves the pending mail queued - the
159
+ * check runs fresh on every attempt, so a worktree recreated at the same
160
+ * path (or a session revived in a live directory) resumes delivery with
161
+ * nothing lost. This covers every wake site at once because chat nudges,
162
+ * watcher notifications, watcher death notices, rehydrate pings, chat
163
+ * echoes, and extraction-child dispatch all funnel through promptSession,
164
+ * and a host instance is bound to its sessions' own directory.
165
+ */
166
+ export function guardDelivery(
167
+ promptSession: HostCapabilities["promptSession"],
168
+ directory: string,
169
+ debug: DebugLog,
170
+ ): HostCapabilities["promptSession"] {
171
+ return (sessionID, body, mode) => {
172
+ if (!directoryAvailable(directory)) {
173
+ debug("runtime:delivery", `skipped wake to ${sessionID}: directory ${directory} no longer exists`);
174
+ return Promise.resolve();
175
+ }
176
+ return promptSession(sessionID, body, mode);
177
+ };
178
+ }
179
+
137
180
  export async function createRuntime(input: {
138
181
  capabilities: HostCapabilities;
139
182
  directory: string;
@@ -146,7 +189,7 @@ export async function createRuntime(input: {
146
189
  */
147
190
  v2Tools?: boolean;
148
191
  }): Promise<ThatchRuntime> {
149
- const { capabilities: caps, directory, worktree, v2Tools } = input;
192
+ const { capabilities, directory, worktree, v2Tools } = input;
150
193
  // The opencode server's cwd is wherever the server happened to start;
151
194
  // `worktree` is the project this plugin instance actually serves.
152
195
  // `directory` is the session's own directory - on resume after the
@@ -169,7 +212,10 @@ export async function createRuntime(input: {
169
212
 
170
213
  // The DB must exist before identity detection: detectRepo consults the
171
214
  // repo_paths cache when the session directory is gone.
172
- const db = new ThatchDB(dbPath);
215
+ // chatOpenTabs composes the tab-state reader (tabs.json across channels,
216
+ // minus confirmed closes) lazily per call - never cached - so roster
217
+ // rendering and broadcast targeting see the current tab state.
218
+ const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
173
219
  const repoCache = repoPathCache(db);
174
220
  const repo = await detectRepo(directory ?? worktree, repoCache);
175
221
 
@@ -181,6 +227,16 @@ export async function createRuntime(input: {
181
227
 
182
228
  const debug = createDebugLog(dbPath);
183
229
 
230
+ // The delivery guard wraps the host's promptSession once, so no wake
231
+ // attempt in this instance runs against a deleted directory (see
232
+ // guardDelivery). The v2 wrap-up command in opencode/v2.ts calls
233
+ // capabilities.promptSession directly and deliberately bypasses this
234
+ // wrapper: it is user-invoked in a live tab, not a wake.
235
+ const caps: HostCapabilities = {
236
+ ...capabilities,
237
+ promptSession: guardDelivery(capabilities.promptSession, directory ?? worktree, debug),
238
+ };
239
+
184
240
  // Shared embedding model: refcounted per db path (see SharedModelPool).
185
241
  const model = sharedModels.acquire(dbPath, modelName);
186
242
  const releaseModel = () => sharedModels.release(dbPath);
@@ -297,15 +353,24 @@ export async function createRuntime(input: {
297
353
  /** Tells one live same-project session about a dead session's watchers
298
354
  * (promptAsync, the watcher-notification path) and clears the durable
299
355
  * death row on success. No live peer = the row waits for the next
300
- * prompt-time surfacing pass. */
301
- const deliverWatcherDeathNotices = async (deadSessionID: string, death: { chatName: string | null; targets: string[] }) => {
356
+ * prompt-time surfacing pass. The dead session's project is the one
357
+ * captured at death time (onSessionDeath), not a re-read of the chat
358
+ * roster: the wrap-up exit unregisters the row between the death record
359
+ * and this delivery, and a re-read would strand a placed death as
360
+ * unplaceable. */
361
+ const deliverWatcherDeathNotices = async (
362
+ deadSessionID: string,
363
+ death: { chatName: string | null; targets: string[] },
364
+ deadProject: string | null,
365
+ ) => {
302
366
  if (death.targets.length === 0) {
303
- // A watchless tab closing is nobody's news.
304
- db.runtimeStateDelete("watcher_death", deadSessionID);
367
+ // A watchless death is nobody's news, and onSessionDeath never
368
+ // writes a row for one - so there is nothing to deliver or clean
369
+ // here. Deleting unconditionally would erase an earlier placed row
370
+ // for the same session (a reopened session's second greenlit
371
+ // /thatch/exit); the surfacing pass cleans any legacy empty row.
305
372
  return;
306
373
  }
307
- const deadRow = db.findChatSession(deadSessionID);
308
- const deadProject = deadRow?.project ?? null;
309
374
  if (!deadProject) return; // unplaceable death - the row waits for surfacing
310
375
  for (const peerID of sessionStatus.keys()) {
311
376
  if (peerID === deadSessionID) continue;
@@ -318,6 +383,10 @@ export async function createRuntime(input: {
318
383
  { parts: [{ type: "text", text: watcherDeathNotice(death.targets, { name: death.chatName, sessionID: deadSessionID }, { rearmsOnResume: false }), synthetic: true }] },
319
384
  "async",
320
385
  );
386
+ // Delayed journal writer (post-await): the reload ownership rule
387
+ // applies - a disposed runtime's rows belong to the reloaded
388
+ // instance (docs/dev/gotchas.md).
389
+ if (disposed) return;
321
390
  db.runtimeStateDelete("watcher_death", deadSessionID);
322
391
  return;
323
392
  } catch (err) {
@@ -399,11 +468,19 @@ export async function createRuntime(input: {
399
468
  db.runtimeStatePut("hosted", directory, (hostedRow.value as string[]).filter((sid) => sid !== deadSessionID), directory);
400
469
  }
401
470
  // Durable death record first, then best-effort live delivery - the
402
- // row is deleted when a live same-project session has the news.
471
+ // row is deleted when a live same-project session has the news. The
472
+ // project is captured HERE (while the chat row still places it): the
473
+ // wrap-up exit unregisters the row right after recording, so the
474
+ // project cannot be re-derived at delivery time. A watchless death
475
+ // writes no row at all: runtimeStatePut upserts, so an empty-targets
476
+ // write would erase an earlier placed row for the same session (a
477
+ // reopened session that greenlights /thatch/exit a second time).
403
478
  const deadRow = db.findChatSession(deadSessionID);
404
479
  const deadProject = deadRow?.project ?? null;
405
- db.runtimeStatePut("watcher_death", deadSessionID, { name: death.chatName, targets: death.targets, project: deadProject, at: nowIso() }, directory);
406
- void deliverWatcherDeathNotices(deadSessionID, death);
480
+ if (death.targets.length > 0) {
481
+ db.runtimeStatePut("watcher_death", deadSessionID, { name: death.chatName, targets: death.targets, project: deadProject, at: nowIso() }, directory);
482
+ }
483
+ void deliverWatcherDeathNotices(deadSessionID, death, deadProject);
407
484
  },
408
485
  });
409
486
  // gh presence decides whether watch_create and watch_branch_create work;
@@ -1282,6 +1359,7 @@ export async function createRuntime(input: {
1282
1359
 
1283
1360
  return {
1284
1361
  coreContext: buildCoreContext(db, model, repo, {
1362
+ openTabs: chatOpenTabsProvider(() => db.chatTabClosures()),
1285
1363
  extractionPayloadProvider: (targetID: string, fetcherID?: string): string | null => {
1286
1364
  // A fetcher identity turns the fetch into the delivery record: the
1287
1365
  // claimed entries stay accepted, and THAT fetcher's completion
@@ -1854,7 +1932,16 @@ export async function createRuntime(input: {
1854
1932
  // initiated only). CONFIRMED death: skip the delivery-persistence
1855
1933
  // heuristic entirely and run the death path now.
1856
1934
  const props = event.properties as { sessionID?: string; chatName?: string | null };
1857
- if (props.sessionID) watchers.sessionDied(props.sessionID, { chatName: props.chatName ?? null });
1935
+ if (props.sessionID) {
1936
+ // Roster demotion record: a confirmed close lets every reader
1937
+ // (roster render, broadcast targeting) distrust a residue tab
1938
+ // row for this session instantly, instead of waiting for
1939
+ // tabs.json to agree or the heartbeat to age out. Self-expiring
1940
+ // (CHAT_TAB_CLOSED_OVERRIDE_MS) and superseded by any later
1941
+ // heartbeat, so a session that regains a tab reads OPEN again.
1942
+ db.runtimeStatePut("chat_tab_closed", props.sessionID, { at: nowIso() }, directory);
1943
+ watchers.sessionDied(props.sessionID, { chatName: props.chatName ?? null });
1944
+ }
1858
1945
  return;
1859
1946
  }
1860
1947
  if (event.type === "session.status") {
@@ -1898,6 +1985,13 @@ export async function createRuntime(input: {
1898
1985
  const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
1899
1986
  if (wrapUp) {
1900
1987
  pendingWrapUp.delete(sessionID);
1988
+ // Reload ownership rule (docs/dev/gotchas.md): after a v2 plugin
1989
+ // reload this instance's journal rows belong to the reloaded
1990
+ // instance - resolve nothing further once disposed. Safe today
1991
+ // because the only producer of this branch (the v2 pump) is
1992
+ // settled before dispose, but the guard makes that invariant
1993
+ // local instead of transitive.
1994
+ if (disposed) return;
1901
1995
  db.runtimeStateDelete("wrapup", sessionID);
1902
1996
  let ready = false;
1903
1997
  try {
@@ -1913,13 +2007,24 @@ export async function createRuntime(input: {
1913
2007
  console.error(`[thatch] wrap-up message fetch failed: ${err}`);
1914
2008
  }
1915
2009
  if (ready) {
1916
- // An exit-greenlit session is leaving the process: leave the
1917
- // chat directory too, so other sessions stop addressing mail to
1918
- // a roster entry whose host is about to vanish. The unregister
2010
+ // An exit-greenlit session is closing out: leave the chat
2011
+ // directory too, so other sessions stop addressing mail to a
2012
+ // roster entry whose host is about to vanish. The unregister
1919
2013
  // tombstone also stops any straggler auto-register from
1920
2014
  // resurrecting the row during shutdown. Compact keeps the
1921
2015
  // session alive, so only the exit path does this.
1922
2016
  if (wrapUp.kind === "exit") {
2017
+ // Watcher deaths run BEFORE the unregister: onSessionDeath
2018
+ // places the death row by the session's chat row, and the
2019
+ // unregister deletes that row. The v2 exit action's emit is
2020
+ // TUI-only (the pump never translates it into a second
2021
+ // death pass, which would erase the placed row), so this is
2022
+ // the exit's only death recording.
2023
+ try {
2024
+ watchers.sessionDied(sessionID, { chatName: db.findChatSession(sessionID)?.name ?? null });
2025
+ } catch (err) {
2026
+ console.error(`[thatch] watcher death recording on exit failed: ${err}`);
2027
+ }
1923
2028
  try {
1924
2029
  db.unregisterChatSession(sessionID);
1925
2030
  } catch (err) {
@@ -1930,7 +2035,7 @@ export async function createRuntime(input: {
1930
2035
  if (wrapUp.kind === "compact") {
1931
2036
  await caps.compactSession(sessionID);
1932
2037
  } else {
1933
- await caps.exitHost();
2038
+ await caps.exitHost(sessionID);
1934
2039
  }
1935
2040
  } catch (err) {
1936
2041
  console.error(`[thatch] wrap-up action failed: ${err}`);
@@ -288,13 +288,29 @@ export class ScoringEngine {
288
288
 
289
289
  createEdge(matcherId: string, itemId: string, weight: number): void {
290
290
  const now = new Date().toISOString();
291
- const { edgesTable, itemForeignKey } = this.#cfg;
292
- this.#db.run(
293
- `INSERT INTO ${edgesTable} (matcher_id, ${itemForeignKey}, weight, created_at)
294
- VALUES (?, ?, ?, ?)
295
- ON CONFLICT(matcher_id, ${itemForeignKey}) DO NOTHING`,
296
- [matcherId, itemId, weight, now] as any,
297
- );
291
+ const { edgesTable, itemForeignKey, matchersTable } = this.#cfg;
292
+ // Insert and bump run atomically: an edge that landed without its
293
+ // updated_at bump would read as older than it is. bun:sqlite nests
294
+ // transaction functions via savepoints, so this composes with a
295
+ // caller's outer transaction (prediction_update's, for one).
296
+ // Bump only when the insert landed - a re-link to an existing edge
297
+ // changes nothing.
298
+ // A matcher's updated_at means "last change to the matcher row or its
299
+ // edge set" (not "matcher text written"): re-linking a matcher to a
300
+ // corrected item is graph activity the planned staleness audit must
301
+ // see. No reader consumes matcher updated_at yet; the bump keeps the
302
+ // timestamp truthful for when that audit lands.
303
+ this.#db.transaction(() => {
304
+ const result = this.#db.run(
305
+ `INSERT INTO ${edgesTable} (matcher_id, ${itemForeignKey}, weight, created_at)
306
+ VALUES (?, ?, ?, ?)
307
+ ON CONFLICT(matcher_id, ${itemForeignKey}) DO NOTHING`,
308
+ [matcherId, itemId, weight, now] as any,
309
+ );
310
+ if (result.changes > 0) {
311
+ this.#db.run(`UPDATE ${matchersTable} SET updated_at = ? WHERE id = ?`, [now, matcherId] as any);
312
+ }
313
+ })();
298
314
  }
299
315
 
300
316
  adjustConfidence(itemId: string, signal: "confirm" | "disconfirm" | "soft"): void {
@@ -349,8 +365,23 @@ export class ScoringEngine {
349
365
  }
350
366
 
351
367
  deleteItem(itemId: string): boolean {
352
- const result = this.#db.run(`DELETE FROM ${this.#cfg.itemsTable} WHERE id = ?`, [itemId]);
353
- return result.changes > 0;
368
+ const { itemsTable, edgesTable, itemForeignKey, matchersTable } = this.#cfg;
369
+ // Bump the matchers whose edges are about to cascade away with the
370
+ // item (edge rows have ON DELETE CASCADE, so the DELETE below removes
371
+ // them implicitly). Same updated_at meaning as createEdge: last change
372
+ // to the matcher row or its edge set. Bump and delete run atomically
373
+ // (bun:sqlite nests via savepoints): a bump whose delete then failed
374
+ // would record graph activity that never happened.
375
+ return this.#db.transaction(() => {
376
+ const now = new Date().toISOString();
377
+ this.#db.run(
378
+ `UPDATE ${matchersTable} SET updated_at = ?
379
+ WHERE id IN (SELECT matcher_id FROM ${edgesTable} WHERE ${itemForeignKey} = ?)`,
380
+ [now, itemId] as any,
381
+ );
382
+ const result = this.#db.run(`DELETE FROM ${itemsTable} WHERE id = ?`, [itemId]);
383
+ return result.changes > 0;
384
+ })();
354
385
  }
355
386
 
356
387
  listItems(store: string): {
@@ -17,10 +17,18 @@
17
17
  * The schemas are literal JSON Schema objects because
18
18
  * Rpc.PortableEventValueSchema accepts StandardSchemaV1 or a JSON Schema
19
19
  * object with type "object"; a literal object keeps this module
20
- * dependency-free. The tab-closed consumer is live: the v2 pump
21
- * (src/opencode/v2.ts) translates a confirmed close into the runtime's
22
- * death path, and the TUI plugin closes the tab - change the shape
23
- * jointly with both consumers (docs/dev/features/session-tabs.md).
20
+ * dependency-free. The registration is the server-to-TUI transport for
21
+ * everything thatch pushes at the TUI (tab lifecycle plus toasts), so its
22
+ * id says "tabs" only historically. One event has a consumer beyond the
23
+ * TUI plugin's tab strip: the v2 pump (src/opencode/v2.ts) translates
24
+ * tab-closed into the runtime's confirmed-death path (the
25
+ * session_tab_close tool's only route to it), so that shape must change
26
+ * jointly with all consumers (docs/dev/features/session-tabs.md).
27
+ * exit-tab-closed is deliberately TUI-only: the wrap-up exit records the
28
+ * session's watcher deaths synchronously before emitting
29
+ * (src/runtime.ts), so the pump ignores it - a pump translation would run
30
+ * the death path a second time and erase the durable death row the first
31
+ * pass wrote. toast is TUI-only by nature (nobody else consumes it).
24
32
  */
25
33
 
26
34
  import { realpathSync } from "node:fs";
@@ -30,6 +38,8 @@ import { resolveMainCheckout } from "./git";
30
38
  export const SESSION_TAB_RPC_ID = "thatch-tabs";
31
39
  export const TAB_OPENED_EVENT = "tab-opened";
32
40
  export const TAB_CLOSED_EVENT = "tab-closed";
41
+ export const EXIT_TAB_CLOSED_EVENT = "exit-tab-closed";
42
+ export const TOAST_EVENT = "toast";
33
43
 
34
44
  /** Full wire type of an emitted event, e.g. "rpc.thatch-tabs.tab-opened". */
35
45
  export const tabOpenedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_OPENED_EVENT}`;
@@ -38,6 +48,12 @@ export const tabOpenedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_OPENED_EVENT}
38
48
  * heartbeat plan's confirmed-close consumer matches on this. */
39
49
  export const tabClosedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_CLOSED_EVENT}`;
40
50
 
51
+ /** Full wire type of the wrap-up exit's TUI-only close event. */
52
+ export const exitTabClosedEventType = `rpc.${SESSION_TAB_RPC_ID}.${EXIT_TAB_CLOSED_EVENT}`;
53
+
54
+ /** Full wire type of the toast request event. */
55
+ export const toastEventType = `rpc.${SESSION_TAB_RPC_ID}.${TOAST_EVENT}`;
56
+
41
57
  export const SESSION_TAB_RPC = {
42
58
  id: SESSION_TAB_RPC_ID,
43
59
  methods: {},
@@ -67,6 +83,31 @@ export const SESSION_TAB_RPC = {
67
83
  additionalProperties: false,
68
84
  },
69
85
  },
86
+ [EXIT_TAB_CLOSED_EVENT]: {
87
+ // TUI-only (see the module header): carries just the tab to close.
88
+ schema: {
89
+ type: "object",
90
+ properties: {
91
+ sessionID: { type: "string" },
92
+ },
93
+ required: ["sessionID"],
94
+ additionalProperties: false,
95
+ },
96
+ },
97
+ [TOAST_EVENT]: {
98
+ // TUI-only by nature. The payload is thatch's ToastInput verbatim
99
+ // (all three fields required there, so required here).
100
+ schema: {
101
+ type: "object",
102
+ properties: {
103
+ message: { type: "string" },
104
+ variant: { type: "string", enum: ["info", "success", "warning", "error"] },
105
+ duration: { type: "number" },
106
+ },
107
+ required: ["message", "variant", "duration"],
108
+ additionalProperties: false,
109
+ },
110
+ },
70
111
  },
71
112
  } as const;
72
113
 
@@ -168,6 +209,16 @@ export function isTabClosedEvent(type: string): boolean {
168
209
  return type === tabClosedEventType;
169
210
  }
170
211
 
212
+ /** The wrap-up exit's close filter: same TUI action, no pump translation. */
213
+ export function isExitTabClosedEvent(type: string): boolean {
214
+ return type === exitTabClosedEventType;
215
+ }
216
+
217
+ /** The toast-request filter: TUI-only by nature. */
218
+ export function isToastEvent(type: string): boolean {
219
+ return type === toastEventType;
220
+ }
221
+
171
222
  /**
172
223
  * The directory guard mirrors the built-in tui.* handlers: an event only
173
224
  * acts on the window whose directory matches the event's location (the
package/src/skills.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { REVIEW_SPECIALISTS } from "./prompts";
3
4
 
4
5
  export interface SkillDef {
5
6
  name: string;
@@ -53,15 +54,10 @@ function loadSharedSkills(): SkillDef[] {
53
54
  "thatch-fact-extractor",
54
55
  "thatch-dedup-classifier",
55
56
  "thatch-project-primer",
56
- "thatch-review-pedantic",
57
- "thatch-review-acceptance",
58
- "thatch-review-state-flow",
59
- "thatch-review-economy",
60
- "thatch-review-no-slop",
61
- "thatch-review-breadcrumbs",
62
- "thatch-review-mark-and-sweep",
63
- "thatch-review-highlights",
64
- "thatch-review-technique",
57
+ // The specialist roster (order and count) comes from prompts.ts so the
58
+ // install set and the review-mode commands' inline procedure cannot
59
+ // diverge.
60
+ ...REVIEW_SPECIALISTS,
65
61
  "thatch-review-synthesizer",
66
62
  "thatch-review-context",
67
63
  "thatch-code-archaeology",
@@ -0,0 +1,125 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CHAT_STALE_MS } from "./chat";
5
+
6
+ /**
7
+ * Reader for opencode's per-TUI-instance tab persistence - the roster's
8
+ * OPEN-vs-RECENT signal. Each opencode TUI window records its open tabs in
9
+ * `<XDG_STATE_HOME>/opencode/<channel>/tui/tabs.json` (the middle segment is
10
+ * the release CHANNEL - "latest" today, so a reader must glob, never
11
+ * hardcode). The file answers "which sessions have a tab right now" for
12
+ * roster classification and broadcast targeting; it is READ-ONLY here -
13
+ * the heartbeat stays the only written liveness signal.
14
+ *
15
+ * This is a presence refinement, not a truth source. A killed window leaves
16
+ * its tab rows behind (the file is restart persistence by design), so a row
17
+ * classified OPEN may occasionally be residue; that trade is accepted in
18
+ * exchange for zero new writers. With tabs disabled (or no state tree at
19
+ * all) the reader returns an empty set and every row honestly degrades to
20
+ * RECENT - never a false OPEN.
21
+ */
22
+
23
+ /** Test/QA override for the opencode state root (the directory that holds
24
+ * one subdirectory per release channel). Unset means the real location. */
25
+ export const OPEN_TABS_STATE_ROOT_ENV = "THATCH_OPENCODE_STATE_ROOT";
26
+
27
+ /** The opencode state root: the directory containing one directory per
28
+ * release channel ("latest" today). */
29
+ export function openTabsStateRoot(env: Record<string, string | undefined> = process.env): string {
30
+ const override = env[OPEN_TABS_STATE_ROOT_ENV];
31
+ if (override) return override;
32
+ const stateHome = env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
33
+ return join(stateHome, "opencode");
34
+ }
35
+
36
+ /** Shape of tabs.json (opencode v2.0.25, session-tabs storage): an optional
37
+ * global section and a per-cwd section, each carrying tab rows with a
38
+ * sessionID. Both sections are read because the scope depends on the
39
+ * `tabs.scope` config ("cwd" or "global") and the reader must honor both. */
40
+ interface TabsFile {
41
+ global?: { tabs?: { sessionID?: string }[] };
42
+ cwd?: Record<string, { tabs?: { sessionID?: string }[] }>;
43
+ }
44
+
45
+ function collectTabs(rows: { sessionID?: string }[] | undefined, into: Set<string>): void {
46
+ if (!Array.isArray(rows)) return;
47
+ for (const row of rows) {
48
+ if (row && typeof row.sessionID === "string" && row.sessionID) into.add(row.sessionID);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Every sessionID carrying a tab in ANY channel's tabs.json, from both
54
+ * scope modes (global section and cwd sections). Per-file failures (no
55
+ * file, bad JSON, malformed sections) are swallowed and contribute
56
+ * nothing: a missing or broken signal degrades to empty, never to an
57
+ * error, so roster rendering and broadcast targeting keep working.
58
+ */
59
+ export function readOpenTabSessions(stateRoot: string = openTabsStateRoot()): Set<string> {
60
+ const open = new Set<string>();
61
+ let channels: string[];
62
+ try {
63
+ channels = readdirSync(stateRoot).filter((name) => !name.startsWith("."));
64
+ } catch {
65
+ // No state tree (opencode never ran here, or tabs are disabled):
66
+ // empty set, everything degrades to RECENT.
67
+ return open;
68
+ }
69
+ for (const channel of channels) {
70
+ let parsed: TabsFile;
71
+ try {
72
+ parsed = JSON.parse(readFileSync(join(stateRoot, channel, "tui", "tabs.json"), "utf8"));
73
+ } catch {
74
+ continue;
75
+ }
76
+ collectTabs(parsed?.global?.tabs, open);
77
+ const byCwd = parsed?.cwd;
78
+ if (byCwd && typeof byCwd === "object") {
79
+ for (const section of Object.values(byCwd)) collectTabs(section?.tabs, open);
80
+ }
81
+ }
82
+ return open;
83
+ }
84
+
85
+ /** How long a confirmed tab close keeps overriding a residue tab row. The
86
+ * close's death path stops the heartbeat, so the row can only read fresh
87
+ * until its frozen last_seen crosses CHAT_STALE_MS; one extra poll
88
+ * interval covers a beat already in flight at close time. After the
89
+ * window the marker is moot (the row is stale regardless), and a session
90
+ * that later regains a tab re-derives OPEN from tabs.json alone. */
91
+ export const CHAT_TAB_CLOSED_OVERRIDE_MS = 2 * CHAT_STALE_MS;
92
+
93
+ /** One confirmed tab close: the session's tab was closed by the
94
+ * session_tab tool and the bridge event confirmed it. */
95
+ export interface ChatTabClosure {
96
+ sessionID: string;
97
+ /** ISO timestamp of the confirmed close. */
98
+ at: string;
99
+ }
100
+
101
+ /**
102
+ * Subtracts fresh confirmed tab closes from the raw tab set, so a residue
103
+ * row in tabs.json (a window that died between the tool's close event and
104
+ * the file write - or before it) cannot read OPEN right after a confirmed
105
+ * close. Closures older than CHAT_TAB_CLOSED_OVERRIDE_MS are ignored.
106
+ */
107
+ export function effectiveOpenTabs(openTabs: ReadonlySet<string>, closures: ChatTabClosure[], now = Date.now()): Set<string> {
108
+ const effective = new Set(openTabs);
109
+ for (const closure of closures) {
110
+ const at = Date.parse(closure.at);
111
+ if (Number.isNaN(at)) continue;
112
+ if (now - at < CHAT_TAB_CLOSED_OVERRIDE_MS) effective.delete(closure.sessionID);
113
+ }
114
+ return effective;
115
+ }
116
+
117
+ /**
118
+ * The production open-tabs provider: read tabs.json at call time (never
119
+ * cached - the file changes between renders) and subtract the confirmed
120
+ * closes recorded in thatch.db. `loadClosures` is the ThatchDB lookup,
121
+ * injected so this composes with any database handle.
122
+ */
123
+ export function chatOpenTabs(loadClosures: () => ChatTabClosure[]): () => Set<string> {
124
+ return () => effectiveOpenTabs(readOpenTabSessions(), loadClosures());
125
+ }