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

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.
@@ -8,6 +8,8 @@ export declare function recordCreated(cwd: string, relPath: string, content: str
8
8
  export declare function unchangedSinceInstall(record: InstallRecord, relPath: string, content: string): boolean;
9
9
  /** A server entry Rigour wrote (npx of its MCP package), which setup may re-pin; one the person changed is theirs. */
10
10
  export declare function isRigourMcpEntry(entry: unknown): boolean;
11
+ /** Whether an MCP server entry, or `claude mcp get`'s description of one, runs Rigour's server: the package, or a checkout's build. */
12
+ export declare function namesRigourMcp(entry: unknown): boolean;
11
13
  /** A hook script Rigour wrote (the Cline hooks): it names Rigour in its header or runs the Rigour CLI. */
12
14
  export declare function isRigourScript(text: string): boolean;
13
15
  /**
@@ -38,6 +38,11 @@ export function isRigourMcpEntry(entry) {
38
38
  const e = entry;
39
39
  return e?.command === 'npx' && Array.isArray(e.args) && e.args.some(a => typeof a === 'string' && a.startsWith('@rigour-labs/mcp'));
40
40
  }
41
+ /** Whether an MCP server entry, or `claude mcp get`'s description of one, runs Rigour's server: the package, or a checkout's build. */
42
+ export function namesRigourMcp(entry) {
43
+ const text = typeof entry === 'string' ? entry : JSON.stringify(entry ?? '');
44
+ return /@rigour-labs\/mcp|rigour-mcp[\\/]+dist/.test(text);
45
+ }
41
46
  /** A hook script Rigour wrote (the Cline hooks): it names Rigour in its header or runs the Rigour CLI. */
42
47
  export function isRigourScript(text) {
43
48
  return /hook for Rigour|@rigour-labs\/cli/.test(text) || isRigourCommand(text);
@@ -24,7 +24,10 @@ export declare function asUserLevel<T extends {
24
24
  path: string;
25
25
  content: string;
26
26
  }>(file: T): T;
27
- export type McpState = 'added' | 'updated' | 'present' | 'removed' | 'absent' | 'no CLI';
27
+ /** `taken`: a user-level server named rigour that is not Rigour's; Rigour's is not registered over it. */
28
+ export type McpState = 'added' | 'updated' | 'present' | 'removed' | 'absent' | 'no CLI' | 'taken';
29
+ /** What setup and doctor say when another server holds the name. */
30
+ export declare const MCP_NAME_TAKEN = "a user-level MCP server named rigour exists and is not Rigour's; rename it, then run rigour setup again";
28
31
  /**
29
32
  * The Rigour MCP server at user level: Claude Code through its own `claude mcp add --scope user`
30
33
  * (its state file is Claude's, not Rigour's to edit), Cursor merged into `~/.cursor/mcp.json`.
@@ -16,7 +16,7 @@ import fs from 'fs';
16
16
  import os from 'os';
17
17
  import path from 'path';
18
18
  import { rigourUserDir } from '@rigour-labs/core';
19
- import { isEmptyConfig, isRigourMcpEntry, readInstallRecord, recordCreated, unchangedSinceInstall, withoutRigour } from './install-record.js';
19
+ import { isEmptyConfig, isRigourMcpEntry, namesRigourMcp, readInstallRecord, recordCreated, unchangedSinceInstall, withoutRigour } from './install-record.js';
20
20
  const MARKER = 'rigour-enabled';
21
21
  /**
22
22
  * Where the agents keep their user-level configs: RIGOUR_AGENT_HOME when set (tests, a sandbox),
@@ -136,6 +136,8 @@ export function asUserLevel(file) {
136
136
  };
137
137
  return { ...file, path: target, content: JSON.stringify(guard(JSON.parse(file.content)), null, 4) };
138
138
  }
139
+ /** What setup and doctor say when another server holds the name. */
140
+ export const MCP_NAME_TAKEN = "a user-level MCP server named rigour exists and is not Rigour's; rename it, then run rigour setup again";
139
141
  /**
140
142
  * The Rigour MCP server at user level: Claude Code through its own `claude mcp add --scope user`
141
143
  * (its state file is Claude's, not Rigour's to edit), Cursor merged into `~/.cursor/mcp.json`.
@@ -147,6 +149,9 @@ export function registerUserMcp(server) {
147
149
  const get = claude(['mcp', 'get', 'rigour']);
148
150
  if (!get.ok)
149
151
  state = add() ? 'added' : 'no CLI';
152
+ // Another server holds the name: Rigour's is not registered over it, and the person is told.
153
+ else if (!namesRigourMcp(get.stdout))
154
+ state = 'taken';
150
155
  // Rigour's own registration at another version is moved to this one, as the hooks are; one the person changed stays.
151
156
  else if (/@rigour-labs\/mcp/.test(get.stdout) && !get.stdout.includes(server.args[server.args.length - 1])) {
152
157
  claude(['mcp', 'remove', '--scope', 'user', 'rigour']);
@@ -169,6 +174,8 @@ export function registerUserMcp(server) {
169
174
  }
170
175
  }
171
176
  const current = config?.mcpServers?.rigour;
177
+ if (current && !namesRigourMcp(current))
178
+ return { claude: state, cursor: 'taken' };
172
179
  if (current && (JSON.stringify(current) === JSON.stringify(server) || !isRigourMcpEntry(current)))
173
180
  return { claude: state, cursor: 'present' };
174
181
  config.mcpServers = { ...(config.mcpServers ?? {}), rigour: server };
@@ -7,7 +7,8 @@ import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { getContextEvents } from '@rigour-labs/core';
9
9
  import { resolveMCPServerConfig } from './init.js';
10
- import { agentHome, enabledHere } from './personal.js';
10
+ import { agentHome, enabledHere, MCP_NAME_TAKEN } from './personal.js';
11
+ import { namesRigourMcp } from './install-record.js';
11
12
  import { isOldEditHook } from './setup-migrations.js';
12
13
  import { getCliVersion } from '../utils/cli-version.js';
13
14
  import { checkoutRoots, eventsAcross } from './studio-checkouts.js';
@@ -39,7 +40,7 @@ export async function checkRepoSetup(cwd, now = new Date(), toolCalls, installed
39
40
  configCheck(cwd, personal),
40
41
  editCheck(agents, now, events, setup),
41
42
  stopCheck(agents.map(a => a.config).join('\n'), now, events, setup),
42
- mcpCheck(mcpConfig, now, calls),
43
+ mcpCheck(mcpConfig, now, calls, nameTaken(readFrom(agentHome()))),
43
44
  ...(version ? [version] : []),
44
45
  prCheck(cwd),
45
46
  ];
@@ -93,8 +94,24 @@ function stopCheck(config, now, events, setup) {
93
94
  return { id: 'stop', name, state: 'missing', detail: 'No stop hook configured', fix: setup };
94
95
  return fired('stop', name, events.filter(e => e.type === 'stop_review'), now, 'finish checks');
95
96
  }
96
- function mcpCheck(mcpJson, now, calls) {
97
+ /** Which agent's user-level config holds a server named rigour that is not Rigour's (personal.ts MCP_NAME_TAKEN). */
98
+ function nameTaken(home) {
99
+ for (const [agent, rel] of [['Claude Code', '.claude.json'], ['Cursor', '.cursor/mcp.json']]) {
100
+ try {
101
+ const entry = JSON.parse(home(rel) || '{}')?.mcpServers?.rigour;
102
+ if (entry && !namesRigourMcp(entry))
103
+ return agent;
104
+ }
105
+ catch {
106
+ // not JSON: not Rigour's to judge
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+ function mcpCheck(mcpJson, now, calls, taken) {
97
112
  const name = 'Rigour tools for agents (MCP)';
113
+ if (taken)
114
+ return { id: 'mcp', name, state: 'broken', detail: `${taken}: ${MCP_NAME_TAKEN}`, fix: 'rename the other server, then rigour setup' };
98
115
  if (!mcpJson.includes('rigour') && calls.length === 0) {
99
116
  return { id: 'mcp', name, state: 'missing', detail: 'Agents cannot ask Rigour for review tasks or lessons', fix: `claude mcp add --scope user rigour -- ${[resolveMCPServerConfig().command, ...resolveMCPServerConfig().args].join(' ')}` };
100
117
  }
@@ -22,7 +22,7 @@ import { installGitPushHook } from './hooks-git.js';
22
22
  import { hooksInitCommand, installMachineHooks, pinnedCliCommand, projectHookPaths } from './hooks.js';
23
23
  import { initCommand, registerProjectMcp, resolveMCPServerConfig } from './init.js';
24
24
  import { writeAgentInstructions } from './init-handshake.js';
25
- import { disableHere, enableHere, registerUserMcp } from './personal.js';
25
+ import { disableHere, enableHere, MCP_NAME_TAKEN, registerUserMcp } from './personal.js';
26
26
  import { applyConfigMigrations, isOldEditHook } from './setup-migrations.js';
27
27
  import { getCliVersion } from '../utils/cli-version.js';
28
28
  import { setupSemantic } from './semantic.js';
@@ -80,6 +80,9 @@ async function personalSetup(cwd, brief) {
80
80
  console.log(chalk.yellow(`Claude Code MCP: add it with ${chalk.cyan(`claude mcp add --scope user rigour -- ${[resolveMCPServerConfig().command, ...resolveMCPServerConfig().args].join(' ')}`)}`));
81
81
  else
82
82
  console.log(chalk.green(`✔ Rigour tools for agents (MCP): Claude Code ${mcp.claude}, Cursor ${mcp.cursor}`));
83
+ for (const [agent, state] of [['Claude Code', mcp.claude], ['Cursor', mcp.cursor]])
84
+ if (state === 'taken')
85
+ console.log(chalk.yellow(`${agent}: ${MCP_NAME_TAKEN}.`));
83
86
  }
84
87
  async function teamSetup(cwd, options) {
85
88
  // A personal switch here would run the machine hooks beside the committed ones.
@@ -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.12",
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.12"
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.12"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "tsc && pnpm bundle-studio",