@jeffober/thatch 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +48 -25
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +11 -0
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +25 -15
  6. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  7. package/artifacts/skills/thatch-refine-personal.md +5 -0
  8. package/artifacts/skills/thatch-refine.md +7 -1
  9. package/artifacts/skills/thatch-review-clarity.md +101 -0
  10. package/artifacts/skills/thatch-review-context.md +55 -2
  11. package/artifacts/skills/thatch-review-followup.md +4 -4
  12. package/artifacts/skills/thatch-review-response.md +1 -1
  13. package/artifacts/skills/thatch-review-synthesizer.md +22 -2
  14. package/bin/thatch +103 -33
  15. package/docs/dev/README.md +11 -8
  16. package/docs/dev/features/README.md +1 -1
  17. package/docs/dev/features/behavior-engine.md +10 -0
  18. package/docs/dev/features/cli.md +23 -7
  19. package/docs/dev/features/commands.md +40 -9
  20. package/docs/dev/features/cross-session-chat.md +99 -27
  21. package/docs/dev/features/database.md +4 -4
  22. package/docs/dev/features/nudge-pipeline.md +14 -0
  23. package/docs/dev/features/opencode-plugin.md +41 -15
  24. package/docs/dev/features/prediction-engine.md +27 -1
  25. package/docs/dev/features/qa-system.md +19 -3
  26. package/docs/dev/features/session-lifecycle.md +6 -6
  27. package/docs/dev/features/session-tabs.md +11 -5
  28. package/docs/dev/gotchas.md +21 -0
  29. package/docs/dev/mcp-parity.md +3 -3
  30. package/docs/dev/skills.md +12 -8
  31. package/docs/user/README.md +22 -14
  32. package/docs/user/behavior-engine.md +7 -1
  33. package/docs/user/cli.md +28 -15
  34. package/docs/user/code-review.md +46 -4
  35. package/docs/user/commands.md +27 -7
  36. package/docs/user/cross-session-chat.md +48 -20
  37. package/docs/user/prediction-engine.md +12 -1
  38. package/docs/user/skills.md +9 -6
  39. package/package.json +1 -1
  40. package/src/capabilities.ts +20 -6
  41. package/src/chat.ts +56 -24
  42. package/src/commands.ts +109 -18
  43. package/src/db.ts +23 -2
  44. package/src/mcp.ts +6 -2
  45. package/src/opencode/tui-plugin.ts +29 -9
  46. package/src/opencode/v2.ts +247 -66
  47. package/src/prompts.ts +55 -6
  48. package/src/runtime.ts +122 -17
  49. package/src/scoring-engine.ts +40 -9
  50. package/src/session-tab-shared.ts +55 -4
  51. package/src/skills.ts +5 -9
  52. package/src/tab-state.ts +125 -0
  53. package/src/tool-defs.ts +55 -22
package/src/tool-defs.ts CHANGED
@@ -133,6 +133,13 @@ export interface CoreContext {
133
133
  * authoritative over the caller-claimed `as` argument. Returns null when
134
134
  * no fresh mapping exists (Cursor, unknown server process). */
135
135
  chatDerivedIdentity?: () => string | null;
136
+ /** Reads the sessions that have an opencode tab open RIGHT NOW (the
137
+ * per-channel tabs.json reader plus confirmed-close overrides, computed
138
+ * fresh at each call). Roster rows carrying a tab render "open"; fresh
139
+ * rows without one render "recent", and broadcast targets only open
140
+ * rows. Unwired (tests, hosts with no local opencode state) means no
141
+ * tab signal: everything degrades to "recent", never a false "open". */
142
+ openTabs?: () => ReadonlySet<string>;
136
143
  /** Host-executed session-tab operations, wired ONLY by the opencode v2
137
144
  * adapter (post-construction into the shared CoreContext, because the
138
145
  * implementation closes over both the runtime's db and the v2 plugin
@@ -2131,12 +2138,17 @@ const chatRegisterDef: ToolDef = {
2131
2138
  };
2132
2139
 
2133
2140
  /** Lists registered sessions with liveness. Stale means the session's host
2134
- * process has not heartbeat-ed it recently - it is gone or hung. */
2141
+ * process has not heartbeat-ed it recently - it is gone or hung. Fresh
2142
+ * rows split into open (a tab is open now) and recent (no known tab).
2143
+ * The three sections render unguarded by design, unlike the CLI's
2144
+ * --recent/--stale guards: an agent choosing a recipient always needs
2145
+ * the full directory, including the tab-less rows it may still reach. */
2135
2146
  const chatListDef: ToolDef = {
2136
2147
  name: "chat_list",
2137
2148
  description:
2138
2149
  "List sessions registered in the cross-session chat directory, with a " +
2139
- "liveness marker (fresh = active, stale = likely gone), each session's " +
2150
+ "liveness marker (open = a tab is open now, recent = host alive but no " +
2151
+ "open tab, stale = host likely gone), each session's " +
2140
2152
  "project and topic, and your own unread count.",
2141
2153
  args: {
2142
2154
  as: mcpIdentityArg(),
@@ -2149,10 +2161,11 @@ const chatListDef: ToolDef = {
2149
2161
  return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
2150
2162
  }
2151
2163
  const unread = ctx.db.unreadChatCount(identity.sessionID);
2152
- const { active, stale } = splitChatRoster(sortChatRoster(sessions));
2164
+ const openTabs = ctx.openTabs?.() ?? new Set<string>();
2165
+ const { active, recent, stale } = splitChatRoster(sortChatRoster(sessions), Date.now(), null, openTabs);
2153
2166
  const line = (s: (typeof sessions)[number]) => {
2154
2167
  const self = s.session_id === identity.sessionID;
2155
- const liveness = chatLiveness(s);
2168
+ const liveness = chatLiveness(s, Date.now(), openTabs);
2156
2169
  const project = s.project ? ` project:${s.project}` : "";
2157
2170
  // The age turns the liveness bucket into a concrete last-check-in;
2158
2171
  // the checkout kind tells sessions on a shared tree who sits in a
@@ -2167,6 +2180,15 @@ const chatListDef: ToolDef = {
2167
2180
  if (active.length > 0) {
2168
2181
  sections.push(`# Active Sessions\n\n${active.map(line).join("\n")}`);
2169
2182
  }
2183
+ if (recent.length > 0) {
2184
+ sections.push(
2185
+ `# Recent Sessions\n\n` +
2186
+ `_These sessions are alive but have no open tab: a wake may not reach them ` +
2187
+ `until their tab reopens, though headless subordinates may still read mail ` +
2188
+ `on their own turns._\n\n` +
2189
+ `${recent.map(line).join("\n")}`,
2190
+ );
2191
+ }
2170
2192
  if (stale.length > 0) {
2171
2193
  sections.push(
2172
2194
  `# Stale Sessions\n\n` +
@@ -2178,13 +2200,17 @@ const chatListDef: ToolDef = {
2178
2200
  // The name-stability note closes the roster because it answers the
2179
2201
  // question a reader asking "is this the same X as before?" needs:
2180
2202
  // assigned names are unique forever; only hook-anchored MCP identities
2181
- // can be revisited by an old name.
2203
+ // can be revisited by an old name. The liveness legend decodes the
2204
+ // per-row verdicts: presence (open) is distinct from a live host
2205
+ // (recent) and from a stopped one (stale).
2182
2206
  return (
2183
2207
  `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n\n` +
2184
2208
  `${sections.join("\n\n")}\n\n` +
2209
+ `Liveness: open = the session has an opencode tab right now; recent = ` +
2210
+ `its harness is alive but no tab is open (wake delivery not guaranteed); ` +
2211
+ `stale = its harness has missed its heartbeat and may be gone. ` +
2185
2212
  `Names are assigned by thatch and never reused or reassigned, so the ` +
2186
- `same name is always the same session. A name whose row shows stale or ` +
2187
- `idle has not been seen recently.`
2213
+ `same name is always the same session.`
2188
2214
  );
2189
2215
  },
2190
2216
  };
@@ -2226,17 +2252,21 @@ const chatSendDef: ToolDef = {
2226
2252
  ? `\n\nDUPLICATE SUPPRESSED: an identical message sent moments ago is still UNREAD in the recipient's inbox, so no second copy was created - the original delivery stands. The same body delivers again once they have read it (or a few seconds past the dedupe window).`
2227
2253
  : "";
2228
2254
  // State the recipient's ACTUAL liveness: the boilerplate "waits until
2229
- // idle" is only true for a live host. A stale recipient's harness is
2230
- // probably gone - say so, so the sender can reroute instead of waiting
2231
- // on a wake that will never fire.
2255
+ // idle" is only true for a session with an open tab. A recent row's
2256
+ // harness is alive but the tab-less wake can be swallowed without
2257
+ // running a turn - say so instead of promising a wake that may never
2258
+ // fire. A stale recipient's harness is probably gone entirely.
2232
2259
  const row = ctx.db.findChatSession(session_id);
2233
- const liveness = row ? chatLiveness(row) : "stale";
2260
+ const liveness = row ? chatLiveness(row, Date.now(), ctx.openTabs?.()) : "stale";
2261
+ const lastSeen = row ? humanAge(row.last_seen) : "just now";
2234
2262
  const delivery =
2235
- liveness === "fresh"
2236
- ? `The recipient is fresh (last seen ${row ? humanAge(row.last_seen) : "just now"}) and will be woken when its session goes idle.`
2237
- : liveness === "active"
2238
- ? `The recipient is an MCP-host session; it sees this message at its next prompt.`
2239
- : `NOTE: the recipient is STALE (last seen ${row ? humanAge(row.last_seen) : "unknown"}). Its harness may no longer be running - the message waits unread until that session resumes, and no wake will fire meanwhile. If you meant a different session, check chat_list and resend.`;
2263
+ liveness === "open"
2264
+ ? `The recipient is open (last seen ${lastSeen}) and will be woken when its session goes idle.`
2265
+ : liveness === "recent"
2266
+ ? `NOTE: the recipient is RECENT (last seen ${lastSeen}): its harness is alive, but the session has NO OPEN TAB. The message waits in its inbox, but wake delivery is not guaranteed - a tab-less session may not run a turn on the wake. Daemon-resident sessions (subordinates) usually still pick the message up on their next activity; a closed tab may sit unread. If you expected a live window, check chat_list.`
2267
+ : liveness === "active"
2268
+ ? `The recipient is an MCP-host session; it sees this message at its next prompt.`
2269
+ : `NOTE: the recipient is STALE (last seen ${row ? humanAge(row.last_seen) : "unknown"}). Its harness may no longer be running - the message waits unread until that session resumes, and no wake will fire meanwhile. If you meant a different session, check chat_list and resend.`;
2240
2270
  return (
2241
2271
  `[sent] to ${name} (${session_id.slice(0, 12)})\n\n` +
2242
2272
  `${delivery}${splitNote}${dedupeNote}`
@@ -2344,11 +2374,12 @@ const chatBroadcastDef: ToolDef = {
2344
2374
  "Broadcast one message to every other registered session on this " +
2345
2375
  "machine at once - for announcements and open questions (\"which of " +
2346
2376
  "you is working on X?\", \"main just moved, rebase if you are based on " +
2347
- "it\"). Stale opencode sessions (harness stopped heartbeating) are skipped and " +
2348
- "reported, since they will never read the mail; MCP sessions receive " +
2349
- "it at their next prompt. Each recipient is woken like any chat " +
2350
- "message, so broadcast sparingly: every live session spends a model " +
2351
- "turn on it.",
2377
+ "it\"). Opencode sessions without an open tab (stale, or fresh but " +
2378
+ "tab-less) are skipped and reported: they are not reliably wakeable, so " +
2379
+ "messaging them would spend a turn on a delivery that likely never " +
2380
+ "runs. MCP sessions receive it at their next prompt. Each recipient is " +
2381
+ "woken like any chat message, so broadcast sparingly: every open " +
2382
+ "session spends a model turn on it.",
2352
2383
  args: {
2353
2384
  body: z.string().describe(
2354
2385
  `Message body, delivered to every other registered session. Bodies over ${CHAT_SPLIT_PART_LEN} characters ` +
@@ -2368,7 +2399,7 @@ const chatBroadcastDef: ToolDef = {
2368
2399
  `recipients: ${result.recipients.length > 0 ? result.recipients.join(", ") : "(none)"}`,
2369
2400
  ];
2370
2401
  if (result.parts > 1) lines.push(`sent as ${result.parts} parts per recipient (the body exceeded ${CHAT_SPLIT_PART_LEN} characters and was auto-split)`);
2371
- if (result.skipped.length > 0) lines.push(`skipped stale: ${result.skipped.join(", ")}`);
2402
+ if (result.skipped.length > 0) lines.push(`skipped (stale or no open tab): ${result.skipped.join(", ")}`);
2372
2403
  if (result.deduped > 0) lines.push(`duplicate suppressed: an identical broadcast sent moments ago is still UNREAD at ${result.deduped} recipient${result.deduped === 1 ? "" : "s"} - the original delivery stands; the same body delivers again to a recipient once they have read it`);
2373
2404
  return (
2374
2405
  lines.join("\n") +
@@ -2625,6 +2656,7 @@ export interface CoreContextExtensions {
2625
2656
  drainExtractionQueue?: CoreContext["drainExtractionQueue"];
2626
2657
  watcherRegistry?: WatcherRegistry;
2627
2658
  projectDir?: string;
2659
+ openTabs?: CoreContext["openTabs"];
2628
2660
  }
2629
2661
 
2630
2662
  /**
@@ -2652,6 +2684,7 @@ export function buildCoreContext(
2652
2684
  drainExtractionQueue: extensions?.drainExtractionQueue,
2653
2685
  watchers: extensions?.watcherRegistry,
2654
2686
  projectDir: extensions?.projectDir,
2687
+ openTabs: extensions?.openTabs,
2655
2688
  };
2656
2689
  }
2657
2690