@rigour-labs/core 6.5.4 → 6.6.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 (35) hide show
  1. package/dist/context/automatic-index-cache.test.d.ts +1 -0
  2. package/dist/context/automatic-index-cache.test.js +30 -0
  3. package/dist/context/automatic-index.js +0 -4
  4. package/dist/context/cache-engine.d.ts +7 -38
  5. package/dist/context/cache-engine.js +7 -70
  6. package/dist/context/cache-engine.test.js +2 -46
  7. package/dist/context/index-bridge.d.ts +1 -7
  8. package/dist/context/index-bridge.js +1 -79
  9. package/dist/firewall/execution-receipt.d.ts +5 -0
  10. package/dist/firewall/execution-receipt.js +10 -1
  11. package/dist/firewall/trusted-control.test.js +10 -0
  12. package/dist/hooks/checker.js +18 -0
  13. package/dist/hooks/checker.test.js +10 -0
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.js +5 -2
  16. package/dist/review/agent-fixes.d.ts +25 -2
  17. package/dist/review/agent-fixes.js +27 -6
  18. package/dist/review/effectiveness.d.ts +13 -0
  19. package/dist/review/learning-events.d.ts +14 -0
  20. package/dist/review/learning-events.js +21 -0
  21. package/dist/review/stories.d.ts +25 -0
  22. package/dist/review/stories.js +75 -0
  23. package/dist/review/stories.test.d.ts +1 -0
  24. package/dist/review/stories.test.js +58 -0
  25. package/dist/storage/cache-cleanup.d.ts +19 -0
  26. package/dist/storage/cache-cleanup.js +90 -0
  27. package/dist/storage/cache-cleanup.test.d.ts +1 -0
  28. package/dist/storage/cache-cleanup.test.js +54 -0
  29. package/dist/storage/db.js +3 -1
  30. package/dist/storage/index.d.ts +1 -0
  31. package/dist/storage/index.js +1 -0
  32. package/dist/storage/local-encryption.js +2 -2
  33. package/dist/storage/team-store.js +2 -2
  34. package/dist/utils/user-state.js +1 -1
  35. package/package.json +6 -6
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ const writes = vi.hoisted(() => ({ count: 0 }));
6
+ vi.mock('../storage/context-telemetry.js', async (importOriginal) => ({
7
+ ...(await importOriginal()),
8
+ setContextCacheRecord: async () => { writes.count++; },
9
+ }));
10
+ const { ensureAutomaticIndex, updateAutomaticIndexForFiles } = await import('./automatic-index.js');
11
+ let cwd;
12
+ beforeEach(() => {
13
+ writes.count = 0;
14
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-index-'));
15
+ fs.mkdirSync(path.join(cwd, 'src'));
16
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'export function alpha() { return 1; }\n');
17
+ fs.writeFileSync(path.join(cwd, 'src/b.ts'), 'export function beta() { return 2; }\n');
18
+ });
19
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
20
+ describe('automatic indexing', () => {
21
+ it('writes nothing to the context cache, however many edits it sees', async () => {
22
+ await ensureAutomaticIndex(cwd, { backgroundSemantic: false });
23
+ for (let edit = 0; edit < 5; edit++) {
24
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), `export function alpha() { return ${edit}; }\n`);
25
+ await updateAutomaticIndexForFiles(cwd, ['src/a.ts']);
26
+ }
27
+ expect(writes.count).toBe(0);
28
+ expect(fs.existsSync(path.join(cwd, '.rigour', 'patterns.json'))).toBe(true);
29
+ });
30
+ });
@@ -3,7 +3,6 @@ import path from 'path';
3
3
  import { PatternIndexer, getDefaultIndexPath, loadPatternIndex, savePatternIndex } from '../pattern-index/indexer.js';
4
4
  import { EMBEDDING_TEXT_VERSION } from '../pattern-index/embeddings.js';
5
5
  import { affectedDependents, buildDependencyGraph, updateDependencyGraph } from './dependency-graph.js';
6
- import { syncIndexToContextCache } from './index-bridge.js';
7
6
  const enrichmentJobs = new Map();
8
7
  function projectRelativeFiles(cwd, files) {
9
8
  const root = path.resolve(cwd);
@@ -78,7 +77,6 @@ async function enrichSemantic(cwd, index) {
78
77
  const merged = mergeSemanticEmbeddings(latest, enriched);
79
78
  const embedded = merged.patterns.filter((pattern) => (pattern.embedding?.length ?? 0) > 0).length;
80
79
  await savePatternIndex(merged, indexPath);
81
- await syncIndexToContextCache(cwd, merged);
82
80
  await writeStatus(cwd, {
83
81
  structural: 'ready',
84
82
  graph: 'ready',
@@ -114,7 +112,6 @@ export async function ensureAutomaticIndex(cwd, options = {}) {
114
112
  const index = existing ?? await indexer.buildIndex();
115
113
  if (!existing)
116
114
  await savePatternIndex(index, indexPath);
117
- await syncIndexToContextCache(cwd, index);
118
115
  if (!(await fs.pathExists(path.join(cwd, '.rigour', 'dependency-graph.json')))) {
119
116
  await buildDependencyGraph(cwd, index);
120
117
  }
@@ -144,7 +141,6 @@ export async function updateAutomaticIndexForFiles(cwd, changedFiles) {
144
141
  const indexer = new PatternIndexer(cwd, { useEmbeddings: false });
145
142
  const index = await indexer.updateFiles(existing, affectedFiles);
146
143
  await savePatternIndex(index, getDefaultIndexPath(cwd));
147
- await syncIndexToContextCache(cwd, index);
148
144
  if (graph)
149
145
  await updateDependencyGraph(cwd, index, graph, affectedFiles);
150
146
  else
@@ -1,32 +1,11 @@
1
1
  /**
2
- * 4-Layer Context Caching Engine for Rigour
2
+ * Context caching for Rigour: the two layers something reads.
3
+ * - Semantic-query cache: the edit and validation scope resolved for a task query.
4
+ * - Task checkpoint cache: compact handoff packets between agents.
3
5
  *
4
- * Implements:
5
- * 1. Content-addressed static cache (AST, exports, imports, dependencies, patterns by content SHA)
6
- * 2. Component context cache (Compact dossiers per component scope)
7
- * 3. Semantic-query cache (Intent embeddings & edit/validation scopes)
8
- * 4. Task checkpoint cache (Subagent handoff packets)
6
+ * The static (per-file) and component layers were removed: nothing read them, and the index sync
7
+ * that wrote them added a fresh row per component on every edit (millions of rows in real use).
9
8
  */
10
- export interface StaticCacheEntry {
11
- astSummary?: any;
12
- exports?: string[];
13
- imports?: string[];
14
- dependencies?: string[];
15
- ownership?: string;
16
- schemas?: any[];
17
- endpoints?: string[];
18
- eventRelationships?: any[];
19
- styleFingerprint?: string;
20
- rigourPatterns?: string[];
21
- }
22
- export interface ComponentDossier {
23
- component: string;
24
- responsibility: string;
25
- canonicalFiles: string[];
26
- contracts: string[];
27
- directConsumers: string[];
28
- validationCommands: string[];
29
- }
30
9
  export interface SemanticQueryEntry {
31
10
  query: string;
32
11
  intentEmbedding?: number[];
@@ -78,17 +57,7 @@ export interface TaskCheckpointPacket {
78
57
  */
79
58
  export declare function hashContent(content: string): string;
80
59
  /**
81
- * Layer 1: Content-Addressed Static Cache
82
- */
83
- export declare function getStaticCache(repo: string, branch: string, filePath: string, fileContent: string, cwd?: string): Promise<StaticCacheEntry | null>;
84
- export declare function setStaticCache(repo: string, branch: string, filePath: string, fileContent: string, entry: StaticCacheEntry, cwd?: string): Promise<void>;
85
- /**
86
- * Layer 2: Component Context Cache
87
- */
88
- export declare function getComponentCache(componentName: string, commitSha: string, profileVersion?: string, cwd?: string): Promise<ComponentDossier | null>;
89
- export declare function setComponentCache(componentName: string, commitSha: string, dossier: ComponentDossier, dependencyFingerprint: string, profileVersion?: string, cwd?: string): Promise<void>;
90
- /**
91
- * Layer 3: Semantic-Query Cache
60
+ * Semantic-Query Cache
92
61
  */
93
62
  export declare function normalizeQuery(query: string): string;
94
63
  export declare function getSemanticQueryCache(query: string, commitSha: string, cwd?: string): Promise<SemanticQueryEntry | null>;
@@ -119,7 +88,7 @@ export declare function filterExistingEditScope(editScope: string[], cwd: string
119
88
  missing: string[];
120
89
  }>;
121
90
  /**
122
- * Layer 4: Task Checkpoint Cache
91
+ * Task Checkpoint Cache
123
92
  */
124
93
  export declare function setTaskCheckpointCache(packet: TaskCheckpointPacket, rawStateTokens: number, cwd?: string): Promise<void>;
125
94
  export declare function getTaskCheckpointCache(taskId: string, agentId: string, phase: string, cwd?: string): Promise<TaskCheckpointPacket | null>;
@@ -1,11 +1,10 @@
1
1
  /**
2
- * 4-Layer Context Caching Engine for Rigour
2
+ * Context caching for Rigour: the two layers something reads.
3
+ * - Semantic-query cache: the edit and validation scope resolved for a task query.
4
+ * - Task checkpoint cache: compact handoff packets between agents.
3
5
  *
4
- * Implements:
5
- * 1. Content-addressed static cache (AST, exports, imports, dependencies, patterns by content SHA)
6
- * 2. Component context cache (Compact dossiers per component scope)
7
- * 3. Semantic-query cache (Intent embeddings & edit/validation scopes)
8
- * 4. Task checkpoint cache (Subagent handoff packets)
6
+ * The static (per-file) and component layers were removed: nothing read them, and the index sync
7
+ * that wrote them added a fresh row per component on every edit (millions of rows in real use).
9
8
  */
10
9
  import { createHash } from 'crypto';
11
10
  import fs from 'fs-extra';
@@ -18,69 +17,7 @@ export function hashContent(content) {
18
17
  return createHash('sha256').update(content).digest('hex').slice(0, 16);
19
18
  }
20
19
  /**
21
- * Layer 1: Content-Addressed Static Cache
22
- */
23
- export async function getStaticCache(repo, branch, filePath, fileContent, cwd) {
24
- const contentSha = hashContent(fileContent);
25
- const cacheKey = `static:${repo}:${branch}:${filePath}:${contentSha}:v1`;
26
- const record = await getContextCacheRecord(cacheKey, cwd);
27
- if (!record)
28
- return null;
29
- try {
30
- return JSON.parse(record.payloadJson);
31
- }
32
- catch {
33
- return null;
34
- }
35
- }
36
- export async function setStaticCache(repo, branch, filePath, fileContent, entry, cwd) {
37
- const contentSha = hashContent(fileContent);
38
- const cacheKey = `static:${repo}:${branch}:${filePath}:${contentSha}:v1`;
39
- const payloadJson = JSON.stringify(entry);
40
- const payloadTokens = Math.ceil(payloadJson.length / 4);
41
- await setContextCacheRecord({
42
- cacheKey,
43
- cacheType: 'static',
44
- repo,
45
- branch,
46
- commitSha: contentSha,
47
- dependencyFingerprint: `sha-${contentSha}`,
48
- payloadJson,
49
- payloadTokens,
50
- }, cwd);
51
- }
52
- /**
53
- * Layer 2: Component Context Cache
54
- */
55
- export async function getComponentCache(componentName, commitSha, profileVersion = '3', cwd) {
56
- const cacheKey = `component:${componentName}:${commitSha}:v${profileVersion}`;
57
- const record = await getContextCacheRecord(cacheKey, cwd);
58
- if (!record)
59
- return null;
60
- try {
61
- return JSON.parse(record.payloadJson);
62
- }
63
- catch {
64
- return null;
65
- }
66
- }
67
- export async function setComponentCache(componentName, commitSha, dossier, dependencyFingerprint, profileVersion = '3', cwd) {
68
- const cacheKey = `component:${componentName}:${commitSha}:v${profileVersion}`;
69
- const payloadJson = JSON.stringify(dossier);
70
- const payloadTokens = Math.ceil(payloadJson.length / 4);
71
- await setContextCacheRecord({
72
- cacheKey,
73
- cacheType: 'component',
74
- repo: dossier.component,
75
- branch: 'main',
76
- commitSha,
77
- dependencyFingerprint,
78
- payloadJson,
79
- payloadTokens,
80
- }, cwd);
81
- }
82
- /**
83
- * Layer 3: Semantic-Query Cache
20
+ * Semantic-Query Cache
84
21
  */
85
22
  export function normalizeQuery(query) {
86
23
  return query.toLowerCase().trim().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ');
@@ -193,7 +130,7 @@ export async function filterExistingEditScope(editScope, cwd) {
193
130
  return { valid, missing };
194
131
  }
195
132
  /**
196
- * Layer 4: Task Checkpoint Cache
133
+ * Task Checkpoint Cache
197
134
  */
198
135
  export async function setTaskCheckpointCache(packet, rawStateTokens, cwd) {
199
136
  const cacheKey = `checkpoint:${packet.taskId}:${packet.agentId}:${packet.phase}`;
@@ -8,8 +8,8 @@ vi.mock('./db.js', () => ({
8
8
  DB_PATH: '/tmp/rigour-test.db',
9
9
  RIGOUR_DIR: '/tmp/.rigour',
10
10
  }));
11
- import { getStaticCache, setStaticCache, getComponentCache, setComponentCache, getSemanticQueryCache, setSemanticQueryCache, findRelatedSemanticQueryCache, queryTokenOverlap, distinctiveTokenAgreement, filterExistingEditScope, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
12
- describe('4-Layer Context Cache Engine', () => {
11
+ import { getSemanticQueryCache, setSemanticQueryCache, findRelatedSemanticQueryCache, queryTokenOverlap, distinctiveTokenAgreement, filterExistingEditScope, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
12
+ describe('Context cache engine', () => {
13
13
  const testCwd = path.join(os.tmpdir(), `rigour-cache-test-${Date.now()}`);
14
14
  beforeEach(async () => {
15
15
  await fs.ensureDir(testCwd);
@@ -26,35 +26,6 @@ describe('4-Layer Context Cache Engine', () => {
26
26
  expect(normalizeQuery(' Add priority to task!! ')).toBe('add priority to task');
27
27
  expect(normalizeQuery('Task SHOULD Support Priority.')).toBe('task should support priority');
28
28
  });
29
- it('Layer 1: should store and retrieve content-addressed static cache', async () => {
30
- const fileContent = 'export function processTask() { return true; }';
31
- const entry = {
32
- exports: ['processTask'],
33
- imports: [],
34
- ownership: 'platform-team',
35
- endpoints: ['/api/task']
36
- };
37
- await setStaticCache('rigour-repo', 'main', 'services/task.ts', fileContent, entry, testCwd);
38
- const cached = await getStaticCache('rigour-repo', 'main', 'services/task.ts', fileContent, testCwd);
39
- expect(cached).not.toBeNull();
40
- expect(cached?.exports).toEqual(['processTask']);
41
- expect(cached?.ownership).toBe('platform-team');
42
- });
43
- it('Layer 2: should store and retrieve component context dossier', async () => {
44
- const dossier = {
45
- component: 'services/task',
46
- responsibility: 'Task lifecycle and persistence',
47
- canonicalFiles: ['services/task.ts'],
48
- contracts: ['TaskContract'],
49
- directConsumers: ['controllers/task'],
50
- validationCommands: ['npm test']
51
- };
52
- await setComponentCache('services/task', 'abc123commit', dossier, 'dep-fingerprint-v1', '3', testCwd);
53
- const retrieved = await getComponentCache('services/task', 'abc123commit', '3', testCwd);
54
- expect(retrieved).not.toBeNull();
55
- expect(retrieved?.responsibility).toBe('Task lifecycle and persistence');
56
- expect(retrieved?.canonicalFiles).toContain('services/task.ts');
57
- });
58
29
  it('Layer 3: should store and retrieve semantic query scope', async () => {
59
30
  const entry = {
60
31
  query: 'task priority persistence',
@@ -125,19 +96,4 @@ describe('4-Layer Context Cache Engine', () => {
125
96
  expect(result.valid).toEqual(['alive.ts']);
126
97
  expect(result.missing).toEqual(['gone.ts']);
127
98
  });
128
- it('Layer 1: invalidates static cache when file content changes', async () => {
129
- const originalContent = 'export function processTask() { return true; }';
130
- const entry = {
131
- exports: ['processTask'],
132
- imports: [],
133
- ownership: 'platform-team',
134
- endpoints: ['/api/task'],
135
- };
136
- await setStaticCache('rigour-repo', 'main', 'services/task.ts', originalContent, entry, testCwd);
137
- const cached = await getStaticCache('rigour-repo', 'main', 'services/task.ts', originalContent, testCwd);
138
- expect(cached?.exports).toEqual(['processTask']);
139
- const changedContent = 'export function processTask() { return false; }';
140
- const stale = await getStaticCache('rigour-repo', 'main', 'services/task.ts', changedContent, testCwd);
141
- expect(stale).toBeNull();
142
- });
143
99
  });
@@ -1,8 +1,6 @@
1
1
  /**
2
- * Bridge between Pattern Index and 4-Layer Context Cache.
3
- * Syncs indexed patterns into static cache entries and component dossiers.
2
+ * Health of the pattern index (.rigour/patterns.json): present, readable, fresh.
4
3
  */
5
- import type { PatternIndex } from '../pattern-index/types.js';
6
4
  export interface IndexHealthReport {
7
5
  indexPath: string;
8
6
  exists: boolean;
@@ -14,10 +12,6 @@ export interface IndexHealthReport {
14
12
  totalFiles?: number;
15
13
  message: string;
16
14
  }
17
- /**
18
- * Sync pattern index entries into static cache and component dossier layers.
19
- */
20
- export declare function syncIndexToContextCache(cwd: string, index: PatternIndex): Promise<number>;
21
15
  /**
22
16
  * Check patterns.json freshness relative to the project directory.
23
17
  */
@@ -1,90 +1,12 @@
1
1
  /**
2
- * Bridge between Pattern Index and 4-Layer Context Cache.
3
- * Syncs indexed patterns into static cache entries and component dossiers.
2
+ * Health of the pattern index (.rigour/patterns.json): present, readable, fresh.
4
3
  */
5
4
  import fs from 'fs-extra';
6
5
  import path from 'path';
7
- import { setStaticCache, setComponentCache, hashContent } from './cache-engine.js';
8
- import { getRepositoryId } from '../storage/lessons.js';
9
6
  function getIndexPath(cwd) {
10
7
  return path.join(cwd, '.rigour', 'patterns.json');
11
8
  }
12
9
  const DEFAULT_STALE_MS = 24 * 60 * 60 * 1000;
13
- async function resolveGitBranch(cwd) {
14
- try {
15
- let gitDir = path.join(cwd, '.git');
16
- const gitStat = await fs.stat(gitDir);
17
- if (gitStat.isFile()) {
18
- const pointer = await fs.readFile(gitDir, 'utf8');
19
- const match = pointer.match(/^gitdir:\s*(.+)$/m);
20
- if (!match)
21
- return 'workspace';
22
- gitDir = path.resolve(cwd, match[1].trim());
23
- }
24
- const head = (await fs.readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
25
- return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : head.slice(0, 12);
26
- }
27
- catch {
28
- return 'workspace';
29
- }
30
- }
31
- function groupByComponent(patterns) {
32
- const groups = new Map();
33
- for (const p of patterns) {
34
- const key = p.type === 'component' ? p.name : p.category || 'uncategorized';
35
- const list = groups.get(key) ?? [];
36
- list.push(p);
37
- groups.set(key, list);
38
- }
39
- return groups;
40
- }
41
- function buildDossier(component, patterns) {
42
- const canonicalFiles = [...new Set(patterns.map(p => p.file))];
43
- const contracts = patterns
44
- .filter(p => p.signature)
45
- .map(p => p.signature)
46
- .slice(0, 20);
47
- const descriptions = patterns.map(p => p.description).filter(Boolean);
48
- const responsibility = descriptions[0] || `Indexed ${patterns.length} patterns for ${component}`;
49
- return {
50
- component,
51
- responsibility,
52
- canonicalFiles,
53
- contracts,
54
- directConsumers: [],
55
- validationCommands: [],
56
- };
57
- }
58
- /**
59
- * Sync pattern index entries into static cache and component dossier layers.
60
- */
61
- export async function syncIndexToContextCache(cwd, index) {
62
- const resolved = path.resolve(cwd);
63
- const repo = await getRepositoryId(resolved);
64
- const branch = await resolveGitBranch(resolved);
65
- const commitSha = hashContent(index.lastUpdated);
66
- let synced = 0;
67
- for (const file of index.files) {
68
- const filePatterns = index.patterns.filter(p => p.file === file.path);
69
- if (filePatterns.length === 0)
70
- continue;
71
- const payload = {
72
- rigourPatterns: filePatterns.map(p => `${p.type}:${p.name}`),
73
- exports: filePatterns.filter(p => p.exported).map(p => p.name),
74
- };
75
- const fileContent = JSON.stringify(payload);
76
- await setStaticCache(repo, branch, file.path, fileContent, payload, resolved);
77
- synced++;
78
- }
79
- const componentGroups = groupByComponent(index.patterns);
80
- for (const [component, patterns] of componentGroups) {
81
- const dossier = buildDossier(component, patterns);
82
- const fingerprint = hashContent(JSON.stringify(patterns.map(p => p.id)));
83
- await setComponentCache(component, commitSha, dossier, fingerprint, '3', resolved);
84
- synced++;
85
- }
86
- return synced;
87
- }
88
10
  /**
89
11
  * Check patterns.json freshness relative to the project directory.
90
12
  */
@@ -1,4 +1,9 @@
1
1
  import type { ExecutionReceipt } from './types.js';
2
+ /**
3
+ * Another append holds the lock. On Windows, creating a file another process is still deleting
4
+ * fails with EPERM (or EACCES/EBUSY) rather than EEXIST; elsewhere those are real permission errors.
5
+ */
6
+ export declare function isLockContention(code: unknown, platform?: NodeJS.Platform): boolean;
2
7
  export declare function appendExecutionReceipt(cwd: string, input: Omit<ExecutionReceipt, 'version' | 'id' | 'repositoryId' | 'createdAt' | 'previousReceipt' | 'digest' | 'signature'>, controlRoot?: string): Promise<ExecutionReceipt>;
3
8
  export declare function listExecutionReceipts(cwd: string, limit?: number, controlRoot?: string): Promise<ExecutionReceipt[]>;
4
9
  export declare function verifyExecutionReceiptChain(cwd: string, controlRoot?: string): Promise<{
@@ -17,6 +17,15 @@ async function receiptKey(cwd, controlRoot) {
17
17
  await fs.writeFile(keyPath, key, { mode: 0o600 });
18
18
  return key;
19
19
  }
20
+ /**
21
+ * Another append holds the lock. On Windows, creating a file another process is still deleting
22
+ * fails with EPERM (or EACCES/EBUSY) rather than EEXIST; elsewhere those are real permission errors.
23
+ */
24
+ export function isLockContention(code, platform = process.platform) {
25
+ if (code === 'EEXIST')
26
+ return true;
27
+ return platform === 'win32' && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY');
28
+ }
20
29
  async function acquireReceiptLock(logPath) {
21
30
  const lockPath = `${logPath}.append-lock`;
22
31
  for (let attempt = 0; attempt < 100; attempt += 1) {
@@ -24,7 +33,7 @@ async function acquireReceiptLock(logPath) {
24
33
  return await fs.promises.open(lockPath, 'wx', 0o600);
25
34
  }
26
35
  catch (error) {
27
- if (error?.code !== 'EEXIST')
36
+ if (!isLockContention(error?.code))
28
37
  throw error;
29
38
  await delay(10);
30
39
  }
@@ -176,3 +176,13 @@ describe('action normalization and receipts', () => {
176
176
  expect(await verifyExecutionReceiptChain(cwd, root)).toMatchObject({ valid: true, count: 1 });
177
177
  });
178
178
  });
179
+ describe('receipt lock contention', () => {
180
+ it('retries while another append releases the lock, including Windows delete-pending errors', async () => {
181
+ const { isLockContention } = await import('./execution-receipt.js');
182
+ expect(isLockContention('EEXIST', 'linux')).toBe(true);
183
+ expect(isLockContention('EPERM', 'win32')).toBe(true);
184
+ expect(isLockContention('EBUSY', 'win32')).toBe(true);
185
+ expect(isLockContention('EPERM', 'linux')).toBe(false); // a real permission error elsewhere
186
+ expect(isLockContention('ENOENT', 'win32')).toBe(false);
187
+ });
188
+ });
@@ -249,6 +249,24 @@ function checkSecurityPatterns(lines, relPath, failures) {
249
249
  checkHardcodedSecrets(line, i, relPath, isTestFile, failures);
250
250
  checkCommandInjection(line, i, relPath, failures);
251
251
  }
252
+ if (!isTestFile)
253
+ checkVendorKeys(lines.join('\n'), relPath, failures);
254
+ }
255
+ /** Credential formats a vendor issues: unambiguous in code, unlike entropy or variable-name guesses. */
256
+ const VENDOR_KEYS = new Set(['aws_access_key', 'openai_key', 'anthropic_key', 'github_token', 'stripe_key', 'slack_token', 'sendgrid_key', 'private_key', 'private_key_full']);
257
+ /** A real vendor key written into code, whatever the variable is called. */
258
+ function checkVendorKeys(content, relPath, failures) {
259
+ for (const detection of scanInputForCredentials(content).detections) {
260
+ if (!VENDOR_KEYS.has(detection.type) || !detection.position)
261
+ continue;
262
+ const raw = content.slice(detection.position.start, detection.position.end);
263
+ if (/^pk_|_test_/.test(raw))
264
+ continue; // publishable and test keys are meant to be shared
265
+ const line = content.slice(0, detection.position.start).split('\n').length;
266
+ if (failures.some(f => f.gate === 'security-patterns' && f.line === line))
267
+ continue;
268
+ failures.push({ gate: 'security-patterns', file: relPath, message: `${detection.description} in code`, severity: 'critical', line });
269
+ }
252
270
  }
253
271
  function checkHardcodedSecrets(line, i, relPath, isTestFile, failures) {
254
272
  if (isTestFile) {
@@ -51,6 +51,16 @@ describe('runHookChecker', () => {
51
51
  expect(result.status).toBe('fail');
52
52
  expect(result.failures[0]).toMatchObject({ gate: 'file-guard', severity: 'critical' });
53
53
  });
54
+ it('catches a real vendor key whatever it is named, and leaves publishable and test keys alone', async () => {
55
+ // Built at runtime: a key-shaped literal in this source would be (rightly) refused by secret scanning.
56
+ const fake = (prefix) => `${prefix}_${'51HxQ'}${'abcdefghijklmnopqrstuv'}`;
57
+ fs.writeFileSync(path.join(testDir, 'pay.ts'), `export const stripe = {\n value: "${fake('sk_live')}",\n};\n`);
58
+ fs.writeFileSync(path.join(testDir, 'public.ts'), `export const key = "${fake('pk_live')}";\nexport const t = "${fake('sk_test')}";\n`);
59
+ const leaked = await runHookChecker({ cwd: testDir, files: ['pay.ts'] });
60
+ expect(leaked.failures).toEqual([expect.objectContaining({ gate: 'security-patterns', line: 2, severity: 'critical', message: 'Stripe API key detected in code' })]);
61
+ const fine = await runHookChecker({ cwd: testDir, files: ['public.ts'] });
62
+ expect(fine.failures.filter(f => f.gate === 'security-patterns')).toEqual([]);
63
+ });
54
64
  it('should detect file size violations', async () => {
55
65
  const filePath = path.join(testDir, 'big.ts');
56
66
  const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
package/dist/index.d.ts CHANGED
@@ -29,8 +29,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
29
29
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
30
30
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
31
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';
33
- export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
32
+ export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
33
+ export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
34
+ export { appendStory, readStories, compactDiff, STORIES_FILE, type Story, type CatchStage } from './review/stories.js';
35
+ export { recordLessonsServed, recordPrCatches, recordReuseSuggested, type LessonChannel } from './review/learning-events.js';
34
36
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
35
37
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
36
38
  export { FileScanner } from './utils/scanner.js';
@@ -46,6 +48,7 @@ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel
46
48
  export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
47
49
  export { SidecarProvider } from './inference/sidecar-provider.js';
48
50
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
51
+ export { cleanContextCache, deadCacheRows, type CacheCleanupReport } from './storage/index.js';
49
52
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
50
53
  export type { RigourDB, CompactResult } from './storage/index.js';
51
54
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
package/dist/index.js CHANGED
@@ -29,8 +29,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
29
29
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
30
30
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
31
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';
33
- export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
32
+ export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
33
+ export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings } from './review/agent-fixes.js';
34
+ export { appendStory, readStories, compactDiff, STORIES_FILE } from './review/stories.js';
35
+ export { recordLessonsServed, recordPrCatches, recordReuseSuggested } from './review/learning-events.js';
34
36
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
35
37
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
36
38
  export { FileScanner } from './utils/scanner.js';
@@ -47,6 +49,7 @@ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG }
47
49
  export { SidecarProvider } from './inference/sidecar-provider.js';
48
50
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
49
51
  // Storage (SQLite Brain)
52
+ export { cleanContextCache, deadCacheRows } from './storage/index.js';
50
53
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
51
54
  // Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
52
55
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
@@ -1,4 +1,18 @@
1
1
  import type { Failure } from '../types/index.js';
2
+ import { type CatchStage } from './stories.js';
3
+ interface OpenFinding {
4
+ file: string;
5
+ rule: string;
6
+ /** What the finding said; absent in entries captured before it was kept. */
7
+ title?: string;
8
+ details?: string;
9
+ before: string;
10
+ openedAt: string;
11
+ /** The stage that first reported it; absent in entries captured before stages were kept. */
12
+ stage?: CatchStage;
13
+ /** Its dismissal key (quiet.ts): a finding someone dismissed was not fixed. */
14
+ key?: string;
15
+ }
2
16
  export interface ResolvedFix {
3
17
  id: string;
4
18
  file: string;
@@ -8,6 +22,9 @@ export interface ResolvedFix {
8
22
  before: string;
9
23
  after: string;
10
24
  resolvedAt: string;
25
+ stage?: CatchStage;
26
+ /** When the finding was first reported, for time to fix. */
27
+ openedAt?: string;
11
28
  }
12
29
  export interface FixCapture {
13
30
  opened: number;
@@ -15,8 +32,14 @@ export interface FixCapture {
15
32
  /** The fixes this review resolved, for turning into lessons (storage/fix-lessons.ts). */
16
33
  fixes: ResolvedFix[];
17
34
  }
18
- /** Record a review's findings: open new ones, resolve open ones the review no longer reports. */
19
- export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[]): FixCapture;
35
+ /**
36
+ * Record a review's findings: open new ones, resolve open ones the review no longer reports.
37
+ * Each resolved fix is also kept as a story, credited to the stage that first reported it.
38
+ */
39
+ export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[], stage?: CatchStage): FixCapture;
20
40
  export declare function listResolvedFixes(cwd: string): ResolvedFix[];
21
41
  export declare function removeResolvedFix(cwd: string, id: string): void;
22
42
  export declare function openFindingCount(cwd: string): number;
43
+ /** Findings reported and not fixed yet, newest first: what still needs someone. */
44
+ export declare function listOpenFindings(cwd: string): Array<Omit<OpenFinding, 'before'>>;
45
+ export {};
@@ -14,24 +14,37 @@ import crypto from 'crypto';
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { checkId, recordOutcome } from './check-outcomes.js';
17
+ import { appendStory, compactDiff } from './stories.js';
18
+ import { dismissedKeys, findingKey } from './quiet.js';
17
19
  const DIR = path.join('.rigour', 'agent-fixes');
18
20
  const MAX_FILE_BYTES = 200_000;
19
21
  const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
- /** Record a review's findings: open new ones, resolve open ones the review no longer reports. */
21
- export function recordReviewOutcome(cwd, findings, reviewedFiles) {
22
+ /**
23
+ * Record a review's findings: open new ones, resolve open ones the review no longer reports.
24
+ * Each resolved fix is also kept as a story, credited to the stage that first reported it.
25
+ */
26
+ export function recordReviewOutcome(cwd, findings, reviewedFiles, stage = 'review') {
22
27
  const open = readOpen(cwd);
23
28
  const current = new Map(findings.flatMap(f => (f.files?.[0] ? [[`${f.id}:${f.files[0]}`, f]] : [])));
24
29
  const fixes = resolveGone(cwd, open, current, new Set(reviewedFiles));
25
- const opened = openNew(cwd, open, current);
30
+ for (const fix of fixes) {
31
+ appendStory(cwd, { at: fix.resolvedAt, openedAt: fix.openedAt, stage: fix.stage ?? stage, file: fix.file, rule: fix.rule, title: fix.title ?? fix.rule, details: fix.details, diff: compactDiff(fix.before, fix.after) });
32
+ }
33
+ const opened = openNew(cwd, open, current, stage);
26
34
  writeOpen(cwd, open);
27
35
  return { opened, resolved: fixes.length, fixes };
28
36
  }
29
37
  /** Open findings this review no longer reports: resolved when their file was reviewed and changed. */
30
38
  function resolveGone(cwd, open, current, reviewed) {
31
39
  const resolved = [];
40
+ const dismissed = dismissedKeys(cwd);
32
41
  for (const [key, entry] of Object.entries(open)) {
33
42
  if (current.has(key))
34
43
  continue;
44
+ if (entry.key && dismissed.has(entry.key)) {
45
+ delete open[key]; // gone because a person dismissed it, not because anyone fixed it
46
+ continue;
47
+ }
35
48
  if (!reviewed.has(entry.file)) {
36
49
  if (Date.now() - Date.parse(entry.openedAt) > OPEN_TTL_MS)
37
50
  delete open[key];
@@ -46,7 +59,7 @@ function resolveGone(cwd, open, current, reviewed) {
46
59
  }
47
60
  return resolved;
48
61
  }
49
- function openNew(cwd, open, current) {
62
+ function openNew(cwd, open, current, stage) {
50
63
  let opened = 0;
51
64
  for (const [key, finding] of current) {
52
65
  if (open[key])
@@ -55,7 +68,7 @@ function openNew(cwd, open, current) {
55
68
  const before = readSmall(cwd, file);
56
69
  if (before === null)
57
70
  continue;
58
- open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString() };
71
+ open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString(), stage, key: findingKey(finding) };
59
72
  opened++;
60
73
  }
61
74
  return opened;
@@ -79,6 +92,14 @@ export function removeResolvedFix(cwd, id) {
79
92
  export function openFindingCount(cwd) {
80
93
  return Object.keys(readOpen(cwd)).length;
81
94
  }
95
+ /** Findings reported and not fixed yet, newest first: what still needs someone. */
96
+ export function listOpenFindings(cwd) {
97
+ const dismissed = dismissedKeys(cwd);
98
+ return Object.values(readOpen(cwd))
99
+ .filter(entry => !(entry.key && dismissed.has(entry.key)))
100
+ .map(({ before: _before, ...rest }) => rest)
101
+ .sort((a, b) => b.openedAt.localeCompare(a.openedAt));
102
+ }
82
103
  function readOpen(cwd) {
83
104
  try {
84
105
  return JSON.parse(fs.readFileSync(path.join(cwd, DIR, 'open.json'), 'utf8'));
@@ -98,7 +119,7 @@ function writeOpen(cwd, open) {
98
119
  }
99
120
  function writeResolved(cwd, entry, after) {
100
121
  const id = crypto.createHash('sha256').update(`${entry.rule}\0${entry.file}\0${entry.before}\0${after}`).digest('hex').slice(0, 16);
101
- const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString() };
122
+ const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString(), ...(entry.stage ? { stage: entry.stage } : {}), openedAt: entry.openedAt };
102
123
  try {
103
124
  fs.mkdirSync(path.join(cwd, DIR, 'resolved'), { recursive: true });
104
125
  fs.writeFileSync(path.join(cwd, DIR, 'resolved', `${id}.json`), JSON.stringify(fix));
@@ -11,6 +11,19 @@ export interface AgentEvent {
11
11
  blocked?: boolean;
12
12
  /** Findings that blocked a stop. */
13
13
  blocking?: number;
14
+ /** lessons_served: where the agent got them, and their subjects (learning-events.ts). */
15
+ via?: string;
16
+ lessons?: string[];
17
+ /** pr_catches: what a branch review reported. */
18
+ findings?: Array<{
19
+ rule: string;
20
+ title: string;
21
+ file: string;
22
+ }>;
23
+ /** reuse_suggested: what the agent was about to write, what already exists, and BLOCK or WARN. */
24
+ planned?: string;
25
+ existing?: string;
26
+ action?: string;
14
27
  }
15
28
  export interface ReviewEffectiveness {
16
29
  toolCalls: Record<string, number>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The two events the learning loop is measured by, written to the same local event log as every
3
+ * agent event: a lesson put in front of an agent, and findings a PR-scope review reported.
4
+ * Studio joins them with stories (stories.ts) to show each lesson's path from where it was
5
+ * learned to whether the same mistake still reaches a PR.
6
+ */
7
+ import type { Failure } from '../types/index.js';
8
+ export type LessonChannel = 'recall' | 'context' | 'review';
9
+ /** Lessons an agent was given before or while writing code. Nothing is written when none were. */
10
+ export declare function recordLessonsServed(cwd: string, via: LessonChannel, subjects: string[]): void;
11
+ /** Findings a review of a whole branch (what a PR contains) reported. Nothing is written when clean. */
12
+ export declare function recordPrCatches(cwd: string, findings: Failure[]): void;
13
+ /** The agent was about to write something that already exists, and was pointed to it. */
14
+ export declare function recordReuseSuggested(cwd: string, planned: string, existing: string, action: 'BLOCK' | 'WARN'): void;
@@ -0,0 +1,21 @@
1
+ import { appendAgentEvent } from './effectiveness.js';
2
+ const MAX_LISTED = 20;
3
+ /** Lessons an agent was given before or while writing code. Nothing is written when none were. */
4
+ export function recordLessonsServed(cwd, via, subjects) {
5
+ const unique = [...new Set(subjects.filter(Boolean))].slice(0, MAX_LISTED);
6
+ if (unique.length)
7
+ appendAgentEvent(cwd, { type: 'lessons_served', via, lessons: unique });
8
+ }
9
+ /** Findings a review of a whole branch (what a PR contains) reported. Nothing is written when clean. */
10
+ export function recordPrCatches(cwd, findings) {
11
+ if (findings.length === 0)
12
+ return;
13
+ appendAgentEvent(cwd, {
14
+ type: 'pr_catches',
15
+ findings: findings.slice(0, MAX_LISTED).map(f => ({ rule: f.id, title: f.title, file: f.files?.[0] ?? '' })),
16
+ });
17
+ }
18
+ /** The agent was about to write something that already exists, and was pointed to it. */
19
+ export function recordReuseSuggested(cwd, planned, existing, action) {
20
+ appendAgentEvent(cwd, { type: 'reuse_suggested', planned, existing, action });
21
+ }
@@ -0,0 +1,25 @@
1
+ /** Where a problem was caught: while the agent wrote, when it asked to review, when it tried to finish, at the PR. */
2
+ export type CatchStage = 'edit' | 'review' | 'stop' | 'pr';
3
+ export interface Story {
4
+ id: string;
5
+ /** When the agent fixed it. */
6
+ at: string;
7
+ /** When Rigour first reported it; absent in stories kept before this was recorded. */
8
+ openedAt?: string;
9
+ stage: CatchStage;
10
+ file: string;
11
+ rule: string;
12
+ title: string;
13
+ details?: string;
14
+ /** Changed lines with context, each prefixed ' ', '-' or '+'. */
15
+ diff: string[];
16
+ }
17
+ export declare const STORIES_FILE: string;
18
+ export declare function appendStory(cwd: string, story: Omit<Story, 'id'>): void;
19
+ /** Every story, oldest first; `since` keeps those at or after it. */
20
+ export declare function readStories(cwd: string, since?: Date): Story[];
21
+ /**
22
+ * The lines that changed between two versions of a file, with context: the common head and
23
+ * tail are trimmed and what is left between them is shown as removed then added.
24
+ */
25
+ export declare function compactDiff(before: string, after: string): string[];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What Rigour stopped, as people read it: one entry per problem an agent fixed after Rigour
3
+ * reported it, with the stage that caught it and the code change that fixed it.
4
+ *
5
+ * agent-fixes.ts keeps the before and after only until `rigour learn` turns them into rules;
6
+ * this log keeps a small, permanent summary for Studio. It never holds whole files: only the
7
+ * changed lines with a little context, capped.
8
+ */
9
+ import crypto from 'crypto';
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ export const STORIES_FILE = path.join('.rigour', 'stories.jsonl');
13
+ /** Kept on disk; older entries are dropped when the log is rewritten. */
14
+ const MAX_STORIES = 500;
15
+ const CONTEXT_LINES = 2;
16
+ const MAX_DIFF_LINES = 40;
17
+ const MAX_LINE_CHARS = 200;
18
+ export function appendStory(cwd, story) {
19
+ const entry = { id: crypto.createHash('sha256').update(`${story.at}\0${story.file}\0${story.rule}`).digest('hex').slice(0, 16), ...story };
20
+ const file = path.join(cwd, STORIES_FILE);
21
+ try {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
24
+ trim(file);
25
+ }
26
+ catch {
27
+ // Stories are a view; failing to keep one must never break a review or a hook.
28
+ }
29
+ }
30
+ /** Every story, oldest first; `since` keeps those at or after it. */
31
+ export function readStories(cwd, since) {
32
+ let lines;
33
+ try {
34
+ lines = fs.readFileSync(path.join(cwd, STORIES_FILE), 'utf8').split('\n');
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ const stories = lines.flatMap(line => {
40
+ try {
41
+ return line.trim() ? [JSON.parse(line)] : [];
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ });
47
+ return since ? stories.filter(s => Date.parse(s.at) >= since.getTime()) : stories;
48
+ }
49
+ /**
50
+ * The lines that changed between two versions of a file, with context: the common head and
51
+ * tail are trimmed and what is left between them is shown as removed then added.
52
+ */
53
+ export function compactDiff(before, after) {
54
+ const a = before.split('\n');
55
+ const b = after.split('\n');
56
+ let head = 0;
57
+ while (head < a.length && head < b.length && a[head] === b[head])
58
+ head++;
59
+ let tail = 0;
60
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
61
+ tail++;
62
+ const clip = (line) => (line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) + '…' : line);
63
+ const lines = [
64
+ ...a.slice(Math.max(0, head - CONTEXT_LINES), head).map(l => ' ' + clip(l)),
65
+ ...a.slice(head, a.length - tail).map(l => '-' + clip(l)),
66
+ ...b.slice(head, b.length - tail).map(l => '+' + clip(l)),
67
+ ...a.slice(a.length - tail, Math.min(a.length, a.length - tail + CONTEXT_LINES)).map(l => ' ' + clip(l)),
68
+ ];
69
+ return lines.length > MAX_DIFF_LINES ? [...lines.slice(0, MAX_DIFF_LINES), ' …'] : lines;
70
+ }
71
+ function trim(file) {
72
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
73
+ if (lines.length > MAX_STORIES * 1.2)
74
+ fs.writeFileSync(file, lines.slice(-MAX_STORIES).join('\n') + '\n');
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
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 { recordReviewOutcome } from './agent-fixes.js';
6
+ import { readAgentEvents } from './effectiveness.js';
7
+ import { recordLessonsServed, recordPrCatches } from './learning-events.js';
8
+ import { compactDiff, readStories } from './stories.js';
9
+ let cwd;
10
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'stories-')); fs.mkdirSync(path.join(cwd, 'src')); });
11
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
12
+ const finding = (id, title) => ({ id, title, details: `${title}.`, severity: 'high', files: ['src/a.ts'], line: 2 });
13
+ describe('compactDiff', () => {
14
+ it('keeps only the changed lines and two lines of context', () => {
15
+ const before = ['a', 'b', 'c', 'old', 'd', 'e', 'f'].join('\n');
16
+ const after = ['a', 'b', 'c', 'new', 'd', 'e', 'f'].join('\n');
17
+ expect(compactDiff(before, after)).toEqual([' b', ' c', '-old', '+new', ' d', ' e']);
18
+ });
19
+ });
20
+ describe('stories from fixes', () => {
21
+ it('credits a fix to the stage that first reported it, with the code change', () => {
22
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), "fetch(url, { headers });\n");
23
+ recordReviewOutcome(cwd, [finding('semantic-bugs', 'Credential header follows redirects')], ['src/a.ts'], 'edit');
24
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), "fetch(url, { headers, redirect: 'error' });\n");
25
+ recordReviewOutcome(cwd, [], ['src/a.ts'], 'stop');
26
+ const [story] = readStories(cwd);
27
+ expect(story).toMatchObject({ stage: 'edit', file: 'src/a.ts', rule: 'semantic-bugs', title: 'Credential header follows redirects' });
28
+ expect(story.diff).toEqual(['-fetch(url, { headers });', "+fetch(url, { headers, redirect: 'error' });", ' ']);
29
+ });
30
+ it('writes no story for a finding someone dismissed, even after its file changed', async () => {
31
+ const { dismissFinding, findingKey } = await import('./quiet.js');
32
+ const f = finding('semantic-bugs', 'Looks risky');
33
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'x\n');
34
+ recordReviewOutcome(cwd, [f], ['src/a.ts'], 'review');
35
+ dismissFinding(cwd, findingKey(f), 'intended');
36
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'y\n');
37
+ recordReviewOutcome(cwd, [], ['src/a.ts'], 'review');
38
+ expect(readStories(cwd)).toEqual([]);
39
+ });
40
+ it('writes no story while the finding is still open', () => {
41
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'x\n');
42
+ recordReviewOutcome(cwd, [finding('semantic-bugs', 'X')], ['src/a.ts'], 'review');
43
+ expect(readStories(cwd)).toEqual([]);
44
+ });
45
+ });
46
+ describe('learning events', () => {
47
+ it('records lessons served and PR catches, and nothing when there are none', () => {
48
+ recordLessonsServed(cwd, 'recall', []);
49
+ recordPrCatches(cwd, []);
50
+ expect(readAgentEvents(cwd)).toEqual([]);
51
+ recordLessonsServed(cwd, 'review', ['Never follow redirects with credentials', 'Never follow redirects with credentials']);
52
+ recordPrCatches(cwd, [finding('semantic-bugs', 'Unbounded query')]);
53
+ expect(readAgentEvents(cwd)).toEqual([
54
+ expect.objectContaining({ type: 'lessons_served', via: 'review', lessons: ['Never follow redirects with credentials'] }),
55
+ expect.objectContaining({ type: 'pr_catches', findings: [{ rule: 'semantic-bugs', title: 'Unbounded query', file: 'src/a.ts' }] }),
56
+ ]);
57
+ });
58
+ });
@@ -0,0 +1,19 @@
1
+ /** Layers no reader uses (context/cache-engine.ts keeps only semantic and checkpoint). */
2
+ export declare const DEAD_CACHE_TYPES: readonly ["static", "component"];
3
+ export interface CacheCleanupReport {
4
+ removed: number;
5
+ kept: number;
6
+ sizeBefore: number;
7
+ sizeAfter: number;
8
+ vacuumed: boolean;
9
+ /** Why the file was not shrunk, when it was not. */
10
+ vacuumSkipped?: string;
11
+ }
12
+ /** How many dead rows the database holds, and its size on disk; null when there is no database. */
13
+ export declare function deadCacheRows(dbPath?: string): Promise<{
14
+ rows: number;
15
+ bytes: number;
16
+ } | null>;
17
+ export declare function cleanContextCache(dbPath?: string, freeBytes?: typeof freeSpace): Promise<CacheCleanupReport>;
18
+ declare function freeSpace(dir: string): number;
19
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Removing context-cache rows nothing reads: the static and component layers that index sync wrote
3
+ * on every edit until it was removed (millions of rows, gigabytes, in real use).
4
+ *
5
+ * Built for a database that is large and a disk that is nearly full. Deleting the rows one by one
6
+ * would copy every changed page into the write-ahead log (as much again on disk); instead the rows
7
+ * worth keeping are set aside, the table is dropped (its pages are freed, not rewritten) and
8
+ * recreated. VACUUM, which hands the freed space back to the disk, needs room for a copy of what
9
+ * remains, so it runs only when that room exists.
10
+ */
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { DB_PATH, openDatabase } from './db.js';
14
+ /** Layers no reader uses (context/cache-engine.ts keeps only semantic and checkpoint). */
15
+ export const DEAD_CACHE_TYPES = ['static', 'component'];
16
+ const DEAD_LIST = DEAD_CACHE_TYPES.map(t => `'${t}'`).join(', ');
17
+ /** VACUUM writes a copy of the live data; ask for that much free space, with headroom. */
18
+ const VACUUM_HEADROOM = 1.2;
19
+ /** How many dead rows the database holds, and its size on disk; null when there is no database. */
20
+ export async function deadCacheRows(dbPath = DB_PATH) {
21
+ if (!fs.existsSync(dbPath))
22
+ return null;
23
+ const db = await openDatabase(dbPath);
24
+ if (!db)
25
+ return null;
26
+ try {
27
+ const row = await db.get(`SELECT COUNT(*) AS n FROM context_cache WHERE cache_type IN (${DEAD_LIST})`);
28
+ return { rows: Number(row?.n ?? 0), bytes: fileBytes(dbPath) };
29
+ }
30
+ finally {
31
+ await db.close();
32
+ }
33
+ }
34
+ export async function cleanContextCache(dbPath = DB_PATH, freeBytes = freeSpace) {
35
+ const sizeBefore = fileBytes(dbPath);
36
+ const db = await openDatabase(dbPath);
37
+ if (!db)
38
+ return { removed: 0, kept: 0, sizeBefore, sizeAfter: sizeBefore, vacuumed: false, vacuumSkipped: 'SQLite is not available' };
39
+ try {
40
+ const { removed, kept } = await dropDeadRows(db);
41
+ if (removed === 0)
42
+ return { removed, kept, sizeBefore, sizeAfter: sizeBefore, vacuumed: false, vacuumSkipped: 'nothing to remove' };
43
+ const live = await liveBytes(db);
44
+ const available = freeBytes(path.dirname(dbPath));
45
+ if (available < live * VACUUM_HEADROOM) {
46
+ return { removed, kept, sizeBefore, sizeAfter: fileBytes(dbPath), vacuumed: false,
47
+ vacuumSkipped: `needs about ${mb(live * VACUUM_HEADROOM)} free to shrink the file, ${mb(available)} available; the space is reused by Rigour either way` };
48
+ }
49
+ await db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
50
+ await db.exec('VACUUM');
51
+ await db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
52
+ return { removed, kept, sizeBefore, sizeAfter: fileBytes(dbPath), vacuumed: true };
53
+ }
54
+ finally {
55
+ await db.close();
56
+ }
57
+ }
58
+ /** Set the readable rows aside, drop the table, recreate it with its indexes, put them back. */
59
+ async function dropDeadRows(db) {
60
+ return db.transaction(async (tx) => {
61
+ const removed = Number((await tx.get(`SELECT COUNT(*) AS n FROM context_cache WHERE cache_type IN (${DEAD_LIST})`))?.n ?? 0);
62
+ if (removed === 0)
63
+ return { removed, kept: 0 };
64
+ const schema = await tx.all(`SELECT sql FROM sqlite_master WHERE tbl_name = 'context_cache' AND sql IS NOT NULL ORDER BY type DESC`);
65
+ await tx.exec(`CREATE TEMP TABLE context_cache_keep AS SELECT * FROM context_cache WHERE cache_type NOT IN (${DEAD_LIST})`);
66
+ await tx.exec('DROP TABLE context_cache');
67
+ for (const { sql } of schema)
68
+ await tx.exec(sql); // the table first (type 'table' sorts after 'index' descending), then its indexes
69
+ await tx.exec('INSERT INTO context_cache SELECT * FROM context_cache_keep');
70
+ const kept = Number((await tx.get('SELECT COUNT(*) AS n FROM context_cache'))?.n ?? 0);
71
+ await tx.exec('DROP TABLE context_cache_keep');
72
+ return { removed, kept };
73
+ });
74
+ }
75
+ async function liveBytes(db) {
76
+ const pages = Number((await db.get('PRAGMA page_count'))?.page_count ?? 0);
77
+ const free = Number((await db.get('PRAGMA freelist_count'))?.freelist_count ?? 0);
78
+ const size = Number((await db.get('PRAGMA page_size'))?.page_size ?? 4096);
79
+ return (pages - free) * size;
80
+ }
81
+ function fileBytes(dbPath) {
82
+ return [dbPath, `${dbPath}-wal`].reduce((n, f) => n + (fs.existsSync(f) ? fs.statSync(f).size : 0), 0);
83
+ }
84
+ function freeSpace(dir) {
85
+ const stats = fs.statfsSync(dir);
86
+ return Number(stats.bavail) * Number(stats.bsize);
87
+ }
88
+ function mb(bytes) {
89
+ return bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${Math.round(bytes / 1024 ** 2)} MB`;
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
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 { cleanContextCache, deadCacheRows } from './cache-cleanup.js';
6
+ import { openDatabase } from './db.js';
7
+ let dir;
8
+ let dbPath;
9
+ async function seed(rows) {
10
+ const db = (await openDatabase(dbPath));
11
+ // One transaction: row-at-a-time commits are slow enough on Windows to time the test out.
12
+ await db.exec('BEGIN');
13
+ for (const [key, type] of rows) {
14
+ await db.run(`INSERT INTO context_cache (cache_key, cache_type, repo, branch, dependency_fingerprint, payload_json, payload_tokens, created_at)
15
+ VALUES (?, ?, 'r', 'main', 'f', ?, 1, 0)`, key, type, JSON.stringify({ padding: 'x'.repeat(2000) }));
16
+ }
17
+ await db.exec('COMMIT');
18
+ await db.close();
19
+ }
20
+ beforeEach(() => {
21
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cache-cleanup-')); // a temporary database: never ~/.rigour
22
+ dbPath = path.join(dir, 'rigour.db');
23
+ });
24
+ // Windows keeps a just-closed SQLite file locked for a moment: retry the removal instead of failing.
25
+ afterEach(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
26
+ // Real SQLite files on disk: slower on Windows runners than the 5 s default allows.
27
+ describe('cleanContextCache', { timeout: 30_000 }, () => {
28
+ it('removes the unread layers, keeps the readable rows and the indexes, and shrinks the file', async () => {
29
+ await seed([
30
+ ...Array.from({ length: 200 }, (_, i) => [`static:${i}`, 'static']),
31
+ ...Array.from({ length: 200 }, (_, i) => [`component:${i}`, 'component']),
32
+ ['semantic:a', 'semantic'], ['checkpoint:b', 'checkpoint'],
33
+ ]);
34
+ expect(await deadCacheRows(dbPath)).toMatchObject({ rows: 400 });
35
+ const report = await cleanContextCache(dbPath, () => Number.MAX_SAFE_INTEGER);
36
+ expect(report).toMatchObject({ removed: 400, kept: 2, vacuumed: true });
37
+ expect(report.sizeAfter).toBeLessThan(report.sizeBefore);
38
+ const db = (await openDatabase(dbPath));
39
+ expect((await db.all('SELECT cache_key FROM context_cache ORDER BY cache_key')).map(r => r.cache_key)).toEqual(['checkpoint:b', 'semantic:a']);
40
+ expect((await db.all("SELECT name FROM sqlite_master WHERE tbl_name = 'context_cache' AND type = 'index'")).map(r => r.name)).toContain('idx_context_cache_type');
41
+ await db.close();
42
+ expect(await deadCacheRows(dbPath)).toMatchObject({ rows: 0 });
43
+ });
44
+ it('still removes the rows but leaves the file size alone when the disk lacks room to shrink it', async () => {
45
+ await seed([['static:1', 'static'], ['semantic:a', 'semantic']]);
46
+ const report = await cleanContextCache(dbPath, () => 0);
47
+ expect(report).toMatchObject({ removed: 1, kept: 1, vacuumed: false });
48
+ expect(report.vacuumSkipped).toContain('free to shrink the file');
49
+ });
50
+ it('does nothing when there is nothing to remove', async () => {
51
+ await seed([['semantic:a', 'semantic']]);
52
+ expect(await cleanContextCache(dbPath, () => 0)).toMatchObject({ removed: 0, vacuumed: false, vacuumSkipped: 'nothing to remove' });
53
+ });
54
+ });
@@ -10,6 +10,7 @@ import path from 'path';
10
10
  import os from 'os';
11
11
  import fs from 'fs-extra';
12
12
  import { createRequire } from 'module';
13
+ import { rigourUserDir } from '../utils/user-state.js';
13
14
  // ---------------------------------------------------------------------------
14
15
  // Optional dynamic import of sqlite3
15
16
  // ---------------------------------------------------------------------------
@@ -42,7 +43,8 @@ function loadSqlite3() {
42
43
  sqlite3Module = null;
43
44
  return sqlite3Module;
44
45
  }
45
- const RIGOUR_DIR = path.join(os.homedir(), '.rigour');
46
+ // RIGOUR_HOME when set (tests, sandboxes) so nothing but a real run touches ~/.rigour.
47
+ const RIGOUR_DIR = rigourUserDir();
46
48
  const DB_PATH = path.join(RIGOUR_DIR, 'rigour.db');
47
49
  /** Current schema version — bump when adding migrations. */
48
50
  const SCHEMA_VERSION = 7;
@@ -3,6 +3,7 @@
3
3
  * Everything in one file: ~/.rigour/rigour.db
4
4
  */
5
5
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, RIGOUR_DIR, DB_PATH } from './db.js';
6
+ export { cleanContextCache, deadCacheRows, DEAD_CACHE_TYPES, type CacheCleanupReport } from './cache-cleanup.js';
6
7
  export type { RigourDB, CompactResult } from './db.js';
7
8
  export { insertScan, getRecentScans, getScoreTrendFromDB, getTopIssues } from './scans.js';
8
9
  export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.js';
@@ -3,6 +3,7 @@
3
3
  * Everything in one file: ~/.rigour/rigour.db
4
4
  */
5
5
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, RIGOUR_DIR, DB_PATH } from './db.js';
6
+ export { cleanContextCache, deadCacheRows, DEAD_CACHE_TYPES } from './cache-cleanup.js';
6
7
  export { insertScan, getRecentScans, getScoreTrendFromDB, getTopIssues } from './scans.js';
7
8
  export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.js';
8
9
  export { reinforcePattern, decayPatterns, getStrongPatterns, getPatterns, getHardRules } from './patterns.js';
@@ -1,9 +1,9 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
2
2
  import { chmod, link, readFile, unlink, writeFile } from 'node:fs/promises';
3
3
  import fs from 'fs-extra';
4
- import os from 'os';
5
4
  import path from 'path';
6
- const KEY_PATH = path.join(os.homedir(), '.rigour', 'team-cache.key');
5
+ import { rigourUserDir } from '../utils/user-state.js';
6
+ const KEY_PATH = path.join(rigourUserDir(), 'team-cache.key');
7
7
  let fileKeyPromise;
8
8
  function decodeKey(value, source) {
9
9
  const normalized = value.trim();
@@ -1,17 +1,17 @@
1
1
  import fs from 'fs-extra';
2
- import os from 'os';
3
2
  import path from 'path';
4
3
  import { openDatabase } from './db.js';
5
4
  import { decryptLocalPayload, encryptLocalPayload } from './local-encryption.js';
6
5
  import { TEAM_SCHEMA } from './team-schema.js';
7
6
  import { diagnoseMissingMembership, explainTeamConnectionError } from './team-diagnostics.js';
7
+ import { rigourUserDir } from '../utils/user-state.js';
8
8
  import { TEAM_VECTOR_SCHEMA, backfillConfiguredTeamEmbeddings, getTeamSemanticHealth, searchConfiguredTeamKnowledge, upsertTeamLessonEmbedding, } from './team-vector-store.js';
9
9
  const DEFAULT_SEMANTIC = {
10
10
  provider: 'pgvector',
11
11
  model: 'Xenova/all-MiniLM-L6-v2',
12
12
  dimensions: 384,
13
13
  };
14
- export const TEAM_CONFIG_PATH = path.join(os.homedir(), '.rigour', 'team.json');
14
+ export const TEAM_CONFIG_PATH = path.join(rigourUserDir(), 'team.json');
15
15
  function nonEmpty(value) {
16
16
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
17
17
  }
@@ -13,7 +13,7 @@ export function rigourUserDir() {
13
13
  export function repoStateDir(cwd) {
14
14
  let root = path.resolve(cwd);
15
15
  try {
16
- root = fs.realpathSync(root);
16
+ root = fs.realpathSync.native(root);
17
17
  }
18
18
  catch { /* not created yet: the resolved path is the key */ }
19
19
  const key = crypto.createHash('sha256').update(root).digest('hex').slice(0, 16);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.5.4",
3
+ "version": "6.6.0",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -70,11 +70,11 @@
70
70
  "pg": "^8.16.3",
71
71
  "sqlite3": "^5.1.7",
72
72
  "openai": "^4.104.0",
73
- "@rigour-labs/brain-darwin-arm64": "6.5.4",
74
- "@rigour-labs/brain-darwin-x64": "6.5.4",
75
- "@rigour-labs/brain-linux-arm64": "6.5.4",
76
- "@rigour-labs/brain-linux-x64": "6.5.4",
77
- "@rigour-labs/brain-win-x64": "6.5.4"
73
+ "@rigour-labs/brain-darwin-arm64": "6.6.0",
74
+ "@rigour-labs/brain-darwin-x64": "6.6.0",
75
+ "@rigour-labs/brain-linux-arm64": "6.6.0",
76
+ "@rigour-labs/brain-linux-x64": "6.6.0",
77
+ "@rigour-labs/brain-win-x64": "6.6.0"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/fs-extra": "^11.0.4",