@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.
- package/dist/cli.js +11 -1
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +23 -2
- package/dist/commands/hooks-git.js +4 -2
- package/dist/commands/hooks-push.js +6 -1
- package/dist/commands/hooks-stop.js +9 -2
- package/dist/commands/hooks.d.ts +2 -0
- package/dist/commands/hooks.js +14 -5
- package/dist/commands/learn-reviews.d.ts +2 -0
- package/dist/commands/learn-reviews.js +22 -3
- package/dist/commands/profile.js +15 -3
- package/dist/commands/repo-setup.js +12 -10
- package/dist/commands/report-only.d.ts +13 -0
- package/dist/commands/report-only.js +45 -0
- package/dist/commands/semantic.js +13 -1
- package/dist/commands/setup.d.ts +4 -0
- package/dist/commands/setup.js +100 -14
- package/dist/commands/studio-learning.d.ts +2 -0
- package/dist/commands/studio-learning.js +5 -3
- package/dist/commands/team.js +8 -2
- package/dist/commands/uninstall.js +2 -1
- package/package.json +3 -3
- package/studio-dist/assets/{index-D5EEZJUE.js → index-CmWtilgA.js} +17 -17
- package/studio-dist/index.html +1 -1
package/dist/cli.js
CHANGED
|
@@ -237,13 +237,21 @@ program
|
|
|
237
237
|
.option('--instructions', 'With --team: also write AGENTS.md, and a one-line CLAUDE.md that imports it, where the project has none')
|
|
238
238
|
.option('--no-semantic', 'Skip installing semantic search (recall and pattern matching then use keywords)')
|
|
239
239
|
.option('--no-brief', 'Do not brief the agent with the team\'s rules and lessons (on by default: from each session\'s first prompt, and on the first edit of each file)')
|
|
240
|
+
.option('--report-only', 'A team\'s first week: the edit check, the stop review and the push gate (git\'s pre-push hook too) say what they would block and block nothing. A real credential is still refused. With --team, for everyone (rigour.yml)')
|
|
241
|
+
.option('--block', 'Make the gates block again after --report-only')
|
|
240
242
|
.action(async (options) => {
|
|
243
|
+
if (options.reportOnly && options.block) {
|
|
244
|
+
console.error('Choose one: --report-only or --block.');
|
|
245
|
+
process.exit(2);
|
|
246
|
+
}
|
|
241
247
|
await setupCommand(process.cwd(), options);
|
|
242
248
|
});
|
|
243
249
|
program
|
|
244
250
|
.command('doctor')
|
|
245
251
|
.description('Check that Rigour is wired up and firing here, and that the install is healthy')
|
|
246
252
|
.option('--clean-cache', 'Remove context-cache rows Rigour no longer reads, and shrink the database when the disk has room')
|
|
253
|
+
.option('--merge-state', 'Merge what a home ending in .rigour from before 6.11.1 left over into the live state directory (a dry run; add --yes)')
|
|
254
|
+
.option('--yes', 'With --merge-state: do it')
|
|
247
255
|
.action(async (options) => {
|
|
248
256
|
await doctorCommand(options);
|
|
249
257
|
});
|
|
@@ -406,7 +414,9 @@ program
|
|
|
406
414
|
.option('--include-bots', 'With --list: also list candidates only review bots raised (hidden by default)')
|
|
407
415
|
.option('--promote <id>', 'Accept a candidate as a lesson (recorded as your decision)')
|
|
408
416
|
.option('--reject <id>', 'Reject a candidate or lesson: the judges are told this team decided against it')
|
|
409
|
-
.option('--
|
|
417
|
+
.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')
|
|
418
|
+
.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')
|
|
419
|
+
.option('--why <text>', 'Why, recorded with --promote, --reject, --not-a-rule or --take-back')
|
|
410
420
|
.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
421
|
.option('--to <reach>', 'With --scope: file, folder or repo')
|
|
412
422
|
.option('--use-wording <id>', 'Take the corrected wording a newer version suggests for a lesson you decided (the old wording is kept as evidence)')
|
|
@@ -2,6 +2,8 @@ export declare function detectInstallKind(binaryPath: string): string;
|
|
|
2
2
|
export declare function hasVersionShadowing(versions: string[]): boolean;
|
|
3
3
|
export declare function doctorCommand(options?: {
|
|
4
4
|
cleanCache?: boolean;
|
|
5
|
+
mergeState?: boolean;
|
|
6
|
+
yes?: boolean;
|
|
5
7
|
}, cwd?: string): Promise<void>;
|
|
6
8
|
/** This repository first: is each part of Rigour wired up, and did it fire this week. */
|
|
7
9
|
export declare function printRepoSetup(cwd: string): Promise<void>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
|
-
import { cleanContextCache, deadCacheRows, loadSettings, resolveDeepOptions, getCachedModel, legacyStateNote, rigourUserDir, SidecarProvider } from '@rigour-labs/core';
|
|
5
|
+
import { applyStateMerge, cleanContextCache, deadCacheRows, loadSettings, resolveDeepOptions, getCachedModel, legacyStateNote, planStateMerge, rigourUserDir, SidecarProvider } from '@rigour-labs/core';
|
|
6
6
|
import { checkRepoSetup } from './repo-setup.js';
|
|
7
7
|
import { configMigrations } from './setup-migrations.js';
|
|
8
8
|
import { selfTestCommand, selfTestGitPushHook } from './hooks-git.js';
|
|
@@ -65,6 +65,8 @@ export async function doctorCommand(options = {}, cwd = process.cwd()) {
|
|
|
65
65
|
console.log(chalk.bold.cyan('\nRigour Doctor\n'));
|
|
66
66
|
if (options.cleanCache)
|
|
67
67
|
return cleanCache();
|
|
68
|
+
if (options.mergeState)
|
|
69
|
+
return mergeState(!!options.yes);
|
|
68
70
|
await printRepoSetup(cwd);
|
|
69
71
|
console.log(semanticStatusLine(cwd) + '\n');
|
|
70
72
|
await printPushGate(cwd);
|
|
@@ -151,7 +153,7 @@ export async function printRepoSetup(cwd) {
|
|
|
151
153
|
// Settings an older Rigour wrote that no longer mean what they say: loud, since nothing else reports them.
|
|
152
154
|
for (const migration of configMigrations(cwd)) {
|
|
153
155
|
console.log(` ${chalk.red('✘')} ${chalk.red(migration.problem)}`);
|
|
154
|
-
console.log(chalk.dim(` fix: ${migration.automatic ? 'rigour setup' : 'edit rigour.yml'}`));
|
|
156
|
+
console.log(chalk.dim(` fix: ${migration.automatic ? 'rigour setup --team' : 'edit rigour.yml'}`));
|
|
155
157
|
}
|
|
156
158
|
console.log('');
|
|
157
159
|
}
|
|
@@ -174,6 +176,25 @@ async function printPushGate(cwd) {
|
|
|
174
176
|
console.log(chalk.dim(` - ${step}`));
|
|
175
177
|
console.log(test.ok ? chalk.green(' ✓ The push gate holds under a real git push.\n') : chalk.red(' ✘ The push gate does not hold.\n'));
|
|
176
178
|
}
|
|
179
|
+
/** `--merge-state`: what an old home ending in .rigour left outside the live state directory, merged in; a dry run unless --yes. */
|
|
180
|
+
function mergeState(yes) {
|
|
181
|
+
const plan = planStateMerge();
|
|
182
|
+
if (!plan)
|
|
183
|
+
return void console.log(chalk.green('Nothing to merge: Rigour keeps its state in one directory here.\n'));
|
|
184
|
+
console.log(`Live state: ${plan.live}\nLeft over in: ${plan.outer}\n`);
|
|
185
|
+
for (const step of plan.steps) {
|
|
186
|
+
console.log(step.action === 'move'
|
|
187
|
+
? ` move ${step.rel} into the live directory`
|
|
188
|
+
: ` keep the live ${step.rel}; rename the left-over one to ${path.basename(step.to)}`);
|
|
189
|
+
}
|
|
190
|
+
if (plan.steps.some(step => path.basename(step.from) === 'rigour.db' && step.action === 'aside')) {
|
|
191
|
+
console.log(chalk.yellow('\n rigour.db is in both: the live one stays in use and the left-over one is kept, renamed, not merged row by row. Its old memory and lessons are not in the live database.'));
|
|
192
|
+
}
|
|
193
|
+
if (!yes)
|
|
194
|
+
return void console.log(chalk.dim('\nNothing changed (a dry run). To do it: rigour doctor --merge-state --yes. Nothing is deleted or overwritten.\n'));
|
|
195
|
+
applyStateMerge(plan.steps);
|
|
196
|
+
console.log(chalk.green(`\nMerged: ${plan.steps.length} entr${plan.steps.length === 1 ? 'y' : 'ies'}. Nothing was deleted.\n`));
|
|
197
|
+
}
|
|
177
198
|
async function printDatabaseHealth() {
|
|
178
199
|
const dead = await deadCacheRows().catch(() => null);
|
|
179
200
|
if (!dead)
|
|
@@ -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: { ...
|
|
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: { ...
|
|
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)}`] };
|
|
@@ -18,6 +18,7 @@ import os from 'os';
|
|
|
18
18
|
import path from 'path';
|
|
19
19
|
import { appendTaskEvent, branchBase, branchFailures, captureHumanEdits, diffFromGit, hookGoalDescription, itemLine, recordGoal, mergeBaseOf, resolveReviewer, reviewChange, reviewerCapReached, reviewerInputs, reviewerBlocks, runReviewer, runToolchain, startBackgroundReview, } from '@rigour-labs/core';
|
|
20
20
|
import { loadHookConfig } from './hooks-stop.js';
|
|
21
|
+
import { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
|
|
21
22
|
export async function hooksPushCommand(stdin, fallbackCwd) {
|
|
22
23
|
const payload = parse(stdin);
|
|
23
24
|
const command = String(payload.tool_input?.command ?? '');
|
|
@@ -62,9 +63,13 @@ export async function pushGate(dir) {
|
|
|
62
63
|
return { exitCode: 0, message: await backgroundReviewNote(repo, branch.mainRef, config) };
|
|
63
64
|
const log = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-push-')), 'gates.log');
|
|
64
65
|
fs.writeFileSync(log, failures.log.join('\n\n'));
|
|
66
|
+
const against = `${path.basename(repo)} against ${branch.mainRef.replace(/^refs\/(remotes\/|heads\/)/, '')} (full output: ${log})`;
|
|
67
|
+
// A team's first week: the push goes ahead, and the agent and the terminal are told what would have stopped it.
|
|
68
|
+
if (reportOnly(repo, config))
|
|
69
|
+
return { exitCode: 0, message: [`${REPORT_ONLY_PREFIX} the push would have been blocked: ${failures.lines.length} check(s) failed for ${against}:`, ...failures.lines].join('\n') };
|
|
65
70
|
return {
|
|
66
71
|
exitCode: 2,
|
|
67
|
-
message: [`Push blocked: ${failures.lines.length} check(s) failed for ${
|
|
72
|
+
message: [`Push blocked: ${failures.lines.length} check(s) failed for ${against}. Fix each, then push again:`, ...failures.lines].join('\n'),
|
|
68
73
|
};
|
|
69
74
|
}
|
|
70
75
|
async function gates(repo, base, mainRef, config) {
|
|
@@ -20,6 +20,7 @@ import { execFileSync } from 'child_process';
|
|
|
20
20
|
import fs from 'fs-extra';
|
|
21
21
|
import path from 'path';
|
|
22
22
|
import yaml from 'yaml';
|
|
23
|
+
import { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
|
|
23
24
|
import { alreadyReviewed, appendAgentEvent, appendTaskEvent, clearStopAttempts, ConfigSchema, countUsage, nextStopAttempt, recordFixLessons, recordReviewed, recordReviewOutcome, workFingerprint, sessionBaseline, STOP_MAX_ATTEMPTS, stopReview, teamMessage, untaught, recordTaught, captureHumanEdits, } from '@rigour-labs/core';
|
|
24
25
|
export async function hooksStopCommand(tool, stdin, fallbackCwd) {
|
|
25
26
|
const payload = parsePayload(stdin);
|
|
@@ -42,7 +43,13 @@ export async function hooksStopCommand(tool, stdin, fallbackCwd) {
|
|
|
42
43
|
if (attempt > STOP_MAX_ATTEMPTS)
|
|
43
44
|
return '';
|
|
44
45
|
try {
|
|
45
|
-
const
|
|
46
|
+
const loaded = config ?? await loadHookConfig(cwd);
|
|
47
|
+
const review = await stopReview(cwd, loaded, attempt, sessionBaseline(cwd, session));
|
|
48
|
+
// A team's first week: what would have held the agent is said, and the agent finishes (report-only.ts).
|
|
49
|
+
const reporting = review.block && reportOnly(cwd, loaded);
|
|
50
|
+
if (reporting)
|
|
51
|
+
process.stderr.write(`${REPORT_ONLY_PREFIX} the agent would have been held to fix:\n${review.message}\n`);
|
|
52
|
+
const decision = reporting ? { ...review, block: false } : review;
|
|
46
53
|
const nothing = decision.reviewedFiles.length === 0;
|
|
47
54
|
appendAgentEvent(cwd, { type: 'stop_review', tool, session, blocked: decision.block, blocking: decision.blocking, against: decision.against, ...(decision.held.length ? { findings: decision.held.slice(0, 20) } : {}), ...(nothing ? { nothing_to_review: true } : {}) });
|
|
48
55
|
appendTaskEvent(cwd, { kind: 'stop-review', agent: tool, session, blocked: decision.block, blocking: decision.blocking, files: decision.reviewedFiles });
|
|
@@ -66,7 +73,7 @@ export async function hooksStopCommand(tool, stdin, fallbackCwd) {
|
|
|
66
73
|
recordTaught(cwd, session, fresh);
|
|
67
74
|
if (!decision.block) {
|
|
68
75
|
clearStopAttempts(cwd, session);
|
|
69
|
-
return guidance.length ? blockWith(tool, teamMessage(guidance)) : '';
|
|
76
|
+
return guidance.length && !reportOnly(cwd, loaded) ? blockWith(tool, teamMessage(guidance)) : '';
|
|
70
77
|
}
|
|
71
78
|
return blockWith(tool, guidance.length ? `${decision.message}\n\n${teamMessage(guidance)}` : decision.message);
|
|
72
79
|
}
|
package/dist/commands/hooks.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface HooksCheckOptions {
|
|
|
34
34
|
}
|
|
35
35
|
/** This CLI, pinned to its version, as a hook runs it (`npx --yes @rigour-labs/cli@6.6.6`). */
|
|
36
36
|
export declare function pinnedCliCommand(): string;
|
|
37
|
+
/** Every project file Rigour's hooks are written to, for every agent: what a team commits when it installs Rigour. */
|
|
38
|
+
export declare function projectHookPaths(): string[];
|
|
37
39
|
/**
|
|
38
40
|
* The personal install's agent hooks, once per machine (personal.ts): every agent's user-level
|
|
39
41
|
* config, each command guarded so it runs only in a repository switched on with `rigour setup`.
|
package/dist/commands/hooks.js
CHANGED
|
@@ -21,6 +21,7 @@ import { isRigourScript, mergeHooksInto, recordCreated } from './install-record.
|
|
|
21
21
|
import { agentHome, asUserLevel, installedAgents } from './personal.js';
|
|
22
22
|
import { hookStdin } from './hook-input.js';
|
|
23
23
|
import { loadHookConfig } from './hooks-stop.js';
|
|
24
|
+
import { REPORT_ONLY_PREFIX, reportOnly } from './report-only.js';
|
|
24
25
|
function getHookCliVersion() {
|
|
25
26
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
const packagePath = path.resolve(thisDir, '../../package.json');
|
|
@@ -382,6 +383,11 @@ const GENERATORS = {
|
|
|
382
383
|
cline: generateClineHooks,
|
|
383
384
|
windsurf: generateWindsurfHooks,
|
|
384
385
|
};
|
|
386
|
+
/** Every project file Rigour's hooks are written to, for every agent: what a team commits when it installs Rigour. */
|
|
387
|
+
export function projectHookPaths() {
|
|
388
|
+
const checker = resolveCheckerCommand();
|
|
389
|
+
return [...new Set(Object.keys(GENERATORS).flatMap(tool => GENERATORS[tool](checker, true).map(file => file.path)))];
|
|
390
|
+
}
|
|
385
391
|
// ── File writing ─────────────────────────────────────────────────────
|
|
386
392
|
function printDryRun(files) {
|
|
387
393
|
console.log(chalk.cyan('\nDry run — files that would be created:\n'));
|
|
@@ -803,6 +809,8 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
803
809
|
result: await runHookChecker({ cwd: group.root, files: group.files, timeout_ms: Number.isFinite(timeout) ? timeout : 5000, agentId }),
|
|
804
810
|
})));
|
|
805
811
|
const result = mergeHookResults(runs.map(r => r.result));
|
|
812
|
+
// A team's first week: the findings are said, and the edit goes through (report-only.ts).
|
|
813
|
+
const blocking = !!options.block && !(await Promise.all(runs.map(async (run) => reportOnly(run.root, await loadHookConfig(run.root).catch(() => undefined))))).some(Boolean);
|
|
806
814
|
const counted = { agent: cursorMode ? 'cursor' : agentId };
|
|
807
815
|
countUsage('hook_check', 1, counted);
|
|
808
816
|
for (const failure of result.failures)
|
|
@@ -822,8 +830,8 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
822
830
|
}),
|
|
823
831
|
logStudioEvent(root, {
|
|
824
832
|
type: 'hook_check', requestId, outcome, status: repoResult.status, agentId,
|
|
825
|
-
// Only a --block hook stops the edit; without it the findings are reported and the agent carries on.
|
|
826
|
-
blocked:
|
|
833
|
+
// Only a --block hook stops the edit; without it (or in report-only) the findings are reported and the agent carries on.
|
|
834
|
+
blocked: blocking && repoResult.status === 'fail',
|
|
827
835
|
files: repoFiles, summary: `${repoFiles.length} file(s), ${repoResult.failures.length} finding(s)`,
|
|
828
836
|
findings: repoResult.failures.slice(0, 10).map(f => ({ gate: f.gate, file: f.file, line: f.line, message: f.message, severity: f.severity })),
|
|
829
837
|
}),
|
|
@@ -839,7 +847,7 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
839
847
|
})
|
|
840
848
|
.join('\n');
|
|
841
849
|
process.stdout.write(JSON.stringify({
|
|
842
|
-
continue: !
|
|
850
|
+
continue: !blocking, // block mode = stop, otherwise warn
|
|
843
851
|
user_message: `⚠️ Rigour: ${result.failures.length} issue(s) found:\n${messages}`,
|
|
844
852
|
}));
|
|
845
853
|
}
|
|
@@ -858,9 +866,10 @@ export async function hooksCheckCommand(cwd, options = {}) {
|
|
|
858
866
|
const loc = failure.line ? `:${failure.line}` : '';
|
|
859
867
|
process.stderr.write(`[rigour/${failure.gate}] ${failure.file}${loc}: ${failure.message}\n`);
|
|
860
868
|
}
|
|
861
|
-
if (
|
|
869
|
+
if (blocking)
|
|
862
870
|
process.exitCode = 2;
|
|
863
|
-
|
|
871
|
+
else if (options.block)
|
|
872
|
+
process.stderr.write(`${REPORT_ONLY_PREFIX} the edit would have been blocked for the above.\n`);
|
|
864
873
|
}
|
|
865
874
|
}
|
|
866
875
|
/** One verdict for a hook call that checked files in several repositories. */
|
|
@@ -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
|
-
|
|
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
|
|
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) {
|
package/dist/commands/profile.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* `rigour profile list|which|add`: one machine, many organizations (core utils/profile.ts).
|
|
3
3
|
*/
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { expandHome, profileFor, profilesPath, readProfiles, writeProfiles } from '@rigour-labs/core/profile';
|
|
7
|
+
import { homeFor, rigourUserDir } from '@rigour-labs/core';
|
|
7
8
|
export function profileListCommand() {
|
|
8
9
|
const profiles = readProfiles();
|
|
9
10
|
if (profiles.length === 0)
|
|
@@ -19,6 +20,15 @@ export function profileWhichCommand(cwd) {
|
|
|
19
20
|
console.log(profile ? `${chalk.bold(profile.name)} (matched ${cwd})` : chalk.dim('No profile matches here: Rigour uses its default home.'));
|
|
20
21
|
console.log(chalk.dim(` home: ${rigourUserDir()}`));
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* A new profile's home, as one directory: a home ending in .rigour with no state nested in it is that folder's parent,
|
|
25
|
+
* so the state is that folder itself (user-state.ts homeFor). One with state nested in it (a home from before 6.11.1)
|
|
26
|
+
* is kept as given, where the live state is.
|
|
27
|
+
*/
|
|
28
|
+
function profileHomeFor(home) {
|
|
29
|
+
const full = path.resolve(expandHome(home));
|
|
30
|
+
return path.basename(full) === '.rigour' ? homeFor(full) : home;
|
|
31
|
+
}
|
|
22
32
|
export function profileAddCommand(name, options) {
|
|
23
33
|
const list = (value) => (value ?? '').split(',').map(v => v.trim()).filter(Boolean);
|
|
24
34
|
const teamParts = [options.organization, options.team, options.actor];
|
|
@@ -27,7 +37,7 @@ export function profileAddCommand(name, options) {
|
|
|
27
37
|
const profile = {
|
|
28
38
|
name,
|
|
29
39
|
match: list(options.match),
|
|
30
|
-
...(options.home ? { home: options.home } : {}),
|
|
40
|
+
...(options.home ? { home: profileHomeFor(options.home) } : {}),
|
|
31
41
|
...(options.organization ? {
|
|
32
42
|
team: {
|
|
33
43
|
organization: options.organization, team: options.team, actor: options.actor,
|
|
@@ -41,4 +51,6 @@ export function profileAddCommand(name, options) {
|
|
|
41
51
|
throw new Error('--match needs at least one path or remote (github.com/acme/*).');
|
|
42
52
|
writeProfiles([...readProfiles().filter(p => p.name !== name), profile]);
|
|
43
53
|
console.log(chalk.green(`Profile ${name} saved to ${profilesPath()}.`));
|
|
54
|
+
if (profile.home)
|
|
55
|
+
console.log(chalk.dim(` Its state: ${path.join(homeFor(path.resolve(expandHome(profile.home))), '.rigour')}`));
|
|
44
56
|
}
|
|
@@ -32,11 +32,13 @@ export async function checkRepoSetup(cwd, now = new Date(), toolCalls, installed
|
|
|
32
32
|
{ name: 'Windsurf', config: read('.windsurf/hooks.json') + home('.codeium/windsurf/hooks.json') },
|
|
33
33
|
].filter(a => a.config);
|
|
34
34
|
const mcpConfig = read('.mcp.json') + read('.cursor/mcp.json') + home('.claude.json') + home('.cursor/mcp.json');
|
|
35
|
-
|
|
35
|
+
// The repository's own committed install: only `rigour setup --team` changes it (a plain setup leaves it as committed).
|
|
36
|
+
const setup = !personal && /rigour/i.test(agents.map(a => a.config).join('\n')) ? 'rigour setup --team' : 'rigour setup';
|
|
37
|
+
const version = versionCheck(agents.map(a => a.config).join('\n'), mcpConfig, installed, setup);
|
|
36
38
|
return [
|
|
37
39
|
configCheck(cwd, personal),
|
|
38
|
-
editCheck(agents, now, events),
|
|
39
|
-
stopCheck(agents.map(a => a.config).join('\n'), now, events),
|
|
40
|
+
editCheck(agents, now, events, setup),
|
|
41
|
+
stopCheck(agents.map(a => a.config).join('\n'), now, events, setup),
|
|
40
42
|
mcpCheck(mcpConfig, now, calls),
|
|
41
43
|
...(version ? [version] : []),
|
|
42
44
|
prCheck(cwd),
|
|
@@ -47,7 +49,7 @@ export async function checkRepoSetup(cwd, now = new Date(), toolCalls, installed
|
|
|
47
49
|
* (`@rigour-labs/cli@6.12.4`), so an upgrade reaches them only when `rigour setup` rewrites the pin.
|
|
48
50
|
* Nothing to say when nothing pins a version (a source checkout runs its own build).
|
|
49
51
|
*/
|
|
50
|
-
function versionCheck(hooks, mcp, installed) {
|
|
52
|
+
function versionCheck(hooks, mcp, installed, setup) {
|
|
51
53
|
const pins = (text, pkg) => [...new Set([...text.matchAll(new RegExp(`@rigour-labs/${pkg}@([0-9A-Za-z.-]+)`, 'g'))].map(m => m[1]))];
|
|
52
54
|
const hookPins = pins(hooks, 'cli');
|
|
53
55
|
const mcpPins = pins(mcp, 'mcp');
|
|
@@ -62,7 +64,7 @@ function versionCheck(hooks, mcp, installed) {
|
|
|
62
64
|
...mcpPins.filter(v => v !== installed).map(v => (/^\d+$/.test(v) || v === 'latest' ? `the MCP server floats on @${v}` : `the MCP server runs ${v}`)),
|
|
63
65
|
];
|
|
64
66
|
return stale.length
|
|
65
|
-
? { id: 'version', name, state: 'broken', detail: `${stale.join(', ')}; installed is ${installed}`, fix:
|
|
67
|
+
? { id: 'version', name, state: 'broken', detail: `${stale.join(', ')}; installed is ${installed}`, fix: setup }
|
|
66
68
|
: { id: 'version', name, state: 'working', detail: installed };
|
|
67
69
|
}
|
|
68
70
|
function configCheck(cwd, personal) {
|
|
@@ -73,22 +75,22 @@ function configCheck(cwd, personal) {
|
|
|
73
75
|
: { id: 'config', name: 'Project settings', state: 'missing', detail: 'No rigour.yml: Rigour uses its defaults', fix: 'rigour setup' };
|
|
74
76
|
}
|
|
75
77
|
/** One check for every agent's edit hook: events do not say which agent fired them, so counts are not split. */
|
|
76
|
-
function editCheck(agents, now, events) {
|
|
78
|
+
function editCheck(agents, now, events, setup) {
|
|
77
79
|
const wired = agents.filter(a => a.config.includes('hooks check'));
|
|
78
80
|
const name = wired.length ? `Checks ${listOf(wired.map(a => a.name))} as it writes` : 'Checks your agent as it writes';
|
|
79
81
|
if (wired.length === 0)
|
|
80
|
-
return { id: 'edit', name, state: 'missing', detail: 'No edit hook configured', fix:
|
|
82
|
+
return { id: 'edit', name, state: 'missing', detail: 'No edit hook configured', fix: setup };
|
|
81
83
|
const broken = wired.find(a => isOldEditHook(a.config));
|
|
82
84
|
if (broken) {
|
|
83
85
|
// setup rewrites Rigour's own entries where they live (the project, or the machine for a personal install).
|
|
84
|
-
return { id: 'edit', name, state: 'broken', detail: `The ${broken.name} hook is the old form: it reads a variable the agent never sets, so no edit is checked`, fix:
|
|
86
|
+
return { id: 'edit', name, state: 'broken', detail: `The ${broken.name} hook is the old form: it reads a variable the agent never sets, so no edit is checked`, fix: setup };
|
|
85
87
|
}
|
|
86
88
|
return fired('edit', name, events.filter(e => e.type === 'hook_check'), now, 'edit checks');
|
|
87
89
|
}
|
|
88
|
-
function stopCheck(config, now, events) {
|
|
90
|
+
function stopCheck(config, now, events, setup) {
|
|
89
91
|
const name = 'Stops an agent finishing with a known bug';
|
|
90
92
|
if (!config.includes('hooks stop'))
|
|
91
|
-
return { id: 'stop', name, state: 'missing', detail: 'No stop hook configured', fix:
|
|
93
|
+
return { id: 'stop', name, state: 'missing', detail: 'No stop hook configured', fix: setup };
|
|
92
94
|
return fired('stop', name, events.filter(e => e.type === 'stop_review'), now, 'finish checks');
|
|
93
95
|
}
|
|
94
96
|
function mcpCheck(mcpJson, now, calls) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Config } from '@rigour-labs/core';
|
|
2
|
+
/** What a gate says instead of blocking. */
|
|
3
|
+
export declare const REPORT_ONLY_PREFIX = "Rigour (report only; nothing was blocked, `rigour setup --block` makes it block):";
|
|
4
|
+
/**
|
|
5
|
+
* Whether this repository's gates only report. The team's rigour.yml decides when it says either way: an explicit
|
|
6
|
+
* `hooks.report_only: false` is a floor a person's switch never lowers, as with the reviewer's caps. Unset, the
|
|
7
|
+
* person's switch applies.
|
|
8
|
+
*/
|
|
9
|
+
export declare function reportOnly(cwd: string, config?: Config): boolean;
|
|
10
|
+
/** What a person's switch says where the team's rigour.yml requires blocking. */
|
|
11
|
+
export declare const TEAM_REQUIRES_BLOCKING = "Rigour: report-only is on for this clone, but the team's rigour.yml requires blocking here (hooks.report_only: false), so the gates block.";
|
|
12
|
+
/** Sets or clears the person's switch for this repository; false outside a git repository. */
|
|
13
|
+
export declare function setReportOnlyHere(cwd: string, on: boolean): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report-only: a team's first week. Every gate (the edit check, the stop review, the push gate, git's pre-push hook
|
|
3
|
+
* included) says what it would have blocked and lets the work through; `rigour setup --block` makes them block.
|
|
4
|
+
* The scan for credentials before a tool call is not one of them and keeps denying a real secret.
|
|
5
|
+
*
|
|
6
|
+
* Read at run time, so the hook commands never change between the first week and after: from rigour.yml
|
|
7
|
+
* (`hooks.report_only`, a team's choice, written by `rigour setup --team --report-only`) or from a marker in the
|
|
8
|
+
* repository's git directory (a person's, written by `rigour setup --report-only`, never in the working tree).
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
const MARKER = 'rigour-report-only';
|
|
14
|
+
/** What a gate says instead of blocking. */
|
|
15
|
+
export const REPORT_ONLY_PREFIX = 'Rigour (report only; nothing was blocked, `rigour setup --block` makes it block):';
|
|
16
|
+
function markerPath(cwd) {
|
|
17
|
+
const common = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, encoding: 'utf8' });
|
|
18
|
+
return common.status === 0 ? path.join(common.stdout.trim(), MARKER) : undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether this repository's gates only report. The team's rigour.yml decides when it says either way: an explicit
|
|
22
|
+
* `hooks.report_only: false` is a floor a person's switch never lowers, as with the reviewer's caps. Unset, the
|
|
23
|
+
* person's switch applies.
|
|
24
|
+
*/
|
|
25
|
+
export function reportOnly(cwd, config) {
|
|
26
|
+
const team = config?.hooks?.report_only;
|
|
27
|
+
const marker = markerPath(cwd);
|
|
28
|
+
const personal = !!marker && fs.existsSync(marker);
|
|
29
|
+
if (team === false && personal)
|
|
30
|
+
process.stderr.write(`${TEAM_REQUIRES_BLOCKING}\n`);
|
|
31
|
+
return team ?? personal;
|
|
32
|
+
}
|
|
33
|
+
/** What a person's switch says where the team's rigour.yml requires blocking. */
|
|
34
|
+
export const TEAM_REQUIRES_BLOCKING = "Rigour: report-only is on for this clone, but the team's rigour.yml requires blocking here (hooks.report_only: false), so the gates block.";
|
|
35
|
+
/** Sets or clears the person's switch for this repository; false outside a git repository. */
|
|
36
|
+
export function setReportOnlyHere(cwd, on) {
|
|
37
|
+
const marker = markerPath(cwd);
|
|
38
|
+
if (!marker)
|
|
39
|
+
return false;
|
|
40
|
+
if (on)
|
|
41
|
+
fs.writeFileSync(marker, 'Rigour reports and never blocks here (rigour setup --report-only). `rigour setup --block` makes it block.\n');
|
|
42
|
+
else
|
|
43
|
+
fs.rmSync(marker, { force: true });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* meaning. `rigour setup` installs it unless asked not to, or the disk is too full; nothing else
|
|
5
5
|
* ever does, so a hook or an MCP call never waits on a download.
|
|
6
6
|
*/
|
|
7
|
+
import { confirm } from '@inquirer/prompts';
|
|
7
8
|
import chalk from 'chalk';
|
|
8
9
|
import { execa } from 'execa';
|
|
9
10
|
import fs from 'fs';
|
|
@@ -51,13 +52,24 @@ function freeBytes(dir) {
|
|
|
51
52
|
return undefined;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
/** What semantic search takes on disk, as measured per platform after setup's pruning (semantic-runtime.ts). */
|
|
56
|
+
const SIZE = 'about 250 MB on Linux, 280 MB on macOS';
|
|
57
|
+
/** A person at a terminal, not a script, a pipe or CI. */
|
|
58
|
+
function interactive() {
|
|
59
|
+
return !!process.stdin.isTTY && !!process.stdout.isTTY && !process.env.CI;
|
|
60
|
+
}
|
|
54
61
|
/** The setup step, as a person reads it. */
|
|
55
62
|
export async function setupSemantic(cwd) {
|
|
56
63
|
if (locateTransformers(cwd)) {
|
|
57
64
|
console.log(chalk.green('✔ Semantic search is on (local embeddings)'));
|
|
58
65
|
return;
|
|
59
66
|
}
|
|
60
|
-
|
|
67
|
+
// A person at a terminal says yes before a download this size; a script or CI installs as before (--no-semantic skips it).
|
|
68
|
+
if (interactive() && !await confirm({ message: `Install semantic search? ${SIZE}, once for every Rigour version on this machine. It lets recall and "is there already a helper for this?" work by meaning.`, default: true })) {
|
|
69
|
+
console.log(chalk.yellow('Semantic search is off. Recall and pattern matching use keywords until `rigour setup` installs it.'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.log(chalk.dim(`Installing semantic search: ${SIZE}, once for every Rigour version on this machine (skip with --no-semantic)...`));
|
|
61
73
|
const result = await ensureSemanticRuntime(cwd);
|
|
62
74
|
if (result.state === 'installed' || result.state === 'present')
|
|
63
75
|
console.log(chalk.green('✔ Semantic search is on (local embeddings)'));
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -4,5 +4,9 @@ export interface SetupOptions {
|
|
|
4
4
|
brief?: boolean;
|
|
5
5
|
team?: boolean;
|
|
6
6
|
instructions?: boolean;
|
|
7
|
+
/** A team's first week: every gate says what it would have blocked and blocks nothing (report-only.ts). */
|
|
8
|
+
reportOnly?: boolean;
|
|
9
|
+
/** Every gate blocks again (clears report-only). */
|
|
10
|
+
block?: boolean;
|
|
7
11
|
}
|
|
8
12
|
export declare function setupCommand(cwd?: string, options?: SetupOptions): Promise<void>;
|