@ericrisco/rsc 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ channel"* — **272 skills across 23 domains**, every one researched against liv
21
21
  2025-2026 sources and **adversarially scored ≥ 8.5/10** before it shipped.
22
22
 
23
23
  ```bash
24
- npx @ericrisco/rsc # plain-language wizard — no jargon, installs what your project needs
24
+ npx @ericrisco/rsc@latest onboard # interview → exact plan → explicit acceptance → verified install
25
25
  ```
26
26
 
27
27
  <br>
@@ -44,14 +44,28 @@ asked you to **install rsc / this harness / these skills**, run this in the **ro
44
44
  project they're equipping** — not inside a clone of this repo (see the note under *Install*):
45
45
 
46
46
  ```bash
47
- npx @ericrisco/rsc@latest
47
+ npx @ericrisco/rsc@latest onboard
48
48
  ```
49
49
 
50
- That launches a plain-language wizard: it asks **which assistants** to target and **what the
51
- user wants**, reads the repo, then installs the floor (`orient` + `rsc-suggest` + `bro` + `harness` +
52
- `init`) plus only the skills that fit — one at a time. No global install and no API key needed.
50
+ That launches the binding plain-language onboarding. It asks the user for technical level,
51
+ accompaniment, project kind and goal **before it writes anything**, reads only the selected project
52
+ root, and shows every selected or deferred skill, agent and hook with its reason. Relay those
53
+ questions to the user; never invent or default their answers. The first pass writes nothing and
54
+ prints a SHA-256 plan id. Only rerun with `--accept-plan <id>` after the user accepts that exact plan.
53
55
 
54
- - **Choose assistants non-interactively:** `npx @ericrisco/rsc@latest --target claude` (comma-separate for several).
56
+ For a non-interactive agent, collect the answers and preview the same plan explicitly:
57
+
58
+ ```bash
59
+ npx @ericrisco/rsc@latest onboard --technical-level mixed --accompaniment L1 \
60
+ --project-kind software --goal "small compound-interest website" \
61
+ --software-scope small --target codex
62
+ # After the user accepts the printed plan:
63
+ npx @ericrisco/rsc@latest onboard --technical-level mixed --accompaniment L1 \
64
+ --project-kind software --goal "small compound-interest website" \
65
+ --software-scope small --target codex --accept-plan PRINTED_SHA256_ID
66
+ ```
67
+
68
+ - **Choose assistants non-interactively:** add `--target claude` (comma-separate for several) to `onboard`.
55
69
  - **Two assistants already installed?** rsc asks instead of guessing. `--target` settles it in one word.
56
70
  - **Already installed, just refreshing skills + hooks:** `rsc sync` (or re-run the command above).
57
71
  - **Add one skill by id:** `rsc add <id>` · **browse the catalog:** `rsc consult "<what you want>"` or `rsc list`.
@@ -118,7 +132,7 @@ injects at most 4,096 bytes. Disable every memory surface for a project with
118
132
  ## Install
119
133
 
120
134
  ```bash
121
- npx @ericrisco/rsc # no install step — runs the latest published catalog
135
+ npx @ericrisco/rsc@latest onboard
122
136
  ```
123
137
 
124
138
  Prefer the short `rsc` command? Install once, globally:
@@ -142,19 +156,12 @@ cd ~/rsc-skills && npm install && npm link
142
156
  > `node scripts/rsc.js …`, the `npm link` above, or pin the published build with
143
157
  > `npx @ericrisco/rsc@latest …`.
144
158
 
145
- The first run asks **which assistants** you want — Claude Code, Codex, Copilot,
146
- Cursor, Gemini, Windsurf, Cline and 11 more (pick any combination) — and installs
147
- the **floor**:
148
- `orient` + `rsc-suggest` (always-on) + `bro` (on request) + `harness` + `init`. In Claude Code it
149
- also wires a `SessionStart` hook (so your assistant proposes new skills on its
150
- own) and a `UserPromptSubmit` hook that re-asserts the **SDD new-feature gate**
151
- on every turn — so a feature request, in any language, routes through `specify`
152
- first, before any skill writes code. Opt out per project with `.rsc/.no-feature-gate`.
153
- It also wires a `PreToolUse` **gitmoji guard**: a `git commit` whose message carries no
154
- [gitmoji](https://gitmoji.dev) is denied, with the corrected message inside the refusal
155
- (`✨ feat(api): add cursor paging`) — so `git log --oneline` stays readable at a glance instead of
156
- depending on the agent remembering a convention. It only judges messages it can actually read
157
- (`-m`, heredoc), never an editor commit or `--amend --no-edit`. Opt out with `.rsc/.no-gitmoji`.
159
+ The first run asks **how technical the conversation should be**, the accompaniment level, what the
160
+ project is for, its goal and the assistants to target. It then presents the complete plan. A small
161
+ website can defer SDD, agents and code guards; an operations harness does not receive them merely
162
+ because it lives in a repository. Deferred components record the evidence that would make rsc
163
+ recommend them later. `rsc reassess` reports that evidence but still cannot install anything
164
+ without a newly accepted plan.
158
165
 
159
166
  Everything stays **in the project**, and the real skill files are written
160
167
  **once** to `.rsc/skills/<id>/`. Each assistant you pick gets a lightweight
@@ -169,53 +176,39 @@ points whose backing skill, agent or local-memory capability actually exists.
169
176
  ## 30-second tour
170
177
 
171
178
  ```
172
- $ rsc
179
+ $ rsc onboard
173
180
  ██████╗ ███████╗ ██████╗ ← animated gradient wordmark
174
181
  ██╔══██╗██╔════╝██╔════╝
175
182
  ██████╔╝███████╗██║
176
183
  272 skills · one CLI · zero bloat
177
184
 
178
- What do you want to do? ↑↓ move · enter select
179
- ❯ Base install — the essentials (8 skills)
180
- Base + Spec-Driven Development — specify → plan → implement → ship
181
- Pick skills by hand, by area
182
- ```
183
-
184
- Pick **by area** and you get a checkbox list — **↑↓ to move, space to toggle,
185
- enter to confirm**:
185
+ How technical should the conversation be?
186
+ How much accompaniment do you want?
187
+ What are you building or running?
188
+ What do you want this project to achieve?
186
189
 
187
- ```
188
- Languages: ↑↓ move · space toggle · a all · enter confirm
189
- ❯ ◉ typescript
190
- ◯ python
191
- ◉ go
192
- ◯ rust
193
- ```
194
-
195
- Then it asks **which assistants** to install for — tick as many as you like:
196
-
197
- ```
198
- Which assistants do you want to install for? space toggle · a all · enter confirm
199
- ❯ ◉ Claude Code (.claude/skills/) ⟵ detected here
200
- ◉ Codex CLI (AGENTS.md)
201
- ◯ GitHub Copilot (.github/copilot-instructions.md)
202
- ◯ Cursor (.cursor/rules/)
203
- ◉ Windsurf (.windsurf/rules/)
204
- ◯ Cline (.clinerules/)
205
- …17 in total — Gemini, Antigravity, Zed, Continue, Roo, Amp, opencode, Jules, Junie, Kiro, Aider
190
+ RSC_ONBOARDING_PLAN
191
+ Plan id: <sha256>
192
+ Selected: …
193
+ Deferred: …
194
+ Accept this exact harness plan?
206
195
  ```
207
196
 
208
- It detects your stack, asks which assistants to install for (the one it found in
209
- your folder is pre-marked), installs only what you chose, then prints the exact
210
- next steps for **Claude Code / Codex / Cursor / Gemini / Antigravity** — and from
211
- there keeps proposing the skills a task needs.
197
+ The terminal and chat adapters produce the same normalized answers and plan id. If project evidence
198
+ changes between preview and acceptance, rsc returns `RSC_PLAN_CHANGED` and writes nothing. After an
199
+ accepted application it verifies the receipt and managed state before printing `RSC_ONBOARDING_READY`.
212
200
 
213
201
  ---
214
202
 
215
203
  ## The CLI
216
204
 
205
+ Fresh projects enter through `rsc onboard`. The direct `add` and `install --profile` forms below
206
+ are maintenance controls for projects that already carry an `.rsc.json` declaration; they cannot
207
+ bypass onboarding in a new folder.
208
+
217
209
  ```bash
218
- rsc # plain-language wizard (recommended) — pick skills AND assistants
210
+ rsc onboard # binding plain-language onboarding (recommended)
211
+ rsc reassess # check persisted deferral triggers; never installs by itself
219
212
  rsc add fastapi postgresdb # install specific skills, by name
220
213
  rsc add youtube-api remotion-video # …grow a channel, edit with Remotion
221
214
  rsc add fastapi --target claude,codex # install into several assistants at once
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.2",
2
+ "version": "1.3.0",
3
3
  "counts": {
4
4
  "skills": 272,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
3
- import { join, dirname, relative, sep } from 'node:path';
2
+ import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
3
+ import { join, dirname, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { planInstall } from './install-plan.js';
6
6
  import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
7
7
  import {
8
8
  targetHasAgents, reconcileAgents, agentPath, agentNames,
9
- resolveAgentNames, agentByName, readDeveloperTier,
9
+ resolveAgentNames, agentByName, allAgentNames, readDeveloperTier,
10
10
  } from '../targets/agents.js';
11
11
  import { readState, writeState } from './lib/state.js';
12
12
  import { readManifest, writeManifest } from './lib/manifest-file.js';
13
13
  import { createBackup } from './lib/backups.js';
14
14
  import {
15
- targetHasCommands, resolveCommands, reconcileCommands, commandPath,
15
+ targetHasCommands, resolveCommands, reconcileCommands, commandPath, allPotentialCommandNames,
16
16
  } from '../targets/commands.js';
17
17
  import {
18
18
  wireMemory, unwireMemory, memoryManagedPaths, memoryModeFor, memoryEnabledForProject, memoryArtifactsPresent,
@@ -63,48 +63,50 @@ function ensureBase(id, cwd, baseVersions) {
63
63
  // reported by a --dry-run. Keep in sync with targets/claude.js wireHook(): a file it
64
64
  // writes but this omits is silently absent from the snapshot, so `rsc restore` cannot
65
65
  // bring it back.
66
- function generatedHookFiles({ target, cwd }) {
66
+ export function generatedHookFiles({ target, cwd, policy }) {
67
67
  if (target !== 'claude') return [];
68
- return [
68
+ const lifecycle = [
69
69
  join(cwd, '.rsc', 'session-start.mjs'),
70
70
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
71
- join(cwd, '.rsc', 'ship-guard.mjs'),
72
- join(cwd, '.rsc', 'danger-guard.mjs'),
73
- join(cwd, '.rsc', 'gitmoji-guard.mjs'),
74
- join(cwd, '.rsc', 'userprompt-gate.mjs'),
75
71
  join(cwd, '.rsc', 'hook-once.mjs'),
76
- join(cwd, '.rsc', 'sello.mjs'),
72
+ join(cwd, '.rsc', 'worktree-reaper.mjs'),
77
73
  ];
74
+ if (policy?.codeHooks === false) return [...lifecycle, join(cwd, '.rsc', 'suggest-always-on.md')];
75
+ return [...lifecycle,
76
+ join(cwd, '.rsc', 'ship-guard.mjs'), join(cwd, '.rsc', 'danger-guard.mjs'),
77
+ join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
78
+ join(cwd, '.rsc', 'sello.mjs')];
78
79
  }
79
80
 
80
- export function managedPathsForInstall({ skillIds, agentIds = [], target, home, cwd }) {
81
+ export function managedPathsForInstall({ skillIds, agentIds = [], target, home, cwd, policy }) {
81
82
  const paths = targetPaths(target, home, cwd);
82
- const plan = planInstall({ skillIds, target, home, cwd });
83
+ const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
83
84
  const out = [paths.stateFile, versionFile(cwd), baseVersionsFile(cwd)];
84
- out.push(...memoryManagedPaths(target, cwd));
85
+ if (policy?.context7 === false) out.push(join(cwd, '.rsc', '.no-context7'));
86
+ if (policy?.memory !== false) out.push(...memoryManagedPaths(target, cwd));
85
87
  if (targetHasAgents(target)) {
86
88
  const state = readState(paths.stateFile);
87
89
  const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
88
- const desired = resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit);
89
- out.push(...[...new Set([...(state.agents || []), ...desired])].map((n) => agentPath(target, cwd, n)), join(cwd, '.rsc', 'developer.json'));
90
+ const desired = policy?.agents || resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit);
91
+ out.push(...desired.map((n) => agentPath(target, cwd, n)));
90
92
  }
91
93
  if (targetHasCommands(target)) {
92
94
  const state = readState(paths.stateFile);
93
95
  const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])];
94
- const desiredAgents = resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit);
96
+ const desiredAgents = policy?.agents || resolveAgentNames([...Object.keys(state.skills || {}), ...skillIds], explicit);
95
97
  const desiredCommands = resolveCommands({
96
98
  target,
97
- skills: [...new Set([...Object.keys(state.skills || {}), ...skillIds])],
99
+ skills: policy ? skillIds : [...new Set([...Object.keys(state.skills || {}), ...skillIds])],
98
100
  agents: desiredAgents,
99
101
  memoryMode: memoryEnabledForProject(cwd) ? memoryModeFor(target) : 'disabled',
100
102
  });
101
- out.push(...[...new Set([...(state.commands || []), ...desiredCommands.map((command) => command.name)])].map((name) => commandPath(target, cwd, name)));
103
+ out.push(...desiredCommands.map((command) => commandPath(target, cwd, command.name)));
102
104
  }
103
105
  for (const step of plan) {
104
106
  if (step.kind === 'skill') {
105
107
  out.push(step.to, baseDir(step.id, cwd));
106
108
  } else if (step.kind === 'hook') {
107
- out.push(step.to, ...generatedHookFiles({ target, cwd }));
109
+ out.push(step.to, ...generatedHookFiles({ target, cwd, policy }));
108
110
  }
109
111
  }
110
112
  return [...new Set(out)];
@@ -129,7 +131,7 @@ function localDecisions(cwd) {
129
131
  // into codex on a machine that already had claude is adding, not replacing. Union, sorted
130
132
  // where order carries no meaning, so the file stays diffable and a merge conflict stays
131
133
  // readable.
132
- export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalogVersion = CLI_VERSION, dropTarget }) {
134
+ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalogVersion = CLI_VERSION, dropTarget, onboarding }) {
133
135
  const prev = readManifest(cwd) || { targets: [], skills: [], agents: [], ownSkills: [], optOuts: [] };
134
136
  const { optOuts, tier } = localDecisions(cwd);
135
137
  const union = (a, b) => [...new Set([...(a || []), ...(b || [])])].sort();
@@ -146,13 +148,14 @@ export function recordInManifest({ cwd, target, skillIds, agentIds = [], catalog
146
148
  tier: tier ?? prev.tier ?? null,
147
149
  optOuts: optOuts.length ? optOuts : (prev.optOuts || []),
148
150
  memory: prev.memory,
151
+ onboarding: onboarding ?? prev.onboarding,
149
152
  });
150
153
  }
151
154
 
152
- export async function applyInstall({ skillIds = [], agentIds = [], target, home, cwd = process.cwd(), operation = 'install', dryRun = false }) {
155
+ export async function applyInstall({ skillIds = [], agentIds = [], target, home, cwd = process.cwd(), operation = 'install', dryRun = false, policy, onboarding }) {
153
156
  const paths = targetPaths(target, home, cwd);
154
- const plan = planInstall({ skillIds, target, home, cwd });
155
- const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd });
157
+ const plan = planInstall({ skillIds, target, home, cwd, hooks: policy?.alwaysOn !== false });
158
+ const managedPaths = managedPathsForInstall({ skillIds, agentIds, target, home, cwd, policy });
156
159
  if (dryRun) return { dryRun: true, skills: skillIds, agents: agentIds, paths: managedPaths };
157
160
  const state = readState(paths.stateFile);
158
161
  const backup = createBackup({ cwd, operation, target, paths: managedPaths, cliVersion: CLI_VERSION });
@@ -161,13 +164,20 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
161
164
  // syncs — a single global marker would be bumped by the first target and make later
162
165
  // targets skip refreshing their exclusive skills' bases.
163
166
  const baseVersions = readBaseVersions(cwd);
167
+ if (policy) {
168
+ for (const id of Object.keys(state.skills || {})) {
169
+ if (skillIds.includes(id)) continue;
170
+ rmSync(paths.skillDir(id), { recursive: true, force: true });
171
+ delete state.skills[id];
172
+ }
173
+ }
164
174
  for (const step of plan) {
165
175
  if (step.kind === 'skill') {
166
176
  const base = ensureBase(step.id, cwd, baseVersions);
167
177
  const files = await writeSkill(target, step.id, base, step.to);
168
178
  state.skills[step.id] = { files, base };
169
179
  } else if (step.kind === 'hook') {
170
- await wireHook(target, paths, join(ensureBase('suggest', cwd, baseVersions), 'SKILL.md'));
180
+ await wireHook(target, paths, join(ensureBase('suggest', cwd, baseVersions), 'SKILL.md'), policy);
171
181
  }
172
182
  }
173
183
  writeBaseVersions(cwd, baseVersions);
@@ -179,8 +189,13 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
179
189
  //
180
190
  // The recorded names come from what was WRITTEN, not from a hardcoded list: a state entry
181
191
  // naming an agent whose file never landed answers "you have it" for something absent.
182
- const explicit = [...new Set([...(state.explicitAgents || readManifest(cwd)?.agents || []), ...agentIds])].sort();
183
- const desiredAgents = resolveAgentNames(Object.keys(state.skills || {}), explicit);
192
+ const inheritedExplicit = policy ? (state.explicitAgents || []) : (state.explicitAgents || readManifest(cwd)?.agents || []);
193
+ const explicit = [...new Set([...inheritedExplicit, ...agentIds])].sort();
194
+ const desiredAgents = policy?.agents
195
+ ? [...new Set([...policy.agents, ...explicit])].sort()
196
+ : policy?.baseAgents === false
197
+ ? explicit
198
+ : resolveAgentNames(Object.keys(state.skills || {}), explicit);
184
199
  const previousAgents = state.agents || [];
185
200
  if (targetHasAgents(target)) {
186
201
  const agentResult = reconcileAgents(target, cwd, readDeveloperTier(cwd), previousAgents, desiredAgents);
@@ -191,8 +206,23 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
191
206
  state.agentCollisions = [];
192
207
  }
193
208
  state.explicitAgents = explicit;
194
- const memoryResult = wireMemory(target, cwd);
209
+ const memoryResult = policy?.memory === false
210
+ ? { mode: 'disabled', reason: 'onboarding-policy', paths: unwireMemory(target, cwd) }
211
+ : wireMemory(target, cwd);
195
212
  state.memory = { mode: memoryResult.mode, reason: memoryResult.reason, paths: memoryResult.paths };
213
+ const context7OptOut = join(cwd, '.rsc', '.no-context7');
214
+ if (policy?.context7 === false) {
215
+ mkdirSync(dirname(context7OptOut), { recursive: true });
216
+ writeFileSync(context7OptOut, 'Managed by the accepted onboarding plan: external MCP connections require separate consent.\n');
217
+ }
218
+ state.policy = policy ? {
219
+ baseAgents: policy.baseAgents !== false,
220
+ alwaysOn: policy.alwaysOn !== false,
221
+ codeHooks: policy.codeHooks !== false,
222
+ gitmojiGuard: policy.gitmojiGuard !== false,
223
+ memory: policy.memory !== false,
224
+ context7: policy.context7 === true,
225
+ } : state.policy;
196
226
  const desiredCommands = resolveCommands({
197
227
  target,
198
228
  skills: Object.keys(state.skills || {}),
@@ -206,7 +236,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
206
236
  writeState(paths.stateFile, state);
207
237
  mkdirSync(dirname(versionFile(cwd)), { recursive: true });
208
238
  writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
209
- recordInManifest({ cwd, target, skillIds, agentIds });
239
+ recordInManifest({ cwd, target, skillIds, agentIds, onboarding });
210
240
  ignoreLocalState(cwd, target);
211
241
  return { ...state, backup };
212
242
  }
@@ -264,6 +294,12 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
264
294
  const prefix = text === '' ? '' : (text.endsWith('\n') ? '' : '\n');
265
295
  try {
266
296
  appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) and managed skill links — machine-local\n${add.join('\n')}\n`);
297
+ if (target) {
298
+ const statePath = targetPaths(target, undefined, cwd).stateFile;
299
+ const state = readState(statePath);
300
+ state.ignoreEntriesAdded = [...new Set([...(state.ignoreEntriesAdded || []), ...add.map(norm)])].sort();
301
+ writeState(statePath, state);
302
+ }
267
303
  return gi;
268
304
  } catch { return null; }
269
305
  }
@@ -356,6 +392,86 @@ export async function uninstall({ skillIds = [], agentIds = [], target, home, cw
356
392
  return [...new Set(removed)];
357
393
  }
358
394
 
395
+ // Reconcile an accepted onboarding plan as a replacement, not an additive install.
396
+ // Ownership comes only from the target state file; unrelated files in the assistant
397
+ // directory are left untouched.
398
+ export function removeTargetInstall({ target, home, cwd = process.cwd() }) {
399
+ const paths = targetPaths(target, home, cwd);
400
+ const state = readState(paths.stateFile);
401
+ const ignored = [paths.stateFile];
402
+ const ownedIgnoreEntries = new Set(state.ignoreEntriesAdded || []);
403
+ const owned = [
404
+ ...Object.values(state.skills || {}).flatMap((entry) => entry.files || []),
405
+ ...(state.agents || []).map((name) => agentPath(target, cwd, name)).filter(Boolean),
406
+ ...(state.commands || []).map((name) => commandPath(target, cwd, name)).filter(Boolean),
407
+ paths.stateFile,
408
+ ];
409
+ const declaredLocationsAreExact = Object.entries(state.skills || {}).every(([id, entry]) =>
410
+ /^[a-z0-9][a-z0-9._-]*$/i.test(id)
411
+ && existsSync(join(ROOT, 'skills', id))
412
+ && (entry.files || []).every((file) => resolve(file) === resolve(paths.skillDir(id))))
413
+ && (state.agents || []).every((name) => {
414
+ if (!allAgentNames().includes(name)) return false;
415
+ const file = agentPath(target, cwd, name);
416
+ return file && owned.some((candidate) => resolve(candidate) === resolve(file));
417
+ })
418
+ && (state.commands || []).every((name) => {
419
+ if (!allPotentialCommandNames().includes(name)) return false;
420
+ const file = commandPath(target, cwd, name);
421
+ return file && owned.some((candidate) => resolve(candidate) === resolve(file));
422
+ });
423
+ const root = resolve(cwd);
424
+ const safe = (file) => {
425
+ const absolute = resolve(file);
426
+ if (absolute === root || !absolute.startsWith(`${root}${sep}`)) return false;
427
+ const parts = relative(root, dirname(absolute)).split(sep).filter(Boolean);
428
+ let cursor = root;
429
+ for (const part of parts) {
430
+ cursor = join(cursor, part);
431
+ if (!existsSync(cursor)) break;
432
+ if (lstatSync(cursor).isSymbolicLink()) {
433
+ const destination = realpathSync(cursor);
434
+ if (destination !== root && !destination.startsWith(`${root}${sep}`)) return false;
435
+ }
436
+ }
437
+ return true;
438
+ };
439
+ if (!declaredLocationsAreExact || owned.some((file) => !safe(file))) throw new Error(`RSC_PREVIOUS_INSTALL_INCOMPATIBLE: ${target} state contains a path outside its managed locations`);
440
+ for (const entry of Object.values(state.skills || {})) {
441
+ for (const file of entry.files || []) { ignored.push(file); rmSync(file, { recursive: true, force: true }); }
442
+ }
443
+ for (const name of state.agents || []) {
444
+ const file = agentPath(target, cwd, name);
445
+ if (file) { ignored.push(file); rmSync(file, { recursive: true, force: true }); }
446
+ }
447
+ for (const name of state.commands || []) {
448
+ const file = commandPath(target, cwd, name);
449
+ if (file) { ignored.push(file); rmSync(file, { recursive: true, force: true }); }
450
+ }
451
+ unwireHook(target, paths);
452
+ unwireMemory(target, cwd);
453
+ rmSync(paths.stateFile, { force: true });
454
+ const gitignore = join(cwd, '.gitignore');
455
+ if (existsSync(gitignore)) {
456
+ const stale = new Set(ignored.map((path) => relative(cwd, path).split(sep).join('/').replace(/^\//, '').replace(/\/$/, ''))
457
+ .filter((entry) => ownedIgnoreEntries.has(entry)));
458
+ const lines = readFileSync(gitignore, 'utf8').split('\n');
459
+ const kept = lines.filter((line) => !stale.has(line.trim().replace(/^\//, '').replace(/\/$/, '')));
460
+ writeFileSync(gitignore, kept.join('\n'));
461
+ }
462
+ }
463
+
464
+ export function pruneSharedBases({ cwd = process.cwd(), skillIds = [] }) {
465
+ const versions = readBaseVersions(cwd);
466
+ const wanted = new Set(skillIds);
467
+ for (const id of Object.keys(versions)) {
468
+ if (wanted.has(id)) continue;
469
+ rmSync(baseDir(id, cwd), { recursive: true, force: true });
470
+ delete versions[id];
471
+ }
472
+ writeBaseVersions(cwd, versions);
473
+ }
474
+
359
475
  export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun = false }) {
360
476
  const paths = targetPaths(target, home, cwd);
361
477
  const state = readState(paths.stateFile);
@@ -365,7 +481,8 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
365
481
  // to rebuild from, and it is sitting right there.
366
482
  const ids = Object.keys(state.skills || {});
367
483
  const manifest = readManifest(cwd);
368
- const declared = ids.length ? ids : (manifest?.skills || []);
484
+ const governedSkills = manifest?.onboarding?.plan?.policy?.skills;
485
+ const declared = governedSkills || (ids.length ? ids : (manifest?.skills || []));
369
486
  const declaredAgents = state.explicitAgents?.length ? state.explicitAgents : (manifest?.agents || []);
370
487
  if (!declared.length && !declaredAgents.length) return dryRun ? { dryRun: true, synced: [], syncedAgents: [], paths: [] } : { synced: [], syncedAgents: [], backup: null };
371
488
  if (dryRun) {
@@ -373,10 +490,19 @@ export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun
373
490
  dryRun: true,
374
491
  synced: declared,
375
492
  syncedAgents: declaredAgents,
376
- paths: managedPathsForInstall({ skillIds: declared, agentIds: declaredAgents, target, home, cwd }),
493
+ paths: managedPathsForInstall({ skillIds: declared, agentIds: declaredAgents, target, home, cwd, policy: manifest?.onboarding?.plan?.policy }),
377
494
  };
378
495
  }
379
- const nextState = await applyInstall({ skillIds: declared, agentIds: declaredAgents, target, home, cwd, operation: 'sync' });
496
+ const nextState = await applyInstall({
497
+ skillIds: declared,
498
+ agentIds: declaredAgents,
499
+ target,
500
+ home,
501
+ cwd,
502
+ operation: 'sync',
503
+ policy: manifest?.onboarding?.plan?.policy,
504
+ onboarding: manifest?.onboarding,
505
+ });
380
506
  return { synced: declared, syncedAgents: declaredAgents, backup: nextState.backup };
381
507
  }
382
508
 
@@ -1,12 +1,12 @@
1
1
  import { targetPaths } from '../targets/index.js';
2
2
 
3
- export function planInstall({ skillIds, target, home, cwd }) {
3
+ export function planInstall({ skillIds, target, home, cwd, hooks = true }) {
4
4
  const t = targetPaths(target, home, cwd);
5
5
  const plan = [];
6
6
  for (const id of skillIds) {
7
7
  plan.push({ kind: 'skill', id, from: `skills/${id}`, to: t.skillDir(id) });
8
8
  }
9
- if (skillIds.includes('suggest')) {
9
+ if (hooks && skillIds.includes('suggest')) {
10
10
  plan.push({ kind: 'hook', target, to: t.hookTarget });
11
11
  }
12
12
  return plan;
@@ -15,7 +15,7 @@ import { join } from 'node:path';
15
15
  export const MANIFEST = '.rsc.json';
16
16
  export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
17
17
 
18
- const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory'];
18
+ const KEYS = ['version', 'targets', 'skills', 'agents', 'ownSkills', 'catalogVersion', 'tier', 'optOuts', 'memory', 'onboarding'];
19
19
 
20
20
  export function readManifest(cwd = process.cwd()) {
21
21
  const file = manifestPath(cwd);
@@ -38,6 +38,7 @@ export function readManifest(cwd = process.cwd()) {
38
38
  tier: raw.tier ?? null,
39
39
  optOuts: raw.optOuts || [],
40
40
  memory: raw.memory ?? undefined,
41
+ onboarding: raw.onboarding ?? undefined,
41
42
  };
42
43
  }
43
44