@rigour-labs/core 6.5.4 → 6.6.1

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 (51) 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/deep/risk.d.ts +6 -3
  10. package/dist/deep/risk.js +19 -3
  11. package/dist/deep/risk.test.js +28 -1
  12. package/dist/deep/router.d.ts +2 -1
  13. package/dist/deep/router.js +2 -2
  14. package/dist/firewall/execution-receipt.d.ts +5 -0
  15. package/dist/firewall/execution-receipt.js +10 -1
  16. package/dist/firewall/trusted-control.test.js +10 -0
  17. package/dist/gates/deep-analysis.js +3 -3
  18. package/dist/hooks/checker.js +18 -0
  19. package/dist/hooks/checker.test.js +10 -0
  20. package/dist/index.d.ts +6 -2
  21. package/dist/index.js +6 -2
  22. package/dist/review/agent-fixes.d.ts +25 -2
  23. package/dist/review/agent-fixes.js +27 -6
  24. package/dist/review/effectiveness.d.ts +13 -0
  25. package/dist/review/learning-events.d.ts +14 -0
  26. package/dist/review/learning-events.js +21 -0
  27. package/dist/review/receipt.d.ts +63 -0
  28. package/dist/review/receipt.js +73 -0
  29. package/dist/review/receipt.test.d.ts +1 -0
  30. package/dist/review/receipt.test.js +60 -0
  31. package/dist/review/review-task.d.ts +3 -1
  32. package/dist/review/review-task.js +5 -4
  33. package/dist/review/review-task.test.js +9 -0
  34. package/dist/review/stories.d.ts +25 -0
  35. package/dist/review/stories.js +75 -0
  36. package/dist/review/stories.test.d.ts +1 -0
  37. package/dist/review/stories.test.js +58 -0
  38. package/dist/review-learning/team-lessons.d.ts +4 -0
  39. package/dist/review-learning/team-lessons.js +9 -1
  40. package/dist/storage/cache-cleanup.d.ts +19 -0
  41. package/dist/storage/cache-cleanup.js +90 -0
  42. package/dist/storage/cache-cleanup.test.d.ts +1 -0
  43. package/dist/storage/cache-cleanup.test.js +54 -0
  44. package/dist/storage/db.js +3 -1
  45. package/dist/storage/index.d.ts +1 -0
  46. package/dist/storage/index.js +1 -0
  47. package/dist/storage/local-encryption.js +2 -2
  48. package/dist/storage/team-store.js +2 -2
  49. package/dist/types/index.js +1 -1
  50. package/dist/utils/user-state.js +1 -1
  51. package/package.json +6 -6
@@ -23,7 +23,7 @@ import { buildReviewTask } from '../review/review-task.js';
23
23
  import { reviewPullRequest } from '../deep/pr-review.js';
24
24
  import { relatedChanges } from '../deep/related-changes.js';
25
25
  import { rankChangedFunctions } from '../deep/risk.js';
26
- import { lessonsForDiff, lessonsSection } from '../review-learning/team-lessons.js';
26
+ import { activeLessons, lessonsForDiff, lessonsSection } from '../review-learning/team-lessons.js';
27
27
  import { rulesForDiff, rulesSection } from '../review-learning/repo-rules.js';
28
28
  import { verifyCodeFindings } from '../deep/code-verifier.js';
29
29
  import { runIntentChecks } from './deep-intent.js';
@@ -153,7 +153,7 @@ export class DeepAnalysisGate extends Gate {
153
153
  this.config.onProgress?.(' Router: no risky change for the model; the gates cover this PR.');
154
154
  return [];
155
155
  }
156
- const focus = buildReviewTask(cwd, options.diff, this.config.router).items;
156
+ const focus = buildReviewTask(cwd, options.diff, this.config.router, this.config.reviewLessons, false, trustedReviews(cwd, options)).items;
157
157
  const changedFiles = Object.keys(options.focusLines ?? {});
158
158
  const related = relatedChanges(cwd, changedFiles, rankChangedFunctions(cwd, options.focusLines ?? {}, options.removedLines));
159
159
  this.config.onProgress?.(` Reviewing the PR as a whole (${focus.length} risky function(s) first)...`);
@@ -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, trustedReviews(cwd, options));
182
+ const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router, trustedReviews(cwd, options), activeLessons(cwd, this.config.reviewLessons));
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.`);
@@ -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
@@ -19,6 +19,7 @@ export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMIS
19
19
  export { isGeneratedFile } from './review/generated-files.js';
20
20
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
21
21
  export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/review-task.js';
22
+ export { buildQualityReceipt, receiptReport, type QualityReceipt, type ReceiptGap, type ReceiptOptions } from './review/receipt.js';
22
23
  export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
23
24
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
24
25
  export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type FunctionRisk, type RiskSignals } from './deep/risk.js';
@@ -29,8 +30,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
29
30
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
30
31
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
31
32
  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';
33
+ export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
34
+ export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
35
+ export { appendStory, readStories, compactDiff, STORIES_FILE, type Story, type CatchStage } from './review/stories.js';
36
+ export { recordLessonsServed, recordPrCatches, recordReuseSuggested, type LessonChannel } from './review/learning-events.js';
34
37
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
35
38
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
36
39
  export { FileScanner } from './utils/scanner.js';
@@ -46,6 +49,7 @@ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel
46
49
  export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
47
50
  export { SidecarProvider } from './inference/sidecar-provider.js';
48
51
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
52
+ export { cleanContextCache, deadCacheRows, type CacheCleanupReport } from './storage/index.js';
49
53
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
50
54
  export type { RigourDB, CompactResult } from './storage/index.js';
51
55
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMIS
19
19
  export { isGeneratedFile } from './review/generated-files.js';
20
20
  export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
21
21
  export { buildReviewTask } from './review/review-task.js';
22
+ export { buildQualityReceipt, receiptReport } from './review/receipt.js';
22
23
  export { acknowledgeReview } from './review/review-ack.js';
23
24
  export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
24
25
  export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './deep/risk.js';
@@ -29,8 +30,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
29
30
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
30
31
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
31
32
  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';
33
+ export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
34
+ export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings } from './review/agent-fixes.js';
35
+ export { appendStory, readStories, compactDiff, STORIES_FILE } from './review/stories.js';
36
+ export { recordLessonsServed, recordPrCatches, recordReuseSuggested } from './review/learning-events.js';
34
37
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
35
38
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
36
39
  export { FileScanner } from './utils/scanner.js';
@@ -47,6 +50,7 @@ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG }
47
50
  export { SidecarProvider } from './inference/sidecar-provider.js';
48
51
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
49
52
  // Storage (SQLite Brain)
53
+ export { cleanContextCache, deadCacheRows } from './storage/index.js';
50
54
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
51
55
  // Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
52
56
  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,63 @@
1
+ /**
2
+ * The quality receipt for a change: for every changed function outside tests, what is known about
3
+ * it before a model looks at anything.
4
+ *
5
+ * - reviewed: risky, and reviewed at its current code during development (the ledger or a
6
+ * committed reviewed.json, keyed by the function's exact source);
7
+ * - changed since review: risky, reviewed once, then edited, so that review no longer counts;
8
+ * - low risk: below the router's bar, left to the deterministic checks;
9
+ * - not covered: risky with no review of this code. These are what a PR review should spend on.
10
+ *
11
+ * Reviews are agents' and people's own records. An independent (enforcing) review does not count
12
+ * them, and the receipt says how many it set aside rather than hiding them.
13
+ */
14
+ import { type RouterPolicy } from '../deep/router.js';
15
+ import { type LessonMode } from '../review-learning/team-lessons.js';
16
+ export interface ReceiptGap {
17
+ file: string;
18
+ function: string;
19
+ start: number;
20
+ score: number;
21
+ /** Reviewed once, then edited. */
22
+ changedSinceReview: boolean;
23
+ /** The team lesson that made it risky, when one did. */
24
+ lesson?: string;
25
+ }
26
+ export interface QualityReceipt {
27
+ functions: number;
28
+ reviewed: number;
29
+ changedSinceReview: number;
30
+ lowRisk: number;
31
+ notCovered: ReceiptGap[];
32
+ /** Who reviewed the functions counted as reviewed: agent, human, or byok:<model>. */
33
+ reviewers: Record<string, number>;
34
+ /** Changed files Rigour cannot split into functions (other languages, config): rules only. */
35
+ otherFiles: number;
36
+ /** Self-reported reviews an independent review did not count. */
37
+ setAside: number;
38
+ }
39
+ export interface ReceiptOptions {
40
+ policy?: RouterPolicy;
41
+ lessonMode?: LessonMode;
42
+ /** Trust nothing the change itself recorded (enforce mode). */
43
+ independent?: boolean;
44
+ }
45
+ export declare function buildQualityReceipt(cwd: string, diff: string, options?: ReceiptOptions): QualityReceipt;
46
+ /** The receipt as reports carry it (snake_case, like the rest of `rigour review --json` and the MCP tool). */
47
+ export declare function receiptReport(r: QualityReceipt): {
48
+ functions: number;
49
+ reviewed: number;
50
+ changed_since_review: number;
51
+ low_risk: number;
52
+ not_covered: {
53
+ lesson?: string | undefined;
54
+ file: string;
55
+ function: string;
56
+ line: number;
57
+ score: number;
58
+ changed_since_review: boolean;
59
+ }[];
60
+ reviewers: Record<string, number>;
61
+ other_files: number;
62
+ set_aside: number;
63
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The quality receipt for a change: for every changed function outside tests, what is known about
3
+ * it before a model looks at anything.
4
+ *
5
+ * - reviewed: risky, and reviewed at its current code during development (the ledger or a
6
+ * committed reviewed.json, keyed by the function's exact source);
7
+ * - changed since review: risky, reviewed once, then edited, so that review no longer counts;
8
+ * - low risk: below the router's bar, left to the deterministic checks;
9
+ * - not covered: risky with no review of this code. These are what a PR review should spend on.
10
+ *
11
+ * Reviews are agents' and people's own records. An independent (enforcing) review does not count
12
+ * them, and the receipt says how many it set aside rather than hiding them.
13
+ */
14
+ import { DEFAULT_MIN_SCORE } from '../deep/router.js';
15
+ import { rankChangedFunctions } from '../deep/risk.js';
16
+ import { activeLessons, DEFAULT_LESSON_MODE } from '../review-learning/team-lessons.js';
17
+ import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
18
+ import { isReviewed, readLedger, reviewedKeys } from './ledger.js';
19
+ const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
20
+ export function buildQualityReceipt(cwd, diff, options = {}) {
21
+ const changed = changedLinesByFile(parseDiff(diff));
22
+ const ranked = rankChangedFunctions(cwd, changed, removedByFile(diff), activeLessons(cwd, options.lessonMode ?? DEFAULT_LESSON_MODE));
23
+ const minScore = options.policy?.min_score ?? DEFAULT_MIN_SCORE;
24
+ const risky = ranked.filter(f => f.score >= minScore);
25
+ const recorded = reviewedKeys(cwd);
26
+ const trusted = options.independent ? [] : recorded;
27
+ const reviewedNow = risky.filter(f => isReviewed(trusted, keyOf(f)));
28
+ const gaps = risky.filter(f => !isReviewed(trusted, keyOf(f)));
29
+ return {
30
+ functions: ranked.length,
31
+ reviewed: reviewedNow.length,
32
+ changedSinceReview: gaps.filter(f => reviewedBefore(recorded, f)).length,
33
+ lowRisk: ranked.length - risky.length,
34
+ notCovered: gaps.map(f => ({
35
+ file: f.file, function: f.name, start: f.start, score: f.score,
36
+ changedSinceReview: reviewedBefore(recorded, f),
37
+ ...(f.signals.lesson ? { lesson: f.signals.lesson } : {}),
38
+ })),
39
+ reviewers: reviewersOf(cwd, reviewedNow),
40
+ otherFiles: Object.keys(changed).filter(file => !PARSEABLE.test(file)).length,
41
+ setAside: options.independent ? risky.filter(f => isReviewed(recorded, keyOf(f))).length : 0,
42
+ };
43
+ }
44
+ function keyOf(f) {
45
+ return { file: f.file, function: f.name, hash: f.hash };
46
+ }
47
+ /** Some version of this function was reviewed, just not the code it has now. */
48
+ function reviewedBefore(recorded, f) {
49
+ return recorded.some(r => r.file === f.file && r.function === f.name && r.hash !== f.hash);
50
+ }
51
+ function reviewersOf(cwd, reviewed) {
52
+ const ledger = readLedger(cwd);
53
+ const counts = {};
54
+ for (const f of reviewed) {
55
+ const entry = [...ledger].reverse().find(e => e.file === f.file && e.function === f.name && e.hash === f.hash);
56
+ const who = entry ? (entry.reviewer.startsWith('byok:') ? 'model' : entry.reviewer) : 'recorded';
57
+ counts[who] = (counts[who] ?? 0) + 1;
58
+ }
59
+ return counts;
60
+ }
61
+ /** The receipt as reports carry it (snake_case, like the rest of `rigour review --json` and the MCP tool). */
62
+ export function receiptReport(r) {
63
+ return {
64
+ functions: r.functions,
65
+ reviewed: r.reviewed,
66
+ changed_since_review: r.changedSinceReview,
67
+ low_risk: r.lowRisk,
68
+ not_covered: r.notCovered.map(g => ({ file: g.file, function: g.function, line: g.start, score: g.score, changed_since_review: g.changedSinceReview, ...(g.lesson ? { lesson: g.lesson } : {}) })),
69
+ reviewers: r.reviewers,
70
+ other_files: r.otherFiles,
71
+ set_aside: r.setAside,
72
+ };
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
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 { findFunction } from '../deep/risk.js';
7
+ import { diffFromGit } from './git-diff.js';
8
+ import { recordReview } from './ledger.js';
9
+ import { buildQualityReceipt } from './receipt.js';
10
+ let repo;
11
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
12
+ const write = (rel, body) => { fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true }); fs.writeFileSync(path.join(repo, rel), body); };
13
+ const review = (file, fn, reviewer = 'agent') => {
14
+ const found = findFunction(repo, file, fn);
15
+ recordReview(repo, { file, function: fn, hash: found.hash, reviewer, verdict: 'no_issue', note: 'checked' });
16
+ };
17
+ beforeEach(() => {
18
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-'));
19
+ git('init', '-q', '-b', 'main');
20
+ git('config', 'user.email', 't@example.com');
21
+ git('config', 'user.name', 't');
22
+ git('config', 'commit.gpgsign', 'false');
23
+ write('.gitignore', '.rigour/\n');
24
+ write('src/pay.ts', 'export const x = 1;\n');
25
+ git('add', '-A');
26
+ git('commit', '-qm', 'init');
27
+ });
28
+ afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
29
+ // Risky: a data write. Low risk: a formatter. Each in its own function.
30
+ const RISKY = (n) => `export async function saveRefund${n}(db: any, cents: number) {\n return db.insert({ cents: cents + ${n} });\n}\n`;
31
+ const PLAIN = 'export function label(name: string) {\n return name.trim();\n}\n';
32
+ describe('buildQualityReceipt', () => {
33
+ it('says what was reviewed at its current code, what changed after review, what is low risk, and what is not covered', () => {
34
+ write('src/pay.ts', RISKY(1) + RISKY(2) + RISKY(3) + PLAIN);
35
+ write('config/app.yml', 'a: 1\n');
36
+ review('src/pay.ts', 'saveRefund1');
37
+ review('src/pay.ts', 'saveRefund2');
38
+ write('src/pay.ts', RISKY(1) + RISKY(2).replace('cents + 2', 'cents + 20') + RISKY(3) + PLAIN); // edited after its review
39
+ const receipt = buildQualityReceipt(repo, diffFromGit(repo));
40
+ expect(receipt).toMatchObject({ functions: 4, reviewed: 1, changedSinceReview: 1, lowRisk: 1, otherFiles: 1, setAside: 0, reviewers: { agent: 1 } });
41
+ expect(receipt.notCovered.map(g => [g.function, g.changedSinceReview])).toEqual([['saveRefund2', true], ['saveRefund3', false]]);
42
+ });
43
+ it('counts no self-reported review when independent, and says how many it set aside', () => {
44
+ write('src/pay.ts', RISKY(1) + RISKY(2));
45
+ review('src/pay.ts', 'saveRefund1');
46
+ const receipt = buildQualityReceipt(repo, diffFromGit(repo), { independent: true });
47
+ expect(receipt).toMatchObject({ reviewed: 0, setAside: 1 });
48
+ expect(receipt.notCovered).toHaveLength(2);
49
+ });
50
+ it('treats a function matching a verified team lesson as risky, and says which lesson', () => {
51
+ write('src/pay.ts', 'export function formatTotal(totalCents: number) {\n return (totalCents / 100).toFixed(2);\n}\n');
52
+ write('.rigour/review-lessons.json', JSON.stringify({ version: 1, lessons: [{
53
+ id: 'l1', text: 'Format money from integer cents, never floats.', file: 'src/pay.ts', symbols: ['totalCents'],
54
+ state: 'verified', evidence: [{ pr: 12, comment: 'c', author: 'reviewer' }], createdAt: '', updatedAt: '',
55
+ }] }));
56
+ const withLesson = buildQualityReceipt(repo, diffFromGit(repo));
57
+ expect(withLesson.notCovered).toEqual([expect.objectContaining({ function: 'formatTotal', lesson: 'Format money from integer cents, never floats.' })]);
58
+ expect(buildQualityReceipt(repo, diffFromGit(repo), { lessonMode: 'off' }).notCovered).toEqual([]);
59
+ });
60
+ });
@@ -9,6 +9,7 @@
9
9
  * pre-empted four times as many acted-on review comments as chunked prompts.
10
10
  */
11
11
  import { type RouterPolicy } from '../deep/router.js';
12
+ import { type ReviewedKey } from './ledger.js';
12
13
  import { type LessonMode } from '../review-learning/team-lessons.js';
13
14
  export interface ReviewTaskItem {
14
15
  file: string;
@@ -37,4 +38,5 @@ export interface ReviewTask {
37
38
  }>;
38
39
  instructions: string;
39
40
  }
40
- export declare function buildReviewTask(cwd: string, diff: string, policy?: RouterPolicy, lessonMode?: LessonMode, repoRules?: boolean): ReviewTask;
41
+ /** `reviewed`: the reviews to trust; an independent (enforcing) review passes none, so self-reported reviews skip nothing. */
42
+ export declare function buildReviewTask(cwd: string, diff: string, policy?: RouterPolicy, lessonMode?: LessonMode, repoRules?: boolean, reviewed?: ReviewedKey[]): ReviewTask;
@@ -12,7 +12,7 @@ import { DEFAULT_MAX_FUNCTIONS, DEFAULT_MIN_SCORE } from '../deep/router.js';
12
12
  import { rankChangedFunctions } from '../deep/risk.js';
13
13
  import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
14
14
  import { isReviewed, reviewedKeys } from './ledger.js';
15
- import { lessonsForDiff } from '../review-learning/team-lessons.js';
15
+ import { activeLessons, DEFAULT_LESSON_MODE, lessonsForDiff } from '../review-learning/team-lessons.js';
16
16
  import { rulesForDiff } from '../review-learning/repo-rules.js';
17
17
  const QUESTIONS = {
18
18
  'data-write': 'Is the write safe under concurrent calls (no check-then-act), idempotent on retry, and does an upsert/conflict clause do what the caller expects?',
@@ -22,11 +22,11 @@ const QUESTIONS = {
22
22
  concurrency: 'What happens if two calls overlap, or one step fails half-way through?',
23
23
  network: 'Are timeouts, aborts and non-2xx responses handled, and does a retry repeat a side effect?',
24
24
  };
25
- export function buildReviewTask(cwd, diff, policy = {}, lessonMode = 'off', repoRules = false) {
25
+ /** `reviewed`: the reviews to trust; an independent (enforcing) review passes none, so self-reported reviews skip nothing. */
26
+ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = DEFAULT_LESSON_MODE, repoRules = false, reviewed = reviewedKeys(cwd)) {
26
27
  const changed = parseDiff(diff);
27
- const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff));
28
+ const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff), activeLessons(cwd, lessonMode));
28
29
  const risky = ranked.filter(f => f.score >= (policy.min_score ?? DEFAULT_MIN_SCORE)).slice(0, policy.max_functions ?? DEFAULT_MAX_FUNCTIONS);
29
- const reviewed = reviewedKeys(cwd);
30
30
  const pending = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
31
31
  const lessons = lessonsForDiff(cwd, diff, lessonMode).map(l => ({ file: l.file, text: l.text, prs: [...new Set(l.evidence.map(e => e.pr))] }));
32
32
  return {
@@ -42,6 +42,7 @@ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = 'off', repo
42
42
  }
43
43
  function toItem(f) {
44
44
  const questions = [
45
+ ...(f.signals.lesson ? [`Your team flagged this before: "${f.signals.lesson}" Does this change repeat it?`] : []),
45
46
  ...(f.signals.removedGuard ? ['The change removed a condition, return or throw here: which inputs did it guard against, and are they still handled?'] : []),
46
47
  ...f.signals.sensitive.map(kind => QUESTIONS[kind]).filter(Boolean),
47
48
  `Do the callers of \`${f.name}\` still get what they assume (types, empty and error cases)?`,
@@ -8,6 +8,7 @@ import { acknowledgeReview } from './review-ack.js';
8
8
  import { buildReviewTask } from './review-task.js';
9
9
  import { exportReviewed, readLedger, REVIEWED_FILE } from './ledger.js';
10
10
  import { reviewAckMessage } from '../hooks/stop-review.js';
11
+ import { writeLessons } from '../review-learning/lessons.js';
11
12
  let repo;
12
13
  const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
13
14
  const write = (body) => fs.writeFileSync(path.join(repo, 'sync.ts'), body);
@@ -51,6 +52,14 @@ describe('review task and acknowledgements', () => {
51
52
  expect(task.items[0].questions[0]).toMatch(/concurrent calls/);
52
53
  expect(task.items[0].questions.at(-1)).toContain('`syncOrders`');
53
54
  });
55
+ it('sends a function a verified team lesson names to review, and asks whether the change repeats it', () => {
56
+ write(AFTER.replace('name.trim()', 'name.toLocaleUpperCase()'));
57
+ writeLessons(repo, [{ id: 'l1', text: 'Locale casing breaks Turkish names.', file: 'sync.ts', symbols: ['toLocaleUpperCase'], state: 'verified', evidence: [{ pr: 7, comment: 'c1', author: 'r' }], createdAt: '', updatedAt: '' }]);
58
+ const task = buildReviewTask(repo, diffFromGit(repo));
59
+ const label = task.items.find(i => i.function === 'label');
60
+ expect(label?.questions[0]).toContain('Locale casing breaks Turkish names.');
61
+ expect(buildReviewTask(repo, diffFromGit(repo), {}, 'off').items.map(i => i.function)).toEqual(['syncOrders']);
62
+ });
54
63
  it('drops an acknowledged function from the task until its code changes again', () => {
55
64
  const ack = acknowledgeReview(repo, { file: 'sync.ts', function: 'syncOrders', verdict: 'no_issue', note: 'upsert key is the unique order id; callers retry safely' });
56
65
  expect(ack.ok).toBe(true);
@@ -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[];