@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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: thatch-review-context
3
- description: Gather project and feature context before a code review. Investigates PR descriptions, git history, ticket references, linked docs/tickets, docs, and memory to build a context brief that prevents false positives about intentionally deferred work. Use before dispatching review specialists or running a solo review.
3
+ description: Gather project and feature context before a code review. Investigates PR descriptions, git history, ticket references, linked docs/tickets, docs, memory, and related PRs (stack members, release trains) to build a context brief that prevents false positives about intentionally deferred work and about pieces delivered by sibling PRs. Use before dispatching review specialists or running a solo review.
4
4
  ---
5
5
 
6
6
  You are a review context builder. Your job is to gather project context that gives reviewers the background they need to avoid false positives about intentionally deferred work, incomplete features, and multi-ticket dependencies.
@@ -85,7 +85,7 @@ If the matching CLI tool (`gh`, `glab`, etc.) is not on PATH and you cannot call
85
85
  #### Detect the connected PR/MR
86
86
 
87
87
  Use the current branch name (without remote or merge-base prefix) to look up an open PR/MR:
88
- - **GitHub**: `gh pr view --head <branch> --json number,title,state,headRefName,baseRefName,headRefOid,baseRefOid`. Fallback: `gh api repos/{owner}/{repo}/pulls --jq ".[] | select(.head.ref==\"<branch>\")"`.
88
+ - **GitHub**: `gh pr view <branch> --json number,title,state,headRefName,baseRefName,headRefOid,baseRefOid`. Fallback: `gh api repos/{owner}/{repo}/pulls --jq ".[] | select(.head.ref==\"<branch>\")"`.
89
89
  - **GitLab**: `glab mr list --source-branch <branch>`.
90
90
  - **Bitbucket Cloud**: REST `https://api.bitbucket.org/2.0/repositories/<owner>/<slug>/pullrequests?q=source.branch.name="<branch>"` (no widely-adopted CLI; call via available HTTP tool).
91
91
  - **Azure DevOps**: `az repos pr list --source-branch <branch> --org <org-url> -r <project>`.
@@ -132,6 +132,46 @@ Directory structure conventions are often undocumented. They live in the repo's
132
132
 
133
133
  This gives the pedantic specialist the structural context without having to discover the pattern independently.
134
134
 
135
+ ### 11. Related PRs: stacks and release trains
136
+
137
+ A change is often one piece of a coordinated set of PRs. The other pieces are outside this diff, so specialists cannot see them. Two false-positive patterns result: a comment references work that a sibling PR delivers, or the change consumes a contract that a sibling PR defines. Discover the set and record what each piece does.
138
+
139
+ Two shapes to look for:
140
+ - **Stack**: PRs that merge into each other. This PR's base branch is another PR's head branch.
141
+ - **Release train**: PRs that all target the main branch but must merge or deploy in a specific order. The order lives in tracker relations or ticket DEPENDENCIES sections (the ticket section that names the change's blocking links; the links carry the ordering, the bullets carry only the rationale), not in the branch structure.
142
+
143
+ #### Discovery
144
+
145
+ - **Stack chaining**: if the connected PR's base branch is not the main branch, another PR probably owns that branch as its head. Find it (`gh pr view <base-branch>` on GitHub, `glab mr list --source-branch <base-branch>` on GitLab), then repeat from that PR until the chain reaches the main branch. Walk at most 5 links in each direction; record deeper stack members as `unexplored` register entries. Also look downward: `gh pr list --base <this-branch>` (GitHub) or `glab mr list --target-branch <this-branch>` (GitLab) finds PRs stacked on top of this one.
146
+ - **Tracker relations**: tickets surfaced by the earlier sources carry blockedBy/blocks relations, parent epics, and DEPENDENCIES sections that name the ordered set. Stack chaining cannot find a release train, because every PR in a train targets main; this route is the primary way trains surface. Map each ticket in the set to its PR/MR (`gh pr list --search "TICKET-ID"` on GitHub, or the tracker's attached-PR link); a ticket with no open PR stays a dependency recorded in the brief's Dependencies section, not a register entry.
147
+ - **Linked PRs** from sources 1 and 8. Deduplicate against the two methods above.
148
+
149
+ A sibling that already merged to main is also covered by the staleness check (source 3). Record it in the register anyway, for context.
150
+
151
+ #### Build the sibling register
152
+
153
+ For each sibling PR/MR:
154
+ - PR/MR number, title, state (open/merged/closed/draft)
155
+ - Its base branch, and for stacks its position in the chain
156
+ - One line: what it implements, from its description and diff stat
157
+
158
+ This field list is canonical: the brief section below and the synthesizer's Cross-PR notes appendix render the register as built here. An `unexplored` entry (a stack member beyond the walk cap) carries the number, title, state, base branch, and chain position, but no what-it-implements line and no consistency verdicts; the sibling's description and diff were not read.
159
+
160
+ #### Targeted consistency verification
161
+
162
+ Read every sibling's description in full. Open a sibling's diff only to verify the specific contracts, symbols, and references this change consumes or mentions. Never run a full second review of a sibling. Checks:
163
+
164
+ 1. **Contract check**: an API, type, schema, config key, or flag this change consumes from a sibling matches the sibling's actual implementation at its head SHA.
165
+ 2. **Reference check**: a comment or doc in this change references a thing X. X exists in this change or in a sibling. If neither delivers X, the reference is dangling.
166
+ 3. **Ordering check**: the change or its ticket claims a merge or release order for the set. Verify the claim is stated and consistent with the stack chain or the tracker relations.
167
+ 4. **Overlap check**: two PRs in the set implement the same thing differently.
168
+
169
+ Tag each check that applied: `consistent`, `inconsistent` (with the evidence), or `unclear`.
170
+
171
+ #### Early-out
172
+
173
+ If the discovery routes above surfaced no related PR/MR and no coordinated ticket set, state `Standalone change — no sibling register` and skip this source. A coordinated ticket set with no open PRs is not standalone: record it under Dependencies. Most personal projects land here; the source costs one look.
174
+
135
175
  ## Context brief format
136
176
 
137
177
  Produce a structured brief:
@@ -154,6 +194,7 @@ List each deferred piece:
154
194
  - What other tickets or PRs this depends on (must merge first)
155
195
  - What tickets or PRs depend on this (are blocked by it)
156
196
  - Whether base branches have landed (for stacked PRs)
197
+ - **Release order**: when a ticket or the PR description claims a merge or deploy order for a set of changes, record the claimed order and where it is claimed (which ticket's DEPENDENCIES section, which tracker relation, or which PR description)
157
198
  - **Staleness**: whether main has moved past the merge-base since the branch point. When the staleness check (source 3) found commits touching this change's paths, list them here — the synthesizer re-verifies behavioral findings against current main before classifying them
158
199
 
159
200
  ### Relevant constraints
@@ -175,6 +216,18 @@ If context gathering found a connected PR/MR but no prior reviews by other users
175
216
 
176
217
  If context gathering found no connected PR/MR (local-branch review), state `Local-branch review — no prior-comment fetch`. This is not a gap; the existing review procedure applies.
177
218
 
219
+ ### Sibling PR context (only when related PRs were detected)
220
+
221
+ List the sibling register as built above (one entry per sibling):
222
+ - PR/MR number, title, state, base branch, and for stacks the chain position
223
+ - What it implements (one line)
224
+ - Consistency verdicts from the targeted checks (`consistent` / `inconsistent` with evidence / `unclear`), only for the checks that applied
225
+ - An `unexplored` entry renders its fields only; the missing what-it-implements line and verdicts record the walk cap, not a gap
226
+
227
+ Whether base branches have landed is recorded under Dependencies above, not here.
228
+
229
+ If discovery found no related PRs and no coordinated ticket set, state `Standalone change — no sibling register`. This is not a gap; the change is not part of a coordinated set. A coordinated ticket set with no open PRs is not standalone: its claimed order is recorded under Dependencies.
230
+
178
231
  ## If context is sparse
179
232
 
180
233
  Not every PR will have rich context. If you find minimal context:
@@ -30,7 +30,7 @@ Run `git fetch origin` (NOT `git pull`). Inspect `git remote -v` to recognize th
30
30
  - `dev.azure.com` → Azure DevOps
31
31
 
32
32
  Probe for an open PR/MR on the current branch:
33
- - **GitHub**: `gh pr view --head <branch> --json number,title,headRefOid,baseRefOid`
33
+ - **GitHub**: `gh pr view <branch> --json number,title,headRefOid,baseRefOid,baseRefName`
34
34
  - **GitLab**: `glab mr list --source-branch <branch>`
35
35
 
36
36
  If no PR/MR is found or no VCS CLI is available, you cannot re-check. Report this and stop.
@@ -79,7 +79,7 @@ Compare `LAST_ROUND_SHA` to `CURRENT_SHA`:
79
79
 
80
80
  **Force-push check**: run `git merge-base --is-ancestor <LAST_ROUND_SHA> <CURRENT_SHA>`. If it fails, the author rewrote history (force-pushed). Line numbers may have drifted. You will need to locate each finding's equivalent location in the new HEAD by matching surrounding context, not by line number.
81
81
 
82
- **Merge-from-main check**: if the author merged main into the branch between rounds, plain `git log <LAST_ROUND_SHA>..<CURRENT_SHA>` and `git diff <LAST_ROUND_SHA> <CURRENT_SHA>` both pull in every main commit, burying the author's actual response commits. To see only the author's commits, run `git log <LAST_ROUND_SHA>..<CURRENT_SHA> --not origin/main` after `git fetch origin`. Then read each remaining commit's diff with `git show <sha>`. Do not assume a large diff stat means the author rewrote the PR. Check for a merge commit first.
82
+ **Merge-from-base check**: if the author merged the default branch into the branch between rounds — or, on a stacked PR, merged or rebased onto the PR's base branch — plain `git log <LAST_ROUND_SHA>..<CURRENT_SHA>` and `git diff <LAST_ROUND_SHA> <CURRENT_SHA>` both pull in every base commit, burying the author's actual response commits. To see only the author's commits, run `git log <LAST_ROUND_SHA>..<CURRENT_SHA> --not origin/<default-branch>` after `git fetch origin` (usually main or master). On a stacked PR (Step 1's probe returned a base branch that is not the default branch — `baseRefName` on GitHub, the MR's target branch on GitLab), also fetch the base branch and exclude it too: `git fetch origin <base-branch>`, then `git log <LAST_ROUND_SHA>..<CURRENT_SHA> --not origin/<default-branch> origin/<base-branch>`. Then read each remaining commit's diff with `git show <sha>`. Do not assume a large diff stat means the author rewrote the PR. Check for a merge commit first.
83
83
 
84
84
  ### Read author responses
85
85
 
@@ -152,7 +152,7 @@ Produce a structured report for the user:
152
152
  - PR/MR number, title
153
153
  - Last-reviewed SHA, current SHA
154
154
  - Whether the author pushed changes, responded to comments, or both
155
- - Force-push or merge-from-main detected (if applicable)
155
+ - Force-push or merge-from-base detected (if applicable)
156
156
 
157
157
  ### Finding verification
158
158
 
@@ -275,7 +275,7 @@ After all findings have been dealt with (adequately addressed and replied to, or
275
275
  ### How to offer
276
276
 
277
277
  Present the offer as a choice:
278
- 1. **Re-run the full structured review**: load `thatch-code-review` on the current HEAD. It will dispatch all eight specialists on the merge-base..HEAD range. The existing follow-up detection will cross-reference your prior comments. On Claude Code or Cursor (no sub-agent support), run each specialist skill in sequence, then run `thatch-review-synthesizer` to aggregate.
278
+ 1. **Re-run the full structured review**: load `thatch-code-review` on the current HEAD. It will dispatch all ten specialists on the merge-base..HEAD range. The existing follow-up detection will cross-reference your prior comments. On Claude Code or Cursor (no sub-agent support), run each specialist skill in sequence, then run `thatch-review-synthesizer` to aggregate.
279
279
  2. **End the re-review here**: the user confirmed their concerns were addressed (or decided how to handle the unaddressed ones) and does not want another full round.
280
280
 
281
281
  The offer is just an offer. The user may decline. They may have only wanted to confirm their concerns were addressed.
@@ -80,7 +80,7 @@ Identify the PR and VCS host:
80
80
  recognize the VCS host: `github.com` -> GitHub (`gh`), `gitlab.*` ->
81
81
  GitLab (`glab`), `bitbucket.org` -> Bitbucket Cloud, `dev.azure.com` ->
82
82
  Azure DevOps.
83
- - Probe for an open PR on the current branch: `gh pr view --head <branch>
83
+ - Probe for an open PR on the current branch: `gh pr view <branch>
84
84
  --json number,title,headRefOid` (GitHub) or equivalent.
85
85
 
86
86
  Fetch ALL review comments:
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: thatch-review-synthesizer
3
- description: Verify and synthesize findings from multiple review specialist skills into a single deduplicated, severity-grouped report. Use after running one or more thatch-review-* specialist skills.
3
+ description: Verify and synthesize findings from multiple review specialist skills into a single deduplicated, severity-grouped report. Cross-references prior review comments and sibling PR registers when provided. Use after running one or more thatch-review-* specialist skills.
4
4
  ---
5
5
 
6
- You are a review synthesizer. You have received findings from one or more review specialists (pedantic, acceptance, state-flow, economy, no-slop, breadcrumbs, mark-and-sweep, highlights, technique). Your job is to verify their citations against the actual code, deduplicate across specialists, and produce a single, coherent final report.
6
+ You are a review synthesizer. You have received findings from one or more review specialists (pedantic, acceptance, state-flow, economy, no-slop, clarity, breadcrumbs, mark-and-sweep, highlights, technique). Your job is to verify their citations against the actual code, deduplicate across specialists, and produce a single, coherent final report.
7
7
 
8
8
  ## Static analysis only
9
9
  You review code by reading it. Do NOT run tests, linters, compilers, or any build commands.
@@ -95,6 +95,14 @@ New findings that match a prior comment tagged `addressed` (preliminary) are sti
95
95
 
96
96
  When the VCS resolve flag (GitLab `resolved`, GitHub `isResolved`) disagrees with the code-state verdict (e.g., thread marked resolved in the UI but the bug persists), trust the code state and call out the disagreement in the appendix entry.
97
97
 
98
+ ## Cross-reference against the sibling PR register
99
+
100
+ If your input includes a **sibling register** (built via `thatch-review-context` source #11 — related PRs detected as stack members or release-train siblings), apply it during classification, before finalizing any finding whose claim crosses PR boundaries.
101
+
102
+ - A finding claiming a referenced thing is **missing** ("comments reference X but nothing implements X", "flagged as unused or undefined") is REJECTED — or moved to the Cross-PR notes appendix as a stack note — when the register shows a sibling PR delivers it. Cite the sibling PR and its register entry as the reason.
103
+ - A finding that this change **consumes a contract a sibling does not actually provide** (shape mismatch, renamed symbol, different semantics at the sibling's head) is CONFIRMED, with the `Provenance` field citing the sibling PR.
104
+ - A problem discovered **in the sibling itself** is not a finding of this review by default. Record it in the Cross-PR notes appendix so it can be raised in the sibling's own review. Escalate it to a finding of THIS review only when the inconsistency affects this change's safety or correctness, or when flagging it now heads off a bug the author will hit in imminent follow-up work in the same project.
105
+
98
106
  ## Severity calibration
99
107
 
100
108
  Assign final severity based on YOUR verification:
@@ -323,6 +331,18 @@ List every entry from the prior-comments register here. For each entry, produce
323
331
 
324
332
  If no prior-comments register was provided (local-branch review or first round on the PR/MR), state `None — no prior review comments to cross-reference` and move on.
325
333
 
334
+ ### Cross-PR notes (appendix — only when a sibling register was provided)
335
+
336
+ List every sibling in the register here. For each sibling, produce a one-block record:
337
+ 1. **PR/MR number, title, state, base branch**, and for stacks its position in the chain
338
+ 2. **What it implements** (one line from the register)
339
+ 3. **Consistency verdicts**: `consistent` / `inconsistent` (with the evidence) / `unclear`, per targeted check that applied
340
+ 4. **Notes for the sibling's own review**: problems discovered in the sibling that belong in its review, not this one
341
+
342
+ An `unexplored` entry renders item 1's fields and states `unexplored — not read` for items 2 and 3.
343
+
344
+ Omit this section entirely when no sibling register was provided (standalone change or local-branch review).
345
+
326
346
  ### Coverage gaps
327
347
 
328
348
  Note which files or areas were NOT covered by any specialist.
package/bin/thatch CHANGED
@@ -13,17 +13,30 @@ import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscript
13
13
  import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, sortChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
14
14
  import { chatEnabled, loadConfig } from "../src/config";
15
15
  import { chatNotificationNudge } from "../src/prompts";
16
+ import { chatOpenTabs as chatOpenTabsProvider } from "../src/tab-state";
16
17
  import { createDebugLog } from "../src/debug";
17
18
 
18
19
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
19
20
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
20
- const db = new ThatchDB(dbPath);
21
21
 
22
- const [command, ...args] = process.argv.slice(2);
22
+ const argv = process.argv.slice(2);
23
23
 
24
- function usage(): never {
24
+ // Help is checked before the DB is opened or any subcommand runs, and
25
+ // against the WHOLE argument list - not just the leading flags - so
26
+ // `thatch chat list -h` and `thatch -h chat list` print usage instead of
27
+ // executing. Asking for help is not an error: exit 0, where a bad
28
+ // command still exits 1.
29
+ if (argv.includes("-h") || argv.includes("--help")) usage(0);
30
+
31
+ const [command, ...args] = argv;
32
+
33
+ const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
34
+
35
+ function usage(exitCode = 1): never {
25
36
  console.error(`USAGE
26
37
  thatch --version, -v Print version and exit
38
+ thatch -h, --help Print this usage and exit (the flag
39
+ works anywhere in the command line)
27
40
  thatch stores List all stores
28
41
  thatch list [store] List memory labels in a store
29
42
  thatch show <label> [store] Display a memory by label
@@ -51,13 +64,16 @@ function usage(): never {
51
64
  Full conversation as OpenAI chat-completions JSONL
52
65
  thatch session search <query> [--session <id>] [--regex] [--limit N]
53
66
  JSONL of matching parts across sessions (decoded content)
54
- thatch chat list [--stale N|all] Registered chat sessions. Default shows
55
- only active (fresh-heartbeat) sessions:
56
- name, session id, age, status, project,
57
- topic. --stale adds the stale section:
58
- N bounds it to sessions seen in the
59
- last N days (default 1); all shows
60
- every stale session.
67
+ thatch chat list [--recent] [--stale N|all]
68
+ Registered chat sessions. Default shows
69
+ only open sessions (plus MCP rows, which
70
+ read mail at their next prompt): name,
71
+ session id, age, status, project, topic.
72
+ Tab-less fresh sessions collapse into a
73
+ hidden-count note; --recent shows them.
74
+ --stale adds the stale section: N bounds
75
+ it to sessions seen in the last N days
76
+ (default 1); all shows every stale one.
61
77
  thatch chat tail [--once] [--limit N|all] [--match RE]
62
78
  [--from NAME] [--to NAME] [--since DT] [--until DT]
63
79
  Follow cross-session chat as a JSONL
@@ -74,7 +90,7 @@ function usage(): never {
74
90
 
75
91
  Stores default to the repo detected from git remote. Use "global" for the
76
92
  global store. Use "all" with search to search project + global together.`);
77
- process.exit(1);
93
+ process.exit(exitCode);
78
94
  }
79
95
 
80
96
  async function defaultStore(): Promise<string> {
@@ -424,27 +440,42 @@ async function sessionCommand(rest: string[]): Promise<void> {
424
440
  const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "TOPIC"];
425
441
 
426
442
  /**
427
- * Renders the `chat list` roster as two sections - Active and Stale - each
428
- * an aligned-column table under a header row. Section headers are bold,
429
- * the stale explainer is muted italic; rows use green names, dim age,
430
- * green/red status, and a muted italic topic.
443
+ * Renders the `chat list` roster as three sections - Active, Recent, and
444
+ * Stale - each an aligned-column table under a header row. Section headers
445
+ * are bold, the Recent and Stale explainers are muted italic; rows use
446
+ * green names, dim age, green/yellow/red status, and a muted italic topic.
447
+ * The STATUS column carries the liveness verdict: open (tab open now),
448
+ * recent (host alive, no open tab), active/idle (MCP rows), stale (host
449
+ * presumed gone).
450
+ * showRecent = false (the CLI default without `--recent`) collapses the
451
+ * Recent section into a muted hidden-count note pointing at the flag.
452
+ * Unlike the Stale hide - which the caller does by pre-filtering rows
453
+ * before this function, dropping them silently - the recent hide needs
454
+ * the rows in hand to count them, so it happens here as a note. An
455
+ * all-recent roster therefore renders the note alone, with no section
456
+ * header: there is no Recent section on the page to head, while the
457
+ * stale hidden note rides under a header because its section IS
458
+ * displayed;
431
459
  * staleCapMs bounds how old a stale row is displayed (null = no bound);
432
460
  * rows past the cap collapse into a muted hidden-count note so the reader
433
461
  * knows rows exist that the window left out.
462
+ * now is the render's single clock sample: the split, every per-row
463
+ * verdict, and the caller's pre-filter must agree, or a row aging past
464
+ * the staleness boundary mid-render could land in two sections at once.
434
465
  * Color is applied only when stdout is an interactive terminal (and
435
466
  * NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
436
467
  * clean plain text.
437
468
  */
438
- function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null): string {
469
+ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null, openTabs?: ReadonlySet<string>, showRecent = true, now = Date.now()): string {
439
470
  const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
440
471
  const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
441
- const { active, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), Date.now(), staleCapMs);
472
+ const { active, recent, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), now, staleCapMs, openTabs);
442
473
  const renderTable = (rows: ChatSessionRow[]) => {
443
474
  const cells = (r: ChatSessionRow) => [
444
475
  r.name,
445
476
  r.session_id,
446
477
  humanAge(r.last_seen),
447
- chatLiveness(r),
478
+ chatLiveness(r, now, openTabs),
448
479
  r.project ?? "unknown",
449
480
  r.topic ?? "",
450
481
  ];
@@ -455,11 +486,14 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
455
486
  const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
456
487
  const body = rows.map((r) => {
457
488
  const [name, sessionID, age, status, project, topic] = align(cells(r));
489
+ // Green = present or reachable (open/active), yellow = alive host
490
+ // without a tab (recent, wake not guaranteed), red = stale.
491
+ const statusColor = status === "stale" ? "31" : status === "recent" ? "33" : "32";
458
492
  return [
459
493
  paint("32", name),
460
494
  paint("2", sessionID),
461
495
  paint("2", age),
462
- paint(status === "stale" ? "31" : "32", status),
496
+ paint(statusColor, status),
463
497
  project,
464
498
  topic ? paint("3;90", topic) : "",
465
499
  ].join(" ").trimEnd();
@@ -470,6 +504,20 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
470
504
  if (active.length > 0) {
471
505
  parts.push([paint("1", "# Active Sessions"), renderTable(active)].join("\n"));
472
506
  }
507
+ if (recent.length > 0) {
508
+ if (showRecent) {
509
+ const explainer = "These sessions have a fresh heartbeat but no open tab, so a wake may not reach them until their tab reopens; headless subordinates may still read mail on their own turns.";
510
+ parts.push([paint("1", "# Recent Sessions"), paint("3;90", explainer), renderTable(recent)].join("\n"));
511
+ } else {
512
+ // Bare note, no section header: there is no Recent section on the
513
+ // page, so a header would announce a section the view does not
514
+ // render. The stale hidden note rides under a header because with
515
+ // --stale its section IS rendered.
516
+ parts.push(
517
+ paint("3;90", `${recent.length} recent session${recent.length === 1 ? "" : "s"} hidden (alive, no open tab); --recent to show`),
518
+ );
519
+ }
520
+ }
473
521
  if (stale.length > 0 || staleHidden > 0) {
474
522
  const lines = [paint("1", "# Stale Sessions")];
475
523
  if (stale.length > 0) {
@@ -499,13 +547,24 @@ async function chatCommand(rest: string[]): Promise<void> {
499
547
  // sessions from burying the fresh signal. --stale all restores the
500
548
  // unbounded view. A bad value dies loudly rather than silently
501
549
  // shrinking the roster.
502
- // Default: active sessions only. --stale [N|all] opts into the
503
- // stale section - bare --stale keeps the one-day display cap, N
504
- // bounds it, all removes it. The value is parsed by hand (not
505
- // flagValue) because bare --stale is meaningful.
550
+ // Default: open sessions only (plus MCP rows, which read mail at
551
+ // their next prompt - "open" is meaningless for them). --recent
552
+ // opts into the Recent section (alive, no open tab). The two hidden
553
+ // defaults work differently: --recent's leaves a muted count note
554
+ // in the renderer (it needs the rows in hand to count them);
555
+ // --stale's drops the rows silently in the pre-filter below, and
556
+ // only the empty-roster message points at the flag. Unlike --stale
557
+ // it takes no window value: recent rows are bounded by the heartbeat
558
+ // window itself, so there is nothing to cap. The value is parsed by
559
+ // hand (not flagValue) because bare --stale is meaningful.
560
+ const showRecent = restArgs.includes("--recent");
506
561
  const showStale = restArgs.includes("--stale");
507
562
  const staleIdx = restArgs.indexOf("--stale");
508
- const staleNext = showStale ? restArgs[staleIdx + 1] : undefined;
563
+ const rawNext = showStale ? restArgs[staleIdx + 1] : undefined;
564
+ // A token starting with "--" is a sibling flag (e.g. "--stale
565
+ // --recent"), not --stale's value: --stale stays bare instead of
566
+ // eating the flag.
567
+ const staleNext = rawNext !== undefined && rawNext.startsWith("--") ? undefined : rawNext;
509
568
  let staleCapMs: number | null = 24 * 60 * 60 * 1000;
510
569
  if (staleNext !== undefined) {
511
570
  if (staleNext === "all") staleCapMs = null;
@@ -514,17 +573,28 @@ async function chatCommand(rest: string[]): Promise<void> {
514
573
  process.exit(1);
515
574
  } else staleCapMs = Number(staleNext) * 24 * 60 * 60 * 1000;
516
575
  }
517
- let sessions = db.listChatSessions();
518
- if (!showStale) sessions = sessions.filter((r) => chatLiveness(r) === "fresh");
576
+ // The open-tab set is read once per invocation at render time: rows
577
+ // with a tab render "open", fresh rows without one "recent".
578
+ const openTabs = chatOpenTabsProvider(() => db.chatTabClosures())();
579
+ // One clock sample per render: the pre-filter, the split, and every
580
+ // per-row verdict must agree, or a row aging past the staleness
581
+ // boundary mid-render could land in two sections at once.
582
+ const now = Date.now();
583
+ const registered = db.listChatSessions();
584
+ // Stale rows are hidden here (not in the renderer) so the
585
+ // emptiness check can tell an empty directory from a stale-only
586
+ // one; recent rows are left in because formatChatRoster needs them
587
+ // to render the hidden-count note.
588
+ const sessions = registered.filter((r) => showStale || chatLiveness(r, now, openTabs) !== "stale");
519
589
  if (sessions.length === 0) {
520
590
  console.log(
521
- showStale
591
+ registered.length === 0
522
592
  ? "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register."
523
593
  : "No active sessions. (Stale sessions are hidden; run with --stale to include them.)",
524
594
  );
525
595
  break;
526
596
  }
527
- console.log(formatChatRoster(sessions, showStale ? staleCapMs : null));
597
+ console.log(formatChatRoster(sessions, showStale ? staleCapMs : null, openTabs, showRecent, now));
528
598
  break;
529
599
  }
530
600
 
@@ -1050,7 +1120,7 @@ switch (command) {
1050
1120
  const sessionID = data.session_id ?? data.conversation_id;
1051
1121
 
1052
1122
  const { peekQueue, getMissedCount, incrementMissedCount } = await import("../src/extract-queue");
1053
- const { extractionNudge, claudeWriteNudge, claudeRecallNudge, predictionNudge, behaviorNudge } = await import("../src/prompts");
1123
+ const { extractionNudge, claudeWriteNudge, claudeRecallNudge, predictionNudge, behaviorNudge, mcpToolName } = await import("../src/prompts");
1054
1124
  const { detectSkew, readNpmCacheForUpdate } = await import("../src/version-check");
1055
1125
 
1056
1126
  let repo = "unknown";
@@ -1144,10 +1214,10 @@ switch (command) {
1144
1214
  parts.push(claudeRecallNudge(matches));
1145
1215
  }
1146
1216
  if (predictions && predictions.length > 0) {
1147
- parts.push(predictionNudge(predictions));
1217
+ parts.push(predictionNudge(predictions, mcpToolName));
1148
1218
  }
1149
1219
  if (behaviors && behaviors.length > 0) {
1150
- parts.push(behaviorNudge(behaviors));
1220
+ parts.push(behaviorNudge(behaviors, mcpToolName));
1151
1221
  }
1152
1222
 
1153
1223
  if (parts.length > 0) {
@@ -1296,13 +1366,13 @@ switch (command) {
1296
1366
  if (repo === "unknown") repo = "global";
1297
1367
 
1298
1368
  const { sidebandPredictions, sidebandSocketPath } = await import("../src/sideband");
1299
- const { predictionNudge } = await import("../src/prompts");
1369
+ const { predictionNudge, mcpToolName } = await import("../src/prompts");
1300
1370
  const sockPath = sidebandSocketPath(dbPath);
1301
1371
  const threshold = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.60");
1302
1372
  const predictions = await sidebandPredictions(sockPath, promptText, [repo, "global"], threshold, 5);
1303
1373
 
1304
1374
  if (predictions && predictions.length > 0) {
1305
- const output = predictionNudge(predictions);
1375
+ const output = predictionNudge(predictions, mcpToolName);
1306
1376
  if (isJson) console.log(JSON.stringify({ additional_context: output }));
1307
1377
  else console.log(output);
1308
1378
  } else {
@@ -27,7 +27,7 @@ Shared core
27
27
  ├── git.ts → detect repo identity (store name) + worktree-deletion recovery (repo_paths cache)
28
28
  ├── hygiene.ts → hygiene report (pending dedups, stale, orphaned branches)
29
29
  ├── prompts.ts → system prompt, compaction, reminders, recall/prediction/behavior nudges, prompt cores, CLAUDE.md instructions
30
- ├── commands.ts → /thatch slash-command registry: actions from prompt cores + wrap-ups, per-host sync
30
+ ├── commands.ts → /thatch slash-command registry: actions (prompt cores + self-contained skill couchings) + wrap-ups, per-host sync
31
31
  ├── sideband.ts → Unix socket server + client: warm-model semantic match for hook processes
32
32
  ├── skills.ts → SKILL.md content + installer (shared + opencode-only arrays)
33
33
  ├── scoring-engine.ts → generic four-table scoring base (shared by prediction + behavior engines)
@@ -91,7 +91,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
91
91
  | `index.ts` | Dual-shape OpenCode plugin entry: the merged default export (`{ id, setup, server }`) loads on opencode v1 (reads `default.server`) and v2 (reads `default.setup`, strips excess keys). Lazy-imports the host adapter so each host's SDK resolves only under its own runtime. |
92
92
  | `opencode/v1.ts` + `opencode/v2.ts` | Host adapters. v1 builds HostCapabilities from the PluginInput client and returns the hooks object; v2 registers through the promise-context domains (ToolEditor, session.hook, event.subscribe) and degrades the surfaces v2 lacks. v2 also wires the session-tab host flow (create -> move -> chat-register -> rpc emit -> prompt) through the `CoreContext.sessionTabHost` seam. |
93
93
  | `opencode/tui-plugin.ts` | The v2 TUI CLI plugin (the package's `./tui` entrypoint): listens for `rpc.thatch-tabs.tab-opened` and `rpc.thatch-tabs.tab-closed` events and opens/closes the subordinate session's tab via `ui.tabs.open`/`ui.tabs.close`. See [features/session-tabs.md](features/session-tabs.md). |
94
- | `capabilities.ts` | The `HostCapabilities` seam: the host operations the shared runtime needs (session create/prompt/get/list/messages/delete, toast, compaction trigger, app exit). `capabilitiesFromClient` bridges the v1 SDK client. |
94
+ | `capabilities.ts` | The `HostCapabilities` seam: the host operations the shared runtime needs (session create/prompt/get/list/messages/delete, toast, compaction trigger, session close-out - app exit on v1, the session's own tab on v2). `capabilitiesFromClient` bridges the v1 SDK client. |
95
95
  | `runtime.ts` | Shared plugin runtime: wires DB, model, extraction; the nudge tiers, system prompt injection, session event handling, child-session bookkeeping, wrap-up resolution. Host-agnostic: consumed by both adapters. |
96
96
  | `os-args.ts` | Pure argv / OS-command-line helpers for startup session resolution (`-s`/`-c`). SDK-free. |
97
97
  | `setup.ts` | `thatch setup --claude` / `--cursor` installer. Writes MCP config (`.mcp.json` / `.cursor/mcp.json`), appends to CLAUDE.md / AGENTS.md (idempotent), installs hooks in settings.json / hooks.json, installs skills. |
@@ -108,10 +108,11 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
108
108
  | `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Three sources: GitHub PRs and branches via `gh api` (injected `GhRunner`), and local shell commands via `bash -c` (injected `CommandRunner`) run as condition variables - only the exit code is read, never the command's output. Diffs against last-seen state, queues events (pointer data plus machine status like check conclusions and exit codes, never external content) per session, and delivers them through a plugin-supplied callback when the session is idle. The `pollSeconds` getter surfaces the poll cadence to tool output and notifications. Process-lifetime state by design - no SQLite. |
109
109
  | `chat.ts` | Cross-session chat between opencode sessions on one machine (opencode-only). `ChatStore` holds the shared directory and inbox in SQLite so any process can read/write them; `ChatPoller` heartbeats hosted sessions and delivers wake prompts through an injected callback when the recipient is idle - only the recipient's host process delivers, so there is no cross-process double-delivery. Heartbeat staleness flags crashed sessions; a per-recipient nudge rate cap is the anti-loop hard brake. See [features/cross-session-chat.md](features/cross-session-chat.md). |
110
110
  | `chat-names.ts` | The static display-name pool for the chat directory: whimsical geek-culture names in fnord's Nomenclater style, baked in so name assignment never costs a model call. The pool is the fallback base for registrations with no usable session title. |
111
+ | `tab-state.ts` | Read-only open-tab reader for the chat roster: globs every opencode channel's `tabs.json` (global + cwd scopes), subtracts fresh confirmed tab closes, and composes the provider the roster surfaces and broadcast targeting consume. Degrades to an empty set - never a false OPEN. See [features/cross-session-chat.md](features/cross-session-chat.md). |
111
112
  | `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
112
113
  | `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), watcher notification (`watcherNotificationNudge`), version warning (`versionWarningNudge`), chat wake notification (`chatNotificationNudge`), chat transcript echo text and echo recognizer (`chatEchoText` / `isChatEchoParts`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. Also the prompt cores (`defragCore`, `hygieneCore`, `reflectCore`, `extractionCore`) - host-agnostic behavior instruction bodies shared by nudges and `/thatch` commands, parameterized by a `ToolNamer` for per-host tool spellings. See [features/commands.md](features/commands.md). |
113
- | `commands.ts` | Slash-command registry and sync. `actionDefs(tool)` renders the on-demand actions (defrag, extract, hygiene, reflect) from the prompt cores; the wrap-up commands (compact, exit) are bespoke greenlight-gated templates. Renderers produce full command files for opencode (`installOpencodeCommands`, synced by the plugin at init) and Claude Code (`installClaudeCommands`, synced by setup); `compilePrompts` in `mcp.ts` exposes the same actions as MCP prompts for Cursor. A parity-guard test pins the three sets to the documented exclusions. See [features/commands.md](features/commands.md). |
114
- | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (fact-extractor, dedup-classifier, project-primer, the review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, plan-refinement, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
114
+ | `commands.ts` | Slash-command registry and sync. `actionDefs(tool)` renders the on-demand actions: the nudge-backed ones (defrag, extract, hygiene, reflect) from the prompt cores, and the self-contained ones (refine, whois, and the review-mode couchings peer-review, self-review) inline in the registry; the wrap-up commands (compact, exit) are bespoke greenlight-gated templates. Renderers produce full command files for opencode (`installOpencodeCommands`, synced by the plugin at init) and Claude Code (`installClaudeCommands`, synced by setup); `compilePrompts` in `mcp.ts` exposes the same actions as MCP prompts for Cursor. A parity-guard test pins the three sets to the documented exclusions. See [features/commands.md](features/commands.md). |
115
+ | `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (everything that works on all three hosts: the memory workflows, the review specialists — whose roster comes from `REVIEW_SPECIALISTS` in prompts.ts — plus the synthesizer, context, followup, and response skills, the walkthrough and archaeology skills, the refinement family, the writing skills, and verification/export) and `OPENCODE_ONLY_SKILLS` (the code-review coordinator and thatch-coordination — they need sub-agent support, so they are not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
115
116
  | `scoring-engine.ts` | Generic four-table scoring engine with Bayesian confidence. Shared base for prediction and behavior engines — each wraps it with table-specific names. |
116
117
  | `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
117
118
  | `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
@@ -129,7 +130,7 @@ methods. The v1 hook shapes:
129
130
  |------|-------------|
130
131
  | `experimental.chat.system.transform` | Appends the thatch system prompt (store names, usage rules). |
131
132
  | `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
132
- | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
133
+ | `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the host action (compact: v1 `session_compact` via `executeCommand`, v2 `session.compact` on the promise domain; exit: record the session's watcher deaths, unregister it from chat, then v1 `app.exit` via `publish` or v2 the exit-tab-closed rpc event closing the session's own tab). See [features/session-lifecycle.md](features/session-lifecycle.md). |
133
134
  | `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
134
135
  | `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
135
136
  | `chat.message` | Recall nudge: embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges fire independently in separate try/catch blocks with separate synthetic parts. There is deliberately NO extraction nudge here - extraction is plugin-driven at session idle (see the extraction feature doc); the old model-driven handshake raced its own state machine and was removed. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation), and for task-dispatched sub-agent sessions (`childToParent` without `extractionChildren`) - their tool lists may exclude the thatch tools and their work is driven by the dispatch prompt. |
@@ -268,14 +269,16 @@ extraction cycle
268
269
  → MCP path (Claude Code/Cursor): unchanged — no SDK client, no child
269
270
  sessions; extract-queue.ts + flush-tools drives the nudge via hooks
270
271
 
271
- toast notifications (opencode-only, TUI-rendered)
272
+ toast notifications (opencode-only, TUI-rendered; the bridge's toast
273
+ event -> the TUI plugin's ui.toast.show on v2)
272
274
  → client.tui.showToast — best-effort, silently ignored if TUI not
273
275
  connected (headless mode); model-invisible (goes to the user only,
274
276
  not the conversation history — the inverse of synthetic nudge parts,
275
277
  which are model-visible but TUI-hidden)
276
278
  → extraction metrics: `[thatch] new: N, updated: M, deleted: K` (success
277
- variant, 4s) — fires when an extraction child goes idle; no-save runs
278
- show `[thatch] extraction complete — nothing to save` (info variant)
279
+ variant, 4s) — fires when an extraction child goes idle, but ONLY when
280
+ memories were actually written; no-save runs show nothing (no
281
+ notification fatigue)
279
282
  → recall matches: `[thatch] recalled N memories` (info variant, 3s) —
280
283
  fires when chat.message matches stored memories
281
284
  → prediction matches: `[thatch] N predictions surfaced` (info variant,
@@ -23,7 +23,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
23
23
  | [behavior-engine.md](behavior-engine.md) | Behavior engine (LLM self-discipline rules, ham/spam, auto-fire) |
24
24
  | [hygiene.md](hygiene.md) | Hygiene system (duplicate, stale, orphaned branch signals) |
25
25
  | [deduplication.md](deduplication.md) | Deduplication system (find pairs, verdict lifecycle) |
26
- | [commands.md](commands.md) | Slash commands (on-demand actions from shared prompt cores, wrap-ups, host sync) |
26
+ | [commands.md](commands.md) | Slash commands (on-demand actions: prompt cores and skill couchings, wrap-ups, host sync) |
27
27
  | [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
28
28
  | [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
29
29
  | [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking, wrap-up commands) |
@@ -113,6 +113,8 @@ Parameters: `behavior` (statement, semantic match at cosine >= 0.85),
113
113
  - Injects `behaviorNudge(items)` as a separate synthetic text part with a
114
114
  `[thatch] Situational behaviors` header.
115
115
  - 0-evidence behaviors use "consider"; behaviors with evidence use "do".
116
+ - The block ends with a snapshot footer (same as the prediction nudge): the
117
+ labels are a submission-time snapshot, not a live view.
116
118
  - Independent try/catch — a behavior failure does not block recall or
117
119
  prediction.
118
120
  - Toast: `[thatch] N behaviors surfaced`.
@@ -250,6 +252,14 @@ behavior_provenance(
250
252
  )
251
253
  ```
252
254
 
255
+ Timestamp semantics: a matcher's `updated_at` is the last change to the
256
+ matcher row OR its edge set — `createEdge` bumps it when a new edge lands
257
+ (atomically with the insert), and deleting an item bumps the matchers whose
258
+ edges cascade away (atomically with the delete). The matcher's own text may
259
+ legitimately be older than its links (a corrected behavior re-links the
260
+ existing matcher without touching its text). A behavior's `updated_at` is
261
+ written at creation and then moved by the last confidence change.
262
+
253
263
  ## Key invariants
254
264
 
255
265
  1. **The firewall principle does NOT apply.** The LLM grading its own
@@ -27,7 +27,13 @@ The thatch CLI is a Bun script at `bin/thatch`. It provides memory inspection, M
27
27
 
28
28
  ## Global behavior
29
29
 
30
- - No `--version`, `--help`, `-h`, or short flags. No subcommand aliases.
30
+ - `--version` and `-v` print the package version and exit 0.
31
+ - `-h` and `--help` print usage and exit 0. The check runs before the DB
32
+ is opened and before any subcommand dispatch, and scans the WHOLE
33
+ argument list - not just the leading flags - so `thatch chat list -h`
34
+ and `thatch -h chat list` show usage instead of executing. Explicit
35
+ help exits 0 where a usage error exits 1.
36
+ - No other short flags. No subcommand aliases.
31
37
  - Unknown command or missing required arg calls `usage()` and exits 1.
32
38
  - Args are positional, parsed by hand from `process.argv.slice(2)`. No arg-parsing library.
33
39
  - DB opened once at startup, closed at end (except `mcp` which closes early).
@@ -47,11 +53,21 @@ Inherits stdio, exits with the child's exit code. Errors and exits 1 if none fou
47
53
  Read-only window on the cross-session chat directory ([cross-session-chat.md](cross-session-chat.md)):
48
54
 
49
55
  - `chat list` renders the roster as aligned columns under a header row
50
- (NAME/AGE/STATUS/PROJECT/TOPIC), colorized only when stdout is a TTY
51
- (`formatChatRoster()` in `bin/thatch`; pipes and QA runners get plain
52
- text). Rows are split into Active and Stale sections by
53
- `splitChatRoster()`; the status column is `chatLiveness()`'s verdict
54
- (`fresh`/`stale` for opencode rows, `active`/`idle` for MCP rows).
56
+ (NAME/SESSION ID/AGE/STATUS/PROJECT/TOPIC), colorized only when stdout
57
+ is a TTY (`formatChatRoster()` in `bin/thatch`; pipes and QA runners
58
+ get plain text). Rows are split into Active, Recent, and Stale
59
+ sections by `splitChatRoster()` (it takes the open-tab set, so fresh
60
+ opencode rows split into open versus recent); the status column is
61
+ `chatLiveness()`'s verdict
62
+ (`open`/`recent`/`stale` for opencode rows - the open-tab reader feeds
63
+ the open/recent split - `active`/`idle` for MCP rows). The Recent and
64
+ Stale sections are flag-guarded, and the two guards hide differently:
65
+ without `--recent` the Recent section is replaced by a muted
66
+ hidden-count note (the renderer keeps the rows to count them), while
67
+ without `--stale` stale rows are dropped entirely by a pre-filter in
68
+ the command case - only the empty-roster message points at the flag.
69
+ `--stale [N|all]` reveals the stale rows under a one-day display cap
70
+ whose overflow collapses into a hidden-count note.
55
71
  - `chat tail` follows the message stream as JSONL: one `ChatTailEvent`
56
72
  per line via `formatChatTailJsonl()` (a bare `JSON.stringify`; no ANSI,
57
73
  no markdown rendering, no local-time conversion - the tail is a log for
@@ -113,7 +129,7 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
113
129
  ## Key invariants
114
130
 
115
131
  - No arg-parsing library. Args are positional, parsed by hand from `process.argv.slice(2)`.
116
- - No `--version`, `--help`, `-h`, or short flags. No subcommand aliases.
132
+ - `--version`/`-v` prints the version; `-h`/`--help` prints usage from anywhere in the argument list (checked upstream of dispatch, before the DB opens) and exits 0. No other short flags. No subcommand aliases.
117
133
  - DB opened once at startup, closed at end (except `mcp` which closes early).
118
134
  - Store name auto-detected from git remote. "global" is the shared store. "all" (search only) means project + global.
119
135
  - Unknown command or missing required arg calls `usage()` and exits 1.