@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.
@@ -5,9 +5,10 @@
5
5
  * at user level, guarded so they run only where Rigour is switched on; this repository is switched
6
6
  * on inside its git directory (personal.ts). For trying Rigour, or using it alone.
7
7
  *
8
- * Team (`--team`, or a repository that already commits a rigour.yml): rigour.yml, the project's
9
- * agent hooks and its MCP server are written into the repository, so everyone who clones gets the
10
- * same gate. Agent instruction files only with `--instructions`.
8
+ * Team (`--team`): rigour.yml, the project's agent hooks and its MCP server are written into the
9
+ * repository, so everyone who clones gets the same gate. Agent instruction files only with
10
+ * `--instructions`. Only `--team` ever writes into the working tree: in a repository that commits its
11
+ * own Rigour hooks, a plain `rigour setup` leaves every file as committed (the team's install runs).
11
12
  *
12
13
  * Both install semantic search once per machine (semantic.ts) unless --no-semantic, end with the
13
14
  * same checks as `rigour doctor`, and are taken back out by `rigour uninstall`.
@@ -18,22 +19,40 @@ import fs from 'fs';
18
19
  import path from 'path';
19
20
  import { printRepoSetup } from './doctor.js';
20
21
  import { installGitPushHook } from './hooks-git.js';
21
- import { hooksInitCommand, installMachineHooks, pinnedCliCommand } from './hooks.js';
22
+ import { hooksInitCommand, installMachineHooks, pinnedCliCommand, projectHookPaths } from './hooks.js';
22
23
  import { initCommand, registerProjectMcp, resolveMCPServerConfig } from './init.js';
23
24
  import { writeAgentInstructions } from './init-handshake.js';
24
25
  import { disableHere, enableHere, registerUserMcp } from './personal.js';
25
- import { applyConfigMigrations } from './setup-migrations.js';
26
+ import { applyConfigMigrations, isOldEditHook } from './setup-migrations.js';
27
+ import { getCliVersion } from '../utils/cli-version.js';
26
28
  import { setupSemantic } from './semantic.js';
29
+ import { setReportOnlyHere, TEAM_REQUIRES_BLOCKING } from './report-only.js';
30
+ import yaml from 'yaml';
27
31
  const AGENT_NAME = { claude: 'Claude Code', cursor: 'Cursor', cline: 'Cline', windsurf: 'Windsurf' };
28
32
  export async function setupCommand(cwd = process.cwd(), options = {}) {
29
- const team = options.team || committedConfig(cwd);
30
- console.log(chalk.bold.cyan(`\nRigour setup (${team ? 'team: committed to this repository' : 'personal: nothing in your working tree'})\n`));
31
- if (team)
33
+ const team = !!options.team;
34
+ const committed = team ? [] : committedRigourHooks(cwd);
35
+ const mode = team ? 'team: committed to this repository' : committed.length ? "the team's install: nothing in your working tree" : 'personal: nothing in your working tree';
36
+ console.log(chalk.bold.cyan(`\nRigour setup (${mode})\n`));
37
+ if (team) {
38
+ const touched = trackedFiles(cwd, ['rigour.yml', ...rigourInstallPaths()]);
39
+ if (touched.length)
40
+ console.log(chalk.yellow(`Writes into these committed files (commit the result so everyone gets it): ${touched.join(', ')}\n`));
32
41
  await teamSetup(cwd, options);
33
- else
42
+ }
43
+ else if (committed.length)
44
+ await leaveTeamInstall(cwd, committed, !!options.instructions);
45
+ else {
46
+ if (trackedFiles(cwd, ['rigour.yml']).length)
47
+ console.log(chalk.dim('This repository commits rigour.yml; `rigour setup --team` commits hooks for everyone. This setup is yours alone.\n'));
34
48
  await personalSetup(cwd, options.brief !== false);
35
- for (const change of applyConfigMigrations(cwd))
36
- console.log(chalk.green(`✔ Migrated ${change}`));
49
+ }
50
+ // rigour.yml is the team's file: only --team changes it (doctor's checks below name what is due).
51
+ if (team)
52
+ for (const change of applyConfigMigrations(cwd))
53
+ console.log(chalk.green(`✔ Migrated ${change}`));
54
+ if (options.reportOnly || options.block)
55
+ setGateMode(cwd, team, !!options.reportOnly);
37
56
  if (options.semantic !== false)
38
57
  await setupSemantic(cwd);
39
58
  await printRepoSetup(cwd);
@@ -75,7 +94,74 @@ async function teamSetup(cwd, options) {
75
94
  else
76
95
  await initCommand(cwd, { instructions: options.instructions, brief: options.brief !== false });
77
96
  }
78
- /** A rigour.yml the repository tracks: the team has adopted Rigour, so setup completes their install. */
79
- function committedConfig(cwd) {
80
- return spawnSync('git', ['ls-files', '--error-unmatch', 'rigour.yml'], { cwd, encoding: 'utf8' }).status === 0;
97
+ /**
98
+ * Report-only or blocking, for this repository: the person's switch in the git directory, and with --team the team's
99
+ * in rigour.yml (`hooks.report_only`: true, or false with --block), so everyone who clones starts the same way.
100
+ */
101
+ function setGateMode(cwd, team, report) {
102
+ setReportOnlyHere(cwd, report);
103
+ const file = path.join(cwd, 'rigour.yml');
104
+ if (!team && report && fs.existsSync(file) && yaml.parse(fs.readFileSync(file, 'utf8'))?.hooks?.report_only === false) {
105
+ console.log(chalk.yellow(TEAM_REQUIRES_BLOCKING));
106
+ return;
107
+ }
108
+ if (team && fs.existsSync(file)) {
109
+ const doc = yaml.parseDocument(fs.readFileSync(file, 'utf8'));
110
+ // A team that turns blocking on means block: an explicit false, the floor no person's switch lowers.
111
+ doc.setIn(['hooks', 'report_only'], report);
112
+ fs.writeFileSync(file, doc.toString());
113
+ }
114
+ console.log(report
115
+ ? chalk.green(`✔ Report only: the edit check, the stop review and the push gate say what they would block and block nothing${team ? ' (hooks.report_only in rigour.yml)' : ''}. A real credential is still refused. Make them block: rigour setup${team ? ' --team' : ''} --block`)
116
+ : chalk.green('✔ The edit check, the stop review and the push gate block again.'));
117
+ }
118
+ /**
119
+ * The team's install, as committed here: tracked project hook and MCP files that run Rigour. Their agents already run
120
+ * them, so a person's setup changes none of them, and personal hooks beside them would run every check twice.
121
+ */
122
+ async function leaveTeamInstall(cwd, committed, instructions) {
123
+ disableHere(cwd, false);
124
+ const text = committed.map(file => fs.readFileSync(path.join(cwd, file), 'utf8')).join('\n');
125
+ const pins = [...new Set([...text.matchAll(/@rigour-labs\/cli@([0-9A-Za-z.-]+)/g)].map(m => m[1]))];
126
+ const installed = getCliVersion();
127
+ const stale = pins.filter(pin => pin !== installed);
128
+ console.log(chalk.green('✔ This repository commits its own Rigour install, and your agents run it. Left as committed:'));
129
+ for (const file of committed)
130
+ console.log(chalk.dim(` ${file}`));
131
+ if (stale.length) {
132
+ // The committed pin is what the agents run: name it, and what it costs, since this setup does not move it.
133
+ const caveat = isOldEditHook(text) ? ' (its edit hook checks nothing)' : '';
134
+ console.log(chalk.yellow(` These hooks pin Rigour ${pins.join(', ')}; they keep running that version${caveat} until someone runs \`rigour setup --team\` and commits the result. Installed here: ${installed}.`));
135
+ }
136
+ else
137
+ console.log(chalk.dim(' To change it for everyone: rigour setup --team, then commit the result.'));
138
+ // This clone's own: git's pre-push hook lives in the git directory, never in the working tree.
139
+ const push = installGitPushHook(cwd, pinnedCliCommand(), { workingTree: false });
140
+ if (push.action === 'managed elsewhere')
141
+ console.log(chalk.yellow(`Git pre-push hooks are managed outside this repository (${push.path}); add: ${pinnedCliCommand()} hooks push --git "$@" || exit $?`));
142
+ else if (push.action !== 'no repository')
143
+ console.log(chalk.green(`✔ Push gate: git's pre-push hook (${push.action}); the only thing installed here`));
144
+ // Asked for by name: written only where the repository has none, so no committed file changes.
145
+ if (instructions)
146
+ await writeAgentInstructions(cwd);
147
+ }
148
+ /** Where a team's Rigour install lives in the repository: the project hook and MCP configs. */
149
+ function rigourInstallPaths() {
150
+ return [...projectHookPaths(), '.mcp.json', '.cursor/mcp.json'];
151
+ }
152
+ /** Which of these paths git tracks here. */
153
+ function trackedFiles(cwd, paths) {
154
+ const tracked = spawnSync('git', ['ls-files', '--', ...paths], { cwd, encoding: 'utf8' });
155
+ return tracked.status === 0 ? tracked.stdout.split('\n').filter(Boolean) : [];
156
+ }
157
+ /** The tracked files of a team's Rigour install: project hook and MCP configs that name Rigour. */
158
+ function committedRigourHooks(cwd) {
159
+ return trackedFiles(cwd, rigourInstallPaths()).filter(file => {
160
+ try {
161
+ return /rigour/i.test(fs.readFileSync(path.join(cwd, file), 'utf8'));
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ });
81
167
  }
@@ -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}`);
@@ -98,8 +98,14 @@ teamCommand
98
98
  .description("Send queued lessons from the team's repositories, and your decisions on this repository's review lessons; receive the team's")
99
99
  .option('--dry-run', 'Report pending records without sending them')
100
100
  .action(async (options) => {
101
- const result = await syncTeamOutbox({ dryRun: Boolean(options.dryRun), cwd: process.cwd() });
102
- console.log(JSON.stringify(result, null, 2));
101
+ try {
102
+ console.log(JSON.stringify(await syncTeamOutbox({ dryRun: Boolean(options.dryRun), cwd: process.cwd() }), null, 2));
103
+ }
104
+ catch (error) {
105
+ // One line, with the fix when there is one; never the database driver's stack.
106
+ console.error(chalk.red(`Team sync failed: ${error instanceof Error ? error.message : String(error)}`));
107
+ process.exitCode = 1;
108
+ }
103
109
  });
104
110
  /** Initialize the schema, or print why it failed (with the fix) and set exit code 1. */
105
111
  async function initializeSchemaOrReport(databaseUrl, pgvector) {
@@ -22,6 +22,7 @@ import fs from 'fs';
22
22
  import path from 'path';
23
23
  import { APPENDED_COMMENT } from './hooks-git.js';
24
24
  import { disableHere, enabledHere, uninstallMachine } from './personal.js';
25
+ import { setReportOnlyHere } from './report-only.js';
25
26
  import { isEmptyConfig, isRigourScript, readInstallRecord, unchangedSinceInstall, withoutRigour } from './install-record.js';
26
27
  import { GITIGNORE_PATTERNS } from './init.js';
27
28
  const CONFIGS = ['.claude/settings.json', '.cursor/hooks.json', '.windsurf/hooks.json', '.cursor/mcp.json', '.mcp.json'];
@@ -78,7 +79,7 @@ export function uninstall(cwd, options = {}) {
78
79
  gitHook(cwd, act, report);
79
80
  // A personal install: the switch inside the git directory (and its exclude line, with .rigour/ itself).
80
81
  if (enabledHere(cwd))
81
- act('switch Rigour off for this repository (personal install)', () => disableHere(cwd, !!options.all));
82
+ act('switch Rigour off for this repository (personal install)', () => { disableHere(cwd, !!options.all); setReportOnlyHere(cwd, false); });
82
83
  else if (options.all && !options.dryRun)
83
84
  disableHere(cwd, true);
84
85
  if (options.machine)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/cli",
3
- "version": "7.0.0-rc.1",
3
+ "version": "7.0.0-rc.10",
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.1"
62
+ "@rigour-labs/core": "7.0.0-rc.10"
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.1"
67
+ "@rigour-labs/studio": "7.0.0-rc.10"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "tsc && pnpm bundle-studio",