@rigour-labs/core 6.5.3 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index-cache.test.d.ts +1 -0
  3. package/dist/context/automatic-index-cache.test.js +30 -0
  4. package/dist/context/automatic-index.js +0 -4
  5. package/dist/context/cache-engine.d.ts +7 -38
  6. package/dist/context/cache-engine.js +7 -70
  7. package/dist/context/cache-engine.test.js +2 -46
  8. package/dist/context/index-bridge.d.ts +1 -7
  9. package/dist/context/index-bridge.js +1 -79
  10. package/dist/deep/risk.d.ts +6 -0
  11. package/dist/deep/risk.js +7 -1
  12. package/dist/deep/risk.test.js +12 -1
  13. package/dist/firewall/arbitration-token.d.ts +12 -3
  14. package/dist/firewall/arbitration-token.js +30 -23
  15. package/dist/firewall/attestation.d.ts +7 -2
  16. package/dist/firewall/attestation.js +49 -28
  17. package/dist/firewall/execution-receipt.d.ts +5 -0
  18. package/dist/firewall/execution-receipt.js +10 -1
  19. package/dist/firewall/trust-boundaries.test.d.ts +1 -0
  20. package/dist/firewall/trust-boundaries.test.js +64 -0
  21. package/dist/firewall/trusted-control.test.js +10 -0
  22. package/dist/gates/deep-analysis.d.ts +6 -0
  23. package/dist/gates/deep-analysis.js +8 -1
  24. package/dist/gates/trusted-reviews.test.d.ts +1 -0
  25. package/dist/gates/trusted-reviews.test.js +16 -0
  26. package/dist/hooks/checker.js +40 -0
  27. package/dist/hooks/checker.test.js +18 -0
  28. package/dist/hooks/index.d.ts +1 -0
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/session-state.d.ts +8 -0
  31. package/dist/hooks/session-state.js +61 -0
  32. package/dist/hooks/stop-review.d.ts +6 -4
  33. package/dist/hooks/stop-review.js +5 -3
  34. package/dist/hooks/templates.js +1 -1
  35. package/dist/index.d.ts +7 -2
  36. package/dist/index.js +7 -2
  37. package/dist/review/agent-fixes.d.ts +25 -2
  38. package/dist/review/agent-fixes.js +27 -6
  39. package/dist/review/check-outcomes.d.ts +2 -1
  40. package/dist/review/check-outcomes.js +11 -2
  41. package/dist/review/effectiveness.d.ts +13 -0
  42. package/dist/review/git-diff.d.ts +3 -0
  43. package/dist/review/git-diff.js +5 -0
  44. package/dist/review/learning-events.d.ts +14 -0
  45. package/dist/review/learning-events.js +21 -0
  46. package/dist/review/migration-order.js +1 -1
  47. package/dist/review/quiet.d.ts +3 -2
  48. package/dist/review/quiet.js +10 -6
  49. package/dist/review/review.d.ts +4 -0
  50. package/dist/review/review.js +14 -2
  51. package/dist/review/review.test.js +19 -0
  52. package/dist/review/stories.d.ts +25 -0
  53. package/dist/review/stories.js +75 -0
  54. package/dist/review/stories.test.d.ts +1 -0
  55. package/dist/review/stories.test.js +58 -0
  56. package/dist/review/trusted-state.d.ts +6 -0
  57. package/dist/review/trusted-state.js +35 -0
  58. package/dist/storage/cache-cleanup.d.ts +19 -0
  59. package/dist/storage/cache-cleanup.js +90 -0
  60. package/dist/storage/cache-cleanup.test.d.ts +1 -0
  61. package/dist/storage/cache-cleanup.test.js +54 -0
  62. package/dist/storage/db.js +3 -1
  63. package/dist/storage/index.d.ts +1 -0
  64. package/dist/storage/index.js +1 -0
  65. package/dist/storage/local-encryption.js +2 -2
  66. package/dist/storage/team-store.js +2 -2
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/utils/user-state.d.ts +7 -0
  69. package/dist/utils/user-state.js +21 -0
  70. package/package.json +6 -6
package/README.md CHANGED
@@ -46,7 +46,7 @@ Rigour's deep analysis is not a wrapper around a generic LLM. The model operates
46
46
  2. **Interpret** — the model receives structured facts (not raw source), focuses on SOLID, design patterns, language idioms, architecture. Constrained input prevents hallucination.
47
47
  3. **Verify** — every LLM finding is cross-referenced against all five signal streams. Wrong line numbers, phantom patterns, non-existent functions → discarded. Only verified findings with confidence scores reach the report.
48
48
 
49
- The lite and deep tiers are fine-tuned via the [DriftBench RLAIF pipeline](https://github.com/rigour-labs/driftbench), where the five signal streams serve as the teacher signal. The max tier (Qwen2.5-Coder-7B) runs the stock model until a fine-tune passes the evaluation gate.
49
+ Deep review is built for frontier models: your own key, or the coding agent's own model. The small local models (lite and deep tiers fine-tuned via the [DriftBench RLAIF pipeline](https://github.com/rigour-labs/driftbench); max is the stock Qwen2.5-Coder-7B) remain available offline but are no longer developed.
50
50
 
51
51
  ### Temporal Drift Engine (v5.1)
52
52
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ const writes = vi.hoisted(() => ({ count: 0 }));
6
+ vi.mock('../storage/context-telemetry.js', async (importOriginal) => ({
7
+ ...(await importOriginal()),
8
+ setContextCacheRecord: async () => { writes.count++; },
9
+ }));
10
+ const { ensureAutomaticIndex, updateAutomaticIndexForFiles } = await import('./automatic-index.js');
11
+ let cwd;
12
+ beforeEach(() => {
13
+ writes.count = 0;
14
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-index-'));
15
+ fs.mkdirSync(path.join(cwd, 'src'));
16
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'export function alpha() { return 1; }\n');
17
+ fs.writeFileSync(path.join(cwd, 'src/b.ts'), 'export function beta() { return 2; }\n');
18
+ });
19
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
20
+ describe('automatic indexing', () => {
21
+ it('writes nothing to the context cache, however many edits it sees', async () => {
22
+ await ensureAutomaticIndex(cwd, { backgroundSemantic: false });
23
+ for (let edit = 0; edit < 5; edit++) {
24
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), `export function alpha() { return ${edit}; }\n`);
25
+ await updateAutomaticIndexForFiles(cwd, ['src/a.ts']);
26
+ }
27
+ expect(writes.count).toBe(0);
28
+ expect(fs.existsSync(path.join(cwd, '.rigour', 'patterns.json'))).toBe(true);
29
+ });
30
+ });
@@ -3,7 +3,6 @@ import path from 'path';
3
3
  import { PatternIndexer, getDefaultIndexPath, loadPatternIndex, savePatternIndex } from '../pattern-index/indexer.js';
4
4
  import { EMBEDDING_TEXT_VERSION } from '../pattern-index/embeddings.js';
5
5
  import { affectedDependents, buildDependencyGraph, updateDependencyGraph } from './dependency-graph.js';
6
- import { syncIndexToContextCache } from './index-bridge.js';
7
6
  const enrichmentJobs = new Map();
8
7
  function projectRelativeFiles(cwd, files) {
9
8
  const root = path.resolve(cwd);
@@ -78,7 +77,6 @@ async function enrichSemantic(cwd, index) {
78
77
  const merged = mergeSemanticEmbeddings(latest, enriched);
79
78
  const embedded = merged.patterns.filter((pattern) => (pattern.embedding?.length ?? 0) > 0).length;
80
79
  await savePatternIndex(merged, indexPath);
81
- await syncIndexToContextCache(cwd, merged);
82
80
  await writeStatus(cwd, {
83
81
  structural: 'ready',
84
82
  graph: 'ready',
@@ -114,7 +112,6 @@ export async function ensureAutomaticIndex(cwd, options = {}) {
114
112
  const index = existing ?? await indexer.buildIndex();
115
113
  if (!existing)
116
114
  await savePatternIndex(index, indexPath);
117
- await syncIndexToContextCache(cwd, index);
118
115
  if (!(await fs.pathExists(path.join(cwd, '.rigour', 'dependency-graph.json')))) {
119
116
  await buildDependencyGraph(cwd, index);
120
117
  }
@@ -144,7 +141,6 @@ export async function updateAutomaticIndexForFiles(cwd, changedFiles) {
144
141
  const indexer = new PatternIndexer(cwd, { useEmbeddings: false });
145
142
  const index = await indexer.updateFiles(existing, affectedFiles);
146
143
  await savePatternIndex(index, getDefaultIndexPath(cwd));
147
- await syncIndexToContextCache(cwd, index);
148
144
  if (graph)
149
145
  await updateDependencyGraph(cwd, index, graph, affectedFiles);
150
146
  else
@@ -1,32 +1,11 @@
1
1
  /**
2
- * 4-Layer Context Caching Engine for Rigour
2
+ * Context caching for Rigour: the two layers something reads.
3
+ * - Semantic-query cache: the edit and validation scope resolved for a task query.
4
+ * - Task checkpoint cache: compact handoff packets between agents.
3
5
  *
4
- * Implements:
5
- * 1. Content-addressed static cache (AST, exports, imports, dependencies, patterns by content SHA)
6
- * 2. Component context cache (Compact dossiers per component scope)
7
- * 3. Semantic-query cache (Intent embeddings & edit/validation scopes)
8
- * 4. Task checkpoint cache (Subagent handoff packets)
6
+ * The static (per-file) and component layers were removed: nothing read them, and the index sync
7
+ * that wrote them added a fresh row per component on every edit (millions of rows in real use).
9
8
  */
10
- export interface StaticCacheEntry {
11
- astSummary?: any;
12
- exports?: string[];
13
- imports?: string[];
14
- dependencies?: string[];
15
- ownership?: string;
16
- schemas?: any[];
17
- endpoints?: string[];
18
- eventRelationships?: any[];
19
- styleFingerprint?: string;
20
- rigourPatterns?: string[];
21
- }
22
- export interface ComponentDossier {
23
- component: string;
24
- responsibility: string;
25
- canonicalFiles: string[];
26
- contracts: string[];
27
- directConsumers: string[];
28
- validationCommands: string[];
29
- }
30
9
  export interface SemanticQueryEntry {
31
10
  query: string;
32
11
  intentEmbedding?: number[];
@@ -78,17 +57,7 @@ export interface TaskCheckpointPacket {
78
57
  */
79
58
  export declare function hashContent(content: string): string;
80
59
  /**
81
- * Layer 1: Content-Addressed Static Cache
82
- */
83
- export declare function getStaticCache(repo: string, branch: string, filePath: string, fileContent: string, cwd?: string): Promise<StaticCacheEntry | null>;
84
- export declare function setStaticCache(repo: string, branch: string, filePath: string, fileContent: string, entry: StaticCacheEntry, cwd?: string): Promise<void>;
85
- /**
86
- * Layer 2: Component Context Cache
87
- */
88
- export declare function getComponentCache(componentName: string, commitSha: string, profileVersion?: string, cwd?: string): Promise<ComponentDossier | null>;
89
- export declare function setComponentCache(componentName: string, commitSha: string, dossier: ComponentDossier, dependencyFingerprint: string, profileVersion?: string, cwd?: string): Promise<void>;
90
- /**
91
- * Layer 3: Semantic-Query Cache
60
+ * Semantic-Query Cache
92
61
  */
93
62
  export declare function normalizeQuery(query: string): string;
94
63
  export declare function getSemanticQueryCache(query: string, commitSha: string, cwd?: string): Promise<SemanticQueryEntry | null>;
@@ -119,7 +88,7 @@ export declare function filterExistingEditScope(editScope: string[], cwd: string
119
88
  missing: string[];
120
89
  }>;
121
90
  /**
122
- * Layer 4: Task Checkpoint Cache
91
+ * Task Checkpoint Cache
123
92
  */
124
93
  export declare function setTaskCheckpointCache(packet: TaskCheckpointPacket, rawStateTokens: number, cwd?: string): Promise<void>;
125
94
  export declare function getTaskCheckpointCache(taskId: string, agentId: string, phase: string, cwd?: string): Promise<TaskCheckpointPacket | null>;
@@ -1,11 +1,10 @@
1
1
  /**
2
- * 4-Layer Context Caching Engine for Rigour
2
+ * Context caching for Rigour: the two layers something reads.
3
+ * - Semantic-query cache: the edit and validation scope resolved for a task query.
4
+ * - Task checkpoint cache: compact handoff packets between agents.
3
5
  *
4
- * Implements:
5
- * 1. Content-addressed static cache (AST, exports, imports, dependencies, patterns by content SHA)
6
- * 2. Component context cache (Compact dossiers per component scope)
7
- * 3. Semantic-query cache (Intent embeddings & edit/validation scopes)
8
- * 4. Task checkpoint cache (Subagent handoff packets)
6
+ * The static (per-file) and component layers were removed: nothing read them, and the index sync
7
+ * that wrote them added a fresh row per component on every edit (millions of rows in real use).
9
8
  */
10
9
  import { createHash } from 'crypto';
11
10
  import fs from 'fs-extra';
@@ -18,69 +17,7 @@ export function hashContent(content) {
18
17
  return createHash('sha256').update(content).digest('hex').slice(0, 16);
19
18
  }
20
19
  /**
21
- * Layer 1: Content-Addressed Static Cache
22
- */
23
- export async function getStaticCache(repo, branch, filePath, fileContent, cwd) {
24
- const contentSha = hashContent(fileContent);
25
- const cacheKey = `static:${repo}:${branch}:${filePath}:${contentSha}:v1`;
26
- const record = await getContextCacheRecord(cacheKey, cwd);
27
- if (!record)
28
- return null;
29
- try {
30
- return JSON.parse(record.payloadJson);
31
- }
32
- catch {
33
- return null;
34
- }
35
- }
36
- export async function setStaticCache(repo, branch, filePath, fileContent, entry, cwd) {
37
- const contentSha = hashContent(fileContent);
38
- const cacheKey = `static:${repo}:${branch}:${filePath}:${contentSha}:v1`;
39
- const payloadJson = JSON.stringify(entry);
40
- const payloadTokens = Math.ceil(payloadJson.length / 4);
41
- await setContextCacheRecord({
42
- cacheKey,
43
- cacheType: 'static',
44
- repo,
45
- branch,
46
- commitSha: contentSha,
47
- dependencyFingerprint: `sha-${contentSha}`,
48
- payloadJson,
49
- payloadTokens,
50
- }, cwd);
51
- }
52
- /**
53
- * Layer 2: Component Context Cache
54
- */
55
- export async function getComponentCache(componentName, commitSha, profileVersion = '3', cwd) {
56
- const cacheKey = `component:${componentName}:${commitSha}:v${profileVersion}`;
57
- const record = await getContextCacheRecord(cacheKey, cwd);
58
- if (!record)
59
- return null;
60
- try {
61
- return JSON.parse(record.payloadJson);
62
- }
63
- catch {
64
- return null;
65
- }
66
- }
67
- export async function setComponentCache(componentName, commitSha, dossier, dependencyFingerprint, profileVersion = '3', cwd) {
68
- const cacheKey = `component:${componentName}:${commitSha}:v${profileVersion}`;
69
- const payloadJson = JSON.stringify(dossier);
70
- const payloadTokens = Math.ceil(payloadJson.length / 4);
71
- await setContextCacheRecord({
72
- cacheKey,
73
- cacheType: 'component',
74
- repo: dossier.component,
75
- branch: 'main',
76
- commitSha,
77
- dependencyFingerprint,
78
- payloadJson,
79
- payloadTokens,
80
- }, cwd);
81
- }
82
- /**
83
- * Layer 3: Semantic-Query Cache
20
+ * Semantic-Query Cache
84
21
  */
85
22
  export function normalizeQuery(query) {
86
23
  return query.toLowerCase().trim().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ');
@@ -193,7 +130,7 @@ export async function filterExistingEditScope(editScope, cwd) {
193
130
  return { valid, missing };
194
131
  }
195
132
  /**
196
- * Layer 4: Task Checkpoint Cache
133
+ * Task Checkpoint Cache
197
134
  */
198
135
  export async function setTaskCheckpointCache(packet, rawStateTokens, cwd) {
199
136
  const cacheKey = `checkpoint:${packet.taskId}:${packet.agentId}:${packet.phase}`;
@@ -8,8 +8,8 @@ vi.mock('./db.js', () => ({
8
8
  DB_PATH: '/tmp/rigour-test.db',
9
9
  RIGOUR_DIR: '/tmp/.rigour',
10
10
  }));
11
- import { getStaticCache, setStaticCache, getComponentCache, setComponentCache, getSemanticQueryCache, setSemanticQueryCache, findRelatedSemanticQueryCache, queryTokenOverlap, distinctiveTokenAgreement, filterExistingEditScope, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
12
- describe('4-Layer Context Cache Engine', () => {
11
+ import { getSemanticQueryCache, setSemanticQueryCache, findRelatedSemanticQueryCache, queryTokenOverlap, distinctiveTokenAgreement, filterExistingEditScope, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
12
+ describe('Context cache engine', () => {
13
13
  const testCwd = path.join(os.tmpdir(), `rigour-cache-test-${Date.now()}`);
14
14
  beforeEach(async () => {
15
15
  await fs.ensureDir(testCwd);
@@ -26,35 +26,6 @@ describe('4-Layer Context Cache Engine', () => {
26
26
  expect(normalizeQuery(' Add priority to task!! ')).toBe('add priority to task');
27
27
  expect(normalizeQuery('Task SHOULD Support Priority.')).toBe('task should support priority');
28
28
  });
29
- it('Layer 1: should store and retrieve content-addressed static cache', async () => {
30
- const fileContent = 'export function processTask() { return true; }';
31
- const entry = {
32
- exports: ['processTask'],
33
- imports: [],
34
- ownership: 'platform-team',
35
- endpoints: ['/api/task']
36
- };
37
- await setStaticCache('rigour-repo', 'main', 'services/task.ts', fileContent, entry, testCwd);
38
- const cached = await getStaticCache('rigour-repo', 'main', 'services/task.ts', fileContent, testCwd);
39
- expect(cached).not.toBeNull();
40
- expect(cached?.exports).toEqual(['processTask']);
41
- expect(cached?.ownership).toBe('platform-team');
42
- });
43
- it('Layer 2: should store and retrieve component context dossier', async () => {
44
- const dossier = {
45
- component: 'services/task',
46
- responsibility: 'Task lifecycle and persistence',
47
- canonicalFiles: ['services/task.ts'],
48
- contracts: ['TaskContract'],
49
- directConsumers: ['controllers/task'],
50
- validationCommands: ['npm test']
51
- };
52
- await setComponentCache('services/task', 'abc123commit', dossier, 'dep-fingerprint-v1', '3', testCwd);
53
- const retrieved = await getComponentCache('services/task', 'abc123commit', '3', testCwd);
54
- expect(retrieved).not.toBeNull();
55
- expect(retrieved?.responsibility).toBe('Task lifecycle and persistence');
56
- expect(retrieved?.canonicalFiles).toContain('services/task.ts');
57
- });
58
29
  it('Layer 3: should store and retrieve semantic query scope', async () => {
59
30
  const entry = {
60
31
  query: 'task priority persistence',
@@ -125,19 +96,4 @@ describe('4-Layer Context Cache Engine', () => {
125
96
  expect(result.valid).toEqual(['alive.ts']);
126
97
  expect(result.missing).toEqual(['gone.ts']);
127
98
  });
128
- it('Layer 1: invalidates static cache when file content changes', async () => {
129
- const originalContent = 'export function processTask() { return true; }';
130
- const entry = {
131
- exports: ['processTask'],
132
- imports: [],
133
- ownership: 'platform-team',
134
- endpoints: ['/api/task'],
135
- };
136
- await setStaticCache('rigour-repo', 'main', 'services/task.ts', originalContent, entry, testCwd);
137
- const cached = await getStaticCache('rigour-repo', 'main', 'services/task.ts', originalContent, testCwd);
138
- expect(cached?.exports).toEqual(['processTask']);
139
- const changedContent = 'export function processTask() { return false; }';
140
- const stale = await getStaticCache('rigour-repo', 'main', 'services/task.ts', changedContent, testCwd);
141
- expect(stale).toBeNull();
142
- });
143
99
  });
@@ -1,8 +1,6 @@
1
1
  /**
2
- * Bridge between Pattern Index and 4-Layer Context Cache.
3
- * Syncs indexed patterns into static cache entries and component dossiers.
2
+ * Health of the pattern index (.rigour/patterns.json): present, readable, fresh.
4
3
  */
5
- import type { PatternIndex } from '../pattern-index/types.js';
6
4
  export interface IndexHealthReport {
7
5
  indexPath: string;
8
6
  exists: boolean;
@@ -14,10 +12,6 @@ export interface IndexHealthReport {
14
12
  totalFiles?: number;
15
13
  message: string;
16
14
  }
17
- /**
18
- * Sync pattern index entries into static cache and component dossier layers.
19
- */
20
- export declare function syncIndexToContextCache(cwd: string, index: PatternIndex): Promise<number>;
21
15
  /**
22
16
  * Check patterns.json freshness relative to the project directory.
23
17
  */
@@ -1,90 +1,12 @@
1
1
  /**
2
- * Bridge between Pattern Index and 4-Layer Context Cache.
3
- * Syncs indexed patterns into static cache entries and component dossiers.
2
+ * Health of the pattern index (.rigour/patterns.json): present, readable, fresh.
4
3
  */
5
4
  import fs from 'fs-extra';
6
5
  import path from 'path';
7
- import { setStaticCache, setComponentCache, hashContent } from './cache-engine.js';
8
- import { getRepositoryId } from '../storage/lessons.js';
9
6
  function getIndexPath(cwd) {
10
7
  return path.join(cwd, '.rigour', 'patterns.json');
11
8
  }
12
9
  const DEFAULT_STALE_MS = 24 * 60 * 60 * 1000;
13
- async function resolveGitBranch(cwd) {
14
- try {
15
- let gitDir = path.join(cwd, '.git');
16
- const gitStat = await fs.stat(gitDir);
17
- if (gitStat.isFile()) {
18
- const pointer = await fs.readFile(gitDir, 'utf8');
19
- const match = pointer.match(/^gitdir:\s*(.+)$/m);
20
- if (!match)
21
- return 'workspace';
22
- gitDir = path.resolve(cwd, match[1].trim());
23
- }
24
- const head = (await fs.readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
25
- return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : head.slice(0, 12);
26
- }
27
- catch {
28
- return 'workspace';
29
- }
30
- }
31
- function groupByComponent(patterns) {
32
- const groups = new Map();
33
- for (const p of patterns) {
34
- const key = p.type === 'component' ? p.name : p.category || 'uncategorized';
35
- const list = groups.get(key) ?? [];
36
- list.push(p);
37
- groups.set(key, list);
38
- }
39
- return groups;
40
- }
41
- function buildDossier(component, patterns) {
42
- const canonicalFiles = [...new Set(patterns.map(p => p.file))];
43
- const contracts = patterns
44
- .filter(p => p.signature)
45
- .map(p => p.signature)
46
- .slice(0, 20);
47
- const descriptions = patterns.map(p => p.description).filter(Boolean);
48
- const responsibility = descriptions[0] || `Indexed ${patterns.length} patterns for ${component}`;
49
- return {
50
- component,
51
- responsibility,
52
- canonicalFiles,
53
- contracts,
54
- directConsumers: [],
55
- validationCommands: [],
56
- };
57
- }
58
- /**
59
- * Sync pattern index entries into static cache and component dossier layers.
60
- */
61
- export async function syncIndexToContextCache(cwd, index) {
62
- const resolved = path.resolve(cwd);
63
- const repo = await getRepositoryId(resolved);
64
- const branch = await resolveGitBranch(resolved);
65
- const commitSha = hashContent(index.lastUpdated);
66
- let synced = 0;
67
- for (const file of index.files) {
68
- const filePatterns = index.patterns.filter(p => p.file === file.path);
69
- if (filePatterns.length === 0)
70
- continue;
71
- const payload = {
72
- rigourPatterns: filePatterns.map(p => `${p.type}:${p.name}`),
73
- exports: filePatterns.filter(p => p.exported).map(p => p.name),
74
- };
75
- const fileContent = JSON.stringify(payload);
76
- await setStaticCache(repo, branch, file.path, fileContent, payload, resolved);
77
- synced++;
78
- }
79
- const componentGroups = groupByComponent(index.patterns);
80
- for (const [component, patterns] of componentGroups) {
81
- const dossier = buildDossier(component, patterns);
82
- const fingerprint = hashContent(JSON.stringify(patterns.map(p => p.id)));
83
- await setComponentCache(component, commitSha, dossier, fingerprint, '3', resolved);
84
- synced++;
85
- }
86
- return synced;
87
- }
88
10
  /**
89
11
  * Check patterns.json freshness relative to the project directory.
90
12
  */
@@ -38,4 +38,10 @@ export declare function findFunction(cwd: string, file: string, name: string): {
38
38
  start: number;
39
39
  end: number;
40
40
  } | undefined;
41
+ /**
42
+ * What a review record is bound to: the function's exact source. Collapsing whitespace once let
43
+ * `"a b"` and `"a b"` share a hash, so a string changed after review still counted as reviewed.
44
+ * Only line endings are normalised (a Windows checkout is the same code); any other change,
45
+ * formatting included, asks for a new review.
46
+ */
41
47
  export declare function functionHash(text: string): string;
package/dist/deep/risk.js CHANGED
@@ -92,8 +92,14 @@ export function findFunction(cwd, file, name) {
92
92
  visit(sourceFile);
93
93
  return found;
94
94
  }
95
+ /**
96
+ * What a review record is bound to: the function's exact source. Collapsing whitespace once let
97
+ * `"a b"` and `"a b"` share a hash, so a string changed after review still counted as reviewed.
98
+ * Only line endings are normalised (a Windows checkout is the same code); any other change,
99
+ * formatting included, asks for a new review.
100
+ */
95
101
  export function functionHash(text) {
96
- return crypto.createHash('sha256').update(text.replace(/\s+/g, ' ').trim()).digest('hex');
102
+ return crypto.createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex');
97
103
  }
98
104
  const GUARD = /\b(?:if|else|return|throw|break|continue|catch)\b|\?\?|\?\./g;
99
105
  function guards(lines) {
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
- import { rankChangedFunctions, scoreRisk } from './risk.js';
5
+ import { functionHash, rankChangedFunctions, scoreRisk } from './risk.js';
6
6
  import { routeFiles } from './router.js';
7
7
  let dir;
8
8
  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-')); });
@@ -61,3 +61,14 @@ describe('routeFiles', () => {
61
61
  expect(stale.stats).toMatchObject({ routed: 1, already_reviewed: 0 });
62
62
  });
63
63
  });
64
+ describe('functionHash', () => {
65
+ it('changes when anything inside the function changes, strings included', () => {
66
+ const a = 'function f() { return "a b"; }';
67
+ const b = 'function f() { return "a b"; }';
68
+ expect(functionHash(a)).not.toBe(functionHash(b));
69
+ expect(functionHash('function f() { return `x ${y}`; }')).not.toBe(functionHash('function f() { return `x ${y}`; }'));
70
+ });
71
+ it('treats a Windows checkout of the same code as the same code', () => {
72
+ expect(functionHash('function f() {\r\n return 1;\r\n}')).toBe(functionHash('function f() {\n return 1;\n}'));
73
+ });
74
+ });
@@ -1,8 +1,17 @@
1
1
  /**
2
- * One-time arbitration tokens — bind Studio approve/reject to the requesting MCP process.
2
+ * One-time arbitration tokens — bind a Studio approve/reject to the MCP process that asked.
3
+ *
4
+ * The token lives only in the requesting process's memory and in a store outside the workspace
5
+ * (never in .rigour/ or the event log, which agents write). Studio consumes it once and signs the
6
+ * decision with it; the requester accepts only a decision whose signature verifies, so appending
7
+ * a decision line to .rigour/events.jsonl approves nothing.
3
8
  */
9
+ export type ArbitrationDecision = 'approve' | 'reject';
4
10
  export declare function issueArbitrationToken(cwd: string, requestId: string, ttlMs?: number): Promise<string>;
5
11
  /**
6
- * Consume a one-time token. Returns false if missing, expired, or mismatched.
12
+ * Studio side: consume the request's token once and sign the human's decision with it.
13
+ * Null when the request is unknown, expired or already decided.
7
14
  */
8
- export declare function consumeArbitrationToken(cwd: string, requestId: string, token: string | undefined): Promise<boolean>;
15
+ export declare function signArbitrationDecision(cwd: string, requestId: string, decision: ArbitrationDecision): Promise<string | null>;
16
+ /** Requester side: true only for a decision signed with the token this process issued. */
17
+ export declare function verifyArbitrationDecision(token: string, requestId: string, decision: unknown, proof: unknown): boolean;
@@ -1,26 +1,29 @@
1
1
  /**
2
- * One-time arbitration tokens — bind Studio approve/reject to the requesting MCP process.
2
+ * One-time arbitration tokens — bind a Studio approve/reject to the MCP process that asked.
3
+ *
4
+ * The token lives only in the requesting process's memory and in a store outside the workspace
5
+ * (never in .rigour/ or the event log, which agents write). Studio consumes it once and signs the
6
+ * decision with it; the requester accepts only a decision whose signature verifies, so appending
7
+ * a decision line to .rigour/events.jsonl approves nothing.
3
8
  */
4
- import { randomBytes, timingSafeEqual } from 'crypto';
9
+ import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
5
10
  import fs from 'fs-extra';
6
11
  import path from 'path';
12
+ import { repoStateDir } from '../utils/user-state.js';
7
13
  function storePath(cwd) {
8
- return path.join(cwd, '.rigour', 'arbitration-tokens.json');
14
+ return path.join(repoStateDir(cwd), 'arbitration-tokens.json');
9
15
  }
10
16
  async function readStore(cwd) {
11
- const p = storePath(cwd);
12
- if (!(await fs.pathExists(p)))
13
- return {};
14
17
  try {
15
- return await fs.readJson(p);
18
+ return await fs.readJson(storePath(cwd));
16
19
  }
17
20
  catch {
18
21
  return {};
19
22
  }
20
23
  }
21
24
  async function writeStore(cwd, store) {
22
- await fs.ensureDir(path.join(cwd, '.rigour'));
23
- await fs.writeJson(storePath(cwd), store, { spaces: 2 });
25
+ await fs.ensureDir(path.dirname(storePath(cwd)), { mode: 0o700 });
26
+ await fs.writeJson(storePath(cwd), store, { spaces: 2, mode: 0o600 });
24
27
  }
25
28
  export async function issueArbitrationToken(cwd, requestId, ttlMs = 60_000) {
26
29
  const token = randomBytes(24).toString('hex');
@@ -34,25 +37,29 @@ export async function issueArbitrationToken(cwd, requestId, ttlMs = 60_000) {
34
37
  await writeStore(cwd, store);
35
38
  return token;
36
39
  }
40
+ function decisionProof(token, requestId, decision) {
41
+ return createHmac('sha256', token).update(`${requestId}:${decision}`).digest('hex');
42
+ }
37
43
  /**
38
- * Consume a one-time token. Returns false if missing, expired, or mismatched.
44
+ * Studio side: consume the request's token once and sign the human's decision with it.
45
+ * Null when the request is unknown, expired or already decided.
39
46
  */
40
- export async function consumeArbitrationToken(cwd, requestId, token) {
41
- if (!token || !requestId)
42
- return false;
47
+ export async function signArbitrationDecision(cwd, requestId, decision) {
48
+ if (!requestId)
49
+ return null;
43
50
  const store = await readStore(cwd);
44
51
  const entry = store[requestId];
45
52
  if (!entry)
46
- return false;
47
- if (Date.now() > entry.expiresAt) {
48
- delete store[requestId];
49
- await writeStore(cwd, store);
50
- return false;
51
- }
52
- const a = Buffer.from(entry.token);
53
- const b = Buffer.from(token);
54
- const ok = a.length === b.length && timingSafeEqual(a, b);
53
+ return null;
55
54
  delete store[requestId];
56
55
  await writeStore(cwd, store);
57
- return ok;
56
+ return Date.now() > entry.expiresAt ? null : decisionProof(entry.token, requestId, decision);
57
+ }
58
+ /** Requester side: true only for a decision signed with the token this process issued. */
59
+ export function verifyArbitrationDecision(token, requestId, decision, proof) {
60
+ if ((decision !== 'approve' && decision !== 'reject') || typeof proof !== 'string')
61
+ return false;
62
+ const expected = Buffer.from(decisionProof(token, requestId, decision));
63
+ const given = Buffer.from(proof);
64
+ return expected.length === given.length && timingSafeEqual(expected, given);
58
65
  }
@@ -30,9 +30,14 @@ export declare function createAttestation(cwd: string, input: {
30
30
  export declare function verifyAttestation(cwd: string, bundle: AttestationBundle): Promise<boolean>;
31
31
  export declare function loadLatestAttestation(cwd: string): Promise<AttestationBundle | null>;
32
32
  /**
33
- * CI admission: valid signature, PASS gates, bound commit/tree, fresh, non-workspace-key unless allowed.
33
+ * CI admission. Admits only a bundle that is signed with a key outside the workspace, records PASS
34
+ * gates, is fresh, is bound to this checkout (commit or tree, and at least one must be present and
35
+ * match), whose artifact digest recomputes from the files here, and, when the caller names the
36
+ * policy it requires, was produced under that policy.
34
37
  */
35
- export declare function admitForCi(cwd: string): Promise<{
38
+ export declare function admitForCi(cwd: string, options?: {
39
+ policyHash?: string;
40
+ }): Promise<{
36
41
  admit: boolean;
37
42
  reason: string;
38
43
  }>;