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

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 CHANGED
@@ -237,13 +237,21 @@ program
237
237
  .option('--instructions', 'With --team: also write AGENTS.md, and a one-line CLAUDE.md that imports it, where the project has none')
238
238
  .option('--no-semantic', 'Skip installing semantic search (recall and pattern matching then use keywords)')
239
239
  .option('--no-brief', 'Do not brief the agent with the team\'s rules and lessons (on by default: from each session\'s first prompt, and on the first edit of each file)')
240
+ .option('--report-only', 'A team\'s first week: the edit check, the stop review and the push gate (git\'s pre-push hook too) say what they would block and block nothing. A real credential is still refused. With --team, for everyone (rigour.yml)')
241
+ .option('--block', 'Make the gates block again after --report-only')
240
242
  .action(async (options) => {
243
+ if (options.reportOnly && options.block) {
244
+ console.error('Choose one: --report-only or --block.');
245
+ process.exit(2);
246
+ }
241
247
  await setupCommand(process.cwd(), options);
242
248
  });
243
249
  program
244
250
  .command('doctor')
245
251
  .description('Check that Rigour is wired up and firing here, and that the install is healthy')
246
252
  .option('--clean-cache', 'Remove context-cache rows Rigour no longer reads, and shrink the database when the disk has room')
253
+ .option('--merge-state', 'Merge what a home ending in .rigour from before 6.11.1 left over into the live state directory (a dry run; add --yes)')
254
+ .option('--yes', 'With --merge-state: do it')
247
255
  .action(async (options) => {
248
256
  await doctorCommand(options);
249
257
  });
@@ -406,7 +414,9 @@ program
406
414
  .option('--include-bots', 'With --list: also list candidates only review bots raised (hidden by default)')
407
415
  .option('--promote <id>', 'Accept a candidate as a lesson (recorded as your decision)')
408
416
  .option('--reject <id>', 'Reject a candidate or lesson: the judges are told this team decided against it')
409
- .option('--why <text>', 'Why, recorded with --promote or --reject')
417
+ .option('--not-a-rule <id>', 'Drop a candidate that is a one-off for its pull request, not a practice either way: never served, the judges are told nothing')
418
+ .option('--take-back <id>', 'Take back the last decision on a lesson (promote, reject or not a rule): a candidate again; the decision stays in its history')
419
+ .option('--why <text>', 'Why, recorded with --promote, --reject, --not-a-rule or --take-back')
410
420
  .option('--scope <id>', 'Set how far a lesson reaches, with --to: its file, its folder, or every change (a team standard); recorded as your decision')
411
421
  .option('--to <reach>', 'With --scope: file, folder or repo')
412
422
  .option('--use-wording <id>', 'Take the corrected wording a newer version suggests for a lesson you decided (the old wording is kept as evidence)')
@@ -2,6 +2,8 @@ export declare function detectInstallKind(binaryPath: string): string;
2
2
  export declare function hasVersionShadowing(versions: string[]): boolean;
3
3
  export declare function doctorCommand(options?: {
4
4
  cleanCache?: boolean;
5
+ mergeState?: boolean;
6
+ yes?: boolean;
5
7
  }, cwd?: string): Promise<void>;
6
8
  /** This repository first: is each part of Rigour wired up, and did it fire this week. */
7
9
  export declare function printRepoSetup(cwd: string): Promise<void>;
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { execFileSync } from 'child_process';
5
- import { cleanContextCache, deadCacheRows, loadSettings, resolveDeepOptions, getCachedModel, legacyStateNote, rigourUserDir, SidecarProvider } from '@rigour-labs/core';
5
+ import { applyStateMerge, cleanContextCache, deadCacheRows, loadSettings, resolveDeepOptions, getCachedModel, legacyStateNote, planStateMerge, rigourUserDir, SidecarProvider } from '@rigour-labs/core';
6
6
  import { checkRepoSetup } from './repo-setup.js';
7
7
  import { configMigrations } from './setup-migrations.js';
8
8
  import { selfTestCommand, selfTestGitPushHook } from './hooks-git.js';
@@ -65,6 +65,8 @@ export async function doctorCommand(options = {}, cwd = process.cwd()) {
65
65
  console.log(chalk.bold.cyan('\nRigour Doctor\n'));
66
66
  if (options.cleanCache)
67
67
  return cleanCache();
68
+ if (options.mergeState)
69
+ return mergeState(!!options.yes);
68
70
  await printRepoSetup(cwd);
69
71
  console.log(semanticStatusLine(cwd) + '\n');
70
72
  await printPushGate(cwd);
@@ -151,7 +153,7 @@ export async function printRepoSetup(cwd) {
151
153
  // Settings an older Rigour wrote that no longer mean what they say: loud, since nothing else reports them.
152
154
  for (const migration of configMigrations(cwd)) {
153
155
  console.log(` ${chalk.red('✘')} ${chalk.red(migration.problem)}`);
154
- console.log(chalk.dim(` fix: ${migration.automatic ? 'rigour setup' : 'edit rigour.yml'}`));
156
+ console.log(chalk.dim(` fix: ${migration.automatic ? 'rigour setup --team' : 'edit rigour.yml'}`));
155
157
  }
156
158
  console.log('');
157
159
  }
@@ -174,6 +176,25 @@ async function printPushGate(cwd) {
174
176
  console.log(chalk.dim(` - ${step}`));
175
177
  console.log(test.ok ? chalk.green(' ✓ The push gate holds under a real git push.\n') : chalk.red(' ✘ The push gate does not hold.\n'));
176
178
  }
179
+ /** `--merge-state`: what an old home ending in .rigour left outside the live state directory, merged in; a dry run unless --yes. */
180
+ function mergeState(yes) {
181
+ const plan = planStateMerge();
182
+ if (!plan)
183
+ return void console.log(chalk.green('Nothing to merge: Rigour keeps its state in one directory here.\n'));
184
+ console.log(`Live state: ${plan.live}\nLeft over in: ${plan.outer}\n`);
185
+ for (const step of plan.steps) {
186
+ console.log(step.action === 'move'
187
+ ? ` move ${step.rel} into the live directory`
188
+ : ` keep the live ${step.rel}; rename the left-over one to ${path.basename(step.to)}`);
189
+ }
190
+ if (plan.steps.some(step => path.basename(step.from) === 'rigour.db' && step.action === 'aside')) {
191
+ console.log(chalk.yellow('\n rigour.db is in both: the live one stays in use and the left-over one is kept, renamed, not merged row by row. Its old memory and lessons are not in the live database.'));
192
+ }
193
+ if (!yes)
194
+ return void console.log(chalk.dim('\nNothing changed (a dry run). To do it: rigour doctor --merge-state --yes. Nothing is deleted or overwritten.\n'));
195
+ applyStateMerge(plan.steps);
196
+ console.log(chalk.green(`\nMerged: ${plan.steps.length} entr${plan.steps.length === 1 ? 'y' : 'ies'}. Nothing was deleted.\n`));
197
+ }
177
198
  async function printDatabaseHealth() {
178
199
  const dead = await deadCacheRows().catch(() => null);
179
200
  if (!dead)
@@ -13,6 +13,7 @@ import { execFileSync, spawnSync } from 'child_process';
13
13
  import fs from 'fs';
14
14
  import os from 'os';
15
15
  import path from 'path';
16
+ import { pinnedHomeEnv } from '@rigour-labs/core/profile';
16
17
  import { pushGate } from './hooks-push.js';
17
18
  const MARK = 'rigour hooks push --git';
18
19
  /**
@@ -101,18 +102,19 @@ export async function selfTestGitPushHook(rigourCommand) {
101
102
  git(clone, ['commit', '-qm', 'base']);
102
103
  git(clone, ['push', '-q', '-u', 'origin', 'HEAD:main']);
103
104
  git(clone, ['checkout', '-qb', 'feature']);
105
+ // The clone is in the temp folder, where another profile may match: the pushes keep this repository's home.
104
106
  const installed = installGitPushHook(clone, rigourCommand);
105
107
  steps.push(`hook ${installed.action} at ${installed.path}`);
106
108
  fs.writeFileSync(path.join(clone, 'src/util.ts'), 'export const used = 1;\nexport const forgotten = 2;\n');
107
109
  git(clone, ['commit', '-qam', 'adds an export nothing uses']);
108
- const refused = spawnSync('git', ['push', 'origin', 'feature'], { cwd: clone, encoding: 'utf8', env: { ...process.env, RIGOUR_TELEMETRY: '0' } });
110
+ const refused = spawnSync('git', ['push', 'origin', 'feature'], { cwd: clone, encoding: 'utf8', env: { ...pinnedHomeEnv(), RIGOUR_TELEMETRY: '0' } });
109
111
  const refusedRef = gitOutput(remote, ['rev-parse', '--verify', '-q', 'refs/heads/feature']);
110
112
  if (refused.status === 0 || refusedRef)
111
113
  return { ok: false, steps: [...steps, `FAIL: a push with an unused export went through (git exit ${refused.status}; remote has feature: ${!!refusedRef})\n${(refused.stderr || '').trim().slice(-600)}`] };
112
114
  steps.push(`a push with an unused export was refused (git exit ${refused.status}); the remote has no feature branch`);
113
115
  fs.writeFileSync(path.join(clone, 'src/util.ts'), 'export const used = 1;\n');
114
116
  git(clone, ['commit', '-qam', 'fix']);
115
- const accepted = spawnSync('git', ['push', 'origin', 'feature'], { cwd: clone, encoding: 'utf8', env: { ...process.env, RIGOUR_TELEMETRY: '0' } });
117
+ const accepted = spawnSync('git', ['push', 'origin', 'feature'], { cwd: clone, encoding: 'utf8', env: { ...pinnedHomeEnv(), RIGOUR_TELEMETRY: '0' } });
116
118
  const acceptedRef = gitOutput(remote, ['rev-parse', '--verify', '-q', 'refs/heads/feature']);
117
119
  if (accepted.status !== 0 || acceptedRef !== gitOutput(clone, ['rev-parse', 'HEAD']))
118
120
  return { ok: false, steps: [...steps, `FAIL: the fixed push did not land (git exit ${accepted.status})\n${(accepted.stderr || '').trim().slice(-600)}`] };
@@ -18,6 +18,7 @@ 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 { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
21
22
  export async function hooksPushCommand(stdin, fallbackCwd) {
22
23
  const payload = parse(stdin);
23
24
  const command = String(payload.tool_input?.command ?? '');
@@ -62,9 +63,13 @@ export async function pushGate(dir) {
62
63
  return { exitCode: 0, message: await backgroundReviewNote(repo, branch.mainRef, config) };
63
64
  const log = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-push-')), 'gates.log');
64
65
  fs.writeFileSync(log, failures.log.join('\n\n'));
66
+ const against = `${path.basename(repo)} against ${branch.mainRef.replace(/^refs\/(remotes\/|heads\/)/, '')} (full output: ${log})`;
67
+ // A team's first week: the push goes ahead, and the agent and the terminal are told what would have stopped it.
68
+ if (reportOnly(repo, config))
69
+ return { exitCode: 0, message: [`${REPORT_ONLY_PREFIX} the push would have been blocked: ${failures.lines.length} check(s) failed for ${against}:`, ...failures.lines].join('\n') };
65
70
  return {
66
71
  exitCode: 2,
67
- message: [`Push blocked: ${failures.lines.length} check(s) failed for ${path.basename(repo)} against ${branch.mainRef.replace(/^refs\/(remotes\/|heads\/)/, '')} (full output: ${log}). Fix each, then push again:`, ...failures.lines].join('\n'),
72
+ message: [`Push blocked: ${failures.lines.length} check(s) failed for ${against}. Fix each, then push again:`, ...failures.lines].join('\n'),
68
73
  };
69
74
  }
70
75
  async function gates(repo, base, mainRef, config) {
@@ -20,6 +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 { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
23
24
  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
25
  export async function hooksStopCommand(tool, stdin, fallbackCwd) {
25
26
  const payload = parsePayload(stdin);
@@ -42,7 +43,13 @@ export async function hooksStopCommand(tool, stdin, fallbackCwd) {
42
43
  if (attempt > STOP_MAX_ATTEMPTS)
43
44
  return '';
44
45
  try {
45
- const decision = await stopReview(cwd, config ?? await loadHookConfig(cwd), attempt, sessionBaseline(cwd, session));
46
+ const loaded = config ?? await loadHookConfig(cwd);
47
+ const review = await stopReview(cwd, loaded, attempt, sessionBaseline(cwd, session));
48
+ // A team's first week: what would have held the agent is said, and the agent finishes (report-only.ts).
49
+ const reporting = review.block && reportOnly(cwd, loaded);
50
+ if (reporting)
51
+ process.stderr.write(`${REPORT_ONLY_PREFIX} the agent would have been held to fix:\n${review.message}\n`);
52
+ const decision = reporting ? { ...review, block: false } : review;
46
53
  const nothing = decision.reviewedFiles.length === 0;
47
54
  appendAgentEvent(cwd, { type: 'stop_review', tool, session, blocked: decision.block, blocking: decision.blocking, against: decision.against, ...(decision.held.length ? { findings: decision.held.slice(0, 20) } : {}), ...(nothing ? { nothing_to_review: true } : {}) });
48
55
  appendTaskEvent(cwd, { kind: 'stop-review', agent: tool, session, blocked: decision.block, blocking: decision.blocking, files: decision.reviewedFiles });
@@ -66,7 +73,7 @@ export async function hooksStopCommand(tool, stdin, fallbackCwd) {
66
73
  recordTaught(cwd, session, fresh);
67
74
  if (!decision.block) {
68
75
  clearStopAttempts(cwd, session);
69
- return guidance.length ? blockWith(tool, teamMessage(guidance)) : '';
76
+ return guidance.length && !reportOnly(cwd, loaded) ? blockWith(tool, teamMessage(guidance)) : '';
70
77
  }
71
78
  return blockWith(tool, guidance.length ? `${decision.message}\n\n${teamMessage(guidance)}` : decision.message);
72
79
  }
@@ -34,6 +34,8 @@ export interface HooksCheckOptions {
34
34
  }
35
35
  /** This CLI, pinned to its version, as a hook runs it (`npx --yes @rigour-labs/cli@6.6.6`). */
36
36
  export declare function pinnedCliCommand(): string;
37
+ /** Every project file Rigour's hooks are written to, for every agent: what a team commits when it installs Rigour. */
38
+ export declare function projectHookPaths(): string[];
37
39
  /**
38
40
  * The personal install's agent hooks, once per machine (personal.ts): every agent's user-level
39
41
  * config, each command guarded so it runs only in a repository switched on with `rigour setup`.
@@ -21,6 +21,7 @@ import { isRigourScript, mergeHooksInto, recordCreated } from './install-record.
21
21
  import { agentHome, asUserLevel, installedAgents } from './personal.js';
22
22
  import { hookStdin } from './hook-input.js';
23
23
  import { loadHookConfig } from './hooks-stop.js';
24
+ import { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
24
25
  function getHookCliVersion() {
25
26
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
26
27
  const packagePath = path.resolve(thisDir, '../../package.json');
@@ -382,6 +383,11 @@ const GENERATORS = {
382
383
  cline: generateClineHooks,
383
384
  windsurf: generateWindsurfHooks,
384
385
  };
386
+ /** Every project file Rigour's hooks are written to, for every agent: what a team commits when it installs Rigour. */
387
+ export function projectHookPaths() {
388
+ const checker = resolveCheckerCommand();
389
+ return [...new Set(Object.keys(GENERATORS).flatMap(tool => GENERATORS[tool](checker, true).map(file => file.path)))];
390
+ }
385
391
  // ── File writing ─────────────────────────────────────────────────────
386
392
  function printDryRun(files) {
387
393
  console.log(chalk.cyan('\nDry run — files that would be created:\n'));
@@ -803,6 +809,8 @@ export async function hooksCheckCommand(cwd, options = {}) {
803
809
  result: await runHookChecker({ cwd: group.root, files: group.files, timeout_ms: Number.isFinite(timeout) ? timeout : 5000, agentId }),
804
810
  })));
805
811
  const result = mergeHookResults(runs.map(r => r.result));
812
+ // A team's first week: the findings are said, and the edit goes through (report-only.ts).
813
+ const blocking = !!options.block && !(await Promise.all(runs.map(async (run) => reportOnly(run.root, await loadHookConfig(run.root).catch(() => undefined))))).some(Boolean);
806
814
  const counted = { agent: cursorMode ? 'cursor' : agentId };
807
815
  countUsage('hook_check', 1, counted);
808
816
  for (const failure of result.failures)
@@ -822,8 +830,8 @@ export async function hooksCheckCommand(cwd, options = {}) {
822
830
  }),
823
831
  logStudioEvent(root, {
824
832
  type: 'hook_check', requestId, outcome, status: repoResult.status, agentId,
825
- // Only a --block hook stops the edit; without it the findings are reported and the agent carries on.
826
- blocked: !!options.block && repoResult.status === 'fail',
833
+ // Only a --block hook stops the edit; without it (or in report-only) the findings are reported and the agent carries on.
834
+ blocked: blocking && repoResult.status === 'fail',
827
835
  files: repoFiles, summary: `${repoFiles.length} file(s), ${repoResult.failures.length} finding(s)`,
828
836
  findings: repoResult.failures.slice(0, 10).map(f => ({ gate: f.gate, file: f.file, line: f.line, message: f.message, severity: f.severity })),
829
837
  }),
@@ -839,7 +847,7 @@ export async function hooksCheckCommand(cwd, options = {}) {
839
847
  })
840
848
  .join('\n');
841
849
  process.stdout.write(JSON.stringify({
842
- continue: !options.block, // block mode = stop, otherwise warn
850
+ continue: !blocking, // block mode = stop, otherwise warn
843
851
  user_message: `⚠️ Rigour: ${result.failures.length} issue(s) found:\n${messages}`,
844
852
  }));
845
853
  }
@@ -858,9 +866,10 @@ export async function hooksCheckCommand(cwd, options = {}) {
858
866
  const loc = failure.line ? `:${failure.line}` : '';
859
867
  process.stderr.write(`[rigour/${failure.gate}] ${failure.file}${loc}: ${failure.message}\n`);
860
868
  }
861
- if (options.block) {
869
+ if (blocking)
862
870
  process.exitCode = 2;
863
- }
871
+ else if (options.block)
872
+ process.stderr.write(`${REPORT_ONLY_PREFIX} the edit would have been blocked for the above.\n`);
864
873
  }
865
874
  }
866
875
  /** One verdict for a hook call that checked files in several repositories. */
@@ -4,6 +4,8 @@ export interface LearnReviewsOptions {
4
4
  limit?: string;
5
5
  promote?: string;
6
6
  reject?: string;
7
+ notARule?: string;
8
+ takeBack?: string;
7
9
  why?: string;
8
10
  list?: boolean;
9
11
  /** With --list: also list candidates only review bots raised (hidden by default, and counted). */
@@ -18,6 +18,10 @@ export async function learnReviewsCommand(cwd, options) {
18
18
  return decide(cwd, options.promote, 'accepted', options.why);
19
19
  if (options.reject)
20
20
  return decide(cwd, options.reject, 'rejected', options.why);
21
+ if (options.notARule)
22
+ return decide(cwd, options.notARule, 'not-a-rule', options.why);
23
+ if (options.takeBack)
24
+ return decide(cwd, options.takeBack, 'took-back', options.why);
21
25
  if (options.list)
22
26
  return list(cwd, options.json, options.includeBots);
23
27
  if (options.useWording)
@@ -81,7 +85,7 @@ function list(cwd, json, includeBots) {
81
85
  console.log('No review lessons yet. Run `rigour learn-reviews`.');
82
86
  }
83
87
  function printLesson(l) {
84
- const label = { verified: chalk.green('lesson '), candidate: chalk.yellow('candidate'), rejected: chalk.red('rejected ') };
88
+ const label = { verified: chalk.green('lesson '), candidate: chalk.yellow('candidate'), rejected: chalk.red('rejected '), dropped: chalk.dim('not a rule') };
85
89
  const prs = [...new Set(l.evidence.map(e => `#${e.pr}`))].join(', ');
86
90
  const by = chalk.dim(`${l.promotedBy ? ` [${l.promotedBy}]` : ''}${l.scope ? ` [${l.scope === 'repo' ? 'every change' : 'its folder'}]` : ''}`);
87
91
  console.log(`${label[l.state]} ${chalk.dim(l.id)} ${l.file || '(no file)'}: ${l.text}${by} ${chalk.dim(`(${prs})`)}`);
@@ -199,17 +203,32 @@ function useWording(cwd, id) {
199
203
  console.log(chalk.green(`✔ Now reads: ${lesson.text}`));
200
204
  }
201
205
  /** A person's decision, kept as evidence with who made it (their git email) and why. */
206
+ /** What each decision tells the person it did. */
207
+ const DECIDED = {
208
+ accepted: text => chalk.green(`✔ A lesson now: ${text}`),
209
+ rejected: text => chalk.yellow(`✔ Rejected: ${text}. The judges are told this team decided against it.`),
210
+ 'not-a-rule': text => chalk.dim(`✔ Not a rule (a one-off for that pull request): ${text}. Never served, and the judges are told nothing.`),
211
+ 'took-back': text => chalk.yellow(`✔ Taken back, a candidate again: ${text}. The earlier decision is kept in its history.`),
212
+ };
202
213
  function decide(cwd, id, decision, why) {
203
214
  const by = deciderOrExit(cwd);
204
215
  if (!by)
205
216
  return;
206
- const lesson = decideLesson(cwd, id, decision, by, why);
217
+ let lesson;
218
+ try {
219
+ lesson = decideLesson(cwd, id, decision, by, why);
220
+ }
221
+ catch (e) {
222
+ console.error(chalk.red(e.message));
223
+ process.exitCode = 1;
224
+ return;
225
+ }
207
226
  if (!lesson) {
208
227
  console.error(chalk.red(`No lesson ${id}.`));
209
228
  process.exitCode = 1;
210
229
  return;
211
230
  }
212
- console.log(decision === 'accepted' ? chalk.green(`✔ A lesson now: ${lesson.text}`) : chalk.yellow(`✔ Rejected: ${lesson.text}. The judges are told this team decided against it.`));
231
+ console.log(DECIDED[decision](lesson.text));
213
232
  }
214
233
  /** The repository's rigour.yml, or the defaults when it cannot be read (no rigour.yml is the defaults already). */
215
234
  async function teamConfig(cwd) {
@@ -2,8 +2,9 @@
2
2
  * `rigour profile list|which|add`: one machine, many organizations (core utils/profile.ts).
3
3
  */
4
4
  import chalk from 'chalk';
5
- import { profileFor, profilesPath, readProfiles, writeProfiles } from '@rigour-labs/core/profile';
6
- import { rigourUserDir } from '@rigour-labs/core';
5
+ import path from 'path';
6
+ import { expandHome, profileFor, profilesPath, readProfiles, writeProfiles } from '@rigour-labs/core/profile';
7
+ import { homeFor, rigourUserDir } from '@rigour-labs/core';
7
8
  export function profileListCommand() {
8
9
  const profiles = readProfiles();
9
10
  if (profiles.length === 0)
@@ -19,6 +20,15 @@ export function profileWhichCommand(cwd) {
19
20
  console.log(profile ? `${chalk.bold(profile.name)} (matched ${cwd})` : chalk.dim('No profile matches here: Rigour uses its default home.'));
20
21
  console.log(chalk.dim(` home: ${rigourUserDir()}`));
21
22
  }
23
+ /**
24
+ * A new profile's home, as one directory: a home ending in .rigour with no state nested in it is that folder's parent,
25
+ * so the state is that folder itself (user-state.ts homeFor). One with state nested in it (a home from before 6.11.1)
26
+ * is kept as given, where the live state is.
27
+ */
28
+ function profileHomeFor(home) {
29
+ const full = path.resolve(expandHome(home));
30
+ return path.basename(full) === '.rigour' ? homeFor(full) : home;
31
+ }
22
32
  export function profileAddCommand(name, options) {
23
33
  const list = (value) => (value ?? '').split(',').map(v => v.trim()).filter(Boolean);
24
34
  const teamParts = [options.organization, options.team, options.actor];
@@ -27,7 +37,7 @@ export function profileAddCommand(name, options) {
27
37
  const profile = {
28
38
  name,
29
39
  match: list(options.match),
30
- ...(options.home ? { home: options.home } : {}),
40
+ ...(options.home ? { home: profileHomeFor(options.home) } : {}),
31
41
  ...(options.organization ? {
32
42
  team: {
33
43
  organization: options.organization, team: options.team, actor: options.actor,
@@ -41,4 +51,6 @@ export function profileAddCommand(name, options) {
41
51
  throw new Error('--match needs at least one path or remote (github.com/acme/*).');
42
52
  writeProfiles([...readProfiles().filter(p => p.name !== name), profile]);
43
53
  console.log(chalk.green(`Profile ${name} saved to ${profilesPath()}.`));
54
+ if (profile.home)
55
+ console.log(chalk.dim(` Its state: ${path.join(homeFor(path.resolve(expandHome(profile.home))), '.rigour')}`));
44
56
  }
@@ -32,11 +32,13 @@ export async function checkRepoSetup(cwd, now = new Date(), toolCalls, installed
32
32
  { name: 'Windsurf', config: read('.windsurf/hooks.json') + home('.codeium/windsurf/hooks.json') },
33
33
  ].filter(a => a.config);
34
34
  const mcpConfig = read('.mcp.json') + read('.cursor/mcp.json') + home('.claude.json') + home('.cursor/mcp.json');
35
- const version = versionCheck(agents.map(a => a.config).join('\n'), mcpConfig, installed);
35
+ // The repository's own committed install: only `rigour setup --team` changes it (a plain setup leaves it as committed).
36
+ const setup = !personal && /rigour/i.test(agents.map(a => a.config).join('\n')) ? 'rigour setup --team' : 'rigour setup';
37
+ const version = versionCheck(agents.map(a => a.config).join('\n'), mcpConfig, installed, setup);
36
38
  return [
37
39
  configCheck(cwd, personal),
38
- editCheck(agents, now, events),
39
- stopCheck(agents.map(a => a.config).join('\n'), now, events),
40
+ editCheck(agents, now, events, setup),
41
+ stopCheck(agents.map(a => a.config).join('\n'), now, events, setup),
40
42
  mcpCheck(mcpConfig, now, calls),
41
43
  ...(version ? [version] : []),
42
44
  prCheck(cwd),
@@ -47,7 +49,7 @@ export async function checkRepoSetup(cwd, now = new Date(), toolCalls, installed
47
49
  * (`@rigour-labs/cli@6.12.4`), so an upgrade reaches them only when `rigour setup` rewrites the pin.
48
50
  * Nothing to say when nothing pins a version (a source checkout runs its own build).
49
51
  */
50
- function versionCheck(hooks, mcp, installed) {
52
+ function versionCheck(hooks, mcp, installed, setup) {
51
53
  const pins = (text, pkg) => [...new Set([...text.matchAll(new RegExp(`@rigour-labs/${pkg}@([0-9A-Za-z.-]+)`, 'g'))].map(m => m[1]))];
52
54
  const hookPins = pins(hooks, 'cli');
53
55
  const mcpPins = pins(mcp, 'mcp');
@@ -62,7 +64,7 @@ function versionCheck(hooks, mcp, installed) {
62
64
  ...mcpPins.filter(v => v !== installed).map(v => (/^\d+$/.test(v) || v === 'latest' ? `the MCP server floats on @${v}` : `the MCP server runs ${v}`)),
63
65
  ];
64
66
  return stale.length
65
- ? { id: 'version', name, state: 'broken', detail: `${stale.join(', ')}; installed is ${installed}`, fix: 'rigour setup' }
67
+ ? { id: 'version', name, state: 'broken', detail: `${stale.join(', ')}; installed is ${installed}`, fix: setup }
66
68
  : { id: 'version', name, state: 'working', detail: installed };
67
69
  }
68
70
  function configCheck(cwd, personal) {
@@ -73,22 +75,22 @@ function configCheck(cwd, personal) {
73
75
  : { id: 'config', name: 'Project settings', state: 'missing', detail: 'No rigour.yml: Rigour uses its defaults', fix: 'rigour setup' };
74
76
  }
75
77
  /** One check for every agent's edit hook: events do not say which agent fired them, so counts are not split. */
76
- function editCheck(agents, now, events) {
78
+ function editCheck(agents, now, events, setup) {
77
79
  const wired = agents.filter(a => a.config.includes('hooks check'));
78
80
  const name = wired.length ? `Checks ${listOf(wired.map(a => a.name))} as it writes` : 'Checks your agent as it writes';
79
81
  if (wired.length === 0)
80
- return { id: 'edit', name, state: 'missing', detail: 'No edit hook configured', fix: 'rigour setup' };
82
+ return { id: 'edit', name, state: 'missing', detail: 'No edit hook configured', fix: setup };
81
83
  const broken = wired.find(a => isOldEditHook(a.config));
82
84
  if (broken) {
83
85
  // setup rewrites Rigour's own entries where they live (the project, or the machine for a personal install).
84
- return { id: 'edit', name, state: 'broken', detail: `The ${broken.name} hook is the old form: it reads a variable the agent never sets, so no edit is checked`, fix: 'rigour setup' };
86
+ return { id: 'edit', name, state: 'broken', detail: `The ${broken.name} hook is the old form: it reads a variable the agent never sets, so no edit is checked`, fix: setup };
85
87
  }
86
88
  return fired('edit', name, events.filter(e => e.type === 'hook_check'), now, 'edit checks');
87
89
  }
88
- function stopCheck(config, now, events) {
90
+ function stopCheck(config, now, events, setup) {
89
91
  const name = 'Stops an agent finishing with a known bug';
90
92
  if (!config.includes('hooks stop'))
91
- return { id: 'stop', name, state: 'missing', detail: 'No stop hook configured', fix: 'rigour setup' };
93
+ return { id: 'stop', name, state: 'missing', detail: 'No stop hook configured', fix: setup };
92
94
  return fired('stop', name, events.filter(e => e.type === 'stop_review'), now, 'finish checks');
93
95
  }
94
96
  function mcpCheck(mcpJson, now, calls) {
@@ -0,0 +1,13 @@
1
+ import type { Config } from '@rigour-labs/core';
2
+ /** What a gate says instead of blocking. */
3
+ export declare const REPORT_ONLY_PREFIX = "Rigour (report only; nothing was blocked, `rigour setup --block` makes it block):";
4
+ /**
5
+ * Whether this repository's gates only report. The team's rigour.yml decides when it says either way: an explicit
6
+ * `hooks.report_only: false` is a floor a person's switch never lowers, as with the reviewer's caps. Unset, the
7
+ * person's switch applies.
8
+ */
9
+ export declare function reportOnly(cwd: string, config?: Config): boolean;
10
+ /** What a person's switch says where the team's rigour.yml requires blocking. */
11
+ export declare const TEAM_REQUIRES_BLOCKING = "Rigour: report-only is on for this clone, but the team's rigour.yml requires blocking here (hooks.report_only: false), so the gates block.";
12
+ /** Sets or clears the person's switch for this repository; false outside a git repository. */
13
+ export declare function setReportOnlyHere(cwd: string, on: boolean): boolean;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Report-only: a team's first week. Every gate (the edit check, the stop review, the push gate, git's pre-push hook
3
+ * included) says what it would have blocked and lets the work through; `rigour setup --block` makes them block.
4
+ * The scan for credentials before a tool call is not one of them and keeps denying a real secret.
5
+ *
6
+ * Read at run time, so the hook commands never change between the first week and after: from rigour.yml
7
+ * (`hooks.report_only`, a team's choice, written by `rigour setup --team --report-only`) or from a marker in the
8
+ * repository's git directory (a person's, written by `rigour setup --report-only`, never in the working tree).
9
+ */
10
+ import { spawnSync } from 'child_process';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ const MARKER = 'rigour-report-only';
14
+ /** What a gate says instead of blocking. */
15
+ export const REPORT_ONLY_PREFIX = 'Rigour (report only; nothing was blocked, `rigour setup --block` makes it block):';
16
+ function markerPath(cwd) {
17
+ const common = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, encoding: 'utf8' });
18
+ return common.status === 0 ? path.join(common.stdout.trim(), MARKER) : undefined;
19
+ }
20
+ /**
21
+ * Whether this repository's gates only report. The team's rigour.yml decides when it says either way: an explicit
22
+ * `hooks.report_only: false` is a floor a person's switch never lowers, as with the reviewer's caps. Unset, the
23
+ * person's switch applies.
24
+ */
25
+ export function reportOnly(cwd, config) {
26
+ const team = config?.hooks?.report_only;
27
+ const marker = markerPath(cwd);
28
+ const personal = !!marker && fs.existsSync(marker);
29
+ if (team === false && personal)
30
+ process.stderr.write(`${TEAM_REQUIRES_BLOCKING}\n`);
31
+ return team ?? personal;
32
+ }
33
+ /** What a person's switch says where the team's rigour.yml requires blocking. */
34
+ export const TEAM_REQUIRES_BLOCKING = "Rigour: report-only is on for this clone, but the team's rigour.yml requires blocking here (hooks.report_only: false), so the gates block.";
35
+ /** Sets or clears the person's switch for this repository; false outside a git repository. */
36
+ export function setReportOnlyHere(cwd, on) {
37
+ const marker = markerPath(cwd);
38
+ if (!marker)
39
+ return false;
40
+ if (on)
41
+ fs.writeFileSync(marker, 'Rigour reports and never blocks here (rigour setup --report-only). `rigour setup --block` makes it block.\n');
42
+ else
43
+ fs.rmSync(marker, { force: true });
44
+ return true;
45
+ }
@@ -4,6 +4,7 @@
4
4
  * meaning. `rigour setup` installs it unless asked not to, or the disk is too full; nothing else
5
5
  * ever does, so a hook or an MCP call never waits on a download.
6
6
  */
7
+ import { confirm } from '@inquirer/prompts';
7
8
  import chalk from 'chalk';
8
9
  import { execa } from 'execa';
9
10
  import fs from 'fs';
@@ -51,13 +52,24 @@ function freeBytes(dir) {
51
52
  return undefined;
52
53
  }
53
54
  }
55
+ /** What semantic search takes on disk, as measured per platform after setup's pruning (semantic-runtime.ts). */
56
+ const SIZE = 'about 250 MB on Linux, 280 MB on macOS';
57
+ /** A person at a terminal, not a script, a pipe or CI. */
58
+ function interactive() {
59
+ return !!process.stdin.isTTY && !!process.stdout.isTTY && !process.env.CI;
60
+ }
54
61
  /** The setup step, as a person reads it. */
55
62
  export async function setupSemantic(cwd) {
56
63
  if (locateTransformers(cwd)) {
57
64
  console.log(chalk.green('✔ Semantic search is on (local embeddings)'));
58
65
  return;
59
66
  }
60
- console.log(chalk.dim('Installing semantic search: about 250 MB on Linux, 280 MB on macOS, once for every Rigour version on this machine (skip with --no-semantic)...'));
67
+ // A person at a terminal says yes before a download this size; a script or CI installs as before (--no-semantic skips it).
68
+ if (interactive() && !await confirm({ message: `Install semantic search? ${SIZE}, once for every Rigour version on this machine. It lets recall and "is there already a helper for this?" work by meaning.`, default: true })) {
69
+ console.log(chalk.yellow('Semantic search is off. Recall and pattern matching use keywords until `rigour setup` installs it.'));
70
+ return;
71
+ }
72
+ console.log(chalk.dim(`Installing semantic search: ${SIZE}, once for every Rigour version on this machine (skip with --no-semantic)...`));
61
73
  const result = await ensureSemanticRuntime(cwd);
62
74
  if (result.state === 'installed' || result.state === 'present')
63
75
  console.log(chalk.green('✔ Semantic search is on (local embeddings)'));
@@ -4,5 +4,9 @@ export interface SetupOptions {
4
4
  brief?: boolean;
5
5
  team?: boolean;
6
6
  instructions?: boolean;
7
+ /** A team's first week: every gate says what it would have blocked and blocks nothing (report-only.ts). */
8
+ reportOnly?: boolean;
9
+ /** Every gate blocks again (clears report-only). */
10
+ block?: boolean;
7
11
  }
8
12
  export declare function setupCommand(cwd?: string, options?: SetupOptions): Promise<void>;