@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.
- package/README.md +48 -25
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +16 -8
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- 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-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +21 -1
- package/bin/thatch +21 -12
- package/docs/dev/README.md +11 -8
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +2 -1
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +85 -21
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +41 -15
- package/docs/dev/features/prediction-engine.md +27 -1
- 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 +11 -5
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +3 -3
- package/docs/dev/skills.md +11 -8
- package/docs/user/README.md +19 -12
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +2 -1
- package/docs/user/code-review.md +43 -2
- package/docs/user/commands.md +27 -7
- package/docs/user/cross-session-chat.md +47 -20
- package/docs/user/prediction-engine.md +12 -1
- package/docs/user/skills.md +8 -6
- package/package.json +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +39 -17
- package/src/commands.ts +109 -18
- package/src/db.ts +23 -2
- package/src/mcp.ts +6 -2
- package/src/opencode/tui-plugin.ts +29 -9
- package/src/opencode/v2.ts +247 -66
- package/src/prompts.ts +54 -6
- package/src/runtime.ts +122 -17
- package/src/scoring-engine.ts +40 -9
- package/src/session-tab-shared.ts +55 -4
- package/src/skills.ts +5 -9
- package/src/tab-state.ts +125 -0
- 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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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)
|
|
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
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
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}`);
|
package/src/scoring-engine.ts
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
353
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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",
|
package/src/tab-state.ts
ADDED
|
@@ -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
|
+
}
|