@ericrisco/rsc 3.0.7 → 3.0.9

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.
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
5
  import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
6
6
  import { targetPaths } from '../../targets/index.js';
7
7
  import { targetHasAgents } from '../../targets/agents.js';
@@ -10,6 +10,8 @@ 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 } from './constitution-draft.js';
14
+ import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
13
15
 
14
16
  const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
15
17
 
@@ -157,12 +159,35 @@ export function mergeProfile(existing, plan, previousRecord) {
157
159
  return `---\n${front.join('\n')}\n---\n${body.startsWith('\n') ? '' : '\n'}${body}`;
158
160
  }
159
161
 
162
+ /**
163
+ * The profile is one person's dials (knowledge-sync never sends it), so it never belongs in a commit
164
+ * either — yet it sat untracked for good, and every session's agent reported it as «cambios sin
165
+ * commitear» (E2E 2026-10-07). Excluded in THIS clone only (`info/exclude`, not `.gitignore`), and
166
+ * never when the team already tracks it: that is their decision.
167
+ */
168
+ export function excludePersonalProfile(cwd) {
169
+ const git = (args) => { try { return execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } };
170
+ if (git(['rev-parse', '--is-inside-work-tree']) !== 'true') return false;
171
+ const rel = '02-DOCS/wiki/harness/user-profile.md';
172
+ if (git(['ls-files', '--', rel])) return false;
173
+ const value = git(['rev-parse', '--git-path', 'info/exclude']);
174
+ if (!value) return false;
175
+ const file = resolve(cwd, value);
176
+ const pattern = `/${rel}`;
177
+ mkdirSync(dirname(file), { recursive: true });
178
+ const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
179
+ if (body.split('\n').includes(pattern)) return true;
180
+ appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
181
+ return true;
182
+ }
183
+
160
184
  export function writeOnboardingDocuments(cwd, plan, planId, previousRecord = null) {
161
185
  const dir = join(cwd, '02-DOCS', 'wiki', 'harness');
162
186
  mkdirSync(dir, { recursive: true });
163
187
  const docs = renderOnboardingDocuments(plan, planId);
164
188
  const profilePath = join(dir, 'user-profile.md');
165
189
  writeFileSync(profilePath, mergeProfile(existsSync(profilePath) ? readFileSync(profilePath, 'utf8') : '', plan, previousRecord));
190
+ excludePersonalProfile(cwd);
166
191
  writeFileSync(join(dir, 'installation-plan.md'), docs.installation);
167
192
  const decisionsPath = join(dir, 'decisions.md');
168
193
  if (!existsSync(decisionsPath)) writeFileSync(decisionsPath, docs.decisions);
@@ -343,34 +368,15 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
343
368
  // Y la retro-compatibilidad no es una lista de exentos (P3): el suelo viaja DENTRO del plan, así
344
369
  // que un recibo aceptado por una versión anterior no lo trae y queda exento por construcción.
345
370
 
346
- const LIB_DIR = dirname(fileURLToPath(import.meta.url));
347
- const TEMPLATE_SOURCE = join(LIB_DIR, '..', '..', 'skills', 'harness', 'assets', '_TEMPLATE');
348
371
  const TEMPLATE_FLOOR = '01-TOOLS/_TEMPLATE/';
349
372
 
350
373
  export const HARNESS_FLOOR_MINIMUM = [TEMPLATE_FLOOR, '02-DOCS/wiki/harness/'];
351
- export const HARNESS_FLOOR_CONSTITUTION = '02-DOCS/wiki/sdd/constitution.md';
352
-
353
- // Los ficheros que la plantilla trae hoy. Se lee del asset en vez de codificar una lista, para que
354
- // añadir un fichero a la plantilla no deje el suelo comprobando de menos en silencio.
355
- //
356
- // Un error aquí NO se convierte en lista vacía. Comérselo dejaba el suelo permanentemente
357
- // insatisfacible, con un mensaje que ofrecía una acción que leía ese mismo asset ausente y que por
358
- // tanto no podía arreglarlo nunca — y sin nombrar jamás la causa real, que es un paquete roto.
359
- function templateAssets() {
360
- return readdirSync(TEMPLATE_SOURCE).sort();
361
- }
362
-
363
- // npm NUNCA empaqueta un fichero llamado `.gitignore`, así que el asset viaja sin punto y se copia
364
- // con él. Sin esto, desde el paquete publicado se copiaban 4 de 5 ficheros y el que faltaba era el
365
- // único que evita comitear el `.env` que el README —copiado por el propio instalador— manda crear.
366
- const DOTTED = { gitignore: '.gitignore' };
367
- const targetName = (asset) => DOTTED[asset] ?? asset;
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;
368
377
 
369
- // La capa entera, no cada proveedor: el flujo documentado es copiar `_TEMPLATE/` a
370
- // `01-TOOLS/<PROVEEDOR>/`, y depender de que cada copia se lleve su propia protección es depender
371
- // de que nadie se salte un paso con credenciales de por medio.
372
- const LAYER_IGNORE = ['# Escrito por rsc: la capa de herramientas guarda credenciales.',
373
- '*/.env', '*/.env.*', '!*/.env.example', '*/keys/', '*/out/', ''].join('\n');
378
+ // La plantilla, sus nombres con punto y el `.gitignore` de la capa viven en `tools-skeleton.js`:
379
+ // lo que se copia aquí es exactamente lo que `purge` puede recuperar, y sólo si sigue intacto.
374
380
 
375
381
  /**
376
382
  * Crea la parte determinista del esqueleto. Copiar ficheros estáticos es un algoritmo, así que por
@@ -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, '')
@@ -248,7 +255,14 @@ export function buildOnboardingPlan(record, evidence) {
248
255
  // in an invoicing workspace, and `init` promises it by technical level: non-technical and mixed get
249
256
  // it, whatever the project is. The guard also decides at runtime from the profile, so the plan only
250
257
  // chooses whether it is present, never whether it bites.
251
- const dangerGuard = normalized.technicalLevel !== 'technical';
258
+ //
259
+ // And since 3.0.8 it is present for EVERYONE. A technical user used to get nothing, so a small
260
+ // project with `main` open had no guard at all (E2E 2026-10-07). The runtime still decides the
261
+ // bite: deny for non-technical, `ask` — only on lost work or rewritten history — for technical.
262
+ const dangerGuard = true;
263
+ // The FTD/SDD lane decision belongs to every software project, small ones included: without it
264
+ // the agent never sees the lanes (E2E 2026-10-07: five sessions, zero feature documents).
265
+ const laneGate = isSoftware;
252
266
  // `sdd` is installed everywhere now, but its DECISION is about practice, not presence — so where
253
267
  // the chain is not practised the explicit `deferred` entry below must be the only one, or the
254
268
  // generic "installed → selected" mapping would shadow it and `reassess` would see nothing to watch.
@@ -269,9 +283,16 @@ export function buildOnboardingPlan(record, evidence) {
269
283
  // so the chain is always one request away; this decision is what `reassess` watches, and what the
270
284
  // floor reads. Collapsing the two would have deleted reassess's reason to exist as a side effect
271
285
  // of an installer change, which is not a decision an installer change gets to make.
272
- if (!practisesSdd) decisions.push(deferred('sdd', 'workflow', isSoftware
273
- ? 'The software scope is small, so specification overhead is not justified yet.'
274
- : 'SDD applies to substantial software work, which is not the declared project purpose.', sddTriggers, softwareTriggers));
286
+ // The reason says both halves, because the plan lists `.claude/skills/sdd` under managed paths
287
+ // and a bare "deferred" next to it read as a contradiction (E2E 2026-10-07). Only the wording
288
+ // changes: the decision stays the one deferred `workflow/sdd` entry that reassess watches.
289
+ const whyDeferred = isSoftware
290
+ ? 'the software scope is small, so specification overhead is not justified yet.'
291
+ : 'SDD applies to substantial software work, which is not the declared project purpose.';
292
+ const sddReason = skills.includes('sdd')
293
+ ? `The sdd skill is installed, so the chain is one request away; the practice is deferred: ${whyDeferred}`
294
+ : whyDeferred.charAt(0).toUpperCase() + whyDeferred.slice(1);
295
+ if (!practisesSdd) decisions.push(deferred('sdd', 'workflow', sddReason, sddTriggers, softwareTriggers));
275
296
  if (baseAgents) {
276
297
  for (const id of agents) decisions.push(selected(id, 'agent', 'The accepted substantial software workflow requires this implementation or review role.'));
277
298
  } else {
@@ -296,6 +317,7 @@ export function buildOnboardingPlan(record, evidence) {
296
317
  codeHooks: practisesSdd,
297
318
  gitmojiGuard,
298
319
  dangerGuard,
320
+ laneGate,
299
321
  memory: true,
300
322
  context7: false,
301
323
  };
@@ -0,0 +1,76 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // The `01-TOOLS/` scaffolding rsc writes on onboarding, in ONE place: what `ensureHarnessSkeleton`
7
+ // copies is exactly what `purge` may take back, and it may take back only a file still identical to
8
+ // what was copied. `01-TOOLS/` is the layer the user edits when adding tools, so anything that
9
+ // differs from the template is theirs and stays.
10
+
11
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url));
12
+ export const TEMPLATE_SOURCE = join(LIB_DIR, '..', '..', 'skills', 'harness', 'assets', '_TEMPLATE');
13
+
14
+ // Los ficheros que la plantilla trae hoy. Se lee del asset en vez de codificar una lista, para que
15
+ // añadir un fichero a la plantilla no deje el suelo comprobando de menos en silencio.
16
+ //
17
+ // Un error aquí NO se convierte en lista vacía. Comérselo dejaba el suelo permanentemente
18
+ // insatisfacible, con un mensaje que ofrecía una acción que leía ese mismo asset ausente y que por
19
+ // tanto no podía arreglarlo nunca — y sin nombrar jamás la causa real, que es un paquete roto.
20
+ export function templateAssets() {
21
+ return readdirSync(TEMPLATE_SOURCE).sort();
22
+ }
23
+
24
+ // npm NUNCA empaqueta un fichero llamado `.gitignore`, así que el asset viaja sin punto y se copia
25
+ // con él. Sin esto, desde el paquete publicado se copiaban 4 de 5 ficheros y el que faltaba era el
26
+ // único que evita comitear el `.env` que el README —copiado por el propio instalador— manda crear.
27
+ const DOTTED = { gitignore: '.gitignore' };
28
+ export const targetName = (asset) => DOTTED[asset] ?? asset;
29
+
30
+ // La capa entera, no cada proveedor: el flujo documentado es copiar `_TEMPLATE/` a
31
+ // `01-TOOLS/<PROVEEDOR>/`, y depender de que cada copia se lleve su propia protección es depender
32
+ // de que nadie se salte un paso con credenciales de por medio.
33
+ export const LAYER_IGNORE = ['# Escrito por rsc: la capa de herramientas guarda credenciales.',
34
+ '*/.env', '*/.env.*', '!*/.env.example', '*/keys/', '*/out/', ''].join('\n');
35
+
36
+ const sameBytes = (a, b) => {
37
+ try { return readFileSync(a).equals(Buffer.isBuffer(b) ? b : readFileSync(b)); } catch { return false; }
38
+ };
39
+ const isPlainFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } };
40
+
41
+ /**
42
+ * Take back the scaffolding rsc wrote, file by file, only where it is still byte-identical to what
43
+ * rsc wrote. Returns `{ removed, kept }` as paths relative to `cwd`, `kept` with a reason. Empty
44
+ * directories left behind are removed by the caller's directory sweep.
45
+ */
46
+ // Committed means the team has it: deleting it leaves the tree dirty and takes it from everybody on
47
+ // the next push (field test 3.0.8: purge deleted a 01-TOOLS/.gitignore that was already in history).
48
+ function tracked(cwd, rel) {
49
+ try { return Boolean(execFileSync('git', ['ls-files', '--', rel], { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return false; }
50
+ }
51
+
52
+ export function removeUnmodifiedSkeleton(cwd, { dryRun = false } = {}) {
53
+ const removed = [];
54
+ const kept = [];
55
+ const committed = (rel) => tracked(cwd, rel) && (kept.push({ path: rel, reason: 'committed to git — the team has it; delete it in a commit if you mean to' }), true);
56
+ const templateDir = join(cwd, '01-TOOLS', '_TEMPLATE');
57
+ if (existsSync(templateDir) && !lstatSync(templateDir).isSymbolicLink()) {
58
+ for (const asset of templateAssets()) {
59
+ const rel = `01-TOOLS/_TEMPLATE/${targetName(asset)}`;
60
+ const file = join(cwd, rel);
61
+ if (!existsSync(file) || committed(rel)) continue;
62
+ if (isPlainFile(file) && sameBytes(file, join(TEMPLATE_SOURCE, asset))) {
63
+ if (!dryRun) rmSync(file, { force: true });
64
+ removed.push(rel);
65
+ } else kept.push({ path: rel, reason: 'changed since rsc wrote it — yours now' });
66
+ }
67
+ }
68
+ const layer = join(cwd, '01-TOOLS', '.gitignore');
69
+ if (existsSync(layer) && !committed('01-TOOLS/.gitignore')) {
70
+ if (isPlainFile(layer) && sameBytes(layer, Buffer.from(LAYER_IGNORE))) {
71
+ if (!dryRun) rmSync(layer, { force: true });
72
+ removed.push('01-TOOLS/.gitignore');
73
+ } else kept.push({ path: '01-TOOLS/.gitignore', reason: 'changed since rsc wrote it — yours now' });
74
+ }
75
+ return { removed, kept };
76
+ }
package/scripts/rsc.js CHANGED
@@ -4,12 +4,13 @@ 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, purge, collisions, ownSkillPaths } from './install-apply.js';
7
+ import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths } from './install-apply.js';
8
+ import { isAbsolute, relative, sep } from 'node:path';
8
9
  import { stackAgentNames } from '../targets/agents.js';
9
10
  import { doctor } from './doctor.js';
10
11
  import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
11
12
  import { refreshRegistry, registryStatus } from './lib/registry.js';
12
- import { audit, writeAuditReport } from './audit.js';
13
+ import { audit, writeAuditReport, recordAuditBaseline } from './audit.js';
13
14
  import { DOMAINS } from './lib/domains.js';
14
15
  import { listBackups, restoreBackup, whereKept } from './lib/backups.js';
15
16
  import { runUpgrade } from './lib/upgrade.js';
@@ -18,11 +19,13 @@ import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-
18
19
  import { RETIRED_SKILLS, replaceRetired } from './lib/retired-skills.js';
19
20
  import { readManifest, writeManifest } from './lib/manifest-file.js';
20
21
  import { versionReport } from './lib/versions.js';
22
+ import { templateAssets, targetName } from './lib/tools-skeleton.js';
21
23
  import {
22
24
  normalizeOnboarding, missingOnboardingFields, scanProject,
23
25
  buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
24
26
  } from './lib/onboarding.js';
25
27
  import { applyAcceptedOnboarding, verifyOnboarding, ensureHarnessSkeleton, harnessReadiness } from './lib/onboarding-apply.js';
28
+ import { CONSTITUTION_PATH, constitutionIsDraft, ensureConstitutionDraft } from './lib/constitution-draft.js';
26
29
 
27
30
  const rawArgv = process.argv.slice(2);
28
31
 
@@ -39,6 +42,33 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
39
42
  process.stdout.write(`${lines.join('\n')}\n`);
40
43
  process.exit(0);
41
44
  }
45
+ const GENERAL_HELP = [
46
+ 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
47
+ 'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
48
+ ' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
49
+ ].join('\n');
50
+ const UNINSTALL_HELP = [
51
+ 'Use: npx @ericrisco/rsc uninstall <id...> [--dry-run] [--target <assistant>]',
52
+ ' Removes the named skills or agents from this project.',
53
+ ' To remove everything rsc installed here, run: npx @ericrisco/rsc purge (keeps 02-DOCS/ unless --with-docs)',
54
+ ].join('\n');
55
+ const PURGE_HELP = [
56
+ 'Use: npx @ericrisco/rsc purge [--dry-run] [--with-docs]',
57
+ ' Removes everything rsc installed in this project and reports what was removed, cleaned and kept.',
58
+ ' --dry-run shows the report without touching anything. 02-DOCS/ is kept unless --with-docs.',
59
+ ].join('\n');
60
+
61
+ // Asking for help must never RUN the command. `uninstall --help` used to be read as an empty id list
62
+ // and report "Removed: (nothing)"; `purge --help` would have purged. Answered before anything else.
63
+ {
64
+ const helpAsked = rawArgv.some((a) => a === '--help' || a === '-h');
65
+ if (rawArgv[0] === 'help' || helpAsked) {
66
+ const about = rawArgv[0] === 'help' ? rawArgv[1] : rawArgv.find((a) => !a.startsWith('-'));
67
+ const text = about === 'uninstall' ? UNINSTALL_HELP : about === 'purge' ? PURGE_HELP : GENERAL_HELP;
68
+ process.stdout.write(`${text}\n`);
69
+ process.exit(0);
70
+ }
71
+ }
42
72
  // `--accompaniment` stays listed although the dial is retired: old scripts and agents still pass it,
43
73
  // and its value must keep being skipped rather than read as a command or a skill id.
44
74
  const GLOBAL_VALUE_FLAGS = new Set([
@@ -159,6 +189,12 @@ function renderPlan(plan, planId) {
159
189
  for (const decision of plan.decisions.filter((d) => d.state === 'excluded')) say(` × ${decision.kind}/${decision.id} — ${decision.reason}`);
160
190
  say('Managed paths:');
161
191
  for (const path of plan.governedPaths) say(` ${path}`);
192
+ // The scaffolding is written once and then belongs to the person, so it is not a managed (digested)
193
+ // path — but it IS written by this plan, and a plan that does not show it is not the whole plan.
194
+ if ((plan.floorPaths || []).includes('01-TOOLS/_TEMPLATE/')) {
195
+ say('Scaffolded once (yours to edit; never overwritten; purge removes only untouched files):');
196
+ for (const path of ['01-TOOLS/.gitignore', ...templateAssets().map((a) => `01-TOOLS/_TEMPLATE/${targetName(a)}`)].sort()) say(` ${path}`);
197
+ }
162
198
  if (plan.replacesUserSkills?.length) {
163
199
  say('Your own skills this plan replaces:');
164
200
  for (const path of plan.replacesUserSkills) say(` ! ${path} — already here and not put by rsc; the catalog skill of the same name takes its place, and yours is kept in .rsc/backups/`);
@@ -166,8 +202,11 @@ function renderPlan(plan, planId) {
166
202
  say(' AGENT: name each of these to the user before accepting, and ask whether to replace or rename.');
167
203
  }
168
204
  if (plan.evidence.parentHarness) say(`Parent harness detected at ${plan.evidence.parentHarness}; it is not inherited by this plan.`);
205
+ // Pinned to the version that computed this plan. The id is a hash of the plan, and the plan
206
+ // depends on the catalog of the running version: `@latest` would accept it with whatever version
207
+ // npm resolves next, which answers RSC_PLAN_CHANGED the day a release lands in between.
169
208
  const pieces = [
170
- 'npx @ericrisco/rsc@latest onboard',
209
+ `npx @ericrisco/rsc@${versionReport(process.cwd()).cli} onboard`,
171
210
  `--technical-level ${plan.record.technicalLevel}`,
172
211
  `--project-kind ${plan.record.projectKind}`,
173
212
  `--goal-base64 ${encodeGoal(plan.record.goal)}`,
@@ -260,6 +299,17 @@ async function runOnboarding(targets) {
260
299
  } catch (error) {
261
300
  say(`RSC_SKELETON_FAILED ${error.message}`);
262
301
  }
302
+ // La constitución, como BORRADOR, cuando el plan eligió SDD y no hay ninguna. Sin esto una
303
+ // instalación no interactiva de software complejo acababa INCOMPLETE: sólo una fase agéntica la
304
+ // escribía. Mismo sitio y mismo motivo que el esqueleto: fuera de la transacción, sin residuo.
305
+ try {
306
+ ensureConstitutionDraft(process.cwd(), plan);
307
+ } catch (error) {
308
+ say(`RSC_SKELETON_FAILED ${error.message}`);
309
+ }
310
+ // The audit cadence starts at install. Without a baseline the very first session after onboarding
311
+ // announced "a skill audit is due" about a set chosen minutes earlier. Never overwrites a real run.
312
+ try { recordAuditBaseline(process.cwd()); } catch { /* a missing baseline only means an early nudge */ }
263
313
  for (const path of plan.replacesUserSkills || []) {
264
314
  const kept = whereKept({ cwd: process.cwd(), relPath: path });
265
315
  if (kept) say(`Your previous ${path} is kept in ${kept}`);
@@ -272,7 +322,14 @@ async function runOnboarding(targets) {
272
322
  // poco se cumplía entero. Spec: 02-DOCS/wiki/sdd/specs/install-completion-floor.md
273
323
  function emitHarnessReadiness(plan, planId) {
274
324
  const readiness = harnessReadiness(process.cwd(), plan);
275
- if (readiness.ready) return void say(`RSC_ONBOARDING_READY ${planId}`);
325
+ if (readiness.ready) {
326
+ say(`RSC_ONBOARDING_READY ${planId}`);
327
+ // Listo no es «nada pendiente»: el borrador se nombra para que la cadena SDD lo complete primero.
328
+ if ((plan.floorPaths || []).includes(CONSTITUTION_PATH) && constitutionIsDraft(process.cwd())) {
329
+ say(` Pending: ${CONSTITUTION_PATH} is a draft — complete it with the \`constitution\` phase before the first SDD feature.`);
330
+ }
331
+ return;
332
+ }
276
333
  say(`RSC_ONBOARDING_INCOMPLETE ${planId}`);
277
334
  for (const missing of safeLines(readiness.missing)) say(` ${missing}`);
278
335
  say(` Next: ${readiness.action}`);
@@ -370,10 +427,34 @@ function flag(name) {
370
427
  // Remove everything rsc installed in this project (skills, hooks, .rsc/), across
371
428
  // every assistant. Keeps 02-DOCS/ unless --with-docs. `purge` / `uninstall --all`.
372
429
  async function runPurge(dryRun, withDocs) {
373
- const removed = await purge({ cwd: process.cwd(), withDocs, dryRun });
374
- say(`${dryRun ? 'Would remove' : 'Removed'} ${removed.length} path(s):`);
375
- for (const r of removed) say(` - ${r}`);
376
- if (!withDocs) say('\nKept 02-DOCS/ (your knowledge base). Add --with-docs to remove it too.');
430
+ const cwd = process.cwd();
431
+ const report = await purgeReport({ cwd, withDocs, dryRun });
432
+ // Relative to the project: the report is read by a person, and every path is inside it.
433
+ const shown = (p) => {
434
+ const rel = relative(cwd, p);
435
+ return !rel || rel.startsWith('..') || isAbsolute(rel) ? p : rel.split(sep).join('/');
436
+ };
437
+ say(`${dryRun ? 'Would remove' : 'Removed'} ${report.removed.length} path(s):`);
438
+ for (const r of report.removed.map(shown).sort()) say(` - ${r}`);
439
+ if (report.cleaned.length) {
440
+ say(`\n${dryRun ? 'Would clean' : 'Cleaned'} ${report.cleaned.length} file(s) — rsc's entries ${dryRun ? 'would be' : 'were'} taken out; ${dryRun ? 'each is deleted if nothing of yours remains' : 'the rest of the file is yours and stays'}:`);
441
+ for (const c of report.cleaned.map(shown).sort()) say(` - ${c}`);
442
+ }
443
+ if (report.kept.length) {
444
+ say(`\nKept ${report.kept.length}:`);
445
+ for (const k of report.kept) say(` - ${k.path} — ${k.reason}`);
446
+ }
447
+ // On a clone most of what purge deletes is committed. Deleting it here is local; committing the
448
+ // deletions uninstalls rsc for the whole team (team simulation D15). Said before anyone commits.
449
+ if (report.tracked?.length) {
450
+ const quote = (p) => (/^[\w./@+-]+$/.test(p) ? p : `'${p.replace(/'/g, `'\\''`)}'`);
451
+ say(`\nWARNING: ${report.tracked.length} of these file(s) are committed in git and ${dryRun ? 'would show' : 'now show'} as deleted or changed:`);
452
+ for (const f of report.tracked.slice(0, 12)) say(` - ${f}`);
453
+ if (report.tracked.length > 12) say(` … and ${report.tracked.length - 12} more`);
454
+ say('Committing these changes uninstalls rsc for the whole team, on their next pull. To remove rsc only on this machine, do not commit them.');
455
+ if (!dryRun) say(`To undo locally: git restore -- ${report.tracked.map(quote).join(' ')}`);
456
+ else say('To undo after a real purge: git restore -- <those files> (purge prints the exact command).');
457
+ }
377
458
  }
378
459
 
379
460
  async function recommendIds(query, { labeledOnly = false } = {}) {
@@ -515,7 +596,7 @@ function printContextBudget(b) {
515
596
  if (b.notApplicable.length) {
516
597
  say(` ${b.note}`);
517
598
  } else {
518
- say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope)`);
599
+ say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope; one-time and conditional banners not counted)`);
519
600
  say(` Per user turn : ${kb(b.perTurnBytes)}`);
520
601
  }
521
602
  say(` Skill descriptions: ${kb(b.descriptionsBytes)} (${b.installedSkills} installed, always in context)`);
@@ -1006,7 +1087,15 @@ async function main() {
1006
1087
  // defeated by an argument the sweep itself told the agent to type.
1007
1088
  const confirmed = argv.includes('--confirm');
1008
1089
  const targets = one ? [one] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
1009
- if (!targets.length) { say('nothing to clean up.'); return; }
1090
+ // The bulk reap also deletes plain local branches that have landed (`branch -d`, never -D):
1091
+ // the worktree is not the only thing a merge leaves behind (E2E defect 14).
1092
+ const reapBranches = () => {
1093
+ if (one) return 0;
1094
+ const { deleted } = W.reapMergedBranches(root);
1095
+ for (const b of deleted) say(`✅ deleted merged branch ${b}`);
1096
+ return deleted.length;
1097
+ };
1098
+ if (!targets.length) { if (!reapBranches()) say('nothing to clean up.'); return; }
1010
1099
  for (const t of targets) {
1011
1100
  const out = W.reapWorktree(root, t, { confirmed });
1012
1101
  if (out.removed) {
@@ -1018,6 +1107,7 @@ async function main() {
1018
1107
  process.exitCode = 1;
1019
1108
  }
1020
1109
  }
1110
+ reapBranches();
1021
1111
  return;
1022
1112
  }
1023
1113
 
@@ -1272,7 +1362,14 @@ async function main() {
1272
1362
  const dry = argv.includes('--dry-run');
1273
1363
  // `uninstall --all` is an alias for a full purge.
1274
1364
  if (argv.includes('--all')) return void (await runPurge(dry, argv.includes('--with-docs')));
1275
- const selected = classifyRequested(requestedIds());
1365
+ const ids = requestedIds();
1366
+ // No id is not "nothing to remove": it is a command that was not given what it needs.
1367
+ if (!ids.length) {
1368
+ console.error(`rsc: uninstall needs at least one skill or agent id.\n${UNINSTALL_HELP}`);
1369
+ process.exitCode = 1;
1370
+ return;
1371
+ }
1372
+ const selected = classifyRequested(ids);
1276
1373
  // A retired skill an old install still holds is not unknown: the state file proves it is ours.
1277
1374
  const held = new Set(listInstalled({ target }));
1278
1375
  selected.skills.push(...selected.unknown.filter((id) => Object.hasOwn(RETIRED_SKILLS, id) && held.has(id)));
@@ -1306,9 +1403,7 @@ async function main() {
1306
1403
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
1307
1404
  default:
1308
1405
  say(`rsc: unknown command '${cmd}'.`);
1309
- say('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');
1310
- say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
1311
- say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
1406
+ say(GENERAL_HELP);
1312
1407
  process.exitCode = 1;
1313
1408
  }
1314
1409
  }
@@ -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