@jeffober/thatch 0.2.4 → 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 (76) hide show
  1. package/README.md +56 -26
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +103 -5
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +16 -8
  6. package/artifacts/skills/thatch-coordination.md +69 -0
  7. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  8. package/artifacts/skills/thatch-pr-description.md +21 -13
  9. package/artifacts/skills/thatch-refine-personal.md +5 -0
  10. package/artifacts/skills/thatch-refine.md +7 -1
  11. package/artifacts/skills/thatch-review-context.md +55 -2
  12. package/artifacts/skills/thatch-review-followup.md +4 -4
  13. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  14. package/artifacts/skills/thatch-review-response.md +1 -1
  15. package/artifacts/skills/thatch-review-synthesizer.md +21 -1
  16. package/bin/thatch +48 -15
  17. package/docs/assets/deepwiki-badge.svg +1 -0
  18. package/docs/dev/README.md +18 -10
  19. package/docs/dev/features/README.md +1 -1
  20. package/docs/dev/features/behavior-engine.md +10 -0
  21. package/docs/dev/features/cli.md +4 -1
  22. package/docs/dev/features/commands.md +40 -9
  23. package/docs/dev/features/cross-session-chat.md +194 -23
  24. package/docs/dev/features/database.md +4 -4
  25. package/docs/dev/features/notifications.md +1 -2
  26. package/docs/dev/features/nudge-pipeline.md +14 -0
  27. package/docs/dev/features/opencode-plugin.md +56 -15
  28. package/docs/dev/features/prediction-engine.md +71 -6
  29. package/docs/dev/features/qa-system.md +19 -3
  30. package/docs/dev/features/session-lifecycle.md +6 -6
  31. package/docs/dev/features/session-tabs.md +161 -0
  32. package/docs/dev/features/setup.md +5 -0
  33. package/docs/dev/features/watchers.md +62 -6
  34. package/docs/dev/gotchas.md +89 -0
  35. package/docs/dev/mcp-parity.md +5 -5
  36. package/docs/dev/setup-and-hooks.md +7 -2
  37. package/docs/dev/skills.md +12 -9
  38. package/docs/plans/generalized-session-heartbeat.md +212 -0
  39. package/docs/plans/prediction-consolidation.md +88 -68
  40. package/docs/user/README.md +29 -13
  41. package/docs/user/behavior-engine.md +7 -1
  42. package/docs/user/cli.md +5 -1
  43. package/docs/user/code-review.md +43 -2
  44. package/docs/user/commands.md +37 -10
  45. package/docs/user/cross-session-chat.md +76 -26
  46. package/docs/user/prediction-engine.md +23 -1
  47. package/docs/user/setup.md +16 -0
  48. package/docs/user/skills.md +9 -7
  49. package/docs/user/watchers.md +22 -7
  50. package/package.json +10 -2
  51. package/src/alerts.ts +1 -1
  52. package/src/capabilities.ts +20 -6
  53. package/src/chat.ts +358 -43
  54. package/src/commands.ts +123 -18
  55. package/src/db.ts +74 -3
  56. package/src/git.ts +3 -1
  57. package/src/hygiene.ts +5 -0
  58. package/src/mcp.ts +101 -9
  59. package/src/opencode/tui-plugin.ts +90 -0
  60. package/src/opencode/v2.ts +440 -66
  61. package/src/prediction.ts +90 -0
  62. package/src/prompts.ts +164 -29
  63. package/src/runtime.ts +298 -15
  64. package/src/scoring-engine.ts +40 -9
  65. package/src/session-db.ts +4 -4
  66. package/src/session-tab-shared.ts +231 -0
  67. package/src/setup.ts +9 -3
  68. package/src/skills.ts +6 -9
  69. package/src/tab-state.ts +125 -0
  70. package/src/tool-defs.ts +454 -76
  71. package/src/tools.ts +2 -0
  72. package/src/watchers.ts +263 -26
  73. package/docs/plans/intuition-drives.md +0 -147
  74. package/docs/plans/llm-alerts.md +0 -287
  75. package/docs/plans/thatch-actions.md +0 -179
  76. package/docs/plans/worktree-deletion-recovery.md +0 -246
package/src/commands.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
+ REVIEW_SPECIALISTS,
4
5
  defragCore,
5
6
  extractionCore,
6
7
  hygieneCore,
@@ -24,12 +25,17 @@ import {
24
25
  *
25
26
  * Two command families live here:
26
27
  *
27
- * - Actions (/thatch/defrag, extract, hygiene, reflect): user-invoked
28
- * wrappers around a behavior that the nudges otherwise only run on their
29
- * own schedule. Each action body is a prompt core from src/prompts.ts, so
30
- * nudge and command wording cannot drift.
28
+ * - Actions (/thatch/defrag, extract, hygiene, peer-review, refine,
29
+ * reflect, self-review, whois): user-invoked commands. The nudge-backed
30
+ * ones (defrag, extract, hygiene, reflect) render prompt cores from
31
+ * src/prompts.ts, so nudge and command wording cannot drift. The rest
32
+ * (peer-review, refine, self-review, whois) are self-contained here:
33
+ * they have no nudge counterpart. peer-review and self-review couch the
34
+ * code review pipeline with a mode overlay: the same machinery, with
35
+ * findings weighted for whose work is under review.
31
36
  * - Wrap-ups (/thatch/compact, exit): greenlight-token commands that need
32
- * the opencode plugin's TUI control routes, so they are opencode-only.
37
+ * the opencode plugin's host action routes (the compact trigger and the
38
+ * session close-out), so they are opencode-only.
33
39
  */
34
40
 
35
41
  /** Greenlight token ending /thatch/compact's assistant response when compaction is safe. */
@@ -39,11 +45,13 @@ export const COMPACT_READY_TOKEN = "THATCH_COMPACT_READY";
39
45
  export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
40
46
 
41
47
  /**
42
- * The plugin triggers compaction by running the TUI's compact action. The
43
- * execute-command route only accepts legacy alias names, and "session_compact"
44
- * maps to the same session.compact action the built-in /compact command runs.
45
- * There is no exit alias, so the exit path publishes the TUI keymap command
46
- * ("app.exit") directly via /tui/publish.
48
+ * The greenlight token's host action, per host: v1 drives the TUI
49
+ * (compaction via the "session_compact" legacy alias through
50
+ * executeCommand, the exit via the "app.exit" keymap command through
51
+ * /tui/publish). v2 calls session.compact on the plugin's promise domain
52
+ * and emits the session-tab bridge's TUI-only exit-tab-closed event; the
53
+ * runtime records the exiting session's watcher deaths before either host
54
+ * action runs (src/runtime.ts).
47
55
  */
48
56
 
49
57
  const sharedChecklist = `1. Flush pending persistence. Call thatch_get_extraction_payload; if it returns buffered tool interactions, process them now (write any memories worth keeping), then call thatch_extraction_done to mark them complete. Complete any memory writes you promised earlier but have not made.
@@ -55,10 +63,42 @@ $ARGUMENTS
55
63
 
56
64
  (That section carries the text typed after the command. When it is empty, treat the user message as n/a - the command was run bare.)`;
57
65
 
66
+ /**
67
+ * How the review-mode commands (peer-review, self-review) run the code
68
+ * review machinery on the host at hand. opencode has the
69
+ * thatch-code-review coordinator skill and sub-agent dispatch; Claude Code
70
+ * and Cursor have neither, so the same procedure runs inline through the
71
+ * shared specialist skills. The mode overlay that differentiates the two
72
+ * commands lives in each command's own body and applies in both paths:
73
+ * sub-agent briefings on opencode, specialist passes inline elsewhere.
74
+ *
75
+ * Both couchings below are shared consts interpolated into the two bodies
76
+ * so the commands' procedure and target resolution cannot drift; each body
77
+ * keeps only its mode rules. The specialist roster and count come from
78
+ * REVIEW_SPECIALISTS in src/prompts.ts, the same source as the install
79
+ * list in src/skills.ts.
80
+ */
81
+ const reviewPipelineCouching = `If the thatch-code-review skill is available, load it and follow its steps, with one addition: apply the mode rules below throughout - to context gathering, every specialist briefing, and the synthesis.
82
+
83
+ If that skill is not available on this host, run the same procedure yourself, in order:
84
+ - Resolve the review range. Compute the merge-base against the base branch's remote tracking ref (for example origin/main), not the working branch's own upstream, and cite every file:line against the range's head SHA with git show, never the working tree. A PR or MR number resolves through the host's CLI (gh pr view, glab mr view) plus fetching the head ref (git fetch origin refs/pull/N/head on GitHub, refs/merge-requests/N/head on GitLab, or the source branch). A repository with no remote reviews the working tree's recent commits instead.
85
+ - Build the context brief with the thatch-review-context skill.
86
+ - Partition large diffs into review units.
87
+ - Run the ${REVIEW_SPECIALISTS.length} specialist skills (${REVIEW_SPECIALISTS.join(", ")}) on each unit.
88
+ - Synthesize with thatch-review-synthesizer.
89
+ The mode rules below apply in every specialist pass and in the synthesis, on every host. If the specialist skills are not installed on this host, tell the user to run thatch setup for it.`;
90
+
91
+ /**
92
+ * How the review-mode commands resolve what to review. Shared so the two
93
+ * commands accept the same target syntax and default identically.
94
+ */
95
+ const reviewTargetCouching = `The user message may name the review target: a branch, a PR or MR number, or a git range. On MCP hosts the Focus: line carries that text instead. If no target is given, review the current branch against its base.`;
96
+
58
97
  /**
59
98
  * One on-demand action: the command name (minus the thatch/ prefix), the
60
99
  * description shown in the command menu, and the instruction body (a prompt
61
- * core). opencodeOnly marks actions that need a session identity or other
100
+ * core for the nudge-backed actions, self-contained text for the rest).
101
+ * opencodeOnly marks actions that need a session identity or other
62
102
  * plugin-only capability; those are excluded from Claude Code command files
63
103
  * and MCP prompts.
64
104
  */
@@ -71,8 +111,11 @@ export interface ActionDef {
71
111
 
72
112
  /**
73
113
  * The on-demand action set, shared by every host that renders actions. The
74
- * bodies come from prompt cores so a wording change lands in the nudge and
75
- * the command at once. `tool` renders tool names in the host's spelling.
114
+ * nudge-backed bodies (defrag, extract, hygiene, reflect) come from prompt
115
+ * cores in src/prompts.ts so a wording change lands in the nudge and the
116
+ * command at once; the rest (peer-review, refine, self-review, whois) are
117
+ * self-contained here because they have no nudge counterpart. `tool`
118
+ * renders tool names in the host's spelling.
76
119
  */
77
120
  export function actionDefs(tool: ToolNamer): ActionDef[] {
78
121
  return [
@@ -100,6 +143,25 @@ After dispatching, call thatch_extraction_done to acknowledge.`,
100
143
  description: "Tend the memory store: stale entries, orphans, pending dedup pairs",
101
144
  body: hygieneCore(),
102
145
  },
146
+ {
147
+ name: "peer-review",
148
+ description: "Review another author's change (PR, MR, branch, or git range) through the full review pipeline",
149
+ // Skill-couching action (refine precedent): no nudge counterpart, so
150
+ // the body lives here. The memory-walk tool name is spelled through
151
+ // the namer so MCP renderings carry the mcp__thatch__ form.
152
+ body: `Review a change authored by someone else: a PR, MR, or branch on origin. The review runs the full thatch review pipeline with findings weighted for org velocity.
153
+
154
+ ${reviewTargetCouching}
155
+
156
+ ${reviewPipelineCouching}
157
+
158
+ ## Peer review mode rules
159
+
160
+ - Weight for org velocity. Findings that speed up or unblock the whole team outrank findings about individual lines. The highest-value findings: a simpler method that achieves the same outcome; an existing function or helper that already does what this change reimplements; documentation that is accurate and extremely clearly written; unintended consequences for callers, sibling features, or contracts the author could not see.
161
+ - Walk the project's memory. The author does not have this project's persistent memory; you do. During context gathering, call ${tool("memory_recall")} with several phrasings about the touched area: past incidents, design constraints, rejected approaches, known footguns. Check each recalled item against the change and report the ones that apply. Frame these findings as sharing knowledge the author could not have had, not as catching mistakes.
162
+ - Do not re-litigate settled designs. When the context brief or memory shows a design decision was agreed (in a ticket discussion, a prior review acceptance, recorded docs, or a memory of the agreement), treat the decision as settled. Findings may address how the change implements that decision. Findings that argue the decision itself was wrong are out of scope for this review.
163
+ - Never report findings of the form "if someone changes this in the future, it will silently break." This review does not solve for engineers who change code without reading it. It solves for engineers and LLMs who WILL read the code. For them, verify the change leaves a breadcrumb trail to wherever this organization records how a feature works (a context module, a feature doc, a README). Flag a missing trail. Do not flag hypothetical future misuse.`,
164
+ },
103
165
  {
104
166
  name: "refine",
105
167
  description: "Refine a plan under the project type's requirements (classifies the project first)",
@@ -115,6 +177,40 @@ The user message may name a project type (team-app, personal, shared-lib, spike,
115
177
  description: "Persist what this session learned as memories",
116
178
  body: reflectCore(tool),
117
179
  },
180
+ {
181
+ name: "self-review",
182
+ description: "Review the user's own completed work before submitting it",
183
+ // Skill-couching action (see peer-review). Names no tools, so the
184
+ // MCP spelling test exempt list covers it.
185
+ body: `Review the user's own completed work before submitting it: as a PR or MR on a shared project, or as a direct commit on a personal project. The review runs the full thatch review pipeline with findings weighted to protect the user's reputation as a conscientious developer.
186
+
187
+ The work under review was authored by the user, or by their agent acting in the user's name. Two goals: the work is sound, and it does not read as sloppy. Reviewers and teammates read the comments, docs, and commit messages as the user's own.
188
+
189
+ ${reviewTargetCouching}
190
+
191
+ ${reviewPipelineCouching}
192
+
193
+ ## Self review mode rules
194
+
195
+ - The reputation lenses weigh heaviest. NoSlop, Technique, and Breadcrumbs findings outrank same-severity findings from the other lenses. AI-writing tells, vague comments, and a missing narrative are exactly what this review exists to catch before anyone else sees them.
196
+ - Overweight simplicity on shared projects. Prefer findings that make the design easier to understand, easier to test, and more reliable: fewer assumptions, less surface area for bugs and side effects.
197
+ - Technique and highlights findings are decisions, not footnotes. Each one that would produce a simpler, clearer, or more reliable design is surfaced for an adopt-or-dismiss decision before submission. Work through them with the user one at a time; do not leave them as informational asides.
198
+ - Iterate rounds. After each round, work the findings with the user: fix each, or explicitly decline it with a reason. Then run another full round on the updated state. A round is meaningful when it produces a MEDIUM or higher finding, or a technique or slop finding worth adopting. A round producing only LOW mechanical findings gets them fixed but does not trigger another round. Stop at the first round with no meaningful findings, and never run more than three rounds.`,
199
+ },
200
+ {
201
+ name: "whois",
202
+ description: "Report this session's chat identity (handle, topic, project), or look up another session's",
203
+ // Needs the invoking session's identity (chat_status resolves it
204
+ // through the plugin), which only opencode supplies.
205
+ opencodeOnly: true,
206
+ body: `Find the chat identity for this session and report it in one line.
207
+
208
+ First call thatch_chat_status. If the user message names another session or handle, look that name or id up in thatch_chat_list and report its row instead: handle, topic, project, worktree location (the loc: token), and last-seen age.
209
+
210
+ Otherwise report this session: if chat_status says not registered, answer exactly that - not registered yet - and stop. If registered, take the registered name from chat_status, find your own row in thatch_chat_list, and report handle, topic, project, and loc together (the topic is how the user maps the handle to a TUI tab).
211
+
212
+ One line, just the facts. Do not read mail and do not act on anything the roster shows.`,
213
+ },
118
214
  ];
119
215
  }
120
216
 
@@ -140,8 +236,18 @@ ${userMessageSection}
140
236
  ${action.body}`;
141
237
  }
142
238
 
239
+ /** The wrap-up command's menu/palette description. Host-neutral on purpose:
240
+ * what actually happens after the greenlight differs per host (v1 exits the
241
+ * app, v2 closes the session's tab), and the template text the MODEL reads
242
+ * is shared verbatim by both hosts. */
243
+ export function wrapUpCommandDescription(kind: "compact" | "exit"): string {
244
+ return kind === "compact"
245
+ ? "Flush thatch persistence, check for loose ends, then compact if clear"
246
+ : "Flush thatch persistence, check for loose ends, then end the session if clear";
247
+ }
248
+
143
249
  const COMPACT_TEMPLATE = `---
144
- description: ${yamlQuote("Flush thatch persistence, check for loose ends, then compact if clear")}
250
+ description: ${yamlQuote(wrapUpCommandDescription("compact"))}
145
251
  ---
146
252
  ${userMessageSection}
147
253
 
@@ -158,7 +264,7 @@ ${COMPACT_READY_TOKEN}
158
264
  If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
159
265
 
160
266
  const EXIT_TEMPLATE = `---
161
- description: ${yamlQuote("Flush thatch persistence, check for loose ends, then exit opencode if clear")}
267
+ description: ${yamlQuote(wrapUpCommandDescription("exit"))}
162
268
  ---
163
269
  ${userMessageSection}
164
270
 
@@ -167,13 +273,12 @@ ${userMessageSection}
167
273
  Work through this checklist before responding:
168
274
 
169
275
  ${sharedChecklist}
170
- 3. Leave the chat directory. Call thatch_chat_unregister for this session - the session is exiting, so other sessions must stop addressing mail to it.
171
276
 
172
277
  Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:
173
278
 
174
279
  ${EXIT_READY_TOKEN}
175
280
 
176
- If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. opencode will only exit when the token is present.`;
281
+ If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only close when the token is present.`;
177
282
 
178
283
  /** One command: the command name (minus the thatch/ prefix) and its full markdown file content. */
179
284
  export interface CommandDef {
@@ -207,7 +312,7 @@ export function opencodeCommandDefs(): CommandDef[] {
207
312
  /**
208
313
  * The Claude Code command set: the actions minus the opencode-only ones.
209
314
  * The wrap-up commands are excluded too - their greenlight check needs the
210
- * plugin's command.execute.before arming and TUI control routes.
315
+ * plugin's command.execute.before arming and host action routes.
211
316
  */
212
317
  export function claudeCommandDefs(): CommandDef[] {
213
318
  return actionDefs(mcpToolName)
package/src/db.ts CHANGED
@@ -11,7 +11,7 @@ import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine
11
11
  import type { RepoPathCache, RepoPathRow } from "./git";
12
12
 
13
13
  export { cosineSimilarity } from "./vector-math";
14
- export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
14
+ export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction, PredictionDedupCandidate } from "./prediction";
15
15
  export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
16
16
  export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatWorktreeKind } from "./chat";
17
17
 
@@ -60,9 +60,15 @@ export class ThatchDB {
60
60
  #predictionsStore!: PredictionEngine;
61
61
  #behaviorsStore!: BehaviorEngine;
62
62
  #chatStore!: ChatStore;
63
+ // The open-tab provider for broadcast targeting (roster OPEN-vs-RECENT).
64
+ // Optional: unwired (tests, callers with no tab reader) means no tab
65
+ // signal, so every fresh opencode row degrades to RECENT - honest, never
66
+ // a false OPEN. Production callers wire chatOpenTabs(src/tab-state.ts).
67
+ #chatOpenTabs?: () => ReadonlySet<string>;
63
68
 
64
- constructor(path: string) {
69
+ constructor(path: string, opts: { chatOpenTabs?: () => ReadonlySet<string> } = {}) {
65
70
  this.#path = path;
71
+ this.#chatOpenTabs = opts.chatOpenTabs;
66
72
  this.#open();
67
73
  }
68
74
 
@@ -249,6 +255,22 @@ export class ThatchDB {
249
255
  )
250
256
  `);
251
257
 
258
+ // Pairwise dedup verdicts for predictions (the prediction-side analog of
259
+ // dedup_pairs). findPredictionDuplicates skips pairs recorded here;
260
+ // prediction_mark_checked writes them. The id pair is stored in canonical
261
+ // sorted order so (A,B) and (B,A) are the same row - readers must sort
262
+ // identically.
263
+ this.#db.run(`
264
+ CREATE TABLE IF NOT EXISTS prediction_dedup_pairs (
265
+ store TEXT NOT NULL,
266
+ id_a TEXT NOT NULL,
267
+ id_b TEXT NOT NULL,
268
+ status TEXT NOT NULL,
269
+ checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
270
+ PRIMARY KEY (store, id_a, id_b)
271
+ )
272
+ `);
273
+
252
274
  // Behavior engine: the LLM's codified self-discipline rules. Same
253
275
  // shape as the prediction engine (matchers, behaviors, edges,
254
276
  // provenance) but separate tables because the semantics differ.
@@ -380,6 +402,20 @@ export class ThatchDB {
380
402
  )
381
403
  `);
382
404
 
405
+ // Sessions the plugin itself created as machinery (direct-extraction
406
+ // children). The in-memory child maps that keep these sessions out of
407
+ // the chat directory are lost on plugin reloads and daemon restarts;
408
+ // a marker here is durable, so the auto-registration paths refuse the
409
+ // session even when the maps were lost. Markers age out with the
410
+ // auto-row TTL (session ids are never reused).
411
+ this.#db.run(`
412
+ CREATE TABLE IF NOT EXISTS chat_machinery (
413
+ session_id TEXT PRIMARY KEY,
414
+ reason TEXT NOT NULL,
415
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
416
+ )
417
+ `);
418
+
383
419
  // Hook-parent-process -> session mapping (Claude Code identity anchor):
384
420
  // hooks learn the true session id from the host's hook payload and run
385
421
  // as children of the same process that spawned the MCP server, so the
@@ -907,6 +943,14 @@ export class ThatchDB {
907
943
  return this.#predictions.scorePredictionNudge(stores, embedding, threshold, limit);
908
944
  }
909
945
 
946
+ findPredictionDuplicates(store: string, threshold = 0.70) {
947
+ return this.#predictions.findPredictionDuplicates(store, threshold);
948
+ }
949
+
950
+ markPredictionPairChecked(store: string, idA: string, idB: string, status: string): void {
951
+ this.#predictions.markPairChecked(store, idA, idB, status);
952
+ }
953
+
910
954
  findNearestMatcher(store: string, embedding: Float32Array, threshold: number) {
911
955
  return this.#predictions.findNearestMatcher(store, embedding, threshold);
912
956
  }
@@ -1035,12 +1079,35 @@ export class ThatchDB {
1035
1079
  return this.#chat.clearLeaveTombstone(sessionID);
1036
1080
  }
1037
1081
 
1082
+ markChatMachinery(sessionID: string, reason: string) {
1083
+ return this.#chat.markMachinery(sessionID, reason);
1084
+ }
1085
+
1086
+ isChatMachinery(sessionID: string) {
1087
+ return this.#chat.isMachinery(sessionID);
1088
+ }
1089
+
1038
1090
  chatLeaveSuperseded(sessionID: string, project: string | null) {
1039
1091
  return this.#chat.leaveSuperseded(sessionID, project);
1040
1092
  }
1041
1093
 
1042
1094
  broadcastChatMessage(fromSession: string, body: string) {
1043
- return this.#chat.broadcast(fromSession, body);
1095
+ return this.#chat.broadcast(fromSession, body, this.#chatOpenTabs?.());
1096
+ }
1097
+
1098
+ /**
1099
+ * Confirmed tab closes recorded by the roster (the session_tab tool's
1100
+ * bridge event). Readers subtract the fresh ones from the open-tab set
1101
+ * (effectiveOpenTabs) so a residue tabs.json row cannot read OPEN right
1102
+ * after a confirmed close.
1103
+ */
1104
+ chatTabClosures(): { sessionID: string; at: string }[] {
1105
+ return this.runtimeStateAll()
1106
+ .filter((r) => r.kind === "chat_tab_closed")
1107
+ .flatMap((r) => {
1108
+ const at = (r.value as { at?: string } | null)?.at;
1109
+ return typeof at === "string" ? [{ sessionID: r.sessionID, at }] : [];
1110
+ });
1044
1111
  }
1045
1112
 
1046
1113
  chatMessageStatus(sessionID: string) {
@@ -1120,6 +1187,10 @@ export class ThatchDB {
1120
1187
  return this.#chat.read(sessionID);
1121
1188
  }
1122
1189
 
1190
+ chatMessageById(sessionID: string, id: number) {
1191
+ return this.#chat.messageById(sessionID, id);
1192
+ }
1193
+
1123
1194
  unreadChatCount(sessionID: string) {
1124
1195
  return this.#chat.unreadCount(sessionID);
1125
1196
  }
package/src/git.ts CHANGED
@@ -102,7 +102,9 @@ function canonicalWorktreeKeys(dir: string): string[] {
102
102
  return [...new Set(keys)];
103
103
  }
104
104
 
105
- function pathExists(dir: string): boolean {
105
+ /** Existence check shared by git helpers and tool arg validation (the
106
+ * session-tab tool validates its directory/worktree args with it). */
107
+ export function pathExists(dir: string): boolean {
106
108
  try {
107
109
  statSync(dir);
108
110
  return true;
package/src/hygiene.ts CHANGED
@@ -24,6 +24,11 @@ export async function hygieneReport(
24
24
  parts.push(`${dupes} duplicate-candidate pair${dupes === 1 ? "" : "s"} pending review`);
25
25
  }
26
26
 
27
+ const predDupes = db.findPredictionDuplicates(repo).length;
28
+ if (predDupes > 0) {
29
+ parts.push(`${predDupes} prediction duplicate pair${predDupes === 1 ? "" : "s"} pending review`);
30
+ }
31
+
27
32
  const cutoff = new Date(Date.now() - STALE_DAYS * 86_400_000).toISOString();
28
33
  const stale = db.staleEntryCount(repo, cutoff);
29
34
  if (stale > 0) {
package/src/mcp.ts CHANGED
@@ -6,6 +6,7 @@ import { BgeEmbeddingModel } from "./embeddings";
6
6
  import { detectRepo } from "./git";
7
7
  import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
8
8
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
9
+ import { chatOpenTabs as chatOpenTabsProvider } from "./tab-state";
9
10
  import { actionDefs, type ActionDef } from "./commands";
10
11
  import { mcpToolName } from "./prompts";
11
12
  import { SidebandServer, sidebandSocketPath } from "./sideband";
@@ -70,7 +71,9 @@ export function compileTools(): Map<string, CompiledTool> {
70
71
  for (const def of TOOL_DEFS) {
71
72
  // opencode-only tools depend on host capabilities MCP servers don't
72
73
  // have (e.g. session identity via HostToolContext) - never expose them.
73
- if (def.opencodeOnly) continue;
74
+ // v2-only tools ride v2 plugin surfaces (session move, rpc emit) with
75
+ // no MCP equivalent - same rule.
76
+ if (def.opencodeOnly || def.v2Only) continue;
74
77
  const schema = z.object(def.args);
75
78
  map.set(def.name, {
76
79
  def,
@@ -120,7 +123,12 @@ export function compilePrompts(): Map<string, ActionDef> {
120
123
  * prompt and search for matches without loading the model themselves.
121
124
  */
122
125
  export async function runMcpServer(): Promise<void> {
123
- const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
126
+ // Deliberate superset of detectRepo's own chain (src/git.ts): the pinned
127
+ // THATCH_PROJECT_DIR (written into project-local MCP configs by setup)
128
+ // beats ambient host vars, which beat the spawn cwd - the server's spawn
129
+ // cwd is the one input no in-repo test can pin.
130
+ const projectDir =
131
+ process.env.THATCH_PROJECT_DIR ?? process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
124
132
 
125
133
  const home = process.env.HOME ?? "/tmp";
126
134
  const configHome = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
@@ -129,8 +137,14 @@ export async function runMcpServer(): Promise<void> {
129
137
 
130
138
  // The DB must exist before identity detection: detectRepo consults the
131
139
  // repo_paths cache when the project directory is gone (deleted worktree).
132
- const db = new ThatchDB(dbPath);
133
- const repo = await detectRepo(projectDir, repoPathCache(db));
140
+ const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
141
+ const detected = await detectRepo(projectDir, repoPathCache(db));
142
+ // unknown-to-global, matching the hook's mapping (bin/thatch): chat
143
+ // registration must follow the same store the tools would read, and
144
+ // resolveStore degrades both spellings to the global store anyway. The
145
+ // alignment keeps hook-registered and server-registered rows comparable
146
+ // by project - which the `as` identity binding depends on.
147
+ const repo = detected === "unknown" ? "global" : detected;
134
148
  const model = new BgeEmbeddingModel(modelName);
135
149
 
136
150
  await seedDefaultBehaviors(db, model);
@@ -160,6 +174,9 @@ export async function runMcpServer(): Promise<void> {
160
174
  defaultStore: repo,
161
175
  extractionPayloadProvider,
162
176
  drainExtractionQueue,
177
+ // Same-machine tab reader: the MCP server can read the local opencode
178
+ // state tree, so its roster renders the same open/recent split.
179
+ openTabs: chatOpenTabsProvider(() => db.chatTabClosures()),
163
180
  // Claude Code identity anchor, two model-proof layers:
164
181
  // 1. The hooks record (parent pid -> session) from payloads the model
165
182
  // cannot influence, and this server is a child of the same Claude
@@ -251,6 +268,27 @@ export async function runMcpServer(): Promise<void> {
251
268
  // requests. Never blocks tool calls.
252
269
  startVersionChecker(dbPath);
253
270
 
271
+ // Idempotent teardown, shared by the stdin-end path (the host closed the
272
+ // pipe - the normal shutdown) and the signal handlers (SIGINT/SIGTERM/
273
+ // SIGHUP kill the process mid-loop, and without handlers they skipped
274
+ // every teardown step). Order matters: the sideband goes first (nothing
275
+ // may reach the model after disposal), the model's ONNX sessions release
276
+ // before the db closes, and the db close runs in a finally so a disposal
277
+ // failure cannot leak the connection.
278
+ const teardown = createMcpTeardown({
279
+ sideband,
280
+ model,
281
+ db,
282
+ dbPath,
283
+ stopVersionChecker,
284
+ removeVersionFile,
285
+ });
286
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
287
+ process.on(signal, () => {
288
+ void teardown(signal).then(() => process.exit(0));
289
+ });
290
+ }
291
+
254
292
  // Read stdin line by line. Each line is a complete JSON-RPC message.
255
293
  const decoder = new TextDecoder();
256
294
  let buf = "";
@@ -303,10 +341,64 @@ export async function runMcpServer(): Promise<void> {
303
341
  }
304
342
  }
305
343
 
306
- sideband.stop();
307
- stopVersionChecker();
308
- removeVersionFile(dbPath);
309
- db.close();
344
+ await teardown("stdin-end");
345
+ }
346
+
347
+ /**
348
+ * Builds the idempotent MCP-server teardown. Injected deps so tests can
349
+ * spy on the close order without spawning anything: sideband first
350
+ * (nothing may reach the model after disposal), version checker + file
351
+ * next, the model's native ONNX sessions in a catch (a disposal failure
352
+ * must not skip the db close), and the db in a finally.
353
+ */
354
+ export function createMcpTeardown(deps: {
355
+ sideband: { stop(): void };
356
+ model: { dispose(): Promise<void> };
357
+ db: { close(): void };
358
+ dbPath: string;
359
+ stopVersionChecker: () => void;
360
+ removeVersionFile: (dbPath: string) => void;
361
+ }): (reason: string) => Promise<void> {
362
+ let inFlight: Promise<void> | undefined;
363
+ return (reason: string): Promise<void> => {
364
+ // Idempotent ACROSS awaits: a second call while the first teardown is
365
+ // still running returns the IN-FLIGHT promise, so an early
366
+ // process.exit(0) waits out the model disposal and the db close
367
+ // instead of cutting them (a fresh resolve() skipped them). `torn`
368
+ // tracking is subsumed: inFlight is set synchronously before any await.
369
+ if (inFlight) return inFlight;
370
+ void reason;
371
+ // Every step is isolated: a teardown step's own failure must not
372
+ // block the remaining ones (the sideband throwing on an
373
+ // already-unlinked socket used to skip the model disposal and the
374
+ // db close entirely).
375
+ try {
376
+ deps.sideband.stop();
377
+ } catch (err) {
378
+ console.error(`[thatch] MCP sideband stop failed: ${err}`);
379
+ }
380
+ try {
381
+ deps.stopVersionChecker();
382
+ } catch (err) {
383
+ console.error(`[thatch] MCP version-checker stop failed: ${err}`);
384
+ }
385
+ try {
386
+ deps.removeVersionFile(deps.dbPath);
387
+ } catch (err) {
388
+ console.error(`[thatch] MCP version-file removal failed: ${err}`);
389
+ }
390
+ inFlight = deps.model
391
+ .dispose()
392
+ .catch((err: unknown) => console.error(`[thatch] MCP model disposal failed: ${err}`))
393
+ .finally(() => {
394
+ try {
395
+ deps.db.close();
396
+ } catch (err) {
397
+ console.error(`[thatch] MCP db close failed: ${err}`);
398
+ }
399
+ });
400
+ return inFlight;
401
+ };
310
402
  }
311
403
 
312
404
  /**
@@ -390,7 +482,7 @@ async function dispatch(
390
482
  arguments: [
391
483
  {
392
484
  name: "focus",
393
- description: "Optional scope for the action, e.g. which store to work on",
485
+ description: "Optional text for the action: a scope (for example, which store to work on), a review target (branch, PR, MR, or git range), or other context",
394
486
  required: false,
395
487
  },
396
488
  ],
@@ -0,0 +1,90 @@
1
+ // The session-tab TUI CLI plugin: the TUI-side half of the session-tab
2
+ // feature (docs/dev/features/session-tabs.md). Loaded by the opencode v2 TUI
3
+ // from the package's ./tui entrypoint; runs once per TUI window, next to the
4
+ // tab primitives it drives.
5
+ //
6
+ // The server-side adapter cannot reach the tab strip - the v2 plugin event
7
+ // domain is subscribe-only and the HTTP surface has no publish route - so
8
+ // the flow crosses processes over an rpc event: the adapter emits
9
+ // rpc.thatch-tabs.tab-opened (sessionID + the coordinator instance's
10
+ // directory), the SSE feed fans it to every window, and THIS plugin acts on
11
+ // it with ui.tabs.open. The guard mirrors the built-in tui.* handlers
12
+ // (packages/tui/src/app.tsx): an event only acts on the window whose
13
+ // directory matches the event's location. ui.tabs.open is idempotent and
14
+ // never steals focus, so no dedupe state is kept - a hot reload re-runs
15
+ // setup from fresh state and double events are no-ops.
16
+ //
17
+ // ISOLATION: this module must only be imported by the TUI process - tests
18
+ // import the pure filter/guard logic from src/session-tab-shared.ts, never
19
+ // this file (the tui entrypoint imports solid-js, an uninstalled optional
20
+ // peer).
21
+
22
+ import { Plugin } from "@opencode/plugin/tui";
23
+ import { isExitTabClosedEvent, isTabClosedEvent, isTabOpenedEvent, isToastEvent, passesDirectoryGuard } from "../session-tab-shared";
24
+
25
+ export default Plugin.define({
26
+ id: "jeffober-thatch-tui",
27
+ setup(context) {
28
+ const off = context.data.listen(({ details }) => {
29
+ const windowDirectory = context.location?.directory ?? context.data.location.default().directory;
30
+ if (!passesDirectoryGuard(details.location?.directory, windowDirectory)) return;
31
+ // Two close events, one TUI action: tab-closed (the session_tab_close
32
+ // tool's confirmed close - the pump also runs its death bookkeeping)
33
+ // and exit-tab-closed (the wrap-up exit's close - the runtime records
34
+ // the deaths itself before emitting, so the pump ignores that one).
35
+ if (isTabClosedEvent(details.type) || isExitTabClosedEvent(details.type)) {
36
+ // Close the tab if THIS window has one for the session. Windows that
37
+ // never opened it (and windows at other directories, filtered above)
38
+ // return false - that is the correct no-op, not an error. The close
39
+ // is reopenable by the user (the strip's reopen stack), so this is
40
+ // tab-level, not session deletion.
41
+ const closeID = (details.data as { sessionID?: string }).sessionID;
42
+ if (!closeID) {
43
+ console.error("[thatch] tab-close event without a sessionID");
44
+ return;
45
+ }
46
+ if (!context.ui.tabs.close(closeID)) {
47
+ console.error("[thatch] tab-close requested but no tab matched (or tabs are disabled)");
48
+ }
49
+ return;
50
+ }
51
+ // The toast request: the server-side runtime's only TUI feedback
52
+ // channel (alerts, extraction metrics, chat registration, blocked
53
+ // wrap-ups). Best-effort like every toast - the event is ephemeral,
54
+ // so a TUI that is absent simply never shows it.
55
+ if (isToastEvent(details.type)) {
56
+ const toast = details.data as { message?: string; variant?: string; duration?: number };
57
+ if (typeof toast.message !== "string") {
58
+ console.error("[thatch] toast event without a message");
59
+ return;
60
+ }
61
+ context.ui.toast.show({
62
+ message: toast.message,
63
+ variant: (toast.variant as "info" | "success" | "warning" | "error") ?? "info",
64
+ duration: typeof toast.duration === "number" ? toast.duration : undefined,
65
+ });
66
+ return;
67
+ }
68
+ if (!isTabOpenedEvent(details.type)) return;
69
+ const sessionID = (details.data as { sessionID?: string }).sessionID;
70
+ if (!sessionID) {
71
+ console.error("[thatch] tab-opened event without a sessionID");
72
+ return;
73
+ }
74
+ // Sync the session into the TUI's cache before opening: the strip
75
+ // renders the session's real title instead of the new-session
76
+ // fallback.
77
+ void context.data.session
78
+ .sync(sessionID)
79
+ .catch(() => {})
80
+ .then(() => {
81
+ if (!context.ui.tabs.open(sessionID)) {
82
+ console.error("[thatch] tab-open requested but session tabs are disabled in this window");
83
+ }
84
+ });
85
+ });
86
+ // The unsubscribe IS the cleanup: without it every plugin hot reload
87
+ // leaks another listener.
88
+ return off;
89
+ },
90
+ });