@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 CHANGED
@@ -98,8 +98,8 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
98
98
 
99
99
  **Commit, push and pull request without a prompt each time.** A harness installed from scratch lets
100
100
  the agent run `git commit`, `git push` and `gh pr create` without asking, because those are the steps
101
- that close every lane. A force-push still asks. The guards still decide: a commit on a closed `main` is
102
- refused whatever the permission says. It is a project decision, saved as `gitPermissions` in
101
+ that close every lane. A force-push still asks. In Claude Code, rsc's guards still decide: a commit on a
102
+ closed `main` is refused whatever the permission says. No other assistant runs those guards. It is a project decision, saved as `gitPermissions` in
103
103
  `.rsc.json`. A clone gets the same setting. A project adopted before this is left as it was until
104
104
  someone runs `rsc git-permissions on`.
105
105
 
@@ -108,16 +108,32 @@ someone runs `rsc git-permissions on`.
108
108
  | Claude Code | `.claude/settings.json` → `permissions.allow` (force-push under `ask`). Claude Code applies it once the folder is trusted. |
109
109
  | Codex | `.codex/rules/rsc-git.rules` (`prefix_rule`; force-push → `prompt`). Loaded when the project is trusted. |
110
110
  | Gemini | `.gemini/settings.json` → `tools.allowed` |
111
- | OpenCode | `opencode.json` → `permission.bash` (force-push → `ask`) |
111
+ | OpenCode | `opencode.json`. With OpenCode 2 installed: the top-level `permissions` list (`shell` rules; force-push → `ask`). rsc's rules go before yours, so any rule of yours that matches later still wins; only a leading `"*"` allow/ask default stays in front. With OpenCode 1.x, or when `opencode --version` cannot be read: `permission.bash`, which both versions read. Moving to 2 moves rsc's entries over. An `opencode.jsonc` is read but never rewritten (its comments would be lost): rsc prints the rules to paste. |
112
112
  | Cursor | Not covered: its CLI matches only the first word, so allowing a push would allow every git command. |
113
113
  | DeepSeek Harness | Not covered: dsh has no per-command allow list. Its own Permissions selector (`/permission`) sets approval for every command at once. |
114
114
 
115
115
  ```bash
116
- rsc git-permissions status # decided or not, and wired per assistant
117
- rsc git-permissions off # ask again for commit, push and PR (saved in .rsc.json)
116
+ rsc git-permissions status # per assistant: wired or not, and in which format (--json for scripts)
117
+ rsc git-permissions off # remove rsc's rules (saved in .rsc.json)
118
118
  rsc git-permissions on # turn it on, also for a project adopted before
119
119
  ```
120
120
 
121
+ **What `off` turns off.** Only rsc's allow rules. Each command says, per assistant, what rsc controls
122
+ there, what stops when it is off, and what is left to the assistant. `off` does not hand rsc's
123
+ protections to the assistant. Claude Code keeps rsc's guards (branch-guard, danger-guard), because
124
+ they do not depend on this switch. OpenCode, Codex and Gemini run no rsc guard. There, your own
125
+ settings decide. With no rule matching, OpenCode 2 asks and OpenCode 1.x runs the command. To refuse a
126
+ force-push in OpenCode 2, add this at the end of `permissions` (use `"ask"` to get a human approval):
127
+
128
+ ```json
129
+ { "action": "shell", "resource": "git push --force*", "effect": "deny" },
130
+ { "action": "shell", "resource": "git push -f*", "effect": "deny" },
131
+ { "action": "shell", "resource": "git push * --force*", "effect": "deny" },
132
+ { "action": "shell", "resource": "git push * -f*", "effect": "deny" }
133
+ ```
134
+
135
+ In OpenCode 1.x the same patterns go at the end of `permission.bash` (`"git push --force*": "deny"`, …).
136
+
121
137
  **How does it know a project is "complex or in production"?** There are two layers. A hook counts
122
138
  what anyone can check: a CI setup, a deployment file (`Dockerfile`, `vercel.json`, `fly.toml`…) or
123
139
  at least two people in the last 50 commits. If any of those is there, a commit on the default branch
@@ -372,7 +388,11 @@ That verdict is not just about the receipt: it also requires the harness floor t
372
388
  `01-TOOLS/_TEMPLATE/`, `02-DOCS/wiki/harness/`, and the constitution when the plan selects SDD.
373
389
  If the floor is missing, the install applied but prints `RSC_ONBOARDING_INCOMPLETE` with each
374
390
  missing path and the action that creates it, because a plan that promised little used to be able to
375
- report success with three markdown files.
391
+ report success with three markdown files. When the plan selects SDD, onboarding writes the
392
+ constitution itself as a draft (`status: draft`, only the facts it knows, no principles), so a
393
+ non-interactive install still ends `RSC_ONBOARDING_READY`; the draft is listed as pending — under
394
+ READY and in `rsc doctor` — until the `constitution` phase completes it. An existing constitution is
395
+ never overwritten.
376
396
 
377
397
  ---
378
398
 
@@ -399,6 +419,10 @@ rsc memory resume # print this branch/worktree continuation
399
419
  rsc memory learn --text "…" --evidence "…" --confidence 0.8 --approve
400
420
  rsc memory off # disable hooks, commands and injection project-wide
401
421
  rsc sync --target claude,codex # refresh managed skills/hooks from the current package version
422
+ rsc agent-model opencode openai/gpt-5 # pin the model the generated agents carry (saved in .rsc.json)
423
+ rsc agent-model opencode inherit # back to the session's model (OpenCode's default)
424
+ rsc agents status # installed agents, and which ones you edited
425
+ rsc agents reset developer # take rsc's version of an agent you edited (yours is backed up first)
402
426
  rsc backups # list project-local snapshots
403
427
  rsc restore latest --dry-run # preview restoring the newest snapshot
404
428
  rsc restore <snapshot-id> # restore a project-local snapshot
@@ -406,6 +430,29 @@ rsc upgrade --dry-run # show npm upgrade + sync commands
406
430
  rsc uninstall postgresdb --dry-run # preview a removal
407
431
  ```
408
432
 
433
+ ### `rsc doctor`: harness health vs onboarding readiness
434
+
435
+ `doctor` answers two different questions, and prints both first, on separate lines:
436
+
437
+ ```text
438
+ Harness health: healthy
439
+ Onboarding readiness: pending
440
+ Pending: 02-DOCS/wiki/sdd/constitution.md (draft)
441
+ Next: Complete 02-DOCS/wiki/sdd/constitution.md with the `constitution` phase …
442
+ ```
443
+
444
+ - **Harness health** (`healthy` in `--json`) says whether what is installed works: skills, agents,
445
+ commands and hook scripts are on disk. It is the only thing the exit code follows (1 when
446
+ unhealthy), so CI and editor extensions can rely on it.
447
+ - **Onboarding readiness** (`onboarding` in `--json`: `status`, `missing`, `pending`, `action`) says
448
+ whether onboarding is finished. It checks the accepted plan's floor, the same check that prints
449
+ `RSC_ONBOARDING_READY`, plus drafts. The status is `ready`; `pending` when a draft such as the
450
+ onboarding constitution still has to be completed; `incomplete` when part of the floor is missing,
451
+ for example a deleted constitution in an SDD plan, with the exact phase or command that fixes it;
452
+ or `not onboarded` when there is no onboarding record, as in a manual `rsc add` install. Readiness
453
+ never changes `healthy` or the exit code. `Missing:` and `Pending:` appear only when they have
454
+ entries.
455
+
409
456
  ---
410
457
 
411
458
 
@@ -842,6 +889,15 @@ selective specialists) and 53 command entries (20 fixed + 33 stack aliases).
842
889
  Unsupported means rsc writes nothing for that surface; it does not emulate a
843
890
  provider feature with an unverified file.
844
891
 
892
+ **Generated agents follow your project, not rsc's defaults.** OpenCode agents carry no `model:`, so
893
+ they use whatever model the session runs (a local one, OpenAI, Anthropic). To pin one for the whole
894
+ team, run `rsc agent-model opencode <model>`: it is saved as `agentModels` in `.rsc.json` and works
895
+ for any target with agents (`inherit` drops the pin). OpenCode agents never grant a tool either: a
896
+ read-only reviewer only gets `edit`, `write`, `patch` and `bash` set to `false`, and an agent that may
897
+ edit or run commands gets no `tools` block, so your project's permission policy decides. If you edit
898
+ a generated agent, `rsc sync` keeps your file and says so. `rsc agents reset <name>` (or `--all`)
899
+ takes rsc's version back after a backup in `.rsc/backups/`.
900
+
845
901
  ---
846
902
 
847
903
  ## Skill format
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.8",
2
+ "version": "3.0.10",
3
3
  "counts": {
4
4
  "skills": 270,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.8",
3
+ "version": "3.0.10",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -17,9 +17,11 @@ import { countGaps, listSkills, listAgents } from './lib/capabilities.js';
17
17
  import { resolveCommands, commandPath, targetHasCommands } from '../targets/commands.js';
18
18
  import { inspectMemoryWiring } from '../targets/memory.js';
19
19
  import { metricsSummary } from '../targets/session-memory-core.mjs';
20
- import { agentPath } from '../targets/agents.js';
20
+ import { agentPath, targetHasAgents, effectiveAgentModel } from '../targets/agents.js';
21
21
  import { projectOptOuts } from '../targets/opt-outs.js';
22
22
  import { versionReport } from './lib/versions.js';
23
+ import { CONSTITUTION_PATH, constitutionIsDraft } from './lib/constitution-draft.js';
24
+ import { onboardingReadiness } from './lib/onboarding-apply.js';
23
25
 
24
26
  // Gates the committed manifest says the team disarmed, still armed here. Read through the same
25
27
  // partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
@@ -297,6 +299,11 @@ export function doctor({ target, home, cwd }) {
297
299
  memory,
298
300
  missingAgents,
299
301
  agentCollisions: state.agentCollisions || [],
302
+ // #298 — generated agents the user edited and sync kept as they are. Not unhealthy: theirs on
303
+ // purpose. Named with the way back, so a stale edit is one command from rsc's version.
304
+ agentsEditedByYou: (state.agentsKept || []).map((k) => ({ ...k, action: `\`npx @ericrisco/rsc agents reset ${k.name}\` takes rsc's version (yours is backed up first).` })),
305
+ // Which model the agents carry: the session's (inherit) or a project pin (`rsc agent-model`).
306
+ agentModel: targetHasAgents(target) ? (effectiveAgentModel(target, root) || 'inherit') : null,
300
307
  missingCommands,
301
308
  commandOrphans,
302
309
  commandCollisions: state.commandCollisions || [],
@@ -310,6 +317,19 @@ export function doctor({ target, home, cwd }) {
310
317
  for (const [id, e] of Object.entries(state.skills)) {
311
318
  for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
312
319
  }
320
+ // Pending is not unhealthy: work the harness expects the user to finish, named with what does it.
321
+ // The draft constitution `rsc onboard` writes is the first entry; it never touches `healthy`.
322
+ report.pending = constitutionIsDraft(root)
323
+ ? [{ id: 'constitution-draft', path: CONSTITUTION_PATH, action: 'Complete it with the `constitution` phase before the first SDD feature; ratifying removes `status: draft`.' }]
324
+ : [];
325
+ // #298 — onboarding readiness is a SEPARATE question from health: is the accepted plan's floor
326
+ // on disk (the same check that prints RSC_ONBOARDING_READY), and is anything still a draft. It is
327
+ // reported, never folded into `healthy`: `healthy` and the exit code keep meaning "the installed
328
+ // harness works", which is what CI and editor extensions built on #277 read. Wrapped because a
329
+ // report must never fail for asking an extra question.
330
+ try { report.onboarding = onboardingReadiness(root); } catch (error) {
331
+ report.onboarding = { status: 'incomplete', missing: ['<onboarding readiness could not be read>'], pending: [], action: String(error?.message || error).slice(0, 200) };
332
+ }
313
333
  report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
314
334
  && !report.opencodePluginsOutdated.length;
315
335
  return report;
@@ -7,7 +7,7 @@ import { planInstall } from './install-plan.js';
7
7
  import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
8
8
  import { shadowTarget } from '../targets/agents-md-shadow.js';
9
9
  import {
10
- targetHasAgents, reconcileAgents, agentPath, agentNames,
10
+ targetHasAgents, reconcileAgents, agentPath, agentNames, writeAgentsDetailed,
11
11
  resolveAgentNames, agentByName, allAgentNames, readDeveloperTier, writeDeveloperTier,
12
12
  } from '../targets/agents.js';
13
13
  import { projectOptOuts } from '../targets/opt-outs.js';
@@ -223,6 +223,7 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
223
223
  optOuts: optOuts ?? projectOptOuts(prev.optOuts),
224
224
  memory: prev.memory,
225
225
  gitPermissions: gitPermissions ?? prev.gitPermissions,
226
+ agentModels: prev.agentModels,
226
227
  onboarding: onboarding ?? prev.onboarding,
227
228
  });
228
229
  }
@@ -306,13 +307,19 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
306
307
  ? explicit
307
308
  : resolveAgentNames(Object.keys(state.skills || {}), explicit);
308
309
  const previousAgents = state.agents || [];
310
+ let keptAgents = [];
309
311
  if (targetHasAgents(target)) {
310
312
  const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), previousAgents, desiredAgents);
311
313
  state.agents = agentResult.names;
312
314
  state.agentCollisions = agentResult.collisions;
315
+ // #298 — agents the user edited and rsc left alone. Recorded so `doctor` can say it on any run,
316
+ // and returned so the command that just ran says it now.
317
+ keptAgents = agentResult.kept;
318
+ state.agentsKept = keptAgents.map(({ name, path }) => ({ name, path }));
313
319
  } else {
314
320
  state.agents = [];
315
321
  state.agentCollisions = [];
322
+ state.agentsKept = [];
316
323
  }
317
324
  state.explicitAgents = explicit;
318
325
  const memoryResult = policy?.memory === false
@@ -381,7 +388,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
381
388
  if (existsSync(join(cwd, '.git'))) mergeHook = installMergeHook(cwd);
382
389
  } catch (err) { mergeHook = { installed: false, state: 'skipped', reason: err.message }; }
383
390
  ignoreLocalState(cwd, target);
384
- return { ...state, backup, mergeHook };
391
+ return { ...state, backup, mergeHook, keptAgents };
385
392
  }
386
393
 
387
394
  // `.rsc/` holds local machine state — hook scripts, install markers, the sello's
@@ -551,9 +558,16 @@ export async function uninstall({ skillIds = [], agentIds = [], target, home, cw
551
558
  delete state.skills[id];
552
559
  }
553
560
  const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), state.agents || [], desiredAgents);
554
- state.agents = agentResult.names;
561
+ // An edited agent no longer wanted stays on disk (#298) and stays recorded as rsc-managed, so a
562
+ // later reset or uninstall can still find it.
563
+ state.agents = [...new Set([...agentResult.names, ...agentResult.kept.map((k) => k.name)])];
555
564
  state.agentCollisions = agentResult.collisions;
565
+ state.agentsKept = agentResult.kept.map(({ name, path }) => ({ name, path }));
556
566
  state.explicitAgents = nextExplicit;
567
+ for (const k of agentResult.kept) {
568
+ const i = removed.indexOf(k.path);
569
+ if (i >= 0) removed.splice(i, 1);
570
+ }
557
571
  const desiredCommands = resolveCommands({
558
572
  target,
559
573
  skills: remainingSkillIds,
@@ -712,7 +726,30 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
712
726
  // question sat untracked, one `git add -A` from the team repo (team simulation D13). Sync is what a
713
727
  // clone runs, so it writes the same exclude. Lazy import: onboarding-apply imports this module.
714
728
  try { (await import('./lib/onboarding-apply.js')).excludePersonalProfile(cwd); } catch { /* not a repo → nothing */ }
715
- return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
729
+ return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup, keptAgents: nextState.keptAgents || [] };
730
+ }
731
+
732
+ /**
733
+ * #298 — `rsc agents reset <name|--all>`: take rsc's version of agents the user edited. The user's
734
+ * file is snapshotted first (a backup under .rsc/backups/), and the answer says where, so taking rsc's
735
+ * version is never the loss the protection exists to prevent.
736
+ */
737
+ export function resetAgents({ target, home, cwd = process.cwd(), names = [], all = false }) {
738
+ if (!targetHasAgents(target)) return { reset: [], backup: null, kept: [] };
739
+ const paths = targetPaths(target, home, cwd);
740
+ const state = readState(paths.stateFile);
741
+ const installed = state.agents || [];
742
+ const chosen = all ? installed : names;
743
+ const unknown = chosen.filter((name) => !installed.includes(name));
744
+ if (unknown.length) throw new Error(`${unknown.join(', ')}: not an agent rsc installed for ${target} (installed: ${installed.join(', ') || 'none'})`);
745
+ if (!chosen.length) return { reset: [], backup: null, kept: [] };
746
+ const files = chosen.map((name) => agentPath(target, cwd, name));
747
+ const backup = createBackup({ cwd, operation: 'agents-reset', target, paths: files.filter((f) => existsSync(f)), cliVersion: CLI_VERSION });
748
+ const { written } = writeAgentsDetailed(target, cwd, readDeveloperTier(cwd), chosen, { force: true });
749
+ state.agentsKept = (state.agentsKept || []).filter((k) => !chosen.includes(k.name));
750
+ writeState(paths.stateFile, state);
751
+ const rel = (p) => relative(cwd, p).split(sep).join('/');
752
+ return { reset: chosen, written, backup: backup ? rel(join(cwd, '.rsc', 'backups', backup.id)) : null };
716
753
  }
717
754
 
718
755
  // Remove EVERYTHING rsc put in this project: installed skills across all targets,
@@ -0,0 +1,156 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // La constitución como BORRADOR.
6
+ //
7
+ // El suelo del arnés exige `02-DOCS/wiki/sdd/constitution.md` cuando el plan elige SDD, y sólo una
8
+ // fase agéntica la escribía. Así que una instalación no interactiva de software complejo —terminal,
9
+ // CI, nadie con un agente delante— terminaba en RSC_ONBOARDING_INCOMPLETE: medio instalada. En la
10
+ // prueba de campo una sesión entera ($3.59, 67 turnos) se gastó sólo en escribirla.
11
+ //
12
+ // Decisión del PO (2026-10-07): el onboarding escribe un BORRADOR y no falla. El borrador lleva
13
+ // `status: draft`, los encabezados reales de la plantilla de la fase `constitution`, y sólo los
14
+ // hechos que el onboarding conoce — objetivo, tipo, alcance, flujo, evidencia de stack. Ningún
15
+ // principio: ratificar es de la fase `constitution`, con el usuario delante (P4). El suelo lo acepta
16
+ // porque sólo mira existencia; la cadena SDD lo detecta por `status: draft` y lo completa antes del
17
+ // primer spec; `doctor` lo lista como pendiente, nunca como fallo.
18
+
19
+ export const CONSTITUTION_PATH = '02-DOCS/wiki/sdd/constitution.md';
20
+ export const DRAFT_MARK = 'borrador — complétalo con la fase `constitution` antes del primer SDD';
21
+
22
+ const WORKFLOW_FACT = {
23
+ branches: 'branches closed with a pull request; the default branch is closed to direct work',
24
+ main: 'straight on the default branch',
25
+ };
26
+
27
+ // El objetivo es texto del usuario y va a un documento que leerá un agente: una línea, acotada.
28
+ const oneLine = (value, limit = 200) => String(value ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ')
29
+ .replace(/\s+/g, ' ').trim().slice(0, limit);
30
+
31
+ /** Puro: el texto del borrador a partir del plan aceptado. Sin reloj ni disco, salvo lo que se le pase. */
32
+ export function renderConstitutionDraft(plan, { project = 'Project', date = new Date() } = {}) {
33
+ const record = plan?.record || {};
34
+ const evidence = plan?.evidence || {};
35
+ const stacks = (evidence.stacks || []).map((s) => oneLine(s, 40)).filter(Boolean);
36
+ const manifests = (evidence.signals || []).filter((s) => String(s).startsWith('manifest:'))
37
+ .map((s) => oneLine(String(s).slice('manifest:'.length), 60));
38
+ const day = date.toISOString().slice(0, 10);
39
+ const name = oneLine(project, 80) || 'Project';
40
+ const stackLine = stacks.length
41
+ ? `Detected at install: ${stacks.join(', ')}${manifests.length ? ` (from ${manifests.join(', ')})` : ''}. Not ratified yet.`
42
+ : 'No stack evidence was detected at install. To be decided in the `constitution` phase.';
43
+ const workflow = WORKFLOW_FACT[record.workflow];
44
+ const pending = '_Pending — to be completed in the `constitution` phase._';
45
+ return `---
46
+ type: constitution
47
+ title: ${name} — Constitution
48
+ description: Draft written by rsc onboard from the accepted plan; not ratified.
49
+ tags: [sdd, constitution]
50
+ timestamp: ${date.toISOString().replace(/\.\d{3}Z$/, 'Z')}
51
+ topic: sdd
52
+ status: draft
53
+ version: draft
54
+ ---
55
+
56
+ # ${name} — Constitution
57
+
58
+ > **${DRAFT_MARK}.**
59
+ > Draft: complete it with the \`constitution\` phase before the first SDD feature. It holds only what
60
+ > onboarding knew; it ratifies no principle. Ratifying it removes \`status: draft\` and starts at v1.0.0.
61
+
62
+ ## Known facts (from onboarding)
63
+
64
+ - Goal: ${oneLine(record.goal)}
65
+ - Project kind: ${oneLine(record.projectKind, 40)}${record.softwareScope ? ` · software scope: ${oneLine(record.softwareScope, 40)}` : ''}
66
+ ${workflow ? `- How code changes land: ${workflow} (\`--workflow ${record.workflow}\`).\n` : ''}- Stack evidence: ${stackLine}
67
+
68
+ ## 1. Stack canon
69
+
70
+ ${stackLine}
71
+
72
+ ## 2. Quality bar
73
+
74
+ ${pending}
75
+
76
+ ## 3. Conventions
77
+
78
+ ${pending}
79
+
80
+ ## 4. Branching & shipping
81
+
82
+ ${workflow ? `Chosen at install: ${workflow}. ` : ''}${pending}
83
+
84
+ ## 5. Security & privacy floor
85
+
86
+ ${pending}
87
+
88
+ ## 6. UX / accessibility floor (if there is a UI)
89
+
90
+ ${pending}
91
+
92
+ ## 7. Performance budgets (where they matter)
93
+
94
+ ${pending}
95
+
96
+ ## 8. Knowledge & decisions
97
+
98
+ ${pending}
99
+
100
+ ## Definition of Done (the merge bar \`verify\` runs against)
101
+
102
+ ${pending}
103
+
104
+ ## Amendment log (append-only)
105
+
106
+ | Date | Version | Change | Why |
107
+ |------|---------|--------|-----|
108
+ | ${day} | draft | Draft generated by \`rsc onboard\`. | The SDD floor needs a constitution; ratification is the \`constitution\` phase's. |
109
+ `;
110
+ }
111
+
112
+ /** ¿La constitución existe y está en borrador? Sólo lee el frontmatter; ilegible ⇒ no es borrador. */
113
+ export function constitutionIsDraft(cwd = process.cwd()) {
114
+ const path = join(cwd, CONSTITUTION_PATH);
115
+ try {
116
+ if (!existsSync(path) || !lstatSync(path).isFile()) return false;
117
+ const text = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
118
+ const block = /^---\n([\s\S]*?)\n---(\n|$)/.exec(text);
119
+ return Boolean(block && /^status:\s*["']?draft["']?\s*$/m.test(block[1]));
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ const isLink = (path) => { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } };
126
+
127
+ /**
128
+ * Escribe el borrador si el plan exige la constitución y no hay ninguna. Nunca sobrescribe: ni una
129
+ * ratificada ni un borrador que el usuario ya empezó. Un enlace (aunque cuelgue) cuenta como
130
+ * existente, y `wx` cierra la carrera entre comprobar y escribir.
131
+ */
132
+ export function ensureConstitutionDraft(cwd, plan, { now = new Date() } = {}) {
133
+ if (!(plan?.floorPaths || []).includes(CONSTITUTION_PATH)) return { created: false };
134
+ // Sin importar `onboarding-apply.js` (doctor lee este módulo y no debe arrastrar el instalador):
135
+ // un directorio intermedio que sea enlace podría sacar la escritura de la raíz, y git versiona los
136
+ // enlaces, así que el vector viaja en un clone. Más estricto que la guarda general: no se sigue.
137
+ let cursor = cwd;
138
+ for (const segment of ['02-DOCS', 'wiki', 'sdd']) {
139
+ cursor = join(cursor, segment);
140
+ if (existsSync(cursor) || isLink(cursor)) {
141
+ if (lstatSync(cursor).isSymbolicLink()) throw new Error(`RSC_ROOT_AMBIGUOUS: ${CONSTITUTION_PATH} would be written through a symlink`);
142
+ }
143
+ }
144
+ const target = join(cwd, CONSTITUTION_PATH);
145
+ let present = false;
146
+ try { lstatSync(target); present = true; } catch { /* ausente */ }
147
+ if (present) return { created: false };
148
+ mkdirSync(join(cwd, '02-DOCS', 'wiki', 'sdd'), { recursive: true });
149
+ try {
150
+ writeFileSync(target, renderConstitutionDraft(plan, { project: basename(cwd), date: now }), { flag: 'wx' });
151
+ } catch (error) {
152
+ if (error.code === 'EEXIST') return { created: false };
153
+ throw error;
154
+ }
155
+ return { created: true };
156
+ }
@@ -15,7 +15,7 @@ import { join } from 'node:path';
15
15
  export const MANIFEST = '.rsc.json';
16
16
  export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
17
17
 
18
- const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'gitPermissions', 'onboarding'];
18
+ const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'gitPermissions', 'agentModels', 'onboarding'];
19
19
 
20
20
  export function readManifest(cwd = process.cwd()) {
21
21
  const file = manifestPath(cwd);
@@ -40,10 +40,19 @@ export function readManifest(cwd = process.cwd()) {
40
40
  memory: raw.memory ?? undefined,
41
41
  // true on a project installed from scratch since 3.0.3; absent on older ones; false once turned off.
42
42
  gitPermissions: typeof raw.gitPermissions === 'boolean' ? raw.gitPermissions : undefined,
43
+ // #298 — per-target agent model: { opencode: 'openai/gpt-5' | 'inherit' }. Only plain objects of
44
+ // strings survive the read; what each value may contain is checked where it is rendered.
45
+ agentModels: agentModelsFrom(raw.agentModels),
43
46
  onboarding: raw.onboarding ?? undefined,
44
47
  };
45
48
  }
46
49
 
50
+ function agentModelsFrom(value) {
51
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
52
+ const entries = Object.entries(value).filter(([, v]) => typeof v === 'string').sort(([a], [b]) => a.localeCompare(b));
53
+ return entries.length ? Object.fromEntries(entries) : undefined;
54
+ }
55
+
47
56
  // Stable output: fixed key order, arrays one entry per line, order as given. Rewriting an
48
57
  // unchanged manifest must produce identical bytes, or every install shows up as a diff.
49
58
  export function writeManifest(cwd, manifest) {
@@ -10,6 +10,7 @@ import { readManifest, writeManifest } from './manifest-file.js';
10
10
  import { encodeGoal, identifyPlan } from './onboarding.js';
11
11
  import { createBackup, restoreBackup } from './backups.js';
12
12
  import { RETIRED_SKILLS, replaceRetired } from './retired-skills.js';
13
+ import { CONSTITUTION_PATH, constitutionIsDraft } from './constitution-draft.js';
13
14
  import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
14
15
 
15
16
  const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
@@ -370,7 +371,9 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
370
371
  const TEMPLATE_FLOOR = '01-TOOLS/_TEMPLATE/';
371
372
 
372
373
  export const HARNESS_FLOOR_MINIMUM = [TEMPLATE_FLOOR, '02-DOCS/wiki/harness/'];
373
- export const HARNESS_FLOOR_CONSTITUTION = '02-DOCS/wiki/sdd/constitution.md';
374
+ // El onboarding escribe ahora un BORRADOR (`constitution-draft.js`) y el suelo lo acepta: sólo
375
+ // mira existencia, y completar el borrador es de la cadena SDD, no del instalador.
376
+ export const HARNESS_FLOOR_CONSTITUTION = CONSTITUTION_PATH;
374
377
 
375
378
  // La plantilla, sus nombres con punto y el `.gitignore` de la capa viven en `tools-skeleton.js`:
376
379
  // lo que se copia aquí es exactamente lo que `purge` puede recuperar, y sólo si sigue intacto.
@@ -442,7 +445,10 @@ function floorSatisfied(cwd, path) {
442
445
  // conflictos de merge, y que un repo clonado puede traer preparado. Así que se valida la forma antes
443
446
  // de mirar el disco: un `..` daba el suelo por satisfecho MIRANDO FUERA de la raíz, y un `null` o una
444
447
  // cadena en lugar de un array tumbaban `rsc install` después de haber instalado.
445
- export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
448
+ //
449
+ // Devuelve los CAMINOS que faltan (como se muestran). `missingHarnessFloor` los redacta para el
450
+ // instalador y `onboardingReadiness` los entrega tal cual a `doctor`: una comprobación, dos lectores.
451
+ export function harnessFloorGaps(cwd = process.cwd(), plan = {}) {
446
452
  const declared = Array.isArray(plan?.floorPaths) ? plan.floorPaths : [];
447
453
  const missing = [];
448
454
  for (const path of declared) {
@@ -450,15 +456,19 @@ export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
450
456
  const usable = segments.length > 0 && !segments.includes('..') && !String(path).startsWith('/');
451
457
  // Un camino que no se puede usar se reporta como NO satisfecho, nunca como satisfecho, y sin
452
458
  // repetir su contenido: es dato ajeno y va a un canal que lee un agente.
453
- if (!usable) { missing.push('missing harness floor <invalid declaration>'); continue; }
459
+ if (!usable) { missing.push('<invalid declaration>'); continue; }
454
460
  // La barra sólo si el camino declarado la traía: el suelo condicional es un FICHERO, y decir
455
461
  // `constitution.md/` invita a buscar un directorio que no existe.
456
462
  const shown = String(path).endsWith('/') ? `${floorKey(path)}/` : floorKey(path);
457
- if (!floorSatisfied(cwd, path)) missing.push(`missing harness floor ${shown}`);
463
+ if (!floorSatisfied(cwd, path)) missing.push(shown);
458
464
  }
459
465
  return missing;
460
466
  }
461
467
 
468
+ export function missingHarnessFloor(cwd = process.cwd(), plan = {}) {
469
+ return harnessFloorGaps(cwd, plan).map((shown) => `missing harness floor ${shown}`);
470
+ }
471
+
462
472
  /**
463
473
  * Lo que el instalador puede afirmar honestamente, y qué hacer si no.
464
474
  *
@@ -476,3 +486,50 @@ export function harnessReadiness(cwd = process.cwd(), plan = {}) {
476
486
  : '',
477
487
  };
478
488
  }
489
+
490
+ // ─────────────────────────────────────────────────────────────────────────────
491
+ // Preparación del onboarding, como pregunta PROPIA (#298 punto 5).
492
+ //
493
+ // `doctor` respondía sólo «¿el arnés instalado funciona?» (`healthy`, y el exit code con él) y un
494
+ // usuario leía ese `true` como «el onboarding está terminado» con la constitución sin escribir. Son
495
+ // dos preguntas: ésta se calcula con el MISMO suelo que decide RSC_ONBOARDING_READY/INCOMPLETE
496
+ // (`harnessFloorGaps`) más los borradores, y nunca toca `healthy`.
497
+ //
498
+ // ready suelo completo, nada en borrador
499
+ // pending suelo completo, pero algo es un borrador que el usuario debe completar
500
+ // incomplete falta parte del suelo del plan aceptado (o el recibo no cuadra con su id)
501
+ // not-onboarded no hay recibo: instalación manual (`rsc add`) o anterior al onboarding
502
+ //
503
+ // Un recibo de una versión que no declaraba suelo queda exento por construcción (P3), igual que en
504
+ // el instalador: aquí no se inventa un suelo que el plan aceptado no traía.
505
+ const ONBOARD_ACTION = 'Run `npx @ericrisco/rsc@latest onboard` to record a plan for this harness (optional: what is installed keeps working).';
506
+ const draftAction = (path) => `Complete ${path} with the \`constitution\` phase (tell your agent "constitution") before the first SDD feature; ratifying removes \`status: draft\`.`;
507
+
508
+ export function onboardingReadiness(cwd = process.cwd(), manifest = readManifest(cwd)) {
509
+ const pending = constitutionIsDraft(cwd) ? [CONSTITUTION_PATH] : [];
510
+ const receipt = manifest?.onboarding;
511
+ if (!receipt?.plan) {
512
+ return { status: 'not-onboarded', missing: [], pending, action: ONBOARD_ACTION };
513
+ }
514
+ let identical = false;
515
+ try { identical = identifyPlan(receipt.plan) === receipt.acceptedPlanId; } catch { /* ilegible: no cuadra */ }
516
+ if (!identical) {
517
+ return {
518
+ status: 'incomplete', missing: ['<onboarding receipt does not match its accepted plan id>'], pending,
519
+ action: 'Do not trust the receipt. Re-run `npx @ericrisco/rsc@latest onboard` and accept a fresh plan.',
520
+ };
521
+ }
522
+ const missing = harnessFloorGaps(cwd, receipt.plan);
523
+ if (missing.length) {
524
+ const actions = [];
525
+ if (missing.includes(CONSTITUTION_PATH)) {
526
+ actions.push(`Write ${CONSTITUTION_PATH} with the \`constitution\` phase (tell your agent "constitution"); the SDD plan you accepted requires it.`);
527
+ }
528
+ if (missing.some((path) => path !== CONSTITUTION_PATH)) {
529
+ actions.push('Invoke the `harness` skill to scaffold 01-TOOLS/ + 02-DOCS/.');
530
+ }
531
+ return { status: 'incomplete', missing, pending, action: actions.join(' ') };
532
+ }
533
+ if (pending.length) return { status: 'pending', missing, pending, action: draftAction(CONSTITUTION_PATH) };
534
+ return { status: 'ready', missing, pending, action: '' };
535
+ }
@@ -117,6 +117,13 @@ export function scanProject(root = process.cwd()) {
117
117
  // Codex stores the always-on layer in AGENTS.md. Ignore a file that contains
118
118
  // only our managed block, while retaining a user's surrounding instructions
119
119
  // as real project evidence across repair/re-onboarding.
120
+ // El `CLAUDE.md` raíz es el Knowledge map, y lo escriben las propias fases de rsc: la
121
+ // `constitution` añade su fila «create CLAUDE.md if absent», y el instalador puede dejar ahí
122
+ // la sombra de AGENTS.md. Contarlo como evidencia movía la identidad del plan justo después de
123
+ // completar el suelo, y el `--accept-plan` del mismo id moría con RSC_PLAN_CHANGED sin que
124
+ // nada se hubiera elegido distinto (reproducido con 3.0.8). La cuenta de markdown no decide
125
+ // nada del plan —sólo su identidad—, así que no se pierde ninguna decisión.
126
+ if (rel === 'CLAUDE.md') isSignal = false;
120
127
  if (entry.name === 'AGENTS.md') {
121
128
  const human = readFileSync(path, 'utf8')
122
129
  .replace(/\n*<!-- rsc-suggest:start -->[\s\S]*?<!-- rsc-suggest:end -->\n*/g, '')