@jeffober/thatch 0.1.38 → 0.1.39

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
@@ -92,8 +92,14 @@ Thatch gives your agent three layers of persistent context:
92
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
-
96
- Plus **26 skills** for memory workflows, structured multi-specialist code
95
+ - **Notifications + user config** -- the agent can ping you out-of-band when
96
+ a long-running outcome lands: a desktop banner, a spoken voice
97
+ announcement, or both (macOS and Linux). Preferences live in a
98
+ hand-editable config file (`~/.config/thatch/config.json`) that the agent
99
+ manages through `config_get`/`config_set` -- ask it to change your voice
100
+ or quiet notifications entirely.
101
+
102
+ Plus **skills** for memory workflows, structured multi-specialist code
97
103
  review, review response, change and feature walkthroughs, memory verification,
98
104
  knowledge export, and writing tasks
99
105
  (PR descriptions, tickets, PR splitting).
@@ -72,11 +72,13 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
72
72
  | `embeddings.ts` | Lazy-load the embedding model. Expose `queryEmbed`/`passageEmbed` and the model `name` (stored as an informational tag). `MockEmbeddingModel` for tests. |
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
+ | `config.ts` | User config file (`~/.config/thatch/config.json`, beside the DB): Zod section schemas (strict — unknown keys fail validation), load with missing/invalid fallback to defaults, field-level merge, atomic save (temp + rename). Backs the `config_get`/`config_set` tools. |
76
+ | `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool. |
75
77
  | `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
78
  | `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
79
  | `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
80
  | `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
- | `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]`. |
81
+ | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (fact-extractor, dedup-classifier, project-primer, the 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` (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]`. |
80
82
  | `scoring-engine.ts` | Generic four-table scoring engine with Bayesian confidence. Shared base for prediction and behavior engines — each wraps it with table-specific names. |
81
83
  | `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
82
84
  | `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
@@ -27,8 +27,9 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
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
29
  | [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
30
+ | [notifications.md](notifications.md) | Out-of-band notification (banner + voice) and the user config file |
30
31
  | [setup.md](setup.md) | Setup system (markers, checkSetup, auto-refresh, binary resolution) |
31
32
  | [cli.md](cli.md) | CLI (subcommands, environment variables) |
32
- | [database.md](database.md) | Database (schema, migrations, WAL, 11 tables) |
33
- | [qa-system.md](qa-system.md) | QA system (93 use cases, automatable vs live, mise tasks) |
33
+ | [database.md](database.md) | Database (schema, migrations, WAL) |
34
+ | [qa-system.md](qa-system.md) | QA system (automatable vs live, mise tasks) |
34
35
  | [cicd.md](cicd.md) | CI/CD (CI pipeline, OIDC publishing, release helper) |
@@ -1,6 +1,6 @@
1
1
  # Database
2
2
 
3
- Thatch stores all data in a single SQLite file. The schema has 11 tables organized around three concerns: memory entries, the prediction engine, and the behavior engine.
3
+ Thatch stores all data in a single SQLite file. The schema is organized around three concerns: memory entries, the prediction engine, and the behavior engine.
4
4
 
5
5
  ## Configuration
6
6
 
@@ -10,7 +10,7 @@ Thatch stores all data in a single SQLite file. The schema has 11 tables organiz
10
10
 
11
11
  ## Schema overview
12
12
 
13
- 11 tables in three groups:
13
+ The tables fall into three groups:
14
14
 
15
15
  | Group | Tables | Purpose |
16
16
  |-------|--------|---------|
@@ -187,7 +187,7 @@ For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
187
187
 
188
188
  ## Interactions with other features
189
189
 
190
- The 18 shared tools are available on all hosts via the single source of truth
190
+ The shared tools are available on all hosts via the single source of truth
191
191
  in `src/tool-defs.ts`; opencode-only tools (e.g. `get_session_info`) are
192
192
  filtered out by the MCP server. Individual tool behavior is documented in
193
193
  [memory-store.md](memory-store.md), [extraction.md](extraction.md),
@@ -227,7 +227,7 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
227
227
 
228
228
  ## Key invariants
229
229
 
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.
230
+ - All hosts share the tool definitions from `src/tool-defs.ts` (shared tools plus 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.
231
231
  - The MCP server always exposes bare names. The `mcp__thatch__` prefix is applied by the MCP client, not thatch.
232
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.
233
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.
@@ -0,0 +1,106 @@
1
+ # Notifications and user config
2
+
3
+ Out-of-band user notification (desktop banner + spoken voice) backed by a
4
+ hand-editable user config file. Both are plain child processes and file I/O,
5
+ so the feature works identically on every host -- opencode, Claude Code, and
6
+ Cursor -- with no host-specific plumbing and no new npm dependencies.
7
+
8
+ ## Source files
9
+
10
+ | File | Role |
11
+ |------|------|
12
+ | `src/config.ts` | Zod section schemas, config file load/save/merge, platform defaults. |
13
+ | `src/notify.ts` | Per-platform dispatch for banner + voice, with an injectable spawner. |
14
+ | `src/tool-defs.ts` | `config_get`, `config_set`, `notify_user` tool definitions (shared registry, non-opencodeOnly). |
15
+
16
+ ## Config design
17
+
18
+ The config is one JSON file at `~/.config/thatch/config.json`, placed next to
19
+ `thatch.db` by deriving the path from `THATCH_DB_PATH` (or the XDG default the
20
+ same way `index.ts` and `mcp.ts` derive the DB path). Tests pass an explicit
21
+ dbPath to keep writes inside a tempdir.
22
+
23
+ Key decisions:
24
+
25
+ - **Sections are `z.strictObject`.** Unknown keys fail validation instead of
26
+ being silently stripped, so a newer-version config round-tripping through an
27
+ older process cannot lose data. An unparseable file is *ignored* (empty
28
+ config + warning), never fatal -- a broken hand edit must not take the agent
29
+ down.
30
+ - **Field-level merge in `config_set`.** `mergeNotificationPrefs` is a shallow
31
+ spread of current + patch (with `undefined` values stripped first). The
32
+ tool's description and the system prompt both state the merge semantics, and
33
+ the setter *echoes the resulting section* so the model can verify the write
34
+ in the same turn. This is the guardrail against the classic
35
+ read-modify-write failure where a model reconstructs a document and drops
36
+ sibling fields.
37
+ - **Atomic writes.** `saveConfig` writes a temp file then renames, so a crash
38
+ mid-write cannot leave a truncated config and concurrent readers never see a
39
+ partial document.
40
+ - **No env-var overrides for notification prefs.** The config file is the
41
+ single source of truth; `THATCH_*` env vars remain reserved for
42
+ infrastructure (DB path, model, thresholds). Preferences the LLM manages
43
+ belong in the file the LLM manages.
44
+
45
+ `notificationDefaults()` supplies the platform fallbacks shown by `config_get`
46
+ and applied by `notify_user`: darwin pins `Zarvox` + `Submarine` (the author's
47
+ preference); other platforms defer to their tools' defaults.
48
+
49
+ ## Notification dispatch
50
+
51
+ `sendNotification(request, spawner)` composes one or two command steps and
52
+ returns a result string for the tool response:
53
+
54
+ - **darwin**: banner via `/usr/bin/osascript -e 'display notification ...'`
55
+ (title defaults to `source`, then `thatch`; sound defaults to `Submarine`),
56
+ voice via `/usr/bin/say -v <voice>` (absolute path -- `~/bin/say` shadows
57
+ the system binary on the author's machine).
58
+ - **linux**: banner via `notify-send`; voice via `spd-say -w`, falling back to
59
+ `espeak` when speech-dispatcher is absent. A voice override goes straight to
60
+ `espeak -v` because `spd-say` has no voice flag.
61
+ - **other platforms** (win32 included): `[unsupported]` result, nothing runs.
62
+ Windows support is deliberately not a goal.
63
+
64
+ Details that matter:
65
+
66
+ - **The spawner is a `CoreContext` extension field** (`ctx.spawner`), the same
67
+ pattern as `watchers` and `extractionPayloadProvider`. Tests inject a mock;
68
+ production falls back to `defaultSpawner` (Bun.spawn). No test ever fires a
69
+ real banner or speaks.
70
+ - **Failures never throw.** Every step's exit code and stderr are captured;
71
+ the result string is `[notified]` when anything succeeded, `[failed]` when
72
+ all requested channels failed, `[skipped]` when configured `mode` is
73
+ `none`, `[unsupported]` on other platforms.
74
+ - **No delivery claims.** osascript exits 0 whether or not the banner
75
+ displayed (focus modes silently drop it), so result text reports command
76
+ success only.
77
+ - **The spoken line prefixes the `source` label** ("PLAT-280: CI is green") so
78
+ a user with several concurrent agent sessions knows which one spoke. The
79
+ tool description enforces this on the model side.
80
+ - Speech is awaited (the tool returns when the sentence finishes) so error
81
+ reporting stays honest. There is no debounce; polling agents notify once at
82
+ the end.
83
+
84
+ ## Prompt surface
85
+
86
+ - `systemPrompt()` and `mcpInstructions()` list the three new tools and carry
87
+ a short "Notifications" section: when to notify, the source-label rule, and
88
+ the config_get-before-config_set instruction.
89
+ - `notify_user` is not `opencodeOnly`: it needs no host capabilities, so MCP
90
+ hosts get it too. Nothing was added to `src/tools.ts` or `src/index.ts`.
91
+
92
+ ## Tests
93
+
94
+ - `tests/config.test.ts` -- path derivation, missing/invalid/strict-schema
95
+ handling, round-trip, atomic save leaves no temp files, merge keeps
96
+ siblings, darwin defaults.
97
+ - `tests/notify.test.ts` -- command construction per platform (mock spawner
98
+ records argv), AppleScript escaping, partial vs total failure, unsupported
99
+ platform, plus tool-level tests (merge-no-wipe, echo, `mode: none` no-op,
100
+ configured voice honored) with `THATCH_DB_PATH` pointed at a tempdir.
101
+ - `tests/tool-defs.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
102
+
103
+ ## QA coverage
104
+
105
+ `tests/qa/auto/uc-059-tool-prefixing.ts` asserts the full bare-name list; the
106
+ three new names are in its `expected` array.
@@ -1,14 +1,13 @@
1
1
  # QA System
2
2
 
3
- Thatch ships 95 QA use cases that verify end-to-end behavior. Each use case is
4
- a `bun:test` file that runs in an isolated environment — a temp-directory copy
5
- of the repo, not the working tree itself. Use cases are split into
6
- **automatable** (no LLM, fast) and **live** (spawns opencode, costs model
7
- tokens).
3
+ Thatch ships a suite of QA use cases that verify end-to-end behavior. Each use
4
+ case is a `bun:test` file that runs in an isolated environment — a
5
+ temp-directory copy of the repo, not the working tree itself. Use cases are
6
+ split into **automatable** (no LLM, fast) and **live** (spawns opencode, costs
7
+ model tokens).
8
8
 
9
9
  ## What it does
10
10
 
11
- - 95 use cases: 75 automatable + 20 live
12
11
  - Automatable use cases run without an LLM — they test CLI behavior, hooks, DB
13
12
  operations, and tool execution directly via bun assertions
14
13
  - Live use cases spawn real opencode sessions — they test the full
@@ -39,7 +38,7 @@ registers all use cases in one suite, enabling `--concurrent
39
38
  --max-concurrency 5` to run 5 use cases at once (33s vs 88s sequential for the
40
39
  auto suite).
41
40
 
42
- ### Automatable use cases (73)
41
+ ### Automatable use cases
43
42
 
44
43
  Run without an LLM. Test CLI behavior, hooks, DB operations, and tool
45
44
  execution directly via bun assertions. These are fast and run in parallel.
@@ -66,14 +65,15 @@ execution directly via bun assertions. These are fast and run in parallel.
66
65
  | UC-027 | behavior-confidence-model | ham/spam adjusts Bayesian confidence |
67
66
  | UC-028 | behavior-delete | semantic-match deletion with cascade |
68
67
 
69
- ### Live use cases (20)
68
+ ### Live use cases
70
69
 
71
70
  Spawn real opencode sessions. Test the full pipeline with a real model. These
72
- cost model tokens and have a 20-minute per-use-case timeout. All 20 are marked
73
- `manualOnly: true` — the opencode binary is not available in CI, and three
74
- (UC-006, UC-010, UC-013) are manual-only for fundamental reasons (Cursor hook
75
- lifecycle, nested opencode session timeout, compaction trigger). Run them
76
- locally with `mise run qa-live`.
71
+ cost model tokens and have a 20-minute per-use-case timeout. Live use cases
72
+ check for `opencode` on PATH at runtime: if it is present they run; if not,
73
+ they skip with `[MANUAL]`. Three (UC-006, UC-010, UC-013) are
74
+ `manualOnly: true` for infrastructure reasons (Cursor hook lifecycle, nested
75
+ opencode session timeout, compaction trigger). Run them locally with
76
+ `mise run qa-live`.
77
77
 
78
78
  | UC | Name | What it tests |
79
79
  |----|------|---------------|
@@ -182,8 +182,8 @@ 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` | 75 automatable use case files |
186
- | `tests/qa/live/uc-NNN-*.ts` | 20 live use case files |
185
+ | `tests/qa/auto/uc-NNN-*.ts` | Automatable use case files, one per use case |
186
+ | `tests/qa/live/uc-NNN-*.ts` | Live use case files, one per use case |
187
187
 
188
188
  No separate QA docs directory exists. QA use cases live in `tests/qa/` as executable tests, not docs.
189
189
 
@@ -191,8 +191,9 @@ No separate QA docs directory exists. QA use cases live in `tests/qa/` as execut
191
191
 
192
192
  1. **Automatable use cases never spawn an LLM.** They test CLI, hooks, DB, and
193
193
  tool execution directly.
194
- 2. **Live use cases spawn real opencode sessions and cost model tokens.** All
195
- 20 are marked `manualOnly` — the opencode binary is not available in CI.
194
+ 2. **Live use cases spawn real opencode sessions and cost model tokens.** They
195
+ skip with `[MANUAL]` when `opencode` is not on PATH; three are
196
+ `manualOnly: true` for infrastructure reasons.
196
197
  3. **`QA_DRY_RUN=1` lists use cases without executing.** Use this for CI and
197
198
  coverage review.
198
199
  4. **Each use case follows the structured format**: preconditions, steps,
@@ -146,3 +146,21 @@ here first. These are the things that have already cost time.
146
146
  - **`bun test` does not typecheck**, and `tsconfig.json` excludes `tests`. Test
147
147
  type errors are editor-only noise unless you run `tsc` on the test files
148
148
  directly. Keep test files type-clean anyway so the editor stays quiet.
149
+
150
+ ## Config and notifications
151
+
152
+ - **Tests touching the config must set `THATCH_DB_PATH`.** The config tools
153
+ derive the config file path from the environment (`THATCH_DB_PATH`, then the
154
+ XDG default), not from the test's tempdir `ThatchDB`. A config test that
155
+ skips this reads and writes the developer's real
156
+ `~/.config/thatch/config.json`. See `tests/notify.test.ts` for the
157
+ save-and-restore pattern.
158
+ - **osascript exits 0 even when macOS drops the banner.** Focus/Do Not Disturb
159
+ or missing notification permission silently swallow it. `notify_user` result
160
+ text therefore reports command success only; never interpret exit 0 as
161
+ delivery.
162
+ - **New QA use cases must be imported into the directory's barrel file**
163
+ (`tests/qa/auto/index.test.ts` or the live one). The `.ts` files are not
164
+ discovered by bun on their own. UC-095 sat out of the suite for a release
165
+ cycle and rotted (asserted 3 watch tools when 4 existed) because nobody
166
+ noticed it never ran.
@@ -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 + 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 |
21
+ | **Tools** (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 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 and branch 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 |
@@ -29,9 +29,9 @@ description: Extract durable project facts ... Use when ...
29
29
  Code, and Cursor all auto-discover skills and use the description for
30
30
  relevance).
31
31
 
32
- ## The 26 skills
32
+ ## The skills
33
33
 
34
- **Shared (25)** — installed everywhere; no sub-agents required:
34
+ **Shared** — installed everywhere; no sub-agents required:
35
35
 
36
36
  | Skill | Role |
37
37
  |-------|------|
@@ -61,11 +61,11 @@ description: Extract durable project facts ... Use when ...
61
61
  | `thatch-memory-verify` | Fact-check a single memory against the current codebase and correct stale claims. Uses git archaeology to preserve historical context when changes were intentional. |
62
62
  | `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 via `thatch-memory-verify`. |
63
63
 
64
- **opencode-only (1)** — the coordinator needs sub-agent support:
64
+ **opencode-only** — the coordinator needs sub-agent support:
65
65
 
66
66
  | Skill | Role |
67
67
  |-------|------|
68
- | `thatch-code-review` | Resolve review target (incl. VCS detection and connected PR/MR lookup for follow-up round detection), gather project context, research affected workflows, estimate complexity, partition, dispatch the 8 specialists in parallel, synthesize with a workflow-change preface and prior-comment cross-reference. |
68
+ | `thatch-code-review` | Resolve review target (incl. VCS detection and connected PR/MR lookup for follow-up round detection), gather project context, research affected workflows, estimate complexity, partition, dispatch the review specialists in parallel, synthesize with a workflow-change preface and prior-comment cross-reference. |
69
69
 
70
70
  ## REVIEW_COMMON
71
71
 
@@ -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[] = [ /* 26 skills above */ ];
102
+ const SHARED_SKILLS: SkillDef[] = [ /* all shared skills above */ ];
103
103
  const OPENCODE_ONLY_SKILLS: SkillDef[] = [ /* code-review coordinator */ ];
104
104
  ```
105
105
 
@@ -138,7 +138,7 @@ and `--cursor` pass only the shared set.
138
138
  ## Memory review skills in practice
139
139
 
140
140
  - **Quick single lens**: load any specialist directly and point it at a branch.
141
- - **Full review on opencode**: load `thatch-code-review` — it gathers project context, researches affected workflows, dispatches all 8 specialists in parallel (with both the context brief and workflow guide injected into each briefing), then synthesizes a report that starts with the workflow changes before findings.
141
+ - **Full review on opencode**: load `thatch-code-review` — it gathers project context, researches affected workflows, dispatches all review specialists in parallel (with both the context brief and workflow guide injected into each briefing), then synthesizes a report that starts with the workflow changes before findings.
142
142
  - **Full review on Claude Code/Cursor**: run each specialist in sequence, then
143
143
  run `thatch-review-synthesizer` to verify and aggregate.
144
144
  - **Follow-up round (re-review)**: load `thatch-review-followup` when the author
@@ -12,6 +12,7 @@ This README is the overview. Each feature has its own guide:
12
12
  - [memory.md](memory.md): persistent memory store
13
13
  - [extraction.md](extraction.md): automatic fact extraction from tool calls
14
14
  - [watchers.md](watchers.md): event-driven notifications (PR and branch watching)
15
+ - [notifications.md](notifications.md): banner and voice notifications, user config
15
16
  - [prediction-engine.md](prediction-engine.md): user decision model
16
17
  - [behavior-engine.md](behavior-engine.md): agent self-discipline rules
17
18
  - [default-behaviors.md](default-behaviors.md): what ships automatically
@@ -143,6 +144,22 @@ Stores are created automatically. No setup required.
143
144
  | `thatch_behavior_list` | List all codified behaviors with matchers, confidence, and provenance. |
144
145
  | `thatch_behavior_delete` | Delete a behavior by semantic match. Edges and provenance are cascade-deleted. |
145
146
 
147
+ ### Config tools
148
+
149
+ | Tool | What it does |
150
+ |------|-------------|
151
+ | `thatch_config_get` | Read the user config (`~/.config/thatch/config.json`), with defaults annotated. The agent manages config through these tools; you can also edit the file by hand. |
152
+ | `thatch_config_set` | Update config fields. Field-level merge: only fields you pass change. Returns the resulting section so the agent can verify. |
153
+
154
+ ### Notification tools
155
+
156
+ | Tool | What it does |
157
+ |------|-------------|
158
+ | `thatch_notify_user` | Notify the user out-of-band: desktop banner and/or spoken voice. Use for long-running outcomes worth interrupting for (CI results, deploys, watcher events). macOS and Linux. |
159
+
160
+ See [notifications.md](notifications.md) for the full behavior and
161
+ configuration.
162
+
146
163
  ### Session tools (opencode only)
147
164
 
148
165
  | Tool | What it does |
@@ -159,7 +176,8 @@ search` - JSONL output designed for piping to `jq` or `grep`.
159
176
 
160
177
  | Tool | What it does |
161
178
  |------|-------------|
162
- | `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
179
+ | `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, thread resolutions, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
180
+ | `thatch_watch_branch_create` | Watch a GitHub branch (typically main) for commit landings, CI check-run completions, and workflow runs, with an optional workflow-name filter. |
163
181
  | `thatch_watch_list` | List this session's active watchers. |
164
182
  | `thatch_watch_cancel` | Cancel one of this session's watchers by id. |
165
183
 
@@ -287,14 +305,14 @@ response, walkthrough, and workflow skills that support the review pipeline:
287
305
 
288
306
  | Skill | opencode | Claude Code | Cursor |
289
307
  |-------|----------|-------------|--------|
290
- | Memory skills (6) | Yes | Yes | Yes |
291
- | Review specialists (8) | Yes | Yes | Yes |
308
+ | Memory skills | Yes | Yes | Yes |
309
+ | Review specialists | Yes | Yes | Yes |
292
310
  | Review synthesizer | Yes | Yes | Yes |
293
311
  | Review context + code archaeology | Yes | Yes | Yes |
294
312
  | Review followup | Yes | Yes | Yes |
295
313
  | Review response (author-side) | Yes | Yes | Yes |
296
- | Walkthrough skills (2) | Yes | Yes | Yes |
297
- | Writing skills (3) | Yes | Yes | Yes |
314
+ | Walkthrough skills | Yes | Yes | Yes |
315
+ | Writing skills | Yes | Yes | Yes |
298
316
  | Code review coordinator | Yes | No (requires sub-agents) | No (requires sub-agents) |
299
317
 
300
318
  ### Using review skills
@@ -379,20 +397,24 @@ Store defaults to your current git repo.
379
397
 
380
398
  ## Configuration
381
399
 
382
- No configuration needed. The default behavior:
400
+ Most configuration needs none. Two layers exist:
383
401
 
384
- - **Database**: `~/.config/thatch/thatch.db` (created automatically)
385
- - **Embedding model**: bge-small-en-v1.5 (downloaded once, cached locally)
386
- - **Store name**: auto-detected from `git remote get-url origin`
387
- - **Search scope**: always includes the project store and `global`
388
-
389
- ### Environment variables
402
+ - **Preferences the agent manages** live in
403
+ `~/.config/thatch/config.json` (beside the database): notification mode,
404
+ voice, and sound. Ask your agent ("set notifications to banner only") or
405
+ edit the file by hand. See [notifications.md](notifications.md).
406
+ - **Environment variables** for infrastructure:
390
407
 
391
408
  | Variable | Default | Description |
392
409
  |----------|---------|-------------|
393
- | `THATCH_DB_PATH` | `$XDG_CONFIG_HOME/thatch/thatch.db` | Override database location |
410
+ | `THATCH_DB_PATH` | `$XDG_CONFIG_HOME/thatch/thatch.db` | Override database location (the config file follows it) |
394
411
  | `THATCH_MODEL` | `Xenova/bge-small-en-v1.5` | Override embedding model |
395
412
 
413
+ Unchanged defaults: the database is created automatically, the embedding
414
+ model downloads once and is cached, the store name is auto-detected from
415
+ `git remote get-url origin`, and search always includes the project store
416
+ and `global`.
417
+
396
418
  `$XDG_CONFIG_HOME` defaults to `~/.config` when unset.
397
419
 
398
420
  **Changing `THATCH_MODEL` on an existing database:** memories embedded by a
@@ -0,0 +1,84 @@
1
+ # Notifications
2
+
3
+ Thatch can notify you out-of-band when something happens worth leaving the
4
+ terminal for: a CI run finishing, a merge or deploy completing, a watched PR
5
+ receiving a comment. The notification is a desktop banner, a spoken voice
6
+ announcement, or both, depending on your preferences.
7
+
8
+ This works on every host (opencode, Claude Code, Cursor) because it is plain
9
+ child processes, not host plumbing.
10
+
11
+ ## The tools
12
+
13
+ | Tool | What it does |
14
+ |------|-------------|
15
+ | `thatch_notify_user` | Send a banner and/or spoken notification. |
16
+ | `thatch_config_get` | Read the current config, with defaults annotated. |
17
+ | `thatch_config_set` | Update config fields. Returns the resulting section. |
18
+
19
+ ### notify_user
20
+
21
+ ```text
22
+ thatch_notify_user(message: "CI is green, clear for merge",
23
+ source: "PLAT-280")
24
+ ```
25
+
26
+ - **macOS**: banner via Notification Center (with an alert sound) and voice
27
+ via the built-in `say` command.
28
+ - **Linux**: banner via `notify-send` (libnotify) and voice via
29
+ `spd-say`/`espeak` when installed. Missing tools are reported, never fatal.
30
+ - **Windows**: not supported. The tool reports this instead of guessing.
31
+
32
+ The agent includes a `source` label (a ticket number or feature name) so you
33
+ know which of your concurrent sessions is speaking. Voice text spells out
34
+ letter sequences ("C I green", not "CI green") so text-to-speech pronounces
35
+ them.
36
+
37
+ The tool result reports command success only. macOS focus modes can silently
38
+ swallow banners; if you are not seeing them, check Focus settings and that
39
+ your terminal app has notification permission.
40
+
41
+ ## Configuring notifications
42
+
43
+ You have two options, and they edit the same file:
44
+
45
+ 1. **Ask your agent.** "Set notifications to banner only" -- the agent reads
46
+ current values with `config_get`, changes what you asked for with
47
+ `config_set`, and shows you the result.
48
+ 2. **Edit the file yourself.** The config lives at
49
+ `~/.config/thatch/config.json` (next to `thatch.db`, or under
50
+ `$XDG_CONFIG_HOME`).
51
+
52
+ ```json
53
+ {
54
+ "notifications": {
55
+ "mode": "both",
56
+ "voice": "Zarvox",
57
+ "sound": "Submarine"
58
+ }
59
+ }
60
+ ```
61
+
62
+ | Field | Values | Default | Applies to |
63
+ |-------|--------|---------|------------|
64
+ | `mode` | `both`, `banner`, `voice`, `none` | `both` | Which channels `notify_user` uses. `none` disables notifications entirely; the agent is told it no-opped. |
65
+ | `voice` | any installed voice name | `Zarvox` (macOS) | Spoken announcements. List macOS voices with `/usr/bin/say -v '?'`. |
66
+ | `sound` | any system sound name | `Submarine` (macOS) | Banner alert sound. Sounds live in `/System/Library/Sounds`. |
67
+
68
+ Fields you leave out fall back to the defaults. Unknown sections or fields
69
+ are rejected when the file is read, and the config is ignored with a warning
70
+ if it cannot be parsed -- a broken hand edit never crashes the agent, it just
71
+ falls back to defaults.
72
+
73
+ The agent can manage the whole file for you through `config_get` /
74
+ `config_set`; edits merge at the field level, so the agent changing your
75
+ voice cannot wipe your mode.
76
+
77
+ ## Limitations
78
+
79
+ - Banners are best-effort by nature of the OS. The agent can only report
80
+ whether the command ran, not whether you saw the banner.
81
+ - Speech is awaited: the tool returns after the sentence finishes. Notifications
82
+ are short by design.
83
+ - There is no debounce. If an agent polls a status in a tight loop, it should
84
+ notify once at the end, not per poll.
@@ -20,7 +20,7 @@ becomes part of the agent's context.
20
20
 
21
21
  ## What ships
22
22
 
23
- 26 skills total (25 shared, 1 opencode-only):
23
+ Skills are split into shared (all hosts) and opencode-only:
24
24
 
25
25
  ### Memory skills
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
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/config.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ // Thatch user config: one JSON file beside thatch.db, hand-editable and
7
+ // managed by the config_get / config_set tools. Sections are strict (unknown
8
+ // keys fail validation instead of being silently dropped) so a hand edit or a
9
+ // newer-version file can never lose data by round-tripping through an older
10
+ // process. config_set merges at the field level: omitted fields keep their
11
+ // values, so a partial section update never wipes sibling preferences.
12
+
13
+ export const notificationPrefsSchema = z.strictObject({
14
+ mode: z.enum(["both", "banner", "voice", "none"]).optional(),
15
+ voice: z.string().optional(),
16
+ sound: z.string().optional(),
17
+ });
18
+
19
+ export const configSchema = z.strictObject({
20
+ notifications: notificationPrefsSchema.optional(),
21
+ });
22
+
23
+ export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
24
+ export type Config = z.infer<typeof configSchema>;
25
+
26
+ /** Sections in presentation order. config_get / config_set iterate this. */
27
+ export const CONFIG_SECTIONS = ["notifications"] as const;
28
+ export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
29
+
30
+ /**
31
+ * Resolves the config file path. By convention it lives next to thatch.db,
32
+ * so a custom THATCH_DB_PATH moves the config with it. Tests pass an
33
+ * explicit dbPath to keep writes inside a tempdir.
34
+ */
35
+ export function configFilePath(dbPath?: string): string {
36
+ if (dbPath) return join(dirname(dbPath), "config.json");
37
+ const dir = process.env.THATCH_DB_PATH
38
+ ? dirname(process.env.THATCH_DB_PATH)
39
+ : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "thatch");
40
+ return join(dir, "config.json");
41
+ }
42
+
43
+ export interface LoadedConfig {
44
+ config: Config;
45
+ /** Non-null when the file exists but is unparseable; the file is ignored. */
46
+ warning: string | null;
47
+ path: string;
48
+ }
49
+
50
+ /** Reads the config file. A missing file is the empty config, not an error. */
51
+ export function loadConfig(dbPath?: string): LoadedConfig {
52
+ const path = configFilePath(dbPath);
53
+ let raw: string;
54
+ try {
55
+ raw = readFileSync(path, "utf8");
56
+ } catch {
57
+ return { config: {}, warning: null, path };
58
+ }
59
+ try {
60
+ const parsed = configSchema.parse(JSON.parse(raw));
61
+ return { config: parsed, warning: null, path };
62
+ } catch (err) {
63
+ return {
64
+ config: {},
65
+ warning: `config file at ${path} is invalid and was ignored (${err instanceof Error ? err.message : String(err)})`,
66
+ path,
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Writes the config atomically (temp file + rename) so a crash mid-write
73
+ * cannot leave a truncated file, and concurrent readers never see a partial
74
+ * document.
75
+ */
76
+ export function saveConfig(config: Config, dbPath?: string): string {
77
+ const path = configFilePath(dbPath);
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ const tmp = `${path}.${process.pid}.tmp`;
80
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
81
+ renameSync(tmp, path);
82
+ return path;
83
+ }
84
+
85
+ /** Field-level merge for one section: omitted fields keep their values. */
86
+ export function mergeNotificationPrefs(
87
+ current: NotificationPrefs | undefined,
88
+ patch: NotificationPrefs,
89
+ ): NotificationPrefs {
90
+ return { ...current, ...patch };
91
+ }
92
+
93
+ /**
94
+ * Platform fallbacks shown by config_get and applied by notify_user when the
95
+ * user has not set a preference. Only darwin has opinionated defaults; other
96
+ * platforms use their tools' own voices and sounds.
97
+ */
98
+ export function notificationDefaults(): { mode: "both" | "banner" | "voice" | "none"; voice?: string; sound?: string } {
99
+ if (process.platform === "darwin") {
100
+ return { mode: "both", voice: "Zarvox", sound: "Submarine" };
101
+ }
102
+ return { mode: "both" };
103
+ }
package/src/embeddings.ts CHANGED
@@ -16,6 +16,7 @@ export interface EmbeddingModel {
16
16
  load(): Promise<void>;
17
17
  queryEmbed(text: string): Promise<Float32Array>;
18
18
  passageEmbed(text: string): Promise<Float32Array>;
19
+ dispose(): Promise<void>;
19
20
  }
20
21
 
21
22
  /**
@@ -81,4 +82,35 @@ export class BgeEmbeddingModel implements EmbeddingModel {
81
82
  });
82
83
  return output.data as Float32Array;
83
84
  }
85
+
86
+ /**
87
+ * Releases the pipeline's native ONNX sessions. The host calls this during
88
+ * graceful shutdown so onnxruntime's NAPI wrap finalizers are freed while
89
+ * the runtime is still alive. If instead they survive to Bun's worker
90
+ * teardown, Bun panics creating their errors (oven-sh/bun#34664).
91
+ *
92
+ * Best-effort by design: dispose never throws, and a failed release is
93
+ * indistinguishable from never loading. An embed call after dispose just
94
+ * lazily re-loads the model.
95
+ */
96
+ async dispose(): Promise<void> {
97
+ // An in-flight load memoizes a rejecting promise on failure. Await it
98
+ // defensively so its error neither escapes dispose nor kills the teardown.
99
+ if (this.#loading) {
100
+ try {
101
+ await this.#loading;
102
+ } catch {
103
+ // Load failed - nothing was loaded, so nothing to release.
104
+ }
105
+ }
106
+ const pipe = this.#pipe;
107
+ this.#pipe = null;
108
+ this.#loading = null;
109
+ if (!pipe) return;
110
+ try {
111
+ await pipe.dispose();
112
+ } catch {
113
+ // Best-effort - see doc comment above.
114
+ }
115
+ }
84
116
  }
package/src/index.ts CHANGED
@@ -816,6 +816,10 @@ export const server: Plugin = async ({ client, worktree }) => {
816
816
  dispose: async () => {
817
817
  stopVersionChecker();
818
818
  watchers.dispose();
819
+ // Release native ONNX sessions while the worker is still healthy. Left
820
+ // to Bun's teardown, their NAPI finalizers panic the process (see
821
+ // BgeEmbeddingModel.dispose).
822
+ await model.dispose();
819
823
  db.close();
820
824
  },
821
825
  };
package/src/notify.ts ADDED
@@ -0,0 +1,123 @@
1
+ // Out-of-band user notification: desktop banner plus spoken voice. Both are
2
+ // plain child processes - no vendored binaries, no npm notifier dependency -
3
+ // because the per-platform commands are one-liners and the alternatives
4
+ // (node-notifier's bundled terminal-notifier) trip Gatekeeper on modern macOS.
5
+ // Dispatch is darwin and linux only by design: win32 support is not a goal,
6
+ // and the tool reports the gap honestly rather than half-working.
7
+
8
+ export type NotifyChannel = "both" | "banner" | "voice";
9
+
10
+ export interface NotifyRequest {
11
+ message: string;
12
+ title?: string;
13
+ source?: string;
14
+ channel: NotifyChannel;
15
+ /** Voice name override; falls back to the platform default. */
16
+ voice?: string;
17
+ /** Banner alert sound (macOS); falls back to Submarine. */
18
+ sound?: string;
19
+ platform?: string;
20
+ }
21
+
22
+ export interface SpawnResult {
23
+ exitCode: number | null;
24
+ stderr: string;
25
+ }
26
+
27
+ export type Spawner = (cmd: string[]) => Promise<SpawnResult>;
28
+
29
+ /** The real runner. Tests inject a mock so nothing actually speaks. */
30
+ export const defaultSpawner: Spawner = async (cmd) => {
31
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
32
+ const stderr = await new Response(proc.stderr).text();
33
+ await proc.exited;
34
+ return { exitCode: proc.exitCode, stderr };
35
+ };
36
+
37
+ /**
38
+ * The spoken line. The source label (ticket number, feature name) goes first
39
+ * so the listener immediately knows which session is talking.
40
+ */
41
+ export function spokenText(request: Pick<NotifyRequest, "message" | "source">): string {
42
+ return request.source ? `${request.source}: ${request.message}` : request.message;
43
+ }
44
+
45
+ /** AppleScript string literal - backslash and double-quote are the escapes. */
46
+ function appleScriptString(s: string): string {
47
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
48
+ }
49
+
50
+ interface StepOutcome {
51
+ label: string;
52
+ ok: boolean;
53
+ detail: string;
54
+ }
55
+
56
+ async function runStep(label: string, cmd: string[], spawner: Spawner): Promise<StepOutcome> {
57
+ try {
58
+ const result = await spawner(cmd);
59
+ if (result.exitCode === 0) {
60
+ return { label, ok: true, detail: label };
61
+ }
62
+ return { label, ok: false, detail: `${label} failed: exited ${result.exitCode}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}` };
63
+ } catch (err) {
64
+ // Missing binary surfaces here (ENOENT) rather than as an exit code.
65
+ return { label, ok: false, detail: `${label} failed: ${err instanceof Error ? err.message : String(err)}` };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Sends the notification and returns a result line for the tool response.
71
+ * A zero exit proves only that the command ran - macOS focus modes can still
72
+ * suppress the banner silently - so the result never claims the user saw it.
73
+ * Never throws: every failure becomes text the calling model can read.
74
+ */
75
+ export async function sendNotification(
76
+ request: NotifyRequest,
77
+ spawner: Spawner = defaultSpawner,
78
+ ): Promise<string> {
79
+ const platform = request.platform ?? process.platform;
80
+ if (platform !== "darwin" && platform !== "linux") {
81
+ return `[unsupported] notifications are not implemented on ${platform} (macOS and Linux only); nothing was sent.`;
82
+ }
83
+
84
+ const outcomes: StepOutcome[] = [];
85
+
86
+ if (request.channel !== "voice") {
87
+ const title = request.title ?? request.source ?? "thatch";
88
+ if (platform === "darwin") {
89
+ const sound = request.sound ?? "Submarine";
90
+ const script =
91
+ `display notification ${appleScriptString(request.message)} ` +
92
+ `with title ${appleScriptString(title)} sound name ${appleScriptString(sound)}`;
93
+ outcomes.push(await runStep("banner (osascript)", ["/usr/bin/osascript", "-e", script], spawner));
94
+ } else {
95
+ outcomes.push(await runStep("banner (notify-send)", ["notify-send", title, request.message], spawner));
96
+ }
97
+ }
98
+
99
+ if (request.channel !== "banner") {
100
+ const spoken = spokenText(request);
101
+ if (platform === "darwin") {
102
+ const voice = request.voice ?? "Zarvox";
103
+ outcomes.push(await runStep(`voice (${voice})`, ["/usr/bin/say", "-v", voice, spoken], spawner));
104
+ } else {
105
+ // spd-say has no voice flag, so a voice override goes straight to
106
+ // espeak (-v). Without an override, speech-dispatcher is the friendlier
107
+ // default; raw espeak is the fallback when it is absent.
108
+ if (request.voice) {
109
+ outcomes.push(await runStep(`voice (espeak -v ${request.voice})`, ["espeak", "-v", request.voice, spoken], spawner));
110
+ } else {
111
+ const spd = await runStep("voice (spd-say)", ["spd-say", "-w", spoken], spawner);
112
+ outcomes.push(spd.ok ? spd : await runStep("voice (espeak)", ["espeak", spoken], spawner));
113
+ }
114
+ }
115
+ }
116
+
117
+ const failed = outcomes.filter((o) => !o.ok);
118
+ if (outcomes.every((o) => !o.ok)) {
119
+ return `[failed] ${failed.map((o) => o.detail).join("; ")}`;
120
+ }
121
+ const parts = outcomes.map((o) => (o.ok ? `${o.detail} ok` : o.detail));
122
+ return `[notified] ${parts.join("; ")}`;
123
+ }
package/src/prompts.ts CHANGED
@@ -23,7 +23,8 @@ Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
23
23
  thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
24
24
  thatch_prediction_delete,
25
25
  thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
26
- thatch_behavior_delete, thatch_get_session_info,
26
+ thatch_behavior_delete, thatch_config_get, thatch_config_set,
27
+ thatch_notify_user, thatch_get_session_info,
27
28
  thatch_session_search, thatch_session_get,
28
29
  thatch_watch_create, thatch_watch_branch_create,
29
30
  thatch_watch_list, thatch_watch_cancel
@@ -178,6 +179,17 @@ way that is about your own operational discipline (not a user preference):
178
179
  Do not codify rules that make you lazier or less thorough. Rules should encode
179
180
  discipline, not shortcuts.
180
181
 
182
+ ## Notifications
183
+
184
+ Use thatch_notify_user for terminal-event outcomes the user should hear about
185
+ even when looking away: CI results, merge or deploy completion, watcher
186
+ events. Always include a source label (ticket number or feature name) so the
187
+ user knows which session spoke. Manage notification preferences with
188
+ thatch_config_get and thatch_config_set - call config_get first; config_set
189
+ merges fields and echoes the result. The config file
190
+ (~/.config/thatch/config.json) is also hand-editable; do not store thatch
191
+ config in memory.
192
+
181
193
  ## What to Store
182
194
 
183
195
  - **Global store**: user observations, agent personality, system environment
@@ -288,7 +300,9 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
288
300
  \`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
289
301
  \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
290
302
  \`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
291
- \`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`. Bare names used below for readability.
303
+ \`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`,
304
+ \`mcp__thatch__config_get\`, \`mcp__thatch__config_set\`,
305
+ \`mcp__thatch__notify_user\`. Bare names used below for readability.
292
306
  get_session_info, session_search, session_get, watch_create,
293
307
  watch_branch_create, watch_list,
294
308
  and watch_cancel are intentionally absent: they are opencode-only (MCP
@@ -412,6 +426,16 @@ way that is about your own operational discipline (not a user preference):
412
426
  Do not codify rules that make you lazier or less thorough. Rules should encode
413
427
  discipline, not shortcuts.
414
428
 
429
+ ## Notifications
430
+
431
+ Use notify_user for terminal-event outcomes the user should hear about even
432
+ when looking away: CI results, merge or deploy completion, watcher events.
433
+ Always include a source label (ticket number or feature name) so the user
434
+ knows which session spoke. Manage notification preferences with config_get
435
+ and config_set - call config_get first; config_set merges fields and echoes
436
+ the result. The config file (~/.config/thatch/config.json) is also
437
+ hand-editable; do not store thatch config in memory.
438
+
415
439
  ## What to Store
416
440
 
417
441
  - **Global store**: user observations, agent personality, system environment
package/src/tool-defs.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
+ import {
5
+ CONFIG_SECTIONS,
6
+ loadConfig,
7
+ mergeNotificationPrefs,
8
+ notificationDefaults,
9
+ notificationPrefsSchema,
10
+ saveConfig,
11
+ type Config,
12
+ type ConfigSection,
13
+ type NotificationPrefs,
14
+ } from "./config";
15
+ import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
4
16
  import { predictionVerb } from "./prompts";
5
17
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
6
18
  import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
@@ -42,6 +54,9 @@ export interface CoreContext {
42
54
  * path - MCP hosts have no poller, no event bus, and no way to deliver a
43
55
  * proactive prompt, so watch tools are opencode-only. */
44
56
  watchers?: WatcherRegistry;
57
+ /** Injectable command runner for notify_user. Defaults to Bun.spawn.
58
+ * Tests inject a mock so notifications never actually fire or speak. */
59
+ spawner?: Spawner;
45
60
  }
46
61
 
47
62
  /**
@@ -818,6 +833,161 @@ const behaviorDeleteDef: ToolDef = {
818
833
  },
819
834
  };
820
835
 
836
+ // ---------------------------------------------------------------------------
837
+ // Config and notification tools. The config is a single JSON file beside
838
+ // thatch.db, hand-editable and also managed through these tools. notify_user
839
+ // shells out to the platform's banner and TTS commands - see src/notify.ts.
840
+ // ---------------------------------------------------------------------------
841
+
842
+ /** Notification preference fields, in presentation order. */
843
+ const NOTIFICATION_FIELDS = ["mode", "voice", "sound"] as const;
844
+
845
+ /** Renders one section with per-field default annotations for echo output. */
846
+ function renderNotificationSection(prefs: NotificationPrefs | undefined): string {
847
+ const defaults = notificationDefaults();
848
+ const lines = ["notifications:"];
849
+ for (const field of NOTIFICATION_FIELDS) {
850
+ const current = prefs?.[field];
851
+ const fallback = defaults[field];
852
+ lines.push(` ${field}: ${current ?? "<unset>"}${fallback ? ` (default: ${fallback})` : ""}`);
853
+ }
854
+ return lines.join("\n");
855
+ }
856
+
857
+ /** Drops keys explicitly set to undefined so a merge never overwrites. */
858
+ function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
859
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
860
+ }
861
+
862
+ const configGetDef: ToolDef = {
863
+ name: "config_get",
864
+ description:
865
+ "Read thatch's user config, optionally restricted to one section. " +
866
+ "Values are annotated with their defaults; unset fields show <unset>. " +
867
+ "The config file (~/.config/thatch/config.json, beside thatch.db) is " +
868
+ "also hand-editable. Call this before config_set.",
869
+ args: {
870
+ section: z.enum(["notifications"]).optional().describe(
871
+ "Restrict output to one section. Omit to read all sections.",
872
+ ),
873
+ },
874
+ async execute(args) {
875
+ const loaded = loadConfig();
876
+ const lines: string[] = [];
877
+ if (loaded.warning) lines.push(`[warning] ${loaded.warning}`, "");
878
+ const sections: ConfigSection[] = args.section
879
+ ? [args.section as ConfigSection]
880
+ : [...CONFIG_SECTIONS];
881
+ for (const name of sections) {
882
+ if (name === "notifications") {
883
+ lines.push(renderNotificationSection(loaded.config.notifications));
884
+ }
885
+ }
886
+ lines.push("", `file: ${loaded.path}`);
887
+ return lines.join("\n");
888
+ },
889
+ };
890
+
891
+ const configSetDef: ToolDef = {
892
+ name: "config_set",
893
+ description:
894
+ "Update thatch's user config. Field-level merge: only fields you pass " +
895
+ "change; omitted fields and omitted sections keep their current values. " +
896
+ "Returns the resulting section so you can verify the write. Call " +
897
+ "config_get first to see current values and defaults.",
898
+ args: {
899
+ notifications: z.strictObject({
900
+ mode: z.enum(["both", "banner", "voice", "none"]).optional().describe(
901
+ "Default channels for notify_user: both, banner only, voice only, " +
902
+ "or none (disable notifications entirely).",
903
+ ),
904
+ voice: z.string().optional().describe(
905
+ "Spoken voice name (macOS default: Zarvox). Omit to use the " +
906
+ "platform default.",
907
+ ),
908
+ sound: z.string().optional().describe(
909
+ "Banner alert sound (macOS, from /System/Library/Sounds; default: " +
910
+ "Submarine). Omit to use the platform default.",
911
+ ),
912
+ }).optional().describe(
913
+ "Notification preferences to update. Fields you omit keep their " +
914
+ "current values.",
915
+ ),
916
+ },
917
+ async execute(args) {
918
+ const loaded = loadConfig();
919
+ const config: Config = { ...loaded.config };
920
+ const patch = args.notifications as Record<string, unknown> | undefined;
921
+ if (!patch || Object.keys(stripUndefined(patch)).length === 0) {
922
+ return (
923
+ "Nothing to update: pass a section with fields to change.\n" +
924
+ renderNotificationSection(config.notifications)
925
+ );
926
+ }
927
+ config.notifications = notificationPrefsSchema.parse(
928
+ mergeNotificationPrefs(config.notifications, stripUndefined(patch)),
929
+ );
930
+ const path = saveConfig(config);
931
+ return (
932
+ `[saved] ${path}\n` +
933
+ renderNotificationSection(config.notifications)
934
+ );
935
+ },
936
+ };
937
+
938
+ const notifyUserDef: ToolDef = {
939
+ name: "notify_user",
940
+ description:
941
+ "Notify the user out-of-band: desktop banner and/or spoken voice " +
942
+ "(macOS: Notification Center + say; Linux: notify-send + spd-say/espeak). " +
943
+ "Use for long-running terminal-event outcomes worth interrupting for: " +
944
+ "CI results, merge or deploy completion, watcher events. ALWAYS include " +
945
+ "source: a word or two identifying the session or work item (ticket " +
946
+ "number or feature name) so the user knows which session spoke. Honors " +
947
+ "the user's configured preferences; pass channel to override for this " +
948
+ "call only. If the configured mode is none, the tool no-ops and says " +
949
+ "so. The result reports command success only - OS focus modes can " +
950
+ "silently suppress banners.",
951
+ args: {
952
+ message: z.string().describe(
953
+ "The notification text. Spell out letter sequences so " +
954
+ "text-to-speech pronounces them (\"C I green\", not \"CI green\").",
955
+ ),
956
+ title: z.string().optional().describe(
957
+ "Banner title. Defaults to source, then \"thatch\".",
958
+ ),
959
+ source: z.string().optional().describe(
960
+ "Short label identifying the session or work item (ticket number or " +
961
+ "feature name), e.g. \"PLAT-280\". Prepended to the spoken message.",
962
+ ),
963
+ channel: z.enum(["both", "banner", "voice"]).optional().describe(
964
+ "Override the configured mode for this call only.",
965
+ ),
966
+ voice: z.string().optional().describe(
967
+ "Override the configured voice for this call.",
968
+ ),
969
+ },
970
+ async execute(args, ctx) {
971
+ const loaded = loadConfig();
972
+ const prefs = loaded.config.notifications ?? {};
973
+ const mode = prefs.mode ?? "both";
974
+ if (mode === "none") {
975
+ return "[skipped] notifications are disabled (notifications.mode: none). " +
976
+ "Use thatch config_set to re-enable if the user asks.";
977
+ }
978
+ const channel = (args.channel as NotifyChannel | undefined) ?? (mode as NotifyChannel);
979
+ return sendNotification({
980
+ message: args.message as string,
981
+ title: args.title as string | undefined,
982
+ source: args.source as string | undefined,
983
+ channel,
984
+ voice: (args.voice as string | undefined) ?? prefs.voice,
985
+ sound: prefs.sound,
986
+ }, ctx.spawner ?? defaultSpawner);
987
+ },
988
+ };
989
+
990
+
821
991
  /**
822
992
  * Reports the calling session's identity. opencode does not surface its
823
993
  * session ID to the model, so the model cannot learn it any other way -
@@ -1148,6 +1318,9 @@ export const TOOL_DEFS: ToolDef[] = [
1148
1318
  behaviorFeedbackDef,
1149
1319
  behaviorListDef,
1150
1320
  behaviorDeleteDef,
1321
+ configGetDef,
1322
+ configSetDef,
1323
+ notifyUserDef,
1151
1324
  getSessionInfoDef,
1152
1325
  sessionSearchDef,
1153
1326
  sessionGetDef,