@jeffober/thatch 0.2.6 → 0.2.8

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.
@@ -168,6 +168,17 @@ The wart shape, for code that looks wrong but is correct:
168
168
  Simple and good beats complex and great. Cut whole claims to shorten;
169
169
  never compress the survivors.
170
170
 
171
+ ## Documentation files
172
+
173
+ Standalone documentation - package docs (a doc.go or package comment), READMEs, and anything under a docs/ directory - serves readers that symbol-anchored text never meets: someone onboarding and an expert looking up one fact, in the same pass. An inline comment or a docstring has the code as its context. A standalone doc has only its own order, so the standard is higher: clear sentences are the floor; the document must also serve the skimmer and the learner.
174
+
175
+ - **The inverted pyramid, at the document and the section level.** Lead with the answer at the top of the doc, and again at the top of each section: what the thing is and what the reader gets, first; the mechanism after; caveats and edge cases last. The reader decides when to stop reading, so stopping early must be safe.
176
+ - **Basics before advanced.** Each section stands on the ones before it. A reader who knows the basics can stop or skip ahead; a reader who does not gets each idea in the order they can absorb it.
177
+ - **Scan anchors.** Bold the load-bearing phrases so a reader who skims only the bold fragments still gets the story; the first sentence of each section carries the section's point. Bold sparingly outside the opening sections; a doc bolded throughout cannot be skimmed.
178
+ - **Presentation, not specificity.** These rules demand better order, never more content. Judge a doc against its own level of detail; do not add enumerations or file pointers the doc's level never promised.
179
+
180
+ No section template follows from this. Headings and structure belong to the repo's own conventions; apply the intent, not a shape.
181
+
171
182
  ## Clarity pass
172
183
 
173
184
  Before you post, save, or send: reread the draft as a reader with zero context. Fix what they would stumble on. Do not announce the pass; just return clean prose.
@@ -58,7 +58,7 @@ Dispatch a sub-agent using the Task tool to research the code workflows affected
58
58
 
59
59
  The sub-agent produces a code guide with one section per affected workflow or feature. Wait for it to complete and collect the guide.
60
60
 
61
- This guide gives specialists the code-level context (purpose, data flow, evolutions, constraints) they need to avoid false positives about intentional behavior and long-standing design decisions, and reduces duplicate code-tracing across the nine specialist lenses.
61
+ This guide gives specialists the code-level context (purpose, data flow, evolutions, constraints) they need to avoid false positives about intentional behavior and long-standing design decisions, and reduces duplicate code-tracing across the ten specialist lenses.
62
62
 
63
63
  If the context brief includes a sibling register (from Step 2's related-PR discovery), fold it into the guide per the `Cross-PR context` section of the thatch-code-archaeology skill — whether you dispatch a sub-agent or do this research inline. The traps the guide exists to catch are comments referencing sibling work, contracts consumed from a sibling, and ordering assumptions across the stack or train.
64
64
 
@@ -90,7 +90,7 @@ For changes estimated at 3 points or fewer, skip partitioning — dispatch one s
90
90
 
91
91
  ## Step 6: Dispatch specialist sub-agents
92
92
 
93
- For each review unit, dispatch sub-agents using the Task tool. Each sub-agent runs one specialist lens on one review unit. The nine specialists are:
93
+ For each review unit, dispatch sub-agents using the Task tool. Each sub-agent runs one specialist lens on one review unit. The ten specialists are:
94
94
 
95
95
  1. **Pedantic** — mechanical correctness: spelling, naming, doc accuracy, specs, guidelines, stale artifacts, structural conventions. Dispatch a sub-agent with instructions to: read every changed file in the unit, check comments/docs/naming/specs/style, check new file and directory placements against the repo's existing layout, report findings.
96
96
 
@@ -102,13 +102,15 @@ For each review unit, dispatch sub-agents using the Task tool. Each sub-agent ru
102
102
 
103
103
  5. **NoSlop** — AI writing anti-pattern detection: change narration, fourth wall breaks, em dashes, hedging, filler. Dispatch a sub-agent with instructions to: read every changed file, scan all comments/docs/strings for slop.
104
104
 
105
- 6. **Breadcrumbs** — comment narrative evaluation: do comments form a coherent outline? Dispatch a sub-agent with instructions to: read every changed file in full, evaluate the comment narrative, flag gaps.
105
+ 6. **Clarity** — prose clarity against the project's clear-writing rules, regardless of who wrote the text: made-up phrases, buzzwords, untranslated jargon, clause chains, buried leads, doc/code responsibility splits, plus document-level presentation in standalone docs (the inverted pyramid, scan anchors, basics before advanced). Dispatch a sub-agent with instructions to: read every changed file in full plus every changed doc, scan all comments/docstrings/error strings/UI strings/doc paragraphs for reader-cost prose, check standalone docs that are new or substantially rewritten for structure, report each finding with the plain-English fix (the replacement, the split, or the claim to cut).
106
106
 
107
- 7. **MarkAndSweep** — mechanical change completeness: renames, flag removals, API substitutions. Dispatch a sub-agent with instructions to: extract old identifiers from removed diff lines, sweep the whole repo with git grep for stragglers, verify touchpoint neatness (dead branches, orphaned imports, stale comments, config/test residue). Self-selects: if the diff shows no mechanical change patterns, it reports "No mechanical change detected" and produces no findings.
107
+ 7. **Breadcrumbs** — comment narrative evaluation: do comments form a coherent outline? Dispatch a sub-agent with instructions to: read every changed file in full, evaluate the comment narrative, flag gaps.
108
108
 
109
- 8. **Highlights** — positive finding detection: notably clever solutions, cleanup done along the way, documentation that meaningfully helps. Dispatch a sub-agent with instructions to: read every changed file in the unit and the before-state with git show, look for things that rise above baseline competence. Medium-high bar: no generic praise, no baseline competence. If nothing rises above the bar, it reports "No highlights" and produces no findings.
109
+ 8. **MarkAndSweep** — mechanical change completeness: renames, flag removals, API substitutions. Dispatch a sub-agent with instructions to: extract old identifiers from removed diff lines, sweep the whole repo with git grep for stragglers, verify touchpoint neatness (dead branches, orphaned imports, stale comments, config/test residue). Self-selects: if the diff shows no mechanical change patterns, it reports "No mechanical change detected" and produces no findings.
110
110
 
111
- 9. **Technique** — teaching-grade lens on better ways to do it: helpers and internal packages the author may not have seen, companion techniques, house patterns, test-craft, API-shape principles, next-reader discoverability. Dispatch a sub-agent with instructions to: read every changed file in the unit, verify any cited alternative exists and achieves the same behavior before reporting, frame notes generously, report all notes as informational. If nothing qualifies, it reports "No technique notes" and produces no findings.
111
+ 9. **Highlights** — positive finding detection: notably clever solutions, cleanup done along the way, documentation that meaningfully helps. Dispatch a sub-agent with instructions to: read every changed file in the unit and the before-state with git show, look for things that rise above baseline competence. Medium-high bar: no generic praise, no baseline competence. If nothing rises above the bar, it reports "No highlights" and produces no findings.
112
+
113
+ 10. **Technique** — teaching-grade lens on better ways to do it: helpers and internal packages the author may not have seen, companion techniques, house patterns, test-craft, API-shape principles, next-reader discoverability. Dispatch a sub-agent with instructions to: read every changed file in the unit, verify any cited alternative exists and achieves the same behavior before reporting, frame notes generously, report all notes as informational. If nothing qualifies, it reports "No technique notes" and produces no findings.
112
114
 
113
115
  For the integration review unit (5+ points), dispatch a sub-agent focused on:
114
116
  - Cross-component contracts — do the interfaces between components match?
@@ -137,7 +139,7 @@ Pass the synthesizer:
137
139
 
138
140
  The final user-facing report MUST begin with a concise explanation of the workflow(s) this PR changes before listing findings. Use one `### Workflow changes` section, with one subheading per affected workflow when there are multiple. Use the before/now walkthrough shape from the thatch-pr-description skill: numbered step = existing behavior; `NOW` sub-bullet = what the PR changes; `N/A` for new stages. This gives the user a map of the change before the findings. Keep it short: 3-6 steps per workflow.
139
141
 
140
- Confirmed LOW findings are mandatory in the final report, including mechanical findings (pedantic, no-slop, breadcrumbs, docs, naming, style, comments, structural conventions). Do not omit them for being non-functional.
142
+ Confirmed LOW findings are mandatory in the final report, including mechanical findings (pedantic, no-slop, clarity, breadcrumbs, docs, naming, style, comments, structural conventions). Do not omit them for being non-functional.
141
143
 
142
144
  Confirmed highlights (from the highlights specialist) appear in a `### Highlights` section after the workflow changes and before the confirmed findings. Most PRs will have none — that is expected. Do not pad with borderline calls. But when the highlights specialist found genuine standouts, include every one that passes the bar.
143
145
 
@@ -164,7 +166,7 @@ When dispatching each sub-agent, include in the prompt:
164
166
  - The git range to review
165
167
  - **The resolved head SHA of the range** — all file reads, file:line citations, and anchors MUST be computed against this ref (`git show <head-sha>:<path>`), never the working tree, which may be on a different branch than the change under review
166
168
  - The specific files in this unit's scope
167
- - The specialist focus (from the nine specialists above)
169
+ - The specialist focus (from the ten specialists above)
168
170
  - The diff stat for this unit's files
169
171
  - **The project context brief** from Step 2, filtered to what is relevant to this unit's scope. Explicitly list any deferred work that falls within this unit's files, and call out TODO ($ticket) markers the specialists should recognize as intentional.
170
172
  - **The workflow guide** from Step 3, filtered to the workflows relevant to this unit's scope. Use it to understand the purpose and evolution of the code before flagging issues. It provides the code-level context (flows, contracts, history, constraints) that prevents false positives about intentional behavior and long-standing design decisions.
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: thatch-review-clarity
3
+ description: Prose clarity in the change's comments, docstrings, error strings, and docs - made-up phrases, buzzwords, untranslated jargon, clause chains, buried leads, doc/code responsibility splits - plus document-level presentation in standalone docs, the inverted pyramid, scan anchors, basics before advanced - checked against the project's clear-writing rules regardless of who wrote the text. Use for post-implementation review of a branch, PR, or commit range.
4
+ ---
5
+
6
+ You are a prose clarity review agent. Your sole job is to check the human-facing text in the change - code comments, docstrings, error messages, UI strings, and documentation - against the project's clear-writing rules.
7
+
8
+ A clarity defect is reader cost, regardless of who wrote the text. Dense jargon a human wrote fails exactly like the same jargon from an AI. Do not guess at authorship; that is the no-slop lens's job.
9
+
10
+ ${REVIEW_COMMON}
11
+
12
+ ## The rules
13
+
14
+ The full rule set is the thatch-clear-writing skill. The checks below are its review form. When both are loaded and they disagree, the skill wins.
15
+
16
+ The reader is an engineer who knows the stack and general engineering vocabulary and has never touched this area. Every sentence must parse on first read. The concepts stay technical; the language stays simple.
17
+
18
+ Brevity means fewer claims, not denser claims. When prose runs long, the fix is deleting a whole claim. Never report a fix that tightens a surviving sentence; tightening is what produces clause chains.
19
+
20
+ ## Categories
21
+
22
+ ### Made-up phrases
23
+ Multi-word terms that sound technical but mean nothing: "identity contract", "canonical hash", "semantic alignment layer". Every term must exist somewhere - in the code, the docs, or standard engineering usage. If you cannot point to where it is defined, the label is the finding. The fix names what the thing does: what runs, what changes, what the reader gets.
24
+
25
+ ### Buzzwords
26
+ "Leverage", "utilize", "facilitate", and any phrase that cannot be restated in plain English. If a phrase sounds like it is trying to sound smart, that is the finding. The fix uses the plain word or states the behavior.
27
+
28
+ ### Untranslated jargon
29
+ A project-private label doing an explanation's job. Roadmap names, subsystem nicknames, ticket shorthand, and branch-local labels are not explanations. Say what the code does first (what reads, writes, blocks, retries, changes); the label may follow if it helps the reader connect prose to code. Ticket refs in place of behavior are this category: "the backfill worker", not "the TICKET-123 backfill worker". Code-domain nouns in prose are this category too: "a request waiting for a worker" not "a waiter".
30
+
31
+ ### Clause chains
32
+ Multiple ideas crammed into one sentence, or noun stacks doing verb work: "the row read", "the waiter". The fix splits the sentence into full parallel sentences, each with its causal clause, or deletes a whole claim. Verbless chains (the telegraphic form) belong to the no-slop lens.
33
+
34
+ ### Buried leads
35
+ A doc or docstring that saves the point for the end. Lead with the answer. Then the evidence. Then the caveats. The reader decides when to stop reading, so stopping early must be safe. Reading-order narration ("This guide will first cover X, then Y") opens with process instead of the point and is this category too.
36
+
37
+ ### Doc structure failures
38
+ Standalone docs only (package docs, READMEs, docs/): the document's order fails the reader even when every sentence is clear.
39
+ - A wall of paragraphs with no scan anchors: no bold on the load-bearing phrases, section leads that do not carry the section's point, so a skimmer cannot find what they came for.
40
+ - Basics and advanced detail interleaved, so the reader who knows the basics cannot skip ahead and the reader who does not gets ideas out of order.
41
+
42
+ Structure findings apply to docs that are new or substantially rewritten in the change; a lightly touched doc gets only its touched sections checked. No section template is imposed: the repo's own headings and conventions are never the finding. Check the order and the scan path within whatever shape the repo uses.
43
+
44
+ ### Doc/code responsibility splits
45
+ Implementation detail in a README or package doc, where the doc comment next to the code owns the mechanism; or a guide-level essay in a code comment, where the README owns the overview. A README is not "the code but in english". State the invariant the design buys in the comment; keep the README a high-level guide.
46
+
47
+ ### Redundant glosses
48
+ Parentheticals and asides that restate the sentence, the adjacent code, or what the surrounding convention already guarantees, instead of adding a distinct fact. Keep "(or has been killed on)". Cut "(the worker runs when the flag is unset)" after "It defaults to enabled". Do not document de facto conventions: if the framework or the migrations make something the default rule, the comment does not say so.
49
+
50
+ ## Reader model
51
+
52
+ Symbol-anchored text and standalone docs fail differently, and the same text can be correct in one and a defect in the other.
53
+
54
+ - **Inline comments and symbol docstrings** (a function, class, type, or constant doc comment) anchor to the immediate code. They may not re-explain visible code. One hop out (the caller, the callee, a guard, another writer) is the comment's context budget.
55
+ - **Standalone docs** (package docs like a doc.go, READMEs, anything under a docs/ directory) serve readers that symbol-anchored text never meets: someone onboarding and an expert looking up one fact, in the same pass. Background and context that a comment must not carry are correct here; restating the same claim in multiple sections is still a defect. The sentence rules are the floor; the document must also serve the skimmer and the learner: the inverted pyramid at the document and section level, scan anchors (a reader who skims only the bold fragments still gets the story), and Basics before advanced (each section stands on the ones before it, so the reader who knows the basics can skip ahead and the reader who does not gets each idea in order).
56
+
57
+ ## What is NOT a finding
58
+
59
+ - Genuine lists. An enumeration the reader needs is not a clause chain.
60
+ - Load-bearing precision: pinned terms of art, error-class contracts (Postgres classes 40 and 55), SQL shapes in comments, cross-references that carry the why.
61
+ - A term of art defined once and used afterwards. Defined terms are not untranslated jargon.
62
+ - A metaphor verb attached to a stated mechanism ("the sweep heals the dropped ids", after the sweep's behavior is stated).
63
+ - First-person informality: "This should not be possible. If we screwed up, ..." is the right voice, not a defect.
64
+ - Wart comments: code that looks wrong but is correct, explained by naming the expected implementation, the crux, and why. This is the right shape, not sloppy prose.
65
+ - A parenthetical that adds a distinct fact. These are placement rules, not presence bans: report the restatement or the dominance pattern, never the element itself.
66
+ - Comments explaining why, tradeoffs, or non-obvious behavior.
67
+ - UI strings whose tone is required by the feature or by an external protocol.
68
+ - Presentation, not specificity. A doc-structure finding demands better order, never more content. Judge a doc against its own level of detail; do not report a doc for lacking enumerations or file pointers its own specificity never promised.
69
+ - Unchanged legacy text outside the touched scope, unless the current change makes it newly wrong or newly suspicious.
70
+
71
+ ## Boundary with the other lenses
72
+
73
+ - The **no-slop** lens owns AI-authorship tells: typography (em dashes, smart quotes), hedging, filler, verbosity, change narration, fourth wall breaks, stale artifacts, mechanism metaphors, and verbless telegraphic chains. Do not report those here. When a sentence is both unclear and an AI tell, leave it to no-slop.
74
+ - The **breadcrumbs** lens owns narrative coherence: do the comments together tell the code's story? A missing comment is theirs. An unclear present comment is yours.
75
+ - The **pedantic** lens owns factual accuracy and the repo's structural conventions (file placement, heading conventions): wrong names, wrong file refs, stale docs. Clear but wrong is theirs. Right but unreadable is yours. A violation of the repo's own doc conventions is theirs, never a structure finding here.
76
+
77
+ ## Method
78
+
79
+ 1. Use the diff stat from your scope gathering to identify changed files.
80
+ 2. For EVERY changed file, read the full current version. For changed markdown docs, read the full doc.
81
+ 3. Scan every comment, docstring, error message string, UI string, and doc paragraph.
82
+ 4. For standalone docs that are new or substantially rewritten in the change, also check the document: the inverted pyramid at the document and section level, scan anchors, Basics before advanced.
83
+ 5. For each violation, report the exact quoted text and the fix: the plain-English replacement, the split, or the claim to cut.
84
+
85
+ Do NOT report on factual accuracy, narrative gaps, or AI tells. Only clarity.
86
+ Do NOT report issues in files you did not actually read.
87
+
88
+ Most findings are LOW. MEDIUM when the unclear prose can mislead a reader about behavior: a made-up phrase that names the wrong owner, a gloss that hides an UPDATE, a buried lead that hides a breaking change, or a doc whose structure hides the one fact the reader came for.
89
+
90
+ ## Category taxonomy
91
+
92
+ - **MADE_UP_PHRASE**: A coined multi-word term that exists nowhere; the behavior must replace the label
93
+ - **BUZZWORD**: A phrase that cannot be restated in plain English
94
+ - **UNTRANSLATED_JARGON**: A project-private label, ticket ref, or code-domain noun doing an explanation's job
95
+ - **CLAUSE_CHAIN**: Multiple ideas in one sentence, or noun stacks doing verb work; the fix splits or deletes whole claims
96
+ - **BURIED_LEAD**: A doc or docstring that saves the point for the end, or opens with reading-order narration
97
+ - **DOC_STRUCTURE**: A standalone doc whose order fails the skimmer or the learner (no scan anchors, interleaved basics and advanced detail, section leads off-point); the repo's own conventions are never the finding
98
+ - **DOC_CODE_SPLIT**: Implementation detail in a README or package doc, or a guide-level essay in a code comment
99
+ - **REDUNDANT_GLOSS**: A parenthetical or aside that restates the sentence, the adjacent code, or a framework default
100
+
101
+ For clarity findings, the source of truth is the project's clear-writing rules and the surrounding code intent; use "N/A — mechanical finding" for the producer chain.
@@ -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 nine 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.
@@ -3,7 +3,7 @@ name: thatch-review-synthesizer
3
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.
package/bin/thatch CHANGED
@@ -18,13 +18,25 @@ import { createDebugLog } from "../src/debug";
18
18
 
19
19
  const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
20
20
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
21
- const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
22
21
 
23
- const [command, ...args] = process.argv.slice(2);
22
+ const argv = process.argv.slice(2);
23
+
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()) });
24
34
 
25
- function usage(): never {
35
+ function usage(exitCode = 1): never {
26
36
  console.error(`USAGE
27
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)
28
40
  thatch stores List all stores
29
41
  thatch list [store] List memory labels in a store
30
42
  thatch show <label> [store] Display a memory by label
@@ -52,13 +64,16 @@ function usage(): never {
52
64
  Full conversation as OpenAI chat-completions JSONL
53
65
  thatch session search <query> [--session <id>] [--regex] [--limit N]
54
66
  JSONL of matching parts across sessions (decoded content)
55
- thatch chat list [--stale N|all] Registered chat sessions. Default shows
56
- only active (fresh-heartbeat) sessions:
57
- name, session id, age, status, project,
58
- topic. --stale adds the stale section:
59
- N bounds it to sessions seen in the
60
- last N days (default 1); all shows
61
- 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.
62
77
  thatch chat tail [--once] [--limit N|all] [--match RE]
63
78
  [--from NAME] [--to NAME] [--since DT] [--until DT]
64
79
  Follow cross-session chat as a JSONL
@@ -75,7 +90,7 @@ function usage(): never {
75
90
 
76
91
  Stores default to the repo detected from git remote. Use "global" for the
77
92
  global store. Use "all" with search to search project + global together.`);
78
- process.exit(1);
93
+ process.exit(exitCode);
79
94
  }
80
95
 
81
96
  async function defaultStore(): Promise<string> {
@@ -425,29 +440,42 @@ async function sessionCommand(rest: string[]): Promise<void> {
425
440
  const CHAT_ROSTER_HEADER = ["NAME", "SESSION ID", "AGE", "STATUS", "PROJECT", "TOPIC"];
426
441
 
427
442
  /**
428
- * Renders the `chat list` roster as two sections - Active and Stale - each
429
- * an aligned-column table under a header row. Section headers are bold,
430
- * the stale explainer is muted italic; rows use green names, dim age,
431
- * green/red status, and a muted italic topic. The STATUS column carries
432
- * the liveness verdict: open (tab open now), recent (host alive, no open
433
- * tab), active/idle (MCP rows), stale (host presumed gone).
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;
434
459
  * staleCapMs bounds how old a stale row is displayed (null = no bound);
435
460
  * rows past the cap collapse into a muted hidden-count note so the reader
436
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.
437
465
  * Color is applied only when stdout is an interactive terminal (and
438
466
  * NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
439
467
  * clean plain text.
440
468
  */
441
- function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null, openTabs?: ReadonlySet<string>): string {
469
+ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null = null, openTabs?: ReadonlySet<string>, showRecent = true, now = Date.now()): string {
442
470
  const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
443
471
  const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
444
- const { active, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), Date.now(), staleCapMs);
472
+ const { active, recent, stale, staleHidden } = splitChatRoster(sortChatRoster(sessions), now, staleCapMs, openTabs);
445
473
  const renderTable = (rows: ChatSessionRow[]) => {
446
474
  const cells = (r: ChatSessionRow) => [
447
475
  r.name,
448
476
  r.session_id,
449
477
  humanAge(r.last_seen),
450
- chatLiveness(r, Date.now(), openTabs),
478
+ chatLiveness(r, now, openTabs),
451
479
  r.project ?? "unknown",
452
480
  r.topic ?? "",
453
481
  ];
@@ -476,6 +504,20 @@ function formatChatRoster(sessions: ChatSessionRow[], staleCapMs: number | null
476
504
  if (active.length > 0) {
477
505
  parts.push([paint("1", "# Active Sessions"), renderTable(active)].join("\n"));
478
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
+ }
479
521
  if (stale.length > 0 || staleHidden > 0) {
480
522
  const lines = [paint("1", "# Stale Sessions")];
481
523
  if (stale.length > 0) {
@@ -505,13 +547,24 @@ async function chatCommand(rest: string[]): Promise<void> {
505
547
  // sessions from burying the fresh signal. --stale all restores the
506
548
  // unbounded view. A bad value dies loudly rather than silently
507
549
  // shrinking the roster.
508
- // Default: active sessions only. --stale [N|all] opts into the
509
- // stale section - bare --stale keeps the one-day display cap, N
510
- // bounds it, all removes it. The value is parsed by hand (not
511
- // 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");
512
561
  const showStale = restArgs.includes("--stale");
513
562
  const staleIdx = restArgs.indexOf("--stale");
514
- 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;
515
568
  let staleCapMs: number | null = 24 * 60 * 60 * 1000;
516
569
  if (staleNext !== undefined) {
517
570
  if (staleNext === "all") staleCapMs = null;
@@ -523,17 +576,25 @@ async function chatCommand(rest: string[]): Promise<void> {
523
576
  // The open-tab set is read once per invocation at render time: rows
524
577
  // with a tab render "open", fresh rows without one "recent".
525
578
  const openTabs = chatOpenTabsProvider(() => db.chatTabClosures())();
526
- let sessions = db.listChatSessions();
527
- if (!showStale) sessions = sessions.filter((r) => chatLiveness(r, Date.now(), openTabs) !== "stale");
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");
528
589
  if (sessions.length === 0) {
529
590
  console.log(
530
- showStale
591
+ registered.length === 0
531
592
  ? "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register."
532
593
  : "No active sessions. (Stale sessions are hidden; run with --stale to include them.)",
533
594
  );
534
595
  break;
535
596
  }
536
- console.log(formatChatRoster(sessions, showStale ? staleCapMs : null, openTabs));
597
+ console.log(formatChatRoster(sessions, showStale ? staleCapMs : null, openTabs, showRecent, now));
537
598
  break;
538
599
  }
539
600
 
@@ -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,12 +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
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
54
62
  (`open`/`recent`/`stale` for opencode rows - the open-tab reader feeds
55
- the open/recent split - `active`/`idle` for MCP rows).
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.
56
71
  - `chat tail` follows the message stream as JSONL: one `ChatTailEvent`
57
72
  per line via `formatChatTailJsonl()` (a bare `JSON.stringify`; no ANSI,
58
73
  no markdown rendering, no local-time conversion - the tail is a log for
@@ -87,6 +102,8 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
87
102
  | `THATCH_PREDICTION_THRESHOLD` | Cutoff for prediction auto-fire | `0.60` |
88
103
  | `THATCH_BEHAVIOR_THRESHOLD` | Cutoff for behavior auto-fire | `0.60` |
89
104
  | `THATCH_CHILD_RECONCILE_MS` | Reload window before a restored extraction child is judged finished (test knob) | `10000` |
105
+ | `THATCH_STRAND_SWEEP_MS` | Interval for the stranded-session self-heal sweep | `21600000` (6h) |
106
+ | `THATCH_STRAND_SWEEP` | Set to `off` (or `0`/`false`) to disarm the strand sweep (test knob) | falls through (sweep on) |
90
107
  | `CLAUDE_PROJECT_DIR` | Claude Code project dir | `process.cwd()` |
91
108
  | `CURSOR_PROJECT_DIR` | Cursor project dir | falls through |
92
109
  | `THATCH_WATCH_DEATH_MINUTES` | Pending-age threshold for watcher death detection | `120` |
@@ -114,7 +131,7 @@ Read-only window on the cross-session chat directory ([cross-session-chat.md](cr
114
131
  ## Key invariants
115
132
 
116
133
  - No arg-parsing library. Args are positional, parsed by hand from `process.argv.slice(2)`.
117
- - No `--version`, `--help`, `-h`, or short flags. No subcommand aliases.
134
+ - `--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.
118
135
  - DB opened once at startup, closed at end (except `mcp` which closes early).
119
136
  - Store name auto-detected from git remote. "global" is the shared store. "all" (search only) means project + global.
120
137
  - Unknown command or missing required arg calls `usage()` and exits 1.
@@ -55,8 +55,8 @@ same source as the install list in src/skills.ts and both host
55
55
  instruction skill lists. `peer-review` weights org
56
56
  velocity and walks the project's persistent memory (the one tool it
57
57
  names, spelled through the `ToolNamer` so MCP renderings stay correct);
58
- `self-review` weights the reputation lenses (no-slop, technique,
59
- breadcrumbs) and iterates rounds until a round produces no meaningful
58
+ `self-review` weights the reputation lenses (no-slop, clarity,
59
+ technique, breadcrumbs) and iterates rounds until a round produces no meaningful
60
60
  findings. The couching also names the MCP `Focus:` line as a target
61
61
  source, because MCP prompts replace the file commands' `$ARGUMENTS`
62
62
  section with a `Focus:` line.
@@ -317,18 +317,26 @@ is restart persistence by design), so dead-window residue reads OPEN until
317
317
  its heartbeat ages out; tabs disabled degrades everything to RECENT.
318
318
 
319
319
  `chat_list`
320
- groups the result into Active and Stale sections - the stale section's
321
- explainer says what stale means, and each row renders its verdict -
320
+ groups the result into Active, Recent, and Stale sections - the Recent
321
+ and Stale sections' explainers say what those states mean, and each row
322
+ renders its verdict -
322
323
  and `chat_send` states the recipient's
323
324
  liveness at send time, so a sender mailing a ghost learns it immediately
324
325
  instead of waiting on a wake that will never fire, and a sender mailing a
325
326
  tab-less session is told the wake is not guaranteed. Both roster surfaces
326
- order rows with `sortChatRoster` (also src/chat.ts): project first,
327
+ order rows with `sortChatRoster` (also src/chat.ts) and split fresh
328
+ opencode rows along the same open/recent boundary (`splitChatRoster`
329
+ takes the open-tab set): project first,
327
330
  alphabetical, with no-project rows last; most recently seen first within
328
- a project. The CLI roster bounds
329
- the stale display to one day by default (`--stale N` in days, `--stale all`
330
- unbounded), collapsing older rows into a hidden-count note; the tool
331
- surface stays unbounded so an agent always sees the full directory.
331
+ a project. The CLI roster guards the Recent and Stale sections behind
332
+ flags, and the two guards hide differently: without `--recent` the
333
+ tab-less rows collapse into a muted hidden-count note (revealed by the
334
+ flag), while without `--stale` stale rows are dropped entirely; with
335
+ `--stale [N|all]` they show under a one-day display cap that collapses
336
+ older rows into a
337
+ hidden-count note. The tool
338
+ surface shows all three sections unbounded so an agent always sees the
339
+ full directory.
332
340
  `session.deleted` is the graceful-exit fast path that unregisters immediately.
333
341
 
334
342
  ### Delivery, re-nudge, and the rate cap
@@ -421,7 +429,7 @@ last `CHAT_TAIL_DEFAULT_LIMIT` (20) messages via `chatTailBacklog()`;
421
429
  `filterChatTailRows()` narrows every feed snapshot (backlog and follow
422
430
  polls alike) by body regexes, participant-name substrings, and a
423
431
  half-open time window. The diff itself is `chatTailDiff()` (unit-tested
424
- in `tests/chat.test.ts`): sent events on first view, then new sends and
432
+ in `tests/chat-tail.test.ts`): sent events on first view, then new sends and
425
433
  newly-read messages per poll, with the diff state seeded from the full
426
434
  feed so neither the limit nor a mid-follow rename/unregister can
427
435
  resurface old rows as sent events. `chat_messages.via_broadcast` marks
@@ -581,8 +589,11 @@ within their clamps - invalid values silently fall back to the default.
581
589
  - `src/prompts.ts` - `chatNotificationNudge()`, `chatEchoText()` /
582
590
  `isChatEchoParts()`, system prompt Cross-Session Chat section, MCP
583
591
  absent-tools note
584
- - `tests/chat.test.ts` - store, pool, migration, echo-text, liveness, tail
585
- diff/backlog, and poller unit tests (temp-dir SQLite, injected delivery)
592
+ - `tests/chat-store.test.ts`, `tests/chat-poller.test.ts`,
593
+ `tests/chat-tail.test.ts`, `tests/chat-echo.test.ts` - store, pool,
594
+ migration, echo-text, liveness, tail diff/backlog, and poller unit tests
595
+ (temp-dir SQLite, injected delivery; shared fixtures in
596
+ `tests/mocks/chat-harness.ts`)
586
597
  - `tests/qa/auto/uc-097-chat.ts` - full lifecycle against a mocked poller
587
598
  - `tests/qa/auto/uc-099-chat-cli.ts` - `chat list` roster and `chat tail`
588
599
  JSONL contract over a seeded DB
@@ -203,7 +203,7 @@ rejected for that reason (plus v1 has no TUI plugin surface at all).
203
203
  records argv), AppleScript escaping, partial vs total failure, unsupported
204
204
  platform, plus tool-level tests (merge-no-wipe, echo, `mode: none` no-op,
205
205
  configured voice honored) with `THATCH_DB_PATH` pointed at a tempdir.
206
- - `tests/tool-defs.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
206
+ - `tests/tool-defs-catalog-session.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
207
207
  - `tests/alerts.test.ts` -- the alert state machine: `deriveRoundShape`
208
208
  (idle-marker delimiter, v1 fallback, synthetic trigger detection), the
209
209
  real-work flag, pause dedup and re-arm, abort/shutdown silence,
@@ -66,17 +66,19 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
66
66
  | tool buffering | `tool.execute.after` hook | `tool.hook("execute.after")` | wired: feeds the same extraction buffer; `Tool.Result.content` is flattened (string passes through, content-part arrays contribute their text parts); the title is derived from the tool name and args (`deriveTitle`), since v2's `Tool.Result.output` is a typed output value, not a title |
67
67
  | noReply deliveries | `promptAsync` with `noReply` | none | gated off via `HostCapabilities.noReplyDelivery` -- chat echoes and the session-start reminder are skipped on v2 (delivering them would start real model turns: a feedback loop) |
68
68
  | synthetic wake deliveries | `promptAsync` with `synthetic` parts | `session.synthetic` endpoint | watcher + chat wake nudges route to v2's synthetic endpoint (TUI-hidden), matching v1 |
69
- | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete rides `session.remove` (upstream #52387, in v2.0.22), runtime-guarded like compact -- an older host keeps the documented gap (the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
69
+ | child sessions | `client.session.create/promptAsync/prompt/delete` | `session.create/prompt` | create+prompt supported (a missing id throws into the extraction fallback); delete rides `session.remove` (upstream #52387, in v2.0.22), runtime-guarded like compact -- an older host keeps the documented gap (the session picker accumulates one `thatch-extraction` entry per extraction, and `-c` "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction); the strand sweep deletes such corpses opportunistically and moves stranded real sessions to their project's canonical checkout (see session-lifecycle.md) |
70
70
  | session status | `client.session.status` | none | returns `{}`; the wake gate treats unknown as idle and the event-fed status map does the gating |
71
71
  | session messages | `client.session.messages` | `session.context` | mapped into the v1 `{info: {role}, parts}` shape, so the wrap-up greenlight check works (the promise domain has no `message` accessor) |
72
72
  | session list | `client.session.list` | none | degrade (`-c` resume listing loses its data source) |
73
+ | session enumeration (strand sweep) | `client.session.list` | readonly read of opencode.db | v2's promise domain has no session list, and the sweep needs machine-wide rows (dead-worktree sessions belong to no live instance), so the v2 adapter reads `session_v2` + `project` directly -- the same readonly pattern as the `-c` inheritance resolution; null on schema drift, which the sweep treats as "nothing known, do nothing" |
74
+ | session move | none | `session.move` (promise domain) | the strand sweep's self-heal for real sessions stranded in a deleted worktree; v1 has no move endpoint, so the adapter omits it and the sweep degrades to machinery-only deletion |
73
75
  | compaction trigger | `client.tui.executeCommand("session_compact")` | `session.compact({sessionID})` | wired behind a runtime guard: upstream [#52385](https://github.com/anomalyco/opencode/pull/52385) exposed `session.compact` on the promise domain (in the installed v2.0.25), newer than the pinned dev types - an older host degrades to a logged no-op (the wrap-up checklist and flush still run, the automatic compaction is skipped) |
74
76
  | toasts | `client.tui.showToast` | toast rpc event | wired: every runtime toast call site (alerts, extraction metrics, chat registration, blocked wrap-ups, watcher rearm notices) emits the bridge's `toast` event, which the `./tui` plugin shows via `ui.toast.show`; the pump never translates it (TUI-only by nature). Best-effort by contract: a TUI-less environment or an rpc-less SDK floor shows nothing, silently |
75
77
  | wrap-up exit | `client.tui.publish` | exit-tab-closed rpc event | the wrap-up exit records the session's watcher deaths synchronously (the runtime, before the chat unregister - `sessionDied` places the death row by the chat row, so the order is load-bearing) and then emits `rpc.thatch-tabs.exit-tab-closed`, a TUI-ONLY close of the session's OWN tab (the `./tui` plugin's `ui.tabs.close`): the tab-scoped semantic upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984) asks for, since the daemon hosts every tab and `app.exit` would take down unrelated sessions. The pump deliberately never translates this event (a second death pass would erase the durable death row the first one wrote; the session_tab_close tool's `tab-closed` is the only death-translating event). Degrades to a logged no-op without the rpc surface; a TUI with no tab for the session (headless run, tabs off) never acts - the event is ephemeral |
76
78
  | session tabs | n/a | rpc event bridge | `thatch_session_tab` (v2Only) spawns a detached subordinate session (create -> move -> chat-register -> emit `rpc.thatch-tabs.tab-opened` -> prompt); the `./tui` CLI plugin consumes the event and calls `ui.tabs.open` (open-without-focus, idempotent). `thatch_session_tab_close` emits `rpc.thatch-tabs.tab-closed {sessionID, chatName}` - the pump translates it into the runtime's confirmed-death path. The registration's third event, `exit-tab-closed` (the wrap-up exit's), is TUI-only - see the wrap-up exit row |
77
79
  | tab close | n/a | no session.deleted on v2 tab close | bounded 2026-10-07: watcher death detection cancels a closed tab's watchers once its pending events age past `THATCH_WATCH_DEATH_MINUTES` (default 120) while deliveries throw 404-class errors, and announces the death to live same-project sessions; the roster row itself still lingers until the reaper. NOTE: an earlier version of this row claimed deliveries "fail their idle gate" - wrong: the v2 gate opens (the status stub reads as idle) and the PROMPT fails; same leak, different link |
78
80
  | wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
79
- | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
81
+ | dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process); a dead process's journaled extraction children are also DELETED through the host before the prune (the opencode session row is global and would otherwise outlive the journal as a picker corpse - the restart-window leak). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
80
82
 
81
83
  ## Live verification and remaining unknowns
82
84
 
@@ -160,9 +162,9 @@ self-heal it).
160
162
  - `src/capabilities.ts` -- `HostCapabilities`, `capabilitiesFromClient`
161
163
  - `src/runtime.ts` -- the shared runtime (all behavioral logic)
162
164
  - `src/os-args.ts` -- pure argv helpers
163
- - `tests/opencode-v2.test.ts` -- v2 adapter contract tests (mocked context)
165
+ - `tests/opencode-v2-*.test.ts` -- v2 adapter contract tests (mocked context; shared fixtures in `tests/mocks/opencode-v2-harness.ts`)
164
166
  - `tests/session-tab-shared.test.ts` -- session-tab validators, rpc definition shape, TUI guard logic
165
- - `tests/plugin.test.ts` -- v1 adapter contract tests (mocked client)
167
+ - `tests/plugin-*.test.ts` -- v1 adapter contract tests (mocked client; shared fixtures in `tests/mocks/plugin-harness.ts`)
166
168
 
167
169
  ## Interactions with other features
168
170