@jeffober/thatch 0.2.5 → 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 (51) hide show
  1. package/README.md +48 -25
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  4. package/artifacts/skills/thatch-code-review.md +16 -8
  5. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  6. package/artifacts/skills/thatch-refine-personal.md +5 -0
  7. package/artifacts/skills/thatch-refine.md +7 -1
  8. package/artifacts/skills/thatch-review-context.md +55 -2
  9. package/artifacts/skills/thatch-review-followup.md +4 -4
  10. package/artifacts/skills/thatch-review-response.md +1 -1
  11. package/artifacts/skills/thatch-review-synthesizer.md +21 -1
  12. package/bin/thatch +21 -12
  13. package/docs/dev/README.md +11 -8
  14. package/docs/dev/features/README.md +1 -1
  15. package/docs/dev/features/behavior-engine.md +10 -0
  16. package/docs/dev/features/cli.md +2 -1
  17. package/docs/dev/features/commands.md +40 -9
  18. package/docs/dev/features/cross-session-chat.md +85 -21
  19. package/docs/dev/features/database.md +4 -4
  20. package/docs/dev/features/nudge-pipeline.md +14 -0
  21. package/docs/dev/features/opencode-plugin.md +41 -15
  22. package/docs/dev/features/prediction-engine.md +27 -1
  23. package/docs/dev/features/qa-system.md +19 -3
  24. package/docs/dev/features/session-lifecycle.md +6 -6
  25. package/docs/dev/features/session-tabs.md +11 -5
  26. package/docs/dev/gotchas.md +21 -0
  27. package/docs/dev/mcp-parity.md +3 -3
  28. package/docs/dev/skills.md +11 -8
  29. package/docs/user/README.md +19 -12
  30. package/docs/user/behavior-engine.md +7 -1
  31. package/docs/user/cli.md +2 -1
  32. package/docs/user/code-review.md +43 -2
  33. package/docs/user/commands.md +27 -7
  34. package/docs/user/cross-session-chat.md +47 -20
  35. package/docs/user/prediction-engine.md +12 -1
  36. package/docs/user/skills.md +8 -6
  37. package/package.json +1 -1
  38. package/src/capabilities.ts +20 -6
  39. package/src/chat.ts +39 -17
  40. package/src/commands.ts +109 -18
  41. package/src/db.ts +23 -2
  42. package/src/mcp.ts +6 -2
  43. package/src/opencode/tui-plugin.ts +29 -9
  44. package/src/opencode/v2.ts +247 -66
  45. package/src/prompts.ts +54 -6
  46. package/src/runtime.ts +122 -17
  47. package/src/scoring-engine.ts +40 -9
  48. package/src/session-tab-shared.ts +55 -4
  49. package/src/skills.ts +5 -9
  50. package/src/tab-state.ts +125 -0
  51. package/src/tool-defs.ts +42 -21
package/bin/thatch CHANGED
@@ -13,11 +13,12 @@ import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscript
13
13
  import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, sortChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
14
14
  import { chatEnabled, loadConfig } from "../src/config";
15
15
  import { chatNotificationNudge } from "../src/prompts";
16
+ import { chatOpenTabs as chatOpenTabsProvider } from "../src/tab-state";
16
17
  import { createDebugLog } from "../src/debug";
17
18
 
18
19
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
19
20
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
20
- const db = new ThatchDB(dbPath);
21
+ const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
21
22
 
22
23
  const [command, ...args] = process.argv.slice(2);
23
24
 
@@ -427,7 +428,9 @@ const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "T
427
428
  * Renders the `chat list` roster as two sections - Active and Stale - each
428
429
  * an aligned-column table under a header row. Section headers are bold,
429
430
  * the stale explainer is muted italic; rows use green names, dim age,
430
- * green/red status, and a muted italic topic.
431
+ * green/red status, and a muted italic topic. The STATUS column carries
432
+ * the liveness verdict: open (tab open now), recent (host alive, no open
433
+ * tab), active/idle (MCP rows), stale (host presumed gone).
431
434
  * staleCapMs bounds how old a stale row is displayed (null = no bound);
432
435
  * rows past the cap collapse into a muted hidden-count note so the reader
433
436
  * knows rows exist that the window left out.
@@ -435,7 +438,7 @@ const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "T
435
438
  * NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
436
439
  * clean plain text.
437
440
  */
438
- function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null): string {
441
+ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null, openTabs?: ReadonlySet<string>): string {
439
442
  const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
440
443
  const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
441
444
  const { active, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), Date.now(), staleCapMs);
@@ -444,7 +447,7 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
444
447
  r.name,
445
448
  r.session_id,
446
449
  humanAge(r.last_seen),
447
- chatLiveness(r),
450
+ chatLiveness(r, Date.now(), openTabs),
448
451
  r.project ?? "unknown",
449
452
  r.topic ?? "",
450
453
  ];
@@ -455,11 +458,14 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
455
458
  const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
456
459
  const body = rows.map((r) => {
457
460
  const [name, sessionID, age, status, project, topic] = align(cells(r));
461
+ // Green = present or reachable (open/active), yellow = alive host
462
+ // without a tab (recent, wake not guaranteed), red = stale.
463
+ const statusColor = status === "stale" ? "31" : status === "recent" ? "33" : "32";
458
464
  return [
459
465
  paint("32", name),
460
466
  paint("2", sessionID),
461
467
  paint("2", age),
462
- paint(status === "stale" ? "31" : "32", status),
468
+ paint(statusColor, status),
463
469
  project,
464
470
  topic ? paint("3;90", topic) : "",
465
471
  ].join(" ").trimEnd();
@@ -514,8 +520,11 @@ async function chatCommand(rest: string[]): Promise<void> {
514
520
  process.exit(1);
515
521
  } else staleCapMs = Number(staleNext) * 24 * 60 * 60 * 1000;
516
522
  }
523
+ // The open-tab set is read once per invocation at render time: rows
524
+ // with a tab render "open", fresh rows without one "recent".
525
+ const openTabs = chatOpenTabsProvider(() => db.chatTabClosures())();
517
526
  let sessions = db.listChatSessions();
518
- if (!showStale) sessions = sessions.filter((r) => chatLiveness(r) === "fresh");
527
+ if (!showStale) sessions = sessions.filter((r) => chatLiveness(r, Date.now(), openTabs) !== "stale");
519
528
  if (sessions.length === 0) {
520
529
  console.log(
521
530
  showStale
@@ -524,7 +533,7 @@ async function chatCommand(rest: string[]): Promise<void> {
524
533
  );
525
534
  break;
526
535
  }
527
- console.log(formatChatRoster(sessions, showStale ? staleCapMs : null));
536
+ console.log(formatChatRoster(sessions, showStale ? staleCapMs : null, openTabs));
528
537
  break;
529
538
  }
530
539
 
@@ -1050,7 +1059,7 @@ switch (command) {
1050
1059
  const sessionID = data.session_id ?? data.conversation_id;
1051
1060
 
1052
1061
  const { peekQueue, getMissedCount, incrementMissedCount } = await import("../src/extract-queue");
1053
- const { extractionNudge, claudeWriteNudge, claudeRecallNudge, predictionNudge, behaviorNudge } = await import("../src/prompts");
1062
+ const { extractionNudge, claudeWriteNudge, claudeRecallNudge, predictionNudge, behaviorNudge, mcpToolName } = await import("../src/prompts");
1054
1063
  const { detectSkew, readNpmCacheForUpdate } = await import("../src/version-check");
1055
1064
 
1056
1065
  let repo = "unknown";
@@ -1144,10 +1153,10 @@ switch (command) {
1144
1153
  parts.push(claudeRecallNudge(matches));
1145
1154
  }
1146
1155
  if (predictions && predictions.length > 0) {
1147
- parts.push(predictionNudge(predictions));
1156
+ parts.push(predictionNudge(predictions, mcpToolName));
1148
1157
  }
1149
1158
  if (behaviors && behaviors.length > 0) {
1150
- parts.push(behaviorNudge(behaviors));
1159
+ parts.push(behaviorNudge(behaviors, mcpToolName));
1151
1160
  }
1152
1161
 
1153
1162
  if (parts.length > 0) {
@@ -1296,13 +1305,13 @@ switch (command) {
1296
1305
  if (repo === "unknown") repo = "global";
1297
1306
 
1298
1307
  const { sidebandPredictions, sidebandSocketPath } = await import("../src/sideband");
1299
- const { predictionNudge } = await import("../src/prompts");
1308
+ const { predictionNudge, mcpToolName } = await import("../src/prompts");
1300
1309
  const sockPath = sidebandSocketPath(dbPath);
1301
1310
  const threshold = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.60");
1302
1311
  const predictions = await sidebandPredictions(sockPath, promptText, [repo, "global"], threshold, 5);
1303
1312
 
1304
1313
  if (predictions && predictions.length > 0) {
1305
- const output = predictionNudge(predictions);
1314
+ const output = predictionNudge(predictions, mcpToolName);
1306
1315
  if (isJson) console.log(JSON.stringify({ additional_context: output }));
1307
1316
  else console.log(output);
1308
1317
  } else {
@@ -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)
@@ -91,7 +91,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
91
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. |
92
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
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, app exit). `capabilitiesFromClient` bridges the v1 SDK client. |
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. |
95
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. |
96
96
  | `os-args.ts` | Pure argv / OS-command-line helpers for startup session resolution (`-s`/`-c`). SDK-free. |
97
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. |
@@ -108,10 +108,11 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
108
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. |
109
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). |
110
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). |
111
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. |
112
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). |
113
- | `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). |
114
- | `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]`. |
115
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. |
116
117
  | `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
117
118
  | `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
@@ -129,7 +130,7 @@ methods. The v1 hook shapes:
129
130
  |------|-------------|
130
131
  | `experimental.chat.system.transform` | Appends the thatch system prompt (store names, usage rules). |
131
132
  | `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
132
- | `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). |
133
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. |
134
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. |
135
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. |
@@ -268,14 +269,16 @@ extraction cycle
268
269
  → MCP path (Claude Code/Cursor): unchanged — no SDK client, no child
269
270
  sessions; extract-queue.ts + flush-tools drives the nudge via hooks
270
271
 
271
- 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)
272
274
  → client.tui.showToast — best-effort, silently ignored if TUI not
273
275
  connected (headless mode); model-invisible (goes to the user only,
274
276
  not the conversation history — the inverse of synthetic nudge parts,
275
277
  which are model-visible but TUI-hidden)
276
278
  → extraction metrics: `[thatch] new: N, updated: M, deleted: K` (success
277
- variant, 4s) — fires when an extraction child goes idle; no-save runs
278
- 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)
279
282
  → recall matches: `[thatch] recalled N memories` (info variant, 3s) —
280
283
  fires when chat.message matches stored memories
281
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
@@ -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
 
@@ -146,10 +146,10 @@ mechanical barrier, a documented trust posture.
146
146
  ### Leave tombstones
147
147
 
148
148
  Every exit path - `chat_unregister`, `session.deleted`, and a greenlit
149
- `/thatch/exit` (the plugin unregisters the exiting session before
150
- publishing `app.exit`, so the roster stops advertising a host that is
151
- about to vanish; the exit template's checklist step asks the model to do
152
- 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
153
153
  `chat_leave_tombstones` row in the same transaction that deletes the
154
154
  directory row. The tombstone is what makes a leave stick: all
155
155
  registration paths consult it (the plugin's idle and prompt auto-registerers are
@@ -235,12 +235,17 @@ the top of the result teaches the paging call, and ids make it exact.
235
235
 
236
236
  `chat_broadcast` posts one message to every other registered session at
237
237
  once, one inbox row per recipient per part - the existing wake machinery
238
- (grouping, gating, rate cap) treats each as ordinary mail. Stale sessions
239
- are skipped and reported rather than messaged: a host that has stopped
240
- heartbeat-ing will never read the mail, and dead mail to a dead process is
241
- just clutter. The sender is excluded. It is a separate tool rather than a
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
242
247
  magic `chat_send` recipient because "send to all" changes the behavior
243
- (fan-out, stale skipping, no address resolution), and a function that
248
+ (fan-out, skip classes, no address resolution), and a function that
244
249
  changes behavior drastically on a parameter value is two functions.
245
250
 
246
251
  Retries are idempotent by READ STATE within a short window: a send (or
@@ -269,7 +274,8 @@ fires no `session.deleted`, so its rows would linger - staleness catches
269
274
  them: a row that has missed two consecutive beats (`CHAT_STALE_MS`, 60s
270
275
  at the default interval) is stale, meaning its harness stopped. Two beats
271
276
  rather than one so a single late poll cycle does not flap the roster.
272
- 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
273
279
  cannot serve: the same session is re-hosted by a new process on every
274
280
  `-s` resume, so a pid stamped on the row lies as soon as a different
275
281
  harness beats it (it says dead while the heartbeat proves alive). RESTART
@@ -280,12 +286,43 @@ into, so without the grace they would read stale while alive-idle and
280
286
  mail to them would not wake the tab. Sessions that emit activity
281
287
  graduate to permanent hosting; still-silent ones (closed tabs) age out
282
288
  at the grace and go stale. `isStale`
283
- (src/chat.ts) is the single rule; `chatLiveness`, the `chat_send` note,
284
- 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`
285
320
  groups the result into Active and Stale sections - the stale section's
286
- 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
287
323
  liveness at send time, so a sender mailing a ghost learns it immediately
288
- 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
289
326
  order rows with `sortChatRoster` (also src/chat.ts): project first,
290
327
  alphabetical, with no-project rows last; most recently seen first within
291
328
  a project. The CLI roster bounds
@@ -312,6 +349,22 @@ and a count, pointer-only like watcher notifications, with the model calling
312
349
  `chat_read` for content. On success the poller stamps `delivered_at`
313
350
  (re-stamping on re-nudges resets the re-nudge timer).
314
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
+
315
368
  The hard brake: at most a fixed number of wake prompts per recipient per
316
369
  hour (default 6, in-memory on purpose - it is a loop guard, not accounting).
317
370
  Without it, two agents politely acknowledging each other would ping-pong
@@ -475,7 +528,9 @@ caller's identity and mailbox, and is absent entirely when chat is
475
528
  disabled - absence is silent by construction.
476
529
  Staleness semantics differ by kind: an opencode row's stale age means its
477
530
  harness stopped beating (broadcast skips it), while an mcp row's age only means
478
- "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.
479
534
 
480
535
  The `/thatch/whois` command (opencode-only action, src/commands.ts) is the
481
536
  user-facing identity report: it relays `chat_status` for the invoking
@@ -489,18 +544,27 @@ mutates anything.
489
544
 
490
545
  Timing constants live in `src/chat.ts`: poll interval 30s, staleness
491
546
  threshold two missed beats (60s), re-nudge window 15 minutes, nudge cap 6 per recipient
492
- per hour, auto-row TTL 7 days. Environment overrides: `THATCH_CHAT_SPLIT_PART_LEN`
493
- (auto-split part budget, default 2000, clamped 500..9000) and
494
- `THATCH_CHAT_MAX_BODY_CHARS` (whole-body acceptance ceiling, default 20000);
495
- both are resolved once at module load and sanitized to positive integers
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
496
555
  within their clamps - invalid values silently fall back to the default.
497
556
 
498
557
  ## Source files
499
558
 
500
559
  - `src/chat.ts` - ChatStore (directory + inbox SQL, assigned-name draw:
501
- pool pick, slugify, counter bump, insert, prune, topic refresh), ChatPoller
560
+ pool pick, slugify, counter bump, insert, prune, topic refresh, broadcast
561
+ targeting), ChatPoller
502
562
  (heartbeat, gated delivery, re-nudge, rate cap, hourly prune sweep),
503
- 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
504
568
  - `src/chat-names.ts` - the static display-name pool (nomenclater style),
505
569
  the base for every assigned name
506
570
  - `src/db.ts` - the chat tables in schema init (including
@@ -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
 
@@ -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