@jeffober/thatch 0.1.36 → 0.1.38

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
@@ -89,7 +89,7 @@ Thatch gives your agent three layers of persistent context:
89
89
  `session_get`, opencode only) and as CLI subcommands (`thatch session
90
90
  list/get/transcript/search`, JSONL output designed for piping to `jq`).
91
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
92
+ only). The agent registers a watch on a GitHub PR or branch (main); thatch polls it in the
93
93
  background and prompts the session when comments, commits, CI results, or
94
94
  status changes arrive. Notifications carry pointer data only.
95
95
 
@@ -73,7 +73,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
73
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`. |
74
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
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. |
76
+ | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches GitHub PRs and branches 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. |
77
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. |
78
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. |
79
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]`. |
@@ -17,9 +17,9 @@ Thatch supports three host agents from a single codebase:
17
17
  `mcp__thatch__` tool prefix, system prompt in AGENTS.md
18
18
 
19
19
  All three paths share the tool definitions from `src/tool-defs.ts`: 18
20
- host-agnostic tools plus opencode-only tools (currently six:
20
+ host-agnostic tools plus opencode-only tools (currently seven:
21
21
  `get_session_info`, `session_search`/`session_get`, and the
22
- `watch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
22
+ `watch_create`/`watch_branch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
23
23
  filters out. The shared tools take args and a `CoreContext`
24
24
  (`{ db, model, defaultStore }`) and return a string. The host-specific
25
25
  wrapping---prefixing, transport, session hooks---lives outside the core.
@@ -1,6 +1,6 @@
1
1
  # QA System
2
2
 
3
- Thatch ships 93 QA use cases that verify end-to-end behavior. Each use case is
3
+ Thatch ships 95 QA use cases that verify end-to-end behavior. Each use case is
4
4
  a `bun:test` file that runs in an isolated environment — a temp-directory copy
5
5
  of the repo, not the working tree itself. Use cases are split into
6
6
  **automatable** (no LLM, fast) and **live** (spawns opencode, costs model
@@ -8,7 +8,7 @@ tokens).
8
8
 
9
9
  ## What it does
10
10
 
11
- - 93 use cases: 73 automatable + 20 live
11
+ - 95 use cases: 75 automatable + 20 live
12
12
  - Automatable use cases run without an LLM — they test CLI behavior, hooks, DB
13
13
  operations, and tool execution directly via bun assertions
14
14
  - Live use cases spawn real opencode sessions — they test the full
@@ -182,7 +182,7 @@ includes all of `tests/`), just not executed during `check`. See
182
182
  | `tests/qa/runner.ts` | `registerUseCase`, `UseCase` type, `ensureMaster`, `createFixture`, `runViaOpencode`, execution logic |
183
183
  | `tests/qa/auto/index.test.ts` | Automatable use case barrel — imports all auto use case modules |
184
184
  | `tests/qa/live/index.test.ts` | Live use case barrel — imports all live use case modules |
185
- | `tests/qa/auto/uc-NNN-*.ts` | 73 automatable use case files |
185
+ | `tests/qa/auto/uc-NNN-*.ts` | 75 automatable use case files |
186
186
  | `tests/qa/live/uc-NNN-*.ts` | 20 live use case files |
187
187
 
188
188
  No separate QA docs directory exists. QA use cases live in `tests/qa/` as executable tests, not docs.
@@ -3,15 +3,18 @@
3
3
  Watchers are the general mechanism for event-driven notifications from
4
4
  external sources: the model registers a watch on something outside the
5
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.
6
+ session when a watched event happens. Two sources ship: GitHub pull
7
+ requests (source `pr`) and GitHub branches (source `branch`, for
8
+ watching CI and workflow runs on main). The registry, poller, and
9
+ delivery are source-agnostic - a new source adds a fetch-and-diff
10
+ function pair and event types.
9
11
 
10
12
  User-facing behavior is documented in [docs/user/watchers.md](../../user/watchers.md).
11
13
 
12
14
  ## What it does
13
15
 
14
- - `thatch_watch_create` / `thatch_watch_list` / `thatch_watch_cancel` tools (opencode-only)
16
+ - `thatch_watch_create` (PRs) / `thatch_watch_branch_create` (branches) /
17
+ `thatch_watch_list` / `thatch_watch_cancel` tools (all opencode-only)
15
18
  - A background poller in the plugin process diffs each watcher's target
16
19
  against its last-seen state
17
20
  - Delivery prompts the session with a synthetic part - the same
@@ -51,12 +54,13 @@ process exit is the real lifetime boundary in both TUI and serve mode
51
54
  ### Registration (`watch_create`)
52
55
 
53
56
  `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.
57
+ `fetchPrState()` - five gh calls (the pull, issue comments, review
58
+ comments, check runs on the head SHA, and one GraphQL query for
59
+ review-thread resolution state), four of them in parallel. The
60
+ baseline doubles as validation: a missing PR, wrong repo, or broken
61
+ gh setup fails registration with a real error instead of producing a
62
+ watcher that never fires. Watch creation is also gated on a cached
63
+ `gh --version` availability check.
60
64
 
61
65
  ### Polling
62
66
 
@@ -76,6 +80,11 @@ Event detection:
76
80
  - description by SHA-256 of the body text (the body itself is never
77
81
  stored)
78
82
  - CI by check-run transitions into a completed status
83
+ - review-thread resolution by symmetric difference of the sorted
84
+ resolved-thread-id lists (thread `isResolved` state exists only in
85
+ the GraphQL API, so the fetch is a `gh api graphql -f query=...`
86
+ call; the `GhRunner` interface carries an args array to fit both
87
+ transports)
79
88
 
80
89
  A diff emits at most 10 events per watcher per cycle, so comment
81
90
  floods collapse into one batch.
@@ -102,9 +111,10 @@ cycle or when the session next goes idle (the event hook calls
102
111
 
103
112
  ### Event vocabulary
104
113
 
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.
114
+ `pr_comment`, `pr_review_comment`, `pr_review_reply`,
115
+ `pr_review_resolved`, `pr_commit`, `pr_status`, `pr_description`,
116
+ `pr_ci`. The tool's `events` argument selects a subset; the default is
117
+ all eight.
108
118
 
109
119
  ## Interactions with other features
110
120
 
@@ -141,10 +151,11 @@ selects a subset; the default is all seven.
141
151
 
142
152
  ## Adding a new source type
143
153
 
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.
154
+ The registry is a discriminated union over sources: `PrWatcher` and
155
+ `BranchWatcher` implement the same lifecycle (id, session, repo,
156
+ events, expiry, snapshot), and `poll()` dispatches to the source's
157
+ fetch/diff pair. Adding a third source means: new event types, a
158
+ `fetchXState`/`diffXState` pair, a new arm of the `Watcher` union, a
159
+ `#pollOne` branch, and the tool surface for registering it. Keep the
160
+ pointer-only notification rule: external content enters the context on
161
+ 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 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 |
21
+ | **Tools** (18 shared + 7 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 |
@@ -99,7 +99,7 @@ The synthesizer reuses the same verification rigor but has its own structure
99
99
  ## The two arrays
100
100
 
101
101
  ```ts
102
- const SHARED_SKILLS: SkillDef[] = [ /* 25 skills above */ ];
102
+ const SHARED_SKILLS: SkillDef[] = [ /* 26 skills above */ ];
103
103
  const OPENCODE_ONLY_SKILLS: SkillDef[] = [ /* code-review coordinator */ ];
104
104
  ```
105
105
 
@@ -11,7 +11,7 @@ This README is the overview. Each feature has its own guide:
11
11
 
12
12
  - [memory.md](memory.md): persistent memory store
13
13
  - [extraction.md](extraction.md): automatic fact extraction from tool calls
14
- - [watchers.md](watchers.md): event-driven notifications (PR watching)
14
+ - [watchers.md](watchers.md): event-driven notifications (PR and branch watching)
15
15
  - [prediction-engine.md](prediction-engine.md): user decision model
16
16
  - [behavior-engine.md](behavior-engine.md): agent self-discipline rules
17
17
  - [default-behaviors.md](default-behaviors.md): what ships automatically
@@ -1,29 +1,39 @@
1
1
  # Watchers
2
2
 
3
- Watchers monitor external sources - today, GitHub pull requests - and
4
- inject a notification into the conversation when something happens. You
5
- ask the agent to watch a PR; thatch polls it in the background; the
6
- agent gets woken up when a watched event occurs. No one has to keep
7
- asking "any updates yet?"
3
+ Watchers monitor external sources - today, GitHub pull requests and
4
+ branches - and inject a notification into the conversation when
5
+ something happens. You ask the agent to watch a PR or a branch; thatch
6
+ polls it in the background; the agent gets woken up when a watched
7
+ event occurs. No one has to keep asking "any updates yet?"
8
8
 
9
9
  ## What it does
10
10
 
11
- The agent calls `thatch_watch_create` to register a watcher on a PR.
12
- thatch captures the PR's current state as a baseline, then polls the
13
- GitHub API through the `gh` CLI once a minute. When the PR changes in a
14
- way the watcher cares about, the plugin prompts the agent with a
15
- notification.
11
+ The agent calls `thatch_watch_create` to register a watcher on a PR,
12
+ or `thatch_watch_branch_create` to register a watcher on a branch
13
+ (typically main). thatch captures the target's current state as a
14
+ baseline, then polls the GitHub API through the `gh` CLI once a
15
+ minute. When the target changes in a way the watcher cares about, the
16
+ plugin prompts the agent with a notification.
16
17
 
17
- Watched events:
18
+ PR watched events:
18
19
 
19
20
  - `pr_comment` - new top-level comments on the PR
20
21
  - `pr_review_comment` - new inline comments on the diff
21
22
  - `pr_review_reply` - replies to an existing review comment
23
+ - `pr_review_resolved` - a review thread resolved or reopened
22
24
  - `pr_commit` - new commits (the head SHA moved)
23
25
  - `pr_status` - the PR opened, closed, or merged
24
26
  - `pr_description` - title or description edited
25
27
  - `pr_ci` - a CI check run on the head SHA completed
26
28
 
29
+ Branch watched events:
30
+
31
+ - `branch_commit` - new commits land on the branch (e.g. a PR merged)
32
+ - `branch_ci` - a check run on the branch head completed
33
+ - `branch_workflow` - a GitHub Actions workflow run starts or finishes
34
+ on the branch, optionally filtered by workflow name (substring,
35
+ case-insensitive)
36
+
27
37
  Notifications are delivered as synthetic parts: the model sees them,
28
38
  the transcript does not. A notification triggers a model turn, so the
29
39
  agent can act on the event even while you are away.
@@ -51,6 +61,8 @@ do about it.
51
61
 
52
62
  Other tools:
53
63
 
64
+ - `thatch_watch_branch_create` - watch a branch (typically main) for
65
+ commits, CI check runs, and workflow runs
54
66
  - `thatch_watch_list` - shows the session's active watchers
55
67
  - `thatch_watch_cancel` - cancels one by id
56
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -101,15 +101,33 @@ export const server: Plugin = async ({ client, worktree }) => {
101
101
  // pointer data; the model fetches details itself with gh.
102
102
  const watchers = new WatcherRegistry({
103
103
  deliver: async (sessionID, events) => {
104
- const target = events[0]?.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/)?.[0]
105
- ?.replace(/github\.com\//, "")
106
- .replace(/\/pull\//, "#") ?? "watched PR";
104
+ // Events carry their watch's target label from the registry, so the
105
+ // notification header is correct for every source (PRs, branches,
106
+ // CI events whose URLs would not parse).
107
107
  await client.session.promptAsync({
108
108
  path: { id: sessionID },
109
109
  body: {
110
- parts: [{ type: "text", text: watcherNotificationNudge(target, events), synthetic: true }],
110
+ parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
111
111
  },
112
112
  });
113
+ // Toast: the notification part is TUI-hidden, so without this the
114
+ // user watching the session sees the model wake up with no visible
115
+ // cause. Announce what fired. Failed checks/workflows surface as a
116
+ // warning variant; routine events as info. Best-effort - the TUI may
117
+ // not be connected (headless mode).
118
+ const failed = events.some((e) => e.summary.includes("failure"));
119
+ const more = events.length > 1 ? ` +${events.length - 1} more` : "";
120
+ try {
121
+ await client.tui.showToast({
122
+ body: {
123
+ message: `\u23F0 ${events[0]?.target ?? "watched target"}: ${events[0]?.summary ?? ""}${more}`,
124
+ variant: failed ? "warning" : "info",
125
+ duration: 5000,
126
+ },
127
+ });
128
+ } catch {
129
+ // TUI may not be connected. Best-effort.
130
+ }
113
131
  },
114
132
  canDeliver: (sessionID) =>
115
133
  !compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
package/src/prompts.ts CHANGED
@@ -25,7 +25,8 @@ Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
25
25
  thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
26
26
  thatch_behavior_delete, thatch_get_session_info,
27
27
  thatch_session_search, thatch_session_get,
28
- thatch_watch_create, thatch_watch_list, thatch_watch_cancel
28
+ thatch_watch_create, thatch_watch_branch_create,
29
+ thatch_watch_list, thatch_watch_cancel
29
30
 
30
31
  ## Stores
31
32
 
@@ -67,7 +68,6 @@ workflows. The host auto-discovers them, but reach for them proactively:
67
68
  - \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
68
69
  - \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
69
70
  - \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
70
-
71
71
  ## When to Write
72
72
 
73
73
  **One signal is enough.** Do not wait for confirmation.
@@ -112,12 +112,15 @@ intractable prompt, not a transient hang.
112
112
  ## Watchers
113
113
 
114
114
  Use thatch_watch_create to watch a GitHub PR for events (comments, review
115
- comments and replies, commits, status changes, description edits, CI check
116
- completions). thatch polls the PR in the background and injects a
117
- notification into this session when a watched event happens. The
118
- notification carries pointer data only (author, URL); fetch details with
119
- the gh CLI when you decide to act. When the user asks you to watch
120
- something, state your handling policy for notifications out loud first
115
+ comments and replies, review thread resolutions, commits, status changes,
116
+ description edits, CI check completions), or thatch_watch_branch_create to
117
+ watch a branch (typically main) for commit landings, check-run completions,
118
+ and workflow runs on the branch. thatch polls in the
119
+ background and injects a notification into this session when a watched event
120
+ happens. The
121
+ notification carries pointer data only (author, URL); fetch
122
+ details with the gh CLI when you decide to act. When the user asks you to
123
+ watch something, state your handling policy for notifications out loud first
121
124
  ("I'll report human comments and handle bugbot replies myself") so the
122
125
  notification turn knows what to do. Treat watcher notifications like
123
126
  background task completions: they are system events, not user input, and
@@ -286,7 +289,8 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
286
289
  \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
287
290
  \`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
288
291
  \`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`. Bare names used below for readability.
289
- get_session_info, session_search, session_get, watch_create, watch_list,
292
+ get_session_info, session_search, session_get, watch_create,
293
+ watch_branch_create, watch_list,
290
294
  and watch_cancel are intentionally absent: they are opencode-only (MCP
291
295
  hosts have no session concept, session database, or proactive-prompt
292
296
  channel), so do not expect them here.
@@ -331,7 +335,6 @@ workflows. The host auto-discovers them, but reach for them proactively:
331
335
  - \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
332
336
  - \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
333
337
  - \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
334
-
335
338
  ## When to Write
336
339
 
337
340
  **One signal is enough.** Do not wait for confirmation.
package/src/tool-defs.ts CHANGED
@@ -3,7 +3,7 @@ import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
4
  import { predictionVerb } from "./prompts";
5
5
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
6
- import { WATCHER_EVENT_TYPES, type WatcherRegistry, type WatcherEventType } from "./watchers";
6
+ import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
7
7
 
8
8
  // Near-duplicate thresholds for matcher/prediction/behavior dedup at
9
9
  // creation time. Matches the thatch_find_duplicates threshold (0.85).
@@ -960,12 +960,13 @@ const watchCreateDef: ToolDef = {
960
960
  description:
961
961
  "Watch a GitHub PR for events and get notified in this session when they " +
962
962
  "happen. Events: new top-level comments, new inline review comments, " +
963
- "replies to review comments, new commits (head SHA change), PR status " +
964
- "changes, title/description edits, and completed CI check runs. The " +
965
- "watcher polls in the background and injects a notification prompt; " +
966
- "notifications carry pointers only (author, URL) - fetch details yourself " +
967
- "with the gh CLI when you decide to act. Watches live until cancelled, " +
968
- "the session ends, or opencode restarts. Requires the gh CLI.",
963
+ "replies to review comments, review thread resolutions, new commits " +
964
+ "(head SHA change), PR status changes, title/description edits, and " +
965
+ "completed CI check runs. The watcher polls in the background and " +
966
+ "injects a notification prompt; notifications carry pointers only " +
967
+ "(author, URL) - fetch details yourself with the gh CLI when you decide " +
968
+ "to act. Watches live until cancelled, the session ends, or opencode " +
969
+ "restarts. Requires the gh CLI.",
969
970
  args: {
970
971
  pr: z.number().int().positive().describe(
971
972
  "The PR number to watch.",
@@ -973,8 +974,8 @@ const watchCreateDef: ToolDef = {
973
974
  repo: z.string().optional().describe(
974
975
  "GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
975
976
  ),
976
- events: z.array(z.enum(WATCHER_EVENT_TYPES as [WatcherEventType, ...WatcherEventType[]])).optional().describe(
977
- "Which event types to watch. Omit for all event types.",
977
+ events: z.array(z.enum(PR_EVENT_TYPES as [PrWatcherEventType, ...PrWatcherEventType[]])).optional().describe(
978
+ "Which event types to watch. Omit for all PR event types.",
978
979
  ),
979
980
  },
980
981
  opencodeOnly: true,
@@ -986,8 +987,8 @@ const watchCreateDef: ToolDef = {
986
987
  return "Watching is unavailable: no watcher registry was wired by this host.";
987
988
  }
988
989
  const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
989
- const events = (args.events as WatcherEventType[] | undefined) ?? [...WATCHER_EVENT_TYPES];
990
- const result = await ctx.watchers.create(host.sessionID, repo, args.pr as number, events);
990
+ const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
991
+ const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events);
991
992
  if (!result.ok) return `Watcher not created: ${result.error}`;
992
993
  const w = result.watcher;
993
994
  return (
@@ -1025,11 +1026,79 @@ const watchListDef: ToolDef = {
1025
1026
  if (watchers.length === 0) return "No active watchers.";
1026
1027
  const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
1027
1028
  return watchers
1028
- .map((w) => `${w.id}: ${w.repo}#${w.pr} events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`)
1029
+ .map((w) => {
1030
+ const target = w.source === "pr" ? `${w.repo}#${w.pr}` : `${w.repo}@${w.branch}`;
1031
+ return `${w.id}: ${target} [${w.source}] events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`;
1032
+ })
1029
1033
  .join("\n");
1030
1034
  },
1031
1035
  };
1032
1036
 
1037
+ /**
1038
+ * Registers a background watcher on a GitHub branch (typically main): commit
1039
+ * landings, check-run completions on the head, and workflow runs. This is
1040
+ * how the model watches "CI against main" or waits for a post-merge build.
1041
+ * opencode-only, like the PR watch tools.
1042
+ */
1043
+ const watchBranchCreateDef: ToolDef = {
1044
+ name: "watch_branch_create",
1045
+ description:
1046
+ "Watch a GitHub branch (typically main) and get notified in this session " +
1047
+ "when things happen on it: branch_commit when new commits land (e.g. a " +
1048
+ "PR merged), branch_ci when a check run on the head completes, and " +
1049
+ "branch_workflow when a GitHub Actions workflow run starts or finishes " +
1050
+ "on the branch. Optionally filter to workflow names (substring match). " +
1051
+ "The watcher polls in the background and injects a notification prompt; " +
1052
+ "notifications carry pointers only - fetch details with the gh CLI when " +
1053
+ "you decide to act. Watches live until cancelled, the session ends, or " +
1054
+ "opencode restarts. Requires the gh CLI.",
1055
+ args: {
1056
+ branch: z.string().describe(
1057
+ "The branch name to watch, e.g. main.",
1058
+ ),
1059
+ repo: z.string().optional().describe(
1060
+ "GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
1061
+ ),
1062
+ events: z.array(z.enum(BRANCH_EVENT_TYPES as [BranchWatcherEventType, ...BranchWatcherEventType[]])).optional().describe(
1063
+ "Which event types to watch. Omit for all branch event types.",
1064
+ ),
1065
+ workflows: z.array(z.string()).optional().describe(
1066
+ "Only notify for workflow runs whose name contains one of these substrings (case-insensitive). Omit for all workflows.",
1067
+ ),
1068
+ },
1069
+ opencodeOnly: true,
1070
+ async execute(args, ctx, host) {
1071
+ if (!host) {
1072
+ return "Watching is unavailable: this host did not provide a session context.";
1073
+ }
1074
+ if (!ctx.watchers) {
1075
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1076
+ }
1077
+ const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1078
+ const events = (args.events as BranchWatcherEventType[] | undefined) ?? [...BRANCH_EVENT_TYPES];
1079
+ const result = await ctx.watchers.createBranch(
1080
+ host.sessionID,
1081
+ repo,
1082
+ args.branch as string,
1083
+ events,
1084
+ (args.workflows as string[] | undefined) ?? [],
1085
+ );
1086
+ if (!result.ok) return `Watcher not created: ${result.error}`;
1087
+ const w = result.watcher;
1088
+ return (
1089
+ `[watching] ${w.repo}@${w.branch}\n` +
1090
+ `id: ${w.id}\n` +
1091
+ `events: ${w.events.join(", ")}\n` +
1092
+ (w.workflows.length > 0 ? `workflow filter: ${w.workflows.join(", ")}\n` : "") +
1093
+ `head: ${w.state.headSha.slice(0, 7)}\n\n` +
1094
+ `The baseline is captured now - only changes from this point notify. ` +
1095
+ `You will receive a system notification in this session when a watched event happens. ` +
1096
+ `State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
1097
+ `since the notification itself carries only pointers.`
1098
+ );
1099
+ },
1100
+ };
1101
+
1033
1102
  /**
1034
1103
  * Cancels one watcher. opencode-only, session-scoped: cancelling by id only
1035
1104
  * works for the session that created it.
@@ -1038,7 +1107,7 @@ const watchCancelDef: ToolDef = {
1038
1107
  name: "watch_cancel",
1039
1108
  description: "Cancel one of this session's watchers by id. opencode-only.",
1040
1109
  args: {
1041
- id: z.string().describe("The watcher id from watch_create or watch_list."),
1110
+ id: z.string().describe("The watcher id from watch_create, watch_branch_create, or watch_list."),
1042
1111
  },
1043
1112
  opencodeOnly: true,
1044
1113
  async execute(args, ctx, host) {
@@ -1083,6 +1152,7 @@ export const TOOL_DEFS: ToolDef[] = [
1083
1152
  sessionSearchDef,
1084
1153
  sessionGetDef,
1085
1154
  watchCreateDef,
1155
+ watchBranchCreateDef,
1086
1156
  watchListDef,
1087
1157
  watchCancelDef,
1088
1158
  ];
package/src/watchers.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * Watchers: event-driven notifications from external sources, delivered into
3
3
  * a live opencode session as injected prompts.
4
4
  *
5
- * The motivating case: the model registers a watcher on a GitHub PR, and
6
- * thatch polls the PR in the background. When something the watcher cares
7
- * about happens (new comments, commits, CI results), the plugin prompts the
8
- * session with a synthetic notification part - the same delivery mechanism
9
- * opencode itself uses for background task completions.
5
+ * The registry supports multiple source types behind one mechanism. The
6
+ * first source was GitHub pull requests (source "pr"); GitHub branches
7
+ * (source "branch") arrived second, for watching CI and workflow runs
8
+ * against main. A source contributes: event types, a fetch function that
9
+ * produces a snapshot of the watched state, and a pure diff between two
10
+ * snapshots. Everything else - registry, pending queue, delivery gating -
11
+ * is source-agnostic.
10
12
  *
11
13
  * Lifetime is deliberately process-scoped. The registry lives in plugin
12
14
  * memory, never in SQLite: opencode loads thatch in-process, so the plugin
@@ -21,38 +23,50 @@
21
23
  */
22
24
 
23
25
  // ---------------------------------------------------------------------------
24
- // Types
26
+ // Event types
25
27
  // ---------------------------------------------------------------------------
26
28
 
27
- /**
28
- * The event vocabulary for the github-pr source. Each type maps to one diff
29
- * against the watcher's last-seen PR state. Notifications carry pointer data
30
- * (author, URL, counts) rather than content, so text authored by strangers
31
- * on GitHub never enters the model's context directly - the model fetches
32
- * details on demand with the gh CLI.
33
- */
34
- export type WatcherEventType =
29
+ /** Event types for source "pr". */
30
+ export type PrWatcherEventType =
35
31
  | "pr_comment"
36
32
  | "pr_review_comment"
37
33
  | "pr_review_reply"
34
+ | "pr_review_resolved"
38
35
  | "pr_commit"
39
36
  | "pr_status"
40
37
  | "pr_description"
41
38
  | "pr_ci";
42
39
 
43
- export const WATCHER_EVENT_TYPES: WatcherEventType[] = [
40
+ /** Event types for source "branch" (typically main): commits land, CI and workflow runs execute. */
41
+ export type BranchWatcherEventType = "branch_commit" | "branch_ci" | "branch_workflow";
42
+
43
+ export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType;
44
+
45
+ export const PR_EVENT_TYPES: PrWatcherEventType[] = [
44
46
  "pr_comment",
45
47
  "pr_review_comment",
46
48
  "pr_review_reply",
49
+ "pr_review_resolved",
47
50
  "pr_commit",
48
51
  "pr_status",
49
52
  "pr_description",
50
53
  "pr_ci",
51
54
  ];
52
55
 
56
+ export const BRANCH_EVENT_TYPES: BranchWatcherEventType[] = [
57
+ "branch_commit",
58
+ "branch_ci",
59
+ "branch_workflow",
60
+ ];
61
+
62
+ /** Every event type across all sources - for diagnostics and tests. */
63
+ export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES];
64
+
53
65
  /** A single detected change, ready for delivery. Pointer data only. */
54
66
  export interface WatcherEvent {
55
67
  type: WatcherEventType;
68
+ /** Which watch produced this event, e.g. "acme/widgets#7" or "acme/widgets@main". */
69
+ target: string;
56
70
  /** Human-readable, content-free summary: who/what/where, never body text. */
57
71
  summary: string;
58
72
  url: string;
@@ -67,6 +81,31 @@ export interface CommentRef {
67
81
  isReply: boolean;
68
82
  }
69
83
 
84
+ /** One CI check run, keyed by id in state snapshots. */
85
+ export interface CheckRunRef {
86
+ name: string;
87
+ status: string;
88
+ conclusion: string | null;
89
+ url: string | null;
90
+ }
91
+
92
+ /** One GitHub Actions workflow run, keyed by run id. */
93
+ export interface WorkflowRunRef {
94
+ name: string;
95
+ status: string;
96
+ conclusion: string | null;
97
+ url: string;
98
+ /** What triggered the run: push, pull_request, schedule, workflow_run, ... */
99
+ event: string;
100
+ /** The branch the run's head commit is on (a tag-triggered run puts the TAG name here). */
101
+ headBranch: string;
102
+ headSha: string;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Watchers: a discriminated union over sources
107
+ // ---------------------------------------------------------------------------
108
+
70
109
  /**
71
110
  * The last-seen state of a PR, captured at watch creation and updated each
72
111
  * poll. Comment arrays are kept whole so each diff can name individual new
@@ -83,25 +122,56 @@ export interface PrState {
83
122
  lastReviewCommentId: number;
84
123
  issueComments: CommentRef[];
85
124
  reviewComments: CommentRef[];
86
- /** Check runs on the head SHA, keyed by check run id. */
87
- checkRuns: Record<string, { name: string; status: string; conclusion: string | null; url: string | null }>;
125
+ /** Sorted ids of review threads currently in the resolved state. */
126
+ resolvedThreads: string[];
127
+ checkRuns: Record<string, CheckRunRef>;
88
128
  }
89
129
 
90
- export interface Watcher {
130
+ /** The last-seen state of a branch. */
131
+ export interface BranchState {
132
+ headSha: string;
133
+ checkRuns: Record<string, CheckRunRef>;
134
+ /** Workflow runs on the branch, keyed by run id (last 20). */
135
+ workflowRuns: Record<string, WorkflowRunRef>;
136
+ }
137
+
138
+ export interface PrWatcher {
91
139
  id: string;
140
+ source: "pr";
92
141
  sessionID: string;
93
142
  /** owner/repo */
94
143
  repo: string;
95
144
  pr: number;
96
- events: WatcherEventType[];
145
+ events: PrWatcherEventType[];
97
146
  /** Epoch ms. When now > expiresAt the watcher is silently dropped. */
98
147
  expiresAt: number;
99
148
  createdAt: number;
100
149
  state: PrState;
101
150
  }
102
151
 
103
- /** Runs one GitHub REST API path via the gh CLI and parses the JSON response. */
104
- export type GhRunner = (apiPath: string) => Promise<unknown>;
152
+ export interface BranchWatcher {
153
+ id: string;
154
+ source: "branch";
155
+ sessionID: string;
156
+ /** owner/repo */
157
+ repo: string;
158
+ branch: string;
159
+ events: BranchWatcherEventType[];
160
+ /** Substring filters on workflow run names; empty means all workflows. */
161
+ workflows: string[];
162
+ expiresAt: number;
163
+ createdAt: number;
164
+ state: BranchState;
165
+ }
166
+
167
+ export type Watcher = PrWatcher | BranchWatcher;
168
+
169
+ /** Runs one GitHub API call via the gh CLI and parses the JSON response.
170
+ * The array is the argument list after `gh api`: a REST path like
171
+ * ["/repos/o/r/pulls/7"] or a GraphQL invocation like
172
+ * ["graphql", "-f", "query={...}"]. Array-shaped so both transports fit
173
+ * without the callers string-concatenating shell quotes. */
174
+ export type GhRunner = (apiArgs: string[]) => Promise<unknown>;
105
175
 
106
176
  export interface WatcherRegistryOptions {
107
177
  /** Delivers a batch of events for a session. Injected so tests never spawn. */
@@ -128,8 +198,8 @@ export interface WatcherRegistryOptions {
128
198
  * existing gh session - thatch never sees or stores a token. Throws on
129
199
  * non-zero exit or unparseable output.
130
200
  */
131
- export async function ghApiRun(apiPath: string): Promise<unknown> {
132
- const proc = Bun.spawn(["gh", "api", apiPath], {
201
+ export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
202
+ const proc = Bun.spawn(["gh", "api", ...apiArgs], {
133
203
  stdout: "pipe",
134
204
  stderr: "pipe",
135
205
  });
@@ -141,7 +211,7 @@ export async function ghApiRun(apiPath: string): Promise<unknown> {
141
211
  ]);
142
212
  if (exitCode !== 0) {
143
213
  const stderr = await new Response(proc.stderr).text();
144
- throw new Error(`gh api ${apiPath} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
214
+ throw new Error(`gh api ${apiArgs.join(" ")} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
145
215
  }
146
216
  return JSON.parse(stdout);
147
217
  } finally {
@@ -176,7 +246,7 @@ export function _resetGhAvailability(): void {
176
246
  }
177
247
 
178
248
  // ---------------------------------------------------------------------------
179
- // PR state construction and diffing
249
+ // Snapshot construction
180
250
  // ---------------------------------------------------------------------------
181
251
 
182
252
  function sha256(text: string): string {
@@ -206,32 +276,9 @@ function maxCommentId(refs: CommentRef[]): number {
206
276
  return refs.reduce((max, c) => Math.max(max, c.id), 0);
207
277
  }
208
278
 
209
- /**
210
- * Fetches the current state of a PR plus everything the diff functions need.
211
- * Four gh calls per poll per watcher: the PR itself, issue comments
212
- * (top-level PR conversation), review comments (inline diff comments), and
213
- * check runs on the head SHA. Comment and check-run fetches run in parallel.
214
- */
215
- export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
216
- const pull = (await gh(`/repos/${repo}/pulls/${pr}`)) as {
217
- head?: { sha?: string };
218
- state?: string;
219
- merged?: boolean;
220
- title?: string;
221
- body?: string | null;
222
- };
223
- const headSha = pull.head?.sha ?? "";
224
- const [issueComments, reviewComments, checkRuns] = await Promise.all([
225
- gh(`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`),
226
- gh(`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`),
227
- gh(`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`),
228
- ]);
229
-
230
- const issueRefs = toCommentRefs(issueComments);
231
- const reviewRefs = toCommentRefs(reviewComments);
232
-
233
- const runs: PrState["checkRuns"] = {};
234
- const rawRuns = (checkRuns as { check_runs?: Array<{
279
+ function parseCheckRuns(raw: unknown): Record<string, CheckRunRef> {
280
+ const runs: Record<string, CheckRunRef> = {};
281
+ const rawRuns = (raw as { check_runs?: Array<{
235
282
  id?: number;
236
283
  name?: string;
237
284
  status?: string;
@@ -247,6 +294,76 @@ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Prom
247
294
  url: run.html_url ?? null,
248
295
  };
249
296
  }
297
+ return runs;
298
+ }
299
+
300
+ function parseWorkflowRuns(raw: unknown): Record<string, WorkflowRunRef> {
301
+ const runs: Record<string, WorkflowRunRef> = {};
302
+ const rawRuns = (raw as { workflow_runs?: Array<{
303
+ id?: number;
304
+ name?: string;
305
+ status?: string;
306
+ conclusion?: string | null;
307
+ html_url?: string;
308
+ event?: string;
309
+ head_branch?: string;
310
+ head_sha?: string;
311
+ }> }).workflow_runs ?? [];
312
+ for (const run of rawRuns) {
313
+ if (run.id === undefined) continue;
314
+ runs[String(run.id)] = {
315
+ name: run.name ?? "workflow",
316
+ status: run.status ?? "unknown",
317
+ conclusion: run.conclusion ?? null,
318
+ url: run.html_url ?? "",
319
+ event: run.event ?? "unknown",
320
+ headBranch: run.head_branch ?? "",
321
+ headSha: run.head_sha ?? "",
322
+ };
323
+ }
324
+ return runs;
325
+ }
326
+
327
+ function parseResolvedThreads(raw: unknown): string[] {
328
+ const nodes = (raw as {
329
+ data?: { repository?: { pullRequest?: { reviewThreads?: { nodes?: Array<{ id?: string; isResolved?: boolean }> } } } };
330
+ }).data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
331
+ return nodes
332
+ .filter((n) => n.id && n.isResolved)
333
+ .map((n) => n.id!)
334
+ .sort();
335
+ }
336
+
337
+ /**
338
+ * Fetches the current state of a PR plus everything the diff functions need.
339
+ * Five gh calls per poll per watcher: the PR itself, issue comments
340
+ * (top-level PR conversation), review comments (inline diff comments), check
341
+ * runs on the head SHA, and one GraphQL query for review-thread resolution
342
+ * state (isResolved exists only in GraphQL, not the REST API). All
343
+ * non-pull fetches run in parallel.
344
+ */
345
+ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
346
+ const pull = (await gh([`/repos/${repo}/pulls/${pr}`])) as {
347
+ head?: { sha?: string };
348
+ state?: string;
349
+ merged?: boolean;
350
+ title?: string;
351
+ body?: string | null;
352
+ };
353
+ const headSha = pull.head?.sha ?? "";
354
+ const [owner, repoName] = repo.split("/");
355
+ const graphqlQuery =
356
+ `query { repository(owner: "${owner}", name: "${repoName}") { ` +
357
+ `pullRequest(number: ${pr}) { reviewThreads(first: 100) { nodes { id isResolved } } } } }`;
358
+ const [issueComments, reviewComments, checkRuns, threads] = await Promise.all([
359
+ gh([`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`]),
360
+ gh([`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`]),
361
+ gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]),
362
+ gh(["graphql", "-f", `query=${graphqlQuery}`]),
363
+ ]);
364
+
365
+ const issueRefs = toCommentRefs(issueComments);
366
+ const reviewRefs = toCommentRefs(reviewComments);
250
367
 
251
368
  return {
252
369
  headSha,
@@ -258,59 +375,168 @@ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Prom
258
375
  lastReviewCommentId: maxCommentId(reviewRefs),
259
376
  issueComments: issueRefs,
260
377
  reviewComments: reviewRefs,
261
- checkRuns: runs,
378
+ resolvedThreads: parseResolvedThreads(threads),
379
+ checkRuns: parseCheckRuns(checkRuns),
262
380
  };
263
381
  }
264
382
 
383
+ /**
384
+ * Fetches the current state of a branch: the head commit, check runs on that
385
+ * head, and recent workflow runs relevant to the branch. The branch and
386
+ * workflow-run calls run in parallel; check runs need the head SHA first.
387
+ *
388
+ * Workflow runs are fetched WITHOUT the API's branch filter and filtered
389
+ * client-side: keep runs whose head_branch matches the branch (push runs)
390
+ * or whose head_sha is the branch's current head (tag-triggered runs like a
391
+ * release Publish run - their head_branch is the tag name, so the API's
392
+ * branch filter would hide exactly the runs a release watch exists for).
393
+ */
394
+ export async function fetchBranchState(gh: GhRunner, repo: string, branch: string): Promise<BranchState> {
395
+ const [branchInfo, runs] = await Promise.all([
396
+ gh([`/repos/${repo}/branches/${branch}`]),
397
+ gh([`/repos/${repo}/actions/runs?per_page=30`]),
398
+ ]);
399
+ const headSha = (branchInfo as { commit?: { sha?: string } }).commit?.sha ?? "";
400
+ const allRuns = parseWorkflowRuns(runs);
401
+ const relevant = Object.fromEntries(
402
+ Object.entries(allRuns).filter(([, run]) => run.headBranch === branch || run.headSha === headSha),
403
+ );
404
+ const checkRuns = await gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]);
405
+ return {
406
+ headSha,
407
+ checkRuns: parseCheckRuns(checkRuns),
408
+ workflowRuns: relevant,
409
+ };
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Diffing
414
+ // ---------------------------------------------------------------------------
415
+
265
416
  /** At most this many events per watcher per cycle; comment floods collapse. */
266
417
  const MAX_EVENTS_PER_DIFF = 10;
267
418
 
419
+ /**
420
+ * Shared check-run diff: emits one event per run that reaches a completed
421
+ * status. A run that was already completed before produces nothing; a run
422
+ * that appears between polls already completed produces one.
423
+ */
424
+ function diffCheckRuns(
425
+ before: Record<string, CheckRunRef>,
426
+ after: Record<string, CheckRunRef>,
427
+ eventType: "pr_ci" | "branch_ci",
428
+ fallbackUrl: string,
429
+ out: WatcherEvent[],
430
+ ): void {
431
+ for (const [runId, run] of Object.entries(after)) {
432
+ const previous = before[runId];
433
+ if (run.status !== "completed") continue;
434
+ if (previous && previous.status === "completed") continue;
435
+ const conclusion = run.conclusion ? ` (${run.conclusion})` : "";
436
+ out.push({
437
+ type: eventType,
438
+ target: "",
439
+ summary: `check "${run.name}" completed${conclusion}`,
440
+ url: run.url ?? fallbackUrl,
441
+ });
442
+ }
443
+ }
444
+
268
445
  /**
269
446
  * Pure diff between the last-seen and current PR state. Detects comments by
270
447
  * id monotonicity, commits by head SHA, description by body hash, CI by
271
- * check-run transitions to a completed status.
448
+ * check-run transitions, thread resolution by symmetric difference of the
449
+ * sorted resolved-id lists.
272
450
  */
273
- export function diffPrState(before: PrState, after: PrState, url: string): WatcherEvent[] {
451
+ export function diffPrState(before: PrState, after: PrState, target: string, baseUrl: string): WatcherEvent[] {
274
452
  const events: WatcherEvent[] = [];
275
453
 
276
454
  if (after.headSha !== before.headSha && after.headSha) {
277
- events.push({
278
- type: "pr_commit",
279
- summary: `head moved to ${after.headSha.slice(0, 7)}`,
280
- url,
281
- });
455
+ events.push({ type: "pr_commit", target, summary: `head moved to ${after.headSha.slice(0, 7)}`, url: baseUrl });
282
456
  }
283
457
 
284
458
  if (after.state !== before.state || after.merged !== before.merged) {
285
459
  const status = after.merged ? "merged" : after.state;
286
- events.push({ type: "pr_status", summary: `PR is now ${status}`, url });
460
+ events.push({ type: "pr_status", target, summary: `PR is now ${status}`, url: baseUrl });
287
461
  }
288
462
 
289
463
  if (after.bodySha !== before.bodySha || after.title !== before.title) {
290
- events.push({ type: "pr_description", summary: "PR title or description changed", url });
464
+ events.push({ type: "pr_description", target, summary: "PR title or description changed", url: baseUrl });
291
465
  }
292
466
 
293
467
  const fresh = (refs: CommentRef[], lastSeenId: number) =>
294
468
  refs.filter((c) => c.id > lastSeenId);
295
469
 
296
470
  for (const c of fresh(after.issueComments, before.lastIssueCommentId)) {
297
- events.push({ type: "pr_comment", summary: `comment by ${c.author}`, url: c.url || url });
471
+ events.push({ type: "pr_comment", target, summary: `comment by ${c.author}`, url: c.url || baseUrl });
298
472
  }
299
473
  for (const c of fresh(after.reviewComments, before.lastReviewCommentId)) {
300
474
  events.push({
301
475
  type: c.isReply ? "pr_review_reply" : "pr_review_comment",
476
+ target,
302
477
  summary: `comment by ${c.author}`,
303
- url: c.url || url,
478
+ url: c.url || baseUrl,
304
479
  });
305
480
  }
306
481
 
307
- for (const [runId, run] of Object.entries(after.checkRuns)) {
308
- const previous = before.checkRuns[runId];
309
- const completed = run.status === "completed";
310
- if (!completed) continue;
311
- if (previous && previous.status === "completed") continue;
312
- const conclusion = run.conclusion ? ` (${run.conclusion})` : "";
313
- events.push({ type: "pr_ci", summary: `check "${run.name}" completed${conclusion}`, url: run.url ?? url });
482
+ const afterResolved = new Set(after.resolvedThreads);
483
+ const beforeResolved = new Set(before.resolvedThreads);
484
+ for (const id of after.resolvedThreads) {
485
+ if (!beforeResolved.has(id)) {
486
+ events.push({ type: "pr_review_resolved", target, summary: "review thread resolved", url: baseUrl });
487
+ }
488
+ }
489
+ for (const id of before.resolvedThreads) {
490
+ if (!afterResolved.has(id)) {
491
+ events.push({ type: "pr_review_resolved", target, summary: "review thread reopened", url: baseUrl });
492
+ }
493
+ }
494
+
495
+ diffCheckRuns(before.checkRuns, after.checkRuns, "pr_ci", baseUrl, events);
496
+
497
+ return events.slice(0, MAX_EVENTS_PER_DIFF);
498
+ }
499
+
500
+ /**
501
+ * Pure diff between the last-seen and current branch state: head movement,
502
+ * check-run completions on the head, and workflow runs appearing or
503
+ * transitioning on the branch.
504
+ */
505
+ export function diffBranchState(
506
+ before: BranchState,
507
+ after: BranchState,
508
+ target: string,
509
+ repo: string,
510
+ ): WatcherEvent[] {
511
+ const events: WatcherEvent[] = [];
512
+
513
+ if (after.headSha !== before.headSha && after.headSha) {
514
+ events.push({
515
+ type: "branch_commit",
516
+ target,
517
+ summary: `head moved to ${after.headSha.slice(0, 7)}`,
518
+ url: `https://github.com/${repo}/commit/${after.headSha}`,
519
+ });
520
+ }
521
+
522
+ diffCheckRuns(before.checkRuns, after.checkRuns, "branch_ci", `https://github.com/${repo}/actions`, events);
523
+
524
+ for (const [runId, run] of Object.entries(after.workflowRuns)) {
525
+ const previous = before.workflowRuns[runId];
526
+ const transition = !previous
527
+ ? `started (${run.status})`
528
+ : previous.status !== run.status
529
+ ? run.status === "completed"
530
+ ? `completed (${run.conclusion ?? "no conclusion"})`
531
+ : `${previous.status} -> ${run.status}`
532
+ : null;
533
+ if (!transition) continue;
534
+ events.push({
535
+ type: "branch_workflow",
536
+ target,
537
+ summary: `workflow "${run.name}" ${transition} [${run.event}]`,
538
+ url: run.url,
539
+ });
314
540
  }
315
541
 
316
542
  return events.slice(0, MAX_EVENTS_PER_DIFF);
@@ -371,24 +597,20 @@ export class WatcherRegistry {
371
597
  // -- CRUD ----------------------------------------------------------------
372
598
 
373
599
  /**
374
- * Registers a watcher and captures the baseline state immediately. The
600
+ * Registers a PR watcher and captures the baseline state immediately. The
375
601
  * baseline fetch doubles as validation: a bad repo, missing PR, or broken
376
602
  * gh setup fails here with a real error instead of a watcher that never
377
603
  * fires.
378
604
  */
379
- async create(
605
+ async createPr(
380
606
  sessionID: string,
381
607
  repo: string,
382
608
  pr: number,
383
- events: WatcherEventType[],
384
- ): Promise<{ ok: true; watcher: Watcher } | { ok: false; error: string }> {
385
- const existing = this.listForSession(sessionID);
386
- if (existing.length >= this.#opts.maxPerSession) {
387
- return {
388
- ok: false,
389
- error: `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`,
390
- };
391
- }
609
+ events: PrWatcherEventType[],
610
+ ): Promise<{ ok: true; watcher: PrWatcher } | { ok: false; error: string }> {
611
+ const limit = this.#checkLimit(sessionID);
612
+ if (limit) return { ok: false, error: limit };
613
+ if (events.length === 0) return { ok: false, error: "No events to watch - pass at least one event type." };
392
614
 
393
615
  let state: PrState;
394
616
  try {
@@ -400,8 +622,9 @@ export class WatcherRegistry {
400
622
  return { ok: false, error: `Could not resolve a head SHA for ${repo}#${pr}. Is it an open PR?` };
401
623
  }
402
624
 
403
- const watcher: Watcher = {
625
+ const watcher: PrWatcher = {
404
626
  id: `watch_${Math.random().toString(36).slice(2, 10)}`,
627
+ source: "pr",
405
628
  sessionID,
406
629
  repo,
407
630
  pr,
@@ -414,6 +637,56 @@ export class WatcherRegistry {
414
637
  return { ok: true, watcher };
415
638
  }
416
639
 
640
+ /**
641
+ * Registers a branch watcher (typically main): commit landings, check-run
642
+ * completions on the head, and workflow runs on the branch. Same baseline
643
+ * validation as createPr.
644
+ */
645
+ async createBranch(
646
+ sessionID: string,
647
+ repo: string,
648
+ branch: string,
649
+ events: BranchWatcherEventType[],
650
+ workflows: string[] = [],
651
+ ): Promise<{ ok: true; watcher: BranchWatcher } | { ok: false; error: string }> {
652
+ const limit = this.#checkLimit(sessionID);
653
+ if (limit) return { ok: false, error: limit };
654
+ if (events.length === 0) return { ok: false, error: "No events to watch - pass at least one event type." };
655
+
656
+ let state: BranchState;
657
+ try {
658
+ state = await fetchBranchState(this.#opts.ghRunner, repo, branch);
659
+ } catch (err) {
660
+ return { ok: false, error: `Failed to read ${repo}@${branch}: ${err instanceof Error ? err.message : String(err)}` };
661
+ }
662
+ if (!state.headSha) {
663
+ return { ok: false, error: `Could not resolve a head SHA for ${repo}@${branch}. Does the branch exist and have commits?` };
664
+ }
665
+
666
+ const watcher: BranchWatcher = {
667
+ id: `watch_${Math.random().toString(36).slice(2, 10)}`,
668
+ source: "branch",
669
+ sessionID,
670
+ repo,
671
+ branch,
672
+ events,
673
+ workflows,
674
+ expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
675
+ createdAt: Date.now(),
676
+ state,
677
+ };
678
+ this.#watchers.set(watcher.id, watcher);
679
+ return { ok: true, watcher };
680
+ }
681
+
682
+ #checkLimit(sessionID: string): string | null {
683
+ const existing = this.listForSession(sessionID);
684
+ if (existing.length >= this.#opts.maxPerSession) {
685
+ return `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`;
686
+ }
687
+ return null;
688
+ }
689
+
417
690
  /** Summaries of one session's watchers, newest first. */
418
691
  listForSession(sessionID: string): Watcher[] {
419
692
  return [...this.#watchers.values()]
@@ -442,9 +715,9 @@ export class WatcherRegistry {
442
715
  /**
443
716
  * One poll cycle: diff every watcher, queue events, then deliver whatever
444
717
  * is pending for sessions that can accept a prompt. Errors are per-watcher:
445
- * one bad PR never blocks the others. Reentrant calls (a cycle slower than
446
- * the interval) are dropped - overlapping diffs against the same baseline
447
- * would queue duplicate events.
718
+ * one bad target never blocks the others. Reentrant calls (a cycle slower
719
+ * than the interval) are dropped - overlapping diffs against the same
720
+ * baseline would queue duplicate events.
448
721
  */
449
722
  async poll(): Promise<void> {
450
723
  if (this.#polling) return;
@@ -457,15 +730,12 @@ export class WatcherRegistry {
457
730
  continue;
458
731
  }
459
732
  try {
460
- const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
461
- const events = diffPrState(watcher.state, after, this.pullUrl(watcher))
462
- .filter((e) => watcher.events.includes(e.type));
733
+ const events = await this.#pollOne(watcher);
463
734
  if (events.length > 0) {
464
735
  const queue = this.#pending.get(watcher.sessionID) ?? [];
465
736
  queue.push(...events);
466
737
  this.#pending.set(watcher.sessionID, queue);
467
738
  }
468
- watcher.state = after;
469
739
  } catch (err) {
470
740
  console.error(`[thatch] watcher ${id} poll failed: ${err}`);
471
741
  }
@@ -476,6 +746,29 @@ export class WatcherRegistry {
476
746
  }
477
747
  }
478
748
 
749
+ /** Fetches and diffs one watcher, filtered to its watched event types. */
750
+ async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
751
+ if (watcher.source === "pr") {
752
+ const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
753
+ const events = diffPrState(watcher.state, after, `${watcher.repo}#${watcher.pr}`, this.#prUrl(watcher))
754
+ .filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
755
+ watcher.state = after;
756
+ return events;
757
+ }
758
+ const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
759
+ const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
760
+ watcher.state = after;
761
+ // The workflow-name filter narrows branch_workflow events only -
762
+ // commits and check runs pass through unfiltered, or a watch filtered
763
+ // to "Publish" would silently drop its commit and CI notifications.
764
+ const matchesFilter = (e: WatcherEvent) =>
765
+ e.type !== "branch_workflow" ||
766
+ watcher.workflows.length === 0 ||
767
+ watcher.workflows.some((wf) => e.summary.toLowerCase().includes(wf.toLowerCase()));
768
+ return all
769
+ .filter((e) => watcher.events.includes(e.type as BranchWatcherEventType) && matchesFilter(e));
770
+ }
771
+
479
772
  /**
480
773
  * Delivers pending events for every session that can accept a prompt.
481
774
  * Failed deliveries stay pending and retry on the next cycle or the next
@@ -505,7 +798,7 @@ export class WatcherRegistry {
505
798
  return (this.#pending.get(sessionID) ?? []).length;
506
799
  }
507
800
 
508
- private pullUrl(watcher: Watcher): string {
801
+ #prUrl(watcher: PrWatcher): string {
509
802
  return `https://github.com/${watcher.repo}/pull/${watcher.pr}`;
510
803
  }
511
804
  }