@rigour-labs/core 6.6.6 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +16 -92
  2. package/dist/environment.test.js +0 -4
  3. package/dist/gates/agent-team.d.ts +0 -2
  4. package/dist/gates/agent-team.js +0 -8
  5. package/dist/gates/checkpoint.d.ts +0 -1
  6. package/dist/gates/checkpoint.js +0 -1
  7. package/dist/gates/context.d.ts +0 -4
  8. package/dist/gates/context.js +7 -17
  9. package/dist/gates/deep-analysis.d.ts +0 -1
  10. package/dist/gates/deep-runner.js +0 -1
  11. package/dist/gates/frontend-secret-exposure.test.js +2 -2
  12. package/dist/gates/hallucinated-imports/index.d.ts +0 -2
  13. package/dist/gates/hallucinated-imports/index.js +2 -2
  14. package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
  15. package/dist/gates/hallucinated-imports.test.js +10 -0
  16. package/dist/gates/runner.js +3 -4
  17. package/dist/gates/test-quality-matchers.d.ts +0 -4
  18. package/dist/gates/test-quality-matchers.js +0 -7
  19. package/dist/gates/test-quality.d.ts +0 -2
  20. package/dist/gates/test-quality.js +1 -10
  21. package/dist/hooks/checker.js +1 -1
  22. package/dist/hooks/index.d.ts +1 -2
  23. package/dist/hooks/index.js +0 -1
  24. package/dist/hooks/types.d.ts +0 -14
  25. package/dist/hooks/types.js +1 -15
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.js +6 -1
  28. package/dist/inference/cloud-provider.d.ts +2 -0
  29. package/dist/inference/cloud-provider.js +16 -6
  30. package/dist/inference/cloud-provider.test.js +21 -0
  31. package/dist/pattern-index/embeddings.d.ts +0 -5
  32. package/dist/pattern-index/embeddings.js +11 -4
  33. package/dist/pattern-index/semantic-runtime.d.ts +10 -0
  34. package/dist/pattern-index/semantic-runtime.js +46 -0
  35. package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
  36. package/dist/pattern-index/semantic-runtime.test.js +31 -0
  37. package/dist/review/backtest-judges.d.ts +38 -0
  38. package/dist/review/backtest-judges.js +63 -0
  39. package/dist/review/backtest-judges.test.d.ts +1 -0
  40. package/dist/review/backtest-judges.test.js +32 -0
  41. package/dist/review/backtest.d.ts +9 -4
  42. package/dist/review/backtest.js +19 -9
  43. package/dist/review/backtest.test.js +2 -2
  44. package/dist/review/baseline.js +2 -1
  45. package/dist/review/dead-code.test.js +7 -0
  46. package/dist/review/fix-scope.d.ts +21 -0
  47. package/dist/review/fix-scope.js +94 -0
  48. package/dist/review/fix-scope.test.d.ts +1 -0
  49. package/dist/review/fix-scope.test.js +104 -0
  50. package/dist/review/lint-overlay.js +1 -1
  51. package/dist/review/orphan-files.js +3 -2
  52. package/dist/review/partial-wiring.js +5 -3
  53. package/dist/review/query-patterns.js +3 -2
  54. package/dist/review/review.js +2 -1
  55. package/dist/review/reviewer/adapters.d.ts +23 -7
  56. package/dist/review/reviewer/adapters.js +32 -7
  57. package/dist/review/reviewer/adapters.test.d.ts +1 -0
  58. package/dist/review/reviewer/adapters.test.js +23 -0
  59. package/dist/review/reviewer/background.d.ts +8 -1
  60. package/dist/review/reviewer/background.js +34 -9
  61. package/dist/review/reviewer/background.test.js +16 -2
  62. package/dist/review/reviewer/consensus.d.ts +36 -0
  63. package/dist/review/reviewer/consensus.js +144 -0
  64. package/dist/review/reviewer/context.d.ts +75 -0
  65. package/dist/review/reviewer/context.js +140 -0
  66. package/dist/review/reviewer/context.test.d.ts +1 -0
  67. package/dist/review/reviewer/context.test.js +18 -0
  68. package/dist/review/reviewer/exec.js +6 -2
  69. package/dist/review/reviewer/exec.test.d.ts +1 -0
  70. package/dist/review/reviewer/exec.test.js +23 -0
  71. package/dist/review/reviewer/inputs.d.ts +2 -0
  72. package/dist/review/reviewer/inputs.js +5 -1
  73. package/dist/review/reviewer/panel.d.ts +67 -0
  74. package/dist/review/reviewer/panel.js +101 -0
  75. package/dist/review/reviewer/panel.test.d.ts +1 -0
  76. package/dist/review/reviewer/panel.test.js +110 -0
  77. package/dist/review/reviewer/prompt.d.ts +11 -0
  78. package/dist/review/reviewer/prompt.js +26 -4
  79. package/dist/review/reviewer/settings.d.ts +58 -0
  80. package/dist/review/reviewer/settings.js +152 -0
  81. package/dist/review/reviewer/settings.test.d.ts +1 -0
  82. package/dist/review/reviewer/settings.test.js +57 -0
  83. package/dist/review/reviewer/store.d.ts +25 -2
  84. package/dist/review/reviewer/store.js +43 -1
  85. package/dist/review/reviewer/usage.d.ts +2 -0
  86. package/dist/review/reviewer/usage.js +28 -0
  87. package/dist/review/reviewer/usage.test.d.ts +1 -0
  88. package/dist/review/reviewer/usage.test.js +14 -0
  89. package/dist/review/reviewer/verdict.d.ts +15 -1
  90. package/dist/review/reviewer/verdict.js +17 -7
  91. package/dist/review/reviewer.d.ts +45 -2
  92. package/dist/review/reviewer.js +186 -25
  93. package/dist/review/reviewer.test.js +127 -12
  94. package/dist/review/test-files.d.ts +5 -1
  95. package/dist/review/test-files.js +6 -2
  96. package/dist/review/toolchain.js +10 -3
  97. package/dist/review/toolchain.test.js +6 -3
  98. package/dist/review/typed/program.d.ts +0 -1
  99. package/dist/review/typed/program.js +3 -3
  100. package/dist/review/typed/redundancy.js +43 -4
  101. package/dist/review/typed/redundancy.test.js +58 -0
  102. package/dist/review/typed/schema-nullability.d.ts +6 -0
  103. package/dist/review/typed/schema-nullability.js +218 -0
  104. package/dist/review/typed/schema-nullability.test.d.ts +1 -0
  105. package/dist/review/typed/schema-nullability.test.js +63 -0
  106. package/dist/review/unused-exports.js +5 -6
  107. package/dist/semantic/learn/types.d.ts +2 -2
  108. package/dist/services/adaptive-thresholds.d.ts +2 -52
  109. package/dist/services/adaptive-thresholds.js +2 -147
  110. package/dist/services/adaptive-thresholds.test.js +1 -81
  111. package/dist/settings.d.ts +14 -6
  112. package/dist/settings.js +0 -12
  113. package/dist/storage/team-diagnostics.js +1 -1
  114. package/dist/storage/team-schema.d.ts +1 -1
  115. package/dist/storage/team-schema.js +2 -0
  116. package/dist/storage/team-store.js +26 -3
  117. package/dist/storage/team-sync-scope.test.js +35 -2
  118. package/dist/telemetry/telemetry.d.ts +5 -0
  119. package/dist/telemetry/telemetry.js +11 -1
  120. package/dist/templates/paradigms.js +0 -8
  121. package/dist/templates/presets.d.ts +0 -1
  122. package/dist/templates/presets.js +0 -23
  123. package/dist/templates/universal-config.js +8 -45
  124. package/dist/types/index.d.ts +108 -566
  125. package/dist/types/index.js +41 -66
  126. package/dist/types/index.test.d.ts +1 -0
  127. package/dist/types/index.test.js +33 -0
  128. package/dist/utils/command-line.d.ts +8 -0
  129. package/dist/utils/command-line.js +8 -0
  130. package/dist/utils/command-line.test.d.ts +1 -0
  131. package/dist/utils/command-line.test.js +8 -0
  132. package/dist/utils/profile.js +1 -1
  133. package/package.json +6 -7
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Two or three judges as a panel, run around the consensus rules (consensus.ts). The judges' own
3
+ * items are grouped into findings; a finding a strict majority raised is confirmed. Every other
4
+ * judge then cross-examines, in one batched call each, only the findings it did not raise: confirm
5
+ * or refute with file:line evidence; no evidence or no answer is unsure. The calls decide by
6
+ * majority; a disputed finding is shown and never blocks. A finding disputed before, on a file no
7
+ * commit has touched since, stays disputed without another call; past the item cap, disputed too.
8
+ *
9
+ * Every finding keeps what a later re-scoring needs (who raised it, every judge's call, the
10
+ * cross-examination answers, each judge's own item and the class), so a probabilistic truth model
11
+ * can be fitted to the ledger later without running anyone again.
12
+ */
13
+ import { clusterItems, MATCH_THRESHOLD, order, similarity, statusOf } from './consensus.js';
14
+ /** The panel's decision on every finding any judge raised. */
15
+ export async function runPanel(input) {
16
+ const n = input.judges.length;
17
+ const clusters = clusterItems(input.judges, input.items);
18
+ const decided = clusters.map(c => {
19
+ const raisers = [...new Set(c.members.map(m => m.judge))];
20
+ const calls = Object.fromEntries(raisers.map(j => [j, 'raised']));
21
+ const item = { ...c.members[0].item, evidence: c.members.map(m => m.item.evidence).find(Boolean) };
22
+ return { item, judges: raisers, calls, status: statusOf(calls, n), ...(c.members.length > 1 ? { members: c.members } : {}) };
23
+ });
24
+ const undecided = decided.filter(d => d.status !== 'confirmed');
25
+ const disputedBefore = (item) => !!item.file && !input.touched.has(item.file) && input.previousDisputed.some(p => similarity(item, p) >= MATCH_THRESHOLD);
26
+ for (const d of undecided.filter(d => disputedBefore(d.item))) {
27
+ for (const judge of input.judges)
28
+ d.calls[judge] ??= 'not asked';
29
+ d.status = 'disputed';
30
+ d.note = 'disputed before; its file is unchanged since';
31
+ }
32
+ const toAsk = undecided.filter(d => !d.note).sort((a, b) => order(a.item, b.item));
33
+ for (const d of toAsk.slice(input.maxItems)) {
34
+ for (const judge of input.judges)
35
+ d.calls[judge] ??= 'not asked';
36
+ d.status = 'disputed';
37
+ d.note = `over review.reviewer.panel_max_items (${input.maxItems})`;
38
+ }
39
+ const asked = toAsk.slice(0, input.maxItems);
40
+ // One batched call per judge, about every asked finding it did not raise; the calls run together.
41
+ await Promise.all(input.judges.map(async (judge) => {
42
+ const mine = asked.filter(d => !d.judges.includes(judge));
43
+ if (!mine.length)
44
+ return;
45
+ const cap = input.capped?.();
46
+ if (cap) {
47
+ for (const d of mine) {
48
+ d.calls[judge] = 'not asked';
49
+ d.note = cap;
50
+ }
51
+ return;
52
+ }
53
+ let answers = [];
54
+ try {
55
+ answers = await input.ask(judge, mine.map(d => d.item));
56
+ }
57
+ catch {
58
+ answers = [];
59
+ }
60
+ for (const d of mine) {
61
+ const answer = answers.find(x => x.id === d.item.id);
62
+ const evidenced = !!answer?.evidence && input.evidenced(answer.evidence);
63
+ d.calls[judge] = answer && evidenced ? answer.call : 'unsure';
64
+ d.cross = [...(d.cross ?? []), { by: judge, call: d.calls[judge], ...(answer?.evidence ? { evidence: answer.evidence } : {}) }];
65
+ }
66
+ }));
67
+ for (const d of asked)
68
+ d.status = statusOf(d.calls, n);
69
+ return decided.sort((x, y) => order(x.item, y.item));
70
+ }
71
+ /** The answers in a judge's cross-examination reply; nothing usable is no answers (every item then disputed). */
72
+ export function parseAnswers(text) {
73
+ const candidates = [text.trim(), ...[...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map(m => m[1].trim()), ...[...text.matchAll(/\{\s*"answers"/g)].map(m => text.slice(m.index))];
74
+ for (const candidate of candidates) {
75
+ for (let end = candidate.lastIndexOf('}'); end > 0; end = candidate.lastIndexOf('}', end - 1)) {
76
+ try {
77
+ const parsed = JSON.parse(candidate.slice(0, end + 1));
78
+ if (!Array.isArray(parsed?.answers))
79
+ continue;
80
+ return parsed.answers.filter((a) => typeof a?.id === 'string' && ['confirm', 'refute', 'unsure'].includes(a.call))
81
+ .map((a) => ({ id: a.id, call: a.call, ...(typeof a.evidence === 'string' ? { evidence: a.evidence } : {}) }));
82
+ }
83
+ catch {
84
+ // not a whole object yet: try a shorter one
85
+ }
86
+ }
87
+ }
88
+ return [];
89
+ }
90
+ /** The accounting with the panel's decision applied: the judges' own items leave, and only what it confirmed blocks. */
91
+ export function applyPanel(accounted, judgeItemIds, decided) {
92
+ const withJudges = (d) => ({ ...d.item, reviewer: d.judges.join('+') });
93
+ const confirmed = decided.filter(d => d.status === 'confirmed').map(withJudges);
94
+ const open = [...accounted.open.filter(item => !judgeItemIds.has(item.id)), ...confirmed.filter(c => !accounted.open.some(o => o.id === c.id && !judgeItemIds.has(o.id)))];
95
+ return {
96
+ ...accounted,
97
+ open,
98
+ disputed: decided.filter(d => d.status === 'disputed').map(withJudges),
99
+ dropped: decided.filter(d => d.status === 'dropped').map(withJudges),
100
+ };
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { clusterItems, similarity, statusOf } from './consensus.js';
3
+ import { applyPanel, parseAnswers, runPanel } from './panel.js';
4
+ let n = 0;
5
+ const item = (file, line, cls, issue, consequence = 'wrong rows') => ({ id: `i${++n}`, kind: 'finding', class: cls, file, line, issue, consequence });
6
+ /** Evidence counts only as a real file:line: here, any src/q.ts line. */
7
+ const evidenced = (text) => /src\/q\.ts:\d+/.test(text);
8
+ const members = (judges, items) => clusterItems(judges, items).map(c => c.members.map(m => `${m.judge}:${m.item.issue}`));
9
+ describe('matching findings across judges', () => {
10
+ it('needs the same file, and shared wording unless it is the same line and class', () => {
11
+ const a = item('src/a.ts', 10, 'correctness', 'window not bounded at both ends', 'scans every past event');
12
+ expect(similarity(a, { ...a, file: 'src/b.ts' })).toBe(0);
13
+ expect(similarity(a, item('src/a.ts', 11, 'correctness', 'dedupe key changes when the user edits', 'duplicate emails go out'))).toBe(0);
14
+ expect(similarity(a, item('src/a.ts', 10, 'correctness', 'something else entirely', 'duplicate emails go out'))).toBeGreaterThanOrEqual(0.5);
15
+ });
16
+ it('keeps two different findings on adjacent lines apart, even listed in a misleading order (first-fit by line would cross them)', () => {
17
+ const judgeA = [item('src/q.ts', 10, 'production-cost', 'time window not bounded at both ends'), item('src/q.ts', 11, 'correctness', 'dedupe key changes when the user edits')];
18
+ const judgeB = [item('src/q.ts', 10, 'correctness', 'dedupe key built from updated_at changes when the user edits'), item('src/q.ts', 11, 'production-cost', 'read window has no upper bound at either end')];
19
+ expect(members(['a', 'b'], [judgeA, judgeB])).toEqual([
20
+ ['a:time window not bounded at both ends', 'b:read window has no upper bound at either end'],
21
+ ['a:dedupe key changes when the user edits', 'b:dedupe key built from updated_at changes when the user edits'],
22
+ ]);
23
+ });
24
+ it('matches one bug two judges cite at different lines', () => {
25
+ const judgeA = [item('src/q.ts', 10, 'correctness', 'lock is checked after the first read of rows')];
26
+ const judgeB = [item('src/q.ts', 40, 'correctness', 'the first read of rows happens before the lock is checked')];
27
+ expect(members(['a', 'b'], [judgeA, judgeB])).toHaveLength(1);
28
+ });
29
+ it('puts what one judge split under the one item another judge wrote', () => {
30
+ const judgeA = [item('src/q.ts', 10, 'production-cost', 'both reads, rows and cards, are unbounded')];
31
+ const judgeB = [item('src/q.ts', 10, 'production-cost', 'rows read is unbounded'), item('src/q.ts', 14, 'production-cost', 'cards read is unbounded')];
32
+ expect(members(['a', 'b'], [judgeA, judgeB])).toEqual([['a:both reads, rows and cards, are unbounded', 'b:rows read is unbounded', 'b:cards read is unbounded']]);
33
+ });
34
+ it('never merges one judge\'s own two findings, however close', () => {
35
+ const judgeA = [item('src/x.ts', 10, 'correctness', 'session.user may be null when the token expired'), item('src/x.ts', 12, 'correctness', 'session.expires compared as a string, not a date')];
36
+ expect(members(['a', 'b'], [judgeA, []])).toEqual([['a:session.user may be null when the token expired'], ['a:session.expires compared as a string, not a date']]);
37
+ });
38
+ it('groups three judges, and is the same every run', () => {
39
+ const items = [
40
+ [item('src/q.ts', 10, 'correctness', 'lock checked after the read')],
41
+ [item('src/q.ts', 11, 'correctness', 'the read happens before the lock is checked'), item('src/r.ts', 3, 'dead-code', 'export unused')],
42
+ [item('src/q.ts', 10, 'correctness', 'lock checked only after the read')],
43
+ ];
44
+ const once = members(['a', 'b', 'c'], items);
45
+ expect(once).toEqual(members(['a', 'b', 'c'], items));
46
+ expect(once.map(c => c.length)).toEqual([3, 1]);
47
+ });
48
+ });
49
+ describe('deciding a finding', () => {
50
+ it('confirms on a strict majority, drops on evidence against with nobody unsure, else disputes', () => {
51
+ expect(statusOf({ a: 'raised', b: 'raised' }, 2)).toBe('confirmed');
52
+ expect(statusOf({ a: 'raised', b: 'confirm' }, 2)).toBe('confirmed');
53
+ expect(statusOf({ a: 'raised', b: 'refute' }, 2)).toBe('dropped');
54
+ expect(statusOf({ a: 'raised', b: 'unsure' }, 2)).toBe('disputed');
55
+ expect(statusOf({ a: 'raised', b: 'raised' }, 3)).toBe('confirmed');
56
+ expect(statusOf({ a: 'raised', b: 'confirm', c: 'refute' }, 3)).toBe('confirmed');
57
+ expect(statusOf({ a: 'raised', b: 'refute', c: 'refute' }, 3)).toBe('dropped');
58
+ expect(statusOf({ a: 'raised', b: 'refute', c: 'unsure' }, 3)).toBe('disputed');
59
+ });
60
+ it('asks each judge once about what it did not raise, needs file:line evidence, and caps what it asks', async () => {
61
+ const asked = {};
62
+ const lone = item('src/q.ts', 10, 'correctness', 'lock checked after the read');
63
+ const refuted = item('src/q.ts', 30, 'correctness', 'the cursor skips the last page');
64
+ const bare = item('src/q.ts', 50, 'correctness', 'an index is missing on created_at');
65
+ const overCap = item('src/z.ts', 1, 'correctness', 'retries forever on 429');
66
+ const answers = { b: [{ id: lone.id, call: 'confirm', evidence: 'src/q.ts:12 reads before lock()' }, { id: refuted.id, call: 'refute', evidence: 'src/q.ts:33 the loop runs once more' }, { id: bare.id, call: 'confirm' }] };
67
+ const decided = await runPanel({
68
+ judges: ['a', 'b'], items: [[lone, refuted, bare, overCap], []], previousDisputed: [], touched: new Set(), maxItems: 3, evidenced,
69
+ ask: async (judge, items) => { asked[judge] = items.map(i => i.issue); return answers[judge] ?? []; },
70
+ });
71
+ expect(asked).toEqual({ b: ['lock checked after the read', 'the cursor skips the last page', 'an index is missing on created_at'] });
72
+ const status = Object.fromEntries(decided.map(d => [d.item.issue, d.status]));
73
+ expect(status).toEqual({ 'lock checked after the read': 'confirmed', 'the cursor skips the last page': 'dropped', 'an index is missing on created_at': 'disputed', 'retries forever on 429': 'disputed' });
74
+ expect(decided.find(d => d.item.id === overCap.id)?.note).toContain('panel_max_items');
75
+ expect(decided.find(d => d.item.id === lone.id)).toMatchObject({ judges: ['a'], calls: { a: 'raised', b: 'confirm' }, cross: [{ by: 'b', call: 'confirm', evidence: 'src/q.ts:12 reads before lock()' }] });
76
+ });
77
+ it('does not ask again about a finding disputed before on a file nothing has touched since', async () => {
78
+ const again = item('src/q.ts', 10, 'correctness', 'lock checked after the read');
79
+ const asked = [];
80
+ const ask = async (_judge, items) => { asked.push(...items.map(i => i.id)); return []; };
81
+ const untouched = await runPanel({ judges: ['a', 'b'], items: [[again], []], previousDisputed: [{ ...again, id: 'old' }], touched: new Set(), maxItems: 20, evidenced, ask });
82
+ expect(untouched[0]).toMatchObject({ status: 'disputed', note: 'disputed before; its file is unchanged since' });
83
+ expect(asked).toEqual([]);
84
+ await runPanel({ judges: ['a', 'b'], items: [[again], []], previousDisputed: [{ ...again, id: 'old' }], touched: new Set(['src/q.ts']), maxItems: 20, evidenced, ask });
85
+ expect(asked).toEqual([again.id]);
86
+ });
87
+ it('a judge that fails to answer leaves the findings disputed, never confirmed', async () => {
88
+ const lone = item('src/q.ts', 10, 'correctness', 'lock checked after the read');
89
+ const decided = await runPanel({ judges: ['a', 'b'], items: [[lone], []], previousDisputed: [], touched: new Set(), maxItems: 20, evidenced, ask: async () => { throw new Error('timed out'); } });
90
+ expect(decided[0]).toMatchObject({ status: 'disputed', calls: { a: 'raised', b: 'unsure' } });
91
+ });
92
+ });
93
+ describe('the panel in the accounting', () => {
94
+ it('blocks only on what it confirmed, and lists the rest', () => {
95
+ const confirmed = item('src/q.ts', 10, 'correctness', 'lock checked after the read');
96
+ const disputed = item('src/q.ts', 30, 'correctness', 'cursor skips a page');
97
+ const human = { ...item('', 0, 'prior point', 'a human point'), kind: 'prior' };
98
+ const result = applyPanel({ open: [confirmed, disputed, human] }, new Set([confirmed.id, disputed.id]), [
99
+ { item: confirmed, judges: ['a', 'b'], calls: { a: 'raised', b: 'raised' }, status: 'confirmed' },
100
+ { item: disputed, judges: ['a'], calls: { a: 'raised', b: 'unsure' }, status: 'disputed' },
101
+ ]);
102
+ expect(result.open.map(i => i.issue)).toEqual(['a human point', 'lock checked after the read']);
103
+ expect(result.open[1].reviewer).toBe('a+b');
104
+ expect(result.disputed.map(i => i.issue)).toEqual(['cursor skips a page']);
105
+ });
106
+ it('reads the answers out of a reply with prose around them, and nothing from a reply without them', () => {
107
+ expect(parseAnswers('Checked.\n```json\n{"answers":[{"id":"x","call":"refute","evidence":"a.ts:3"},{"id":"y","call":"maybe"}]}\n```')).toEqual([{ id: 'x', call: 'refute', evidence: 'a.ts:3' }]);
108
+ expect(parseAnswers('I could not decide.')).toEqual([]);
109
+ });
110
+ });
@@ -11,6 +11,8 @@ export interface PromptInputs {
11
11
  diffstatFile: string;
12
12
  diffFile: string;
13
13
  hintsFile: string;
14
+ /** What the team already knows (reviewer/context.ts). */
15
+ contextFile: string;
14
16
  deltaBlock: string;
15
17
  mergeBlock: string;
16
18
  }
@@ -19,3 +21,12 @@ export declare function deltaBlock(previousHead: string, previousVerdict: string
19
21
  export declare function mergeBlock(base: string, impactFile: string | undefined): string;
20
22
  /** Changes when the instructions change, so a cached verdict from older instructions is not reused. */
21
23
  export declare const PROMPT_VERSION: string;
24
+ /** One judge's single call on the items the other judge raised alone: confirm or refute each, with the code that shows it. */
25
+ export declare function crossExamPrompt(repoRoot: string, head: string, diffFile: string, items: Array<{
26
+ id: string;
27
+ class: string;
28
+ file?: string;
29
+ line?: number;
30
+ issue: string;
31
+ consequence?: string;
32
+ }>): string;
@@ -22,6 +22,8 @@ Inputs:
22
22
  - The pull request description: ${v.prBodyFile}
23
23
  - What changed: ${v.diffstatFile}; the full diff is in ${v.diffFile} (the same as
24
24
  \`git diff ${v.baseSha}...${v.head}\`). Read the files themselves for context.
25
+ - What this team already knows: verified lessons and rules for these files, findings it settled
26
+ (never raise one again unless the code now shows something new), docs about this code: ${v.contextFile}
25
27
  ${v.deltaBlock}${v.mergeBlock}- Deterministic hints already computed (candidates to confirm, never the full list): ${v.hintsFile}
26
28
  - The repository's rules: AGENTS.md (and CLAUDE.md). A violation of a rule there in changed code
27
29
  is a finding.
@@ -85,8 +87,11 @@ Do these steps in order. Report only what you verified in the code, with file:li
85
87
  in the description still true of the code, and the repository's rules.
86
88
 
87
89
  Classes: correctness, production-cost, dead-code, duplication, stale-claim, helper-bypass,
88
- repo-rule. Every finding blocks; do not report style preferences or trade-offs you would not
89
- request changes for.
90
+ repo-rule. Every finding needs a consequence: the wrong outcome it causes (an input and what
91
+ goes wrong) or the cost it adds (reads, calls or memory per what). A finding with no wrong outcome
92
+ and no cost (faster, simpler or cleaner code) is an opinion: leave consequence empty and it is
93
+ shown, never blocking. Do not report style preferences or trade-offs you would not request
94
+ changes for.
90
95
 
91
96
  Your final message must be ONLY this JSON, starting with { and ending with }, nothing before or after it:
92
97
  {"prior_points":[{"point":"...","review":"<login> <submitted_at>","severity":"blocking"|"should-fix"|"non-blocking","resolved":true|false,"evidence":"file:line ...","checked_siblings":["file:line"]}],
@@ -94,7 +99,7 @@ Your final message must be ONLY this JSON, starting with { and ending with }, no
94
99
  "reads":[{"file":"...","line":0,"read":"...","rules":[{"rule":"...","known_before_read":true|false,"applied_before_read":true|false}],"consumer":{"file":"...","line":0,"uses":"ids-only"|"rows"|"aggregate"},"narrower_source":null|"...","keys":[{"name":"...","inputs":"...","stable_under_edit":true|false}],"window_bounded":true|false|null,"keyset":true|false|null,"index":"..."}],
95
100
  "scans":[{"file":"...","line":0,"function":"...","outer":"...","inner":"...","fix":"..."}],
96
101
  "merge_impact":[{"symbol":"...","main_file":"...","call_site":"file:line","holds":true|false,"why":"..."}],
97
- "findings":[{"class":"...","file":"...","line":0,"issue":"...","why":"..."}],
102
+ "findings":[{"class":"...","file":"...","line":0,"issue":"...","why":"...","consequence":"<wrong outcome for an input, or the cost; empty for an opinion>"}],
98
103
  "carried":["<delta mode: ids of previous open items that still stand>"],
99
104
  "resolved_previous":[{"id":"<delta mode: id of a previous open item now fixed>","evidence":"file:line and the fix"}]}`;
100
105
  }
@@ -119,5 +124,22 @@ export function mergeBlock(base, impactFile) {
119
124
  /** Changes when the instructions change, so a cached verdict from older instructions is not reused. */
120
125
  export const PROMPT_VERSION = createHash('sha256').update(renderPrompt({
121
126
  repoRoot: '<repo>', branch: '<branch>', head: '<head>', base: '<base>', baseSha: '<sha>', mode: 'full', reviewsFile: '<r>', humanCount: 0,
122
- prBodyFile: '<b>', diffstatFile: '<s>', diffFile: '<d>', hintsFile: '<h>', deltaBlock: '', mergeBlock: '',
127
+ prBodyFile: '<b>', diffstatFile: '<s>', diffFile: '<d>', hintsFile: '<h>', contextFile: '<c>', deltaBlock: '', mergeBlock: '',
123
128
  })).digest('hex').slice(0, 12);
129
+ /** One judge's single call on the items the other judge raised alone: confirm or refute each, with the code that shows it. */
130
+ export function crossExamPrompt(repoRoot, head, diffFile, items) {
131
+ return `You are one of two independent reviewers of the branch at ${head} in ${repoRoot}. The other
132
+ reviewer raised the items below, and you did not. Judge each one against the code, read-only: open
133
+ the file, follow the callers and callees you need, and use the diff at ${diffFile}.
134
+
135
+ For each item answer "confirm" when the code shows the problem is real, or "refute" when the code
136
+ shows it is not, and quote the file:line that shows it in "evidence". When you cannot tell from
137
+ the code, answer "unsure". An answer without file:line evidence counts as unsure. Do not raise
138
+ new items.
139
+
140
+ Items:
141
+ ${JSON.stringify(items, null, 2)}
142
+
143
+ Your final message must be ONLY this JSON, starting with { and ending with }:
144
+ {"answers":[{"id":"...","call":"confirm"|"refute"|"unsure","evidence":"file:line ..."}]}`;
145
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The reviewer settings a run uses, and where each came from. Four layers, the nearest wins:
3
+ * a flag on this run, an environment variable (hooks and CI take no flags), the person's own
4
+ * settings (`reviewer` in the profile's settings.json, edited by hand or in Studio), and the team's
5
+ * rigour.yml (its defaults when there is none). The team can set a floor no nearer layer goes
6
+ * below: `panel: required` and `mode_required`. A nearer layer that asks for less is refused, and
7
+ * the refusal is reported, never silent. Under a floor, a person also cannot turn reviews off, lower
8
+ * the judges or escalate on risk only. A panel needs two vendors, so `panel` implies mode full,
9
+ * except that a nearer choice of one judge turns off a panel the team only turned on.
10
+ */
11
+ import { type UserReviewerSettings } from '../../settings.js';
12
+ import type { Config } from '../../types/index.js';
13
+ type Mode = 'single' | 'cross' | 'full';
14
+ export type Source = 'flag' | 'env' | 'user' | 'team';
15
+ /** What a single run asks for: `rigour review --reviewer --full | --single | --panel | --no-panel`. */
16
+ export interface RunChoice {
17
+ mode?: Mode;
18
+ panel?: boolean;
19
+ }
20
+ export interface ResolvedReviewer {
21
+ enabled: boolean;
22
+ on_push: 'background' | 'wait' | 'off';
23
+ reviewers: string[];
24
+ models: Record<string, string>;
25
+ model?: string;
26
+ timeout_ms: number;
27
+ mode: Mode;
28
+ panel: boolean;
29
+ panel_max_items: number;
30
+ /** Whether reviewer findings may be dismissed: the team's decision, never a person's. */
31
+ dismissals: boolean;
32
+ /** The daily caps that apply: the lower of the team's and the person's. */
33
+ max_runs_per_day?: number;
34
+ max_usd_per_day?: number;
35
+ judges: 2 | 3;
36
+ escalate: 'always' | 'risk';
37
+ cross_models: Record<string, string>;
38
+ /** Where the mode and the panel choice came from. */
39
+ source: {
40
+ mode: Source;
41
+ panel: Source;
42
+ };
43
+ /** The team's floor: a run that cannot meet it is unavailable, never a quieter review. */
44
+ required: {
45
+ mode: boolean;
46
+ panel: boolean;
47
+ };
48
+ /** Nearer choices that were not applied (the team's floor, an unknown value), one line each, for the report. */
49
+ refused: string[];
50
+ }
51
+ export declare function resolveReviewer(config: Config, choice?: RunChoice, user?: UserReviewerSettings | undefined, env?: NodeJS.ProcessEnv): ResolvedReviewer;
52
+ /** A change to the person's own reviewer settings: a value sets it, `null` goes back to the team's. */
53
+ export type UserReviewerPatch = {
54
+ [K in keyof UserReviewerSettings]?: UserReviewerSettings[K] | null;
55
+ };
56
+ /** Applies a validated patch to the person's settings (settings.json in the profile's home) and returns what is stored. */
57
+ export declare function saveUserReviewer(patch: UserReviewerPatch): UserReviewerSettings;
58
+ export {};
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The reviewer settings a run uses, and where each came from. Four layers, the nearest wins:
3
+ * a flag on this run, an environment variable (hooks and CI take no flags), the person's own
4
+ * settings (`reviewer` in the profile's settings.json, edited by hand or in Studio), and the team's
5
+ * rigour.yml (its defaults when there is none). The team can set a floor no nearer layer goes
6
+ * below: `panel: required` and `mode_required`. A nearer layer that asks for less is refused, and
7
+ * the refusal is reported, never silent. Under a floor, a person also cannot turn reviews off, lower
8
+ * the judges or escalate on risk only. A panel needs two vendors, so `panel` implies mode full,
9
+ * except that a nearer choice of one judge turns off a panel the team only turned on.
10
+ */
11
+ import { loadSettings, saveSettings } from '../../settings.js';
12
+ const RANK = { single: 0, cross: 1, full: 2 };
13
+ /** How near a layer is to this run: the nearer wins. */
14
+ const NEAR = { flag: 0, env: 1, user: 2, team: 3 };
15
+ export function resolveReviewer(config, choice = {}, user = loadSettings().reviewer, env = process.env) {
16
+ const team = config.review?.reviewer ?? { enabled: false, on_push: 'background', reviewers: ['claude'], mode: 'single', models: {}, timeout_ms: 15 * 60_000, panel: 'off', mode_required: false, panel_max_items: 20, dismissals: false, judges: 2, escalate: 'always', cross_models: {} };
17
+ const refused = [];
18
+ const envMode = parseMode(env.RIGOUR_REVIEWER_MODE);
19
+ const envPanel = parseSwitch(env.RIGOUR_REVIEWER_PANEL);
20
+ if (env.RIGOUR_REVIEWER_MODE && !envMode)
21
+ refused.push(`RIGOUR_REVIEWER_MODE=${env.RIGOUR_REVIEWER_MODE} ignored: use single, cross or full`);
22
+ if (env.RIGOUR_REVIEWER_PANEL && envPanel === undefined)
23
+ refused.push(`RIGOUR_REVIEWER_PANEL=${env.RIGOUR_REVIEWER_PANEL} ignored: use on or off`);
24
+ const layers = [
25
+ { source: 'flag', mode: choice.mode, panel: choice.panel },
26
+ { source: 'env', mode: envMode, panel: envPanel },
27
+ { source: 'user', mode: user?.mode, panel: user?.panel },
28
+ ];
29
+ const pick = (key) => layers.find(layer => layer[key] !== undefined);
30
+ const requirePanel = team.panel === 'required';
31
+ let panel = team.panel !== 'off';
32
+ let panelSource = 'team';
33
+ const nearPanel = pick('panel');
34
+ if (nearPanel) {
35
+ if (requirePanel && nearPanel.panel === false)
36
+ refused.push(`panel off (${nearPanel.source}) refused: rigour.yml sets review.reviewer.panel: required`);
37
+ else
38
+ [panel, panelSource] = [nearPanel.panel, nearPanel.source];
39
+ }
40
+ let mode = team.mode;
41
+ let modeSource = 'team';
42
+ const nearMode = pick('mode');
43
+ if (nearMode) {
44
+ if (team.mode_required && RANK[nearMode.mode] < RANK[team.mode])
45
+ refused.push(`mode ${nearMode.mode} (${nearMode.source}) refused: rigour.yml sets review.reviewer.mode_required with mode ${team.mode}`);
46
+ else
47
+ [mode, modeSource] = [nearMode.mode, nearMode.source];
48
+ }
49
+ if (panel && mode !== 'full') {
50
+ // A panel needs two vendors. Asking for fewer judges at a nearer layer than the panel's turns the panel off, unless the team requires it.
51
+ const fewer = nearMode && RANK[nearMode.mode] < RANK.full;
52
+ if (fewer && NEAR[nearMode.source] < NEAR[panelSource] && !requirePanel)
53
+ panel = false;
54
+ else {
55
+ if (fewer)
56
+ refused.push(`mode ${nearMode.mode} (${nearMode.source}) refused: ${requirePanel ? 'rigour.yml sets review.reviewer.panel: required' : `the panel (${panelSource}) needs judges from two vendors`}`);
57
+ [mode, modeSource] = ['full', panelSource];
58
+ }
59
+ }
60
+ const floor = requirePanel || team.mode_required;
61
+ if (floor && team.enabled && user?.enabled === false)
62
+ refused.push('reviews off (user) refused: rigour.yml requires the reviewer');
63
+ if (floor && user?.judges !== undefined && user.judges < team.judges)
64
+ refused.push(`judges ${user.judges} (user) refused: rigour.yml requires ${team.judges}`);
65
+ return {
66
+ enabled: floor && team.enabled ? true : user?.enabled ?? team.enabled,
67
+ on_push: team.on_push,
68
+ reviewers: user?.reviewers?.length ? user.reviewers : team.reviewers,
69
+ models: { ...team.models, ...(user?.models ?? {}) },
70
+ ...(team.model ? { model: team.model } : {}),
71
+ timeout_ms: team.timeout_ms,
72
+ mode,
73
+ panel,
74
+ panel_max_items: team.panel_max_items,
75
+ dismissals: team.dismissals,
76
+ ...cap('max_runs_per_day', team.max_runs_per_day, user?.max_runs_per_day, refused),
77
+ ...cap('max_usd_per_day', team.max_usd_per_day, user?.max_usd_per_day, refused),
78
+ judges: floor ? Math.max(team.judges, user?.judges ?? team.judges) : user?.judges ?? team.judges,
79
+ escalate: requiredEscalation(team, user, refused),
80
+ cross_models: team.cross_models,
81
+ source: { mode: modeSource, panel: panelSource },
82
+ required: { mode: team.mode_required, panel: requirePanel },
83
+ refused,
84
+ };
85
+ }
86
+ /** A cap is the lower of the team's and the person's: a person can spend less than the team allows, never more. */
87
+ function cap(key, team, user, refused) {
88
+ if (user !== undefined && team !== undefined && user > team)
89
+ refused.push(`${key} ${user} (user) refused: rigour.yml caps it at ${team}`);
90
+ const value = team === undefined ? user : user === undefined ? team : Math.min(team, user);
91
+ return value === undefined ? {} : { [key]: value };
92
+ }
93
+ /** A team that requires full or panel review wants it on every review: no user may escalate on risk only. */
94
+ function requiredEscalation(team, user, refused) {
95
+ const required = team.panel === 'required' || team.mode_required;
96
+ if (required && user?.escalate === 'risk')
97
+ refused.push('escalate risk (user) refused: rigour.yml requires every review to be full');
98
+ return required ? 'always' : user?.escalate ?? team.escalate;
99
+ }
100
+ function parseMode(value) {
101
+ return value === 'single' || value === 'cross' || value === 'full' ? value : undefined;
102
+ }
103
+ function parseSwitch(value) {
104
+ if (/^(1|on|true|yes)$/i.test(value ?? ''))
105
+ return true;
106
+ if (/^(0|off|false|no)$/i.test(value ?? ''))
107
+ return false;
108
+ return undefined;
109
+ }
110
+ /** Applies a validated patch to the person's settings (settings.json in the profile's home) and returns what is stored. */
111
+ export function saveUserReviewer(patch) {
112
+ const problem = patchProblem(patch);
113
+ if (problem)
114
+ throw new Error(problem);
115
+ const settings = loadSettings();
116
+ const next = { ...(settings.reviewer ?? {}) };
117
+ for (const [key, value] of Object.entries(patch)) {
118
+ if (value === null)
119
+ delete next[key];
120
+ else if (value !== undefined)
121
+ next[key] = value;
122
+ }
123
+ saveSettings({ ...settings, reviewer: next });
124
+ return next;
125
+ }
126
+ function patchProblem(patch) {
127
+ const known = new Set(['enabled', 'mode', 'panel', 'judges', 'escalate', 'reviewers', 'models', 'max_runs_per_day', 'max_usd_per_day']);
128
+ const unknown = Object.keys(patch).find(key => !known.has(key));
129
+ if (unknown)
130
+ return `not a reviewer setting a person can change: ${unknown}`;
131
+ const ok = (value, test) => value === null || value === undefined || test(value);
132
+ if (!ok(patch.enabled, v => typeof v === 'boolean'))
133
+ return 'enabled is true or false';
134
+ if (!ok(patch.mode, v => v === 'single' || v === 'cross' || v === 'full'))
135
+ return 'mode is single, cross or full';
136
+ if (!ok(patch.panel, v => typeof v === 'boolean'))
137
+ return 'panel is true or false';
138
+ if (!ok(patch.judges, v => v === 2 || v === 3))
139
+ return 'judges is 2 or 3';
140
+ if (!ok(patch.escalate, v => v === 'always' || v === 'risk'))
141
+ return 'escalate is always or risk';
142
+ if (!ok(patch.max_runs_per_day, v => Number.isInteger(v) && v > 0))
143
+ return 'max_runs_per_day is a whole number above 0';
144
+ if (!ok(patch.max_usd_per_day, v => typeof v === 'number' && v > 0))
145
+ return 'max_usd_per_day is a number above 0';
146
+ if (!ok(patch.reviewers, v => Array.isArray(v) && v.every(x => x === 'claude' || x === 'cursor' || x === 'codex')))
147
+ return 'reviewers lists claude, cursor or codex';
148
+ // A model name is handed to an agent CLI as an argument: one that starts with "-" would be read as a flag.
149
+ if (!ok(patch.models, v => !!v && typeof v === 'object' && Object.values(v).every(x => typeof x === 'string' && /^[\w.:/@-]+$/.test(x) && !x.startsWith('-'))))
150
+ return 'models maps a reviewer to a model name (letters, digits and . : / @ -, not starting with -)';
151
+ return undefined;
152
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ConfigSchema } from '../../types/index.js';
3
+ import { resolveReviewer } from './settings.js';
4
+ const team = (reviewer = {}) => ConfigSchema.parse({ version: 1, review: { reviewer } });
5
+ describe('the reviewer settings a run uses', () => {
6
+ it('is one judge, no panel, until someone asks', () => {
7
+ expect(resolveReviewer(team(), {}, undefined, {})).toMatchObject({ enabled: false, mode: 'single', panel: false, judges: 2, escalate: 'always', source: { mode: 'team', panel: 'team' }, refused: [] });
8
+ });
9
+ it('takes the nearest layer: a flag, then the environment, then the person, then the team', () => {
10
+ const user = { enabled: true, mode: 'full', panel: true, judges: 3 };
11
+ expect(resolveReviewer(team({ mode: 'single' }), {}, user, {})).toMatchObject({ enabled: true, mode: 'full', panel: true, judges: 3, source: { mode: 'user', panel: 'user' } });
12
+ expect(resolveReviewer(team(), {}, user, { RIGOUR_REVIEWER_PANEL: 'off', RIGOUR_REVIEWER_MODE: 'cross' })).toMatchObject({ mode: 'cross', panel: false, source: { mode: 'env', panel: 'env' } });
13
+ expect(resolveReviewer(team(), { mode: 'single', panel: false }, user, { RIGOUR_REVIEWER_MODE: 'full' })).toMatchObject({ mode: 'single', source: { mode: 'flag' } });
14
+ });
15
+ it('turns a panel into a full review, since it needs two vendors', () => {
16
+ expect(resolveReviewer(team({ mode: 'single' }), { panel: true }, undefined, {})).toMatchObject({ mode: 'full', panel: true, source: { mode: 'flag', panel: 'flag' } });
17
+ });
18
+ it('keeps the team floor, and says what it refused', () => {
19
+ const floor = team({ mode: 'full', panel: 'required', mode_required: true });
20
+ const resolved = resolveReviewer(floor, { mode: 'single', panel: false }, { escalate: 'risk' }, {});
21
+ expect(resolved).toMatchObject({ mode: 'full', panel: true, escalate: 'always', required: { mode: true, panel: true } });
22
+ expect(resolved.refused).toEqual([
23
+ 'panel off (flag) refused: rigour.yml sets review.reviewer.panel: required',
24
+ 'mode single (flag) refused: rigour.yml sets review.reviewer.mode_required with mode full',
25
+ 'escalate risk (user) refused: rigour.yml requires every review to be full',
26
+ ]);
27
+ });
28
+ it('reports an environment value it does not understand instead of ignoring it quietly', () => {
29
+ expect(resolveReviewer(team(), {}, undefined, { RIGOUR_REVIEWER_MODE: 'triple', RIGOUR_REVIEWER_PANEL: 'maybe' }).refused).toEqual([
30
+ 'RIGOUR_REVIEWER_MODE=triple ignored: use single, cross or full',
31
+ 'RIGOUR_REVIEWER_PANEL=maybe ignored: use on or off',
32
+ ]);
33
+ });
34
+ it('lets a nearer choice of one judge turn a panel off, unless the team requires it, and says when it refuses', () => {
35
+ expect(resolveReviewer(team({ mode: 'full', panel: 'on' }), { mode: 'single' }, undefined, {})).toMatchObject({ mode: 'single', panel: false, refused: [] });
36
+ expect(resolveReviewer(team(), { mode: 'single' }, { panel: true }, {})).toMatchObject({ mode: 'single', panel: false }); // the flag is nearer than your setting
37
+ const user = resolveReviewer(team(), {}, { mode: 'single', panel: true }, {});
38
+ expect(user).toMatchObject({ mode: 'full', panel: true });
39
+ expect(user.refused).toEqual(['mode single (user) refused: the panel (user) needs judges from two vendors']);
40
+ expect(resolveReviewer(team({ mode: 'full', panel: 'required' }), { mode: 'single' }, undefined, {}).refused).toEqual(['mode single (flag) refused: rigour.yml sets review.reviewer.panel: required']);
41
+ });
42
+ it('under a team floor a person cannot turn reviews off or lower the judges', () => {
43
+ const floor = team({ enabled: true, mode: 'full', judges: 3, mode_required: true });
44
+ const resolved = resolveReviewer(floor, {}, { enabled: false, judges: 2 }, {});
45
+ expect(resolved).toMatchObject({ enabled: true, judges: 3 });
46
+ expect(resolved.refused).toEqual(['reviews off (user) refused: rigour.yml requires the reviewer', 'judges 2 (user) refused: rigour.yml requires 3']);
47
+ expect(resolveReviewer(team({ enabled: true }), {}, { enabled: false, judges: 3 }, {})).toMatchObject({ enabled: false, judges: 3 }); // no floor: your choice
48
+ });
49
+ it('takes the lower daily cap: a person can spend less than the team allows, never more', () => {
50
+ expect(resolveReviewer(team({ max_runs_per_day: 20, max_usd_per_day: 5 }), {}, { max_runs_per_day: 6 }, {})).toMatchObject({ max_runs_per_day: 6, max_usd_per_day: 5, refused: [] });
51
+ const higher = resolveReviewer(team({ max_runs_per_day: 20 }), {}, { max_runs_per_day: 50 }, {});
52
+ expect(higher.max_runs_per_day).toBe(20);
53
+ expect(higher.refused).toEqual(['max_runs_per_day 50 (user) refused: rigour.yml caps it at 20']);
54
+ expect(resolveReviewer(team(), {}, { max_usd_per_day: 2 }, {})).toMatchObject({ max_usd_per_day: 2 }); // no team cap: yours
55
+ expect(resolveReviewer(team(), {}, undefined, {}).max_runs_per_day).toBeUndefined(); // unset by default
56
+ });
57
+ });
@@ -1,4 +1,15 @@
1
1
  import type { Exec } from './exec.js';
2
+ /** What the reviewer spent on one local day in this repository: agent runs, and the dollars the CLIs reported. */
3
+ export interface DaySpend {
4
+ runs: number;
5
+ usd: number;
6
+ }
7
+ export interface ReviewAttempt {
8
+ head: string;
9
+ outcome: 'unavailable' | 'skipped';
10
+ reason: string;
11
+ at: string;
12
+ }
2
13
  export interface BranchState {
3
14
  head: string;
4
15
  verdict: string;
@@ -6,6 +17,8 @@ export interface BranchState {
6
17
  chain: number;
7
18
  rulesHash: string;
8
19
  reviewsKey: string;
20
+ /** Everything but the commit that decides a verdict: the same commit with the same key reuses it. */
21
+ inputsKey?: string;
9
22
  at: string;
10
23
  }
11
24
  export declare class VerdictStore {
@@ -19,8 +32,18 @@ export declare class VerdictStore {
19
32
  writeJson(file: string, value: unknown): void;
20
33
  branchState(branch: string): BranchState | undefined;
21
34
  recordBranch(branch: string, state: Omit<BranchState, 'chain' | 'at'>): void;
22
- /** The branch's record (`json`), the background reviewer's `pid` and `log`. */
23
- branchFile(branch: string, kind?: 'json' | 'pid' | 'log'): string;
35
+ /** Today's spend, summed from the day's append-only log (one line per run or batch of runs). */
36
+ spend(day?: string): DaySpend;
37
+ /** Adds runs and reported dollars to today's log: an append, so the background reviewer and a person's run never lose each other's count. */
38
+ addSpend(runs: number, usd: number | undefined, day?: string): void;
39
+ private spendFile;
40
+ /** The decision a verdict led to (what blocks, what is disputed or a note), kept so the same commit is not decided again. */
41
+ decidedPath(verdictPath: string): string;
42
+ /** A review on the branch that ended without a verdict, and why: what the status shows instead of "no verdict yet". */
43
+ recordAttempt(branch: string, attempt: ReviewAttempt): void;
44
+ attempt(branch: string): ReviewAttempt | undefined;
45
+ /** The branch's record (`json`), its last attempt without a verdict (`attempt`), the background reviewer's `pid` and `log`. */
46
+ branchFile(branch: string, kind?: 'json' | 'attempt' | 'pid' | 'log'): string;
24
47
  /** Where the background reviewer checks a commit out: under the git directory, never the working tree. */
25
48
  worktreeDir(head: string): string;
26
49
  }