@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.
- package/README.md +48 -25
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +11 -0
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +25 -15
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-clarity.md +101 -0
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +22 -2
- package/bin/thatch +103 -33
- package/docs/dev/README.md +11 -8
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +23 -7
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +99 -27
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +41 -15
- package/docs/dev/features/prediction-engine.md +27 -1
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +11 -5
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +3 -3
- package/docs/dev/skills.md +12 -8
- package/docs/user/README.md +22 -14
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +28 -15
- package/docs/user/code-review.md +46 -4
- package/docs/user/commands.md +27 -7
- package/docs/user/cross-session-chat.md +48 -20
- package/docs/user/prediction-engine.md +12 -1
- package/docs/user/skills.md +9 -6
- package/package.json +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +56 -24
- package/src/commands.ts +109 -18
- package/src/db.ts +23 -2
- package/src/mcp.ts +6 -2
- package/src/opencode/tui-plugin.ts +29 -9
- package/src/opencode/v2.ts +247 -66
- package/src/prompts.ts +55 -6
- package/src/runtime.ts +122 -17
- package/src/scoring-engine.ts +40 -9
- package/src/session-tab-shared.ts +55 -4
- package/src/skills.ts +5 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +55 -22
package/docs/user/skills.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/capabilities.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
94
|
-
*
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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
|
|
355
|
-
*
|
|
356
|
-
* - "
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
|
|
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
|
-
|
|
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
|
|
383
|
-
* reachable -
|
|
384
|
-
* their next prompt)
|
|
385
|
-
*
|
|
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
|
-
|
|
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.
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
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
|
-
//
|
|
898
|
-
//
|
|
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,
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
|
75
|
-
*
|
|
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("
|
|
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("
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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-
|
|
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) {
|