@rigour-labs/core 6.5.1 → 6.5.3

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 (120) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index.d.ts +5 -0
  3. package/dist/context/automatic-index.js +24 -3
  4. package/dist/context/index-status.test.d.ts +1 -0
  5. package/dist/context/index-status.test.js +26 -0
  6. package/dist/deep/fact-extractor.js +2 -1
  7. package/dist/deep/fact-extractor.test.js +2 -1
  8. package/dist/gates/ast.js +4 -2
  9. package/dist/gates/coverage.js +4 -2
  10. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  11. package/dist/gates/dedupe-failures.test.js +12 -0
  12. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  13. package/dist/gates/deprecated-dependencies.js +104 -0
  14. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  15. package/dist/gates/deprecated-dependencies.test.js +55 -0
  16. package/dist/gates/runner.d.ts +7 -1
  17. package/dist/gates/runner.js +25 -11
  18. package/dist/gates/security-patterns-data.js +2 -1
  19. package/dist/gates/security-patterns-owasp.test.js +15 -0
  20. package/dist/gates/semantic-bugs.js +1 -1
  21. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  22. package/dist/gates/unindexed-reads/coverage.js +24 -0
  23. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  24. package/dist/gates/unindexed-reads/index.js +98 -0
  25. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  26. package/dist/gates/unindexed-reads/queries.js +147 -0
  27. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  28. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  29. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  30. package/dist/gates/unindexed-reads/schema.js +256 -0
  31. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  32. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  33. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  34. package/dist/gates/unindexed-reads/sql.js +131 -0
  35. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  36. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +4 -1
  39. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  40. package/dist/inference/brain-placeholder.test.js +28 -0
  41. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  42. package/dist/inference/llama-engine-checksum.test.js +27 -0
  43. package/dist/inference/llama-engine.d.ts +4 -1
  44. package/dist/inference/llama-engine.js +15 -8
  45. package/dist/memory/recall.d.ts +37 -0
  46. package/dist/memory/recall.js +93 -0
  47. package/dist/memory/recall.test.d.ts +1 -0
  48. package/dist/memory/recall.test.js +36 -0
  49. package/dist/pattern-index/assess.d.ts +16 -0
  50. package/dist/pattern-index/assess.js +37 -0
  51. package/dist/pattern-index/embeddings.d.ts +19 -0
  52. package/dist/pattern-index/embeddings.js +16 -0
  53. package/dist/pattern-index/index.d.ts +3 -2
  54. package/dist/pattern-index/index.js +4 -2
  55. package/dist/pattern-index/indexer.js +19 -9
  56. package/dist/pattern-index/matcher.d.ts +6 -0
  57. package/dist/pattern-index/matcher.js +13 -3
  58. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  59. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  60. package/dist/pattern-index/staleness.d.ts +2 -15
  61. package/dist/pattern-index/staleness.js +1 -78
  62. package/dist/pattern-index/types.d.ts +2 -0
  63. package/dist/review/agent-fixes.d.ts +4 -0
  64. package/dist/review/agent-fixes.js +10 -7
  65. package/dist/review/agent-fixes.test.js +4 -4
  66. package/dist/review/changed-lines.d.ts +2 -1
  67. package/dist/review/changed-lines.js +12 -2
  68. package/dist/review/check-outcomes.d.ts +31 -0
  69. package/dist/review/check-outcomes.js +81 -0
  70. package/dist/review/check-outcomes.test.d.ts +1 -0
  71. package/dist/review/check-outcomes.test.js +56 -0
  72. package/dist/review/migration-order.d.ts +5 -0
  73. package/dist/review/migration-order.js +70 -0
  74. package/dist/review/migration-order.test.d.ts +1 -0
  75. package/dist/review/migration-order.test.js +62 -0
  76. package/dist/review/quiet.d.ts +2 -0
  77. package/dist/review/quiet.js +12 -3
  78. package/dist/review/review.d.ts +4 -0
  79. package/dist/review/review.js +17 -5
  80. package/dist/review/review.test.js +36 -1
  81. package/dist/semantic/ast.d.ts +1 -0
  82. package/dist/semantic/ast.js +4 -0
  83. package/dist/semantic/learn/compile.js +3 -1
  84. package/dist/semantic/learn/learn.js +40 -5
  85. package/dist/semantic/types.d.ts +2 -0
  86. package/dist/services/context-scope-summary.test.d.ts +1 -0
  87. package/dist/services/context-scope-summary.test.js +17 -0
  88. package/dist/services/context-telemetry-service.js +2 -1
  89. package/dist/storage/context-telemetry.js +33 -19
  90. package/dist/storage/db.js +52 -1
  91. package/dist/storage/findings.js +1 -1
  92. package/dist/storage/fix-lessons.d.ts +13 -0
  93. package/dist/storage/fix-lessons.js +84 -0
  94. package/dist/storage/fix-lessons.test.d.ts +1 -0
  95. package/dist/storage/fix-lessons.test.js +61 -0
  96. package/dist/storage/index.d.ts +2 -1
  97. package/dist/storage/index.js +2 -1
  98. package/dist/storage/lessons.d.ts +16 -2
  99. package/dist/storage/lessons.js +27 -61
  100. package/dist/storage/lessons.test.js +1 -15
  101. package/dist/storage/local-memory.js +39 -5
  102. package/dist/storage/local-memory.test.d.ts +1 -0
  103. package/dist/storage/local-memory.test.js +55 -0
  104. package/dist/storage/patterns.d.ts +1 -1
  105. package/dist/storage/patterns.js +1 -1
  106. package/dist/storage/share-memory.test.d.ts +1 -0
  107. package/dist/storage/share-memory.test.js +34 -0
  108. package/dist/storage/team-vector-store.js +2 -1
  109. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  110. package/dist/storage/telemetry-scope.test.js +38 -0
  111. package/dist/templates/universal-config.js +11 -0
  112. package/dist/types/index.d.ts +132 -0
  113. package/dist/types/index.js +15 -0
  114. package/dist/utils/glob-paths.d.ts +4 -0
  115. package/dist/utils/glob-paths.js +26 -0
  116. package/dist/utils/glob-paths.test.d.ts +1 -0
  117. package/dist/utils/glob-paths.test.js +40 -0
  118. package/dist/utils/scanner.js +4 -2
  119. package/dist/utils/scanner.test.js +2 -1
  120. package/package.json +7 -7
@@ -16,6 +16,8 @@ import { analyzeFiles } from '../engine.js';
16
16
  import { programBatches, loadProjectConfig } from '../program.js';
17
17
  import { compileLearnedRule, LEARNED_PREFIX } from './compile.js';
18
18
  import { extractEdits } from './extract.js';
19
+ import { endLineOf, forEachNode, lineOf } from '../ast.js';
20
+ import { functionKeyOf, isInvocation } from './identity.js';
19
21
  const DEFAULT_MAX_HITS = 3;
20
22
  const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
21
23
  const NOT_SOURCE = /\.d\.[cm]?ts$|(?:^|\/)(?:__tests__|__mocks__|tests?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
@@ -73,6 +75,12 @@ function openTree(cwd, file) {
73
75
  throw new Error(`Cannot parse ${file} in ${cwd}`);
74
76
  return { cwd, checker: program.getTypeChecker(), sourceFile };
75
77
  }
78
+ /**
79
+ * How often each candidate fires: before the fix, after it, and elsewhere. "After" counts only
80
+ * the function that was fixed: an unfixed copy of the bug next to it in the same file is
81
+ * another place the rule should find (listed with the repository hits), not a sign the rule
82
+ * is wrong about the fix.
83
+ */
76
84
  function countFindings(input, rules, files, repoFiles) {
77
85
  const compiled = rules.map(compileLearnedRule);
78
86
  const tally = (dir, scan) => analyzeFiles(dir, scan, { rules: compiled });
@@ -80,11 +88,38 @@ function countFindings(input, rules, files, repoFiles) {
80
88
  const before = tally(input.beforeDir, files);
81
89
  const after = tally(input.afterDir, files);
82
90
  const repo = tally(input.repoDir, repoFiles);
83
- return new Map(rules.map(rule => [rule.id, {
84
- before: byRule(before, rule.id).length,
85
- after: byRule(after, rule.id).length,
86
- hits: byRule(repo, rule.id).map(f => `${f.file}:${f.line}`),
87
- }]));
91
+ const keyAt = functionKeysAt(input.afterDir, files);
92
+ return new Map(rules.map(rule => {
93
+ const afterHits = byRule(after, rule.id);
94
+ const atFix = afterHits.filter(f => f.file === rule.source.file && keyAt(f.file, f.line) === rule.source.function);
95
+ const besideFix = afterHits.filter(f => !atFix.includes(f)).map(f => `${f.file}:${f.line}`);
96
+ const elsewhere = byRule(repo, rule.id).map(f => `${f.file}:${f.line}`);
97
+ return [rule.id, { before: byRule(before, rule.id).length, after: atFix.length, hits: [...new Set([...besideFix, ...elsewhere])] }];
98
+ }));
99
+ }
100
+ /** The key of the function enclosing a call on a given line of a fixed file (the same key rules are scoped by). */
101
+ function functionKeysAt(dir, files) {
102
+ const keys = new Map();
103
+ return (file, line) => {
104
+ if (!keys.has(file)) {
105
+ const byLine = new Map();
106
+ try {
107
+ forEachNode(openTree(dir, file).sourceFile, node => {
108
+ // Every line a call spans: a finding can point inside the call, not at its first line.
109
+ if (!isInvocation(node))
110
+ return;
111
+ for (let line = lineOf(node); line <= endLineOf(node); line++)
112
+ if (!byLine.has(line))
113
+ byLine.set(line, functionKeyOf(node));
114
+ });
115
+ }
116
+ catch {
117
+ // An unparseable file attributes nothing to the fix.
118
+ }
119
+ keys.set(file, byLine);
120
+ }
121
+ return files.includes(file) ? keys.get(file).get(line) : undefined;
122
+ };
88
123
  }
89
124
  function failure(counts, maxHits) {
90
125
  if (counts.before === 0)
@@ -16,6 +16,8 @@ export interface SemanticFinding {
16
16
  /** cwd-relative posix path of the reported line. */
17
17
  file: string;
18
18
  line: number;
19
+ /** Last line of the code the finding is about (a call), when it spans several lines. */
20
+ endLine?: number;
19
21
  message: string;
20
22
  hint: string;
21
23
  /** Supporting locations, e.g. "http.ts:51 deps.fetch(url, init)". */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
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 { getContextScopeSummary } from './context-telemetry-service.js';
6
+ let root;
7
+ beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-summary-')); });
8
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
9
+ describe('getContextScopeSummary', () => {
10
+ it('counts rule files and skips a rules directory such as .clinerules/', async () => {
11
+ fs.mkdirSync(path.join(root, '.clinerules'));
12
+ fs.writeFileSync(path.join(root, '.clinerules', 'rigour.md'), 'rules');
13
+ fs.writeFileSync(path.join(root, 'CLAUDE.md'), 'Use small PRs.');
14
+ const summary = await getContextScopeSummary(root);
15
+ expect(summary.alwaysOnRuleTokens).toBeGreaterThan(0);
16
+ });
17
+ });
@@ -432,7 +432,8 @@ export async function getContextScopeSummary(cwd) {
432
432
  let alwaysOnRuleTokens = 0;
433
433
  for (const fileName of GOVERNANCE_RULE_FILES) {
434
434
  const filePath = path.join(projectRoot, fileName);
435
- if (await fs.pathExists(filePath)) {
435
+ // .clinerules may be a directory of rule files; only count regular files.
436
+ if ((await fs.stat(filePath).catch(() => null))?.isFile()) {
436
437
  alwaysOnRuleTokens += estimateTokenCount(await fs.readFile(filePath, 'utf8'));
437
438
  }
438
439
  }
@@ -6,6 +6,26 @@ import { randomUUID } from 'crypto';
6
6
  import fs from 'fs-extra';
7
7
  import path from 'path';
8
8
  import { openDatabase, isSQLiteAvailable } from './db.js';
9
+ import { getRepositoryId } from './lessons.js';
10
+ /** The repository a telemetry row belongs to, or null when no working directory was given. */
11
+ async function repositoryOf(cwd) {
12
+ return cwd ? getRepositoryId(cwd) : null;
13
+ }
14
+ /** WHERE clause for a telemetry read: the task when given, and this repository when cwd is given. */
15
+ async function scopedWhere(taskId, cwd) {
16
+ const clauses = [];
17
+ const params = [];
18
+ if (taskId) {
19
+ clauses.push('task_id = ?');
20
+ params.push(taskId);
21
+ }
22
+ const repositoryId = await repositoryOf(cwd);
23
+ if (repositoryId) {
24
+ clauses.push('repository_id = ?');
25
+ params.push(repositoryId);
26
+ }
27
+ return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params };
28
+ }
9
29
  // Fallback JSON persistence path if SQLite is unavailable
10
30
  function getFallbackDir(cwd) {
11
31
  return path.join(cwd || process.cwd(), '.rigour');
@@ -27,8 +47,8 @@ export async function recordContextEvent(event, cwd) {
27
47
  if (db) {
28
48
  await db.run(`INSERT INTO context_events (
29
49
  id, task_id, session_id, agent_id, tool_name, query_hash, cache_status,
30
- candidate_tokens, returned_tokens, deduplicated_tokens, candidate_files, returned_files, created_at
31
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, event.taskId || null, event.sessionId || null, event.agentId || null, event.toolName, event.queryHash || null, event.cacheStatus, candidateTokens, returnedTokens, deduplicatedTokens, candidateFiles, returnedFiles, timestamp);
50
+ candidate_tokens, returned_tokens, deduplicated_tokens, candidate_files, returned_files, created_at, repository_id
51
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, event.taskId || null, event.sessionId || null, event.agentId || null, event.toolName, event.queryHash || null, event.cacheStatus, candidateTokens, returnedTokens, deduplicatedTokens, candidateFiles, returnedFiles, timestamp, await repositoryOf(cwd));
32
52
  await db.close();
33
53
  return id;
34
54
  }
@@ -56,8 +76,8 @@ export async function recordModelUsage(usage, cwd) {
56
76
  if (db) {
57
77
  await db.run(`INSERT INTO model_usage (
58
78
  id, task_id, session_id, agent_id, provider, model,
59
- input_tokens, output_tokens, cached_input_tokens, observed_cost_usd, source, created_at
60
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, usage.taskId || null, usage.sessionId || null, usage.agentId || null, usage.provider || 'cursor', usage.model || 'claude-3-5-sonnet', usage.inputTokens, usage.outputTokens, usage.cachedInputTokens || 0, usage.observedCostUsd || 0, usage.source, timestamp);
79
+ input_tokens, output_tokens, cached_input_tokens, observed_cost_usd, source, created_at, repository_id
80
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, usage.taskId || null, usage.sessionId || null, usage.agentId || null, usage.provider || 'cursor', usage.model || 'claude-3-5-sonnet', usage.inputTokens, usage.outputTokens, usage.cachedInputTokens || 0, usage.observedCostUsd || 0, usage.source, timestamp, await repositoryOf(cwd));
61
81
  await db.close();
62
82
  return id;
63
83
  }
@@ -234,12 +254,12 @@ export async function recordCheckpointMetric(metric, cwd) {
234
254
  const db = await openDatabase();
235
255
  if (db) {
236
256
  await db.run(`INSERT INTO checkpoint_metrics (
237
- checkpoint_id, task_id, agent_id, raw_state_tokens, checkpoint_tokens, replay_tokens_avoided, created_at
238
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
257
+ checkpoint_id, task_id, agent_id, raw_state_tokens, checkpoint_tokens, replay_tokens_avoided, created_at, repository_id
258
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
239
259
  ON CONFLICT(checkpoint_id) DO UPDATE SET
240
260
  raw_state_tokens=excluded.raw_state_tokens,
241
261
  checkpoint_tokens=excluded.checkpoint_tokens,
242
- replay_tokens_avoided=excluded.replay_tokens_avoided`, metric.checkpointId, metric.taskId, metric.agentId, metric.rawStateTokens, metric.checkpointTokens, metric.replayTokensAvoided, timestamp);
262
+ replay_tokens_avoided=excluded.replay_tokens_avoided`, metric.checkpointId, metric.taskId, metric.agentId, metric.rawStateTokens, metric.checkpointTokens, metric.replayTokensAvoided, timestamp, await repositoryOf(cwd));
243
263
  await db.close();
244
264
  return;
245
265
  }
@@ -261,10 +281,8 @@ export async function getContextEvents(taskId, cwd) {
261
281
  try {
262
282
  const db = await openDatabase();
263
283
  if (db) {
264
- const query = taskId
265
- ? `SELECT * FROM context_events WHERE task_id = ? ORDER BY created_at DESC`
266
- : `SELECT * FROM context_events ORDER BY created_at DESC`;
267
- const params = taskId ? [taskId] : [];
284
+ const { where, params } = await scopedWhere(taskId, cwd);
285
+ const query = `SELECT * FROM context_events ${where} ORDER BY created_at DESC`;
268
286
  const rows = await db.all(query, ...params);
269
287
  await db.close();
270
288
  return rows.map((r) => ({
@@ -303,10 +321,8 @@ export async function getModelUsages(taskId, cwd) {
303
321
  try {
304
322
  const db = await openDatabase();
305
323
  if (db) {
306
- const query = taskId
307
- ? `SELECT * FROM model_usage WHERE task_id = ? ORDER BY created_at DESC`
308
- : `SELECT * FROM model_usage ORDER BY created_at DESC`;
309
- const params = taskId ? [taskId] : [];
324
+ const { where, params } = await scopedWhere(taskId, cwd);
325
+ const query = `SELECT * FROM model_usage ${where} ORDER BY created_at DESC`;
310
326
  const rows = await db.all(query, ...params);
311
327
  await db.close();
312
328
  return rows.map((r) => ({
@@ -344,10 +360,8 @@ export async function getCheckpointMetrics(taskId, cwd) {
344
360
  try {
345
361
  const db = await openDatabase();
346
362
  if (db) {
347
- const query = taskId
348
- ? `SELECT * FROM checkpoint_metrics WHERE task_id = ? ORDER BY created_at DESC`
349
- : `SELECT * FROM checkpoint_metrics ORDER BY created_at DESC`;
350
- const params = taskId ? [taskId] : [];
363
+ const { where, params } = await scopedWhere(taskId, cwd);
364
+ const query = `SELECT * FROM checkpoint_metrics ${where} ORDER BY created_at DESC`;
351
365
  const rows = await db.all(query, ...params);
352
366
  await db.close();
353
367
  return rows.map((r) => ({
@@ -333,8 +333,11 @@ export async function openDatabase(dbPath) {
333
333
  });
334
334
  });
335
335
  const db = wrapDatabase(raw);
336
+ // Wait for a lock instead of failing at once: a hook and an MCP server often open the database
337
+ // at the same moment, and without this the second open fails with SQLITE_BUSY.
338
+ await db.exec('PRAGMA busy_timeout = 5000');
336
339
  // WAL mode for better concurrent read performance
337
- await db.exec('PRAGMA journal_mode = WAL');
340
+ await enableWal(db);
338
341
  await db.exec('PRAGMA foreign_keys = ON');
339
342
  // Run schema creation + migrations
340
343
  await db.exec(SCHEMA_SQL);
@@ -498,6 +501,54 @@ async function runMigrations(db) {
498
501
  `);
499
502
  await db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '7')");
500
503
  }
504
+ if (current < 8) {
505
+ // Telemetry rows record their repository, so Studio shows this repository's numbers, not
506
+ // every repository's on the machine. Older rows stay NULL and are left out of per-repo views.
507
+ await addColumnIfMissing(db, 'context_events', 'repository_id', 'ALTER TABLE context_events ADD COLUMN repository_id TEXT');
508
+ await addColumnIfMissing(db, 'model_usage', 'repository_id', 'ALTER TABLE model_usage ADD COLUMN repository_id TEXT');
509
+ await addColumnIfMissing(db, 'checkpoint_metrics', 'repository_id', 'ALTER TABLE checkpoint_metrics ADD COLUMN repository_id TEXT');
510
+ await db.exec(`
511
+ CREATE INDEX IF NOT EXISTS idx_context_events_repo ON context_events(repository_id, created_at);
512
+ CREATE INDEX IF NOT EXISTS idx_model_usage_repo ON model_usage(repository_id, created_at);
513
+ CREATE INDEX IF NOT EXISTS idx_checkpoint_metrics_repo ON checkpoint_metrics(repository_id, created_at);
514
+ `);
515
+ await db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '8')");
516
+ }
517
+ }
518
+ /**
519
+ * Switch to WAL. On a new database, two connections switching at once make the second fail with
520
+ * SQLITE_BUSY at once (busy_timeout does not cover a journal-mode change), so retry briefly; the
521
+ * mode is persistent, so once either succeeds, every later open finds WAL already set.
522
+ */
523
+ async function enableWal(db, attempts = 10) {
524
+ for (let attempt = 1;; attempt++) {
525
+ try {
526
+ await db.exec('PRAGMA journal_mode = WAL');
527
+ return;
528
+ }
529
+ catch (error) {
530
+ if (!/SQLITE_BUSY/.test(String(error)) || attempt >= attempts)
531
+ throw error;
532
+ await new Promise(resolve => setTimeout(resolve, 25 * attempt));
533
+ }
534
+ }
535
+ }
536
+ /**
537
+ * Add a column unless it is there: a hook and an MCP server can open the database at once, and
538
+ * both may run the same migration; a second plain ALTER would fail with "duplicate column".
539
+ */
540
+ async function addColumnIfMissing(db, table, column, alterSql) {
541
+ // The table is a bound parameter and the ALTER a literal written by the caller: nothing is interpolated.
542
+ const columns = await db.all('SELECT name FROM pragma_table_info(?)', table);
543
+ if (columns.some((c) => c.name === column))
544
+ return;
545
+ try {
546
+ await db.exec(alterSql);
547
+ }
548
+ catch (error) {
549
+ if (!/duplicate column/i.test(String(error)))
550
+ throw error;
551
+ }
501
552
  }
502
553
  /**
503
554
  * Compact the database — prune old data, reclaim disk space.
@@ -25,7 +25,7 @@ export async function getFindingsForScan(store, scanId) {
25
25
  * Used by local memory to match known patterns against new scans.
26
26
  */
27
27
  export async function getDeepFindings(store, repo, limit = 50) {
28
- return store.all(`SELECT f.* FROM findings f
28
+ return store.all(`SELECT f.*, s.timestamp AS scan_time FROM findings f
29
29
  JOIN scans s ON f.scan_id = s.id
30
30
  WHERE s.repo = ? AND (f.source = 'llm' OR f.source = 'hybrid' OR f.confidence >= 0.7)
31
31
  ORDER BY f.confidence DESC LIMIT ?`, repo, limit);
@@ -0,0 +1,13 @@
1
+ import type { ResolvedFix } from '../review/agent-fixes.js';
2
+ /** Distinct files a kind of fix must reach before its lesson is validated. */
3
+ export declare const FILES_TO_VALIDATE = 2;
4
+ /** Groups fixes of one kind of defect: every lesson subject starts with it. */
5
+ export declare function fixLessonPrefix(fix: Pick<ResolvedFix, 'rule' | 'title'>): string;
6
+ /**
7
+ * What a lesson says, and what it is embedded and matched by: the kind of defect, then what was
8
+ * wrong in the first fix. The detail is what lets a task match it by meaning (a direct match
9
+ * scored 0.37 with it and 0.05 with the title alone).
10
+ */
11
+ export declare function fixLessonSubject(fix: Pick<ResolvedFix, 'rule' | 'title' | 'details'>): string;
12
+ /** Record resolved fixes as lesson evidence. Returns the ids of lessons created or updated. */
13
+ export declare function recordFixLessons(cwd: string, fixes: ResolvedFix[]): Promise<string[]>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Lessons from fixes: when an agent fixes a finding Rigour reported, that fix
3
+ * becomes evidence for a lesson of kind `fix`, one per kind of defect.
4
+ *
5
+ * A lesson starts as a personal candidate and is validated by evidence alone:
6
+ * fixes of the same kind landing in two different files make it a pattern
7
+ * rather than a one-off (a repeat fix in one file may be the same bug
8
+ * reintroduced). Validated lessons are what team knowledge search serves and
9
+ * what pgvector embeds; a person can still promote one to the whole team.
10
+ */
11
+ import { randomUUID } from 'crypto';
12
+ import { openDatabase } from './db.js';
13
+ import { decryptLocalPayload, encryptLocalPayload } from './local-encryption.js';
14
+ import { getRepositoryId, queueLessonSync, registerRepository, txSafeLesson } from './lessons.js';
15
+ import { loadTeamConfiguration } from './team-store.js';
16
+ /** Distinct files a kind of fix must reach before its lesson is validated. */
17
+ export const FILES_TO_VALIDATE = 2;
18
+ /** Example fixes kept per lesson; older ones still count through `files`. */
19
+ const MAX_EXAMPLES = 5;
20
+ /** Groups fixes of one kind of defect: every lesson subject starts with it. */
21
+ export function fixLessonPrefix(fix) {
22
+ return `Fixed before: ${fix.title?.trim() || fix.rule} (${fix.rule}).`;
23
+ }
24
+ /**
25
+ * What a lesson says, and what it is embedded and matched by: the kind of defect, then what was
26
+ * wrong in the first fix. The detail is what lets a task match it by meaning (a direct match
27
+ * scored 0.37 with it and 0.05 with the title alone).
28
+ */
29
+ export function fixLessonSubject(fix) {
30
+ const detail = fix.details?.trim().split(/(?<=[.!?])\s/)[0]?.slice(0, 240);
31
+ return detail ? `${fixLessonPrefix(fix)} ${detail}` : fixLessonPrefix(fix);
32
+ }
33
+ /** Record resolved fixes as lesson evidence. Returns the ids of lessons created or updated. */
34
+ export async function recordFixLessons(cwd, fixes) {
35
+ if (fixes.length === 0)
36
+ return [];
37
+ const db = await openDatabase();
38
+ if (!db)
39
+ return [];
40
+ try {
41
+ const repositoryId = await getRepositoryId(cwd);
42
+ await registerRepository(db, cwd, repositoryId);
43
+ const config = await loadTeamConfiguration();
44
+ const ids = [];
45
+ for (const fix of fixes)
46
+ ids.push(await recordOne(db, repositoryId, fix, config));
47
+ return [...new Set(ids)];
48
+ }
49
+ finally {
50
+ await db.close();
51
+ }
52
+ }
53
+ async function recordOne(db, repositoryId, fix, config) {
54
+ const subject = fixLessonSubject(fix);
55
+ const now = Date.now();
56
+ const row = await db.get(`SELECT id, evidence_json FROM lessons WHERE repository_id = ? AND kind = 'fix' AND substr(subject, 1, ?) = ?`, repositoryId, fixLessonPrefix(fix).length, fixLessonPrefix(fix));
57
+ const prior = row ? await decryptLocalPayload(row.evidence_json) : { rule: fix.rule, files: [], examples: [] };
58
+ if (prior.examples.some(e => e.fixId === fix.id))
59
+ return row.id;
60
+ const evidence = {
61
+ rule: fix.rule,
62
+ files: [...new Set([...prior.files, fix.file])],
63
+ examples: [{ fixId: fix.id, file: fix.file, details: fix.details?.slice(0, 300), resolvedAt: fix.resolvedAt }, ...prior.examples].slice(0, MAX_EXAMPLES),
64
+ };
65
+ const validated = evidence.files.length >= FILES_TO_VALIDATE;
66
+ const confidence = validated ? 0.8 : 0.4;
67
+ const id = row?.id ?? `lesson-${randomUUID()}`;
68
+ if (row) {
69
+ await db.run(`UPDATE lessons SET state = CASE WHEN state IN ('promoted', 'rejected') THEN state ELSE ? END,
70
+ evidence_json = ?, confidence = MAX(confidence, ?), updated_at = ? WHERE id = ?`, validated ? 'validated' : 'candidate', await encryptLocalPayload(evidence), confidence, now, id);
71
+ }
72
+ else {
73
+ await db.run(`INSERT INTO lessons (
74
+ id, repository_id, actor_id, team_id, visibility, state, kind, subject,
75
+ evidence_json, confidence, source, created_at, updated_at
76
+ ) VALUES (?, ?, ?, ?, 'personal', ?, 'fix', ?, ?, ?, 'agent-fix', ?, ?)`, id, repositoryId, config?.actorId || process.env.RIGOUR_ACTOR_ID || null, config?.teamId || process.env.RIGOUR_TEAM_ID || null, validated ? 'validated' : 'candidate', subject, await encryptLocalPayload(evidence), confidence, now, now);
77
+ }
78
+ if (config) {
79
+ const lesson = await txSafeLesson(db, id);
80
+ if (lesson)
81
+ await queueLessonSync(db, lesson, now);
82
+ }
83
+ return id;
84
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ /** An in-memory lessons table, enough for the reads and writes fix lessons make. */
3
+ const db = vi.hoisted(() => ({ rows: new Map(), reset() { this.rows = new Map(); } }));
4
+ vi.mock('./db.js', () => {
5
+ const store = {
6
+ run: async (sql, ...p) => {
7
+ if (sql.includes('INSERT INTO lessons'))
8
+ db.rows.set(p[0], { id: p[0], state: p[4], subject: p[5], evidence_json: p[6], confidence: p[7] });
9
+ if (sql.includes('UPDATE lessons')) {
10
+ const row = db.rows.get(p[4]);
11
+ if (!['promoted', 'rejected'].includes(row.state))
12
+ row.state = p[0];
13
+ Object.assign(row, { evidence_json: p[1], confidence: Math.max(row.confidence, p[2]) });
14
+ }
15
+ return { changes: 1, lastID: 0 };
16
+ },
17
+ get: async (sql, ...p) => (sql.includes("kind = 'fix'") ? [...db.rows.values()].find(r => r.subject.slice(0, p[1]) === p[2]) : undefined),
18
+ all: async () => [],
19
+ exec: async () => undefined,
20
+ close: async () => undefined,
21
+ };
22
+ return { openDatabase: async () => store };
23
+ });
24
+ vi.mock('./local-encryption.js', () => ({ encryptLocalPayload: async (v) => JSON.stringify(v), decryptLocalPayload: async (v) => JSON.parse(v) }));
25
+ vi.mock('./team-store.js', () => ({ loadTeamConfiguration: async () => null }));
26
+ const { recordFixLessons, fixLessonSubject } = await import('./fix-lessons.js');
27
+ const fix = (id, file) => ({
28
+ id, file, rule: 'semantic-bugs', title: 'Unbounded read without paging', details: 'Rows read without a limit.', before: 'a', after: 'b', resolvedAt: '2026-10-02T00:00:00Z',
29
+ });
30
+ const lessons = () => [...db.rows.values()];
31
+ beforeEach(() => db.reset());
32
+ describe('recordFixLessons', () => {
33
+ it('turns a fix into a candidate lesson named after the defect it removed', async () => {
34
+ await recordFixLessons(process.cwd(), [fix('f1', 'src/orders.ts')]);
35
+ expect(lessons().map(l => [l.subject, l.state])).toEqual([['Fixed before: Unbounded read without paging (semantic-bugs). Rows read without a limit.', 'candidate']]);
36
+ });
37
+ it('validates the lesson once fixes of that kind reach a second file, and counts the same fix once', async () => {
38
+ await recordFixLessons(process.cwd(), [fix('f1', 'src/orders.ts')]);
39
+ await recordFixLessons(process.cwd(), [fix('f1', 'src/orders.ts'), fix('f2', 'src/orders.ts')]);
40
+ expect(lessons()[0].state).toBe('candidate');
41
+ await recordFixLessons(process.cwd(), [fix('f3', 'src/invoices.ts')]);
42
+ expect(lessons()).toHaveLength(1);
43
+ expect(lessons()[0].state).toBe('validated');
44
+ expect(JSON.parse(lessons()[0].evidence_json).files).toEqual(['src/orders.ts', 'src/invoices.ts']);
45
+ });
46
+ it("never overrides a person's decision to promote or reject", async () => {
47
+ await recordFixLessons(process.cwd(), [fix('f1', 'src/orders.ts')]);
48
+ lessons()[0].state = 'rejected';
49
+ await recordFixLessons(process.cwd(), [fix('f2', 'src/invoices.ts')]);
50
+ expect(lessons()[0].state).toBe('rejected');
51
+ });
52
+ it('names a fix whose finding had no title after its rule', () => {
53
+ expect(fixLessonSubject({ rule: 'hallucinated-imports' })).toBe('Fixed before: hallucinated-imports (hallucinated-imports).');
54
+ });
55
+ it('groups fixes of one kind even when their details differ', async () => {
56
+ await recordFixLessons(process.cwd(), [fix('f1', 'src/orders.ts')]);
57
+ await recordFixLessons(process.cwd(), [{ ...fix('f2', 'src/invoices.ts'), details: 'Invoices read in full to count them.' }]);
58
+ expect(lessons()).toHaveLength(1);
59
+ expect(lessons()[0].state).toBe('validated');
60
+ });
61
+ });
@@ -11,7 +11,8 @@ export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './loca
11
11
  export type { ProjectStats } from './local-memory.js';
12
12
  export { recordContextEvent, recordModelUsage, setContextCacheRecord, getContextCacheRecord, listContextCacheRecords, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics } from './context-telemetry.js';
13
13
  export type { ContextEvent, ModelUsage, ContextCacheRecord, CheckpointMetric } from './context-telemetry.js';
14
- export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId } from './lessons.js';
14
+ export { recordFixLessons, fixLessonSubject, fixLessonPrefix, FILES_TO_VALIDATE } from './fix-lessons.js';
15
+ export { recordInteractionEvidence, countInteractionEvidence, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, shareMemoryLesson } from './lessons.js';
15
16
  export type { ApplicableLessons, InteractionEvidence, LessonRecord, LessonState, LessonVisibility } from './lessons.js';
16
17
  export { queueLocalLessonsForTeam } from './team-import.js';
17
18
  export type { LocalLessonImportOptions, LocalLessonImportResult } from './team-import.js';
@@ -8,7 +8,8 @@ export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.
8
8
  export { reinforcePattern, decayPatterns, getStrongPatterns, getPatterns, getHardRules } from './patterns.js';
9
9
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './local-memory.js';
10
10
  export { recordContextEvent, recordModelUsage, setContextCacheRecord, getContextCacheRecord, listContextCacheRecords, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics } from './context-telemetry.js';
11
- export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId } from './lessons.js';
11
+ export { recordFixLessons, fixLessonSubject, fixLessonPrefix, FILES_TO_VALIDATE } from './fix-lessons.js';
12
+ export { recordInteractionEvidence, countInteractionEvidence, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, shareMemoryLesson } from './lessons.js';
12
13
  export { queueLocalLessonsForTeam } from './team-import.js';
13
14
  export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, } from './team-store.js';
14
15
  export { explainTeamConnectionError } from './team-diagnostics.js';
@@ -32,11 +32,22 @@ export interface InteractionEvidence {
32
32
  /** A concise, evidence-backed proposition that could be reused beyond this call. */
33
33
  reusableClaim?: string;
34
34
  }
35
- export declare function shouldValidateInteractionLesson(evidence: InteractionEvidence, verifiedSuccessfulOutcomes?: number): boolean;
36
35
  export declare function recordInteractionEvidence(cwd: string, evidence: InteractionEvidence): Promise<string | null>;
37
36
  export declare function countInteractionEvidence(cwd: string): Promise<number>;
38
37
  export declare function getRepositoryId(cwd: string): Promise<string>;
39
- export declare function recordInteractionLesson(cwd: string, evidence: InteractionEvidence): Promise<string | null>;
38
+ export declare function registerRepository(db: {
39
+ run(sql: string, ...params: unknown[]): Promise<unknown>;
40
+ }, cwd: string, repositoryId: string): Promise<void>;
41
+ /**
42
+ * Share a memory with the team: a lesson of kind `memory` whose subject is the memory itself.
43
+ * It starts as a team candidate, so teammates' agents only receive it once a person promotes it
44
+ * (team knowledge search serves team lessons in the `promoted` state). Returns the lesson id, or
45
+ * null when the local store is unavailable.
46
+ */
47
+ export declare function shareMemoryLesson(cwd: string, key: string, value: string): Promise<string | null>;
48
+ export declare function queueLessonSync(db: {
49
+ run(sql: string, ...params: unknown[]): Promise<unknown>;
50
+ }, lesson: LessonRecord, now: number): Promise<void>;
40
51
  export declare function listLessons(cwd: string, limit?: number): Promise<LessonRecord[]>;
41
52
  /**
42
53
  * Returns the evidence visible to the current workspace for exploration. Cross-repository
@@ -58,3 +69,6 @@ export declare function transitionLesson(id: string, state: LessonState, options
58
69
  visibility?: LessonVisibility;
59
70
  queueSync?: boolean;
60
71
  }): Promise<boolean>;
72
+ export declare function txSafeLesson(db: {
73
+ get(sql: string, ...params: unknown[]): Promise<any>;
74
+ }, id: string): Promise<LessonRecord | null>;