@rigour-labs/cli 6.8.1 → 6.9.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ 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
17
  import { threadCommand } from './commands/thread.js';
18
+ import { outcomesCommand } from './commands/outcomes.js';
18
19
  import { briefCommand, hooksBriefCommand, hooksBriefFileCommand } from './commands/brief.js';
19
20
  import { hooksPushCommand } from './commands/hooks-push.js';
20
21
  import { hooksReviewBackgroundCommand } from './commands/hooks-review-background.js';
@@ -259,6 +260,8 @@ program
259
260
  .option('--max', 'Use the strongest local model (Qwen2.5-Coder-7B, 4.7GB; 16GB RAM)')
260
261
  .option('--model-path <gguf>', 'Run a local GGUF instead of the published model (to evaluate a fine-tune)')
261
262
  .option('--pr-body <path>', 'File with the PR description, read by --max and cloud review (default: the GitHub Actions pull request)')
263
+ .option('--goal', 'Check the change against the goal its PR description declares (Scope, Out of scope, Done when), whatever your settings say')
264
+ .option('--no-goal', 'Do not check the change against its PR description for this run (a team that requires it refuses this)')
262
265
  .option('--independent', 'Trust nothing the change wrote, for an enforcing check: review every risky function whatever agents recorded as reviewed, and with --base read dismissals, check outcomes and rigour.yml from the base')
263
266
  .option('--diff-tests', 'Run changed exported functions before and after the change and report behaviour changes (vitest/jest packages; needs --max or -k)')
264
267
  .option('-k, --api-key <key>', 'Cloud API key for deep analysis')
@@ -520,6 +523,17 @@ program
520
523
  .action(async (goal, options) => {
521
524
  process.exit(await briefCommand(process.cwd(), goal, options));
522
525
  });
526
+ program
527
+ .command('outcomes')
528
+ .description('What happened after recent pull requests merged: CI on the merge commit, later commits and fixes on their files, reverts. Needs the outcome loop on (learning.outcomes.mode, RIGOUR_OUTCOMES, or --outcomes)')
529
+ .option('--pr <number>', 'Only this merged pull request')
530
+ .option('--last <n>', 'The last n merged pull requests (default 20)')
531
+ .option('--outcomes', 'Read outcomes for this run, whatever your settings say')
532
+ .option('--no-outcomes', 'Do not read outcomes for this run (a team that requires them refuses this)')
533
+ .option('--json', 'Output the records in JSON format')
534
+ .action(async (options) => {
535
+ process.exit(await outcomesCommand(process.cwd(), options));
536
+ });
523
537
  program
524
538
  .command('thread [key]')
525
539
  .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')
@@ -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 { appendTaskEvent, branchBase, branchFailures, captureHumanEdits, diffFromGit, itemLine, mergeBaseOf, resolveReviewer, reviewChange, reviewerInputs, reviewerBlocks, runReviewer, runToolchain, startBackgroundReview, } from '@rigour-labs/core';
19
+ import { appendTaskEvent, branchBase, branchFailures, captureHumanEdits, diffFromGit, hookGoalDescription, itemLine, recordGoal, 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);
@@ -61,7 +61,9 @@ async function gates(repo, base, mainRef, config) {
61
61
  const log = [];
62
62
  const source = { mode: 'since', commit: base };
63
63
  const diff = diffFromGit(repo, source);
64
- const review = await reviewChange({ cwd: repo, config, diff, source, typed: true });
64
+ const goalDescription = await hookGoalDescription(repo, config);
65
+ const review = await reviewChange({ cwd: repo, config, diff, source, typed: true, ...(goalDescription !== undefined ? { goalDescription } : {}) });
66
+ recordGoal(repo, 'push', goalDescription, review);
65
67
  // What the review reports is what must be fixed (quiet.ts `mustFix`); the stop hook uses the same set.
66
68
  const mustFix = [...review.findings, ...branchFailures(repo, base, mainRef, config)];
67
69
  for (const f of mustFix)
@@ -0,0 +1,6 @@
1
+ export declare function outcomesCommand(cwd: string, options: {
2
+ pr?: string;
3
+ last?: string;
4
+ outcomes?: boolean;
5
+ json?: boolean;
6
+ }): Promise<number>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `rigour outcomes`: what happened after the repository's recent pull requests merged (core outcomes/): CI on each merge
3
+ * commit, the later commits on its files and the fixes among them, and a revert. Read when the outcome loop is on
4
+ * (learning.outcomes.mode, RIGOUR_OUTCOMES, your settings, or --outcomes for this run); kept in .rigour/outcomes.json.
5
+ */
6
+ import chalk from 'chalk';
7
+ import { runOutcomes } from '@rigour-labs/core';
8
+ import { loadConfig } from './review-config.js';
9
+ export async function outcomesCommand(cwd, options) {
10
+ const config = await loadConfig(cwd, {});
11
+ const run = await runOutcomes(cwd, config, {
12
+ ...(options.outcomes !== undefined ? { flag: options.outcomes } : {}),
13
+ ...(options.pr ? { pr: Number(options.pr.replace(/^#/, '')) } : {}),
14
+ ...(options.last ? { last: Number(options.last) } : {}),
15
+ });
16
+ // Off is a choice, not a failure: a CI step that runs this for every team stays green where outcomes are off.
17
+ if (options.json) {
18
+ console.log(JSON.stringify(run, null, 2));
19
+ return 0;
20
+ }
21
+ for (const line of run.switch.refused)
22
+ console.error(chalk.yellow(`Not applied: ${line}`));
23
+ if (!run.switch.enabled) {
24
+ console.log(chalk.dim(`Nothing read (${run.switch.source}): ${run.stopped}.`));
25
+ return 0;
26
+ }
27
+ for (const o of run.outcomes)
28
+ console.log(outcomeLine(o));
29
+ console.log(chalk.dim(`${run.outcomes.length} pull request(s), ${run.read} read now, the rest settled.${run.stopped ? ` Stopped early: ${run.stopped}.` : ''}`));
30
+ return 0;
31
+ }
32
+ function outcomeLine(o) {
33
+ const fixes = o.followUps.filter(f => f.fix).length;
34
+ const ci = o.ci === 'failure' ? chalk.red('CI failed') : o.ci === 'success' ? chalk.green('CI passed') : chalk.dim(`CI ${o.ci}`);
35
+ const after = `${o.followUps.length} later commit(s) on its files${fixes ? `, ${chalk.yellow(`${fixes} fix(es)`)}` : ''}`;
36
+ return `#${o.pr} ${o.mergeSha.slice(0, 9)} merged ${o.mergedAt.slice(0, 10)}: ${ci}, ${after}${o.reverted ? `, ${chalk.red(`reverted by ${o.reverted.sha.slice(0, 9)}`)}` : ''}${o.settled ? '' : chalk.dim(' (window open)')}`;
37
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The goal check's record for `rigour review --json`: whether it ran, which layer decided, what the description
3
+ * declared, and why a check that was on checked nothing (docs/GOAL.md).
4
+ */
5
+ import type { ResolvedSwitch, ReviewResult } from '@rigour-labs/core';
6
+ /** What the goal check did on this run, and why: for the JSON record and a line in the human verdict. */
7
+ export interface GoalReport {
8
+ enabled: boolean;
9
+ source: ResolvedSwitch['source'];
10
+ required: boolean;
11
+ refused: string[];
12
+ /** Whether the description declared anything to check (Scope, Out of scope, or a Done when item naming a file or symbol). */
13
+ declared: boolean;
14
+ scope?: string[];
15
+ out_of_scope?: string[];
16
+ done_when?: number;
17
+ /** Why a check that is on checked nothing. */
18
+ reason?: string;
19
+ }
20
+ export declare function goalReport(goal: ResolvedSwitch, description: string | undefined, result: Pick<ReviewResult, 'goal'>): GoalReport;
@@ -0,0 +1,8 @@
1
+ export function goalReport(goal, description, result) {
2
+ return {
3
+ enabled: goal.enabled, source: goal.source, required: goal.required, refused: goal.refused, declared: !!result.goal,
4
+ ...(result.goal ? { scope: result.goal.scope, out_of_scope: result.goal.outOfScope, done_when: result.goal.doneWhen.length } : {}),
5
+ ...(goal.enabled && description === undefined ? { reason: 'no pull request description: pass --pr-body, or run in a pull request\'s GitHub Actions job' } : {}),
6
+ ...(goal.enabled && description !== undefined && !result.goal ? { reason: 'the description declares no Scope, Out of scope, or Done when item naming a file or symbol' } : {}),
7
+ };
8
+ }
@@ -1,7 +1,7 @@
1
1
  import { type Config, type ReviewerResult, type ReviewResult, type RunChoice } from '@rigour-labs/core';
2
2
  /** The base a branch review runs against: the one named, else where the branch left main. */
3
3
  export declare function reviewerBase(cwd: string, named: string | undefined): string | undefined;
4
- export declare function reviewerFor(cwd: string, base: string | undefined, config: Config, full: boolean, choice: RunChoice, review: ReviewResult): Promise<ReviewerResult>;
4
+ export declare function reviewerFor(cwd: string, base: string | undefined, config: Config, full: boolean, choice: RunChoice, review: ReviewResult, goal?: boolean): Promise<ReviewerResult>;
5
5
  export declare function printReviewer(result: ReviewerResult, options?: {
6
6
  notes?: boolean;
7
7
  }): void;
@@ -12,10 +12,10 @@ import { branchBase, itemLine, reviewerInputs, reviewStatus, runReviewer } from
12
12
  export function reviewerBase(cwd, named) {
13
13
  return named ?? branchBase(cwd)?.mainRef.replace(/^refs\/(remotes\/|heads\/)/, '');
14
14
  }
15
- export async function reviewerFor(cwd, base, config, full, choice, review) {
15
+ export async function reviewerFor(cwd, base, config, full, choice, review, goal) {
16
16
  if (!base)
17
17
  return { outcome: 'unavailable', items: [], unverified: [], resolved: [], answerInReply: [], notes: [], advisory: [], disputed: [], dropped: [], dismissed: [], reason: 'no base to review against: pass --base, or fetch the main branch', reviewers: [], cached: false };
18
- return runReviewer(cwd, base, config, undefined, undefined, { trigger: 'review', full, choice, ...reviewerInputs(review) });
18
+ return runReviewer(cwd, base, config, undefined, undefined, { trigger: 'review', full, choice, ...(goal !== undefined ? { goal } : {}), ...reviewerInputs(review) });
19
19
  }
20
20
  /** Verified should-fixes shown in full before the rest fold into a count. */
21
21
  const ADVISORY_SHOWN = 5;
@@ -12,6 +12,7 @@ export interface ReviewOptions {
12
12
  max?: boolean;
13
13
  modelPath?: string;
14
14
  prBody?: string;
15
+ goal?: boolean;
15
16
  diffTests?: boolean;
16
17
  independent?: boolean;
17
18
  apiKey?: string;
@@ -16,7 +16,8 @@ import { execFileSync } from 'child_process';
16
16
  import fs from 'fs-extra';
17
17
  import path from 'path';
18
18
  import chalk from 'chalk';
19
- import { buildReviewTask, costBucket, diffFromGit, durationBucket, flushDailyUsage, Logger, LogLevel, resolveDeepOptions, trackUsage, reviewChange, toReviewFinding, GitDiffError, mergeBaseOf, receiptReport, recordPrCatches, reviewerBlocks } from '@rigour-labs/core';
19
+ import { buildReviewTask, costBucket, diffFromGit, durationBucket, flushDailyUsage, Logger, LogLevel, resolveDeepOptions, trackUsage, resolveSwitch, reviewChange, toReviewFinding, GitDiffError, mergeBaseOf, receiptReport, recordPrCatches, reviewerBlocks } from '@rigour-labs/core';
20
+ import { goalReport } from './review-goal.js';
20
21
  import { receiptFor } from './review-receipt.js';
21
22
  import { whatWasChecked } from './review-checked.js';
22
23
  import { printReviewer, printStatus, reviewerBase, reviewerFor, reviewerJson } from './review-reviewer.js';
@@ -47,6 +48,8 @@ export async function reviewCommand(cwd, options = {}) {
47
48
  if (!options.ci && !options.json && !options.githubSummary && isDeep)
48
49
  console.log(chalk.blue.bold('Deep analysis enabled.\n'));
49
50
  const source = options.base ? { mode: 'base', base: options.base } : { mode: 'working' };
51
+ const goal = resolveSwitch('goal', config, options.goal);
52
+ const goalDescription = goal.enabled ? readPrBody(cwd, options) : undefined;
50
53
  const result = await reviewChange({
51
54
  cwd, config, diff, source,
52
55
  files: options.files ? options.files.split(',').map(f => f.trim()).filter(Boolean) : undefined,
@@ -54,13 +57,14 @@ export async function reviewCommand(cwd, options = {}) {
54
57
  deep: isDeep ? deepOptions(cwd, options) : undefined,
55
58
  trustedRef,
56
59
  typed: true,
60
+ ...(goalDescription !== undefined ? { goalDescription } : {}),
57
61
  });
58
62
  if (options.base)
59
63
  recordPrCatches(cwd, result.findings);
60
64
  const receipt = receiptFor(cwd, diff ?? changeDiff(cwd, source), config, !!options.independent);
61
- const reviewer = options.reviewer ? await reviewerFor(cwd, reviewerBase(cwd, options.base), config, !!options.full, { ...(options.single ? { mode: 'single' } : {}), ...(options.panel !== undefined ? { panel: options.panel } : {}) }, result) : undefined;
65
+ const reviewer = options.reviewer ? await reviewerFor(cwd, reviewerBase(cwd, options.base), config, !!options.full, { ...(options.single ? { mode: 'single' } : {}), ...(options.panel !== undefined ? { panel: options.panel } : {}) }, result, options.goal) : undefined;
62
66
  const checked = options.json ? whatWasChecked(cwd, options, trustedRef, result) : undefined;
63
- await print(result, options, receipt, reviewer, { cwd, scope: scopeOf(options), commits: commitsOf(cwd, options.base), ms: Date.now() - started }, checked);
67
+ await print(result, options, receipt, reviewer, { cwd, scope: scopeOf(options), commits: commitsOf(cwd, options.base), ms: Date.now() - started }, checked, goalReport(goal, goalDescription, result));
64
68
  if (!isDeep && !receipt && !options.ci && !options.json && !options.githubSummary) {
65
69
  hintReviewTask(cwd, diff ?? diffFromGit(cwd, source), config.gates.deep?.router);
66
70
  }
@@ -147,10 +151,12 @@ function deepOptions(cwd, options) {
147
151
  independent: !!options.independent,
148
152
  };
149
153
  }
150
- async function print(result, options, receipt, reviewer, context, checked) {
154
+ async function print(result, options, receipt, reviewer, context, checked, goal) {
151
155
  const summary = buildCiReviewSummary(result.findings, result.report?.failures.length ?? 0, result.changedLines, result.unlocated + result.fileFindings.length);
152
156
  if (options.json)
153
- return writeJson(result, summary, receipt, reviewer, checked);
157
+ return writeJson(result, summary, receipt, reviewer, checked, goal);
158
+ if (goal?.refused.length && !options.githubSummary)
159
+ console.error(chalk.yellow(`Goal check: ${goal.refused.join('; ')}`));
154
160
  if (options.githubSummary || options.ci) {
155
161
  // The machine-read outputs say why a review is not a pass on stderr; the human one says it in its verdict.
156
162
  if (result.deepError)
@@ -190,11 +196,12 @@ function changeDiff(cwd, source) {
190
196
  return undefined;
191
197
  }
192
198
  }
193
- function writeJson(result, summary, receipt, reviewer, checked) {
199
+ function writeJson(result, summary, receipt, reviewer, checked, goal) {
194
200
  const stats = result.report?.stats;
195
201
  const json = JSON.stringify({
196
202
  status: result.status,
197
203
  ...(checked ? { checked } : {}),
204
+ ...(goal ? { goal } : {}),
198
205
  score: stats?.score ?? 100,
199
206
  ai_health_score: stats?.ai_health_score,
200
207
  structural_score: stats?.structural_score,
@@ -10,9 +10,9 @@
10
10
  import { execFileSync } from 'child_process';
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
- import YAML from 'yaml';
14
- import { ConfigSchema, dismissReviewerFinding, loadSettings, resolveReviewer, reviewerAvailability, reviewStatus, saveUserReviewer, } from '@rigour-labs/core';
13
+ import { dismissReviewerFinding, loadSettings, resolveReviewer, reviewerAvailability, reviewStatus, saveUserReviewer, } from '@rigour-labs/core';
15
14
  import { loadConfig } from './review-config.js';
15
+ import { writeTeamSettings } from './studio-team-settings.js';
16
16
  const TEAM_KEYS = ['enabled', 'mode', 'panel', 'mode_required', 'judges', 'escalate', 'reviewers', 'on_push', 'panel_max_items', 'dismissals'];
17
17
  export async function loadStudioReviewer(cwd) {
18
18
  const config = await loadConfig(cwd, {});
@@ -76,36 +76,6 @@ export async function saveTeamReviewer(cwd, body) {
76
76
  const unknown = Object.keys(patch).find(key => !TEAM_WRITABLE.has(key));
77
77
  if (unknown)
78
78
  throw new Error(`not a team reviewer setting: ${unknown}`);
79
- const file = path.join(cwd, 'rigour.yml');
80
- const exists = fs.existsSync(file);
81
- if (exists && fs.lstatSync(file).isSymbolicLink())
82
- throw new Error('rigour.yml is a link: edit the file it points to directly');
83
- if (!exists && create !== true)
84
- throw new Error('there is no rigour.yml: creating one makes this the team\'s setup, so confirm it (create: true)');
85
- const doc = exists ? YAML.parseDocument(fs.readFileSync(file, 'utf8')) : new YAML.Document({});
86
- if (doc.errors.length)
87
- throw new Error(`rigour.yml does not parse: ${doc.errors[0].message}`);
88
- for (const [key, value] of Object.entries(patch)) {
89
- if (value === null)
90
- doc.deleteIn(['review', 'reviewer', key]);
91
- else
92
- doc.setIn(['review', 'reviewer', key], value);
93
- }
94
- const parsed = ConfigSchema.safeParse(doc.toJS());
95
- if (!parsed.success)
96
- throw new Error(`that would not be a valid rigour.yml: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
97
- fs.writeFileSync(file, String(doc));
98
- return { ...(await loadStudioReviewer(cwd)), diff: diffOf(cwd, exists) };
99
- }
100
- /** What the person will commit: git's diff of rigour.yml, or the whole new file. */
101
- function diffOf(cwd, existed) {
102
- if (!existed)
103
- return fs.readFileSync(path.join(cwd, 'rigour.yml'), 'utf8').split('\n').filter(Boolean).map(line => `+${line}`).join('\n');
104
- try {
105
- return execFileSync('git', ['diff', '--no-color', '--unified=1', '--', 'rigour.yml'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
106
- .split('\n').filter(line => /^[+-](?![+-])/.test(line) || line.startsWith('@@')).join('\n');
107
- }
108
- catch {
109
- return '';
110
- }
79
+ const diff = writeTeamSettings(cwd, Object.entries(patch).map(([key, value]) => [['review', 'reviewer', key], value]), create === true);
80
+ return { ...(await loadStudioReviewer(cwd)), diff };
111
81
  }
@@ -0,0 +1,16 @@
1
+ import { type ResolvedSwitch, type SwitchName, type TeamSwitch } from '@rigour-labs/core';
2
+ export interface StudioSwitch {
3
+ effective: ResolvedSwitch;
4
+ team: TeamSwitch;
5
+ /** Whether the team's setting comes from a rigour.yml, or is Rigour's default. */
6
+ teamFile: boolean;
7
+ /** The person's own choice; null when they left it to the team. */
8
+ user: boolean | null;
9
+ }
10
+ /** A switch's name from a request path, or undefined when it names none. */
11
+ export declare function switchNamed(name: string): SwitchName | undefined;
12
+ export declare function loadStudioSwitch(cwd: string, name: SwitchName): Promise<StudioSwitch>;
13
+ /** `{ user: true | false | null }` changes the person's own; `{ team: 'off' | 'on' | 'required' | null, create? }` the team's. */
14
+ export declare function saveStudioSwitch(cwd: string, name: SwitchName, body: unknown): Promise<StudioSwitch & {
15
+ diff?: string;
16
+ }>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Studio's switches (core's switches.ts: the goal check, ...): whether each runs, where that choice comes from, and the
3
+ * person's and the team's settings. The person's goes to their settings.json; the team's to rigour.yml in the working
4
+ * tree, never committed (studio-team-settings.ts).
5
+ */
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { loadSettings, resolveSwitch, saveUserSwitch, SWITCHES } from '@rigour-labs/core';
9
+ import { loadConfig } from './review-config.js';
10
+ import { writeTeamSettings } from './studio-team-settings.js';
11
+ /** A switch's name from a request path, or undefined when it names none. */
12
+ export function switchNamed(name) {
13
+ return Object.hasOwn(SWITCHES, name) ? name : undefined;
14
+ }
15
+ export async function loadStudioSwitch(cwd, name) {
16
+ const config = await loadConfig(cwd, {});
17
+ const user = loadSettings()[name];
18
+ return {
19
+ // Studio shows what a run here would do without a flag; the environment is the terminal's, not the person's choice.
20
+ effective: resolveSwitch(name, config, undefined, user, {}),
21
+ team: SWITCHES[name].team(config) ?? 'off',
22
+ teamFile: fs.existsSync(path.join(cwd, 'rigour.yml')),
23
+ user: user ?? null,
24
+ };
25
+ }
26
+ /** `{ user: true | false | null }` changes the person's own; `{ team: 'off' | 'on' | 'required' | null, create? }` the team's. */
27
+ export async function saveStudioSwitch(cwd, name, body) {
28
+ const payload = (body ?? {});
29
+ if ('user' in payload === 'team' in payload)
30
+ throw new Error('send either user (true, false or null) or team (off, on, required or null)');
31
+ if ('user' in payload) {
32
+ saveUserSwitch(name, payload.user);
33
+ return loadStudioSwitch(cwd, name);
34
+ }
35
+ if (payload.team !== null && payload.team !== 'off' && payload.team !== 'on' && payload.team !== 'required')
36
+ throw new Error('team is off, on, required, or null for Rigour\'s default');
37
+ const diff = writeTeamSettings(cwd, [[SWITCHES[name].teamPath, payload.team]], payload.create === true);
38
+ return { ...(await loadStudioSwitch(cwd, name)), diff };
39
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Sets each key path to its value (`null` removes it: Rigour's default again) and returns the diff to commit. The whole
3
+ * file must still be a valid rigour.yml before it is written. A missing file is created only when asked (`create`),
4
+ * since that turns a personal install into the team's.
5
+ */
6
+ export declare function writeTeamSettings(cwd: string, edits: Array<[string[], unknown]>, create: boolean): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A team setting written from Studio: rigour.yml in the working tree, comments and layout kept, never committed. Team
3
+ * policy still reaches everyone through a reviewed commit, so the caller shows the diff to commit.
4
+ */
5
+ import { execFileSync } from 'child_process';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import YAML from 'yaml';
9
+ import { ConfigSchema } from '@rigour-labs/core';
10
+ /**
11
+ * Sets each key path to its value (`null` removes it: Rigour's default again) and returns the diff to commit. The whole
12
+ * file must still be a valid rigour.yml before it is written. A missing file is created only when asked (`create`),
13
+ * since that turns a personal install into the team's.
14
+ */
15
+ export function writeTeamSettings(cwd, edits, create) {
16
+ const file = path.join(cwd, 'rigour.yml');
17
+ const exists = fs.existsSync(file);
18
+ if (exists && fs.lstatSync(file).isSymbolicLink())
19
+ throw new Error('rigour.yml is a link: edit the file it points to directly');
20
+ if (!exists && !create)
21
+ throw new Error('there is no rigour.yml: creating one makes this the team\'s setup, so confirm it (create: true)');
22
+ const doc = exists ? YAML.parseDocument(fs.readFileSync(file, 'utf8')) : new YAML.Document({});
23
+ if (doc.errors.length)
24
+ throw new Error(`rigour.yml does not parse: ${doc.errors[0].message}`);
25
+ for (const [key, value] of edits) {
26
+ if (value === null)
27
+ doc.deleteIn(key);
28
+ else
29
+ doc.setIn(key, value);
30
+ }
31
+ const parsed = ConfigSchema.safeParse(doc.toJS());
32
+ if (!parsed.success)
33
+ throw new Error(`that would not be a valid rigour.yml: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
34
+ fs.writeFileSync(file, String(doc));
35
+ return diffOf(cwd, exists);
36
+ }
37
+ /** What the person will commit: git's diff of rigour.yml, or the whole new file. */
38
+ function diffOf(cwd, existed) {
39
+ if (!existed)
40
+ return fs.readFileSync(path.join(cwd, 'rigour.yml'), 'utf8').split('\n').filter(Boolean).map(line => `+${line}`).join('\n');
41
+ try {
42
+ return execFileSync('git', ['diff', '--no-color', '--unified=1', '--', 'rigour.yml'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
43
+ .split('\n').filter(line => /^[+-](?![+-])/.test(line) || line.startsWith('@@')).join('\n');
44
+ }
45
+ catch {
46
+ return '';
47
+ }
48
+ }
@@ -291,6 +291,21 @@ async function handleApiRequest(req, res, url, ctx) {
291
291
  }
292
292
  return true;
293
293
  }
294
+ const switchPath = /^\/api\/switches\/([a-z]+)$/.exec(url.pathname);
295
+ if (switchPath && (req.method === 'GET' || req.method === 'POST')) {
296
+ try {
297
+ const { loadStudioSwitch, saveStudioSwitch, switchNamed } = await import('./studio-switches.js');
298
+ const name = switchNamed(switchPath[1]);
299
+ if (!name)
300
+ sendJson(res, 404, { error: `no switch named ${switchPath[1]}` });
301
+ else
302
+ sendJson(res, 200, req.method === 'GET' ? await loadStudioSwitch(cwd, name) : await saveStudioSwitch(cwd, name, JSON.parse((await readBody(req)) || '{}')));
303
+ }
304
+ catch (e) {
305
+ sendJson(res, req.method === 'GET' ? 500 : 400, { error: e.message });
306
+ }
307
+ return true;
308
+ }
294
309
  if (url.pathname === '/api/learning') {
295
310
  try {
296
311
  const { loadLearning } = await import('./studio-learning.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/cli",
3
- "version": "6.8.1",
3
+ "version": "6.9.0-rc.1",
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,13 +59,13 @@
59
59
  "inquirer": "9.2.16",
60
60
  "ora": "^8.0.1",
61
61
  "yaml": "^2.8.2",
62
- "@rigour-labs/core": "6.8.1"
62
+ "@rigour-labs/core": "6.9.0-rc.1"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/fs-extra": "^11.0.4",
66
66
  "@types/inquirer": "9.0.7",
67
67
  "@types/node": "^25.0.3",
68
- "@rigour-labs/studio": "6.8.1"
68
+ "@rigour-labs/studio": "6.9.0-rc.1"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsc && pnpm bundle-studio",