@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
@@ -27,7 +27,7 @@ Shared core
27
27
  ├── git.ts → detect repo identity (store name) + worktree-deletion recovery (repo_paths cache)
28
28
  ├── hygiene.ts → hygiene report (pending dedups, stale, orphaned branches)
29
29
  ├── prompts.ts → system prompt, compaction, reminders, recall/prediction/behavior nudges, prompt cores, CLAUDE.md instructions
30
- ├── commands.ts → /thatch slash-command registry: actions from prompt cores + wrap-ups, per-host sync
30
+ ├── commands.ts → /thatch slash-command registry: actions (prompt cores + self-contained skill couchings) + wrap-ups, per-host sync
31
31
  ├── sideband.ts → Unix socket server + client: warm-model semantic match for hook processes
32
32
  ├── skills.ts → SKILL.md content + installer (shared + opencode-only arrays)
33
33
  ├── scoring-engine.ts → generic four-table scoring base (shared by prediction + behavior engines)
@@ -45,6 +45,10 @@ OpenCode plugin path
45
45
  │ lazy-imports the adapter matching the host
46
46
  ├── opencode/v1.ts → v1 adapter: @opencode-ai/plugin hooks object
47
47
  ├── opencode/v2.ts → v2 adapter: @opencode/plugin promise-context domains
48
+ ├── opencode/tui-plugin.ts → v2 TUI CLI plugin (./tui entrypoint):
49
+ │ consumes the session-tab rpc events, drives ui.tabs
50
+ ├── session-tab-shared.ts → SDK-free cross-entry contract: session-tab rpc
51
+ │ definition, subordinate prompt builder, validators
48
52
  ├── capabilities.ts → HostCapabilities seam: the host operations the
49
53
  │ runtime needs; capabilitiesFromClient bridges the
50
54
  │ v1 SDK client
@@ -85,8 +89,9 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
85
89
  | `tools.ts` | Thin opencode wrappers. `buildCoreContext` constructs the shared per-call context; `createTools` wraps each tool-def in opencode's `tool()` with a `thatch_` prefix (v1 only). |
86
90
  | `mcp.ts` | Stdio JSON-RPC 2.0 server. Compiles zod schemas to JSON Schema via `z.toJSONSchema()` for `tools/list`. Validates args via `z.object().parse()` in `tools/call`. All logging to stderr (stdout is the transport). |
87
91
  | `index.ts` | Dual-shape OpenCode plugin entry: the merged default export (`{ id, setup, server }`) loads on opencode v1 (reads `default.server`) and v2 (reads `default.setup`, strips excess keys). Lazy-imports the host adapter so each host's SDK resolves only under its own runtime. |
88
- | `opencode/v1.ts` + `opencode/v2.ts` | Host adapters. v1 builds HostCapabilities from the PluginInput client and returns the hooks object; v2 registers through the promise-context domains (ToolEditor, session.hook, event.subscribe) and degrades the surfaces v2 lacks. |
89
- | `capabilities.ts` | The `HostCapabilities` seam: the host operations the shared runtime needs (session create/prompt/get/list/messages/delete, toast, compaction trigger, app exit). `capabilitiesFromClient` bridges the v1 SDK client. |
92
+ | `opencode/v1.ts` + `opencode/v2.ts` | Host adapters. v1 builds HostCapabilities from the PluginInput client and returns the hooks object; v2 registers through the promise-context domains (ToolEditor, session.hook, event.subscribe) and degrades the surfaces v2 lacks. v2 also wires the session-tab host flow (create -> move -> chat-register -> rpc emit -> prompt) through the `CoreContext.sessionTabHost` seam. |
93
+ | `opencode/tui-plugin.ts` | The v2 TUI CLI plugin (the package's `./tui` entrypoint): listens for `rpc.thatch-tabs.tab-opened` and `rpc.thatch-tabs.tab-closed` events and opens/closes the subordinate session's tab via `ui.tabs.open`/`ui.tabs.close`. See [features/session-tabs.md](features/session-tabs.md). |
94
+ | `capabilities.ts` | The `HostCapabilities` seam: the host operations the shared runtime needs (session create/prompt/get/list/messages/delete, toast, compaction trigger, session close-out - app exit on v1, the session's own tab on v2). `capabilitiesFromClient` bridges the v1 SDK client. |
90
95
  | `runtime.ts` | Shared plugin runtime: wires DB, model, extraction; the nudge tiers, system prompt injection, session event handling, child-session bookkeeping, wrap-up resolution. Host-agnostic: consumed by both adapters. |
91
96
  | `os-args.ts` | Pure argv / OS-command-line helpers for startup session resolution (`-s`/`-c`). SDK-free. |
92
97
  | `setup.ts` | `thatch setup --claude` / `--cursor` installer. Writes MCP config (`.mcp.json` / `.cursor/mcp.json`), appends to CLAUDE.md / AGENTS.md (idempotent), installs hooks in settings.json / hooks.json, installs skills. |
@@ -98,15 +103,16 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
98
103
  | `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`. |
99
104
  | `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. |
100
105
  | `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 and the alert state machine. |
101
- | `alerts.ts` | LLM alert state machine (opencode-only): pause/done/error decisions from session events, per-session dedup (no timer-based debounce - one alert per busy->idle transition, per request id for pauses). Fed by `runtime.ts`'s event hook; delivers through `notify.ts`. Plain memory by design (a reload loses at most one transition). See [../plans/llm-alerts.md](../plans/llm-alerts.md). |
106
+ | `alerts.ts` | LLM alert state machine (opencode-only): pause/done/error decisions from session events, per-session dedup (no timer-based debounce - one alert per busy->idle transition, per request id for pauses). Fed by `runtime.ts`'s event hook; delivers through `notify.ts`. Plain memory by design (a reload loses at most one transition). See [features/notifications.md](features/notifications.md). |
102
107
  | `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. |
103
108
  | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Three sources: GitHub PRs and branches via `gh api` (injected `GhRunner`), and local shell commands via `bash -c` (injected `CommandRunner`) run as condition variables - only the exit code is read, never the command's output. Diffs against last-seen state, queues events (pointer data plus machine status like check conclusions and exit codes, never external content) per session, and delivers them through a plugin-supplied callback when the session is idle. The `pollSeconds` getter surfaces the poll cadence to tool output and notifications. Process-lifetime state by design - no SQLite. |
104
109
  | `chat.ts` | Cross-session chat between opencode sessions on one machine (opencode-only). `ChatStore` holds the shared directory and inbox in SQLite so any process can read/write them; `ChatPoller` heartbeats hosted sessions and delivers wake prompts through an injected callback when the recipient is idle - only the recipient's host process delivers, so there is no cross-process double-delivery. Heartbeat staleness flags crashed sessions; a per-recipient nudge rate cap is the anti-loop hard brake. See [features/cross-session-chat.md](features/cross-session-chat.md). |
105
110
  | `chat-names.ts` | The static display-name pool for the chat directory: whimsical geek-culture names in fnord's Nomenclater style, baked in so name assignment never costs a model call. The pool is the fallback base for registrations with no usable session title. |
111
+ | `tab-state.ts` | Read-only open-tab reader for the chat roster: globs every opencode channel's `tabs.json` (global + cwd scopes), subtracts fresh confirmed tab closes, and composes the provider the roster surfaces and broadcast targeting consume. Degrades to an empty set - never a false OPEN. See [features/cross-session-chat.md](features/cross-session-chat.md). |
106
112
  | `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. |
107
113
  | `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`), watcher notification (`watcherNotificationNudge`), version warning (`versionWarningNudge`), chat wake notification (`chatNotificationNudge`), chat transcript echo text and echo recognizer (`chatEchoText` / `isChatEchoParts`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. Also the prompt cores (`defragCore`, `hygieneCore`, `reflectCore`, `extractionCore`) - host-agnostic behavior instruction bodies shared by nudges and `/thatch` commands, parameterized by a `ToolNamer` for per-host tool spellings. See [features/commands.md](features/commands.md). |
108
- | `commands.ts` | Slash-command registry and sync. `actionDefs(tool)` renders the on-demand actions (defrag, extract, hygiene, reflect) from the prompt cores; the wrap-up commands (compact, exit) are bespoke greenlight-gated templates. Renderers produce full command files for opencode (`installOpencodeCommands`, synced by the plugin at init) and Claude Code (`installClaudeCommands`, synced by setup); `compilePrompts` in `mcp.ts` exposes the same actions as MCP prompts for Cursor. A parity-guard test pins the three sets to the documented exclusions. See [features/commands.md](features/commands.md). |
109
- | `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, plan-refinement, 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]`. |
114
+ | `commands.ts` | Slash-command registry and sync. `actionDefs(tool)` renders the on-demand actions: the nudge-backed ones (defrag, extract, hygiene, reflect) from the prompt cores, and the self-contained ones (refine, whois, and the review-mode couchings peer-review, self-review) inline in the registry; the wrap-up commands (compact, exit) are bespoke greenlight-gated templates. Renderers produce full command files for opencode (`installOpencodeCommands`, synced by the plugin at init) and Claude Code (`installClaudeCommands`, synced by setup); `compilePrompts` in `mcp.ts` exposes the same actions as MCP prompts for Cursor. A parity-guard test pins the three sets to the documented exclusions. See [features/commands.md](features/commands.md). |
115
+ | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (everything that works on all three hosts: the memory workflows, the review specialists — whose roster comes from `REVIEW_SPECIALISTS` in prompts.ts — plus the synthesizer, context, followup, and response skills, the walkthrough and archaeology skills, the refinement family, the writing skills, and verification/export) and `OPENCODE_ONLY_SKILLS` (the code-review coordinator and thatch-coordination — they need sub-agent support, so they are not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
110
116
  | `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. |
111
117
  | `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
112
118
  | `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
@@ -124,7 +130,7 @@ methods. The v1 hook shapes:
124
130
  |------|-------------|
125
131
  | `experimental.chat.system.transform` | Appends the thatch system prompt (store names, usage rules). |
126
132
  | `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
127
- | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
133
+ | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the host action (compact: v1 `session_compact` via `executeCommand`, v2 `session.compact` on the promise domain; exit: record the session's watcher deaths, unregister it from chat, then v1 `app.exit` via `publish` or v2 the exit-tab-closed rpc event closing the session's own tab). See [features/session-lifecycle.md](features/session-lifecycle.md). |
128
134
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
129
135
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
130
136
  | `chat.message` | Recall nudge: embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges fire independently in separate try/catch blocks with separate synthetic parts. There is deliberately NO extraction nudge here - extraction is plugin-driven at session idle (see the extraction feature doc); the old model-driven handshake raced its own state machine and was removed. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
@@ -263,14 +269,16 @@ extraction cycle
263
269
  → MCP path (Claude Code/Cursor): unchanged — no SDK client, no child
264
270
  sessions; extract-queue.ts + flush-tools drives the nudge via hooks
265
271
 
266
- toast notifications (opencode-only, TUI-rendered)
272
+ toast notifications (opencode-only, TUI-rendered; the bridge's toast
273
+ event -> the TUI plugin's ui.toast.show on v2)
267
274
  → client.tui.showToast — best-effort, silently ignored if TUI not
268
275
  connected (headless mode); model-invisible (goes to the user only,
269
276
  not the conversation history — the inverse of synthetic nudge parts,
270
277
  which are model-visible but TUI-hidden)
271
278
  → extraction metrics: `[thatch] new: N, updated: M, deleted: K` (success
272
- variant, 4s) — fires when an extraction child goes idle; no-save runs
273
- show `[thatch] extraction complete — nothing to save` (info variant)
279
+ variant, 4s) — fires when an extraction child goes idle, but ONLY when
280
+ memories were actually written; no-save runs show nothing (no
281
+ notification fatigue)
274
282
  → recall matches: `[thatch] recalled N memories` (info variant, 3s) —
275
283
  fires when chat.message matches stored memories
276
284
  → prediction matches: `[thatch] N predictions surfaced` (info variant,
@@ -23,7 +23,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
23
23
  | [behavior-engine.md](behavior-engine.md) | Behavior engine (LLM self-discipline rules, ham/spam, auto-fire) |
24
24
  | [hygiene.md](hygiene.md) | Hygiene system (duplicate, stale, orphaned branch signals) |
25
25
  | [deduplication.md](deduplication.md) | Deduplication system (find pairs, verdict lifecycle) |
26
- | [commands.md](commands.md) | Slash commands (on-demand actions from shared prompt cores, wrap-ups, host sync) |
26
+ | [commands.md](commands.md) | Slash commands (on-demand actions: prompt cores and skill couchings, wrap-ups, host sync) |
27
27
  | [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
28
28
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
29
29
  | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking, wrap-up commands) |
@@ -113,6 +113,8 @@ Parameters: `behavior` (statement, semantic match at cosine >= 0.85),
113
113
  - Injects `behaviorNudge(items)` as a separate synthetic text part with a
114
114
  `[thatch] Situational behaviors` header.
115
115
  - 0-evidence behaviors use "consider"; behaviors with evidence use "do".
116
+ - The block ends with a snapshot footer (same as the prediction nudge): the
117
+ labels are a submission-time snapshot, not a live view.
116
118
  - Independent try/catch — a behavior failure does not block recall or
117
119
  prediction.
118
120
  - Toast: `[thatch] N behaviors surfaced`.
@@ -250,6 +252,14 @@ behavior_provenance(
250
252
  )
251
253
  ```
252
254
 
255
+ Timestamp semantics: a matcher's `updated_at` is the last change to the
256
+ matcher row OR its edge set — `createEdge` bumps it when a new edge lands
257
+ (atomically with the insert), and deleting an item bumps the matchers whose
258
+ edges cascade away (atomically with the delete). The matcher's own text may
259
+ legitimately be older than its links (a corrected behavior re-links the
260
+ existing matcher without touching its text). A behavior's `updated_at` is
261
+ written at creation and then moved by the last confidence change.
262
+
253
263
  ## Key invariants
254
264
 
255
265
  1. **The firewall principle does NOT apply.** The LLM grading its own
@@ -51,7 +51,8 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
51
51
  (`formatChatRoster()` in `bin/thatch`; pipes and QA runners get plain
52
52
  text). Rows are split into Active and Stale sections by
53
53
  `splitChatRoster()`; the status column is `chatLiveness()`'s verdict
54
- (`fresh`/`stale` for opencode rows, `active`/`idle` for MCP rows).
54
+ (`open`/`recent`/`stale` for opencode rows - the open-tab reader feeds
55
+ the open/recent split - `active`/`idle` for MCP rows).
55
56
  - `chat tail` follows the message stream as JSONL: one `ChatTailEvent`
56
57
  per line via `formatChatTailJsonl()` (a bare `JSON.stringify`; no ANSI,
57
58
  no markdown rendering, no local-time conversion - the tail is a log for
@@ -88,6 +89,8 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
88
89
  | `THATCH_CHILD_RECONCILE_MS` | Reload window before a restored extraction child is judged finished (test knob) | `10000` |
89
90
  | `CLAUDE_PROJECT_DIR` | Claude Code project dir | `process.cwd()` |
90
91
  | `CURSOR_PROJECT_DIR` | Cursor project dir | falls through |
92
+ | `THATCH_WATCH_DEATH_MINUTES` | Pending-age threshold for watcher death detection | `120` |
93
+ | `THATCH_PROJECT_DIR` | Pinned project dir (written by project-local `thatch setup` MCP configs) | falls through |
91
94
  | `CLAUDE_CONFIG_DIR` | Claude config dir | `~/.claude` |
92
95
  | `CURSOR_CONFIG_DIR` | Cursor config dir | `~/.cursor` |
93
96
  | `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | Enable async extraction | unset |
@@ -2,8 +2,11 @@
2
2
 
3
3
  Thatch's `/thatch/*` commands come in two families:
4
4
 
5
- - **Actions** (`defrag`, `extract`, `hygiene`, `reflect`) — user-invoked
6
- wrappers around behaviors the nudges otherwise run on their own schedule.
5
+ - **Actions** (`defrag`, `extract`, `hygiene`, `peer-review`, `refine`,
6
+ `reflect`, `self-review`, `whois`) — user-invoked commands. The
7
+ nudge-backed ones (`defrag`, `extract`, `hygiene`, `reflect`) wrap
8
+ behaviors the nudges otherwise run on their own schedule; the rest
9
+ are self-contained, with no nudge counterpart.
7
10
  - **Wrap-ups** (`compact`, `exit`) — greenlight-gated pre-compaction and
8
11
  pre-exit checklists. opencode only: the greenlight check needs the
9
12
  plugin's `command.execute.before` arming and TUI control routes.
@@ -12,11 +15,12 @@ User-facing guide: [../../user/commands.md](../../user/commands.md).
12
15
 
13
16
  ## Prompt cores
14
17
 
15
- Action bodies are **prompt cores**: constants in `src/prompts.ts`
16
- (`defragCore`, `hygieneCore`, `reflectCore`, `extractionCore`) that hold the
17
- host-agnostic instruction text for one behavior. The nudge envelopes wrap
18
- the same cores with escalation tiers and the stop-and-wait ending; command
19
- and prompt renderers wrap them with frontmatter and a user-message section.
18
+ The nudge-backed action bodies are **prompt cores**: constants in
19
+ `src/prompts.ts` (`defragCore`, `hygieneCore`, `reflectCore`,
20
+ `extractionCore`) that hold the host-agnostic instruction text for one
21
+ behavior. The nudge envelopes wrap the same cores with escalation tiers
22
+ and the stop-and-wait ending; command and prompt renderers wrap them with
23
+ frontmatter and a user-message section.
20
24
  The point is drift-proofing: a wording change lands in the nudge and the
21
25
  command at once, and a test asserts each rendered file contains its core
22
26
  verbatim.
@@ -30,13 +34,40 @@ per host: `thatch_find_duplicates` on opencode, `mcp__thatch__find_duplicates`
30
34
  on MCP hosts. Each host's renderer passes its own namer
31
35
  (`opencodeToolName` / `mcpToolName`).
32
36
 
37
+ ## Skill-couching actions
38
+
39
+ A command body can **couch** a skill: wrap the skill's procedure with the
40
+ additional instructions the command needs. `refine`, `peer-review`,
41
+ `self-review`, and `whois` are not prompt cores: they have no nudge
42
+ counterpart to stay in sync with, so their bodies live directly in
43
+ `actionDefs` (src/commands.ts). `refine` triggers the `thatch-refine`
44
+ router skill and `whois` prints the chat identity; neither applies an
45
+ overlay.
46
+
47
+ `peer-review` and `self-review` share one pipeline couching
48
+ (`reviewPipelineCouching` in src/commands.ts): run the code review
49
+ machinery — the `thatch-code-review` coordinator where that skill is
50
+ installed (opencode), the inline specialist sequence where it is not
51
+ (Claude Code, Cursor) — and apply a mode overlay that reweights the
52
+ findings for whose work is under review. The roster and count for the
53
+ inline sequence come from `REVIEW_SPECIALISTS` in src/prompts.ts, the
54
+ same source as the install list in src/skills.ts and both host
55
+ instruction skill lists. `peer-review` weights org
56
+ velocity and walks the project's persistent memory (the one tool it
57
+ names, spelled through the `ToolNamer` so MCP renderings stay correct);
58
+ `self-review` weights the reputation lenses (no-slop, technique,
59
+ breadcrumbs) and iterates rounds until a round produces no meaningful
60
+ findings. The couching also names the MCP `Focus:` line as a target
61
+ source, because MCP prompts replace the file commands' `$ARGUMENTS`
62
+ section with a `Focus:` line.
63
+
33
64
  ## Rendering and sync
34
65
 
35
66
  `src/commands.ts` is the single registry:
36
67
 
37
68
  - `actionDefs(tool)` returns the action set with bodies rendered for one
38
69
  host. Actions needing plugin-only capabilities are marked `opencodeOnly`
39
- (`extract`: needs session identity via `get_session_info`).
70
+ (`extract` and `whois`: they need the invoking session's identity).
40
71
  - `opencodeCommandDefs()` = wrap-up templates + all actions. Synced by the
41
72
  plugin at init into `$XDG_CONFIG_HOME/opencode/command/thatch/`
42
73
  (`installOpencodeCommands`), content-compare, self-healing.
@@ -52,7 +83,7 @@ on MCP hosts. Each host's renderer passes its own namer
52
83
 
53
84
  A parity-guard test asserts the three sets are the same actions minus the
54
85
  documented exclusions: opencode = Claude Code set + `compact`, `exit`,
55
- `extract`; MCP prompts = Claude Code's set.
86
+ `extract`, `whois`; MCP prompts = Claude Code's set.
56
87
 
57
88
  ## The extract action and session IDs
58
89
 
@@ -86,6 +86,29 @@ worktree, a `.git` directory means the project root, no `.git` reads as
86
86
  undetected. The roster shows it as a `loc:` token so sessions coordinating
87
87
  on a shared tree can tell who sits where.
88
88
 
89
+ ### Machinery sessions never auto-register
90
+
91
+ Sessions the plugin itself creates as machinery are not chat
92
+ participants and never join the directory: the direct-extraction child
93
+ (`triggerExtraction`, titled `thatch-extraction`) is the current case.
94
+ The primary guard is durable, not in-memory: child creation writes a
95
+ `chat_machinery` row (src/db.ts), and both auto-register paths (prompt
96
+ and idle) refuse a marked session. This matters because the in-memory
97
+ `childToParent` mapping that normally keeps children out is lost to a
98
+ plugin reload or daemon restart - an instance that processes the child's
99
+ events without the mapping would otherwise register it, and since v2
100
+ cannot delete sessions (see docs/dev/features/opencode-plugin.md), the
101
+ row never cleans up. The poller heartbeats every registered row of the
102
+ project it serves, so a machinery corpse stayed fresh forever and the
103
+ stale-row reaper could not reap it (81 corpse rows by 2026-10-07). The
104
+ idle path deletes any corpse row it finds for a marked session
105
+ (self-healing) and additionally refuses a session whose fetched title is
106
+ the machinery title (`isMachinerySessionTitle`, src/chat.ts) - the title
107
+ is the one signal that survives even a lost marker. Markers age out with
108
+ the 7-day auto-row TTL; session ids are never reused, so an expired
109
+ marker cannot resurrect a row. Explicit `chat_register` is unaffected:
110
+ a model deliberately joining the directory is never machinery.
111
+
89
112
  ### Startup registration and reclaim
90
113
 
91
114
  A session continued via `opencode -s <id>` gets no event when it comes
@@ -123,10 +146,10 @@ mechanical barrier, a documented trust posture.
123
146
  ### Leave tombstones
124
147
 
125
148
  Every exit path - `chat_unregister`, `session.deleted`, and a greenlit
126
- `/thatch/exit` (the plugin unregisters the exiting session before
127
- publishing `app.exit`, so the roster stops advertising a host that is
128
- about to vanish; the exit template's checklist step asks the model to do
129
- the same earlier) - writes a
149
+ `/thatch/exit` (the plugin unregisters the exiting session before the
150
+ host close-out - v1 publishes `app.exit`, v2 closes the session's own
151
+ tab - so the roster stops advertising a host that is about to vanish)
152
+ - writes a
130
153
  `chat_leave_tombstones` row in the same transaction that deletes the
131
154
  directory row. The tombstone is what makes a leave stick: all
132
155
  registration paths consult it (the plugin's idle and prompt auto-registerers are
@@ -168,15 +191,78 @@ unregistration; a departed sender degrades to an unknown name in the
168
191
  reader's view (the endpoints are deliberately not foreign keys - leaving the
169
192
  directory must not be blocked by history).
170
193
 
194
+ Bodies over the part budget are not refused: `splitChatBody` (src/chat.ts)
195
+ auto-splits them into parts, and the first part is prefixed "(message 1 of
196
+ N)" while every part but the last is suffixed "(continued in next
197
+ message)", so a reader assembles the batch before acting on it. Cuts
198
+ prefer blank-line paragraph boundaries and back off a hard cut so a
199
+ surrogate pair never splits mid-character. The parts of one send are
200
+ inserted in ONE transaction - a crash cannot leave a partial batch - and
201
+ the tool output reports the split ("SENT AS N PARTS") so a caller cannot
202
+ mistake it for a single delivery.
203
+
204
+ Two budgets govern the split, and the docs state both honestly:
205
+
206
+ - The part budget is `CHAT_SPLIT_PART_LEN`, default 2000 UTF-16 units
207
+ (`THATCH_CHAT_SPLIT_PART_LEN` to override, clamped to 500..9000). The
208
+ DEFAULT is sized to the worst host we have observed: a version-skewed
209
+ opencode install whose tool-result renderer clipped at exactly 2000
210
+ characters with no saved-output pointer, cutting each part off before
211
+ the model saw it. Current opencode v2 (pinned tag v2.0.24) passes
212
+ 50 KiB and 2000 lines per tool result (packages/core/src/tool-output.ts
213
+ MAX_LINES/MAX_BYTES, host-configurable via
214
+ `tool_output.max_lines/max_bytes`), so a host on a known-good version
215
+ can safely raise the override.
216
+ - The whole body is capped by `CHAT_MAX_BODY_CHARS`, default 20,000
217
+ characters (`THATCH_CHAT_MAX_BODY_CHARS`), checked up front at accept; the
218
+ number of parts derives from it (ceil(total/partLen)). The refusal
219
+ reports the attempted count so a caller can split deterministically.
220
+ The grouped chat_read of a split batch renders as ONE tool result, so
221
+ the ceiling keeps a worst-case batch read (at most ten 2000-char parts
222
+ plus framing) comfortably inside the 50 KiB v2 envelope while still
223
+ bounding the context bomb the old 10k refusal used to block.
224
+
225
+ Read-side clipping: the chat_read drain shows each message up to
226
+ `CHAT_READ_PAGE_LEN` (2000) characters; longer rows are shown clipped
227
+ with a per-message marker that names the unit - "[clipped: showing 2000
228
+ of 9050 characters" - and the deterministic recovery path. Every drain
229
+ line carries the message id, and `chat_read` accepts `message` (a row id)
230
+ and `offset` (a character offset) to page through one message a page at a
231
+ time without draining or marking anything read. This is the recovery for
232
+ both a drain clip and a HOST-side tool-result clip (the version-skew host
233
+ above): even when a marker or a whole message is cut, the frame note at
234
+ the top of the result teaches the paging call, and ids make it exact.
235
+
171
236
  `chat_broadcast` posts one message to every other registered session at
172
- once, one inbox row per recipient - the existing wake machinery (grouping,
173
- gating, rate cap) treats each as ordinary mail. Stale sessions are skipped
174
- and reported rather than messaged: a host that has stopped heartbeat-ing
175
- will never read the mail, and dead mail to a dead process is just clutter.
176
- The sender is excluded. It is a separate tool rather than a magic
177
- `chat_send` recipient because "send to all" changes the behavior (fan-out,
178
- stale skipping, no address resolution), and a function that changes
179
- behavior drastically on a parameter value is two functions.
237
+ once, one inbox row per recipient per part - the existing wake machinery
238
+ (grouping, gating, rate cap) treats each as ordinary mail. Opencode rows
239
+ are skipped and reported unless they are OPEN (fresh heartbeat AND a tab
240
+ open now, per the open-tab reader below): a host that has stopped
241
+ heartbeat-ing will never read the mail, and a fresh-but-tab-less session's
242
+ wake can be swallowed without running a turn, so messaging it would spend
243
+ a model turn on a delivery that likely never fires. MCP sessions always
244
+ receive (they read mail at their next prompt). Every skip is listed by
245
+ name in the result's `skipped` field. The sender is excluded. It is a
246
+ separate tool rather than a
247
+ magic `chat_send` recipient because "send to all" changes the behavior
248
+ (fan-out, skip classes, no address resolution), and a function that
249
+ changes behavior drastically on a parameter value is two functions.
250
+
251
+ Retries are idempotent by READ STATE within a short window: a send (or
252
+ per-recipient broadcast batch) whose content matches an existing row on
253
+ sender, recipient, body, and delivery kind within `CHAT_DEDUPE_WINDOW_MS`
254
+ (10s) succeeds without a second insert - but ONLY while a matching row is
255
+ still UNREAD in the recipient's inbox. The mail is there; a retry adds
256
+ nothing. Once every matching row was read, the identical body DELIVERS
257
+ again: presumptively an intentional resend after the first was consumed
258
+ (a repeated "ok", a resend the reader asked for). The tool output says
259
+ why it suppressed ("still UNREAD in the recipient's inbox"). This exists
260
+ because a harness that times out a chat_send which actually committed
261
+ retries the call, and the identical body otherwise lands twice (observed
262
+ 2026-10-07). The same body outside the window is always a new message.
263
+ For a split batch, ANY unread part suppresses the retry - idempotent even
264
+ while the recipient is mid-read (the poller keeps nudging the unread
265
+ parts), and only a fully read batch lets an intentional repeat through.
180
266
 
181
267
  ### Polling, heartbeat, staleness
182
268
 
@@ -188,7 +274,8 @@ fires no `session.deleted`, so its rows would linger - staleness catches
188
274
  them: a row that has missed two consecutive beats (`CHAT_STALE_MS`, 60s
189
275
  at the default interval) is stale, meaning its harness stopped. Two beats
190
276
  rather than one so a single late poll cycle does not flap the roster.
191
- Heartbeat age is the ONLY liveness signal, by design. A host process id
277
+ Heartbeat age is the only WRITTEN liveness signal, by design (the tab
278
+ reader below is read-only). A host process id
192
279
  cannot serve: the same session is re-hosted by a new process on every
193
280
  `-s` resume, so a pid stamped on the row lies as soon as a different
194
281
  harness beats it (it says dead while the heartbeat proves alive). RESTART
@@ -199,12 +286,43 @@ into, so without the grace they would read stale while alive-idle and
199
286
  mail to them would not wake the tab. Sessions that emit activity
200
287
  graduate to permanent hosting; still-silent ones (closed tabs) age out
201
288
  at the grace and go stale. `isStale`
202
- (src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
203
- and the broadcast skip all call it, so they cannot disagree. `chat_list`
289
+ (src/chat.ts) is the single staleness rule; `chatLiveness`, the `chat_send`
290
+ note, and the broadcast skip all call it, so they cannot disagree.
291
+
292
+ ### The open-tab reader: OPEN vs RECENT
293
+
294
+ Heartbeat age alone conflates presence with recency: a finished
295
+ conversation stays fresh until its directory's instance stops beating it
296
+ (days), and a closed tab leaves a fresh row. The refinement is a READ-ONLY
297
+ second signal, `src/tab-state.ts`: at roster render and broadcast time,
298
+ glob `<XDG_STATE_HOME>/opencode/*/tui/tabs.json` (one file per release
299
+ channel - "latest" today, hence the glob), parse BOTH scope modes
300
+ (`global.tabs` and per-path `cwd.*.tabs`; the scope depends on opencode's
301
+ `tabs.scope` config), and build the set of sessionIDs carrying a tab.
302
+ Missing files, bad JSON, and absent state trees degrade to an empty set -
303
+ an unknown tab state never reads as OPEN. `chatLiveness` takes the set and
304
+ returns `open` (fresh + tab), `recent` (fresh + no tab), `stale`;
305
+ MCP rows keep `active`/`idle`. Callers compose the raw set with confirmed
306
+ tab closes via `effectiveOpenTabs` (`chatOpenTabs` is the wired provider):
307
+ the `session.tab_closed` bridge event (the session_tab tool's confirmed
308
+ close) writes a `chat_tab_closed` runtime_state marker, and a FRESH marker
309
+ (inside `CHAT_TAB_CLOSED_OVERRIDE_MS`, 2 staleness windows) subtracts the
310
+ session from the set - so a residue tabs.json row cannot read OPEN right
311
+ after a confirmed close. The marker self-expires (the death path stops the
312
+ heartbeat, so the row goes stale on heartbeat age regardless) and is
313
+ superseded by any later beat.
314
+
315
+ Known limits, accepted: a killed window leaves tab rows behind (the file
316
+ is restart persistence by design), so dead-window residue reads OPEN until
317
+ its heartbeat ages out; tabs disabled degrades everything to RECENT.
318
+
319
+ `chat_list`
204
320
  groups the result into Active and Stale sections - the stale section's
205
- explainer says what stale means - and `chat_send` states the recipient's
321
+ explainer says what stale means, and each row renders its verdict -
322
+ and `chat_send` states the recipient's
206
323
  liveness at send time, so a sender mailing a ghost learns it immediately
207
- instead of waiting on a wake that will never fire. Both roster surfaces
324
+ instead of waiting on a wake that will never fire, and a sender mailing a
325
+ tab-less session is told the wake is not guaranteed. Both roster surfaces
208
326
  order rows with `sortChatRoster` (also src/chat.ts): project first,
209
327
  alphabetical, with no-project rows last; most recently seen first within
210
328
  a project. The CLI roster bounds
@@ -231,6 +349,22 @@ and a count, pointer-only like watcher notifications, with the model calling
231
349
  `chat_read` for content. On success the poller stamps `delivered_at`
232
350
  (re-stamping on re-nudges resets the re-nudge timer).
233
351
 
352
+ Before any wake attempt runs, the delivery guard (`guardDelivery` in
353
+ `src/runtime.ts`) checks that the runtime instance's own directory - the
354
+ bound directory of every session it hosts - still exists. A deleted
355
+ worktree (merged branch) would otherwise error every attempt, and opencode
356
+ surfaces that error to the user as a toast. The guard skips the attempt
357
+ with a `runtime:delivery` debug line instead, leaving the mail queued; the
358
+ check is per attempt, so a worktree recreated at the same path resumes
359
+ delivery. The existence check cannot say why a path is absent (deleted
360
+ tree, broken symlink, parent permission), so any miss classifies as gone -
361
+ deliberate, because surfacing non-ENOENT errors through the attempt would
362
+ buy the toast back for no recovery the pending-and-retry cycle does not
363
+ already provide. The guard wraps `promptSession` in
364
+ `createRuntime`, which covers chat nudges, watcher notifications and death
365
+ notices, rehydrate pings, chat echoes, and extraction dispatch at once;
366
+ MCP hosts never build a runtime, so they are unaffected.
367
+
234
368
  The hard brake: at most a fixed number of wake prompts per recipient per
235
369
  hour (default 6, in-memory on purpose - it is a loop guard, not accounting).
236
370
  Without it, two agents politely acknowledging each other would ping-pong
@@ -360,11 +494,23 @@ parent pid against that table (`chatDerivedIdentity` on CoreContext,
360
494
  freshness-capped at 600s to bound pid reuse). A second model-proof layer
361
495
  is `CLAUDE_CODE_SESSION_ID`, which the host sets in the stdio server's
362
496
  environment at spawn. `resolveChatIdentity` priority: opencode host
363
- context > ppid mapping > env id > claimed `as`. Cursor's hooks and MCP
497
+ context > ppid mapping > env id > claimed `as`. The `as` fallback is also directory-bound: the
498
+ claimed identity must belong to the caller's project (the store slug
499
+ every registration writes as the row's project column) - a cross-project
500
+ or null-project claim is refused with a re-register escape hatch.
501
+ Cursor's hooks and MCP
364
502
  servers share one workspace process (ambiguous ppid, no per-session env),
365
503
  so Cursor keeps the claimed-`as` path, documented in the user doc's
366
504
  security model.
367
505
 
506
+ **Server project resolution.** The MCP server resolves its own project
507
+ directory as `THATCH_PROJECT_DIR ?? CURSOR_PROJECT_DIR ??
508
+ CLAUDE_PROJECT_DIR ?? cwd` (a superset of detectRepo's chain), maps
509
+ `unknown` to `global` exactly like the hook, and project-local setups
510
+ pin `THATCH_PROJECT_DIR` into the written MCP config so the resolution
511
+ is cwd-independent. Global-scope registrations are never pinned - one
512
+ entry serves every project.
513
+
368
514
  **Continuation adoption (Claude Code).** Claude Code forks the session id
369
515
  on resume - and has been observed to fork spontaneously (agent-team
370
516
  conversion, self-update, config reload) - which would strand the old
@@ -382,21 +528,43 @@ caller's identity and mailbox, and is absent entirely when chat is
382
528
  disabled - absence is silent by construction.
383
529
  Staleness semantics differ by kind: an opencode row's stale age means its
384
530
  harness stopped beating (broadcast skips it), while an mcp row's age only means
385
- "between turns" (broadcast always delivers).
531
+ "between turns" (broadcast always delivers). Broadcast also skips fresh
532
+ opencode rows with no open tab (see the open-tab reader) - the skip
533
+ classes are reported by name either way.
534
+
535
+ The `/thatch/whois` command (opencode-only action, src/commands.ts) is the
536
+ user-facing identity report: it relays `chat_status` for the invoking
537
+ session's handle, then joins the roster row for topic, project, and the
538
+ `loc:` worktree token - the topic being the live TUI session title, it is
539
+ how the operator maps a roster handle to a visible tab. An argument looks
540
+ up another session's row instead. It reports only; it never reads mail or
541
+ mutates anything.
386
542
 
387
543
  ## Defaults
388
544
 
389
545
  Timing constants live in `src/chat.ts`: poll interval 30s, staleness
390
546
  threshold two missed beats (60s), re-nudge window 15 minutes, nudge cap 6 per recipient
391
- per hour, auto-row TTL 7 days. There are no environment overrides yet; add
392
- them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
547
+ per hour, auto-row TTL 7 days, confirmed-close override window two
548
+ staleness windows (`CHAT_TAB_CLOSED_OVERRIDE_MS` in `src/tab-state.ts`).
549
+ Environment overrides: `THATCH_CHAT_SPLIT_PART_LEN`
550
+ (auto-split part budget, default 2000, clamped 500..9000),
551
+ `THATCH_CHAT_MAX_BODY_CHARS` (whole-body acceptance ceiling, default 20000), and
552
+ `THATCH_OPENCODE_STATE_ROOT` (the open-tab reader's state root, tests and
553
+ QA sandboxes only);
554
+ the split constants are resolved once at module load and sanitized to positive integers
555
+ within their clamps - invalid values silently fall back to the default.
393
556
 
394
557
  ## Source files
395
558
 
396
559
  - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
397
- pool pick, slugify, counter bump, insert, prune, topic refresh), ChatPoller
560
+ pool pick, slugify, counter bump, insert, prune, topic refresh, broadcast
561
+ targeting), ChatPoller
398
562
  (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
399
- staleness helper
563
+ staleness helper, chatLiveness (the open/recent/active/idle/stale
564
+ verdicts)
565
+ - `src/tab-state.ts` - the read-only open-tab reader: per-channel
566
+ tabs.json glob (global + cwd scopes), confirmed-close override window,
567
+ the composed open-tabs provider
400
568
  - `src/chat-names.ts` - the static display-name pool (nomenclater style),
401
569
  the base for every assigned name
402
570
  - `src/db.ts` - the chat tables in schema init (including
@@ -418,5 +586,8 @@ them the way `THATCH_WATCH_POLL_SECONDS` works if a user needs them.
418
586
  - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
419
587
  - `tests/qa/auto/uc-099-chat-cli.ts` - `chat list` roster and `chat tail`
420
588
  JSONL contract over a seeded DB
589
+ - `tests/qa/auto/uc-116-chat-send-dedupe.ts` - a retried send is suppressed
590
+ - `tests/qa/auto/uc-119-chat-split-long-messages.ts` - an oversized body is
591
+ auto-split into marker-decorated parts
421
592
  - `tests/qa/live/uc-098-chat-cross-session.ts` - two real sessions exchange
422
593
  a message through the shared DB
@@ -96,7 +96,7 @@ prediction_matchers(
96
96
  embedding BLOB,
97
97
  model TEXT,
98
98
  created_at TEXT NOT NULL,
99
- updated_at TEXT NOT NULL
99
+ updated_at TEXT NOT NULL -- last change to the matcher row or its edge set
100
100
  )
101
101
  ```
102
102
 
@@ -116,7 +116,7 @@ predictions(
116
116
  confirm_count REAL NOT NULL DEFAULT 0,
117
117
  disconfirm_count REAL NOT NULL DEFAULT 0,
118
118
  created_at TEXT NOT NULL,
119
- updated_at TEXT NOT NULL
119
+ updated_at TEXT NOT NULL -- written at creation, then last confidence change
120
120
  )
121
121
  ```
122
122
 
@@ -167,7 +167,7 @@ behavior_matchers(
167
167
  embedding BLOB,
168
168
  model TEXT,
169
169
  created_at TEXT NOT NULL,
170
- updated_at TEXT NOT NULL
170
+ updated_at TEXT NOT NULL -- last change to the matcher row or its edge set
171
171
  )
172
172
  ```
173
173
 
@@ -185,7 +185,7 @@ behaviors(
185
185
  confirm_count REAL NOT NULL DEFAULT 0,
186
186
  disconfirm_count REAL NOT NULL DEFAULT 0,
187
187
  created_at TEXT NOT NULL,
188
- updated_at TEXT NOT NULL
188
+ updated_at TEXT NOT NULL -- written at creation, then last confidence change
189
189
  )
190
190
  ```
191
191
 
@@ -10,8 +10,7 @@ Two delivery paths share the dispatcher and the config file:
10
10
  - **Agent-initiated**: the LLM calls `notify_user`. Works on every host.
11
11
  - **Automatic alerts** (opencode-only): the plugin runtime watches session
12
12
  events and notifies when the LLM pauses for human input or finishes a
13
- round of real work. See the Alerts section below and
14
- `docs/plans/llm-alerts.md` for the design decisions.
13
+ round of real work. See the Alerts section below for the design decisions.
15
14
 
16
15
  ## Source files
17
16
 
@@ -131,6 +131,20 @@ Tiers 2, 3, and 4 all fire independently. Any subset may inject. All share
131
131
  one embedding computation, reused across three cosine scans against
132
132
  different tables.
133
133
 
134
+ #### Snapshot semantics
135
+
136
+ A nudge block is computed once, when the message is submitted, and stays
137
+ attached to that message for the whole turn - including every model round
138
+ trip after mid-turn tool calls. Predictions, behaviors, or memories the
139
+ conversation mutates mid-turn do NOT refresh an already-injected block
140
+ (v1: the synthetic part lives on the stored message; v2:
141
+ `pendingInjections` re-injects the prompt-time text into every outbound
142
+ request of the turn, bound to the message it was computed for - see
143
+ [opencode-plugin.md](opencode-plugin.md)). The prediction and behavior
144
+ blocks therefore end with a snapshot footer naming the store state they
145
+ reflect and the live tool to re-check. The recall nudge carries no footer:
146
+ it lists memory labels without confidence claims.
147
+
134
148
  ### MCP path (`bin/thatch`, `flush-tools` subcommand)
135
149
 
136
150
  The `flush-tools` subcommand fires on `UserPromptSubmit` (Claude Code) or