@rigour-labs/core 6.5.2 → 6.5.4

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 (138) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index.d.ts +5 -0
  3. package/dist/context/automatic-index.js +24 -3
  4. package/dist/context/index-status.test.d.ts +1 -0
  5. package/dist/context/index-status.test.js +26 -0
  6. package/dist/deep/risk.d.ts +6 -0
  7. package/dist/deep/risk.js +7 -1
  8. package/dist/deep/risk.test.js +12 -1
  9. package/dist/firewall/arbitration-token.d.ts +12 -3
  10. package/dist/firewall/arbitration-token.js +30 -23
  11. package/dist/firewall/attestation.d.ts +7 -2
  12. package/dist/firewall/attestation.js +49 -28
  13. package/dist/firewall/trust-boundaries.test.d.ts +1 -0
  14. package/dist/firewall/trust-boundaries.test.js +64 -0
  15. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  16. package/dist/gates/dedupe-failures.test.js +12 -0
  17. package/dist/gates/deep-analysis.d.ts +6 -0
  18. package/dist/gates/deep-analysis.js +8 -1
  19. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  20. package/dist/gates/deprecated-dependencies.js +104 -0
  21. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  22. package/dist/gates/deprecated-dependencies.test.js +55 -0
  23. package/dist/gates/runner.d.ts +7 -1
  24. package/dist/gates/runner.js +25 -11
  25. package/dist/gates/security-patterns-data.js +2 -1
  26. package/dist/gates/security-patterns-owasp.test.js +15 -0
  27. package/dist/gates/semantic-bugs.js +1 -1
  28. package/dist/gates/trusted-reviews.test.d.ts +1 -0
  29. package/dist/gates/trusted-reviews.test.js +16 -0
  30. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  31. package/dist/gates/unindexed-reads/coverage.js +24 -0
  32. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  33. package/dist/gates/unindexed-reads/index.js +98 -0
  34. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  35. package/dist/gates/unindexed-reads/queries.js +147 -0
  36. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  37. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  38. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  39. package/dist/gates/unindexed-reads/schema.js +256 -0
  40. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  41. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  42. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  43. package/dist/gates/unindexed-reads/sql.js +131 -0
  44. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  45. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  46. package/dist/hooks/checker.js +22 -0
  47. package/dist/hooks/checker.test.js +8 -0
  48. package/dist/hooks/index.d.ts +1 -0
  49. package/dist/hooks/index.js +1 -0
  50. package/dist/hooks/session-state.d.ts +8 -0
  51. package/dist/hooks/session-state.js +61 -0
  52. package/dist/hooks/stop-review.d.ts +6 -4
  53. package/dist/hooks/stop-review.js +5 -3
  54. package/dist/hooks/templates.js +1 -1
  55. package/dist/index.d.ts +6 -1
  56. package/dist/index.js +6 -1
  57. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  58. package/dist/inference/brain-placeholder.test.js +28 -0
  59. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  60. package/dist/inference/llama-engine-checksum.test.js +27 -0
  61. package/dist/inference/llama-engine.d.ts +4 -1
  62. package/dist/inference/llama-engine.js +15 -8
  63. package/dist/memory/recall.d.ts +37 -0
  64. package/dist/memory/recall.js +93 -0
  65. package/dist/memory/recall.test.d.ts +1 -0
  66. package/dist/memory/recall.test.js +36 -0
  67. package/dist/pattern-index/assess.d.ts +16 -0
  68. package/dist/pattern-index/assess.js +37 -0
  69. package/dist/pattern-index/embeddings.d.ts +19 -0
  70. package/dist/pattern-index/embeddings.js +16 -0
  71. package/dist/pattern-index/index.d.ts +3 -2
  72. package/dist/pattern-index/index.js +4 -2
  73. package/dist/pattern-index/indexer.js +15 -7
  74. package/dist/pattern-index/matcher.d.ts +6 -0
  75. package/dist/pattern-index/matcher.js +13 -3
  76. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  77. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  78. package/dist/pattern-index/staleness.d.ts +2 -15
  79. package/dist/pattern-index/staleness.js +1 -78
  80. package/dist/pattern-index/types.d.ts +2 -0
  81. package/dist/review/agent-fixes.d.ts +4 -0
  82. package/dist/review/agent-fixes.js +10 -7
  83. package/dist/review/agent-fixes.test.js +4 -4
  84. package/dist/review/changed-lines.d.ts +2 -1
  85. package/dist/review/changed-lines.js +12 -2
  86. package/dist/review/check-outcomes.d.ts +32 -0
  87. package/dist/review/check-outcomes.js +90 -0
  88. package/dist/review/check-outcomes.test.d.ts +1 -0
  89. package/dist/review/check-outcomes.test.js +56 -0
  90. package/dist/review/git-diff.d.ts +3 -0
  91. package/dist/review/git-diff.js +5 -0
  92. package/dist/review/migration-order.d.ts +5 -0
  93. package/dist/review/migration-order.js +70 -0
  94. package/dist/review/migration-order.test.d.ts +1 -0
  95. package/dist/review/migration-order.test.js +62 -0
  96. package/dist/review/quiet.d.ts +5 -2
  97. package/dist/review/quiet.js +21 -8
  98. package/dist/review/review.d.ts +6 -0
  99. package/dist/review/review.js +20 -3
  100. package/dist/review/review.test.js +30 -0
  101. package/dist/review/trusted-state.d.ts +6 -0
  102. package/dist/review/trusted-state.js +35 -0
  103. package/dist/semantic/ast.d.ts +1 -0
  104. package/dist/semantic/ast.js +4 -0
  105. package/dist/semantic/learn/compile.js +3 -1
  106. package/dist/semantic/learn/learn.js +40 -5
  107. package/dist/semantic/types.d.ts +2 -0
  108. package/dist/services/context-scope-summary.test.d.ts +1 -0
  109. package/dist/services/context-scope-summary.test.js +17 -0
  110. package/dist/services/context-telemetry-service.js +2 -1
  111. package/dist/storage/context-telemetry.js +33 -19
  112. package/dist/storage/db.js +52 -1
  113. package/dist/storage/findings.js +1 -1
  114. package/dist/storage/fix-lessons.d.ts +13 -0
  115. package/dist/storage/fix-lessons.js +84 -0
  116. package/dist/storage/fix-lessons.test.d.ts +1 -0
  117. package/dist/storage/fix-lessons.test.js +61 -0
  118. package/dist/storage/index.d.ts +2 -1
  119. package/dist/storage/index.js +2 -1
  120. package/dist/storage/lessons.d.ts +16 -2
  121. package/dist/storage/lessons.js +27 -61
  122. package/dist/storage/lessons.test.js +1 -15
  123. package/dist/storage/local-memory.js +39 -5
  124. package/dist/storage/local-memory.test.d.ts +1 -0
  125. package/dist/storage/local-memory.test.js +55 -0
  126. package/dist/storage/patterns.d.ts +1 -1
  127. package/dist/storage/patterns.js +1 -1
  128. package/dist/storage/share-memory.test.d.ts +1 -0
  129. package/dist/storage/share-memory.test.js +34 -0
  130. package/dist/storage/team-vector-store.js +2 -1
  131. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  132. package/dist/storage/telemetry-scope.test.js +38 -0
  133. package/dist/templates/universal-config.js +11 -0
  134. package/dist/types/index.d.ts +137 -0
  135. package/dist/types/index.js +15 -0
  136. package/dist/utils/user-state.d.ts +7 -0
  137. package/dist/utils/user-state.js +21 -0
  138. package/package.json +7 -7
@@ -0,0 +1,90 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { UnindexedReadsGate } from './index.js';
6
+ let repo;
7
+ const write = (rel, body) => {
8
+ fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
9
+ fs.writeFileSync(path.join(repo, rel), body);
10
+ };
11
+ const run = (config = { enabled: true }) => new UnindexedReadsGate(config).run({ cwd: repo, patterns: ['src/**/*.ts'] });
12
+ const TABLE = `create table orders (
13
+ id uuid primary key,
14
+ customer_id uuid,
15
+ status text not null,
16
+ placed_at timestamptz,
17
+ campaign_id uuid
18
+ );
19
+ create index orders_customer_idx on orders (customer_id, status, placed_at desc);
20
+ create index orders_campaign_window_idx on orders (placed_at, id) where status = 'open' and campaign_id is not null;
21
+ `;
22
+ const WINDOW_READ = `
23
+ export async function openOrders(db: Db, since: string, until: string) {
24
+ return db
25
+ .from('orders')
26
+ .select('id, placed_at')
27
+ .eq('status', 'open')
28
+ .not('customer_id', 'is', null)
29
+ .gt('placed_at', since)
30
+ .lte('placed_at', until)
31
+ .order('placed_at')
32
+ .order('id');
33
+ }
34
+ `;
35
+ beforeEach(() => { repo = fs.mkdtempSync(path.join(os.tmpdir(), 'unindexed-')); });
36
+ afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
37
+ describe('UnindexedReadsGate', () => {
38
+ it('flags a window read whose only candidate index is partial on a filter the read does not have', async () => {
39
+ write('supabase/migrations/20260101000000_orders.sql', TABLE);
40
+ write('src/orders.ts', WINDOW_READ);
41
+ const failures = await run();
42
+ expect(failures).toHaveLength(1);
43
+ expect(failures[0]).toMatchObject({ id: 'unindexed-reads', files: ['src/orders.ts'], line: 4 });
44
+ expect(failures[0].details).toContain('`orders`');
45
+ expect(failures[0].details).toContain('`placed_at`');
46
+ });
47
+ it('is silent once a later migration adds an index that serves the read', async () => {
48
+ write('supabase/migrations/20260101000000_orders.sql', TABLE);
49
+ write('supabase/migrations/20260102000000_orders_open.sql', `create index concurrently orders_open_window_idx on public.orders (placed_at, id) where status = 'open' and customer_id is not null;`);
50
+ write('src/orders.ts', WINDOW_READ);
51
+ expect(await run()).toEqual([]);
52
+ });
53
+ it('is silent for a primary-key lookup, a table outside the migrations, and an uncertain table', async () => {
54
+ write('db/migrations/001_orders.sql', TABLE + 'create table notes (id int, body text);\nalter table notes rename column body to text;\n');
55
+ write('src/reads.ts', `
56
+ db.from('orders').select('*').eq('id', id);
57
+ db.from('auth_users').select('*').eq('email', email);
58
+ db.from('notes').select('*').eq('text', q);
59
+ `);
60
+ expect(await run()).toEqual([]);
61
+ });
62
+ it('is silent unless enabled, and for a read that narrows nothing', async () => {
63
+ write('supabase/migrations/20260101000000_orders.sql', TABLE);
64
+ write('src/orders.ts', WINDOW_READ + `\ndb.from('orders').select('*').limit(10);\n`);
65
+ expect(await run({ enabled: false })).toEqual([]);
66
+ expect(await new UnindexedReadsGate({ enabled: true }).run({ cwd: repo, patterns: ['src/**/*.ts'] })).toHaveLength(1);
67
+ });
68
+ it('needs every migration set that defines the table to lack a serving index', async () => {
69
+ write('apps/a/supabase/migrations/1_orders.sql', TABLE);
70
+ write('apps/b/supabase/migrations/1_orders.sql', TABLE + `create index orders_status_idx on orders (status);`);
71
+ write('src/orders.ts', WINDOW_READ);
72
+ expect(await run()).toEqual([]);
73
+ });
74
+ it('counts a row-level policy as a filter: names its column, and is silent once that column is indexed', async () => {
75
+ const subs = `create table subs (id text primary key, user_id uuid not null, status text);
76
+ create policy "own subs" on subs for select using (auth.uid() = user_id);`;
77
+ write('supabase/migrations/1_subs.sql', subs);
78
+ write('src/subs.ts', `export const active = (db: Db) => db.from('subs').select('*').in('status', ['active']).maybeSingle();\n`);
79
+ const [finding] = await run();
80
+ expect(finding.details).toContain('`user_id`');
81
+ write('supabase/migrations/2_subs_user.sql', 'create index subs_user_idx on subs (user_id);');
82
+ expect(await run()).toEqual([]);
83
+ });
84
+ it('is silent for a table whose policy it cannot read', async () => {
85
+ write('supabase/migrations/1_docs.sql', `create table docs (id int primary key, org_id int, title text);
86
+ create policy "members" on docs for select using (exists (select 1 from members m where m.org_id = docs.org_id));`);
87
+ write('src/docs.ts', `db.from('docs').select('*').eq('title', t);\n`);
88
+ expect(await run()).toEqual([]);
89
+ });
90
+ });
@@ -110,6 +110,11 @@ export async function runHookChecker(options) {
110
110
  if (!resolved) {
111
111
  continue;
112
112
  }
113
+ // Before ignore patterns: rigour.yml commonly ignores .rigour/ for scanning, not for writes.
114
+ if (isRigourState(resolved.relPath)) {
115
+ failures.push(rigourStateFailure(resolved.relPath));
116
+ continue;
117
+ }
113
118
  // Respect rigour.yml ignore patterns
114
119
  if (isIgnored(resolved.relPath, ignorePatterns)) {
115
120
  continue;
@@ -270,6 +275,23 @@ function checkCommandInjection(line, i, relPath, failures) {
270
275
  });
271
276
  }
272
277
  }
278
+ // ── Rigour's own state (always on) ────────────────────────────────
279
+ /**
280
+ * .rigour/ holds what decides a review: dismissals, check outcomes, learned rules, the review
281
+ * ledger, operator scopes. Rigour writes it through its own commands and tools, never through
282
+ * the agent's edit tool, so an agent edit there is refused whatever rigour.yml says.
283
+ */
284
+ function isRigourState(relPath) {
285
+ return relPath.replace(/\\/g, '/').startsWith('.rigour/');
286
+ }
287
+ function rigourStateFailure(relPath) {
288
+ return {
289
+ gate: 'file-guard',
290
+ file: relPath,
291
+ message: `BLOCKED: "${relPath}" is Rigour's own state. Change it with Rigour's commands (rigour dismiss, rigour_review_ack, rigour_remember), not by editing the file.`,
292
+ severity: 'critical',
293
+ };
294
+ }
273
295
  // ── Protected Paths Enforcement (real-time) ───────────────────────
274
296
  /**
275
297
  * Block writes to protected paths defined in rigour.yml safety.protected_paths.
@@ -43,6 +43,14 @@ describe('runHookChecker', () => {
43
43
  const optedIn = await runHookChecker({ cwd: testDir, files: [filePath] });
44
44
  expect(optedIn.failures.some(f => f.gate === 'governance')).toBe(true);
45
45
  });
46
+ it("refuses an agent edit to Rigour's own state, even when rigour.yml ignores .rigour/", async () => {
47
+ fs.writeFileSync(path.join(testDir, 'rigour.yml'), 'version: 1\nignore:\n - ".rigour/**"\n');
48
+ fs.mkdirSync(path.join(testDir, '.rigour'), { recursive: true });
49
+ fs.writeFileSync(path.join(testDir, '.rigour', 'dismissed.json'), '{"version":1,"entries":[]}');
50
+ const result = await runHookChecker({ cwd: testDir, files: ['.rigour/dismissed.json'] });
51
+ expect(result.status).toBe('fail');
52
+ expect(result.failures[0]).toMatchObject({ gate: 'file-guard', severity: 'critical' });
53
+ });
46
54
  it('should detect file size violations', async () => {
47
55
  const filePath = path.join(testDir, 'big.ts');
48
56
  const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
@@ -16,3 +16,4 @@ export { generateDLPHookFiles } from './dlp-templates.js';
16
16
  export type { GeneratedDLPHookFile } from './dlp-templates.js';
17
17
  export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
18
18
  export type { StopDecision } from './stop-review.js';
19
+ export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts } from './session-state.js';
@@ -11,3 +11,4 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
11
11
  export { generateDLPHookFiles } from './dlp-templates.js';
12
12
  // Stop review ("before you say done")
13
13
  export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
14
+ export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts } from './session-state.js';
@@ -0,0 +1,8 @@
1
+ /** Record where the session started, once; later calls keep the first commit. */
2
+ export declare function recordSessionBaseline(cwd: string, session: string): void;
3
+ /** The commit the session started from, if it is still in history (a rebase can drop it). */
4
+ export declare function sessionBaseline(cwd: string, session: string): string | undefined;
5
+ /** This stop's attempt number: blocks so far plus one. */
6
+ export declare function nextStopAttempt(cwd: string, session: string): number;
7
+ /** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
8
+ export declare function clearStopAttempts(cwd: string, session: string): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What the agent hooks remember per session, kept outside the workspace (utils/user-state.ts)
3
+ * so the agent being reviewed cannot rewrite it:
4
+ * - baseline: the commit HEAD was at when the session first edited a file. The stop review
5
+ * covers everything since then, so committing mid-session hides nothing.
6
+ * - attempts: how many times the stop review has blocked this session.
7
+ */
8
+ import { spawnSync } from 'child_process';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { repoStateDir } from '../utils/user-state.js';
12
+ /** Sessions untouched this long are forgotten, so the store stays small. */
13
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14
+ function storePath(cwd) {
15
+ return path.join(repoStateDir(cwd), 'sessions.json');
16
+ }
17
+ function readStore(cwd) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(storePath(cwd), 'utf8'));
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ }
25
+ function update(cwd, session, change) {
26
+ const store = readStore(cwd);
27
+ const now = Date.now();
28
+ for (const [id, entry] of Object.entries(store))
29
+ if (now - entry.at > SESSION_TTL_MS)
30
+ delete store[id];
31
+ store[session] = { ...change(store[session] ?? { at: now }), at: now };
32
+ fs.mkdirSync(path.dirname(storePath(cwd)), { recursive: true, mode: 0o700 });
33
+ fs.writeFileSync(storePath(cwd), JSON.stringify(store), { mode: 0o600 });
34
+ return store[session];
35
+ }
36
+ /** Record where the session started, once; later calls keep the first commit. */
37
+ export function recordSessionBaseline(cwd, session) {
38
+ if (!session || readStore(cwd)[session]?.baseline)
39
+ return;
40
+ const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd, encoding: 'utf8' });
41
+ if (head.status !== 0)
42
+ return; // no commits yet: the working-tree review already covers everything
43
+ update(cwd, session, entry => ({ ...entry, baseline: head.stdout.trim() }));
44
+ }
45
+ /** The commit the session started from, if it is still in history (a rebase can drop it). */
46
+ export function sessionBaseline(cwd, session) {
47
+ const baseline = readStore(cwd)[session]?.baseline;
48
+ if (!baseline)
49
+ return undefined;
50
+ const known = spawnSync('git', ['cat-file', '-e', `${baseline}^{commit}`], { cwd });
51
+ return known.status === 0 ? baseline : undefined;
52
+ }
53
+ /** This stop's attempt number: blocks so far plus one. */
54
+ export function nextStopAttempt(cwd, session) {
55
+ return update(cwd, session, entry => ({ ...entry, attempts: (entry.attempts ?? 0) + 1 })).attempts ?? 1;
56
+ }
57
+ /** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
58
+ export function clearStopAttempts(cwd, session) {
59
+ if (readStore(cwd)[session]?.attempts)
60
+ update(cwd, session, entry => ({ ...entry, attempts: 0 }));
61
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
- * The "before you say done" review: when an agent is about to stop, review the
3
- * uncommitted change and, if it introduced serious findings, tell the agent
4
- * what to fix instead of letting it finish.
2
+ * The "before you say done" review: when an agent is about to stop, review what
3
+ * the session changed (since its baseline commit, or the uncommitted change when
4
+ * none was recorded) and, if it introduced serious findings, tell the agent what
5
+ * to fix instead of letting it finish.
5
6
  *
6
7
  * Only findings that deserve it block: critical ones, and high ones that are
7
8
  * proven (the semantic engine traced them) or security findings. A high
@@ -20,7 +21,8 @@ export interface StopDecision {
20
21
  findings: Failure[];
21
22
  reviewedFiles: string[];
22
23
  }
23
- export declare function stopReview(cwd: string, config: Config, attempt: number): Promise<StopDecision>;
24
+ /** `since`: the session's baseline commit (session-state.ts); committing after it hides nothing. */
25
+ export declare function stopReview(cwd: string, config: Config, attempt: number, since?: string): Promise<StopDecision>;
24
26
  export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
25
27
  /** Critical, or high and either proven by the semantic engine or a security finding. */
26
28
  export declare function blocksStop(finding: Failure): boolean;
@@ -3,9 +3,11 @@ import { buildReviewTask } from '../review/review-task.js';
3
3
  import { diffFromGit } from '../review/git-diff.js';
4
4
  export const STOP_MAX_ATTEMPTS = 3;
5
5
  const MAX_LISTED = 8;
6
- export async function stopReview(cwd, config, attempt) {
7
- const diff = diffFromGit(cwd, { mode: 'working' });
8
- const result = await reviewChange({ cwd, config, diff });
6
+ /** `since`: the session's baseline commit (session-state.ts); committing after it hides nothing. */
7
+ export async function stopReview(cwd, config, attempt, since) {
8
+ const source = since ? { mode: 'since', commit: since } : { mode: 'working' };
9
+ const diff = diffFromGit(cwd, source);
10
+ const result = await reviewChange({ cwd, config, diff, source });
9
11
  const blocking = result.findings.filter(blocksStop);
10
12
  const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
11
13
  const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines) };
@@ -47,7 +47,7 @@ function generateClaudeHooks(checkerCommand) {
47
47
  hooks: [
48
48
  {
49
49
  type: "command",
50
- command: `${checkerCommand} --files "$TOOL_INPUT_file_path"`,
50
+ command: `${checkerCommand} --stdin`,
51
51
  }
52
52
  ]
53
53
  }
package/dist/index.d.ts CHANGED
@@ -11,8 +11,10 @@ export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js'
11
11
  export * from './utils/logger.js';
12
12
  export { normalizeScopePatterns, isScoped } from './utils/scope.js';
13
13
  export { deepAnalysisError } from './utils/deep-status.js';
14
+ export { rigourUserDir, repoStateDir } from './utils/user-state.js';
14
15
  export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, type ReviewFinding } from './review/review.js';
15
16
  export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
17
+ export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-state.js';
16
18
  export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
17
19
  export { isGeneratedFile } from './review/generated-files.js';
18
20
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
@@ -27,6 +29,7 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
27
29
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
28
30
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
29
31
  export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
32
+ export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
30
33
  export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
31
34
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
32
35
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
@@ -47,7 +50,9 @@ export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, rese
47
50
  export type { RigourDB, CompactResult } from './storage/index.js';
48
51
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
49
52
  export type { ProjectStats } from './storage/index.js';
50
- export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, queueLocalLessonsForTeam } from './storage/index.js';
53
+ export { recordFixLessons, fixLessonSubject, fixLessonPrefix, FILES_TO_VALIDATE } from './storage/index.js';
54
+ export { recordInteractionEvidence, countInteractionEvidence, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, shareMemoryLesson, queueLocalLessonsForTeam } from './storage/index.js';
55
+ export { rankMemories, rankByMeaning, memoryText, SEMANTIC_FLOOR, RECALL_LIMIT, type MemoryEntry, type MemoryScope, type RankedMemory, type Ranked } from './memory/recall.js';
51
56
  export type { ApplicableLessons, InteractionEvidence, LessonRecord, LessonState, LessonVisibility, LocalLessonImportOptions, LocalLessonImportResult } from './storage/index.js';
52
57
  export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, explainTeamConnectionError, } from './storage/index.js';
53
58
  export type { TeamConfiguration, TeamSemanticConfiguration, TeamModeStatus, TeamDoctorResult, SemanticKnowledgeCandidate, SemanticKnowledgeResult, } from './storage/index.js';
package/dist/index.js CHANGED
@@ -11,8 +11,10 @@ export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js'
11
11
  export * from './utils/logger.js';
12
12
  export { normalizeScopePatterns, isScoped } from './utils/scope.js';
13
13
  export { deepAnalysisError } from './utils/deep-status.js';
14
+ export { rigourUserDir, repoStateDir } from './utils/user-state.js';
14
15
  export { reviewChange, toReviewFinding } from './review/review.js';
15
16
  export { splitByChangedLines } from './review/changed-lines.js';
17
+ export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-state.js';
16
18
  export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
17
19
  export { isGeneratedFile } from './review/generated-files.js';
18
20
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
@@ -27,6 +29,7 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
27
29
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
28
30
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
29
31
  export { diffFromGit, GitDiffError } from './review/git-diff.js';
32
+ export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
30
33
  export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
31
34
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
32
35
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
@@ -47,7 +50,9 @@ export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
47
50
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
48
51
  // Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
49
52
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
50
- export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, queueLocalLessonsForTeam } from './storage/index.js';
53
+ export { recordFixLessons, fixLessonSubject, fixLessonPrefix, FILES_TO_VALIDATE } from './storage/index.js';
54
+ export { recordInteractionEvidence, countInteractionEvidence, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, shareMemoryLesson, queueLocalLessonsForTeam } from './storage/index.js';
55
+ export { rankMemories, rankByMeaning, memoryText, SEMANTIC_FLOOR, RECALL_LIMIT } from './memory/recall.js';
51
56
  export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, explainTeamConnectionError, } from './storage/index.js';
52
57
  // Temporal Drift Engine (v5 — cross-session trend analysis + per-provenance EWMA)
53
58
  export { generateTemporalDriftReport, formatDriftSummary } from './services/temporal-drift.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { describe, expect, it } from 'vitest';
6
+ const PACKAGES = path.resolve(__dirname, '../../..');
7
+ const scripts = fs.readdirSync(PACKAGES)
8
+ .filter(name => name.startsWith('brain-') && !name.includes('win'))
9
+ .map(name => path.join(PACKAGES, name, 'bin', 'rigour-brain'));
10
+ describe('rigour-brain placeholder', () => {
11
+ it.runIf(process.platform !== 'win32')('prints its message without running the command it names', () => {
12
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-'));
13
+ const marker = path.join(bin, 'ran');
14
+ fs.writeFileSync(path.join(bin, 'rigour'), `#!/bin/sh\necho "$@" > "${marker}"\n`, { mode: 0o755 });
15
+ try {
16
+ expect(scripts.length).toBeGreaterThan(0);
17
+ for (const script of scripts) {
18
+ const result = spawnSync('sh', [script, '--version'], { env: { PATH: `${bin}:/usr/bin:/bin` }, encoding: 'utf8' });
19
+ expect(result.status).toBe(1);
20
+ expect(result.stderr).toContain('rigour deep pull');
21
+ expect(fs.existsSync(marker)).toBe(false);
22
+ }
23
+ }
24
+ finally {
25
+ fs.rmSync(bin, { recursive: true, force: true });
26
+ }
27
+ });
28
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Readable } from 'stream';
5
+ import { afterAll, describe, expect, it, vi } from 'vitest';
6
+ /** Install into a scratch folder: the real one is ~/.rigour. */
7
+ const home = vi.hoisted(() => {
8
+ const p = require('path');
9
+ return p.join(require('os').tmpdir(), `rigour-engine-${process.pid}`);
10
+ });
11
+ vi.mock('../storage/db.js', async (importOriginal) => ({ ...(await importOriginal()), RIGOUR_DIR: home }));
12
+ const { installLlamaEngine, managedEngineDir, releaseAssetFor } = await import('./llama-engine.js');
13
+ afterAll(() => fs.rmSync(home, { recursive: true, force: true }));
14
+ describe('llama.cpp engine install', () => {
15
+ it('pins a checksum for every published platform', () => {
16
+ for (const key of ['darwin-arm64', 'darwin-x64', 'linux-x64', 'win32-x64']) {
17
+ expect(releaseAssetFor(key)?.sha256).toMatch(/^[0-9a-f]{64}$/);
18
+ }
19
+ expect(releaseAssetFor('linux-arm64')).toBeUndefined();
20
+ });
21
+ it.runIf(releaseAssetFor(`${os.platform()}-${os.arch()}`))('refuses an archive that does not match its pinned checksum, and installs nothing', async () => {
22
+ const tampered = async () => ({ statusCode: 200, headers: {}, body: Readable.from([Buffer.from('not the real llama.cpp archive')]) });
23
+ await expect(installLlamaEngine(undefined, tampered)).rejects.toThrow(/does not match its pinned checksum/);
24
+ expect(fs.existsSync(managedEngineDir())).toBe(false);
25
+ expect(fs.readdirSync(path.dirname(managedEngineDir())).filter(f => f.endsWith('.zip'))).toEqual([]);
26
+ });
27
+ });
@@ -4,7 +4,10 @@ export declare function llamaBinaryName(): string;
4
4
  /** Install directory for the pinned release: ~/.rigour/bin/llama-<tag>. */
5
5
  export declare function managedEngineDir(): string;
6
6
  export declare function managedEnginePath(): string;
7
- export declare function releaseAssetFor(platformKey: string): string | undefined;
7
+ export declare function releaseAssetFor(platformKey: string): {
8
+ file: string;
9
+ sha256: string;
10
+ } | undefined;
8
11
  export interface ProbeResult {
9
12
  ok: boolean;
10
13
  version?: string;
@@ -16,12 +16,16 @@ export const LLAMA_RELEASE_TAG = 'b5604';
16
16
  const LLAMA_RELEASE_BASE = `https://github.com/ggml-org/llama.cpp/releases/download/${LLAMA_RELEASE_TAG}`;
17
17
  const PROBE_TIMEOUT_MS = 5_000;
18
18
  const EXTRACT_TIMEOUT_MS = 120_000;
19
- /** Platform → llama.cpp release asset. linux-arm64 has no published build. */
19
+ /**
20
+ * Platform → llama.cpp release asset and its SHA-256, checked against the digest GitHub publishes
21
+ * for the release. A download that does not match is refused: the engine runs code on the
22
+ * user's machine. linux-arm64 has no published build.
23
+ */
20
24
  const LLAMA_RELEASE_ASSETS = {
21
- 'darwin-arm64': `llama-${LLAMA_RELEASE_TAG}-bin-macos-arm64.zip`,
22
- 'darwin-x64': `llama-${LLAMA_RELEASE_TAG}-bin-macos-x64.zip`,
23
- 'linux-x64': `llama-${LLAMA_RELEASE_TAG}-bin-ubuntu-x64.zip`,
24
- 'win32-x64': `llama-${LLAMA_RELEASE_TAG}-bin-win-cpu-x64.zip`,
25
+ 'darwin-arm64': { file: `llama-${LLAMA_RELEASE_TAG}-bin-macos-arm64.zip`, sha256: '673c5f8c4a7a2226ca6f3db5141d1ed78dcdf178d902c1486b77b3dda41e6a61' },
26
+ 'darwin-x64': { file: `llama-${LLAMA_RELEASE_TAG}-bin-macos-x64.zip`, sha256: 'ab9ba59f3e355aebe6bc2c632b3489d4ab98d727f0ee03b9df6b4a6207452ad2' },
27
+ 'linux-x64': { file: `llama-${LLAMA_RELEASE_TAG}-bin-ubuntu-x64.zip`, sha256: '3981c1e353399fbc35ea23316854b3c302877c00fb6b7fa4063a0623ad9e0309' },
28
+ 'win32-x64': { file: `llama-${LLAMA_RELEASE_TAG}-bin-win-cpu-x64.zip`, sha256: 'df32d58de0b57c3c9de2e68e44b947efbac1b8a3e417536b6703cbd8da11eddd' },
25
29
  };
26
30
  export function llamaBinaryName() {
27
31
  return os.platform() === 'win32' ? 'llama-cli.exe' : 'llama-cli';
@@ -84,13 +88,16 @@ export async function installLlamaEngine(onProgress, get) {
84
88
  }
85
89
  const installDir = managedEngineDir();
86
90
  const workDir = `${installDir}.partial`;
87
- const zipPath = path.join(path.dirname(installDir), asset);
91
+ const zipPath = path.join(path.dirname(installDir), asset.file);
88
92
  await fs.ensureDir(path.dirname(installDir));
89
93
  await fs.remove(workDir);
90
94
  onProgress?.(`⬇ Downloading inference engine (llama.cpp ${LLAMA_RELEASE_TAG})...`);
91
95
  try {
92
- await downloadToFile(`${LLAMA_RELEASE_BASE}/${asset}`, zipPath, { get });
93
- onProgress?.(' Extracting...');
96
+ const { sha256 } = await downloadToFile(`${LLAMA_RELEASE_BASE}/${asset.file}`, zipPath, { get });
97
+ if (sha256 !== asset.sha256) {
98
+ throw new Error(`llama.cpp ${asset.file} does not match its pinned checksum (got ${sha256.slice(0, 12)}…, expected ${asset.sha256.slice(0, 12)}…); not installing it.`);
99
+ }
100
+ onProgress?.(' Verified checksum. Extracting...');
94
101
  await extractZip(zipPath, workDir);
95
102
  await installBinFolder(workDir, installDir);
96
103
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Recall by meaning: the few memories that match what an agent is about to do,
3
+ * instead of every memory at once.
4
+ *
5
+ * Ranking uses the same local embedding model as the pattern index and team
6
+ * knowledge (all-MiniLM-L6-v2, loaded lazily so its native dependency never
7
+ * loads for anything else). When the model is unavailable it falls back to
8
+ * keyword overlap and says so in each result's `match`.
9
+ */
10
+ export type MemoryScope = 'repo' | 'user';
11
+ export interface MemoryEntry {
12
+ scope: MemoryScope;
13
+ key: string;
14
+ value: string;
15
+ timestamp?: string;
16
+ }
17
+ export type RankedMemory = Ranked<MemoryEntry>;
18
+ /**
19
+ * The similarity a memory needs to be returned, for local memory and team knowledge alike. Measured
20
+ * with scripts/eval/memory-recall.mjs: at 0.25 recall and precision are both 81% and no unrelated
21
+ * query gets anything; at the previous 0.45, 62% of right memories were missed.
22
+ */
23
+ export declare const SEMANTIC_FLOOR = 0.25;
24
+ /** Share of the query's words a memory must contain when ranking falls back to keywords. */
25
+ export declare const KEYWORD_FLOOR = 0.5;
26
+ export declare const RECALL_LIMIT = 3;
27
+ type Embed = (text: string) => Promise<number[]>;
28
+ export type Ranked<T> = T & {
29
+ score: number;
30
+ match: 'semantic' | 'keyword';
31
+ };
32
+ export declare function rankMemories(query: string, entries: MemoryEntry[], embed?: Embed): Promise<RankedMemory[]>;
33
+ /** The items whose text best matches the query by meaning, at most RECALL_LIMIT, each above the floor. */
34
+ export declare function rankByMeaning<T>(query: string, items: T[], textOf: (item: T) => string, embed?: Embed): Promise<Array<Ranked<T>>>;
35
+ /** What a memory is searched by: its key reads as a title (`db_access` → "db access"). */
36
+ export declare function memoryText(entry: Pick<MemoryEntry, 'key' | 'value'>): string;
37
+ export {};
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Recall by meaning: the few memories that match what an agent is about to do,
3
+ * instead of every memory at once.
4
+ *
5
+ * Ranking uses the same local embedding model as the pattern index and team
6
+ * knowledge (all-MiniLM-L6-v2, loaded lazily so its native dependency never
7
+ * loads for anything else). When the model is unavailable it falls back to
8
+ * keyword overlap and says so in each result's `match`.
9
+ */
10
+ /**
11
+ * The similarity a memory needs to be returned, for local memory and team knowledge alike. Measured
12
+ * with scripts/eval/memory-recall.mjs: at 0.25 recall and precision are both 81% and no unrelated
13
+ * query gets anything; at the previous 0.45, 62% of right memories were missed.
14
+ */
15
+ export const SEMANTIC_FLOOR = 0.25;
16
+ /** Share of the query's words a memory must contain when ranking falls back to keywords. */
17
+ export const KEYWORD_FLOOR = 0.5;
18
+ export const RECALL_LIMIT = 3;
19
+ /** Memories considered per recall; more are skipped so a large store cannot stall an agent. */
20
+ const MAX_CANDIDATES = 300;
21
+ /**
22
+ * Embeddings by text, kept for the life of the process: the MCP server is long-lived and the same
23
+ * memories and lessons are ranked on every recall, so each text is embedded once, not per query.
24
+ */
25
+ const EMBEDDING_CACHE_LIMIT = 2000;
26
+ const embeddingCache = new Map();
27
+ async function cachedEmbedding(embed, text) {
28
+ const hit = embeddingCache.get(text);
29
+ if (hit)
30
+ return hit;
31
+ const vector = await embed(text);
32
+ if (vector.length === 0)
33
+ return vector; // unavailable: do not remember a failure
34
+ if (embeddingCache.size >= EMBEDDING_CACHE_LIMIT)
35
+ embeddingCache.delete(embeddingCache.keys().next().value);
36
+ embeddingCache.set(text, vector);
37
+ return vector;
38
+ }
39
+ export async function rankMemories(query, entries, embed) {
40
+ return rankByMeaning(query, entries, memoryText, embed);
41
+ }
42
+ /** The items whose text best matches the query by meaning, at most RECALL_LIMIT, each above the floor. */
43
+ export async function rankByMeaning(query, items, textOf, embed) {
44
+ const candidates = items.slice(0, MAX_CANDIDATES);
45
+ if (candidates.length === 0)
46
+ return [];
47
+ const embedText = embed ?? (await import('../pattern-index/embeddings.js')).generateEmbedding;
48
+ const queryVector = await embedText(query);
49
+ const ranked = queryVector.length > 0
50
+ ? await bySimilarity(queryVector, candidates, textOf, embedText)
51
+ : byKeywords(query, candidates, textOf);
52
+ return ranked.sort((a, b) => b.score - a.score).slice(0, RECALL_LIMIT);
53
+ }
54
+ async function bySimilarity(queryVector, items, textOf, embed) {
55
+ const ranked = [];
56
+ for (const item of items) {
57
+ const score = cosine(queryVector, await cachedEmbedding(embed, textOf(item)));
58
+ if (score >= SEMANTIC_FLOOR)
59
+ ranked.push({ ...item, score, match: 'semantic' });
60
+ }
61
+ return ranked;
62
+ }
63
+ function byKeywords(query, items, textOf) {
64
+ const wanted = words(query);
65
+ if (wanted.size === 0)
66
+ return [];
67
+ return items.flatMap(item => {
68
+ const have = words(textOf(item));
69
+ const score = [...wanted].filter(word => have.has(word)).length / wanted.size;
70
+ return score >= KEYWORD_FLOOR ? [{ ...item, score, match: 'keyword' }] : [];
71
+ });
72
+ }
73
+ /** What a memory is searched by: its key reads as a title (`db_access` → "db access"). */
74
+ export function memoryText(entry) {
75
+ return `${entry.key.replace(/[_-]+/g, ' ')}: ${entry.value}`;
76
+ }
77
+ const STOP_WORDS = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'how', 'what', 'when', 'use', 'our', 'are', 'not']);
78
+ function words(text) {
79
+ return new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter(word => word.length > 2 && !STOP_WORDS.has(word)));
80
+ }
81
+ function cosine(a, b) {
82
+ if (a.length === 0 || a.length !== b.length)
83
+ return 0;
84
+ let dot = 0;
85
+ let normA = 0;
86
+ let normB = 0;
87
+ for (let i = 0; i < a.length; i++) {
88
+ dot += a[i] * b[i];
89
+ normA += a[i] * a[i];
90
+ normB += b[i] * b[i];
91
+ }
92
+ return normA && normB ? dot / Math.sqrt(normA * normB) : 0;
93
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RECALL_LIMIT, rankMemories } from './recall.js';
3
+ const memories = [
4
+ { scope: 'repo', key: 'retry_policy', value: 'Wrap network calls in withRetry from lib/net; never hand-roll backoff.' },
5
+ { scope: 'repo', key: 'db_access', value: 'Reports read from the replica, never the primary.' },
6
+ { scope: 'user', key: 'pr_size', value: 'Keep pull requests under 400 lines.' },
7
+ ];
8
+ /** A stand-in embedding: one dimension per topic word, so similarity is predictable. */
9
+ const TOPICS = ['retry', 'backoff', 'network', 'replica', 'report', 'pull', 'request'];
10
+ const embed = async (text) => TOPICS.map(topic => (text.toLowerCase().includes(topic) ? 1 : 0));
11
+ describe('rankMemories', () => {
12
+ it('returns the memories that match by meaning, best first, above the floor', async () => {
13
+ const ranked = await rankMemories('add exponential backoff to a flaky network call', memories, embed);
14
+ expect(ranked.map(m => [m.key, m.match])).toEqual([['retry_policy', 'semantic']]);
15
+ });
16
+ it('returns nothing rather than the closest weak match', async () => {
17
+ expect(await rankMemories('rename a css class', memories, embed)).toEqual([]);
18
+ });
19
+ it('falls back to keyword overlap when the model is unavailable, and says so', async () => {
20
+ const ranked = await rankMemories('replica reports', memories, async () => []);
21
+ expect(ranked.map(m => [m.key, m.match])).toEqual([['db_access', 'keyword']]);
22
+ });
23
+ it(`returns at most ${RECALL_LIMIT}`, async () => {
24
+ const many = Array.from({ length: 10 }, (_, i) => ({ scope: 'repo', key: `retry_${i}`, value: 'network retry backoff' }));
25
+ expect(await rankMemories('network retry backoff', many, embed)).toHaveLength(RECALL_LIMIT);
26
+ });
27
+ it('embeds each text once, not on every recall', async () => {
28
+ let calls = 0;
29
+ const counting = async (text) => { calls++; return embed(text); };
30
+ const fresh = memories.map(m => ({ ...m, value: `${m.value} (cache test)` }));
31
+ await rankMemories('network retry', fresh, counting);
32
+ const first = calls;
33
+ await rankMemories('replica reports', fresh, counting);
34
+ expect(calls - first).toBe(1); // only the new query
35
+ });
36
+ });