@rigour-labs/cli 7.0.0-rc.5 → 7.0.0-rc.7

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
@@ -406,7 +406,9 @@ program
406
406
  .option('--include-bots', 'With --list: also list candidates only review bots raised (hidden by default)')
407
407
  .option('--promote <id>', 'Accept a candidate as a lesson (recorded as your decision)')
408
408
  .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')
409
+ .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')
410
+ .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')
411
+ .option('--why <text>', 'Why, recorded with --promote, --reject, --not-a-rule or --take-back')
410
412
  .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
413
  .option('--to <reach>', 'With --scope: file, folder or repo')
412
414
  .option('--use-wording <id>', 'Take the corrected wording a newer version suggests for a lesson you decided (the old wording is kept as evidence)')
@@ -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)}`] };
@@ -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) {
@@ -50,6 +50,8 @@ export interface LessonJourney {
50
50
  };
51
51
  /** A candidate only review bots raised, nothing waiting on a person (core quietBotCandidate): hidden until a person asks to see bot points. */
52
52
  fromBots?: true;
53
+ /** A person's last decision on a review lesson (promoted, rejected, not a rule) can be taken back to a candidate (core takeBackable). */
54
+ canTakeBack?: true;
53
55
  /**
54
56
  * Teammates' decisions on a review lesson, received from the team database (core team-decisions.ts): each with the
55
57
  * teammate's display name and when the team database got it; and, when a later team decision settled the lesson
@@ -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 { acceptSuggestedText, scopeLesson, decideCompiledCheck, decideLesson, fixLessonPrefix, proposeCompiledChecks, readCompiledChecks, suspension, localOutcomeMetrics, pendingDecision, quietBotCandidate, listKnowledgeLessons, readLessons, lastDecision, readTeamDecisionCache } from '@rigour-labs/core';
10
+ import { acceptSuggestedText, scopeLesson, decideCompiledCheck, decideLesson, fixLessonPrefix, proposeCompiledChecks, readCompiledChecks, suspension, localOutcomeMetrics, pendingDecision, quietBotCandidate, listKnowledgeLessons, readLessons, lastDecision, takeBackable, readTeamDecisionCache } from '@rigour-labs/core';
11
11
  import { decider } from './git-identity.js';
12
12
  import { checkoutRoots, eventsAcross, storiesAcross } from './studio-checkouts.js';
13
13
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -53,6 +53,7 @@ export function buildLearning(input) {
53
53
  ...(l.suggestedText ? { suggestedText: { text: l.suggestedText, why: l.suggestedWhy ?? 'reworded' } } : {}),
54
54
  reach: { scope: l.scope ?? 'file', hasFile: !!l.file },
55
55
  ...(quietBotCandidate(l) ? { fromBots: true } : {}),
56
+ ...(takeBackable(l) ? { canTakeBack: true } : {}),
56
57
  })),
57
58
  // Lessons back to a candidate when outcomes stopped promoting come first: a person decides each once.
58
59
  ].sort((a, b) => Number(!!b.reclassified) - Number(!!a.reclassified) || b.learnedAt.localeCompare(a.learnedAt))
@@ -107,6 +108,7 @@ function decisionFor(lesson) {
107
108
  function studioWhy(why) {
108
109
  return typeof why === 'string' && why.trim() ? why.trim() : 'decided in Studio';
109
110
  }
111
+ const REVIEW_DECISIONS = new Set(['accepted', 'rejected', 'dismissed', 'not-a-rule', 'took-back']);
110
112
  /** 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. */
111
113
  export function decideReviewLesson(cwd, body) {
112
114
  const { id, decision, why } = (body ?? {});
@@ -116,8 +118,8 @@ export function decideReviewLesson(cwd, body) {
116
118
  return rewordFromStudio(cwd, id);
117
119
  if (decision === 'scope')
118
120
  return scopeFromStudio(cwd, id, body.to, why);
119
- if (decision !== 'accepted' && decision !== 'rejected' && decision !== 'dismissed')
120
- throw new Error('decision is accepted, rejected, dismissed, reworded or scope');
121
+ if (!REVIEW_DECISIONS.has(decision))
122
+ throw new Error('decision is accepted, rejected, dismissed, not-a-rule, took-back, reworded or scope');
121
123
  const lesson = decideLesson(cwd, id, decision, decider(cwd), studioWhy(why));
122
124
  if (!lesson)
123
125
  throw new Error(`no review lesson ${id}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/cli",
3
- "version": "7.0.0-rc.5",
3
+ "version": "7.0.0-rc.7",
4
4
  "description": "Code review that happens while your agent writes: deterministic checks on the lines it changed, quiet unless it can prove a defect, and learning from your team's fixes and dismissals. Works with Claude Code, Cursor, Codex, Cline and Windsurf.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -59,12 +59,12 @@
59
59
  "globby": "^14.0.1",
60
60
  "ora": "^8.0.1",
61
61
  "yaml": "^2.8.2",
62
- "@rigour-labs/core": "7.0.0-rc.5"
62
+ "@rigour-labs/core": "7.0.0-rc.7"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/fs-extra": "^11.0.4",
66
66
  "@types/node": "^25.0.3",
67
- "@rigour-labs/studio": "7.0.0-rc.5"
67
+ "@rigour-labs/studio": "7.0.0-rc.7"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "tsc && pnpm bundle-studio",