@rigour-labs/core 6.5.2 → 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 (110) 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/gates/dedupe-failures.test.d.ts +1 -0
  7. package/dist/gates/dedupe-failures.test.js +12 -0
  8. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  9. package/dist/gates/deprecated-dependencies.js +104 -0
  10. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  11. package/dist/gates/deprecated-dependencies.test.js +55 -0
  12. package/dist/gates/runner.d.ts +7 -1
  13. package/dist/gates/runner.js +25 -11
  14. package/dist/gates/security-patterns-data.js +2 -1
  15. package/dist/gates/security-patterns-owasp.test.js +15 -0
  16. package/dist/gates/semantic-bugs.js +1 -1
  17. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  18. package/dist/gates/unindexed-reads/coverage.js +24 -0
  19. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  20. package/dist/gates/unindexed-reads/index.js +98 -0
  21. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  22. package/dist/gates/unindexed-reads/queries.js +147 -0
  23. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  24. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  25. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  26. package/dist/gates/unindexed-reads/schema.js +256 -0
  27. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  28. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  29. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  30. package/dist/gates/unindexed-reads/sql.js +131 -0
  31. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  32. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  33. package/dist/index.d.ts +4 -1
  34. package/dist/index.js +4 -1
  35. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  36. package/dist/inference/brain-placeholder.test.js +28 -0
  37. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  38. package/dist/inference/llama-engine-checksum.test.js +27 -0
  39. package/dist/inference/llama-engine.d.ts +4 -1
  40. package/dist/inference/llama-engine.js +15 -8
  41. package/dist/memory/recall.d.ts +37 -0
  42. package/dist/memory/recall.js +93 -0
  43. package/dist/memory/recall.test.d.ts +1 -0
  44. package/dist/memory/recall.test.js +36 -0
  45. package/dist/pattern-index/assess.d.ts +16 -0
  46. package/dist/pattern-index/assess.js +37 -0
  47. package/dist/pattern-index/embeddings.d.ts +19 -0
  48. package/dist/pattern-index/embeddings.js +16 -0
  49. package/dist/pattern-index/index.d.ts +3 -2
  50. package/dist/pattern-index/index.js +4 -2
  51. package/dist/pattern-index/indexer.js +15 -7
  52. package/dist/pattern-index/matcher.d.ts +6 -0
  53. package/dist/pattern-index/matcher.js +13 -3
  54. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  55. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  56. package/dist/pattern-index/staleness.d.ts +2 -15
  57. package/dist/pattern-index/staleness.js +1 -78
  58. package/dist/pattern-index/types.d.ts +2 -0
  59. package/dist/review/agent-fixes.d.ts +4 -0
  60. package/dist/review/agent-fixes.js +10 -7
  61. package/dist/review/agent-fixes.test.js +4 -4
  62. package/dist/review/changed-lines.d.ts +2 -1
  63. package/dist/review/changed-lines.js +12 -2
  64. package/dist/review/check-outcomes.d.ts +31 -0
  65. package/dist/review/check-outcomes.js +81 -0
  66. package/dist/review/check-outcomes.test.d.ts +1 -0
  67. package/dist/review/check-outcomes.test.js +56 -0
  68. package/dist/review/migration-order.d.ts +5 -0
  69. package/dist/review/migration-order.js +70 -0
  70. package/dist/review/migration-order.test.d.ts +1 -0
  71. package/dist/review/migration-order.test.js +62 -0
  72. package/dist/review/quiet.d.ts +2 -0
  73. package/dist/review/quiet.js +12 -3
  74. package/dist/review/review.d.ts +2 -0
  75. package/dist/review/review.js +7 -2
  76. package/dist/review/review.test.js +11 -0
  77. package/dist/semantic/ast.d.ts +1 -0
  78. package/dist/semantic/ast.js +4 -0
  79. package/dist/semantic/learn/compile.js +3 -1
  80. package/dist/semantic/learn/learn.js +40 -5
  81. package/dist/semantic/types.d.ts +2 -0
  82. package/dist/services/context-scope-summary.test.d.ts +1 -0
  83. package/dist/services/context-scope-summary.test.js +17 -0
  84. package/dist/services/context-telemetry-service.js +2 -1
  85. package/dist/storage/context-telemetry.js +33 -19
  86. package/dist/storage/db.js +52 -1
  87. package/dist/storage/findings.js +1 -1
  88. package/dist/storage/fix-lessons.d.ts +13 -0
  89. package/dist/storage/fix-lessons.js +84 -0
  90. package/dist/storage/fix-lessons.test.d.ts +1 -0
  91. package/dist/storage/fix-lessons.test.js +61 -0
  92. package/dist/storage/index.d.ts +2 -1
  93. package/dist/storage/index.js +2 -1
  94. package/dist/storage/lessons.d.ts +16 -2
  95. package/dist/storage/lessons.js +27 -61
  96. package/dist/storage/lessons.test.js +1 -15
  97. package/dist/storage/local-memory.js +39 -5
  98. package/dist/storage/local-memory.test.d.ts +1 -0
  99. package/dist/storage/local-memory.test.js +55 -0
  100. package/dist/storage/patterns.d.ts +1 -1
  101. package/dist/storage/patterns.js +1 -1
  102. package/dist/storage/share-memory.test.d.ts +1 -0
  103. package/dist/storage/share-memory.test.js +34 -0
  104. package/dist/storage/team-vector-store.js +2 -1
  105. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  106. package/dist/storage/telemetry-scope.test.js +38 -0
  107. package/dist/templates/universal-config.js +11 -0
  108. package/dist/types/index.d.ts +132 -0
  109. package/dist/types/index.js +15 -0
  110. package/package.json +7 -7
@@ -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>;
@@ -5,11 +5,6 @@ import { openDatabase } from './db.js';
5
5
  import { encryptLocalPayload } from './local-encryption.js';
6
6
  import { lessonRowToRecord as rowToLesson } from './lesson-record.js';
7
7
  import { loadTeamConfiguration } from './team-store.js';
8
- export function shouldValidateInteractionLesson(evidence, verifiedSuccessfulOutcomes = 0) {
9
- return evidence.outcome === 'success'
10
- && Boolean(evidence.reusableClaim?.trim())
11
- && (evidence.deterministic === true || verifiedSuccessfulOutcomes >= 3);
12
- }
13
8
  export async function recordInteractionEvidence(cwd, evidence) {
14
9
  const db = await openDatabase();
15
10
  if (!db)
@@ -77,7 +72,7 @@ export async function getRepositoryId(cwd) {
77
72
  }
78
73
  return createHash('sha256').update(identity).digest('hex');
79
74
  }
80
- async function registerRepository(db, cwd, repositoryId) {
75
+ export async function registerRepository(db, cwd, repositoryId) {
81
76
  let canonicalUri = path.resolve(cwd);
82
77
  try {
83
78
  const config = await readGitConfig(cwd);
@@ -93,65 +88,34 @@ async function registerRepository(db, cwd, repositoryId) {
93
88
  ON CONFLICT(id) DO UPDATE SET canonical_uri=excluded.canonical_uri,
94
89
  display_name=excluded.display_name, last_seen=excluded.last_seen`, repositoryId, canonicalUri, remoteName || path.basename(cwd), Date.now());
95
90
  }
96
- export async function recordInteractionLesson(cwd, evidence) {
91
+ /**
92
+ * Share a memory with the team: a lesson of kind `memory` whose subject is the memory itself.
93
+ * It starts as a team candidate, so teammates' agents only receive it once a person promotes it
94
+ * (team knowledge search serves team lessons in the `promoted` state). Returns the lesson id, or
95
+ * null when the local store is unavailable.
96
+ */
97
+ export async function shareMemoryLesson(cwd, key, value) {
97
98
  const db = await openDatabase();
98
99
  if (!db)
99
100
  return null;
100
101
  try {
101
102
  const repositoryId = await getRepositoryId(cwd);
102
103
  await registerRepository(db, cwd, repositoryId);
103
- const id = `lesson-${randomUUID()}`;
104
- const now = Date.now();
105
- const verifiedSuccess = shouldValidateInteractionLesson(evidence, evidence.verifiedOutcome ? 3 : 0);
106
- const confidence = verifiedSuccess ? 0.8 : 0.3;
107
- const state = confidence >= 0.8 ? 'validated' : 'candidate';
108
104
  const config = await loadTeamConfiguration();
109
- const actorId = config?.actorId || process.env.RIGOUR_ACTOR_ID || null;
110
- const priorRow = await db.get(`SELECT * FROM lessons
111
- WHERE repository_id = ? AND actor_id IS ? AND visibility = 'personal'
112
- AND state = 'candidate' AND kind = 'interaction' AND subject = ?
113
- ORDER BY updated_at DESC LIMIT 1`, repositoryId, actorId, evidence.reusableClaim?.trim() || `interaction:${evidence.tool}`);
114
- if (priorRow) {
115
- const prior = await rowToLesson(priorRow);
116
- const previousSuccesses = Number(prior.evidence.successfulOutcomes ?? 0);
117
- const successfulOutcomes = previousSuccesses + (evidence.outcome === 'success' ? 1 : 0);
118
- const previousVerified = Number(prior.evidence.verifiedSuccessfulOutcomes ?? 0);
119
- const verifiedSuccessfulOutcomes = previousVerified + (evidence.verifiedOutcome && evidence.outcome === 'success' ? 1 : 0);
120
- const repeatedValidation = shouldValidateInteractionLesson(evidence, verifiedSuccessfulOutcomes);
121
- const mergedEvidence = {
122
- ...evidence,
123
- observationCount: Number(prior.evidence.observationCount ?? 1) + 1,
124
- successfulOutcomes,
125
- verifiedSuccessfulOutcomes,
126
- previousRequestId: prior.evidence.requestId,
127
- };
128
- const updated = {
129
- ...prior,
130
- state: repeatedValidation ? 'validated' : 'candidate',
131
- evidence: mergedEvidence,
132
- confidence: repeatedValidation ? 0.8 : Math.min(0.7, prior.confidence + 0.15),
133
- updatedAt: now,
134
- };
135
- await db.run('UPDATE lessons SET state = ?, evidence_json = ?, confidence = ?, updated_at = ? WHERE id = ?', updated.state, await encryptLocalPayload(updated.evidence), updated.confidence, updated.updatedAt, updated.id);
136
- if (config)
137
- await queueLessonSync(db, updated, now);
138
- return prior.id;
139
- }
140
- const storedEvidence = {
141
- ...evidence,
142
- observationCount: 1,
143
- successfulOutcomes: evidence.outcome === 'success' ? 1 : 0,
144
- verifiedSuccessfulOutcomes: evidence.verifiedOutcome && evidence.outcome === 'success' ? 1 : 0,
145
- };
146
- const encryptedEvidence = await encryptLocalPayload(storedEvidence);
105
+ const now = Date.now();
106
+ const subject = `${key}: ${value}`;
107
+ const existing = await db.get(`SELECT id FROM lessons WHERE repository_id = ? AND kind = 'memory' AND visibility = 'team' AND subject = ?`, repositoryId, subject);
108
+ if (existing)
109
+ return String(existing.id);
110
+ const id = `lesson-${randomUUID()}`;
147
111
  await db.run(`INSERT INTO lessons (
148
112
  id, repository_id, actor_id, team_id, visibility, state, kind, subject,
149
113
  evidence_json, confidence, source, created_at, updated_at
150
- ) VALUES (?, ?, ?, ?, 'personal', ?, 'interaction', ?, ?, ?, 'mcp', ?, ?)`, id, repositoryId, actorId, config?.teamId || process.env.RIGOUR_TEAM_ID || null, state, evidence.reusableClaim?.trim() || `interaction:${evidence.tool}`, encryptedEvidence, confidence, now, now);
114
+ ) VALUES (?, ?, ?, ?, 'team', 'candidate', 'memory', ?, ?, 0.5, 'rigour_remember', ?, ?)`, id, repositoryId, config?.actorId || process.env.RIGOUR_ACTOR_ID || null, config?.teamId || process.env.RIGOUR_TEAM_ID || null, subject, await encryptLocalPayload({ key, value, sharedAt: now }), now, now);
151
115
  if (config) {
152
- const personal = await txSafeLesson(db, id);
153
- if (personal)
154
- await queueLessonSync(db, personal, now);
116
+ const lesson = await txSafeLesson(db, id);
117
+ if (lesson)
118
+ await queueLessonSync(db, lesson, now);
155
119
  }
156
120
  return id;
157
121
  }
@@ -159,7 +123,7 @@ export async function recordInteractionLesson(cwd, evidence) {
159
123
  await db.close();
160
124
  }
161
125
  }
162
- async function queueLessonSync(db, lesson, now) {
126
+ export async function queueLessonSync(db, lesson, now) {
163
127
  await db.run(`INSERT INTO sync_outbox (id, operation, entity_type, entity_id, payload_json, created_at)
164
128
  VALUES (?, 'upsert', 'lesson', ?, ?, ?)`, `outbox-${randomUUID()}`, lesson.id, await encryptLocalPayload(lesson), now);
165
129
  }
@@ -178,7 +142,7 @@ export async function listLessons(cwd, limit = 100) {
178
142
  await registerRepository(db, cwd, repositoryId);
179
143
  const rows = await db.all(`SELECT lessons.*, repositories.display_name AS repository_name
180
144
  FROM lessons LEFT JOIN repositories ON repositories.id = lessons.repository_id
181
- WHERE repository_id = ?
145
+ WHERE repository_id = ? AND kind != 'interaction'
182
146
  ORDER BY updated_at DESC LIMIT ?`, repositoryId, limit);
183
147
  return await Promise.all(rows.map(rowToLesson));
184
148
  }
@@ -207,14 +171,16 @@ export async function listKnowledgeLessons(cwd, limit = 500) {
207
171
  const rows = config
208
172
  ? await db.all(`SELECT lessons.*, repositories.display_name AS repository_name
209
173
  FROM lessons LEFT JOIN repositories ON repositories.id = lessons.repository_id
210
- WHERE lessons.repository_id = ?
174
+ WHERE lessons.kind != 'interaction'
175
+ AND (lessons.repository_id = ?
211
176
  OR (lessons.visibility = 'personal' AND lessons.actor_id = ?)
212
- OR (lessons.visibility = 'team' AND lessons.team_id = ? AND lessons.state = 'promoted')
177
+ OR (lessons.visibility = 'team' AND lessons.team_id = ? AND lessons.state = 'promoted'))
213
178
  ORDER BY lessons.updated_at DESC LIMIT ?`, repositoryId, config.actorId, config.teamId, limit)
214
179
  : await db.all(`SELECT lessons.*, repositories.display_name AS repository_name
215
180
  FROM lessons LEFT JOIN repositories ON repositories.id = lessons.repository_id
216
- WHERE lessons.repository_id = ?
217
- OR (lessons.visibility = 'personal' AND lessons.actor_id IS NULL)
181
+ WHERE lessons.kind != 'interaction'
182
+ AND (lessons.repository_id = ?
183
+ OR (lessons.visibility = 'personal' AND lessons.actor_id IS NULL))
218
184
  ORDER BY lessons.updated_at DESC LIMIT ?`, repositoryId, limit);
219
185
  return await Promise.all(rows.map(rowToLesson));
220
186
  }
@@ -298,7 +264,7 @@ export async function transitionLesson(id, state, options = {}) {
298
264
  await db.close();
299
265
  }
300
266
  }
301
- async function txSafeLesson(db, id) {
267
+ export async function txSafeLesson(db, id) {
302
268
  const row = await db.get('SELECT * FROM lessons WHERE id = ?', id);
303
269
  return row ? rowToLesson(row) : null;
304
270
  }
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
5
  import { openDatabase } from './db.js';
6
6
  import { decryptLocalPayload, encryptLocalPayload } from './local-encryption.js';
7
- import { getRepositoryId, shouldValidateInteractionLesson } from './lessons.js';
7
+ import { getRepositoryId } from './lessons.js';
8
8
  import { queueLocalLessonsForTeam } from './team-import.js';
9
9
  const roots = [];
10
10
  afterEach(async () => {
@@ -40,20 +40,6 @@ describe('getRepositoryId', () => {
40
40
  expect(await getRepositoryId(worktree)).toHaveLength(64);
41
41
  });
42
42
  });
43
- describe('interaction lesson validation', () => {
44
- const evidence = { tool: 'rigour_context_scope', outcome: 'success', requestId: 'req-1' };
45
- it('keeps repeated unverified interaction as evidence only', () => {
46
- expect(shouldValidateInteractionLesson(evidence, 0)).toBe(false);
47
- expect(shouldValidateInteractionLesson(evidence, 2)).toBe(false);
48
- });
49
- it('validates deterministic or repeatedly verified outcomes', () => {
50
- expect(shouldValidateInteractionLesson({ ...evidence, deterministic: true, reusableClaim: 'Use scoped retrieval before edits.' }, 0)).toBe(true);
51
- expect(shouldValidateInteractionLesson({ ...evidence, verifiedOutcome: true, reusableClaim: 'Run the focused test suite.' }, 3)).toBe(true);
52
- });
53
- it('does not turn a successful tool call into reusable knowledge', () => {
54
- expect(shouldValidateInteractionLesson({ ...evidence, deterministic: true }, 3)).toBe(false);
55
- });
56
- });
57
43
  describe('local lesson team import', () => {
58
44
  it('dry-runs, assigns legacy ownership, and queues each lesson version once', async () => {
59
45
  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-team-import-'));