@ericrisco/rsc 3.0.7 → 3.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -12
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +69 -11
- package/scripts/install-apply.js +221 -18
- package/scripts/lib/constitution-draft.js +156 -0
- package/scripts/lib/onboarding-apply.js +31 -25
- package/scripts/lib/onboarding.js +26 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +109 -14
- package/skills/constitution/SKILL.md +2 -0
- package/skills/ftd/SKILL.md +9 -0
- package/skills/sdd/SKILL.md +2 -2
- package/skills/specify/SKILL.md +1 -1
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/knowledge-sync.mjs +490 -48
- package/targets/opt-outs.js +2 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- package/targets/session-memory-core.mjs +219 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/worktree-reaper.mjs +114 -4
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ rsc 3.0 fixes all four, by default, with no new commands to learn:
|
|
|
94
94
|
| **You choose how the project works** | The install asks once: straight on `main`, or branches and pull requests? Branches are recommended only for long-lived, complex code. With `main` the agent works on it and never asks about branches. With branches a commit on `main` is refused, and before each code change the agent asks: this branch, a new one, or unlock `main`? It never opens a branch on its own. | Say **«unlock main»** / **«lock main»**, or `rsc main unlock` / `lock` |
|
|
95
95
|
| **One workspace per agent** | If another assistant session is working in the same folder, new work goes to a worktree under `.worktrees/<branch>/`. It stays inside the project, is never committed, and is removed once that branch is merged. | `rsc isolation off` |
|
|
96
96
|
| **The agent picks the method** | A simple change takes Fast-Track (one document, proof for every task). Something big or complex takes the spec-driven chain. The agent decides and says why. You still approve *what* gets built: the spec and the clarifications, then step by step or autopilot. | Ask for the other lane |
|
|
97
|
-
| **Knowledge reaches everyone** | `01-TOOLS/` and `02-DOCS/` travel through one exchange branch, `rsc/knowledge`. They go up when your turn ends and come down into whatever branch each person is on. They reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
|
|
97
|
+
| **Knowledge reaches everyone** | `01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` travel through one exchange branch, `rsc/knowledge`. They go up when your turn ends and come down into whatever branch each person is on. They reach `main` inside your normal pull requests. | `rsc knowledge-sync off` |
|
|
98
98
|
|
|
99
99
|
**Commit, push and pull request without a prompt each time.** A harness installed from scratch lets
|
|
100
100
|
the agent run `git commit`, `git push` and `gh pr create` without asking, because those are the steps
|
|
@@ -372,7 +372,11 @@ That verdict is not just about the receipt: it also requires the harness floor t
|
|
|
372
372
|
`01-TOOLS/_TEMPLATE/`, `02-DOCS/wiki/harness/`, and the constitution when the plan selects SDD.
|
|
373
373
|
If the floor is missing, the install applied but prints `RSC_ONBOARDING_INCOMPLETE` with each
|
|
374
374
|
missing path and the action that creates it, because a plan that promised little used to be able to
|
|
375
|
-
report success with three markdown files.
|
|
375
|
+
report success with three markdown files. When the plan selects SDD, onboarding writes the
|
|
376
|
+
constitution itself as a draft (`status: draft`, only the facts it knows, no principles), so a
|
|
377
|
+
non-interactive install still ends `RSC_ONBOARDING_READY`; the draft is listed as pending — under
|
|
378
|
+
READY and in `rsc doctor` — until the `constitution` phase completes it. An existing constitution is
|
|
379
|
+
never overwritten.
|
|
376
380
|
|
|
377
381
|
---
|
|
378
382
|
|
|
@@ -471,8 +475,10 @@ list somebody has to remember to update is a list that goes quietly stale. Decla
|
|
|
471
475
|
|
|
472
476
|
### Your wiki and tools stay in sync — on by default
|
|
473
477
|
|
|
474
|
-
`01-TOOLS/` and `02-DOCS/` are the team's knowledge, so rsc keeps them
|
|
475
|
-
without anybody thinking about git, **and without ever pushing to `main`**.
|
|
478
|
+
`01-TOOLS/`, `02-DOCS/wiki/` and `02-DOCS/attachments/` are the team's knowledge, so rsc keeps them
|
|
479
|
+
the same on every machine without anybody thinking about git, **and without ever pushing to `main`**.
|
|
480
|
+
Exactly those three folders, minus rsc's own `01-TOOLS/_TEMPLATE/` and your personal
|
|
481
|
+
`02-DOCS/wiki/harness/user-profile.md`. Your `main` is
|
|
476
482
|
protected, or should be; rsc assumes it is.
|
|
477
483
|
|
|
478
484
|
The knowledge travels through one exchange branch, `rsc/knowledge`, that nobody works on by hand:
|
|
@@ -484,13 +490,22 @@ main · protected untouched — gets it inside the next merged pull reque
|
|
|
484
490
|
new · clones main has it all after the first message
|
|
485
491
|
```
|
|
486
492
|
|
|
487
|
-
- **When a turn ends**, your changes in those folders are committed
|
|
488
|
-
and sent to `rsc/knowledge` on `origin`, from whatever branch you are on.
|
|
489
|
-
`
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
493
|
+
- **When a turn ends**, your changes in those folders are committed on your branch as
|
|
494
|
+
`📝 docs(auto): …` and sent to `rsc/knowledge` on `origin`, from whatever branch you are on. Only
|
|
495
|
+
the copy on `rsc/knowledge` carries `[skip ci]`; the commit on your branch does not, so your next
|
|
496
|
+
ordinary push still runs CI. Two cases commit nothing on your branch: a closed `main`, and code of
|
|
497
|
+
yours still uncommitted (a docs commit would land ahead of the code it describes). Then the snapshot
|
|
498
|
+
is built aside, only goes to `rsc/knowledge`, and the files stay modified for you to commit with
|
|
499
|
+
your work. Knowledge that you or the agent already **committed** on the branch goes up too — only
|
|
500
|
+
its knowledge part, under a `docs(auto) … [skip ci]` message. Commits that came in by merging
|
|
501
|
+
`main` are your teammates', not yours, and are not sent again.
|
|
502
|
+
- **Before each message**, what teammates sent is brought into **the branch you are on** as a
|
|
503
|
+
`📥 docs(auto): sync` commit, and you are told in one line. Only the knowledge folders are touched.
|
|
504
|
+
A doc that `main` on `origin` already has, identical, is left for your ordinary merge of `main`. On
|
|
505
|
+
a closed `main` nothing is written at all — an uncommitted file there would make your next
|
|
506
|
+
`git pull` refuse to run — you are told what is waiting, and it arrives with that pull or in the
|
|
507
|
+
next branch you open. When the last fetch is more than ten minutes old, the message fetches first
|
|
508
|
+
(three seconds at most), so even a one-message session sees fresh docs.
|
|
494
509
|
A branch you open later catches up on its first message: everything on `rsc/knowledge` since it
|
|
495
510
|
left `main` comes down, including what you wrote yourself on another branch.
|
|
496
511
|
- **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
|
|
@@ -505,7 +520,7 @@ What it will not do, by design:
|
|
|
505
520
|
| --- | --- |
|
|
506
521
|
| Push to `main` | Never. Only `rsc/knowledge` receives anything. |
|
|
507
522
|
| Push your code or your unpushed commits | Only the knowledge folders go up, even from a commit that also has code. Your code waits for you. |
|
|
508
|
-
| Trigger CI or a deploy | Every
|
|
523
|
+
| Trigger CI or a deploy | Every commit on `rsc/knowledge` says `[skip ci]`; the one on your branch does not, so it never skips the CI of your own push. |
|
|
509
524
|
| Bring in anybody else's code | Only knowledge paths come down. Code, config and `.claude/` are never pulled. |
|
|
510
525
|
| Sync `02-DOCS/wiki/harness/user-profile.md` | Those are one person's dials. |
|
|
511
526
|
| Overwrite a file you are editing | It tells you, and your version stays. |
|
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/audit.js
CHANGED
|
@@ -207,6 +207,17 @@ export function stampAudit(cwd, date = new Date().toISOString()) {
|
|
|
207
207
|
writeFileSync(join(dir, 'audit.json'), JSON.stringify({ lastRun: date }, null, 2) + '\n');
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// The start of the cadence for a harness that has never been audited: written by onboarding so the
|
|
211
|
+
// first audit falls due STALE_DAYS after install, not in the first session. `baseline` and not
|
|
212
|
+
// `lastRun`, because no audit ran — the SessionStart hook reads either. Leaves an existing file alone.
|
|
213
|
+
export function recordAuditBaseline(cwd, date = new Date().toISOString()) {
|
|
214
|
+
const file = join(cwd, '.rsc', 'audit.json');
|
|
215
|
+
if (existsSync(file)) return false;
|
|
216
|
+
mkdirSync(join(cwd, '.rsc'), { recursive: true });
|
|
217
|
+
writeFileSync(file, JSON.stringify({ baseline: date }, null, 2) + '\n');
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
210
221
|
// Write the report into the harness wiki when one exists; always stamp .rsc/audit.json.
|
|
211
222
|
export function writeAuditReport(report, cwd = process.cwd()) {
|
|
212
223
|
const written = [];
|
package/scripts/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { hooksDir, hooksDirIsOurs } from '../targets/worktree-reaper.mjs';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
1
|
+
import { hooksDir, hooksDirIsOurs, classifyWorktrees } from '../targets/worktree-reaper.mjs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, relative, sep } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import { targetPaths, TARGET_IDS } from '../targets/index.js';
|
|
6
7
|
import { readState } from './lib/state.js';
|
|
@@ -19,6 +20,7 @@ import { metricsSummary } from '../targets/session-memory-core.mjs';
|
|
|
19
20
|
import { agentPath } from '../targets/agents.js';
|
|
20
21
|
import { projectOptOuts } from '../targets/opt-outs.js';
|
|
21
22
|
import { versionReport } from './lib/versions.js';
|
|
23
|
+
import { CONSTITUTION_PATH, constitutionIsDraft } from './lib/constitution-draft.js';
|
|
22
24
|
|
|
23
25
|
// Gates the committed manifest says the team disarmed, still armed here. Read through the same
|
|
24
26
|
// partition the installer uses, so a machine-only switch somebody's older rsc wrote into the
|
|
@@ -101,7 +103,18 @@ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd
|
|
|
101
103
|
// Is the post-merge trigger actually armed in this clone? Three states, because they need three
|
|
102
104
|
// different answers: armed, absent (repair it), or someone else's (leave it — chaining is our job
|
|
103
105
|
// at install time, and overwriting a husky hook to fix a convenience would be indefensible).
|
|
104
|
-
|
|
106
|
+
// The skills that read or write a design identity (02-DOCS/wiki/brand/) or a starting point. The
|
|
107
|
+
// state is always reported; the FIX — "run design-loop, then design-dna" — is advice, and advice needs
|
|
108
|
+
// evidence. A todo web app with none of these installed was told to run two skills it does not have
|
|
109
|
+
// and never asked for (E2E 2026-10-07).
|
|
110
|
+
const DESIGN_SKILLS = new Set(['design', 'design-loop', 'design-dna', 'brand-voice', 'content-engine', 'marketing', 'presentations', 'make']);
|
|
111
|
+
function withoutUnfoundedFix(summary, inPlay) {
|
|
112
|
+
if (inPlay || !summary || !summary.fix) return summary;
|
|
113
|
+
const { fix, ...rest } = summary;
|
|
114
|
+
return { ...rest, note: 'no design skill is installed here, so nothing is recommended' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function worktreeCleanupStatus(root, policy) {
|
|
105
118
|
if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
|
|
106
119
|
if (existsSync(join(root, '.rsc', '.no-worktree-cleanup'))) return { state: 'off-by-choice' };
|
|
107
120
|
// Ask git where hooks live instead of assuming `.git/hooks`. Until 2.0.1 this read the assumed
|
|
@@ -118,6 +131,13 @@ function worktreeCleanupStatus(root) {
|
|
|
118
131
|
}
|
|
119
132
|
const hook = join(dir, 'post-merge');
|
|
120
133
|
if (!existsSync(hook)) {
|
|
134
|
+
// The merge hook is one of the code hooks, and the accepted plan can defer those. Recommending
|
|
135
|
+
// `repair` then is a loop with no exit — repair re-runs the same install, which honours the same
|
|
136
|
+
// plan and skips the hook again — and it contradicted `healthy: true` on every fresh small
|
|
137
|
+
// software install (E2E 2026-10-07). Said as what it is, with nothing to do.
|
|
138
|
+
if (policy?.codeHooks === false) {
|
|
139
|
+
return { state: 'not-in-plan', reason: 'the accepted plan defers code hooks, and the post-merge cleanup is one of them; `rsc reassess` revisits that.' };
|
|
140
|
+
}
|
|
121
141
|
return { state: 'absent', action: 'Run `npx @ericrisco/rsc repair` to arm the post-merge cleanup in this clone.' };
|
|
122
142
|
}
|
|
123
143
|
try {
|
|
@@ -128,6 +148,27 @@ function worktreeCleanupStatus(root) {
|
|
|
128
148
|
} catch { return { state: 'unreadable' }; }
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
// The two things a team trips over with worktrees (team simulation D9): a `.worktrees/` git does not
|
|
152
|
+
// ignore — one `git add -A` away from committing a whole checkout — and landed worktrees still on
|
|
153
|
+
// disk, which look exactly like live ones. Reported with the way out, never blocking `healthy`: both
|
|
154
|
+
// cost tidiness, not work.
|
|
155
|
+
const realOf = (p) => { try { return realpathSync.native(p); } catch { return p; } };
|
|
156
|
+
function worktreeHygiene(root) {
|
|
157
|
+
if (!existsSync(join(root, '.git'))) return { state: 'not-a-repo' };
|
|
158
|
+
const probe = spawnSync('git', ['-C', root, 'check-ignore', '-q', '--no-index', '.worktrees/rsc-probe/file'], { encoding: 'utf8' });
|
|
159
|
+
const ignored = probe.status === 0;
|
|
160
|
+
let leftovers = [];
|
|
161
|
+
try {
|
|
162
|
+
leftovers = classifyWorktrees(root)
|
|
163
|
+
.filter((c) => c.verdict === 'safe' || c.verdict === 'ask')
|
|
164
|
+
.map((c) => ({ path: relative(realOf(root), c.path).split(sep).join('/') || c.path, branch: c.branch, verdict: c.verdict }));
|
|
165
|
+
} catch { /* unanswerable is not a finding */ }
|
|
166
|
+
const actions = [];
|
|
167
|
+
if (!ignored) actions.push('`.worktrees/` is not ignored by git here: run `npx @ericrisco/rsc sync` (it adds it to the rsc block of .gitignore) and commit .gitignore so every clone gets it.');
|
|
168
|
+
if (leftovers.length) actions.push(`${leftovers.length} landed worktree(s) are still on disk: \`npx @ericrisco/rsc worktrees\` lists them, \`npx @ericrisco/rsc worktrees reap\` removes the safe ones.`);
|
|
169
|
+
return { ignored, leftovers, ...(actions.length ? { action: actions.join(' ') } : {}) };
|
|
170
|
+
}
|
|
171
|
+
|
|
131
172
|
// Every skill this catalog ships declares `origin: risco` in its own frontmatter — all 272 of them,
|
|
132
173
|
// uniformly. So "is this ours?" is answerable from the file itself, and does not need the list in
|
|
133
174
|
// `.rsc.json` that nobody ever filled: that list is parallel accounting (P3), it has been empty in
|
|
@@ -184,8 +225,8 @@ export function doctor({ target, home, cwd }) {
|
|
|
184
225
|
try {
|
|
185
226
|
memory.metrics = hasStore
|
|
186
227
|
? metricsSummary({ cwd: root })
|
|
187
|
-
: { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost:
|
|
188
|
-
} catch { memory.metrics = { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost:
|
|
228
|
+
: { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: null, toolCalls: null }, unknown: { cost: 0, toolCalls: 0 } };
|
|
229
|
+
} catch { memory.metrics = { sessions: [], total: { cost: null, toolCalls: null }, knownTotal: { cost: null, toolCalls: null }, unknown: { cost: 0, toolCalls: 0 } }; }
|
|
189
230
|
}
|
|
190
231
|
const actualSkills = listSkills({ target, home, cwd: root }).map((entry) => entry.id);
|
|
191
232
|
const actualAgents = listAgents({ target, home, cwd: root }).agents.map((entry) => entry.id);
|
|
@@ -208,6 +249,9 @@ export function doctor({ target, home, cwd }) {
|
|
|
208
249
|
const path = agentPath(target, root, id);
|
|
209
250
|
return path && !existsSync(path);
|
|
210
251
|
}).map((id) => ({ id, path: agentPath(target, root, id), action: 'Run `npx @ericrisco/rsc sync` to restore this managed agent.' }));
|
|
252
|
+
// What THIS harness installed, not every skill visible from here: a design skill in the user's
|
|
253
|
+
// global scope is how a todo app's doctor came to recommend design-dna (E2E 2026-10-07).
|
|
254
|
+
const designInPlay = Object.keys(state.skills || {}).some((id) => DESIGN_SKILLS.has(id));
|
|
211
255
|
const report = {
|
|
212
256
|
target,
|
|
213
257
|
installed: Object.keys(state.skills),
|
|
@@ -224,17 +268,18 @@ export function doctor({ target, home, cwd }) {
|
|
|
224
268
|
// The worktree cleanup's trigger is a git hook, and `.git/hooks/` is not cloned. So a repo can
|
|
225
269
|
// carry a perfectly healthy harness and still never clean up, on every machine but the one that
|
|
226
270
|
// ran the installer. Reported, never nagged about: `repair` is what puts it back (P6).
|
|
227
|
-
worktreeCleanup: worktreeCleanupStatus(root),
|
|
271
|
+
worktreeCleanup: worktreeCleanupStatus(root, state.policy),
|
|
272
|
+
worktreeHygiene: worktreeHygiene(root),
|
|
228
273
|
ownSkills: ownSkillsIn(paths, readProjectManifest(root)?.ownSkills || []),
|
|
229
274
|
sello: selloStatus(root),
|
|
230
275
|
// Whether this harness has a design identity at all. `design` has always DECLARED that it
|
|
231
276
|
// stops without one; until lib/design-identity.js nothing checked it (P2). Reported here,
|
|
232
277
|
// never nagged about and never blocking: a missing identity is low risk (P7).
|
|
233
|
-
designIdentity: designIdentity(root),
|
|
278
|
+
designIdentity: withoutUnfoundedFix(designIdentity(root), designInPlay),
|
|
234
279
|
// The other half of the same question: an identity is what this project settled on, a starting
|
|
235
280
|
// point is what it has to settle FROM. Three skills promise to propose one and none could look;
|
|
236
281
|
// this is the look. One field, not a section — a report grows for every user who runs it (P5).
|
|
237
|
-
designStartingPoint: startingPointSummary(root),
|
|
282
|
+
designStartingPoint: withoutUnfoundedFix(startingPointSummary(root), designInPlay),
|
|
238
283
|
// An inert gate must never read as an armed one (the same rule the sello follows):
|
|
239
284
|
// the gitmoji guard has a per-project kill switch, so say which state it is in.
|
|
240
285
|
gitmojiGuard: state.policy?.gitmojiGuard === false
|
|
@@ -266,6 +311,11 @@ export function doctor({ target, home, cwd }) {
|
|
|
266
311
|
for (const [id, e] of Object.entries(state.skills)) {
|
|
267
312
|
for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
|
|
268
313
|
}
|
|
314
|
+
// Pending is not unhealthy: work the harness expects the user to finish, named with what does it.
|
|
315
|
+
// The draft constitution `rsc onboard` writes is the first entry; it never touches `healthy`.
|
|
316
|
+
report.pending = constitutionIsDraft(root)
|
|
317
|
+
? [{ id: 'constitution-draft', path: CONSTITUTION_PATH, action: 'Complete it with the `constitution` phase before the first SDD feature; ratifying removes `status: draft`.' }]
|
|
318
|
+
: [];
|
|
269
319
|
report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
|
|
270
320
|
&& !report.opencodePluginsOutdated.length;
|
|
271
321
|
return report;
|
|
@@ -306,9 +356,17 @@ const bytesOf = (p) => { try { return statSync(p).size; } catch { return 0; } };
|
|
|
306
356
|
|
|
307
357
|
// The always-on body a wired scope injects: the hook command embeds the exact SKILL.md path,
|
|
308
358
|
// so read it from there rather than guessing the layout.
|
|
359
|
+
// The body the SessionStart hook prints is the `.md` file its command names — today the short
|
|
360
|
+
// `.rsc/suggest-always-on.md`, not the whole `suggest` SKILL.md. Reading only `…SKILL.md` out of the
|
|
361
|
+
// command meant the current wiring never matched and the fallback counted the full skill: doctor said
|
|
362
|
+
// 7.1 KB for a hook that injects 0.3 KB of body (E2E 2026-10-07). The command names the project
|
|
363
|
+
// through ${CLAUDE_PROJECT_DIR}, so that is resolved against the scope before measuring.
|
|
309
364
|
function alwaysOnBytesFor(scopeRoot, settingsRaw) {
|
|
310
|
-
const
|
|
311
|
-
|
|
365
|
+
const raw = (settingsRaw || '').replace(/\\\\/g, '/');
|
|
366
|
+
const candidates = [...raw.matchAll(/\\?"([^"\\]*suggest[^"\\]*\.md)\\?"/g)].map((m) => m[1]
|
|
367
|
+
.replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, scopeRoot)
|
|
368
|
+
.replace(/^~(?=\/)/, homedir()));
|
|
369
|
+
for (const file of candidates) if (bytesOf(file)) return bytesOf(file);
|
|
312
370
|
return bytesOf(join(scopeRoot, '.rsc', 'skills', 'suggest', 'SKILL.md'));
|
|
313
371
|
}
|
|
314
372
|
|
package/scripts/install-apply.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { rmSync, rmdirSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync, renameSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
@@ -16,6 +16,7 @@ import { withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
|
16
16
|
import { isRetired, replaceRetired } from './lib/retired-skills.js';
|
|
17
17
|
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
18
18
|
import { createBackup } from './lib/backups.js';
|
|
19
|
+
import { removeUnmodifiedSkeleton } from './lib/tools-skeleton.js';
|
|
19
20
|
import {
|
|
20
21
|
targetHasCommands, resolveCommands, reconcileCommands, commandPath, allPotentialCommandNames,
|
|
21
22
|
} from '../targets/commands.js';
|
|
@@ -81,6 +82,7 @@ export function generatedHookFiles({ target, cwd, policy }) {
|
|
|
81
82
|
join(cwd, '.rsc', 'session-start.mjs'),
|
|
82
83
|
join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
|
|
83
84
|
join(cwd, '.rsc', 'hook-once.mjs'),
|
|
85
|
+
join(cwd, '.rsc', 'shell-unwrap.mjs'),
|
|
84
86
|
join(cwd, '.rsc', 'worktree-reaper.mjs'),
|
|
85
87
|
join(cwd, '.rsc', 'auto-update.mjs'),
|
|
86
88
|
// team-safe-default: the 3.0 start-up duties and the trunk policy (also read by branch-guard).
|
|
@@ -90,12 +92,14 @@ export function generatedHookFiles({ target, cwd, policy }) {
|
|
|
90
92
|
// The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
|
|
91
93
|
// whether or not the code guards are.
|
|
92
94
|
const danger = policy?.dangerGuard === false ? [] : [join(cwd, '.rsc', 'danger-guard.mjs')];
|
|
93
|
-
|
|
95
|
+
const laneGate = policy?.laneGate ?? policy?.codeHooks !== false;
|
|
96
|
+
const lane = laneGate ? [join(cwd, '.rsc', 'userprompt-gate.mjs'), join(cwd, '.rsc', 'ftd-nudge.mjs')] : [];
|
|
97
|
+
if (policy?.codeHooks === false) return [...lifecycle, ...danger, ...(laneGate ? lane : [join(cwd, '.rsc', 'suggest-always-on.md')])];
|
|
94
98
|
return [...lifecycle, ...danger,
|
|
95
99
|
join(cwd, '.rsc', 'ship-guard.mjs'),
|
|
96
100
|
join(cwd, '.rsc', 'branch-guard.mjs'),
|
|
97
101
|
join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
|
|
98
|
-
join(cwd, '.rsc', 'sello.mjs')];
|
|
102
|
+
join(cwd, '.rsc', 'sello.mjs'), ...(laneGate ? [join(cwd, '.rsc', 'ftd-nudge.mjs')] : [])];
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export function managedPathsForInstall({ skillIds, agentIds = [], target, home, cwd, policy }) {
|
|
@@ -335,6 +339,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
335
339
|
alwaysOn: policy.alwaysOn !== false,
|
|
336
340
|
codeHooks: policy.codeHooks !== false,
|
|
337
341
|
dangerGuard: policy.dangerGuard !== false,
|
|
342
|
+
laneGate: policy.laneGate ?? policy.codeHooks !== false,
|
|
338
343
|
gitmojiGuard: policy.gitmojiGuard !== false,
|
|
339
344
|
memory: policy.memory !== false,
|
|
340
345
|
context7: policy.context7 === true,
|
|
@@ -402,7 +407,12 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
|
|
|
402
407
|
// whole directory would quietly stop versioning their work, which is the same sin this
|
|
403
408
|
// release exists to fix: treating what is theirs as if it were ours. So shared
|
|
404
409
|
// directories are excluded entry by entry, and only the entries rsc manages.
|
|
405
|
-
|
|
410
|
+
// `.worktrees/` is where the isolation rule sends every concurrent session (`git worktree add
|
|
411
|
+
// .worktrees/<branch>`). Nothing ignored it, so in a teammate's clone it surfaced as `?? .worktrees/`
|
|
412
|
+
// — one `git add -A` from committing a whole checkout (team simulation D9). It goes in THIS block,
|
|
413
|
+
// the committed one, so every clone inherits it with its first pull; `.git/info/exclude` would
|
|
414
|
+
// only ever cover the machine that ran the installer.
|
|
415
|
+
const wanted = ['.rsc/', '.worktrees/'];
|
|
406
416
|
if (target) {
|
|
407
417
|
const paths = targetPaths(target, undefined, cwd);
|
|
408
418
|
const rel = (abs) => relative(cwd, abs).split(sep).join('/');
|
|
@@ -697,6 +707,11 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
697
707
|
policy: manifest?.onboarding?.plan?.policy,
|
|
698
708
|
onboarding: manifest?.onboarding,
|
|
699
709
|
});
|
|
710
|
+
// A teammate never runs onboarding — the harness arrives in the clone — so the exclude onboarding
|
|
711
|
+
// writes for the personal profile never reached them, and their first answer to the one onboarding
|
|
712
|
+
// question sat untracked, one `git add -A` from the team repo (team simulation D13). Sync is what a
|
|
713
|
+
// clone runs, so it writes the same exclude. Lazy import: onboarding-apply imports this module.
|
|
714
|
+
try { (await import('./lib/onboarding-apply.js')).excludePersonalProfile(cwd); } catch { /* not a repo → nothing */ }
|
|
700
715
|
return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
|
|
701
716
|
}
|
|
702
717
|
|
|
@@ -706,13 +721,58 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
|
|
|
706
721
|
// own knowledge — kept unless `withDocs` is set. Returns the paths touched.
|
|
707
722
|
// Note: backups live under `.rsc/backups/`, which this removes — so purge does not
|
|
708
723
|
// snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
|
|
709
|
-
export async function purge(
|
|
710
|
-
const
|
|
724
|
+
export async function purge(options = {}) {
|
|
725
|
+
const report = await purgeReport(options);
|
|
726
|
+
return [...report.removed, ...report.cleaned];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// A config file whose only content was rsc's is not "cleaned" when the rsc entries leave it as `{}`:
|
|
730
|
+
// it is a stub nobody asked for, and calling it removed while it sits on disk is the lie the E2E
|
|
731
|
+
// caught. `$schema` and `version` are format boilerplate rsc itself adds, not user content.
|
|
732
|
+
function emptyConfig(file) {
|
|
733
|
+
let value;
|
|
734
|
+
try { value = JSON.parse(readFileSync(file, 'utf8')); } catch { return false; }
|
|
735
|
+
const empty = (v) => (Array.isArray(v) ? v.every(empty)
|
|
736
|
+
: v && typeof v === 'object' ? Object.values(v).every(empty) : false);
|
|
737
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
738
|
+
const rest = Object.fromEntries(Object.entries(value).filter(([k]) => k !== '$schema' && k !== 'version'));
|
|
739
|
+
return empty(rest);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const RSC_IGNORE_HEADER = '# rsc local state (hooks, seals, logs) and managed skill links — machine-local';
|
|
743
|
+
|
|
744
|
+
// Remove the blocks `ignoreLocalState` appended: the header line, the entries under it up to the
|
|
745
|
+
// next blank line, and the blank seam before it. Lines the user wrote are never part of a block.
|
|
746
|
+
function stripIgnoreBlocks(text) {
|
|
747
|
+
const lines = text.split('\n');
|
|
748
|
+
const out = [];
|
|
749
|
+
for (let i = 0; i < lines.length; i++) {
|
|
750
|
+
if (lines[i] !== RSC_IGNORE_HEADER) { out.push(lines[i]); continue; }
|
|
751
|
+
if (out.length && out[out.length - 1] === '') out.pop();
|
|
752
|
+
while (i + 1 < lines.length && lines[i + 1].trim() !== '') i++;
|
|
753
|
+
}
|
|
754
|
+
return out.join('\n');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Remove EVERYTHING rsc put in this project and say exactly what happened to each thing:
|
|
759
|
+
* removed — gone from disk;
|
|
760
|
+
* cleaned — rsc's entries taken out, the file kept because something of the user's remains;
|
|
761
|
+
* kept — left on purpose, with the reason (and the command, when it is the user's call).
|
|
762
|
+
* `02-DOCS/` is the user's own knowledge — kept unless `withDocs` is set. Remote branches are never
|
|
763
|
+
* deleted from here. Backups live under `.rsc/backups/`, which this removes — so purge does not
|
|
764
|
+
* snapshot (a pre-purge backup would delete itself). It is the deliberate escape hatch.
|
|
765
|
+
*/
|
|
766
|
+
export async function purgeReport({ home, cwd = process.cwd(), withDocs = false, dryRun = false } = {}) {
|
|
767
|
+
const removed = new Set();
|
|
768
|
+
const cleaned = new Set();
|
|
769
|
+
const kept = [];
|
|
711
770
|
const drop = (p, recursive = false) => {
|
|
712
|
-
if (!existsSync(p)) return;
|
|
713
|
-
removed.
|
|
771
|
+
if (!existsSync(p) && !isLink(p)) return;
|
|
772
|
+
removed.add(p);
|
|
714
773
|
if (!dryRun) rmSync(p, { recursive, force: true });
|
|
715
774
|
};
|
|
775
|
+
const touchedConfigs = new Set();
|
|
716
776
|
for (const target of TARGET_IDS) {
|
|
717
777
|
const paths = targetPaths(target, home, cwd);
|
|
718
778
|
if (existsSync(paths.stateFile)) {
|
|
@@ -730,25 +790,168 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
|
|
|
730
790
|
}
|
|
731
791
|
drop(paths.stateFile);
|
|
732
792
|
}
|
|
793
|
+
const bootstrap = join(paths.projectRoot || cwd, '.claude', 'rsc-bootstrap.mjs');
|
|
794
|
+
const hadBootstrap = target === 'claude' && existsSync(bootstrap);
|
|
733
795
|
// Unwiring mutates shared config files, so only run it for real (dry runs report
|
|
734
796
|
// those files without touching them).
|
|
735
797
|
if (!dryRun) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
798
|
+
for (const p of [
|
|
799
|
+
...unwireHook(target, paths),
|
|
800
|
+
...unwireMemory(target, cwd),
|
|
801
|
+
...unwireUpdate(target, cwd),
|
|
802
|
+
...unwireKnowledge(target, cwd),
|
|
803
|
+
...unwireGitPermissions(target, cwd),
|
|
804
|
+
]) touchedConfigs.add(p);
|
|
805
|
+
if (hadBootstrap && !existsSync(bootstrap)) removed.add(bootstrap);
|
|
741
806
|
} else {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
807
|
+
for (const p of [
|
|
808
|
+
...memoryArtifactsPresent(target, cwd),
|
|
809
|
+
...updateArtifactsPresent(target, cwd),
|
|
810
|
+
...knowledgeArtifactsPresent(target, cwd),
|
|
811
|
+
]) touchedConfigs.add(p);
|
|
812
|
+
if (paths.hookTarget && existsSync(paths.hookTarget)
|
|
813
|
+
&& /\.rsc\/|rsc-suggest:start|skills\/rsc\/suggest/.test(readFileSync(paths.hookTarget, 'utf8').replaceAll('\\\\', '/'))) {
|
|
814
|
+
touchedConfigs.add(paths.hookTarget);
|
|
815
|
+
}
|
|
816
|
+
if (hadBootstrap) removed.add(bootstrap);
|
|
745
817
|
}
|
|
746
818
|
// A lost state file also loses proof of ownership. Leave same-named user files
|
|
747
819
|
// behind rather than guessing from a catalog id and deleting their work.
|
|
748
820
|
}
|
|
749
|
-
|
|
821
|
+
const rscDir = join(cwd, '.rsc');
|
|
822
|
+
for (const p of touchedConfigs) {
|
|
823
|
+
if (p === rscDir || p.startsWith(`${rscDir}${sep}`)) { removed.add(p); continue; }
|
|
824
|
+
if (dryRun) { if (existsSync(p)) cleaned.add(p); continue; }
|
|
825
|
+
if (!existsSync(p)) { removed.add(p); continue; }
|
|
826
|
+
let body = '';
|
|
827
|
+
try { body = readFileSync(p, 'utf8'); } catch { /* unreadable: leave it, call it cleaned */ }
|
|
828
|
+
if (/\.json$/i.test(p) ? emptyConfig(p) : body.trim() === '') {
|
|
829
|
+
rmSync(p, { force: true });
|
|
830
|
+
removed.add(p);
|
|
831
|
+
} else cleaned.add(p);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// The `.gitignore` block rsc appended. The file goes only if nothing of the user's is left in it.
|
|
835
|
+
const gitignore = join(cwd, '.gitignore');
|
|
836
|
+
if (existsSync(gitignore)) {
|
|
837
|
+
const text = readFileSync(gitignore, 'utf8');
|
|
838
|
+
if (text.includes(RSC_IGNORE_HEADER)) {
|
|
839
|
+
const rest = stripIgnoreBlocks(text);
|
|
840
|
+
if (rest.trim() === '') { removed.add(gitignore); if (!dryRun) rmSync(gitignore, { force: true }); }
|
|
841
|
+
else { cleaned.add(gitignore); if (!dryRun) writeFileSync(gitignore, rest.endsWith('\n') ? rest : `${rest}\n`); }
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// The committed declaration of the harness that no longer exists.
|
|
846
|
+
drop(join(cwd, '.rsc.json'));
|
|
847
|
+
|
|
848
|
+
// The `01-TOOLS/` scaffolding onboarding wrote, only where nobody has touched it.
|
|
849
|
+
const skeleton = removeUnmodifiedSkeleton(cwd, { dryRun });
|
|
850
|
+
for (const rel of skeleton.removed) removed.add(join(cwd, rel));
|
|
851
|
+
for (const k of skeleton.kept) kept.push(k);
|
|
852
|
+
|
|
853
|
+
drop(rscDir, true);
|
|
750
854
|
if (withDocs) drop(join(cwd, '02-DOCS'), true);
|
|
751
|
-
|
|
855
|
+
else if (existsSync(join(cwd, '02-DOCS'))) kept.push({ path: '02-DOCS/', reason: 'your knowledge base. Add --with-docs to remove it too.' });
|
|
856
|
+
|
|
857
|
+
// Lines rsc added to `.git/info/exclude` for files that are now gone.
|
|
858
|
+
const exclude = gitExcludeFile(cwd);
|
|
859
|
+
if (exclude && existsSync(exclude)) {
|
|
860
|
+
const gone = new Set([...removed].map((p) => `/${relative(cwd, p).split(sep).join('/')}`));
|
|
861
|
+
const lines = readFileSync(exclude, 'utf8').split('\n');
|
|
862
|
+
const keptLines = lines.filter((l) => !(gone.has(l.trim()) || l.trim().startsWith('/.rsc/')));
|
|
863
|
+
if (keptLines.length !== lines.length) {
|
|
864
|
+
cleaned.add(exclude);
|
|
865
|
+
if (!dryRun) writeFileSync(exclude, keptLines.join('\n'));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Directories that only held what was just removed. Deepest first; never `cwd` itself.
|
|
870
|
+
const parents = new Set();
|
|
871
|
+
for (const p of removed) {
|
|
872
|
+
for (let d = dirname(p); d !== cwd && d.startsWith(`${cwd}${sep}`); d = dirname(d)) parents.add(d);
|
|
873
|
+
}
|
|
874
|
+
const ordered = [...parents].sort((a, b) => b.split(sep).length - a.split(sep).length);
|
|
875
|
+
const willBeEmpty = (dir) => {
|
|
876
|
+
try {
|
|
877
|
+
return readdirSync(dir).every((name) => {
|
|
878
|
+
const child = join(dir, name);
|
|
879
|
+
return removed.has(child);
|
|
880
|
+
});
|
|
881
|
+
} catch { return false; }
|
|
882
|
+
};
|
|
883
|
+
for (const dir of ordered) {
|
|
884
|
+
if (!existsSync(dir) || isLink(dir)) continue;
|
|
885
|
+
if (dryRun ? willBeEmpty(dir) : readdirSync(dir).length === 0) {
|
|
886
|
+
if (!dryRun) rmdirSync(dir);
|
|
887
|
+
removed.add(dir);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// The knowledge exchange branch lives on the remote, which is shared: deleting it is the user's
|
|
892
|
+
// call, so say it exists and hand them the command.
|
|
893
|
+
for (const ref of gitLines(cwd, ['for-each-ref', '--format=%(refname)', 'refs/remotes/*/rsc/knowledge', 'refs/heads/rsc/knowledge'])) {
|
|
894
|
+
const remote = /^refs\/remotes\/([^/]+)\/rsc\/knowledge$/.exec(ref)?.[1];
|
|
895
|
+
if (remote) kept.push({ path: `${remote}/rsc/knowledge`, reason: `remote branch; rsc never deletes remote branches. To delete it: git push ${remote} --delete rsc/knowledge` });
|
|
896
|
+
else kept.push({ path: 'rsc/knowledge', reason: 'local branch. To delete it: git branch -D rsc/knowledge' });
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// The post-merge hook rsc installs (worktree-reaper.mjs). It lives in git's private hooks dir, so it
|
|
900
|
+
// is per clone, never committed — and left behind it kept calling a reaper that no longer exists.
|
|
901
|
+
// Only a hook carrying rsc's marker is touched; one rsc had chained aside is put back in place.
|
|
902
|
+
const [hooks] = gitLines(cwd, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
|
|
903
|
+
const hook = hooks ? join(hooks, 'post-merge') : null;
|
|
904
|
+
if (hook && existsSync(hook)) {
|
|
905
|
+
let body = '';
|
|
906
|
+
try { body = readFileSync(hook, 'utf8'); } catch { /* unreadable → not provably ours */ }
|
|
907
|
+
if (body.includes('# rsc-managed worktree cleanup (post-merge)')) {
|
|
908
|
+
const chained = join(hooks, 'post-merge.rsc-local');
|
|
909
|
+
if (existsSync(chained)) {
|
|
910
|
+
cleaned.add(hook);
|
|
911
|
+
if (!dryRun) { rmSync(hook, { force: true }); renameSync(chained, hook); }
|
|
912
|
+
} else drop(hook);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// The project's instruction files are never purge's to delete. Say so, so the report accounts for them.
|
|
917
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
918
|
+
const file = join(cwd, name);
|
|
919
|
+
if (existsSync(file) && !removed.has(file) && !cleaned.has(file)) {
|
|
920
|
+
kept.push({ path: name, reason: 'your project instructions; purge never deletes them. If they still mention rsc or 02-DOCS, edit those lines by hand.' });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// A path that is both removed and cleaned was cleaned, then emptied, then removed.
|
|
925
|
+
for (const p of removed) cleaned.delete(p);
|
|
926
|
+
// `.rsc/` is rsc's own directory and goes whole: listing its files one by one adds noise, not truth.
|
|
927
|
+
if (removed.has(rscDir)) for (const p of [...removed]) if (p.startsWith(`${rscDir}${sep}`)) removed.delete(p);
|
|
928
|
+
|
|
929
|
+
// What of the removed or cleaned is COMMITTED. On a teammate's clone that is most of it (.rsc.json,
|
|
930
|
+
// .claude/settings.json, the bootstrap…): changing it here is local, committing the changes
|
|
931
|
+
// uninstalls rsc for everyone who pulls (team simulation D15). The index still has them after the
|
|
932
|
+
// delete, so this reads the same in a dry run and a real one.
|
|
933
|
+
const gone = new Set([...removed, ...cleaned].map((p) => relative(cwd, p).split(sep).join('/')));
|
|
934
|
+
const under = (file) => { for (let d = file; d && d !== '.'; d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') if (gone.has(d)) return true; return false; };
|
|
935
|
+
const listed = spawnSync('git', ['ls-files', '-z'], { cwd, encoding: 'utf8' });
|
|
936
|
+
const tracked = listed.status === 0
|
|
937
|
+
? listed.stdout.split('\0').filter(Boolean).filter(under) : [];
|
|
938
|
+
return { removed: [...removed], cleaned: [...cleaned], kept, tracked };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function isLink(p) {
|
|
942
|
+
try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function gitLines(cwd, args) {
|
|
946
|
+
try {
|
|
947
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
948
|
+
return r.status === 0 ? r.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : [];
|
|
949
|
+
} catch { return []; }
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function gitExcludeFile(cwd) {
|
|
953
|
+
const [value] = gitLines(cwd, ['rev-parse', '--git-path', 'info/exclude']);
|
|
954
|
+
return value ? resolve(cwd, value) : null;
|
|
752
955
|
}
|
|
753
956
|
|
|
754
957
|
if (import.meta.url === `file://${process.argv[1]}`) {
|