@ericrisco/rsc 1.2.0 → 1.2.2

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
@@ -403,6 +403,8 @@ Take a fuzzy intent to a shipped, verified change — phase by phase. `npx @eric
403
403
 
404
404
  [sdd](skills/sdd/) · [constitution](skills/constitution/) · [idea-refinement](skills/idea-refinement/) · [specify](skills/specify/) · [clarify](skills/clarify/) · [plan](skills/plan/) · [tasks](skills/tasks/) · [analyze](skills/analyze/) · [decision-challenge](skills/decision-challenge/) · [implement](skills/implement/) · [source-grounded-development](skills/source-grounded-development/) · [verify](skills/verify/) · [review](skills/review/) · [simplify-code](skills/simplify-code/) · [ship](skills/ship/) · [debug](skills/debug/) · [worktrees](skills/worktrees/) · [parallel](skills/parallel/)
405
405
 
406
+ > Two of those are not phases the chain walks on its own. `idea-refinement` **is** invoked — `specify` runs its FRAME block before the first question round. `decision-challenge` is **on-demand**: it exists, it is good, and no phase calls it yet. Listed so you can reach for it, not because the chain will. And the limit of what FRAME buys you, stated rather than implied: a second reading by the same model breaks correlation of **framing**, not of model — it shares the priors it is checking.
407
+
406
408
  ### 💼 Run a business
407
409
 
408
410
  [finance-ops](skills/finance-ops/) · [invoicing](skills/invoicing/) · [bookkeeping](skills/bookkeeping/) · [pricing](skills/pricing/) · [sales-pipeline](skills/sales-pipeline/) · [lead-gen](skills/lead-gen/) · [cold-outreach](skills/cold-outreach/) · [proposals](skills/proposals/) · [contracts](skills/contracts/) · [customer-support](skills/customer-support/) · [client-onboarding](skills/client-onboarding/) · [retention](skills/retention/) · [hiring](skills/hiring/) · [people-ops](skills/people-ops/) · [inventory](skills/inventory/) · [logistics-ops](skills/logistics-ops/) · [procurement](skills/procurement/) · [meeting-notes](skills/meeting-notes/) · [sop-builder](skills/sop-builder/) · [project-ops](skills/project-ops/)
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.0",
2
+ "version": "1.2.2",
3
3
  "counts": {
4
4
  "skills": 272,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,15 @@
9
9
  // the problem `specify` exists to solve, not something a parser can do, and a gate that implies
10
10
  // otherwise is worse than one that admits its edge.
11
11
 
12
+ import { parseFrontmatter } from './frontmatter.js';
13
+
14
+ // The two families below are NOT retroactive. The corpus is 34 specs written before this rule
15
+ // existed, and none of them can answer "what if we don't build it" honestly after the fact; a gate
16
+ // that turns 34 files red gets switched off within a week. So the requirement starts on a date, and
17
+ // the date is derived from the file's own `timestamp:` — not from a list of exempted specs, which
18
+ // would be exactly the parallel accounting P3 forbids.
19
+ export const NEW_FAMILIES_SINCE = '2026-09-06';
20
+
12
21
  // The four states a point can be in. Order is the table's order in the skill body.
13
22
  export const OPEN_POINT_TYPES = [
14
23
  'pregunta abierta', // formulable now, unanswered → clarify asks it
@@ -21,13 +30,29 @@ export const OPEN_POINT_TYPES = [
21
30
  // ("Objetivos (resultado)", "Criterios de aceptación (binarios)"). Matching literal strings would
22
31
  // be useless, so each family is a set of normalised prefixes.
23
32
  const FAMILIES = {
24
- problem: ['problema & por que', 'problema y por que', 'problem & why'],
25
- goals: ['objetivos', 'goals'],
26
- nongoals: ['no-objetivos', 'no objetivos', 'non-goals'],
27
- users: ['usuarios & contexto', 'usuarios y contexto', 'users & context'],
28
- behaviour: ['comportamiento', 'behaviour', 'behavior'],
29
- acceptance: ['criterios de aceptacion', 'acceptance criteria'],
30
- open: ['puntos a clarificar', 'puntos de clarify', 'puntos que siguen abiertos', 'points to clarify'],
33
+ problem: { prefixes: ['problema & por que', 'problema y por que', 'problem & why'] },
34
+ goals: { prefixes: ['objetivos', 'goals'] },
35
+ nongoals: { prefixes: ['no-objetivos', 'no objetivos', 'non-goals'] },
36
+ users: { prefixes: ['usuarios & contexto', 'usuarios y contexto', 'users & context'] },
37
+ behaviour: { prefixes: ['comportamiento', 'behaviour', 'behavior'] },
38
+ acceptance: { prefixes: ['criterios de aceptacion', 'acceptance criteria'] },
39
+ open: {
40
+ prefixes: ['puntos a clarificar', 'puntos de clarify', 'puntos que siguen abiertos', 'points to clarify'],
41
+ },
42
+ // A spec that cannot say what happens if nobody builds it has not been interrogated, only
43
+ // de-risked. `since` is what keeps that demand off the specs written before it existed.
44
+ inaction: {
45
+ prefixes: [
46
+ 'que pasa si no lo construimos', '¿que pasa si no lo construimos',
47
+ 'coste de no construirlo', 'el coste de no construirlo',
48
+ 'cost of not building', 'the cost of not building',
49
+ ],
50
+ since: NEW_FAMILIES_SINCE,
51
+ },
52
+ cheapest: {
53
+ prefixes: ['la alternativa mas barata', 'alternativa mas barata', 'cheapest alternative', 'the cheapest alternative'],
54
+ since: NEW_FAMILIES_SINCE,
55
+ },
31
56
  };
32
57
 
33
58
  const UNCHECKED = [
@@ -38,6 +63,9 @@ const UNCHECKED = [
38
63
  // narrower than the rule it appears to enforce is the decorative gate all over again.
39
64
  'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
40
65
  'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
66
+ // Sin esta línea, el verde sería más estrecho que la regla que aparenta imponer — que es la
67
+ // definición de puerta decorativa, sólo que por omisión en vez de por ausencia.
68
+ `las specs anteriores a ${NEW_FAMILIES_SINCE} — quedan exentas de "qué pasa si no lo construimos" y de "la alternativa más barata", que no pueden rellenarse a posteriori`,
41
69
  ];
42
70
 
43
71
  function fold(s) {
@@ -50,7 +78,7 @@ function fold(s) {
50
78
 
51
79
  function familyOf(heading) {
52
80
  const h = fold(heading);
53
- for (const [family, prefixes] of Object.entries(FAMILIES)) {
81
+ for (const [family, { prefixes }] of Object.entries(FAMILIES)) {
54
82
  if (prefixes.some((p) => h.startsWith(p))) return family;
55
83
  }
56
84
  return null;
@@ -67,6 +95,58 @@ function isSubstantive(line) {
67
95
  return true;
68
96
  }
69
97
 
98
+ // The template's guidance does not fit on one line, and a section still holding it is a section
99
+ // nobody filled in. The single-line check above never saw those: `<What concretely happens if` does
100
+ // not end in `>`, so it read as content and an untouched template passed the gate green.
101
+ //
102
+ // Only a block that actually CLOSES counts as guidance. A stray `<` that never finds its `>` — a
103
+ // latency budget written `<400ms`, a generic in a code sample — stays content, because guessing
104
+ // wrong in that direction hides real prose.
105
+ function guidanceLines(lines) {
106
+ const skip = new Set();
107
+ for (let i = 0; i < lines.length; i += 1) {
108
+ const t = lines[i].trim();
109
+ if (!t.startsWith('<') || t.endsWith('>')) continue;
110
+ for (let j = i + 1; j < lines.length; j += 1) {
111
+ const u = lines[j].trim();
112
+ if (u === '' || u.startsWith('#')) break; // the block ended without closing: not guidance
113
+ if (u.endsWith('>')) {
114
+ for (let k = i; k <= j; k += 1) skip.add(k);
115
+ i = j;
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ return skip;
121
+ }
122
+
123
+ function hasSubstance(lines) {
124
+ const guidance = guidanceLines(lines);
125
+ return lines.some((line, i) => !guidance.has(i) && isSubstantive(line));
126
+ }
127
+
128
+ // The day this spec was written, as YYYY-MM-DD, or null when there is nothing trustworthy to read.
129
+ // Null always means EXEMPT: a spec we cannot date is not a spec we get to fail. Three ways to land
130
+ // there, and all three are safe — the CLI itself calls specCompleteness('') to print `unchecked`,
131
+ // and parseFrontmatter throws on that.
132
+ function specDay(markdown) {
133
+ try {
134
+ const raw = parseFrontmatter(markdown).timestamp;
135
+ if (typeof raw !== 'string') return null;
136
+ const day = raw.slice(0, 10); // ISO order is lexicographic order; no timezone maths needed
137
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function requiredFamilies(markdown) {
144
+ const day = specDay(markdown);
145
+ return Object.entries(FAMILIES)
146
+ .filter(([, { since }]) => !since || (day !== null && day >= since))
147
+ .map(([family]) => family);
148
+ }
149
+
70
150
  function parseOpenPoints(lines) {
71
151
  const points = [];
72
152
  for (const line of lines) {
@@ -108,9 +188,9 @@ export function specCompleteness(markdown) {
108
188
  if (current && sections.has(current)) sections.get(current).push(line);
109
189
  }
110
190
 
111
- const families = Object.keys(FAMILIES);
191
+ const families = requiredFamilies(markdown);
112
192
  const missing = families.filter((f) => !sections.has(f));
113
- const empty = families.filter((f) => sections.has(f) && !sections.get(f).some(isSubstantive));
193
+ const empty = families.filter((f) => sections.has(f) && !hasSubstance(sections.get(f)));
114
194
 
115
195
  const openPoints = sections.has('open') ? parseOpenPoints(sections.get('open')) : [];
116
196
  const untyped = openPoints.filter((p) => !p.typed).map((p) => p.text);
package/scripts/rsc.js CHANGED
@@ -487,6 +487,52 @@ async function main() {
487
487
  say('Use: npx @ericrisco/rsc registry refresh | registry status');
488
488
  return;
489
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
+ }
490
536
  case 'capabilities': {
491
537
  // "What do I already have that solves this?" — the deterministic step the
492
538
  // automation-gap rule requires BEFORE anyone proposes creating a skill or an
@@ -760,7 +806,7 @@ async function main() {
760
806
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
761
807
  default:
762
808
  say(`rsc: unknown command '${cmd}'.`);
763
- 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|…> | 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');
764
810
  say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
765
811
  say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
766
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
 
@@ -22,6 +22,21 @@ Fire on the **faintest** sign the user is thinking about a new feature or change
22
22
 
23
23
  You are not slowing them down; you make the intent reviewable *before* code exists, which is far cheaper than discovering the misunderstanding in a PR. End every spec by handing to `clarify`/`plan` — never to `implement`.
24
24
 
25
+ ### Your first sentence is not a verdict on the idea
26
+
27
+ Catching the intent eagerly, above, is right. What comes *after* catching it is where this phase has been failing: the opening line validates. "Great idea", "makes sense", "that would be really useful" — or, since half this catalog's work happens in Spanish, "buena idea", "qué gran idea", "me encanta", "tiene todo el sentido" — is a verdict you do not have the evidence for, delivered at the cheapest possible moment to be wrong, to someone who still believes you. The ban is on the **judgement**, in any language, not on any particular wording: the list above is what it looks like, not what it is limited to. A spec built on a validated premise is coherent, plannable, testable and pointed at the wrong thing; nothing downstream can catch that, because every later gate compares artifacts to each other and they all agree.
28
+
29
+ So the first response to an intent carries exactly two things:
30
+
31
+ 1. **A restatement** of what you understood, in one sentence, so a misread surfaces now.
32
+ 2. **The strongest objection you can actually articulate** from what you already know or can cheaply check — why this might not be worth building, or might be the wrong shape.
33
+
34
+ Never a judgement of the idea's quality, in either direction. And go look before you object: an objection sourced from the repo, the wiki or a prior spec is worth ten sourced from your priors.
35
+
36
+ If, having looked, there is no real objection, **say so in one line and move on — silence is a valid result**. Manufacturing an objection to look rigorous is the same failure wearing the opposite coat, and a doubt that fires on every idea measures you, not the idea. A typo fix does not get a restatement and an objection; it gets fixed.
37
+
38
+ **And the objection never blocks.** It is a divergence, not a veto: the user often holds context you do not — a business reason, a conversation elsewhere, a constraint nobody wrote down. If they choose to proceed anyway, do not re-argue. **Write the reason into the spec** and continue. That is what keeps this from being decorative without giving it power it should not have: it cannot stop the decision, it can only stop the decision from being tacit. If the thing later goes wrong, the record says the doubt was raised and overridden — not that nobody looked.
39
+
25
40
  **Offer autopilot once, right here.** At this boundary, propose how to run the rest of the chain:
26
41
  > *"¿Quieres que lo lleve hasta el final yo solo — spec → plan → código → verify, parando solo si algo es ambiguo — o prefieres que pare a que apruebes en cada fase?"*
27
42
 
@@ -50,6 +65,22 @@ Before asking anything, read `02-DOCS/wiki/harness/user-profile.md` for the tech
50
65
 
51
66
  If no profile exists, default to non-technical framing and keep questions plain. Never assume fluency.
52
67
 
68
+ ## FRAME before you ask anything — from `idea-refinement`
69
+
70
+ Before the frontier round, write five lines. They are the FRAME block of `../idea-refinement/SKILL.md`, a skill this catalog ships and the README advertises, and that until now no phase invoked:
71
+
72
+ - **Actor** — who is struggling, and in what situation?
73
+ - **Current workaround** — what do they do today, *including doing nothing*?
74
+ - **Desired progress** — what observable change would make this useful?
75
+ - **Constraints** — time, money, trust, regulation, the product that already exists.
76
+ - **Known vs assumed** — separate what you have actually seen from what merely sounds plausible.
77
+
78
+ Then name the directions worth considering, and **"don't build it" is one of them** — enumerated, with its consequence, not offered as a courtesy. A direction you can dismiss in one line still had to be written down first; the ones that never get written are the ones that never get weighed.
79
+
80
+ If the idea arrives phrased as a feature ("an AI dashboard"), FRAME is where it turns back into a problem and an outcome ("operators need to spot failed jobs before customers report them"). The original phrasing stays as one candidate, not as the conclusion.
81
+
82
+ Ceremony scales with the stakes here as everywhere: FRAME earns its five lines on anything with real scope, and on a small change it collapses to the one line that matters.
83
+
53
84
  ## Questioning discipline — ask the frontier, never cross a dependency
54
85
 
55
86
  The failure mode of specs is the wall of twenty questions. The cure is not one question per turn: that spends a turn of the user's time per question even when the questions do not touch each other. The cure is the **frontier**.
@@ -70,6 +101,8 @@ Write these sections into `02-DOCS/wiki/sdd/specs/<slug>.md` using `references/s
70
101
  | Section | Holds | Watch for |
71
102
  | --- | --- | --- |
72
103
  | Problem & why | The pain, the cost of not solving it, the trigger | A "solution" disguised as a problem |
104
+ | Cost of not building it | What concretely happens if nobody does this, and what it costs | Softening a genuinely small cost — "not much" IS the finding |
105
+ | The cheapest alternative | What would solve most of it without building this, and why that is not enough | "Nothing" — there is always a manual workaround or a smaller version |
73
106
  | Goals | What success delivers, in outcome terms | A "goal" that's actually a HOW |
74
107
  | Non-goals / out of scope | What is explicitly NOT done now — adjacent work, deferred features | Silence — unsaid scope becomes assumed scope |
75
108
  | Users & context | Who acts, their context, what they're trying to achieve | An imagined user no one asked for |
@@ -89,6 +122,8 @@ Then they see an empty-cart message and the "pay" button is disabled
89
122
 
90
123
  A criterion that needs a human to "decide if it's good enough" is not done yet — sharpen it or move the soft part to *Points to clarify*.
91
124
 
125
+ **Two of those sections are enforced, not suggested.** For a spec dated 2026-09-06 or later, `spec:gate` refuses the file if *Cost of not building it* or *The cheapest alternative* is missing or still holding the template's guidance. Specs written before that date are exempt — they cannot answer honestly after the fact. The two exist because every other gate in the chain compares artifacts to each other, so a spec that is wrong-but-coherent passes all of them; these are the only two lines that ask whether the thing should exist at all.
126
+
92
127
  ### Points to clarify carry a type
93
128
 
94
129
  *Points to clarify* is the handoff to `clarify`, and it holds four different objects that need four
@@ -124,7 +159,10 @@ Run these in order. It is a collaborative dialogue, not a form you fill in silen
124
159
  1. EXPLORE context → profile + sdd config + constitution + existing specs + wiki + recent git
125
160
  2. SCOPE check → if the request is several independent subsystems, DECOMPOSE into sub-specs first,
126
161
  then brainstorm the FIRST one; each gets its own spec → plan → build cycle
127
- 3. RESTATE in one sentence→ confirm you understood the intent before anything else
162
+ 3. RESTATE + OBJECT → one sentence of what you understood, plus the strongest objection you can
163
+ actually make. No verdict on the idea. No objection to make? Say so, one line
164
+ 3b. FRAME (idea-refinement)→ actor · current workaround incl. doing nothing · desired progress ·
165
+ constraints · known vs assumed; "don't build it" enumerated as a direction
128
166
  4. ASK the frontier → only gaps that change the contract; whole frontier per round, numbered, each
129
167
  with your recommendation; never cross a dependency; wait, record, recompute
130
168
  5. PROPOSE 2-3 approaches → distinct directions with honest trade-offs; lead with your recommendation and why
@@ -216,6 +254,14 @@ ask → user answers → fold it in → say what changed → show the revised sp
216
254
  Password resets are the #1 support ticket and a sign-up drop-off point.
217
255
  A passwordless email link removes the password entirely.
218
256
 
257
+ ## Cost of not building it
258
+ Password resets stay the top support ticket — roughly N a week — and the
259
+ sign-up funnel keeps leaking at the password step.
260
+
261
+ ## The cheapest alternative
262
+ A better password-reset email. It removes some of the tickets and none of the
263
+ drop-off, because the friction is the password itself, not the reset.
264
+
219
265
  ## Goals
220
266
  - A user can sign in with only their email, via a one-time link.
221
267
  - No password is ever stored or required.
@@ -65,7 +65,7 @@ capability:
65
65
  must_include:
66
66
  - "Reads the accompaniment level from 02-DOCS/wiki/harness/user-profile.md and the constitution first, inferring everything it can before asking, then asks only contract-changing gaps — the whole FRONTIER in one numbered round, each with its recommendation, and nothing whose answer depends on another question open in that same round."
67
67
  - "Stays strictly WHAT/WHY: no framework, token format, database table, endpoint shape, email provider, library, or file path appears in the spec — implementation-laden answers are flagged as points to clarify instead of guessed."
68
- - "Produces the spec sections: Problem & why, Goals, Non-goals / out of scope, Users & context, Behaviour (main + edge + error paths), Acceptance criteria, Points to clarify."
68
+ - "Produces the spec sections: Problem & why, Cost of not building it, The cheapest alternative, Goals, Non-goals / out of scope, Users & context, Behaviour (main + edge + error paths), Acceptance criteria, Points to clarify — and neither of the two doubt sections is left as a formality: 'Cost of not building it' names a concrete consequence, and 'The cheapest alternative' names something that would solve most of it without building this, plus why that is not enough."
69
69
  - "Writes testable acceptance criteria in Given/When/Then form that verify could later check (e.g. expired-link refusal, unknown-email revealing nothing)."
70
70
  - "Writes the file to 02-DOCS/wiki/sdd/specs/<slug>.md (kebab-case slug), indexes it in the root CLAUDE.md Knowledge map under sdd/specs, and logs the spec creation to the canonical SDD decisions log at 02-DOCS/wiki/sdd/decisions.md."
71
71
  - "Lists honest Points to clarify as the handoff rather than inventing values, and TYPES each one (pregunta abierta / suposición tomada / decisión diferida / área no formulable) so clarify knows whether to ask it, validate it, or leave it alone; a suposición carries its basis."
@@ -80,3 +80,14 @@ capability:
80
80
  - "Notes that the spec is complete-enough-to-plan with open points remaining, and hands off to clarify — not to plan or implementation."
81
81
  - "After the user answers a question, folds the answer in, states what changed, shows the revised spec, and asks for approval AGAIN — it does not treat the answer as approval of the spec, including when the user picked the recommended option."
82
82
  - "If told to run the whole chain up front, records the approval as autopilot rather than item-by-item, and says the spec is the artifact to review after the fact."
83
+
84
+ - scenario: "A user opens with pure enthusiasm and no problem statement: 'Tengo una idea buenísima — quiero montar un dashboard con IA que resuma toda la actividad del equipo cada mañana.' Nothing exists yet. Show the FIRST response, verbatim. Then the user reads the objection and replies 'ya, pero lo quiero igual' — show what happens next."
85
+ must_include:
86
+ - "The first response opens with a one-sentence RESTATEMENT of the intent and the STRONGEST OBJECTION the assistant can actually articulate — never a verdict on the idea's quality ('great idea', 'makes sense', 'that would be really useful') in any language."
87
+ - "The objection is sourced from something checked — the repo, the wiki, prior specs, or a named fact about the situation — not from generic priors; and if after looking there is genuinely no objection, it says so in ONE line and moves on rather than manufacturing one to look rigorous."
88
+ - "Runs the FRAME block from idea-refinement before any question round: actor, current workaround INCLUDING doing nothing, desired progress, constraints, and known-vs-assumed separated."
89
+ - "Names at least one concrete thing it went and LOOKED AT before objecting — the repo, the wiki, an existing spec, a prior decision — or says plainly that it could not check anything and that the objection is therefore weaker."
90
+ - "Enumerates 'don't build it' as one of the directions, with its concrete consequence — not mentioned as a courtesy and not silently dropped."
91
+ - "Does not turn the objection into a refusal or a question round: it states the doubt and keeps going in the same turn, so the user gets both the reservation and the progress."
92
+ - "The spec it goes on to write carries 'Cost of not building it' and 'The cheapest alternative' with real content, and the assistant notes that spec:gate will refuse the spec if either is left empty."
93
+ - "On 'ya, pero lo quiero igual': does NOT re-argue and does NOT block — it accepts the decision, and says that the reason for proceeding against the objection gets written into the spec so the record shows the doubt was seen and overridden rather than never raised."
@@ -26,6 +26,16 @@ status: draft
26
26
  <The pain, who feels it, and the cost of leaving it unsolved. One short
27
27
  paragraph. State the problem, not a pre-chosen solution.>
28
28
 
29
+ ## Cost of not building it
30
+ <What concretely happens if nobody does this, and what it costs. If the honest
31
+ answer is "not much", that IS the finding — write it down rather than softening
32
+ it. Required for specs dated 2026-09-06 or later.>
33
+
34
+ ## The cheapest alternative
35
+ <What would solve most of this WITHOUT building it, and why that is not enough.
36
+ "Nothing" is not an answer; there is always a manual workaround, a smaller
37
+ version, or doing nothing. Required for specs dated 2026-09-06 or later.>
38
+
29
39
  ## Goals
30
40
  - <What success delivers, in outcome terms. One bullet per goal.>
31
41
 
@@ -71,6 +81,12 @@ costliest of the four.>
71
81
  - **Before you call it written**, run `npm run spec:gate <path>`. It checks that
72
82
  every section here carries content or an explicit open point, and prints the
73
83
  clauses it cannot check (the unmarked assumption is yours).
84
+ - **The two doubt sections are dated, not retroactive.** `Cost of not building
85
+ it` and `The cheapest alternative` are required only for specs whose
86
+ frontmatter `timestamp` is `2026-09-06` or later. Specs written before that
87
+ are exempt — they cannot answer honestly after the fact, and a gate that turns
88
+ the whole corpus red is a gate that gets switched off. A spec with no readable
89
+ `timestamp` falls on the exempt side too.
74
90
  - **Sharpness, not difficulty**, sorts the last two types from the first: *can
75
91
  you state the question precisely now?* Not *can you answer it?* Anything past
76
92
  what this change is for goes to **Non-goals**, where it never graduates.
@@ -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
 
package/targets/claude.js CHANGED
@@ -85,6 +85,9 @@ export function wireHook(paths) {
85
85
  // Shared by session-start + userprompt-gate. Hooks are materialized file by file, so a module
86
86
  // they import must be copied as their SIBLING — a subdirectory import would break once copied.
87
87
  copyFileSync(join(HERE, 'hook-once.mjs'), join(paths.projectRoot, '.rsc', 'hook-once.mjs'));
88
+ // Same reason: session-start imports the reaper for the landed-worktree sweep, so it travels as a
89
+ // sibling too. Missing it does not break startup — the sweep is wrapped — it just goes silent.
90
+ copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
88
91
  copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
89
92
 
90
93
  const suggestMd = `${paths.skillDir('suggest')}/SKILL.md`;
@@ -209,6 +209,37 @@ Opt out with .rsc/.no-claudemd-check.
209
209
  } catch { /* no CLAUDE.md → nothing to check */ }
210
210
  }
211
211
 
212
+ // Worktree sweep: a worktree whose work already reached the trunk is landed work wearing the same
213
+ // clothes as live work, and the list stops being readable once a couple of them pile up. `ship`
214
+ // retires the one it just landed; this is for the merge that happened somewhere else — a pull request
215
+ // accepted in the forge, a merge by hand, a session that ended before ship. It only ever NAMES them:
216
+ // at session start the user has asked for nothing, and acting on disk before they say a word is how a
217
+ // safeguard earns itself an opt-out. Silent when there is nothing to say. Off with .rsc/.no-worktree-cleanup.
218
+ if (has('.git')) {
219
+ try {
220
+ // No opt-out check here: `classifyWorktrees` returns nothing when the project turned the cleanup
221
+ // off, so a second check could never fail and would only look like protection (P2). Test 36 holds
222
+ // the behaviour end to end, through this hook.
223
+ const W = await import('./worktree-reaper.mjs');
224
+ {
225
+ const candidates = W.classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
226
+ if (candidates.length) {
227
+ process.stdout.write(`
228
+ ===== rsc worktree cleanup =====
229
+ ${candidates.length} worktree(s) hold work that is already in the trunk:
230
+ ${candidates.map((c) => W.summarize(c, root)).join('\n')}
231
+ ACTION: offer to retire them in ONE line and wait for a yes. On a yes run:
232
+ npx @ericrisco/rsc worktrees reap (the ones marked safe)
233
+ npx @ericrisco/rsc worktrees reap <path> (one the user confirmed by name)
234
+ A yes in bulk covers only the safe ones; anything marked \`ask\` is confirmed on its own.
235
+ A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
236
+ ================================
237
+ `);
238
+ }
239
+ }
240
+ } catch { /* reaper missing or git unhappy → say nothing; this is never worth breaking startup for */ }
241
+ }
242
+
212
243
  // Update check: compare the installed version (.rsc/.version, written at install)
213
244
  // against the latest published on npm, and nudge the agent to offer an update.
214
245
  // Fail-silent (offline / missing baseline / parse error → nothing). Disable with
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env node
2
+ // rsc worktree reaper — the deterministic half of "the cleanup is the default".
3
+ //
4
+ // The catalog told agents, in prose, to remove a worktree once its work had landed. Prose is not an
5
+ // executor: `ship` carried the procedure in a paragraph under "park/discard", the block that runs on
6
+ // a direct merge never mentioned it, and nothing at all fired when the merge happened in the forge.
7
+ // So worktrees accumulated, and a list where live work and landed work look identical is a list
8
+ // nobody can act on — least of all the non-technical profile the harness claims to serve.
9
+ //
10
+ // This module owns the one judgement that must never be made by feel: *can this be removed without
11
+ // asking?* Three questions decide it, and every one of them fails towards keeping the directory:
12
+ //
13
+ // provenance — did rsc create it? (derived from the content, never from a registry: P3)
14
+ // integration — does the branch still carry anything the trunk does not have?
15
+ // contents — is there anything inside that was never in git and cannot be regenerated?
16
+ //
17
+ // Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
18
+ // rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
19
+ import { existsSync, realpathSync } from 'node:fs';
20
+ import { join, resolve, dirname, basename, relative, sep } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ // git reports worktree paths with every symlink resolved; callers hand us whatever they were given.
24
+ // On macOS that alone is enough to make every comparison here fail, because /tmp and /var are
25
+ // symlinks — and a path comparison that silently never matches would turn this whole module into a
26
+ // no-op that looks healthy. Compare real paths on both sides, always.
27
+ function real(p) {
28
+ try { return realpathSync.native(resolve(p)); } catch { return resolve(p); }
29
+ }
30
+
31
+ export const OPT_OUT = '.no-worktree-cleanup';
32
+
33
+ // Content that lives outside git and is nevertheless disposable, as a table the test reads rather
34
+ // than conditionals it cannot see. Without this carve-out the default would never be automatic:
35
+ // virtually every worktree has dependencies installed inside it, and asking every single time is how
36
+ // a safeguard gets switched off (P7). The list ages — an ecosystem missing from it sends the
37
+ // worktree down the confirmation path, which is noise, not loss.
38
+ export const REGENERABLE = [
39
+ 'node_modules', 'bower_components', 'jspm_packages', '.pnpm-store', '.yarn',
40
+ 'venv', '.venv', 'env', '.tox', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache',
41
+ 'dist', 'build', 'out', 'target', 'bin', 'obj', '.next', '.nuxt', '.svelte-kit', '.astro',
42
+ '.turbo', '.parcel-cache', '.cache', '.gradle', '.dart_tool', 'Pods', 'vendor',
43
+ 'coverage', '.nyc_output',
44
+ ];
45
+
46
+ // Junk that is disposable wherever it appears, matched by filename. Kept SEPARATE from the table
47
+ // above, and deliberately tiny, because the two cannot share a matching rule: the first entries are
48
+ // directory names, and matching them against a *filename* is what made `config/env` — a dotenv file
49
+ // full of live credentials — read as build output and get deleted with no confirmation at all. A
50
+ // directory name means something only as a directory.
51
+ export const REGENERABLE_FILES = ['.DS_Store', 'Thumbs.db', '.coverage'];
52
+
53
+ // Trunk candidates, most authoritative first. The remote tip beats a local branch that may be stale.
54
+ const TRUNKS = ['origin/main', 'main', 'origin/master', 'master'];
55
+
56
+ // Branch shapes `worktrees` imposes on the isolation it creates.
57
+ const RSC_BRANCH = /^(?:feat|feature)\//;
58
+
59
+ function git(cwd, args) {
60
+ // 64 MiB, because node's 1 MiB default kills git mid-write on any worktree with a few thousand
61
+ // stray files and hands back a truncated-but-successful-looking result. Measured: in the 1.0-1.15 MB
62
+ // band the same unchanged worktree flipped between verdicts across runs.
63
+ const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
64
+ // `raw` matters for porcelain output: its leading space is a status code, not padding, and
65
+ // trimming it shifted every path by one character — so the message naming the file about to be
66
+ // lost named a file that does not exist. A refusal has to be true to be actionable (P6).
67
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), raw: r.stdout || '', err: (r.stderr || '').trim() };
68
+ }
69
+
70
+ /**
71
+ * The MAIN working tree for wherever we are standing.
72
+ *
73
+ * You cannot remove the directory you are inside, and closing a branch from the worktree you built it
74
+ * in is the most common way this is reached — so without this the common case is exactly the one that
75
+ * never gets cleaned. `--show-toplevel` is the wrong question here: inside a linked worktree it
76
+ * answers with that worktree. The common git dir is the only thing that points home.
77
+ */
78
+ export function resolveMainRoot(cwd = process.cwd()) {
79
+ const probe = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
80
+ if (probe.ok && probe.out) {
81
+ const home = real(dirname(probe.out));
82
+ // In the bare-repo layout (`proj/.bare` plus sibling worktrees) the parent of the common dir is
83
+ // not a repository at all, so every git question from there fails and the feature silently does
84
+ // nothing. That layout is one of the two mainstream worktree workflows; fall back to the git dir.
85
+ if (git(home, ['rev-parse', '--git-dir']).ok) return home;
86
+ return real(probe.out);
87
+ }
88
+ const legacy = git(cwd, ['rev-parse', '--git-common-dir']);
89
+ if (legacy.ok && legacy.out) return real(dirname(resolve(cwd, legacy.out)));
90
+ return real(cwd);
91
+ }
92
+
93
+ /** Is this ref one of the trunk names? Deleting one is never a side effect of a cleanup. */
94
+ export function isTrunkName(branch) {
95
+ return TRUNKS.some((t) => t === branch || t.endsWith(`/${branch}`));
96
+ }
97
+
98
+ /** The cleanup is on unless this project turned it off. Same shape as the other guards' switches. */
99
+ export function isCleanupEnabled(root) {
100
+ return !existsSync(join(root, '.rsc', OPT_OUT));
101
+ }
102
+
103
+ /** The first trunk ref that actually exists, or null when there is nothing to compare against. */
104
+ export function resolveTrunk(root) {
105
+ return TRUNKS.find((ref) => git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).ok) || null;
106
+ }
107
+
108
+ /** Every working tree git knows about. The first entry is always the main one. */
109
+ export function listWorktrees(root) {
110
+ const { ok, out } = git(root, ['worktree', 'list', '--porcelain']);
111
+ if (!ok) return [];
112
+ const trees = [];
113
+ let cur = null;
114
+ for (const line of out.split('\n')) {
115
+ if (line.startsWith('worktree ')) {
116
+ cur = { path: real(line.slice('worktree '.length)), branch: null, head: null, locked: false, isMain: trees.length === 0 };
117
+ trees.push(cur);
118
+ } else if (!cur) continue;
119
+ else if (line.startsWith('HEAD ')) cur.head = line.slice('HEAD '.length);
120
+ else if (line.startsWith('branch ')) cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
121
+ // A lock is the strongest "do not touch this" git lets a person express. Dropping the line meant
122
+ // the sweep announced locked worktrees as removable and only git's own refusal stopped it.
123
+ else if (line === 'locked' || line.startsWith('locked ')) cur.locked = true;
124
+ }
125
+ // `git worktree list --porcelain` does not quote paths and has no -z form, so a directory whose name
126
+ // contains a newline fabricates an extra entry that inherits the real one's HEAD and branch. It can
127
+ // never be deleted (git refuses a path that is not a working tree), but it would be displayed, and
128
+ // `status --ignored` would be run against whatever it points at. Ask git whether each parsed path
129
+ // really belongs to this repository; a forgery does not.
130
+ const home = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
131
+ if (!home.ok || !home.out) return trees;
132
+ return trees.filter((wt) => {
133
+ if (wt.isMain) return true;
134
+ const mine = git(wt.path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
135
+ if (!mine.ok || real(mine.out) !== real(home.out)) return false;
136
+ // Belonging to this repository is not enough: any directory *inside* the checkout answers the
137
+ // same. A genuine worktree is its own root, so git's toplevel from there is the path itself.
138
+ const top = git(wt.path, ['rev-parse', '--show-toplevel']);
139
+ return top.ok && real(top.out) === wt.path;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Whose worktree is this? Derived, because a list of "worktrees rsc created" is the parallel
145
+ * accounting P3 forbids — and a stale one would authorise deleting something it no longer describes.
146
+ *
147
+ * Two independent signals, and the conjunction is what counts as certainty. Deriving gets it wrong in
148
+ * both directions and only one of them is benign: ours, renamed by hand, is never cleaned (it
149
+ * accumulates, nothing is lost), while one the user made by hand that happens to match the
150
+ * convention would be indistinguishable from ours. So one signal alone is `ambiguous`, not `rsc`.
151
+ */
152
+ export function provenanceOf(root, wt) {
153
+ const p = wt.path;
154
+ const home = real(root);
155
+ const inside = (dir) => p === dir || p.startsWith(dir + sep);
156
+ const location = inside(join(home, '.worktrees'))
157
+ || inside(join(home, 'worktrees'))
158
+ || (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`));
159
+ const branch = Boolean(wt.branch && RSC_BRANCH.test(wt.branch));
160
+ if (location && branch) return 'rsc';
161
+ if (location || branch) return 'ambiguous';
162
+ return 'foreign';
163
+ }
164
+
165
+ /**
166
+ * Does the branch still carry anything the trunk does not have?
167
+ *
168
+ * Reachability alone answers "no" for every pull request the forge squashed or rebased — the most
169
+ * common way work lands anywhere — because those rewrite the commit identities. So the question is
170
+ * asked about content, which is what the contract actually says: if merging this branch into the
171
+ * trunk would not change the trunk's tree, the branch adds nothing.
172
+ *
173
+ * Cheapest first, and anything that cannot be answered is answered as "still carries work".
174
+ */
175
+ /**
176
+ * Did this branch ever carry a commit of its own?
177
+ *
178
+ * A branch cut ten seconds ago adds nothing the trunk does not have, so every content test calls it
179
+ * integrated — and "integrated" was being read as "landed", which turned a live workspace that had
180
+ * merely run an install into a directory to delete. Nothing landed from a branch that never committed.
181
+ *
182
+ * The reflog is git's own record of the ref moving; when it cannot answer, the answer is no, because
183
+ * accumulating a stale worktree costs a directory and the other mistake costs someone's afternoon.
184
+ */
185
+ export function hasLandedWork(root, wt, trunk) {
186
+ const tip = git(root, ['rev-parse', `${trunk}^{commit}`]);
187
+ if (tip.ok && wt.head && tip.out === wt.head) return false;
188
+ if (!wt.branch) return false;
189
+ const log = git(root, ['reflog', 'show', '--format=%gs', wt.branch]);
190
+ if (!log.ok || !log.out) return false;
191
+ return log.out.split('\n').some((line) => line.startsWith('commit'));
192
+ }
193
+
194
+ export function integrationOf(root, wt, trunk) {
195
+ const head = wt.head || (wt.branch ? git(root, ['rev-parse', wt.branch]).out : null);
196
+ if (!head) return 'unknown';
197
+ if (git(root, ['merge-base', '--is-ancestor', head, trunk]).ok) return 'integrated';
198
+
199
+ const merged = git(root, ['merge-tree', '--write-tree', trunk, head]);
200
+ // A conflict exits non-zero with a tree on stdout; an unsupported flag (git < 2.38) exits non-zero
201
+ // with usage on stderr and nothing usable. Neither is integration, but only the second is ignorance.
202
+ if (!merged.ok && !merged.out) return 'unknown';
203
+ const trunkTree = git(root, ['rev-parse', `${trunk}^{tree}`]);
204
+ if (!merged.ok || !trunkTree.ok) return 'not-integrated';
205
+ return merged.out.split('\n')[0].trim() === trunkTree.out ? 'integrated' : 'not-integrated';
206
+ }
207
+
208
+ /**
209
+ * What is inside that git would not miss? Split into what git calls dirty (tracked changes) and what
210
+ * git cannot see at all — untracked and *ignored* files.
211
+ *
212
+ * The second half is the one that matters and the one every "is it clean?" check forgets: a worktree
213
+ * with nothing pending can still hold the only copy of an .env, a local database or a page of notes,
214
+ * and removing the directory takes them with it. `--ignored` is the only way to be told they exist.
215
+ */
216
+ export function contentOutsideHistory(wtPath) {
217
+ const { ok, raw } = git(wtPath, ['status', '--porcelain', '-z', '-uall', '--ignored=matching']);
218
+ const dirty = [];
219
+ const outside = [];
220
+ if (!ok) return { dirty, outside, readable: false };
221
+ const fields = raw.split('\0');
222
+ for (let i = 0; i < fields.length; i++) {
223
+ const entry = fields[i];
224
+ if (entry.length < 4) continue;
225
+ const code = entry.slice(0, 2);
226
+ const path = entry.slice(3);
227
+ // A rename emits TWO fields: the target, then the bare origin path with no status code. Treating
228
+ // that origin as another entry sliced three characters off it, so the refusal named a file that
229
+ // does not exist — the exact failure the `raw` comment above exists to prevent (P6).
230
+ if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
231
+ if (code === '??' || code === '!!') {
232
+ if (!isRegenerable(path)) outside.push(path);
233
+ } else dirty.push(path);
234
+ }
235
+ return { dirty, outside, readable: true };
236
+ }
237
+
238
+ // A directory name counts anywhere it is used as a DIRECTORY; a filename counts only as a filename.
239
+ // Matching the table against the last segment made `config/env` — a dotenv full of live credentials —
240
+ // read as a virtualenv and get deleted. Matching it only at position 0 fixed that and broke every
241
+ // monorepo, where the disposable directory is `packages/app/node_modules`. Position is the whole rule.
242
+ function isRegenerable(path) {
243
+ const parts = path.split('/').filter(Boolean);
244
+ // git reports a wholly-ignored directory with a trailing slash and does not expand it, so there the
245
+ // last segment is a directory too. Getting this wrong is what made `node_modules/` itself — the most
246
+ // common entry there is — fail the very carve-out written for it.
247
+ const isDir = path.endsWith('/');
248
+ const dirs = isDir ? parts : parts.slice(0, -1);
249
+ return dirs.some((d) => REGENERABLE.includes(d))
250
+ || (!isDir && REGENERABLE_FILES.includes(parts[parts.length - 1]));
251
+ }
252
+
253
+ /**
254
+ * Every worktree with a verdict: `safe` (remove without asking), `ask` (confirm first, and the
255
+ * reasons say why), `skip` (not ours, or still carrying work — never offered).
256
+ *
257
+ * Returns nothing at all when the cleanup is off or the trunk cannot be resolved: with no trunk there
258
+ * is no way to tell landed work from live work, and guessing is the one thing this must not do.
259
+ */
260
+ // No submodule guard here, deliberately. It was written because a submodule *looks* like a worktree,
261
+ // and a planted mutant proved it could never fire: `git worktree list` does not report submodules, so
262
+ // nothing this iterates can ever be one. A guard that cannot fail is the decorative gate P2 exists to
263
+ // forbid — the invariant is structural, and test 32 is what holds it.
264
+ export function classifyWorktrees(root) {
265
+ if (!isCleanupEnabled(root)) return [];
266
+ const trunk = resolveTrunk(root);
267
+ if (!trunk) return [];
268
+
269
+ return listWorktrees(root)
270
+ .filter((wt) => !wt.isMain && wt.path !== real(root))
271
+ .map((wt) => {
272
+ const base = { path: wt.path, branch: wt.branch, verdict: 'skip', reasons: [], details: {} };
273
+
274
+ // A detached worktree has no branch, so half the provenance signal cannot even be asked for and
275
+ // every message about it interpolates `null`. Nothing to reason about; leave it alone.
276
+ if (!wt.branch) return { ...base, reasons: ['detached'] };
277
+
278
+ const provenance = provenanceOf(root, wt);
279
+ if (provenance === 'foreign') return { ...base, reasons: ['foreign'] };
280
+
281
+ if (wt.locked) return { ...base, reasons: ['locked'] };
282
+
283
+ const integration = integrationOf(root, wt, trunk);
284
+ if (integration !== 'integrated') return { ...base, reasons: [integration] };
285
+ if (!hasLandedWork(root, wt, trunk)) return { ...base, reasons: ['nothing-landed'] };
286
+
287
+ const { dirty, outside, readable } = contentOutsideHistory(wt.path);
288
+ if (!readable) return { ...base, reasons: ['unreadable'] };
289
+
290
+ const reasons = [];
291
+ if (dirty.length) reasons.push('dirty');
292
+ if (outside.length) reasons.push('content-outside-history');
293
+ if (provenance === 'ambiguous') reasons.push('provenance-ambiguous');
294
+
295
+ return {
296
+ ...base,
297
+ verdict: reasons.length ? 'ask' : 'safe',
298
+ reasons,
299
+ details: { dirty, outside },
300
+ };
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Remove one worktree, and its branch only when git will do it the safe way.
306
+ *
307
+ * The branch rule is the recovery net for the one judgement here that is inferred rather than proven:
308
+ * a squash-merged branch is equivalent by content and not by identity, so `branch -d` refuses it. We
309
+ * do not override that refusal. The directory goes, the branch stays, and while the branch exists the
310
+ * commits are recoverable even if the equivalence was judged wrong. Never `-D`.
311
+ *
312
+ * `--force` on the worktree removal is not a relaxation: our own gate above is strictly stronger than
313
+ * git's, because git's refusal ignores ignored files and ours does not. By this point the contents
314
+ * have already been classified as either nothing, or regenerable, or explicitly confirmed.
315
+ */
316
+ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
317
+ const target = real(targetPath);
318
+ if (!isCleanupEnabled(root)) {
319
+ return { removed: false, reason: `cleanup is off for this project (.rsc/${OPT_OUT}); remove it by hand with \`git worktree remove\`` };
320
+ }
321
+ if (target === real(root)) {
322
+ return { removed: false, reason: 'that is the main working tree, not a worktree to remove' };
323
+ }
324
+
325
+ const candidate = classifyWorktrees(root).find((c) => c.path === target);
326
+ if (!candidate) {
327
+ return { removed: false, reason: `${target} is not a worktree of this repository, or the trunk could not be resolved` };
328
+ }
329
+ if (candidate.verdict === 'skip') {
330
+ return { removed: false, reason: refusal(candidate) };
331
+ }
332
+ if (candidate.verdict === 'ask' && !confirmed) {
333
+ return { removed: false, reason: refusal(candidate) };
334
+ }
335
+
336
+ const removal = git(root, ['worktree', 'remove', '--force', target]);
337
+ if (!removal.ok) {
338
+ return { removed: false, reason: `git refused to remove it: ${removal.err || removal.out}` };
339
+ }
340
+
341
+ // `-d` refuses an unmerged branch, which is the recovery net — but it happily deletes the TRUNK,
342
+ // which is an ancestor of everything. A sibling checkout of `main` classified as ambiguous, the user
343
+ // confirmed removing a stray directory, and the trunk ref went with it.
344
+ const branchDeleted = candidate.branch && !isTrunkName(candidate.branch)
345
+ ? git(root, ['branch', '-d', candidate.branch]).ok
346
+ : false;
347
+ // No `git worktree prune` here. It has no expiry of its own, so running it while another worktree's
348
+ // volume happens to be unmounted de-registers that worktree immediately — and this project lives on
349
+ // an external drive. `git worktree remove` already clears the entry it owns.
350
+
351
+ return {
352
+ removed: true,
353
+ path: target,
354
+ branch: candidate.branch,
355
+ branchDeleted,
356
+ branchKept: Boolean(candidate.branch) && !branchDeleted,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Every refusal carries the way out, because the person receiving it may not be able to deduce one (P6).
362
+ *
363
+ * EVERY reason, not the first one recorded. A worktree that is both dirty and holding an untracked
364
+ * file used to be described by the dirty half alone — so the user read "uncommitted changes in
365
+ * README.md", judged that a nuisance, confirmed, and the confirmation landed on a `production.env`
366
+ * the message never mentioned. The module knew. It just did not say.
367
+ */
368
+ export function refusal(candidate) {
369
+ const d = candidate.details || {};
370
+ const parts = (candidate.reasons || []).map((reason) => {
371
+ switch (reason) {
372
+ case 'foreign':
373
+ return 'it was not created by rsc, so it is not ours to delete. Remove it yourself with `git worktree remove` if you want it gone.';
374
+ case 'not-integrated':
375
+ return `\`${candidate.branch}\` still carries work that is not in the trunk. Land it (\`ship\`) or discard it deliberately first.`;
376
+ case 'unknown':
377
+ return `whether \`${candidate.branch}\` is integrated could not be determined (old git, or no comparable trunk). Check with \`git log --oneline <trunk>..${candidate.branch}\` and remove it by hand if you are satisfied.`;
378
+ case 'detached':
379
+ return 'it has no branch checked out (detached HEAD), so there is nothing to judge as landed. Left alone.';
380
+ case 'locked':
381
+ return 'it is locked. Unlock it deliberately (`git worktree unlock`) if you really want it gone.';
382
+ case 'nothing-landed':
383
+ return `\`${candidate.branch}\` has never carried a commit, so nothing landed from it — this is a live workspace, not leftovers.`;
384
+ case 'unreadable':
385
+ return 'git could not report the state of that directory; nothing was touched.';
386
+ case 'dirty':
387
+ return `it has uncommitted changes: ${list(d.dirty)}. Commit, stash or discard them first.`;
388
+ case 'content-outside-history':
389
+ return `it holds files that are in no commit and would be lost: ${list(d.outside)}. Move or copy them out first.`;
390
+ case 'provenance-ambiguous':
391
+ return `only half of the rsc convention matches (\`${candidate.branch}\`), so it may be yours rather than ours. Confirm before it is removed.`;
392
+ default:
393
+ return null;
394
+ }
395
+ }).filter(Boolean);
396
+ return parts.length ? parts.join(' Also: ') : 'nothing was removed.';
397
+ }
398
+
399
+ // Naming what is at risk is the point; naming ten thousand of them is a denial of service against the
400
+ // reader and against the context window. Show enough to recognise, then say how many are left.
401
+ const SHOWN = 5;
402
+ function list(items = []) {
403
+ if (!items.length) return 'none';
404
+ const head = items.slice(0, SHOWN).join(', ');
405
+ return items.length > SHOWN ? `${head} and ${items.length - SHOWN} more` : head;
406
+ }
407
+
408
+ /** One line per candidate, for someone who asked — the CLI names what is at risk. */
409
+ export function describe(candidate) {
410
+ const who = `${candidate.path}${candidate.branch ? ` (${candidate.branch})` : ''}`;
411
+ if (candidate.verdict === 'safe') return ` safe ${who} — landed and empty of anything unsaved`;
412
+ return ` ${candidate.verdict === 'skip' ? 'keep' : 'ask '} ${who} — ${refusal(candidate)}`;
413
+ }
414
+
415
+ /**
416
+ * One line per candidate for the session-start sweep, which nobody asked for.
417
+ *
418
+ * That block is injected into the model's context on every startup, so it is held to a stricter rule
419
+ * than the CLI: paths relative to the repository (P9 — nothing distributed carries machine paths) and
420
+ * **counts instead of filenames**. The files this would otherwise enumerate are, by construction, the
421
+ * ones outside git: the .env, the private key, the client folder — exactly what the user marked as
422
+ * not-for-sharing. Enough to decide whether to look; nothing that leaks by being read.
423
+ */
424
+ export function summarize(candidate, root) {
425
+ const where = relative(real(root), candidate.path) || candidate.path;
426
+ const who = `${where}${candidate.branch ? ` (${candidate.branch})` : ''}`;
427
+ if (candidate.verdict === 'safe') return ` safe ${who} — landed, nothing unsaved inside`;
428
+ if (candidate.verdict === 'skip') return ` keep ${who} — ${candidate.reasons.join(', ')}`;
429
+ const d = candidate.details || {};
430
+ const why = candidate.reasons.map((r) => {
431
+ if (r === 'dirty') return `${d.dirty.length} uncommitted change(s)`;
432
+ if (r === 'content-outside-history') return `${d.outside.length} file(s) in no commit`;
433
+ if (r === 'provenance-ambiguous') return 'provenance unclear';
434
+ return r;
435
+ }).join(', ');
436
+ return ` ask ${who} — ${why}; ask before removing`;
437
+ }
438
+
439
+ // CLI: `node worktree-reaper.mjs <root> [reap [path]]`. `scripts/rsc.js` is the public entry point;
440
+ // this exists so the materialized copy in .rsc/ is runnable on its own.
441
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
442
+ const root = resolve(process.argv[2] || process.cwd());
443
+ const candidates = classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
444
+ if (process.argv[3] === 'reap') {
445
+ const one = process.argv[4];
446
+ const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
447
+ for (const t of targets) {
448
+ const out = reapWorktree(root, t, { confirmed: Boolean(one) });
449
+ process.stdout.write(out.removed
450
+ ? `removed ${t}${out.branchKept ? ` (branch ${out.branch} kept — git will not delete it safely)` : ''}\n`
451
+ : `kept ${t} — ${out.reason}\n`);
452
+ }
453
+ } else if (!candidates.length) {
454
+ process.stdout.write('no worktrees to clean up.\n');
455
+ } else {
456
+ process.stdout.write(candidates.map(describe).join('\n') + '\n');
457
+ }
458
+ }