@jeffober/thatch 0.1.44 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,54 @@ thatch setup --claude --global # or --cursor --global
51
51
  Restart your editor and thatch's tools are available as `mcp__thatch__*`.
52
52
  Requires [Bun] on PATH.
53
53
 
54
+ The embedding model downloads once at first use. It is cached in the
55
+ platform's per-user cache dir: `~/Library/Caches/thatch/models` on macOS (or
56
+ `$XDG_CACHE_HOME/thatch/models` when `XDG_CACHE_HOME` is set),
57
+ `$XDG_CACHE_HOME/thatch/models` (default `~/.cache/thatch/models`) elsewhere.
58
+ Set `THATCH_MODEL_CACHE` to override the location. The cache survives
59
+ upgrades and works from read-only installs.
60
+
61
+ ### NixOS / Nix
62
+
63
+ This repo is a flake. Bun and the native embedding runtime are bundled, so no
64
+ global `npm install` or `bun` on PATH is required.
65
+
66
+ Try it without installing:
67
+
68
+ ```bash
69
+ nix run github:sysread/thatch -- --version
70
+ ```
71
+
72
+ Install system-wide via the NixOS module (in your flake `configuration.nix`):
73
+
74
+ ```nix
75
+ {
76
+ inputs.thatch.url = "github:sysread/thatch";
77
+
78
+ # in your NixOS system's modules:
79
+ imports = [ inputs.thatch.nixosModules.default ];
80
+ programs.thatch.enable = true; # puts `thatch` on PATH for every user
81
+ }
82
+ ```
83
+
84
+ Or add the package yourself with the overlay
85
+ (`nixpkgs.overlays = [ inputs.thatch.overlays.default ];` then
86
+ `environment.systemPackages = [ pkgs.thatch ];`), or drop
87
+ `inputs.thatch.packages.${system}.default` into a `home.packages` /
88
+ `environment.systemPackages` list directly.
89
+
90
+ Once `thatch` is on PATH, wire it into your editor as usual:
91
+
92
+ ```bash
93
+ cd /path/to/your/project
94
+ thatch setup --claude --global # or --cursor
95
+ ```
96
+
97
+ The embedding model still downloads once at first use and is cached in the
98
+ platform's per-user cache dir (the per-platform paths above; override with
99
+ `THATCH_MODEL_CACHE`), so it survives upgrades and works from the read-only
100
+ Nix store.
101
+
54
102
  ### Other MCP-compatible harnesses
55
103
 
56
104
  ```json
@@ -108,13 +156,17 @@ Thatch gives your agent:
108
156
  hostile message cannot impersonate your instructions. Disable the whole
109
157
  feature with `chat.enabled: false` in the thatch config (or just the
110
158
  auto-joining with `chat.autoRegister: false`).
111
- - **Wrap-up commands** (opencode only) -- `/thatch/compact` and `/thatch/exit`
112
- run a pre-flight checklist before a compaction or an exit: the agent flushes
113
- pending fact extraction, finishes promised memory writes, and surfaces
114
- todos or follow-ups it never addressed. It ends its response with a
115
- greenlight token only when the checklist is clean; thatch then triggers the
116
- compaction or quits opencode. With items outstanding, the agent lists them
117
- and nothing fires -- you decide when to retry.
159
+ - **Slash commands** -- `/thatch/defrag` (consolidate duplicate memories),
160
+ `/thatch/extract` (drain the extraction queue now), `/thatch/hygiene`
161
+ (tend stale and orphaned memories), and `/thatch/reflect` (persist what
162
+ the session learned) run on demand the same behaviors the nudges run on
163
+ their own schedule. Plus the opencode-only wrap-ups: `/thatch/compact` and
164
+ `/thatch/exit` run a pre-flight checklist before a compaction or an exit:
165
+ the agent flushes pending fact extraction, finishes promised memory
166
+ writes, and surfaces todos or follow-ups it never addressed. It ends its
167
+ response with a greenlight token only when the checklist is clean; thatch
168
+ then triggers the compaction or quits opencode. With items outstanding,
169
+ the agent lists them and nothing fires -- you decide when to retry.
118
170
  - **Notifications + user config** -- the agent can ping you out-of-band when a
119
171
  long-running outcome lands: a desktop banner, a spoken voice
120
172
  announcement, or both (macOS and Linux). Preferences live in a
@@ -131,6 +183,24 @@ See the [user guide](docs/user/README.md) for the full tool list, CLI
131
183
  commands, configuration, environment variables, and detailed setup for each
132
184
  host.
133
185
 
186
+ ## What works in Claude Code
187
+
188
+ The memory tools, prediction and behavior engines, conversation search via
189
+ the `thatch session` CLI, cross-session chat (with stop-hook wake), skills,
190
+ and the on-demand actions `/thatch/defrag`, `/thatch/hygiene`, and
191
+ `/thatch/reflect` (synced by `thatch setup --claude`). Not available: the
192
+ wrap-up commands and `/thatch/extract` (they need the opencode plugin's
193
+ session identity and TUI control routes), live recall/prediction nudges
194
+ beyond the hook-based flow, and toast notifications.
195
+
196
+ ## What works in Cursor
197
+
198
+ The same core set as Claude Code (memory, predictions, behaviors, chat with
199
+ stop-hook wake, skills, and the `defrag`/`hygiene`/`reflect` actions). Cursor
200
+ has no file-based slash commands, so the actions surface through MCP prompts
201
+ instead. Not available: the wrap-up commands, `/thatch/extract`, and toast
202
+ notifications.
203
+
134
204
  ## Privacy
135
205
 
136
206
  Everything is local. The embedding model downloads once from Hugging Face Hub
@@ -152,6 +222,9 @@ bun install
152
222
  mise run check # typecheck + bun test + markdownlint (the CI gate)
153
223
  ```
154
224
 
225
+ On Nix, `nix develop` drops you into a shell with bun, mise, and node already
226
+ on PATH -- no system install needed.
227
+
155
228
  Tests never reach outside the sandbox: temp-directory SQLite files, mock
156
229
  embeddings, no network.
157
230
 
package/bin/thatch CHANGED
@@ -10,7 +10,7 @@ import { hygieneReport } from "../src/hygiene";
10
10
  import { claudeSessionStartReminder } from "../src/prompts";
11
11
  import { setupClaudeCode, setupCursor, registerClaudeMcpServer } from "../src/setup";
12
12
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
13
- import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
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
16
  import { createDebugLog } from "../src/debug";
@@ -49,8 +49,11 @@ function usage(): never {
49
49
  Full conversation as OpenAI chat-completions JSONL
50
50
  thatch session search <query> [--session <id>] [--regex] [--limit N]
51
51
  JSONL of matching parts across sessions (decoded content)
52
- thatch chat list Registered chat sessions, grouped Active/Stale:
53
- name, age, status, project, topic
52
+ thatch chat list [--stale N|all] Registered chat sessions, grouped Active/Stale:
53
+ name, age, status, project, topic.
54
+ --stale N (days) hides stale sessions
55
+ older than N; default 1. --stale all
56
+ shows every stale session.
54
57
  thatch chat tail [--once] [--limit N|all] [--match RE]
55
58
  [--from NAME] [--to NAME] [--since DT] [--until DT]
56
59
  Follow cross-session chat as a JSONL
@@ -412,14 +415,17 @@ const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
412
415
  * an aligned-column table under a header row. Section headers are bold,
413
416
  * the stale explainer is muted italic; rows use green names, dim age,
414
417
  * green/red status, and a muted italic topic.
418
+ * staleCapMs bounds how old a stale row is displayed (null = no bound);
419
+ * rows past the cap collapse into a muted hidden-count note so the reader
420
+ * knows rows exist that the window left out.
415
421
  * Color is applied only when stdout is an interactive terminal (and
416
422
  * NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
417
423
  * clean plain text.
418
424
  */
419
- function formatChatRoster(sessions: ChatSessionRow[]): string {
425
+ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null): string {
420
426
  const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
421
427
  const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
422
- const { active, stale } = splitChatRoster(sessions);
428
+ const { active, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), Date.now(), staleCapMs);
423
429
  const renderTable = (rows: ChatSessionRow[]) => {
424
430
  const cells = (r: ChatSessionRow) => [
425
431
  r.name,
@@ -449,9 +455,21 @@ function formatChatRoster(sessions: ChatSessionRow[]): string {
449
455
  if (active.length > 0) {
450
456
  parts.push([paint("1", "# Active Sessions"), renderTable(active)].join("\n"));
451
457
  }
452
- if (stale.length > 0) {
453
- const explainer = `These sessions have missed their heartbeat for over ${Math.round(CHAT_STALE_MS / 1000)} seconds. Their harnesses may no longer be running (stopped, or the machine slept); mail waits until they resume.`;
454
- parts.push([paint("1", "# Stale Sessions"), paint("3;90", explainer), renderTable(stale)].join("\n"));
458
+ if (stale.length > 0 || staleHidden > 0) {
459
+ const lines = [paint("1", "# Stale Sessions")];
460
+ if (stale.length > 0) {
461
+ const explainer = `These sessions have missed their heartbeat for over ${Math.round(CHAT_STALE_MS / 1000)} seconds. Their harnesses may no longer be running (stopped, or the machine slept); mail waits until they resume.`;
462
+ lines.push(paint("3;90", explainer), renderTable(stale));
463
+ }
464
+ if (staleHidden > 0) {
465
+ lines.push(
466
+ paint(
467
+ "3;90",
468
+ `${staleHidden} older stale session${staleHidden === 1 ? "" : "s"} hidden; --stale N widens the window to N days, --stale all shows everything.`,
469
+ ),
470
+ );
471
+ }
472
+ parts.push(lines.join("\n"));
455
473
  }
456
474
  return parts.join("\n\n");
457
475
  }
@@ -461,12 +479,26 @@ async function chatCommand(rest: string[]): Promise<void> {
461
479
 
462
480
  switch (sub) {
463
481
  case "list": {
482
+ // --stale N bounds how old a stale row the roster displays, in days
483
+ // (fractions allowed); the default of one day keeps long-dead
484
+ // sessions from burying the fresh signal. --stale all restores the
485
+ // unbounded view. A bad value dies loudly rather than silently
486
+ // shrinking the roster.
487
+ const staleArg = flagValue(restArgs, "--stale");
488
+ let staleCapMs: number | null = 24 * 60 * 60 * 1000;
489
+ if (staleArg !== undefined) {
490
+ if (staleArg === "all") staleCapMs = null;
491
+ else if (!/^\d+(\.\d+)?$/.test(staleArg) || Number(staleArg) <= 0) {
492
+ console.error(`error: --stale expects a positive number of days or "all", got "${staleArg}"`);
493
+ process.exit(1);
494
+ } else staleCapMs = Number(staleArg) * 24 * 60 * 60 * 1000;
495
+ }
464
496
  const sessions = db.listChatSessions();
465
497
  if (sessions.length === 0) {
466
498
  console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
467
499
  break;
468
500
  }
469
- console.log(formatChatRoster(sessions));
501
+ console.log(formatChatRoster(sessions, staleCapMs));
470
502
  break;
471
503
  }
472
504
 
@@ -831,6 +863,9 @@ switch (command) {
831
863
  console.log(` Instructions: ${result.claudeMd}`);
832
864
  console.log(` Hooks: ${result.settings}`);
833
865
  printSkills(result);
866
+ if (result.commands.length > 0) {
867
+ console.log(` Commands: ${result.commands.length} written (${result.commands.map((p) => p.split("/").pop()).join(", ")})`);
868
+ }
834
869
  console.log();
835
870
  if (result.mcpAddCommand) {
836
871
  // Global setup cannot write ~/.claude.json directly, so run the
@@ -24,7 +24,8 @@ Shared core
24
24
  ├── embeddings.ts → embedding model via transformers.js
25
25
  ├── git.ts → detect repo identity (store name)
26
26
  ├── hygiene.ts → hygiene report (pending dedups, stale, orphaned branches)
27
- ├── prompts.ts → system prompt, compaction, reminders, recall/prediction/behavior nudges, CLAUDE.md instructions
27
+ ├── prompts.ts → system prompt, compaction, reminders, recall/prediction/behavior nudges, prompt cores, CLAUDE.md instructions
28
+ ├── commands.ts → /thatch slash-command registry: actions from prompt cores + wrap-ups, per-host sync
28
29
  ├── sideband.ts → Unix socket server + client: warm-model semantic match for hook processes
29
30
  ├── skills.ts → SKILL.md content + installer (shared + opencode-only arrays)
30
31
  ├── scoring-engine.ts → generic four-table scoring base (shared by prediction + behavior engines)
@@ -83,7 +84,8 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
83
84
  | `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). |
84
85
  | `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. |
85
86
  | `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. |
86
- | `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. |
87
+ | `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). |
88
+ | `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). |
87
89
  | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (fact-extractor, dedup-classifier, project-primer, the review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
88
90
  | `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. |
89
91
  | `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
@@ -23,6 +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
27
  | [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
27
28
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
28
29
  | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking, wrap-up commands) |
@@ -0,0 +1,74 @@
1
+ # Slash commands: actions and wrap-ups
2
+
3
+ Thatch's `/thatch/*` commands come in two families:
4
+
5
+ - **Actions** (`defrag`, `extract`, `hygiene`, `reflect`) — user-invoked
6
+ wrappers around behaviors the nudges otherwise run on their own schedule.
7
+ - **Wrap-ups** (`compact`, `exit`) — greenlight-gated pre-compaction and
8
+ pre-exit checklists. opencode only: the greenlight check needs the
9
+ plugin's `command.execute.before` arming and TUI control routes.
10
+
11
+ User-facing guide: [../../user/commands.md](../../user/commands.md).
12
+
13
+ ## Prompt cores
14
+
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.
20
+ The point is drift-proofing: a wording change lands in the nudge and the
21
+ command at once, and a test asserts each rendered file contains its core
22
+ verbatim.
23
+
24
+ The stop-and-wait ending ("do not advance pending work on this nudge's
25
+ account") is nudge-only by design. A `/thatch` command IS user input, so
26
+ the command envelope must not inherit it.
27
+
28
+ Cores that name memory tools take a `ToolNamer` because spellings differ
29
+ per host: `thatch_find_duplicates` on opencode, `mcp__thatch__find_duplicates`
30
+ on MCP hosts. Each host's renderer passes its own namer
31
+ (`opencodeToolName` / `mcpToolName`).
32
+
33
+ ## Rendering and sync
34
+
35
+ `src/commands.ts` is the single registry:
36
+
37
+ - `actionDefs(tool)` returns the action set with bodies rendered for one
38
+ host. Actions needing plugin-only capabilities are marked `opencodeOnly`
39
+ (`extract`: needs session identity via `get_session_info`).
40
+ - `opencodeCommandDefs()` = wrap-up templates + all actions. Synced by the
41
+ plugin at init into `$XDG_CONFIG_HOME/opencode/command/thatch/`
42
+ (`installOpencodeCommands`), content-compare, self-healing.
43
+ - `claudeCommandDefs()` = actions minus `opencodeOnly`. Synced by
44
+ `thatch setup --claude` (`installClaudeCommands`) into
45
+ `<claudeDir>/commands/thatch/`, reported in the setup output. There is no
46
+ plugin on this host to sync on load, so setup is the sync point.
47
+ - MCP prompts: `compilePrompts()` in `src/mcp.ts` exposes the same
48
+ non-opencodeOnly actions through `prompts/list` / `prompts/get`, which
49
+ Cursor surfaces as slash commands. The optional `focus` argument is
50
+ appended to the body as a `Focus:` line, standing in for the file
51
+ commands' `$ARGUMENTS`.
52
+
53
+ A parity-guard test asserts the three sets are the same actions minus the
54
+ documented exclusions: opencode = Claude Code set + `compact`, `exit`,
55
+ `extract`; MCP prompts = Claude Code's set.
56
+
57
+ ## The extract action and session IDs
58
+
59
+ `/thatch/extract` runs the extraction flow on demand: learn the session ID
60
+ from `thatch_get_session_info`, dispatch the extractor sub-agent with an
61
+ explicit `session_id`, acknowledge with `thatch_extraction_done`. The
62
+ explicit ID matters because the sub-agent's session differs from the
63
+ parent's — the same reason the extraction nudge passes it.
64
+
65
+ `get_extraction_payload` accepts an omitted `session_id`: on the opencode
66
+ path the wrapper supplies the invoking session's `HostToolContext.sessionID`.
67
+ The zod arg shape is shared between hosts, so optionality cannot be
68
+ per-host at the schema level; on MCP hosts (no session context) an omitted
69
+ `session_id` returns a clear "pass the parent's session_id" error. The rule
70
+ the model applies, stated in the arg descriptions and the dev docs:
71
+
72
+ > Omit `session_id` when acting on the session you are running in. Pass the
73
+ > parent session's `session_id` when running inside a sub-agent dispatched
74
+ > to process another session's extraction queue.
@@ -58,10 +58,13 @@ outlives a process, so liveness needs a signal beyond process lifetime.
58
58
 
59
59
  ### Registration and identity
60
60
 
61
- Sessions are auto-registered by the plugin: the first `session.status`
62
- idle event for a top-level session inserts its directory row, and a
63
- session continued via `-s <id>` registers at harness start (below);
64
- both paths are skipped when `chat.autoRegister: false` or when the
61
+ Sessions are auto-registered by the plugin: a top-level session's first
62
+ real user message inserts its directory row (the `chat.message` hook,
63
+ which fires before the model runs - so the session is addressable within
64
+ its very first turn), and the first `session.status` idle event remains a
65
+ backstop that also converges the topic once the auto-titler lands. A
66
+ session continued via `-s <id>` or `-c` registers at harness start
67
+ (below). All paths are skipped when `chat.autoRegister: false` or when the
65
68
  session left explicitly (a leave tombstone, see below). Names are assigned, never
66
69
  claimed: a pool draw (`CHAT_NAME_POOL`, src/chat-names.ts) plus a
67
70
  per-base counter row (`chat_name_counters`, src/db.ts) that only ever
@@ -94,9 +97,14 @@ args=` on macOS) and parses `-s`/`--session` from that.
94
97
  Registration is keyed by session id: the row already exists, so the name
95
98
  is RECLAIMED (names are owned by the session id, minted once, never
96
99
  renamed) and the row's heartbeat is refreshed - hosting is the
97
- heartbeat, so the serving harness beats and delivers for the row again. `-c/--continue` resolves no id on the
98
- command line; that
99
- session registers on its first idle like any other. Asleep-mail: the
100
+ heartbeat, so the serving harness beats and delivers for the row again.
101
+ `-c/--continue` resolves no id on the command line, so the plugin
102
+ resolves the same target the harness does: `client.session.list()`
103
+ (directory-scoped, the plugin's client carries the directory), sorted by
104
+ `time.updated` descending like the TUI does, first row without a
105
+ `parentID`. That registration is not synchronous at init (the list call
106
+ is a server round trip), but it lands within a beat of startup - the
107
+ heartbeat is live long before the first prompt. Asleep-mail: the
100
108
  startup path kicks a delivery pass when it finishes, so the continued
101
109
  session learns what it missed at startup instead of waiting out the
102
110
  first poll cycle.
@@ -118,8 +126,8 @@ publishing `app.exit`, so the roster stops advertising a host that is
118
126
  about to vanish; the exit template's checklist step asks the model to do
119
127
  the same earlier) - writes a
120
128
  `chat_leave_tombstones` row in the same transaction that deletes the
121
- directory row. The tombstone is what makes a leave stick: both
122
- registration paths consult it (the plugin's idle auto-registerer is
129
+ directory row. The tombstone is what makes a leave stick: all
130
+ registration paths consult it (the plugin's idle and prompt auto-registerers are
123
131
  suppressed outright; the MCP hook's ensure-register prints the leave line
124
132
  instead of rejoining), so a leave cannot be silently undone by the next
125
133
  idle moment. The gate lives in `ChatStore.register` via `#tombstoneGate`
@@ -187,7 +195,13 @@ and the broadcast skip all call it, so they cannot disagree. `chat_list`
187
195
  groups the result into Active and Stale sections - the stale section's
188
196
  explainer says what stale means - and `chat_send` states the recipient's
189
197
  liveness at send time, so a sender mailing a ghost learns it immediately
190
- instead of waiting on a wake that will never fire.
198
+ instead of waiting on a wake that will never fire. Both roster surfaces
199
+ order rows with `sortChatRoster` (also src/chat.ts): project first,
200
+ alphabetical, with no-project rows last; most recently seen first within
201
+ a project. The CLI roster bounds
202
+ the stale display to one day by default (`--stale N` in days, `--stale all`
203
+ unbounded), collapsing older rows into a hidden-count note; the tool
204
+ surface stays unbounded so an agent always sees the full directory.
191
205
  `session.deleted` is the graceful-exit fast path that unregisters immediately.
192
206
 
193
207
  ### Delivery, re-nudge, and the rate cap
@@ -197,6 +197,11 @@ continues working while the child extracts.
197
197
  - **opencode**: peeks accepted + pending interactions, builds JSON via
198
198
  `buildExtractionPayload`
199
199
  - **MCP**: peeks the file-backed queue, builds the same JSON payload
200
+ - `session_id` is optional: omitted, it resolves to the invoking session
201
+ via `HostToolContext.sessionID` (opencode path). On MCP hosts there is no
202
+ session context, so an omitted `session_id` returns a "pass the parent
203
+ session's session_id" error. Explicit IDs always win - that is how a
204
+ sub-agent drains the parent's queue
200
205
  - Returns `null` when no interactions are queued
201
206
  - Read-only — it peeks the queue; it does not consume it. Consumption is
202
207
  `extraction_done`'s job
@@ -146,6 +146,14 @@ here first. These are the things that have already cost time.
146
146
  - **The binary path is baked into installed hook commands.** `thatch setup`
147
147
  resolves `<bin>` from PATH (or the script's absolute path) and writes it into
148
148
  every hook command, so hooks survive after the setup session ends.
149
+ - **Tool-arg optionality cannot be per-host at the schema level.** The zod
150
+ arg shape in `TOOL_DEFS` is shared by the opencode plugin and the MCP
151
+ server, so making an arg optional (e.g. `get_extraction_payload`'s
152
+ `session_id`) makes it optional everywhere. Per-host enforcement has to
153
+ happen in the tool's `execute`: fall back to `HostToolContext.sessionID`
154
+ on the opencode path, return a "pass the parent's session_id" error on
155
+ MCP hosts, which have no session context. See
156
+ [features/commands.md](features/commands.md).
149
157
 
150
158
  ## Tests
151
159
 
@@ -23,6 +23,7 @@ This document maps the feature parity and documents the remaining gaps.
23
23
  | **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
25
25
  | **Wrap-up commands** (`/thatch/compact`, `/thatch/exit`) | Command markdown synced to `$XDG_CONFIG_HOME/opencode/command/thatch/` at plugin init; `command.execute.before` arms the session and the next idle resolves the greenlight token into a TUI action (`session.compact` via `executeCommand`, `app.exit` via `publish`) | No equivalent — no plugin host to arm the check or trigger the action | No equivalent | **Gapped** — needs the plugin's SDK client and TUI control routes |
26
+ | **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
26
27
  | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-thatch, non-skill, non-task tool calls in-process; **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
27
28
  | **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
28
29
  | **Prompt-aware recall nudge** | `chat.message` hook embeds the prompt with the in-process warm model, searches `db.search()`, pushes a nudge part if matches ≥ threshold | `UserPromptSubmit` hook → `thatch flush-tools` connects to the MCP server's sideband socket; the warm server embeds + searches; hook prints the nudge or falls back to the write nudge | `beforeSubmitPrompt` hook → `thatch flush-tools --json` (same sideband path) | **Full** — sideband socket gives cold hook processes access to the warm MCP server's model |
@@ -50,10 +50,12 @@ the TUI is not connected (headless mode).
50
50
  | Instructions | `CLAUDE.md` (idempotent `appendBlock`) | `$CLAUDE_CONFIG_DIR/CLAUDE.md` |
51
51
  | Hooks | `.claude/settings.json` | `$CLAUDE_CONFIG_DIR/settings.json` |
52
52
  | Skills | `.claude/skills/` (in the repo) | `$CLAUDE_CONFIG_DIR/skills/` |
53
+ | Commands | `.claude/commands/thatch/` (in the repo) | `$CLAUDE_CONFIG_DIR/commands/thatch/` |
53
54
 
54
55
  `CLAUDE_CONFIG_DIR` overrides the default `~/.claude` for all user-scoped
55
56
  paths. Project-local keeps everything in the repo: `.mcp.json`, `CLAUDE.md`,
56
- `.claude/settings.json`, and `.claude/skills/` (so skills version with the
57
+ `.claude/settings.json`, `.claude/skills/`, and `.claude/commands/thatch/`
58
+ (so skills and commands version with the
57
59
  project and every contributor gets them).
58
60
 
59
61
  ### Hook events (nested `settings.json`)
@@ -66,6 +66,12 @@ substitution, matching the existing systemPrompt/mcpInstructions convention.
66
66
  The wrap-up commands (`compact`, `exit`) stay as-is; their checklist text
67
67
  becomes a core reused by both templates (already true via `sharedChecklist`).
68
68
 
69
+ `/thatch/extract` interacts with opencode's direct extraction (parent idle
70
+ spawns the extractor child; the nudge is only a fallback). The action must
71
+ reuse the same accept/complete semantics and must not fight the `extracting`
72
+ set: if direct extraction is already running for the session, the action
73
+ should report that and stop rather than double-dispatch.
74
+
69
75
  ## Host parity
70
76
 
71
77
  Commands are markdown or MCP prompts, synced per host. Behavior parity is the
@@ -74,30 +80,52 @@ goal; format differences are documented, not hidden.
74
80
  - **opencode** (existing): `installOpencodeCommands` syncs markdown to
75
81
  `~/.config/opencode/command/thatch/` on every plugin load.
76
82
  - **Claude Code**: native user-level slash commands read markdown from
77
- `~/.claude/commands/thatch/` with the same `$ARGUMENTS` substitution.
78
- `thatch setup --claude` gains a sync step (idempotent content-compare, same
79
- as the opencode installer). Differences: no plugin, so no
83
+ `~/.claude/commands/thatch/` (user scope) or `.claude/commands/thatch/`
84
+ (project scope), with the same `$ARGUMENTS` substitution. The sync lives in
85
+ `setup.ts` alongside the existing installers and follows their patterns:
86
+ idempotent content-compare like `installSkills`, both global and local
87
+ scopes, reported in the setup output next to the skills report.
88
+ (Deviation from the draft below: no `checkSetup` entry. That function
89
+ answers one question - "is thatch set up at all" - and command files
90
+ self-heal on every setup run, so flagging their absence as a startup
91
+ warning would be noise.) Differences: no plugin, so no
80
92
  `command.execute.before` arming -- greenlight-token wrap-up commands do not
81
- ship to Claude Code; and tool names use the `mcp__thatch__` prefix.
93
+ ship to Claude Code; and tool names use the `mcp__thatch__` prefix. MCP
94
+ hosts do have hooks now (session-start reminders, chat-wake stop hooks), so
95
+ hook wiring is not the barrier; the missing piece is only the plugin's TUI
96
+ control routes.
82
97
  - **Cursor**: no file-based commands. The stdio MCP server (`mcp.ts`) gains
83
98
  MCP Prompts support (`prompts/list`, `prompts/get`), which Cursor surfaces
84
- as slash commands. Prompt arguments use MCP's
99
+ as slash commands. The dispatch switch currently handles `initialize`,
100
+ `tools/list`, `tools/call`, and `ping`; prompts get a `compileTools`-style
101
+ `compilePrompts()` and two new cases. Prompt arguments use MCP's
85
102
  `{{argument}}`-style templating, so each action core gets an MCP-prompt
86
103
  rendering alongside the markdown rendering.
87
104
 
88
105
  README gains "What works in Claude Code" and "What works in Cursor" sections
89
- listing per-host support without restating the shared docs.
106
+ listing per-host support without restating the shared docs. The parity matrix
107
+ in `docs/dev/mcp-parity.md` gets an Actions row next to the existing
108
+ Wrap-up commands row.
90
109
 
91
110
  ## Session ID plumbing
92
111
 
93
- `get_extraction_payload` and `extraction_done` take `session_id`. Today the
94
- nudge carries the ID; an action has no way to know it.
112
+ Since the plan was drafted, `HostToolContext` (tool-defs.ts) landed: every
113
+ tool's `execute` receives the invoking session's ID as a third parameter on
114
+ the opencode path, and the watch tools already consume it. `extraction_done`
115
+ also already has the optional `session_id` with the omit-vs-parent rule in
116
+ its description. The remaining work is narrower than first planned:
117
+
118
+ - `get_extraction_payload`: `session_id` becomes optional. `execute` uses
119
+ `args.session_id ?? host?.sessionID`. On the opencode path the wrapper
120
+ supplies the invoking session, so omitted means "this session."
121
+ - MCP path: the host context is omitted (no session concept), so an omitted
122
+ `session_id` returns a clear error telling the model to pass the parent
123
+ session's ID from the extraction nudge.
95
124
 
96
- - opencode path: the thin wrappers in `tools.ts` receive the invoking
97
- session's ID from the host. `session_id` becomes optional there; omitted
98
- means "the current session."
99
- - MCP path: there is no session context in a tool call, so `session_id`
100
- stays required. The tool description and docs state this plainly.
125
+ One wrinkle to document: the zod arg shape is shared between hosts, so
126
+ optionality is global -- you cannot make the arg required on MCP and optional
127
+ on opencode at the schema level. Enforcement happens in `execute` per host,
128
+ and the arg description must carry the rule so the model self-corrects.
101
129
 
102
130
  The rule the LLM must apply, stated everywhere the tools are described:
103
131
 
@@ -105,10 +133,10 @@ The rule the LLM must apply, stated everywhere the tools are described:
105
133
  > parent session's `session_id` when running inside a sub-agent that was
106
134
  > dispatched to process another session's extraction queue.
107
135
 
108
- This rule appears in: the tool descriptions in `tool-defs.ts`, the extraction
136
+ This rule appears in: the arg descriptions in `tool-defs.ts`, the extraction
109
137
  prompt core (used by both nudge and action envelopes), the fact-extractor
110
- skill, and the dev docs. Tests cover both paths: omit-resolves-to-current and
111
- explicit-parent-ID (the existing sub-agent drain bug's regression path).
138
+ skill, and the dev docs. Tests cover both paths: omit-resolves-to-current
139
+ and explicit-parent-ID (the existing sub-agent drain bug's regression path).
112
140
 
113
141
  ## Decisions
114
142
 
@@ -127,18 +155,25 @@ explicit-parent-ID (the existing sub-agent drain bug's regression path).
127
155
  - `tests/commands.test.ts` (or the existing plugin tests): command-file
128
156
  content assertions per host, including that each action's markdown contains
129
157
  its core text verbatim.
158
+ - Claude setup tests: command sync writes, updates only on content change,
159
+ and `checkSetup` flags a missing or stale command file.
130
160
  - MCP tests: `prompts/list` returns every action; `prompts/get` interpolates
131
- arguments.
132
- - Plugin tests: `get_extraction_payload` without `session_id` resolves to the
133
- invoking session; with an explicit parent ID, drains the parent's queue.
161
+ arguments; an omitted-session-ID `get_extraction_payload` call errors with
162
+ the pass-the-parent-ID message (no host context on the MCP path).
163
+ - Plugin tests: `get_extraction_payload` without `session_id` resolves via
164
+ the host context to the invoking session; with an explicit parent ID,
165
+ drains the parent's queue (the existing sub-agent drain bug's regression
166
+ path).
134
167
  - Parity guard: a test asserts every opencode command def has a
135
168
  corresponding MCP prompt (and vice versa) except the documented exclusions
136
169
  (wrap-up greenlight commands on non-opencode hosts).
137
170
 
138
171
  ## Docs
139
172
 
140
- - `docs/dev/commands.md` (two-tier: dev doc + user-facing README section):
173
+ - `docs/dev/features/commands.md` and `docs/user/commands.md` (two-tier):
141
174
  action list, host parity matrix, session ID rule.
175
+ - `docs/dev/mcp-parity.md`: Actions row in the parity matrix; revisit the
176
+ Wrap-up commands row if greenlight arming ever lands on MCP hosts.
142
177
  - README: "What works in Claude Code" / "What works in Cursor" sections.
143
178
  - QA use cases: one auto use case for `/thatch/defrag` end-to-end, one for
144
179
  `/thatch/extract` with omitted session ID.
@@ -19,6 +19,7 @@ This README is the overview. Each feature has its own guide:
19
19
  - [default-behaviors.md](default-behaviors.md): what ships automatically
20
20
  - [hygiene.md](hygiene.md): store maintenance signals
21
21
  - [deduplication.md](deduplication.md): duplicate detection and resolution
22
+ - [commands.md](commands.md): `/thatch/*` slash commands (on-demand actions and wrap-ups)
22
23
  - [skills.md](skills.md): structured workflow skills
23
24
  - [code-review.md](code-review.md): multi-agent code review pipeline
24
25
  - [setup.md](setup.md): installation and configuration
@@ -242,7 +243,10 @@ Beyond the tools, thatch hooks into opencode itself:
242
243
  agent listed, and you re-run the command once they're handled. Text typed
243
244
  after the command reaches the agent first, labeled as a `User Message`
244
245
  section ahead of the checklist (say goodbye or hand off context:
245
- `/thatch/exit Good work - see you tomorrow`).
246
+ `/thatch/exit Good work - see you tomorrow`). Alongside the wrap-ups,
247
+ on-demand actions (`/thatch/defrag`, `/thatch/extract`, `/thatch/hygiene`,
248
+ `/thatch/reflect`) run the memory-maintenance behaviors whenever you want,
249
+ not just when a nudge fires -- see [commands.md](commands.md).
246
250
  A greenlit `/thatch/exit` also unregisters the session from the chat
247
251
  directory, so other sessions stop sending it mail. The commands
248
252
  self-install into `~/.config/opencode/command/thatch/` at plugin startup;