@ericrisco/rsc 1.4.1 → 2.0.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@ericrisco/rsc?color=63d68a&labelColor=12161c&label=npm)](https://www.npmjs.com/package/@ericrisco/rsc)
8
8
  [![downloads](https://img.shields.io/npm/dm/@ericrisco/rsc?color=63d68a&labelColor=12161c&label=downloads)](https://www.npmjs.com/package/@ericrisco/rsc)
9
- [![skills](https://img.shields.io/badge/skills-272-63d68a?labelColor=12161c)](#the-catalog)
9
+ [![skills](https://img.shields.io/badge/skills-273-63d68a?labelColor=12161c)](#the-catalog)
10
10
  [![license](https://img.shields.io/badge/license-MIT-63d68a?labelColor=12161c)](LICENSE)
11
11
  [![stars](https://img.shields.io/github/stars/ericrisco/rsc-harness?color=63d68a&labelColor=12161c)](https://github.com/ericrisco/rsc-harness/stargazers)
12
12
 
@@ -90,8 +90,41 @@ npx @ericrisco/rsc@latest onboard --technical-level mixed --accompaniment L1 \
90
90
  - **Add one skill by id:** `rsc add <id>` · **browse the catalog:** `rsc consult "<what you want>"` or `rsc list`.
91
91
 
92
92
  From then on it's self-driving: `rsc-suggest` proposes the next skill as tasks appear, and in
93
- Claude Code a hook re-asserts the spec-first **new-feature gate** on every turn — so a feature
94
- request routes through `specify` before any skill writes code.
93
+ Claude Code a hook re-asserts the **lane decisor** on every turn — so every request is classified
94
+ before anything is written.
95
+
96
+ ---
97
+
98
+ ## 🛣️ Three lanes, and the harness never picks the expensive one for you
99
+
100
+ <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/lanes.svg" alt="The rsc lane decisor: a request is classified into answer (read-only), FTD (the default lane, one feature document) or SDD (the ten-phase chain, which only the user selects)." width="960">
101
+
102
+ Every turn takes exactly one lane, and your agent names the one it took in a line.
103
+
104
+ 1. **Answer.** The request asks for information — explain, compare, investigate, audit, review,
105
+ propose. It is **read-only**: nothing is written, no artifact is created, no writer is delegated.
106
+ Asking your agent to *think about* building something is still this lane. When intent is
107
+ ambiguous it asks one question and stays here; ambiguity slows the lane down, it never raises it.
108
+ 2. **FTD — Fast-Track Development.** The request authorises a change. This is the default for
109
+ ordinary work and needs no ceremony. One feature document per feature holds intent, scope, a
110
+ checklist, the evidence and the next step. Tasks are checked off against observed proof, never
111
+ against intention. A branch if the work writes code; nothing if it only touches docs or config.
112
+ 3. **SDD — the ten-phase chain.** Unchanged, and **the harness never enters it alone.** It proposes
113
+ the chain only when a durable spec, plan and task list would remove a *substantial* ambiguity —
114
+ a test of usefulness, not of size — and enters it only when you ask for it or accept that
115
+ proposal. Size, file count and perceived risk never select it on their own.
116
+
117
+ **Isolation cleans itself up.** When a lane opens a branch or a worktree, you no longer have to
118
+ remember to retire it: a `post-merge` hook does it the moment the work lands on the trunk, on both
119
+ landing paths — a local merge and the pull after a forge merge. It removes only what is provably
120
+ landed and holds nothing unsaved; anything it refuses tells you why. The previous version asked an
121
+ agent to run the cleanup at the end of a long phase, and that step was skipped on both features that
122
+ reached it.
123
+
124
+ Earlier versions routed everything that sounded like building into the chain. That contradicted the
125
+ project's own first rule — friction is proportional to risk — and a harness that interrupts the 80%
126
+ that is harmless gets switched off, which protects nothing. Both lanes are still here; what changed
127
+ is which one you land in by default, and who decides.
95
128
 
96
129
  ---
97
130
 
@@ -199,7 +232,7 @@ $ rsc onboard
199
232
  ██████╗ ███████╗ ██████╗ ← animated gradient wordmark
200
233
  ██╔══██╗██╔════╝██╔════╝
201
234
  ██████╔╝███████╗██║
202
- 272 skills · one CLI · zero bloat
235
+ 273 skills · one CLI · zero bloat
203
236
 
204
237
  How technical should the conversation be?
205
238
  How much accompaniment do you want?
@@ -226,7 +259,7 @@ report success with three markdown files.
226
259
 
227
260
  ## The CLI
228
261
 
229
- Fresh projects enter through `rsc onboard`. The direct `add` and `install --profile` forms below
262
+ Fresh projects enter through `rsc onboard`. The direct `add` and `install` forms below
230
263
  are maintenance controls for projects that already carry an `.rsc.json` declaration; they cannot
231
264
  bypass onboarding in a new folder.
232
265
 
@@ -236,10 +269,6 @@ rsc reassess # check persisted deferral triggers; never i
236
269
  rsc add fastapi postgresdb # install specific skills, by name
237
270
  rsc add youtube-api remotion-video # …grow a channel, edit with Remotion
238
271
  rsc add fastapi --target claude,codex # install into several assistants at once
239
- rsc install --profile minimal # the base: orient + suggest + bro + unslop + show-me + eli5 + harness + init
240
- rsc install --profile core # floor + the full SDD workflow
241
- rsc install --profile full # everything (all 272 skills)
242
- rsc install --profile full --without go
243
272
  rsc consult "I want to launch a SaaS" # recommend only, no install
244
273
  rsc registry refresh # write .rsc/skill-registry.{json,md}
245
274
  rsc list # installed skills, agents and commands
@@ -313,9 +342,11 @@ When someone changes the harness and you `git pull`, `rsc doctor` tells you what
313
342
  matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
314
343
 
315
344
  **Own skills.** A skill your team wrote lives in the repo and already works for whoever clones,
316
- with no command at all. Declare it in `.rsc.json` under `ownSkills` and `doctor` will also say
317
- when someone is missing it — that is all declaring does. rsc never installs, updates or
318
- overwrites it: its version is the commit.
345
+ with no command at all. rsc never installs, updates or overwrites it: its version is the commit.
346
+ Installing a catalog skill of the same name is refused rather than silently winning, and `doctor`
347
+ lists what is yours by reading the files themselves — no list to keep in `.rsc.json`, because a
348
+ list somebody has to remember to update is a list that goes quietly stale. Declaring a skill under
349
+ `ownSkills` still works and still reports when someone is missing it.
319
350
 
320
351
 
321
352
  ## 🩹 Something's off? One command
@@ -400,7 +431,7 @@ just asks in plain language.
400
431
 
401
432
  ## The catalog
402
433
 
403
- 272 skills, grouped by what you're trying to do. Click any skill to read its
434
+ 273 skills, grouped by what you're trying to do. Click any skill to read its
404
435
  `SKILL.md`. It fires on its own when a task matches.
405
436
 
406
437
  ### 🧭 Core & control plane
@@ -415,6 +446,12 @@ The front door and the workspace brain.
415
446
  > **bro** is installed with every profile and rewrites any answer in plain, natural
416
447
  > language when the user asks — without making its full body always-on.
417
448
 
449
+ <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/company-brain.svg" alt="The rsc company brain as a 3D knowledge graph: loose material drifts in from the inbox on the left and is absorbed into the bright green wiki cluster, whose brightest hubs are its .base views; dim clusters behind are raw sources and the agent's own captured worklog; an amber cluster is gaps.md, what the wiki knows it is missing." width="960">
450
+
451
+ Anything you drop in goes to `inbox/` and is ingested; the wiki consolidates it as plain markdown you can open as an
452
+ Obsidian vault; and every skill reads it before acting, so the next session starts where the last one stopped. Raw
453
+ sources and the agent's own worklog stay underneath — present, never deleted, out of the way.
454
+
418
455
  > #### 📦 The `02-DOCS/` brain is now 100% Open Knowledge Format (OKF v0.1) conformant
419
456
  >
420
457
  > Google Cloud published the [**Open Knowledge Format**](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf)
@@ -436,9 +473,9 @@ The front door and the workspace brain.
436
473
  > root, anything in `inbox/`) is *moved* into `raw/`, never left as clutter.
437
474
 
438
475
  ### 📐 Spec-Driven Development
439
- Take a fuzzy intent to a shipped, verified change — phase by phase. `npx @ericrisco/rsc install --profile core`.
476
+ Two lanes for change: `ftd` for ordinary work — one feature document, evidence, done — and the ten-phase chain when durable artifacts would settle a real ambiguity. Both ship with the harness.
440
477
 
441
- [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/)
478
+ [ftd](skills/ftd/) · [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/)
442
479
 
443
480
  > 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.
444
481
 
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "1.4.1",
2
+ "version": "2.0.1",
3
3
  "counts": {
4
- "skills": 272,
4
+ "skills": 273,
5
5
  "agents": 33,
6
6
  "commands": 53,
7
7
  "fixedCommands": 20
@@ -1956,6 +1956,27 @@
1956
1956
  ],
1957
1957
  "profiles": []
1958
1958
  },
1959
+ {
1960
+ "id": "ftd",
1961
+ "description": "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and branches when the work writes code. NOT the ten-phase chain (that is `sdd`), NOT the classification itself (that is the decisor in `suggest`), NOT a request for information, which writes nothing.",
1962
+ "tags": [
1963
+ "ftd",
1964
+ "lane",
1965
+ "default",
1966
+ "feature-document",
1967
+ "evidence"
1968
+ ],
1969
+ "recommends": [
1970
+ "worktrees",
1971
+ "debug",
1972
+ "verify"
1973
+ ],
1974
+ "profiles": [
1975
+ "minimal",
1976
+ "core",
1977
+ "full"
1978
+ ]
1979
+ },
1959
1980
  {
1960
1981
  "id": "fundraising",
1961
1982
  "description": "Use when planning or running an equity round as a process: sizing the raise to a milestone, choosing post-money SAFE vs priced round, tiering investors by intro path, sequencing outreach for momentum, or reading a term sheet before signing. NOT the slide narrative (that is `pitch-deck`), NOT the cap-table or valuation math (that is `financial-model`).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.4.1",
3
+ "version": "2.0.1",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -1,10 +1,11 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { targetPaths, TARGET_IDS } from '../targets/index.js';
5
5
  import { readState } from './lib/state.js';
6
6
  import { divergence } from './lib/divergence.js';
7
7
  import { loadManifest } from './lib/manifest.js';
8
+ import { readManifest as readProjectManifest } from './lib/manifest-file.js';
8
9
  import { listBackups } from './lib/backups.js';
9
10
  import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
10
11
  import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
@@ -79,6 +80,62 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
79
80
  return [...seen];
80
81
  }
81
82
 
83
+ // Is the post-merge trigger actually armed in this clone? Three states, because they need three
84
+ // different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
85
+ // at install time, and overwriting a husky hook to fix a convenience would be indefensible).
86
+ function worktreeCleanupStatus(root) {
87
+ const hook = join(root, '.git', 'hooks', 'post-merge');
88
+ if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
89
+ if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
90
+ if (!existsSync(hook)) {
91
+ return { state: 'absent', action: 'Run `npx @ericrisco/rsc repair` to arm the post-merge cleanup in this clone.' };
92
+ }
93
+ try {
94
+ const armed = readFileSync(hook, 'utf8').includes('rsc-managed worktree cleanup');
95
+ return armed
96
+ ? { state: 'armed' }
97
+ : { state: 'foreign', action: 'A post-merge hook from another tool is in place; `npx @ericrisco/rsc repair` chains ours behind it.' };
98
+ } catch { return { state: 'unreadable' }; }
99
+ }
100
+
101
+ // Every skill this catalog ships declares `origin: risco` in its own frontmatter — all 272 of them,
102
+ // uniformly. So "is this ours?" is answerable from the file itself, and does not need the list in
103
+ // `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
104
+ // every project since it was introduced, and `doctor` has been faithfully reporting on the emptiness.
105
+ //
106
+ // Failing towards "the user's" is deliberate. Reading a catalog skill as the user's leaves a file
107
+ // that stops being updated; reading the user's as the catalog's invites something to overwrite it.
108
+ // Only one of those loses work.
109
+ const CATALOG_ORIGIN = 'risco';
110
+
111
+ function ownSkillsIn(paths, declared = []) {
112
+ const present = [];
113
+ let dir;
114
+ try { dir = dirname(paths.skillDir('_')); } catch { return { present, missing: [], declared }; }
115
+ let entries = [];
116
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { /* nothing installed here yet */ }
117
+ for (const entry of entries) {
118
+ // Symlinks included, deliberately. rsc installs a catalog skill AS a symlink into `.rsc/skills/`
119
+ // and a hand-written one is a real directory, so the entry type is itself a provenance signal —
120
+ // and skipping symlinks would have made the frontmatter check below dead code that still looked
121
+ // like it worked. It did: a mutant that removed the check passed every test until this line was
122
+ // fixed. Both signals are read now, and the frontmatter is the one that decides.
123
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
124
+ const md = join(dir, entry.name, 'SKILL.md');
125
+ if (!existsSync(md)) continue;
126
+ let origin = null;
127
+ try {
128
+ const head = readFileSync(md, 'utf8').slice(0, 2048);
129
+ origin = (/^origin:\s*(\S+)\s*$/m.exec(head) || [])[1] || null;
130
+ } catch { continue; }
131
+ if (origin !== CATALOG_ORIGIN) present.push(entry.name);
132
+ }
133
+ // Declared-but-absent stays meaningful for anyone who did fill the list by hand, and it is the
134
+ // only half a derivation cannot cover: a file that is gone cannot describe itself.
135
+ const missing = declared.filter((name) => !existsSync(paths.skillDir(name)));
136
+ return { present: present.sort(), missing, declared };
137
+ }
138
+
82
139
  export function doctor({ target, home, cwd }) {
83
140
  const root = cwd || process.cwd();
84
141
  const paths = targetPaths(target, home, cwd);
@@ -134,6 +191,11 @@ export function doctor({ target, home, cwd }) {
134
191
  latest: backups[0]?.id || null,
135
192
  },
136
193
  contextBudget: contextBudget({ target, home, cwd }),
194
+ // The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
195
+ // carry a perfectly healthy harness and still never clean up, on every machine but the one that
196
+ // ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
197
+ worktreeCleanup: worktreeCleanupStatus(root),
198
+ ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
137
199
  sello: selloStatus(root),
138
200
  // Whether this harness has a design identity at all. `design` has always DECLARED that it
139
201
  // stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
@@ -383,6 +445,18 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
383
445
  });
384
446
  }
385
447
  const drift = divergence({ cwd, target, home });
448
+ // A separate finding, not a fourth clause of the one below: that one says this checkout
449
+ // is behind its own team, and the fix is to align with the declaration. This one says the
450
+ // declaration itself is behind the catalog, so aligning with it changes nothing. Folded
451
+ // together, the report would name the right skill and the wrong reason.
452
+ if (drift.floorMissing?.length) {
453
+ findings.push({
454
+ id: 'floor-missing',
455
+ severity: 'high',
456
+ summary: `.rsc.json predates a skill the catalog now requires: ${drift.floorMissing.join(', ')}. The always-on body routes work to it, so that route currently answers nowhere.`,
457
+ action: 'Run `npx @ericrisco/rsc sync` — it adds what the catalog requires and touches nothing else.',
458
+ });
459
+ }
386
460
  if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
387
461
  // The day-two case: a teammate changed the harness and this checkout has not caught
388
462
  // up. Reported always, even after someone declines to align — the divergence does not
@@ -10,6 +10,7 @@ import {
10
10
  resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
11
11
  } from '../targets/agents.js';
12
12
  import { readState, writeState } from './lib/state.js';
13
+ import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
13
14
  import { readManifest, writeManifest } from './lib/manifest-file.js';
14
15
  import { createBackup } from './lib/backups.js';
15
16
  import {
@@ -241,6 +242,23 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
241
242
  mkdirSync(dirname(versionFile(cwd)), { recursive: true });
242
243
  writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
243
244
  recordInManifest({ cwd, target, skillIds, agentIds, onboarding });
245
+ // The worktree cleanup's trigger. It lives in `.git/hooks/`, which is NOT cloned, so it has to be
246
+ // (re)written by every operation that touches a user's repo — install, sync and repair all land
247
+ // here. Target-agnostic on purpose: it is a git hook, not an assistant hook, and a cleanup that
248
+ // only worked for one of the sixteen assistants would be a cleanup nobody could rely on. The
249
+ // reaper travels with it for the same reason; until now only the Claude adapter materialized it,
250
+ // although `managedPathsForInstall` has always declared it for every target.
251
+ // Only where the accepted plan governs code hooks. An operations harness — company, research,
252
+ // content — declares a narrower set of paths, and writing a git hook it never agreed to would be
253
+ // acting outside the plan the user accepted (P4). Caught by the onboarding route-inventory gate,
254
+ // which is exactly the kind of catch that justifies its existence.
255
+ try {
256
+ if (policy?.codeHooks === false) throw new Error('code hooks not governed by the accepted plan');
257
+ mkdirSync(join(cwd, '.rsc'), { recursive: true });
258
+ cpSync(join(ROOT, 'targets', 'worktree-reaper.mjs'), join(cwd, '.rsc', 'worktree-reaper.mjs'));
259
+ const { installMergeHook } = await import('../targets/worktree-reaper.mjs');
260
+ if (existsSync(join(cwd, '.git'))) installMergeHook(cwd);
261
+ } catch { /* a project without git, or a read-only .git: the cleanup simply does not arm here */ }
244
262
  ignoreLocalState(cwd, target);
245
263
  return { ...state, backup };
246
264
  }
@@ -512,7 +530,21 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
512
530
  const ids = Object.keys(state.skills || {});
513
531
  const manifest = readManifest(cwd);
514
532
  const governedSkills = manifest?.onboarding?.plan?.policy?.skills;
515
- const declared = governedSkills || (ids.length ? ids : (manifest?.skills || []));
533
+ // The accepted plan is a FLOOR, not a ceiling. Reading it as the whole declaration is what deleted
534
+ // users' skills: `add` recorded the new skill in `manifest.skills` — the living list — and sync
535
+ // then rebuilt from a governed list that predated it and pruned the difference, recursively.
536
+ // Reported by a user and reproduced 2026-09-17. The receipt stays untouched, because it is
537
+ // hash-checked against what the user accepted and is not a log of what happened since.
538
+ const declaredRaw = governedSkills
539
+ ? [...new Set([...governedSkills, ...(manifest?.skills || [])])].sort()
540
+ : (ids.length ? ids : (manifest?.skills || []));
541
+ // A declaration is frozen at the moment it was written, so a skill the catalog later makes
542
+ // mandatory never reaches an existing harness: `add` and `install` apply the floor, sync did
543
+ // not, and sync is the only one an upgrade runs. 2.0.0 shipped the three-lane decisor inside
544
+ // `suggest` — already declared, so already upgraded — routing ordinary work to an `ftd` that
545
+ // sync had no reason to fetch. Applied only to a harness that already declares something: a
546
+ // directory declaring nothing is not a harness missing its floor.
547
+ const declared = declaredRaw.length ? withDefaultSkillFloor(declaredRaw) : declaredRaw;
516
548
  const declaredAgents = state.explicitAgents?.length ? state.explicitAgents : (manifest?.agents || []);
517
549
  if (!declared.length && !declaredAgents.length) return dryRun ? { dryRun: true, synced: [], syncedAgents: [], paths: [] } : { synced: [], syncedAgents: [], backup: null };
518
550
  if (dryRun) {
@@ -4,7 +4,12 @@
4
4
  // `orient` and `suggest` are runtime always-on. `bro` is installed by default
5
5
  // but remains conditionally loaded: its description routes human-language asks
6
6
  // without paying for its body on unrelated turns.
7
- export const DEFAULT_SKILL_FLOOR = Object.freeze(['orient', 'suggest', 'bro']);
7
+ //
8
+ // `ftd` joined in 2.0.1 by the same criterion as `suggest`: the always-on decisor
9
+ // routes ordinary work to `../ftd/SKILL.md`, so a harness without it has a default
10
+ // lane that points at nothing. Membership of every profile was not enough — a
11
+ // declaration written before the skill existed never gains it (see `syncInstalled`).
12
+ export const DEFAULT_SKILL_FLOOR = Object.freeze(['orient', 'suggest', 'bro', 'ftd']);
8
13
 
9
14
  export function withDefaultSkillFloor(skillIds = []) {
10
15
  return [...new Set([...DEFAULT_SKILL_FLOOR, ...skillIds])];
@@ -2,6 +2,7 @@ import { readManifest } from './manifest-file.js';
2
2
  import { readState } from './state.js';
3
3
  import { targetPaths } from '../../targets/index.js';
4
4
  import { existsSync } from 'node:fs';
5
+ import { DEFAULT_SKILL_FLOOR } from './default-skill-floor.js';
5
6
 
6
7
  // What the team declared versus what is on this machine.
7
8
  //
@@ -15,10 +16,13 @@ import { existsSync } from 'node:fs';
15
16
  // ownMissing — declared team skill, absent from the repo. Aligning says so and writes
16
17
  // nothing: it comes from the repo, not from us.
17
18
  // extra — installed BY RSC, no longer declared.
19
+ // floorMissing — a skill the catalog makes mandatory that this declaration predates.
20
+ // Not drift between teammates: drift between the harness and the catalog,
21
+ // and the only bucket that survives everyone running the same `git pull`.
18
22
  // A hand-written skill that nobody declared is in none of them. Not declaring is a valid
19
23
  // way to keep things of your own, not an oversight to correct.
20
24
  export function divergence({ cwd = process.cwd(), target, home } = {}) {
21
- const empty = { missing: [], extra: [], ownMissing: [] };
25
+ const empty = { missing: [], extra: [], ownMissing: [], floorMissing: [] };
22
26
  const manifest = readManifest(cwd);
23
27
  if (!manifest) return empty;
24
28
 
@@ -29,5 +33,8 @@ export function divergence({ cwd = process.cwd(), target, home } = {}) {
29
33
  missing: (manifest.skills || []).filter((id) => !installed.has(id)),
30
34
  extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
31
35
  ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
36
+ // Read off the declaration, not off the disk: a skill installed by hand into a harness
37
+ // that still does not declare it is one sync away from being pruned again.
38
+ floorMissing: DEFAULT_SKILL_FLOOR.filter((id) => !(manifest.skills || []).includes(id)),
32
39
  };
33
40
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  export const DOMAINS = [
7
7
  { title: 'Core & control plane', ids: ['init', 'harness', 'orient', 'suggest', 'bro', 'unslop', 'author-skill', 'sdd-init'] },
8
- { title: 'Spec-Driven Development', ids: ['sdd', 'constitution', 'idea-refinement', 'specify', 'clarify', 'plan', 'tasks', 'analyze', 'decision-challenge', 'implement', 'source-grounded-development', 'verify', 'review', 'simplify-code', 'ship', 'debug', 'worktrees', 'parallel'] },
8
+ { title: 'Spec-Driven Development', ids: ['ftd', 'sdd', 'constitution', 'idea-refinement', 'specify', 'clarify', 'plan', 'tasks', 'analyze', 'decision-challenge', 'implement', 'source-grounded-development', 'verify', 'review', 'simplify-code', 'ship', 'debug', 'worktrees', 'parallel'] },
9
9
  { title: 'Run a business', ids: ['finance-ops', 'invoicing', 'bookkeeping', 'pricing', 'sales-pipeline', 'lead-gen', 'cold-outreach', 'proposals', 'contracts', 'customer-support', 'client-onboarding', 'retention', 'hiring', 'people-ops', 'inventory', 'logistics-ops', 'procurement', 'meeting-notes', 'sop-builder', 'project-ops'] },
10
10
  { title: 'Raise & model money', ids: ['pitch-deck', 'investor-materials', 'financial-model', 'fundraising', 'unit-economics', 'grants'] },
11
11
  { title: 'Legal, privacy & compliance', ids: ['gdpr-privacy', 'terms-conditions', 'compliance', 'data-policy', 'ip-trademark'] },
@@ -181,8 +181,24 @@ export function buildOnboardingPlan(record, evidence) {
181
181
  if (/database|persistence|persistencia|base de datos/.test(goal)) goalSignals.push('persistence');
182
182
  if (/integration|integración|webhook|third-party|tercero/.test(goal)) goalSignals.push('external-integrations');
183
183
  const complexitySignals = [...new Set([...(evidence.complexitySignals || []), ...goalSignals])].sort();
184
- const needsSdd = isSoftware && (normalized.softwareScope !== 'small' || complexitySignals.length > 0);
185
- const profile = needsSdd ? 'core' : 'minimal';
184
+ // One harness. WHICH SKILLS you get no longer depends on a prediction nobody can make at minute
185
+ // zero — the kind of project, and how big the work will turn out to be. The old default installed
186
+ // eight skills and none of the ten phases the always-on layer routes work to, so the first real
187
+ // request of every new install stopped to install what the harness had just demanded.
188
+ //
189
+ // What still depends on this being a code project is the machinery that EXECUTES: subagents, code
190
+ // hooks and the gitmoji guard. A company-ops workspace has no git to hook and no diff to review,
191
+ // and writing those anyway is acting outside the plan the user accepted (P4) — the onboarding
192
+ // route-inventory gate refuses it, which is how this boundary was found rather than guessed.
193
+ const profile = 'core';
194
+ const codeProject = isSoftware;
195
+ const needsSdd = codeProject;
196
+ // Installed is not the same as practised, and conflating them is how "everyone gets the chain"
197
+ // turns into "everyone is told they are missing a constitution". The skills ship with every
198
+ // harness so the chain is always reachable; the FLOOR — the artifacts a project is expected to
199
+ // actually have — keeps asking the question it always asked: is this code, and is the work big
200
+ // enough that the chain will really be run here?
201
+ const practisesSdd = isSoftware && (normalized.softwareScope !== 'small' || complexitySignals.length > 0);
186
202
  const catalog = loadManifest();
187
203
  const catalogIds = new Set(catalog.skills.map((skill) => skill.id));
188
204
  const detectedSkills = (evidence.stacks || []).filter((stack) => catalogIds.has(stack));
@@ -210,11 +226,14 @@ export function buildOnboardingPlan(record, evidence) {
210
226
  const declared = (readManifest(evidence.root ?? process.cwd())?.skills ?? [])
211
227
  .filter((id) => catalogIds.has(id) && !inAnyProfile.has(id));
212
228
  const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills, ...declared])].sort();
213
- const baseAgents = needsSdd;
214
- const hooks = needsSdd;
229
+ const baseAgents = practisesSdd;
230
+ const hooks = practisesSdd;
215
231
  const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];
216
232
  const gitmojiGuard = hooks && normalized.targets.includes('claude');
217
- const decisions = skills.map((id) => detectedSkills.includes(id)
233
+ // `sdd` is installed everywhere now, but its DECISION is about practice, not presence — so where
234
+ // the chain is not practised the explicit `deferred` entry below must be the only one, or the
235
+ // generic "installed → selected" mapping would shadow it and `reassess` would see nothing to watch.
236
+ const decisions = skills.filter((id) => practisesSdd || id !== 'sdd').map((id) => detectedSkills.includes(id)
218
237
  ? selected(id, 'skill', `Detected ${id} evidence inside the selected project root.`, 'workspace-evidence')
219
238
  : selected(id, 'skill', needsSdd
220
239
  ? (id === 'sdd' && complexitySignals.length
@@ -227,7 +246,11 @@ export function buildOnboardingPlan(record, evidence) {
227
246
  { type: 'complexity-added', atLeast: 1 },
228
247
  { type: 'manifest-with-implementation' },
229
248
  ];
230
- if (!needsSdd) decisions.push(deferred('sdd', 'workflow', isSoftware
249
+ // Deferred means "not practised here yet", NOT "not installed". The skills ship with every harness
250
+ // so the chain is always one request away; this decision is what `reassess` watches, and what the
251
+ // floor reads. Collapsing the two would have deleted reassess's reason to exist as a side effect
252
+ // of an installer change, which is not a decision an installer change gets to make.
253
+ if (!practisesSdd) decisions.push(deferred('sdd', 'workflow', isSoftware
231
254
  ? 'The software scope is small, so specification overhead is not justified yet.'
232
255
  : 'SDD applies to substantial software work, which is not the declared project purpose.', sddTriggers, softwareTriggers));
233
256
  if (baseAgents) {
@@ -251,7 +274,7 @@ export function buildOnboardingPlan(record, evidence) {
251
274
  baseAgents,
252
275
  agents,
253
276
  alwaysOn: true,
254
- codeHooks: needsSdd,
277
+ codeHooks: practisesSdd,
255
278
  gitmojiGuard,
256
279
  memory: true,
257
280
  context7: false,
@@ -274,8 +297,7 @@ export function buildOnboardingPlan(record, evidence) {
274
297
  const floorPaths = [
275
298
  '01-TOOLS/_TEMPLATE/',
276
299
  '02-DOCS/wiki/harness/',
277
- ...(decisions.some((decision) => decision.id === 'sdd' && decision.state === 'selected')
278
- ? ['02-DOCS/wiki/sdd/constitution.md'] : []),
300
+ ...(practisesSdd ? ['02-DOCS/wiki/sdd/constitution.md'] : []),
279
301
  ].sort();
280
302
  return {
281
303
  schemaVersion: 1,
package/scripts/rsc.js CHANGED
@@ -556,6 +556,27 @@ async function guardCollisions(targets, ids) {
556
556
  return confirm('Replace them anyway?');
557
557
  }
558
558
 
559
+ // The governed list that `sync` reads lives INSIDE the onboarding receipt, not in `manifest.skills`.
560
+ // Updating one without the other is what deleted users' skills: `add` installed a skill and recorded
561
+ // it in `manifest.skills`, `sync` then read a governed list that predated the install, and pruned
562
+ // everything absent from it — recursively. Reported by a user, reproduced 2026-09-17: nine declared
563
+ // skills against eight governed, visible in the manifest before sync was even run.
564
+ //
565
+ // So the two travel together, always. Any command that changes what is installed carries both, and
566
+ // `applyInstall` persists the receipt rather than conserving the stale one.
567
+ function governedBy(receipt, skills) {
568
+ // The receipt is hash-checked against the plan the user accepted, so its policy is NOT a place to
569
+ // record what happened afterwards: rewriting `plan.policy.skills` made every install report "the
570
+ // receipt does not match its accepted plan id", which is the integrity check doing its job.
571
+ //
572
+ // An earlier fix for the reported data loss did exactly that, and it was wrong — the right place
573
+ // was never `add`, it was `sync`, which treated the accepted plan as the only declaration and
574
+ // pruned everything absent from it. The policy still travels for hooks and agents; what must not
575
+ // travel is a rewritten receipt.
576
+ if (!receipt) return { policy: undefined, onboarding: undefined };
577
+ return { policy: { ...receipt.plan.policy, skills }, onboarding: undefined };
578
+ }
579
+
559
580
  async function main() {
560
581
  // One resolution for every command, doctor included: `doctor` used to resolve on its
561
582
  // own and could report a different assistant than the one just installed into.
@@ -600,24 +621,43 @@ async function main() {
600
621
  const currentManifest = readManifest();
601
622
  const receipt = currentManifest?.onboarding;
602
623
  const maintainedIds = receipt ? [...new Set([...(currentManifest.skills || []), ...(receipt.plan.policy.skills || []), ...ids])].sort() : ids;
603
- const policy = receipt ? { ...receipt.plan.policy, skills: maintainedIds } : undefined;
604
- for (const t of targets) await applyInstall({ skillIds: maintainedIds, agentIds: selected.agents, target: t, policy });
624
+ const { policy, onboarding } = governedBy(receipt, maintainedIds);
625
+ for (const t of targets) await applyInstall({ skillIds: maintainedIds, agentIds: selected.agents, target: t, policy, onboarding });
605
626
  markMaintenanceDrift(`add ${requested.join(',')}`);
606
627
  say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
607
628
  return void say(' ↻ Reload/restart your assistant so the new skill activates.');
608
629
  }
609
630
  case 'install': {
631
+ // Before the harness check on purpose: an obsolete flag is an argument error, and telling
632
+ // someone to run onboarding first when what they actually typed no longer exists sends them
633
+ // down the wrong path. There is one harness now, so there is nothing to choose. A flag that
634
+ // silently stops meaning what it used to is worse than one that is gone — a script would keep
635
+ // passing `--profile minimal` and keep believing it installed eight skills. Refused, with what
636
+ // replaced it (P6).
637
+ if (flag('profile')) {
638
+ say('⚠️ `--profile` no longer exists: rsc installs one harness — the always-on layer, the FTD lane and the SDD chain.');
639
+ say(' Everything else still arrives on demand: `npx @ericrisco/rsc add <id>`.');
640
+ return;
641
+ }
610
642
  if (!hasDeclaredHarness()) return onboardingRequired(onboardingInput(targets));
611
- const profile = flag('profile') || 'minimal';
612
643
  const without = argv.filter((a, i) => argv[i - 1] === '--without');
613
- let ids = skillsForProfile(loadManifest(), profile);
644
+ let ids = skillsForProfile(loadManifest(), 'core');
614
645
  ids = withDefaultSkillFloor(ids).filter((id) => !without.includes(id));
615
646
  if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
616
647
  const receipt = readManifest()?.onboarding;
617
- const policy = receipt ? { ...receipt.plan.policy, skills: ids } : undefined;
618
- for (const t of targets) await applyInstall({ skillIds: ids, target: t, policy });
619
- markMaintenanceDrift(`install ${profile}`);
620
- say(`✅ Profile '${profile}' installed for ${targets.join(', ')} (${ids.length} skills)`);
648
+ // Deliberately NOT persisting the receipt here, unlike `add` above. `install --profile` REPLACES
649
+ // the set rather than adding to it, so writing it back would overwrite what the accepted plan
650
+ // declared with whatever profile was just named — measured: `install --profile minimal` after an
651
+ // SDD onboarding erased the SDD chain from the governed list, and the floor check then reported
652
+ // nothing pending. Unioning instead would leave `install` pruning to the profile while the
653
+ // governed list asked for more, and `sync` putting them back: churn.
654
+ // Now that profiles are gone this set IS the harness, so persisting it no longer overwrites a
655
+ // narrower accepted plan with a wider guess: they are the same set. The desync `add` had is
656
+ // therefore closed here too.
657
+ const { policy, onboarding } = governedBy(receipt, ids);
658
+ for (const t of targets) await applyInstall({ skillIds: ids, target: t, policy, onboarding });
659
+ markMaintenanceDrift('install harness');
660
+ say(`✅ Harness installed for ${targets.join(', ')} (${ids.length} skills)`);
621
661
  printAgentHandoff();
622
662
  return;
623
663
  }
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: ftd
3
+ description: "Use for ordinary authorised change — the default lane once the decisor has ruled out a question and not selected the chain. Keeps one feature document per feature in 02-DOCS holding intent, scope, checklist, evidence and next step, checks tasks off only against observed proof, and branches when the work writes code. NOT the ten-phase chain (that is `sdd`), NOT the classification itself (that is the decisor in `suggest`), NOT a request for information, which writes nothing."
4
+ tags: [ftd, lane, default, feature-document, evidence]
5
+ recommends: [worktrees, debug, verify]
6
+ profiles: [minimal, core, full]
7
+ origin: risco
8
+ ---
9
+
10
+ # FTD — Fast-Track Development
11
+
12
+ The lane ordinary work takes. Not a lighter methodology: the **same** discipline about evidence with
13
+ none of the coordination the ten-phase chain exists to buy. One document instead of five, written
14
+ because the work might be interrupted, not because somebody has to approve it first.
15
+
16
+ You are here because the decisor in `../suggest/SKILL.md` ruled that this turn authorises a change
17
+ and did not select `../sdd/SKILL.md`. If it is still a question, you are in the wrong place: go back
18
+ and answer it.
19
+
20
+ ## Why this lane exists
21
+
22
+ The constitution's seventh principle has said it from the start: *«la fricción es proporcional al
23
+ riesgo. Silencio para lo trivial, ceremonia solo donde el fallo es caro. Un sistema que molesta en el
24
+ 80% inofensivo acaba apagado, y apagado no protege el 20% que importa.»*
25
+
26
+ The harness spent a long time contradicting it — every request that sounded like building went
27
+ through ten phases. This lane is that principle finally implemented, not a new idea.
28
+
29
+ ## The feature document
30
+
31
+ One per feature, in the workspace wiki under `02-DOCS/wiki/ftd/<slug>.md`. **Never inside the code
32
+ repository**: whoever clones it should get code, not somebody else's notes in progress.
33
+
34
+ It is written **before the first change**, not after, and it holds exactly this:
35
+
36
+ ```markdown
37
+ # <feature>
38
+
39
+ ## Intent — what changes, and why it is worth doing
40
+ ## Scope — what is in, and what is explicitly not
41
+ ## Checklist — one line per task, each with what would prove it done
42
+ ## Evidence — the observed output per completed task
43
+ ## Next — the single next step, so an interruption costs nothing
44
+ ```
45
+
46
+ No decision journal, no phase records, no approvals. If a decision was meaningful, one line of
47
+ rationale goes next to the task it belongs to.
48
+
49
+ **The checkbox is not the proof.** A task is checked off when its stated check has been *observed* —
50
+ the test output, the command's result, the page rendering. A task whose check was skipped, failed or
51
+ could not run is recorded as such, honestly. The tenth principle applies here exactly as it does in
52
+ the chain: a result asserted without evidence is treated as not done.
53
+
54
+ ## Isolation
55
+
56
+ If the work writes code, it runs on a branch — never on the default branch, so `origin` and the local
57
+ tree stay clean. **No worktree**: this lane is short enough that setting one up and tearing it down
58
+ costs more than it protects. Work that only touches documentation, the wiki or configuration needs no
59
+ branch at all.
60
+
61
+ `../worktrees/SKILL.md` owns the isolation mechanics if you need them. Once the branch lands, the
62
+ cleanup is automatic and you do not run anything.
63
+
64
+ ## Growing out of the lane
65
+
66
+ A feature that turns out to need the chain is **promoted, not restarted**: the feature document
67
+ becomes the input to `../specify/SKILL.md`, and no completed work is discarded. Promotion happens for
68
+ the same reason the chain is ever entered — durable artifacts would remove a substantial ambiguity —
69
+ and never because the work turned out to be big.
70
+
71
+ ## Anti-patterns → STOP
72
+
73
+ | If you're about to… | Reality / Fix |
74
+ | --- | --- |
75
+ | Skip the document because the change is small | The document is what survives an interruption. Write it, keep it short. |
76
+ | Check a task off because you believe it works | Observed proof or it is not done (P10). Record the check you could not run. |
77
+ | Write the document into the code repository | It belongs in `02-DOCS`. A clone should get code, not notes. |
78
+ | Open a worktree "to be safe" | That is the chain's ceremony arriving through the back door (P7). |
79
+ | Escalate to the chain because the change is large | Size never selects it. Only unresolved ambiguity that artifacts would settle. |
80
+ | Produce a spec, plan or task artifact here | Those are the chain's. This lane has one document. |
81
+
82
+ ## See Also
83
+
84
+ - `../suggest/SKILL.md` — the decisor that routed here, and the other two lanes.
85
+ - `../sdd/SKILL.md` — the ten-phase chain, for when artifacts genuinely earn their cost.
86
+ - `../verify/SKILL.md` — the evidence gate, when this lane's checks need the full battery.
87
+ - `../debug/SKILL.md` — a failure to reproduce before fixing; comes back here afterwards.
88
+
89
+ ## Orientación (siempre)
90
+
91
+ Cierra cada turno con el **bloque-brújula** (📍 dónde estás · ✅ qué hiciste · 🧭 por qué · ➡️ siguiente,
92
+ terminando en pregunta), calibrado al dial de `02-DOCS/wiki/harness/user-profile.md`. Nunca termines
93
+ en seco. Protocolo completo: skill `orient`.
@@ -0,0 +1,66 @@
1
+ skill: ftd
2
+
3
+ # Cases for FTD — Fast-Track Development, the lane ordinary authorised change takes.
4
+ # FTD keeps ONE feature document per feature in 02-DOCS (intent, scope, checklist,
5
+ # evidence, next step), checks tasks off only against observed proof, and branches when
6
+ # the work writes code. It is NOT the ten-phase chain (sdd), NOT the classification
7
+ # itself (the decisor lives in the always-on suggest body), and NOT a request for
8
+ # information — those write nothing at all.
9
+
10
+ should_trigger:
11
+ - prompt: "Añade exportación a CSV usando los filtros del informe que ya existen."
12
+ why: "An outcome asked for plainly: the change is authorised, understood, and no durable spec would settle anything the code does not. This is the default lane, entered without ceremony."
13
+
14
+ - prompt: "Rename the users table across every call site that touches it — there are about forty."
15
+ why: "Large and mechanical. Size is exactly what must NOT escalate to the chain; forty call sites carry no unresolved design question, so it stays here with one feature document."
16
+
17
+ - prompt: "Bump the dependency and fix whatever breaks."
18
+ why: "Authorised, open-ended in effort but not in intent. The feature document exists so an interruption mid-way costs nothing, which is the whole reason this lane writes one."
19
+
20
+ - prompt: "Migra los tres endpoints al nuevo formato de respuesta y deja constancia de lo que has comprobado."
21
+ why: "Multiple coordinated steps plus an explicit ask for evidence — the checklist-with-observed-proof shape this skill owns, without any phase handoffs."
22
+
23
+ - prompt: "Arregla el typo del botón de guardar."
24
+ why: "Trivial and authorised. Still this lane: the document is short, but the lane is the same, and the boundary is authorisation rather than size."
25
+
26
+ should_not_trigger:
27
+ - prompt: "¿Por qué el login tarda tanto en producción?"
28
+ route_to: "debug"
29
+ why: "A question about a failure, with nothing authorising a change. Investigation writes nothing; if it becomes a fix, debug reproduces the cause first."
30
+
31
+ - prompt: "Compare Postgres and SQLite for this workload and recommend one."
32
+ route_to: "suggest"
33
+ why: "Information only. The decisor's read-only lane answers and creates no artifact — a recommendation is not permission to implement it."
34
+
35
+ - prompt: "No tengo claro si los permisos van por rol o por recurso, y afecta al esquema, a la API y a la UI."
36
+ route_to: "specify"
37
+ why: "A substantial unresolved ambiguity that durable spec/plan/tasks would settle. The harness proposes the chain here; only an explicit request or an accepted proposal enters it."
38
+
39
+ - prompt: "Quiero especificar bien el sistema de permisos antes de tocar nada."
40
+ route_to: "specify"
41
+ why: "The chain requested by name. Explicit selection always wins over the decisor's default."
42
+
43
+ - prompt: "Revisa este diff y dime si tiene bugs."
44
+ route_to: "code-review"
45
+ why: "Reading a finished diff for defects is a review, not a change. Nothing here authorises writing, so no feature document is created."
46
+
47
+ capability:
48
+ - scenario: "A developer at accompaniment level L2 says: 'Añade exportación a CSV usando los filtros del informe que ya existen.' Show how the lane runs from the first word to delivery."
49
+ must_include:
50
+ - "Names the lane out loud in one line before acting, as the decisor requires — this is FTD, not a question and not the chain"
51
+ - "Creates one feature document at 02-DOCS/wiki/ftd/<slug>.md BEFORE the first change, and says which document it created and how many tasks it holds"
52
+ - "Writes the document into the workspace wiki, never into the code repository, so whoever clones the repo gets code and not somebody's notes in progress"
53
+ - "Keeps the document to intent, scope, checklist, evidence and next step — no spec, no plan, no tasks artifact, no decision journal"
54
+ - "States for each checklist item what would prove it done, before doing it"
55
+ - "Opens a branch because the work writes code, and explicitly does NOT open a worktree"
56
+ - "Checks a task off only after observing its stated check, and records a check that failed, was skipped or could not run rather than omitting it (P10)"
57
+ - "Does not escalate to the ten-phase chain on the basis of size, file count or perceived risk"
58
+ - "Closes by reporting the verified outcome, every failed or pending check, and the single next step"
59
+
60
+ - scenario: "Mid-way through the same work, the developer says the requirement has changed: the export must also respect a date range that does not exist in the current filters. Show how the lane absorbs it."
61
+ must_include:
62
+ - "Revises the existing feature document rather than starting a second one, preserving completed work that is still valid"
63
+ - "Adds the genuinely new tasks, and reopens any invalidated task with the reason written next to it"
64
+ - "Distinguishes a change the user authorised from a finding the agent made — a finding alone never expands scope"
65
+ - "Considers promotion to the chain only if durable artifacts would settle a substantial ambiguity, and says so rather than escalating because the work grew"
66
+ - "If promoted, carries the feature document into specify as input instead of discarding the work already done"
@@ -0,0 +1,24 @@
1
+ {
2
+ "about": "Fixture corpus for the three-lane decisor. The decisor is prose and judgement — chosen deliberately over deterministic signals — so this corpus is the mechanism that makes it checkable (P2). Scored by the behaviour eval, not by npm test; what npm test checks is that the corpus stays honest: every lane represented, every case reasoned, and the cases this lane exists because of still present.",
3
+ "lanes": {
4
+ "answer": "information only; writes nothing, creates no artifact",
5
+ "ftd": "authorised change, ordinary; one feature document",
6
+ "sdd": "the ten-phase chain, by explicit request or accepted proposal only"
7
+ },
8
+ "cases": [
9
+ { "request": "¿Por qué el login tarda tanto en producción?", "expect": "answer", "why": "investigation; nothing authorises a change" },
10
+ { "request": "Compare Postgres and SQLite for this workload and recommend one", "expect": "answer", "why": "comparison and a recommendation are information, not a mandate" },
11
+ { "request": "Revisa este diff y dime si tiene bugs", "expect": "answer", "why": "review is read-only even though it is about code that changes" },
12
+ { "request": "Audita la seguridad de la capa de autenticación", "expect": "answer", "why": "audit; a finding is not permission to fix it" },
13
+ { "request": "¿Y si añadiéramos exportación a CSV?", "expect": "answer", "why": "thinking aloud about a feature is not a request to build it; ask before building" },
14
+ { "request": "Propón cómo estructurarías el módulo de pagos", "expect": "answer", "why": "proposal-only; the old gate would have started the chain here" },
15
+ { "request": "Añade exportación a CSV usando los filtros del informe que ya existen", "expect": "ftd", "why": "an outcome asked for plainly: authorised, understood, no ambiguity artifacts would settle" },
16
+ { "request": "Arregla el typo del botón de guardar", "expect": "ftd", "why": "trivial and authorised; one document, no ceremony" },
17
+ { "request": "Renombra la tabla de usuarios en las 40 llamadas que la usan", "expect": "ftd", "why": "large and mechanical: size alone must never select the chain" },
18
+ { "request": "Sube la versión de la dependencia y arregla lo que rompa", "expect": "ftd", "why": "authorised change with no unresolved design question" },
19
+ { "request": "Migra los tres endpoints al nuevo formato de respuesta", "expect": "ftd", "why": "many files, but nothing a spec would settle that the code does not" },
20
+ { "request": "Quiero especificar bien el sistema de permisos antes de tocar nada", "expect": "sdd", "why": "explicit request for the chain" },
21
+ { "request": "Vamos a hacer una spec de esto", "expect": "sdd", "why": "explicit request, stated in the user's own words" },
22
+ { "request": "No tengo claro si los permisos van por rol o por recurso, y afecta al esquema, a la API y a la UI", "expect": "sdd", "why": "propose the chain: durable artifacts would settle a substantial ambiguity, and only an accepted proposal enters it" }
23
+ ]
24
+ }
@@ -116,16 +116,18 @@ 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
- npx @ericrisco/rsc worktrees reap <path-of-this-feature-worktree> # by name: this one, not all of them
119
+ # the merge above already retired the worktree and its branch: post-merge does it
120
120
  git branch -d feature/<slug> 2>/dev/null || true # no worktree involved? then the branch alone
121
121
  git push origin --delete feature/<slug> 2>/dev/null || true
122
122
  ```
123
123
 
124
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`).
125
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)`).
126
+ **You no longer run the cleanup; you check that it ran.** A `post-merge` hook retires the worktree and its branch the moment the work lands, on both landing paths — the local merge above and the `pull --ff-only` after a forge merge. It removes only what the reaper already classifies as safe, so anything holding unsaved work survives with its reason, exactly as before.
127
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.
128
+ This used to be an instruction in this paragraph, and it was skipped on both features that reached it. That is why it is a hook now: an instruction at the end of a long phase is the least reliable place to put a step that matters (P1).
129
+
130
+ If something did survive, it survived *for a reason* — read it rather than forcing it: `npx @ericrisco/rsc worktrees` lists what is left and why. `worktrees reap <path>` still exists for the backlog and for anything the hook deliberately refused; name the path, because bare `reap` retires every worktree that qualifies and with `parallel` running two streams that is how shipping A deletes B. Naming a path selects it, it does not accept the risk: add `--confirm` only after the user has seen the reason and said yes.
129
131
 
130
132
  ### Option 2 — pull request
131
133
 
@@ -163,7 +165,7 @@ Implements `02-DOCS/wiki/sdd/specs/<slug>.md`. <the user-facing reason>
163
165
 
164
166
  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.
165
167
 
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.
168
+ Once it is merged, pull the trunk — `git switch main && git pull --ff-only` — and the cleanup rides along with that pull: `post-merge` fires on a fast-forward too, verified. 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
169
 
168
170
  For stacked PRs, create each PR against the previous branch or against a feature-track branch, with bodies that name their dependency:
169
171
 
@@ -12,7 +12,7 @@ origin: risco
12
12
  Your body is injected at the start of **every** session and again after every compaction, so you
13
13
  are the one piece guaranteed to be present before any other skill is matched. Two jobs, in order:
14
14
 
15
- 1. **Route feature intent into SDD** before any code is written.
15
+ 1. **Classify every turn into one of three lanes** before anything is written.
16
16
  2. **Keep the session equipped** — spot the skill the task needs but the user does not have.
17
17
 
18
18
  Everything below is what only this layer can do. The method behind each rule lives in the skill that
@@ -20,34 +20,31 @@ owns it; this is the pointer, not the manual.
20
20
 
21
21
  ---
22
22
 
23
- ## 1. Routing: feature intent goes through SDD first
23
+ ## 1. The decisor: classify the turn before acting
24
24
 
25
- The moment someone wants something to **exist or behave differently** — build, add, change,
26
- integrate, "it should also…", "¿y si…?", in any language — route the turn to `specify` before any
27
- code is written. No skill outranks this. The stack and builder skills that match the same request
28
- (`nextjs`, `react`, `fastapi`, `flutter`, `go`, `postgresdb`, `building-agents`, `design`,
29
- `chatbot`, `course-builder`, `marketing`…) run **inside** the chain, after the plan is approved —
30
- matching strongly is not a reason to skip ahead.
25
+ Every turn takes one of three lanes, and you name the one you took in a line.
31
26
 
32
- Two exceptions, and say out loud when you take one:
27
+ **Answer** — the request asks for information: explain, compare, investigate, audit, review,
28
+ recommend. Read-only: write nothing, create no artifact, delegate no writer. Asking you to *think
29
+ about* building something is still this lane; only asking to build authorises it. When change
30
+ intent is ambiguous, ask one question and stay here — ambiguity slows the lane, never raises it.
33
31
 
34
- - a genuinely one-line, low-risk change (typo, copy tweak, config bump, non-breaking bump) — just do it;
35
- - a bug fix restoring intended behaviour — that is `debug`, then resume.
32
+ **FTD — Fast-Track Development** — the request authorises a change. The default for ordinary work,
33
+ entered without ceremony. One feature document per feature in `02-DOCS`: intent, scope, checklist,
34
+ evidence, next step. Tasks are checked off against observed proof, never intention. A branch if it
35
+ writes code, none if it only touches docs, wiki or config; never a worktree. → `../ftd/SKILL.md`.
36
36
 
37
- When you cannot tell, choose `specify`. A skipped spec is where drift hides.
37
+ **SDD** — the ten-phase chain, unchanged, and **never entered by the harness alone**. Propose it only
38
+ when durable spec/plan/tasks would remove a *substantial* ambiguity — a test of usefulness, not of
39
+ size — and enter it only on explicit request or an accepted proposal. Size, file count and perceived
40
+ risk never select it. → `../sdd/SKILL.md`.
38
41
 
39
- Judge the **meaning**, not the wording: the trigger is semantic, so it holds in any language,
40
- including ones with no example here. A URL plus a description of desired behaviour is a feature
41
- request. If the user engaged **SDD autopilot**, that one consent covers the whole run — advance
42
- through the phases without re-asking.
42
+ Judge the **meaning**, not the wording: the trigger is semantic in any language. A bug fix restoring
43
+ intended behaviour is `debug`. Autopilot consent covers a whole run — advance without re-asking.
43
44
 
44
- If `specify` / `sdd` are not installed, offer to add them (§2) before routing.
45
- When `.rsc.json` records SDD as deferred, first run `npx @ericrisco/rsc@latest reassess`.
46
- Stay silent on `RSC_REASSESSMENT_NO_CHANGE`. If it reports new evidence, explain what changed and
47
- show the new plan command; SDD still needs a newly accepted plan id and is never added silently.
48
-
49
- Method, phase map and full decision table: `../sdd/SKILL.md`. On Claude Code this rule also arrives
50
- as a per-turn hook; the brevity here is deduplication, not relaxation.
45
+ Lane's skill missing? Offer it (§2) first. SDD recorded as deferred in `.rsc.json`? Run
46
+ `npx @ericrisco/rsc@latest reassess`; silent on `RSC_REASSESSMENT_NO_CHANGE`, and on new evidence
47
+ show the plan command — SDD still needs a newly accepted plan id, never added silently.
51
48
 
52
49
  ---
53
50
 
@@ -90,19 +90,31 @@ universal fallback and is exactly what the native tool does under the hood.
90
90
  | You have… | Create | Leave intact | Discard |
91
91
  | --- | --- | --- | --- |
92
92
  | A native worktree tool (`EnterWorktree`-style) | enter, named for the feature slug; the session's working directory moves into the isolated checkout | exit with `keep` — worktree and branch stay on disk for later | exit with `remove` — deletes both when the work is done or abandoned |
93
- | Plain git only | `git worktree add -b feat/<slug> …` | leave the dir; it persists | `git worktree remove` + `git branch -d` |
93
+ | Plain git only | `git worktree add -b feat/<slug> .worktrees/<slug>` | leave the dir; it persists until its work lands | `git worktree remove` + `git branch -d` |
94
94
 
95
95
  Removing a worktree that holds uncommitted or unmerged work must be an explicit, confirmed choice,
96
96
  never a silent cleanup: refuse the silent path and confirm the discard with the user, quoting what
97
97
  would be lost.
98
98
 
99
+ **You do not have to remember to clean up.** Once the work lands on the trunk, a `post-merge` hook
100
+ retires the worktree and its branch on its own — it removes only what the reaper already classifies
101
+ as safe, and anything holding unsaved work is refused exactly as it is above. Nothing to run, and
102
+ nothing to remember: the previous version of this skill asked an agent to remember, and it was
103
+ skipped on both features that reached that point.
104
+
99
105
  ```bash
100
106
  # from the repo root, default branch up to date
101
107
  git fetch origin
102
- git worktree add -b feat/<slug> ../<repo>-<slug> origin/<default-branch>
103
- # work happens in ../<repo>-<slug>; the main checkout is untouched
108
+ git worktree add -b feat/<slug> .worktrees/<slug> origin/<default-branch>
109
+ # work happens in .worktrees/<slug>; the main checkout is untouched
104
110
  ```
105
111
 
112
+ **Inside the repo, always — never a sibling.** `.worktrees/` is already gitignored by the harness,
113
+ the reaper already recognises it as its own, and the difference matters after the work lands: a
114
+ sibling directory sits outside the tree, so it survives every cleanup of the repository and stops
115
+ being visible to whoever would retire it. Two of them accumulated here before this was written down.
116
+ Deleting the repo should delete its worktrees with it.
117
+
106
118
  ```bash
107
119
  # branch-only path (the degenerate case: no separate directory needed)
108
120
  git switch -c feat/<slug> origin/<default-branch>
@@ -89,18 +89,19 @@ export function claimOnce(key, { dir = markerDir(), windowMs = 30_000, now = Dat
89
89
  // The SDD gate text lives here, not inline in userprompt-gate.mjs, so it has exactly one
90
90
  // definition: the hook emits it and `rsc doctor` measures it from the same constant. Anything
91
91
  // that reports a byte count for text it does not own drifts the moment the text changes.
92
- export const SDD_GATE_TEXT = `===== rsc SDD new-feature gate (highest precedence) =====
93
- Before acting on this turn: if the user wants to BUILD, ADD, or CHANGE a feature — in
94
- ANY language, judged by intent, not by keywords — you MUST route it through SDD via
95
- \`specify\` FIRST. No feature code is written by ANY skill (stack skills included —
96
- nextjs/react/fastapi/flutter/go/postgresdb/building-agents/design — and any builder skill
97
- such as chatbot/course-builder/marketing) until a spec AND a plan exist and the user has
98
- approved them. No skill outranks this gate.
99
- - Unclear / in-between? -> \`specify\` (the safe default; a skipped spec is where drift hides).
100
- - One-line / low-risk change, or a bug fix restoring intended behaviour? -> skip the chain,
101
- do it, and say so out loud.
102
- Full gate + decision table live in the always-on \`suggest\` body; method in \`sdd\`.
103
- =========================================================
92
+ export const SDD_GATE_TEXT = `===== rsc lane decisor (highest precedence) =====
93
+ Classify this turn before acting, and name the lane in one line.
94
+ - Asks for information — explain, compare, audit, review, propose? -> Answer.
95
+ Write nothing and create no artifact. Change intent unclear -> ask one
96
+ question and stay read-only; ambiguity slows the lane, it never raises it.
97
+ - Authorises a change? -> FTD (Fast-Track Development). One feature document,
98
+ tasks checked off only against observed proof, a branch if it writes code.
99
+ - SDD, the ten-phase chain, is never entered by the harness alone. Propose it
100
+ only when durable spec/plan/tasks would remove a substantial ambiguity, and
101
+ enter it only on an explicit request or an accepted proposal. Size, file
102
+ count and risk never select it.
103
+ Method: \`ftd\` · chain: \`sdd\` · full decisor in the always-on \`suggest\` body.
104
+ =================================================
104
105
  `;
105
106
 
106
107
  /**
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // Imported by `.rsc/session-start.mjs` (the sweep) and by `scripts/rsc.js` (`rsc worktrees`), so the
18
18
  // rule exists once and both entry points cannot drift apart. Same shape as `sello.mjs`.
19
- import { existsSync, realpathSync } from 'node:fs';
19
+ import { existsSync, realpathSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
20
20
  import { join, resolve, dirname, basename, relative, sep } from 'node:path';
21
21
  import { spawnSync } from 'node:child_process';
22
22
 
@@ -365,6 +365,106 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
365
365
  * README.md", judged that a nuisance, confirmed, and the confirmation landed on a `production.env`
366
366
  * the message never mentioned. The module knew. It just did not say.
367
367
  */
368
+ /**
369
+ * The unattended half: remove what classification already called `safe`, and nothing else.
370
+ *
371
+ * `sweep` offers and never acts, on purpose — there is a human reading it. This runs from a git hook
372
+ * the moment work lands, where there is nobody to ask, so the two verdicts that would have become a
373
+ * question become a refusal instead. It adds NO judgement of its own: the judgement is the dangerous
374
+ * part, it is written above, and it is already tested in both directions.
375
+ *
376
+ * Two properties its caller depends on, both load-bearing:
377
+ * - it never throws. The caller is git, mid-merge. A throw here would turn a cleanup into a failed
378
+ * merge, which is a far worse bug than the one this fixes.
379
+ * - it is silent when there is nothing to do, so the common merge prints nothing.
380
+ */
381
+ export function autoReap(root) {
382
+ const result = { reaped: [], skipped: [], disabled: false };
383
+ try {
384
+ if (!isCleanupEnabled(root)) {
385
+ result.disabled = true;
386
+ return result;
387
+ }
388
+ for (const candidate of classifyWorktrees(root)) {
389
+ // Not the protection, and it must not be mistaken for one: `reapWorktree` refuses an `ask`
390
+ // on its own, and that refusal is the gate — mutation-tested by 18b and 23. Verified here on
391
+ // 2026-09-17 by removing this line: every test still passed. It stays because skipping early
392
+ // avoids re-classifying the whole repository once per candidate, and because the recorded
393
+ // reason is then the verdict itself rather than a message written for a human to read.
394
+ if (candidate.verdict !== 'safe') {
395
+ result.skipped.push({ path: candidate.path, reason: candidate.reasons.join(', ') || candidate.verdict });
396
+ continue;
397
+ }
398
+ const out = reapWorktree(root, candidate.path);
399
+ if (out.removed) result.reaped.push(candidate.path);
400
+ else result.skipped.push({ path: candidate.path, reason: out.reason });
401
+ }
402
+ } catch (err) {
403
+ // Swallowed deliberately, and recorded rather than discarded: the merge must survive whatever
404
+ // went wrong in here, but a silent failure that leaves no trace is how this rots unnoticed.
405
+ result.skipped.push({ path: root, reason: `cleanup could not run: ${err.message}` });
406
+ }
407
+ return result;
408
+ }
409
+
410
+ export const HOOK_MARKER = '# rsc-managed worktree cleanup (post-merge) v1';
411
+
412
+ // Every branch exits 0. That is the whole contract with git: this hook runs in the middle of
413
+ // somebody's merge, and a cleanup that can turn a good merge into a failed one is a worse bug than
414
+ // the accumulation it exists to fix. Missing node, missing reaper, broken reaper, unreadable repo —
415
+ // all of them are "do nothing", never "fail".
416
+ const HOOK_BODY = `#!/bin/sh
417
+ ${'# rsc-managed worktree cleanup (post-merge) v1'}
418
+ # Retires worktrees whose work has just landed. Installed by rsc; safe to delete.
419
+ # Turn it off for this project with: .rsc/.no-worktree-cleanup
420
+ hook_dir=$(dirname "$0")
421
+ if [ -x "$hook_dir/post-merge.rsc-local" ]; then
422
+ "$hook_dir/post-merge.rsc-local" "$@" || true
423
+ fi
424
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
425
+ [ -f "$root/.rsc/worktree-reaper.mjs" ] || exit 0
426
+ command -v node >/dev/null 2>&1 || exit 0
427
+ node "$root/.rsc/worktree-reaper.mjs" "$root" auto 2>/dev/null || true
428
+ exit 0
429
+ `;
430
+
431
+ /**
432
+ * Install the trigger. The judgement has existed and been tested for a while; what never existed was
433
+ * something that runs it at the moment work lands. `post-merge` is that moment, and it covers both
434
+ * of ship's landing paths — a local `merge --no-ff` and the `pull --ff-only` after a forge merge.
435
+ *
436
+ * A hook lives in `.git/hooks/`, which is not cloned. So this is called on install AND on repair,
437
+ * and `doctor` reports its absence: a trigger nobody re-installs is a trigger that quietly stops
438
+ * existing on every machine but the one that ran the installer.
439
+ *
440
+ * Somebody else's post-merge is moved aside and chained, never overwritten. Husky and lefthook put
441
+ * real work in there, and eating it to install a convenience would be indefensible.
442
+ */
443
+ export function installMergeHook(root) {
444
+ try {
445
+ const dir = join(real(root), '.git', 'hooks');
446
+ const hook = join(dir, 'post-merge');
447
+ mkdirSync(dir, { recursive: true });
448
+ if (existsSync(hook)) {
449
+ const current = readFileSync(hook, 'utf8');
450
+ if (current.includes(HOOK_MARKER)) {
451
+ // Already ours. Rewrite so an older body converges, but never chain ourselves behind
452
+ // ourselves — repair runs this repeatedly and stacking would run the cleanup N times.
453
+ writeFileSync(hook, HOOK_BODY);
454
+ chmodSync(hook, 0o755);
455
+ return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
456
+ }
457
+ renameSync(hook, join(dir, 'post-merge.rsc-local'));
458
+ chmodSync(join(dir, 'post-merge.rsc-local'), 0o755);
459
+ }
460
+ writeFileSync(hook, HOOK_BODY);
461
+ chmodSync(hook, 0o755);
462
+ return { installed: true, chained: existsSync(join(dir, 'post-merge.rsc-local')) };
463
+ } catch (err) {
464
+ return { installed: false, reason: err.message };
465
+ }
466
+ }
467
+
368
468
  export function refusal(candidate) {
369
469
  const d = candidate.details || {};
370
470
  const parts = (candidate.reasons || []).map((reason) => {
@@ -441,7 +541,11 @@ export function summarize(candidate, root) {
441
541
  if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
442
542
  const root = resolve(process.argv[2] || process.cwd());
443
543
  const candidates = classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
444
- if (process.argv[3] === 'reap') {
544
+ if (process.argv[3] === 'auto') {
545
+ // The unattended entry point, called by the post-merge hook. Prints only what it actually did.
546
+ const out = autoReap(root);
547
+ for (const p of out.reaped) process.stdout.write(`rsc: retired worktree ${p}\n`);
548
+ } else if (process.argv[3] === 'reap') {
445
549
  const one = process.argv[4];
446
550
  const targets = one ? [resolve(one)] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
447
551
  for (const t of targets) {