@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.
Files changed (54) hide show
  1. package/README.md +8 -1
  2. package/artifacts/skills/thatch-clear-writing.md +103 -5
  3. package/artifacts/skills/thatch-coordination.md +69 -0
  4. package/artifacts/skills/thatch-pr-description.md +21 -13
  5. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  6. package/bin/thatch +95 -44
  7. package/docs/assets/deepwiki-badge.svg +1 -0
  8. package/docs/dev/README.md +7 -2
  9. package/docs/dev/features/cli.md +2 -0
  10. package/docs/dev/features/cross-session-chat.md +118 -11
  11. package/docs/dev/features/notifications.md +1 -2
  12. package/docs/dev/features/opencode-plugin.md +18 -3
  13. package/docs/dev/features/prediction-engine.md +44 -5
  14. package/docs/dev/features/session-tabs.md +155 -0
  15. package/docs/dev/features/setup.md +12 -0
  16. package/docs/dev/features/watchers.md +62 -6
  17. package/docs/dev/gotchas.md +68 -0
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +7 -2
  20. package/docs/dev/skills.md +1 -1
  21. package/docs/plans/generalized-session-heartbeat.md +212 -0
  22. package/docs/plans/prediction-consolidation.md +88 -68
  23. package/docs/user/README.md +12 -3
  24. package/docs/user/cli.md +5 -1
  25. package/docs/user/commands.md +11 -4
  26. package/docs/user/cross-session-chat.md +31 -8
  27. package/docs/user/prediction-engine.md +11 -0
  28. package/docs/user/setup.md +29 -0
  29. package/docs/user/skills.md +1 -1
  30. package/docs/user/watchers.md +22 -7
  31. package/package.json +10 -2
  32. package/src/alerts.ts +1 -1
  33. package/src/chat.ts +320 -27
  34. package/src/commands.ts +14 -0
  35. package/src/db.ts +51 -1
  36. package/src/git.ts +3 -1
  37. package/src/hygiene.ts +5 -0
  38. package/src/mcp.ts +95 -7
  39. package/src/opencode/tui-plugin.ts +70 -0
  40. package/src/opencode/v2.ts +197 -4
  41. package/src/prediction.ts +90 -0
  42. package/src/prompts.ts +110 -23
  43. package/src/runtime.ts +187 -9
  44. package/src/session-db.ts +4 -4
  45. package/src/session-tab-shared.ts +180 -0
  46. package/src/setup.ts +59 -3
  47. package/src/skills.ts +1 -0
  48. package/src/tool-defs.ts +412 -55
  49. package/src/tools.ts +2 -0
  50. package/src/watchers.ts +263 -26
  51. package/docs/plans/intuition-drives.md +0 -147
  52. package/docs/plans/llm-alerts.md +0 -287
  53. package/docs/plans/thatch-actions.md +0 -179
  54. 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