@ericrisco/rsc 3.0.8 → 3.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -6
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +21 -1
- package/scripts/install-apply.js +41 -4
- package/scripts/lib/constitution-draft.js +156 -0
- package/scripts/lib/manifest-file.js +10 -1
- package/scripts/lib/onboarding-apply.js +61 -4
- package/scripts/lib/onboarding.js +7 -0
- package/scripts/rsc.js +146 -13
- package/skills/constitution/SKILL.md +2 -0
- package/skills/ftd/SKILL.md +9 -0
- package/skills/sdd/SKILL.md +2 -2
- package/skills/specify/SKILL.md +1 -1
- package/targets/agents.js +144 -18
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/ftd-nudge.mjs +1 -1
- package/targets/git-permissions.js +254 -16
- package/targets/knowledge-sync.mjs +288 -4
- package/targets/opt-outs.js +1 -0
- package/targets/session-memory-core.mjs +8 -0
- package/targets/worktree-reaper.mjs +5 -1
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';
|
|
@@ -25,6 +28,7 @@ import {
|
|
|
25
28
|
buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
|
|
26
29
|
} from './lib/onboarding.js';
|
|
27
30
|
import { applyAcceptedOnboarding, verifyOnboarding, ensureHarnessSkeleton, harnessReadiness } from './lib/onboarding-apply.js';
|
|
31
|
+
import { CONSTITUTION_PATH, constitutionIsDraft, ensureConstitutionDraft } from './lib/constitution-draft.js';
|
|
28
32
|
|
|
29
33
|
const rawArgv = process.argv.slice(2);
|
|
30
34
|
|
|
@@ -42,7 +46,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
|
|
|
42
46
|
process.exit(0);
|
|
43
47
|
}
|
|
44
48
|
const GENERAL_HELP = [
|
|
45
|
-
'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> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
|
|
46
50
|
'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
|
|
47
51
|
' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
|
|
48
52
|
].join('\n');
|
|
@@ -73,7 +77,7 @@ const PURGE_HELP = [
|
|
|
73
77
|
const GLOBAL_VALUE_FLAGS = new Set([
|
|
74
78
|
'--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
|
|
75
79
|
]);
|
|
76
|
-
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', 'sello', 'repair', 'uninstall', 'purge']);
|
|
77
81
|
function positionalTokens(input) {
|
|
78
82
|
const out = [];
|
|
79
83
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -298,6 +302,14 @@ async function runOnboarding(targets) {
|
|
|
298
302
|
} catch (error) {
|
|
299
303
|
say(`RSC_SKELETON_FAILED ${error.message}`);
|
|
300
304
|
}
|
|
305
|
+
// La constitución, como BORRADOR, cuando el plan eligió SDD y no hay ninguna. Sin esto una
|
|
306
|
+
// instalación no interactiva de software complejo acababa INCOMPLETE: sólo una fase agéntica la
|
|
307
|
+
// escribía. Mismo sitio y mismo motivo que el esqueleto: fuera de la transacción, sin residuo.
|
|
308
|
+
try {
|
|
309
|
+
ensureConstitutionDraft(process.cwd(), plan);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
say(`RSC_SKELETON_FAILED ${error.message}`);
|
|
312
|
+
}
|
|
301
313
|
// The audit cadence starts at install. Without a baseline the very first session after onboarding
|
|
302
314
|
// announced "a skill audit is due" about a set chosen minutes earlier. Never overwrites a real run.
|
|
303
315
|
try { recordAuditBaseline(process.cwd()); } catch { /* a missing baseline only means an early nudge */ }
|
|
@@ -313,7 +325,14 @@ async function runOnboarding(targets) {
|
|
|
313
325
|
// poco se cumplía entero. Spec: 02-DOCS/wiki/sdd/specs/install-completion-floor.md
|
|
314
326
|
function emitHarnessReadiness(plan, planId) {
|
|
315
327
|
const readiness = harnessReadiness(process.cwd(), plan);
|
|
316
|
-
if (readiness.ready)
|
|
328
|
+
if (readiness.ready) {
|
|
329
|
+
say(`RSC_ONBOARDING_READY ${planId}`);
|
|
330
|
+
// Listo no es «nada pendiente»: el borrador se nombra para que la cadena SDD lo complete primero.
|
|
331
|
+
if ((plan.floorPaths || []).includes(CONSTITUTION_PATH) && constitutionIsDraft(process.cwd())) {
|
|
332
|
+
say(` Pending: ${CONSTITUTION_PATH} is a draft — complete it with the \`constitution\` phase before the first SDD feature.`);
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
317
336
|
say(`RSC_ONBOARDING_INCOMPLETE ${planId}`);
|
|
318
337
|
for (const missing of safeLines(readiness.missing)) say(` ${missing}`);
|
|
319
338
|
say(` Next: ${readiness.action}`);
|
|
@@ -400,7 +419,7 @@ function reportUnknown(ids) {
|
|
|
400
419
|
// flag says which: `purge` sweeps every target by design, and the catalog/consult
|
|
401
420
|
// side barely touches one, so blocking them would be a regression, not a safeguard.
|
|
402
421
|
const NEEDS_TARGET = new Set([
|
|
403
|
-
'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
|
|
422
|
+
'onboard', 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry', 'agents',
|
|
404
423
|
]);
|
|
405
424
|
|
|
406
425
|
function flag(name) {
|
|
@@ -557,6 +576,11 @@ function printAgentHandoff() {
|
|
|
557
576
|
}
|
|
558
577
|
if (readiness.ready) {
|
|
559
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.`);
|
|
560
584
|
say(' Do NOT auto-start a task — wait for the user.');
|
|
561
585
|
} else {
|
|
562
586
|
// El otro emisor. Decía «ready» desde el wizard y desde `install` sin mirar el suelo, así que
|
|
@@ -569,6 +593,19 @@ function printAgentHandoff() {
|
|
|
569
593
|
say('════════════════════════════════════════════════');
|
|
570
594
|
}
|
|
571
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
|
+
|
|
572
609
|
// What the harness costs in context before the user types anything. Printed ahead of the raw
|
|
573
610
|
// health JSON because it is the part a human acts on: the figures say whether the harness is
|
|
574
611
|
// paying its way, and the findings say what to do about it.
|
|
@@ -608,6 +645,7 @@ async function syncDeclared(targets, dry = false) {
|
|
|
608
645
|
const result = await syncInstalled({ target: t, dryRun: dry });
|
|
609
646
|
const verb = dry ? 'Would sync' : 'Synced';
|
|
610
647
|
say(`${verb} ${t}: ${result.synced.length ? result.synced.join(', ') : '(nothing to sync)'}`);
|
|
648
|
+
for (const k of result.keptAgents || []) say(` ${k.message}`);
|
|
611
649
|
if (dry && result.paths?.length) {
|
|
612
650
|
for (const p of result.paths) say(` ${p}`);
|
|
613
651
|
}
|
|
@@ -854,6 +892,7 @@ async function main() {
|
|
|
854
892
|
// #277 — an automation on top of rsc reads the exit code, not the prose.
|
|
855
893
|
if (!report.healthy) process.exitCode = 1;
|
|
856
894
|
if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
|
|
895
|
+
printDoctorHeadline(report);
|
|
857
896
|
printContextBudget(report.contextBudget);
|
|
858
897
|
return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
|
|
859
898
|
}
|
|
@@ -936,26 +975,28 @@ async function main() {
|
|
|
936
975
|
const sub = argv[1] || 'status';
|
|
937
976
|
const root = process.cwd();
|
|
938
977
|
const { readManifest, writeManifest } = await import('./lib/manifest-file.js');
|
|
939
|
-
const {
|
|
978
|
+
const { gitPermissionsState, explainGitPermissions, GIT_PERMISSION_TARGETS } = await import('../targets/git-permissions.js');
|
|
940
979
|
if (sub === 'on' || sub === 'off') {
|
|
941
980
|
const current = readManifest(root);
|
|
942
981
|
if (!current) { say('No .rsc.json here: install the harness first (`npx @ericrisco/rsc onboard`).'); process.exitCode = 2; return; }
|
|
943
982
|
writeManifest(root, { ...current, gitPermissions: sub === 'on' });
|
|
944
983
|
for (const t of targets) await syncInstalled({ target: t, cwd: root });
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
: '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.');
|
|
948
|
-
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 }));
|
|
949
986
|
}
|
|
950
987
|
if (sub === 'status') {
|
|
951
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)]));
|
|
952
992
|
return void say(JSON.stringify({
|
|
953
993
|
declared: declared === undefined ? 'undecided' : declared,
|
|
954
|
-
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])),
|
|
955
996
|
notCovered: targets.filter((t) => !GIT_PERMISSION_TARGETS.includes(t)),
|
|
956
997
|
}, null, 2));
|
|
957
998
|
}
|
|
958
|
-
say('Use: npx @ericrisco/rsc git-permissions on|off|status');
|
|
999
|
+
say('Use: npx @ericrisco/rsc git-permissions on|off|status [--json]');
|
|
959
1000
|
process.exitCode = 2;
|
|
960
1001
|
return;
|
|
961
1002
|
}
|
|
@@ -1007,6 +1048,98 @@ async function main() {
|
|
|
1007
1048
|
process.exitCode = 1;
|
|
1008
1049
|
return;
|
|
1009
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
|
+
}
|
|
1010
1143
|
case 'sync':
|
|
1011
1144
|
return syncDeclared(targets, argv.includes('--dry-run'));
|
|
1012
1145
|
case 'backups': {
|
|
@@ -86,6 +86,8 @@ Write `02-DOCS/wiki/sdd/constitution.md` from the template in `references/consti
|
|
|
86
86
|
|
|
87
87
|
Create `02-DOCS/wiki/sdd/` if it does not exist. Do not overwrite an existing constitution — amend it.
|
|
88
88
|
|
|
89
|
+
**A draft is "complete me", not "amend me".** `rsc onboard` writes a skeleton with `status: draft` in the frontmatter: the template's headings, the facts onboarding knew (goal, kind, scope, workflow, detected stack) and no principle. Keep those facts, run the interview, fill the sections, then on ratification drop `status: draft`, set `version: v1.0.0` and log v1.0.0 in the amendment log.
|
|
90
|
+
|
|
89
91
|
## Versioning & amendment protocol
|
|
90
92
|
|
|
91
93
|
The constitution is versioned so `analyze` and `review` can cite "constitution v1.2.0, principle 4".
|
package/skills/ftd/SKILL.md
CHANGED
|
@@ -34,6 +34,11 @@ repository**: whoever clones it should get code, not somebody else's notes in pr
|
|
|
34
34
|
It is written **before the first change**, not after, and it holds exactly this:
|
|
35
35
|
|
|
36
36
|
```markdown
|
|
37
|
+
---
|
|
38
|
+
author: <who asked for it — git user.name>
|
|
39
|
+
branch: <the branch the work runs on>
|
|
40
|
+
status: in-progress
|
|
41
|
+
---
|
|
37
42
|
# <feature>
|
|
38
43
|
|
|
39
44
|
## Intent — what changes, and why it is worth doing
|
|
@@ -43,6 +48,10 @@ It is written **before the first change**, not after, and it holds exactly this:
|
|
|
43
48
|
## Next — the single next step, so an interruption costs nothing
|
|
44
49
|
```
|
|
45
50
|
|
|
51
|
+
The frontmatter says whose work it is, so «¿qué ha hecho cada uno?» has an answer. You never close
|
|
52
|
+
it by hand: when the branch lands on the default branch, rsc's post-merge hook sets `status: done`
|
|
53
|
+
and replaces Next with where and when it merged (on a closed default branch, via `rsc/knowledge`).
|
|
54
|
+
|
|
46
55
|
No decision journal, no phase records, no approvals. If a decision was meaningful, one line of
|
|
47
56
|
rationale goes next to the task it belongs to.
|
|
48
57
|
|
package/skills/sdd/SKILL.md
CHANGED
|
@@ -77,7 +77,7 @@ When a request lands, do not start typing code. Place it on the map first, then
|
|
|
77
77
|
> **The new-feature gate (hard).** The moment the user is thinking about a new feature or change — "add…", "build…", "it should also…", "quiero añadir…" — it goes to `specify` first, *even if a stack skill (nextjs/fastapi/flutter/react…) also fired and could just build it*. **No feature code is written — by any skill — until a spec AND a plan exist and the user has approved them.** A stack skill about to build an unspec'd, non-trivial feature must stop and route here. The only exception is a genuinely one-line, low-risk change; name it and skip.
|
|
78
78
|
|
|
79
79
|
1. **No `02-DOCS/wiki/sdd/config.yaml` and this is non-trivial?** → `sdd-init`.
|
|
80
|
-
2. **No constitution yet AND this project will grow?** → `constitution` once, then come back to the chain.
|
|
80
|
+
2. **No constitution yet AND this project will grow — or its frontmatter says `status: draft` (the one `rsc onboard` writes)?** → `constitution` once, then come back to the chain.
|
|
81
81
|
3. **Ambiguous / architectural / risky change before spec?** → optional proposal artifact via `specify`.
|
|
82
82
|
4. **A new idea, fuzzy, no spec on disk?** → `specify`.
|
|
83
83
|
5. **A spec exists but feels risky / has open questions?** → `clarify`.
|
|
@@ -93,7 +93,7 @@ If you genuinely cannot tell which phase you are in, ask the user one question:
|
|
|
93
93
|
### Skip rules (be honest about them)
|
|
94
94
|
|
|
95
95
|
- A **one-line, low-risk change** (typo, copy tweak, config bump) does not need the full chain. Say so, do it, and verify. The method serves shipping, not ceremony.
|
|
96
|
-
- `constitution` runs **once per project**, not per feature. If `02-DOCS/wiki/sdd/constitution.md` exists, read it as guardrails and move on.
|
|
96
|
+
- `constitution` runs **once per project**, not per feature. If `02-DOCS/wiki/sdd/constitution.md` exists, read it as guardrails and move on — unless it is still `status: draft`: then complete it first.
|
|
97
97
|
- `clarify` and `analyze` are **gates, not paperwork**. If a spec is genuinely unambiguous and tiny, name that out loud and pass through — but the bias is to run them, because skipped gates are where drift hides.
|
|
98
98
|
|
|
99
99
|
## Autopilot mode — run the whole chain on one up-front yes
|
package/skills/specify/SKILL.md
CHANGED
|
@@ -341,7 +341,7 @@ repeated here as prohibitions: naming a behaviour makes it more available, not l
|
|
|
341
341
|
## Project grounding (02-DOCS + CLAUDE.md)
|
|
342
342
|
|
|
343
343
|
- Read `02-DOCS/wiki/sdd/config.yaml` if present. If it is missing and the change is non-trivial, recommend `sdd-init` before proceeding; if the user asks to continue, record the missing config as a risk.
|
|
344
|
-
- Read `02-DOCS/wiki/sdd/constitution.md` first — its principles are inherited constraints, not things to re-decide. If it's missing, note that the project has no constitution yet and suggest the `constitution` phase before continuing (you can still draft a spec, but flag the absence).
|
|
344
|
+
- Read `02-DOCS/wiki/sdd/constitution.md` first — its principles are inherited constraints, not things to re-decide. If it's missing, note that the project has no constitution yet and suggest the `constitution` phase before continuing (you can still draft a spec, but flag the absence). If its frontmatter says `status: draft` (written by `rsc onboard`, unratified), route to `constitution` first and come back: a draft has no principles to inherit.
|
|
345
345
|
- **No constitution yet?** Still write the spec, but inherit nothing — lean harder on the wiki and the user's answers, and record every constraint you would have inherited as a *point to clarify* instead of assuming it.
|
|
346
346
|
- Write the spec to `02-DOCS/wiki/sdd/specs/<slug>.md`. Create the directory if absent.
|
|
347
347
|
- Add a row in `02-DOCS/wiki/index.md` (the Knowledge map; root `CLAUDE.md` keeps only a short pointer) linking the new spec under the `sdd/specs` topic (additive only — never delete existing rows). Create the index if absent.
|
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') },
|
|
@@ -157,11 +164,26 @@ export function writeDeveloperTier(cwd, tier) {
|
|
|
157
164
|
return t;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
// OpenCode V1 tool names a declared capability maps to. `read`/`search` need nothing: what the agent
|
|
168
|
+
// may read is the project's policy to decide, and saying `read: true` would be a grant.
|
|
169
|
+
const RESTRICT_TOOLS = { edit: ['edit', 'write', 'patch'], shell: ['bash'] };
|
|
170
|
+
function restrictedTools(tools) {
|
|
171
|
+
if (!tools) return [];
|
|
172
|
+
return Object.entries(RESTRICT_TOOLS).filter(([cap]) => !tools.includes(cap)).flatMap(([, names]) => names);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderMd(spec, model, agent, toolsFormat = spec.toolsFormat) {
|
|
176
|
+
const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`];
|
|
177
|
+
if (model) fm.push(`model: ${model}`);
|
|
162
178
|
if (spec.mode) fm.push(`mode: ${spec.mode}`);
|
|
163
179
|
if (agent.tools) {
|
|
164
|
-
if (
|
|
180
|
+
if (toolsFormat === 'restrict') {
|
|
181
|
+
const denied = restrictedTools(agent.tools);
|
|
182
|
+
if (denied.length) {
|
|
183
|
+
fm.push('tools:');
|
|
184
|
+
for (const tool of denied) fm.push(` ${tool}: false`);
|
|
185
|
+
}
|
|
186
|
+
} else if (toolsFormat === 'map') {
|
|
165
187
|
fm.push('tools:');
|
|
166
188
|
for (const tool of agent.tools) fm.push(` ${tool}: true`);
|
|
167
189
|
} else {
|
|
@@ -171,44 +193,146 @@ function renderMd(spec, model, agent) {
|
|
|
171
193
|
fm.push('---', '');
|
|
172
194
|
return `${fm.join('\n')}${agent.body}\n`;
|
|
173
195
|
}
|
|
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`;
|
|
196
|
+
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
197
|
function renderToml(model, agent) {
|
|
176
198
|
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
177
199
|
// body as a TOML multiline LITERAL string ('''…''') — no escape processing.
|
|
178
200
|
const tools = agent.tools ? `tools = [${agent.tools.map((tool) => `"${tool}"`).join(', ')}]\n` : '';
|
|
179
|
-
return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\
|
|
201
|
+
return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\n${model ? `model = "${model}"\n` : ''}${tools}developer_instructions = '''\n${agent.body}\n'''\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── #298: the model, decided by the project. `agentModels` in .rsc.json maps a target to a model id
|
|
205
|
+
// or to `inherit`. Absent → the target's default (inherit on OpenCode, the tier model elsewhere). The
|
|
206
|
+
// value lands verbatim in YAML/TOML/JSON from a COMMITTED file, so anything outside a plain model-id
|
|
207
|
+
// alphabet is ignored rather than written: a pulled .rsc.json must not be able to inject frontmatter.
|
|
208
|
+
export const AGENT_MODEL_INHERIT = 'inherit';
|
|
209
|
+
export const isValidAgentModel = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/.test(value);
|
|
210
|
+
export function agentModelSetting(cwd, target) {
|
|
211
|
+
let value;
|
|
212
|
+
try { value = readManifest(cwd)?.agentModels?.[target]; } catch { value = undefined; }
|
|
213
|
+
return isValidAgentModel(value) ? value : null;
|
|
214
|
+
}
|
|
215
|
+
export const targetInheritsByDefault = (target) => AGENT_TARGETS[target]?.model('balanced') === null;
|
|
216
|
+
function resolveModel(target, cwd, tier) {
|
|
217
|
+
const setting = agentModelSetting(cwd, target);
|
|
218
|
+
if (setting === AGENT_MODEL_INHERIT) return null;
|
|
219
|
+
if (setting) return setting;
|
|
220
|
+
return AGENT_TARGETS[target].model(tier);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const effectiveAgentModel = (target, cwd, tier = readDeveloperTier(cwd)) => (AGENT_TARGETS[target] ? resolveModel(target, cwd, tier) : null);
|
|
224
|
+
|
|
225
|
+
function renderAgent(target, spec, model, agent, toolsFormat) {
|
|
226
|
+
return spec.format === 'json' ? renderJson(model, agent)
|
|
227
|
+
: spec.format === 'toml' ? renderToml(model, agent)
|
|
228
|
+
: renderMd(spec, model, agent, toolsFormat);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── #298: what rsc wrote, per file, so a user edit is told apart from rsc's own file. Machine-local
|
|
232
|
+
// (.rsc/ is gitignored): a digest says "this machine's rsc wrote these bytes", which is not a team fact.
|
|
233
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
234
|
+
const digestFile = (cwd) => join(cwd, '.rsc', 'agent-digests.json');
|
|
235
|
+
const relKey = (cwd, path) => relative(cwd, path).split(sep).join('/');
|
|
236
|
+
function readDigests(cwd) {
|
|
237
|
+
try { const d = JSON.parse(readFileSync(digestFile(cwd), 'utf8')); return d && typeof d === 'object' ? d : {}; } catch { return {}; }
|
|
238
|
+
}
|
|
239
|
+
function writeDigests(cwd, digests) {
|
|
240
|
+
mkdirSync(dirname(digestFile(cwd)), { recursive: true });
|
|
241
|
+
const ordered = Object.fromEntries(Object.keys(digests).sort().map((k) => [k, digests[k]]));
|
|
242
|
+
writeFileSync(digestFile(cwd), `${JSON.stringify(ordered, null, 2)}\n`);
|
|
180
243
|
}
|
|
181
244
|
|
|
245
|
+
// Every rendering rsc could have produced for this agent on this target: either tier, the current
|
|
246
|
+
// model setting, and — for OpenCode — the pre-#298 shape (pinned Anthropic model, `tool: true` map).
|
|
247
|
+
// A file with no digest that matches one of these is rsc's and may be replaced; anything else is the
|
|
248
|
+
// user's. Bodies from older releases are not reconstructed: such a file is kept and reported, the safe
|
|
249
|
+
// direction, and `rsc agents reset` is one command away.
|
|
250
|
+
function knownRenderings(target, cwd, agent) {
|
|
251
|
+
const spec = AGENT_TARGETS[target];
|
|
252
|
+
const out = new Set();
|
|
253
|
+
for (const tier of ['balanced', 'heavy']) {
|
|
254
|
+
out.add(renderAgent(target, spec, resolveModel(target, cwd, tier), agent));
|
|
255
|
+
out.add(renderAgent(target, spec, spec.model(tier), agent));
|
|
256
|
+
if (spec.legacyModel) out.add(renderAgent(target, spec, spec.legacyModel(tier), agent, 'map'));
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 'rsc' when the file on disk is rsc's to overwrite or remove, 'user' when it carries the user's edit.
|
|
262
|
+
function ownership(target, cwd, agent, path, digests) {
|
|
263
|
+
if (!existsSync(path)) return 'absent';
|
|
264
|
+
const current = readFileSync(path, 'utf8');
|
|
265
|
+
const key = relKey(cwd, path);
|
|
266
|
+
if (digests[key]) return digests[key] === sha(current) ? 'rsc' : 'user';
|
|
267
|
+
return knownRenderings(target, cwd, agent).has(current) ? 'rsc' : 'user';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const keptNotice = (name, path, cwd) => ({
|
|
271
|
+
name,
|
|
272
|
+
path,
|
|
273
|
+
message: `kept your edited agent ${name} (${relKey(cwd, path)}; rsc's version differs; \`rsc agents reset ${name}\` to take it)`,
|
|
274
|
+
});
|
|
275
|
+
|
|
182
276
|
export function agentPath(target, cwd, name = 'developer') {
|
|
183
277
|
const spec = AGENT_TARGETS[target];
|
|
184
278
|
return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
|
|
185
279
|
}
|
|
186
280
|
|
|
187
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Write the agents, keeping any the user edited. Returns { written, kept }: `kept` names each agent
|
|
283
|
+
* left as the user has it, with the message to show. `force` takes rsc's version regardless — only
|
|
284
|
+
* `rsc agents reset` passes it, after backing the user's file up.
|
|
285
|
+
*/
|
|
286
|
+
export function writeAgentsDetailed(target, cwd, tier = readDeveloperTier(cwd), names = agentNames(), { force = false } = {}) {
|
|
188
287
|
const spec = AGENT_TARGETS[target];
|
|
189
|
-
if (!spec) return [];
|
|
288
|
+
if (!spec) return { written: [], kept: [] };
|
|
289
|
+
const digests = readDigests(cwd);
|
|
190
290
|
const written = [];
|
|
291
|
+
const kept = [];
|
|
191
292
|
for (const name of names) {
|
|
192
293
|
const agent = byName(name);
|
|
193
294
|
if (!agent) continue;
|
|
194
295
|
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);
|
|
296
|
+
const content = renderAgent(target, spec, resolveModel(target, cwd, effectiveTier), agent);
|
|
199
297
|
const path = agentPath(target, cwd, agent.name);
|
|
298
|
+
if (!force && ownership(target, cwd, agent, path, digests) === 'user') {
|
|
299
|
+
// Unless it already IS what rsc would write — then there is nothing to protect, only to record.
|
|
300
|
+
if (readFileSync(path, 'utf8') !== content) { kept.push(keptNotice(agent.name, path, cwd)); continue; }
|
|
301
|
+
}
|
|
200
302
|
mkdirSync(dirname(path), { recursive: true });
|
|
201
303
|
writeFileSync(path, content);
|
|
304
|
+
digests[relKey(cwd, path)] = sha(content);
|
|
202
305
|
written.push(path);
|
|
203
306
|
}
|
|
204
|
-
|
|
307
|
+
writeDigests(cwd, digests);
|
|
308
|
+
return { written, kept };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function writeAgents(target, cwd, tier = readDeveloperTier(cwd), names = agentNames(), options) {
|
|
312
|
+
return writeAgentsDetailed(target, cwd, tier, names, options).written;
|
|
205
313
|
}
|
|
206
314
|
|
|
207
315
|
export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNames = []) {
|
|
208
|
-
if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [] };
|
|
316
|
+
if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [], kept: [] };
|
|
209
317
|
const previous = new Set(previousNames);
|
|
210
318
|
const desired = new Set(desiredNames);
|
|
211
|
-
const
|
|
319
|
+
const kept = [];
|
|
320
|
+
// An agent no longer wanted is removed only while it is still rsc's file. One the user edited is
|
|
321
|
+
// theirs now: deleting it on an uninstall or a stack change is the same loss sync must not cause.
|
|
322
|
+
const digests = readDigests(cwd);
|
|
323
|
+
const stale = [];
|
|
324
|
+
for (const name of [...previous].filter((n) => !desired.has(n))) {
|
|
325
|
+
const agent = byName(name);
|
|
326
|
+
const path = agentPath(target, cwd, name);
|
|
327
|
+
if (agent && ownership(target, cwd, agent, path, digests) === 'user') {
|
|
328
|
+
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` });
|
|
329
|
+
} else stale.push(name);
|
|
330
|
+
}
|
|
331
|
+
const removed = removeAgents(target, cwd, stale);
|
|
332
|
+
if (removed.length) {
|
|
333
|
+
for (const path of removed) delete digests[relKey(cwd, path)];
|
|
334
|
+
writeDigests(cwd, digests);
|
|
335
|
+
}
|
|
212
336
|
const written = [];
|
|
213
337
|
const collisions = [];
|
|
214
338
|
const names = [];
|
|
@@ -218,10 +342,12 @@ export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNa
|
|
|
218
342
|
collisions.push(path);
|
|
219
343
|
continue;
|
|
220
344
|
}
|
|
221
|
-
|
|
345
|
+
const result = writeAgentsDetailed(target, cwd, tier, [name]);
|
|
346
|
+
written.push(...result.written);
|
|
347
|
+
kept.push(...result.kept);
|
|
222
348
|
if (existsSync(path)) names.push(name);
|
|
223
349
|
}
|
|
224
|
-
return { written, removed, collisions, names };
|
|
350
|
+
return { written, removed, collisions, names, kept };
|
|
225
351
|
}
|
|
226
352
|
|
|
227
353
|
/**
|
|
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
|
|
|
43
43
|
* nothing checks is just drift with a head start.
|
|
44
44
|
*/
|
|
45
45
|
export const PROJECT_OPT_OUTS = [
|
|
46
|
-
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
46
|
+
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
/**
|
package/targets/ftd-nudge.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const KEEP = 50;
|
|
|
23
23
|
|
|
24
24
|
export const NUDGE = 'rsc · lane: this session is changing code and has no feature document yet. ' +
|
|
25
25
|
'Before going on, decide the lane and say it in one line: simple → write 02-DOCS/wiki/ftd/<slug>.md now ' +
|
|
26
|
-
'(Intent, Scope, Checklist with how each item is proven, Evidence, Next) — one document per feature, ' +
|
|
26
|
+
'(frontmatter author/branch/status: in-progress; Intent, Scope, Checklist with how each item is proven, Evidence, Next) — one document per feature, ' +
|
|
27
27
|
'a box ticked only with its observed proof under Evidence, Next kept current; big or ' +
|
|
28
28
|
'interlocking → SDD (spec first). A one-line, zero-risk change needs no document: say so instead.';
|
|
29
29
|
|