@ericrisco/rsc 3.0.10 → 3.0.12

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/README.md CHANGED
@@ -232,6 +232,14 @@ Every turn takes exactly one lane, and your agent names the one it took in a lin
232
232
 
233
233
  The agent says which lane it took and why, in one line. Ask for the other one and it switches.
234
234
 
235
+ **The review only brings you what is real.** In SDD, `review` sends three adversarial reviewers at
236
+ the diff (correctness, security, tests). They often find the same defect in different words, and a
237
+ reviewer hunting for problems can overstate one. So before anything reaches you,
238
+ `rsc review consolidate` merges the duplicates (same place, or the same quoted line), and every
239
+ serious finding left goes to a fresh `finding-verifier` agent whose starting position is that the
240
+ finding is false. It blocks the merge only if the verifier reproduces it or traces it. You get
241
+ "11 reported → 4 unique → 2 confirmed", and the refuted ones are listed with the reason.
242
+
235
243
  **Isolation cleans itself up.** When a lane opens a branch or a worktree, you no longer have to
236
244
  remember to retire it: a `post-merge` hook does it the moment the work lands on the trunk, on both
237
245
  landing paths — a local merge and the pull after a forge merge. It removes only what is provably
@@ -423,6 +431,7 @@ rsc agent-model opencode openai/gpt-5 # pin the model the generated agents car
423
431
  rsc agent-model opencode inherit # back to the session's model (OpenCode's default)
424
432
  rsc agents status # installed agents, and which ones you edited
425
433
  rsc agents reset developer # take rsc's version of an agent you edited (yours is backed up first)
434
+ rsc review consolidate c.md s.md t.md # merge the review lenses' findings; serious ones go to finding-verifier
426
435
  rsc backups # list project-local snapshots
427
436
  rsc restore latest --dry-run # preview restoring the newest snapshot
428
437
  rsc restore <snapshot-id> # restore a project-local snapshot
@@ -884,7 +893,7 @@ The richer surfaces are intentionally narrower than skill support:
884
893
  | Windsurf, Cline, Roo | unsupported | yes | unsupported |
885
894
  | Antigravity, Zed, Continue, Amp, Jules, Aider | unsupported | unsupported | unsupported |
886
895
 
887
- `manifest.json` is the generated public inventory: 33 agents (4 base + 29
896
+ `manifest.json` is the generated public inventory: 34 agents (5 base + 29
888
897
  selective specialists) and 53 command entries (20 fixed + 33 stack aliases).
889
898
  Unsupported means rsc writes nothing for that surface; it does not emulate a
890
899
  provider feature with an unverified file.
package/manifest.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "3.0.10",
2
+ "version": "3.0.12",
3
3
  "counts": {
4
4
  "skills": 270,
5
- "agents": 33,
5
+ "agents": 34,
6
6
  "commands": 53,
7
7
  "fixedCommands": 20
8
8
  },
@@ -5715,6 +5715,14 @@
5715
5715
  "fastapi"
5716
5716
  ]
5717
5717
  },
5718
+ {
5719
+ "id": "finding-verifier",
5720
+ "description": "Adversarial verifier of review findings: takes ONE consolidated finding and tries to prove it false against the code. Default stance: the finding is false; only a reproduced or traced failure is CONFIRMED. Fresh context, edits nothing.",
5721
+ "role": "refuter",
5722
+ "tier": "balanced",
5723
+ "tools": null,
5724
+ "skills": []
5725
+ },
5718
5726
  {
5719
5727
  "id": "flutter-build-resolver",
5720
5728
  "description": "Flutter build resolver: reproduces a failing build and applies the minimum verified repair. NOT widget review or package modernization.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.10",
3
+ "version": "3.0.12",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@ import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } fr
8
8
  import { shadowTarget } from '../targets/agents-md-shadow.js';
9
9
  import {
10
10
  targetHasAgents, reconcileAgents, agentPath, agentNames, writeAgentsDetailed,
11
- resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
11
+ resolveAgentNames, withAgentCompanions, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
12
12
  } from '../targets/agents.js';
13
13
  import { projectOptOuts } from '../targets/opt-outs.js';
14
14
  import { readState, writeState } from './lib/state.js';
@@ -114,13 +114,13 @@ export function managedPathsForInstall({ skillIds, agentIds = [], target, home,
114
114
  if (targetHasAgents(target)) {
115
115
  const state = readState(paths.stateFile);
116
116
  const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
117
- const desired = policy?.agents || resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit);
117
+ const desired = withAgentCompanions(policy?.agents || resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit));
118
118
  out.push(...desired.map((n) => agentPath(target, cwd, n)));
119
119
  }
120
120
  if (targetHasCommands(target)) {
121
121
  const state = readState(paths.stateFile);
122
122
  const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
123
- const desiredAgents = policy?.agents || resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit);
123
+ const desiredAgents = withAgentCompanions(policy?.agents || resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit));
124
124
  const desiredCommands = resolveCommands({
125
125
  target,
126
126
  skills: policy ? skillIds : [...new Set([...Object.keys(state.skills || {}), ...skillIds])],
@@ -301,11 +301,11 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
301
301
  // naming an agent whose file never landed answers "you have it" for something absent.
302
302
  const inheritedExplicit = policy ? (state.explicitAgents || []) : (state.explicitAgents || readManifest(cwd)?.agents || []);
303
303
  const explicit = [...new Set([...inheritedExplicit, ...agentIds])].sort();
304
- const desiredAgents = policy?.agents
304
+ const desiredAgents = withAgentCompanions(policy?.agents
305
305
  ? [...new Set([...policy.agents, ...explicit])].sort()
306
306
  : policy?.baseAgents === false
307
307
  ? explicit
308
- : resolveAgentNames(Object.keys(state.skills || {}), explicit);
308
+ : resolveAgentNames(Object.keys(state.skills || {}), explicit));
309
309
  const previousAgents = state.agents || [];
310
310
  let keptAgents = [];
311
311
  if (targetHasAgents(target)) {
@@ -0,0 +1,175 @@
1
+ // Consolidation of the refuter panel's findings — the deterministic half of `review`.
2
+ //
3
+ // Three fresh-context lenses attack the same diff, so they often report the SAME defect in three
4
+ // different sentences, and the author spends triage time discovering that eleven findings are four.
5
+ // Grouping them is an algorithm, not a judgement (P1), so it lives here and not in the skill's prose.
6
+ // What survives grouping and is serious enough to block goes to `finding-verifier` — that half IS
7
+ // judgement, and stays in the agent.
8
+ //
9
+ // Input: what the lenses emit — fenced ```json findings blocks inside their reports, or plain JSON.
10
+ // Output: unique findings, highest severity first, each with the lenses that reported it and a
11
+ // `verify` flag on the ones the verifier must try to refute before they may block.
12
+
13
+ export const SEVERITIES = ['blocker', 'should-fix', 'nit', 'question'];
14
+ const ALIASES = {
15
+ critical: 'blocker', high: 'blocker', blocking: 'blocker',
16
+ medium: 'should-fix', shouldfix: 'should-fix', 'should_fix': 'should-fix', major: 'should-fix',
17
+ low: 'nit', info: 'nit', minor: 'nit',
18
+ };
19
+ const rank = (s) => SEVERITIES.indexOf(s);
20
+ export const DEFAULT_WINDOW = 3;
21
+
22
+ export function normalizeSeverity(raw) {
23
+ const s = String(raw || '').trim().toLowerCase();
24
+ if (SEVERITIES.includes(s)) return s;
25
+ return ALIASES[s] || ALIASES[s.replace(/[-\s]/g, '')] || null;
26
+ }
27
+
28
+ const text = (v) => (typeof v === 'string' ? v.trim() : '');
29
+ const normFile = (f) => text(f).replace(/\\/g, '/').replace(/^\.\//, '');
30
+ const normEvidence = (e) => text(e).replace(/\s+/g, ' ').toLowerCase();
31
+
32
+ // Pull every finding out of one source. A source is a lens report (markdown with fenced blocks) or a
33
+ // JSON file. Anything unparseable is reported, never silently dropped: a lost finding is the one
34
+ // failure a consolidation step must not add.
35
+ export function parseFindings(raw, source = 'stdin') {
36
+ const findings = [];
37
+ const errors = [];
38
+ const take = (value, where) => {
39
+ const list = Array.isArray(value) ? value : (Array.isArray(value?.findings) ? value.findings : [value]);
40
+ for (const item of list) {
41
+ if (!item || typeof item !== 'object') { errors.push(`${where}: a finding must be an object`); continue; }
42
+ findings.push({ ...item, source });
43
+ }
44
+ };
45
+ const blocks = [...String(raw).matchAll(/```json[ \t]+findings[^\n]*\n([\s\S]*?)```/g)];
46
+ if (blocks.length) {
47
+ blocks.forEach((m, i) => {
48
+ try { take(JSON.parse(m[1]), `${source} block ${i + 1}`); }
49
+ catch (e) { errors.push(`${source} block ${i + 1}: not valid JSON (${e.message})`); }
50
+ });
51
+ return { findings, errors };
52
+ }
53
+ const trimmed = String(raw).trim();
54
+ if (!trimmed) return { findings, errors };
55
+ try { take(JSON.parse(trimmed), source); }
56
+ catch { errors.push(`${source}: no \`\`\`json findings block and not plain JSON`); }
57
+ return { findings, errors };
58
+ }
59
+
60
+ // The pre-report gate, checked instead of trusted: a blocker or should-fix with no location or no
61
+ // concrete failure scenario is a suspicion, and a suspicion is a question. The downgrade is visible.
62
+ export function normalizeFinding(f) {
63
+ const out = {
64
+ lens: text(f.lens) || text(f.source) || 'unknown',
65
+ severity: normalizeSeverity(f.severity),
66
+ file: normFile(f.file),
67
+ line: Number.isInteger(Number(f.line)) && Number(f.line) > 0 ? Number(f.line) : null,
68
+ claim: text(f.claim) || text(f.title) || text(f.summary),
69
+ failure_scenario: text(f.failure_scenario) || text(f.repro),
70
+ evidence: text(f.evidence),
71
+ notes: [],
72
+ };
73
+ if (!out.severity) {
74
+ out.notes.push(`severity "${f.severity ?? ''}" unknown → question`);
75
+ out.severity = 'question';
76
+ }
77
+ if (rank(out.severity) <= rank('should-fix')) {
78
+ const missing = [];
79
+ if (!out.file || !out.line) missing.push('file:line');
80
+ if (!out.failure_scenario) missing.push('failure_scenario');
81
+ if (missing.length) {
82
+ out.notes.push(`${out.severity} without ${missing.join(' and ')} → question`);
83
+ out.severity = 'question';
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Two findings are the same defect when they quote the same evidence, or when they point at the same
90
+ // place (same file, lines within the window) AND say the same thing. Place alone is not enough: the
91
+ // first real panel run put two different defects on adjacent lines (a discount applied to one unit,
92
+ // and a missing 0..100 check), and merging them buried the second one where no verifier looked.
93
+ // A missed merge costs one extra verification; a wrong merge hides a defect — so the bar is
94
+ // conservative. "Say the same thing" is word overlap (Jaccard) of claim, or claim + failure scenario.
95
+ const STOP = new Set('the and for with not that this its are was from any only one but has have into than then when which will can its'.split(' '));
96
+ const words = (s) => new Set(String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)));
97
+ function overlap(a, b) {
98
+ const A = words(a); const B = words(b);
99
+ let shared = 0;
100
+ for (const w of A) if (B.has(w)) shared += 1;
101
+ const union = A.size + B.size - shared;
102
+ return union ? shared / union : 0;
103
+ }
104
+ export const SAME_CLAIM = 0.2;
105
+ export function similarity(a, b) {
106
+ return Math.max(overlap(a.claim, b.claim),
107
+ overlap(`${a.claim} ${a.failure_scenario}`, `${b.claim} ${b.failure_scenario}`));
108
+ }
109
+ function sameDefect(a, b, window) {
110
+ const ea = normEvidence(a.evidence);
111
+ if (ea.length >= 12 && ea === normEvidence(b.evidence)) return true;
112
+ const near = a.file && a.file === b.file && a.line && b.line && Math.abs(a.line - b.line) <= window;
113
+ return Boolean(near) && similarity(a, b) >= SAME_CLAIM;
114
+ }
115
+
116
+ export function consolidate(rawFindings, { window = DEFAULT_WINDOW } = {}) {
117
+ const items = rawFindings.map(normalizeFinding);
118
+ const parent = items.map((_, i) => i);
119
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
120
+ for (let i = 0; i < items.length; i += 1) {
121
+ for (let j = i + 1; j < items.length; j += 1) {
122
+ if (sameDefect(items[i], items[j], window)) parent[find(j)] = find(i);
123
+ }
124
+ }
125
+ const groups = new Map();
126
+ items.forEach((it, i) => {
127
+ const root = find(i);
128
+ if (!groups.has(root)) groups.set(root, []);
129
+ groups.get(root).push(it);
130
+ });
131
+ const uniq = (xs) => [...new Set(xs.filter(Boolean))];
132
+ const merged = [...groups.values()].map((members) => {
133
+ const severity = SEVERITIES[Math.min(...members.map((m) => rank(m.severity)))];
134
+ const lines = members.map((m) => m.line).filter(Boolean);
135
+ return {
136
+ severity,
137
+ file: uniq(members.map((m) => m.file))[0] || '',
138
+ lines: lines.length ? [Math.min(...lines), Math.max(...lines)] : [],
139
+ lenses: uniq(members.map((m) => m.lens)).sort(),
140
+ claims: uniq(members.map((m) => m.claim)),
141
+ failure_scenarios: uniq(members.map((m) => m.failure_scenario)),
142
+ evidence: uniq(members.map((m) => m.evidence)),
143
+ notes: uniq(members.flatMap((m) => m.notes)),
144
+ reported: members.length,
145
+ verify: rank(severity) <= rank('should-fix'),
146
+ };
147
+ });
148
+ merged.sort((a, b) => rank(a.severity) - rank(b.severity)
149
+ || a.file.localeCompare(b.file) || (a.lines[0] || 0) - (b.lines[0] || 0));
150
+ merged.forEach((m, i) => { m.id = `F${i + 1}`; });
151
+ return {
152
+ reported: items.length,
153
+ unique: merged.length,
154
+ toVerify: merged.filter((m) => m.verify).length,
155
+ findings: merged,
156
+ };
157
+ }
158
+
159
+ const where = (f) => (f.file ? `${f.file}${f.lines.length ? `:${f.lines[0]}${f.lines[1] !== f.lines[0] ? `-${f.lines[1]}` : ''}` : ''}` : '(no location)');
160
+
161
+ export function renderConsolidation(result, errors = []) {
162
+ const out = [
163
+ `${result.reported} finding(s) reported → ${result.unique} unique · ${result.toVerify} go to finding-verifier`,
164
+ ];
165
+ for (const f of result.findings) {
166
+ out.push('', `${f.id} [${f.severity}] ${where(f)} — ${f.claims[0] || '(no claim)'}`);
167
+ out.push(` lenses: ${f.lenses.join(', ')}${f.reported > 1 ? ` (${f.reported} reports merged)` : ''}`);
168
+ for (const c of f.claims.slice(1)) out.push(` also: ${c}`);
169
+ for (const s of f.failure_scenarios) out.push(` failure: ${s}`);
170
+ for (const n of f.notes) out.push(` note: ${n}`);
171
+ if (f.verify) out.push(' → verify: dispatch finding-verifier; it blocks only if CONFIRMED');
172
+ }
173
+ for (const e of errors) out.push('', `⚠️ ${e}`);
174
+ return out.join('\n');
175
+ }
package/scripts/rsc.js CHANGED
@@ -46,7 +46,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
46
46
  process.exit(0);
47
47
  }
48
48
  const GENERAL_HELP = [
49
- 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
49
+ 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | review consolidate [reports…] | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
50
50
  'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
51
51
  ' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
52
52
  ].join('\n');
@@ -77,7 +77,7 @@ const PURGE_HELP = [
77
77
  const GLOBAL_VALUE_FLAGS = new Set([
78
78
  '--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
79
79
  ]);
80
- const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'sello', 'repair', 'uninstall', 'purge']);
80
+ const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'review', 'sello', 'repair', 'uninstall', 'purge']);
81
81
  function positionalTokens(input) {
82
82
  const out = [];
83
83
  for (let i = 0; i < input.length; i++) {
@@ -1181,6 +1181,37 @@ async function main() {
1181
1181
  say('Use: npx @ericrisco/rsc registry refresh | registry status');
1182
1182
  return;
1183
1183
  }
1184
+ case 'review': {
1185
+ // `review consolidate` — the deterministic half of the refuter panel (P1): the three lenses
1186
+ // often report one defect three ways, so grouping happens here, by location and evidence, and
1187
+ // only the unique serious findings go on to `finding-verifier`, which is judgement.
1188
+ const USE = 'Use: npx @ericrisco/rsc review consolidate [report.md|findings.json ...] [--json] [--window <lines>]\n Without files it reads stdin. Each lens report carries its findings in ```json findings blocks.';
1189
+ if (argv[1] !== 'consolidate') { say(USE); if (argv[1]) process.exitCode = 1; return; }
1190
+ const { readFileSync } = await import('node:fs');
1191
+ const R = await import('./lib/review-findings.js');
1192
+ const w = flag('window');
1193
+ const window = w === undefined ? R.DEFAULT_WINDOW : Number(w);
1194
+ if (!Number.isInteger(window) || window < 0) { say(`rsc: --window takes a whole number of lines, got '${w}'.\n${USE}`); process.exitCode = 1; return; }
1195
+ const rest = argv.slice(2);
1196
+ const files = rest.filter((a, i) => !a.startsWith('--') && rest[i - 1] !== '--window');
1197
+ const sources = [];
1198
+ try {
1199
+ if (files.length) for (const f of files) sources.push([readFileSync(f, 'utf8'), f]);
1200
+ else if (!process.stdin.isTTY) sources.push([readFileSync(0, 'utf8'), 'stdin']);
1201
+ } catch (e) { say(`rsc: cannot read ${e.path || 'stdin'} (${e.code || e.message}).`); process.exitCode = 1; return; }
1202
+ if (!sources.length) { say(USE); process.exitCode = 1; return; }
1203
+ const all = []; const errors = [];
1204
+ for (const [raw, src] of sources) {
1205
+ const parsed = R.parseFindings(raw, src);
1206
+ all.push(...parsed.findings); errors.push(...parsed.errors);
1207
+ }
1208
+ const result = R.consolidate(all, { window });
1209
+ if (argv.includes('--json')) say(JSON.stringify({ ...result, errors }, null, 2));
1210
+ else say(R.renderConsolidation(result, errors));
1211
+ // An unreadable block is a finding that may have been lost: say it with the exit code too.
1212
+ if (errors.length) process.exitCode = 1;
1213
+ return;
1214
+ }
1184
1215
  case 'worktrees': {
1185
1216
  // The deterministic half of "the cleanup is the default". `ship` and the `worktrees` skill call
1186
1217
  // this instead of judging by eye, which is what turns a rule that was only ever written down
@@ -93,6 +93,8 @@ Read the surrounding code, trace the *value*, confirm the path is reachable.
93
93
 
94
94
  If you cannot trace it to a concrete value and a reachable sink, you do not yet have a finding.
95
95
 
96
+ When several reviewers looked at the same diff, merge before reporting. Have each one end with a ` ```json findings ` block, run `npx @ericrisco/rsc review consolidate <reports…>`, and send each blocker it marks `verify` to the `finding-verifier` agent. That is the `review` skill's step, and it applies here unchanged.
97
+
96
98
  ## The verdict
97
99
 
98
100
  End every review with exactly one, plainly — no mushy middle:
@@ -128,7 +128,8 @@ gap deliberately with `--accept-partial-lenses` (it is recorded). Every approval
128
128
 
129
129
  **Lenses by risk tier** — tier 0 never reaches you (the gate passes docs/copy silently);
130
130
  tier 1 → run the single most relevant pass from the table above yourself; tier 2 → dispatch
131
- **three parallel fresh-context subagents** (correctness · security · tests-as-evidence), each told
131
+ **three parallel fresh-context subagents** (`refuter-correctness` · `refuter-security` ·
132
+ `refuter-tests`), each told
132
133
  to *refute* readiness, not confirm it. Fresh context is the point: a reviewer who inherits the
133
134
  implementer's context inherits its blind spots.
134
135
 
@@ -164,6 +165,26 @@ Two extra finding classes the four inputs unlock, neither reachable from a diff:
164
165
  - **Mapping, both directions** — an acceptance criterion with no test that can be made to fail, and
165
166
  a test pinning behaviour no criterion asked for.
166
167
 
168
+ **Consolidate, then verify — before anything reaches the author.** Three lenses on one diff often
169
+ report one defect three ways, and a lens hunting for something can overstate what it found. Two
170
+ steps, in this order:
171
+
172
+ 1. **Group (deterministic).** Each lens ends its report with a ` ```json findings ` block. Save the
173
+ three reports and run `npx @ericrisco/rsc review consolidate <reports…>`. It merges findings on
174
+ the same file within 3 lines, or quoting the same evidence; keeps the highest severity and every
175
+ lens that saw it; and downgrades a blocker or should-fix with no `file:line` or failure scenario to
176
+ a question. Several lenses on one finding is a reason to look first, never a reason to raise its
177
+ severity.
178
+ 2. **Verify (judgement).** For each finding it marks `verify`, dispatch the **`finding-verifier`**
179
+ agent with that one finding plus the same four inputs, in parallel. It starts from "this is false"
180
+ and returns **CONFIRMED** (reproduced or traced, with the proof), **REFUTED** (cites the guard that
181
+ makes it impossible), or **UNPROVEN** (becomes a question). It never gets the lens's reasoning.
182
+
183
+ Report the consolidated count honestly ("11 reported → 4 unique → 2 confirmed"), and list what was
184
+ refuted and why: a refuted finding hidden from the author is a review nobody can audit. At tier 1
185
+ (one pass, run by you) there is nothing to group, but a blocker still goes to `finding-verifier`
186
+ before it blocks.
187
+
167
188
  **A finding blocks only if it survives all three filters** — no exceptions, and eagerness to
168
189
  find something is not evidence:
169
190
 
@@ -171,8 +192,8 @@ find something is not evidence:
171
192
  `--note "<finding>"` (they land in `.rsc/sello-findings.md`, surfaced by `doctor`) and
172
193
  suggest an issue; they never block this delivery.
173
194
  2. **Severity** — only `blocker` blocks. `should-fix`/`nit` → `--note`.
174
- 3. **Evidence** — a `repro` or a concrete failure scenario. A suspicion without one is a
175
- `[question]`, and questions don't block.
195
+ 3. **Evidence** — `finding-verifier` returned **CONFIRMED** with its proof. REFUTED is dropped (with
196
+ its reason in the report); UNPROVEN is a `[question]`, and questions don't block.
176
197
 
177
198
  **Fixing a blocker is budgeted, one attempt.** Before touching anything: estimate the fix and
178
199
  declare it — `npx @ericrisco/rsc sello budget --lines <N>`. After the fix:
package/targets/agents.js CHANGED
@@ -75,7 +75,15 @@ ${REFUTER_FOUR_INPUTS}
75
75
  3. Did you inspect the relevant **caller, import, and relevant test**?
76
76
  4. Can the severity survive the **existing guards** you verified?
77
77
 
78
- If an answer is no, lower the severity or omit the finding. Every **HIGH or CRITICAL** needs the line and failure mode in the report. **Zero findings with an attack list is valid.**
78
+ If an answer is no, lower the severity or omit the finding. Every **blocker or should-fix** needs the line and failure mode in the report. **Zero findings with an attack list is valid.**
79
+
80
+ **Report findings in one shape, so the panel can be consolidated** — a fenced ${'`json findings`'} block holding an array (empty when you found nothing):
81
+
82
+ ${'```'}json findings
83
+ [{ "lens": "<your lens>", "severity": "blocker|should-fix|nit|question", "file": "path/from/repo/root", "line": 42, "claim": "one sentence: what is wrong", "failure_scenario": "concrete input + state -> wrong result", "evidence": "the exact changed line, quoted" }]
84
+ ${'```'}
85
+
86
+ ${'`rsc review consolidate`'} merges what several lenses reported about the same place and sends each unique blocker or should-fix to ${'`finding-verifier`'}, whose job is to prove it false. A blocker or should-fix with no ${'`file`'}, ${'`line`'} or ${'`failure_scenario`'} is downgraded to a question there, mechanically.
79
87
 
80
88
  **Common false positives to reject:** an equivalent mutant with no diverging input; a documented dummy value that never reaches a sink; a deliberate boundary already enforced by a caller; generated/vendor code outside the change; style preference presented as correctness; and a theoretical race with no shared state or overlapping lifetime.
81
89
 
@@ -133,8 +141,46 @@ ${REFUTER_CONTRACT}
133
141
 
134
142
  **Check the mapping both ways:** every acceptance criterion needs a falsification procedure that can be made to fail, and every test should trace to something someone asked for.`,
135
143
  },
144
+ // The second half of ECC's orch-review, adapted: after `rsc review consolidate` collapses the panel's
145
+ // duplicates, every unique blocker/should-fix gets ONE more fresh context whose default is that the
146
+ // finding is false. A refuter attacks the diff; the verifier attacks the finding. Same asymmetry,
147
+ // pointed the other way — which is what stops an exaggerated lens from reaching the author as a blocker.
148
+ {
149
+ name: 'finding-verifier',
150
+ desc: 'Adversarial verifier of review findings: takes ONE consolidated finding and tries to prove it false against the code. Default stance: the finding is false; only a reproduced or traced failure is CONFIRMED. Fresh context, edits nothing.',
151
+ body: `You are the **finding verifier** for this project. ${'`review`'} hands you **one** finding that survived ${'`rsc review consolidate`'} — a blocker or should-fix some refuter lens reported against a diff. Your job is to **prove it false**. Your starting position is that it is false; the finding has to beat you.
152
+
153
+ **Your inputs:** the finding (location, claim, failure scenario, evidence) plus the same four the lenses had —
154
+
155
+ ${REFUTER_FOUR_INPUTS}
156
+
157
+ **You do NOT get** the refuter's reasoning or the builder's defence. Judge the claim against the code, not against either story.
158
+
159
+ **How to try to kill it:**
160
+ 1. Read the cited line **and** its callers, guards and tests — the check that makes it impossible often lives two functions up, or in a caller that never passes that input.
161
+ 2. Try the failure scenario for real when you can: run the test, execute the command, or write a throwaway check in a temp directory outside the repo. An observed failure outranks any argument.
162
+ 3. Ask whether the change **caused** it. A defect that was already there is real but does not block this delivery.
163
+
164
+ **Verdict — exactly one, with its proof:**
165
+ - **CONFIRMED** — you reproduced it, or traced a reachable path from a concrete input to the wrong result. Quote the output or the path. Only this verdict may block.
166
+ - **REFUTED** — you found what makes it impossible (the guard, the caller, the type, the test). Cite it with file and line.
167
+ - **UNPROVEN** — neither: it becomes a question for the human, and questions never block.
168
+
169
+ Also state whether the severity holds. A CONFIRMED finding whose blast radius is narrow is a should-fix, not a blocker — say so. You fix nothing and edit no file: your output is the verdict, the proof, and the commands you ran.`,
170
+ },
136
171
  ];
137
172
 
173
+
174
+ // Agents that only make sense next to others, and so travel with them. A project whose accepted plan
175
+ // lists the refuters explicitly (onboarded before 3.0.11) would otherwise get a `review` skill that
176
+ // dispatches `finding-verifier` into a project that does not have it. Same panel the user accepted,
177
+ // one more member — not a new decision, so it is added on sync rather than asked.
178
+ const COMPANIONS = { 'finding-verifier': (names) => names.some((n) => n.startsWith('refuter-')) };
179
+ export function withAgentCompanions(names = []) {
180
+ const extra = Object.entries(COMPANIONS).filter(([name, needs]) => !names.includes(name) && needs(names)).map(([name]) => name);
181
+ return extra.length ? [...names, ...extra] : names;
182
+ }
183
+
138
184
  export const BASE_AGENT_NAMES = Object.freeze(AGENTS.map((agent) => agent.name));
139
185
  export { stackAgents, stackAgentNames, validateAgentCatalog };
140
186
  export const allAgentNames = () => [...BASE_AGENT_NAMES, ...stackAgentNames()];