@jeffober/thatch 0.1.44 → 0.1.46

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. The package bundles Bun and the native embedding
64
+ runtime, so you need no global `npm install` and no `bun` on PATH.
65
+
66
+ Try it without installing:
67
+
68
+ ```bash
69
+ nix run github:sysread/thatch -- --version
70
+ ```
71
+
72
+ Install options:
73
+
74
+ - **NixOS module** (system-wide, all users):
75
+
76
+ ```nix
77
+ {
78
+ inputs.thatch.url = "github:sysread/thatch";
79
+
80
+ # in your NixOS system's modules:
81
+ imports = [ inputs.thatch.nixosModules.default ];
82
+ programs.thatch.enable = true; # puts `thatch` on PATH for every user
83
+ }
84
+ ```
85
+
86
+ - **Overlay**: add `nixpkgs.overlays = [ inputs.thatch.overlays.default ];`,
87
+ then `environment.systemPackages = [ pkgs.thatch ];`
88
+ - **Package directly**: add `inputs.thatch.packages.${system}.default` to a
89
+ `home.packages` or `environment.systemPackages` list
90
+
91
+ Once `thatch` is on PATH, wire it into your editor as usual:
92
+
93
+ ```bash
94
+ cd /path/to/your/project
95
+ thatch setup --claude --global # or --cursor
96
+ ```
97
+
98
+ The embedding model still downloads once at first use. Its cache lives in the
99
+ per-user cache dir described above, outside the read-only Nix store, so it
100
+ survives upgrades. `THATCH_MODEL_CACHE` overrides the location.
101
+
54
102
  ### Other MCP-compatible harnesses
55
103
 
56
104
  ```json
@@ -62,7 +110,7 @@ Requires [Bun] on PATH.
62
110
  ```
63
111
 
64
112
  Include thatch's instructions in your agent's system prompt manually; see
65
- [docs/dev/mcp-parity.md](docs/dev/mcp-parity.md) for the prompt text.
113
+ [MCP parity](https://github.com/sysread/thatch/wiki/Mcp-Parity) for the prompt text.
66
114
 
67
115
  ## What's inside
68
116
 
@@ -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
@@ -127,10 +179,28 @@ review, review response, change and feature walkthroughs, memory verification,
127
179
  knowledge export, and writing tasks
128
180
  (PR descriptions, tickets, PR splitting).
129
181
 
130
- See the [user guide](docs/user/README.md) for the full tool list, CLI
182
+ See the [user guide](https://github.com/sysread/thatch/wiki/Guide%3A-Overview) 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,16 +222,19 @@ 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
 
158
231
  ## Docs
159
232
 
160
- - [User guide](docs/user/README.md) -- setup, tools, configuration, CLI
161
- - [Development](docs/dev/README.md) -- architecture, module responsibilities
162
- - [MCP parity](docs/dev/mcp-parity.md) -- OpenCode plugin vs MCP feature comparison
163
- - [QA tests](tests/qa/) -- executable use cases (auto/ and live/ subdirs)
164
- - [Design docs](docs/plans/) -- design-decision snapshots and in-progress plans
233
+ - [User guide](https://github.com/sysread/thatch/wiki/Guide%3A-Overview) -- setup, tools, configuration, CLI
234
+ - [Development](https://github.com/sysread/thatch/wiki/Developer-Guide) -- architecture, module responsibilities
235
+ - [MCP parity](https://github.com/sysread/thatch/wiki/Mcp-Parity) -- OpenCode plugin vs MCP feature comparison
236
+ - [QA tests](https://github.com/sysread/thatch/tree/main/tests/qa) -- executable use cases (auto/ and live/ subdirs)
237
+ - [Design docs](https://github.com/sysread/thatch/tree/main/docs/plans) -- design-decision snapshots and in-progress plans
165
238
 
166
239
  ## License
167
240
 
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) |
@@ -5,7 +5,7 @@ Thatch uses GitHub Actions for CI and publishing, plus a local release helper sc
5
5
  ## CI pipeline (.github/workflows/ci.yml)
6
6
 
7
7
  Runs on every push/PR to main. Three jobs:
8
- 1. **Typecheck** — `bunx tsc -p tsconfig.check.json` (includes test files that the build's `tsconfig.json` excludes)
8
+ 1. **Typecheck** — `bunx tsc -p tsconfig.check.json` (includes src, tests, and the CI scripts in `.github/scripts`; the build's `tsconfig.json` includes only src)
9
9
  2. **Tests** — `bun test` (bun:test framework, no network, `:memory:` DB or temp dirs)
10
10
  3. **Markdownlint** — `bunx markdownlint-cli2` on `README.md` and `docs/**/*.md` (excluding `docs/plans/**`)
11
11
 
@@ -22,6 +22,14 @@ Key details:
22
22
  - npm 12 shipped a sigstore bug that broke OIDC publishing; the pin prevents it
23
23
  - The workflow deliberately sets NO `registry-url`, which would write a token-expecting `.npmrc` that preempts the OIDC exchange
24
24
 
25
+ ## Wiki pipeline (.github/workflows/wiki.yml)
26
+
27
+ Triggered by pushes to main that touch `docs/**`, the workflow file, or the render script. One-way mirror of `docs/` into the GitHub wiki: renders each doc to a flat wiki page (tiered prefixes `Guide:`, bare, `Feature:`; see `.github/scripts/sync-wiki.ts`), rewrites relative md links, generates `Home.md` + `_Sidebar.md`, wipes and rewrites all wiki pages, then pushes to `sysread/thatch.wiki.git`.
28
+
29
+ - Uses the `WIKI_TOKEN` Actions secret (classic PAT, `public_repo` scope) because the built-in `GITHUB_TOKEN` cannot push to wiki repos
30
+ - Precondition: the wiki must be initialized once via the UI (GitHub does not auto-create `.wiki.git` repos)
31
+ - `docs/plans/` and `docs/in-progress/` are excluded; duplicate or reserved page names fail the render script
32
+
25
33
  ## Release helper (bin/release)
26
34
 
27
35
  A bash script (`set -euo pipefail`) that bumps, checks, commits, tags, and pushes.
@@ -45,7 +53,7 @@ Steps:
45
53
  | `test` | Run the test suite (`bun test`) |
46
54
  | `test-watch` | Run tests in watch mode |
47
55
  | `coverage` | Run tests with coverage report |
48
- | `typecheck` | Typecheck src + tests (`bunx tsc -p tsconfig.check.json`) |
56
+ | `typecheck` | Typecheck src + tests + `.github/scripts` (`bunx tsc -p tsconfig.check.json`) |
49
57
  | `lint-md` | Lint markdown docs (`bunx markdownlint-cli2`) |
50
58
  | `check` | The CI gate: typecheck + tests + markdownlint |
51
59
  | `release` | Bump version, commit, tag, push (args: `patch\|minor\|major`) |
@@ -60,7 +68,7 @@ Steps:
60
68
  ## Development tooling
61
69
 
62
70
  - **Bun 1.3.14** (pinned via mise)
63
- - **TypeScript** with `tsconfig.check.json` for typecheck (includes test files; build's `tsconfig.json` excludes them)
71
+ - **TypeScript** with `tsconfig.check.json` for typecheck (includes src, tests, and `.github/scripts`; build's `tsconfig.json` includes only src)
64
72
  - **bun:test** framework (no network, `:memory:` DB or temp dirs, mock embeddings)
65
73
  - **markdownlint-cli2** with custom config (`.markdownlint-cli2.jsonc`): disables MD013 (line length), MD032 (blanks around lists), MD036 (emphasis as heading), MD060 (table alignment). Keeps MD040 (code-block language), MD047 (trailing newline), heading hierarchy.
66
74
  - **Zod** for tool schema validation and JSON Schema generation (`src/tool-defs.ts`)
@@ -74,10 +82,11 @@ Steps:
74
82
 
75
83
  - `.github/workflows/ci.yml` — CI pipeline
76
84
  - `.github/workflows/publish.yml` — publish pipeline (OIDC)
85
+ - `.github/workflows/wiki.yml` + `.github/scripts/sync-wiki.ts` — wiki mirror pipeline
77
86
  - `bin/release` — release helper script
78
87
  - `mise.toml` — task definitions, tool pins, env vars
79
88
  - `.markdownlint-cli2.jsonc` — markdownlint config
80
- - `tsconfig.check.json` — typecheck config (includes tests)
89
+ - `tsconfig.check.json` — typecheck config (includes src, tests, `.github/scripts`)
81
90
 
82
91
  ## Key invariants
83
92
 
@@ -86,4 +95,4 @@ Steps:
86
95
  - npm is pinned to 11.x. npm 12 shipped a sigstore bug that broke OIDC publishing.
87
96
  - The release helper checks for tagged-but-not-published state (tag exists, npm returns E404) and refuses to proceed with recovery instructions.
88
97
  - CI runs on push/PR to main. Publish runs only on `v*` tags. The release helper bridges them: tag + push triggers publish.
89
- - `tsconfig.check.json` includes test files for typecheck. The build's `tsconfig.json` excludes them.
98
+ - `tsconfig.check.json` includes src, tests, and `.github/scripts` for typecheck. The build's `tsconfig.json` includes only src.
@@ -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`)