@rigour-labs/cli 7.0.0-rc.10 → 7.0.0-rc.11

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.
@@ -27,3 +27,5 @@ export declare function pushGate(dir: string): Promise<PushGateResult>;
27
27
  export declare function isPush(command: string): boolean;
28
28
  /** The directory the push runs in: `cd <dir> && git push` or `git -C <dir> push`. */
29
29
  export declare function pushTarget(command: string): string | undefined;
30
+ /** The repository a directory is in, or undefined outside one. */
31
+ export declare function repositoryOf(dir: string): string | undefined;
@@ -18,9 +18,15 @@ import os from 'os';
18
18
  import path from 'path';
19
19
  import { appendTaskEvent, branchBase, branchFailures, captureHumanEdits, diffFromGit, hookGoalDescription, itemLine, recordGoal, mergeBaseOf, resolveReviewer, reviewChange, reviewerCapReached, reviewerInputs, reviewerBlocks, runReviewer, runToolchain, startBackgroundReview, } from '@rigour-labs/core';
20
20
  import { loadHookConfig } from './hooks-stop.js';
21
+ import { recordToolStart, toolCallId } from './shell-writes.js';
21
22
  import { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
22
23
  export async function hooksPushCommand(stdin, fallbackCwd) {
23
24
  const payload = parse(stdin);
25
+ // Every shell call: when it started, so the edit check after it finds the files it wrote (shell-writes.ts).
26
+ const started = repositoryOf(payload.cwd ?? fallbackCwd);
27
+ const call = toolCallId(payload);
28
+ if (started && call)
29
+ recordToolStart(started, call);
24
30
  const command = String(payload.tool_input?.command ?? '');
25
31
  if (!isPush(command))
26
32
  return { exitCode: 0, message: '' };
@@ -147,7 +153,8 @@ export function pushTarget(command) {
147
153
  const dir = match?.slice(1).find(Boolean);
148
154
  return dir?.replace(/^~(?=\/|$)/, os.homedir());
149
155
  }
150
- function repositoryOf(dir) {
156
+ /** The repository a directory is in, or undefined outside one. */
157
+ export function repositoryOf(dir) {
151
158
  const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
152
159
  return result.status === 0 ? result.stdout.trim() : undefined;
153
160
  }
@@ -22,6 +22,8 @@ import { agentHome, asUserLevel, installedAgents } from './personal.js';
22
22
  import { hookStdin } from './hook-input.js';
23
23
  import { loadHookConfig } from './hooks-stop.js';
24
24
  import { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
25
+ import { filesWrittenSince, toolCallId } from './shell-writes.js';
26
+ import { repositoryOf } from './hooks-push.js';
25
27
  function getHookCliVersion() {
26
28
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
27
29
  const packagePath = path.resolve(thisDir, '../../package.json');
@@ -160,8 +162,9 @@ function generateClaudeHooks(checker, block, dlp = true, brief = false) {
160
162
  const blockFlag = block ? ' --block' : '';
161
163
  const checkerCommand = checkerToShellCommand(checker);
162
164
  const hooks = {
165
+ // Every write is checked, whichever tool made it: a shell command's writes too (shell-writes.ts).
163
166
  PostToolUse: [{
164
- matcher: "Write|Edit|MultiEdit",
167
+ matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash",
165
168
  hooks: [{
166
169
  type: "command",
167
170
  command: `${checkerCommand} --stdin${blockFlag}`,
@@ -582,6 +585,8 @@ export function parseStdinFiles(input) {
582
585
  if (typeof ti === 'object' && ti !== null) {
583
586
  if (ti.file_path)
584
587
  return [ti.file_path];
588
+ if (ti.notebook_path)
589
+ return [ti.notebook_path]; // Claude Code NotebookEdit
585
590
  if (ti.path)
586
591
  return [ti.path];
587
592
  }
@@ -767,6 +772,7 @@ export async function hooksCheckCommand(cwd, options = {}) {
767
772
  let rawStdin = '';
768
773
  let cursorMode = false;
769
774
  let hookSession;
775
+ let shellWrites;
770
776
  if (options.stdin) {
771
777
  rawStdin = await hookStdin();
772
778
  // Detect Cursor/IDE hook payload format
@@ -781,15 +787,24 @@ export async function hooksCheckCommand(cwd, options = {}) {
781
787
  recordSessionBaseline(cwd, session);
782
788
  hookSession = session;
783
789
  }
790
+ // A shell call: the files it wrote, found from the working tree, not the command (shell-writes.ts).
791
+ if (payload.tool_name === 'Bash') {
792
+ const repo = repositoryOf(typeof payload.cwd === 'string' ? payload.cwd : cwd);
793
+ const call = toolCallId(payload);
794
+ shellWrites = repo && call ? filesWrittenSince(repo, call) ?? [] : [];
795
+ }
784
796
  }
785
797
  catch (parseErr) {
786
798
  // Not valid JSON — log for debugging (stderr only, stdout must stay clean)
787
799
  process.stderr.write(`[rigour-hook-debug] stdin JSON parse failed: ${parseErr?.message?.slice(0, 100)}\n`);
788
800
  }
789
801
  }
790
- const files = options.stdin
802
+ // A shell call that wrote nothing: nothing to check, and nothing to say.
803
+ if (shellWrites && shellWrites.length === 0)
804
+ return;
805
+ const files = shellWrites ?? (options.stdin
791
806
  ? parseStdinFiles(rawStdin)
792
- : (options.files ?? '').split(',').map(f => f.trim()).filter(Boolean);
807
+ : (options.files ?? '').split(',').map(f => f.trim()).filter(Boolean));
793
808
  if (files.length === 0) {
794
809
  // The old edit hook passes "$TOOL_INPUT_file_path", which the agent never sets: an empty --files. Say so on every
795
810
  // edit, with a non-zero exit the agent shows the person, until it is migrated; it never blocks the edit.
@@ -0,0 +1,13 @@
1
+ /** The call's id in the hook payload: Claude Code's tool_use_id, else the session's. */
2
+ export declare function toolCallId(payload: {
3
+ tool_use_id?: unknown;
4
+ session_id?: unknown;
5
+ conversation_id?: unknown;
6
+ }): string | undefined;
7
+ /** Before the call: when it started. */
8
+ export declare function recordToolStart(repo: string, id: string, now?: number): void;
9
+ /**
10
+ * After the call: the files it wrote (absolute paths), changed or new in the working tree and written since it started.
11
+ * Undefined when no start was recorded for it (the hook before the call did not run). The record is used once.
12
+ */
13
+ export declare function filesWrittenSince(repo: string, id: string): string[] | undefined;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The files a shell command wrote, so the edit check sees writes an agent makes through its shell (a heredoc, a
3
+ * redirect, `sed -i`, `gofmt -w`) as it sees an Edit. Found without reading the command: the hook before the call
4
+ * records when it started, and the hook after it asks git which files are changed or new and were written since then.
5
+ */
6
+ import { spawnSync } from 'child_process';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { repoStateDir } from '@rigour-labs/core';
10
+ /** A file's mtime and the call's start come from two clocks a few milliseconds apart: this much earlier still counts. */
11
+ const CLOCK_SLACK_MS = 50;
12
+ /** The call's id in the hook payload: Claude Code's tool_use_id, else the session's. */
13
+ export function toolCallId(payload) {
14
+ const id = payload.tool_use_id ?? payload.session_id ?? payload.conversation_id;
15
+ return typeof id === 'string' && /^[\w.:-]{1,200}$/.test(id) ? id : undefined;
16
+ }
17
+ function startFile(repo, id) {
18
+ return path.join(repoStateDir(repo), 'tool-calls', id);
19
+ }
20
+ /** Before the call: when it started. */
21
+ export function recordToolStart(repo, id, now = Date.now()) {
22
+ const file = startFile(repo, id);
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ fs.writeFileSync(file, String(now));
25
+ }
26
+ /**
27
+ * After the call: the files it wrote (absolute paths), changed or new in the working tree and written since it started.
28
+ * Undefined when no start was recorded for it (the hook before the call did not run). The record is used once.
29
+ */
30
+ export function filesWrittenSince(repo, id) {
31
+ const file = startFile(repo, id);
32
+ let start;
33
+ try {
34
+ start = Number(fs.readFileSync(file, 'utf8'));
35
+ fs.rmSync(file, { force: true });
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ const status = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
41
+ if (status.status !== 0)
42
+ return [];
43
+ const written = [];
44
+ const entries = status.stdout.split('\0');
45
+ for (let i = 0; i < entries.length; i++) {
46
+ const entry = entries[i];
47
+ if (entry.length < 4)
48
+ continue;
49
+ const code = entry.slice(0, 2);
50
+ if (code[0] === 'R' || code[0] === 'C')
51
+ i++; // a rename's old path follows; the entry holds the new one
52
+ if (code.includes('D'))
53
+ continue;
54
+ const full = path.join(repo, entry.slice(3));
55
+ try {
56
+ const stat = fs.statSync(full);
57
+ if (stat.isFile() && stat.mtimeMs >= start - CLOCK_SLACK_MS)
58
+ written.push(full);
59
+ }
60
+ catch {
61
+ // gone since git listed it
62
+ }
63
+ }
64
+ return written;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/cli",
3
- "version": "7.0.0-rc.10",
3
+ "version": "7.0.0-rc.11",
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"
@@ -59,12 +59,12 @@
59
59
  "globby": "^14.0.1",
60
60
  "ora": "^8.0.1",
61
61
  "yaml": "^2.8.2",
62
- "@rigour-labs/core": "7.0.0-rc.10"
62
+ "@rigour-labs/core": "7.0.0-rc.11"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/fs-extra": "^11.0.4",
66
66
  "@types/node": "^25.0.3",
67
- "@rigour-labs/studio": "7.0.0-rc.10"
67
+ "@rigour-labs/studio": "7.0.0-rc.11"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "tsc && pnpm bundle-studio",