@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.
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
@@ -372,7 +372,11 @@ That verdict is not just about the receipt: it also requires the harness floor t
372
372
  `01-TOOLS/_TEMPLATE/`, `02-DOCS/wiki/harness/`, and the constitution when the plan selects SDD.
373
373
  If the floor is missing, the install applied but prints `RSC_ONBOARDING_INCOMPLETE` with each
374
374
  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.
375
+ report success with three markdown files. When the plan selects SDD, onboarding writes the
376
+ constitution itself as a draft (`status: draft`, only the facts it knows, no principles), so a
377
+ non-interactive install still ends `RSC_ONBOARDING_READY`; the draft is listed as pending — under
378
+ READY and in `rsc doctor` — until the `constitution` phase completes it. An existing constitution is
379
+ never overwritten.
376
380
 
377
381
  ---
378
382
 
@@ -471,8 +475,10 @@ list somebody has to remember to update is a list that goes quietly stale. Decla
471
475
 
472
476
  ### Your wiki and tools stay in sync — on by default
473
477
 
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
478
+ `01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` are the team's knowledge, so rsc keeps them
479
+ the same on every machine without anybody thinking about git, **and without ever pushing to `main`**.
480
+ Exactly those three folders, minus rsc's own `01-TOOLS/_TEMPLATE/` and your personal
481
+ `02-DOCS/wiki/harness/user-profile.md`. Your `main` is
476
482
  protected, or should be; rsc assumes it is.
477
483
 
478
484
  The knowledge travels through one exchange branch, `rsc/knowledge`, that nobody works on by hand:
@@ -484,13 +490,22 @@ main · protected untouched — gets it inside the next merged pull reque
484
490
  new · clones main has it all after the first message
485
491
  ```
486
492
 
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.
493
+ - **When a turn ends**, your changes in those folders are committed on your branch as
494
+ `📝 docs(auto): …` and sent to `rsc/knowledge` on `origin`, from whatever branch you are on. Only
495
+ the copy on `rsc/knowledge` carries `[skip ci]`; the commit on your branch does not, so your next
496
+ ordinary push still runs CI. Two cases commit nothing on your branch: a closed `main`, and code of
497
+ yours still uncommitted (a docs commit would land ahead of the code it describes). Then the snapshot
498
+ is built aside, only goes to `rsc/knowledge`, and the files stay modified for you to commit with
499
+ your work. Knowledge that you or the agent already **committed** on the branch goes up too — only
500
+ its knowledge part, under a `docs(auto) … [skip ci]` message. Commits that came in by merging
501
+ `main` are your teammates', not yours, and are not sent again.
502
+ - **Before each message**, what teammates sent is brought into **the branch you are on** as a
503
+ `📥 docs(auto): sync` commit, and you are told in one line. Only the knowledge folders are touched.
504
+ A doc that `main` on `origin` already has, identical, is left for your ordinary merge of `main`. On
505
+ a closed `main` nothing is written at all — an uncommitted file there would make your next
506
+ `git pull` refuse to run — you are told what is waiting, and it arrives with that pull or in the
507
+ next branch you open. When the last fetch is more than ten minutes old, the message fetches first
508
+ (three seconds at most), so even a one-message session sees fresh docs.
494
509
  A branch you open later catches up on its first message: everything on `rsc/knowledge` since it
495
510
  left `main` comes down, including what you wrote yourself on another branch.
496
511
  - **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
@@ -505,7 +520,7 @@ What it will not do, by design:
505
520
  | --- | --- |
506
521
  | Push to `main` | Never. Only `rsc/knowledge` receives anything. |
507
522
  | 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]`. |
523
+ | 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
524
  | Bring in anybody else's code | Only knowledge paths come down. Code, config and `.claude/` are never pulled. |
510
525
  | Sync `02-DOCS/wiki/harness/user-profile.md` | Those are one person's dials. |
511
526
  | 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.9",
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.9",
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';
@@ -19,6 +20,7 @@ import { metricsSummary } from '../targets/session-memory-core.mjs';
19
20
  import { agentPath } from '../targets/agents.js';
20
21
  import { projectOptOuts } from '../targets/opt-outs.js';
21
22
  import { versionReport } from './lib/versions.js';
23
+ import { CONSTITUTION_PATH, constitutionIsDraft } from './lib/constitution-draft.js';
22
24
 
23
25
  // Gates the committed manifest says the team disarmed, still armed here. Read through the same
24
26
  // partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
@@ -101,7 +103,18 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
101
103
  // Is the post-merge trigger actually armed in this clone? Three states, because they need three
102
104
  // different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
103
105
  // at install time, and overwriting a husky hook to fix a convenience would be indefensible).
104
- function worktreeCleanupStatus(root) {
106
+ // The skills that read or write a design identity (02-DOCS/wiki/brand/) or a starting point. The
107
+ // state is always reported; the FIX — "run design-loop, then design-dna" — is advice, and advice needs
108
+ // evidence. A todo web app with none of these installed was told to run two skills it does not have
109
+ // and never asked for (E2E 2026-10-07).
110
+ const DESIGN_SKILLS = new Set(['design', 'design-loop', 'design-dna', 'brand-voice', 'content-engine', 'marketing', 'presentations', 'make']);
111
+ function withoutUnfoundedFix(summary, inPlay) {
112
+ if (inPlay || !summary || !summary.fix) return summary;
113
+ const { fix, ...rest } = summary;
114
+ return { ...rest, note: 'no design skill is installed here, so nothing is recommended' };
115
+ }
116
+
117
+ function worktreeCleanupStatus(root, policy) {
105
118
  if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
106
119
  if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
107
120
  // Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
@@ -118,6 +131,13 @@ function worktreeCleanupStatus(root) {
118
131
  }
119
132
  const hook = join(dir, 'post-merge');
120
133
  if (!existsSync(hook)) {
134
+ // The merge hook is one of the code hooks, and the accepted plan can defer those. Recommending
135
+ // `repair` then is a loop with no exit — repair re-runs the same install, which honours the same
136
+ // plan and skips the hook again — and it contradicted `healthy: true` on every fresh small
137
+ // software install (E2E 2026-10-07). Said as what it is, with nothing to do.
138
+ if (policy?.codeHooks === false) {
139
+ 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.' };
140
+ }
121
141
  return { state: 'absent', action: 'Run `npx @ericrisco/rsc repair` to arm the post-merge cleanup in this clone.' };
122
142
  }
123
143
  try {
@@ -128,6 +148,27 @@ function worktreeCleanupStatus(root) {
128
148
  } catch { return { state: 'unreadable' }; }
129
149
  }
130
150
 
151
+ // The two things a team trips over with worktrees (team simulation D9): a `.worktrees/` git does not
152
+ // ignore — one `git add -A` away from committing a whole checkout — and landed worktrees still on
153
+ // disk, which look exactly like live ones. Reported with the way out, never blocking `healthy`: both
154
+ // cost tidiness, not work.
155
+ const realOf = (p) => { try { return realpathSync.native(p); } catch { return p; } };
156
+ function worktreeHygiene(root) {
157
+ if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
158
+ const probe = spawnSync('git', ['-C', root, 'check-ignore', '-q', '--no-index', '.worktrees/rsc-probe/file'], { encoding: 'utf8' });
159
+ const ignored = probe.status === 0;
160
+ let leftovers = [];
161
+ try {
162
+ leftovers = classifyWorktrees(root)
163
+ .filter((c) => c.verdict === 'safe' || c.verdict === 'ask')
164
+ .map((c) => ({ path: relative(realOf(root), c.path).split(sep).join('/') || c.path, branch: c.branch, verdict: c.verdict }));
165
+ } catch { /* unanswerable is not a finding */ }
166
+ const actions = [];
167
+ 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.');
168
+ 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.`);
169
+ return { ignored, leftovers, ...(actions.length ? { action: actions.join(' ') } : {}) };
170
+ }
171
+
131
172
  // Every skill this catalog ships declares `origin: risco` in its own frontmatter — all 272 of them,
132
173
  // uniformly. So "is this ours?" is answerable from the file itself, and does not need the list in
133
174
  // `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
@@ -184,8 +225,8 @@ export function doctor({ target, home, cwd }) {
184
225
  try {
185
226
  memory.metrics = hasStore
186
227
  ? 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 } }; }
228
+ : { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: null, toolCalls: null }, unknown: { cost: 0, toolCalls: 0 } };
229
+ } catch { memory.metrics = { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: null, toolCalls: null }, unknown: { cost: 0, toolCalls: 0 } }; }
189
230
  }
190
231
  const actualSkills = listSkills({ target, home, cwd: root }).map((entry) => entry.id);
191
232
  const actualAgents = listAgents({ target, home, cwd: root }).agents.map((entry) => entry.id);
@@ -208,6 +249,9 @@ export function doctor({ target, home, cwd }) {
208
249
  const path = agentPath(target, root, id);
209
250
  return path && !existsSync(path);
210
251
  }).map((id) => ({ id, path: agentPath(target, root, id), action: 'Run `npx @ericrisco/rsc sync` to restore this managed agent.' }));
252
+ // What THIS harness installed, not every skill visible from here: a design skill in the user's
253
+ // global scope is how a todo app's doctor came to recommend design-dna (E2E 2026-10-07).
254
+ const designInPlay = Object.keys(state.skills || {}).some((id) => DESIGN_SKILLS.has(id));
211
255
  const report = {
212
256
  target,
213
257
  installed: Object.keys(state.skills),
@@ -224,17 +268,18 @@ export function doctor({ target, home, cwd }) {
224
268
  // The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
225
269
  // carry a perfectly healthy harness and still never clean up, on every machine but the one that
226
270
  // ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
227
- worktreeCleanup: worktreeCleanupStatus(root),
271
+ worktreeCleanup: worktreeCleanupStatus(root, state.policy),
272
+ worktreeHygiene: worktreeHygiene(root),
228
273
  ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
229
274
  sello: selloStatus(root),
230
275
  // Whether this harness has a design identity at all. `design` has always DECLARED that it
231
276
  // stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
232
277
  // never nagged about and never blocking: a missing identity is low risk (P7).
233
- designIdentity: designIdentity(root),
278
+ designIdentity: withoutUnfoundedFix(designIdentity(root), designInPlay),
234
279
  // The other half of the same question: an identity is what this project settled on, a starting
235
280
  // point is what it has to settle FROM. Three skills promise to propose one and none could look;
236
281
  // this is the look. One field, not a section — a report grows for every user who runs it (P5).
237
- designStartingPoint: startingPointSummary(root),
282
+ designStartingPoint: withoutUnfoundedFix(startingPointSummary(root), designInPlay),
238
283
  // An inert gate must never read as an armed one (the same rule the sello follows):
239
284
  // the gitmoji guard has a per-project kill switch, so say which state it is in.
240
285
  gitmojiGuard: state.policy?.gitmojiGuard === false
@@ -266,6 +311,11 @@ export function doctor({ target, home, cwd }) {
266
311
  for (const [id, e] of Object.entries(state.skills)) {
267
312
  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.' });
268
313
  }
314
+ // Pending is not unhealthy: work the harness expects the user to finish, named with what does it.
315
+ // The draft constitution `rsc onboard` writes is the first entry; it never touches `healthy`.
316
+ report.pending = constitutionIsDraft(root)
317
+ ? [{ id: 'constitution-draft', path: CONSTITUTION_PATH, action: 'Complete it with the `constitution` phase before the first SDD feature; ratifying removes `status: draft`.' }]
318
+ : [];
269
319
  report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
270
320
  && !report.opencodePluginsOutdated.length;
271
321
  return report;
@@ -306,9 +356,17 @@ const bytesOf = (p) => { try { return statSync(p).size; } catch { return 0; } };
306
356
 
307
357
  // The always-on body a wired scope injects: the hook command embeds the exact SKILL.md path,
308
358
  // so read it from there rather than guessing the layout.
359
+ // The body the SessionStart hook prints is the `.md` file its command names — today the short
360
+ // `.rsc/suggest-always-on.md`, not the whole `suggest` SKILL.md. Reading only `…SKILL.md` out of the
361
+ // command meant the current wiring never matched and the fallback counted the full skill: doctor said
362
+ // 7.1 KB for a hook that injects 0.3 KB of body (E2E 2026-10-07). The command names the project
363
+ // through ${CLAUDE_PROJECT_DIR}, so that is resolved against the scope before measuring.
309
364
  function alwaysOnBytesFor(scopeRoot, settingsRaw) {
310
- const fromCommand = /"([^"]*suggest[^"]*SKILL\.md)"/.exec(settingsRaw || '');
311
- if (fromCommand && bytesOf(fromCommand[1])) return bytesOf(fromCommand[1]);
365
+ const raw = (settingsRaw || '').replace(/\\\\/g, '/');
366
+ const candidates = [...raw.matchAll(/\\?"([^"\\]*suggest[^"\\]*\.md)\\?"/g)].map((m) => m[1]
367
+ .replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, scopeRoot)
368
+ .replace(/^~(?=\/)/, homedir()));
369
+ for (const file of candidates) if (bytesOf(file)) return bytesOf(file);
312
370
  return bytesOf(join(scopeRoot, '.rsc', 'skills', 'suggest', 'SKILL.md'));
313
371
  }
314
372
 
@@ -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]}`) {