@jeffober/thatch 0.1.28 → 0.1.29

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 (60) hide show
  1. package/README.md +24 -10
  2. package/artifacts/skills/thatch-code-review.md +9 -7
  3. package/artifacts/skills/thatch-review-economy.md +97 -0
  4. package/artifacts/skills/thatch-review-followup.md +1 -1
  5. package/artifacts/skills/thatch-review-synthesizer.md +15 -5
  6. package/bin/qa-run +38 -0
  7. package/bin/thatch +10 -5
  8. package/docs/dev/README.md +71 -18
  9. package/docs/dev/features/README.md +33 -0
  10. package/docs/dev/features/behavior-engine.md +271 -0
  11. package/docs/dev/features/cicd.md +89 -0
  12. package/docs/dev/features/cli.md +81 -0
  13. package/docs/dev/features/compaction-recovery.md +48 -0
  14. package/docs/dev/features/database.md +244 -0
  15. package/docs/dev/features/deduplication.md +102 -0
  16. package/docs/dev/features/extraction.md +253 -0
  17. package/docs/dev/features/hygiene.md +81 -0
  18. package/docs/dev/features/memory-store.md +154 -0
  19. package/docs/dev/features/multi-host.md +228 -0
  20. package/docs/dev/features/nudge-pipeline.md +282 -0
  21. package/docs/dev/features/prediction-engine.md +253 -0
  22. package/docs/dev/features/qa-system.md +205 -0
  23. package/docs/dev/features/session-lifecycle.md +110 -0
  24. package/docs/dev/features/setup.md +199 -0
  25. package/docs/dev/features/sideband.md +104 -0
  26. package/docs/dev/gotchas.md +3 -3
  27. package/docs/dev/mcp-parity.md +6 -4
  28. package/docs/dev/setup-and-hooks.md +4 -4
  29. package/docs/dev/skills.md +10 -8
  30. package/docs/plans/README.md +2 -2
  31. package/docs/user/README.md +58 -20
  32. package/docs/user/behavior-engine.md +85 -0
  33. package/docs/user/cli.md +98 -0
  34. package/docs/user/code-review.md +73 -0
  35. package/docs/user/deduplication.md +70 -0
  36. package/docs/user/default-behaviors.md +75 -0
  37. package/docs/user/extraction.md +74 -0
  38. package/docs/user/hygiene.md +76 -0
  39. package/docs/user/memory.md +81 -0
  40. package/docs/user/prediction-engine.md +87 -0
  41. package/docs/user/setup.md +110 -0
  42. package/docs/user/skills.md +97 -0
  43. package/package.json +2 -2
  44. package/src/behavior.ts +120 -0
  45. package/src/db.ts +160 -417
  46. package/src/embeddings.ts +0 -41
  47. package/src/extraction.ts +2 -2
  48. package/src/git.ts +3 -1
  49. package/src/index.ts +55 -9
  50. package/src/mcp.ts +30 -1
  51. package/src/prediction.ts +127 -0
  52. package/src/prompts.ts +135 -188
  53. package/src/scoring-engine.ts +374 -0
  54. package/src/seed-behaviors.ts +339 -0
  55. package/src/setup.ts +2 -6
  56. package/src/sideband.ts +36 -8
  57. package/src/skills.ts +1 -0
  58. package/src/tool-defs.ts +172 -25
  59. package/src/vector-math.ts +30 -0
  60. package/docs/qa/README.md +0 -175
package/src/tool-defs.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  import { z } from "zod";
2
- import type { ThatchDB, DedupCandidate } from "./db";
2
+ import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
+ import { predictionVerb } from "./prompts";
4
5
 
5
- // Near-duplicate thresholds for matcher/prediction dedup at creation time.
6
- // Matches the thatch_find_duplicates threshold (0.85).
6
+ // Near-duplicate thresholds for matcher/prediction/behavior dedup at
7
+ // creation time. Matches the thatch_find_duplicates threshold (0.85).
7
8
  const MATCHER_DEDUP_COSINE = 0.85;
8
9
  const PREDICTION_DEDUP_COSINE = 0.85;
10
+ const BEHAVIOR_DEDUP_COSINE = 0.85;
9
11
 
10
12
  // Minimum matcher cosine to consider a prediction relevant. Matches the
11
13
  // auto-fire threshold in index.ts (PREDICTION_THRESHOLD). The query tool
12
14
  // should not return predictions from near-zero-similarity matchers that
13
15
  // would never fire in the auto-fire.
14
- const PREDICTION_QUERY_THRESHOLD = 0.45;
16
+ const PREDICTION_QUERY_THRESHOLD = 0.60;
15
17
 
16
18
  /**
17
19
  * Shared context passed to every tool's execute function. Framework-agnostic -
@@ -68,7 +70,7 @@ function formatEntry(
68
70
  return parts.join("\n");
69
71
  }
70
72
 
71
- function formatRecallResult(entry: any): string {
73
+ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
72
74
  let meta = `store:${entry.store}`;
73
75
  if (entry.branch) meta += ` branch:${entry.branch}`;
74
76
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
@@ -363,25 +365,6 @@ const markCheckedDef: ToolDef = {
363
365
  },
364
366
  };
365
367
 
366
- /**
367
- * Extraction-buffer acknowledgment, with AMQP-style accept/complete roles.
368
- *
369
- * Called in a PARENT session after dispatching the fact-extractor, it accepts
370
- * the buffer: entries move to a holding area and the nudge quiets, but they
371
- * are not dropped until the extractor completes. Called in a CHILD extractor
372
- * at the end of its run, it completes the parent's accepted entries -
373
- * including no-save runs that write no memory. If the child errors or is
374
- * deleted before either signal, the host requeues the entries so the facts
375
- * are not lost.
376
- *
377
- * The actual state changes happen in the host's post-tool hook
378
- * (tool.execute.after for opencode, PostToolBatch/appendBatch for MCP) -
379
- * this tool's execute function is a no-op confirmation. The tool exists so
380
- * the model has a recognizable tool name to key on. In the MCP path the
381
- * file-backed queue is consumed on this call (or on any memory_remember),
382
- * which is durable across interruption because the queue persists on disk
383
- * until then.
384
- */
385
368
  /**
386
369
  * Fetches the queued extraction payload for a session. The sub-agent calls
387
370
  * this with the parent's session ID to retrieve the tool interactions that
@@ -493,7 +476,7 @@ const predictionQueryDef: ToolDef = {
493
476
  const scored = ctx.db.scorePredictions(matchers);
494
477
  if (scored.length === 0) return "No matching predictions found.";
495
478
  return scored.map((s) => {
496
- const verb = s.evidence_count === 0 ? "you may prefer" : "you tend to";
479
+ const verb = predictionVerb(s.evidence_count);
497
480
  return `[${s.confidence.toFixed(2)} conf, ${s.evidence_count} tests] ` +
498
481
  `When ${s.matcher_description}: ${verb} ${s.statement}`;
499
482
  }).join("\n");
@@ -650,6 +633,166 @@ const predictionDeleteDef: ToolDef = {
650
633
  },
651
634
  };
652
635
 
636
+ // ---------------------------------------------------------------------------
637
+ // Behavior engine: LLM self-discipline rules with ham/spam feedback
638
+ // ---------------------------------------------------------------------------
639
+
640
+ const behaviorCodifyDef: ToolDef = {
641
+ name: "behavior_codify",
642
+ description:
643
+ "Codify a self-discipline rule: when situation X arises, you should " +
644
+ "do Y. Use when you recognize a situation you should react to in a " +
645
+ "specific, repeatable way that is NOT a user preference (use " +
646
+ "prediction_update for those). The rule is about your own operational " +
647
+ "discipline, not what the user wants. Examples: check the whole " +
648
+ "codebase for a library before importing it; investigate disabled " +
649
+ "tests before touching the area; read a large function fully before " +
650
+ "editing it.",
651
+ args: {
652
+ situation: z.string().describe(
653
+ "Description of the situation that triggers this behavior. What context " +
654
+ "or task type makes this rule apply?",
655
+ ),
656
+ behavior: z.string().describe(
657
+ "The behavioral rule. What should you do when this situation arises?",
658
+ ),
659
+ rationale: z.string().describe(
660
+ "Why you are codifying this rule. What happened that made you realize " +
661
+ "this behavior is worth persisting?",
662
+ ),
663
+ store: z.string().optional().describe(
664
+ "Store to write to. Defaults to the project store.",
665
+ ),
666
+ },
667
+ async execute(args, ctx) {
668
+ const store = (args.store as string) || ctx.defaultStore;
669
+ const situationText = args.situation as string;
670
+ const behaviorText = args.behavior as string;
671
+ const rationale = args.rationale as string;
672
+
673
+ const matcherEmbed = await ctx.model.passageEmbed(situationText);
674
+ const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
675
+
676
+ return ctx.db.transaction(() => {
677
+ let matcherId = ctx.db.findNearestBehaviorMatcher(store, matcherEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
678
+ if (!matcherId) matcherId = ctx.db.createBehaviorMatcher(store, situationText, matcherEmbed, ctx.model.name);
679
+
680
+ let behaviorId = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE)?.id;
681
+ if (!behaviorId) {
682
+ behaviorId = ctx.db.createBehavior(store, behaviorText, rationale, behaviorEmbed, ctx.model.name);
683
+ ctx.db.createBehaviorEdge(matcherId, behaviorId, 1.0);
684
+ ctx.db.addBehaviorProvenance(behaviorId, "codify", rationale);
685
+ return `[codified] ${store} :: "${behaviorText}" for "${situationText}"`;
686
+ }
687
+
688
+ ctx.db.createBehaviorEdge(matcherId, behaviorId, 1.0);
689
+ ctx.db.addBehaviorProvenance(behaviorId, "codify", rationale);
690
+ const existing = ctx.db.getBehavior(behaviorId);
691
+ return `[linked] "${existing?.statement ?? behaviorText}" for "${situationText}" confidence=${(existing?.confidence ?? 0).toFixed(2)} (${existing?.confirm_count ?? 0}/${existing?.disconfirm_count ?? 0})`;
692
+ });
693
+ },
694
+ };
695
+
696
+ const behaviorFeedbackDef: ToolDef = {
697
+ name: "behavior_feedback",
698
+ description:
699
+ "Record ham/spam feedback on a surfaced behavior. When the behavior " +
700
+ "nudge surfaces rules and you evaluate each against the current " +
701
+ "situation, call this with relevant: true (ham) if the rule applies, " +
702
+ "or relevant: false (spam) if it does not. This trains the classifier " +
703
+ "so future nudges are more accurate. Also use when the user corrects " +
704
+ "your behavior and you realize a codified rule led you astray or " +
705
+ "should have been followed.",
706
+ args: {
707
+ behavior: z.string().describe(
708
+ "The behavior statement to provide feedback on. Use behavior_list " +
709
+ "to find the exact text; matching is semantic (cosine >= 0.85).",
710
+ ),
711
+ relevant: z.boolean().describe(
712
+ "true (ham) if the behavior is relevant to the current situation. " +
713
+ "false (spam) if it is not relevant.",
714
+ ),
715
+ context: z.string().describe(
716
+ "Brief description of the current situation, so the feedback is " +
717
+ "auditable in provenance.",
718
+ ),
719
+ store: z.string().optional().describe(
720
+ "Store to search. Defaults to the project store.",
721
+ ),
722
+ },
723
+ async execute(args, ctx) {
724
+ const store = (args.store as string) || ctx.defaultStore;
725
+ const behaviorText = args.behavior as string;
726
+ const relevant = args.relevant as boolean;
727
+ const contextText = args.context as string;
728
+
729
+ const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
730
+ const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
731
+ if (!behavior) return `No behavior matching "${behaviorText}" found in "${store}".`;
732
+
733
+ const signal = relevant ? "confirm" : "disconfirm";
734
+ ctx.db.transaction(() => {
735
+ ctx.db.adjustBehaviorConfidence(behavior.id, signal);
736
+ ctx.db.addBehaviorProvenance(behavior.id, signal, `${relevant ? "ham" : "spam"}: ${contextText}`);
737
+ });
738
+ const updated = ctx.db.getBehavior(behavior.id);
739
+ return `[${signal}] "${updated?.statement ?? behaviorText}" confidence=${(updated?.confidence ?? 0).toFixed(2)} (${updated?.confirm_count ?? 0}/${updated?.disconfirm_count ?? 0})`;
740
+ },
741
+ };
742
+
743
+ const behaviorListDef: ToolDef = {
744
+ name: "behavior_list",
745
+ description:
746
+ "List all codified behaviors with their matchers, confidence, and " +
747
+ "evidence count. For inspection and debugging.",
748
+ args: {
749
+ store: z.string().optional().describe(
750
+ "Which store to list. Defaults to the project store.",
751
+ ),
752
+ },
753
+ async execute(args, ctx) {
754
+ const store = (args.store as string) || ctx.defaultStore;
755
+ const behaviors = ctx.db.listBehaviors(store);
756
+ if (behaviors.length === 0) return `No behaviors in "${store}".`;
757
+ return behaviors.map((b) => {
758
+ const matchers = b.matchers.map((m) => ` - "${m.description}" (w:${m.weight})`).join("\n");
759
+ const provenance = ctx.db.getBehaviorProvenance(b.id);
760
+ const provLines = provenance.map((pr) => ` - [${pr.created_at.slice(0, 10)}] ${pr.signal}: ${pr.detail ?? ""}`).join("\n");
761
+ return `[${b.confidence.toFixed(2)} conf, ${b.evidence_count} tests] ${b.statement}` +
762
+ (b.rationale ? `\n rationale: ${b.rationale}` : "") +
763
+ (matchers ? `\n matchers:\n${matchers}` : "") +
764
+ (provLines ? `\n provenance:\n${provLines}` : "");
765
+ }).join("\n\n");
766
+ },
767
+ };
768
+
769
+ const behaviorDeleteDef: ToolDef = {
770
+ name: "behavior_delete",
771
+ description:
772
+ "Delete a codified behavior. Useful when a behavior was created in " +
773
+ "error or is no longer relevant. Edges and provenance are deleted " +
774
+ "automatically (cascade).",
775
+ args: {
776
+ statement: z.string().describe(
777
+ "The behavior statement to delete. Use behavior_list to find the " +
778
+ "exact text; matching is semantic (cosine >= 0.85).",
779
+ ),
780
+ store: z.string().optional().describe(
781
+ "Store to delete from. Defaults to the project store.",
782
+ ),
783
+ },
784
+ async execute(args, ctx) {
785
+ const store = (args.store as string) || ctx.defaultStore;
786
+ const statementText = args.statement as string;
787
+ const behaviorEmbed = await ctx.model.passageEmbed(statementText);
788
+ const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
789
+ if (!behavior) return `No behavior matching "${statementText}" found in "${store}".`;
790
+ const deleted = ctx.db.deleteBehavior(behavior.id);
791
+ if (!deleted) return `Failed to delete behavior "${behavior.statement}".`;
792
+ return `[deleted] "${behavior.statement}" from "${store}"`;
793
+ },
794
+ };
795
+
653
796
  /**
654
797
  * All tool definitions, in the order they should be presented to the agent.
655
798
  * The opencode plugin wraps each in `tool()`; the MCP server exposes them
@@ -670,4 +813,8 @@ export const TOOL_DEFS: ToolDef[] = [
670
813
  predictionUpdateDef,
671
814
  predictionListDef,
672
815
  predictionDeleteDef,
816
+ behaviorCodifyDef,
817
+ behaviorFeedbackDef,
818
+ behaviorListDef,
819
+ behaviorDeleteDef,
673
820
  ];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reconstructs a Float32Array from a stored BLOB. The Uint8Array bun:sqlite
3
+ * hands back may itself be a view, so honor its offset and length rather than
4
+ * reading the whole backing buffer.
5
+ */
6
+ export function blobToVector(blob: Uint8Array): Float32Array {
7
+ return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
8
+ }
9
+
10
+ /**
11
+ * Cosine similarity between two Float32Array vectors of equal dimension.
12
+ * Returns a value in [-1, 1]. Normalized embeddings will be close to [0, 1].
13
+ * Throws on dimension mismatch - comparing vectors from different embedding
14
+ * spaces is always a caller bug; callers filter by length first.
15
+ */
16
+ export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
17
+ if (a.length !== b.length) {
18
+ throw new Error(`cosineSimilarity: dimension mismatch (${a.length} vs ${b.length})`);
19
+ }
20
+ let dot = 0;
21
+ let magA = 0;
22
+ let magB = 0;
23
+ for (let i = 0; i < a.length; i++) {
24
+ dot += a[i] * b[i];
25
+ magA += a[i] * a[i];
26
+ magB += b[i] * b[i];
27
+ }
28
+ const denominator = Math.sqrt(magA) * Math.sqrt(magB);
29
+ return denominator === 0 ? 0 : dot / denominator;
30
+ }
package/docs/qa/README.md DELETED
@@ -1,175 +0,0 @@
1
- # QA — Test Plan
2
-
3
- Thatch uses `bun:test`. The full suite runs in well under a second with no
4
- network access and no writes outside temp directories.
5
-
6
- ## Test conventions
7
-
8
- - **DB tests** create a real SQLite file in a `mkdtempSync` temp directory,
9
- removed in `afterEach`. (Real files, not `:memory:` — WAL behavior differs.)
10
- - **Embedding tests** use `MockEmbeddingModel`: hash-seeded deterministic
11
- vectors, so identical texts embed identically and unrelated texts land
12
- near-orthogonal. The real `BgeEmbeddingModel` is never instantiated in
13
- tests — nothing downloads models.
14
- - **Git unit tests** exercise `parseGitUrl` on strings; **git integration
15
- tests** create throwaway repos in temp directories and run real git.
16
- - **Tool tests** build tools via the factory functions with an injected temp
17
- DB + mock model, then call `execute` directly.
18
- - **Plugin tests** call the `server` entry with a mock client and redirect
19
- `THATCH_DB_PATH` and `XDG_CONFIG_HOME` into a temp directory so skill
20
- installation never touches the real `~/.config`.
21
-
22
- ## Running tests
23
-
24
- ```bash
25
- bun test # all tests
26
- bun test --watch # watch mode
27
- mise run check # typecheck + bun test + markdownlint (the CI gate)
28
- mise run typecheck # tsc alone
29
- mise run lint-md # markdownlint only
30
- ```
31
-
32
- `mise run check` is the canonical quality gate (also run by CI on push/PR to
33
- `main`). Typecheck uses `tsconfig.check.json` (which includes the test files
34
- that the build's `tsconfig.json` excludes). Markdownlint enforces structural
35
- correctness on `README.md` and `docs/` excluding `docs/plans/`; see
36
- `.markdownlint-cli2.jsonc` for rule config.
37
-
38
- ## Test files and coverage
39
-
40
- ### `tests/db.test.ts`
41
-
42
- - `cosineSimilarity`: identical/orthogonal/opposite/zero vectors; throws on
43
- dimension mismatch
44
- - Schema creation, store creation/idempotence
45
- - `slugify`: ASCII, unicode preservation, hash fallback for all-symbol labels
46
- - `remember`: insert, duplicate rejection without `overwrite`, overwrite,
47
- metadata, embedding round-trip through a view with non-zero `byteOffset`
48
- - `recall`: ranking, multi-store, unknown store, empty store list, limit,
49
- branch scoping, skipping dimension-mismatched entries
50
- - `listEntries`, `showEntry`, `forgetEntry`
51
- - Dedup: `findDuplicates` thresholding, checked-pair suppression, verdict
52
- invalidation on overwrite and forget, dimension-mismatch pair skipping
53
- - `findSimilar`: threshold/ranking, self-exclusion, dimension skipping, no
54
- telemetry side effects
55
- - Recall telemetry: returned rows (and only returned rows) stamped
56
- - Hygiene queries: stale counting with recall-refresh, branch listing/counts
57
- - Column migration: pre-telemetry databases gain the new columns on open
58
-
59
- ### `tests/git.test.ts`
60
-
61
- - `parseGitUrl` across SSH shorthand, HTTPS/HTTP, `ssh://`, `git://`,
62
- GitLab/self-hosted, hyphens/dots, trailing whitespace, unparseable input
63
-
64
- ### `tests/git-integration.test.ts`
65
-
66
- - `detectRepo` against real temp git repos: HTTPS remote, SSH remote,
67
- no-`.git`-suffix remote, no-remote fallback, non-git-directory fallback
68
- - `listBranches`: local branches listed; empty outside a git repo
69
-
70
- ### `tests/embeddings.test.ts`
71
-
72
- - `MockEmbeddingModel` contract: dims, loaded, determinism, query/passage
73
- equivalence, distinct texts → distinct near-orthogonal vectors, model name
74
- - `BgeEmbeddingModel` (with injected pipeline factory): lazy-load, loaded
75
- state, concurrent load memoization, error retry, query prefix for
76
- asymmetric search, passage has no prefix, model name tag, default model name
77
-
78
- ### `tests/tools.test.ts`
79
-
80
- - `thatch_memory_remember`: happy path, duplicate rejection, overwrite,
81
- explicit store, metadata
82
- - `thatch_memory_recall`: default repo+global scope, empty results, store
83
- override, limit
84
- - `thatch_memory_list` / `show` / `forget`: found, not-found, store override
85
- - `thatch_store_list`
86
- - `thatch_find_duplicates`: surfaces similar pairs, cluster grouping,
87
- no-match message, store override, skips checked pairs
88
- - `thatch_dedup_mark_checked`: records verdict, store override
89
- - Write-time collision warning: fires on similar content, silent for
90
- unrelated content, never warns against itself on overwrite
91
-
92
- ### `tests/plugin.test.ts`
93
-
94
- - `server` export shape: all thirteen tools, every hook present, tools carry
95
- description/args/execute
96
- - System transform and compaction hooks append their text
97
- - `tool.execute.after` → `chat.message` extraction round-trip: nudge carries
98
- the JSON payload, buffers are per-session, flush is one-shot, `thatch_*`
99
- tools are excluded from buffering
100
- - Skill files land under the redirected `XDG_CONFIG_HOME`
101
- - `event` handler: calls `client.session.prompt` on `session.created` with
102
- correct session ID and nudge text; ignores non-`session.created` events
103
- - `sessionStartReminder`: includes store name and recall instructions
104
- - Recall nudge via `chat.message`: surfaces when prompt matches a stored
105
- memory, no nudge for unrelated or short prompts, extraction priority
106
- - Prediction auto-fire via `chat.message`: surfaces when prompt matches a
107
- stored matcher, no nudge for unrelated prompts, prediction and recall
108
- nudges fire independently as separate synthetic parts
109
-
110
- ### `tests/tool-defs.test.ts`
111
-
112
- - Tool count (13) and name list
113
- - Zod schema validation for each tool's args
114
- - Execute functions: `memory_remember` save/duplicate/overwrite/archived,
115
- `memory_recall` default/empty/archived, `memory_list`/`show`/`forget`,
116
- `store_list`, `find_duplicates` clean store
117
- - Prediction execute functions: `prediction_update` create/confirm/disconfirm/
118
- create-on-existing (dedup links without disconfirming), `prediction_query`
119
- matching/unrelated, `prediction_list` with provenance, `prediction_delete`
120
- found/not-found
121
-
122
- ### `tests/prediction.test.ts`
123
-
124
- - Schema: tables exist, `populationP0` fallback on empty store
125
- - Matcher creation and lookup: `findMatchers` ranking, `findNearestMatcher`
126
- threshold/dedup
127
- - Prediction creation: `createPrediction` seeds at p0, `adjustConfidence`
128
- confirm/disconfirm/soft/multiple, non-existent ID no-op
129
- - Edges and scoring: `createEdge` links, `scorePredictions` empty/dedup/
130
- weight-preservation, `scorePredictionNudge` threshold filtering + dedup
131
- - `findNearestPrediction`: store-wide search, threshold, cross-matcher
132
- - Provenance: `addProvenance` + `getProvenance` read-back, `deletePrediction`
133
- cascade to edges and provenance
134
- - `listPredictions`: sorted by confidence, matchers included
135
- - `populationP0`: under-20 fallback, population hit rate with sufficient evidence
136
-
137
- ### `tests/hygiene.test.ts`
138
-
139
- - `hygieneReport`: silent on a healthy store; counts duplicate candidates,
140
- stale memories, and orphaned branch-scoped memories (against a real temp
141
- git repo); skips the branch check outside a git repo
142
- - `sessionStartReminder` hygiene block: appended when present, omitted when
143
- null, single-arg call unchanged
144
-
145
- ## Known gaps
146
-
147
- - `BgeEmbeddingModel`'s default `PipelineFactory` (the real HF import) is
148
- untested — exercising it means downloading the real model, which violates
149
- the no-network rule. The lazy-load logic is tested with an injected mock
150
- factory. The real model is exercised indirectly by any real opencode use.
151
- - The skill install failure path in `index.ts` has no test (would require
152
- mocking `fs` to force a write error).
153
- - `bin/thatch` has no automated tests; it is a thin shell over `db.ts`.
154
-
155
- ## QA use-case suite
156
-
157
- End-to-end QA scenarios live in `tests/qa/` as bun test files, not docs.
158
- Each use case is a `uc-NNN-*.test.ts` file that defines preconditions,
159
- steps, and expected results as string constants, then registers itself
160
- via `registerUseCase` from `tests/qa/runner.ts`.
161
-
162
- Most use cases shell out to `opencode run` with an isolated repo copy
163
- (temp DB, temp config, temp home). Automatable use cases override the
164
- `run` function with direct CLI/bun assertions. Manual-only use cases
165
- (compaction, visual TUI) are skipped automatically.
166
-
167
- Run the suite:
168
-
169
- ```bash
170
- mise run qa # full run (spawns opencode sessions, costs model tokens)
171
- mise run qa-dry-run # list use cases without spawning sessions
172
- ```
173
-
174
- The regular test suite (`mise run check`) excludes `tests/qa/` by using
175
- a flat glob (`bun test tests/*.test.ts`).