@jeffober/thatch 0.1.48 → 0.2.1

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 (50) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/bin/thatch +31 -16
  5. package/docs/dev/README.md +39 -13
  6. package/docs/dev/features/behavior-engine.md +3 -2
  7. package/docs/dev/features/cicd.md +1 -1
  8. package/docs/dev/features/compaction-recovery.md +1 -1
  9. package/docs/dev/features/cross-session-chat.md +12 -3
  10. package/docs/dev/features/extraction.md +10 -2
  11. package/docs/dev/features/hygiene.md +3 -1
  12. package/docs/dev/features/memory-store.md +1 -1
  13. package/docs/dev/features/multi-host.md +4 -2
  14. package/docs/dev/features/notifications.md +121 -4
  15. package/docs/dev/features/nudge-pipeline.md +16 -2
  16. package/docs/dev/features/opencode-plugin.md +133 -0
  17. package/docs/dev/features/qa-system.md +43 -0
  18. package/docs/dev/features/repo-identity.md +1 -1
  19. package/docs/dev/features/session-lifecycle.md +6 -3
  20. package/docs/dev/features/watchers.md +12 -5
  21. package/docs/dev/gotchas.md +94 -4
  22. package/docs/dev/mcp-parity.md +4 -3
  23. package/docs/dev/setup-and-hooks.md +7 -2
  24. package/docs/plans/llm-alerts.md +287 -0
  25. package/docs/plans/prediction-consolidation.md +3 -3
  26. package/docs/user/README.md +8 -4
  27. package/docs/user/memory.md +1 -1
  28. package/docs/user/notifications.md +63 -4
  29. package/docs/user/setup.md +4 -0
  30. package/docs/user/watchers.md +15 -7
  31. package/package.json +3 -1
  32. package/src/alerts.ts +360 -0
  33. package/src/capabilities.ts +174 -0
  34. package/src/chat.ts +80 -5
  35. package/src/commands.ts +44 -5
  36. package/src/config.ts +32 -1
  37. package/src/db.ts +157 -12
  38. package/src/embeddings.ts +43 -0
  39. package/src/extraction.ts +210 -0
  40. package/src/index.ts +77 -1339
  41. package/src/opencode/v1.ts +54 -0
  42. package/src/opencode/v2.ts +605 -0
  43. package/src/os-args.ts +99 -0
  44. package/src/prompts.ts +181 -31
  45. package/src/runtime.ts +2004 -0
  46. package/src/seed-behaviors.ts +1 -1
  47. package/src/session-db.ts +32 -0
  48. package/src/tool-defs.ts +340 -46
  49. package/src/tools.ts +16 -35
  50. package/src/watchers.ts +109 -14
package/src/tools.ts CHANGED
@@ -1,48 +1,29 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import type { ThatchDB } from "./db";
3
- import type { EmbeddingModel } from "./embeddings";
4
- import { TOOL_DEFS, type CoreContext, type HostToolContext } from "./tool-defs";
5
- import type { WatcherRegistry } from "./watchers";
2
+ import { TOOL_DEFS, trimHostContext, type CoreContext } from "./tool-defs";
3
+
4
+ // V1-ONLY module: this file imports the opencode v1 SDK at runtime, so
5
+ // nothing shared may import from it. The host-agnostic pieces
6
+ // (buildCoreContext, trimHostContext, CoreContext, TOOL_DEFS) live in
7
+ // tool-defs.ts precisely so the v2 adapter and the MCP server can use them
8
+ // without evaluating the v1 SDK - the v2 plugin install skips optional
9
+ // peers, and a missing SDK import here would kill the whole plugin load.
6
10
 
7
11
  /**
8
- * Builds the opencode tool map from shared tool definitions. Each definition
9
- * in TOOL_DEFS is wrapped in opencode's `tool()` with a `thatch_` prefix
10
- * on the name - opencode uses the object key as the tool name, so the prefix
11
- * lives here, not in the shared definitions.
12
+ * Builds the v1 opencode tool map from shared tool definitions. Each
13
+ * definition in TOOL_DEFS is wrapped in opencode's `tool()` with a
14
+ * `thatch_` prefix on the name - opencode uses the object key as the tool
15
+ * name, so the prefix lives here, not in the shared definitions. Takes the
16
+ * shared CoreContext (buildCoreContext) rather than raw deps, so both host
17
+ * adapters feed the same context construction.
12
18
  */
13
- export function createTools(
14
- db: ThatchDB,
15
- model: EmbeddingModel,
16
- defaultStore: string,
17
- extensions?: {
18
- extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
19
- drainExtractionQueue?: CoreContext["drainExtractionQueue"];
20
- watcherRegistry?: WatcherRegistry;
21
- projectDir?: string;
22
- },
23
- ): Record<string, ReturnType<typeof tool>> {
24
- const ctx: CoreContext = {
25
- db,
26
- model,
27
- defaultStore,
28
- extractionPayloadProvider: extensions?.extractionPayloadProvider,
29
- drainExtractionQueue: extensions?.drainExtractionQueue,
30
- watchers: extensions?.watcherRegistry,
31
- projectDir: extensions?.projectDir,
32
- };
33
-
19
+ export function createTools(coreContext: CoreContext): Record<string, ReturnType<typeof tool>> {
34
20
  const tools: Record<string, ReturnType<typeof tool>> = {};
35
21
  for (const def of TOOL_DEFS) {
36
22
  tools[`thatch_${def.name}`] = tool({
37
23
  description: def.description,
38
24
  args: def.args,
39
25
  async execute(args, hostContext) {
40
- // Trim opencode's ToolContext to the host-agnostic fields the shared
41
- // definitions know about. Tests and MCP paths pass no host context.
42
- const host: HostToolContext | undefined = hostContext
43
- ? { sessionID: hostContext.sessionID, agent: hostContext.agent }
44
- : undefined;
45
- return def.execute(args as Record<string, unknown>, ctx, host);
26
+ return def.execute(args as Record<string, unknown>, coreContext, trimHostContext(hostContext));
46
27
  },
47
28
  });
48
29
  }
package/src/watchers.ts CHANGED
@@ -16,16 +16,14 @@
16
16
  * never its output (injection hygiene: command output can be external
17
17
  * content, so it never rides a notification).
18
18
  *
19
- * Lifetime is deliberately process-scoped. The registry lives in plugin
20
- * memory, never in SQLite: opencode loads thatch in-process, so the plugin
21
- * process is the natural owner of its watchers. This makes multi-instance
22
- * ownership structurally impossible (each process polls only what it
23
- * registered), leaves no orphan data behind, and matches the extraction
24
- * pipeline's opencode path, which is also in-memory. A crashed or restarted
25
- * opencode loses its watchers - acceptable, because the session that created
26
- * them lost its conversational context too, and a delivered notification
27
- * into a session that no longer knows why it is being watched is worse than
28
- * a lost watch.
19
+ * Lifetime is deliberately process-scoped for the LIVE registry: the
20
+ * poller and delivery state live in plugin memory, never in SQLite -
21
+ * opencode loads thatch in-process, so the plugin process is the natural
22
+ * owner of its watchers, and no cross-process "who polls this?" claim
23
+ * logic is needed. The DEFINITIONS, however, are journaled to
24
+ * runtime_state (the optional journal hook) so they survive v2 plugin
25
+ * reloads and, as dormant rows, full restarts - see the rearm() and the
26
+ * runtime's dormant-watcher scan.
29
27
  */
30
28
 
31
29
  // ---------------------------------------------------------------------------
@@ -280,6 +278,8 @@ export interface WatcherRegistryOptions {
280
278
  pollIntervalMs?: number;
281
279
  ttlMinutes?: number;
282
280
  maxPerSession?: number;
281
+ /** Persistence hook: called after membership changes with the session's watcher list. */
282
+ journal?: (sessionID: string, watchers: Watcher[]) => void;
283
283
  }
284
284
 
285
285
  // ---------------------------------------------------------------------------
@@ -391,6 +391,17 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
391
391
  }
392
392
  }
393
393
 
394
+ /**
395
+ * The stable target label a watcher reports in events, lists, and recovery
396
+ * notices: "owner/repo#N" for PRs, "owner/repo@branch" for branches, and a
397
+ * one-line clip of the command for command watches.
398
+ */
399
+ export function watcherTarget(watcher: Watcher): string {
400
+ if (watcher.source === "pr") return `${watcher.repo}#${watcher.pr}`;
401
+ if (watcher.source === "branch") return `${watcher.repo}@${watcher.branch}`;
402
+ return commandTargetLabel(watcher.command);
403
+ }
404
+
394
405
  let ghAvailability: boolean | null = null;
395
406
 
396
407
  /**
@@ -829,6 +840,13 @@ export class WatcherRegistry {
829
840
  #timer: ReturnType<typeof setInterval> | null = null;
830
841
  #delivering = false;
831
842
  #polling = false;
843
+ /**
844
+ * Persistence hook (optional): called after every membership change with
845
+ * the session's current watcher list, so the runtime can journal the
846
+ * definitions and re-arm them after a v2 plugin reload. Passing no hook
847
+ * (tests, MCP) keeps the registry in-memory only.
848
+ */
849
+ #journal?: (sessionID: string, watchers: Watcher[]) => void;
832
850
  readonly #opts: WatcherRegistryOptions & {
833
851
  pollIntervalMs: number;
834
852
  ttlMinutes: number;
@@ -836,6 +854,18 @@ export class WatcherRegistry {
836
854
  commandRunner: CommandRunner;
837
855
  commandTimeoutMs: number;
838
856
  };
857
+ /**
858
+ * The most recently constructed instance in this process. opencode v2
859
+ * re-runs plugin setup() when the session's directory changes
860
+ * (session_move between directories of the same repo) or on plugin-file
861
+ * changes; each setup() built a fresh registry while the OLD instance's
862
+ * poller timer kept running - the watch tools then queried an empty
863
+ * registry ("No active watchers" / "No watcher in this session") while
864
+ * delivery kept flowing from the orphaned poller. The handoff stops the
865
+ * predecessor's poller at construction so at most one registry polls per
866
+ * process; the watch tools' journal reconcile covers the state transfer.
867
+ */
868
+ static #live: WatcherRegistry | undefined;
839
869
 
840
870
  constructor(options: WatcherRegistryOptions) {
841
871
  this.#opts = {
@@ -846,6 +876,9 @@ export class WatcherRegistry {
846
876
  commandTimeoutMs: defaultCommandTimeoutMs(),
847
877
  ...options,
848
878
  };
879
+ this.#journal = options.journal;
880
+ WatcherRegistry.#live?.stop();
881
+ WatcherRegistry.#live = this;
849
882
  }
850
883
 
851
884
  // -- Lifecycle -----------------------------------------------------------
@@ -889,6 +922,59 @@ export class WatcherRegistry {
889
922
 
890
923
  // -- CRUD ----------------------------------------------------------------
891
924
 
925
+ /** Journal the session's current watcher list (no-op without a hook). */
926
+ #emit(sessionID: string): void {
927
+ this.#journal?.(sessionID, this.listForSession(sessionID));
928
+ }
929
+
930
+ /** Restore persisted watchers (same-process reload rehydration). */
931
+ hydrate(watchers: Watcher[]): void {
932
+ for (const w of watchers) {
933
+ // Do not clobber a watcher created after the journal was written.
934
+ if (!this.#watchers.has(w.id)) this.#watchers.set(w.id, w);
935
+ }
936
+ }
937
+
938
+ /**
939
+ * Re-arms persisted watcher definitions recovered on session resume after
940
+ * a restart (the dormant journal rows the runtime keeps instead of
941
+ * pruning). Definitions whose TTL expired while the session was away are
942
+ * dropped - a watch that outlived its own expiry must not fire on a stale
943
+ * baseline. PR and branch definitions are REVALIDATED by re-fetching
944
+ * their baseline: a deleted branch or closed PR would otherwise re-arm
945
+ * into silent poll failure, and a stale baseline would flood the session
946
+ * with every event that happened while it was away; fetch failures drop
947
+ * the definition and count as failed. The rest join the live registry
948
+ * newest-first up to the per-session limit (the session may have created
949
+ * watchers since the restart), and the journal is rewritten either way:
950
+ * with the current process's pid when anything re-arms, deleted when
951
+ * nothing does.
952
+ */
953
+ async rearm(sessionID: string, defs: Watcher[]): Promise<{ rearmed: Watcher[]; expired: number; failed: number }> {
954
+ const now = Date.now();
955
+ const fresh = defs.filter((w) => now <= w.expiresAt).sort((a, b) => b.createdAt - a.createdAt);
956
+ const budget = Math.max(0, this.#opts.maxPerSession - this.listForSession(sessionID).length);
957
+ const rearmed: Watcher[] = [];
958
+ let failed = 0;
959
+ for (const def of fresh.slice(0, budget)) {
960
+ if (def.source !== "command") {
961
+ try {
962
+ def.state = def.source === "pr"
963
+ ? await fetchPrState(this.#opts.ghRunner, def.repo, def.pr)
964
+ : await fetchBranchState(this.#opts.ghRunner, def.repo, def.branch);
965
+ } catch {
966
+ failed++;
967
+ continue;
968
+ }
969
+ }
970
+ // Do not clobber a watcher created after the journal was written.
971
+ if (!this.#watchers.has(def.id)) this.#watchers.set(def.id, def);
972
+ rearmed.push(def);
973
+ }
974
+ this.#emit(sessionID);
975
+ return { rearmed, expired: defs.length - fresh.length, failed };
976
+ }
977
+
892
978
  /**
893
979
  * Registers a PR watcher and captures the baseline state immediately. The
894
980
  * baseline fetch doubles as validation: a bad repo, missing PR, or broken
@@ -929,6 +1015,7 @@ export class WatcherRegistry {
929
1015
  state,
930
1016
  };
931
1017
  this.#watchers.set(watcher.id, watcher);
1018
+ this.#emit(sessionID);
932
1019
  return { ok: true, watcher };
933
1020
  }
934
1021
 
@@ -973,6 +1060,7 @@ export class WatcherRegistry {
973
1060
  state,
974
1061
  };
975
1062
  this.#watchers.set(watcher.id, watcher);
1063
+ this.#emit(sessionID);
976
1064
  return { ok: true, watcher };
977
1065
  }
978
1066
 
@@ -1028,6 +1116,7 @@ export class WatcherRegistry {
1028
1116
  state: { lastExit: baseline.timedOut ? 124 : baseline.exitCode },
1029
1117
  };
1030
1118
  this.#watchers.set(watcher.id, watcher);
1119
+ this.#emit(sessionID);
1031
1120
  return { ok: true, watcher };
1032
1121
  }
1033
1122
 
@@ -1051,6 +1140,7 @@ export class WatcherRegistry {
1051
1140
  const watcher = this.#watchers.get(id);
1052
1141
  if (!watcher || watcher.sessionID !== sessionID) return false;
1053
1142
  this.#watchers.delete(id);
1143
+ this.#emit(sessionID);
1054
1144
  return true;
1055
1145
  }
1056
1146
 
@@ -1060,6 +1150,7 @@ export class WatcherRegistry {
1060
1150
  if (w.sessionID === sessionID) this.#watchers.delete(id);
1061
1151
  }
1062
1152
  this.#pending.delete(sessionID);
1153
+ this.#emit(sessionID);
1063
1154
  }
1064
1155
 
1065
1156
  // -- Polling and delivery ------------------------------------------------
@@ -1079,6 +1170,7 @@ export class WatcherRegistry {
1079
1170
  for (const [id, watcher] of this.#watchers) {
1080
1171
  if (now > watcher.expiresAt) {
1081
1172
  this.#watchers.delete(id);
1173
+ this.#emit(watcher.sessionID);
1082
1174
  continue;
1083
1175
  }
1084
1176
  try {
@@ -1092,7 +1184,10 @@ export class WatcherRegistry {
1092
1184
  // if the session is busy. Cancellation at detection time keeps
1093
1185
  // "wait for this run to finish" from leaving a standing watch
1094
1186
  // polling a target nobody is waiting on anymore.
1095
- if (watcher.once) this.#watchers.delete(id);
1187
+ if (watcher.once) {
1188
+ this.#watchers.delete(id);
1189
+ this.#emit(watcher.sessionID);
1190
+ }
1096
1191
  }
1097
1192
  } catch (err) {
1098
1193
  console.error(`[thatch] watcher ${id} poll failed: ${err}`);
@@ -1108,7 +1203,7 @@ export class WatcherRegistry {
1108
1203
  async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
1109
1204
  if (watcher.source === "pr") {
1110
1205
  const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
1111
- const events = diffPrState(watcher.state, after, `${watcher.repo}#${watcher.pr}`, this.#prUrl(watcher))
1206
+ const events = diffPrState(watcher.state, after, watcherTarget(watcher), this.#prUrl(watcher))
1112
1207
  .filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
1113
1208
  watcher.state = after;
1114
1209
  return events;
@@ -1124,13 +1219,13 @@ export class WatcherRegistry {
1124
1219
  if (result.timedOut || result.exitCode !== 0) return [];
1125
1220
  return [{
1126
1221
  type: "command_success",
1127
- target: commandTargetLabel(watcher.command),
1222
+ target: watcherTarget(watcher),
1128
1223
  summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
1129
1224
  url: "",
1130
1225
  }];
1131
1226
  }
1132
1227
  const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
1133
- const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
1228
+ const all = diffBranchState(watcher.state, after, watcherTarget(watcher), watcher.repo);
1134
1229
  watcher.state = after;
1135
1230
  // The workflow-name filter narrows branch_workflow events only -
1136
1231
  // commits and check runs pass through unfiltered, or a watch filtered