@rigour-labs/core 6.5.3 → 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.
- package/README.md +1 -1
- package/dist/context/automatic-index-cache.test.d.ts +1 -0
- package/dist/context/automatic-index-cache.test.js +30 -0
- package/dist/context/automatic-index.js +0 -4
- package/dist/context/cache-engine.d.ts +7 -38
- package/dist/context/cache-engine.js +7 -70
- package/dist/context/cache-engine.test.js +2 -46
- package/dist/context/index-bridge.d.ts +1 -7
- package/dist/context/index-bridge.js +1 -79
- package/dist/deep/risk.d.ts +6 -0
- package/dist/deep/risk.js +7 -1
- package/dist/deep/risk.test.js +12 -1
- package/dist/firewall/arbitration-token.d.ts +12 -3
- package/dist/firewall/arbitration-token.js +30 -23
- package/dist/firewall/attestation.d.ts +7 -2
- package/dist/firewall/attestation.js +49 -28
- package/dist/firewall/execution-receipt.d.ts +5 -0
- package/dist/firewall/execution-receipt.js +10 -1
- package/dist/firewall/trust-boundaries.test.d.ts +1 -0
- package/dist/firewall/trust-boundaries.test.js +64 -0
- package/dist/firewall/trusted-control.test.js +10 -0
- package/dist/gates/deep-analysis.d.ts +6 -0
- package/dist/gates/deep-analysis.js +8 -1
- package/dist/gates/trusted-reviews.test.d.ts +1 -0
- package/dist/gates/trusted-reviews.test.js +16 -0
- package/dist/hooks/checker.js +40 -0
- package/dist/hooks/checker.test.js +18 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/session-state.d.ts +8 -0
- package/dist/hooks/session-state.js +61 -0
- package/dist/hooks/stop-review.d.ts +6 -4
- package/dist/hooks/stop-review.js +5 -3
- package/dist/hooks/templates.js +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/review/agent-fixes.d.ts +25 -2
- package/dist/review/agent-fixes.js +27 -6
- package/dist/review/check-outcomes.d.ts +2 -1
- package/dist/review/check-outcomes.js +11 -2
- package/dist/review/effectiveness.d.ts +13 -0
- package/dist/review/git-diff.d.ts +3 -0
- package/dist/review/git-diff.js +5 -0
- package/dist/review/learning-events.d.ts +14 -0
- package/dist/review/learning-events.js +21 -0
- package/dist/review/migration-order.js +1 -1
- package/dist/review/quiet.d.ts +3 -2
- package/dist/review/quiet.js +10 -6
- package/dist/review/review.d.ts +4 -0
- package/dist/review/review.js +14 -2
- package/dist/review/review.test.js +19 -0
- package/dist/review/stories.d.ts +25 -0
- package/dist/review/stories.js +75 -0
- package/dist/review/stories.test.d.ts +1 -0
- package/dist/review/stories.test.js +58 -0
- package/dist/review/trusted-state.d.ts +6 -0
- package/dist/review/trusted-state.js +35 -0
- package/dist/storage/cache-cleanup.d.ts +19 -0
- package/dist/storage/cache-cleanup.js +90 -0
- package/dist/storage/cache-cleanup.test.d.ts +1 -0
- package/dist/storage/cache-cleanup.test.js +54 -0
- package/dist/storage/db.js +3 -1
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/local-encryption.js +2 -2
- package/dist/storage/team-store.js +2 -2
- package/dist/types/index.d.ts +5 -0
- package/dist/utils/user-state.d.ts +7 -0
- package/dist/utils/user-state.js +21 -0
- package/package.json +6 -6
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
* Signed attestation bundles for completed transactions.
|
|
3
3
|
* Keys prefer env / home directory — not agent-writable workspace keys for CI admit.
|
|
4
4
|
*/
|
|
5
|
-
import { createHash, createHmac, randomBytes } from 'crypto';
|
|
5
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
|
-
import os from 'os';
|
|
8
7
|
import path from 'path';
|
|
9
8
|
import { execa } from 'execa';
|
|
9
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
10
10
|
const KEY_FILE = 'attestation.key';
|
|
11
11
|
export async function resolveAttestationKey(cwd) {
|
|
12
12
|
const envKey = process.env.RIGOUR_ATTESTATION_KEY;
|
|
13
13
|
if (envKey && envKey.length >= 32) {
|
|
14
14
|
return { key: Buffer.from(envKey, 'utf8'), source: 'env' };
|
|
15
15
|
}
|
|
16
|
-
const homeKeyPath = path.join(
|
|
16
|
+
const homeKeyPath = path.join(rigourUserDir(), KEY_FILE);
|
|
17
17
|
if (await fs.pathExists(homeKeyPath)) {
|
|
18
18
|
return { key: await fs.readFile(homeKeyPath), source: 'home' };
|
|
19
19
|
}
|
|
@@ -22,7 +22,7 @@ export async function resolveAttestationKey(cwd) {
|
|
|
22
22
|
return { key: await fs.readFile(workspaceKeyPath), source: 'workspace' };
|
|
23
23
|
}
|
|
24
24
|
// Create home key by default (outside agent workspace)
|
|
25
|
-
await fs.ensureDir(
|
|
25
|
+
await fs.ensureDir(rigourUserDir());
|
|
26
26
|
const key = randomBytes(32);
|
|
27
27
|
await fs.writeFile(homeKeyPath, key, { mode: 0o600 });
|
|
28
28
|
return { key, source: 'home' };
|
|
@@ -130,10 +130,11 @@ export async function createAttestation(cwd, input) {
|
|
|
130
130
|
export async function verifyAttestation(cwd, bundle) {
|
|
131
131
|
const { key } = await resolveAttestationKey(cwd);
|
|
132
132
|
const { signature, signedAt, ...rest } = bundle;
|
|
133
|
-
const expected = createHmac('sha256', key)
|
|
133
|
+
const expected = Buffer.from(createHmac('sha256', key)
|
|
134
134
|
.update(payloadForSign(rest) + signedAt)
|
|
135
|
-
.digest('hex');
|
|
136
|
-
|
|
135
|
+
.digest('hex'));
|
|
136
|
+
const given = Buffer.from(String(signature ?? ''));
|
|
137
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
137
138
|
}
|
|
138
139
|
export async function loadLatestAttestation(cwd) {
|
|
139
140
|
const p = path.join(cwd, '.rigour', 'attestation-latest.json');
|
|
@@ -147,9 +148,12 @@ export async function loadLatestAttestation(cwd) {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
/**
|
|
150
|
-
* CI admission
|
|
151
|
+
* CI admission. Admits only a bundle that is signed with a key outside the workspace, records PASS
|
|
152
|
+
* gates, is fresh, is bound to this checkout (commit or tree, and at least one must be present and
|
|
153
|
+
* match), whose artifact digest recomputes from the files here, and, when the caller names the
|
|
154
|
+
* policy it requires, was produced under that policy.
|
|
151
155
|
*/
|
|
152
|
-
export async function admitForCi(cwd) {
|
|
156
|
+
export async function admitForCi(cwd, options = {}) {
|
|
153
157
|
const { source } = await resolveAttestationKey(cwd);
|
|
154
158
|
if (source === 'workspace' && process.env.RIGOUR_ALLOW_WORKSPACE_ATTESTATION_KEY !== '1') {
|
|
155
159
|
return {
|
|
@@ -168,30 +172,47 @@ export async function admitForCi(cwd) {
|
|
|
168
172
|
if (bundle.gateResults.status !== 'PASS') {
|
|
169
173
|
return { admit: false, reason: `Gates not PASS (${bundle.gateResults.status})` };
|
|
170
174
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!bundle.artifactDigest || bundle.artifactDigest.length < 16) {
|
|
175
|
-
return { admit: false, reason: 'Attestation artifactDigest missing or trivial' };
|
|
175
|
+
const requiredPolicy = options.policyHash ?? process.env.RIGOUR_REQUIRED_POLICY_HASH;
|
|
176
|
+
if (requiredPolicy && bundle.policyHash !== requiredPolicy) {
|
|
177
|
+
return { admit: false, reason: `Attestation policy ${bundle.policyHash} is not the required ${requiredPolicy}` };
|
|
176
178
|
}
|
|
177
179
|
const maxAgeMs = Number(process.env.RIGOUR_ATTESTATION_MAX_AGE_MS || 24 * 60 * 60 * 1000);
|
|
178
180
|
const age = Date.now() - Date.parse(bundle.signedAt);
|
|
179
181
|
if (!Number.isFinite(age) || age < 0 || age > maxAgeMs) {
|
|
180
182
|
return { admit: false, reason: 'Attestation expired or has invalid signedAt' };
|
|
181
183
|
}
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
};
|
|
184
|
+
const binding = await checkBinding(cwd, bundle);
|
|
185
|
+
if (binding)
|
|
186
|
+
return { admit: false, reason: binding };
|
|
187
|
+
if (await recomputeArtifactDigest(cwd, bundle) !== bundle.artifactDigest) {
|
|
188
|
+
return { admit: false, reason: 'Attestation artifactDigest does not match the files in this checkout' };
|
|
188
189
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
190
|
+
return { admit: true, reason: 'Valid attestation with PASS gates, bound to this checkout, artifacts verified' };
|
|
191
|
+
}
|
|
192
|
+
/** Why the bundle is not bound to this checkout, or null when it is. A bundle bound to nothing is refused. */
|
|
193
|
+
async function checkBinding(cwd, bundle) {
|
|
194
|
+
if (!bundle.commitSha && !bundle.treeDigest)
|
|
195
|
+
return 'Attestation is bound to no commit or tree';
|
|
196
|
+
const [headSha, headTree] = await Promise.all([getGitCommitSha(cwd), getGitTreeDigest(cwd)]);
|
|
197
|
+
if (bundle.commitSha && bundle.commitSha !== headSha) {
|
|
198
|
+
return `Attestation commitSha ${bundle.commitSha.slice(0, 8)} is not HEAD ${(headSha ?? 'none').slice(0, 8)}`;
|
|
199
|
+
}
|
|
200
|
+
if (bundle.treeDigest && bundle.treeDigest !== headTree)
|
|
201
|
+
return 'Attestation treeDigest mismatch vs HEAD tree';
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
/** The digest createAttestation would compute for this checkout: the named files' contents, or the tree. */
|
|
205
|
+
async function recomputeArtifactDigest(cwd, bundle) {
|
|
206
|
+
const files = bundle.filesUsed ?? [];
|
|
207
|
+
if (files.length === 0)
|
|
208
|
+
return createHash('sha256').update(`tree:${bundle.treeDigest ?? ''}`).digest('hex');
|
|
209
|
+
const contents = new Map();
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
const abs = path.resolve(cwd, f);
|
|
212
|
+
if (!abs.startsWith(path.resolve(cwd) + path.sep))
|
|
213
|
+
continue; // never read outside the checkout
|
|
214
|
+
if (await fs.pathExists(abs))
|
|
215
|
+
contents.set(f, await fs.readFile(abs, 'utf-8'));
|
|
216
|
+
}
|
|
217
|
+
return computeArtifactDigest(files, contents);
|
|
197
218
|
}
|
|
@@ -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
|
|
36
|
+
if (!isLockContention(error?.code))
|
|
28
37
|
throw error;
|
|
29
38
|
await delay(10);
|
|
30
39
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { issueArbitrationToken, signArbitrationDecision, verifyArbitrationDecision } from './arbitration-token.js';
|
|
7
|
+
import { admitForCi, createAttestation } from './attestation.js';
|
|
8
|
+
let repo;
|
|
9
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'trust-'));
|
|
12
|
+
git('init', '-q', '-b', 'main');
|
|
13
|
+
git('config', 'user.email', 't@example.com');
|
|
14
|
+
git('config', 'user.name', 't');
|
|
15
|
+
git('config', 'commit.gpgsign', 'false');
|
|
16
|
+
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
|
17
|
+
git('add', '-A');
|
|
18
|
+
git('commit', '-qm', 'init');
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
|
|
21
|
+
describe('human arbitration', () => {
|
|
22
|
+
it('accepts only a decision signed with the issued token, once', async () => {
|
|
23
|
+
const token = await issueArbitrationToken(repo, 'r1');
|
|
24
|
+
expect(fs.existsSync(path.join(repo, '.rigour', 'arbitration-tokens.json'))).toBe(false);
|
|
25
|
+
// A line any process can append to the event log carries no valid proof.
|
|
26
|
+
expect(verifyArbitrationDecision(token, 'r1', 'approve', undefined)).toBe(false);
|
|
27
|
+
expect(verifyArbitrationDecision(token, 'r1', 'approve', 'f'.repeat(64))).toBe(false);
|
|
28
|
+
const proof = await signArbitrationDecision(repo, 'r1', 'approve');
|
|
29
|
+
expect(verifyArbitrationDecision(token, 'r1', 'approve', proof)).toBe(true);
|
|
30
|
+
expect(verifyArbitrationDecision(token, 'r1', 'reject', proof)).toBe(false);
|
|
31
|
+
expect(await signArbitrationDecision(repo, 'r1', 'approve')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('CI admission', () => {
|
|
35
|
+
const tx = (over = {}) => ({
|
|
36
|
+
id: 'tx1', agentId: 'agent', scope: ['**/*'], policyHash: 'policy-a', capabilitiesIssued: [], filesChanged: ['a.ts'], ...over,
|
|
37
|
+
});
|
|
38
|
+
const pass = { status: 'PASS', failedGates: [] };
|
|
39
|
+
it('admits a fresh, bound bundle whose files are unchanged', async () => {
|
|
40
|
+
await createAttestation(repo, { transaction: tx(), gateResults: pass });
|
|
41
|
+
expect(await admitForCi(repo)).toMatchObject({ admit: true });
|
|
42
|
+
});
|
|
43
|
+
it('refuses when an attested file differs from what was signed, even at the same commit', async () => {
|
|
44
|
+
await createAttestation(repo, { transaction: tx(), gateResults: pass });
|
|
45
|
+
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 2;\n'); // HEAD and its tree are unchanged
|
|
46
|
+
expect(await admitForCi(repo)).toMatchObject({ admit: false, reason: expect.stringContaining('artifactDigest') });
|
|
47
|
+
});
|
|
48
|
+
it('refuses a bundle produced under another policy than the one required', async () => {
|
|
49
|
+
await createAttestation(repo, { transaction: tx(), gateResults: pass });
|
|
50
|
+
expect(await admitForCi(repo, { policyHash: 'policy-b' })).toMatchObject({ admit: false });
|
|
51
|
+
expect(await admitForCi(repo, { policyHash: 'policy-a' })).toMatchObject({ admit: true });
|
|
52
|
+
});
|
|
53
|
+
it('refuses a bundle bound to no commit or tree', async () => {
|
|
54
|
+
const noRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'trust-norepo-'));
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(path.join(noRepo, 'a.ts'), 'export const a = 1;\n');
|
|
57
|
+
await createAttestation(noRepo, { transaction: tx(), gateResults: pass });
|
|
58
|
+
expect(await admitForCi(noRepo)).toMatchObject({ admit: false, reason: 'Attestation is bound to no commit or tree' });
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
fs.rmSync(noRepo, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -15,6 +15,7 @@ import { Gate, GateContext } from './base.js';
|
|
|
15
15
|
import { Failure, Provenance, DeepOptions } from '../types/index.js';
|
|
16
16
|
import type { InferenceUsage } from '../inference/types.js';
|
|
17
17
|
import { type RouterPolicy, type RouterStats } from '../deep/router.js';
|
|
18
|
+
import { reviewedKeys } from '../review/ledger.js';
|
|
18
19
|
import { type LessonMode } from '../review-learning/team-lessons.js';
|
|
19
20
|
export interface DeepGateConfig {
|
|
20
21
|
options: DeepOptions;
|
|
@@ -88,3 +89,8 @@ export declare class DeepAnalysisGate extends Gate {
|
|
|
88
89
|
/** Per-call options; unset config falls back to per-provider defaults. */
|
|
89
90
|
/** Per-call inference timeout: a local 7B reads a file with its reference pack far slower than the small tiers. */
|
|
90
91
|
export declare function defaultTimeout(options: DeepOptions): number;
|
|
92
|
+
/**
|
|
93
|
+
* Functions the router may skip as already reviewed: what agents recorded (the ledger and
|
|
94
|
+
* reviewed.json), unless the run is independent, as an enforcing check is.
|
|
95
|
+
*/
|
|
96
|
+
export declare function trustedReviews(cwd: string, options: Pick<DeepOptions, 'independent'>): ReturnType<typeof reviewedKeys>;
|
|
@@ -179,7 +179,7 @@ export class DeepAnalysisGate extends Gate {
|
|
|
179
179
|
const { options } = this.config;
|
|
180
180
|
if (!isCloud(options) || this.config.router?.enabled === false || !options.focusLines)
|
|
181
181
|
return facts;
|
|
182
|
-
const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router,
|
|
182
|
+
const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router, trustedReviews(cwd, options));
|
|
183
183
|
this.outcome.router = routed.stats;
|
|
184
184
|
if (routed.stats.files_skipped > 0) {
|
|
185
185
|
this.config.onProgress?.(` Router: ${routed.stats.routed} of ${routed.stats.functions} changed function(s) to the model; ${routed.stats.files_skipped} file(s) left to the gates.`);
|
|
@@ -302,3 +302,10 @@ async function withTimeout(promise, ms, message) {
|
|
|
302
302
|
clearTimeout(timer);
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Functions the router may skip as already reviewed: what agents recorded (the ledger and
|
|
307
|
+
* reviewed.json), unless the run is independent, as an enforcing check is.
|
|
308
|
+
*/
|
|
309
|
+
export function trustedReviews(cwd, options) {
|
|
310
|
+
return options.independent ? [] : reviewedKeys(cwd);
|
|
311
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { trustedReviews } from './deep-analysis.js';
|
|
6
|
+
let cwd;
|
|
7
|
+
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
8
|
+
describe('trustedReviews', () => {
|
|
9
|
+
it('lets a normal run skip what agents recorded as reviewed, and an independent run skip nothing', () => {
|
|
10
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-reviews-'));
|
|
11
|
+
fs.mkdirSync(path.join(cwd, '.rigour'));
|
|
12
|
+
fs.writeFileSync(path.join(cwd, '.rigour', 'reviewed.json'), JSON.stringify({ version: 1, entries: [{ file: 'src/a.ts', function: 'f', hash: 'h', reviewer: 'agent', verdict: 'no_issue' }] }));
|
|
13
|
+
expect(trustedReviews(cwd, {}).map(r => r.function)).toEqual(['f']);
|
|
14
|
+
expect(trustedReviews(cwd, { independent: true })).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
package/dist/hooks/checker.js
CHANGED
|
@@ -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;
|
|
@@ -244,6 +249,24 @@ function checkSecurityPatterns(lines, relPath, failures) {
|
|
|
244
249
|
checkHardcodedSecrets(line, i, relPath, isTestFile, failures);
|
|
245
250
|
checkCommandInjection(line, i, relPath, failures);
|
|
246
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
|
+
}
|
|
247
270
|
}
|
|
248
271
|
function checkHardcodedSecrets(line, i, relPath, isTestFile, failures) {
|
|
249
272
|
if (isTestFile) {
|
|
@@ -270,6 +293,23 @@ function checkCommandInjection(line, i, relPath, failures) {
|
|
|
270
293
|
});
|
|
271
294
|
}
|
|
272
295
|
}
|
|
296
|
+
// ── Rigour's own state (always on) ────────────────────────────────
|
|
297
|
+
/**
|
|
298
|
+
* .rigour/ holds what decides a review: dismissals, check outcomes, learned rules, the review
|
|
299
|
+
* ledger, operator scopes. Rigour writes it through its own commands and tools, never through
|
|
300
|
+
* the agent's edit tool, so an agent edit there is refused whatever rigour.yml says.
|
|
301
|
+
*/
|
|
302
|
+
function isRigourState(relPath) {
|
|
303
|
+
return relPath.replace(/\\/g, '/').startsWith('.rigour/');
|
|
304
|
+
}
|
|
305
|
+
function rigourStateFailure(relPath) {
|
|
306
|
+
return {
|
|
307
|
+
gate: 'file-guard',
|
|
308
|
+
file: relPath,
|
|
309
|
+
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.`,
|
|
310
|
+
severity: 'critical',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
273
313
|
// ── Protected Paths Enforcement (real-time) ───────────────────────
|
|
274
314
|
/**
|
|
275
315
|
* Block writes to protected paths defined in rigour.yml safety.protected_paths.
|
|
@@ -43,6 +43,24 @@ 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
|
+
});
|
|
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
|
+
});
|
|
46
64
|
it('should detect file size violations', async () => {
|
|
47
65
|
const filePath = path.join(testDir, 'big.ts');
|
|
48
66
|
const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/hooks/index.js
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
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) };
|
package/dist/hooks/templates.js
CHANGED
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,8 +29,10 @@ 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';
|
|
30
|
-
export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
|
|
31
|
-
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';
|
|
32
36
|
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
|
|
33
37
|
export { parseDiff, changedLinesByFile } from './utils/diff.js';
|
|
34
38
|
export { FileScanner } from './utils/scanner.js';
|
|
@@ -44,6 +48,7 @@ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel
|
|
|
44
48
|
export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
|
|
45
49
|
export { SidecarProvider } from './inference/sidecar-provider.js';
|
|
46
50
|
export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
|
|
51
|
+
export { cleanContextCache, deadCacheRows, type CacheCleanupReport } from './storage/index.js';
|
|
47
52
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
|
|
48
53
|
export type { RigourDB, CompactResult } from './storage/index.js';
|
|
49
54
|
export { checkLocalPatterns, persistAndReinforce, getProjectStats } 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,8 +29,10 @@ 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';
|
|
30
|
-
export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
|
|
31
|
-
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';
|
|
32
36
|
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
|
|
33
37
|
export { parseDiff, changedLinesByFile } from './utils/diff.js';
|
|
34
38
|
export { FileScanner } from './utils/scanner.js';
|
|
@@ -45,6 +49,7 @@ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG }
|
|
|
45
49
|
export { SidecarProvider } from './inference/sidecar-provider.js';
|
|
46
50
|
export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
|
|
47
51
|
// Storage (SQLite Brain)
|
|
52
|
+
export { cleanContextCache, deadCacheRows } from './storage/index.js';
|
|
48
53
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
|
|
49
54
|
// Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
|
|
50
55
|
export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
|