@mainahq/core 1.0.2 → 1.1.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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/ai/__tests__/availability.test.ts +131 -0
  3. package/src/ai/__tests__/delegation.test.ts +55 -1
  4. package/src/ai/availability.ts +23 -0
  5. package/src/ai/delegation.ts +5 -3
  6. package/src/context/__tests__/budget.test.ts +29 -6
  7. package/src/context/__tests__/engine.test.ts +1 -0
  8. package/src/context/__tests__/selector.test.ts +23 -3
  9. package/src/context/__tests__/wiki.test.ts +349 -0
  10. package/src/context/budget.ts +12 -8
  11. package/src/context/engine.ts +37 -0
  12. package/src/context/selector.ts +30 -4
  13. package/src/context/wiki.ts +296 -0
  14. package/src/db/index.ts +12 -0
  15. package/src/feedback/__tests__/capture.test.ts +166 -0
  16. package/src/feedback/__tests__/signals.test.ts +144 -0
  17. package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
  18. package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
  19. package/src/feedback/capture.ts +102 -0
  20. package/src/feedback/signals.ts +68 -0
  21. package/src/index.ts +108 -1
  22. package/src/init/__tests__/init.test.ts +477 -18
  23. package/src/init/index.ts +419 -13
  24. package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
  25. package/src/prompts/defaults/index.ts +3 -1
  26. package/src/prompts/defaults/wiki-compile.md +20 -0
  27. package/src/prompts/defaults/wiki-query.md +18 -0
  28. package/src/stats/__tests__/tool-usage.test.ts +133 -0
  29. package/src/stats/tracker.ts +92 -0
  30. package/src/verify/__tests__/builtin.test.ts +270 -0
  31. package/src/verify/__tests__/pipeline.test.ts +11 -8
  32. package/src/verify/builtin.ts +350 -0
  33. package/src/verify/pipeline.ts +32 -2
  34. package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
  35. package/src/verify/tools/wiki-lint-runner.ts +38 -0
  36. package/src/verify/tools/wiki-lint.ts +898 -0
  37. package/src/wiki/__tests__/compiler.test.ts +389 -0
  38. package/src/wiki/__tests__/extractors/code.test.ts +99 -0
  39. package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
  40. package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
  41. package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
  42. package/src/wiki/__tests__/graph.test.ts +344 -0
  43. package/src/wiki/__tests__/hooks.test.ts +119 -0
  44. package/src/wiki/__tests__/indexer.test.ts +285 -0
  45. package/src/wiki/__tests__/linker.test.ts +230 -0
  46. package/src/wiki/__tests__/louvain.test.ts +229 -0
  47. package/src/wiki/__tests__/query.test.ts +316 -0
  48. package/src/wiki/__tests__/schema.test.ts +114 -0
  49. package/src/wiki/__tests__/signals.test.ts +474 -0
  50. package/src/wiki/__tests__/state.test.ts +168 -0
  51. package/src/wiki/__tests__/tracking.test.ts +118 -0
  52. package/src/wiki/__tests__/types.test.ts +387 -0
  53. package/src/wiki/compiler.ts +1075 -0
  54. package/src/wiki/extractors/code.ts +90 -0
  55. package/src/wiki/extractors/decision.ts +217 -0
  56. package/src/wiki/extractors/feature.ts +206 -0
  57. package/src/wiki/extractors/workflow.ts +112 -0
  58. package/src/wiki/graph.ts +445 -0
  59. package/src/wiki/hooks.ts +49 -0
  60. package/src/wiki/indexer.ts +105 -0
  61. package/src/wiki/linker.ts +117 -0
  62. package/src/wiki/louvain.ts +190 -0
  63. package/src/wiki/prompts/compile-architecture.md +59 -0
  64. package/src/wiki/prompts/compile-decision.md +66 -0
  65. package/src/wiki/prompts/compile-entity.md +56 -0
  66. package/src/wiki/prompts/compile-feature.md +60 -0
  67. package/src/wiki/prompts/compile-module.md +42 -0
  68. package/src/wiki/prompts/wiki-query.md +25 -0
  69. package/src/wiki/query.ts +338 -0
  70. package/src/wiki/schema.ts +111 -0
  71. package/src/wiki/signals.ts +368 -0
  72. package/src/wiki/state.ts +89 -0
  73. package/src/wiki/tracking.ts +30 -0
  74. package/src/wiki/types.ts +169 -0
  75. package/src/workflow/context.ts +26 -0
@@ -0,0 +1,296 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, join, relative } from "node:path";
3
+ import { recordArticlesLoaded } from "../wiki/signals";
4
+ import { calculateTokens, type LayerContent } from "./budget";
5
+
6
+ // ── Types ────────────────────────────────────────────────────────────────────
7
+
8
+ export interface WikiContextOptions {
9
+ wikiDir: string;
10
+ workingFiles?: string[]; // from L1 — find their wiki articles
11
+ command?: string; // current command — determines which articles to load
12
+ }
13
+
14
+ interface WikiArticle {
15
+ path: string; // relative path within wiki dir (e.g. "decisions/adr-001.md")
16
+ title: string;
17
+ content: string;
18
+ category: string; // "decision" | "module" | "feature" | "architecture" | "index" | "other"
19
+ score: number;
20
+ }
21
+
22
+ // ── Constants ────────────────────────────────────────────────────────────────
23
+
24
+ /** Categories of wiki articles mapped to directory names. */
25
+ const CATEGORY_DIRS: Record<string, string> = {
26
+ decisions: "decision",
27
+ modules: "module",
28
+ features: "feature",
29
+ architecture: "architecture",
30
+ };
31
+
32
+ /**
33
+ * Commands that trigger specific article categories.
34
+ * Missing commands get the default behavior (index + top articles).
35
+ */
36
+ const COMMAND_CATEGORIES: Record<string, string[]> = {
37
+ review: ["decision", "module"],
38
+ verify: ["decision", "module"],
39
+ explain: ["decision", "module", "feature", "architecture"],
40
+ context: ["decision", "module", "feature", "architecture"],
41
+ commit: ["feature", "architecture"],
42
+ plan: ["feature", "architecture"],
43
+ design: ["decision"],
44
+ analyze: ["decision", "module", "feature", "architecture"],
45
+ pr: ["decision", "module", "feature"],
46
+ };
47
+
48
+ // ── Internal helpers ─────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Extracts a title from Markdown content.
52
+ * Uses the first `# ` heading, or falls back to the filename.
53
+ */
54
+ function extractTitle(content: string, filename: string): string {
55
+ const firstLine = content.split("\n").find((line) => line.startsWith("# "));
56
+ if (firstLine) {
57
+ return firstLine.replace(/^#\s+/, "").trim();
58
+ }
59
+ return basename(filename, ".md");
60
+ }
61
+
62
+ /**
63
+ * Simple Ebbinghaus-style score based on file modification time.
64
+ * More recently modified files get higher scores.
65
+ * Formula: exp(-0.05 * daysSinceModified) clamped to [0.1, 1.0].
66
+ */
67
+ function fileRecencyScore(filePath: string): number {
68
+ try {
69
+ const stat = statSync(filePath);
70
+ const daysSinceModified =
71
+ (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
72
+ const raw = Math.exp(-0.05 * daysSinceModified);
73
+ return Math.min(1, Math.max(0.1, raw));
74
+ } catch {
75
+ return 0.5; // fallback if stat fails
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Determines the category of a wiki article from its directory path.
81
+ */
82
+ function categorizeArticle(relPath: string): string {
83
+ const firstDir = relPath.split("/")[0] ?? "";
84
+ return CATEGORY_DIRS[firstDir] ?? "other";
85
+ }
86
+
87
+ /**
88
+ * Recursively collects all .md files from a directory.
89
+ * Excludes index.md (loaded separately).
90
+ */
91
+ function collectArticles(dir: string, baseDir: string): string[] {
92
+ const results: string[] = [];
93
+
94
+ let entries: string[];
95
+ try {
96
+ entries = readdirSync(dir) as unknown as string[];
97
+ } catch {
98
+ return results;
99
+ }
100
+
101
+ for (const entry of entries) {
102
+ const fullPath = join(dir, entry);
103
+ let stat: ReturnType<typeof statSync> | undefined;
104
+ try {
105
+ stat = statSync(fullPath);
106
+ } catch {
107
+ continue;
108
+ }
109
+
110
+ if (stat.isDirectory()) {
111
+ results.push(...collectArticles(fullPath, baseDir));
112
+ } else if (stat.isFile() && entry.endsWith(".md")) {
113
+ const rel = relative(baseDir, fullPath);
114
+ // Skip top-level index.md (loaded separately)
115
+ if (rel !== "index.md") {
116
+ results.push(fullPath);
117
+ }
118
+ }
119
+ }
120
+
121
+ return results;
122
+ }
123
+
124
+ /**
125
+ * Reads a file safely. Returns empty string on failure.
126
+ */
127
+ function tryReadFileSync(filePath: string): string {
128
+ try {
129
+ if (!existsSync(filePath)) return "";
130
+ return readFileSync(filePath, "utf8");
131
+ } catch {
132
+ return "";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Checks if a wiki article is relevant to the given working files.
138
+ * Matches module articles whose name appears in working file paths.
139
+ */
140
+ function isRelevantToWorkingFiles(
141
+ articleRelPath: string,
142
+ workingFiles: string[],
143
+ ): boolean {
144
+ if (workingFiles.length === 0) return false;
145
+
146
+ // Extract the article's base name (e.g., "context-engine" from "modules/context-engine.md")
147
+ const articleName = basename(articleRelPath, ".md").toLowerCase();
148
+
149
+ return workingFiles.some((f) => {
150
+ const lower = f.toLowerCase();
151
+ // Check if any working file path contains the article name
152
+ return (
153
+ lower.includes(articleName) ||
154
+ articleName.includes(basename(lower).replace(/\.\w+$/, ""))
155
+ );
156
+ });
157
+ }
158
+
159
+ // ── Public API ───────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Load wiki context from `.maina/wiki/`.
163
+ *
164
+ * 1. Checks if wiki dir exists with articles. Returns null if not initialized.
165
+ * 2. Always loads index.md (overview of what's available).
166
+ * 3. Loads relevant articles based on command context.
167
+ * 4. Scores articles by recency (Ebbinghaus-style) + working file relevance.
168
+ * 5. Returns a formatted LayerContent.
169
+ */
170
+ export function loadWikiContext(
171
+ options: WikiContextOptions,
172
+ ): LayerContent | null {
173
+ const { wikiDir, workingFiles, command } = options;
174
+
175
+ // Check if wiki directory exists
176
+ if (!existsSync(wikiDir)) {
177
+ return null;
178
+ }
179
+
180
+ // Check if there are any files at all
181
+ let dirEntries: string[];
182
+ try {
183
+ dirEntries = readdirSync(wikiDir) as unknown as string[];
184
+ } catch {
185
+ return null;
186
+ }
187
+
188
+ if (dirEntries.length === 0) {
189
+ return null;
190
+ }
191
+
192
+ // Load index.md if it exists
193
+ const indexPath = join(wikiDir, "index.md");
194
+ const indexContent = tryReadFileSync(indexPath);
195
+
196
+ // Collect all articles
197
+ const articlePaths = collectArticles(wikiDir, wikiDir);
198
+
199
+ // No articles and no index — wiki is empty
200
+ if (articlePaths.length === 0 && indexContent.length === 0) {
201
+ return null;
202
+ }
203
+
204
+ // Build article objects with scores
205
+ const articles: WikiArticle[] = [];
206
+ for (const fullPath of articlePaths) {
207
+ const relPath = relative(wikiDir, fullPath);
208
+ const content = tryReadFileSync(fullPath);
209
+ if (content.length === 0) continue;
210
+
211
+ const category = categorizeArticle(relPath);
212
+ const recency = fileRecencyScore(fullPath);
213
+
214
+ // Boost score if article is relevant to working files
215
+ const workingBoost =
216
+ workingFiles !== undefined &&
217
+ isRelevantToWorkingFiles(relPath, workingFiles)
218
+ ? 0.3
219
+ : 0;
220
+
221
+ const score = Math.min(1, recency + workingBoost);
222
+
223
+ articles.push({
224
+ path: relPath,
225
+ title: extractTitle(content, relPath),
226
+ content,
227
+ category,
228
+ score,
229
+ });
230
+ }
231
+
232
+ // Filter by command-relevant categories
233
+ const relevantCategories = command ? (COMMAND_CATEGORIES[command] ?? []) : [];
234
+
235
+ let filteredArticles: WikiArticle[];
236
+ if (relevantCategories.length > 0) {
237
+ filteredArticles = articles.filter(
238
+ (a) => relevantCategories.includes(a.category) || a.category === "other",
239
+ );
240
+ } else {
241
+ // Default: all articles
242
+ filteredArticles = articles;
243
+ }
244
+
245
+ // Sort by score descending
246
+ filteredArticles.sort((a, b) => b.score - a.score);
247
+
248
+ // Assemble text
249
+ const text = assembleWikiText(indexContent, filteredArticles);
250
+ const tokens = calculateTokens(text);
251
+
252
+ // Record which articles were loaded for RL tracking (non-blocking)
253
+ if (filteredArticles.length > 0) {
254
+ const signalsPath = join(wikiDir, ".signals.json");
255
+ const loadedArticlePaths = filteredArticles.map((a) => a.path);
256
+ recordArticlesLoaded(signalsPath, loadedArticlePaths, command ?? "unknown");
257
+ }
258
+
259
+ return {
260
+ name: "wiki",
261
+ text,
262
+ tokens,
263
+ priority: 4,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Formats wiki content into an LLM-readable text block.
269
+ */
270
+ export function assembleWikiText(
271
+ indexContent: string,
272
+ articles: WikiArticle[],
273
+ ): string {
274
+ const parts: string[] = [];
275
+
276
+ parts.push("## Wiki Knowledge (Layer 5)");
277
+ parts.push("");
278
+
279
+ if (indexContent.length > 0) {
280
+ parts.push("### Index");
281
+ parts.push(indexContent);
282
+ parts.push("");
283
+ }
284
+
285
+ if (articles.length > 0) {
286
+ parts.push("### Relevant Articles");
287
+
288
+ for (const article of articles) {
289
+ parts.push(`#### ${article.title} (wiki/${article.path})`);
290
+ parts.push(article.content);
291
+ parts.push("---");
292
+ }
293
+ }
294
+
295
+ return parts.join("\n");
296
+ }
package/src/db/index.ts CHANGED
@@ -149,6 +149,18 @@ function createStatsTables(db: Database): void {
149
149
  } catch {
150
150
  // Column already exists — safe to ignore
151
151
  }
152
+
153
+ db.exec(`
154
+ CREATE TABLE IF NOT EXISTS tool_usage (
155
+ id TEXT PRIMARY KEY,
156
+ tool TEXT NOT NULL,
157
+ input_hash TEXT NOT NULL,
158
+ duration_ms INTEGER NOT NULL,
159
+ cache_hit INTEGER NOT NULL DEFAULT 0,
160
+ timestamp TEXT NOT NULL,
161
+ workflow_id TEXT
162
+ );
163
+ `);
152
164
  }
153
165
 
154
166
  /**
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createCacheManager } from "../../cache/manager";
5
+ import { buildToolCacheKey, captureResult, getCachedResult } from "../capture";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = join(
11
+ import.meta.dir,
12
+ `tmp-capture-${Date.now()}-${Math.random().toString(36).slice(2)}`,
13
+ );
14
+ mkdirSync(tmpDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ rmSync(tmpDir, { recursive: true, force: true });
20
+ } catch {
21
+ /* ignore */
22
+ }
23
+ });
24
+
25
+ describe("buildToolCacheKey", () => {
26
+ test("returns consistent hash for same inputs", () => {
27
+ const key1 = buildToolCacheKey("reviewCode", { diff: "hello" });
28
+ const key2 = buildToolCacheKey("reviewCode", { diff: "hello" });
29
+ expect(key1).toBe(key2);
30
+ });
31
+
32
+ test("returns different hash for different tool names", () => {
33
+ const key1 = buildToolCacheKey("reviewCode", { diff: "hello" });
34
+ const key2 = buildToolCacheKey("verify", { diff: "hello" });
35
+ expect(key1).not.toBe(key2);
36
+ });
37
+
38
+ test("returns different hash for different inputs", () => {
39
+ const key1 = buildToolCacheKey("reviewCode", { diff: "hello" });
40
+ const key2 = buildToolCacheKey("reviewCode", { diff: "world" });
41
+ expect(key1).not.toBe(key2);
42
+ });
43
+ });
44
+
45
+ describe("getCachedResult", () => {
46
+ test("returns null on cache miss", () => {
47
+ const result = getCachedResult("reviewCode", { diff: "test" }, tmpDir);
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ test("returns cached value after captureResult stores it", () => {
52
+ captureResult({
53
+ tool: "reviewCode",
54
+ input: { diff: "test-diff" },
55
+ output: '{"passed": true}',
56
+ durationMs: 100,
57
+ mainaDir: tmpDir,
58
+ });
59
+ const cached = getCachedResult("reviewCode", { diff: "test-diff" }, tmpDir);
60
+ expect(cached).toBe('{"passed": true}');
61
+ });
62
+
63
+ test("returns null for different input", () => {
64
+ captureResult({
65
+ tool: "reviewCode",
66
+ input: { diff: "original" },
67
+ output: '{"passed": true}',
68
+ durationMs: 100,
69
+ mainaDir: tmpDir,
70
+ });
71
+ const cached = getCachedResult("reviewCode", { diff: "modified" }, tmpDir);
72
+ expect(cached).toBeNull();
73
+ });
74
+ });
75
+
76
+ describe("captureResult", () => {
77
+ test("stores result in cache", () => {
78
+ captureResult({
79
+ tool: "verify",
80
+ input: { files: ["a.ts"] },
81
+ output: '{"passed": true, "findings": []}',
82
+ durationMs: 50,
83
+ mainaDir: tmpDir,
84
+ });
85
+ const cache = createCacheManager(tmpDir);
86
+ const key = buildToolCacheKey("verify", { files: ["a.ts"] });
87
+ const entry = cache.get(key);
88
+ expect(entry).not.toBeNull();
89
+ expect(entry?.value).toBe('{"passed": true, "findings": []}');
90
+ });
91
+
92
+ test("records tool usage in stats", () => {
93
+ const { getStatsDb } = require("../../db/index");
94
+ captureResult({
95
+ tool: "reviewCode",
96
+ input: { diff: "test" },
97
+ output: "result",
98
+ durationMs: 250,
99
+ mainaDir: tmpDir,
100
+ });
101
+ const dbResult = getStatsDb(tmpDir);
102
+ expect(dbResult.ok).toBe(true);
103
+ if (!dbResult.ok) return;
104
+ const { db } = dbResult.value;
105
+ const rows = db.query("SELECT * FROM tool_usage").all() as Array<{
106
+ tool: string;
107
+ duration_ms: number;
108
+ cache_hit: number;
109
+ }>;
110
+ expect(rows).toHaveLength(1);
111
+ expect(rows[0]?.tool).toBe("reviewCode");
112
+ expect(rows[0]?.duration_ms).toBe(250);
113
+ expect(rows[0]?.cache_hit).toBe(0);
114
+ });
115
+
116
+ test("rejects previous result on re-run with workflowId", () => {
117
+ const { getFeedbackDb } = require("../../db/index");
118
+ const { recordFeedback } = require("../collector");
119
+
120
+ // Simulate a prior tool call with workflow context
121
+ recordFeedback(tmpDir, {
122
+ promptHash: "reviewCode-mcp",
123
+ task: "reviewCode",
124
+ accepted: true,
125
+ timestamp: new Date().toISOString(),
126
+ });
127
+ // Set workflow_id on the row
128
+ const dbResult = getFeedbackDb(tmpDir);
129
+ if (!dbResult.ok) return;
130
+ const { db } = dbResult.value;
131
+ db.prepare(
132
+ "UPDATE feedback SET workflow_id = ? WHERE id = (SELECT id FROM feedback ORDER BY rowid DESC LIMIT 1)",
133
+ ).run("wf-test");
134
+
135
+ // Re-run the tool (different input) — should reject previous
136
+ captureResult({
137
+ tool: "reviewCode",
138
+ input: { diff: "new-diff" },
139
+ output: "new-result",
140
+ durationMs: 100,
141
+ mainaDir: tmpDir,
142
+ workflowId: "wf-test",
143
+ });
144
+
145
+ const rows = db
146
+ .query(
147
+ "SELECT accepted FROM feedback WHERE command = ? AND workflow_id = ? ORDER BY created_at ASC",
148
+ )
149
+ .all("reviewCode", "wf-test") as Array<{ accepted: number }>;
150
+
151
+ // First entry should be rejected (0), new entry recorded via microtask
152
+ expect(rows[0]?.accepted).toBe(0);
153
+ });
154
+
155
+ test("does not throw on any failure", () => {
156
+ expect(() => {
157
+ captureResult({
158
+ tool: "reviewCode",
159
+ input: { diff: "test" },
160
+ output: "result",
161
+ durationMs: 100,
162
+ mainaDir: "/nonexistent/path/that/should/not/exist",
163
+ });
164
+ }).not.toThrow();
165
+ });
166
+ });
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getFeedbackDb } from "../../db/index";
5
+ import { recordFeedback } from "../collector";
6
+ import { emitAcceptSignal, emitRejectSignal } from "../signals";
7
+
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = join(
12
+ import.meta.dir,
13
+ `tmp-signals-${Date.now()}-${Math.random().toString(36).slice(2)}`,
14
+ );
15
+ mkdirSync(tmpDir, { recursive: true });
16
+ });
17
+
18
+ afterEach(() => {
19
+ try {
20
+ rmSync(tmpDir, { recursive: true, force: true });
21
+ } catch {
22
+ /* ignore */
23
+ }
24
+ });
25
+
26
+ /** Insert a feedback row and set its workflow_id. */
27
+ function insertFeedback(
28
+ tool: string,
29
+ workflowId: string,
30
+ accepted: boolean,
31
+ ): void {
32
+ recordFeedback(tmpDir, {
33
+ promptHash: `${tool}-mcp`,
34
+ task: tool,
35
+ accepted,
36
+ timestamp: new Date().toISOString(),
37
+ });
38
+ const dbResult = getFeedbackDb(tmpDir);
39
+ if (!dbResult.ok) return;
40
+ const { db } = dbResult.value;
41
+ db.prepare(
42
+ `UPDATE feedback SET workflow_id = ?
43
+ WHERE id = (SELECT id FROM feedback ORDER BY rowid DESC LIMIT 1)`,
44
+ ).run(workflowId);
45
+ }
46
+
47
+ describe("emitAcceptSignal", () => {
48
+ test("marks recent review/verify entries as accepted", () => {
49
+ insertFeedback("reviewCode", "wf-1", false);
50
+ insertFeedback("verify", "wf-1", false);
51
+
52
+ emitAcceptSignal(tmpDir, "wf-1");
53
+
54
+ const dbResult = getFeedbackDb(tmpDir);
55
+ expect(dbResult.ok).toBe(true);
56
+ if (!dbResult.ok) return;
57
+ const { db } = dbResult.value;
58
+ const rows = db
59
+ .query("SELECT command, accepted FROM feedback WHERE workflow_id = ?")
60
+ .all("wf-1") as Array<{ command: string; accepted: number }>;
61
+ for (const row of rows) {
62
+ expect(row.accepted).toBe(1);
63
+ }
64
+ });
65
+
66
+ test("does not affect entries from a different workflow", () => {
67
+ insertFeedback("reviewCode", "wf-1", false);
68
+ insertFeedback("reviewCode", "wf-2", false);
69
+
70
+ emitAcceptSignal(tmpDir, "wf-1");
71
+
72
+ const dbResult = getFeedbackDb(tmpDir);
73
+ if (!dbResult.ok) return;
74
+ const { db } = dbResult.value;
75
+ const wf2 = db
76
+ .query("SELECT accepted FROM feedback WHERE workflow_id = ?")
77
+ .get("wf-2") as { accepted: number } | null;
78
+ expect(wf2?.accepted).toBe(0);
79
+ });
80
+
81
+ test("accepts custom tool list", () => {
82
+ insertFeedback("reviewCode", "wf-1", false);
83
+ insertFeedback("verify", "wf-1", false);
84
+
85
+ emitAcceptSignal(tmpDir, "wf-1", ["reviewCode"]);
86
+
87
+ const dbResult = getFeedbackDb(tmpDir);
88
+ if (!dbResult.ok) return;
89
+ const { db } = dbResult.value;
90
+ const review = db
91
+ .query(
92
+ "SELECT accepted FROM feedback WHERE command = ? AND workflow_id = ?",
93
+ )
94
+ .get("reviewCode", "wf-1") as { accepted: number } | null;
95
+ expect(review?.accepted).toBe(1);
96
+ const verify = db
97
+ .query(
98
+ "SELECT accepted FROM feedback WHERE command = ? AND workflow_id = ?",
99
+ )
100
+ .get("verify", "wf-1") as { accepted: number } | null;
101
+ expect(verify?.accepted).toBe(0);
102
+ });
103
+ });
104
+
105
+ describe("emitRejectSignal", () => {
106
+ test("marks the most recent entry for a tool+workflow as rejected", () => {
107
+ insertFeedback("reviewCode", "wf-1", true);
108
+ emitRejectSignal(tmpDir, "reviewCode", "wf-1");
109
+
110
+ const dbResult = getFeedbackDb(tmpDir);
111
+ if (!dbResult.ok) return;
112
+ const { db } = dbResult.value;
113
+ const row = db
114
+ .query(
115
+ "SELECT accepted FROM feedback WHERE command = ? AND workflow_id = ? ORDER BY created_at DESC LIMIT 1",
116
+ )
117
+ .get("reviewCode", "wf-1") as { accepted: number } | null;
118
+ expect(row?.accepted).toBe(0);
119
+ });
120
+
121
+ test("only rejects the most recent entry, not older ones", () => {
122
+ insertFeedback("reviewCode", "wf-1", true);
123
+ insertFeedback("reviewCode", "wf-1", true);
124
+ emitRejectSignal(tmpDir, "reviewCode", "wf-1");
125
+
126
+ const dbResult = getFeedbackDb(tmpDir);
127
+ if (!dbResult.ok) return;
128
+ const { db } = dbResult.value;
129
+ const rows = db
130
+ .query(
131
+ "SELECT accepted FROM feedback WHERE command = ? AND workflow_id = ? ORDER BY created_at ASC",
132
+ )
133
+ .all("reviewCode", "wf-1") as Array<{ accepted: number }>;
134
+ expect(rows).toHaveLength(2);
135
+ expect(rows[0]?.accepted).toBe(1);
136
+ expect(rows[1]?.accepted).toBe(0);
137
+ });
138
+
139
+ test("does not throw when no matching entries exist", () => {
140
+ expect(() => {
141
+ emitRejectSignal(tmpDir, "reviewCode", "nonexistent");
142
+ }).not.toThrow();
143
+ });
144
+ });