@rigour-labs/core 6.4.3 → 6.5.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 (123) hide show
  1. package/dist/deep/agent-review.d.ts +19 -0
  2. package/dist/deep/agent-review.js +19 -0
  3. package/dist/deep/agent-review.test.d.ts +1 -0
  4. package/dist/deep/agent-review.test.js +53 -0
  5. package/dist/deep/code-pass.d.ts +10 -0
  6. package/dist/deep/code-pass.js +64 -33
  7. package/dist/deep/code-pass.test.js +53 -12
  8. package/dist/deep/code-verifier.d.ts +3 -1
  9. package/dist/deep/code-verifier.js +11 -11
  10. package/dist/deep/code-verifier.test.js +8 -0
  11. package/dist/deep/pr-diff.d.ts +7 -0
  12. package/dist/deep/pr-diff.js +47 -0
  13. package/dist/deep/pr-review.d.ts +33 -0
  14. package/dist/deep/pr-review.js +81 -0
  15. package/dist/deep/pr-review.test.d.ts +1 -0
  16. package/dist/deep/pr-review.test.js +80 -0
  17. package/dist/deep/related-changes.d.ts +13 -0
  18. package/dist/deep/related-changes.js +56 -0
  19. package/dist/deep/related-changes.test.d.ts +1 -0
  20. package/dist/deep/related-changes.test.js +33 -0
  21. package/dist/deep/review-tools.d.ts +16 -0
  22. package/dist/deep/review-tools.js +111 -0
  23. package/dist/deep/review-tools.test.d.ts +1 -0
  24. package/dist/deep/review-tools.test.js +39 -0
  25. package/dist/deep/risk.d.ts +41 -0
  26. package/dist/deep/risk.js +135 -0
  27. package/dist/deep/risk.test.d.ts +1 -0
  28. package/dist/deep/risk.test.js +63 -0
  29. package/dist/deep/router.d.ts +27 -0
  30. package/dist/deep/router.js +15 -0
  31. package/dist/deep/self-check.d.ts +14 -9
  32. package/dist/deep/self-check.js +41 -32
  33. package/dist/deep/tool-loop.d.ts +19 -0
  34. package/dist/deep/tool-loop.js +23 -0
  35. package/dist/gates/deep-analysis.d.ts +27 -0
  36. package/dist/gates/deep-analysis.js +73 -2
  37. package/dist/gates/deep-runner.js +14 -0
  38. package/dist/gates/frontend-secret-exposure.d.ts +2 -0
  39. package/dist/gates/frontend-secret-exposure.js +8 -0
  40. package/dist/gates/frontend-secret-exposure.test.js +14 -0
  41. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +1 -0
  42. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +27 -0
  43. package/dist/gates/hallucinated-imports/framework-modules.d.ts +12 -3
  44. package/dist/gates/hallucinated-imports/framework-modules.js +32 -6
  45. package/dist/gates/hallucinated-imports/index.js +2 -1
  46. package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +1 -0
  47. package/dist/gates/hallucinated-imports/js-resolver-types.test.js +14 -0
  48. package/dist/gates/hallucinated-imports/js-resolver.d.ts +6 -1
  49. package/dist/gates/hallucinated-imports/js-resolver.js +25 -3
  50. package/dist/gates/phantom-apis.js +2 -2
  51. package/dist/gates/phantom-apis.test.js +10 -0
  52. package/dist/gates/promise-safety.js +1 -1
  53. package/dist/gates/test-quality-matchers.js +3 -0
  54. package/dist/gates/test-quality.test.js +13 -0
  55. package/dist/hooks/checker.test.js +9 -0
  56. package/dist/hooks/stop-review.d.ts +2 -0
  57. package/dist/hooks/stop-review.js +19 -3
  58. package/dist/hooks/templates.d.ts +2 -0
  59. package/dist/hooks/templates.js +16 -2
  60. package/dist/hooks/templates.test.d.ts +1 -0
  61. package/dist/hooks/templates.test.js +16 -0
  62. package/dist/index.d.ts +13 -0
  63. package/dist/index.js +13 -0
  64. package/dist/inference/cloud-provider.d.ts +12 -1
  65. package/dist/inference/cloud-provider.js +137 -2
  66. package/dist/inference/cloud-provider.test.d.ts +1 -0
  67. package/dist/inference/cloud-provider.test.js +118 -0
  68. package/dist/inference/pricing.d.ts +15 -0
  69. package/dist/inference/pricing.js +13 -0
  70. package/dist/inference/types.d.ts +42 -0
  71. package/dist/review/changed-function-spans.d.ts +4 -0
  72. package/dist/review/changed-function-spans.js +48 -0
  73. package/dist/review/changed-lines.d.ts +9 -3
  74. package/dist/review/changed-lines.js +19 -2
  75. package/dist/review/deep-runs.d.ts +22 -0
  76. package/dist/review/deep-runs.js +59 -0
  77. package/dist/review/deep-runs.test.d.ts +1 -0
  78. package/dist/review/deep-runs.test.js +23 -0
  79. package/dist/review/generated-files.d.ts +3 -0
  80. package/dist/review/generated-files.js +35 -0
  81. package/dist/review/generated-files.test.d.ts +1 -0
  82. package/dist/review/generated-files.test.js +25 -0
  83. package/dist/review/ledger.d.ts +27 -0
  84. package/dist/review/ledger.js +72 -0
  85. package/dist/review/quiet.d.ts +14 -0
  86. package/dist/review/quiet.js +69 -0
  87. package/dist/review/quiet.test.d.ts +1 -0
  88. package/dist/review/quiet.test.js +27 -0
  89. package/dist/review/review-ack.d.ts +16 -0
  90. package/dist/review/review-ack.js +25 -0
  91. package/dist/review/review-task.d.ts +40 -0
  92. package/dist/review/review-task.js +50 -0
  93. package/dist/review/review-task.test.d.ts +1 -0
  94. package/dist/review/review-task.test.js +80 -0
  95. package/dist/review/review.d.ts +11 -1
  96. package/dist/review/review.js +15 -6
  97. package/dist/review/review.test.js +25 -0
  98. package/dist/review-learning/acted-on.d.ts +25 -0
  99. package/dist/review-learning/acted-on.js +69 -0
  100. package/dist/review-learning/learn-from-reviews.d.ts +34 -0
  101. package/dist/review-learning/learn-from-reviews.js +65 -0
  102. package/dist/review-learning/lessons.d.ts +52 -0
  103. package/dist/review-learning/lessons.js +149 -0
  104. package/dist/review-learning/repo-rules.d.ts +16 -0
  105. package/dist/review-learning/repo-rules.js +84 -0
  106. package/dist/review-learning/repo-rules.test.d.ts +1 -0
  107. package/dist/review-learning/repo-rules.test.js +39 -0
  108. package/dist/review-learning/review-learning.test.d.ts +1 -0
  109. package/dist/review-learning/review-learning.test.js +104 -0
  110. package/dist/review-learning/team-lessons.d.ts +4 -0
  111. package/dist/review-learning/team-lessons.js +24 -0
  112. package/dist/semantic/rules/credential-redirect.js +4 -1
  113. package/dist/services/context-telemetry-service.d.ts +9 -0
  114. package/dist/services/context-telemetry-service.js +4 -0
  115. package/dist/services/observed-savings.d.ts +36 -0
  116. package/dist/services/observed-savings.js +94 -0
  117. package/dist/services/observed-savings.test.d.ts +1 -0
  118. package/dist/services/observed-savings.test.js +37 -0
  119. package/dist/settings.js +4 -1
  120. package/dist/templates/universal-config.js +7 -3
  121. package/dist/types/index.d.ts +276 -10
  122. package/dist/types/index.js +38 -5
  123. package/package.json +6 -6
@@ -13,7 +13,7 @@ import path from 'path';
13
13
  import ts from 'typescript';
14
14
  import { isNodeBuiltin } from '../hallucinated-imports-stdlib.js';
15
15
  import { resolveTsPathTarget } from './ts-path-target.js';
16
- import { isSvelteKitProvided, resolveSvelteKitLib } from './framework-modules.js';
16
+ import { isNuxtProvided, isSvelteKitProvided, resolveSvelteKitLib } from './framework-modules.js';
17
17
  import { loadTsPathConfig } from './tsconfig-paths.js';
18
18
  /**
19
19
  * Check JavaScript/TypeScript imports in a source file and add hallucinated findings.
@@ -21,6 +21,8 @@ import { loadTsPathConfig } from './tsconfig-paths.js';
21
21
  export async function checkJSImports(content, file, ctx) {
22
22
  const depsForFile = await resolveJSDepsForFile(file, ctx.cwd, ctx.rootDeps, ctx.depCacheByDir);
23
23
  const kitRoot = await ctx.kitRoots.rootFor(file);
24
+ const nuxtRoot = await ctx.nuxtRoots?.rootFor(file);
25
+ const typeOnly = typeOnlySpecifiers(content, file);
24
26
  for (const spec of collectJSImportSpecs(content, file)) {
25
27
  const { line } = spec;
26
28
  const importPath = bundlerSpecifier(spec.importPath);
@@ -28,9 +30,11 @@ export async function checkJSImports(content, file, ctx) {
28
30
  continue;
29
31
  if (kitRoot && isSvelteKitProvided(importPath))
30
32
  continue;
33
+ if (nuxtRoot && isNuxtProvided(importPath, ctx.extractPackageName(importPath)))
34
+ continue;
31
35
  const reason = importPath.startsWith('.')
32
36
  ? await checkRelativeImport(file, importPath, ctx)
33
- : await checkBareImport(file, importPath, depsForFile, kitRoot, ctx);
37
+ : await checkBareImport(file, importPath, depsForFile, kitRoot, ctx, typeOnly.has(spec.importPath));
34
38
  if (reason) {
35
39
  ctx.hallucinated.push({
36
40
  file, line, importPath,
@@ -40,6 +44,21 @@ export async function checkJSImports(content, file, ctx) {
40
44
  }
41
45
  }
42
46
  }
47
+ /** Specifiers imported only for types: `import type`, `export type … from`, or anything in a .d.ts file. */
48
+ export function typeOnlySpecifiers(content, file) {
49
+ const all = /\.d\.[cm]?ts$/.test(file);
50
+ const specs = new Set();
51
+ const pattern = all
52
+ ? /(?:import|export)[^;]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g
53
+ : /(?:import|export)\s+type\s[^;]*?from\s*['"]([^'"]+)['"]/g;
54
+ for (const match of content.matchAll(pattern))
55
+ specs.add(match[1] ?? match[2]);
56
+ return specs;
57
+ }
58
+ /** `@types/x` for `x`; `@types/scope__x` for `@scope/x`. */
59
+ export function typesPackageFor(pkgName) {
60
+ return pkgName.startsWith('@') ? `@types/${pkgName.slice(1).replace('/', '__')}` : `@types/${pkgName}`;
61
+ }
43
62
  /**
44
63
  * Bundler conventions: a query on a relative import (`./logo.svg?raw`, `./worker.ts?worker`)
45
64
  * names a transform of that file, so the file is what must exist; a `virtual:` module or a
@@ -82,7 +101,7 @@ async function resolveThroughRootDirs(file, importPath, rootDirs, ctx) {
82
101
  return false;
83
102
  }
84
103
  /** Reason the bare import is hallucinated, or null when it resolves. */
85
- async function checkBareImport(file, importPath, depsForFile, kitRoot, ctx) {
104
+ async function checkBareImport(file, importPath, depsForFile, kitRoot, ctx, typeOnly = false) {
86
105
  const aliasResolution = await resolveTsPathAlias(file, importPath, ctx.cwd, ctx.projectFiles, ctx.tsPathCacheByDir);
87
106
  if (aliasResolution === true)
88
107
  return null;
@@ -98,6 +117,9 @@ async function checkBareImport(file, importPath, depsForFile, kitRoot, ctx) {
98
117
  const pkgName = ctx.extractPackageName(importPath);
99
118
  if (isNodeBuiltin(pkgName) || depsForFile.has(pkgName))
100
119
  return null;
120
+ // Types for a package can come from DefinitelyTyped; a type-only import needs nothing at runtime.
121
+ if (typeOnly && depsForFile.has(typesPackageFor(pkgName)))
122
+ return null;
101
123
  if (ctx.hasNodeModules && await fs.pathExists(path.join(ctx.cwd, 'node_modules', pkgName)))
102
124
  return null;
103
125
  return `Package '${pkgName}' not in package.json dependencies`;
@@ -188,7 +188,7 @@ export class PhantomApisGate extends Gate {
188
188
  continue;
189
189
  for (const [alias, moduleName] of moduleAliases) {
190
190
  // Match: alias.methodName( or alias.property.something(
191
- const callPattern = new RegExp(`\\b${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g');
191
+ const callPattern = new RegExp(`(?<![\\w$.])${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g'); // not `obj.path.split(`
192
192
  let match;
193
193
  while ((match = callPattern.exec(line)) !== null) {
194
194
  const method = match[1];
@@ -243,7 +243,7 @@ export class PhantomApisGate extends Gate {
243
243
  for (let i = 0; i < lines.length; i++) {
244
244
  const line = this.stripStringLiterals(lines[i]);
245
245
  for (const [alias, moduleName] of moduleAliases) {
246
- const callPattern = new RegExp(`\\b${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g');
246
+ const callPattern = new RegExp(`(?<![\\w$.])${this.escapeRegex(alias)}\\.(\\w+)\\s*\\(`, 'g'); // not `obj.path.split(`
247
247
  let match;
248
248
  while ((match = callPattern.exec(line)) !== null) {
249
249
  const method = match[1];
@@ -58,6 +58,16 @@ const exists = path.exists('/tmp');
58
58
  expect(failures[0].details).toContain('combine');
59
59
  expect(failures[0].details).toContain('exists');
60
60
  });
61
+ it('should NOT flag a property that shares the module name', async () => {
62
+ mockFindFiles.mockResolvedValue(['src/generate.ts']);
63
+ mockReadFile.mockResolvedValue(`
64
+ import * as path from 'node:path';
65
+ const tag = proc.path.split('.')[0];
66
+ const dir = path.dirname(file);
67
+ `);
68
+ const failures = await gate.run({ cwd: '/project' });
69
+ expect(failures).toHaveLength(0);
70
+ });
61
71
  it('should NOT flag real path methods', async () => {
62
72
  mockFindFiles.mockResolvedValue(['src/paths.ts']);
63
73
  mockReadFile.mockResolvedValue(`
@@ -23,7 +23,7 @@ export class PromiseSafetyGate extends Gate {
23
23
  this.config = {
24
24
  enabled: config.enabled ?? true,
25
25
  check_unhandled_then: config.check_unhandled_then ?? true,
26
- check_unsafe_parse: config.check_unsafe_parse ?? true,
26
+ check_unsafe_parse: config.check_unsafe_parse ?? false,
27
27
  check_async_without_await: config.check_async_without_await ?? true,
28
28
  check_unsafe_fetch: config.check_unsafe_fetch ?? true,
29
29
  ignore_patterns: config.ignore_patterns ?? [],
@@ -10,6 +10,9 @@ export const JS_ASSERTION_PATTERNS = [
10
10
  /expect\s*\(/,
11
11
  /assert\s*[.(]/,
12
12
  /\.toEqual|\.toBe|\.toContain|\.toMatch|\.toThrow|\.toHaveBeenCalled|\.toHaveLength|\.toBeTruthy|\.toBeFalsy|\.toBeDefined|\.toBeNull|\.toBeUndefined|\.toBeGreaterThan|\.toBeLessThan|\.toHaveProperty|\.toStrictEqual|\.rejects|\.resolves/,
13
+ // Type-level tests: the compiler is the assertion.
14
+ /expectTypeOf\s*[<(]|assertType\s*[<(]|@ts-expect-error|\bsatisfies\s+[A-Z]/,
15
+ /^\s*(?:const|let)\s+[\w$]+\s*:\s*[^=]+=/,
13
16
  ];
14
17
  /**
15
18
  * Regex patterns for detecting mocks in JavaScript/TypeScript tests
@@ -47,6 +47,19 @@ describe('API', () => {
47
47
  expect(failures).toHaveLength(1);
48
48
  expect(failures[0].details).toContain('no-assertion');
49
49
  });
50
+ it('should not flag type-level tests, where the compiler is the assertion', async () => {
51
+ mockFindFiles.mockResolvedValue(['src/types.test.ts']);
52
+ mockReadFile.mockResolvedValue(`
53
+ it('our document type accepts the published one', () => {
54
+ const _published: Document = null as unknown as Published.Document;
55
+ });
56
+ it('infers the output', () => {
57
+ expectTypeOf(output).toEqualTypeOf<string>();
58
+ });
59
+ `);
60
+ const failures = await gate.run({ cwd: '/project' });
61
+ expect(failures).toHaveLength(0);
62
+ });
50
63
  it('should flag tautological assertions', async () => {
51
64
  mockFindFiles.mockResolvedValue(['src/basic.test.ts']);
52
65
  mockReadFile.mockResolvedValue(`
@@ -34,6 +34,15 @@ describe('runHookChecker', () => {
34
34
  expect(result.failures).toHaveLength(0);
35
35
  expect(result.duration_ms).toBeGreaterThanOrEqual(0);
36
36
  });
37
+ it('lets an agent edit CLAUDE.md by default, and blocks it only when the team opts in', async () => {
38
+ const filePath = path.join(testDir, 'CLAUDE.md');
39
+ fs.writeFileSync(filePath, '# Rules\n\n- Use the retry wrapper for partner APIs.\n');
40
+ const byDefault = await runHookChecker({ cwd: testDir, files: [filePath] });
41
+ expect(byDefault.failures.filter(f => f.gate === 'governance')).toEqual([]);
42
+ fs.writeFileSync(path.join(testDir, 'rigour.yml'), yaml.stringify({ version: 1, gates: { governance: { enforce_memory: true } } }));
43
+ const optedIn = await runHookChecker({ cwd: testDir, files: [filePath] });
44
+ expect(optedIn.failures.some(f => f.gate === 'governance')).toBe(true);
45
+ });
37
46
  it('should detect file size violations', async () => {
38
47
  const filePath = path.join(testDir, 'big.ts');
39
48
  const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
@@ -9,6 +9,7 @@
9
9
  * an agent back; callers also cap the number of attempts.
10
10
  */
11
11
  import type { Config, Failure } from '../types/index.js';
12
+ import { type ReviewTaskItem } from '../review/review-task.js';
12
13
  export declare const STOP_MAX_ATTEMPTS = 3;
13
14
  export interface StopDecision {
14
15
  block: boolean;
@@ -20,6 +21,7 @@ export interface StopDecision {
20
21
  reviewedFiles: string[];
21
22
  }
22
23
  export declare function stopReview(cwd: string, config: Config, attempt: number): Promise<StopDecision>;
24
+ export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
23
25
  /** Critical, or high and either proven by the semantic engine or a security finding. */
24
26
  export declare function blocksStop(finding: Failure): boolean;
25
27
  export declare function stopMessage(findings: Failure[], attempt: number): string;
@@ -1,13 +1,29 @@
1
1
  import { reviewChange } from '../review/review.js';
2
+ import { buildReviewTask } from '../review/review-task.js';
3
+ import { diffFromGit } from '../review/git-diff.js';
2
4
  export const STOP_MAX_ATTEMPTS = 3;
3
5
  const MAX_LISTED = 8;
4
6
  export async function stopReview(cwd, config, attempt) {
5
- const result = await reviewChange({ cwd, config, source: { mode: 'working' } });
7
+ const diff = diffFromGit(cwd, { mode: 'working' });
8
+ const result = await reviewChange({ cwd, config, diff });
6
9
  const blocking = result.findings.filter(blocksStop);
10
+ const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
7
11
  const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines) };
8
- if (blocking.length === 0)
12
+ if (blocking.length === 0 && unreviewed.length === 0)
9
13
  return { block: false, message: '', blocking: 0, ...reviewed };
10
- return { block: true, message: stopMessage(blocking, attempt), blocking: blocking.length, ...reviewed };
14
+ const message = [
15
+ ...(blocking.length ? [stopMessage(blocking, attempt)] : []),
16
+ ...(unreviewed.length ? [reviewAckMessage(unreviewed, attempt)] : []),
17
+ ].join('\n\n');
18
+ return { block: true, message, blocking: blocking.length + unreviewed.length, ...reviewed };
19
+ }
20
+ export function reviewAckMessage(items, attempt) {
21
+ const listed = items.slice(0, MAX_LISTED).map(i => `- ${i.file}:${i.start} \`${i.function}\`: ${i.questions[0]}`);
22
+ return [
23
+ `${items.length} risky changed function(s) have not been reviewed (attempt ${attempt} of ${STOP_MAX_ATTEMPTS}).`,
24
+ 'Call rigour_review with mode "agent" for the questions, check each function, then rigour_review_ack it:',
25
+ ...listed,
26
+ ].join('\n');
11
27
  }
12
28
  /** Critical, or high and either proven by the semantic engine or a security finding. */
13
29
  export function blocksStop(finding) {
@@ -15,6 +15,8 @@
15
15
  *
16
16
  */
17
17
  import type { HookTool } from './types.js';
18
+ /** The `rigour hooks stop` command matching a `rigour hooks check` command, if the checker is the CLI. */
19
+ export declare function stopCommandFor(checkerCommand: string, tool: 'claude' | 'cursor'): string | undefined;
18
20
  export interface GeneratedHookFile {
19
21
  path: string;
20
22
  content: string;
@@ -14,6 +14,13 @@
14
14
  * Both produce functionally equivalent hook configs for each tool.
15
15
  *
16
16
  */
17
+ import { STOP_MAX_ATTEMPTS } from './stop-review.js';
18
+ /** Seconds Claude Code waits for the stop review before letting the agent stop. */
19
+ const STOP_HOOK_TIMEOUT_S = 120;
20
+ /** The `rigour hooks stop` command matching a `rigour hooks check` command, if the checker is the CLI. */
21
+ export function stopCommandFor(checkerCommand, tool) {
22
+ return /\bhooks check$/.test(checkerCommand.trim()) ? `${checkerCommand.trim().replace(/hooks check$/, 'hooks stop')} --tool ${tool}` : undefined;
23
+ }
17
24
  /**
18
25
  * Generate hook config files for a specific tool.
19
26
  */
@@ -44,7 +51,11 @@ function generateClaudeHooks(checkerCommand) {
44
51
  }
45
52
  ]
46
53
  }
47
- ]
54
+ ],
55
+ // Before the agent finishes: review the uncommitted change, as `rigour hooks init` does.
56
+ ...(stopCommandFor(checkerCommand, 'claude')
57
+ ? { Stop: [{ hooks: [{ type: "command", command: stopCommandFor(checkerCommand, 'claude'), timeout: STOP_HOOK_TIMEOUT_S }] }] }
58
+ : {}),
48
59
  }
49
60
  };
50
61
  return [
@@ -63,7 +74,10 @@ function generateCursorHooks(checkerCommand) {
63
74
  {
64
75
  command: `${checkerCommand} --stdin`,
65
76
  }
66
- ]
77
+ ],
78
+ ...(stopCommandFor(checkerCommand, 'cursor')
79
+ ? { stop: [{ command: stopCommandFor(checkerCommand, 'cursor'), loop_limit: STOP_MAX_ATTEMPTS }] }
80
+ : {}),
67
81
  }
68
82
  };
69
83
  const wrapper = `#!/usr/bin/env node
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateHookFiles, stopCommandFor } from './templates.js';
3
+ const CHECKER = 'npx @rigour-labs/cli@6.5.0 hooks check';
4
+ describe('hook templates', () => {
5
+ it('reviews before the agent stops when the checker is the CLI, like `rigour hooks init`', () => {
6
+ const claude = JSON.parse(generateHookFiles('claude', CHECKER)[0].content);
7
+ expect(claude.hooks.Stop[0].hooks[0]).toEqual({ type: 'command', command: 'npx @rigour-labs/cli@6.5.0 hooks stop --tool claude', timeout: 120 });
8
+ const cursor = JSON.parse(generateHookFiles('cursor', CHECKER).find(f => f.path.endsWith('hooks.json')).content);
9
+ expect(cursor.hooks.stop[0]).toEqual({ command: 'npx @rigour-labs/cli@6.5.0 hooks stop --tool cursor', loop_limit: 3 });
10
+ });
11
+ it('adds no stop hook for a checker that is not the CLI', () => {
12
+ expect(stopCommandFor('node ./my-checker.js', 'claude')).toBeUndefined();
13
+ const claude = JSON.parse(generateHookFiles('claude', 'node ./my-checker.js')[0].content);
14
+ expect(claude.hooks.Stop).toBeUndefined();
15
+ });
16
+ });
package/dist/index.d.ts CHANGED
@@ -13,6 +13,18 @@ export { normalizeScopePatterns, isScoped } from './utils/scope.js';
13
13
  export { deepAnalysisError } from './utils/deep-status.js';
14
14
  export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, type ReviewFinding } from './review/review.js';
15
15
  export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
16
+ export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
17
+ export { isGeneratedFile } from './review/generated-files.js';
18
+ export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/review-task.js';
19
+ export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
20
+ export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
21
+ export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type FunctionRisk, type RiskSignals } from './deep/risk.js';
22
+ export { routeFiles, type RouterPolicy, type RouterStats } from './deep/router.js';
23
+ export { appendDeepRun, readDeepRuns, summarizeDeepRuns, type DeepRun, type DeepRunSummary } from './review/deep-runs.js';
24
+ export { learnFromReviews, type LearnFromReviewsOptions, type LearnFromReviewsResult } from './review-learning/learn-from-reviews.js';
25
+ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, lessonsPath, type ReviewLesson } from './review-learning/lessons.js';
26
+ export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
27
+ export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
16
28
  export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
17
29
  export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
18
30
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
@@ -52,6 +64,7 @@ export * from './context/index-bridge.js';
52
64
  export * from './context/automatic-index.js';
53
65
  export * from './context/dependency-graph.js';
54
66
  export * from './services/context-telemetry-service.js';
67
+ export * from './services/observed-savings.js';
55
68
  export * from './services/system-health.js';
56
69
  export * from './services/agent-history.js';
57
70
  export * from './services/engineering-knowledge-graph.js';
package/dist/index.js CHANGED
@@ -13,6 +13,18 @@ export { normalizeScopePatterns, isScoped } from './utils/scope.js';
13
13
  export { deepAnalysisError } from './utils/deep-status.js';
14
14
  export { reviewChange, toReviewFinding } from './review/review.js';
15
15
  export { splitByChangedLines } from './review/changed-lines.js';
16
+ export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
17
+ export { isGeneratedFile } from './review/generated-files.js';
18
+ export { buildReviewTask } from './review/review-task.js';
19
+ export { acknowledgeReview } from './review/review-ack.js';
20
+ export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
21
+ export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './deep/risk.js';
22
+ export { routeFiles } from './deep/router.js';
23
+ export { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './review/deep-runs.js';
24
+ export { learnFromReviews } from './review-learning/learn-from-reviews.js';
25
+ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
26
+ export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
27
+ export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
16
28
  export { diffFromGit, GitDiffError } from './review/git-diff.js';
17
29
  export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
18
30
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
@@ -51,6 +63,7 @@ export * from './context/index-bridge.js';
51
63
  export * from './context/automatic-index.js';
52
64
  export * from './context/dependency-graph.js';
53
65
  export * from './services/context-telemetry-service.js';
66
+ export * from './services/observed-savings.js';
54
67
  export * from './services/system-health.js';
55
68
  export * from './services/agent-history.js';
56
69
  export * from './services/engineering-knowledge-graph.js';
@@ -12,7 +12,7 @@
12
12
  * User provides: api_key + provider name + optional base_url + optional model_name
13
13
  * We figure out the rest. Their key, their choice.
14
14
  */
15
- import type { InferenceProvider, InferenceOptions } from './types.js';
15
+ import type { ChatMessage, ChatReply, ChatTool, InferenceProvider, InferenceOptions, InferenceUsage } from './types.js';
16
16
  export declare class CloudProvider implements InferenceProvider {
17
17
  readonly name: string;
18
18
  private client;
@@ -21,6 +21,7 @@ export declare class CloudProvider implements InferenceProvider {
21
21
  private baseUrl?;
22
22
  private modelName;
23
23
  private isClaude;
24
+ private spent;
24
25
  constructor(providerName: string, apiKey: string, options?: {
25
26
  baseUrl?: string;
26
27
  modelName?: string;
@@ -28,7 +29,17 @@ export declare class CloudProvider implements InferenceProvider {
28
29
  isAvailable(): Promise<boolean>;
29
30
  setup(onProgress?: (message: string) => void): Promise<void>;
30
31
  analyze(prompt: string, options?: InferenceOptions): Promise<string>;
32
+ /** One turn of a tool-using conversation (Anthropic tool use, or OpenAI-compatible function calling). */
33
+ chat(messages: ChatMessage[], tools: ChatTool[], options?: InferenceOptions): Promise<ChatReply>;
34
+ private chatClaude;
35
+ private chatOpenAICompat;
36
+ /** Tokens used since setup, and their cost: provider-reported where given, else list price. */
37
+ usage(): InferenceUsage;
38
+ private record;
31
39
  private analyzeClaude;
32
40
  private analyzeOpenAICompat;
41
+ /** OpenRouter forwards Anthropic's cache_control to Claude models. */
42
+ private cachesPrompts;
43
+ private isOpenRouter;
33
44
  dispose(): void;
34
45
  }
@@ -1,3 +1,4 @@
1
+ import { priceTokens } from './pricing.js';
1
2
  /** Default models per provider (user can override via model_name) */
2
3
  const DEFAULT_MODELS = {
3
4
  claude: 'claude-opus-4-6',
@@ -34,6 +35,7 @@ export class CloudProvider {
34
35
  baseUrl;
35
36
  modelName;
36
37
  isClaude;
38
+ spent = { inputTokens: 0, outputTokens: 0, reportedCost: 0, unreportedIn: 0, unreportedOut: 0 };
37
39
  constructor(providerName, apiKey, options) {
38
40
  if (!apiKey || apiKey.trim().length === 0) {
39
41
  throw new Error(`API key cannot be empty for provider "${providerName}"`);
@@ -89,6 +91,62 @@ export class CloudProvider {
89
91
  return this.analyzeOpenAICompat(prompt, options);
90
92
  }
91
93
  }
94
+ /** One turn of a tool-using conversation (Anthropic tool use, or OpenAI-compatible function calling). */
95
+ async chat(messages, tools, options) {
96
+ if (!this.client)
97
+ throw new Error('Provider not set up. Call setup() first.');
98
+ return this.isClaude ? this.chatClaude(messages, tools, options) : this.chatOpenAICompat(messages, tools, options);
99
+ }
100
+ async chatClaude(messages, tools, options) {
101
+ const response = await this.client.messages.create({
102
+ model: this.modelName,
103
+ max_tokens: options?.maxTokens || 4096,
104
+ temperature: options?.temperature ?? 0.1,
105
+ messages: cacheFirstPrompt(toAnthropicMessages(messages)),
106
+ ...(tools.length ? { tools: tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })) } : {}),
107
+ ...(tools.length && options?.toolChoice === 'none' ? { tool_choice: { type: 'none' } } : {}),
108
+ }, requestOptions(options));
109
+ this.record(response.usage?.input_tokens, response.usage?.output_tokens);
110
+ const blocks = response.content ?? [];
111
+ return {
112
+ text: blocks.filter(b => b.type === 'text').map(b => b.text).join('\n'),
113
+ toolCalls: blocks.filter(b => b.type === 'tool_use').map(b => ({ id: b.id, name: b.name, arguments: b.input ?? {} })),
114
+ };
115
+ }
116
+ async chatOpenAICompat(messages, tools, options) {
117
+ const response = await this.client.chat.completions.create({
118
+ model: this.modelName,
119
+ max_tokens: options?.maxTokens || 4096,
120
+ temperature: options?.temperature ?? 0.1,
121
+ messages: this.cachesPrompts() ? cacheFirstPrompt(toOpenAIMessages(messages)) : toOpenAIMessages(messages),
122
+ ...(tools.length ? { tools: tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } })) } : {}),
123
+ ...(tools.length && options?.toolChoice === 'none' ? { tool_choice: 'none' } : {}),
124
+ ...(this.isOpenRouter() ? { usage: { include: true } } : {}),
125
+ }, requestOptions(options));
126
+ this.record(response.usage?.prompt_tokens, response.usage?.completion_tokens, response.usage?.cost);
127
+ const message = response.choices[0]?.message ?? {};
128
+ return {
129
+ text: message.content ?? '',
130
+ toolCalls: (message.tool_calls ?? []).map((c) => ({ id: c.id, name: c.function?.name, arguments: parseArguments(c.function?.arguments) })),
131
+ };
132
+ }
133
+ /** Tokens used since setup, and their cost: provider-reported where given, else list price. */
134
+ usage() {
135
+ const { inputTokens, outputTokens, reportedCost, unreportedIn, unreportedOut } = this.spent;
136
+ const priced = unreportedIn + unreportedOut === 0 ? 0 : priceTokens(this.modelName, unreportedIn, unreportedOut);
137
+ return { inputTokens, outputTokens, ...(priced === undefined ? {} : { costUsd: reportedCost + priced }) };
138
+ }
139
+ record(inputTokens = 0, outputTokens = 0, reportedCost) {
140
+ this.spent.inputTokens += inputTokens;
141
+ this.spent.outputTokens += outputTokens;
142
+ if (typeof reportedCost === 'number') {
143
+ this.spent.reportedCost += reportedCost;
144
+ }
145
+ else {
146
+ this.spent.unreportedIn += inputTokens;
147
+ this.spent.unreportedOut += outputTokens;
148
+ }
149
+ }
92
150
  async analyzeClaude(prompt, options) {
93
151
  const response = await this.client.messages.create({
94
152
  model: this.modelName,
@@ -97,7 +155,8 @@ export class CloudProvider {
97
155
  messages: [
98
156
  { role: 'user', content: prompt }
99
157
  ],
100
- });
158
+ }, requestOptions(options));
159
+ this.record(response.usage?.input_tokens, response.usage?.output_tokens);
101
160
  const textBlock = response.content.find((b) => b.type === 'text');
102
161
  if (!textBlock?.text) {
103
162
  throw new Error(`Empty response from ${this.providerName} API (model: ${this.modelName}). Response had ${response.content.length} blocks but no text.`);
@@ -113,14 +172,90 @@ export class CloudProvider {
113
172
  { role: 'user', content: prompt }
114
173
  ],
115
174
  ...(options?.jsonMode ? { response_format: { type: 'json_object' } } : {}),
116
- });
175
+ // OpenRouter reports the call's real cost when asked; other endpoints may reject the field.
176
+ ...(this.isOpenRouter() ? { usage: { include: true } } : {}),
177
+ }, requestOptions(options));
178
+ this.record(response.usage?.prompt_tokens, response.usage?.completion_tokens, response.usage?.cost);
117
179
  const content = response.choices[0]?.message?.content;
118
180
  if (!content) {
119
181
  throw new Error(`Empty response from ${this.providerName} API (model: ${this.modelName}). No content in choices.`);
120
182
  }
121
183
  return content;
122
184
  }
185
+ /** OpenRouter forwards Anthropic's cache_control to Claude models. */
186
+ cachesPrompts() {
187
+ return this.isOpenRouter() && /(^|\/)(anthropic\/|claude)/i.test(this.modelName);
188
+ }
189
+ isOpenRouter() {
190
+ return /(^|\.)openrouter\.ai(\/|$)/.test((this.baseUrl || '').replace(/^https?:\/\//, ''));
191
+ }
123
192
  dispose() {
124
193
  this.client = null;
125
194
  }
126
195
  }
196
+ /** The per-call timeout, honoured by both SDKs (their default is minutes, with retries). */
197
+ function requestOptions(options) {
198
+ return options?.timeout ? { timeout: options.timeout } : {};
199
+ }
200
+ /**
201
+ * Mark the first prompt for caching: a tool conversation resends it every
202
+ * turn, and it is most of the input (the diff and the instructions).
203
+ */
204
+ function cacheFirstPrompt(messages) {
205
+ const [first, ...rest] = messages;
206
+ if (!first || first.role !== 'user' || typeof first.content !== 'string')
207
+ return messages;
208
+ return [{ role: 'user', content: [{ type: 'text', text: first.content, cache_control: { type: 'ephemeral' } }] }, ...rest];
209
+ }
210
+ /** Anthropic wants tool results as user turns, consecutive results grouped into one. */
211
+ function toAnthropicMessages(messages) {
212
+ const out = [];
213
+ for (const message of messages) {
214
+ if (message.role === 'tool') {
215
+ const block = { type: 'tool_result', tool_use_id: message.toolCallId, content: message.content };
216
+ const last = out[out.length - 1];
217
+ if (last?.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result')
218
+ last.content.push(block);
219
+ else
220
+ out.push({ role: 'user', content: [block] });
221
+ }
222
+ else if (message.role === 'assistant') {
223
+ const content = message.content ? [{ type: 'text', text: message.content }] : [];
224
+ for (const call of message.toolCalls ?? [])
225
+ content.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments });
226
+ out.push({ role: 'assistant', content });
227
+ }
228
+ else {
229
+ out.push({ role: 'user', content: message.content });
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+ function toOpenAIMessages(messages) {
235
+ return messages.map((message) => {
236
+ if (message.role === 'tool')
237
+ return { role: 'tool', tool_call_id: message.toolCallId, content: message.content };
238
+ if (message.role === 'assistant') {
239
+ return {
240
+ role: 'assistant',
241
+ content: message.content || null,
242
+ ...(message.toolCalls?.length ? {
243
+ tool_calls: message.toolCalls.map(c => ({ id: c.id, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.arguments) } })),
244
+ } : {}),
245
+ };
246
+ }
247
+ return { role: 'user', content: message.content };
248
+ });
249
+ }
250
+ /** Function-call arguments arrive as a JSON string; a malformed one becomes no arguments. */
251
+ function parseArguments(raw) {
252
+ if (typeof raw !== 'string')
253
+ return {};
254
+ try {
255
+ const value = JSON.parse(raw);
256
+ return value && typeof value === 'object' ? value : {};
257
+ }
258
+ catch {
259
+ return {};
260
+ }
261
+ }
@@ -0,0 +1 @@
1
+ export {};