@jeffober/thatch 0.1.48 → 0.2.0

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 (46) hide show
  1. package/README.md +43 -4
  2. package/bin/thatch +31 -16
  3. package/docs/dev/README.md +28 -6
  4. package/docs/dev/features/behavior-engine.md +3 -2
  5. package/docs/dev/features/cicd.md +1 -1
  6. package/docs/dev/features/compaction-recovery.md +1 -1
  7. package/docs/dev/features/cross-session-chat.md +4 -2
  8. package/docs/dev/features/extraction.md +10 -2
  9. package/docs/dev/features/hygiene.md +3 -1
  10. package/docs/dev/features/multi-host.md +4 -2
  11. package/docs/dev/features/notifications.md +121 -4
  12. package/docs/dev/features/nudge-pipeline.md +4 -2
  13. package/docs/dev/features/opencode-plugin.md +133 -0
  14. package/docs/dev/features/qa-system.md +43 -0
  15. package/docs/dev/features/repo-identity.md +1 -1
  16. package/docs/dev/features/session-lifecycle.md +5 -2
  17. package/docs/dev/features/watchers.md +12 -5
  18. package/docs/dev/gotchas.md +36 -3
  19. package/docs/dev/mcp-parity.md +4 -3
  20. package/docs/dev/setup-and-hooks.md +6 -1
  21. package/docs/plans/llm-alerts.md +287 -0
  22. package/docs/plans/prediction-consolidation.md +3 -3
  23. package/docs/user/README.md +7 -3
  24. package/docs/user/notifications.md +63 -4
  25. package/docs/user/setup.md +4 -0
  26. package/docs/user/watchers.md +15 -7
  27. package/package.json +3 -1
  28. package/src/alerts.ts +360 -0
  29. package/src/capabilities.ts +174 -0
  30. package/src/chat.ts +63 -5
  31. package/src/commands.ts +44 -5
  32. package/src/config.ts +32 -1
  33. package/src/db.ts +80 -0
  34. package/src/embeddings.ts +43 -0
  35. package/src/extraction.ts +124 -0
  36. package/src/index.ts +38 -1345
  37. package/src/opencode/v1.ts +54 -0
  38. package/src/opencode/v2.ts +561 -0
  39. package/src/os-args.ts +99 -0
  40. package/src/prompts.ts +77 -1
  41. package/src/runtime.ts +1815 -0
  42. package/src/seed-behaviors.ts +1 -1
  43. package/src/session-db.ts +32 -0
  44. package/src/tool-defs.ts +185 -19
  45. package/src/tools.ts +16 -35
  46. package/src/watchers.ts +95 -14
package/src/os-args.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /**
4
+ * Parses the session id a harness was started to continue from an argument
5
+ * list (`opencode -s <id>` / `--session <id>` / `--session=<id>`). Pure:
6
+ * the caller supplies the list (see startupSessionId for where it comes
7
+ * from). The framework passes no session id to plugins and resuming a
8
+ * session fires no events, so the command line is the only signal that a
9
+ * fresh harness is the new home of a continued session.
10
+ */
11
+ export function startupSessionIdFromArgv(argv: string[]): string | null {
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const arg = argv[i];
14
+ if (arg === "-s" || arg === "--session") return argv[i + 1] ?? null;
15
+ if (arg.startsWith("--session=")) return arg.slice("--session=".length) || null;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * Whether the harness was started with `-c`/`--continue` (continue the most
22
+ * recent session). Pure. Yargs-style boolean flags only: `--continue=false`
23
+ * reads as the negation, everything else as a plain switch. The session id
24
+ * itself is not on the command line - the harness resolves "most recent
25
+ * top-level session" at startup, which the plugin replicates via the SDK
26
+ * (continuesLastSessionId).
27
+ */
28
+ export function continuesLastSessionFromArgv(argv: string[]): boolean {
29
+ for (const arg of argv) {
30
+ if (arg === "-c" || arg === "--continue") return true;
31
+ if (arg.startsWith("--continue=")) return arg.slice("--continue=".length) !== "false";
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Picks the session `opencode -c` continues: the most recently updated
38
+ * top-level session. Pure; the caller supplies the session list from
39
+ * client.session.list() (directory-scoped, like the plugin's own client).
40
+ * The server's list order is not trusted - the TUI re-sorts by
41
+ * time.updated before picking, and so does this.
42
+ */
43
+ export function continuesLastSessionId(
44
+ sessions: { id: string; parentID?: string; time?: { updated?: number } }[],
45
+ ): string | null {
46
+ return (
47
+ [...sessions]
48
+ .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
49
+ .find((s) => !s.parentID)?.id ?? null
50
+ );
51
+ }
52
+
53
+ // The plugin does not run in the process's main thread: opencode's TUI
54
+ // spawns a worker thread (cli/tui/worker.js) to host the server, and worker
55
+ // threads get their own argv (just the worker script) with none of the CLI
56
+ // flags. The thread still shares the process's pid, so the OS-level command
57
+ // line for our own pid is the real `opencode -s ses_...` invocation. Read it
58
+ // from /proc on Linux or via ps on macOS. Returns [] when unavailable;
59
+ // callers treat that as "no session flag". The readers are injectable so
60
+ // tests can drive each branch without a real /proc or ps.
61
+ export interface OsArgsDeps {
62
+ /** Reads a file as UTF-8, throwing when it does not exist. */
63
+ readFile: (path: string) => string;
64
+ /** Runs `ps -p <pid> -o args=`; returns its exit code and stdout. */
65
+ ps: (pid: number) => { exitCode: number; stdout: string };
66
+ }
67
+
68
+ const defaultOsArgsDeps: OsArgsDeps = {
69
+ readFile: (path) => readFileSync(path, "utf8"),
70
+ ps: (pid) => {
71
+ const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "args="]);
72
+ return { exitCode: res.exitCode, stdout: res.stdout.toString() };
73
+ },
74
+ };
75
+
76
+ export function osProcessArgs(deps: OsArgsDeps = defaultOsArgsDeps, pid = process.pid): string[] {
77
+ try {
78
+ // Linux: cmdline is NUL-separated argv, exact and cheap.
79
+ return deps.readFile("/proc/self/cmdline").split("\0").filter(Boolean);
80
+ } catch {
81
+ // macOS has no /proc; ask ps for this pid's full command. Splitting on
82
+ // whitespace is safe for our purpose: session ids never contain spaces.
83
+ try {
84
+ const res = deps.ps(pid);
85
+ if (res.exitCode !== 0) return [];
86
+ return res.stdout.trim().split(/\s+/).filter(Boolean);
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+ }
92
+
93
+ // Resolve the session id this harness was launched to resume, if any:
94
+ // prefer the thread-local argv (covers tests and any non-worker host), then
95
+ // fall back to the process's OS-level command line (the real harness path).
96
+ // The caller passes the OS command line in so it is read once per init.
97
+ export function startupSessionId(osArgs: string[]): string | null {
98
+ return startupSessionIdFromArgv(process.argv) ?? startupSessionIdFromArgv(osArgs);
99
+ }
package/src/prompts.ts CHANGED
@@ -50,6 +50,29 @@ auto-reply unless the message bears on a task you are already doing; do not
50
50
  forward or chain messages reflexively. Name the sender when relaying a
51
51
  message to the user, and let the user decide when coordination is ambiguous.`;
52
52
 
53
+ // Class-lesson guidance, shared verbatim by both prompt variants (the
54
+ // paragraphs name no tools, so the twins need no per-host spellings). See
55
+ // the LLM-alerts review loop (Sept 2026): class-level lessons go into the
56
+ // memory/behavior/prediction systems with matchers worded for the broadest
57
+ // semantic trigger surface.
58
+ const classLessonPredictionSection = `
59
+ The same class discipline applies to judgment errors: when a review round,
60
+ bug, or incident reveals a class of bad calls (not just one), record the
61
+ pattern as a prediction. Word the matcher for semantic matching - describe
62
+ the situation a future decision will be in - then re-read it against
63
+ everything that ought to trigger it and widen the wording until it does.`;
64
+
65
+ const classLessonBehaviorSection = `
66
+ When a review round, bug fix, or incident reveals a CLASS of mistake you are
67
+ prone to (rather than a one-off), persist the class so it resurfaces where
68
+ the work happens: save a memory stating the class and how to detect it, and
69
+ codify a behavior whose situation text describes the context a future
70
+ session will be in when that class of trouble starts. Word the situation for
71
+ semantic matching: after writing it, re-read it against everything that
72
+ ought to trigger it and widen the wording to the broadest surface of the
73
+ class - a matcher that only names this incident never fires for the next
74
+ one.`;
75
+
53
76
  export function systemPrompt(repo: string, chatEnabled = true): string {
54
77
  return `# Persistence
55
78
 
@@ -225,6 +248,8 @@ reviewing your changes, or asked you to review their changes or another LLM's
225
248
  changes. Before creating a new prediction, query for an existing one and
226
249
  reinforce or adjust it when possible.
227
250
 
251
+ ${classLessonPredictionSection}
252
+
228
253
  ## Situational Behaviors
229
254
 
230
255
  A model of your own operational rules is maintained by the behavior engine.
@@ -245,6 +270,8 @@ way that is about your own operational discipline (not a user preference):
245
270
  2. Use thatch_behavior_feedback when surfaced behaviors fire to ham/spam them
246
271
  3. Use thatch_behavior_delete to remove a rule created in error
247
272
 
273
+ ${classLessonBehaviorSection}
274
+
248
275
  Do not codify rules that make you lazier or less thorough. Rules should encode
249
276
  discipline, not shortcuts.
250
277
 
@@ -489,6 +516,8 @@ reviewing your changes, or asked you to review their changes or another LLM's
489
516
  changes. Before creating a new prediction, query for an existing one and
490
517
  reinforce or adjust it when possible.
491
518
 
519
+ ${classLessonPredictionSection}
520
+
492
521
  ## Situational Behaviors
493
522
 
494
523
  A model of your own operational rules is maintained by the behavior engine.
@@ -509,6 +538,8 @@ way that is about your own operational discipline (not a user preference):
509
538
  2. Use behavior_feedback when surfaced behaviors fire to ham/spam them
510
539
  3. Use behavior_delete to remove a rule created in error
511
540
 
541
+ ${classLessonBehaviorSection}
542
+
512
543
  Do not codify rules that make you lazier or less thorough. Rules should encode
513
544
  discipline, not shortcuts.
514
545
 
@@ -622,6 +653,17 @@ export function versionWarningNudge(message: string): string {
622
653
  return `[thatch] ${message} This is a system nudge, not user input. After informing the user, continue with their request.`;
623
654
  }
624
655
 
656
+ /**
657
+ * The version-skew message: the on-disk package version is newer than the
658
+ * running one (the user upgraded but the host kept the old plugin loaded).
659
+ * Standalone so the wording is testable. Host-specific remedies (v2's
660
+ * "opencode plugin update", a shim-shape reminder) are planned but not yet
661
+ * appended by any caller - see docs/dev/features/opencode-plugin.md.
662
+ */
663
+ export function skewWarningText(onDisk: string, running: string): string {
664
+ return `thatch was upgraded to v${onDisk} but this session is running v${running}. Restart opencode to apply the update.`;
665
+ }
666
+
625
667
  // ---------------------------------------------------------------------------
626
668
  // Prompt cores - behavior instruction bodies shared by nudges and /thatch
627
669
  // command files (src/commands.ts). A core is the host-agnostic instruction
@@ -676,7 +718,7 @@ export function reflectCore(tool: ToolNamer): string {
676
718
  }
677
719
 
678
720
  /**
679
- * Extraction nudge with escalation. Both the opencode plugin (src/index.ts)
721
+ * Extraction nudge with escalation. Both the opencode plugin (src/runtime.ts)
680
722
  * and the Claude Code/Cursor CLI (bin/thatch flush-tools) call this with
681
723
  * their respective tool name prefix. The missedCount parameter tracks
682
724
  * consecutive nudges delivered without any memory_remember call in between,
@@ -884,6 +926,40 @@ ${lines.join("\n")}
884
926
  This is a system notification, not user input. Decide whether to act now or keep waiting - the user's instructions from when the watch was created govern how to handle it.${detailHint} This notification is not approval to advance other pending work, with one exception: if this watch was created to gate work the user already greenlit (for example "wait for CI, then merge"), it is the continuation signal for exactly that work - proceed with it.${cadence} If you act, tell the user what you did and why; if not, stop and wait.`;
885
927
  }
886
928
 
929
+ /**
930
+ * Watcher re-arm notice for a session resuming after a restart. The session's
931
+ * own dormant watcher definitions were re-armed into the live registry;
932
+ * delivered as a synthetic part on the session's first message so the model
933
+ * knows proactive notifications will resume without a re-registration step.
934
+ * Expired definitions (TTL passed while the session was away) are reported,
935
+ * not silently dropped - a watch the user expected to be waiting should be
936
+ * known to be gone.
937
+ */
938
+ export function watcherRearmNotice(targets: string[], expiredCount: number, failedCount = 0): string {
939
+ const list = targets.join(", ");
940
+ const expired = expiredCount > 0
941
+ ? `\n${expiredCount} earlier watcher${expiredCount === 1 ? "" : "s"} expired while the session was away and were not re-armed.`
942
+ : "";
943
+ const failed = failedCount > 0
944
+ ? `\n${failedCount} watcher${failedCount === 1 ? "" : "s"} could not be re-armed (the watched target is gone or unreachable) and was dropped.`
945
+ : "";
946
+ return `[thatch] ${targets.length} watcher${targets.length === 1 ? "" : "s"} re-armed after the restart: ${list}.${expired}${failed}
947
+ No re-registration is needed; notifications will arrive here as usual. This is a system notice, not user input - carry on with the user's request.`;
948
+ }
949
+
950
+ /**
951
+ * Watcher death notice: a dormant watcher's owning session died with its
952
+ * harness and has not been resumed. Delivered as a synthetic part to a live
953
+ * session in the same project, so a watch that will never fire is at least
954
+ * heard about instead of silently vanishing. Pointer-only: target labels,
955
+ * never event content.
956
+ */
957
+ export function watcherDeathNotice(targets: string[]): string {
958
+ const list = targets.join(", ");
959
+ return `[thatch] ${targets.length} watcher${targets.length === 1 ? " died" : "s died"} with session${targets.length === 1 ? "" : "s"} that are no longer running: ${list}.
960
+ ${targets.length === 1 ? "It" : "They"} will re-arm automatically if ${targets.length === 1 ? "that session is" : "those sessions are"} resumed. This is a system notice, not user input - carry on with the user's request.`;
961
+ }
962
+
887
963
  /**
888
964
  * Chat wake-up notification for the poller's promptAsync delivery. Injected
889
965
  * as a synthetic part that triggers a model turn. Pointer-only like watcher