@jeffober/thatch 0.2.0 → 0.2.2

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 (37) hide show
  1. package/README.md +4 -2
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/artifacts/skills/thatch-plan-refinement.md +3 -2
  5. package/artifacts/skills/thatch-refine-infra.md +66 -0
  6. package/artifacts/skills/thatch-refine-oss-contribution.md +68 -0
  7. package/artifacts/skills/thatch-refine-personal.md +61 -0
  8. package/artifacts/skills/thatch-refine-shared-lib.md +69 -0
  9. package/artifacts/skills/thatch-refine-spike.md +53 -0
  10. package/artifacts/skills/thatch-refine-team-app.md +69 -0
  11. package/artifacts/skills/thatch-refine.md +84 -0
  12. package/docs/dev/README.md +11 -7
  13. package/docs/dev/features/cross-session-chat.md +8 -1
  14. package/docs/dev/features/memory-store.md +1 -1
  15. package/docs/dev/features/nudge-pipeline.md +12 -0
  16. package/docs/dev/features/opencode-plugin.md +1 -1
  17. package/docs/dev/features/session-lifecycle.md +1 -1
  18. package/docs/dev/gotchas.md +71 -1
  19. package/docs/dev/mcp-parity.md +1 -1
  20. package/docs/dev/setup-and-hooks.md +1 -1
  21. package/docs/dev/skills.md +8 -1
  22. package/docs/user/README.md +8 -1
  23. package/docs/user/commands.md +8 -0
  24. package/docs/user/memory.md +1 -1
  25. package/docs/user/skills.md +12 -0
  26. package/package.json +1 -1
  27. package/src/chat.ts +17 -0
  28. package/src/commands.ts +23 -3
  29. package/src/db.ts +77 -12
  30. package/src/extraction.ts +89 -3
  31. package/src/index.ts +45 -0
  32. package/src/opencode/v2.ts +48 -4
  33. package/src/prompts.ts +118 -30
  34. package/src/runtime.ts +271 -72
  35. package/src/skills.ts +7 -0
  36. package/src/tool-defs.ts +156 -28
  37. package/src/watchers.ts +14 -0
@@ -75,7 +75,7 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
75
75
  | TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
76
76
  | tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
77
77
  | 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 |
78
- | 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; 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 |
78
+ | 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 |
79
79
 
80
80
  ## Live verification and remaining unknowns
81
81
 
@@ -117,7 +117,7 @@ first-ever install is invisible until the next server start.
117
117
  ## Interactions with other features
118
118
 
119
119
  - Extraction pipeline ([extraction.md](extraction.md)): direct extraction is triggered by `session.status idle`; child lifecycle managed here
120
- - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers
120
+ - Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers; task-dispatched sub-agents (in `childToParent`, not in `extractionChildren`) suppress all tiers
121
121
  - Hygiene ([hygiene.md](hygiene.md)): hygiene report runs at `session.created` for top-level sessions
122
122
  - Compaction recovery ([compaction-recovery.md](compaction-recovery.md)): `session.compacted` event clears the compacting flag
123
123
  - Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which triggers drain via `tool.execute.after`
@@ -41,7 +41,7 @@ here first. These are the things that have already cost time.
41
41
  a memory, write it with `archived: true`; to unarchive, `archived: false`.
42
42
  - **Updating an archived memory requires explicit `archived` param.** If the
43
43
  entry is already archived and a `remember` call omits the `archived` param,
44
- the tool returns an error (`db.ts:395`). Pass `archived: true` to keep it
44
+ the tool returns an error (`db.ts:585`). Pass `archived: true` to keep it
45
45
  archived or `archived: false` to unarchive. This guard prevents accidental
46
46
  unarchival via an unrelated content update.
47
47
 
@@ -229,3 +229,73 @@ wrapped tool's hook semantics instead of buffering. Lesson for sibling
229
229
  hooks: when a filter reasons about tool identity, an aggregator tool
230
230
  (arbitrary code execution over other tools) defeats name-based matching -
231
231
  unwrap the aggregation before classifying.
232
+
233
+ ## LLMs guess tool-call shapes on the first call, so teach the shape at the mention site
234
+
235
+ A September 2026 audit of every thatch tool-call error in the opencode
236
+ session database (~19k calls, both v1 and v2 storage) found the failures
237
+ cluster on the model's FIRST thatch call - usually the startup recall round -
238
+ and fall into a short list of avoidable mistakes:
239
+
240
+ - **Bare positional string instead of an object argument** (the largest
241
+ bucket): `thatch_memory_recall({ query: "..." })` called as
242
+ `thatch_memory_recall("...")`. Root cause: the opencode system prompt's
243
+ startup section and the recall nudge both used positional-style examples
244
+ (`thatch_memory_recall "query"`), and Code Mode models mimic that shape
245
+ inside `execute`. Both now teach the object form.
246
+ - **Required discriminator params omitted**: `memory_remember` without
247
+ `label` (models pass `title`/`id`/`statement` instead, or nothing),
248
+ `prediction_update` without `signal`, `behavior_codify` without
249
+ `situation`. Fixes: `label` is optional and derived from content;
250
+ tool descriptions state the all-args-required contract.
251
+ - **Nudges leak into tool-less agent contexts**: task-dispatched sub-agents
252
+ (explore/general and friends) have restricted tool lists that exclude the
253
+ thatch tools, but `chat.message` nudges fired for them anyway, producing
254
+ guaranteed "No tool named ..." error rounds. The chat.message hook now
255
+ skips nudges for child sessions that are not extraction children.
256
+
257
+ The general lesson: any surface that names a tool to an LLM (system prompt,
258
+ nudge, skill text) is a usage-shape prior. Give the argument shape at the
259
+ mention site, because the model's first call happens before it has seen a
260
+ working example. Also note Code Mode `execute` validation failures are only
261
+ persisted in the v2 `session_message` store - they are invisible in the v1
262
+ `part` table, so error-rate analysis on old data undercounts them.
263
+
264
+ ## ThatchDB lazy reopen: new accessors must go through the private getters, and journal writes stay guarded at call sites
265
+
266
+ `ThatchDB` reopens its SQLite connection on first use after `close()`
267
+ (`src/db.ts`, the `#db`/`#predictions`/`#behaviors`/`#chat` private getters), so
268
+ an in-flight tool call that races a v2 plugin reload's `dispose()` completes
269
+ instead of throwing "Cannot use a closed database". Two rules keep this intact:
270
+
271
+ - **New facade methods need no extra wiring, but new state-holding members
272
+ do.** The engine facades (`ChatStore`, `PredictionEngine`, `BehaviorEngine`)
273
+ capture the `Database` handle at construction; the reopen rebuilds them. A
274
+ new member that captures the Database directly must get its own private
275
+ getter that ensures the handle is open first - a plain field keeps serving
276
+ the stale closed handle and the reopen silently never fires for it (this
277
+ exact bug is pinned by the "engine facades are rebuilt" test in
278
+ tests/db.test.ts).
279
+ - **Post-dispose journal writes are no-ops at their call sites, not in
280
+ db.ts.** After a reload, the RELOADED instance owns the `runtime_state`
281
+ rows, so delayed writers in `src/runtime.ts` check the `disposed` flag
282
+ BEFORE touching the db - the lazy reopen would otherwise hand a stale
283
+ writer a fresh connection. All three journal callbacks are guarded (the
284
+ extraction pipeline's finalization, the WatcherRegistry journal - a poll
285
+ cycle suspended at an await can outlive `watchers.dispose()` - and the
286
+ wrap-up arming write, whose command executes are not drained by the v2
287
+ cleanup). Do not "fix" the guard away by moving it into db.ts: the
288
+ accessor cannot tell a legitimate in-flight caller from a stale writer.
289
+
290
+ ## Command frontmatter descriptions must be quoted YAML scalars
291
+
292
+ - **A plain-scalar frontmatter value containing a colon followed by a space
293
+ is invalid YAML, and not every host path rescues it.** `/thatch/hygiene`'s
294
+ description ("Tend the memory store: stale entries, ...") shipped unquoted;
295
+ the first parse throws
296
+ and only opencode's fallback sanitizer recovers it — and a long-lived
297
+ server was observed dropping the description entirely while a fresh server
298
+ parsed it fine, which made the bug look like a TUI rendering issue. The
299
+ renderer quotes every description (`yamlQuote` in src/commands.ts) and a
300
+ unit test pins the quoted form. Keep it that way for any new frontmatter
301
+ field whose value is free-form prose.
@@ -23,7 +23,7 @@ This document maps the feature parity and documents the remaining gaps.
23
23
  | **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
24
24
  | **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
25
25
  | **Wrap-up commands** (`/thatch/compact`, `/thatch/exit`) | Command markdown synced to `$XDG_CONFIG_HOME/opencode/command/thatch/` at plugin init; `command.execute.before` arms the session and the next idle resolves the greenlight token into a TUI action (`session.compact` via `executeCommand`, `app.exit` via `publish`) | No equivalent — no plugin host to arm the check or trigger the action | No equivalent | **Gapped** — needs the plugin's SDK client and TUI control routes |
26
- | **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
26
+ | **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`, `refine`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
27
27
  | **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-meta tool calls in-process (`isMetaToolName` in `src/extraction.ts`: thatch_*, skill, task, agent, subagent — execute calls unwrapping only thatch tools are also excluded); **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
28
28
  | **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
29
29
  | **Automatic alerts** (notify on LLM pause / turn end) | Plugin event hook drives the `alerts.ts` state machine: `question.asked`/`permission.asked` → pause alert; `session.status` idle with a real-work round → done alert; unrecovered errors → needs-attention. Delivered via the shared `notify.ts` dispatcher, channel per event from the `alerts` config section | No event stream — only `thatch_notify_user` when the agent chooses | No event stream — only `thatch_notify_user` when the agent chooses | **Gapped** — MCP hosts have no plugin event hook to watch; Claude Code's `Notification`/`Stop` hooks could feed the same classifier later |
@@ -25,7 +25,7 @@ code (`src/opencode/v1.ts`):
25
25
  | `experimental.session.compacting` | `{ sessionID }` | `{ context: string[] }` | Marks the session as compacting, pushes re-familiarization context. |
26
26
  | `experimental.compaction.autocontinue` | `{ sessionID }` | `{ enabled: boolean }` | Clears the compacting flag so `chat.message` nudges resume post-compaction. |
27
27
  | `tool.execute.after` | `{ tool, sessionID, callID, args }` | `{ title, output, metadata }` | Buffers non-`thatch_`, non-`skill`, non-`task` tool calls into the in-memory extraction ring buffer (max 20). In child sessions, also tracks new/updated/deleted counts via `childMetrics` for the extraction toast. |
28
- | `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting or while `extracting` is set. |
28
+ | `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting, while `extracting` is set, or for task-dispatched sub-agent sessions (children that are not extraction children). |
29
29
  | `event` | `{ event: { type, properties } }` | — | Dispatches on `event.type`: `session.created` (top-level) → `client.session.prompt` with reminder + hygiene heartbeat; `session.created` (child with `parentID`) → records `childToParent` + snapshots parent's pending buffer. `session.status` idle (parent, pending interactions) → `triggerExtraction` (direct child-session extraction); idle (child) → complete/drain, `showToast` with metrics, delete child. `session.error` (child) → requeue parent's accepted entries. `session.deleted` → requeue if child, drop accepted if parent, clean maps. `session.compacted` → clear compacting flag. |
30
30
  | `dispose` | — | — | Closes the DB. |
31
31
 
@@ -55,7 +55,14 @@ description: Extract durable project facts ... Use when ...
55
55
  | `thatch-code-walkthrough` | Explain a feature, module, or workflow to the user as a teaching walkthrough: identify the code area (optionally from a branch or PR), research how it works, teach it with file:line citations and analogies, list the key files. |
56
56
  | `thatch-session-reflection` | End-of-session memory recording (project, user, tools, self). |
57
57
  | `thatch-coding-workflow` | Plan and execute code changes with a task-list-driven workflow. Use when implementing features, fixing bugs, or making multi-file changes. |
58
- | `thatch-plan-refinement` | Refine a plan before implementing it: fresh-context reviewer rounds until consensus, with an optional deep-mode lens fan-out (reuse, alternatives, hidden problems, safe-to-modify, archaeology). |
58
+ | `thatch-plan-refinement` | Refine a plan before implementing it: fresh-context reviewer rounds until consensus, with an optional deep-mode lens fan-out (reuse, alternatives, hidden problems, safe-to-modify, archaeology). The generic core loop. |
59
+ | `thatch-refine` | Classify the project (audience, lifecycle, surface) and refine a plan under that type's requirements. Entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
60
+ | `thatch-refine-team-app` | Refinement deltas for multi-developer team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
61
+ | `thatch-refine-personal` | Refinement deltas for solo projects with no downstream consumers: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
62
+ | `thatch-refine-shared-lib` | Refinement deltas for changes touching a library's API surface: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
63
+ | `thatch-refine-spike` | Refinement deltas for throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on, state the question and the time box. |
64
+ | `thatch-refine-infra` | Refinement deltas for CI, Terraform, and k8s changes: blast radius across pipelines and environments, rollback plan and staged verification are mandatory, pattern-following is near-absolute. |
65
+ | `thatch-refine-oss-contribution` | Refinement deltas for PRs to repos you do not maintain: the maintainer's design authority governs, minimal diff, upstream discussion gates significant design work. |
59
66
  | `thatch-pr-description` | Draft PR descriptions with SYNOPSIS / PURPOSE / DESCRIPTION / WALK-THROUGH / NOTES, project-context research, clarity checks, and bold+italic emphasis for scanning. |
60
67
  | `thatch-ticket-description` | Draft ticket/issue descriptions with clear sections, project-context research, clarity checks, and bold+italic emphasis for scanning. |
61
68
  | `thatch-split-overlarge-pr` | Split already-completed work from an overlarge PR into human-reviewable, release-safe PRs targeting main. |
@@ -104,7 +104,7 @@ Stores are created automatically. No setup required.
104
104
 
105
105
  | Tool | What it does |
106
106
  |------|-------------|
107
- | `thatch_memory_remember` | Save a piece of information. Label it, and thatch embeds it for later recall. |
107
+ | `thatch_memory_remember` | Save a piece of information; thatch embeds it for later recall. The label is optional - it is derived from the content's first heading or line when omitted. |
108
108
  | `thatch_memory_recall` | Search for relevant information using natural language. Searches both the current project's store and `global` by default. |
109
109
  | `thatch_memory_list` | List all memory labels in a store. |
110
110
  | `thatch_memory_show` | Read the full content of a memory by exact label. |
@@ -328,6 +328,13 @@ response, walkthrough, and workflow skills that support the review pipeline:
328
328
  | `thatch-code-walkthrough` | Explains a feature, module, or workflow to the user as a teaching walkthrough with file:line citations. Also used to draft high-level docs for new or undocumented features. |
329
329
  | `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
330
330
  | `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
331
+ | `thatch-refine` | Classifies the project (audience, lifecycle, surface) and refines a plan under that type's requirements. The entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
332
+ | `thatch-refine-team-app` | Team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
333
+ | `thatch-refine-personal` | Solo projects: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
334
+ | `thatch-refine-shared-lib` | Library API surfaces: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
335
+ | `thatch-refine-spike` | Throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on. |
336
+ | `thatch-refine-infra` | CI, Terraform, and k8s changes: blast radius, mandatory rollback plan and staged verification, pattern-following is near-absolute. |
337
+ | `thatch-refine-oss-contribution` | PRs to repos you do not maintain: the maintainer's design authority governs, minimal diff, upstream discussion gates significant design work. |
331
338
 
332
339
  ### Writing skills
333
340
 
@@ -17,6 +17,13 @@ or you invoke it.
17
17
  The agent asks before forgetting anything that may still matter.
18
18
  - `/thatch/reflect` — run the session-reflection skill to persist what the
19
19
  current session learned.
20
+ - `/thatch/refine` — refine a plan for a code change under the current
21
+ project type's requirements: the agent classifies the project (team,
22
+ personal, shared library, spike, infrastructure, or open source
23
+ contribution), confirms the pick, and runs the refinement loop with
24
+ that type's expectations applied. Text after the command can name the
25
+ type directly (for example, `/thatch/refine shared-lib`) to skip
26
+ classification.
20
27
  - `/thatch/compact` and `/thatch/exit` — the wrap-up commands. They run a
21
28
  pre-flight checklist (flush extraction, finish promised memory writes,
22
29
  surface loose ends) before a compaction or an exit, gated by a greenlight
@@ -31,6 +38,7 @@ or you invoke it.
31
38
  | `/thatch/extract` | yes | no | no |
32
39
  | `/thatch/hygiene` | yes | yes | yes (as an MCP prompt) |
33
40
  | `/thatch/reflect` | yes | yes | yes (as an MCP prompt) |
41
+ | `/thatch/refine` | yes | yes | yes (as an MCP prompt) |
34
42
  | `/thatch/compact`, `/thatch/exit` | yes | no | no |
35
43
 
36
44
  Why the gaps: `/thatch/extract` needs the invoking session's ID, which only
@@ -33,7 +33,7 @@ store when recalling.
33
33
 
34
34
  | Tool | What it does |
35
35
  |------|-------------|
36
- | `thatch_memory_remember` | Save a memory with a label and content. Optional: branch, confidence (1-10), archived flag, overwrite. |
36
+ | `thatch_memory_remember` | Save a memory with a label and content. The label may be omitted - it is derived from the content's first heading or line. Optional: branch, confidence (1-10), archived flag, overwrite. |
37
37
  | `thatch_memory_recall` | Semantic search across stores. Returns matching memories with similarity scores. |
38
38
  | `thatch_memory_list` | List all memories in a store (metadata only, no content). |
39
39
  | `thatch_memory_show` | Show full content of a single memory by label. |
@@ -77,6 +77,18 @@ Skills are split into shared (all hosts) and opencode-only:
77
77
  | `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
78
78
  | `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
79
79
 
80
+ ### Plan refinement skills
81
+
82
+ | Skill | Purpose |
83
+ |-------|---------|
84
+ | `thatch-refine` | Classifies the project (audience, lifecycle, surface) and refines a plan under that type's requirements. The entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
85
+ | `thatch-refine-team-app` | Team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
86
+ | `thatch-refine-personal` | Solo projects: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
87
+ | `thatch-refine-shared-lib` | Library API surfaces: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
88
+ | `thatch-refine-spike` | Throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on. |
89
+ | `thatch-refine-infra` | CI, Terraform, and k8s changes: blast radius, mandatory rollback plan and staged verification, pattern-following is near-absolute. |
90
+ | `thatch-refine-oss-contribution` | PRs to repos you do not maintain: the maintainer's design authority governs, minimal diff, upstream discussion gates significant design work. |
91
+
80
92
  ## Stale skill cleanup
81
93
 
82
94
  When skills are renamed or removed in a new release, thatch automatically
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/chat.ts CHANGED
@@ -1371,6 +1371,23 @@ export function hostedSessionIds(options: {
1371
1371
  return [...new Set(hosted)].filter((id) => !excluded.has(id));
1372
1372
  }
1373
1373
 
1374
+ /**
1375
+ * Grace-rehost ids still inside their rehost window, pruning expired
1376
+ * entries as a side effect (the map is the runtime's live restart-grace
1377
+ * state - see CHAT_REHOST_GRACE_MS in runtime.ts). A restart re-hosts the
1378
+ * dead daemon's hosted set under this grace: open TUI tabs reconnect but
1379
+ * emit no events until the user types, so without it they go falsely
1380
+ * stale while alive-idle.
1381
+ */
1382
+ export function unexpiredGraceRehosts(grace: Map<string, number>, now: number): string[] {
1383
+ const live: string[] = [];
1384
+ for (const [id, deadline] of grace) {
1385
+ if (now >= deadline) grace.delete(id);
1386
+ else live.push(id);
1387
+ }
1388
+ return live;
1389
+ }
1390
+
1374
1391
  export class ChatPoller {
1375
1392
  #opts: Required<ChatPollerOptions>;
1376
1393
  #timer: ReturnType<typeof setInterval> | null = null;
package/src/commands.ts CHANGED
@@ -100,6 +100,16 @@ After dispatching, call thatch_extraction_done to acknowledge.`,
100
100
  description: "Tend the memory store: stale entries, orphans, pending dedup pairs",
101
101
  body: hygieneCore(),
102
102
  },
103
+ {
104
+ name: "refine",
105
+ description: "Refine a plan under the project type's requirements (classifies the project first)",
106
+ // A skill trigger, not a nudge behavior: the body points at the
107
+ // thatch-refine router skill, which classifies the project and loads
108
+ // the matching type deltas on top of the plan-refinement core loop.
109
+ body: `Load and follow the thatch-refine skill to refine a plan for the current project.
110
+
111
+ The user message may name a project type (team-app, personal, shared-lib, spike, infra, oss-contribution); if it does, skip classification and use that type. If no plan is drafted yet in this session, the skill's loop starts with drafting one.`,
112
+ },
103
113
  {
104
114
  name: "reflect",
105
115
  description: "Persist what this session learned as memories",
@@ -108,10 +118,20 @@ After dispatching, call thatch_extraction_done to acknowledge.`,
108
118
  ];
109
119
  }
110
120
 
121
+ /**
122
+ * Renders a YAML double-quoted scalar. Plain scalars break on values
123
+ * containing ": " (invalid YAML - the mapping key would be ambiguous), and
124
+ * opencode's lenient fallback parser does not rescue every host path, so the
125
+ * description must always be quoted.
126
+ */
127
+ function yamlQuote(value: string): string {
128
+ return JSON.stringify(value);
129
+ }
130
+
111
131
  /** Renders one action as a full command markdown file. */
112
132
  export function renderActionCommand(action: ActionDef): string {
113
133
  return `---
114
- description: ${action.description}
134
+ description: ${yamlQuote(action.description)}
115
135
  ---
116
136
  ${userMessageSection}
117
137
 
@@ -121,7 +141,7 @@ ${action.body}`;
121
141
  }
122
142
 
123
143
  const COMPACT_TEMPLATE = `---
124
- description: Flush thatch persistence, check for loose ends, then compact if clear
144
+ description: ${yamlQuote("Flush thatch persistence, check for loose ends, then compact if clear")}
125
145
  ---
126
146
  ${userMessageSection}
127
147
 
@@ -138,7 +158,7 @@ ${COMPACT_READY_TOKEN}
138
158
  If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
139
159
 
140
160
  const EXIT_TEMPLATE = `---
141
- description: Flush thatch persistence, check for loose ends, then exit opencode if clear
161
+ description: ${yamlQuote("Flush thatch persistence, check for loose ends, then exit opencode if clear")}
142
162
  ---
143
163
  ${userMessageSection}
144
164
 
package/src/db.ts CHANGED
@@ -55,12 +55,67 @@ export interface DedupCandidate {
55
55
  * stored as BLOBs. Similarity search is brute-force cosine in JS.
56
56
  */
57
57
  export class ThatchDB {
58
- #db: Database;
59
- #predictions: PredictionEngine;
60
- #behaviors: BehaviorEngine;
61
- #chat: ChatStore;
58
+ #path: string;
59
+ #handle: Database | null = null;
60
+ #predictionsStore!: PredictionEngine;
61
+ #behaviorsStore!: BehaviorEngine;
62
+ #chatStore!: ChatStore;
62
63
 
63
64
  constructor(path: string) {
65
+ this.#path = path;
66
+ this.#open();
67
+ }
68
+
69
+ /**
70
+ * Connection accessor. Every read goes through here, so a handle closed by
71
+ * dispose() (a v2 plugin reload tears down the old activation while tool
72
+ * calls may still be in flight) transparently reopens on the next touch
73
+ * instead of surfacing bun:sqlite's "Cannot use a closed database". WAL
74
+ * journaling plus busy_timeout make the extra connection safe - the chat
75
+ * subsystem already runs as several concurrent connections by design.
76
+ *
77
+ * OWNERSHIP RULE: reopen serves READS and in-flight callers only. After a
78
+ * reload, the RELOADED instance owns the runtime_state journal rows, so
79
+ * post-dispose journal writes must no-op - they are guarded by the
80
+ * disposed flag at their call sites in src/runtime.ts, checked BEFORE any
81
+ * db access reaches this accessor. Do not move that guard in here: this
82
+ * method cannot distinguish a legitimate in-flight caller from a stale
83
+ * writer.
84
+ *
85
+ * Reopen caveat: a `:memory:` database has no file behind it, so a
86
+ * close-then-use cycle on one yields a brand-new EMPTY database. No
87
+ * production path does that (:memory: is test-only and tests never use the
88
+ * instance after close), but keep it that way - use a tmp-file db if a
89
+ * test needs close-then-use.
90
+ */
91
+ get #db(): Database {
92
+ if (this.#handle === null) this.#open();
93
+ return this.#handle!;
94
+ }
95
+
96
+ // The engine facades get the same treatment as #db: every accessor
97
+ // ensures an open connection first, so delegation routes through the
98
+ // facade REBUILT by the reopen rather than the stale one. Without this,
99
+ // a post-close chat call would reach the old ChatStore's captured handle
100
+ // and throw "Cannot use a closed database" even though ThatchDB itself
101
+ // reopened.
102
+ get #predictions(): PredictionEngine {
103
+ if (this.#handle === null) this.#open();
104
+ return this.#predictionsStore;
105
+ }
106
+
107
+ get #behaviors(): BehaviorEngine {
108
+ if (this.#handle === null) this.#open();
109
+ return this.#behaviorsStore;
110
+ }
111
+
112
+ get #chat(): ChatStore {
113
+ if (this.#handle === null) this.#open();
114
+ return this.#chatStore;
115
+ }
116
+
117
+ #open(): void {
118
+ const path = this.#path;
64
119
  // bun:sqlite creates the file but not its parent directory. On a fresh
65
120
  // machine the default path (~/.config/thatch/thatch.db) has no dir yet, so
66
121
  // create it here - otherwise every command fails with SQLITE_CANTOPEN.
@@ -68,18 +123,23 @@ export class ThatchDB {
68
123
  if (path !== ":memory:" && !path.startsWith("file:")) {
69
124
  mkdirSync(dirname(path), { recursive: true });
70
125
  }
71
- this.#db = new Database(path, { create: true });
72
- this.#db.run("PRAGMA journal_mode = WAL");
73
- this.#db.run("PRAGMA busy_timeout = 5000");
126
+ const db = new Database(path, { create: true });
127
+ db.run("PRAGMA journal_mode = WAL");
128
+ db.run("PRAGMA busy_timeout = 5000");
74
129
  // Enforce FK constraints per connection. The prediction tables use
75
130
  // ON DELETE CASCADE for edges and provenance; enabling this pragma
76
131
  // ensures those cascades fire. Also enforces the entries table's
77
132
  // existing FK to stores(name), which was declarative but unenforced
78
133
  // before this branch.
79
- this.#db.run("PRAGMA foreign_keys = ON");
80
- this.#predictions = new PredictionEngine(this.#db);
81
- this.#behaviors = new BehaviorEngine(this.#db);
82
- this.#chat = new ChatStore(this.#db);
134
+ db.run("PRAGMA foreign_keys = ON");
135
+ this.#handle = db;
136
+ // The engine facades hold the Database handle they were constructed
137
+ // with, so a reopened connection rebuilds them. They are stateless
138
+ // views over SQLite - all durable state lives in the tables - so
139
+ // reconstruction is lossless.
140
+ this.#predictionsStore = new PredictionEngine(db);
141
+ this.#behaviorsStore = new BehaviorEngine(db);
142
+ this.#chatStore = new ChatStore(db);
83
143
  this.#initSchema();
84
144
  }
85
145
 
@@ -1169,7 +1229,12 @@ export class ThatchDB {
1169
1229
  }
1170
1230
 
1171
1231
  close(): void {
1172
- this.#db.close();
1232
+ // Drops the current handle only. A later use reopens (see the #db
1233
+ // accessor) - the MCP server and CLI call this on process shutdown,
1234
+ // where nothing follows; the opencode plugin's dispose calls it on
1235
+ // reload, where straggler in-flight tool calls must survive.
1236
+ this.#handle?.close();
1237
+ this.#handle = null;
1173
1238
  }
1174
1239
  }
1175
1240
 
package/src/extraction.ts CHANGED
@@ -233,6 +233,93 @@ export class ExtractionPipeline {
233
233
  this.#journal?.("accepted", sessionID, undefined);
234
234
  }
235
235
 
236
+ #claims = new Map<string, { target: string; entries: ToolInteraction[] }>();
237
+
238
+ /**
239
+ * Claim the session's pending + previously-accepted entries for a fetcher
240
+ * (the extractor sub-agent fetching its payload). The entries stay in the
241
+ * accepted holding area; the claim records WHICH fetcher they were
242
+ * delivered to, so that fetcher's completion signal consumes only its own
243
+ * delivery. Without this, any child's completion dropped the whole
244
+ * accepted set - a sibling sub-agent going idle at the wrong moment
245
+ * silently discarded another extractor's in-flight payload (the
246
+ * accept-then-racing-completion loss, observed September 2026).
247
+ *
248
+ * Entries are claimed by reference identity: later accepts append new
249
+ * arrays, so a stale claim never reaches entries claimed after it.
250
+ */
251
+ claim(sessionID: string, fetcherID: string): ToolInteraction[] {
252
+ this.accept(sessionID);
253
+ const accepted = this.#accepted.get(sessionID) ?? [];
254
+ this.#claims.set(fetcherID, { target: sessionID, entries: accepted });
255
+ return accepted;
256
+ }
257
+
258
+ /**
259
+ * Consume only the fetcher's claimed entries. Returns false when the
260
+ * fetcher holds no claim - a sibling that never fetched, or a fetch that
261
+ * never happened. Callers must NOT fall back to whole-set completion on
262
+ * the idle path: a fetcher that never fetched processed nothing, and
263
+ * dropping the set would lose another extractor's claimed payload.
264
+ */
265
+ completeClaimed(fetcherID: string): boolean {
266
+ const claim = this.#claims.get(fetcherID);
267
+ if (!claim) return false;
268
+ this.#claims.delete(fetcherID);
269
+ const accepted = this.#accepted.get(claim.target);
270
+ if (accepted) {
271
+ const claimed = new Set(claim.entries);
272
+ const rest = accepted.filter((ix) => !claimed.has(ix));
273
+ if (rest.length > 0) {
274
+ this.#accepted.set(claim.target, rest);
275
+ this.#journal?.("accepted", claim.target, rest);
276
+ } else {
277
+ this.#accepted.delete(claim.target);
278
+ this.#acceptedAt.delete(claim.target);
279
+ this.#journal?.("accepted", claim.target, undefined);
280
+ }
281
+ }
282
+ return true;
283
+ }
284
+
285
+ /** Forget a fetcher's claim (its session was deleted or cleaned up). */
286
+ dropClaim(fetcherID: string): void {
287
+ this.#claims.delete(fetcherID);
288
+ }
289
+
290
+ /**
291
+ * Requeue only the fetcher's claimed entries back to pending: its session
292
+ * errored, so the delivery was never processed. Returns false when the
293
+ * fetcher holds no claim - a task-kind child that never fetched processed
294
+ * nothing, and requeueing the whole accepted set here would yank another
295
+ * extractor's in-flight claim back to pending for duplicate extraction.
296
+ * Callers decide whether a claim-less error falls back to whole-set
297
+ * requeue (extraction children: yes - the error is a strong immediate
298
+ * death signal; task children: no).
299
+ */
300
+ requeueClaimed(fetcherID: string): boolean {
301
+ const claim = this.#claims.get(fetcherID);
302
+ if (!claim) return false;
303
+ this.#claims.delete(fetcherID);
304
+ const accepted = this.#accepted.get(claim.target);
305
+ if (accepted) {
306
+ const claimed = new Set(claim.entries);
307
+ const rest = accepted.filter((ix) => !claimed.has(ix));
308
+ if (rest.length > 0) {
309
+ this.#accepted.set(claim.target, rest);
310
+ this.#journal?.("accepted", claim.target, rest);
311
+ } else {
312
+ this.#accepted.delete(claim.target);
313
+ this.#acceptedAt.delete(claim.target);
314
+ this.#journal?.("accepted", claim.target, undefined);
315
+ }
316
+ }
317
+ const buf = this.#buffers.get(claim.target) ?? [];
318
+ this.#buffers.set(claim.target, [...claim.entries, ...buf]);
319
+ this.#journal?.("buffer", claim.target, this.#buffers.get(claim.target));
320
+ return true;
321
+ }
322
+
236
323
  /**
237
324
  * Move accepted entries back to pending: the extractor errored or its
238
325
  * session was deleted before completing, so the facts were never
@@ -254,9 +341,8 @@ export class ExtractionPipeline {
254
341
  /**
255
342
  * Requeues accepted entries that have waited longer than maxAgeMs for a
256
343
  * completion signal that never came: the extractor sub-agent crashed
257
- * without acking, or (v2) completed without a parent linkage the plugin
258
- * could see. Without this, accepted entries linger unprocessed AND
259
- * un-nudged - silent loss. Returns the session ids that had entries
344
+ * without acking, or the completion ack was lost. Without this, accepted
345
+ * entries linger unprocessed AND un-nudged - silent loss. Returns the session ids that had entries
260
346
  * requeued; the regular nudge path picks them up from pending on the
261
347
  * session's next message.
262
348
  */
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  // SDK. The pure helpers re-exported here live in SDK-free modules for the
15
15
  // same reason.
16
16
 
17
+ import { appendFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
17
19
  import type { Plugin } from "@opencode-ai/plugin";
18
20
  import type { Plugin as V2Plugin } from "@opencode/plugin";
19
21
 
@@ -27,6 +29,49 @@ export {
27
29
  } from "./os-args";
28
30
  export { hygieneReport } from "./hygiene";
29
31
 
32
+ // One-per-process unhandledRejection logger, opt-in via THATCH_DEBUG. The
33
+ // host logs these bare ("unhandled rejection" + message, no stack), which is
34
+ // exactly wrong for diagnosing which async continuation in the plugin
35
+ // escaped its error handling - the dispose-race class in particular escapes
36
+ // through several surfaces (pollers, hooks, delayed finalizers). Writes to
37
+ // the debug log beside the database (same file createDebugLog uses), tagged
38
+ // so a grep on "[rejection:" pulls the story out.
39
+ //
40
+ // Registered at module scope because rejections can fire before or after
41
+ // setup runs, and once per process because v2 re-evaluates this module on
42
+ // every plugin reload (a bare process.on would pile up listeners). The key
43
+ // is a Symbol.for so the guard survives module re-evaluation in the same
44
+ // thread; worker threads have their own globalThis and process, and one
45
+ // logger per thread is correct anyway. Registering the listener marks the
46
+ // rejections "handled" - suppressing the host's default crash-on-unhandled -
47
+ // so the handler also echoes the detail to console.error to keep the
48
+ // failure visible while diagnosing.
49
+ const rejectionLoggerKey = Symbol.for("thatch.unhandledRejectionLogger");
50
+ if (process.env.THATCH_DEBUG && !(globalThis as any)[rejectionLoggerKey]) {
51
+ const home = process.env.HOME ?? "/tmp";
52
+ const configHome = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
53
+ const dbPath = process.env.THATCH_DB_PATH ?? `${configHome}/thatch/thatch.db`;
54
+ // A file: URI db path has no meaningful sibling debug.log (appendFileSync
55
+ // cannot open "file:.../debug.log"); skip logging rather than silently
56
+ // drop every rejection into the catch below.
57
+ (globalThis as any)[rejectionLoggerKey] = !dbPath.startsWith("file:");
58
+ if ((globalThis as any)[rejectionLoggerKey]) {
59
+ const file = join(dirname(dbPath), "debug.log");
60
+ process.on("unhandledRejection", (reason) => {
61
+ const detail =
62
+ reason instanceof Error
63
+ ? `${reason.message}\n${reason.stack ?? "(no stack)"}`
64
+ : String(reason);
65
+ console.error(`[thatch] unhandled rejection: ${detail.split("\n")[0]}`);
66
+ try {
67
+ appendFileSync(file, `${new Date().toISOString()} [rejection:unhandled] ${detail}\n`);
68
+ } catch {
69
+ // Diagnostics must not break the host (same contract as debug.log).
70
+ }
71
+ });
72
+ }
73
+ }
74
+
30
75
  // Named v1 export, preserved for the shim and tests. Lazy wrapper: never
31
76
  // evaluate the v1 adapter (and its SDK import) under a v2 host.
32
77
  export const server: Plugin = async (input, options) => (await import("./opencode/v1")).server(input, options);