@jeffober/thatch 0.1.5 → 0.1.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/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@ import { ThatchDB } from "./db";
3
3
  import { BgeEmbeddingModel } from "./embeddings";
4
4
  import { detectRepo } from "./git";
5
5
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
6
+ import { SidebandServer, sidebandSocketPath } from "./sideband";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // JSON-RPC 2.0 types
@@ -79,6 +80,10 @@ function compileTools(): Map<string, CompiledTool> {
79
80
  * model loads lazily on the first tool call that needs it, so the server
80
81
  * starts instantly and only pays the ~34 MB download cost when a memory is
81
82
  * actually written or recalled.
83
+ *
84
+ * A Unix domain socket sideband is opened at startup so that one-shot hook
85
+ * processes (thatch flush-tools) can ask the warm MCP server to embed a
86
+ * prompt and search for matches without loading the model themselves.
82
87
  */
83
88
  export async function runMcpServer(): Promise<void> {
84
89
  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
@@ -94,6 +99,19 @@ export async function runMcpServer(): Promise<void> {
94
99
  const ctx: CoreContext = { db, model, defaultStore: repo };
95
100
  const tools = compileTools();
96
101
 
102
+ // The sideband socket lets one-shot hook processes (flush-tools) ask the
103
+ // warm MCP server to embed a prompt and search for matches — without
104
+ // loading the ~34 MB model themselves. If the socket can't be opened
105
+ // (permissions, tmpdir issues), the MCP server still works; only the
106
+ // prompt-aware recall nudge degrades.
107
+ const sockPath = sidebandSocketPath(dbPath);
108
+ const sideband = new SidebandServer(sockPath, model, db);
109
+ try {
110
+ sideband.start();
111
+ } catch (err) {
112
+ console.error(`[thatch] sideband socket failed: ${err}`);
113
+ }
114
+
97
115
  // Read stdin line by line. Each line is a complete JSON-RPC message.
98
116
  const decoder = new TextDecoder();
99
117
  let buf = "";
@@ -123,6 +141,7 @@ export async function runMcpServer(): Promise<void> {
123
141
  }
124
142
  }
125
143
 
144
+ sideband.stop();
126
145
  db.close();
127
146
  }
128
147
 
package/src/prompts.ts CHANGED
@@ -202,3 +202,114 @@ export function claudeExtractionNudge(count: number, payload: string): string {
202
202
  `Load the thatch-fact-extractor skill, then use mcp__thatch__memory_remember ` +
203
203
  `to save any new durable facts from this payload:\n${payload}`;
204
204
  }
205
+
206
+ /**
207
+ * Static instructions for Cursor's AGENTS.md. Same contract as
208
+ * claudeInstructions() — the host loads this at every session start, and the
209
+ * repo store name is auto-detected at runtime by the MCP server. Tool names
210
+ * use the same mcp__thatch__ prefix since Cursor discovers MCP tools via the
211
+ * same protocol.
212
+ */
213
+ export function cursorInstructions(): string {
214
+ return `# Persistence
215
+
216
+ Thatch provides persistent memory across Cursor sessions. Use it to persist
217
+ knowledge so future sessions can build on what you've already learned.
218
+ Tools are prefixed in Cursor: \`mcp__thatch__memory_remember\`,
219
+ \`mcp__thatch__memory_recall\`, \`mcp__thatch__memory_list\`,
220
+ \`mcp__thatch__memory_show\`, \`mcp__thatch__memory_forget\`,
221
+ \`mcp__thatch__store_list\`, \`mcp__thatch__find_duplicates\`,
222
+ \`mcp__thatch__dedup_mark_checked\`.
223
+ Bare names used below for readability.
224
+
225
+ ## Stores
226
+
227
+ - \`global\`: user preferences, personality, system environment (shared across projects)
228
+ - Per-project stores: project-specific knowledge (auto-detected from git remote)
229
+ - Use \`mcp__thatch__store_list\` to see active stores
230
+
231
+ Memories can be scoped to a git branch via the \`branch\` param (feature design,
232
+ WIP, PR status). Unscoped memories are project-wide and always included in search.
233
+
234
+ ## Session Startup
235
+
236
+ 1. \`memory_recall\` "user preferences and personality" (global store)
237
+ 2. \`memory_recall\` "project architecture and conventions" (project store)
238
+ 3. If on a non-main branch, \`memory_recall\` with the branch name
239
+ 4. \`memory_list\` and \`store_list\` to see what's available
240
+
241
+ Before diving into code, \`memory_recall\` with a query relevant to the area
242
+ you're working in. Prior sessions may have already investigated it.
243
+
244
+ ## When to Write
245
+
246
+ **One signal is enough.** Do not wait for confirmation.
247
+ Save immediately on: feedback, preferences, corrections, emotional signals,
248
+ new project knowledge, conventions, architectural decisions, investigation results.
249
+
250
+ ## What to Store
251
+
252
+ - **Global store**: user observations, agent personality, system environment
253
+ - **Project store**: repo organization, infrastructure, languages/frameworks,
254
+ component relationships, conventions, operational playbooks
255
+ - **Branch-scoped**: branch purpose, feature design decisions, PR status,
256
+ temporary workarounds
257
+ - **Insights**: ALWAYS save non-obvious gotchas, patterns, and architectural
258
+ lessons discovered during implementation. Never prompt the user; just do it.
259
+
260
+ ## How to Write
261
+
262
+ - \`memory_recall\` first to check for duplicates. Use overwrite: true to
263
+ update rather than creating a new entry.
264
+ - One topic per memory. Write for a future instance with zero current context.
265
+ - Confidence 1-2: single signal. 5-6: moderate. 9: explicitly stated.
266
+ 10: hard constraint.
267
+
268
+ ## What NOT to Store
269
+
270
+ Session-specific context, incomplete/unverified info, anything already in
271
+ AGENTS.md, speculative conclusions.
272
+
273
+ ## Explicit Requests
274
+
275
+ "Remember X" — save immediately.
276
+ "Forget X" — \`memory_recall\` to find it, then \`memory_forget\`.`;
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Prompt-aware recall nudge — fires when the user's prompt semantically
281
+ // matches existing memories. Injected via opencode's chat.message hook
282
+ // (in-process, warm model) or via the sideband socket for Claude Code/Cursor
283
+ // (flush-tools CLI subcommand).
284
+ // ---------------------------------------------------------------------------
285
+
286
+ export interface NudgeMatch {
287
+ label: string;
288
+ score: number;
289
+ }
290
+
291
+ /**
292
+ * The recall nudge for the opencode plugin path. Uses the thatch_ tool prefix
293
+ * since opencode discovers tools directly from the plugin registration.
294
+ */
295
+ export function recallNudge(matches: NudgeMatch[]): string {
296
+ return formatRecallNudge(matches, "thatch_memory_recall");
297
+ }
298
+
299
+ /**
300
+ * The recall nudge for the Claude Code / Cursor hook path. Uses the bare tool
301
+ * name (memory_recall) since the mcp__thatch__ prefix is verbose and the
302
+ * CLAUDE.md / AGENTS.md instructions already establish the bare-name convention.
303
+ */
304
+ export function claudeRecallNudge(matches: NudgeMatch[]): string {
305
+ return formatRecallNudge(matches, "memory_recall");
306
+ }
307
+
308
+ function formatRecallNudge(matches: NudgeMatch[], toolName: string): string {
309
+ const n = matches.length;
310
+ const word = n === 1 ? "memory" : "memories";
311
+ const verb = n === 1 ? "relates" : "relate";
312
+ const labels = matches.slice(0, 2).map((m) => `"${m.label}"`).join(", ");
313
+ const etc = n > 2 ? ", etc." : "";
314
+ return `[thatch] ${n} ${word} ${verb} to this prompt (${labels}${etc}). Use ${toolName} before responding.`;
315
+ }
package/src/setup.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { claudeInstructions } from "./prompts";
3
+ import { claudeInstructions, cursorInstructions } from "./prompts";
4
4
  import { installSkills, type SkillFile } from "./skills";
5
5
 
6
6
  // ---------------------------------------------------------------------------
@@ -91,17 +91,26 @@ function writeMcpConfig(path: string, thatchBin: string): void {
91
91
  // ---------------------------------------------------------------------------
92
92
 
93
93
  const THATCH_MARKER = "# Persistence\n\nThatch provides persistent memory across Claude Code sessions.";
94
+ const THATCH_END_MARKER = '"Forget X" — `memory_recall` to find it, then `memory_forget`.';
95
+ const CURSOR_MARKER = "# Persistence\n\nThatch provides persistent memory across Cursor sessions.";
96
+ const CURSOR_END_MARKER = THATCH_END_MARKER;
94
97
 
95
- function appendInstructions(path: string): void {
96
- const instructions = claudeInstructions() + "\n";
97
-
98
+ /**
99
+ * Idempotently append (or replace) a block of instructions in a markdown file.
100
+ * The block is delimited by startMarker and endMarker so re-running setup
101
+ * updates drifted content without clobbering surrounding text.
102
+ */
103
+ function appendBlock(
104
+ path: string,
105
+ instructions: string,
106
+ startMarker: string,
107
+ endMarker: string,
108
+ ): void {
98
109
  if (existsSync(path)) {
99
110
  const existing = readFileSync(path, "utf8");
100
- if (existing.includes(THATCH_MARKER)) {
101
- // Already has thatch instructions — replace the block.
102
- const startIdx = existing.indexOf("# Persistence\n\nThatch provides persistent memory across Claude Code sessions.");
111
+ if (existing.includes(startMarker)) {
112
+ const startIdx = existing.indexOf(startMarker);
103
113
  if (startIdx >= 0) {
104
- const endMarker = '"Forget X" — `memory_recall` to find it, then `memory_forget`.';
105
114
  const endIdx = existing.indexOf(endMarker, startIdx);
106
115
  if (endIdx >= 0) {
107
116
  const afterEnd = endIdx + endMarker.length;
@@ -122,6 +131,14 @@ function appendInstructions(path: string): void {
122
131
  }
123
132
  }
124
133
 
134
+ function appendInstructions(path: string): void {
135
+ appendBlock(path, claudeInstructions() + "\n", THATCH_MARKER, THATCH_END_MARKER);
136
+ }
137
+
138
+ function appendCursorInstructions(path: string): void {
139
+ appendBlock(path, cursorInstructions() + "\n", CURSOR_MARKER, CURSOR_END_MARKER);
140
+ }
141
+
125
142
  // ---------------------------------------------------------------------------
126
143
  // settings.json — hook installation
127
144
  // ---------------------------------------------------------------------------
@@ -257,3 +274,149 @@ export function setupClaudeCode(
257
274
  : null,
258
275
  };
259
276
  }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Cursor setup — .cursor/mcp.json, AGENTS.md, .cursor/hooks.json, skills
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Resolve the Cursor config directory. Cursor stores config under `~/.cursor`
284
+ * by default. No documented env override exists (unlike Claude Code's
285
+ * CLAUDE_CONFIG_DIR), but we honor a hypothetical CURSOR_CONFIG_DIR for
286
+ * symmetry and forward-compatibility.
287
+ */
288
+ function cursorConfigDir(homeDir: string): string {
289
+ return process.env.CURSOR_CONFIG_DIR ?? join(homeDir, ".cursor");
290
+ }
291
+
292
+ interface CursorSetupPaths {
293
+ mcpConfigPath: string;
294
+ agentsMdPath: string;
295
+ hooksPath: string;
296
+ skillsDir: string;
297
+ global: boolean;
298
+ }
299
+
300
+ function resolveCursorPaths(global: boolean, projectDir: string, homeDir: string): CursorSetupPaths {
301
+ const configDir = cursorConfigDir(homeDir);
302
+ const skillsDir = join(configDir, "skills");
303
+
304
+ if (global) {
305
+ return {
306
+ // Cursor's global MCP config is a simple file — no equivalent of
307
+ // `claude mcp add --scope user`. Writing ~/.cursor/mcp.json is enough.
308
+ mcpConfigPath: join(configDir, "mcp.json"),
309
+ agentsMdPath: join(configDir, "AGENTS.md"),
310
+ hooksPath: join(configDir, "hooks.json"),
311
+ skillsDir,
312
+ global: true,
313
+ };
314
+ }
315
+
316
+ return {
317
+ mcpConfigPath: join(projectDir, ".cursor", "mcp.json"),
318
+ agentsMdPath: join(projectDir, "AGENTS.md"),
319
+ hooksPath: join(projectDir, ".cursor", "hooks.json"),
320
+ skillsDir,
321
+ global: false,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Cursor hooks use a flat format (no nesting, no `type` field):
327
+ * { "version": 1, "hooks": { "event": [{ "command": "..." }] } }
328
+ */
329
+ interface CursorHooksJson {
330
+ version: number;
331
+ hooks: {
332
+ [event: string]: { command: string }[];
333
+ };
334
+ [key: string]: unknown;
335
+ }
336
+
337
+ function writeCursorHooks(path: string, thatchBin: string): void {
338
+ let config: CursorHooksJson = { version: 1, hooks: {} };
339
+ if (existsSync(path)) {
340
+ try {
341
+ config = JSON.parse(readFileSync(path, "utf8"));
342
+ } catch {
343
+ // Corrupt — start fresh.
344
+ }
345
+ }
346
+ if (!config.version) config.version = 1;
347
+ if (!config.hooks) config.hooks = {};
348
+
349
+ // --json makes reminder output { additional_context: "..." } which Cursor
350
+ // injects into the session. Without --json, stdout is plain text (Claude
351
+ // Code style) and Cursor would not parse it.
352
+ const sessionStartCmd = `${thatchBin} reminder --json`;
353
+ // postToolUse fires per-tool (Cursor has no PostToolBatch equivalent).
354
+ // Silent — no stdout — so the agent loop is not delayed.
355
+ const bufferCmd = `${thatchBin} buffer-tool`;
356
+ // beforeSubmitPrompt is Cursor's UserPromptSubmit equivalent. Drains the
357
+ // queue and prints the extraction nudge as JSON additional_context.
358
+ const flushCmd = `${thatchBin} flush-tools --json`;
359
+
360
+ config.hooks.sessionStart = replaceCursorThatchHooks(config.hooks.sessionStart ?? [], sessionStartCmd);
361
+ config.hooks.postToolUse = replaceCursorThatchHooks(config.hooks.postToolUse ?? [], bufferCmd);
362
+ config.hooks.beforeSubmitPrompt = replaceCursorThatchHooks(config.hooks.beforeSubmitPrompt ?? [], flushCmd);
363
+
364
+ mkdirSync(join(path, ".."), { recursive: true });
365
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
366
+ }
367
+
368
+ function replaceCursorThatchHooks(
369
+ entries: { command: string }[],
370
+ command: string,
371
+ ): { command: string }[] {
372
+ const filtered = entries.filter((e) => !e.command?.includes("thatch"));
373
+ filtered.push({ command });
374
+ return filtered;
375
+ }
376
+
377
+ export interface CursorSetupResult {
378
+ mcpConfig: string;
379
+ agentsMd: string;
380
+ hooks: string;
381
+ skills: SkillFile[];
382
+ global: boolean;
383
+ }
384
+
385
+ /**
386
+ * Installs thatch into Cursor. Writes .cursor/mcp.json (or ~/.cursor/mcp.json
387
+ * for global), appends instructions to AGENTS.md, installs sessionStart +
388
+ * postToolUse + beforeSubmitPrompt hooks in .cursor/hooks.json, and installs
389
+ * skill files. All operations are idempotent.
390
+ *
391
+ * Cursor's global MCP config is a simple file write (unlike Claude Code which
392
+ * needs `claude mcp add --scope user`). Skills install to ~/.cursor/skills/
393
+ * (auto-discovered by Cursor, which also reads ~/.claude/skills/ for compat).
394
+ *
395
+ * @param thatchBin Absolute path to the thatch binary
396
+ * @param global Whether to install globally or project-locally
397
+ * @param projectDir The project directory (used for project-local installs)
398
+ * @param homeDir Home directory (defaults to os.homedir())
399
+ */
400
+ export function setupCursor(
401
+ thatchBin: string,
402
+ global: boolean,
403
+ projectDir: string,
404
+ homeDir?: string,
405
+ ): CursorSetupResult {
406
+ const { homedir } = require("node:os");
407
+ const home = homeDir ?? homedir();
408
+ const paths = resolveCursorPaths(global, projectDir, home);
409
+
410
+ writeMcpConfig(paths.mcpConfigPath, thatchBin);
411
+ appendCursorInstructions(paths.agentsMdPath);
412
+ writeCursorHooks(paths.hooksPath, thatchBin);
413
+ const skills = installClaudeSkills(paths.skillsDir);
414
+
415
+ return {
416
+ mcpConfig: paths.mcpConfigPath,
417
+ agentsMd: paths.agentsMdPath,
418
+ hooks: paths.hooksPath,
419
+ skills,
420
+ global,
421
+ };
422
+ }
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import { unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createServer, connect, type Server, type Socket } from "node:net";
6
+ import type { ThatchDB } from "./db";
7
+ import type { EmbeddingModel } from "./embeddings";
8
+
9
+ /**
10
+ * Deterministic socket path from the DB path. Both the MCP server and hook
11
+ * processes resolve the same DB path (from THATCH_DB_PATH or the default
12
+ * under XDG_CONFIG_HOME), so they independently arrive at the same socket
13
+ * path without any out-of-band coordination.
14
+ */
15
+ export function sidebandSocketPath(dbPath: string): string {
16
+ const hash = createHash("sha256").update(dbPath).digest("hex").slice(0, 16);
17
+ return join(tmpdir(), `thatch-${hash}.sock`);
18
+ }
19
+
20
+ export interface SidebandMatch {
21
+ label: string;
22
+ score: number;
23
+ store: string;
24
+ }
25
+
26
+ interface MatchRequest {
27
+ method: "match";
28
+ text: string;
29
+ stores: string[];
30
+ threshold: number;
31
+ limit: number;
32
+ }
33
+
34
+ interface MatchResponse {
35
+ ok: boolean;
36
+ matches?: SidebandMatch[];
37
+ error?: string;
38
+ }
39
+
40
+ /**
41
+ * Serves embedding + semantic search requests over a Unix domain socket.
42
+ * The MCP server (long-lived, warm model) runs this so that one-shot hook
43
+ * processes can ask "does this prompt match any memories?" without loading
44
+ * the ~34 MB model themselves.
45
+ *
46
+ * The protocol is newline-delimited JSON: one request per connection, one
47
+ * response per connection. Requests are `{"method":"match","text":"...",
48
+ * "stores":[...],"threshold":N,"limit":N}`. Responses are `{"ok":true,
49
+ * "matches":[...]}` or `{"ok":false,"error":"..."}`.
50
+ */
51
+ export class SidebandServer {
52
+ #server: Server | null = null;
53
+ #path: string;
54
+ #model: EmbeddingModel;
55
+ #db: ThatchDB;
56
+
57
+ constructor(path: string, model: EmbeddingModel, db: ThatchDB) {
58
+ this.#path = path;
59
+ this.#model = model;
60
+ this.#db = db;
61
+ }
62
+
63
+ /** Opens the socket. Cleans up any stale socket file from a crashed server. */
64
+ start(): void {
65
+ try {
66
+ unlinkSync(this.#path);
67
+ } catch {
68
+ // No stale socket — expected on first run.
69
+ }
70
+
71
+ this.#server = createServer((socket) => {
72
+ this.#handleConnection(socket);
73
+ });
74
+
75
+ this.#server.listen(this.#path);
76
+ }
77
+
78
+ /** Closes the socket and removes the socket file. */
79
+ stop(): void {
80
+ this.#server?.close();
81
+ this.#server = null;
82
+ try {
83
+ unlinkSync(this.#path);
84
+ } catch {
85
+ // Already gone.
86
+ }
87
+ }
88
+
89
+ get path(): string {
90
+ return this.#path;
91
+ }
92
+
93
+ #handleConnection(socket: Socket): void {
94
+ let buf = "";
95
+ socket.on("data", (chunk: Buffer) => {
96
+ buf += chunk.toString();
97
+ const nl = buf.indexOf("\n");
98
+ if (nl < 0) return;
99
+
100
+ const line = buf.slice(0, nl).trim();
101
+ buf = buf.slice(nl + 1);
102
+ this.#handleRequest(line, socket);
103
+ });
104
+ socket.on("error", () => {
105
+ // Client disconnected abruptly — nothing to do.
106
+ });
107
+ }
108
+
109
+ async #handleRequest(line: string, socket: Socket): Promise<void> {
110
+ let req: MatchRequest;
111
+ try {
112
+ req = JSON.parse(line);
113
+ } catch {
114
+ this.#respond(socket, { ok: false, error: "Invalid JSON" });
115
+ return;
116
+ }
117
+
118
+ if (req.method !== "match") {
119
+ this.#respond(socket, { ok: false, error: `Unknown method: ${req.method}` });
120
+ return;
121
+ }
122
+
123
+ try {
124
+ const embedding = await this.#model.queryEmbed(req.text);
125
+ const results = this.#db.search(req.stores, embedding, { limit: req.limit });
126
+ const matches: SidebandMatch[] = results
127
+ .filter((r) => r._score >= req.threshold)
128
+ .map((r) => ({
129
+ label: r.label,
130
+ score: Math.round(r._score * 1000) / 1000,
131
+ store: r.store,
132
+ }));
133
+ this.#respond(socket, { ok: true, matches });
134
+ } catch (err: any) {
135
+ this.#respond(socket, { ok: false, error: String(err?.message ?? err) });
136
+ }
137
+ }
138
+
139
+ #respond(socket: Socket, res: MatchResponse): void {
140
+ if (!socket.destroyed) {
141
+ socket.write(JSON.stringify(res) + "\n");
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Connect to the sideband server and request semantic matches for a prompt.
148
+ * Returns null on any failure (server not running, stale socket, timeout) —
149
+ * callers must treat null as "skip the recall nudge" and fall back gracefully.
150
+ */
151
+ export function sidebandMatch(
152
+ socketPath: string,
153
+ text: string,
154
+ stores: string[],
155
+ threshold: number,
156
+ limit: number,
157
+ timeoutMs = 2000,
158
+ ): Promise<SidebandMatch[] | null> {
159
+ return new Promise((resolve) => {
160
+ let settled = false;
161
+ const done = (val: SidebandMatch[] | null) => {
162
+ if (settled) return;
163
+ settled = true;
164
+ resolve(val);
165
+ };
166
+
167
+ const socket = connect(socketPath);
168
+ const req = JSON.stringify({ method: "match", text, stores, threshold, limit }) + "\n";
169
+ let buf = "";
170
+
171
+ const timer = setTimeout(() => {
172
+ socket.destroy();
173
+ done(null);
174
+ }, timeoutMs);
175
+
176
+ socket.on("error", () => {
177
+ clearTimeout(timer);
178
+ // Stale socket file left by a crashed server — clean it up so the next
179
+ // connection attempt doesn't hit ECONNREFUSED again.
180
+ try {
181
+ unlinkSync(socketPath);
182
+ } catch {
183
+ // Already gone or never existed.
184
+ }
185
+ done(null);
186
+ });
187
+
188
+ socket.on("connect", () => {
189
+ socket.write(req);
190
+ });
191
+
192
+ socket.on("data", (chunk: Buffer) => {
193
+ buf += chunk.toString();
194
+ const nl = buf.indexOf("\n");
195
+ if (nl < 0) return;
196
+
197
+ clearTimeout(timer);
198
+ try {
199
+ const res = JSON.parse(buf.slice(0, nl)) as MatchResponse;
200
+ done(res.ok ? (res.matches ?? []) : null);
201
+ } catch {
202
+ done(null);
203
+ }
204
+ socket.destroy();
205
+ });
206
+ });
207
+ }
package/src/skills.ts CHANGED
@@ -649,25 +649,33 @@ For each finding from the specialists:
649
649
 
650
650
  3. **Determine provenance.** Is the finding a new issue introduced by this change, or a pre-existing problem? Note pre-existing bugs separately.
651
651
 
652
- 4. **Check runtime model applicability.** Can the finding realistically manifest in normal usage given the application's runtime model? A finding that requires conditions impossible in the actual runtime context is not a real bug (e.g., state accumulation in a short-lived process, concurrency in single-threaded code).
652
+ **Finding type determines which steps apply next.** Steps 4-6 apply to behavioral findings (from acceptance, state-flow). Mechanical findings (from pedantic, no-slop, breadcrumbs — docs, naming, style, comments, prose) skip steps 4-6 and use the mechanical verification criteria in step 4m instead.
653
653
 
654
- 5. **Prove the causal chain** for any finding about state, data shape, cross-module contracts, or behavior:
654
+ 4. **Check runtime model applicability (behavioral findings only).** Can the finding realistically manifest in normal usage given the application's runtime model? A finding that requires conditions impossible in the actual runtime context is not a real bug (e.g., state accumulation in a short-lived process, concurrency in single-threaded code).
655
+
656
+ 5. **Prove the causal chain (behavioral findings only)** for any finding about state, data shape, cross-module contracts, or behavior:
655
657
  - Identify the authoritative producer or source of the state/data.
656
658
  - Identify the transforms between producer and consumer.
657
659
  - Identify the consumer or branch where failure occurs.
658
660
  - Identify the real entrypoint/workflow that exercises this chain.
659
661
  If the only way to trigger the issue is by manually fabricating invalid data/state or bypassing the real producers/guards, reject the finding. If the citation is real but you cannot prove the producer chain, classify as UNVERIFIABLE rather than CONFIRMED.
660
662
 
661
- 6. **Verify intent.** If the specialist flagged behavior as a bug but the code appears to work as designed, check whether the behavior is intentional:
663
+ 6. **Verify intent (behavioral findings only).** If the specialist flagged behavior as a bug but the code appears to work as designed, check whether the behavior is intentional:
662
664
  - Read the callers of the cited code to see if the pattern makes sense in context.
663
665
  - Check git log or git blame on the cited file for commit messages explaining the decision.
664
666
  - Use thatch_memory_recall to search for documented rationale.
665
667
  If the behavior is intentional or an accepted limitation, reject the finding.
666
668
 
669
+ 4m. **Mechanical verification (pedantic, no-slop, breadcrumbs, docs, naming, style, comments).** For mechanical findings, verification means:
670
+ - The cited text exists at the cited location (already confirmed in step 2).
671
+ - The finding is branch-introduced or newly made relevant by the change. Pre-existing issues go in the pre-existing appendix, not rejected.
672
+ - The cited text violates the stated guideline, specialist taxonomy, or project norm. Identify the source of truth: the specific guideline, convention, or writing norm being violated.
673
+ Runtime reachability, producer chains, and intent verification do not apply. A mechanical finding is not a "bug" and does not need a "trigger scenario."
674
+
667
675
  7. **Classify:**
668
- - **CONFIRMED**: The cited code matches, the claim is accurate, the bug is reachable through realistic usage, you proved the workflow/producer chain where applicable, and the behavior is not intentional.
669
- - **REJECTED**: The citation is wrong, the claim is inaccurate, the bug cannot manifest in the actual runtime context, or the behavior is an intentional design decision (explain why briefly). Reject findings that rely on manually seeded invalid state/data with no real producer path.
670
- - **UNVERIFIABLE**: The citation is correct but you cannot confirm the behavioral claim without deeper tracing (state what's missing). This is the default for plausible claims that lack a proven trigger path, producer chain, or authoritative source of truth.
676
+ - **CONFIRMED**: For behavioral findings: the cited code matches, the claim is accurate, the bug is reachable through realistic usage, you proved the workflow/producer chain where applicable, and the behavior is not intentional. For mechanical findings: the cited text exists, is branch-introduced or newly made relevant, and violates the stated guideline or specialist taxonomy.
677
+ - **REJECTED**: For behavioral findings: the citation is wrong, the claim is inaccurate, the bug cannot manifest in the actual runtime context, or the behavior is an intentional design decision (explain why briefly). Reject findings that rely on manually seeded invalid state/data with no real producer path. For mechanical findings: the cited text does not exist, does not violate the stated guideline, is unchanged legacy outside the touched scope, or the finding duplicates another confirmed finding.
678
+ - **UNVERIFIABLE**: The citation is correct but you cannot confirm the claim without deeper tracing. This is the default for plausible behavioral claims that lack a proven trigger path, producer chain, or authoritative source of truth. Mechanical findings should rarely be UNVERIFIABLE — if the text exists and violates the guideline, it is CONFIRMED.
671
679
 
672
680
  ## Deduplication
673
681
 
@@ -687,6 +695,8 @@ Assign final severity based on YOUR verification:
687
695
 
688
696
  You MUST produce the full report structure below. Do not simplify or omit sections. Every confirmed finding must include all numbered fields. If a section has no entries, write "None" — do not skip the section.
689
697
 
698
+ Confirmed LOW findings are mandatory. Do not summarize them away or omit them for being non-functional. Pedantic, no-slop, breadcrumbs, docs, naming, style, and comment findings are first-class review findings when confirmed. Group them under LOW, but include every one.
699
+
690
700
  ### Scope
691
701
  - Branch/range reviewed
692
702
  - Design context (if provided)
@@ -858,13 +868,15 @@ For the integration review unit (5+ points), dispatch a sub-agent focused on:
858
868
 
859
869
  After all sub-agents complete, load the thatch-review-synthesizer skill to verify findings, deduplicate across specialists, classify (CONFIRMED/REJECTED/UNVERIFIABLE), and produce the final severity-grouped report.
860
870
 
871
+ Confirmed LOW findings are mandatory in the final report, including mechanical findings (pedantic, no-slop, breadcrumbs, docs, naming, style, comments). Do not omit them for being non-functional.
872
+
861
873
  Alternatively, perform the synthesis yourself:
862
874
  1. Read each finding's cited location to verify evidence accuracy.
863
875
  2. Deduplicate findings flagged by multiple specialists.
864
876
  3. Group findings by root cause where multiple findings stem from the same issue.
865
- 4. Classify each as CONFIRMED, REJECTED, or UNVERIFIABLE based on citation verification, reachability, and intent verification.
877
+ 4. Classify each as CONFIRMED, REJECTED, or UNVERIFIABLE. For behavioral findings, apply citation verification, reachability, and intent verification. For mechanical findings, verify the cited text exists, is branch-introduced or newly made relevant, and violates the stated guideline or specialist taxonomy.
866
878
  5. Calibrate severity (BLOCKING > HIGH > MEDIUM > LOW) based on your verification.
867
- 6. Produce a final report grouped by severity, with coverage gaps noted.
879
+ 6. Produce a final report grouped by severity, with coverage gaps noted. Include every confirmed LOW finding.
868
880
 
869
881
  ## Specialist briefing template
870
882