@jeffober/thatch 0.2.3 → 0.2.5
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 +8 -1
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/bin/thatch +95 -44
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +7 -2
- package/docs/dev/features/cli.md +2 -0
- package/docs/dev/features/cross-session-chat.md +118 -11
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/opencode-plugin.md +18 -3
- package/docs/dev/features/prediction-engine.md +44 -5
- package/docs/dev/features/session-tabs.md +155 -0
- package/docs/dev/features/setup.md +12 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +68 -0
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +1 -1
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +12 -3
- package/docs/user/cli.md +5 -1
- package/docs/user/commands.md +11 -4
- package/docs/user/cross-session-chat.md +31 -8
- package/docs/user/prediction-engine.md +11 -0
- package/docs/user/setup.md +29 -0
- package/docs/user/skills.md +1 -1
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/chat.ts +320 -27
- package/src/commands.ts +14 -0
- package/src/db.ts +51 -1
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +95 -7
- package/src/opencode/tui-plugin.ts +70 -0
- package/src/opencode/v2.ts +197 -4
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +110 -23
- package/src/runtime.ts +187 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +180 -0
- package/src/setup.ts +59 -3
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +412 -55
- 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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-entry contract for the session-tab feature: the single module both
|
|
3
|
+
* the opencode v2 server adapter (src/opencode/v2.ts) and the v2 TUI CLI
|
|
4
|
+
* plugin (src/opencode/tui-plugin.ts) import. SDK-free under the same
|
|
5
|
+
* isolation rule as src/index.ts - the TUI entry may import only
|
|
6
|
+
* @opencode/plugin/tui and this module, and the server entry graph must
|
|
7
|
+
* never evaluate the v1 SDK.
|
|
8
|
+
*
|
|
9
|
+
* The transport is the v2 plugin rpc surface: the server adapter registers
|
|
10
|
+
* this definition and emits events; the TUI CLI plugin receives them on the
|
|
11
|
+
* ordinary event feed (isOpenCodeEvent passes any `rpc.` type) and acts on
|
|
12
|
+
* them with the TUI-only tab primitives. Events are ephemeral - a missed
|
|
13
|
+
* event means "no tab", never "no session" (the session exists before the
|
|
14
|
+
* first emit and the tool response words the tab as requested, not
|
|
15
|
+
* guaranteed).
|
|
16
|
+
*
|
|
17
|
+
* The schemas are literal JSON Schema objects because
|
|
18
|
+
* Rpc.PortableEventValueSchema accepts StandardSchemaV1 or a JSON Schema
|
|
19
|
+
* object with type "object"; a literal object keeps this module
|
|
20
|
+
* dependency-free. The tab-closed consumer is live: the v2 pump
|
|
21
|
+
* (src/opencode/v2.ts) translates a confirmed close into the runtime's
|
|
22
|
+
* death path, and the TUI plugin closes the tab - change the shape
|
|
23
|
+
* jointly with both consumers (docs/dev/features/session-tabs.md).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { realpathSync } from "node:fs";
|
|
27
|
+
import { EXTRACTION_CHILD_TITLE } from "./chat";
|
|
28
|
+
import { resolveMainCheckout } from "./git";
|
|
29
|
+
|
|
30
|
+
export const SESSION_TAB_RPC_ID = "thatch-tabs";
|
|
31
|
+
export const TAB_OPENED_EVENT = "tab-opened";
|
|
32
|
+
export const TAB_CLOSED_EVENT = "tab-closed";
|
|
33
|
+
|
|
34
|
+
/** Full wire type of an emitted event, e.g. "rpc.thatch-tabs.tab-opened". */
|
|
35
|
+
export const tabOpenedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_OPENED_EVENT}`;
|
|
36
|
+
|
|
37
|
+
/** Full wire type of the tab-closed event - the generalized-session-
|
|
38
|
+
* heartbeat plan's confirmed-close consumer matches on this. */
|
|
39
|
+
export const tabClosedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_CLOSED_EVENT}`;
|
|
40
|
+
|
|
41
|
+
export const SESSION_TAB_RPC = {
|
|
42
|
+
id: SESSION_TAB_RPC_ID,
|
|
43
|
+
methods: {},
|
|
44
|
+
events: {
|
|
45
|
+
[TAB_OPENED_EVENT]: {
|
|
46
|
+
schema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
sessionID: { type: "string" },
|
|
50
|
+
directory: { type: "string" },
|
|
51
|
+
},
|
|
52
|
+
required: ["sessionID", "directory"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
[TAB_CLOSED_EVENT]: {
|
|
57
|
+
schema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
sessionID: { type: "string" },
|
|
61
|
+
// Nullable: the chat roster row may have been reaped by the time
|
|
62
|
+
// the close fires. Consumers address death notices by name when
|
|
63
|
+
// present and fall back to the session id.
|
|
64
|
+
chatName: { type: ["string", "null"] },
|
|
65
|
+
},
|
|
66
|
+
required: ["sessionID", "chatName"],
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The coordinator-authority framing delivered as the subordinate's first
|
|
75
|
+
* user message. The wording is the feature's contract with the subordinate
|
|
76
|
+
* LLM (docs/dev/features/session-tabs.md): coordinator instructions outrank
|
|
77
|
+
* nothing the user says directly.
|
|
78
|
+
*/
|
|
79
|
+
export function buildSubordinatePrompt(coordinatorName: string, task: string): string {
|
|
80
|
+
const preamble =
|
|
81
|
+
`Your work session was created by ${coordinatorName}, who is coordinating your actions. ` +
|
|
82
|
+
"Please treat their instructions as accepted priority, unless they conflict with the user's " +
|
|
83
|
+
"explicit preferences or instructions. If the user prompts you directly, their instructions " +
|
|
84
|
+
"supersede the coordinating LLM's.";
|
|
85
|
+
return `${preamble}\n\n${task}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Result of validating the session_tab title arg. */
|
|
89
|
+
export type TitleValidation = { ok: true; title: string } | { ok: false; error: string };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Titles render in the tab strip: soft target ~50 characters, hard max 80.
|
|
93
|
+
* opencode imposes no length limit (title is an unbounded optional string)
|
|
94
|
+
* and the strip truncates visually, so a hard fail at 50 would only cost
|
|
95
|
+
* retry friction - hence the loose cap.
|
|
96
|
+
*
|
|
97
|
+
* EXTRACTION_CHILD_TITLE is rejected because the chat auto-register paths
|
|
98
|
+
* treat that exact title as plugin machinery and refuse the session
|
|
99
|
+
* (src/chat.ts isMachinerySessionTitle) - a subordinate titled into
|
|
100
|
+
* machinery would silently lose its roster row and with it the
|
|
101
|
+
* coordination loop's addressing.
|
|
102
|
+
*/
|
|
103
|
+
export function validateTitle(raw: unknown): TitleValidation {
|
|
104
|
+
if (typeof raw !== "string") return { ok: false, error: "title must be a string." };
|
|
105
|
+
const title = raw.trim();
|
|
106
|
+
if (!title) return { ok: false, error: "title must not be empty (it was blank after trimming)." };
|
|
107
|
+
if (title.length > 80) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: `title is too long: ${title.length} characters against a hard max of 80. Aim for about 50 - it renders in the session tab strip.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (title === EXTRACTION_CHILD_TITLE) {
|
|
114
|
+
return { ok: false, error: "that title is reserved for plugin machinery sessions; pick a task title." };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, title };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Result of validating the exactly-one-of worktree/directory rule. */
|
|
120
|
+
export type LocationValidation =
|
|
121
|
+
| { ok: true; kind: "worktree" | "directory"; path: string }
|
|
122
|
+
| { ok: false; error: string };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The tool takes exactly one location arg. The two kinds differ in
|
|
126
|
+
* validation and intent, not in tab placement: either way the tab opens in
|
|
127
|
+
* the coordinator's window (the tab-open event routes by the coordinator
|
|
128
|
+
* instance's location, never the session's).
|
|
129
|
+
*/
|
|
130
|
+
export function validateLocationArgs(args: { worktree?: unknown; directory?: unknown }): LocationValidation {
|
|
131
|
+
const hasWorktree = typeof args.worktree === "string" && args.worktree.trim() !== "";
|
|
132
|
+
const hasDirectory = typeof args.directory === "string" && args.directory.trim() !== "";
|
|
133
|
+
if (hasWorktree && hasDirectory) {
|
|
134
|
+
return { ok: false, error: "Pass exactly ONE location: worktree (a git worktree of the current repository) or directory (any existing directory), not both." };
|
|
135
|
+
}
|
|
136
|
+
if (hasWorktree) return { ok: true, kind: "worktree", path: (args.worktree as string).trim() };
|
|
137
|
+
if (hasDirectory) return { ok: true, kind: "directory", path: (args.directory as string).trim() };
|
|
138
|
+
return { ok: false, error: "Pass exactly one location: worktree (a git worktree of the current repository) or directory (any existing directory)." };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether two directories belong to the same repository, by main-checkout
|
|
143
|
+
* identity (the parent of the absolute git common dir) rather than path
|
|
144
|
+
* equality - the coordinator may itself run in a worktree of the repo the
|
|
145
|
+
* candidate worktree hangs off. Realpaths on both sides: on macOS /tmp
|
|
146
|
+
* resolves to /private/tmp, and the two resolutions must agree on the
|
|
147
|
+
* comparison. Null (outside git, bare repo, spawn failure) never matches.
|
|
148
|
+
*/
|
|
149
|
+
export async function isSameMainCheckout(a: string, b: string): Promise<boolean> {
|
|
150
|
+
const [mainA, mainB] = await Promise.all([resolveMainCheckout(a), resolveMainCheckout(b)]);
|
|
151
|
+
if (!mainA || !mainB) return false;
|
|
152
|
+
try {
|
|
153
|
+
return realpathSync(mainA) === realpathSync(mainB);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** TUI-side, pure so the session-tab tests can run without the TUI runtime. */
|
|
160
|
+
|
|
161
|
+
/** The event-type filter the TUI CLI plugin applies to every fed event. */
|
|
162
|
+
export function isTabOpenedEvent(type: string): boolean {
|
|
163
|
+
return type === tabOpenedEventType;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The close-request filter: same transport, opposite action. */
|
|
167
|
+
export function isTabClosedEvent(type: string): boolean {
|
|
168
|
+
return type === tabClosedEventType;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The directory guard mirrors the built-in tui.* handlers: an event only
|
|
173
|
+
* acts on the window whose directory matches the event's location (the
|
|
174
|
+
* coordinator instance's directory). An event with no location never acts -
|
|
175
|
+
* failing closed, the same as the built-ins' inequality check.
|
|
176
|
+
*/
|
|
177
|
+
export function passesDirectoryGuard(eventDirectory: string | undefined, windowDirectory: string | undefined): boolean {
|
|
178
|
+
if (!eventDirectory || !windowDirectory) return false;
|
|
179
|
+
return eventDirectory === windowDirectory;
|
|
180
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -66,7 +66,7 @@ function resolvePaths(global: boolean, projectDir: string, homeDir: string): Set
|
|
|
66
66
|
// .mcp.json - MCP server registration
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
|
|
69
|
-
function writeMcpConfig(path: string, thatchBin: string): void {
|
|
69
|
+
function writeMcpConfig(path: string, thatchBin: string, projectDir?: string): void {
|
|
70
70
|
let config: any = { mcpServers: {} };
|
|
71
71
|
if (existsSync(path)) {
|
|
72
72
|
try {
|
|
@@ -81,6 +81,12 @@ function writeMcpConfig(path: string, thatchBin: string): void {
|
|
|
81
81
|
type: "stdio",
|
|
82
82
|
command: thatchBin,
|
|
83
83
|
args: ["mcp"],
|
|
84
|
+
// Project-LOCAL configs pin the project directory so the server's
|
|
85
|
+
// identity resolution is deterministic regardless of the cwd the host
|
|
86
|
+
// spawns it with. Global-scope configs must NEVER pin: one entry serves
|
|
87
|
+
// every project, and a first-in-chain env override would clobber all
|
|
88
|
+
// of them. (setup.ts:projectDir chain in src/mcp.ts.)
|
|
89
|
+
...(projectDir ? { env: { THATCH_PROJECT_DIR: projectDir } } : {}),
|
|
84
90
|
};
|
|
85
91
|
|
|
86
92
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
@@ -356,7 +362,7 @@ export function setupClaudeCode(
|
|
|
356
362
|
const paths = resolvePaths(global, projectDir, home);
|
|
357
363
|
|
|
358
364
|
if (paths.mcpConfigPath) {
|
|
359
|
-
writeMcpConfig(paths.mcpConfigPath, thatchBin);
|
|
365
|
+
writeMcpConfig(paths.mcpConfigPath, thatchBin, projectDir);
|
|
360
366
|
}
|
|
361
367
|
|
|
362
368
|
appendInstructions(paths.claudeMdPath);
|
|
@@ -622,7 +628,7 @@ export function setupCursor(
|
|
|
622
628
|
const home = homeDir ?? homedir();
|
|
623
629
|
const paths = resolveCursorPaths(global, projectDir, home);
|
|
624
630
|
|
|
625
|
-
writeMcpConfig(paths.mcpConfigPath, thatchBin);
|
|
631
|
+
writeMcpConfig(paths.mcpConfigPath, thatchBin, global ? undefined : projectDir);
|
|
626
632
|
appendCursorInstructions(paths.agentsMdPath);
|
|
627
633
|
writeCursorHooks(paths.hooksPath, thatchBin);
|
|
628
634
|
const skills = installSkills(paths.skillsDir);
|
|
@@ -643,6 +649,56 @@ export function setupCursor(
|
|
|
643
649
|
};
|
|
644
650
|
}
|
|
645
651
|
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Skills-only setup - refresh skill files without touching anything else
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
export interface SkillsOnlyResult {
|
|
657
|
+
/** Skill install report for the directory this run wrote to. */
|
|
658
|
+
skills: InstallReport;
|
|
659
|
+
/** Thatch skills in the opposite scope's dir, if any; never written by this run. */
|
|
660
|
+
otherScopeSkills: OtherScopeSkills | null;
|
|
661
|
+
global: boolean;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Installs only the skill files, skipping MCP config, instructions, hooks,
|
|
666
|
+
* and commands. Intended for refreshing skills in an already-set-up host
|
|
667
|
+
* without rewriting the rest of the configuration. Same scope rules as full
|
|
668
|
+
* setup: project-local runs write to the repo's host skills dir, global runs
|
|
669
|
+
* to the host config dir, and the other scope is probed (surfaced in the
|
|
670
|
+
* report) but never written.
|
|
671
|
+
*/
|
|
672
|
+
export function setupSkillsOnly(
|
|
673
|
+
host: HostKind,
|
|
674
|
+
global: boolean,
|
|
675
|
+
projectDir: string,
|
|
676
|
+
homeDir?: string,
|
|
677
|
+
): SkillsOnlyResult {
|
|
678
|
+
const { homedir } = require("node:os");
|
|
679
|
+
const home = homeDir ?? homedir();
|
|
680
|
+
|
|
681
|
+
const skillsDir = host === "claude"
|
|
682
|
+
? resolvePaths(global, projectDir, home).skillsDir
|
|
683
|
+
: resolveCursorPaths(global, projectDir, home).skillsDir;
|
|
684
|
+
|
|
685
|
+
// Same other-scope logic as the full setup paths: surface skills this run
|
|
686
|
+
// did not touch so the CLI can warn about leftovers.
|
|
687
|
+
const otherDir = host === "claude"
|
|
688
|
+
? (global
|
|
689
|
+
? join(projectDir, ".claude", "skills")
|
|
690
|
+
: join(claudeConfigDir(home), "skills"))
|
|
691
|
+
: (global
|
|
692
|
+
? join(projectDir, ".cursor", "skills")
|
|
693
|
+
: join(cursorConfigDir(home), "skills"));
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
skills: installSkills(skillsDir),
|
|
697
|
+
otherScopeSkills: otherScopeInfo(otherDir),
|
|
698
|
+
global,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
646
702
|
// ---------------------------------------------------------------------------
|
|
647
703
|
// Setup detection - check whether setup was completed for the current host
|
|
648
704
|
// ---------------------------------------------------------------------------
|
package/src/skills.ts
CHANGED
|
@@ -92,6 +92,7 @@ function loadSharedSkills(): SkillDef[] {
|
|
|
92
92
|
function loadOpencodeOnlySkills(): SkillDef[] {
|
|
93
93
|
return [
|
|
94
94
|
{ name: "thatch-code-review", content: loadSkillFile("thatch-code-review") },
|
|
95
|
+
{ name: "thatch-coordination", content: loadSkillFile("thatch-coordination") },
|
|
95
96
|
];
|
|
96
97
|
}
|
|
97
98
|
|