@jeffober/thatch 0.2.5 → 0.2.7

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 (53) hide show
  1. package/README.md +48 -25
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +11 -0
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +25 -15
  6. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  7. package/artifacts/skills/thatch-refine-personal.md +5 -0
  8. package/artifacts/skills/thatch-refine.md +7 -1
  9. package/artifacts/skills/thatch-review-clarity.md +101 -0
  10. package/artifacts/skills/thatch-review-context.md +55 -2
  11. package/artifacts/skills/thatch-review-followup.md +4 -4
  12. package/artifacts/skills/thatch-review-response.md +1 -1
  13. package/artifacts/skills/thatch-review-synthesizer.md +22 -2
  14. package/bin/thatch +103 -33
  15. package/docs/dev/README.md +11 -8
  16. package/docs/dev/features/README.md +1 -1
  17. package/docs/dev/features/behavior-engine.md +10 -0
  18. package/docs/dev/features/cli.md +23 -7
  19. package/docs/dev/features/commands.md +40 -9
  20. package/docs/dev/features/cross-session-chat.md +99 -27
  21. package/docs/dev/features/database.md +4 -4
  22. package/docs/dev/features/nudge-pipeline.md +14 -0
  23. package/docs/dev/features/opencode-plugin.md +41 -15
  24. package/docs/dev/features/prediction-engine.md +27 -1
  25. package/docs/dev/features/qa-system.md +19 -3
  26. package/docs/dev/features/session-lifecycle.md +6 -6
  27. package/docs/dev/features/session-tabs.md +11 -5
  28. package/docs/dev/gotchas.md +21 -0
  29. package/docs/dev/mcp-parity.md +3 -3
  30. package/docs/dev/skills.md +12 -8
  31. package/docs/user/README.md +22 -14
  32. package/docs/user/behavior-engine.md +7 -1
  33. package/docs/user/cli.md +28 -15
  34. package/docs/user/code-review.md +46 -4
  35. package/docs/user/commands.md +27 -7
  36. package/docs/user/cross-session-chat.md +48 -20
  37. package/docs/user/prediction-engine.md +12 -1
  38. package/docs/user/skills.md +9 -6
  39. package/package.json +1 -1
  40. package/src/capabilities.ts +20 -6
  41. package/src/chat.ts +56 -24
  42. package/src/commands.ts +109 -18
  43. package/src/db.ts +23 -2
  44. package/src/mcp.ts +6 -2
  45. package/src/opencode/tui-plugin.ts +29 -9
  46. package/src/opencode/v2.ts +247 -66
  47. package/src/prompts.ts +55 -6
  48. package/src/runtime.ts +122 -17
  49. package/src/scoring-engine.ts +40 -9
  50. package/src/session-tab-shared.ts +55 -4
  51. package/src/skills.ts +5 -9
  52. package/src/tab-state.ts +125 -0
  53. package/src/tool-defs.ts +55 -22
@@ -43,16 +43,17 @@ Skills are split into shared (all hosts) and opencode-only:
43
43
  | `thatch-review-state-flow` | Data flow and contracts: module boundaries, implicit state machines, error propagation, separation of concerns. |
44
44
  | `thatch-review-economy` | Design simplicity and maintainability: is the complexity earned? Evaluates both the overall change design (forest) and individual touch points (trees) for unnecessary complexity, redundancy, and simpler available alternatives. |
45
45
  | `thatch-review-no-slop` | AI writing anti-patterns: change narration, fourth wall breaks, em dashes, hedging, filler. |
46
+ | `thatch-review-clarity` | Prose clarity in comments, docstrings, error strings, and docs: made-up phrases, buzzwords, untranslated jargon, clause chains, buried leads, doc/code responsibility splits, checked against the project's clear-writing rules regardless of who wrote the text. |
46
47
  | `thatch-review-breadcrumbs` | Comment narrative: do comments form a coherent outline of the code's behavior? |
47
48
  | `thatch-review-mark-and-sweep` | Mechanical change completeness: whole-repo sweep for stragglers after renames, flag removals, API substitutions. |
48
49
  | `thatch-review-highlights` | Positive finding detection: notably clever solutions, cleanup done along the way, documentation that helps. Medium-high bar. |
49
50
  | `thatch-review-technique` | Technique lens: teaching-grade notes on unseen helpers/internal packages, companion techniques, house patterns, test-craft, API-shape principles, next-reader discoverability. Informational only, never blocking. |
50
51
  | `thatch-review-synthesizer` | Verifies and synthesizes findings from multiple specialists into a deduplicated, severity-grouped report. |
51
- | `thatch-review-context` | Gathers project context (PRs, tickets, linked docs/designs followed from the change, TODOs, deferred work) before a review. Prevents false positives about intentionally deferred work. |
52
+ | `thatch-review-context` | Gathers project context (PRs, tickets, linked docs/designs followed from the change, TODOs, deferred work, related PRs) before a review. Prevents false positives about intentionally deferred work and about pieces delivered by sibling PRs in a stack or release train. |
52
53
  | `thatch-code-archaeology` | Investigates an existing feature, debugs an unfamiliar area, or begins a new ticket. The research skill; pair with `thatch-coding-workflow`. |
53
54
  | `thatch-review-followup` | Verifies whether the author's responses and code changes since your last review adequately addressed your prior findings. |
54
55
  | `thatch-review-response` | Author-side review response: triage findings on your own PR, fix bugs one by one, reply on each thread, post a top-level summary comment. |
55
- | `thatch-code-review` | Multi-agent review coordinator (opencode-only). Dispatches parallel sub-agents for specialist fan-out and synthesis. |
56
+ | `thatch-code-review` | Multi-agent review coordinator (opencode-only). Dispatches parallel sub-agents for specialist fan-out and synthesis. Also the machinery behind the `/thatch/self-review` and `/thatch/peer-review` mode commands, which couch it with a weighting overlay and run the specialists inline on hosts where the coordinator is not installed. |
56
57
 
57
58
  ### Walkthrough skills
58
59
 
@@ -75,15 +76,15 @@ Skills are split into shared (all hosts) and opencode-only:
75
76
  | Skill | Purpose |
76
77
  |-------|---------|
77
78
  | `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
78
- | `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
79
+ | `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). Plans with UI or session state must include a state-and-transition matrix; a revision self-check runs after each revision; extended runs rotate reviewer lenses and stop on a round with no build-changing findings. |
79
80
 
80
81
  ### Plan refinement skills
81
82
 
82
83
  | Skill | Purpose |
83
84
  |-------|---------|
84
- | `thatch-refine` | Classifies the project (audience, lifecycle, surface) and refines a plan under that type's requirements. The entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
85
+ | `thatch-refine` | Classifies the project (audience, lifecycle, surface) and refines a plan under that type's requirements. The entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. Deep-mode triggers in the core skill are binding; type deltas relax round counts, never mode selection. |
85
86
  | `thatch-refine-team-app` | Team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
86
- | `thatch-refine-personal` | Solo projects: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
87
+ | `thatch-refine-personal` | Solo projects: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. Relaxes round counts, never mode selection. |
87
88
  | `thatch-refine-shared-lib` | Library API surfaces: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
88
89
  | `thatch-refine-spike` | Throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on. |
89
90
  | `thatch-refine-infra` | CI, Terraform, and k8s changes: blast radius, mandatory rollback plan and staged verification, pattern-following is near-absolute. |
@@ -104,7 +105,9 @@ window.
104
105
  re-installs from its canonical source). Edit the canonical source
105
106
  in the thatch repo instead.
106
107
  - The opencode-only coordinator (`thatch-code-review`) requires
107
- sub-agent support. It is not available on Claude Code or Cursor.
108
+ sub-agent support. It is not available on Claude Code or Cursor; the
109
+ `/thatch/self-review` and `/thatch/peer-review` commands run the
110
+ same specialists inline there instead.
108
111
  - Skill content is markdown read at runtime, not compiled into the
109
112
  binary. If the skill file is missing or corrupted, the skill will
110
113
  not load (silent failure).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -54,7 +54,13 @@ export interface HostCapabilities {
54
54
  * only the title carries over.
55
55
  */
56
56
  sessionCreate(input: { parentID: string; title: string }): Promise<{ id: string }>;
57
- /** Delete a session (child-session cleanup). Degrades to a no-op resolve on v2. */
57
+ /**
58
+ * Delete a session (extraction-child cleanup, so the picker stays clean
59
+ * and `-c` resume does not land in a child). v1: client delete. v2:
60
+ * session.remove on the promise domain (upstream #52387), runtime-guarded
61
+ * because the pinned dev types predate the surface - an older host
62
+ * degrades to a no-op (best-effort by the caller's contract).
63
+ */
58
64
  sessionDelete(id: string): Promise<void>;
59
65
  /**
60
66
  * Prompt a session with text parts. `sync` mode blocks until the child
@@ -90,15 +96,23 @@ export interface HostCapabilities {
90
96
  /**
91
97
  * Trigger the host's compaction for a session (the wrap-up compact
92
98
  * action). v1 dispatches the TUI's session_compact command (the legacy
93
- * alias route; the id is ignored). v2 has no compaction trigger reachable
94
- * from the promise context and degrades to a logged no-op.
99
+ * alias route; the id is ignored). v2 calls session.compact on the
100
+ * promise domain (upstream #52385), runtime-guarded because the pinned
101
+ * dev types predate that surface; an older host degrades to a logged
102
+ * no-op.
95
103
  */
96
104
  compactSession(sessionID: string): Promise<void>;
97
105
  /**
98
- * Exit the host application (the wrap-up exit action). v1 publishes the
99
- * TUI's app.exit command; v2 has no TUI surface and degrades to a no-op.
106
+ * End the session's presence on the host (the wrap-up exit action). The
107
+ * runtime records the session's watcher deaths and unregisters it from
108
+ * chat BEFORE calling here, so implementations only close out the host
109
+ * side. v1 publishes the TUI's app.exit command - the id is unused
110
+ * because the whole app is leaving. v2 emits the session-tab bridge's
111
+ * exit-tab-closed event, a TUI-only close of the session's OWN tab (the
112
+ * daemon hosts every tab, so an app-wide exit would take down unrelated
113
+ * sessions too); a host with no bridge degrades to a logged no-op.
100
114
  */
101
- exitHost(): Promise<void>;
115
+ exitHost(sessionID: string): Promise<void>;
102
116
  }
103
117
 
104
118
  /**
package/src/chat.ts CHANGED
@@ -351,18 +351,28 @@ export function humanAge(iso: string, now = Date.now()): string {
351
351
  return `${Math.floor(minutes / (24 * 60))}d ago`;
352
352
  }
353
353
 
354
- /** The liveness verdict for one roster row, from heartbeat age alone:
355
- * - "fresh"/"stale" for opencode rows (poller-driven heartbeat; stale means * the hosting harness has missed two beats and is presumed gone)
356
- * - "active"/"idle" for MCP rows (turn-driven; idle between prompts is the
357
- * NORMAL state, so an idle MCP row is reachable, not dead)
358
- * Heartbeat age is the only signal on purpose. A process id cannot serve:
359
- * the same session is re-hosted by a new process on every `-s` resume, so
360
- * a pid stamped on the row lies as soon as a different harness beats it.
361
- * One clock, one rule. */
362
- export function chatLiveness(row: ChatSessionRow, now = Date.now()): "fresh" | "stale" | "active" | "idle" {
354
+ /** The liveness verdict for one roster row, from heartbeat age plus (for
355
+ * opencode rows) the open-tab set:
356
+ * - "open" - fresh heartbeat AND a tab is open now (session presence)
357
+ * - "recent" - fresh heartbeat, no known tab: an instance that saw this
358
+ * session is alive and beating it, but the session itself may or may
359
+ * not be wakeable (a closed tab's wake can be swallowed; a daemon-
360
+ * resident subordinate runs headless and may still pick up mail)
361
+ * - "stale" - the hosting harness has missed two beats and is presumed
362
+ * gone
363
+ * - "active"/"idle" for MCP rows (turn-driven; idle between prompts is
364
+ * the NORMAL state, so an idle MCP row is reachable, not dead)
365
+ * Heartbeat age alone decides staleness - a process id cannot serve: the
366
+ * same session is re-hosted by a new process on every `-s` resume, so a
367
+ * pid stamped on the row lies as soon as a different harness beats it.
368
+ * The tab set is a READ-ONLY refinement (src/tab-state.ts) consumed at
369
+ * render/broadcast time; omitting it degrades every fresh opencode row
370
+ * to "recent" - honest, never a false "open". */
371
+ export function chatLiveness(row: ChatSessionRow, now = Date.now(), openTabs?: ReadonlySet<string>): "open" | "recent" | "stale" | "active" | "idle" {
363
372
  const stale = isStale(row, now);
364
373
  if (row.host_kind === "mcp") return stale ? "idle" : "active";
365
- return stale ? "stale" : "fresh";
374
+ if (stale) return "stale";
375
+ return openTabs?.has(row.session_id) ? "open" : "recent";
366
376
  }
367
377
 
368
378
  /** Orders roster rows for display: project first (so a shared-tree reader
@@ -379,31 +389,41 @@ export function sortChatRoster(rows: ChatSessionRow[]): ChatSessionRow[] {
379
389
  });
380
390
  }
381
391
 
382
- /** Splits the roster for the two-section display: Active (wake-able or
383
- * reachable - fresh opencode rows and every MCP row, which read mail at
384
- * their next prompt) and Stale (opencode rows whose harness has stopped
385
- * reporting; mail to them waits until they resume).
392
+ /** Splits the roster for the three-section display: Active (wakeable or
393
+ * reachable - opencode rows with an open tab, plus every MCP row, which
394
+ * read mail at their next prompt), Recent (fresh opencode rows with no
395
+ * open tab: the harness is alive and beating, but a wake may not reach
396
+ * the session until its tab reopens - headless subordinates may still
397
+ * read mail on their own turns), and Stale (opencode rows whose harness
398
+ * has stopped reporting; mail to them waits until they resume).
399
+ * openTabs refines fresh opencode rows into open versus recent; when
400
+ * omitted every fresh opencode row degrades to recent, never a false
401
+ * open (chatLiveness's rule).
386
402
  * staleCapMs bounds how old a stale row the roster displays, so
387
403
  * long-dead sessions cannot bury the fresh signal; hidden rows come
388
404
  * back only as the staleHidden count, so the CLI can say what it left
389
405
  * out. null (the default) shows every stale row - the tool surface has
390
406
  * no cap, only the CLI applies one. */
391
- export function splitChatRoster(rows: ChatSessionRow[], now = Date.now(), staleCapMs: number | null = null): { active: ChatSessionRow[]; stale: ChatSessionRow[]; staleHidden: number } {
407
+ export function splitChatRoster(rows: ChatSessionRow[], now = Date.now(), staleCapMs: number | null = null, openTabs?: ReadonlySet<string>): { active: ChatSessionRow[]; recent: ChatSessionRow[]; stale: ChatSessionRow[]; staleHidden: number } {
392
408
  const active: ChatSessionRow[] = [];
409
+ const recent: ChatSessionRow[] = [];
393
410
  const stale: ChatSessionRow[] = [];
394
411
  let staleHidden = 0;
395
412
  for (const row of rows) {
396
- if (chatLiveness(row, now) === "stale") {
413
+ const verdict = chatLiveness(row, now, openTabs);
414
+ if (verdict === "stale") {
397
415
  if (staleCapMs !== null && now - Date.parse(row.last_seen) > staleCapMs) {
398
416
  staleHidden++;
399
417
  } else {
400
418
  stale.push(row);
401
419
  }
420
+ } else if (verdict === "recent") {
421
+ recent.push(row);
402
422
  } else {
403
423
  active.push(row);
404
424
  }
405
425
  }
406
- return { active, stale, staleHidden };
426
+ return { active, recent, stale, staleHidden };
407
427
  }
408
428
 
409
429
  /**
@@ -858,10 +878,16 @@ export class ChatStore {
858
878
  }
859
879
 
860
880
  /**
861
- * Posts a message to every other registered session at once. Stale
862
- * sessions are skipped, not messaged - a host process that has stopped
863
- * heartbeat-ing will never read the mail, and a broadcast is for reaching
864
- * live agents. Each recipient gets its own inbox row per part, so the
881
+ * Posts a message to every other registered session at once. Opencode
882
+ * rows are skipped unless they are OPEN - a fresh heartbeat AND a tab
883
+ * open right now. Both skip classes are honest: a STALE row's host
884
+ * process has stopped heartbeat-ing and will never read the mail, and a
885
+ * RECENT row (fresh heartbeat, no open tab) is not reliably wakeable -
886
+ * the wake can be swallowed without a tab to receive it, so the fan-out
887
+ * would spend a model turn on a delivery that likely never runs. MCP
888
+ * rows always receive (they read mail at their next prompt). Every skip
889
+ * is listed by name in `skipped` so the caller can see who was left
890
+ * out and why. Each recipient gets its own inbox row per part, so the
865
891
  * existing wake machinery (grouping, gating, rate cap) treats the
866
892
  * broadcast as ordinary per-recipient mail. An oversized body is
867
893
  * auto-split (splitChatBody) and every recipient gets every part inside
@@ -871,6 +897,7 @@ export class ChatStore {
871
897
  broadcast(
872
898
  fromSession: string,
873
899
  body: string,
900
+ openTabs?: ReadonlySet<string>,
874
901
  ): { ok: true; recipients: string[]; skipped: string[]; deduped: number; parts: number } | { ok: false; error: string } {
875
902
  const trimmed = body.trim();
876
903
  if (!trimmed) return { ok: false, error: `Message body cannot be empty (attempted ${body.length} characters).` };
@@ -885,6 +912,9 @@ export class ChatStore {
885
912
  };
886
913
  }
887
914
  const parts = splitChatBody(trimmed);
915
+ // No provider = no tab signal: every fresh opencode row degrades to
916
+ // RECENT (honest - an unknown tab state never reads as open).
917
+ const open = openTabs ?? new Set<string>();
888
918
  const recipients: string[] = [];
889
919
  const skipped: string[] = [];
890
920
  let deduped = 0;
@@ -894,10 +924,12 @@ export class ChatStore {
894
924
  this.#db.transaction(() => {
895
925
  for (const row of this.list()) {
896
926
  if (row.session_id === fromSession) continue;
897
- // Staleness means "host process gone" only for opencode rows; an
898
- // mcp row between turns reads its mail at the next prompt, so it
927
+ // Two honest skip classes for opencode rows: STALE (host process
928
+ // gone - never reads the mail) and RECENT (fresh heartbeat, no
929
+ // open tab - the wake is not guaranteed to run a turn). An mcp
930
+ // row between turns reads its mail at the next prompt, so it
899
931
  // always receives.
900
- if (row.host_kind === "opencode" && isStale(row)) {
932
+ if (row.host_kind === "opencode" && (isStale(row) || !open.has(row.session_id))) {
901
933
  skipped.push(row.name);
902
934
  continue;
903
935
  }
package/src/commands.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
+ REVIEW_SPECIALISTS,
4
5
  defragCore,
5
6
  extractionCore,
6
7
  hygieneCore,
@@ -24,12 +25,17 @@ import {
24
25
  *
25
26
  * Two command families live here:
26
27
  *
27
- * - Actions (/thatch/defrag, extract, hygiene, reflect): user-invoked
28
- * wrappers around a behavior that the nudges otherwise only run on their
29
- * own schedule. Each action body is a prompt core from src/prompts.ts, so
30
- * nudge and command wording cannot drift.
28
+ * - Actions (/thatch/defrag, extract, hygiene, peer-review, refine,
29
+ * reflect, self-review, whois): user-invoked commands. The nudge-backed
30
+ * ones (defrag, extract, hygiene, reflect) render prompt cores from
31
+ * src/prompts.ts, so nudge and command wording cannot drift. The rest
32
+ * (peer-review, refine, self-review, whois) are self-contained here:
33
+ * they have no nudge counterpart. peer-review and self-review couch the
34
+ * code review pipeline with a mode overlay: the same machinery, with
35
+ * findings weighted for whose work is under review.
31
36
  * - Wrap-ups (/thatch/compact, exit): greenlight-token commands that need
32
- * the opencode plugin's TUI control routes, so they are opencode-only.
37
+ * the opencode plugin's host action routes (the compact trigger and the
38
+ * session close-out), so they are opencode-only.
33
39
  */
34
40
 
35
41
  /** Greenlight token ending /thatch/compact's assistant response when compaction is safe. */
@@ -39,11 +45,13 @@ export const COMPACT_READY_TOKEN = "THATCH_COMPACT_READY";
39
45
  export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
40
46
 
41
47
  /**
42
- * The plugin triggers compaction by running the TUI's compact action. The
43
- * execute-command route only accepts legacy alias names, and "session_compact"
44
- * maps to the same session.compact action the built-in /compact command runs.
45
- * There is no exit alias, so the exit path publishes the TUI keymap command
46
- * ("app.exit") directly via /tui/publish.
48
+ * The greenlight token's host action, per host: v1 drives the TUI
49
+ * (compaction via the "session_compact" legacy alias through
50
+ * executeCommand, the exit via the "app.exit" keymap command through
51
+ * /tui/publish). v2 calls session.compact on the plugin's promise domain
52
+ * and emits the session-tab bridge's TUI-only exit-tab-closed event; the
53
+ * runtime records the exiting session's watcher deaths before either host
54
+ * action runs (src/runtime.ts).
47
55
  */
48
56
 
49
57
  const sharedChecklist = `1. Flush pending persistence. Call thatch_get_extraction_payload; if it returns buffered tool interactions, process them now (write any memories worth keeping), then call thatch_extraction_done to mark them complete. Complete any memory writes you promised earlier but have not made.
@@ -55,10 +63,42 @@ $ARGUMENTS
55
63
 
56
64
  (That section carries the text typed after the command. When it is empty, treat the user message as n/a - the command was run bare.)`;
57
65
 
66
+ /**
67
+ * How the review-mode commands (peer-review, self-review) run the code
68
+ * review machinery on the host at hand. opencode has the
69
+ * thatch-code-review coordinator skill and sub-agent dispatch; Claude Code
70
+ * and Cursor have neither, so the same procedure runs inline through the
71
+ * shared specialist skills. The mode overlay that differentiates the two
72
+ * commands lives in each command's own body and applies in both paths:
73
+ * sub-agent briefings on opencode, specialist passes inline elsewhere.
74
+ *
75
+ * Both couchings below are shared consts interpolated into the two bodies
76
+ * so the commands' procedure and target resolution cannot drift; each body
77
+ * keeps only its mode rules. The specialist roster and count come from
78
+ * REVIEW_SPECIALISTS in src/prompts.ts, the same source as the install
79
+ * list in src/skills.ts.
80
+ */
81
+ const reviewPipelineCouching = `If the thatch-code-review skill is available, load it and follow its steps, with one addition: apply the mode rules below throughout - to context gathering, every specialist briefing, and the synthesis.
82
+
83
+ If that skill is not available on this host, run the same procedure yourself, in order:
84
+ - Resolve the review range. Compute the merge-base against the base branch's remote tracking ref (for example origin/main), not the working branch's own upstream, and cite every file:line against the range's head SHA with git show, never the working tree. A PR or MR number resolves through the host's CLI (gh pr view, glab mr view) plus fetching the head ref (git fetch origin refs/pull/N/head on GitHub, refs/merge-requests/N/head on GitLab, or the source branch). A repository with no remote reviews the working tree's recent commits instead.
85
+ - Build the context brief with the thatch-review-context skill.
86
+ - Partition large diffs into review units.
87
+ - Run the ${REVIEW_SPECIALISTS.length} specialist skills (${REVIEW_SPECIALISTS.join(", ")}) on each unit.
88
+ - Synthesize with thatch-review-synthesizer.
89
+ The mode rules below apply in every specialist pass and in the synthesis, on every host. If the specialist skills are not installed on this host, tell the user to run thatch setup for it.`;
90
+
91
+ /**
92
+ * How the review-mode commands resolve what to review. Shared so the two
93
+ * commands accept the same target syntax and default identically.
94
+ */
95
+ const reviewTargetCouching = `The user message may name the review target: a branch, a PR or MR number, or a git range. On MCP hosts the Focus: line carries that text instead. If no target is given, review the current branch against its base.`;
96
+
58
97
  /**
59
98
  * One on-demand action: the command name (minus the thatch/ prefix), the
60
99
  * description shown in the command menu, and the instruction body (a prompt
61
- * core). opencodeOnly marks actions that need a session identity or other
100
+ * core for the nudge-backed actions, self-contained text for the rest).
101
+ * opencodeOnly marks actions that need a session identity or other
62
102
  * plugin-only capability; those are excluded from Claude Code command files
63
103
  * and MCP prompts.
64
104
  */
@@ -71,8 +111,11 @@ export interface ActionDef {
71
111
 
72
112
  /**
73
113
  * The on-demand action set, shared by every host that renders actions. The
74
- * bodies come from prompt cores so a wording change lands in the nudge and
75
- * the command at once. `tool` renders tool names in the host's spelling.
114
+ * nudge-backed bodies (defrag, extract, hygiene, reflect) come from prompt
115
+ * cores in src/prompts.ts so a wording change lands in the nudge and the
116
+ * command at once; the rest (peer-review, refine, self-review, whois) are
117
+ * self-contained here because they have no nudge counterpart. `tool`
118
+ * renders tool names in the host's spelling.
76
119
  */
77
120
  export function actionDefs(tool: ToolNamer): ActionDef[] {
78
121
  return [
@@ -100,6 +143,25 @@ After dispatching, call thatch_extraction_done to acknowledge.`,
100
143
  description: "Tend the memory store: stale entries, orphans, pending dedup pairs",
101
144
  body: hygieneCore(),
102
145
  },
146
+ {
147
+ name: "peer-review",
148
+ description: "Review another author's change (PR, MR, branch, or git range) through the full review pipeline",
149
+ // Skill-couching action (refine precedent): no nudge counterpart, so
150
+ // the body lives here. The memory-walk tool name is spelled through
151
+ // the namer so MCP renderings carry the mcp__thatch__ form.
152
+ body: `Review a change authored by someone else: a PR, MR, or branch on origin. The review runs the full thatch review pipeline with findings weighted for org velocity.
153
+
154
+ ${reviewTargetCouching}
155
+
156
+ ${reviewPipelineCouching}
157
+
158
+ ## Peer review mode rules
159
+
160
+ - Weight for org velocity. Findings that speed up or unblock the whole team outrank findings about individual lines. The highest-value findings: a simpler method that achieves the same outcome; an existing function or helper that already does what this change reimplements; documentation that is accurate and extremely clearly written; unintended consequences for callers, sibling features, or contracts the author could not see.
161
+ - Walk the project's memory. The author does not have this project's persistent memory; you do. During context gathering, call ${tool("memory_recall")} with several phrasings about the touched area: past incidents, design constraints, rejected approaches, known footguns. Check each recalled item against the change and report the ones that apply. Frame these findings as sharing knowledge the author could not have had, not as catching mistakes.
162
+ - Do not re-litigate settled designs. When the context brief or memory shows a design decision was agreed (in a ticket discussion, a prior review acceptance, recorded docs, or a memory of the agreement), treat the decision as settled. Findings may address how the change implements that decision. Findings that argue the decision itself was wrong are out of scope for this review.
163
+ - Never report findings of the form "if someone changes this in the future, it will silently break." This review does not solve for engineers who change code without reading it. It solves for engineers and LLMs who WILL read the code. For them, verify the change leaves a breadcrumb trail to wherever this organization records how a feature works (a context module, a feature doc, a README). Flag a missing trail. Do not flag hypothetical future misuse.`,
164
+ },
103
165
  {
104
166
  name: "refine",
105
167
  description: "Refine a plan under the project type's requirements (classifies the project first)",
@@ -115,6 +177,26 @@ The user message may name a project type (team-app, personal, shared-lib, spike,
115
177
  description: "Persist what this session learned as memories",
116
178
  body: reflectCore(tool),
117
179
  },
180
+ {
181
+ name: "self-review",
182
+ description: "Review the user's own completed work before submitting it",
183
+ // Skill-couching action (see peer-review). Names no tools, so the
184
+ // MCP spelling test exempt list covers it.
185
+ body: `Review the user's own completed work before submitting it: as a PR or MR on a shared project, or as a direct commit on a personal project. The review runs the full thatch review pipeline with findings weighted to protect the user's reputation as a conscientious developer.
186
+
187
+ The work under review was authored by the user, or by their agent acting in the user's name. Two goals: the work is sound, and it does not read as sloppy. Reviewers and teammates read the comments, docs, and commit messages as the user's own.
188
+
189
+ ${reviewTargetCouching}
190
+
191
+ ${reviewPipelineCouching}
192
+
193
+ ## Self review mode rules
194
+
195
+ - The reputation lenses weigh heaviest. NoSlop, Clarity, Technique, and Breadcrumbs findings outrank same-severity findings from the other lenses. AI-writing tells, unclear prose, and a missing narrative are exactly what this review exists to catch before anyone else sees them.
196
+ - Overweight simplicity on shared projects. Prefer findings that make the design easier to understand, easier to test, and more reliable: fewer assumptions, less surface area for bugs and side effects.
197
+ - Technique and highlights findings are decisions, not footnotes. Each one that would produce a simpler, clearer, or more reliable design is surfaced for an adopt-or-dismiss decision before submission. Work through them with the user one at a time; do not leave them as informational asides.
198
+ - Iterate rounds. After each round, work the findings with the user: fix each, or explicitly decline it with a reason. Then run another full round on the updated state. A round is meaningful when it produces a MEDIUM or higher finding, or a technique or slop finding worth adopting. A round producing only LOW mechanical findings gets them fixed but does not trigger another round. Stop at the first round with no meaningful findings, and never run more than three rounds.`,
199
+ },
118
200
  {
119
201
  name: "whois",
120
202
  description: "Report this session's chat identity (handle, topic, project), or look up another session's",
@@ -154,8 +236,18 @@ ${userMessageSection}
154
236
  ${action.body}`;
155
237
  }
156
238
 
239
+ /** The wrap-up command's menu/palette description. Host-neutral on purpose:
240
+ * what actually happens after the greenlight differs per host (v1 exits the
241
+ * app, v2 closes the session's tab), and the template text the MODEL reads
242
+ * is shared verbatim by both hosts. */
243
+ export function wrapUpCommandDescription(kind: "compact" | "exit"): string {
244
+ return kind === "compact"
245
+ ? "Flush thatch persistence, check for loose ends, then compact if clear"
246
+ : "Flush thatch persistence, check for loose ends, then end the session if clear";
247
+ }
248
+
157
249
  const COMPACT_TEMPLATE = `---
158
- description: ${yamlQuote("Flush thatch persistence, check for loose ends, then compact if clear")}
250
+ description: ${yamlQuote(wrapUpCommandDescription("compact"))}
159
251
  ---
160
252
  ${userMessageSection}
161
253
 
@@ -172,7 +264,7 @@ ${COMPACT_READY_TOKEN}
172
264
  If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
173
265
 
174
266
  const EXIT_TEMPLATE = `---
175
- description: ${yamlQuote("Flush thatch persistence, check for loose ends, then exit opencode if clear")}
267
+ description: ${yamlQuote(wrapUpCommandDescription("exit"))}
176
268
  ---
177
269
  ${userMessageSection}
178
270
 
@@ -181,13 +273,12 @@ ${userMessageSection}
181
273
  Work through this checklist before responding:
182
274
 
183
275
  ${sharedChecklist}
184
- 3. Leave the chat directory. Call thatch_chat_unregister for this session - the session is exiting, so other sessions must stop addressing mail to it.
185
276
 
186
277
  Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:
187
278
 
188
279
  ${EXIT_READY_TOKEN}
189
280
 
190
- If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. opencode will only exit when the token is present.`;
281
+ If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only close when the token is present.`;
191
282
 
192
283
  /** One command: the command name (minus the thatch/ prefix) and its full markdown file content. */
193
284
  export interface CommandDef {
@@ -221,7 +312,7 @@ export function opencodeCommandDefs(): CommandDef[] {
221
312
  /**
222
313
  * The Claude Code command set: the actions minus the opencode-only ones.
223
314
  * The wrap-up commands are excluded too - their greenlight check needs the
224
- * plugin's command.execute.before arming and TUI control routes.
315
+ * plugin's command.execute.before arming and host action routes.
225
316
  */
226
317
  export function claudeCommandDefs(): CommandDef[] {
227
318
  return actionDefs(mcpToolName)
package/src/db.ts CHANGED
@@ -60,9 +60,15 @@ export class ThatchDB {
60
60
  #predictionsStore!: PredictionEngine;
61
61
  #behaviorsStore!: BehaviorEngine;
62
62
  #chatStore!: ChatStore;
63
+ // The open-tab provider for broadcast targeting (roster OPEN-vs-RECENT).
64
+ // Optional: unwired (tests, callers with no tab reader) means no tab
65
+ // signal, so every fresh opencode row degrades to RECENT - honest, never
66
+ // a false OPEN. Production callers wire chatOpenTabs(src/tab-state.ts).
67
+ #chatOpenTabs?: () => ReadonlySet<string>;
63
68
 
64
- constructor(path: string) {
69
+ constructor(path: string, opts: { chatOpenTabs?: () => ReadonlySet<string> } = {}) {
65
70
  this.#path = path;
71
+ this.#chatOpenTabs = opts.chatOpenTabs;
66
72
  this.#open();
67
73
  }
68
74
 
@@ -1086,7 +1092,22 @@ export class ThatchDB {
1086
1092
  }
1087
1093
 
1088
1094
  broadcastChatMessage(fromSession: string, body: string) {
1089
- return this.#chat.broadcast(fromSession, body);
1095
+ return this.#chat.broadcast(fromSession, body, this.#chatOpenTabs?.());
1096
+ }
1097
+
1098
+ /**
1099
+ * Confirmed tab closes recorded by the roster (the session_tab tool's
1100
+ * bridge event). Readers subtract the fresh ones from the open-tab set
1101
+ * (effectiveOpenTabs) so a residue tabs.json row cannot read OPEN right
1102
+ * after a confirmed close.
1103
+ */
1104
+ chatTabClosures(): { sessionID: string; at: string }[] {
1105
+ return this.runtimeStateAll()
1106
+ .filter((r) => r.kind === "chat_tab_closed")
1107
+ .flatMap((r) => {
1108
+ const at = (r.value as { at?: string } | null)?.at;
1109
+ return typeof at === "string" ? [{ sessionID: r.sessionID, at }] : [];
1110
+ });
1090
1111
  }
1091
1112
 
1092
1113
  chatMessageStatus(sessionID: string) {
package/src/mcp.ts CHANGED
@@ -6,6 +6,7 @@ import { BgeEmbeddingModel } from "./embeddings";
6
6
  import { detectRepo } from "./git";
7
7
  import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
8
8
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
9
+ import { chatOpenTabs as chatOpenTabsProvider } from "./tab-state";
9
10
  import { actionDefs, type ActionDef } from "./commands";
10
11
  import { mcpToolName } from "./prompts";
11
12
  import { SidebandServer, sidebandSocketPath } from "./sideband";
@@ -136,7 +137,7 @@ export async function runMcpServer(): Promise<void> {
136
137
 
137
138
  // The DB must exist before identity detection: detectRepo consults the
138
139
  // repo_paths cache when the project directory is gone (deleted worktree).
139
- const db = new ThatchDB(dbPath);
140
+ const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
140
141
  const detected = await detectRepo(projectDir, repoPathCache(db));
141
142
  // unknown-to-global, matching the hook's mapping (bin/thatch): chat
142
143
  // registration must follow the same store the tools would read, and
@@ -173,6 +174,9 @@ export async function runMcpServer(): Promise<void> {
173
174
  defaultStore: repo,
174
175
  extractionPayloadProvider,
175
176
  drainExtractionQueue,
177
+ // Same-machine tab reader: the MCP server can read the local opencode
178
+ // state tree, so its roster renders the same open/recent split.
179
+ openTabs: chatOpenTabsProvider(() => db.chatTabClosures()),
176
180
  // Claude Code identity anchor, two model-proof layers:
177
181
  // 1. The hooks record (parent pid -> session) from payloads the model
178
182
  // cannot influence, and this server is a child of the same Claude
@@ -478,7 +482,7 @@ async function dispatch(
478
482
  arguments: [
479
483
  {
480
484
  name: "focus",
481
- description: "Optional scope for the action, e.g. which store to work on",
485
+ description: "Optional text for the action: a scope (for example, which store to work on), a review target (branch, PR, MR, or git range), or other context",
482
486
  required: false,
483
487
  },
484
488
  ],
@@ -20,7 +20,7 @@
20
20
  // peer).
21
21
 
22
22
  import { Plugin } from "@opencode/plugin/tui";
23
- import { isTabClosedEvent, isTabOpenedEvent, passesDirectoryGuard } from "../session-tab-shared";
23
+ import { isExitTabClosedEvent, isTabClosedEvent, isTabOpenedEvent, isToastEvent, passesDirectoryGuard } from "../session-tab-shared";
24
24
 
25
25
  export default Plugin.define({
26
26
  id: "jeffober-thatch-tui",
@@ -28,16 +28,19 @@ export default Plugin.define({
28
28
  const off = context.data.listen(({ details }) => {
29
29
  const windowDirectory = context.location?.directory ?? context.data.location.default().directory;
30
30
  if (!passesDirectoryGuard(details.location?.directory, windowDirectory)) return;
31
- if (isTabClosedEvent(details.type)) {
32
- // A tool-initiated close request: close the tab if THIS window has
33
- // one for the session. Windows that never opened it (and windows at
34
- // other directories, filtered above) return false - that is the
35
- // correct no-op, not an error. The close is reopenable by the user
36
- // (the strip's reopen stack), so this is tab-level, not session
37
- // deletion.
31
+ // Two close events, one TUI action: tab-closed (the session_tab_close
32
+ // tool's confirmed close - the pump also runs its death bookkeeping)
33
+ // and exit-tab-closed (the wrap-up exit's close - the runtime records
34
+ // the deaths itself before emitting, so the pump ignores that one).
35
+ if (isTabClosedEvent(details.type) || isExitTabClosedEvent(details.type)) {
36
+ // Close the tab if THIS window has one for the session. Windows that
37
+ // never opened it (and windows at other directories, filtered above)
38
+ // return false - that is the correct no-op, not an error. The close
39
+ // is reopenable by the user (the strip's reopen stack), so this is
40
+ // tab-level, not session deletion.
38
41
  const closeID = (details.data as { sessionID?: string }).sessionID;
39
42
  if (!closeID) {
40
- console.error("[thatch] tab-closed event without a sessionID");
43
+ console.error("[thatch] tab-close event without a sessionID");
41
44
  return;
42
45
  }
43
46
  if (!context.ui.tabs.close(closeID)) {
@@ -45,6 +48,23 @@ export default Plugin.define({
45
48
  }
46
49
  return;
47
50
  }
51
+ // The toast request: the server-side runtime's only TUI feedback
52
+ // channel (alerts, extraction metrics, chat registration, blocked
53
+ // wrap-ups). Best-effort like every toast - the event is ephemeral,
54
+ // so a TUI that is absent simply never shows it.
55
+ if (isToastEvent(details.type)) {
56
+ const toast = details.data as { message?: string; variant?: string; duration?: number };
57
+ if (typeof toast.message !== "string") {
58
+ console.error("[thatch] toast event without a message");
59
+ return;
60
+ }
61
+ context.ui.toast.show({
62
+ message: toast.message,
63
+ variant: (toast.variant as "info" | "success" | "warning" | "error") ?? "info",
64
+ duration: typeof toast.duration === "number" ? toast.duration : undefined,
65
+ });
66
+ return;
67
+ }
48
68
  if (!isTabOpenedEvent(details.type)) return;
49
69
  const sessionID = (details.data as { sessionID?: string }).sessionID;
50
70
  if (!sessionID) {