@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.
- package/README.md +56 -26
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +16 -8
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +21 -1
- package/bin/thatch +48 -15
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +18 -10
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +4 -1
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +194 -23
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +56 -15
- package/docs/dev/features/prediction-engine.md +71 -6
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +161 -0
- package/docs/dev/features/setup.md +5 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +89 -0
- package/docs/dev/mcp-parity.md +5 -5
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +12 -9
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +29 -13
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +5 -1
- package/docs/user/code-review.md +43 -2
- package/docs/user/commands.md +37 -10
- package/docs/user/cross-session-chat.md +76 -26
- package/docs/user/prediction-engine.md +23 -1
- package/docs/user/setup.md +16 -0
- package/docs/user/skills.md +9 -7
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +358 -43
- package/src/commands.ts +123 -18
- package/src/db.ts +74 -3
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +101 -9
- package/src/opencode/tui-plugin.ts +90 -0
- package/src/opencode/v2.ts +440 -66
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +164 -29
- package/src/runtime.ts +298 -15
- package/src/scoring-engine.ts +40 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +231 -0
- package/src/setup.ts +9 -3
- package/src/skills.ts +6 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +454 -76
- package/src/tools.ts +2 -0
- package/src/watchers.ts +263 -26
- package/docs/plans/intuition-drives.md +0 -147
- package/docs/plans/llm-alerts.md +0 -287
- package/docs/plans/thatch-actions.md +0 -179
- 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,
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
|
75
|
-
*
|
|
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("
|
|
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("
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
+
});
|