@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/README.md +67 -6
- package/manifest.json +10 -2
- package/package.json +1 -1
- package/scripts/doctor.js +15 -1
- package/scripts/install-apply.js +41 -4
- package/scripts/lib/manifest-file.js +10 -1
- package/scripts/lib/onboarding-apply.js +58 -4
- package/scripts/lib/review-findings.js +175 -0
- package/scripts/rsc.js +160 -12
- package/skills/code-review/SKILL.md +2 -0
- package/skills/review/SKILL.md +24 -3
- package/targets/agents.js +181 -19
- package/targets/git-permissions.js +254 -16
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 {
|
|
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 {
|
|
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
|
-
|
|
962
|
-
|
|
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(
|
|
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:
|
package/skills/review/SKILL.md
CHANGED
|
@@ -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 ·
|
|
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** —
|
|
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
|
-
|
|
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 **
|
|
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
|
-
|
|
161
|
-
|
|
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 (
|
|
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)}"\
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|