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

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')
@@ -0,0 +1 @@
1
+ export declare function personOf(cwd: string): string;
@@ -0,0 +1,10 @@
1
+ /** Who a person's decision is recorded as: their git email in the checkout, or `unknown` without one. */
2
+ import { execFileSync } from 'child_process';
3
+ export function personOf(cwd) {
4
+ try {
5
+ return execFileSync('git', ['config', 'user.email'], { cwd, encoding: 'utf8' }).trim() || 'unknown';
6
+ }
7
+ catch {
8
+ return 'unknown'; // no git identity: recorded as unknown
9
+ }
10
+ }
@@ -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)
@@ -10,6 +10,7 @@
10
10
  import { execFileSync } from 'child_process';
11
11
  import chalk from 'chalk';
12
12
  import path from 'path';
13
+ import { personOf } from './git-identity.js';
13
14
  import { branchBase, decideLesson, defaultExec, githubToken, learnFromReviews, lessonsPath, readLessons, ruleWriterFor, ConfigSchema } from '@rigour-labs/core';
14
15
  import { loadConfig } from './review-config.js';
15
16
  export async function learnReviewsCommand(cwd, options) {
@@ -57,14 +58,7 @@ function list(cwd, json) {
57
58
  }
58
59
  /** A person's decision, kept as evidence with who made it (their git email) and why. */
59
60
  function decide(cwd, id, decision, why) {
60
- let by = 'unknown';
61
- try {
62
- by = execFileSync('git', ['config', 'user.email'], { cwd, encoding: 'utf8' }).trim() || by;
63
- }
64
- catch {
65
- // no git identity: recorded as unknown
66
- }
67
- const lesson = decideLesson(cwd, id, decision, by, why);
61
+ const lesson = decideLesson(cwd, id, decision, personOf(cwd), why);
68
62
  if (!lesson) {
69
63
  console.error(chalk.red(`No lesson ${id}.`));
70
64
  process.exitCode = 1;
@@ -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,39 @@
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
+ if (run.lessons?.added)
31
+ console.log(`Lessons: ${run.lessons.added} new piece(s) of evidence, ${run.lessons.suggested.length} to look at in Studio (a later fix changed their lines), ${run.lessons.demoted.length} taken back.`);
32
+ return 0;
33
+ }
34
+ function outcomeLine(o) {
35
+ const fixes = o.followUps.filter(f => f.fix).length;
36
+ const ci = o.ci === 'failure' ? chalk.red('CI failed') : o.ci === 'success' ? chalk.green('CI passed') : chalk.dim(`CI ${o.ci}`);
37
+ const after = `${o.followUps.length} later commit(s) on its files${fixes ? `, ${chalk.yellow(`${fixes} fix(es)`)}` : ''}`;
38
+ 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)')}`;
39
+ }
@@ -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,
@@ -21,6 +21,18 @@ export interface LessonJourney {
21
21
  stoppedInDevelopment: number | null;
22
22
  reachedPr: number | null;
23
23
  canDecide: boolean;
24
+ /** A review lesson evidence took back (review-learning/outcome-evidence.ts): why, and the pull requests that did. A person may promote it again. */
25
+ takenBack?: {
26
+ detail: string;
27
+ prs: number[];
28
+ at: string;
29
+ };
30
+ /** A later fix changed the point's own lines (review-learning/outcome-evidence.ts): evidence for a person to promote or dismiss, never a promotion on its own. */
31
+ suggested?: {
32
+ detail: string;
33
+ pr: number;
34
+ at: string;
35
+ };
24
36
  }
25
37
  export interface StudioLearning {
26
38
  lessons: LessonJourney[];
@@ -39,6 +51,11 @@ export declare function buildLearning(input: {
39
51
  events: AgentEvent[];
40
52
  weeks?: number;
41
53
  }): StudioLearning;
54
+ /** A person's decision on a review lesson from Studio, recorded as `rigour learn-reviews --promote / --reject` records it: with their git email, and final. */
55
+ export declare function decideReviewLesson(cwd: string, body: unknown): {
56
+ id: string;
57
+ state: string;
58
+ };
42
59
  export declare function loadLearning(cwd: string, now?: Date, weeks?: number): Promise<StudioLearning>;
43
60
  /**
44
61
  * "Fixed before: Credential header follows redirects (semantic-bugs). The header…" → the defect,
@@ -7,7 +7,8 @@
7
7
  * lesson's subject prefix). Counts that Rigour cannot know here are null, never 0: PR catches
8
8
  * recorded on another machine (CI) never reach this one.
9
9
  */
10
- import { fixLessonPrefix, listKnowledgeLessons, readLessons } from '@rigour-labs/core';
10
+ import { decideLesson, fixLessonPrefix, listKnowledgeLessons, readLessons } from '@rigour-labs/core';
11
+ import { personOf } from './git-identity.js';
11
12
  import { checkoutRoots, eventsAcross, storiesAcross } from './studio-checkouts.js';
12
13
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
13
14
  const WEEKS = 4;
@@ -40,14 +41,15 @@ export function buildLearning(input) {
40
41
  id: l.id,
41
42
  text: l.text,
42
43
  origin: 'pr',
43
- learnedFrom: `At PR ${[...new Set(l.evidence.map(e => `#${e.pr}`))].join(', ')}, from ${[...new Set(l.evidence.map(e => e.author))].join(', ')}`,
44
+ // Where it was learned: the review points, not the outcome or decision evidence added since.
45
+ learnedFrom: learnedFromPoints(l.evidence.filter(e => (e.kind ?? 'point') === 'point')),
44
46
  learnedAt: l.createdAt,
45
47
  state: l.state,
46
48
  scope: 'this repo',
47
49
  told: told(l.text),
48
50
  stoppedInDevelopment: null,
49
51
  reachedPr: null,
50
- canDecide: false,
52
+ ...decisionFor(l),
51
53
  })),
52
54
  ].sort((a, b) => b.learnedAt.localeCompare(a.learnedAt))
53
55
  // The same lesson learned in several places (this repo and personal lessons from others) shows once.
@@ -65,6 +67,36 @@ export function buildLearning(input) {
65
67
  });
66
68
  return { lessons, weeks, prRecorded };
67
69
  }
70
+ function learnedFromPoints(points) {
71
+ const authors = [...new Set(points.map(e => e.author).filter(Boolean))];
72
+ return `At PR ${[...new Set(points.map(e => `#${e.pr}`))].join(', ')}${authors.length ? `, from ${authors.join(', ')}` : ''}`;
73
+ }
74
+ /**
75
+ * What a person can decide on a review lesson, from the last of its evidence and decisions: taken back by evidence
76
+ * (promote it again, or drop it), or a candidate with a later fix on its lines (promote it, or dismiss the evidence).
77
+ */
78
+ function decisionFor(lesson) {
79
+ if (lesson.state !== 'candidate')
80
+ return { canDecide: false };
81
+ const last = lesson.evidence.filter(e => e.kind === 'demoted' || e.kind === 'lines' || e.kind === 'accepted' || e.kind === 'rejected' || e.kind === 'dismissed').at(-1);
82
+ if (last?.kind === 'demoted')
83
+ return { canDecide: true, takenBack: { detail: last.detail ?? '', prs: lesson.evidence.filter(e => e.kind === 'against').map(e => e.pr), at: last.at ?? '' } };
84
+ if (last?.kind === 'lines')
85
+ return { canDecide: true, suggested: { detail: last.detail ?? '', pr: last.pr, at: last.at ?? '' } };
86
+ return { canDecide: false };
87
+ }
88
+ /** A person's decision on a review lesson from Studio, recorded as `rigour learn-reviews --promote / --reject` records it: with their git email, and final. */
89
+ export function decideReviewLesson(cwd, body) {
90
+ const { id, decision, why } = (body ?? {});
91
+ if (typeof id !== 'string' || !/^[0-9a-f]{12}$/.test(id))
92
+ throw new Error('a review lesson id (12 hex characters) is required');
93
+ if (decision !== 'accepted' && decision !== 'rejected' && decision !== 'dismissed')
94
+ throw new Error('decision is accepted, rejected or dismissed');
95
+ const lesson = decideLesson(cwd, id, decision, personOf(cwd), typeof why === 'string' && why.trim() ? why.trim() : 'decided in Studio');
96
+ if (!lesson)
97
+ throw new Error(`no review lesson ${id}`);
98
+ return { id: lesson.id, state: lesson.state };
99
+ }
68
100
  export async function loadLearning(cwd, now = new Date(), weeks = WEEKS) {
69
101
  const roots = checkoutRoots(cwd);
70
102
  return buildLearning({ now, lessons: await listKnowledgeLessons(cwd), reviewLessons: readLessons(cwd), stories: storiesAcross(roots), events: eventsAcross(roots), weeks });
@@ -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,31 @@ 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
+ }
309
+ if (url.pathname === '/api/review-lessons' && req.method === 'POST') {
310
+ try {
311
+ const { decideReviewLesson } = await import('./studio-learning.js');
312
+ sendJson(res, 200, decideReviewLesson(cwd, JSON.parse((await readBody(req)) || '{}')));
313
+ }
314
+ catch (e) {
315
+ sendJson(res, 400, { error: e.message });
316
+ }
317
+ return true;
318
+ }
294
319
  if (url.pathname === '/api/learning') {
295
320
  try {
296
321
  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.2",
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.2"
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.2"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsc && pnpm bundle-studio",