@jeffober/thatch 0.1.34 → 0.1.36

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 CHANGED
@@ -83,6 +83,15 @@ Thatch gives your agent three layers of persistent context:
83
83
  situations arise. The agent ham/spams each surfaced rule to train the
84
84
  classifier. Confidence adjusts the same way as predictions. An anti-laziness
85
85
  guard in the prompt prevents the agent from codifying shortcuts.
86
+ - **Conversation search** -- the agent can search its own past opencode
87
+ conversations by substring or regex and retrieve full messages, including
88
+ complete tool inputs and outputs. Available as tools (`session_search`,
89
+ `session_get`, opencode only) and as CLI subcommands (`thatch session
90
+ list/get/transcript/search`, JSONL output designed for piping to `jq`).
91
+ - **Watchers** -- event-driven notifications from external sources (opencode
92
+ only). The agent registers a watch on a GitHub PR; thatch polls it in the
93
+ background and prompts the session when comments, commits, CI results, or
94
+ status changes arrive. Notifications carry pointer data only.
86
95
 
87
96
  Plus **26 skills** for memory workflows, structured multi-specialist code
88
97
  review, review response, change and feature walkthroughs, memory verification,
@@ -98,6 +107,12 @@ host.
98
107
  Everything is local. The embedding model downloads once from Hugging Face Hub
99
108
  and is cached. No data leaves your machine.
100
109
 
110
+ One exception: watchers (opencode only) call the GitHub API through the `gh`
111
+ CLI to poll PRs you explicitly asked to watch, using your existing gh
112
+ authentication. Thatch never sees or stores the token, and notifications
113
+ carry pointer data only - GitHub comment text never enters your context
114
+ unless the agent fetches it.
115
+
101
116
  ## Development
102
117
 
103
118
  ```bash
@@ -26,6 +26,8 @@ Also detect whether the current branch has a **connected PR/MR** even when none
26
26
 
27
27
  Also fetch any refs not locally reachable so branches and PRs that were never checked out can be reviewed (e.g. `git fetch origin <branch>` for a PR head).
28
28
 
29
+ **Anchor discipline**: the working tree may be checked out on any branch — main, another feature, a detached HEAD — and is not guaranteed to match the change under review. Resolve the head SHA of the review range (`git rev-parse <head-ref>`), and compute every file:line citation and review-comment anchor against that ref with `git show <head-sha>:<path>` — never from the working tree, and never from the base. When the working tree happens to be the PR branch this is equivalent, so apply the rule unconditionally rather than branching on it. Pass the head SHA to sub-agents (see the briefing template).
30
+
29
31
  Run git diff --stat on the resolved range and git log --oneline to understand the change.
30
32
 
31
33
  ## Step 2: Gather project context
@@ -38,7 +40,9 @@ Load the thatch-review-context skill and follow its methodology to build a conte
38
40
  - thatch_memory_recall for feature/project status, design decisions, ticket dependencies
39
41
  - Project docs, READMEs, design docs
40
42
  - Issue tracker (gh issue/view) if ticket IDs are found
41
- - **Prior review comments** — if Step 1 detected a connected PR/MR, fetch ALL prior review comments on it (see source #8 in the review-context skill) and build the prior-comments register with a preliminary addressed-check status per comment. This is what distinguishes a **follow-up round** (register non-empty) from the **first round** (register empty or PR has no prior reviews). If Step 1 found no connected PR/MR, the brief explicitly states `Local-branch review — no prior-comment fetch`.
43
+ - **Links in the change** — docs, designs, tickets, and related PRs linked from the PR description, commit messages, or the changed files themselves; follow them for scope and design context (see source #8 in the review-context skill)
44
+ - **Staleness check** — whether main has moved past the merge-base touching the same paths (see source #3 in the review-context skill); when it has, record the commits in the brief
45
+ - **Prior review comments** — if Step 1 detected a connected PR/MR, fetch ALL prior review comments on it (see source #9 in the review-context skill) and build the prior-comments register with a preliminary addressed-check status per comment. This is what distinguishes a **follow-up round** (register non-empty) from the **first round** (register empty or PR has no prior reviews). If Step 1 found no connected PR/MR, the brief explicitly states `Local-branch review — no prior-comment fetch`.
42
46
 
43
47
  Produce the context brief in the format the skill prescribes (project context, this change's scope, deferred work, dependencies, relevant constraints, and the prior review comments section when present).
44
48
 
@@ -123,6 +127,7 @@ Pass the synthesizer:
123
127
  - The workflow guide from Step 3.
124
128
  - The specialist findings.
125
129
  - The prior-comments register when present.
130
+ - The staleness signal from the brief (commits on main after the merge-base touching this change's paths), when present.
126
131
 
127
132
  The final user-facing report MUST begin with a concise explanation of the workflow(s) this PR changes before listing findings. Use one `### Workflow changes` section, with one subheading per affected workflow when there are multiple. Use the before/now walkthrough shape from the thatch-pr-description skill: numbered step = existing behavior; `NOW` sub-bullet = what the PR changes; `N/A` for new stages. This gives the user a map of the change before the findings. Keep it short: 3-6 steps per workflow.
128
133
 
@@ -136,7 +141,7 @@ Alternatively, perform the synthesis yourself:
136
141
  1. Read each finding's cited location to verify evidence accuracy.
137
142
  2. Deduplicate findings flagged by multiple specialists.
138
143
  3. Group findings by root cause where multiple findings stem from the same issue.
139
- 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. For highlights, verify the cited text exists, the claim is accurate, and the highlighted thing genuinely rises above baseline competence.
144
+ 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. For highlights, verify the cited text exists, the claim is accurate, and the highlighted thing genuinely rises above baseline competence. When the brief reports staleness (main moved past the merge-base touching this change's paths), re-verify each behavioral finding against current main first — a finding true only at the merge-base is a rebase note for the author, not a defect in this PR.
140
145
  5. Cross-reference against the prior-comments register if one was built in Step 2: tag matching findings `Provenance: previously identified by @author, PR #N, DATE` and produce the `### Previously identified findings` appendix per the synthesizer skill.
141
146
  6. Calibrate severity (BLOCKING > HIGH > MEDIUM > LOW) based on your verification.
142
147
  7. Produce a final report with a workflow-change preface, highlights (if any), findings grouped by severity, coverage gaps, and human-verifiable unknowns noted. Include every confirmed LOW finding.
@@ -145,6 +150,7 @@ Alternatively, perform the synthesis yourself:
145
150
 
146
151
  When dispatching each sub-agent, include in the prompt:
147
152
  - The git range to review
153
+ - **The resolved head SHA of the range** — all file reads, file:line citations, and anchors MUST be computed against this ref (`git show <head-sha>:<path>`), never the working tree, which may be on a different branch than the change under review
148
154
  - The specific files in this unit's scope
149
155
  - The specialist focus (from the eight specialists above)
150
156
  - The diff stat for this unit's files
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: thatch-review-context
3
- description: Gather project and feature context before a code review. Investigates PR descriptions, git history, ticket references, docs, and memory to build a context brief that prevents false positives about intentionally deferred work. Use before dispatching review specialists or running a solo review.
3
+ description: Gather project and feature context before a code review. Investigates PR descriptions, git history, ticket references, linked docs/tickets, docs, and memory to build a context brief that prevents false positives about intentionally deferred work. Use before dispatching review specialists or running a solo review.
4
4
  ---
5
5
 
6
6
  You are a review context builder. Your job is to gather project context that gives reviewers the background they need to avoid false positives about intentionally deferred work, incomplete features, and multi-ticket dependencies.
@@ -28,6 +28,7 @@ Branch names often contain ticket identifiers. Look for patterns:
28
28
  - `git log --all --oneline --grep="TICKET-ID"` — find related commits on other branches
29
29
  - `git log -S "TODO (TICKET"` — find TODO markers that reference this or related tickets
30
30
  - `git branch -a` — other branches may show planned or in-progress related work
31
+ - **Staleness check**: `git log --oneline <merge-base>..origin/main -- <changed paths>` — commits that landed on main AFTER this branch's merge-base, touching the same files. If any exist, the branch is stale relative to main and findings true at the merge-base may already be moot. List these commits in the brief under Dependencies so the synthesizer re-verifies findings against current main.
31
32
 
32
33
  ### 4. TODO ($ticket) markers in the diff
33
34
  Scan the diff for TODO markers that reference ticket identifiers:
@@ -56,7 +57,17 @@ If ticket IDs were found:
56
57
  - Look for milestone assignments, dependencies, and blocking relationships
57
58
  - Check for epic or parent issues that describe the overall feature
58
59
 
59
- ### 8. Prior review comments (follow-up round detection)
60
+ ### 8. Links in the change (follow the references)
61
+
62
+ Collect links to material outside the diff that a reviewer would need for context, then follow them:
63
+ - PR description links: docs, design docs, RFCs, tickets, related PRs
64
+ - Commit message links and ticket references
65
+ - Links inside the changed files themselves: READMEs, doc comments, config comments referencing designs or runbooks
66
+ - Ticket bodies from source 7, which often link parent epics, blocking issues, or design docs
67
+
68
+ Follow each link one hop and read it for review-relevant context: scope, design decisions, constraints, deferred work, sibling changes. If a linked document references another artifact that is directly about this change (a parent epic, a design doc for the same feature), follow that too. Stop at depth 2, and stop following a path once the linked material stops changing what a reviewer would flag. Record anything that changes review context in the brief. A link that 404s or requires access you do not have is not a finding — note it as `link unreachable` and move on.
69
+
70
+ ### 9. Prior review comments (follow-up round detection)
60
71
 
61
72
  If the change under review has a connected PR/MR on the upstream remote, fetch all prior review comments so this review does not duplicate already-identified issues. Any prior review activity on the PR/MR means **this is a follow-up round**, not the first review. If no PR/MR is connected, this is a **local-branch review** — skip this source entirely; the existing review procedure applies.
62
73
 
@@ -113,7 +124,7 @@ For each comment, attempt to determine whether it has been addressed in the **cu
113
124
 
114
125
  The register produced here is a **preliminary** classification. The synthesizer produces the final cross-reference, including any prelim-status overrides when new findings from the specialist round reproduce or refute a prior comment.
115
126
 
116
- ### 9. Repo structure conventions (implicit)
127
+ ### 10. Repo structure conventions (implicit)
117
128
  Directory structure conventions are often undocumented. They live in the repo's layout itself, not in a CONTRIBUTING.md. Look at the directory tree to identify how the repo organizes code:
118
129
  - `git ls-files | head -50` or `ls` at the top level to see the organizational pattern
119
130
  - For multi-app repos, identify whether shared packages live inside app directories or at the repo root
@@ -143,6 +154,7 @@ List each deferred piece:
143
154
  - What other tickets or PRs this depends on (must merge first)
144
155
  - What tickets or PRs depend on this (are blocked by it)
145
156
  - Whether base branches have landed (for stacked PRs)
157
+ - **Staleness**: whether main has moved past the merge-base since the branch point. When the staleness check (source 3) found commits touching this change's paths, list them here — the synthesizer re-verifies behavioral findings against current main before classifying them
146
158
 
147
159
  ### Relevant constraints
148
160
  - Design decisions or architectural constraints from memories or docs
@@ -31,11 +31,9 @@ For each finding from the specialists:
31
31
 
32
32
  5a. **Verify governing constraints for data-shape findings.** For findings claiming a field can be NULL/missing/orphaned/malformed, locate and read the field's definition (schema migration, model, type declaration). If a NOT NULL, FK, enum, or type constraint forecloses the claimed state, REJECT and cite the constraint. Do not classify such a finding CONFIRMED without having read the governing definition.
33
33
 
34
+ 5b. **Re-verify against post-branch-point main (behavioral findings, only when the context brief flags staleness).** If the brief reports commits on main after this branch's merge-base touching this change's paths, the branch may be stale and a finding can be true at the merge-base but already moot on main. For each behavioral finding, read the equivalent code on current main (`git show origin/main:<path>`). A finding that main has already fixed or mooted is a **rebase note**, not a defect in this PR: keep it out of the confirmed findings and list it in the report's rebase-notes appendix instead.
35
+
34
36
  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:
35
- - Read the callers of the cited code to see if the pattern makes sense in context.
36
- - Check git log or git blame on the cited file for commit messages explaining the decision.
37
- - Use thatch_memory_recall to search for documented rationale.
38
- If the behavior is intentional or an accepted limitation, reject the finding.
39
37
 
40
38
  4m. **Mechanical verification (pedantic, no-slop, breadcrumbs, docs, naming, style, comments, structural conventions).** For mechanical findings, verification means:
41
39
  - The cited text exists at the cited location (already confirmed in step 2). For PLACEMENT findings, the cited evidence is the file path and the existing directory structure, not quoted code. Verify the file exists at the claimed path and that the existing structure shows the convention the specialist cited.
@@ -71,7 +69,7 @@ Multiple findings may stem from the same underlying issue (e.g., a contract mism
71
69
 
72
70
  ## Cross-reference against prior review comments
73
71
 
74
- If your input includes a **prior-comments register** (built by the coordinator via `thatch-review-context` source #8 — prior review comments retrieved from the PR/MR's review history), cross-reference every confirmed and rejected finding against it so the final report does not present already-identified issues as fresh discoveries.
72
+ If your input includes a **prior-comments register** (built by the coordinator via `thatch-review-context` source #9 — prior review comments retrieved from the PR/MR's review history), cross-reference every confirmed and rejected finding against it so the final report does not present already-identified issues as fresh discoveries.
75
73
 
76
74
  For each new finding:
77
75
  1. **Search for a matching prior comment** by location and semantic claim. Match if both hold:
@@ -255,6 +253,10 @@ Findings you rejected and a one-line reason why. Include the specialist and loca
255
253
 
256
254
  Findings you verified as real but pre-existing, with a one-line note on the issue and its potential impact.
257
255
 
256
+ ### Rebase notes (appendix, brief — only when the context brief flags staleness)
257
+
258
+ Findings that were true at the merge-base but are already fixed or mooted by later commits on main. One line each: the finding, and the main commit that moots it. Omit this section entirely when the brief reports no staleness.
259
+
258
260
  ### Previously identified findings (appendix — only when a prior-comments register was provided)
259
261
 
260
262
  List every entry from the prior-comments register here. For each entry, produce a one-block record:
package/bin/thatch CHANGED
@@ -9,6 +9,7 @@ import { BgeEmbeddingModel } from "../src/embeddings";
9
9
  import { hygieneReport } from "../src/hygiene";
10
10
  import { claudeSessionStartReminder } from "../src/prompts";
11
11
  import { setupClaudeCode, setupCursor } from "../src/setup";
12
+ import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
12
13
 
13
14
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
14
15
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
@@ -34,6 +35,15 @@ function usage(): never {
34
35
  thatch flush-predictions [--json] Query predictions for the prompt (hook only)
35
36
  thatch setup --claude [--cursor] [--global]
36
37
  Install config + instructions + hooks + skills
38
+ thatch session list -s <id> [--after T] [--before T]
39
+ JSONL timeline of a session's parts (one line per part)
40
+ thatch session get --id <part_id|msg_id>
41
+ Full JSON of one part (tool parts include their output)
42
+ or one message with all its parts
43
+ thatch session transcript -s <id>
44
+ Full conversation as OpenAI chat-completions JSONL
45
+ thatch session search <query> [--session <id>] [--regex] [--limit N]
46
+ JSONL of matching parts across sessions (decoded content)
37
47
 
38
48
  Stores default to the repo detected from git remote. Use "global" for the
39
49
  global store. Use "all" with search to search project + global together.`);
@@ -67,6 +77,148 @@ function projectDir(): string {
67
77
  return process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
68
78
  }
69
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // session subcommand group - read-only archaeology on the opencode db
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Parses an ISO timestamp or epoch-milliseconds value into epoch ms. */
85
+ function parseTimeBound(value: string, flag: string): number {
86
+ const asNum = Number(value);
87
+ if (Number.isFinite(asNum) && value !== "") return asNum;
88
+ const parsed = Date.parse(value);
89
+ if (Number.isNaN(parsed)) {
90
+ console.error(`error: ${flag} expects an ISO timestamp or epoch ms, got "${value}"`);
91
+ process.exit(1);
92
+ }
93
+ return parsed;
94
+ }
95
+
96
+ /** Reads a flag's value (-flag value or --flag=value forms). */
97
+ function flagValue(flags: string[], ...names: string[]): string | undefined {
98
+ for (let i = 0; i < flags.length; i++) {
99
+ for (const name of names) {
100
+ if (flags[i] === name) return flags[i + 1];
101
+ if (flags[i]?.startsWith(`${name}=`)) return flags[i]!.slice(name.length + 1);
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function openSessionDb(): SessionDB {
108
+ const path = resolveOpencodeDbPath();
109
+ if (!path) {
110
+ console.error(
111
+ "error: no opencode database found. The session commands inspect the\n" +
112
+ "session history of the opencode host; other hosts (Claude Code, Cursor)\n" +
113
+ "have no such database. Set OPENCODE_DB to point at an opencode.db file.",
114
+ );
115
+ process.exit(1);
116
+ }
117
+ return new SessionDB(path);
118
+ }
119
+
120
+ async function sessionCommand(rest: string[]): Promise<void> {
121
+ const [sub, ...restArgs] = rest;
122
+ const sessionID = flagValue(restArgs, "-s", "--session");
123
+ const after = flagValue(restArgs, "--after");
124
+ const before = flagValue(restArgs, "--before");
125
+
126
+ const db = openSessionDb();
127
+ try {
128
+ switch (sub) {
129
+ case "list": {
130
+ if (!sessionID) {
131
+ console.error("error: session list requires -s/--session <session id>");
132
+ process.exit(1);
133
+ }
134
+ const parts = db.listParts(
135
+ sessionID,
136
+ after ? parseTimeBound(after, "--after") : undefined,
137
+ before ? parseTimeBound(before, "--before") : undefined,
138
+ );
139
+ for (const part of parts) console.log(JSON.stringify(partToTimelineEntry(part)));
140
+ break;
141
+ }
142
+
143
+ case "get": {
144
+ const id = flagValue(restArgs, "--id");
145
+ if (!id) {
146
+ console.error("error: session get requires --id <part_id|msg_id>");
147
+ process.exit(1);
148
+ }
149
+ if (id.startsWith("msg_")) {
150
+ const message = db.getMessage(id);
151
+ if (!message) {
152
+ console.error(`error: no message "${id}" in ${db.path}`);
153
+ process.exit(1);
154
+ }
155
+ console.log(JSON.stringify(messageToFullJson(message), null, 2));
156
+ } else {
157
+ const part = db.getPart(id);
158
+ if (!part) {
159
+ console.error(`error: no part "${id}" in ${db.path}`);
160
+ process.exit(1);
161
+ }
162
+ console.log(JSON.stringify(partToFullJson(part), null, 2));
163
+ }
164
+ break;
165
+ }
166
+
167
+ case "transcript": {
168
+ if (!sessionID) {
169
+ console.error("error: session transcript requires -s/--session <session id>");
170
+ process.exit(1);
171
+ }
172
+ for (const message of db.listMessages(sessionID)) {
173
+ for (const entry of partToTranscriptEntries(message.role, message.parts)) {
174
+ console.log(JSON.stringify(entry));
175
+ }
176
+ }
177
+ break;
178
+ }
179
+
180
+ case "search": {
181
+ // Positional = args not preceded by a value-taking flag. Values like
182
+ // "50" or a session id don't start with "-", so predecessor checks
183
+ // are the only reliable discriminator.
184
+ const valueFlags = new Set(["-s", "--session", "--limit"]);
185
+ const positional = restArgs.filter((a, i) => !a.startsWith("-") && !valueFlags.has(restArgs[i - 1] ?? ""));
186
+ const query = positional[0];
187
+ if (!query) {
188
+ console.error("error: session search requires a query (substring, or regex with --regex)");
189
+ process.exit(1);
190
+ }
191
+ const limit = flagValue(restArgs, "--limit");
192
+ const matches = db.search(query, {
193
+ regex: restArgs.includes("--regex"),
194
+ sessionID,
195
+ limit: limit ? parseInt(limit, 10) : undefined,
196
+ });
197
+ // Titles let a cross-session result list be triaged without a
198
+ // second lookup per hit. Sessions are cached - matches cluster.
199
+ const titles = new Map<string, string | null>();
200
+ for (const part of matches) {
201
+ if (!titles.has(part.sessionId)) {
202
+ titles.set(part.sessionId, db.getSession(part.sessionId)?.title ?? null);
203
+ }
204
+ const entry: Record<string, unknown> = {
205
+ session_id: part.sessionId,
206
+ session_title: titles.get(part.sessionId),
207
+ ...partToTimelineEntry(part),
208
+ };
209
+ console.log(JSON.stringify(entry));
210
+ }
211
+ break;
212
+ }
213
+
214
+ default:
215
+ usage();
216
+ }
217
+ } finally {
218
+ db.close();
219
+ }
220
+ }
221
+
70
222
  switch (command) {
71
223
  case "--version":
72
224
  case "-v":
@@ -290,6 +442,11 @@ switch (command) {
290
442
  break;
291
443
  }
292
444
 
445
+ case "session": {
446
+ await sessionCommand(args);
447
+ break;
448
+ }
449
+
293
450
  case "buffer-batch": {
294
451
  // PostToolBatch hook: append the batch's tool_calls to the session's
295
452
  // JSONL queue. Silent on success - no stdout - so the next model call
@@ -31,12 +31,17 @@ Shared core
31
31
  ├── prediction.ts → prediction engine wrapper (thin layer over ScoringEngine)
32
32
  ├── behavior.ts → behavior engine wrapper (thin layer over ScoringEngine)
33
33
  ├── seed-behaviors.ts → default behavior seeding on first run
34
+ ├── session-db.ts → read-only opencode.db access: session timeline, full parts,
35
+ │ decoded-content search, OpenAI transcript builder (backs the
36
+ │ `thatch session` CLI and the opencode-only session tools)
34
37
  └── vector-math.ts → blobToVector + cosineSimilarity helpers
35
38
 
36
39
  OpenCode plugin path
37
40
  ├── index.ts → plugin entry: wires DB/model/extraction, registers tools + hooks
38
41
  ├── tools.ts → thin opencode tool() wrappers over tool-defs
39
- └── extraction.ts → in-memory ring buffer + shared payload builders
42
+ ├── extraction.ts → in-memory ring buffer + shared payload builders
43
+ └── watchers.ts → in-memory watcher registry + poller (opencode-only,
44
+ event-driven PR notifications delivered as prompts)
40
45
 
41
46
  MCP server path
42
47
  ├── mcp.ts → stdio JSON-RPC server: z.toJSONSchema() for tools/list,
@@ -67,6 +72,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
67
72
  | `embeddings.ts` | Lazy-load the embedding model. Expose `queryEmbed`/`passageEmbed` and the model `name` (stored as an informational tag). `MockEmbeddingModel` for tests. |
68
73
  | `extraction.ts` | Per-session in-memory ring buffer (cap 20) that buffers non-thatch tool interactions and serializes them into the JSON payload the `get_extraction_payload` tool returns. The in-memory pipeline is opencode-only, but the payload builders (`buildExtractionPayload`, `deriveTitle`) are shared by both paths — `extract-queue.ts` imports `deriveTitle`, `mcp.ts` imports `buildExtractionPayload` for the extraction payload provider. `summarizeArgs` is used internally by `buildExtractionPayload`. |
69
74
  | `extract-queue.ts` | File-backed per-session JSONL queue (caps 20, oldest dropped). Shared by the Claude Code and Cursor hook paths, which fire one-shot per event with no cross-call state. This is the MCP-side equivalent of `extraction.ts`. |
75
+ | `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
76
+ | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches a GitHub PR via `gh api` (injected `GhRunner`), diffs against last-seen state, queues pointer-only events per session, and delivers them through a plugin-supplied callback when the session is idle. Process-lifetime state by design - no SQLite. |
70
77
  | `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
71
78
  | `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
72
79
  | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (25 skills: fact-extractor, dedup-classifier, project-primer, 8 review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (1 skill: code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
@@ -87,8 +94,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
87
94
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
88
95
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
89
96
  | `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
90
- | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status` (idle): if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child. `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`. `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
91
- | `dispose` | Closes the DB. |
97
+ | `event` | Subscribes to all session bus events. `session.created`: records `childToParent` + `parentSnapshots` (shallow copy of the parent's buffer for snapshot-aware drain), then sends the session-start reminder via `client.session.prompt` carrying the hygiene heartbeat (pending dedup pairs, stale count, orphaned branch memories) when any signal is non-zero. `session.status`: records the session's live status for the watcher delivery gate; on idle, if the session is a child, drains its snapshot, fires a toast with `childMetrics`, and deletes the child session; if the session is a parent with pending tool interactions and not already extracting, calls `triggerExtraction` to create a direct-extraction child, then flushes pending watcher events (the session just became deliverable). `session.error`: requeues the parent's buffer (child died without draining). `session.deleted`: cleans up `childToParent`, `parentSnapshots`, `childMetrics`, and `extracting`, and cancels the session's watchers. `session.compacted`: clears the compacting flag so `chat.message` nudges resume. |
98
+ | `dispose` | Stops the version checker and the watcher poller, closes the DB. |
92
99
 
93
100
  Hook failures are logged with a `[thatch]` prefix — never swallowed silently.
94
101
  Two of these hooks were dead for weeks because failures were invisible.
@@ -26,6 +26,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
26
26
  | [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
27
27
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
28
28
  | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking) |
29
+ | [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
29
30
  | [setup.md](setup.md) | Setup system (markers, checkSetup, auto-refresh, binary resolution) |
30
31
  | [cli.md](cli.md) | CLI (subcommands, environment variables) |
31
32
  | [database.md](database.md) | Database (schema, migrations, WAL, 11 tables) |
@@ -16,9 +16,12 @@ Thatch supports three host agents from a single codebase:
16
16
  - **Cursor MCP**---same stdio MCP server, `--json` hook output format,
17
17
  `mcp__thatch__` tool prefix, system prompt in AGENTS.md
18
18
 
19
- All three paths expose the same 18 tool definitions from `src/tool-defs.ts`.
20
- The tools are host-agnostic: each takes args and a `CoreContext`
21
- (`{ db, model, defaultStore }`) and returns a string. The host-specific
19
+ All three paths share the tool definitions from `src/tool-defs.ts`: 18
20
+ host-agnostic tools plus opencode-only tools (currently six:
21
+ `get_session_info`, `session_search`/`session_get`, and the
22
+ `watch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
23
+ filters out. The shared tools take args and a `CoreContext`
24
+ (`{ db, model, defaultStore }`) and return a string. The host-specific
22
25
  wrapping---prefixing, transport, session hooks---lives outside the core.
23
26
 
24
27
  ## The three paths
@@ -147,7 +150,7 @@ an asymmetry a reviewer can catch by diffing the tool lines.
147
150
 
148
151
  ## Tool name prefixing
149
152
 
150
- The 18 tool definitions in `src/tool-defs.ts` use bare names:
153
+ The tool definitions in `src/tool-defs.ts` use bare names:
151
154
  `memory_remember`, `memory_recall`, `memory_list`, etc. Each host applies its
152
155
  own prefix.
153
156
 
@@ -177,13 +180,16 @@ behavior, with some features having no MCP counterpart.
177
180
  | code-review coordinator skill | Yes | No | No |
178
181
  | Background sub-agents | Yes (experimental) | No | No |
179
182
  | Tool batching | N/A (in-process) | PostToolBatch (batch) | postToolUse (per-tool) |
183
+ | `get_session_info` (session identity) | Yes | No | No |
184
+ | `session_search` / `session_get` (conversation archaeology) | Yes (tools) | CLI only (`thatch session ...`) | CLI only (`thatch session ...`) |
180
185
 
181
186
  For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
182
187
 
183
188
  ## Interactions with other features
184
189
 
185
- All 18 tools are shared across all hosts via the single source of truth in
186
- `src/tool-defs.ts`. Individual tool behavior is documented in
190
+ The 18 shared tools are available on all hosts via the single source of truth
191
+ in `src/tool-defs.ts`; opencode-only tools (e.g. `get_session_info`) are
192
+ filtered out by the MCP server. Individual tool behavior is documented in
187
193
  [memory-store.md](memory-store.md), [extraction.md](extraction.md),
188
194
  [prediction-engine.md](prediction-engine.md),
189
195
  [behavior-engine.md](behavior-engine.md), and
@@ -216,13 +222,13 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
216
222
  | `src/setup.ts` | Setup installer for Claude Code and Cursor---markers, hooks, skills |
217
223
  | `src/prompts.ts` | All three system prompt variants---`systemPrompt()`, `claudeInstructions()`, `cursorInstructions()` |
218
224
  | `src/tools.ts` | Thin opencode tool wrappers---imports tool-defs, adds `thatch_` prefix via `tool()` |
219
- | `src/tool-defs.ts` | Single source of truth for all 18 tool definitions---name, description, zod schema, execute |
225
+ | `src/tool-defs.ts` | Single source of truth for all tool definitions---name, description, zod schema, execute, `opencodeOnly` flag |
220
226
  | `bin/thatch` | CLI hook commands for MCP hosts---`reminder`, `buffer-tool`, `flush-tools`, `setup` |
221
227
 
222
228
  ## Key invariants
223
229
 
224
- - All three hosts share the same 18 tool definitions from `src/tool-defs.ts`. Adding a tool requires updating `TOOL_DEFS` and all three prompt functions.
230
+ - All hosts share the tool definitions from `src/tool-defs.ts` (18 shared + opencode-only tools marked `opencodeOnly: true`, which the MCP server filters out). Adding a tool requires updating `TOOL_DEFS` and all three prompt functions; opencode-only tools go in the opencode prompt only.
225
231
  - The MCP server always exposes bare names. The `mcp__thatch__` prefix is applied by the MCP client, not thatch.
226
232
  - The three system prompt variants are independent string constants in `src/prompts.ts` with no shared template. Editing shared prose in one requires mirroring in the other two, or they drift.
227
- - The tool list line in all three prompts must match `TOOL_DEFS` in `src/tool-defs.ts`. The historical failure mode: a tool is added to `TOOL_DEFS` but only two of three prompts are updated.
228
- - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts) have no MCP counterpart.
233
+ - The tool list line in all three prompts must match `TOOL_DEFS` in `src/tool-defs.ts` (opencode's list includes opencode-only tools; the MCP lists exclude them). The historical failure mode: a tool is added to `TOOL_DEFS` but only two of three prompts are updated.
234
+ - opencode-only features (direct extraction, compaction recovery, code-review coordinator, background sub-agents, TUI toasts, `get_session_info`, `session_search`/`session_get`, PR watchers) have no MCP counterpart. The `thatch session` CLI subcommands work anywhere - they only need an opencode.db file to read.
@@ -0,0 +1,150 @@
1
+ # Watchers
2
+
3
+ Watchers are the general mechanism for event-driven notifications from
4
+ external sources: the model registers a watch on something outside the
5
+ session, thatch polls it in the background, and the plugin prompts the
6
+ session when a watched event happens. The first source is GitHub pull
7
+ requests. The registry, poller, and delivery are source-agnostic - a
8
+ future source only adds a fetch-and-diff function and event types.
9
+
10
+ User-facing behavior is documented in [docs/user/watchers.md](../../user/watchers.md).
11
+
12
+ ## What it does
13
+
14
+ - `thatch_watch_create` / `thatch_watch_list` / `thatch_watch_cancel` tools (opencode-only)
15
+ - A background poller in the plugin process diffs each watcher's target
16
+ against its last-seen state
17
+ - Delivery prompts the session with a synthetic part - the same
18
+ mechanism opencode uses for background task completions
19
+ - Notifications carry pointer data only (author, URL, counts); the
20
+ model fetches content on demand with `gh`
21
+
22
+ ## The process-lifetime decision
23
+
24
+ The registry lives in plugin memory, never in SQLite. Three reasons:
25
+
26
+ 1. **Ownership is structural.** opencode loads thatch in-process, so
27
+ the plugin process that registered a watcher is the only process
28
+ that can poll and deliver it. A shared registry would need
29
+ claiming logic to answer "which process polls this?" - and
30
+ version-skewed opencode instances sharing a DB have already caused
31
+ corruption once.
32
+ 2. **No orphans.** When the process exits, watcher state evaporates
33
+ with it. There is no cleanup path to get wrong.
34
+ 3. **Precedent.** The extraction pipeline's opencode path is also
35
+ in-memory; the file-backed queue exists only for the MCP path's
36
+ separate short-lived hook processes.
37
+
38
+ The cost: a restart loses watches. Accepted because the session that
39
+ created them loses its conversational context too - a notification
40
+ into a session that no longer knows why it is being watched is worse
41
+ than a lost watch.
42
+
43
+ `session.deleted` also cancels the session's watchers and pending
44
+ events, and `dispose` stops the poller. These are cheap insurance;
45
+ process exit is the real lifetime boundary in both TUI and serve mode
46
+ (plugins load per-instance in the server process under
47
+ `opencode serve`, so watchers work there too).
48
+
49
+ ## How it works
50
+
51
+ ### Registration (`watch_create`)
52
+
53
+ `create()` immediately fetches the PR's state as a baseline via
54
+ `fetchPrState()` - four `gh api` calls (the pull, issue comments,
55
+ review comments, check runs on the head SHA), three of them in
56
+ parallel. The baseline doubles as validation: a missing PR, wrong
57
+ repo, or broken gh setup fails registration with a real error instead
58
+ of producing a watcher that never fires. Watch creation is also gated
59
+ on a cached `gh --version` availability check.
60
+
61
+ ### Polling
62
+
63
+ A `setInterval` loop (unref'd - it never keeps the process alive)
64
+ runs `poll()` every 60 seconds (configurable). Each cycle:
65
+ fetch the current state, run `diffPrState()` against the watcher's
66
+ last-seen state, filter events to the watched types, queue them, and
67
+ attempt delivery. Poll errors are per-watcher; one bad PR never
68
+ blocks the others. The diff is a pure function, so it is unit-tested
69
+ without any network access.
70
+
71
+ Event detection:
72
+
73
+ - comments by id monotonicity (fetch is sorted descending)
74
+ - commits by head SHA change
75
+ - status/merged by field change
76
+ - description by SHA-256 of the body text (the body itself is never
77
+ stored)
78
+ - CI by check-run transitions into a completed status
79
+
80
+ A diff emits at most 10 events per watcher per cycle, so comment
81
+ floods collapse into one batch.
82
+
83
+ ### Delivery
84
+
85
+ Events queue in an in-memory pending map keyed by session. Delivery
86
+ is gated by a `canDeliver` predicate the plugin supplies: the session
87
+ must be idle (tracked from `session.status` events) and not
88
+ compacting. Proactive prompts are never injected into a running turn.
89
+
90
+ When the gate is open, the plugin calls `client.session.promptAsync`
91
+ on the session with a synthetic text part built by
92
+ `watcherNotificationNudge()`. This triggers a full model turn - the
93
+ model reads the notification and decides whether to act. The
94
+ notification text borrows the background-task-completion framing
95
+ (system event, not user input, not approval to advance other work),
96
+ following the same load-bearing wording discipline as the extraction
97
+ nudge.
98
+
99
+ Failed or gated deliveries stay pending and retry on the next poll
100
+ cycle or when the session next goes idle (the event hook calls
101
+ `deliverPending()` there).
102
+
103
+ ### Event vocabulary
104
+
105
+ `pr_comment`, `pr_review_comment`, `pr_review_reply`, `pr_commit`,
106
+ `pr_status`, `pr_description`, `pr_ci`. The tool's `events` argument
107
+ selects a subset; the default is all seven.
108
+
109
+ ## Interactions with other features
110
+
111
+ - Extraction pipeline ([extraction.md](extraction.md)): the
112
+ notification turn's tool calls are buffered and extracted like any
113
+ other turn. Watch tools themselves are `thatch_*`-prefixed and
114
+ excluded from buffering.
115
+ - Session lifecycle ([session-lifecycle.md](session-lifecycle.md)):
116
+ `session.status` feeds the delivery gate; `session.deleted`
117
+ cancels watchers.
118
+ - Multi-host ([multi-host.md](multi-host.md)): opencode-only. MCP
119
+ hosts have no poller, no event bus, and no channel to start a turn
120
+ proactively. A future CLI daemon could close this gap.
121
+
122
+ ## Configuration
123
+
124
+ - `THATCH_WATCH_POLL_SECONDS` - poll interval (default 60)
125
+ - `THATCH_WATCH_TTL_MINUTES` - watcher time-to-live (default 480)
126
+ - `THATCH_WATCH_MAX_PER_SESSION` - active watchers per session
127
+ (default 5)
128
+
129
+ ## Source files
130
+
131
+ - `src/watchers.ts` - registry, poller, gh CLI runner, diff functions
132
+ - `src/tool-defs.ts` - the three watch tool definitions
133
+ - `src/index.ts` - registry construction, delivery closure,
134
+ session.status tracking, session.deleted cleanup, dispose
135
+ - `src/prompts.ts` - `watcherNotificationNudge()`, system prompt
136
+ Watchers section
137
+ - `tests/watchers.test.ts` - registry, poll, and diff unit tests
138
+ (mocked gh, no network)
139
+ - `tests/qa/auto/uc-095-watchers.ts` - end-to-end lifecycle against a
140
+ mocked gh runner
141
+
142
+ ## Adding a new source type
143
+
144
+ The registry is source-agnostic in shape but currently hardcoded to
145
+ the github-pr fetch/diff pair. A new source type means: new event
146
+ types in `WATCHER_EVENT_TYPES`, a fetch/diff function pair, a
147
+ `source_type` discriminator on `Watcher` (add it when the second
148
+ source arrives - do not parameterize early), and the tool surface for
149
+ registering it. Keep the pointer-only notification rule: external
150
+ content enters the context on explicit fetch, never via notification.
@@ -18,7 +18,7 @@ This document maps the feature parity and documents the remaining gaps.
18
18
 
19
19
  | Feature | OpenCode plugin | Claude Code MCP + hooks | Cursor MCP + hooks | Parity |
20
20
  |---------|----------------|------------------------|---------------------|--------|
21
- | **Tools** (18, single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix | **Full** |
21
+ | **Tools** (18 shared + 6 opencode-only, single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | **Full** for the 18 shared tools; `get_session_info` (session identity) and `session_search`/`session_get` (conversation archaeology) are opencode-only — MCP hosts have no session concept. The `thatch session` CLI subcommands cover the same archaeology on any host with an opencode.db file. The `watch_*` tools (PR watchers) are opencode-only — MCP hosts have no poller or proactive-prompt channel |
22
22
  | **System prompt** (store names, usage rules) | `experimental.chat.system.transform` — dynamic, repo baked in at runtime | CLAUDE.md static text appended by `thatch setup`; repo auto-detected by the MCP server at startup | AGENTS.md static text appended by `thatch setup`; repo auto-detected at startup | **Approximate** — static text persists through compaction, so the agent retains the usage instructions, but the dynamic per-turn refresh is lost |
23
23
  | **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |