@ericrisco/rsc 3.0.7 → 3.0.8

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
@@ -94,7 +94,7 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
94
94
  | **You choose how the project works** | The install asks once: straight on `main`, or branches and pull requests? Branches are recommended only for long-lived, complex code. With `main` the agent works on it and never asks about branches. With branches a commit on `main` is refused, and before each code change the agent asks: this branch, a new one, or unlock `main`? It never opens a branch on its own. | Say **«unlock main»** / **«lock main»**, or `rsc main unlock` / `lock` |
95
95
  | **One workspace per agent** | If another assistant session is working in the same folder, new work goes to a worktree under `.worktrees/<branch>/`. It stays inside the project, is never committed, and is removed once that branch is merged. | `rsc isolation off` |
96
96
  | **The agent picks the method** | A simple change takes Fast-Track (one document, proof for every task). Something big or complex takes the spec-driven chain. The agent decides and says why. You still approve *what* gets built: the spec and the clarifications, then step by step or autopilot. | Ask for the other lane |
97
- | **Knowledge reaches everyone** | `01-TOOLS/` and `02-DOCS/` travel through one exchange branch, `rsc/knowledge`. They go up when your turn ends and come down into whatever branch each person is on. They reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
97
+ | **Knowledge reaches everyone** | `01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` travel through one exchange branch, `rsc/knowledge`. They go up when your turn ends and come down into whatever branch each person is on. They reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
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
@@ -471,8 +471,10 @@ list somebody has to remember to update is a list that goes quietly stale. Decla
471
471
 
472
472
  ### Your wiki and tools stay in sync — on by default
473
473
 
474
- `01-TOOLS/` and `02-DOCS/` are the team's knowledge, so rsc keeps them the same on every machine
475
- without anybody thinking about git, **and without ever pushing to `main`**. Your `main` is
474
+ `01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` are the team's knowledge, so rsc keeps them
475
+ the same on every machine without anybody thinking about git, **and without ever pushing to `main`**.
476
+ Exactly those three folders, minus rsc's own `01-TOOLS/_TEMPLATE/` and your personal
477
+ `02-DOCS/wiki/harness/user-profile.md`. Your `main` is
476
478
  protected, or should be; rsc assumes it is.
477
479
 
478
480
  The knowledge travels through one exchange branch, `rsc/knowledge`, that nobody works on by hand:
@@ -484,13 +486,22 @@ main · protected untouched — gets it inside the next merged pull reque
484
486
  new · clones main has it all after the first message
485
487
  ```
486
488
 
487
- - **When a turn ends**, your changes in those folders are committed as `📝 docs(auto): … [skip ci]`
488
- and sent to `rsc/knowledge` on `origin`, from whatever branch you are on. If you are on a closed
489
- `main`, nothing is committed locally: the snapshot is built aside and only goes to `rsc/knowledge`.
490
- Knowledge that you or the agent already **committed** on the branch during the turn goes up too —
491
- only its `01-TOOLS/` and `02-DOCS/` part, under a `docs(auto) … [skip ci]` message.
492
- - **Before each message**, what teammates sent is brought into **the branch you are on**, and you are
493
- told in one line. Only the knowledge folders are touched.
489
+ - **When a turn ends**, your changes in those folders are committed on your branch as
490
+ `📝 docs(auto): …` and sent to `rsc/knowledge` on `origin`, from whatever branch you are on. Only
491
+ the copy on `rsc/knowledge` carries `[skip ci]`; the commit on your branch does not, so your next
492
+ ordinary push still runs CI. Two cases commit nothing on your branch: a closed `main`, and code of
493
+ yours still uncommitted (a docs commit would land ahead of the code it describes). Then the snapshot
494
+ is built aside, only goes to `rsc/knowledge`, and the files stay modified for you to commit with
495
+ your work. Knowledge that you or the agent already **committed** on the branch goes up too — only
496
+ its knowledge part, under a `docs(auto) … [skip ci]` message. Commits that came in by merging
497
+ `main` are your teammates', not yours, and are not sent again.
498
+ - **Before each message**, what teammates sent is brought into **the branch you are on** as a
499
+ `📥 docs(auto): sync` commit, and you are told in one line. Only the knowledge folders are touched.
500
+ A doc that `main` on `origin` already has, identical, is left for your ordinary merge of `main`. On
501
+ a closed `main` nothing is written at all — an uncommitted file there would make your next
502
+ `git pull` refuse to run — you are told what is waiting, and it arrives with that pull or in the
503
+ next branch you open. When the last fetch is more than ten minutes old, the message fetches first
504
+ (three seconds at most), so even a one-message session sees fresh docs.
494
505
  A branch you open later catches up on its first message: everything on `rsc/knowledge` since it
495
506
  left `main` comes down, including what you wrote yourself on another branch.
496
507
  - **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
@@ -505,7 +516,7 @@ What it will not do, by design:
505
516
  | --- | --- |
506
517
  | Push to `main` | Never. Only `rsc/knowledge` receives anything. |
507
518
  | Push your code or your unpushed commits | Only the knowledge folders go up, even from a commit that also has code. Your code waits for you. |
508
- | Trigger CI or a deploy | Every automatic commit says `[skip ci]`. |
519
+ | Trigger CI or a deploy | Every commit on `rsc/knowledge` says `[skip ci]`; the one on your branch does not, so it never skips the CI of your own push. |
509
520
  | Bring in anybody else's code | Only knowledge paths come down. Code, config and `.claude/` are never pulled. |
510
521
  | Sync `02-DOCS/wiki/harness/user-profile.md` | Those are one person's dials. |
511
522
  | Overwrite a file you are editing | It tells you, and your version stays. |
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.7",
2
+ "version": "3.0.8",
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.7",
3
+ "version": "3.0.8",
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/audit.js CHANGED
@@ -207,6 +207,17 @@ export function stampAudit(cwd, date = new Date().toISOString()) {
207
207
  writeFileSync(join(dir, 'audit.json'), JSON.stringify({ lastRun: date }, null, 2) + '\n');
208
208
  }
209
209
 
210
+ // The start of the cadence for a harness that has never been audited: written by onboarding so the
211
+ // first audit falls due STALE_DAYS after install, not in the first session. `baseline` and not
212
+ // `lastRun`, because no audit ran — the SessionStart hook reads either. Leaves an existing file alone.
213
+ export function recordAuditBaseline(cwd, date = new Date().toISOString()) {
214
+ const file = join(cwd, '.rsc', 'audit.json');
215
+ if (existsSync(file)) return false;
216
+ mkdirSync(join(cwd, '.rsc'), { recursive: true });
217
+ writeFileSync(file, JSON.stringify({ baseline: date }, null, 2) + '\n');
218
+ return true;
219
+ }
220
+
210
221
  // Write the report into the harness wiki when one exists; always stamp .rsc/audit.json.
211
222
  export function writeAuditReport(report, cwd = process.cwd()) {
212
223
  const written = [];
package/scripts/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
- import { hooksDir, hooksDirIsOurs } from '../targets/worktree-reaper.mjs';
2
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
- import { join, dirname } from 'node:path';
1
+ import { hooksDir, hooksDirIsOurs, classifyWorktrees } from '../targets/worktree-reaper.mjs';
2
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
3
+ import { join, dirname, relative, sep } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { homedir } from 'node:os';
5
6
  import { targetPaths, TARGET_IDS } from '../targets/index.js';
6
7
  import { readState } from './lib/state.js';
@@ -101,7 +102,18 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
101
102
  // Is the post-merge trigger actually armed in this clone? Three states, because they need three
102
103
  // different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
103
104
  // at install time, and overwriting a husky hook to fix a convenience would be indefensible).
104
- function worktreeCleanupStatus(root) {
105
+ // The skills that read or write a design identity (02-DOCS/wiki/brand/) or a starting point. The
106
+ // state is always reported; the FIX — "run design-loop, then design-dna" — is advice, and advice needs
107
+ // evidence. A todo web app with none of these installed was told to run two skills it does not have
108
+ // and never asked for (E2E 2026-10-07).
109
+ const DESIGN_SKILLS = new Set(['design', 'design-loop', 'design-dna', 'brand-voice', 'content-engine', 'marketing', 'presentations', 'make']);
110
+ function withoutUnfoundedFix(summary, inPlay) {
111
+ if (inPlay || !summary || !summary.fix) return summary;
112
+ const { fix, ...rest } = summary;
113
+ return { ...rest, note: 'no design skill is installed here, so nothing is recommended' };
114
+ }
115
+
116
+ function worktreeCleanupStatus(root, policy) {
105
117
  if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
106
118
  if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
107
119
  // Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
@@ -118,6 +130,13 @@ function worktreeCleanupStatus(root) {
118
130
  }
119
131
  const hook = join(dir, 'post-merge');
120
132
  if (!existsSync(hook)) {
133
+ // The merge hook is one of the code hooks, and the accepted plan can defer those. Recommending
134
+ // `repair` then is a loop with no exit — repair re-runs the same install, which honours the same
135
+ // plan and skips the hook again — and it contradicted `healthy: true` on every fresh small
136
+ // software install (E2E 2026-10-07). Said as what it is, with nothing to do.
137
+ if (policy?.codeHooks === false) {
138
+ return { state: 'not-in-plan', reason: 'the accepted plan defers code hooks, and the post-merge cleanup is one of them; `rsc reassess` revisits that.' };
139
+ }
121
140
  return { state: 'absent', action: 'Run `npx @ericrisco/rsc repair` to arm the post-merge cleanup in this clone.' };
122
141
  }
123
142
  try {
@@ -128,6 +147,27 @@ function worktreeCleanupStatus(root) {
128
147
  } catch { return { state: 'unreadable' }; }
129
148
  }
130
149
 
150
+ // The two things a team trips over with worktrees (team simulation D9): a `.worktrees/` git does not
151
+ // ignore — one `git add -A` away from committing a whole checkout — and landed worktrees still on
152
+ // disk, which look exactly like live ones. Reported with the way out, never blocking `healthy`: both
153
+ // cost tidiness, not work.
154
+ const realOf = (p) => { try { return realpathSync.native(p); } catch { return p; } };
155
+ function worktreeHygiene(root) {
156
+ if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
157
+ const probe = spawnSync('git', ['-C', root, 'check-ignore', '-q', '--no-index', '.worktrees/rsc-probe/file'], { encoding: 'utf8' });
158
+ const ignored = probe.status === 0;
159
+ let leftovers = [];
160
+ try {
161
+ leftovers = classifyWorktrees(root)
162
+ .filter((c) => c.verdict === 'safe' || c.verdict === 'ask')
163
+ .map((c) => ({ path: relative(realOf(root), c.path).split(sep).join('/') || c.path, branch: c.branch, verdict: c.verdict }));
164
+ } catch { /* unanswerable is not a finding */ }
165
+ const actions = [];
166
+ if (!ignored) actions.push('`.worktrees/` is not ignored by git here: run `npx @ericrisco/rsc sync` (it adds it to the rsc block of .gitignore) and commit .gitignore so every clone gets it.');
167
+ if (leftovers.length) actions.push(`${leftovers.length} landed worktree(s) are still on disk: \`npx @ericrisco/rsc worktrees\` lists them, \`npx @ericrisco/rsc worktrees reap\` removes the safe ones.`);
168
+ return { ignored, leftovers, ...(actions.length ? { action: actions.join(' ') } : {}) };
169
+ }
170
+
131
171
  // Every skill this catalog ships declares `origin: risco` in its own frontmatter — all 272 of them,
132
172
  // uniformly. So "is this ours?" is answerable from the file itself, and does not need the list in
133
173
  // `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
@@ -184,8 +224,8 @@ export function doctor({ target, home, cwd }) {
184
224
  try {
185
225
  memory.metrics = hasStore
186
226
  ? metricsSummary({ cwd: root })
187
- : { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: 0, toolCalls: 0 }, unknown: { cost: 0, toolCalls: 0 } };
188
- } catch { memory.metrics = { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: 0, toolCalls: 0 }, unknown: { cost: 0, toolCalls: 0 } }; }
227
+ : { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: null, toolCalls: null }, unknown: { cost: 0, toolCalls: 0 } };
228
+ } catch { memory.metrics = { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: null, toolCalls: null }, unknown: { cost: 0, toolCalls: 0 } }; }
189
229
  }
190
230
  const actualSkills = listSkills({ target, home, cwd: root }).map((entry) => entry.id);
191
231
  const actualAgents = listAgents({ target, home, cwd: root }).agents.map((entry) => entry.id);
@@ -208,6 +248,9 @@ export function doctor({ target, home, cwd }) {
208
248
  const path = agentPath(target, root, id);
209
249
  return path && !existsSync(path);
210
250
  }).map((id) => ({ id, path: agentPath(target, root, id), action: 'Run `npx @ericrisco/rsc sync` to restore this managed agent.' }));
251
+ // What THIS harness installed, not every skill visible from here: a design skill in the user's
252
+ // global scope is how a todo app's doctor came to recommend design-dna (E2E 2026-10-07).
253
+ const designInPlay = Object.keys(state.skills || {}).some((id) => DESIGN_SKILLS.has(id));
211
254
  const report = {
212
255
  target,
213
256
  installed: Object.keys(state.skills),
@@ -224,17 +267,18 @@ export function doctor({ target, home, cwd }) {
224
267
  // The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
225
268
  // carry a perfectly healthy harness and still never clean up, on every machine but the one that
226
269
  // ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
227
- worktreeCleanup: worktreeCleanupStatus(root),
270
+ worktreeCleanup: worktreeCleanupStatus(root, state.policy),
271
+ worktreeHygiene: worktreeHygiene(root),
228
272
  ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
229
273
  sello: selloStatus(root),
230
274
  // Whether this harness has a design identity at all. `design` has always DECLARED that it
231
275
  // stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
232
276
  // never nagged about and never blocking: a missing identity is low risk (P7).
233
- designIdentity: designIdentity(root),
277
+ designIdentity: withoutUnfoundedFix(designIdentity(root), designInPlay),
234
278
  // The other half of the same question: an identity is what this project settled on, a starting
235
279
  // point is what it has to settle FROM. Three skills promise to propose one and none could look;
236
280
  // this is the look. One field, not a section — a report grows for every user who runs it (P5).
237
- designStartingPoint: startingPointSummary(root),
281
+ designStartingPoint: withoutUnfoundedFix(startingPointSummary(root), designInPlay),
238
282
  // An inert gate must never read as an armed one (the same rule the sello follows):
239
283
  // the gitmoji guard has a per-project kill switch, so say which state it is in.
240
284
  gitmojiGuard: state.policy?.gitmojiGuard === false
@@ -306,9 +350,17 @@ const bytesOf = (p) => { try { return statSync(p).size; } catch { return 0; } };
306
350
 
307
351
  // The always-on body a wired scope injects: the hook command embeds the exact SKILL.md path,
308
352
  // so read it from there rather than guessing the layout.
353
+ // The body the SessionStart hook prints is the `.md` file its command names — today the short
354
+ // `.rsc/suggest-always-on.md`, not the whole `suggest` SKILL.md. Reading only `…SKILL.md` out of the
355
+ // command meant the current wiring never matched and the fallback counted the full skill: doctor said
356
+ // 7.1 KB for a hook that injects 0.3 KB of body (E2E 2026-10-07). The command names the project
357
+ // through ${CLAUDE_PROJECT_DIR}, so that is resolved against the scope before measuring.
309
358
  function alwaysOnBytesFor(scopeRoot, settingsRaw) {
310
- const fromCommand = /"([^"]*suggest[^"]*SKILL\.md)"/.exec(settingsRaw || '');
311
- if (fromCommand && bytesOf(fromCommand[1])) return bytesOf(fromCommand[1]);
359
+ const raw = (settingsRaw || '').replace(/\\\\/g, '/');
360
+ const candidates = [...raw.matchAll(/\\?"([^"\\]*suggest[^"\\]*\.md)\\?"/g)].map((m) => m[1]
361
+ .replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, scopeRoot)
362
+ .replace(/^~(?=\/)/, homedir()));
363
+ for (const file of candidates) if (bytesOf(file)) return bytesOf(file);
312
364
  return bytesOf(join(scopeRoot, '.rsc', 'skills', 'suggest', 'SKILL.md'));
313
365
  }
314
366
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
2
+ import { rmSync, rmdirSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync, renameSync } from 'node:fs';
3
3
  import { join, dirname, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { spawnSync } from 'node:child_process';
@@ -16,6 +16,7 @@ import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
16
16
  import { isRetired, replaceRetired } from './lib/retired-skills.js';
17
17
  import { readManifest, writeManifest } from './lib/manifest-file.js';
18
18
  import { createBackup } from './lib/backups.js';
19
+ import { removeUnmodifiedSkeleton } from './lib/tools-skeleton.js';
19
20
  import {
20
21
  targetHasCommands, resolveCommands, reconcileCommands, commandPath, allPotentialCommandNames,
21
22
  } from '../targets/commands.js';
@@ -81,6 +82,7 @@ export function generatedHookFiles({ target, cwd, policy }) {
81
82
  join(cwd, '.rsc', 'session-start.mjs'),
82
83
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
83
84
  join(cwd, '.rsc', 'hook-once.mjs'),
85
+ join(cwd, '.rsc', 'shell-unwrap.mjs'),
84
86
  join(cwd, '.rsc', 'worktree-reaper.mjs'),
85
87
  join(cwd, '.rsc', 'auto-update.mjs'),
86
88
  // team-safe-default: the 3.0 start-up duties and the trunk policy (also read by branch-guard).
@@ -90,12 +92,14 @@ export function generatedHookFiles({ target, cwd, policy }) {
90
92
  // The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
91
93
  // whether or not the code guards are.
92
94
  const danger = policy?.dangerGuard === false ? [] : [join(cwd, '.rsc', 'danger-guard.mjs')];
93
- if (policy?.codeHooks === false) return [...lifecycle, ...danger, join(cwd, '.rsc', 'suggest-always-on.md')];
95
+ const laneGate = policy?.laneGate ?? policy?.codeHooks !== false;
96
+ const lane = laneGate ? [join(cwd, '.rsc', 'userprompt-gate.mjs'), join(cwd, '.rsc', 'ftd-nudge.mjs')] : [];
97
+ if (policy?.codeHooks === false) return [...lifecycle, ...danger, ...(laneGate ? lane : [join(cwd, '.rsc', 'suggest-always-on.md')])];
94
98
  return [...lifecycle, ...danger,
95
99
  join(cwd, '.rsc', 'ship-guard.mjs'),
96
100
  join(cwd, '.rsc', 'branch-guard.mjs'),
97
101
  join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
98
- join(cwd, '.rsc', 'sello.mjs')];
102
+ join(cwd, '.rsc', 'sello.mjs'), ...(laneGate ? [join(cwd, '.rsc', 'ftd-nudge.mjs')] : [])];
99
103
  }
100
104
 
101
105
  export function managedPathsForInstall({ skillIds, agentIds = [], target, home, cwd, policy }) {
@@ -335,6 +339,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
335
339
  alwaysOn: policy.alwaysOn !== false,
336
340
  codeHooks: policy.codeHooks !== false,
337
341
  dangerGuard: policy.dangerGuard !== false,
342
+ laneGate: policy.laneGate ?? policy.codeHooks !== false,
338
343
  gitmojiGuard: policy.gitmojiGuard !== false,
339
344
  memory: policy.memory !== false,
340
345
  context7: policy.context7 === true,
@@ -402,7 +407,12 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
402
407
  // whole directory would quietly stop versioning their work, which is the same sin this
403
408
  // release exists to fix: treating what is theirs as if it were ours. So shared
404
409
  // directories are excluded entry by entry, and only the entries rsc manages.
405
- const wanted = ['.rsc/'];
410
+ // `.worktrees/` is where the isolation rule sends every concurrent session (`git worktree add
411
+ // .worktrees/<branch>`). Nothing ignored it, so in a teammate's clone it surfaced as `?? .worktrees/`
412
+ // — one `git add -A` from committing a whole checkout (team simulation D9). It goes in THIS block,
413
+ // the committed one, so every clone inherits it with its first pull; `.git/info/exclude` would
414
+ // only ever cover the machine that ran the installer.
415
+ const wanted = ['.rsc/', '.worktrees/'];
406
416
  if (target) {
407
417
  const paths = targetPaths(target, undefined, cwd);
408
418
  const rel = (abs) => relative(cwd, abs).split(sep).join('/');
@@ -697,6 +707,11 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
697
707
  policy: manifest?.onboarding?.plan?.policy,
698
708
  onboarding: manifest?.onboarding,
699
709
  });
710
+ // A teammate never runs onboarding — the harness arrives in the clone — so the exclude onboarding
711
+ // writes for the personal profile never reached them, and their first answer to the one onboarding
712
+ // question sat untracked, one `git add -A` from the team repo (team simulation D13). Sync is what a
713
+ // clone runs, so it writes the same exclude. Lazy import: onboarding-apply imports this module.
714
+ try { (await import('./lib/onboarding-apply.js')).excludePersonalProfile(cwd); } catch { /* not a repo → nothing */ }
700
715
  return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
701
716
  }
702
717
 
@@ -706,13 +721,58 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
706
721
  // own knowledge — kept unless `withDocs` is set. Returns the paths touched.
707
722
  // Note: backups live under `.rsc/backups/`, which this removes — so purge does not
708
723
  // snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
709
- export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
710
- const removed = [];
724
+ export async function purge(options = {}) {
725
+ const report = await purgeReport(options);
726
+ return [...report.removed, ...report.cleaned];
727
+ }
728
+
729
+ // A config file whose only content was rsc's is not "cleaned" when the rsc entries leave it as `{}`:
730
+ // it is a stub nobody asked for, and calling it removed while it sits on disk is the lie the E2E
731
+ // caught. `$schema` and `version` are format boilerplate rsc itself adds, not user content.
732
+ function emptyConfig(file) {
733
+ let value;
734
+ try { value = JSON.parse(readFileSync(file, 'utf8')); } catch { return false; }
735
+ const empty = (v) => (Array.isArray(v) ? v.every(empty)
736
+ : v && typeof v === 'object' ? Object.values(v).every(empty) : false);
737
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
738
+ const rest = Object.fromEntries(Object.entries(value).filter(([k]) => k !== '$schema' && k !== 'version'));
739
+ return empty(rest);
740
+ }
741
+
742
+ const RSC_IGNORE_HEADER = '# rsc local state (hooks, seals, logs) and managed skill links — machine-local';
743
+
744
+ // Remove the blocks `ignoreLocalState` appended: the header line, the entries under it up to the
745
+ // next blank line, and the blank seam before it. Lines the user wrote are never part of a block.
746
+ function stripIgnoreBlocks(text) {
747
+ const lines = text.split('\n');
748
+ const out = [];
749
+ for (let i = 0; i < lines.length; i++) {
750
+ if (lines[i] !== RSC_IGNORE_HEADER) { out.push(lines[i]); continue; }
751
+ if (out.length && out[out.length - 1] === '') out.pop();
752
+ while (i + 1 < lines.length && lines[i + 1].trim() !== '') i++;
753
+ }
754
+ return out.join('\n');
755
+ }
756
+
757
+ /**
758
+ * Remove EVERYTHING rsc put in this project and say exactly what happened to each thing:
759
+ * removed — gone from disk;
760
+ * cleaned — rsc's entries taken out, the file kept because something of the user's remains;
761
+ * kept — left on purpose, with the reason (and the command, when it is the user's call).
762
+ * `02-DOCS/` is the user's own knowledge — kept unless `withDocs` is set. Remote branches are never
763
+ * deleted from here. Backups live under `.rsc/backups/`, which this removes — so purge does not
764
+ * snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
765
+ */
766
+ export async function purgeReport({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
767
+ const removed = new Set();
768
+ const cleaned = new Set();
769
+ const kept = [];
711
770
  const drop = (p, recursive = false) => {
712
- if (!existsSync(p)) return;
713
- removed.push(p);
771
+ if (!existsSync(p) && !isLink(p)) return;
772
+ removed.add(p);
714
773
  if (!dryRun) rmSync(p, { recursive, force: true });
715
774
  };
775
+ const touchedConfigs = new Set();
716
776
  for (const target of TARGET_IDS) {
717
777
  const paths = targetPaths(target, home, cwd);
718
778
  if (existsSync(paths.stateFile)) {
@@ -730,25 +790,168 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
730
790
  }
731
791
  drop(paths.stateFile);
732
792
  }
793
+ const bootstrap = join(paths.projectRoot || cwd, '.claude', 'rsc-bootstrap.mjs');
794
+ const hadBootstrap = target === 'claude' && existsSync(bootstrap);
733
795
  // Unwiring mutates shared config files, so only run it for real (dry runs report
734
796
  // those files without touching them).
735
797
  if (!dryRun) {
736
- removed.push(...unwireHook(target, paths));
737
- removed.push(...unwireMemory(target, cwd));
738
- removed.push(...unwireUpdate(target, cwd));
739
- removed.push(...unwireKnowledge(target, cwd));
740
- removed.push(...unwireGitPermissions(target, cwd));
798
+ for (const p of [
799
+ ...unwireHook(target, paths),
800
+ ...unwireMemory(target, cwd),
801
+ ...unwireUpdate(target, cwd),
802
+ ...unwireKnowledge(target, cwd),
803
+ ...unwireGitPermissions(target, cwd),
804
+ ]) touchedConfigs.add(p);
805
+ if (hadBootstrap && !existsSync(bootstrap)) removed.add(bootstrap);
741
806
  } else {
742
- removed.push(...memoryArtifactsPresent(target, cwd));
743
- removed.push(...updateArtifactsPresent(target, cwd));
744
- removed.push(...knowledgeArtifactsPresent(target, cwd));
807
+ for (const p of [
808
+ ...memoryArtifactsPresent(target, cwd),
809
+ ...updateArtifactsPresent(target, cwd),
810
+ ...knowledgeArtifactsPresent(target, cwd),
811
+ ]) touchedConfigs.add(p);
812
+ if (paths.hookTarget && existsSync(paths.hookTarget)
813
+ && /\.rsc\/|rsc-suggest:start|skills\/rsc\/suggest/.test(readFileSync(paths.hookTarget, 'utf8').replaceAll('\\\\', '/'))) {
814
+ touchedConfigs.add(paths.hookTarget);
815
+ }
816
+ if (hadBootstrap) removed.add(bootstrap);
745
817
  }
746
818
  // A lost state file also loses proof of ownership. Leave same-named user files
747
819
  // behind rather than guessing from a catalog id and deleting their work.
748
820
  }
749
- drop(join(cwd, '.rsc'), true);
821
+ const rscDir = join(cwd, '.rsc');
822
+ for (const p of touchedConfigs) {
823
+ if (p === rscDir || p.startsWith(`${rscDir}${sep}`)) { removed.add(p); continue; }
824
+ if (dryRun) { if (existsSync(p)) cleaned.add(p); continue; }
825
+ if (!existsSync(p)) { removed.add(p); continue; }
826
+ let body = '';
827
+ try { body = readFileSync(p, 'utf8'); } catch { /* unreadable: leave it, call it cleaned */ }
828
+ if (/\.json$/i.test(p) ? emptyConfig(p) : body.trim() === '') {
829
+ rmSync(p, { force: true });
830
+ removed.add(p);
831
+ } else cleaned.add(p);
832
+ }
833
+
834
+ // The `.gitignore` block rsc appended. The file goes only if nothing of the user's is left in it.
835
+ const gitignore = join(cwd, '.gitignore');
836
+ if (existsSync(gitignore)) {
837
+ const text = readFileSync(gitignore, 'utf8');
838
+ if (text.includes(RSC_IGNORE_HEADER)) {
839
+ const rest = stripIgnoreBlocks(text);
840
+ if (rest.trim() === '') { removed.add(gitignore); if (!dryRun) rmSync(gitignore, { force: true }); }
841
+ else { cleaned.add(gitignore); if (!dryRun) writeFileSync(gitignore, rest.endsWith('\n') ? rest : `${rest}\n`); }
842
+ }
843
+ }
844
+
845
+ // The committed declaration of the harness that no longer exists.
846
+ drop(join(cwd, '.rsc.json'));
847
+
848
+ // The `01-TOOLS/` scaffolding onboarding wrote, only where nobody has touched it.
849
+ const skeleton = removeUnmodifiedSkeleton(cwd, { dryRun });
850
+ for (const rel of skeleton.removed) removed.add(join(cwd, rel));
851
+ for (const k of skeleton.kept) kept.push(k);
852
+
853
+ drop(rscDir, true);
750
854
  if (withDocs) drop(join(cwd, '02-DOCS'), true);
751
- return [...new Set(removed)];
855
+ else if (existsSync(join(cwd, '02-DOCS'))) kept.push({ path: '02-DOCS/', reason: 'your knowledge base. Add --with-docs to remove it too.' });
856
+
857
+ // Lines rsc added to `.git/info/exclude` for files that are now gone.
858
+ const exclude = gitExcludeFile(cwd);
859
+ if (exclude && existsSync(exclude)) {
860
+ const gone = new Set([...removed].map((p) => `/${relative(cwd, p).split(sep).join('/')}`));
861
+ const lines = readFileSync(exclude, 'utf8').split('\n');
862
+ const keptLines = lines.filter((l) => !(gone.has(l.trim()) || l.trim().startsWith('/.rsc/')));
863
+ if (keptLines.length !== lines.length) {
864
+ cleaned.add(exclude);
865
+ if (!dryRun) writeFileSync(exclude, keptLines.join('\n'));
866
+ }
867
+ }
868
+
869
+ // Directories that only held what was just removed. Deepest first; never `cwd` itself.
870
+ const parents = new Set();
871
+ for (const p of removed) {
872
+ for (let d = dirname(p); d !== cwd && d.startsWith(`${cwd}${sep}`); d = dirname(d)) parents.add(d);
873
+ }
874
+ const ordered = [...parents].sort((a, b) => b.split(sep).length - a.split(sep).length);
875
+ const willBeEmpty = (dir) => {
876
+ try {
877
+ return readdirSync(dir).every((name) => {
878
+ const child = join(dir, name);
879
+ return removed.has(child);
880
+ });
881
+ } catch { return false; }
882
+ };
883
+ for (const dir of ordered) {
884
+ if (!existsSync(dir) || isLink(dir)) continue;
885
+ if (dryRun ? willBeEmpty(dir) : readdirSync(dir).length === 0) {
886
+ if (!dryRun) rmdirSync(dir);
887
+ removed.add(dir);
888
+ }
889
+ }
890
+
891
+ // The knowledge exchange branch lives on the remote, which is shared: deleting it is the user's
892
+ // call, so say it exists and hand them the command.
893
+ for (const ref of gitLines(cwd, ['for-each-ref', '--format=%(refname)', 'refs/remotes/*/rsc/knowledge', 'refs/heads/rsc/knowledge'])) {
894
+ const remote = /^refs\/remotes\/([^/]+)\/rsc\/knowledge$/.exec(ref)?.[1];
895
+ if (remote) kept.push({ path: `${remote}/rsc/knowledge`, reason: `remote branch; rsc never deletes remote branches. To delete it: git push ${remote} --delete rsc/knowledge` });
896
+ else kept.push({ path: 'rsc/knowledge', reason: 'local branch. To delete it: git branch -D rsc/knowledge' });
897
+ }
898
+
899
+ // The post-merge hook rsc installs (worktree-reaper.mjs). It lives in git's private hooks dir, so it
900
+ // is per clone, never committed — and left behind it kept calling a reaper that no longer exists.
901
+ // Only a hook carrying rsc's marker is touched; one rsc had chained aside is put back in place.
902
+ const [hooks] = gitLines(cwd, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
903
+ const hook = hooks ? join(hooks, 'post-merge') : null;
904
+ if (hook && existsSync(hook)) {
905
+ let body = '';
906
+ try { body = readFileSync(hook, 'utf8'); } catch { /* unreadable → not provably ours */ }
907
+ if (body.includes('# rsc-managed worktree cleanup (post-merge)')) {
908
+ const chained = join(hooks, 'post-merge.rsc-local');
909
+ if (existsSync(chained)) {
910
+ cleaned.add(hook);
911
+ if (!dryRun) { rmSync(hook, { force: true }); renameSync(chained, hook); }
912
+ } else drop(hook);
913
+ }
914
+ }
915
+
916
+ // The project's instruction files are never purge's to delete. Say so, so the report accounts for them.
917
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
918
+ const file = join(cwd, name);
919
+ if (existsSync(file) && !removed.has(file) && !cleaned.has(file)) {
920
+ kept.push({ path: name, reason: 'your project instructions; purge never deletes them. If they still mention rsc or 02-DOCS, edit those lines by hand.' });
921
+ }
922
+ }
923
+
924
+ // A path that is both removed and cleaned was cleaned, then emptied, then removed.
925
+ for (const p of removed) cleaned.delete(p);
926
+ // `.rsc/` is rsc's own directory and goes whole: listing its files one by one adds noise, not truth.
927
+ if (removed.has(rscDir)) for (const p of [...removed]) if (p.startsWith(`${rscDir}${sep}`)) removed.delete(p);
928
+
929
+ // What of the removed or cleaned is COMMITTED. On a teammate's clone that is most of it (.rsc.json,
930
+ // .claude/settings.json, the bootstrap…): changing it here is local, committing the changes
931
+ // uninstalls rsc for everyone who pulls (team simulation D15). The index still has them after the
932
+ // delete, so this reads the same in a dry run and a real one.
933
+ const gone = new Set([...removed, ...cleaned].map((p) => relative(cwd, p).split(sep).join('/')));
934
+ const under = (file) => { for (let d = file; d && d !== '.'; d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') if (gone.has(d)) return true; return false; };
935
+ const listed = spawnSync('git', ['ls-files', '-z'], { cwd, encoding: 'utf8' });
936
+ const tracked = listed.status === 0
937
+ ? listed.stdout.split('\0').filter(Boolean).filter(under) : [];
938
+ return { removed: [...removed], cleaned: [...cleaned], kept, tracked };
939
+ }
940
+
941
+ function isLink(p) {
942
+ try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
943
+ }
944
+
945
+ function gitLines(cwd, args) {
946
+ try {
947
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
948
+ return r.status === 0 ? r.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : [];
949
+ } catch { return []; }
950
+ }
951
+
952
+ function gitExcludeFile(cwd) {
953
+ const [value] = gitLines(cwd, ['rev-parse', '--git-path', 'info/exclude']);
954
+ return value ? resolve(cwd, value) : null;
752
955
  }
753
956
 
754
957
  if (import.meta.url === `file://${process.argv[1]}`) {