@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 +52 -15
- package/manifest.json +23 -2
- package/package.json +1 -1
- package/scripts/doctor.js +76 -2
- package/scripts/install-apply.js +33 -1
- package/scripts/lib/default-skill-floor.js +6 -1
- package/scripts/lib/divergence.js +8 -1
- package/scripts/lib/domains.js +1 -1
- package/scripts/lib/onboarding.js +31 -9
- package/scripts/rsc.js +48 -8
- package/skills/ftd/SKILL.md +93 -0
- package/skills/ftd/evals/cases.yaml +66 -0
- package/skills/ftd/evals/routing.json +24 -0
- package/skills/ship/SKILL.md +6 -4
- package/skills/suggest/SKILL.md +20 -23
- package/skills/worktrees/SKILL.md +15 -3
- package/targets/hook-once.mjs +13 -12
- package/targets/worktree-reaper.mjs +106 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@ericrisco/rsc)
|
|
8
8
|
[](https://www.npmjs.com/package/@ericrisco/rsc)
|
|
9
|
-
[](#the-catalog)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](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
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
2
|
+
"version": "2.0.1",
|
|
3
3
|
"counts": {
|
|
4
|
-
"skills":
|
|
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
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
|
package/scripts/install-apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/scripts/lib/domains.js
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
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 =
|
|
214
|
-
const hooks =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
...(
|
|
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
|
|
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(),
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
}
|
package/skills/ship/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
|
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
|
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -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. **
|
|
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.
|
|
23
|
+
## 1. The decisor: classify the turn before acting
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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>
|
|
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>
|
|
103
|
-
# work happens in
|
|
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>
|
package/targets/hook-once.mjs
CHANGED
|
@@ -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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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] === '
|
|
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) {
|