@ericrisco/rsc 1.1.6 → 1.2.1

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,137 @@
1
+ import {
2
+ existsSync, readdirSync, readFileSync, statSync, writeFileSync,
3
+ } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { dirname, join } from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { stackAgents } from '../targets/agent-catalog.js';
9
+ import { allAgentNames } from '../targets/agents.js';
10
+ import { resolveCommands } from '../targets/commands.js';
11
+
12
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
13
+ const META_PATH = join(ROOT, 'schema', 'ecc-originality.json');
14
+ const DATA_PATH = join(ROOT, 'schema', 'ecc-originality.u64');
15
+ const NGRAM_SIZE = 8;
16
+ const EXEMPTION_CATEGORIES = new Set(['name', 'error', 'tool-command', 'language-term']);
17
+
18
+ export function normalizeTokens(text) {
19
+ return String(text || '').normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}_+.#/-]+/gu) || [];
20
+ }
21
+
22
+ function fingerprint(tokens, offset, size = NGRAM_SIZE) {
23
+ return createHash('sha256').update(tokens.slice(offset, offset + size).join(' ')).digest('hex').slice(0, 16);
24
+ }
25
+
26
+ function fingerprintsOf(text, size = NGRAM_SIZE) {
27
+ const tokens = normalizeTokens(text);
28
+ const out = [];
29
+ for (let offset = 0; offset <= tokens.length - size; offset += 1) {
30
+ out.push({ fingerprint: fingerprint(tokens, offset, size), offset });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function walk(directory) {
36
+ if (!existsSync(directory)) throw new Error(`originality: corpus directory missing: ${directory}`);
37
+ return readdirSync(directory).sort().flatMap((name) => {
38
+ const path = join(directory, name);
39
+ return statSync(path).isDirectory() ? walk(path) : [path];
40
+ });
41
+ }
42
+
43
+ function catalogDocuments() {
44
+ const skillIds = readdirSync(join(ROOT, 'skills')).filter((id) => {
45
+ try { return statSync(join(ROOT, 'skills', id, 'SKILL.md')).isFile(); } catch { return false; }
46
+ });
47
+ const commands = resolveCommands({
48
+ target: 'cursor', skills: skillIds, agents: allAgentNames(), memoryMode: 'full',
49
+ });
50
+ return [
51
+ ...stackAgents().map((agent) => ({ id: `agent:${agent.name}`, text: `${agent.desc}\n${agent.body}` })),
52
+ ...commands.map((command) => ({ id: `command:${command.name}`, text: `${command.description}\n${command.body}` })),
53
+ ];
54
+ }
55
+
56
+ export function fingerprintCorpus(metaPath = META_PATH, dataPath = DATA_PATH) {
57
+ const metadata = JSON.parse(readFileSync(metaPath, 'utf8'));
58
+ if (metadata.ngramSize !== NGRAM_SIZE) throw new Error(`originality: unsupported n-gram size ${metadata.ngramSize}`);
59
+ for (const exemption of metadata.exemptions || []) {
60
+ if (!EXEMPTION_CATEGORIES.has(exemption.category) || !/^[0-9a-f]{16}$/.test(exemption.fingerprint || '')) {
61
+ throw new Error('originality: every exemption needs a valid fingerprint and closed category');
62
+ }
63
+ }
64
+ const bytes = readFileSync(dataPath);
65
+ if (bytes.length % 8 !== 0 || bytes.length / 8 !== metadata.fingerprintCount) {
66
+ throw new Error('originality: fingerprint corpus length does not match its receipt');
67
+ }
68
+ const digest = createHash('sha256').update(bytes).digest('hex');
69
+ if (digest !== metadata.dataSha256) throw new Error('originality: fingerprint corpus failed its integrity receipt');
70
+ const fingerprints = new Set();
71
+ for (let offset = 0; offset < bytes.length; offset += 8) fingerprints.add(bytes.subarray(offset, offset + 8).toString('hex'));
72
+ return { metadata, fingerprints };
73
+ }
74
+
75
+ export function checkCatalogOriginality({ corpus = fingerprintCorpus(), documents = catalogDocuments() } = {}) {
76
+ const exempt = new Set((corpus.metadata.exemptions || []).map((entry) => entry.fingerprint));
77
+ const matches = [];
78
+ for (const document of documents) {
79
+ for (const candidate of fingerprintsOf(document.text, corpus.metadata.ngramSize)) {
80
+ if (corpus.fingerprints.has(candidate.fingerprint) && !exempt.has(candidate.fingerprint)) {
81
+ matches.push({ document: document.id, tokenOffset: candidate.offset, fingerprint: candidate.fingerprint });
82
+ }
83
+ }
84
+ }
85
+ return {
86
+ ngramSize: corpus.metadata.ngramSize,
87
+ corpusRevision: corpus.metadata.revision,
88
+ documents: documents.length,
89
+ exemptions: exempt.size,
90
+ matches,
91
+ };
92
+ }
93
+
94
+ function refresh(corpusRoot) {
95
+ const files = ['agents', 'commands'].flatMap((directory) => walk(join(corpusRoot, directory)));
96
+ const hashes = new Set();
97
+ for (const path of files) {
98
+ for (const candidate of fingerprintsOf(readFileSync(path, 'utf8'))) hashes.add(candidate.fingerprint);
99
+ }
100
+ const sorted = [...hashes].sort();
101
+ const bytes = Buffer.alloc(sorted.length * 8);
102
+ sorted.forEach((hash, index) => Buffer.from(hash, 'hex').copy(bytes, index * 8));
103
+ writeFileSync(DATA_PATH, bytes);
104
+ const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: corpusRoot, encoding: 'utf8' }).trim();
105
+ const metadata = {
106
+ schemaVersion: 1,
107
+ source: 'https://github.com/affaan-m/everything-claude-code',
108
+ revision,
109
+ generatedAt: new Date().toISOString().slice(0, 10),
110
+ included: ['agents/**', 'commands/**'],
111
+ ngramSize: NGRAM_SIZE,
112
+ normalization: 'NFKC lowercase; Unicode letters/numbers plus _+.#/-; whitespace joined',
113
+ fingerprintFormat: 'first 64 bits of SHA-256, sorted big-endian; collisions fail closed',
114
+ fingerprintCount: sorted.length,
115
+ dataSha256: createHash('sha256').update(bytes).digest('hex'),
116
+ exemptions: [],
117
+ };
118
+ writeFileSync(META_PATH, `${JSON.stringify(metadata, null, 2)}\n`);
119
+ return metadata;
120
+ }
121
+
122
+ function main() {
123
+ if (process.argv[2] === '--refresh') {
124
+ if (!process.argv[3]) throw new Error('Usage: node scripts/originality-check.js --refresh <ecc-checkout>');
125
+ const metadata = refresh(process.argv[3]);
126
+ console.log(`originality corpus refreshed (${metadata.fingerprintCount} fingerprints at ${metadata.revision})`);
127
+ return;
128
+ }
129
+ const result = checkCatalogOriginality();
130
+ if (result.matches.length) {
131
+ for (const match of result.matches) console.error(`${match.document}: shared ${result.ngramSize}-token fingerprint ${match.fingerprint} at token ${match.tokenOffset}`);
132
+ process.exit(1);
133
+ }
134
+ console.log(`originality OK (${result.documents} documents vs ${fingerprintCorpus().metadata.fingerprintCount} corpus fingerprints; ${result.exemptions} exemptions)`);
135
+ }
136
+
137
+ if (import.meta.url === `file://${process.argv[1]}`) main();
package/scripts/rsc.js CHANGED
@@ -4,7 +4,8 @@ import { detectTarget, resolveTargets, TARGETS } from '../targets/index.js';
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, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
7
+ import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
8
+ import { stackAgentNames } from '../targets/agents.js';
8
9
  import { doctor } from './doctor.js';
9
10
  import { say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
10
11
  import { refreshRegistry, registryStatus } from './lib/registry.js';
@@ -20,6 +21,54 @@ const cmd = argv[0];
20
21
 
21
22
  const targetLabel = (id) => TARGETS.find((t) => t.id === id)?.label || id;
22
23
 
24
+ function requestedIds(start = 1) {
25
+ const valueFlags = new Set(['--target', '--profile', '--without']);
26
+ const ids = [];
27
+ for (let i = start; i < argv.length; i++) {
28
+ if (valueFlags.has(argv[i])) { i++; continue; }
29
+ if (argv[i].startsWith('--')) continue;
30
+ ids.push(argv[i]);
31
+ }
32
+ return ids;
33
+ }
34
+
35
+ function editDistance(a, b) {
36
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
37
+ for (let i = 1; i <= a.length; i++) {
38
+ let diagonal = prev[0];
39
+ prev[0] = i;
40
+ for (let j = 1; j <= b.length; j++) {
41
+ const above = prev[j];
42
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
43
+ diagonal = above;
44
+ }
45
+ }
46
+ return prev[b.length];
47
+ }
48
+
49
+ function classifyRequested(ids) {
50
+ const skills = new Set(loadManifest().skills.map((skill) => skill.id));
51
+ const agents = new Set(stackAgentNames());
52
+ const out = { skills: [], agents: [], unknown: [] };
53
+ for (const id of ids) {
54
+ if (skills.has(id)) out.skills.push(id);
55
+ else if (agents.has(id)) out.agents.push(id);
56
+ else out.unknown.push(id);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function reportUnknown(ids) {
62
+ if (!ids.length) return false;
63
+ const candidates = [...loadManifest().skills.map((skill) => skill.id), ...stackAgentNames()];
64
+ for (const id of ids) {
65
+ const close = [...candidates].sort((a, b) => editDistance(id, a) - editDistance(id, b) || a.localeCompare(b)).slice(0, 3);
66
+ console.error(`rsc: unknown skill or agent '${id}'. Closest: ${close.join(', ')}`);
67
+ }
68
+ process.exitCode = 1;
69
+ return true;
70
+ }
71
+
23
72
  // Commands that act ON one assistant. Only these stop when two are installed and no
24
73
  // flag says which: `purge` sweeps every target by design, and the catalog/consult
25
74
  // side barely touches one, so blocking them would be a regression, not a safeguard.
@@ -113,6 +162,8 @@ function printNextSteps(targets, ids) {
113
162
  say('\n Add something by hand anytime: npx @ericrisco/rsc add <skill>');
114
163
  say(' Browse the catalog / get picks: npx @ericrisco/rsc consult "whatever you need"');
115
164
  say('────────────────────────────────────────────────────────');
165
+ if (targets.includes('codex')) say(' Codex: review and trust the project lifecycle hook once with `/hooks`; until then memory is reported as requiring trust.');
166
+ if (targets.includes('cursor')) say(' Cursor: startup memory is assisted because its sessionStart hook is fire-and-forget; the installed always-on rule performs the read-before-action fallback.');
116
167
  printAgentHandoff();
117
168
  }
118
169
 
@@ -268,15 +319,12 @@ async function main() {
268
319
  case undefined:
269
320
  return wizard(f ? targets : null);
270
321
  case 'add': {
271
- // Positional args = skill ids; skip flags and any flag value (the token after a --flag).
272
- const requested = [];
273
- for (let i = 1; i < argv.length; i++) {
274
- if (argv[i].startsWith('--')) { i++; continue; }
275
- requested.push(argv[i]);
276
- }
277
- const ids = withDefaultSkillFloor(requested);
322
+ const requested = requestedIds();
323
+ const selected = classifyRequested(requested);
324
+ if (reportUnknown(selected.unknown)) return;
325
+ const ids = withDefaultSkillFloor(selected.skills);
278
326
  if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
279
- for (const t of targets) await applyInstall({ skillIds: ids, target: t });
327
+ for (const t of targets) await applyInstall({ skillIds: ids, agentIds: selected.agents, target: t });
280
328
  say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
281
329
  return void say(' ↻ Reload/restart your assistant so the new skill activates.');
282
330
  }
@@ -328,14 +376,66 @@ async function main() {
328
376
  else say('\n(no harness wiki here — printed above only; run `harness` to keep a written record)');
329
377
  return;
330
378
  }
331
- case 'list':
332
- return void say(listInstalled({ target }).join('\n') || '(nothing installed)');
379
+ case 'list': {
380
+ const skills = listInstalled({ target }).map((id) => `skill\t${id}`);
381
+ const agents = listInstalledAgents({ target }).map((agent) => `agent\t${agent.id}\t${agent.source}${agent.skills.length ? `:${agent.skills.join(',')}` : ''}`);
382
+ const commands = listInstalledCommands({ target }).map((command) => `command\t${command.id}\t${command.path}`);
383
+ return void say([...skills, ...agents, ...commands].join('\n') || '(nothing installed)');
384
+ }
333
385
  case 'doctor': {
334
386
  const report = doctor({ target });
335
387
  if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
336
388
  printContextBudget(report.contextBudget);
337
389
  return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
338
390
  }
391
+ case 'memory': {
392
+ const sub = argv[1] || 'status';
393
+ const root = process.cwd();
394
+ if (sub === 'on' || sub === 'off') {
395
+ const { readManifest, writeManifest } = await import('./lib/manifest-file.js');
396
+ const current = readManifest(root) || { version: 1, targets: targets || [], skills: [], agents: [], ownSkills: [], optOuts: [] };
397
+ writeManifest(root, { ...current, memory: sub === 'off' ? false : { enabled: true } });
398
+ for (const t of targets) await syncInstalled({ target: t, cwd: root });
399
+ say(`rsc memory ${sub}: ${sub === 'on' ? 'enabled' : 'disabled'} for this project (${targets.join(', ') || 'declaration only'}).`);
400
+ return;
401
+ }
402
+ const M = await import('../targets/session-memory-core.mjs');
403
+ if (sub === 'status') {
404
+ const reports = targets.map((t) => doctor({ target: t, cwd: root }).memory);
405
+ return void say(JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2));
406
+ }
407
+ if (sub === 'resume') {
408
+ const result = M.resume({ cwd: root });
409
+ return void say(argv.includes('--json') ? JSON.stringify(result, null, 2) : (result.context || '(no local continuation for this branch/worktree)'));
410
+ }
411
+ if (sub === 'save') {
412
+ const result = M.capture({
413
+ cwd: root,
414
+ sessionId: typeof flag('session') === 'string' ? flag('session') : `manual-${Date.now()}`,
415
+ target: target || 'manual',
416
+ event: 'save',
417
+ force: true,
418
+ });
419
+ return void say(JSON.stringify(result, null, 2));
420
+ }
421
+ if (sub === 'learn') {
422
+ const value = (name) => { const v = flag(name); return typeof v === 'string' && !v.startsWith('--') ? v : undefined; };
423
+ const result = M.learn({
424
+ cwd: root,
425
+ text: value('text'),
426
+ evidence: value('evidence'),
427
+ scope: value('scope') || 'project',
428
+ confidence: value('confidence'),
429
+ approved: argv.includes('--approve'),
430
+ });
431
+ if (!result.saved) process.exitCode = 1;
432
+ return void say(JSON.stringify(result, null, 2));
433
+ }
434
+ if (sub === 'metrics') return void say(JSON.stringify(M.metricsSummary({ cwd: root }), null, 2));
435
+ say('Use: npx @ericrisco/rsc memory on|off|status|save [--session id]|resume [--json]|learn --text "…" --evidence "…" --scope project|global --confidence 0..1 --approve|metrics');
436
+ process.exitCode = 1;
437
+ return;
438
+ }
339
439
  case 'sync': {
340
440
  const dry = argv.includes('--dry-run');
341
441
  for (const t of targets) {
@@ -387,6 +487,52 @@ async function main() {
387
487
  say('Use: npx @ericrisco/rsc registry refresh | registry status');
388
488
  return;
389
489
  }
490
+ case 'worktrees': {
491
+ // The deterministic half of "the cleanup is the default". `ship` and the `worktrees` skill call
492
+ // this instead of judging by eye, which is what turns a rule that was only ever written down
493
+ // into one that actually runs.
494
+ const W = await import('../targets/worktree-reaper.mjs');
495
+ // From inside a worktree, home is the main checkout — you cannot remove the floor you stand on.
496
+ const root = W.resolveMainRoot(process.cwd());
497
+ const sub = argv[1];
498
+
499
+ if (!W.isCleanupEnabled(root)) {
500
+ say(`worktree cleanup is off for this project (.rsc/${W.OPT_OUT}). Delete that file to turn it back on.`);
501
+ return;
502
+ }
503
+
504
+ const candidates = W.classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
505
+
506
+ if (sub === 'reap') {
507
+ const one = argv[2] && !argv[2].startsWith('--') ? argv[2] : undefined;
508
+ // Naming a path SELECTS it; it does not accept the risk of removing it. Conflating the two
509
+ // meant the strongest refusal in the module — "holds files that are in no commit" — was
510
+ // defeated by an argument the sweep itself told the agent to type.
511
+ const confirmed = argv.includes('--confirm');
512
+ const targets = one ? [one] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
513
+ if (!targets.length) { say('nothing to clean up.'); return; }
514
+ for (const t of targets) {
515
+ const out = W.reapWorktree(root, t, { confirmed });
516
+ if (out.removed) {
517
+ say(`✅ removed ${out.path}`);
518
+ if (out.branchKept) say(` branch ${out.branch} kept — git will not delete it safely, and while it exists the commits are recoverable.`);
519
+ } else {
520
+ say(`⏸ kept ${t} — ${out.reason}`);
521
+ if (one && !confirmed) say(' If that is acceptable and you want it gone anyway: add --confirm');
522
+ process.exitCode = 1;
523
+ }
524
+ }
525
+ return;
526
+ }
527
+
528
+ if (!candidates.length) { say('No worktrees to clean up.'); return; }
529
+ say(`${candidates.length} worktree(s) whose work has landed:`);
530
+ for (const c of candidates) say(W.describe(c));
531
+ say('');
532
+ say('Remove the safe ones: npx @ericrisco/rsc worktrees reap');
533
+ say('Remove one by name: npx @ericrisco/rsc worktrees reap <path> [--confirm]');
534
+ return;
535
+ }
390
536
  case 'capabilities': {
391
537
  // "What do I already have that solves this?" — the deterministic step the
392
538
  // automation-gap rule requires BEFORE anyone proposes creating a skill or an
@@ -446,6 +592,13 @@ async function main() {
446
592
  } else {
447
593
  say(`# ${caps.target} has no file-based agents — the agent option does not apply here`);
448
594
  }
595
+ if (caps.commandsSupported) {
596
+ for (const command of caps.commands) say(`command\t${command.id}\tproject\t${command.path}`);
597
+ if (!caps.commands.length) say('# no managed command files found (this target supports them)');
598
+ } else {
599
+ say(`# ${caps.target} has no supported project command surface`);
600
+ }
601
+ say(`memory\t${caps.memory.mode}\t${caps.memory.status}\t${caps.memory.reason}`);
449
602
  }
450
603
  if (!full) say('# catalog shown as ids; add --full for descriptions, or use `catalog --available` to match by meaning');
451
604
  return;
@@ -623,8 +776,9 @@ async function main() {
623
776
  const dry = argv.includes('--dry-run');
624
777
  // `uninstall --all` is an alias for a full purge.
625
778
  if (argv.includes('--all')) return void (await runPurge(dry, argv.includes('--with-docs')));
626
- const ids = argv.slice(1).filter((a) => !a.startsWith('--'));
627
- const removed = await uninstall({ skillIds: ids, target, dryRun: dry });
779
+ const selected = classifyRequested(requestedIds());
780
+ if (reportUnknown(selected.unknown)) return;
781
+ const removed = await uninstall({ skillIds: selected.skills, agentIds: selected.agents, target, dryRun: dry });
628
782
  return void say((dry ? 'Would remove:\n' : 'Removed:\n') + (removed.join('\n') || '(nothing)'));
629
783
  }
630
784
  case 'repair': {
@@ -652,7 +806,7 @@ async function main() {
652
806
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
653
807
  default:
654
808
  say(`rsc: unknown command '${cmd}'.`);
655
- say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
809
+ say('Use: npx @ericrisco/rsc | 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');
656
810
  say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
657
811
  say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
658
812
  process.exitCode = 1;
@@ -116,12 +116,17 @@ Stacked PR / feature-track support still fits inside the three landing options:
116
116
  git switch main && git pull --ff-only
117
117
  git merge --no-ff feature/<slug> -m "feat: <what shipped> (<spec-slug>)" # no AI trailer
118
118
  git push origin main
119
- git branch -d feature/<slug> # delete merged branch; -D only if intentionally dropping unmerged work
119
+ npx @ericrisco/rsc worktrees reap <path-of-this-feature-worktree> # by name: this one, not all of them
120
+ git branch -d feature/<slug> 2>/dev/null || true # no worktree involved? then the branch alone
120
121
  git push origin --delete feature/<slug> 2>/dev/null || true
121
122
  ```
122
123
 
123
124
  Use `--no-ff` so the feature is one legible merge commit tied to the spec. Confirm the trunk still builds after the merge if the repo has a local gate (defer the actual run to `verify`).
124
125
 
126
+ **Name the path.** Bare `worktrees reap` retires *every* worktree that currently qualifies, which is not what landing one branch means — with `parallel` running two streams, it is how shipping A deletes B. Pass the path of the worktree this branch lived in; capture it before you switch, since you are usually standing in it (`WT=$(pwd)`).
127
+
128
+ Naming a path selects it, it does not accept the risk of removing it: a worktree holding anything unsaved is still refused, with the reason. Add `--confirm` only after the user has seen that reason and said yes. Run it from anywhere — it finds the main checkout itself.
129
+
125
130
  ### Option 2 — pull request
126
131
 
127
132
  Write the **commit(s)** clean, push, then open the PR with `gh`. The PR body links the spec/plan and lists what shipped — **and carries no AI attribution.**
@@ -158,6 +163,8 @@ Implements `02-DOCS/wiki/sdd/specs/<slug>.md`. <the user-facing reason>
158
163
 
159
164
  Then either let the gate run (team/CI) or self-merge once green: `gh pr merge --squash --delete-branch` (or `--merge` to preserve the history). Squash when the branch history is noisy; preserve when each commit is meaningful.
160
165
 
166
+ Once it is merged, pull the trunk and reap the one you landed — `git switch main && git pull --ff-only && npx @ericrisco/rsc worktrees reap "$WT"`. A squashed pull request is exactly the case the reaper judges by content rather than by commit identity, so it is recognised as landed; the local branch is kept, because git will not delete a squashed branch safely and while it exists the work is recoverable.
167
+
161
168
  For stacked PRs, create each PR against the previous branch or against a feature-track branch, with bodies that name their dependency:
162
169
 
163
170
  ```markdown
@@ -172,13 +179,13 @@ Never stack to hide review risk. Stack because each slice is independently revie
172
179
  - **Park:** leave the branch, push it so it's not lost (`git push -u origin feature/<slug>`), and log *why it's parked* to `02-DOCS/wiki/sdd/decisions.md`. Do not merge.
173
180
  - **Discard:** deletion is **destructive and unrecoverable** once the branch is gone from both sides, so it takes an explicit confirmation that quotes the branch name (the literal `yes, delete feature/<slug>`) before `git branch -D`. Anything ambiguous means keep it. Log the discard and the reason so the dead-end is remembered, not re-attempted.
174
181
 
175
- **If the work lived in a worktree, clean it up provenance-aware.** After the merge/park/discard, only
176
- remove a worktree **rsc created** (under `.worktrees/`/`worktrees/` or the `../<repo>-<slug>` dir),
177
- never one the user or a native tool owns. Guard first: confirm it's a linked worktree
178
- (`git rev-parse --git-dir` ≠ `--git-common-dir`), rule out a submodule
179
- (`git rev-parse --show-superproject-working-tree` is empty), `cd` to the main working tree before
180
- removing, and run `git worktree prune` after. Full procedure: `../worktrees/SKILL.md` (Provenance-aware
181
- cleanup). If a native `EnterWorktree`-style tool created it, exit through that tool, not raw git.
182
+ **Park and discard do NOT reap.** The cleanup default acts only on work that is already in the trunk;
183
+ a parked branch is the opposite of that, and `worktrees reap` refuses it by design. Leave the worktree
184
+ where it is — the next `ship` that lands the branch will retire it. Discarding a worktree along with
185
+ unmerged work stays what it always was: explicit, confirmed with the quoted branch name, and logged.
186
+
187
+ If a native `EnterWorktree`-style tool created the workspace, exit through that tool rather than the
188
+ reaper — it owns its own lifecycle and its tracking has to stay consistent.
182
189
 
183
190
  ## Commit message discipline
184
191
 
@@ -109,39 +109,47 @@ git switch -c feat/<slug> origin/<default-branch>
109
109
  ```
110
110
 
111
111
  ```bash
112
- # when the work is done and merged (or abandoned) — clean up the worktree
113
- git worktree remove ../<repo>-<slug> # refuses if dirty; resolve first, don't force blindly
114
- git branch -d feat/<slug> # -d (safe) not -D, unless the user confirms discarding
112
+ # when the work has landed — the cleanup is the default, and it is a command, not a judgement call
113
+ npx @ericrisco/rsc worktrees reap
115
114
  ```
116
115
 
117
116
  Either way, the contract is identical: **after this step, the cwd is an isolated branch off a clean
118
117
  base, and the default-branch checkout is exactly as it was.** Confirm that out loud (at the dial's
119
118
  level) before handing to `implement`.
120
119
 
121
- ### Provenance-aware cleanup (don't remove a workspace you didn't create)
120
+ ### Cleanup is the default, and it is not yours to judge
122
121
 
123
- Removing the wrong worktree leaves phantom state and can destroy someone else's in-progress work.
124
- Before any `git worktree remove`, clear these guards:
122
+ A worktree whose work is already in the trunk is retired **by default** — you do not decide that by
123
+ reading the situation, because "is this safe to delete?" is a deterministic question and a wrong
124
+ answer costs a directory that cannot be brought back. Ask the command:
125
125
 
126
126
  ```bash
127
- # 1) Are we even inside a linked worktree? (GIT_DIR != GIT_COMMON_DIR ⇒ yes)
128
- [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ] && echo "linked worktree"
129
- # 2) Submodule false-positive guard — never treat a submodule as a worktree to remove
130
- git rev-parse --show-superproject-working-tree # non-empty ⇒ this is a submodule; STOP
131
- # 3) cd to the MAIN working tree before removing (you cannot remove the worktree you stand in)
132
- cd "$(git rev-parse --git-common-dir)/.."
133
- git worktree remove <path>
134
- git worktree prune # self-heal stale administrative entries
127
+ npx @ericrisco/rsc worktrees # every landed worktree, with a verdict and a reason
128
+ npx @ericrisco/rsc worktrees reap # retire the ones that are safe with nothing to lose
129
+ npx @ericrisco/rsc worktrees reap <path> # retire one the user has just confirmed
135
130
  ```
136
131
 
137
- - **Only remove worktrees rsc/you created** — those under `.worktrees/` or `worktrees/`, or the
138
- `../<repo>-<slug>` this skill made. A worktree the user or a native tool owns is **not yours to
139
- delete**; leave it and say so.
140
- - **Native tool owns its own lifecycle.** If you isolated via a native `EnterWorktree`-style tool,
141
- exit through that tool's `remove`/`keep` — do **not** hand-run `git worktree remove` on a workspace
142
- the native tool created; let it clean up so its tracking stays consistent.
143
- - **`git worktree prune`** after a remove clears stale metadata when a directory vanished out from
144
- under git — cheap self-healing, safe to run.
132
+ Three things have to hold before a worktree is retired without asking, and each fails towards
133
+ **keeping** it:
134
+
135
+ - **rsc created it** — location *and* branch shape both match. Only one matches → it might be the
136
+ user's, so it is asked about, never removed silently. Neither → foreign; not touched, not offered.
137
+ - **The branch adds nothing the trunk does not have** — asked about *content*, so a squash-merged
138
+ pull request counts as landed. Its branch is kept even so: git will not delete it safely, and while
139
+ it exists the commits survive a wrong call.
140
+ - **Nothing inside would be lost** — not just "no pending changes": untracked and ignored files count,
141
+ because a worktree that git calls clean can still hold the only copy of a `.env` or a local
142
+ database. Installed dependencies and build output are the one carve-out.
143
+
144
+ Anything else is reported with the reason and the way out. A refusal you cannot act on is not safety.
145
+
146
+ **The native tool owns its own lifecycle.** If you isolated via a native `EnterWorktree`-style tool,
147
+ exit through that tool's `remove`/`keep`; the reaper is for worktrees git alone knows about.
148
+
149
+ **When the command is unavailable** (no rsc in this project), the manual equivalent is: confirm it is
150
+ a linked worktree (`git rev-parse --git-dir` ≠ `--git-common-dir`), rule out a submodule
151
+ (`git rev-parse --show-superproject-working-tree` must be empty), `cd` to the main working tree, then
152
+ `git worktree remove` and `git worktree prune`. Never `-D` a branch to make the cleanup succeed.
145
153
 
146
154
  ## Model tier — `light` (opt-in routing)
147
155