@jeffober/thatch 0.1.42 → 0.1.44
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 +11 -2
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/thatch +281 -47
- package/docs/dev/README.md +9 -1
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +18 -11
- package/docs/dev/features/cross-session-chat.md +124 -40
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/dev/skills.md +1 -0
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +19 -2
- package/docs/user/cli.md +35 -37
- package/docs/user/cross-session-chat.md +92 -19
- package/docs/user/skills.md +1 -0
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +376 -147
- package/src/commands.ts +107 -0
- package/src/db.ts +54 -5
- package/src/debug.ts +44 -0
- package/src/git.ts +22 -0
- package/src/index.ts +264 -40
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +30 -2
- package/src/setup.ts +203 -33
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +107 -27
- package/src/watchers.ts +51 -29
package/src/commands.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thatch's wrap-up slash commands for opencode, shipped as command markdown
|
|
6
|
+
* files synced into the global opencode config dir (~/.config/opencode/command/
|
|
7
|
+
* thatch/). The plugin writes them on every load so template updates self-heal
|
|
8
|
+
* without a reinstall. Config is loaded before plugins during server startup,
|
|
9
|
+
* so a first-ever install becomes visible on the next start.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Greenlight token ending /thatch/compact's assistant response when compaction is safe. */
|
|
13
|
+
export const COMPACT_READY_TOKEN = "THATCH_COMPACT_READY";
|
|
14
|
+
|
|
15
|
+
/** Greenlight token ending /thatch/exit's assistant response when exiting is safe. */
|
|
16
|
+
export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The plugin triggers compaction by running the TUI's compact action. The
|
|
20
|
+
* execute-command route only accepts legacy alias names, and "session_compact"
|
|
21
|
+
* maps to the same session.compact action the built-in /compact command runs.
|
|
22
|
+
* There is no exit alias, so the exit path publishes the TUI keymap command
|
|
23
|
+
* ("app.exit") directly via /tui/publish.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
2. Check for loose ends. Review the conversation for todos, follow-ups, or open questions raised earlier that were never addressed, and surface anything the user should address before the wrap-up completes.`;
|
|
28
|
+
|
|
29
|
+
const userMessageSection = `# User Message
|
|
30
|
+
|
|
31
|
+
$ARGUMENTS
|
|
32
|
+
|
|
33
|
+
(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.)`;
|
|
34
|
+
|
|
35
|
+
const COMPACT_TEMPLATE = `---
|
|
36
|
+
description: Flush thatch persistence, check for loose ends, then compact if clear
|
|
37
|
+
---
|
|
38
|
+
${userMessageSection}
|
|
39
|
+
|
|
40
|
+
# Pre-compact wrap-up
|
|
41
|
+
|
|
42
|
+
Work through this checklist before responding:
|
|
43
|
+
|
|
44
|
+
${sharedChecklist}
|
|
45
|
+
|
|
46
|
+
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:
|
|
47
|
+
|
|
48
|
+
${COMPACT_READY_TOKEN}
|
|
49
|
+
|
|
50
|
+
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.`;
|
|
51
|
+
|
|
52
|
+
const EXIT_TEMPLATE = `---
|
|
53
|
+
description: Flush thatch persistence, check for loose ends, then exit opencode if clear
|
|
54
|
+
---
|
|
55
|
+
${userMessageSection}
|
|
56
|
+
|
|
57
|
+
# Pre-exit wrap-up
|
|
58
|
+
|
|
59
|
+
Work through this checklist before responding:
|
|
60
|
+
|
|
61
|
+
${sharedChecklist}
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
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:
|
|
65
|
+
|
|
66
|
+
${EXIT_READY_TOKEN}
|
|
67
|
+
|
|
68
|
+
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.`;
|
|
69
|
+
|
|
70
|
+
/** One wrap-up command: the command name (minus the thatch/ prefix) and its full markdown file content. */
|
|
71
|
+
export interface CommandDef {
|
|
72
|
+
name: string;
|
|
73
|
+
content: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The wrap-up command set, as full command-file contents ready to write. */
|
|
77
|
+
export function opencodeCommandDefs(): CommandDef[] {
|
|
78
|
+
return [
|
|
79
|
+
{ name: "compact", content: COMPACT_TEMPLATE },
|
|
80
|
+
{ name: "exit", content: EXIT_TEMPLATE },
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sync the wrap-up commands into <configHome>/opencode/command/thatch/.
|
|
86
|
+
* Idempotent: each file is written only when its on-disk content differs.
|
|
87
|
+
* Returns the paths that were written (empty when everything was current).
|
|
88
|
+
*/
|
|
89
|
+
export function installOpencodeCommands(configHome: string): string[] {
|
|
90
|
+
const dir = join(configHome, "opencode", "command", "thatch");
|
|
91
|
+
const written: string[] = [];
|
|
92
|
+
for (const def of opencodeCommandDefs()) {
|
|
93
|
+
const path = join(dir, `${def.name}.md`);
|
|
94
|
+
let current: string | null = null;
|
|
95
|
+
try {
|
|
96
|
+
current = readFileSync(path, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
current = null;
|
|
99
|
+
}
|
|
100
|
+
if (current !== def.content) {
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
writeFileSync(path, def.content);
|
|
103
|
+
written.push(path);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return written;
|
|
107
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -4,13 +4,13 @@ import {
|
|
|
4
4
|
PredictionEngine,
|
|
5
5
|
} from "./prediction";
|
|
6
6
|
import { BehaviorEngine } from "./behavior";
|
|
7
|
-
import { ChatStore, type ChatHostKind } from "./chat";
|
|
7
|
+
import { ChatStore, type ChatHostKind, type ChatWorktreeKind } from "./chat";
|
|
8
8
|
import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
|
|
9
9
|
|
|
10
10
|
export { cosineSimilarity } from "./vector-math";
|
|
11
11
|
export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
|
|
12
12
|
export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
|
|
13
|
-
export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow } from "./chat";
|
|
13
|
+
export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatWorktreeKind } from "./chat";
|
|
14
14
|
|
|
15
15
|
export interface MemoryRow {
|
|
16
16
|
slug: string;
|
|
@@ -239,7 +239,8 @@ export class ThatchDB {
|
|
|
239
239
|
host_kind TEXT NOT NULL DEFAULT ('opencode'),
|
|
240
240
|
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
241
241
|
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
242
|
-
auto INTEGER NOT NULL DEFAULT 0
|
|
242
|
+
auto INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
worktree TEXT NOT NULL DEFAULT ('')
|
|
243
244
|
)
|
|
244
245
|
`);
|
|
245
246
|
|
|
@@ -283,12 +284,28 @@ export class ThatchDB {
|
|
|
283
284
|
)
|
|
284
285
|
`);
|
|
285
286
|
|
|
287
|
+
// Hook-parent-process -> session mapping (Claude Code identity anchor):
|
|
288
|
+
// hooks learn the true session id from the host's hook payload and run
|
|
289
|
+
// as children of the same process that spawned the MCP server, so the
|
|
290
|
+
// server resolves its own parent pid against this table - an identity
|
|
291
|
+
// the model cannot claim. Rows are timestamped; stale rows are ignored
|
|
292
|
+
// (pid reuse) rather than deleted.
|
|
293
|
+
this.#db.run(`
|
|
294
|
+
CREATE TABLE IF NOT EXISTS chat_host_pids (
|
|
295
|
+
ppid INTEGER PRIMARY KEY,
|
|
296
|
+
session_id TEXT NOT NULL,
|
|
297
|
+
seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
298
|
+
)
|
|
299
|
+
`);
|
|
300
|
+
|
|
286
301
|
this.#migrateColumns();
|
|
287
302
|
this.#migrateChatNameCollation();
|
|
288
303
|
this.#migrateChatTopic();
|
|
289
304
|
this.#migrateChatHostKind();
|
|
290
305
|
this.#migrateChatBroadcastFlag();
|
|
291
306
|
this.#migrateChatAuto();
|
|
307
|
+
this.#migrateChatWorktree();
|
|
308
|
+
this.#migrateChatHostPid();
|
|
292
309
|
}
|
|
293
310
|
|
|
294
311
|
// chat_sessions tables created before the auto column lack it; the ALTER
|
|
@@ -310,6 +327,26 @@ export class ThatchDB {
|
|
|
310
327
|
}
|
|
311
328
|
}
|
|
312
329
|
|
|
330
|
+
// chat_sessions tables created before the worktree column lack it; the
|
|
331
|
+
// ALTER adds it, and existing rows read as undetected (rendered as no
|
|
332
|
+
// loc token in chat_list) - they registered before the capture existed.
|
|
333
|
+
#migrateChatWorktree(): void {
|
|
334
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
335
|
+
if (cols.length > 0 && !cols.includes("worktree")) {
|
|
336
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN worktree TEXT NOT NULL DEFAULT ''");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The released schema has no host_pid column (liveness is heartbeat age;
|
|
341
|
+
// see chatLiveness). A development database may carry one; drop it so
|
|
342
|
+
// every database matches a fresh install.
|
|
343
|
+
#migrateChatHostPid(): void {
|
|
344
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
345
|
+
if (cols.includes("host_pid")) {
|
|
346
|
+
this.#db.run("ALTER TABLE chat_sessions DROP COLUMN host_pid");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
313
350
|
// chat_messages tables created before the via_broadcast column lack it;
|
|
314
351
|
// the ALTER adds it, and existing rows read as direct sends (which they
|
|
315
352
|
// were).
|
|
@@ -856,8 +893,8 @@ export class ThatchDB {
|
|
|
856
893
|
// Cross-session chat: delegates to ChatStore
|
|
857
894
|
// ---------------------------------------------------------------------------
|
|
858
895
|
|
|
859
|
-
registerChatSession(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind, nameBase: string | null = null) {
|
|
860
|
-
return this.#chat.register(sessionID, project, topic, kind, nameBase);
|
|
896
|
+
registerChatSession(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind, nameBase: string | null = null, worktree: ChatWorktreeKind = null) {
|
|
897
|
+
return this.#chat.register(sessionID, project, topic, kind, nameBase, worktree);
|
|
861
898
|
}
|
|
862
899
|
|
|
863
900
|
refreshChatTopic(sessionID: string, title: string) {
|
|
@@ -923,6 +960,18 @@ export class ThatchDB {
|
|
|
923
960
|
return this.#chat.unreadCount(sessionID);
|
|
924
961
|
}
|
|
925
962
|
|
|
963
|
+
recordChatHostPid(ppid: number, sessionID: string) {
|
|
964
|
+
return this.#chat.recordHostPid(ppid, sessionID);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
findChatSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
|
|
968
|
+
return this.#chat.findSessionByHostPid(ppid, maxAgeSeconds);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
continueChatSession(oldSessionID: string, newSessionID: string) {
|
|
972
|
+
return this.#chat.continueSession(oldSessionID, newSessionID);
|
|
973
|
+
}
|
|
974
|
+
|
|
926
975
|
heartbeatChatSessions(sessionIDs: string[]) {
|
|
927
976
|
return this.#chat.heartbeat(sessionIDs);
|
|
928
977
|
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Opt-in diagnostic logging for questions a live harness cannot otherwise
|
|
5
|
+
// answer ("what did the plugin actually see at init?"). Lines go to
|
|
6
|
+
// debug.log beside the database, never to the host's stdout or stderr:
|
|
7
|
+
// the plugin runs inside opencode's TUI process, where console output is
|
|
8
|
+
// invisible or garbles the screen, and test sandboxes point the database at
|
|
9
|
+
// a tmp dir so their debug output never reaches the quality gate.
|
|
10
|
+
//
|
|
11
|
+
// Off unless THATCH_DEBUG is set. THATCH_DEBUG=1 (or "all" / "*") logs every
|
|
12
|
+
// tag. Otherwise it is a comma-separated list of tag filters: a filter
|
|
13
|
+
// matches a tag when it equals the whole tag ("chat:startup") or the
|
|
14
|
+
// feature part before the colon ("chat" matches every chat:* tag).
|
|
15
|
+
//
|
|
16
|
+
// Tags are `feature:aspect`, rendered in square brackets, so a grep on
|
|
17
|
+
// "[chat:" pulls one feature's story out of a shared file.
|
|
18
|
+
|
|
19
|
+
export type DebugLog = (tag: string, msg: string) => void;
|
|
20
|
+
|
|
21
|
+
// Decide, from a THATCH_DEBUG value, whether a tag is enabled. Exported for
|
|
22
|
+
// tests; callers use createDebugLog.
|
|
23
|
+
export function debugEnabled(spec: string | undefined, tag: string): boolean {
|
|
24
|
+
if (!spec) return false;
|
|
25
|
+
const s = spec.trim();
|
|
26
|
+
if (s === "1" || s === "all" || s === "*") return true;
|
|
27
|
+
const feature = tag.split(":")[0];
|
|
28
|
+
return s.split(",").map((f) => f.trim()).filter(Boolean).some((f) => f === tag || f === feature);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build the logger for a database location. Every line: ISO timestamp,
|
|
32
|
+
// bracketed tag, message. Writes never throw: diagnostics must not break
|
|
33
|
+
// the host, so a missing directory or a full disk simply drops the line.
|
|
34
|
+
export function createDebugLog(dbPath: string, spec: string | undefined = process.env.THATCH_DEBUG): DebugLog {
|
|
35
|
+
const file = join(dirname(dbPath), "debug.log");
|
|
36
|
+
return (tag, msg) => {
|
|
37
|
+
if (!debugEnabled(spec, tag)) return;
|
|
38
|
+
try {
|
|
39
|
+
appendFileSync(file, `${new Date().toISOString()} [${tag}] ${msg}\n`);
|
|
40
|
+
} catch {
|
|
41
|
+
// Intentionally silent; see above.
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Classifies the checkout the given directory serves: "worktree" for a
|
|
7
|
+
* linked git worktree (its .git is a FILE pointing at the main repo's
|
|
8
|
+
* gitdir), "root" for the project's main checkout (.git is a DIRECTORY),
|
|
9
|
+
* and null when undetectable (no .git at all, or no directory given).
|
|
10
|
+
* Synchronous and cheap: callers capture this once at chat registration,
|
|
11
|
+
* so the roster can show which sessions share the main tree.
|
|
12
|
+
*/
|
|
13
|
+
export function detectWorktreeKind(dir?: string | null): "root" | "worktree" | null {
|
|
14
|
+
if (!dir) return null;
|
|
15
|
+
try {
|
|
16
|
+
const st = statSync(join(dir, ".git"));
|
|
17
|
+
if (st.isFile()) return "worktree";
|
|
18
|
+
if (st.isDirectory()) return "root";
|
|
19
|
+
return null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
2
24
|
|
|
3
25
|
/**
|
|
4
26
|
* Extracts an `owner/repo` slug from a git remote URL. Handles the common
|