@rigour-labs/cli 6.7.10 → 6.8.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.
- package/dist/cli.js +43 -0
- package/dist/commands/brief.d.ts +29 -0
- package/dist/commands/brief.js +169 -0
- package/dist/commands/brief.test.d.ts +1 -0
- package/dist/commands/brief.test.js +134 -0
- package/dist/commands/hooks-push.js +2 -1
- package/dist/commands/hooks-push.test.js +3 -0
- package/dist/commands/hooks-stop.js +2 -1
- package/dist/commands/hooks-stop.test.js +3 -1
- package/dist/commands/hooks.d.ts +4 -0
- package/dist/commands/hooks.js +32 -7
- package/dist/commands/hooks.test.js +9 -0
- package/dist/commands/thread.d.ts +3 -0
- package/dist/commands/thread.js +26 -0
- package/dist/commands/thread.test.d.ts +1 -0
- package/dist/commands/thread.test.js +32 -0
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { demoCommand } from './commands/demo.js';
|
|
|
14
14
|
import { hooksInitCommand, hooksCheckCommand } from './commands/hooks.js';
|
|
15
15
|
import { hooksStopCommand } from './commands/hooks-stop.js';
|
|
16
16
|
import { backtestCommand, backtestInitCommand } from './commands/backtest.js';
|
|
17
|
+
import { threadCommand } from './commands/thread.js';
|
|
18
|
+
import { briefCommand, hooksBriefCommand, hooksBriefFileCommand } from './commands/brief.js';
|
|
17
19
|
import { hooksPushCommand } from './commands/hooks-push.js';
|
|
18
20
|
import { hooksReviewBackgroundCommand } from './commands/hooks-review-background.js';
|
|
19
21
|
import { gitPushGateCommand, selfTestCommand, selfTestGitPushHook } from './commands/hooks-git.js';
|
|
@@ -460,6 +462,7 @@ hooksCmd
|
|
|
460
462
|
.option('--dry-run', 'Show what files would be created without writing them')
|
|
461
463
|
.option('-f, --force', 'Overwrite existing hook files')
|
|
462
464
|
.option('--block', 'Configure hooks to block on failure (exit code 2)')
|
|
465
|
+
.option('--brief', 'Also brief Claude Code with the team\'s rules and lessons: from each session\'s first prompt, and on the first edit of each file (off unless asked)')
|
|
463
466
|
.addHelpText('after', `
|
|
464
467
|
Examples:
|
|
465
468
|
$ rigour hooks init # Auto-detect tools, generate hooks
|
|
@@ -471,6 +474,30 @@ Examples:
|
|
|
471
474
|
.action(async (options) => {
|
|
472
475
|
await hooksInitCommand(process.cwd(), options);
|
|
473
476
|
});
|
|
477
|
+
hooksCmd
|
|
478
|
+
.command('brief')
|
|
479
|
+
.description('Prompt hook: brief the agent once per session from its first prompt (installed by rigour hooks init --brief); reads the hook payload on stdin')
|
|
480
|
+
.action(async () => {
|
|
481
|
+
const chunks = [];
|
|
482
|
+
if (!process.stdin.isTTY)
|
|
483
|
+
for await (const chunk of process.stdin)
|
|
484
|
+
chunks.push(chunk);
|
|
485
|
+
const reply = await hooksBriefCommand(Buffer.concat(chunks).toString('utf8'), process.cwd());
|
|
486
|
+
if (reply)
|
|
487
|
+
process.stdout.write(reply + '\n');
|
|
488
|
+
});
|
|
489
|
+
hooksCmd
|
|
490
|
+
.command('brief-file')
|
|
491
|
+
.description('Edit hook: the team\'s word on a file the first time a session edits it (installed by rigour hooks init --brief); reads the hook payload on stdin')
|
|
492
|
+
.action(async () => {
|
|
493
|
+
const chunks = [];
|
|
494
|
+
if (!process.stdin.isTTY)
|
|
495
|
+
for await (const chunk of process.stdin)
|
|
496
|
+
chunks.push(chunk);
|
|
497
|
+
const reply = await hooksBriefFileCommand(Buffer.concat(chunks).toString('utf8'), process.cwd());
|
|
498
|
+
if (reply)
|
|
499
|
+
process.stdout.write(reply + '\n');
|
|
500
|
+
});
|
|
474
501
|
hooksCmd
|
|
475
502
|
.command('stop')
|
|
476
503
|
.description('Stop hook: review the branch against main (on main, what the session changed) before the agent finishes; reads the hook payload on stdin')
|
|
@@ -485,6 +512,21 @@ hooksCmd
|
|
|
485
512
|
if (reply)
|
|
486
513
|
process.stdout.write(reply + '\n');
|
|
487
514
|
});
|
|
515
|
+
program
|
|
516
|
+
.command('brief [goal]')
|
|
517
|
+
.description('The briefing for the task in this checkout: the repository\'s rules, the team\'s verified lessons and settled points for the files it will likely touch, at most 10, each cited. Goal: the argument, else the pull request\'s title and description, else the branch')
|
|
518
|
+
.option('--files <list>', 'The files the task will touch, comma separated (default: the branch\'s changes and files the goal names)')
|
|
519
|
+
.option('--json', 'Output the briefing in JSON format')
|
|
520
|
+
.action(async (goal, options) => {
|
|
521
|
+
process.exit(await briefCommand(process.cwd(), goal, options));
|
|
522
|
+
});
|
|
523
|
+
program
|
|
524
|
+
.command('thread [key]')
|
|
525
|
+
.description('The thread of an engineering task: what agents, checks, pushes and reviews did to it, in order. Key: a ticket (PROJ-123), a branch, or a pull request (#42); the checkout\'s own task by default')
|
|
526
|
+
.option('--json', 'Output the thread in JSON format')
|
|
527
|
+
.action((key, options) => {
|
|
528
|
+
process.exit(threadCommand(process.cwd(), key, options));
|
|
529
|
+
});
|
|
488
530
|
const backtestCmd = program
|
|
489
531
|
.command('backtest')
|
|
490
532
|
.description('Score the review against the points people made reviewing this repository (.rigour/backtest.json); exit 1 until every point is caught with no false block')
|
|
@@ -592,6 +634,7 @@ hooksCmd
|
|
|
592
634
|
.option('--mode <mode>', 'Check mode: "check" (default) or "dlp" (credential scanning)')
|
|
593
635
|
.option('--agent <name>', 'Agent name for DLP audit trail (e.g., cursor, claude)')
|
|
594
636
|
.option('--dlp-allow-last', 'Record the last DLP warning as learned false positives (hook feedback)')
|
|
637
|
+
.option('--brief', 'DLP mode before an edit: also give the team\'s word on the file, the first time the session edits it')
|
|
595
638
|
.addHelpText('after', `
|
|
596
639
|
Examples:
|
|
597
640
|
$ rigour hooks check --files src/app.ts
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** `rigour brief [goal]`: the briefing for the task in this checkout, as text or JSON. The goal, else the pull request's title and description, else the branch. */
|
|
2
|
+
export declare function briefCommand(cwd: string, goal: string | undefined, options: {
|
|
3
|
+
files?: string;
|
|
4
|
+
json?: boolean;
|
|
5
|
+
}): Promise<number>;
|
|
6
|
+
/**
|
|
7
|
+
* Claude Code's UserPromptSubmit hook: the session's first prompt is the goal; the briefing is added to the agent's
|
|
8
|
+
* context once per session, and never again in it. Prints nothing when switched off or when there is nothing to say.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hooksBriefCommand(stdin: string, fallbackCwd: string): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Claude Code's PreToolUse hook on Edit, Write and MultiEdit: the first time a session edits a file, the team's word on
|
|
13
|
+
* that file (at most three items) is added to the agent's context. Once per file per session; the check for that comes
|
|
14
|
+
* first, so every later edit of the file costs one small file read. Prints nothing when off or when there is nothing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function hooksBriefFileCommand(stdin: string, fallbackCwd: string): Promise<string>;
|
|
17
|
+
export interface FileHookPayload {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
session_id?: string;
|
|
20
|
+
tool_name?: string;
|
|
21
|
+
tool_input?: {
|
|
22
|
+
file_path?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The team's word on the file a PreToolUse payload is about to edit, the first time the session edits it; '' otherwise.
|
|
27
|
+
* Shared by the edit hook and the DLP pre-tool hook, so a machine with DLP on pays no second process per edit.
|
|
28
|
+
*/
|
|
29
|
+
export declare function fileBriefingContext(payload: FileHookPayload, fallbackCwd: string): Promise<string>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The briefing an agent gets before it writes: `rigour brief` for a person or any agent, and `rigour hooks brief` for
|
|
3
|
+
* Claude Code's prompt hook, which briefs once per session from the session's first prompt. Off unless installed with
|
|
4
|
+
* `rigour hooks init --brief`; `brief.enabled: false` in rigour.yml or RIGOUR_BRIEF=0 stops it wherever it is installed.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { briefFile, briefingText, briefTask, fileBriefingText, threadsDir } from '@rigour-labs/core';
|
|
11
|
+
import { loadHookConfig } from './hooks-stop.js';
|
|
12
|
+
/** Whether briefings are switched off here: the team's rigour.yml or the person's environment. */
|
|
13
|
+
async function briefingOff(cwd) {
|
|
14
|
+
if (/^(0|false|off|no)$/i.test(process.env.RIGOUR_BRIEF?.trim() ?? ''))
|
|
15
|
+
return { off: true, max: 0 };
|
|
16
|
+
try {
|
|
17
|
+
const config = await loadHookConfig(cwd);
|
|
18
|
+
// The same lessons the team's reviewer checks a change against (gates.deep.review_lessons), before the code exists.
|
|
19
|
+
return { off: config.brief?.enabled === false, max: config.brief?.max_items ?? 10, ...(config.gates.deep?.review_lessons ? { lessons: config.gates.deep.review_lessons } : {}) };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { off: false, max: 10 }; // a broken rigour.yml is reported by the checks, not by a briefing
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** `rigour brief [goal]`: the briefing for the task in this checkout, as text or JSON. The goal, else the pull request's title and description, else the branch. */
|
|
26
|
+
export async function briefCommand(cwd, goal, options) {
|
|
27
|
+
const { off, max, lessons } = await briefingOff(cwd);
|
|
28
|
+
if (off) {
|
|
29
|
+
if (options.json)
|
|
30
|
+
console.log(JSON.stringify({ off: true, items: [] }));
|
|
31
|
+
else
|
|
32
|
+
console.error(chalk.yellow('Briefings are switched off here (brief.enabled: false in rigour.yml, or RIGOUR_BRIEF=0).'));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const files = options.files?.split(',').map(f => f.trim()).filter(Boolean);
|
|
36
|
+
const briefing = briefTask(cwd, { goal: goal?.trim() || pullRequestGoal(cwd), ...(files?.length ? { files } : {}), limit: max, agent: 'cli', ...(lessons ? { lessons } : {}) });
|
|
37
|
+
if (options.json)
|
|
38
|
+
console.log(JSON.stringify(briefing, null, 2));
|
|
39
|
+
else
|
|
40
|
+
console.log(briefingText(briefing) || chalk.dim(`Nothing to brief: no rule or verified lesson of this repository applies to ${briefing.files.length ? briefing.files.join(', ') : 'this task'} yet.`));
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Claude Code's UserPromptSubmit hook: the session's first prompt is the goal; the briefing is added to the agent's
|
|
45
|
+
* context once per session, and never again in it. Prints nothing when switched off or when there is nothing to say.
|
|
46
|
+
*/
|
|
47
|
+
export async function hooksBriefCommand(stdin, fallbackCwd) {
|
|
48
|
+
let payload = {};
|
|
49
|
+
try {
|
|
50
|
+
payload = JSON.parse(stdin);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
const cwd = payload.cwd || fallbackCwd;
|
|
56
|
+
const session = payload.session_id;
|
|
57
|
+
const { off, max, lessons } = await briefingOff(cwd);
|
|
58
|
+
if (off || !session || briefedAlready(cwd, session))
|
|
59
|
+
return '';
|
|
60
|
+
const briefing = briefTask(cwd, { goal: payload.prompt, limit: max, session, agent: 'claude', ...(lessons ? { lessons } : {}) });
|
|
61
|
+
markBriefed(cwd, session);
|
|
62
|
+
const text = briefingText(briefing);
|
|
63
|
+
return text ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }) : '';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Claude Code's PreToolUse hook on Edit, Write and MultiEdit: the first time a session edits a file, the team's word on
|
|
67
|
+
* that file (at most three items) is added to the agent's context. Once per file per session; the check for that comes
|
|
68
|
+
* first, so every later edit of the file costs one small file read. Prints nothing when off or when there is nothing.
|
|
69
|
+
*/
|
|
70
|
+
export async function hooksBriefFileCommand(stdin, fallbackCwd) {
|
|
71
|
+
let payload = {};
|
|
72
|
+
try {
|
|
73
|
+
payload = JSON.parse(stdin);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
const text = await fileBriefingContext(payload, fallbackCwd);
|
|
79
|
+
return text ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: text } }) : '';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The team's word on the file a PreToolUse payload is about to edit, the first time the session edits it; '' otherwise.
|
|
83
|
+
* Shared by the edit hook and the DLP pre-tool hook, so a machine with DLP on pays no second process per edit.
|
|
84
|
+
*/
|
|
85
|
+
export async function fileBriefingContext(payload, fallbackCwd) {
|
|
86
|
+
const session = payload.session_id;
|
|
87
|
+
const target = payload.tool_input?.file_path;
|
|
88
|
+
if (!session || typeof target !== 'string' || !target)
|
|
89
|
+
return '';
|
|
90
|
+
const cwd = payload.cwd || fallbackCwd;
|
|
91
|
+
const root = gitOut(cwd, ['rev-parse', '--show-toplevel']);
|
|
92
|
+
if (!root)
|
|
93
|
+
return '';
|
|
94
|
+
// Once per file per session, checked first and keyed on the path as the agent sent it (one session spells a file one
|
|
95
|
+
// way): every later edit of the file costs this check only.
|
|
96
|
+
const key = `${session}\u0000${path.resolve(cwd, target)}`;
|
|
97
|
+
if (briefedAlready(root, key))
|
|
98
|
+
return '';
|
|
99
|
+
markBriefed(root, key); // first, so two quick edits of one file never brief twice
|
|
100
|
+
// Where the file sits, as git says it, never by comparing paths (symlinked folders, Windows drive letters and short
|
|
101
|
+
// names make two spellings of one folder). A file being created is placed through the nearest folder that exists.
|
|
102
|
+
let dir = path.dirname(path.resolve(cwd, target));
|
|
103
|
+
const rest = [path.basename(target)];
|
|
104
|
+
while (!fs.existsSync(dir) && path.dirname(dir) !== dir) {
|
|
105
|
+
rest.unshift(path.basename(dir));
|
|
106
|
+
dir = path.dirname(dir);
|
|
107
|
+
}
|
|
108
|
+
if (gitOut(dir, ['rev-parse', '--show-toplevel']) !== root)
|
|
109
|
+
return ''; // outside this repository: not the team's file
|
|
110
|
+
const file = `${gitOut(dir, ['rev-parse', '--show-prefix']) ?? ''}${rest.join('/')}`;
|
|
111
|
+
if (!file)
|
|
112
|
+
return '';
|
|
113
|
+
const { off, lessons } = await briefingOff(root);
|
|
114
|
+
if (off)
|
|
115
|
+
return '';
|
|
116
|
+
return fileBriefingText(briefFile(root, file, { session, agent: 'claude', ...(lessons ? { lessons } : {}) }));
|
|
117
|
+
}
|
|
118
|
+
function gitOut(cwd, args) {
|
|
119
|
+
const run = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
|
|
120
|
+
return run.status === 0 ? run.stdout.trim() : undefined;
|
|
121
|
+
}
|
|
122
|
+
/** The pull request's title and description as the goal, when gh can read one quickly; undefined otherwise. */
|
|
123
|
+
function pullRequestGoal(cwd) {
|
|
124
|
+
const view = spawnSync('gh', ['pr', 'view', '--json', 'title,body'], { cwd, encoding: 'utf8', timeout: 5000 });
|
|
125
|
+
if (view.status !== 0)
|
|
126
|
+
return undefined;
|
|
127
|
+
try {
|
|
128
|
+
const pr = JSON.parse(view.stdout);
|
|
129
|
+
return [pr.title, pr.body].filter((t) => typeof t === 'string' && t.trim()).join('\n') || undefined;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function briefedFile(cwd) {
|
|
136
|
+
const dir = threadsDir(cwd);
|
|
137
|
+
return dir ? path.join(dir, '..', 'briefed-sessions.json') : undefined;
|
|
138
|
+
}
|
|
139
|
+
function briefedAlready(cwd, session) {
|
|
140
|
+
const file = briefedFile(cwd);
|
|
141
|
+
try {
|
|
142
|
+
return !!file && !!JSON.parse(fs.readFileSync(file, 'utf8'))[session];
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function markBriefed(cwd, session) {
|
|
149
|
+
const file = briefedFile(cwd);
|
|
150
|
+
if (!file)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
let all = {};
|
|
154
|
+
try {
|
|
155
|
+
all = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// first briefing in this repository
|
|
159
|
+
}
|
|
160
|
+
all[session] = new Date().toISOString();
|
|
161
|
+
// Keep the newest 2,000 entries (sessions, and files within them): a guard against repeating, not a history (the thread is).
|
|
162
|
+
const kept = Object.entries(all).sort((a, b) => (a[1] < b[1] ? 1 : -1)).slice(0, 2000);
|
|
163
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
164
|
+
fs.writeFileSync(file, JSON.stringify(Object.fromEntries(kept)));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// a session briefed twice is the worst case
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { readThread } from '@rigour-labs/core';
|
|
7
|
+
import { briefCommand, hooksBriefCommand, hooksBriefFileCommand } from './brief.js';
|
|
8
|
+
import { hooksCheckCommand, hooksInitCommand } from './hooks.js';
|
|
9
|
+
let repo;
|
|
10
|
+
const write = (file, text) => {
|
|
11
|
+
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(repo, file), text);
|
|
13
|
+
};
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'brief-cli-'));
|
|
16
|
+
execFileSync('git', ['-C', repo, 'init', '-q', '-b', 'feat/retry']);
|
|
17
|
+
write('AGENTS.md', '- Every job in `src/jobs/` must take `withLock()` before its first read; a job that reads first double-sends.\n');
|
|
18
|
+
write('src/jobs/retry.ts', 'export async function retryJob() {}\n');
|
|
19
|
+
execFileSync('git', ['-C', repo, 'add', '-A']);
|
|
20
|
+
delete process.env.RIGOUR_BRIEF;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
delete process.env.RIGOUR_BRIEF;
|
|
25
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
describe('the prompt hook', () => {
|
|
28
|
+
const payload = (session, prompt = 'make the jobs retry with backoff') => JSON.stringify({ cwd: repo, session_id: session, prompt, hook_event_name: 'UserPromptSubmit' });
|
|
29
|
+
it("briefs from the session's first prompt, once per session, as context Claude Code adds", async () => {
|
|
30
|
+
const reply = JSON.parse(await hooksBriefCommand(payload('s1'), '/'));
|
|
31
|
+
expect(reply.hookSpecificOutput.hookEventName).toBe('UserPromptSubmit');
|
|
32
|
+
expect(reply.hookSpecificOutput.additionalContext).toContain('1. [must] Every job in `src/jobs/` must take `withLock()`');
|
|
33
|
+
expect(await hooksBriefCommand(payload('s1', 'now add tests'), '/')).toBe(''); // never twice in a session
|
|
34
|
+
expect(await hooksBriefCommand(payload('s2'), '/')).not.toBe(''); // a new session is briefed
|
|
35
|
+
expect(readThread(repo)?.events.map(e => [e.kind, e.session, e.agent])).toEqual([['brief', 's1', 'claude'], ['brief', 's2', 'claude']]);
|
|
36
|
+
});
|
|
37
|
+
it('says nothing when the team or the person switched briefings off, or the payload is not a session', async () => {
|
|
38
|
+
write('rigour.yml', 'version: 1\nbrief:\n enabled: false\n');
|
|
39
|
+
expect(await hooksBriefCommand(payload('s1'), '/')).toBe('');
|
|
40
|
+
fs.rmSync(path.join(repo, 'rigour.yml'));
|
|
41
|
+
process.env.RIGOUR_BRIEF = '0';
|
|
42
|
+
expect(await hooksBriefCommand(payload('s2'), '/')).toBe('');
|
|
43
|
+
delete process.env.RIGOUR_BRIEF;
|
|
44
|
+
expect(await hooksBriefCommand('not json', '/')).toBe('');
|
|
45
|
+
expect(await hooksBriefCommand(JSON.stringify({ cwd: repo, prompt: 'x' }), '/')).toBe('');
|
|
46
|
+
expect(readThread(repo)?.events ?? []).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('caps the briefing at the max_items the team set', async () => {
|
|
49
|
+
write('AGENTS.md', Array.from({ length: 8 }, (_, i) => `- Every job in \`src/jobs/\` must call \`step${i}()\` before \`retryJob\` reads.\n`).join('\n'));
|
|
50
|
+
write('rigour.yml', 'version: 1\nbrief:\n max_items: 3\n');
|
|
51
|
+
const text = JSON.parse(await hooksBriefCommand(payload('s3', 'retry job'), '/')).hookSpecificOutput.additionalContext;
|
|
52
|
+
expect(text.split('\n').filter(l => /^\d+\. /.test(l))).toHaveLength(3);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('the edit hook', () => {
|
|
56
|
+
const edit = (session, file) => JSON.stringify({ cwd: repo, session_id: session, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: path.join(repo, file) } });
|
|
57
|
+
it("gives the team's word on a file the first time a session edits it, once per file per session", async () => {
|
|
58
|
+
const first = JSON.parse(await hooksBriefFileCommand(edit('s1', 'src/jobs/retry.ts'), '/'));
|
|
59
|
+
expect(first.hookSpecificOutput.hookEventName).toBe('PreToolUse');
|
|
60
|
+
expect(first.hookSpecificOutput.additionalContext).toContain('Rigour, before you edit src/jobs/retry.ts');
|
|
61
|
+
expect(first.hookSpecificOutput.additionalContext).toContain('1. [must] Every job in `src/jobs/` must take `withLock()`');
|
|
62
|
+
expect(await hooksBriefFileCommand(edit('s1', 'src/jobs/retry.ts'), '/')).toBe(''); // the same file again
|
|
63
|
+
expect(await hooksBriefFileCommand(edit('s2', 'src/jobs/retry.ts'), '/')).not.toBe(''); // another session
|
|
64
|
+
expect(await hooksBriefFileCommand(edit('s1', 'README.md'), '/')).toBe(''); // nothing applies: nothing said
|
|
65
|
+
expect(readThread(repo)?.events.map(e => [e.kind, e.file, e.session, e.items])).toEqual([['brief', 'src/jobs/retry.ts', 's1', 1], ['brief', 'src/jobs/retry.ts', 's2', 1], ['brief', 'README.md', 's1', 0]]);
|
|
66
|
+
});
|
|
67
|
+
it('places a file being created in a new folder, and says nothing for a file of another repository inside this one', async () => {
|
|
68
|
+
const created = JSON.parse(await hooksBriefFileCommand(edit('n1', 'src/jobs/new/sweep.ts'), '/'));
|
|
69
|
+
expect(created.hookSpecificOutput.additionalContext).toContain('Rigour, before you edit src/jobs/new/sweep.ts');
|
|
70
|
+
fs.mkdirSync(path.join(repo, 'vendor/other'), { recursive: true });
|
|
71
|
+
execFileSync('git', ['-C', path.join(repo, 'vendor/other'), 'init', '-q']);
|
|
72
|
+
expect(await hooksBriefFileCommand(edit('n1', 'vendor/other/src/jobs/x.ts'), '/')).toBe('');
|
|
73
|
+
});
|
|
74
|
+
it('says nothing for a file outside the repository, a bad payload, or when briefings are switched off', async () => {
|
|
75
|
+
expect(await hooksBriefFileCommand(JSON.stringify({ cwd: repo, session_id: 's1', tool_input: { file_path: '/etc/hosts' } }), '/')).toBe('');
|
|
76
|
+
expect(await hooksBriefFileCommand('not json', '/')).toBe('');
|
|
77
|
+
expect(await hooksBriefFileCommand(JSON.stringify({ cwd: repo, tool_input: { file_path: 'src/jobs/retry.ts' } }), '/')).toBe('');
|
|
78
|
+
process.env.RIGOUR_BRIEF = 'off';
|
|
79
|
+
expect(await hooksBriefFileCommand(edit('s3', 'src/jobs/retry.ts'), '/')).toBe('');
|
|
80
|
+
delete process.env.RIGOUR_BRIEF;
|
|
81
|
+
write('rigour.yml', 'version: 1\nbrief:\n enabled: false\n');
|
|
82
|
+
expect(await hooksBriefFileCommand(edit('s4', 'src/jobs/retry.ts'), '/')).toBe('');
|
|
83
|
+
expect(readThread(repo)?.events ?? []).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('the DLP pre-tool hook with --brief', () => {
|
|
87
|
+
it("adds the file's briefing to its own answer on a first edit, and nothing on other tools or later edits", async () => {
|
|
88
|
+
const out = [];
|
|
89
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => (out.push(String(chunk)), true));
|
|
90
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
91
|
+
const run = async (tool, session) => {
|
|
92
|
+
out.length = 0;
|
|
93
|
+
await hooksCheckCommand(repo, { mode: 'dlp', brief: true, files: JSON.stringify({ cwd: repo, session_id: session, tool_name: tool, tool_input: { file_path: path.join(repo, 'src/jobs/retry.ts') } }) });
|
|
94
|
+
return JSON.parse(out.join(''));
|
|
95
|
+
};
|
|
96
|
+
const first = await run('Edit', 'd1');
|
|
97
|
+
expect(first.status).toBe('clean'); // the DLP answer is unchanged
|
|
98
|
+
expect(first.hookSpecificOutput).toMatchObject({ hookEventName: 'PreToolUse', additionalContext: expect.stringContaining('Rigour, before you edit src/jobs/retry.ts') });
|
|
99
|
+
expect((await run('Edit', 'd1')).hookSpecificOutput).toBeUndefined(); // briefed once in the session
|
|
100
|
+
expect((await run('Read', 'd2')).hookSpecificOutput).toBeUndefined(); // not an edit
|
|
101
|
+
out.length = 0;
|
|
102
|
+
await hooksCheckCommand(repo, { mode: 'dlp', files: JSON.stringify({ cwd: repo, session_id: 'd3', tool_name: 'Edit', tool_input: { file_path: path.join(repo, 'src/jobs/retry.ts') } }) });
|
|
103
|
+
expect(JSON.parse(out.join('')).hookSpecificOutput).toBeUndefined(); // without --brief, DLP only
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('rigour brief', () => {
|
|
107
|
+
it('prints the briefing as JSON for a goal and files', async () => {
|
|
108
|
+
const out = [];
|
|
109
|
+
vi.spyOn(console, 'log').mockImplementation((line) => void out.push(String(line)));
|
|
110
|
+
expect(await briefCommand(repo, 'retry', { files: 'src/jobs/retry.ts', json: true })).toBe(0);
|
|
111
|
+
expect(JSON.parse(out.join('\n'))).toMatchObject({ goal: 'retry', files: ['src/jobs/retry.ts'], items: [{ kind: 'rule', requirement: true, cite: 'AGENTS.md' }] });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('rigour hooks init --brief', () => {
|
|
115
|
+
it('installs the prompt hook only when asked', async () => {
|
|
116
|
+
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
117
|
+
await hooksInitCommand(repo, { tool: 'claude' });
|
|
118
|
+
const plain = JSON.parse(fs.readFileSync(path.join(repo, '.claude/settings.json'), 'utf8')).hooks;
|
|
119
|
+
expect(plain.UserPromptSubmit).toBeUndefined();
|
|
120
|
+
expect(plain.PreToolUse.some((h) => h.matcher === 'Write|Edit|MultiEdit')).toBe(false);
|
|
121
|
+
await hooksInitCommand(repo, { tool: 'claude', brief: true, force: true });
|
|
122
|
+
const hook = JSON.parse(fs.readFileSync(path.join(repo, '.claude/settings.json'), 'utf8')).hooks.UserPromptSubmit;
|
|
123
|
+
expect(hook[0].hooks[0].command).toMatch(/ brief$/);
|
|
124
|
+
expect(hook[0].hooks[0].timeout).toBe(20);
|
|
125
|
+
// DLP on (the default): the DLP hook, which already runs before every tool, briefs too; no second process per edit.
|
|
126
|
+
const pre = JSON.parse(fs.readFileSync(path.join(repo, '.claude/settings.json'), 'utf8')).hooks.PreToolUse;
|
|
127
|
+
expect(pre.some((h) => h.matcher === 'Write|Edit|MultiEdit')).toBe(false);
|
|
128
|
+
expect(pre.find((h) => h.matcher === '.*').hooks[0].command).toMatch(/--mode dlp --stdin --brief$/);
|
|
129
|
+
// DLP off: an edit hook of its own.
|
|
130
|
+
await hooksInitCommand(repo, { tool: 'claude', brief: true, dlp: false, force: true });
|
|
131
|
+
const own = JSON.parse(fs.readFileSync(path.join(repo, '.claude/settings.json'), 'utf8')).hooks.PreToolUse.find((h) => h.matcher === 'Write|Edit|MultiEdit');
|
|
132
|
+
expect(own.hooks[0].command).toMatch(/ brief-file$/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -16,7 +16,7 @@ import { spawnSync } from 'child_process';
|
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import os from 'os';
|
|
18
18
|
import path from 'path';
|
|
19
|
-
import { branchBase, branchFailures, captureHumanEdits, diffFromGit, itemLine, mergeBaseOf, resolveReviewer, reviewChange, reviewerInputs, reviewerBlocks, runReviewer, runToolchain, startBackgroundReview, } from '@rigour-labs/core';
|
|
19
|
+
import { appendTaskEvent, branchBase, branchFailures, captureHumanEdits, diffFromGit, itemLine, mergeBaseOf, resolveReviewer, reviewChange, reviewerInputs, reviewerBlocks, runReviewer, runToolchain, startBackgroundReview, } from '@rigour-labs/core';
|
|
20
20
|
import { loadHookConfig } from './hooks-stop.js';
|
|
21
21
|
export async function hooksPushCommand(stdin, fallbackCwd) {
|
|
22
22
|
const payload = parse(stdin);
|
|
@@ -45,6 +45,7 @@ export async function pushGate(dir) {
|
|
|
45
45
|
// learning never blocks a push
|
|
46
46
|
}
|
|
47
47
|
const failures = await gates(repo, base, branch.mainRef, config);
|
|
48
|
+
appendTaskEvent(repo, { kind: 'push', passed: failures.lines.length === 0, failed: failures.lines.length });
|
|
48
49
|
if (failures.lines.length === 0)
|
|
49
50
|
return { exitCode: 0, message: await backgroundReviewNote(repo, branch.mainRef, config) };
|
|
50
51
|
const log = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-push-')), 'gates.log');
|
|
@@ -3,6 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { readThread } from '@rigour-labs/core';
|
|
6
7
|
import { hooksPushCommand, isPush, pushTarget } from './hooks-push.js';
|
|
7
8
|
describe('push command parsing', () => {
|
|
8
9
|
it('acts on a real push only', () => {
|
|
@@ -60,6 +61,8 @@ describe('rigour hooks push', () => {
|
|
|
60
61
|
expect(result.exitCode).toBe(2);
|
|
61
62
|
expect(result.message).toContain('- typed-checks-unavailable could not run: ');
|
|
62
63
|
expect(result.message).toContain('.generated/tsconfig.json');
|
|
64
|
+
// The task's thread keeps the blocked push.
|
|
65
|
+
expect(readThread(repo, 'feature')?.events.map(e => [e.kind, e.passed, e.failed])).toEqual([['push', false, 1]]);
|
|
63
66
|
});
|
|
64
67
|
it('blocks a push that adds an export nothing uses, saying where', async () => {
|
|
65
68
|
write('src/util.ts', 'export const used = 1;\nexport const forgotten = 2;\n');
|
|
@@ -20,7 +20,7 @@ import { execFileSync } from 'child_process';
|
|
|
20
20
|
import fs from 'fs-extra';
|
|
21
21
|
import path from 'path';
|
|
22
22
|
import yaml from 'yaml';
|
|
23
|
-
import { alreadyReviewed, appendAgentEvent, clearStopAttempts, ConfigSchema, countUsage, nextStopAttempt, recordFixLessons, recordReviewed, recordReviewOutcome, workFingerprint, sessionBaseline, STOP_MAX_ATTEMPTS, stopReview, teamMessage, untaught, recordTaught, captureHumanEdits, } from '@rigour-labs/core';
|
|
23
|
+
import { alreadyReviewed, appendAgentEvent, appendTaskEvent, clearStopAttempts, ConfigSchema, countUsage, nextStopAttempt, recordFixLessons, recordReviewed, recordReviewOutcome, workFingerprint, sessionBaseline, STOP_MAX_ATTEMPTS, stopReview, teamMessage, untaught, recordTaught, captureHumanEdits, } from '@rigour-labs/core';
|
|
24
24
|
export async function hooksStopCommand(tool, stdin, fallbackCwd) {
|
|
25
25
|
const payload = parsePayload(stdin);
|
|
26
26
|
const cwd = payload.cwd || fallbackCwd;
|
|
@@ -45,6 +45,7 @@ export async function hooksStopCommand(tool, stdin, fallbackCwd) {
|
|
|
45
45
|
const decision = await stopReview(cwd, config ?? await loadHookConfig(cwd), attempt, sessionBaseline(cwd, session));
|
|
46
46
|
const nothing = decision.reviewedFiles.length === 0;
|
|
47
47
|
appendAgentEvent(cwd, { type: 'stop_review', tool, session, blocked: decision.block, blocking: decision.blocking, against: decision.against, ...(nothing ? { nothing_to_review: true } : {}) });
|
|
48
|
+
appendTaskEvent(cwd, { kind: 'stop-review', agent: tool, session, blocked: decision.block, blocking: decision.blocking, files: decision.reviewedFiles });
|
|
48
49
|
if (nothing)
|
|
49
50
|
process.stderr.write(`Rigour stop review: nothing to review against ${decision.against}.\n`);
|
|
50
51
|
countUsage('stop_review');
|
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import { recordSessionBaseline } from '@rigour-labs/core';
|
|
6
|
+
import { readThread, recordSessionBaseline } from '@rigour-labs/core';
|
|
7
7
|
import { hooksStopCommand } from './hooks-stop.js';
|
|
8
8
|
// A credential header on a redirect-following request: a proven (verified) high finding.
|
|
9
9
|
const LEAKY = "export async function notify(endpoint: string, signature: string) {\n return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });\n}\n";
|
|
@@ -35,6 +35,8 @@ describe('rigour hooks stop', () => {
|
|
|
35
35
|
expect(first.decision).toBe('block');
|
|
36
36
|
expect(first.reason).toContain('src/notify.ts:2');
|
|
37
37
|
expect(first.reason).toContain('attempt 1 of 3');
|
|
38
|
+
// The task's thread keeps the blocked stop review, with the agent and its session.
|
|
39
|
+
expect(readThread(repo)?.events.map(e => [e.kind, e.agent, e.session, e.blocked])).toEqual([['stop-review', 'claude', 's1', true]]);
|
|
38
40
|
for (const n of [1, 2]) {
|
|
39
41
|
write('src/notify.ts', `${LEAKY}// attempt ${n}\n`); // the agent edits, still leaking
|
|
40
42
|
await hooksStopCommand('claude', payload, '/');
|
package/dist/commands/hooks.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface HooksOptions {
|
|
|
15
15
|
block?: boolean;
|
|
16
16
|
/** Also generate DLP pre-input warning hooks */
|
|
17
17
|
dlp?: boolean;
|
|
18
|
+
/** Also brief the agent from its first prompt (Claude Code's UserPromptSubmit). Off unless asked. */
|
|
19
|
+
brief?: boolean;
|
|
18
20
|
}
|
|
19
21
|
export interface HooksCheckOptions {
|
|
20
22
|
files?: string;
|
|
@@ -27,6 +29,8 @@ export interface HooksCheckOptions {
|
|
|
27
29
|
agent?: string;
|
|
28
30
|
/** Record last DLP warning detections as learned false positives (hook feedback) */
|
|
29
31
|
dlpAllowLast?: boolean;
|
|
32
|
+
/** DLP mode before an edit: also give the team's word on the file, the first time the session edits it (one process for both). */
|
|
33
|
+
brief?: boolean;
|
|
30
34
|
}
|
|
31
35
|
/** This CLI, pinned to its version, as a hook runs it (`npx --yes @rigour-labs/cli@6.6.6`). */
|
|
32
36
|
export declare function pinnedCliCommand(): string;
|
package/dist/commands/hooks.js
CHANGED
|
@@ -12,7 +12,8 @@ import path from 'path';
|
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import { randomUUID } from 'crypto';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
|
-
import {
|
|
15
|
+
import { fileBriefingContext } from './brief.js';
|
|
16
|
+
import { allowLastDLPBlock, appendTaskEvent, createDLPAuditEntry, formatDLPAlert, recordInteractionEvidence, runHookChecker, countUsage, scanInputForCredentials, updateAutomaticIndexForFiles, writeDLPBlockManifest, STOP_MAX_ATTEMPTS, recordHookPayload, recordSessionBaseline, recordAgentWrites, } from '@rigour-labs/core';
|
|
16
17
|
import { pushGateShell, rigourUserDir } from '@rigour-labs/core';
|
|
17
18
|
import { groupFilesByRepo, recordEditCatches } from './hooks-check-repos.js';
|
|
18
19
|
import { installGitPushHook } from './hooks-git.js';
|
|
@@ -100,6 +101,13 @@ function stopHookCommand(checker, tool) {
|
|
|
100
101
|
const args = checker.args[checker.args.length - 1] === 'check' ? [...checker.args.slice(0, -1), 'stop'] : [...checker.args, 'stop'];
|
|
101
102
|
return checkerToShellCommand({ command: checker.command, args: [...args, '--tool', tool] });
|
|
102
103
|
}
|
|
104
|
+
/** A briefing hook: the checker command with `brief` (or `brief-file`) in place of `check`. */
|
|
105
|
+
function briefHookCommand(checker, command = 'brief') {
|
|
106
|
+
const args = checker.args[checker.args.length - 1] === 'check' ? [...checker.args.slice(0, -1), command] : [...checker.args, command];
|
|
107
|
+
return checkerToShellCommand({ command: checker.command, args });
|
|
108
|
+
}
|
|
109
|
+
/** Seconds Claude Code waits for the briefing: it reads local files only. */
|
|
110
|
+
const BRIEF_HOOK_TIMEOUT_S = 20;
|
|
103
111
|
/** Seconds Claude Code waits for the stop review before letting the agent stop. */
|
|
104
112
|
const STOP_HOOK_TIMEOUT_S = 120;
|
|
105
113
|
/** Seconds Claude Code waits for the push gate: the project's tests and the reviewer can take minutes. */
|
|
@@ -144,7 +152,7 @@ function resolveTools(cwd, toolFlag) {
|
|
|
144
152
|
return detected;
|
|
145
153
|
}
|
|
146
154
|
// ── Per-tool hook generators ─────────────────────────────────────────
|
|
147
|
-
function generateClaudeHooks(checker, block, dlp = true) {
|
|
155
|
+
function generateClaudeHooks(checker, block, dlp = true, brief = false) {
|
|
148
156
|
const blockFlag = block ? ' --block' : '';
|
|
149
157
|
const checkerCommand = checkerToShellCommand(checker);
|
|
150
158
|
const hooks = {
|
|
@@ -171,11 +179,20 @@ function generateClaudeHooks(checker, block, dlp = true) {
|
|
|
171
179
|
matcher: ".*",
|
|
172
180
|
hooks: [{
|
|
173
181
|
type: "command",
|
|
174
|
-
|
|
182
|
+
// With --brief, the same process also briefs on a file's first edit: no second process before each edit.
|
|
183
|
+
command: `${checkerCommand} --mode dlp --stdin${brief ? ' --brief' : ''}`,
|
|
175
184
|
}]
|
|
176
185
|
});
|
|
177
186
|
}
|
|
178
187
|
hooks.PreToolUse = preToolUse;
|
|
188
|
+
// Opt-in: the team's briefing for the task, once per session, from the session's first prompt (rigour hooks brief),
|
|
189
|
+
// and the team's word on each file the first time the session edits it (rigour hooks brief-file).
|
|
190
|
+
if (brief) {
|
|
191
|
+
hooks.UserPromptSubmit = [{ hooks: [{ type: "command", command: briefHookCommand(checker), timeout: BRIEF_HOOK_TIMEOUT_S }] }];
|
|
192
|
+
// The DLP hook already runs before every tool and briefs too; without it, an edit hook of its own.
|
|
193
|
+
if (!dlp)
|
|
194
|
+
preToolUse.push({ matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: briefHookCommand(checker, 'brief-file'), timeout: BRIEF_HOOK_TIMEOUT_S }] });
|
|
195
|
+
}
|
|
179
196
|
const settings = { hooks };
|
|
180
197
|
return [{
|
|
181
198
|
path: '.claude/settings.json',
|
|
@@ -481,7 +498,7 @@ export async function hooksInitCommand(cwd, options = {}) {
|
|
|
481
498
|
// Collect generated files — each generator includes DLP hooks in the SAME config file
|
|
482
499
|
const allFiles = [];
|
|
483
500
|
for (const tool of tools) {
|
|
484
|
-
allFiles.push(...GENERATORS[tool](checker, block, dlp));
|
|
501
|
+
allFiles.push(...GENERATORS[tool](checker, block, dlp, !!options.brief));
|
|
485
502
|
}
|
|
486
503
|
if (options.dryRun) {
|
|
487
504
|
printDryRun(allFiles);
|
|
@@ -490,7 +507,7 @@ export async function hooksInitCommand(cwd, options = {}) {
|
|
|
490
507
|
const { written, skipped, failedPaths } = await writeHookFiles(cwd, allFiles, !!options.force);
|
|
491
508
|
const failed = failedPaths.size;
|
|
492
509
|
const unavailableTools = new Set(tools.filter(tool => {
|
|
493
|
-
const generatedPaths = GENERATORS[tool](checker, block, dlp).map(file => file.path);
|
|
510
|
+
const generatedPaths = GENERATORS[tool](checker, block, dlp, !!options.brief).map(file => file.path);
|
|
494
511
|
return generatedPaths.every(filePath => failedPaths.has(filePath));
|
|
495
512
|
}));
|
|
496
513
|
console.log('');
|
|
@@ -626,8 +643,10 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
626
643
|
// Parse Cursor's structured payload to extract prompt text
|
|
627
644
|
let textToScan = rawInput;
|
|
628
645
|
let cursorMode = false;
|
|
646
|
+
let toolPayload;
|
|
629
647
|
try {
|
|
630
648
|
const payload = JSON.parse(rawInput);
|
|
649
|
+
toolPayload = payload;
|
|
631
650
|
// Claude Code's PreToolUse hook sees every agent tool call: record it so
|
|
632
651
|
// Studio's context savings are observed, not assumed.
|
|
633
652
|
recordHookPayload(payload, cwd);
|
|
@@ -672,7 +691,9 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
672
691
|
}
|
|
673
692
|
}
|
|
674
693
|
else {
|
|
675
|
-
|
|
694
|
+
// The first edit of a file in a session: the team's word on it rides on this same hook's answer.
|
|
695
|
+
const briefing = options.brief && /^(Write|Edit|MultiEdit)$/.test(String(toolPayload?.tool_name ?? '')) ? await fileBriefingContext(toolPayload, cwd).catch(() => '') : '';
|
|
696
|
+
process.stdout.write(JSON.stringify(briefing ? { ...result, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: briefing } } : result));
|
|
676
697
|
}
|
|
677
698
|
if (result.status !== 'clean') {
|
|
678
699
|
process.stderr.write('\n' + formatDLPAlert(result) + '\n');
|
|
@@ -702,6 +723,7 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
702
723
|
const timeout = options.timeout ? Number(options.timeout) : 5000;
|
|
703
724
|
let rawStdin = '';
|
|
704
725
|
let cursorMode = false;
|
|
726
|
+
let hookSession;
|
|
705
727
|
if (options.stdin) {
|
|
706
728
|
rawStdin = await readStdin();
|
|
707
729
|
// Detect Cursor/IDE hook payload format
|
|
@@ -712,8 +734,10 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
712
734
|
}
|
|
713
735
|
// The session's first edit fixes the commit its stop review starts from (hooks-stop.ts).
|
|
714
736
|
const session = payload.session_id || payload.conversation_id;
|
|
715
|
-
if (typeof session === 'string')
|
|
737
|
+
if (typeof session === 'string') {
|
|
716
738
|
recordSessionBaseline(cwd, session);
|
|
739
|
+
hookSession = session;
|
|
740
|
+
}
|
|
717
741
|
}
|
|
718
742
|
catch (parseErr) {
|
|
719
743
|
// Not valid JSON — log for debugging (stderr only, stdout must stay clean)
|
|
@@ -744,6 +768,7 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
744
768
|
const outcome = repoResult.status === 'pass' ? 'success' : repoResult.status === 'fail' ? 'rejected' : 'error';
|
|
745
769
|
return [
|
|
746
770
|
Promise.resolve().then(() => recordAgentWrites(root, repoFiles)), // as the agent left them: a person's later change is a lesson
|
|
771
|
+
Promise.resolve().then(() => appendTaskEvent(root, { kind: 'edit-check', ...(hookSession ? { session: hookSession } : {}), ...(agentId || cursorMode ? { agent: agentId || 'cursor' } : {}), files: repoFiles, findings: repoResult.failures.length, status: repoResult.status })),
|
|
747
772
|
updateAutomaticIndexForFiles(root, repoFiles),
|
|
748
773
|
recordEditCatches(root, repoResult, repoFiles),
|
|
749
774
|
recordInteractionEvidence(root, {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Tests for hooks init command.
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import { readThread } from '@rigour-labs/core';
|
|
5
7
|
import { hooksInitCommand, hooksCheckCommand, parseStdinFiles } from './hooks.js';
|
|
6
8
|
import * as fs from 'fs';
|
|
7
9
|
import * as path from 'path';
|
|
@@ -197,6 +199,13 @@ describe('hooksCheckCommand', () => {
|
|
|
197
199
|
const output = stdoutSpy.mock.calls.map(call => String(call[0])).join('');
|
|
198
200
|
expect(output).toContain('"status":"pass"');
|
|
199
201
|
});
|
|
202
|
+
it("keeps each edit check on the task's thread, with the files and the findings", async () => {
|
|
203
|
+
execFileSync('git', ['-C', testDir, 'init', '-q', '-b', 'feat/PROJ-21-thread']);
|
|
204
|
+
fs.writeFileSync(path.join(testDir, 'ok.ts'), 'export const x = 1;\n');
|
|
205
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
206
|
+
await hooksCheckCommand(testDir, { files: 'ok.ts', agent: 'codex' });
|
|
207
|
+
expect(readThread(testDir, 'feat/PROJ-21-thread')?.events.map(e => [e.kind, e.agent, e.files, e.findings, e.status])).toEqual([['edit-check', 'codex', ['ok.ts'], 0, 'pass']]);
|
|
208
|
+
});
|
|
200
209
|
it('reports skipped, not pass, when the hook named no file', async () => {
|
|
201
210
|
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
202
211
|
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigour thread [key]`: the thread of one engineering task, everything Rigour saw happen to it in order. The key is a
|
|
3
|
+
* ticket (`PROJ-123`), a branch, or a pull request (`#42`); without one, the checkout's own task.
|
|
4
|
+
*/
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { readThread, threadText } from '@rigour-labs/core';
|
|
7
|
+
export function threadCommand(cwd, key, options) {
|
|
8
|
+
const thread = readThread(cwd, key);
|
|
9
|
+
if (!thread) {
|
|
10
|
+
const what = key ? `no thread for ${key}` : 'no task here: the checkout is on a detached head';
|
|
11
|
+
if (options.json)
|
|
12
|
+
console.log(JSON.stringify({ task: key ?? null, events: [], error: what }, null, 2));
|
|
13
|
+
else
|
|
14
|
+
console.error(chalk.yellow(`${what}. A thread starts when a hooked agent edits, a stop review or push gate runs, or a review runs on a branch.`));
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
if (options.json) {
|
|
18
|
+
console.log(JSON.stringify(thread, null, 2));
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
const [head, ...rest] = threadText(thread);
|
|
22
|
+
console.log(chalk.bold(head));
|
|
23
|
+
for (const line of rest)
|
|
24
|
+
console.log(line.startsWith(' ') ? chalk.dim(line) : line);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { appendTaskEvent } from '@rigour-labs/core';
|
|
7
|
+
import { threadCommand } from './thread.js';
|
|
8
|
+
let repo;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'thread-cli-'));
|
|
11
|
+
execFileSync('git', ['-C', repo, 'init', '-q', '-b', 'feat/PROJ-9-thing']);
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
describe('rigour thread', () => {
|
|
18
|
+
it('prints the thread, as text or JSON, and exits 1 with a reason when there is none', () => {
|
|
19
|
+
const out = [];
|
|
20
|
+
vi.spyOn(console, 'log').mockImplementation((line) => void out.push(String(line)));
|
|
21
|
+
vi.spyOn(console, 'error').mockImplementation((line) => void out.push(String(line)));
|
|
22
|
+
expect(threadCommand(repo, 'feat/PROJ-9-thing', {})).toBe(1);
|
|
23
|
+
expect(out.join('\n')).toContain('no thread for feat/PROJ-9-thing');
|
|
24
|
+
appendTaskEvent(repo, { kind: 'push', passed: true, failed: 0 });
|
|
25
|
+
out.length = 0;
|
|
26
|
+
expect(threadCommand(repo, undefined, {})).toBe(0);
|
|
27
|
+
expect(out[0]).toContain('branch:feat/PROJ-9-thing'); // no commit names the ticket yet
|
|
28
|
+
out.length = 0;
|
|
29
|
+
expect(threadCommand(repo, 'feat/PROJ-9-thing', { json: true })).toBe(0);
|
|
30
|
+
expect(JSON.parse(out.join('\n'))).toMatchObject({ task: 'branch:feat/PROJ-9-thing', events: [{ kind: 'push', passed: true }] });
|
|
31
|
+
});
|
|
32
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.0",
|
|
4
4
|
"description": "Code review that happens while your agent writes: deterministic checks on the lines it changed, quiet unless it can prove a defect, and learning from your team's fixes and dismissals. Works with Claude Code, Cursor, Codex, Cline and Windsurf.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.13"
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"inquirer": "9.2.16",
|
|
57
57
|
"ora": "^8.0.1",
|
|
58
58
|
"yaml": "^2.8.2",
|
|
59
|
-
"@rigour-labs/core": "6.
|
|
59
|
+
"@rigour-labs/core": "6.8.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/fs-extra": "^11.0.4",
|
|
63
63
|
"@types/inquirer": "9.0.7",
|
|
64
64
|
"@types/node": "^25.0.3",
|
|
65
|
-
"@rigour-labs/studio": "6.
|
|
65
|
+
"@rigour-labs/studio": "6.8.0"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc && pnpm bundle-studio",
|