@jeffober/thatch 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +56 -26
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +103 -5
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +16 -8
  6. package/artifacts/skills/thatch-coordination.md +69 -0
  7. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  8. package/artifacts/skills/thatch-pr-description.md +21 -13
  9. package/artifacts/skills/thatch-refine-personal.md +5 -0
  10. package/artifacts/skills/thatch-refine.md +7 -1
  11. package/artifacts/skills/thatch-review-context.md +55 -2
  12. package/artifacts/skills/thatch-review-followup.md +4 -4
  13. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  14. package/artifacts/skills/thatch-review-response.md +1 -1
  15. package/artifacts/skills/thatch-review-synthesizer.md +21 -1
  16. package/bin/thatch +48 -15
  17. package/docs/assets/deepwiki-badge.svg +1 -0
  18. package/docs/dev/README.md +18 -10
  19. package/docs/dev/features/README.md +1 -1
  20. package/docs/dev/features/behavior-engine.md +10 -0
  21. package/docs/dev/features/cli.md +4 -1
  22. package/docs/dev/features/commands.md +40 -9
  23. package/docs/dev/features/cross-session-chat.md +194 -23
  24. package/docs/dev/features/database.md +4 -4
  25. package/docs/dev/features/notifications.md +1 -2
  26. package/docs/dev/features/nudge-pipeline.md +14 -0
  27. package/docs/dev/features/opencode-plugin.md +56 -15
  28. package/docs/dev/features/prediction-engine.md +71 -6
  29. package/docs/dev/features/qa-system.md +19 -3
  30. package/docs/dev/features/session-lifecycle.md +6 -6
  31. package/docs/dev/features/session-tabs.md +161 -0
  32. package/docs/dev/features/setup.md +5 -0
  33. package/docs/dev/features/watchers.md +62 -6
  34. package/docs/dev/gotchas.md +89 -0
  35. package/docs/dev/mcp-parity.md +5 -5
  36. package/docs/dev/setup-and-hooks.md +7 -2
  37. package/docs/dev/skills.md +12 -9
  38. package/docs/plans/generalized-session-heartbeat.md +212 -0
  39. package/docs/plans/prediction-consolidation.md +88 -68
  40. package/docs/user/README.md +29 -13
  41. package/docs/user/behavior-engine.md +7 -1
  42. package/docs/user/cli.md +5 -1
  43. package/docs/user/code-review.md +43 -2
  44. package/docs/user/commands.md +37 -10
  45. package/docs/user/cross-session-chat.md +76 -26
  46. package/docs/user/prediction-engine.md +23 -1
  47. package/docs/user/setup.md +16 -0
  48. package/docs/user/skills.md +9 -7
  49. package/docs/user/watchers.md +22 -7
  50. package/package.json +10 -2
  51. package/src/alerts.ts +1 -1
  52. package/src/capabilities.ts +20 -6
  53. package/src/chat.ts +358 -43
  54. package/src/commands.ts +123 -18
  55. package/src/db.ts +74 -3
  56. package/src/git.ts +3 -1
  57. package/src/hygiene.ts +5 -0
  58. package/src/mcp.ts +101 -9
  59. package/src/opencode/tui-plugin.ts +90 -0
  60. package/src/opencode/v2.ts +440 -66
  61. package/src/prediction.ts +90 -0
  62. package/src/prompts.ts +164 -29
  63. package/src/runtime.ts +298 -15
  64. package/src/scoring-engine.ts +40 -9
  65. package/src/session-db.ts +4 -4
  66. package/src/session-tab-shared.ts +231 -0
  67. package/src/setup.ts +9 -3
  68. package/src/skills.ts +6 -9
  69. package/src/tab-state.ts +125 -0
  70. package/src/tool-defs.ts +454 -76
  71. package/src/tools.ts +2 -0
  72. package/src/watchers.ts +263 -26
  73. package/docs/plans/intuition-drives.md +0 -147
  74. package/docs/plans/llm-alerts.md +0 -287
  75. package/docs/plans/thatch-actions.md +0 -179
  76. package/docs/plans/worktree-deletion-recovery.md +0 -246
@@ -58,7 +58,7 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
58
58
  | Capability | v1 source | v2 source | v2 strategy |
59
59
  |---|---|---|---|
60
60
  | bus events | `event` hook | `event.subscribe` (raw SSE) | client-side location filter; location-less events resolve via `session.get` (`{sessionID}` input) and drop unresolvable (mirrors v1's server-side filter); events for plugin-created extraction children pass by ID (they live in the project directory, which differs from the instance directory on below-root launches) |
61
- | incoming message | `chat.message` hook | `session.hook("prompt")` | the hook awaits the runtime; nudge injections append to the OUTBOUND request's last user message in the generate hook (v2 wire `Message` carries parts in `content`, not `parts`) |
61
+ | incoming message | `chat.message` hook | `session.hook("prompt")` | the hook awaits the runtime; nudge injections append to the OUTBOUND request's user message whose text matches the entry's stored prompt, in the context hook (v2 wire `Message` carries parts in `content`, not `parts`). Entries form a per-session set (cap 3, oldest evicted) so a prompt queued mid-turn keeps its own entry instead of evicting the running turn's; a prompt whose nudge computation comes up empty clears the session's whole set, so the running turn's remaining rounds lose their block in that one case (smaller than the staleness it prevents: an entry left behind would match its own prompt text in later requests' session history and replay a stale block there) |
62
62
  | system prompt | `experimental.chat.system.transform` | `session.hook("context")` | mutates the request's system array; the runtime's plain strings convert to `{type: "text", text}` SystemPart objects at the boundary |
63
63
  | compaction | `experimental.session.compacting` + `.autocontinue` + `session.compacted` | `session.hook("compaction")` | flag lands; context-injection surface unverified |
64
64
  | wrap-up commands | `command.execute.before` + command files | `command.transform` + `CommandEditor.add` | registered in code: `execute` arms the greenlight check (`onCommandExecuteBefore`) and delivers the same prompt body the v1 file carries, substituting the invocation's prompt text for `$ARGUMENTS` (v2's `session.prompt` does no template expansion); the runtime installs only ACTION command files on v2 and REMOVES stale wrap-up files from earlier v1 runs (a file and a registered command with the same name would collide) |
@@ -66,14 +66,15 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
66
66
  | tool buffering | `tool.execute.after` hook | `tool.hook("execute.after")` | wired: feeds the same extraction buffer; `Tool.Result.content` is flattened (string passes through, content-part arrays contribute their text parts); the title is derived from the tool name and args (`deriveTitle`), since v2's `Tool.Result.output` is a typed output value, not a title |
67
67
  | noReply deliveries | `promptAsync` with `noReply` | none | gated off via `HostCapabilities.noReplyDelivery` -- chat echoes and the session-start reminder are skipped on v2 (delivering them would start real model turns: a feedback loop) |
68
68
  | synthetic wake deliveries | `promptAsync` with `synthetic` parts | `session.synthetic` endpoint | watcher + chat wake nudges route to v2's synthetic endpoint (TUI-hidden), matching v1 |
69
- | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete degrades (extraction children are not cleaned up on v2 -- the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
69
+ | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete rides `session.remove` (upstream #52387, in v2.0.22), runtime-guarded like compact -- an older host keeps the documented gap (the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
70
70
  | session status | `client.session.status` | none | returns `{}`; the wake gate treats unknown as idle and the event-fed status map does the gating |
71
71
  | session messages | `client.session.messages` | `session.context` | mapped into the v1 `{info: {role}, parts}` shape, so the wrap-up greenlight check works (the promise domain has no `message` accessor) |
72
72
  | session list | `client.session.list` | none | degrade (`-c` resume listing loses its data source) |
73
- | compaction trigger | `client.tui.executeCommand("session_compact")` | none | degrade (no `session.compact` on the promise domain's Pick, and the built-in `/compact` is a TUI palette action calling the server endpoint directly - the server command registry only knows config/plugin-registered names; the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
- | toasts | `client.tui.showToast` | none reachable | degrade (the `tui.toast.show` event has no producer surface from the promise context; a `tui` companion plugin entrypoint is the known lift candidate) |
75
- | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
76
- | tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
73
+ | compaction trigger | `client.tui.executeCommand("session_compact")` | `session.compact({sessionID})` | wired behind a runtime guard: upstream [#52385](https://github.com/anomalyco/opencode/pull/52385) exposed `session.compact` on the promise domain (in the installed v2.0.25), newer than the pinned dev types - an older host degrades to a logged no-op (the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
+ | toasts | `client.tui.showToast` | toast rpc event | wired: every runtime toast call site (alerts, extraction metrics, chat registration, blocked wrap-ups, watcher rearm notices) emits the bridge's `toast` event, which the `./tui` plugin shows via `ui.toast.show`; the pump never translates it (TUI-only by nature). Best-effort by contract: a TUI-less environment or an rpc-less SDK floor shows nothing, silently |
75
+ | wrap-up exit | `client.tui.publish` | exit-tab-closed rpc event | the wrap-up exit records the session's watcher deaths synchronously (the runtime, before the chat unregister - `sessionDied` places the death row by the chat row, so the order is load-bearing) and then emits `rpc.thatch-tabs.exit-tab-closed`, a TUI-ONLY close of the session's OWN tab (the `./tui` plugin's `ui.tabs.close`): the tab-scoped semantic upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984) asks for, since the daemon hosts every tab and `app.exit` would take down unrelated sessions. The pump deliberately never translates this event (a second death pass would erase the durable death row the first one wrote; the session_tab_close tool's `tab-closed` is the only death-translating event). Degrades to a logged no-op without the rpc surface; a TUI with no tab for the session (headless run, tabs off) never acts - the event is ephemeral |
76
+ | session tabs | n/a | rpc event bridge | `thatch_session_tab` (v2Only) spawns a detached subordinate session (create -> move -> chat-register -> emit `rpc.thatch-tabs.tab-opened` -> prompt); the `./tui` CLI plugin consumes the event and calls `ui.tabs.open` (open-without-focus, idempotent). `thatch_session_tab_close` emits `rpc.thatch-tabs.tab-closed {sessionID, chatName}` - the pump translates it into the runtime's confirmed-death path. The registration's third event, `exit-tab-closed` (the wrap-up exit's), is TUI-only - see the wrap-up exit row |
77
+ | tab close | n/a | no session.deleted on v2 tab close | bounded 2026-10-07: watcher death detection cancels a closed tab's watchers once its pending events age past `THATCH_WATCH_DEATH_MINUTES` (default 120) while deliveries throw 404-class errors, and announces the death to live same-project sessions; the roster row itself still lingers until the reaper. NOTE: an earlier version of this row claimed deliveries "fail their idle gate" - wrong: the v2 gate opens (the status stub reads as idle) and the PROMPT fails; same leak, different link |
77
78
  | wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
78
79
  | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
79
80
 
@@ -89,20 +90,46 @@ suite runs green against a v2 serve.
89
90
  Still unverified against the live binary (static analysis only):
90
91
 
91
92
  - Whether `session.hook("prompt")` fires for `session.synthetic` inbox
92
- items. If it does not, `pendingInjections` from the prior real turn gets
93
- appended to the synthetic turn's outbound request.
93
+ items. If it does not, `pendingInjections` from the prior real turn would
94
+ be appended to the synthetic turn's outbound request. Structurally
95
+ bounded 2026-10-12: each `pendingInjections` entry is bound to the prompt
96
+ text it was computed from, and the context hook injects it only into a
97
+ request user message whose joined text equals that prompt - a replay onto
98
+ any other message (synthetic turn, missed prompt hook, failed
99
+ `onChatMessage`) is skipped. Text equality is the tightest binding
100
+ available, not an airtight one: an identical prompt re-submitted after a
101
+ missed prompt hook still matches and replays; the next prompt-hook run
102
+ that fires is the recovery. The prompt-time block still re-fires on every
103
+ round of its OWN turn (v1's persistent-synthetic-part semantics), and the
104
+ block carries a snapshot footer stating the labels are a submission-time
105
+ snapshot and pointing at the live re-check tool.
94
106
  - Whether `SessionGenerate.messages` are fresh objects per model call within
95
- a turn. If not, the generate hook pushes duplicate nudges on every tool
96
- round trip.
107
+ a turn. The context hook appends injections to the matched message's
108
+ content, which changes the text the binding check compares. With fresh
109
+ objects per call (what the tests assume), the prompt-time block re-fires
110
+ on every round of its own turn. With reused objects, round 1's
111
+ injections would change the matched message's text, so the binding check
112
+ would skip rounds 2 and on - within-turn replay would break after round
113
+ 1 instead of duplicating.
114
+ - Entries outliving their session: `pendingInjections` is keyed by session
115
+ id and never swept on session end. Bounded - a per-session set capped at
116
+ 3 entries, and the map itself dies with the adapter activation - but a
117
+ session's entries do persist after the session is gone, pre-existing.
118
+ - Cross-directory hook dispatch scope: the event pump filters events by
119
+ directory, but the session hooks (`prompt`, `context`, `compaction`)
120
+ have no such filter - whether a hook can fire for another directory's
121
+ session is unverified, pre-existing.
97
122
  - An npm-installed copy (not the dev checkout, which has devDependencies
98
123
  present) loading under v2 - the isolation rule's real-world proof.
99
124
 
100
- The app-exit gap (no server-side publish surface for `tui.command.execute`)
101
- is upstream:
102
- [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984).
125
+ The wrap-up exit closes the session's own tab over the session-tab rpc
126
+ bridge - the tab-scoped close upstream
127
+ [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984)
128
+ asks for. Known degrade: a TUI with no tab for the session (headless run,
129
+ tabs off) never acts - the event is ephemeral, nobody is listening.
103
130
  Install opencode-v2 (brew formula `anomalyco/tap/opencode-v2`; conflicts
104
- with the v1 formula's binary name, so it cannot coexist) and re-run the QA
105
- live suite against it.
131
+ with the v1 formula's binary name, so it cannot coexist) and re-run the
132
+ QA live suite against it.
106
133
 
107
134
  ## User setup compatibility
108
135
 
@@ -112,15 +139,29 @@ key (`plugin` vs `plugins`) is auto-migrated by v2. The only user-owned file
112
139
  with a content change is the dev-session shim, and its new dual-shape content
113
140
  loads under both hosts.
114
141
 
142
+ The session-tab feature adds a second user-owned artifact: the TUI-side
143
+ plugin entry. A local FILE plugin shim carries a `server` entrypoint only
144
+ (v2's `plugin/module.ts`), and the TUI's plugin discovery loads directories
145
+ and symlinks, never plain files - so the dev-checkout install must be a
146
+ DIRECTORY shim: `thatch/index.ts` (the dual-shape re-export, unchanged) plus
147
+ `thatch/tui.ts` (re-exporting the TUI plugin). DELETE the old `thatch.ts`
148
+ file in the same step - a file and a directory shim coexisting dies with
149
+ `duplicate instance plugin ids: thatch` before any plugin code runs (the
150
+ duplicate check is in activation, so nothing inside the plugin can
151
+ self-heal it).
152
+
115
153
  ## Source files
116
154
 
117
155
  - `src/index.ts` -- dual entry, merged default export, lazy wrappers
118
156
  - `src/opencode/v1.ts` -- v1 hooks adapter
119
157
  - `src/opencode/v2.ts` -- v2 promise-context adapter + capability impl
158
+ - `src/opencode/tui-plugin.ts` -- v2 TUI CLI plugin (the `./tui` entrypoint: consumes the session-tab rpc events, drives `ui.tabs`)
159
+ - `src/session-tab-shared.ts` -- the cross-entry session-tab contract (rpc definition, prompt builder, validators)
120
160
  - `src/capabilities.ts` -- `HostCapabilities`, `capabilitiesFromClient`
121
161
  - `src/runtime.ts` -- the shared runtime (all behavioral logic)
122
162
  - `src/os-args.ts` -- pure argv helpers
123
163
  - `tests/opencode-v2.test.ts` -- v2 adapter contract tests (mocked context)
164
+ - `tests/session-tab-shared.test.ts` -- session-tab validators, rpc definition shape, TUI guard logic
124
165
  - `tests/plugin.test.ts` -- v1 adapter contract tests (mocked client)
125
166
 
126
167
  ## Interactions with other features
@@ -6,8 +6,9 @@ calls and updates it based on user feedback.
6
6
 
7
7
  ## What it does
8
8
 
9
- - Four tools: `prediction_query`, `prediction_update`, `prediction_list`,
10
- `prediction_delete`. All defined in `src/tool-defs.ts`.
9
+ - Tools: `prediction_query`, `prediction_update`, `prediction_list`,
10
+ `prediction_delete`, `prediction_mark_checked`. All defined in
11
+ `src/tool-defs.ts`.
11
12
  - Auto-fires at every user message: embeds the prompt, cosine-matches against
12
13
  matchers, injects scored predictions as a synthetic nudge part.
13
14
  - Bayesian confidence model — a graded score that is reinforced or weakened
@@ -101,6 +102,33 @@ Runs in a single DB transaction:
101
102
  Returns a status line with final confidence and confirm/disconfirm counts.
102
103
  Embeds both the matcher and prediction text.
103
104
 
105
+ ### Dedup pass (corpus-level consolidation)
106
+
107
+ Write-time dedup (0.85, both stores) catches near-identical statements at
108
+ creation. Wording-different duplicates — the same preference restated weeks
109
+ later — land below that floor and accumulate. The dedup pass finds them:
110
+
111
+ - `findPredictionDuplicates(store, threshold = 0.70)` in `src/prediction.ts`:
112
+ pairwise cosine over the store's predictions, skipping pairs already
113
+ adjudicated in `prediction_dedup_pairs`. The threshold is band placement:
114
+ [0.85, 1] is write-time territory, below 0.70 short-statement embeddings
115
+ are too noisy to call duplicates.
116
+ - Pairs are keyed on the canonical SORTED id pair — (A,B) and (B,A) are one
117
+ row, the same contract as the memory side's `dedup_pairs`.
118
+ - `prediction_mark_checked(id_a, id_b, status)` records the verdict:
119
+ `duplicate` (merged or treat as one) or `distinct` (both legitimate).
120
+ Distinct verdicts are the load-bearing case — a destroyed duplicate is
121
+ gone, but a judged-distinct pair re-surfaces every session start unless
122
+ recorded.
123
+ - The hygiene report surfaces `N prediction duplicate pairs pending review`;
124
+ the agent merges (fold the loser's matchers with `prediction_update`, then
125
+ `prediction_delete` the loser) or judges distinct, and marks the pair
126
+ either way. Scans are per-store: cross-store writes are edge-linked at
127
+ write time instead of duplicated.
128
+ - The co-fire refinement (behavioral evidence layered onto this method once
129
+ fire tracking exists) is designed in
130
+ [../../plans/prediction-consolidation.md](../../plans/prediction-consolidation.md).
131
+
104
132
  ### Auto-fire (opencode: `chat.message` hook)
105
133
 
106
134
  - Reuses the prompt embedding already computed for the recall nudge — no extra
@@ -113,6 +141,8 @@ Embeds both the matcher and prediction text.
113
141
  `[thatch] User decision model` header.
114
142
  - 0-evidence predictions use "you may prefer"; predictions with evidence use
115
143
  "you tend to".
144
+ - The block ends with a snapshot footer (see the block example below): the
145
+ labels are a submission-time snapshot, not a live view.
116
146
  - Independent try/catch — a prediction failure does not block recall or
117
147
  behavior.
118
148
  - Toast: `[thatch] N predictions surfaced`.
@@ -131,11 +161,27 @@ Embeds both the matcher and prediction text.
131
161
  The auto-fire block looks like:
132
162
 
133
163
  ```text
134
- ## User decision model
164
+ [thatch] User decision model
135
165
  - [0.72 conf, 4 tests] When {matcher}: you tend to {prediction}
136
166
  - [0.61 conf, 2 tests] When {matcher2}: you may prefer {prediction2}
167
+ Snapshot: these labels reflect the prediction store as of this message's
168
+ submission. Predictions updated after submission (including during this
169
+ turn) do not refresh it; re-check with thatch_prediction_query before
170
+ relying on them.
137
171
  ```
138
172
 
173
+ The footer's tool name follows the host: opencode sees
174
+ `thatch_prediction_query`; MCP hosts (Claude Code, Cursor) see
175
+ `mcp__thatch__prediction_query`. The renderer resolves the spelling
176
+ through the caller's `ToolNamer`.
177
+
178
+ The snapshot footer exists because the block is computed once when the
179
+ message is submitted and stays attached to it for the whole turn (v1:
180
+ synthetic part on the stored message; v2: `pendingInjections` re-injected
181
+ into every outbound request of the turn). Predictions the conversation
182
+ mutates mid-turn do not refresh the block; the footer tells the consumer
183
+ the labels are a submission-time snapshot and points at the live tool.
184
+
139
185
  Prompt instructions: follow strong predictions silently, surface weak or
140
186
  competing predictions to the user, update the model when the user responds.
141
187
 
@@ -190,11 +236,11 @@ that user preferences are stable, not time-sensitive.
190
236
 
191
237
  | File | Role |
192
238
  |------|------|
193
- | `src/prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names |
239
+ | `src/prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names, plus the corpus dedup pass (`findPredictionDuplicates`, pair verdicts) |
194
240
  | `src/scoring-engine.ts` | Generic four-table scoring engine with Bayesian confidence (shared base for prediction and behavior) |
195
241
  | `src/db.ts` | Prediction tables, `scorePredictionNudge` |
196
- | `src/tool-defs.ts` | Four prediction tools (`query`, `update`, `list`, `delete`) |
197
- | `src/prompts.ts` | `predictionNudge` formatting, verb selection |
242
+ | `src/tool-defs.ts` | Five prediction tools (`query`, `update`, `list`, `delete`, `mark_checked`) |
243
+ | `src/prompts.ts` | `predictionNudge` formatting, verb selection, dedup-merge guidance |
198
244
  | `src/sideband.ts` | `predictions` method for the MCP path |
199
245
 
200
246
  ## Database tables
@@ -241,8 +287,25 @@ prediction_provenance(
241
287
  created_at TEXT,
242
288
  FOREIGN KEY (prediction_id) REFERENCES predictions(id) ON DELETE CASCADE
243
289
  )
290
+
291
+ prediction_dedup_pairs(
292
+ store TEXT,
293
+ id_a TEXT,
294
+ id_b TEXT,
295
+ status TEXT,
296
+ checked_at TEXT,
297
+ PRIMARY KEY (store, id_a, id_b)
298
+ )
244
299
  ```
245
300
 
301
+ Timestamp semantics: a matcher's `updated_at` is the last change to the
302
+ matcher row OR its edge set — `createEdge` bumps it when a new edge lands,
303
+ and deleting an item bumps the matchers whose edges cascade away. The
304
+ matcher's own text may legitimately be older than its links (a corrected
305
+ prediction re-links the existing matcher without touching its text).
306
+ An item's `updated_at` is written at creation and then moved by the last
307
+ confidence change.
308
+
246
309
  ## Key invariants
247
310
 
248
311
  1. **The firewall principle.** The model that uses predictions cannot grade
@@ -254,3 +317,5 @@ prediction_provenance(
254
317
  `chat.message` hook and MCP sideband) prevents scoring drift.
255
318
  5. **Matcher text is embedded raw.** No header prepend — unlike
256
319
  `memory_remember`, which prepends `# label\n\n` before embedding.
320
+ 6. **Adjudicated dedup pairs never re-surface.** Verdict rows are keyed on
321
+ the canonical sorted id pair; `findPredictionDuplicates` skips them.
@@ -70,9 +70,11 @@ execution directly via bun assertions. These are fast and run in parallel.
70
70
  Spawn real opencode sessions. Test the full pipeline with a real model. These
71
71
  cost model tokens and have a 20-minute per-use-case timeout. Live use cases
72
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
73
+ they skip with `[MANUAL]`. Six (UC-006, UC-010, UC-013, UC-083, UC-084,
74
+ and UC-085) are
74
75
  `manualOnly: true` for infrastructure reasons (Cursor hook lifecycle, nested
75
- opencode session timeout, compaction trigger). Run them locally with
76
+ opencode session timeout, compaction trigger, sub-agent support, skill
77
+ loading, and a PR with prior review comments). Run them locally with
76
78
  `mise run qa-live`.
77
79
 
78
80
  | UC | Name | What it tests |
@@ -86,6 +88,20 @@ opencode session timeout, compaction trigger). Run them locally with
86
88
  | UC-013 | compaction-context | compaction hook injects context |
87
89
  | UC-019 | archived-memory-lifecycle | archive -> search excludes -> unarchive -> search includes |
88
90
  | UC-020 | extraction-escalation | polite -> insistent -> ALL-CAPS over missed nudges |
91
+ | UC-038 | no-save-run | an extraction child that finds nothing worth saving (no toast) |
92
+ | UC-043 | compaction-suppression | extraction nudges suppressed while an extraction child runs |
93
+ | UC-077 | child-session-drain | direct extraction: the child drains the parent's queue |
94
+ | UC-078 | child-session-error | direct extraction: the child errors, held entries requeue |
95
+ | UC-079 | child-session-deletion | direct extraction: the child is deleted, entries requeue |
96
+ | UC-080 | parent-session-deletion | parent deleted mid-extraction, cleanup completes |
97
+ | UC-083 | review-coordinator | the multi-agent review coordinator pipeline |
98
+ | UC-084 | review-walkthrough | the change-walkthrough skill on a real diff |
99
+ | UC-085 | review-followup | follow-up round verification against prior review comments |
100
+ | UC-086 | review-response | author-side response to incoming review comments |
101
+ | UC-087 | review-no-subagents | the solo review path on hosts without sub-agents |
102
+ | UC-098 | chat-cross-session | two real opencode sessions exchange chat messages |
103
+ | UC-119 | session-tab-spawn | the session_tab spawn flow end to end |
104
+ | UC-122 | review-mode-commands | /thatch/self-review and /thatch/peer-review overlay efficacy (seeded fixture) |
89
105
 
90
106
  ### mise tasks
91
107
 
@@ -235,7 +251,7 @@ No separate QA docs directory exists. QA use cases live in `tests/qa/` as execut
235
251
  1. **Automatable use cases never spawn an LLM.** They test CLI, hooks, DB, and
236
252
  tool execution directly.
237
253
  2. **Live use cases spawn real opencode sessions and cost model tokens.** They
238
- skip with `[MANUAL]` when `opencode` is not on PATH; three are
254
+ skip with `[MANUAL]` when `opencode` is not on PATH; six are
239
255
  `manualOnly: true` for infrastructure reasons.
240
256
  3. **`QA_DRY_RUN=1` lists use cases without executing.** Use this for CI and
241
257
  coverage review.
@@ -73,12 +73,12 @@ When a parent session (no parentID) goes idle:
73
73
 
74
74
  User-invoked slash commands, shipped as command markdown synced by the plugin (see `src/commands.ts`). The template instructs the model to flush pending fact extraction (`thatch_get_extraction_payload` + `thatch_extraction_done`), finish promised memory writes, and surface unaddressed todos -- then end its response with a greenlight token (`THATCH_COMPACT_READY` / `THATCH_EXIT_READY`) only when safe to proceed. Text typed after the command is forwarded into a labeled `# User Message` section ahead of the `# Pre-*-wrap-up` checklist section (opencode's `$ARGUMENTS` substitution): the headers give the user's words provenance, so they cannot be misread as part of the wrap-up instructions. opencode's substitution is purely mechanical with no default-value form (verified against v1.18.30), so the template cannot switch on emptiness; instead the section always carries a fallback line telling the model to treat an empty section as n/a (bare command, no user text).
75
75
 
76
- 1. `command.execute.before` arms the session in `pendingWrapUp` when the command runs
77
- 2. On the session's next idle, the plugin fetches the session's messages via the SDK client and checks the final assistant message's trailing text for the token (trimmed, exact match)
78
- 3. Token present: trigger the TUI action and return early -- compaction is starting (the checklist drained the buffer) or the process is exiting
79
- - compact: `client.tui.executeCommand({ body: { command: "session_compact" } })`. The execute-command route only accepts legacy alias names; `session_compact` maps to the TUI's `session.compact` action, the same thing the built-in `/compact` runs
80
- - exit: unregister the session from the chat directory first (a greenlit exit's host is about to vanish; other sessions must stop addressing mail to it), then `client.tui.publish({ body: { type: "tui.command.execute", properties: { command: "app.exit" } } })`. No exit alias exists, so the TUI keymap command is published directly. The exit template's checklist also asks the model to call `thatch_chat_unregister` as its last persistence step; the plugin-side unregister is the deterministic backstop. Compaction deliberately does NOT unregister - the session continues
81
- 4. Token absent: warning toast pointing at the blockers in the response, then fall through -- the model may have buffered tool interactions that still need extraction
76
+ 1. `command.execute.before` arms the session in `pendingWrapUp` when the command runs (on v2 the registered command's `execute` runs the same arming through `onCommandExecuteBefore`)
77
+ 2. On the session's next idle, the plugin fetches the session's messages (v1: SDK client; v2: `session.context`, mapped into the v1 shape) and checks the final assistant message's trailing text for the token (trimmed, exact match)
78
+ 3. Token present: trigger the host action and return early -- compaction is starting (the checklist drained the buffer) or the session is closing
79
+ - compact: v1 dispatches the TUI's `session_compact` command (`client.tui.executeCommand` -- the execute-command route only accepts legacy alias names; `session_compact` maps to the TUI's `session.compact` action, the same thing the built-in `/compact` runs). v2 calls `session.compact({sessionID})` on the promise domain directly (upstream #52385), runtime-guarded because the pinned dev types predate the surface -- an older host degrades to a logged no-op
80
+ - exit: record the session's watcher deaths first (`watchers.sessionDied` -- BEFORE the unregister below, because the death row is placed by the session's chat row and the unregister deletes it; the runtime also captures the project there, so the async death-notice delivery still places after the row is gone), unregister the session from the chat directory (a greenlit exit's host is about to vanish; other sessions must stop addressing mail to it), then close the session out on the host. v1 publishes the `app.exit` TUI keymap command directly (`client.tui.publish` -- no exit alias exists). v2 emits the session-tab bridge's `exit-tab-closed` event, a TUI-ONLY close of the session's OWN tab: the daemon hosts every tab, so `app.exit` would take down unrelated sessions, and the pump deliberately never translates this event (the deaths were already recorded; a second pass would erase the durable death row). The unregister is plugin-side only, after the death recording: the template deliberately does NOT ask the model to call `thatch_chat_unregister`, because a mid-turn unregister would delete the chat row before the death placement could use it. Compaction deliberately does NOT unregister - the session continues
81
+ 4. Token absent: warning toast pointing at the blockers in the response, then fall through -- the model may have buffered tool interactions that still need extraction (on v2 the toast rides the bridge's toast event; a TUI-less environment shows nothing - the blockers themselves are in the model's response)
82
82
 
83
83
  ### Command file install
84
84
 
@@ -0,0 +1,161 @@
1
+ # Session tabs (coordinated subordinates)
2
+
3
+ ## What it does
4
+
5
+ `thatch_session_tab` (opencode v2 only) lets a coordinating LLM spawn a
6
+ detached subordinate LLM session in a new TUI tab. The subordinate is a full
7
+ peer session: it runs its own agent loop across many turns, in a directory
8
+ of the coordinator's choosing, while the coordinator keeps working. The user
9
+ sees the subordinate's tab appear beside the coordinator's, unfocused.
10
+
11
+ This is NOT the `task` tool: task subagents run inside the dispatcher's turn
12
+ and return their result inline. A tab subordinate outlives the dispatching
13
+ turn and is supervised over cross-session chat.
14
+
15
+ ## How it works
16
+
17
+ ```text
18
+ coordinator session (LLM)
19
+ | tool call: thatch_session_tab {prompt, title, worktree|directory}
20
+ v
21
+ v2 adapter (src/opencode/v2.ts, buildSessionTabHost via CoreContext.sessionTabHost)
22
+ | validate (tool-defs: title, exactly-one location arg, fs existence,
23
+ | worktree same-main-checkout identity)
24
+ | 1. session.create({title, metadata: {thatch: {...}}, location})
25
+ | 2. worktree flow: session.move({directory: worktree}) (idle -> steer = immediate)
26
+ | 3. db.registerChatSession(subordinate) (participant, never machinery-marked)
27
+ | 4. rpc emit rpc.thatch-tabs.tab-opened {sessionID, directory}
28
+ | 5. session.prompt({text: preamble + task})
29
+ v
30
+ SSE event feed (isOpenCodeEvent passes rpc.*; fans out across all locations)
31
+ v
32
+ TUI CLI plugin (src/opencode/tui-plugin.ts, loaded from package exports "./tui")
33
+ | data.listen -> type filter -> directory guard (mirrors the built-in
34
+ | tui.* handlers: the event routes by the coordinator instance's
35
+ | directory) -> data.session.sync -> ui.tabs.open(sessionID)
36
+ v
37
+ Tab strip: subordinate tab appears, unfocused
38
+ ```
39
+
40
+ Key properties:
41
+
42
+ - **v2-only by construction.** The ToolDef carries `v2Only: true`; the v1
43
+ adapter (`src/tools.ts`) and the MCP server (`src/mcp.ts`) filter it. The
44
+ system prompt lists the tool only when the runtime is built with
45
+ `v2Tools: true` - a prompt listing a nonexistent tool is the
46
+ first-call-mistake class.
47
+ - **The event routes by the coordinator's location**, so the tab opens in
48
+ the coordinator's window under any `tabs.scope` - even when the session
49
+ lives in a worktree or temp dir. Tab ownership is by window, not by
50
+ session location.
51
+ - **The move precedes the emit**: a failed move leaves no stranded open tab
52
+ (placement is identical either way because routing is by coordinator
53
+ location).
54
+ - **No ack**: a headless run, a connecting TUI, or `tabs.mode: off` means
55
+ the event lands nowhere (harmlessly). The tool response says "tab
56
+ requested"; the session exists and is findable in the sessions list
57
+ regardless.
58
+ - **The subordinate is a chat participant**, deliberately never marked
59
+ machinery: pre-registration (idempotent, like an explicit chat_register)
60
+ gives the coordinator its chat name in the tool response, and the
61
+ auto-register converges on the same name. Gated on the chat-enabled
62
+ config like every other registration path: with chat off the
63
+ subordinate still spawns, but no roster row is written and the tool
64
+ response reports no chat name. `validateTitle` rejects the
65
+ machinery title so `isMachinerySessionTitle` can never misfire. A
66
+ mid-flow failure (create, move, emit, prompt) throws with the created
67
+ session id and the completed steps in the message - v2 has no session
68
+ delete, so the stranded session can at least be named and reported.
69
+ - **The worktree flow boots a second per-location thatch instance** (shared
70
+ db): the subordinate's pollers, nudges, extraction, wrap-up, and alerts
71
+ run in the worktree location's instance, and chat wake for the idle
72
+ subordinate depends on that instance being alive. The watcher poller
73
+ handoff is per location (`WatcherRegistry.#live` is keyed by directory):
74
+ the subordinate's instance booting never stops the coordinator's
75
+ poller - each location's instance polls only its own directory's
76
+ watchers. The pump caches the subordinate's location from the
77
+ tab-opened payload's directory field (its final, post-move directory),
78
+ never from the rpc envelope's location stamp (that is the publisher's
79
+ directory), so the subordinate's location-less execution events route
80
+ to the instance that owns it.
81
+
82
+ ## The cross-entry contract
83
+
84
+ `src/session-tab-shared.ts` (SDK-free, per the isolation rule in
85
+ `src/index.ts`) holds the rpc definition (`thatch-tabs`: `tab-opened`;
86
+ `tab-closed` - emitted by the close tool (below) and translated by the v2
87
+ pump into the runtime's confirmed-death path, which also records a
88
+ `chat_tab_closed` marker the chat roster's open-tab reader uses to demote
89
+ the row instantly ([cross-session-chat.md](cross-session-chat.md));
90
+ `exit-tab-closed` - the
91
+ wrap-up exit's TUI-only close, which the runtime records the exit's deaths
92
+ itself before emitting, so the pump deliberately never translates that
93
+ one; and `toast` - every runtime toast's TUI-only feedback channel), the
94
+ subordinate prompt builder, and the validators. The TUI entrypoint may
95
+ import only `@opencode/plugin/tui` and this module.
96
+
97
+ ## Interactions
98
+
99
+ - **Chat**: the subordinate registers in its final directory's repo slug (a
100
+ worktree resolves to the main checkout's slug), so coordinator and
101
+ subordinate share a chat scope across the move.
102
+ - **Extraction/wrap-up/alerts**: the subordinate is a normal session owned
103
+ by its location's instance; the coordinator observes it via chat, not the
104
+ event pump (its post-move events fail the pump's directory filter
105
+ deliberately - the `childSessions` forwarding set is for extraction
106
+ children, and adding moved sessions would double-process their events).
107
+ - **Session metadata**: the created session carries
108
+ `metadata.thatch.{coordinatedBy, coordinatorSessionID, worktree?}`. The
109
+ host inherits parent metadata onto children - the subordinate's own
110
+ extraction children carry the marker too; treat inherited markers as
111
+ inherited, not first-party.
112
+
113
+ ## Closing a subordinate's tab
114
+
115
+ `thatch_session_tab_close {session}` (the target: the subordinate's chat
116
+ name or session id, resolved name-first through the chat roster) emits
117
+ `rpc.thatch-tabs.tab-closed {sessionID, chatName?}` over the same rpc
118
+ bridge. The TUI plugin consumes it symmetrically with open: the directory
119
+ guard applies, and `ui.tabs.close(sessionID)` closes the tab in every
120
+ window showing one (windows not showing it no-op). Close is TAB-LEVEL: the
121
+ session survives, the strip's reopen stack can restore it, and chat history
122
+ is untouched.
123
+
124
+ Two consumers ride the event: the death-detection machinery
125
+ (generalized-session-heartbeat) treats a tool-initiated close as a
126
+ CONFIRMED close - the closed session's watchers cancel immediately instead
127
+ of waiting on the 2h delivery-failure heuristic - and the coordinator's own
128
+ workflow closes dispatched subordinates once their work is verified. The
129
+ pump routes the close by the CLOSED session's own directory (resolved from
130
+ the pump's session-location cache, or live through the session API), not
131
+ by the event's location stamp: the stamp names the publishing coordinator
132
+ instance, while the watchers and status bookkeeping live in the instance
133
+ that owns the session - its post-move location.
134
+
135
+ ## Install requirement (the `./tui` entrypoint)
136
+
137
+ A local FILE plugin shim gets a `server` entrypoint only
138
+ (`packages/core/src/plugin/module.ts` at v2), and the TUI's plugin discovery
139
+ loads directories and symlinks, never plain files. The dev-checkout install
140
+ must therefore be a directory shim (`thatch/index.ts` + `thatch/tui.ts`),
141
+ and the old file shim must be DELETED in the same step - coexisting shims
142
+ die with `duplicate instance plugin ids: thatch` before any plugin code can
143
+ run. npm installs need the `exports` map to include `.` (the bare-specifier
144
+ path) alongside `./tui`, and `@opencode/plugin` is an optional peer
145
+ dependency for the TUI entry's runtime resolution.
146
+
147
+ ## Testing
148
+
149
+ - Unit: `tests/session-tab-shared.test.ts` (validators, definition shape,
150
+ guard logic), `tests/opencode-v2.test.ts` (the execute flows' ordering via
151
+ the `makeContext` double with `session.move` + the `rpc` domain).
152
+ - QA: uc-059 (tool list + MCP leak check), uc-014/uc-060 (skill counts),
153
+ uc-119 (the live spawn flow through the docker sandbox).
154
+ - Live smoke (recorded 2026-10-08, ALL SCENARIOS PASSED): headless legs -
155
+ directory variant, worktree variant (the move persisted in the session
156
+ row + metadata), headless no-tab; TUI-visual legs (a real container
157
+ attach) - the subordinate's tab appeared unfocused, the title rendered,
158
+ the subordinate responded, and the persisted strip state matched; kill +
159
+ re-attach restored both tabs (restart persistence). Sandbox note: mkdir
160
+ the /qa host dir BEFORE `docker run -v` - a missing host path is created
161
+ inside the Docker VM, invisible to the mac.
@@ -115,6 +115,11 @@ Cursor takes priority because Cursor also sets `CLAUDE_PROJECT_DIR` as an
115
115
  alias. Without the priority check, Cursor sessions would be misidentified as
116
116
  Claude Code.
117
117
 
118
+ Separately, the project directory for store/chat identity resolution
119
+ follows `THATCH_PROJECT_DIR ?? CURSOR_PROJECT_DIR ?? CLAUDE_PROJECT_DIR
120
+ ?? cwd` in the MCP server; project-local setups pin `THATCH_PROJECT_DIR`
121
+ into the written MCP config.
122
+
118
123
  `checkSetup` looks for start and end markers in the host's instructions file
119
124
  --- `CLAUDE.md` for Claude Code, `AGENTS.md` for Cursor. It checks local first,
120
125
  then global. Local takes priority.
@@ -88,9 +88,15 @@ A `setInterval` loop (unref'd - it never keeps the process alive)
88
88
  runs `poll()` every 60 seconds (configurable). Each cycle:
89
89
  fetch the current state, run `diffPrState()` against the watcher's
90
90
  last-seen state, filter events to the watched types, queue them, and
91
- attempt delivery. Poll errors are per-watcher; one bad PR never
92
- blocks the others. The diff is a pure function, so it is unit-tested
93
- without any network access.
91
+ attempt delivery. When the fetched snapshot differs from the last-seen
92
+ state, the cycle also rewrites the session's journal row: registry
93
+ rebuilds (v2 reload rehydration, the watch tools' journal reconcile)
94
+ hydrate the journaled state verbatim, so a journal that lags the
95
+ poller would make the next diff re-fire already-delivered events -
96
+ observed live in October 2026 as a watch re-delivering the same CI
97
+ completion on every reload. Poll errors are per-watcher; one bad PR
98
+ never blocks the others. The diff is a pure function, so it is
99
+ unit-tested without any network access.
94
100
 
95
101
  The interval is surfaced to the model through the registry's
96
102
  `pollSeconds` getter: watch-creation output states when the first
@@ -126,6 +132,41 @@ when this run finishes" request from leaving a standing watch polling
126
132
  a target nobody is waiting on. `watch_list` marks one-shot watchers
127
133
  with a `[once]` tag, and the registration output states the mode.
128
134
 
135
+ ### Expiry
136
+
137
+ A watch whose TTL passes is dropped at the next poll cycle, and the
138
+ owning session is told: a `watch_expired` event queues through the
139
+ normal pending path, naming the target and the watch's lifetime in
140
+ minutes. The nudge framing for an all-expiry delivery states that the
141
+ watched condition never occurred - deliberately without the
142
+ greenlit-gating carve-out, because "the watch ran out of clock" must
143
+ never read as "the wait is over". Watches that expire while a session
144
+ is away are a separate path: they are not re-armed, and the re-arm
145
+ notice reports the expired count.
146
+
147
+ ### Death detection (closed v2 tabs)
148
+
149
+ A closed opencode v2 tab fires no `session.deleted`, so its watchers
150
+ would poll until the TTL with deliveries queued forever. The registry
151
+ declares death when ALL of: the session's pending queue has been
152
+ non-empty longer than the threshold (`THATCH_WATCH_DEATH_MINUTES`,
153
+ default 120), its delivery attempts have THROWN consecutively (a skip -
154
+ busy or compacting - resets the chain; skips are never death evidence),
155
+ the throw is 404-class (verified live 2026-10-07: prompt_async to a
156
+ closed v2 tab returns 404 while the session row persists; a 5xx loop is
157
+ a wedged server, never death), and the session is hosted by this
158
+ process (sibling v1 processes and MCP sessions are never touched).
159
+ MCP-owner watches keep TTL-only death: their turn-driven heartbeat
160
+ cannot distinguish a closed Cursor tab from a parked one. On death:
161
+ watchers and pending drop, and live same-project sessions get a
162
+ `watcherDeathNotice` carrying the dead session's chat name and session
163
+ id (the thatch-coordination loop's respawn key); a durable
164
+ `watcher_death` runtime_state row covers sessions that were busy at
165
+ death time, surfaced and cleared at other sessions' next prompt. The
166
+ notice says the watches were CANCELLED and points at `watch_create` -
167
+ the re-arm-on-resume promise belongs only to the restart-dormant path,
168
+ where the definitions survive and resuming the session re-arms them.
169
+
129
170
  ### Command watches
130
171
 
131
172
  The command source stretches the fetch/diff recipe in two ways:
@@ -180,6 +221,11 @@ Events queue in an in-memory pending map keyed by session. Delivery
180
221
  is gated by a `canDeliver` predicate the plugin supplies: the session
181
222
  must be idle (tracked from `session.status` events) and not
182
223
  compacting. Proactive prompts are never injected into a running turn.
224
+ The queue is visible to the session itself: `watch_list` appends an
225
+ "N events detected, waiting for idle to deliver" line with a
226
+ per-target breakdown while events sit undelivered, so a busy session
227
+ with queued events is distinguishable from a watcher that fired
228
+ nothing.
183
229
 
184
230
  When the gate is open, the plugin calls `client.session.promptAsync`
185
231
  on the session with a synthetic text part built by
@@ -206,9 +252,11 @@ cycle or when the session next goes idle (the event hook calls
206
252
  `pr_review_resolved`, `pr_commit`, `pr_status`, `pr_description`,
207
253
  `pr_ci`. The tool's `events` argument selects a subset; the default is
208
254
  all eight. Branch watchers add `branch_commit`, `branch_ci`, and
209
- `branch_workflow` (the full vocabulary is `WATCHER_EVENT_TYPES` in
210
- src/watchers.ts, test-enforced); command watchers register the single
211
- `command_success` type.
255
+ `branch_workflow`; command watchers register the single
256
+ `command_success` type. The registry itself emits one lifecycle type
257
+ across all sources, `watch_expired`, which is not selectable via the
258
+ `events` argument (the full vocabulary is `WATCHER_EVENT_TYPES` in
259
+ src/watchers.ts, test-enforced).
212
260
 
213
261
  ## Interactions with other features
214
262
 
@@ -229,6 +277,8 @@ src/watchers.ts, test-enforced); command watchers register the single
229
277
  - `THATCH_WATCH_TTL_MINUTES` - watcher time-to-live (default 480)
230
278
  - `THATCH_WATCH_MAX_PER_SESSION` - active watchers per session
231
279
  (default 5; all sources share the budget)
280
+ - `THATCH_WATCH_DEATH_MINUTES` - pending-age threshold before a dead
281
+ session's watchers are cancelled and announced (default 120)
232
282
  - `THATCH_WATCH_COMMAND_TIMEOUT_SECONDS` - per-run kill timeout for
233
283
  watched commands (default 30)
234
284
 
@@ -248,6 +298,12 @@ src/watchers.ts, test-enforced); command watchers register the single
248
298
  mocked gh runner
249
299
  - `tests/qa/auto/uc-101-command-watchers.ts` - command-watch lifecycle
250
300
  against a mocked command runner
301
+ - `tests/qa/auto/uc-114-watcher-expiry-replay.ts` - expiry notification
302
+ and the no-replay guarantee across journal rehydration
303
+ - `tests/qa/auto/uc-118-watcher-death-detection.ts` - closed-tab death
304
+ detection (404-class delivery persistence) and the once-only notice
305
+ - `tests/qa/auto/uc-115-watch-list-pending-events.ts` - pending-event
306
+ visibility in watch_list while the session is busy
251
307
 
252
308
  ## Adding a new source type
253
309