@ericrisco/rsc 3.0.9 → 3.0.11

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/scripts/rsc.js CHANGED
@@ -4,9 +4,12 @@ import { detectTarget, installedTargets, resolveTargets, TARGETS } from '../targ
4
4
  import { detectRepo } from './detect-repo.js';
5
5
  import { rank } from './consult.js';
6
6
  import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
7
- import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths } from './install-apply.js';
7
+ import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths, resetAgents } from './install-apply.js';
8
8
  import { isAbsolute, relative, sep } from 'node:path';
9
- import { stackAgentNames } from '../targets/agents.js';
9
+ import {
10
+ stackAgentNames, targetHasAgents, isValidAgentModel, agentModelSetting, targetInheritsByDefault,
11
+ effectiveAgentModel, writeAgentsDetailed, readDeveloperTier, AGENT_MODEL_INHERIT, AGENT_TARGET_IDS,
12
+ } from '../targets/agents.js';
10
13
  import { doctor } from './doctor.js';
11
14
  import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
12
15
  import { refreshRegistry, registryStatus } from './lib/registry.js';
@@ -43,7 +46,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
43
46
  process.exit(0);
44
47
  }
45
48
  const GENERAL_HELP = [
46
- 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | 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',
47
50
  'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
48
51
  ' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
49
52
  ].join('\n');
@@ -74,7 +77,7 @@ const PURGE_HELP = [
74
77
  const GLOBAL_VALUE_FLAGS = new Set([
75
78
  '--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
76
79
  ]);
77
- const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', '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']);
78
81
  function positionalTokens(input) {
79
82
  const out = [];
80
83
  for (let i = 0; i < input.length; i++) {
@@ -416,7 +419,7 @@ function reportUnknown(ids) {
416
419
  // flag says which: `purge` sweeps every target by design, and the catalog/consult
417
420
  // side barely touches one, so blocking them would be a regression, not a safeguard.
418
421
  const NEEDS_TARGET = new Set([
419
- 'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
422
+ 'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry', 'agents',
420
423
  ]);
421
424
 
422
425
  function flag(name) {
@@ -573,6 +576,11 @@ function printAgentHandoff() {
573
576
  }
574
577
  if (readiness.ready) {
575
578
  say(' 3. Tell the user rsc is ready; they can start in their own words.');
579
+ // #298 — ready is not «nothing pending»: the onboarding draft is named here too, not only in
580
+ // RSC_ONBOARDING_READY, so an agent arriving through install/sync tells the user before an SDD feature.
581
+ let draft = false;
582
+ try { draft = constitutionIsDraft(process.cwd()); } catch { /* unreadable is not a draft */ }
583
+ if (draft) say(` Tell them too: ${CONSTITUTION_PATH} is a draft — complete it with the \`constitution\` phase before the first SDD feature.`);
576
584
  say(' Do NOT auto-start a task — wait for the user.');
577
585
  } else {
578
586
  // El otro emisor. Decía «ready» desde el wizard y desde `install` sin mirar el suelo, así que
@@ -585,6 +593,19 @@ function printAgentHandoff() {
585
593
  say('════════════════════════════════════════════════');
586
594
  }
587
595
 
596
+ // #298 — the two answers a person looks for first, before any figure or JSON: does the installed
597
+ // harness work (`healthy`, the one the exit code follows) and is onboarding finished (the accepted
598
+ // plan's floor plus drafts). Kept apart on purpose — readiness never changes health nor the exit code.
599
+ const READINESS_LABEL = { ready: 'ready', pending: 'pending', incomplete: 'incomplete', 'not-onboarded': 'not onboarded' };
600
+ function printDoctorHeadline(report) {
601
+ const o = report.onboarding || {};
602
+ say(`Harness health: ${report.healthy ? 'healthy' : 'unhealthy'}`);
603
+ say(`Onboarding readiness: ${READINESS_LABEL[o.status] || 'unknown'}`);
604
+ if (o.missing?.length) say(`Missing: ${safeLines(o.missing).join(', ')}`);
605
+ if (o.pending?.length) say(`Pending: ${safeLines(o.pending).map((p) => (p === CONSTITUTION_PATH ? `${p} (draft)` : p)).join(', ')}`);
606
+ if (o.action) say(`Next: ${o.action}`);
607
+ }
608
+
588
609
  // What the harness costs in context before the user types anything. Printed ahead of the raw
589
610
  // health JSON because it is the part a human acts on: the figures say whether the harness is
590
611
  // paying its way, and the findings say what to do about it.
@@ -624,6 +645,7 @@ async function syncDeclared(targets, dry = false) {
624
645
  const result = await syncInstalled({ target: t, dryRun: dry });
625
646
  const verb = dry ? 'Would sync' : 'Synced';
626
647
  say(`${verb} ${t}: ${result.synced.length ? result.synced.join(', ') : '(nothing to sync)'}`);
648
+ for (const k of result.keptAgents || []) say(` ${k.message}`);
627
649
  if (dry && result.paths?.length) {
628
650
  for (const p of result.paths) say(` ${p}`);
629
651
  }
@@ -870,6 +892,7 @@ async function main() {
870
892
  // #277 — an automation on top of rsc reads the exit code, not the prose.
871
893
  if (!report.healthy) process.exitCode = 1;
872
894
  if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
895
+ printDoctorHeadline(report);
873
896
  printContextBudget(report.contextBudget);
874
897
  return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
875
898
  }
@@ -952,26 +975,28 @@ async function main() {
952
975
  const sub = argv[1] || 'status';
953
976
  const root = process.cwd();
954
977
  const { readManifest, writeManifest } = await import('./lib/manifest-file.js');
955
- const { gitPermissionsWired, GIT_PERMISSION_TARGETS } = await import('../targets/git-permissions.js');
978
+ const { gitPermissionsState, explainGitPermissions, GIT_PERMISSION_TARGETS } = await import('../targets/git-permissions.js');
956
979
  if (sub === 'on' || sub === 'off') {
957
980
  const current = readManifest(root);
958
981
  if (!current) { say('No .rsc.json here: install the harness first (`npx @ericrisco/rsc onboard`).'); process.exitCode = 2; return; }
959
982
  writeManifest(root, { ...current, gitPermissions: sub === 'on' });
960
983
  for (const t of targets) await syncInstalled({ target: t, cwd: root });
961
- say(sub === 'on'
962
- ? 'rsc git-permissions on: the agent may git commit, git push and gh pr create without asking (a force-push still asks; Cursor is not covered). Commit .rsc.json so the team gets the same decision.'
963
- : 'rsc git-permissions off: commit, push and PR ask again, as the assistant does by default. Commit .rsc.json so the team gets the same decision.');
964
- return;
984
+ // What rsc controls, what stops when off, and what is the assistant's own job (#298).
985
+ return void say(explainGitPermissions({ targets, cwd: root, mode: sub }));
965
986
  }
966
987
  if (sub === 'status') {
967
988
  const declared = readManifest(root)?.gitPermissions;
989
+ if (!flag('json')) return void say(explainGitPermissions({ targets, cwd: root, mode: 'status', declared }));
990
+ const covered = targets.filter((t) => GIT_PERMISSION_TARGETS.includes(t));
991
+ const states = Object.fromEntries(covered.map((t) => [t, gitPermissionsState(t, root)]));
968
992
  return void say(JSON.stringify({
969
993
  declared: declared === undefined ? 'undecided' : declared,
970
- wired: Object.fromEntries(targets.filter((t) => GIT_PERMISSION_TARGETS.includes(t)).map((t) => [t, gitPermissionsWired(t, root)])),
994
+ wired: Object.fromEntries(covered.map((t) => [t, states[t].wired])),
995
+ format: Object.fromEntries(covered.map((t) => [t, states[t].format])),
971
996
  notCovered: targets.filter((t) => !GIT_PERMISSION_TARGETS.includes(t)),
972
997
  }, null, 2));
973
998
  }
974
- say('Use: npx @ericrisco/rsc git-permissions on|off|status');
999
+ say('Use: npx @ericrisco/rsc git-permissions on|off|status [--json]');
975
1000
  process.exitCode = 2;
976
1001
  return;
977
1002
  }
@@ -1023,6 +1048,98 @@ async function main() {
1023
1048
  process.exitCode = 1;
1024
1049
  return;
1025
1050
  }
1051
+ case 'agent-model': {
1052
+ // #298 — which model the generated agents carry: the session's (`inherit`, OpenCode's default)
1053
+ // or one the project pins. A PROJECT decision, saved as `agentModels` in .rsc.json, so a clone
1054
+ // renders the same agents; the files of that target are re-rendered at once.
1055
+ const root = process.cwd();
1056
+ const t = argv[1];
1057
+ const value = argv[2] || 'status';
1058
+ const describe = (id) => {
1059
+ const setting = agentModelSetting(root, id);
1060
+ const effective = effectiveAgentModel(id, root);
1061
+ return {
1062
+ target: id,
1063
+ declared: setting || (targetInheritsByDefault(id) ? 'default (inherit)' : 'default (tier model)'),
1064
+ effective: effective || 'inherit — the agent uses the session model',
1065
+ };
1066
+ };
1067
+ if (!t || t === 'status') {
1068
+ return void say(JSON.stringify(targets.filter(targetHasAgents).map(describe), null, 2));
1069
+ }
1070
+ if (!targetHasAgents(t)) {
1071
+ console.error(`rsc: ${t} has no file-based agents. Targets with agents: ${AGENT_TARGET_IDS.join(', ')}`);
1072
+ process.exitCode = 2;
1073
+ return;
1074
+ }
1075
+ if (value === 'status') return void say(JSON.stringify(describe(t), null, 2));
1076
+ if (!isValidAgentModel(value)) {
1077
+ console.error(`rsc: '${value}' is not a model id (letters, digits and . _ : / @ + - only), nor 'inherit'.\nUse: npx @ericrisco/rsc agent-model <target> <model|inherit|status>`);
1078
+ process.exitCode = 2;
1079
+ return;
1080
+ }
1081
+ const current = readManifest(root);
1082
+ if (!current) { say('No .rsc.json here: install the harness first (`npx @ericrisco/rsc onboard`).'); process.exitCode = 2; return; }
1083
+ const next = { ...(current.agentModels || {}) };
1084
+ // `inherit` on a target whose default is inherit stores nothing: the manifest stays as small as the decision.
1085
+ if (value === AGENT_MODEL_INHERIT && targetInheritsByDefault(t)) delete next[t];
1086
+ else next[t] = value;
1087
+ writeManifest(root, { ...current, agentModels: Object.keys(next).length ? next : undefined });
1088
+ const { targetPaths: pathsOf } = await import('../targets/index.js');
1089
+ const { readState } = await import('./lib/state.js');
1090
+ const installedAgents = readState(pathsOf(t, undefined, root).stateFile).agents || [];
1091
+ const { written, kept } = writeAgentsDetailed(t, root, readDeveloperTier(root), installedAgents);
1092
+ say(value === AGENT_MODEL_INHERIT
1093
+ ? `rsc agent-model ${t} inherit: the agents carry no model and use the session's.`
1094
+ : `rsc agent-model ${t} ${value}: the agents are pinned to ${value}.`);
1095
+ say(` Re-rendered ${written.length} agent file(s). Commit .rsc.json so the team gets the same decision.`);
1096
+ for (const k of kept) say(` ${k.message}`);
1097
+ return;
1098
+ }
1099
+ case 'agents': {
1100
+ const sub = argv[1] || 'status';
1101
+ const root = process.cwd();
1102
+ if (sub === 'reset') {
1103
+ const all = argv.includes('--all');
1104
+ const names = requestedIds(2);
1105
+ if (!all && !names.length) {
1106
+ console.error('Use: npx @ericrisco/rsc agents reset <name...|--all> [--target <assistant>]');
1107
+ process.exitCode = 2;
1108
+ return;
1109
+ }
1110
+ const { targetPaths: pathsOf } = await import('../targets/index.js');
1111
+ const { readState } = await import('./lib/state.js');
1112
+ const withAgents = targets.filter(targetHasAgents);
1113
+ let done = 0;
1114
+ for (const t of withAgents) {
1115
+ const installed = readState(pathsOf(t, undefined, root).stateFile).agents || [];
1116
+ const mine = all ? [] : names.filter((n) => installed.includes(n));
1117
+ if (!all && !mine.length) continue;
1118
+ const out = resetAgents({ target: t, cwd: root, names: mine, all });
1119
+ done += out.reset.length;
1120
+ if (out.reset.length) {
1121
+ say(`Reset ${t}: ${out.reset.join(', ')} — rsc's version is back.`);
1122
+ if (out.backup) say(` Your previous files are kept in ${out.backup}/files/ (\`rsc restore\` lists the snapshot).`);
1123
+ }
1124
+ }
1125
+ if (!all && !done) {
1126
+ console.error(`rsc: ${names.join(', ')}: not an agent rsc installed here (${withAgents.join(', ') || 'no target with agents'}).`);
1127
+ process.exitCode = 1;
1128
+ }
1129
+ return;
1130
+ }
1131
+ if (sub === 'status') {
1132
+ const { targetPaths: pathsOf } = await import('../targets/index.js');
1133
+ const { readState } = await import('./lib/state.js');
1134
+ return void say(JSON.stringify(targets.filter(targetHasAgents).map((t) => {
1135
+ const state = readState(pathsOf(t, undefined, root).stateFile);
1136
+ return { target: t, agents: state.agents || [], editedByYou: (state.agentsKept || []).map((k) => k.name) };
1137
+ }), null, 2));
1138
+ }
1139
+ say('Use: npx @ericrisco/rsc agents status | agents reset <name...|--all>');
1140
+ process.exitCode = 2;
1141
+ return;
1142
+ }
1026
1143
  case 'sync':
1027
1144
  return syncDeclared(targets, argv.includes('--dry-run'));
1028
1145
  case 'backups': {
@@ -1064,6 +1181,37 @@ async function main() {
1064
1181
  say('Use: npx @ericrisco/rsc registry refresh | registry status');
1065
1182
  return;
1066
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
+ }
1067
1215
  case 'worktrees': {
1068
1216
  // The deterministic half of "the cleanup is the default". `ship` and the `worktrees` skill call
1069
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
@@ -9,7 +9,9 @@
9
9
  // elsewhere. The chosen tier (balanced default, or heavy) lives in `.rsc/developer.json`,
10
10
  // written by `init` at onboarding and read here so re-syncs honor it.
11
11
  import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
12
- import { join, dirname } from 'node:path';
12
+ import { join, dirname, relative, sep } from 'node:path';
13
+ import { createHash } from 'node:crypto';
14
+ import { readManifest } from '../scripts/lib/manifest-file.js';
13
15
  import {
14
16
  stackAgents, stackAgentNames, stackAgentByName,
15
17
  resolveStackAgentNames, validateAgentCatalog,
@@ -32,7 +34,12 @@ const AGENT_TARGETS = {
32
34
  claude: { dir: '.claude/agents', ext: '.md', format: 'md', model: (t) => (t === 'heavy' ? 'opus' : 'sonnet') },
33
35
  junie: { dir: '.junie/agents', ext: '.md', format: 'md', model: (t) => (t === 'heavy' ? 'opus' : 'sonnet') },
34
36
  cursor: { dir: '.cursor/agents', ext: '.md', format: 'md', model: (t) => TIER_MODEL.anthropic[t] },
35
- opencode: { dir: '.opencode/agents', ext: '.md', format: 'md', mode: 'subagent', toolsFormat: 'map', model: (t) => `anthropic/${TIER_MODEL.anthropic[t]}` },
37
+ // #298 — OpenCode is the one tool people point at a local model or another provider per session, so
38
+ // its agents INHERIT the session model by default (`model: null` → no `model:` line) and only carry
39
+ // one when the project pins it (`agentModels.opencode` in .rsc.json). And its `tools` map is
40
+ // restrict-only: rsc never grants a tool there, it can only take away what a role never declared,
41
+ // so a stricter project permission policy is never loosened by an rsc agent.
42
+ opencode: { dir: '.opencode/agents', ext: '.md', format: 'md', mode: 'subagent', toolsFormat: 'restrict', model: () => null, legacyModel: (t) => `anthropic/${TIER_MODEL.anthropic[t]}` },
36
43
  gemini: { dir: '.gemini/agents', ext: '.md', format: 'md', model: (t) => TIER_MODEL.google[t] },
37
44
  copilot: { dir: '.github/agents', ext: '.agent.md', format: 'md', model: (t) => TIER_MODEL.anthropic[t] },
38
45
  kiro: { dir: '.kiro/agents', ext: '.json', format: 'json', model: (t) => (t === 'heavy' ? 'claude-opus-4' : 'claude-sonnet-4') },
@@ -68,7 +75,15 @@ ${REFUTER_FOUR_INPUTS}
68
75
  3. Did you inspect the relevant **caller, import, and relevant test**?
69
76
  4. Can the severity survive the **existing guards** you verified?
70
77
 
71
- 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.
72
87
 
73
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.
74
89
 
@@ -126,8 +141,36 @@ ${REFUTER_CONTRACT}
126
141
 
127
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.`,
128
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
+ },
129
171
  ];
130
172
 
173
+
131
174
  export const BASE_AGENT_NAMES = Object.freeze(AGENTS.map((agent) => agent.name));
132
175
  export { stackAgents, stackAgentNames, validateAgentCatalog };
133
176
  export const allAgentNames = () => [...BASE_AGENT_NAMES, ...stackAgentNames()];
@@ -157,11 +200,26 @@ export function writeDeveloperTier(cwd, tier) {
157
200
  return t;
158
201
  }
159
202
 
160
- function renderMd(spec, model, agent) {
161
- const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`, `model: ${model}`];
203
+ // OpenCode V1 tool names a declared capability maps to. `read`/`search` need nothing: what the agent
204
+ // may read is the project's policy to decide, and saying `read: true` would be a grant.
205
+ const RESTRICT_TOOLS = { edit: ['edit', 'write', 'patch'], shell: ['bash'] };
206
+ function restrictedTools(tools) {
207
+ if (!tools) return [];
208
+ return Object.entries(RESTRICT_TOOLS).filter(([cap]) => !tools.includes(cap)).flatMap(([, names]) => names);
209
+ }
210
+
211
+ function renderMd(spec, model, agent, toolsFormat = spec.toolsFormat) {
212
+ const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`];
213
+ if (model) fm.push(`model: ${model}`);
162
214
  if (spec.mode) fm.push(`mode: ${spec.mode}`);
163
215
  if (agent.tools) {
164
- if (spec.toolsFormat === 'map') {
216
+ if (toolsFormat === 'restrict') {
217
+ const denied = restrictedTools(agent.tools);
218
+ if (denied.length) {
219
+ fm.push('tools:');
220
+ for (const tool of denied) fm.push(` ${tool}: false`);
221
+ }
222
+ } else if (toolsFormat === 'map') {
165
223
  fm.push('tools:');
166
224
  for (const tool of agent.tools) fm.push(` ${tool}: true`);
167
225
  } else {
@@ -171,44 +229,146 @@ function renderMd(spec, model, agent) {
171
229
  fm.push('---', '');
172
230
  return `${fm.join('\n')}${agent.body}\n`;
173
231
  }
174
- const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, model, ...(agent.tools ? { tools: agent.tools } : {}), prompt: agent.body }, null, 2)}\n`;
232
+ const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, ...(model ? { model } : {}), ...(agent.tools ? { tools: agent.tools } : {}), prompt: agent.body }, null, 2)}\n`;
175
233
  function renderToml(model, agent) {
176
234
  const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
177
235
  // body as a TOML multiline LITERAL string ('''…''') — no escape processing.
178
236
  const tools = agent.tools ? `tools = [${agent.tools.map((tool) => `"${tool}"`).join(', ')}]\n` : '';
179
- return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\nmodel = "${model}"\n${tools}developer_instructions = '''\n${agent.body}\n'''\n`;
237
+ return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\n${model ? `model = "${model}"\n` : ''}${tools}developer_instructions = '''\n${agent.body}\n'''\n`;
238
+ }
239
+
240
+ // ── #298: the model, decided by the project. `agentModels` in .rsc.json maps a target to a model id
241
+ // or to `inherit`. Absent → the target's default (inherit on OpenCode, the tier model elsewhere). The
242
+ // value lands verbatim in YAML/TOML/JSON from a COMMITTED file, so anything outside a plain model-id
243
+ // alphabet is ignored rather than written: a pulled .rsc.json must not be able to inject frontmatter.
244
+ export const AGENT_MODEL_INHERIT = 'inherit';
245
+ export const isValidAgentModel = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/.test(value);
246
+ export function agentModelSetting(cwd, target) {
247
+ let value;
248
+ try { value = readManifest(cwd)?.agentModels?.[target]; } catch { value = undefined; }
249
+ return isValidAgentModel(value) ? value : null;
250
+ }
251
+ export const targetInheritsByDefault = (target) => AGENT_TARGETS[target]?.model('balanced') === null;
252
+ function resolveModel(target, cwd, tier) {
253
+ const setting = agentModelSetting(cwd, target);
254
+ if (setting === AGENT_MODEL_INHERIT) return null;
255
+ if (setting) return setting;
256
+ return AGENT_TARGETS[target].model(tier);
257
+ }
258
+
259
+ export const effectiveAgentModel = (target, cwd, tier = readDeveloperTier(cwd)) => (AGENT_TARGETS[target] ? resolveModel(target, cwd, tier) : null);
260
+
261
+ function renderAgent(target, spec, model, agent, toolsFormat) {
262
+ return spec.format === 'json' ? renderJson(model, agent)
263
+ : spec.format === 'toml' ? renderToml(model, agent)
264
+ : renderMd(spec, model, agent, toolsFormat);
265
+ }
266
+
267
+ // ── #298: what rsc wrote, per file, so a user edit is told apart from rsc's own file. Machine-local
268
+ // (.rsc/ is gitignored): a digest says "this machine's rsc wrote these bytes", which is not a team fact.
269
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
270
+ const digestFile = (cwd) => join(cwd, '.rsc', 'agent-digests.json');
271
+ const relKey = (cwd, path) => relative(cwd, path).split(sep).join('/');
272
+ function readDigests(cwd) {
273
+ try { const d = JSON.parse(readFileSync(digestFile(cwd), 'utf8')); return d && typeof d === 'object' ? d : {}; } catch { return {}; }
274
+ }
275
+ function writeDigests(cwd, digests) {
276
+ mkdirSync(dirname(digestFile(cwd)), { recursive: true });
277
+ const ordered = Object.fromEntries(Object.keys(digests).sort().map((k) => [k, digests[k]]));
278
+ writeFileSync(digestFile(cwd), `${JSON.stringify(ordered, null, 2)}\n`);
279
+ }
280
+
281
+ // Every rendering rsc could have produced for this agent on this target: either tier, the current
282
+ // model setting, and — for OpenCode — the pre-#298 shape (pinned Anthropic model, `tool: true` map).
283
+ // A file with no digest that matches one of these is rsc's and may be replaced; anything else is the
284
+ // user's. Bodies from older releases are not reconstructed: such a file is kept and reported, the safe
285
+ // direction, and `rsc agents reset` is one command away.
286
+ function knownRenderings(target, cwd, agent) {
287
+ const spec = AGENT_TARGETS[target];
288
+ const out = new Set();
289
+ for (const tier of ['balanced', 'heavy']) {
290
+ out.add(renderAgent(target, spec, resolveModel(target, cwd, tier), agent));
291
+ out.add(renderAgent(target, spec, spec.model(tier), agent));
292
+ if (spec.legacyModel) out.add(renderAgent(target, spec, spec.legacyModel(tier), agent, 'map'));
293
+ }
294
+ return out;
295
+ }
296
+
297
+ // 'rsc' when the file on disk is rsc's to overwrite or remove, 'user' when it carries the user's edit.
298
+ function ownership(target, cwd, agent, path, digests) {
299
+ if (!existsSync(path)) return 'absent';
300
+ const current = readFileSync(path, 'utf8');
301
+ const key = relKey(cwd, path);
302
+ if (digests[key]) return digests[key] === sha(current) ? 'rsc' : 'user';
303
+ return knownRenderings(target, cwd, agent).has(current) ? 'rsc' : 'user';
180
304
  }
181
305
 
306
+ const keptNotice = (name, path, cwd) => ({
307
+ name,
308
+ path,
309
+ message: `kept your edited agent ${name} (${relKey(cwd, path)}; rsc's version differs; \`rsc agents reset ${name}\` to take it)`,
310
+ });
311
+
182
312
  export function agentPath(target, cwd, name = 'developer') {
183
313
  const spec = AGENT_TARGETS[target];
184
314
  return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
185
315
  }
186
316
 
187
- export function writeAgents(target, cwd, tier = readDeveloperTier(cwd), names = agentNames()) {
317
+ /**
318
+ * Write the agents, keeping any the user edited. Returns { written, kept }: `kept` names each agent
319
+ * left as the user has it, with the message to show. `force` takes rsc's version regardless — only
320
+ * `rsc agents reset` passes it, after backing the user's file up.
321
+ */
322
+ export function writeAgentsDetailed(target, cwd, tier = readDeveloperTier(cwd), names = agentNames(), { force = false } = {}) {
188
323
  const spec = AGENT_TARGETS[target];
189
- if (!spec) return [];
324
+ if (!spec) return { written: [], kept: [] };
325
+ const digests = readDigests(cwd);
190
326
  const written = [];
327
+ const kept = [];
191
328
  for (const name of names) {
192
329
  const agent = byName(name);
193
330
  if (!agent) continue;
194
331
  const effectiveTier = agent.tier === 'heavy' ? 'heavy' : tier;
195
- const model = spec.model(effectiveTier);
196
- const content = spec.format === 'json' ? renderJson(model, agent)
197
- : spec.format === 'toml' ? renderToml(model, agent)
198
- : renderMd(spec, model, agent);
332
+ const content = renderAgent(target, spec, resolveModel(target, cwd, effectiveTier), agent);
199
333
  const path = agentPath(target, cwd, agent.name);
334
+ if (!force && ownership(target, cwd, agent, path, digests) === 'user') {
335
+ // Unless it already IS what rsc would write — then there is nothing to protect, only to record.
336
+ if (readFileSync(path, 'utf8') !== content) { kept.push(keptNotice(agent.name, path, cwd)); continue; }
337
+ }
200
338
  mkdirSync(dirname(path), { recursive: true });
201
339
  writeFileSync(path, content);
340
+ digests[relKey(cwd, path)] = sha(content);
202
341
  written.push(path);
203
342
  }
204
- return written;
343
+ writeDigests(cwd, digests);
344
+ return { written, kept };
345
+ }
346
+
347
+ export function writeAgents(target, cwd, tier = readDeveloperTier(cwd), names = agentNames(), options) {
348
+ return writeAgentsDetailed(target, cwd, tier, names, options).written;
205
349
  }
206
350
 
207
351
  export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNames = []) {
208
- if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [] };
352
+ if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [], kept: [] };
209
353
  const previous = new Set(previousNames);
210
354
  const desired = new Set(desiredNames);
211
- const removed = removeAgents(target, cwd, [...previous].filter((name) => !desired.has(name)));
355
+ const kept = [];
356
+ // An agent no longer wanted is removed only while it is still rsc's file. One the user edited is
357
+ // theirs now: deleting it on an uninstall or a stack change is the same loss sync must not cause.
358
+ const digests = readDigests(cwd);
359
+ const stale = [];
360
+ for (const name of [...previous].filter((n) => !desired.has(n))) {
361
+ const agent = byName(name);
362
+ const path = agentPath(target, cwd, name);
363
+ if (agent && ownership(target, cwd, agent, path, digests) === 'user') {
364
+ kept.push({ name, path, message: `kept your edited agent ${name} (${relKey(cwd, path)}); rsc no longer installs it, delete it yourself if you do not want it` });
365
+ } else stale.push(name);
366
+ }
367
+ const removed = removeAgents(target, cwd, stale);
368
+ if (removed.length) {
369
+ for (const path of removed) delete digests[relKey(cwd, path)];
370
+ writeDigests(cwd, digests);
371
+ }
212
372
  const written = [];
213
373
  const collisions = [];
214
374
  const names = [];
@@ -218,10 +378,12 @@ export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNa
218
378
  collisions.push(path);
219
379
  continue;
220
380
  }
221
- written.push(...writeAgents(target, cwd, tier, [name]));
381
+ const result = writeAgentsDetailed(target, cwd, tier, [name]);
382
+ written.push(...result.written);
383
+ kept.push(...result.kept);
222
384
  if (existsSync(path)) names.push(name);
223
385
  }
224
- return { written, removed, collisions, names };
386
+ return { written, removed, collisions, names, kept };
225
387
  }
226
388
 
227
389
  /**