@rigour-labs/cli 6.10.1 → 6.11.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
@@ -403,6 +403,9 @@ program
403
403
  .option('--promote <id>', 'Accept a candidate as a lesson (recorded as your decision)')
404
404
  .option('--reject <id>', 'Reject a candidate or lesson: the judges are told this team decided against it')
405
405
  .option('--why <text>', 'Why, recorded with --promote or --reject')
406
+ .option('--compile', 'Propose a deterministic check for each verified lesson a template fits, and list the compiled checks')
407
+ .option('--approve-check <id>', 'Approve a compiled check: it runs on every review (a note unless gates.compiled_lessons.block)')
408
+ .option('--withdraw-check <id>', 'Take a compiled check back: it stops running')
406
409
  .option('--json', 'Output as JSON')
407
410
  .action(async (options) => {
408
411
  await learnReviewsCommand(process.cwd(), options);
@@ -48,9 +48,9 @@ export async function hooksStopCommand(tool, stdin, fallbackCwd) {
48
48
  appendTaskEvent(cwd, { kind: 'stop-review', agent: tool, session, blocked: decision.block, blocking: decision.blocking, files: decision.reviewedFiles });
49
49
  if (nothing)
50
50
  process.stderr.write(`Rigour stop review: nothing to review against ${decision.against}.\n`);
51
- countUsage('stop_review');
51
+ countUsage('stop_review', 1, { agent: tool });
52
52
  if (decision.block)
53
- countUsage(attempt > 1 ? 'stop_block_repeat' : 'stop_block');
53
+ countUsage(attempt > 1 ? 'stop_block_repeat' : 'stop_block', 1, { agent: tool });
54
54
  recordReviewed(cwd, session, fingerprint);
55
55
  const capture = recordReviewOutcome(cwd, decision.findings, decision.reviewedFiles, 'stop');
56
56
  await recordFixLessons(cwd, capture.fixes).catch(() => undefined); // learning never blocks the agent
@@ -760,9 +760,10 @@ export async function hooksCheckCommand(cwd, options = {}) {
760
760
  result: await runHookChecker({ cwd: group.root, files: group.files, timeout_ms: Number.isFinite(timeout) ? timeout : 5000, agentId }),
761
761
  })));
762
762
  const result = mergeHookResults(runs.map(r => r.result));
763
- countUsage('hook_check');
763
+ const counted = { agent: cursorMode ? 'cursor' : agentId };
764
+ countUsage('hook_check', 1, counted);
764
765
  for (const failure of result.failures)
765
- countUsage(`hook_finding:${failure.gate}`);
766
+ countUsage(`hook_finding:${failure.gate}`, 1, counted);
766
767
  await Promise.allSettled(runs.flatMap(({ root, files: repoFiles, result: repoResult }) => {
767
768
  const requestId = randomUUID();
768
769
  const outcome = repoResult.status === 'pass' ? 'success' : repoResult.status === 'fail' ? 'rejected' : 'error';
@@ -10,6 +10,11 @@ export interface LearnReviewsOptions {
10
10
  /** Rewrite each new point as the rule behind it with the team's reviewer CLI (memory, never training). */
11
11
  rules?: boolean;
12
12
  pr?: string;
13
+ /** Propose a deterministic check for every verified lesson a template fits, and list the compiled checks. */
14
+ compile?: boolean;
15
+ /** A person's decision on a compiled check: it runs (approve) or stops (withdraw). */
16
+ approveCheck?: string;
17
+ withdrawCheck?: string;
13
18
  }
14
19
  export declare function learnReviewsCommand(cwd: string, options: LearnReviewsOptions): Promise<void>;
15
20
  /** owner/name from the origin remote (https or ssh, any host alias). */
@@ -11,7 +11,7 @@ import { execFileSync } from 'child_process';
11
11
  import chalk from 'chalk';
12
12
  import path from 'path';
13
13
  import { personOf } from './git-identity.js';
14
- import { branchBase, decideLesson, defaultExec, githubToken, learnFromReviews, lessonsPath, readLessons, ruleWriterFor, ConfigSchema } from '@rigour-labs/core';
14
+ import { branchBase, decideCompiledCheck, decideLesson, defaultExec, proposeCompiledChecks, readCompiledChecks, suspension, githubToken, learnFromReviews, lessonsPath, readLessons, ruleWriterFor, ConfigSchema } from '@rigour-labs/core';
15
15
  import { loadConfig } from './review-config.js';
16
16
  export async function learnReviewsCommand(cwd, options) {
17
17
  if (options.promote)
@@ -20,6 +20,12 @@ export async function learnReviewsCommand(cwd, options) {
20
20
  return decide(cwd, options.reject, 'rejected', options.why);
21
21
  if (options.list)
22
22
  return list(cwd, options.json);
23
+ if (options.compile)
24
+ return compile(cwd, options.json);
25
+ if (options.approveCheck)
26
+ return decideCheck(cwd, options.approveCheck, 'active');
27
+ if (options.withdrawCheck)
28
+ return decideCheck(cwd, options.withdrawCheck, 'withdrawn');
23
29
  try {
24
30
  const config = await teamConfig(cwd);
25
31
  const writeRules = options.rules ? await ruleWriterFor(cwd, config, defaultExec, line => console.error(chalk.yellow(line))) : undefined;
@@ -56,6 +62,57 @@ function list(cwd, json) {
56
62
  if (lessons.length === 0)
57
63
  console.log('No review lessons yet. Run `rigour learn-reviews`.');
58
64
  }
65
+ /** Proposes checks for the verified lessons a template fits, then lists every compiled check and its state. */
66
+ function compile(cwd, json) {
67
+ const proposed = proposeCompiledChecks(cwd);
68
+ const checks = readCompiledChecks(cwd);
69
+ if (json)
70
+ return void console.log(JSON.stringify({ proposed: proposed.map(c => c.id), checks }, null, 2));
71
+ const label = { proposed: chalk.yellow('proposed '), active: chalk.green('active '), withdrawn: chalk.dim('withdrawn') };
72
+ const lessons = new Map(readLessons(cwd).map(l => [l.id, l]));
73
+ for (const c of checks) {
74
+ const suspended = suspension(c, lessons);
75
+ console.log(`${suspended ? chalk.red('suspended') : label[c.state]} ${chalk.dim(c.id)} ${c.files}: ${c.kind === 'forbid' ? `no \`${c.symbol}\`` : `\`${c.symbol}\` needs \`${c.with}\``} (${c.message})`);
76
+ if (suspended)
77
+ console.log(chalk.dim(` ${suspended}`));
78
+ if (c.state === 'proposed' && c.backtest)
79
+ console.log(chalk.dim(` ${backtestLine(c.backtest)}`));
80
+ }
81
+ if (checks.length === 0)
82
+ console.log('No verified lesson fits a check yet: a lesson compiles when it names its file and its symbols in backticks and says never, avoid, instead of, always or must.');
83
+ else if (proposed.length)
84
+ console.log(`\n${proposed.length} new proposal(s). A proposed check runs only once a person approves it: rigour learn-reviews --approve-check <id>`);
85
+ }
86
+ /** What the history says about a proposed check: counts, and a rate only from RATE_MIN. */
87
+ function backtestLine(b) {
88
+ const share = (s) => `${s.fired} of ${s.n}${s.rate === null ? '' : ` (${Math.round(s.rate * 100)}%)`}`;
89
+ return `fires on ${share(b.repeating)} merged pull request(s) a review found the lesson repeating in; on ${share(b.other)} other merged change(s) to its files`;
90
+ }
91
+ function decideCheck(cwd, id, state) {
92
+ try {
93
+ decideCheckOrThrow(cwd, id, state);
94
+ }
95
+ catch (error) {
96
+ console.error(chalk.red(error.message));
97
+ process.exitCode = 1;
98
+ }
99
+ }
100
+ function decideCheckOrThrow(cwd, id, state) {
101
+ // Who decided is committed with the check: a decision with no one to name is refused, never recorded empty.
102
+ const by = personOf(cwd);
103
+ if (by === 'unknown') {
104
+ console.error(chalk.red('No git email is set in this checkout (git config user.email): who approves or takes back a compiled check is committed with it.'));
105
+ process.exitCode = 1;
106
+ return;
107
+ }
108
+ const check = decideCompiledCheck(cwd, id, state, by);
109
+ if (!check) {
110
+ console.error(chalk.red(`No compiled check ${id}.`));
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+ console.log(state === 'active' ? chalk.green(`✔ Runs from now on (a note unless gates.compiled_lessons.block): ${check.message}`) : chalk.yellow(`✔ Taken back: ${check.message}`));
115
+ }
59
116
  /** A person's decision, kept as evidence with who made it (their git email) and why. */
60
117
  function decide(cwd, id, decision, why) {
61
118
  const lesson = decideLesson(cwd, id, decision, personOf(cwd), why);
@@ -16,7 +16,7 @@ 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, resolveSwitch, reviewChange, toReviewFinding, GitDiffError, mergeBaseOf, receiptReport, recordPrCatches, reviewerBlocks } from '@rigour-labs/core';
19
+ import { learningUsage, buildReviewTask, costBucket, diffFromGit, durationBucket, flushDailyUsage, Logger, LogLevel, resolveDeepOptions, trackUsage, resolveSwitch, reviewChange, toReviewFinding, GitDiffError, mergeBaseOf, receiptReport, recordPrCatches, reviewerBlocks } from '@rigour-labs/core';
20
20
  import { goalReport } from './review-goal.js';
21
21
  import { receiptFor } from './review-receipt.js';
22
22
  import { whatWasChecked } from './review-checked.js';
@@ -68,7 +68,7 @@ export async function reviewCommand(cwd, options = {}) {
68
68
  if (!isDeep && !receipt && !options.ci && !options.json && !options.githubSummary) {
69
69
  hintReviewTask(cwd, diff ?? diffFromGit(cwd, source), config.gates.deep?.router);
70
70
  }
71
- await reportUsage(result, isDeep, options, Date.now() - started);
71
+ await reportUsage(result, isDeep, options, Date.now() - started, cwd, config);
72
72
  process.exit(reviewer && reviewerBlocks(reviewer) ? Math.max(exitCodeFor(result), EXIT_FAIL) : exitCodeFor(result));
73
73
  }
74
74
  catch (error) {
@@ -250,7 +250,7 @@ function hintReviewTask(cwd, diff, router) {
250
250
  }
251
251
  }
252
252
  /** Anonymous usage (opt-in; TELEMETRY.md): counts and gate names only, never files or messages. */
253
- async function reportUsage(result, isDeep, options, ms) {
253
+ async function reportUsage(result, isDeep, options, ms, cwd, config) {
254
254
  const byGate = (findings) => findings.reduce((acc, f) => ({ ...acc, [f.id]: (acc[f.id] ?? 0) + 1 }), {});
255
255
  const deep = result.report?.stats.deep;
256
256
  await trackUsage('review_completed', {
@@ -267,7 +267,8 @@ async function reportUsage(result, isDeep, options, ms) {
267
267
  deep_cost_bucket: costBucket(deep?.cost_usd),
268
268
  duration: durationBucket(ms),
269
269
  }, { version: process.env.RIGOUR_CLI_VERSION });
270
- await flushDailyUsage();
270
+ // Once a day, what this repository's learning loop did goes with the day's counts (TELEMETRY.md).
271
+ await flushDailyUsage({ daily: () => learningUsage(cwd, config) });
271
272
  }
272
273
  /** A deep run that did not happen overrides the changed-line verdict. */
273
274
  function exitCodeFor(result) {
@@ -7,7 +7,7 @@
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 { type OutcomeMetrics, type AgentEvent, type LessonRecord, type ReviewLesson, type Story } from '@rigour-labs/core';
10
+ import { type CompiledCheck, type OutcomeMetrics, type AgentEvent, type LessonRecord, type ReviewLesson, type Story } from '@rigour-labs/core';
11
11
  export interface LessonJourney {
12
12
  id: string;
13
13
  text: string;
@@ -49,6 +49,10 @@ export interface StudioLearning {
49
49
  prRecorded: boolean;
50
50
  /** What happened after merges this checkout read (core outcomes/metrics.ts); absent before `rigour outcomes` has run. */
51
51
  outcomes?: OutcomeMetrics;
52
+ /** Lessons compiled into checks (core review-learning/compiled-lessons.ts), for a person to approve or take back; `suspended` says why one approved no longer runs. */
53
+ compiled?: Array<CompiledCheck & {
54
+ suspended?: string;
55
+ }>;
52
56
  }
53
57
  export declare function buildLearning(input: {
54
58
  now: Date;
@@ -63,6 +67,15 @@ export declare function decideReviewLesson(cwd: string, body: unknown): {
63
67
  id: string;
64
68
  state: string;
65
69
  };
70
+ /**
71
+ * A person's decision on a compiled check from Studio, as `rigour learn-reviews --approve-check / --withdraw-check`
72
+ * records it: with their git email, committed with the check. With no git email set, it is refused.
73
+ */
74
+ export declare function decideCompiledCheckFromStudio(cwd: string, body: unknown): CompiledCheck;
75
+ /** Proposes checks for the verified lessons a template fits, each backtested on the main branch's history. */
76
+ export declare function proposeChecksFromStudio(cwd: string): {
77
+ proposed: number;
78
+ };
66
79
  export declare function loadLearning(cwd: string, now?: Date, weeks?: number): Promise<StudioLearning>;
67
80
  /**
68
81
  * "Fixed before: Credential header follows redirects (semantic-bugs). The header…" → the defect,
@@ -7,7 +7,7 @@
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 { decideLesson, fixLessonPrefix, localOutcomeMetrics, pendingDecision, listKnowledgeLessons, readLessons } from '@rigour-labs/core';
10
+ import { decideCompiledCheck, decideLesson, fixLessonPrefix, proposeCompiledChecks, readCompiledChecks, suspension, localOutcomeMetrics, pendingDecision, listKnowledgeLessons, readLessons } from '@rigour-labs/core';
11
11
  import { personOf } from './git-identity.js';
12
12
  import { checkoutRoots, eventsAcross, storiesAcross } from './studio-checkouts.js';
13
13
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -99,11 +99,35 @@ export function decideReviewLesson(cwd, body) {
99
99
  throw new Error(`no review lesson ${id}`);
100
100
  return { id: lesson.id, state: lesson.state };
101
101
  }
102
+ /**
103
+ * A person's decision on a compiled check from Studio, as `rigour learn-reviews --approve-check / --withdraw-check`
104
+ * records it: with their git email, committed with the check. With no git email set, it is refused.
105
+ */
106
+ export function decideCompiledCheckFromStudio(cwd, body) {
107
+ const { id, state } = (body ?? {});
108
+ if (typeof id !== 'string' || !/^c-[\w-]+$/.test(id))
109
+ throw new Error('a compiled check id is required');
110
+ if (state !== 'active' && state !== 'withdrawn')
111
+ throw new Error('state is active or withdrawn');
112
+ const by = personOf(cwd);
113
+ if (by === 'unknown')
114
+ throw new Error('no git email is set in this checkout (git config user.email): who decides a compiled check is committed with it');
115
+ const check = decideCompiledCheck(cwd, id, state, by);
116
+ if (!check)
117
+ throw new Error(`no compiled check ${id}`);
118
+ return check;
119
+ }
120
+ /** Proposes checks for the verified lessons a template fits, each backtested on the main branch's history. */
121
+ export function proposeChecksFromStudio(cwd) {
122
+ return { proposed: proposeCompiledChecks(cwd).length };
123
+ }
102
124
  export async function loadLearning(cwd, now = new Date(), weeks = WEEKS) {
103
125
  const roots = checkoutRoots(cwd);
104
126
  const learning = buildLearning({ now, lessons: await listKnowledgeLessons(cwd), reviewLessons: readLessons(cwd), stories: storiesAcross(roots), events: eventsAcross(roots), weeks });
105
127
  const outcomes = localOutcomeMetrics(cwd);
106
- return outcomes ? { ...learning, outcomes } : learning;
128
+ const lessons = new Map(readLessons(cwd).map(l => [l.id, l]));
129
+ const withChecks = { ...learning, compiled: readCompiledChecks(cwd).map(c => ({ ...c, ...(suspension(c, lessons) ? { suspended: suspension(c, lessons) } : {}) })) };
130
+ return outcomes ? { ...withChecks, outcomes } : withChecks;
107
131
  }
108
132
  /**
109
133
  * "Fixed before: Credential header follows redirects (semantic-bugs). The header…" → the defect,
@@ -323,6 +323,17 @@ async function handleApiRequest(req, res, url, ctx) {
323
323
  }
324
324
  return true;
325
325
  }
326
+ if (url.pathname === '/api/compiled-checks' && req.method === 'POST') {
327
+ try {
328
+ const { decideCompiledCheckFromStudio, proposeChecksFromStudio } = await import('./studio-learning.js');
329
+ const body = JSON.parse((await readBody(req)) || '{}');
330
+ sendJson(res, 200, body?.propose === true ? proposeChecksFromStudio(cwd) : decideCompiledCheckFromStudio(cwd, body));
331
+ }
332
+ catch (e) {
333
+ sendJson(res, 400, { error: e.message });
334
+ }
335
+ return true;
336
+ }
326
337
  if (url.pathname === '/api/learning') {
327
338
  try {
328
339
  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.10.1",
3
+ "version": "6.11.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.10.1"
62
+ "@rigour-labs/core": "6.11.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.10.1"
68
+ "@rigour-labs/studio": "6.11.0-rc.2"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsc && pnpm bundle-studio",