@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/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';
@@ -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) return void say(`RSC_ONBOARDING_READY ${planId}`);
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 { gitPermissionsWired, GIT_PERMISSION_TARGETS } = await import('../targets/git-permissions.js');
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
- say(sub === 'on'
946
- ? '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.'
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(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])),
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".
@@ -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
 
@@ -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
@@ -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
- 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') },
@@ -157,11 +164,26 @@ export function writeDeveloperTier(cwd, tier) {
157
164
  return t;
158
165
  }
159
166
 
160
- function renderMd(spec, model, agent) {
161
- const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`, `model: ${model}`];
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 (spec.toolsFormat === 'map') {
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)}"\nmodel = "${model}"\n${tools}developer_instructions = '''\n${agent.body}\n'''\n`;
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
- export function writeAgents(target, cwd, tier = readDeveloperTier(cwd), names = agentNames()) {
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 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);
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
- return written;
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 removed = removeAgents(target, cwd, [...previous].filter((name) => !desired.has(name)));
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
- written.push(...writeAgents(target, cwd, tier, [name]));
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
  /**
@@ -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