@ericrisco/rsc 1.1.4 → 1.1.5

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
@@ -52,6 +52,7 @@ user wants**, reads the repo, then installs the floor (`orient` + `rsc-suggest`
52
52
  `init`) plus only the skills that fit — one at a time. No global install and no API key needed.
53
53
 
54
54
  - **Choose assistants non-interactively:** `npx @ericrisco/rsc@latest --target claude` (comma-separate for several).
55
+ - **Two assistants already installed?** rsc asks instead of guessing. `--target` settles it in one word.
55
56
  - **Already installed, just refreshing skills + hooks:** `rsc sync` (or re-run the command above).
56
57
  - **Add one skill by id:** `rsc add <id>` · **browse the catalog:** `rsc consult "<what you want>"` or `rsc list`.
57
58
 
@@ -203,6 +204,75 @@ rsc uninstall postgresdb --dry-run # preview a removal
203
204
 
204
205
  ---
205
206
 
207
+
208
+ ## 👥 Sharing a harness with your team
209
+
210
+ The harness travels by git, but not all of it — and the split is the point.
211
+
212
+ **Commit these:**
213
+
214
+ | | |
215
+ | --- | --- |
216
+ | `.rsc.json` | The decision: which assistants, which skills, **which catalog version**, the developer tier, which gates you disarmed |
217
+ | `01-TOOLS/` · `02-DOCS/` | Your tooling and your wiki, if you use them |
218
+ | Skills and agents you wrote by hand | They are yours. rsc does not claim them, does not count them as drift, and does not touch them |
219
+
220
+ **Do not commit these** — rsc adds them to `.gitignore` for you:
221
+
222
+ | | Why |
223
+ | --- | --- |
224
+ | `.rsc/` | Machine state: hook scripts, seals, logs |
225
+ | The skill entries rsc manages | Symlinks on macOS/Linux, real copies on Windows — two incompatible shapes of one thing |
226
+
227
+ Whoever clones runs **one command** and ends up with the same harness:
228
+
229
+ ```bash
230
+ npx @ericrisco/rsc@latest sync
231
+ ```
232
+
233
+ Same skills, same version — `.rsc.json` pins the catalog, so a teammate who clones in three
234
+ months gets what you had, not what shipped since. Upgrading is a deliberate act, never a side
235
+ effect of rebuilding.
236
+
237
+ When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
238
+ matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
239
+
240
+ **Own skills.** A skill your team wrote lives in the repo and already works for whoever clones,
241
+ with no command at all. Declare it in `.rsc.json` under `ownSkills` and `doctor` will also say
242
+ when someone is missing it — that is all declaring does. rsc never installs, updates or
243
+ overwrites it: its version is the commit.
244
+
245
+
246
+ ## 🩹 Something's off? One command
247
+
248
+ Recognise any of these? They are all the same fix.
249
+
250
+ | What you see | |
251
+ | --- | --- |
252
+ | `"target": "codex"` when you work in Claude Code | |
253
+ | `This target has no hook injection` and you did not expect that | |
254
+ | Skills appear that you never asked for | |
255
+ | You cloned a repo and your assistant sees no skills at all | |
256
+ | A hook seems to run several times per turn | |
257
+ | Template lines showed up inside your hand-written `AGENTS.md` | |
258
+
259
+ ```bash
260
+ npx @ericrisco/rsc@latest repair
261
+ ```
262
+
263
+ Safe in any folder: with no rsc there, it says so and writes nothing. It shows what it
264
+ found before touching anything, keeps a recoverable copy, and running it twice changes
265
+ nothing the second time. Add `--dry-run` to see the whole pass without a single write.
266
+
267
+ **What it fixes on its own** — putting the harness back to what was already declared:
268
+ dangling links from a clone, hooks wired several times, the 0.1 layout no assistant reads.
269
+
270
+ **What it asks about** — anything that changes a decision: moving the harness to another
271
+ assistant, or touching files you already committed.
272
+
273
+ **What it never touches:** skills and agents you wrote by hand. rsc did not install them,
274
+ so rsc does not repair, move or delete them — not even when rebuilding from scratch.
275
+
206
276
  ## Update
207
277
 
208
278
  `rsc` is an npm package, so updating is two steps — bump the package, then
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.4",
2
+ "version": "1.1.5",
3
3
  "counts": {
4
4
  "skills": 281
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { join } 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
+ import { divergence } from './lib/divergence.js';
6
7
  import { loadManifest } from './lib/manifest.js';
7
8
  import { listBackups } from './lib/backups.js';
8
9
  import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
@@ -40,6 +41,40 @@ function selloStatus(root) {
40
41
  } catch { return { enabled: false }; }
41
42
  }
42
43
 
44
+ // A wired hook is a promise to run a file. `settings.json` existing only proves the
45
+ // promise was made — and a fresh clone brings that file (committed) without .rsc/
46
+ // (ignored), so every session start fails while the report says all-clear. Read the
47
+ // commands we wired and check the files they name actually exist.
48
+ //
49
+ // Hookless targets get an empty list by construction: their always-on surface is a
50
+ // markdown block with no script behind it, so there is nothing that can go missing.
51
+ export function missingHookScripts({ target, home = homedir(), cwd = process.cwd() } = {}) {
52
+ if (HOOKLESS_TARGETS.has(target)) return [];
53
+ const file = targetPaths(target, home, cwd).hookTarget;
54
+ let settings;
55
+ try { settings = JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
56
+ const commands = [];
57
+ for (const entries of Object.values(settings?.hooks || {})) {
58
+ if (!Array.isArray(entries)) continue;
59
+ for (const entry of entries) for (const h of entry?.hooks || []) {
60
+ if (typeof h?.command === 'string') commands.push(h.command);
61
+ }
62
+ }
63
+ const seen = new Set();
64
+ // Commands name the project through ${CLAUDE_PROJECT_DIR}, which only the client
65
+ // expands. Resolve it here or every wired script reads as missing and the report
66
+ // calls a healthy install broken — the mirror image of the bug this check fixes.
67
+ const expand = (c) => c.split('${CLAUDE_PROJECT_DIR}').join(cwd);
68
+ for (const raw of commands) {
69
+ const cmd = expand(raw);
70
+ for (const m of cmd.matchAll(/["']?([^"'\s]*[\\/]\.rsc[\\/][^"'\s]+\.mjs)["']?/g)) {
71
+ const script = m[1];
72
+ if (!existsSync(script)) seen.add(script);
73
+ }
74
+ }
75
+ return [...seen];
76
+ }
77
+
43
78
  export function doctor({ target, home, cwd }) {
44
79
  const root = cwd || process.cwd();
45
80
  const paths = targetPaths(target, home, cwd);
@@ -50,7 +85,8 @@ export function doctor({ target, home, cwd }) {
50
85
  target,
51
86
  installed: Object.keys(state.skills),
52
87
  missing: [],
53
- hookWired: existsSync(paths.hookTarget),
88
+ // Wired means it can actually run, not merely that the file declaring it is there.
89
+ hookWired: existsSync(paths.hookTarget) && missingHookScripts({ target, home, cwd }).length === 0,
54
90
  manifestSkills: manifest.counts.skills,
55
91
  backups: {
56
92
  exists: existsSync(join(root, '.rsc', 'backups')),
@@ -285,6 +321,37 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
285
321
  + 'root, or update both to a version that de-duplicates at runtime.',
286
322
  });
287
323
  }
324
+ const orphanScripts = missingHookScripts({ target, home, cwd });
325
+ if (orphanScripts.length) {
326
+ // ONE finding, not one per file: a clone is missing all eight at once, and a list of
327
+ // eight paths buries the single thing the reader has to do (principle 5).
328
+ findings.push({
329
+ id: 'hook-scripts-missing',
330
+ severity: 'high',
331
+ summary: `${orphanScripts.length} hook script(s) named by the wiring are not on disk, so those hooks `
332
+ + 'fail every time they fire. This is what a fresh clone looks like: the settings travelled, the '
333
+ + 'scripts did not.',
334
+ action: 'Rebuild them with `npx @ericrisco/rsc repair` from this project root.',
335
+ });
336
+ }
337
+ const drift = divergence({ cwd, target, home });
338
+ if (drift.missing.length || drift.extra.length || drift.ownMissing.length) {
339
+ // The day-two case: a teammate changed the harness and this checkout has not caught
340
+ // up. Reported always, even after someone declines to align — the divergence does not
341
+ // stop being true because they said no.
342
+ const parts = [];
343
+ if (drift.missing.length) parts.push(`missing ${drift.missing.join(', ')}`);
344
+ if (drift.ownMissing.length) parts.push(`${drift.ownMissing.join(', ')} declared by the team but not in this repo`);
345
+ if (drift.extra.length) parts.push(`${drift.extra.join(', ')} installed but no longer declared`);
346
+ findings.push({
347
+ id: 'manifest-divergence',
348
+ severity: 'medium',
349
+ summary: `This checkout does not match what .rsc.json declares: ${parts.join('; ')}.`,
350
+ action: drift.ownMissing.length && !drift.missing.length && !drift.extra.length
351
+ ? 'Those come from the repo, not from rsc — pull, or ask whoever wrote them.'
352
+ : 'Align with `npx @ericrisco/rsc sync`. Nothing is written until you run it.',
353
+ });
354
+ }
288
355
  findings.push(...duplicateEntryFindings(scopes));
289
356
  for (const s of scopes) {
290
357
  if (s.hookCountsUnknown) {
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
3
- import { join, dirname, basename } from 'node:path';
2
+ import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
3
+ import { join, dirname, basename, relative, 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 { targetHasAgents, writeAgents, removeAgents, agentPath, agentNames } from '../targets/agents.js';
8
8
  import { readState, writeState } from './lib/state.js';
9
+ import { readManifest, writeManifest } from './lib/manifest-file.js';
9
10
  import { createBackup } from './lib/backups.js';
10
11
 
11
12
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -67,7 +68,7 @@ function generatedHookFiles({ target, cwd }) {
67
68
  ];
68
69
  }
69
70
 
70
- function managedPathsForInstall({ skillIds, target, home, cwd }) {
71
+ export function managedPathsForInstall({ skillIds, target, home, cwd }) {
71
72
  const paths = targetPaths(target, home, cwd);
72
73
  const plan = planInstall({ skillIds, target, home, cwd });
73
74
  const out = [paths.stateFile, versionFile(cwd), baseVersionsFile(cwd)];
@@ -82,6 +83,43 @@ function managedPathsForInstall({ skillIds, target, home, cwd }) {
82
83
  return [...new Set(out)];
83
84
  }
84
85
 
86
+ // The opt-outs a team disarmed and the developer tier live as marker files under .rsc/,
87
+ // which is machine-local and therefore lost on every clone: a team that disarmed the
88
+ // gitmoji guard found it armed again on the next checkout, with nobody having decided
89
+ // that. They are decisions, so they belong in the committed declaration.
90
+ function localDecisions(cwd) {
91
+ const dir = join(cwd, '.rsc');
92
+ let optOuts = [];
93
+ try {
94
+ optOuts = readdirSync(dir).filter((f) => f.startsWith('.no-')).map((f) => f.slice(4)).sort();
95
+ } catch { /* no .rsc yet */ }
96
+ let tier = null;
97
+ try { tier = JSON.parse(readFileSync(join(dir, 'developer.json'), 'utf8')).tier ?? null; } catch { /* unset */ }
98
+ return { optOuts, tier };
99
+ }
100
+
101
+ // Record the decision, merging so a second assistant never erases the first: installing
102
+ // into codex on a machine that already had claude is adding, not replacing. Union, sorted
103
+ // where order carries no meaning, so the file stays diffable and a merge conflict stays
104
+ // readable.
105
+ export function recordInManifest({ cwd, target, skillIds, catalogVersion = CLI_VERSION, dropTarget }) {
106
+ const prev = readManifest(cwd) || { targets: [], skills: [], ownSkills: [], optOuts: [] };
107
+ const { optOuts, tier } = localDecisions(cwd);
108
+ const union = (a, b) => [...new Set([...(a || []), ...(b || [])])].sort();
109
+ return writeManifest(cwd, {
110
+ version: 1,
111
+ // Union by default: installing into a second assistant is adding, not replacing.
112
+ // `dropTarget` is the one exception — a MOVE, where leaving the old one declared would
113
+ // have every clone rebuild a harness that was deliberately abandoned.
114
+ targets: union(prev.targets, [target]).filter((t) => t !== dropTarget),
115
+ skills: union(prev.skills, skillIds),
116
+ ownSkills: prev.ownSkills || [],
117
+ catalogVersion,
118
+ tier: tier ?? prev.tier ?? null,
119
+ optOuts: optOuts.length ? optOuts : (prev.optOuts || []),
120
+ });
121
+ }
122
+
85
123
  export async function applyInstall({ skillIds, target, home, cwd = process.cwd(), operation = 'install', dryRun = false }) {
86
124
  const paths = targetPaths(target, home, cwd);
87
125
  const plan = planInstall({ skillIds, target, home, cwd });
@@ -120,7 +158,8 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
120
158
  writeState(paths.stateFile, state);
121
159
  mkdirSync(dirname(versionFile(cwd)), { recursive: true });
122
160
  writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
123
- ignoreLocalState(cwd);
161
+ recordInManifest({ cwd, target, skillIds });
162
+ ignoreLocalState(cwd, target);
124
163
  return { ...state, backup };
125
164
  }
126
165
 
@@ -132,23 +171,54 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
132
171
  // Additive and idempotent by construction: append one line to an existing
133
172
  // .gitignore, never rewrite or reorder it, and never create the file in a directory
134
173
  // that is not a git repository.
135
- export function ignoreLocalState(cwd = process.cwd()) {
174
+ export function ignoreLocalState(cwd = process.cwd(), target) {
136
175
  if (!existsSync(join(cwd, '.git'))) return null;
137
176
  const gi = join(cwd, '.gitignore');
138
177
  let text = '';
139
178
  try { text = existsSync(gi) ? readFileSync(gi, 'utf8') : ''; } catch { return null; }
140
- const already = text.split('\n').some((l) => {
141
- const s = l.trim().replace(/\/$/, '');
142
- return s === '.rsc' || s === '/.rsc';
143
- });
144
- if (already) return null;
179
+
180
+ // `.rsc/` is machine state. The per-target skill entries are derived: symlinks here,
181
+ // real copies on Windows, so committing either shape puts two incompatible forms of the
182
+ // same thing into one repo.
183
+ //
184
+ // But `claude` writes into `.claude/skills/` and `cursor` into `.cursor/rules/`, which
185
+ // are SHARED with the user — their own skills and rules live there too. Excluding the
186
+ // whole directory would quietly stop versioning their work, which is the same sin this
187
+ // release exists to fix: treating what is theirs as if it were ours. So shared
188
+ // directories are excluded entry by entry, and only the entries rsc manages.
189
+ const wanted = ['.rsc/'];
190
+ if (target) {
191
+ const paths = targetPaths(target, undefined, cwd);
192
+ const rel = (abs) => relative(cwd, abs).split(sep).join('/');
193
+ for (const id of Object.keys(readState(paths.stateFile).skills || {})) wanted.push(rel(paths.skillDir(id)));
194
+ // The per-file ledger is derived AND platform-shaped (symlink here, real copy on
195
+ // Windows), so committing it puts two incompatible inventories of one thing in one
196
+ // repo. The declaration that does travel is .rsc.json, in the root.
197
+ wanted.push(rel(paths.stateFile));
198
+ // Subagents are catalog content, re-materialised by every install and sync.
199
+ for (const f of agentNames()) wanted.push(rel(agentPath(target, cwd, f)));
200
+ }
201
+ // `.rsc`, `/.rsc` and `.rsc/` are the same rule to git. Normalising both ends is what
202
+ // keeps this idempotent against a .gitignore a human wrote in their own spelling.
203
+ const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
204
+ const present = new Set(text.split('\n').map(norm));
205
+ const add = wanted.filter((w) => !present.has(norm(w)));
206
+ if (!add.length) return null;
207
+
208
+ // Append only. Never rewrite, never reorder: the rest of that file is theirs.
145
209
  const prefix = text === '' ? '' : (text.endsWith('\n') ? '' : '\n');
146
210
  try {
147
- appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) — machine-local, never committed\n.rsc/\n`);
211
+ appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) and managed skill links — machine-local\n${add.join('\n')}\n`);
148
212
  return gi;
149
213
  } catch { return null; }
150
214
  }
151
215
 
216
+ export function collisions({ cwd = process.cwd(), target, home, skillIds = [] }) {
217
+ const paths = targetPaths(target, home, cwd);
218
+ const managed = new Set(Object.keys(readState(paths.stateFile).skills || {}));
219
+ return skillIds.filter((id) => !managed.has(id) && existsSync(paths.skillDir(id)));
220
+ }
221
+
152
222
  export function listInstalled({ target, home, cwd = process.cwd() }) {
153
223
  const paths = targetPaths(target, home, cwd);
154
224
  return Object.keys(readState(paths.stateFile).skills);
@@ -185,17 +255,22 @@ export async function uninstall({ skillIds, target, home, cwd = process.cwd(), d
185
255
  export async function syncInstalled({ target, home, cwd = process.cwd(), dryRun = false }) {
186
256
  const paths = targetPaths(target, home, cwd);
187
257
  const state = readState(paths.stateFile);
258
+ // The per-target state is machine wiring and does not travel, so a fresh clone has
259
+ // none of it: sync found zero skills and did nothing, leaving someone with a repo that
260
+ // declares a harness and has none. The committed manifest is exactly the declaration
261
+ // to rebuild from, and it is sitting right there.
188
262
  const ids = Object.keys(state.skills || {});
189
- if (!ids.length) return dryRun ? { dryRun: true, synced: [], paths: [] } : { synced: [], backup: null };
263
+ const declared = ids.length ? ids : (readManifest(cwd)?.skills || []);
264
+ if (!declared.length) return dryRun ? { dryRun: true, synced: [], paths: [] } : { synced: [], backup: null };
190
265
  if (dryRun) {
191
266
  return {
192
267
  dryRun: true,
193
- synced: ids,
194
- paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
268
+ synced: declared,
269
+ paths: managedPathsForInstall({ skillIds: declared, target, home, cwd }),
195
270
  };
196
271
  }
197
- const nextState = await applyInstall({ skillIds: ids, target, home, cwd, operation: 'sync' });
198
- return { synced: ids, backup: nextState.backup };
272
+ const nextState = await applyInstall({ skillIds: declared, target, home, cwd, operation: 'sync' });
273
+ return { synced: declared, backup: nextState.backup };
199
274
  }
200
275
 
201
276
  // Remove EVERYTHING rsc put in this project: installed skills across all targets,
@@ -0,0 +1,33 @@
1
+ import { readManifest } from './manifest-file.js';
2
+ import { readState } from './state.js';
3
+ import { targetPaths } from '../../targets/index.js';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ // What the team declared versus what is on this machine.
7
+ //
8
+ // The everyday case is not cloning — it is a teammate changing the harness and everyone
9
+ // else running `git pull`. The clone is day one; divergence is every other day. Nothing
10
+ // used to notice, so two people drifted apart in silence.
11
+ //
12
+ // Three buckets, because the three need different answers, and collapsing them is how a
13
+ // report starts nagging someone about their own work:
14
+ // missing — declared catalog skill, not installed here. Aligning installs it.
15
+ // ownMissing — declared team skill, absent from the repo. Aligning says so and writes
16
+ // nothing: it comes from the repo, not from us.
17
+ // extra — installed BY RSC, no longer declared.
18
+ // A hand-written skill that nobody declared is in none of them. Not declaring is a valid
19
+ // way to keep things of your own, not an oversight to correct.
20
+ export function divergence({ cwd = process.cwd(), target, home } = {}) {
21
+ const empty = { missing: [], extra: [], ownMissing: [] };
22
+ const manifest = readManifest(cwd);
23
+ if (!manifest) return empty;
24
+
25
+ const paths = targetPaths(target, home, cwd);
26
+ const installed = new Set(Object.keys(readState(paths.stateFile).skills || {}));
27
+
28
+ return {
29
+ missing: (manifest.skills || []).filter((id) => !installed.has(id)),
30
+ extra: [...installed].filter((id) => !(manifest.skills || []).includes(id)),
31
+ ownMissing: (manifest.ownSkills || []).filter((name) => !existsSync(paths.skillDir(name))),
32
+ };
33
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // `.rsc.json` — the team's decision about this project's harness, committed.
5
+ //
6
+ // It exists because nothing else could travel. The per-target state lives inside the
7
+ // assistant's own directory, which is machine wiring; `syncInstalled()` reads it, so a
8
+ // clone finds zero skills and does nothing. The declaration had to move to the root.
9
+ //
10
+ // It is a DECLARATION, never an inventory: ids and names only, no paths and no file
11
+ // lists. Two reasons. Paths differ by platform (some machines symlink, others copy), so
12
+ // committing them reproduces in the manifest the very conflict this removes from the
13
+ // skill directories. And a committed file gets merge conflicts by nature — one entry per
14
+ // line, stable key order, so resolving one means reading it, not understanding a format.
15
+ export const MANIFEST = '.rsc.json';
16
+ export const manifestPath = (cwd = process.cwd()) => join(cwd, MANIFEST);
17
+
18
+ const KEYS = ['version', 'targets', 'skills', 'ownSkills', 'catalogVersion', 'tier', 'optOuts'];
19
+
20
+ export function readManifest(cwd = process.cwd()) {
21
+ const file = manifestPath(cwd);
22
+ if (!existsSync(file)) return null;
23
+ let raw;
24
+ // An unreadable manifest is NOT the same as an absent one. Absent means "not adopted
25
+ // yet" and is normal; corrupt means the file that governs this project cannot be
26
+ // trusted, and silently treating it as absent would then overwrite it on the next
27
+ // install. Say it and stop.
28
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
29
+ throw new Error(`${MANIFEST} exists but is not readable JSON — fix or delete it; rsc will not overwrite it`);
30
+ }
31
+ return {
32
+ version: raw.version ?? 1,
33
+ targets: raw.targets || [],
34
+ skills: raw.skills || [],
35
+ ownSkills: raw.ownSkills || [],
36
+ catalogVersion: raw.catalogVersion ?? null,
37
+ tier: raw.tier ?? null,
38
+ optOuts: raw.optOuts || [],
39
+ };
40
+ }
41
+
42
+ // Stable output: fixed key order, arrays one entry per line, order as given. Rewriting an
43
+ // unchanged manifest must produce identical bytes, or every install shows up as a diff.
44
+ export function writeManifest(cwd, manifest) {
45
+ const out = {};
46
+ for (const k of KEYS) if (manifest[k] !== undefined) out[k] = manifest[k];
47
+ out.version ??= 1;
48
+ const ordered = {};
49
+ for (const k of KEYS) if (out[k] !== undefined) ordered[k] = out[k];
50
+ const file = manifestPath(cwd);
51
+ writeFileSync(file, JSON.stringify(ordered, null, 2) + '\n');
52
+ return file;
53
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { targetPaths, installedTargets, detectTarget, unwireHook } from '../../targets/index.js';
5
+ import { readState } from './state.js';
6
+ import { readManifest } from './manifest-file.js';
7
+ import { createBackup } from './backups.js';
8
+ import { countHookEntries } from '../doctor.js';
9
+ import { applyInstall, recordInManifest, managedPathsForInstall } from '../install-apply.js';
10
+
11
+ // Fixing a harness that came from an earlier version.
12
+ //
13
+ // The other two deliveries fix the future and fix nobody: everyone who built a harness on
14
+ // 0.1 or 1.0 wakes up the day after with exactly what they had, and does not know it. This
15
+ // is the one command for them — no flags, no need to know what is wrong.
16
+ //
17
+ // EVERY finding is born with its class, and the executor only reads that field. It never
18
+ // decides:
19
+ //
20
+ // restore — put the harness back to what was ALREADY declared. Nobody decides anything,
21
+ // so it happens on its own, after a recoverable copy.
22
+ // change — alter something a person or a team chose. Always asked, one by one.
23
+ //
24
+ // Putting the boundary in the data instead of the executor means it can be tested without
25
+ // running anything, and it cannot drift apart between branches.
26
+
27
+ const finding = (id, cls, summary, action) => ({ id, class: cls, summary, action });
28
+
29
+ export function diagnose({ cwd = process.cwd(), target, home = homedir(), invoked = false } = {}) {
30
+ const paths = targetPaths(target, home, cwd);
31
+ const state = readState(paths.stateFile);
32
+ const installed = Object.keys(state.skills || {});
33
+ const manifest = readManifest(cwd);
34
+ const out = [];
35
+
36
+ // Nothing of ours here at all: say so and touch nothing. Running this in the wrong
37
+ // folder must be harmless.
38
+ if (!installed.length && !manifest && !existsSync(join(cwd, '.rsc'))) return out;
39
+
40
+ // The 0.1 layout Claude Code never discovered. writeSkill() sweeps it, but only when
41
+ // that skill is reinstalled — so anyone who never reinstalled still carries it.
42
+ if (existsSync(join(paths.root, 'rsc'))) {
43
+ out.push(finding('nested-layout', 'restore',
44
+ 'A .claude/skills/rsc/ directory from the 0.1 layout is still here; no assistant ever read it.',
45
+ 'It gets removed. Nothing that works today lives in it.'));
46
+ }
47
+
48
+ // Repeated wiring. On Windows the needle never matched, so every install ADDED a hook
49
+ // instead of replacing it: there were real machines running each hook four times.
50
+ const repeated = duplicatedHooks(paths.hookTarget);
51
+ if (repeated.length) {
52
+ out.push(finding('duplicate-hooks', 'restore',
53
+ `${repeated.join(', ')} wired more than once, so those hooks run several times per event.`,
54
+ 'The extra copies are removed, leaving one of each.'));
55
+ }
56
+
57
+ // A clone: the declaration travelled, the materialised skills did not.
58
+ const dangling = installed.filter((id) => !existsSync(paths.skillDir(id)))
59
+ .concat((manifest?.skills || []).filter((id) => !existsSync(paths.skillDir(id))));
60
+ if (dangling.length) {
61
+ out.push(finding('dangling-links', 'restore',
62
+ `${[...new Set(dangling)].join(', ')} are declared but not on disk — what a fresh clone looks like.`,
63
+ 'They get rebuilt from the catalog at the version the manifest pins.'));
64
+ }
65
+
66
+ // Writing the manifest records something already true, which sounds like a restoration.
67
+ // But it makes a new committable file appear in someone's root, and that surprises —
68
+ // and surprising people is what started all of this. So: automatic when they typed the
69
+ // command (they already said yes), asked when we are the ones who noticed.
70
+ if (!manifest && installed.length) {
71
+ out.push(finding('no-manifest', invoked ? 'restore' : 'change',
72
+ 'This harness predates .rsc.json, so it cannot be shared by git yet.',
73
+ 'A manifest is written from what is already installed here.'));
74
+ }
75
+
76
+ // Installed somewhere the project does not look like. This is #249: `harness` writes an
77
+ // AGENTS.md, detection read it as codex, and people ended up wired to an assistant they
78
+ // never chose. Moving is a change — it is where the harness lives.
79
+ const others = installedTargets(cwd).filter((t) => t !== target);
80
+ const looksLike = detectTarget(cwd);
81
+ if (!others.length && installed.length && looksLike !== target && !manifest) {
82
+ out.push(finding('wrong-target', 'change',
83
+ `The harness is installed for ${target}, but this project looks like ${looksLike}.`,
84
+ `It moves to ${looksLike}, and the old wiring — including the block in the root file — is removed.`),
85
+ );
86
+ out[out.length - 1].to = looksLike;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // Remove the old wiring's skills. The list comes from the target's OWN state, which by
92
+ // construction holds only what rsc installed — so "we never touch what we did not write"
93
+ // is a property of the input here, not a check that could be forgotten or drift.
94
+ //
95
+ // It was a check, once, sitting on a list that mixed state and manifest ids. A mutant that
96
+ // deleted the check killed no test, and chasing that revealed the branch was unreachable
97
+ // anyway. A guard that cannot be seen to fail is worse than no guard (principle 2), so it
98
+ // became structure instead.
99
+ function removeManagedSkills(paths) {
100
+ for (const id of Object.keys(readState(paths.stateFile).skills || {})) {
101
+ rmSync(paths.skillDir(id), { recursive: true, force: true });
102
+ }
103
+ rmSync(paths.stateFile, { force: true });
104
+ }
105
+
106
+ function duplicatedHooks(hookTarget) {
107
+ let settings;
108
+ try { settings = JSON.parse(readFileSync(hookTarget, 'utf8')); } catch { return []; }
109
+ const { perEvent } = countHookEntries(settings);
110
+ const dup = [];
111
+ for (const [event, ids] of Object.entries(perEvent)) {
112
+ for (const [id, n] of Object.entries(ids)) if (n > 1) dup.push(`${event}/${id}`);
113
+ }
114
+ return dup;
115
+ }
116
+
117
+ export async function repair({ cwd = process.cwd(), target, home = homedir(), dryRun = false, invoked = false, accept } = {}) {
118
+ const found = diagnose({ cwd, target, home, invoked });
119
+ const restores = found.filter((f) => f.class === 'restore');
120
+ const changes = found.filter((f) => f.class === 'change');
121
+
122
+ // Anything the person has to decide is only applied if they actually said yes. With
123
+ // nobody to ask, it stays pending — and the restorations still get done, because they
124
+ // never depended on that decision.
125
+ const okChanges = [];
126
+ for (const c of changes) if (accept && await accept(c)) okChanges.push(c);
127
+ const todo = [...restores, ...okChanges];
128
+ if (!todo.length || dryRun) {
129
+ return { applied: todo, pending: changes.filter((c) => !okChanges.includes(c)), backup: null };
130
+ }
131
+
132
+ const paths = targetPaths(target, home, cwd);
133
+ const ids = [...new Set([...Object.keys(readState(paths.stateFile).skills || {}), ...(readManifest(cwd)?.skills || [])])];
134
+ const backup = createBackup({
135
+ cwd, operation: 'repair', target,
136
+ paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
137
+ });
138
+
139
+ for (const f of todo) {
140
+ if (f.id === 'nested-layout') rmSync(join(paths.root, 'rsc'), { recursive: true, force: true });
141
+ if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' }); }
142
+ if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' });
143
+ if (f.id === 'no-manifest') recordInManifest({ cwd, target, skillIds: ids });
144
+ if (f.id === 'wrong-target') {
145
+ // Install where the project actually points, THEN take the old wiring down — in that
146
+ // order, so a failure halfway leaves a working harness rather than none. Taking the
147
+ // block out of the old file gives it back byte-identical: in #249 that file was the
148
+ // project's hand-written constitution, and it had already been written into once.
149
+ await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair' });
150
+ unwireHook(target, paths);
151
+ removeManagedSkills(paths);
152
+ recordInManifest({ cwd, target: f.to, skillIds: ids, dropTarget: target });
153
+ }
154
+ }
155
+ return { applied: todo, pending: changes.filter((c) => !okChanges.includes(c)), backup };
156
+ }
package/scripts/lib/ui.js CHANGED
@@ -29,6 +29,11 @@ function interactive() {
29
29
  return Boolean(stdin.isTTY && stdout.isTTY);
30
30
  }
31
31
 
32
+ // The one criterion for "is a human here to answer". Exported so target resolution
33
+ // asks the same question the TUI already asks — two criteria for one question is how
34
+ // a terminal run and a CI run drift apart.
35
+ export const isInteractive = interactive;
36
+
32
37
  const C = {
33
38
  dim: (s) => `\x1b[2m${s}\x1b[22m`,
34
39
  bold: (s) => `\x1b[1m${s}\x1b[22m`,
package/scripts/rsc.js CHANGED
@@ -1,22 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadManifest, skillsForProfile } from './lib/manifest.js';
3
- import { detectTarget, TARGETS } from '../targets/index.js';
3
+ import { detectTarget, resolveTargets, TARGETS } from '../targets/index.js';
4
4
  import { detectRepo } from './detect-repo.js';
5
5
  import { rank } from './consult.js';
6
6
  import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
7
- import { applyInstall, listInstalled, uninstall, syncInstalled, purge } from './install-apply.js';
7
+ import { applyInstall, listInstalled, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
8
8
  import { doctor } from './doctor.js';
9
- import { say, select, pickFrom, banner, confirm } from './lib/ui.js';
9
+ import { say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
10
10
  import { refreshRegistry, registryStatus } from './lib/registry.js';
11
11
  import { audit, writeAuditReport } from './audit.js';
12
12
  import { DOMAINS } from './lib/domains.js';
13
13
  import { listBackups, restoreBackup } from './lib/backups.js';
14
14
  import { runUpgrade } from './lib/upgrade.js';
15
+ import { diagnose, repair } from './lib/repair.js';
15
16
  import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-floor.js';
16
17
 
17
18
  const argv = process.argv.slice(2);
18
19
  const cmd = argv[0];
19
20
 
21
+ const targetLabel = (id) => TARGETS.find((t) => t.id === id)?.label || id;
22
+
23
+ // Commands that act ON one assistant. Only these stop when two are installed and no
24
+ // flag says which: `purge` sweeps every target by design, and the catalog/consult
25
+ // side barely touches one, so blocking them would be a regression, not a safeguard.
26
+ const NEEDS_TARGET = new Set([
27
+ 'add', 'install', 'list', 'doctor', 'sync', 'uninstall', 'capabilities', 'catalog', 'registry',
28
+ ]);
29
+
20
30
  function flag(name) {
21
31
  const i = argv.indexOf(`--${name}`);
22
32
  return i >= 0 ? (argv[i + 1] || true) : undefined;
@@ -157,7 +167,7 @@ function printContextBudget(b) {
157
167
  say('════════════════════════════════════════════════\n');
158
168
  }
159
169
 
160
- async function wizard() {
170
+ async function wizard(flagTargets) {
161
171
  const m = loadManifest();
162
172
  await banner(m.counts.skills);
163
173
  say(' the skill catalog for your assistant (Claude Code · Codex · Cursor · Gemini · Antigravity)\n');
@@ -190,7 +200,7 @@ async function wizard() {
190
200
  continue;
191
201
  }
192
202
 
193
- const targets = await selectAgents();
203
+ const targets = flagTargets || await selectAgents();
194
204
  if (targets === null) continue; // esc in the assistant picker → back to menu
195
205
  say(`\nI'll install ${ids.length} skills for: ${targets.join(', ')}`);
196
206
  say(' ' + ids.join(', '));
@@ -209,16 +219,54 @@ async function wizard() {
209
219
  }
210
220
  }
211
221
 
222
+ // Installing replaces whatever sits where a skill goes. If that is something the user
223
+ // wrote by hand, it is the one thing an install can destroy — so it is named first and
224
+ // they decide. Silence here is what made someone lose three months of work with a backup
225
+ // they never knew to restore.
226
+ async function guardCollisions(targets, ids) {
227
+ const hit = [...new Set(targets.flatMap((t) => collisions({ target: t, skillIds: ids })))];
228
+ if (!hit.length) return true;
229
+ say(`\n⚠️ These are already here and rsc did not put them — they look like your own work:`);
230
+ for (const id of hit) say(` ${id}`);
231
+ say(' Installing would replace them. A backup is kept, but you would have to know to restore it.');
232
+ if (!isInteractive()) {
233
+ console.error('rsc: refusing to overwrite hand-written skills. Rename them, or re-run with --force.');
234
+ process.exitCode = 1;
235
+ return false;
236
+ }
237
+ return confirm('Replace them anyway?');
238
+ }
239
+
212
240
  async function main() {
213
- // --target accepts one id or a comma list (e.g. --target claude,codex). No flag → detect.
241
+ // One resolution for every command, doctor included: `doctor` used to resolve on its
242
+ // own and could report a different assistant than the one just installed into.
243
+ // --target accepts one id or a comma list (e.g. --target claude,codex).
214
244
  const f = flag('target');
215
- const targets = typeof f === 'string'
216
- ? f.split(',').map((s) => s.trim()).filter(Boolean)
217
- : [detectTarget()];
245
+ const resolved = resolveTargets({ flagValue: typeof f === 'string' ? f : undefined });
246
+ let targets = resolved.ids;
247
+ if (resolved.ambiguous && NEEDS_TARGET.has(cmd)) {
248
+ // Two installations, no flag: the whole point of this release is that we do NOT pick.
249
+ if (isInteractive()) {
250
+ const picked = await pickFrom(
251
+ 'Two assistants are installed here. Which one do you mean?',
252
+ resolved.ambiguous.map((id) => ({ id, label: `${targetLabel(id)} (${listInstalled({ target: id }).length} skills)` })),
253
+ );
254
+ if (!picked || !picked.length) return void say('Cancelled — nothing was touched.');
255
+ targets = picked;
256
+ } else {
257
+ console.error(
258
+ `rsc: ${resolved.ambiguous.join(' and ')} are both installed here, so I will not guess.\n` +
259
+ ` Say which one: npx @ericrisco/rsc ${cmd} --target ${resolved.ambiguous[0]}`,
260
+ );
261
+ process.exitCode = 1;
262
+ return;
263
+ }
264
+ }
265
+ if (!targets.length) targets = [detectTarget()];
218
266
  const target = targets[0];
219
267
  switch (cmd) {
220
268
  case undefined:
221
- return wizard();
269
+ return wizard(f ? targets : null);
222
270
  case 'add': {
223
271
  // Positional args = skill ids; skip flags and any flag value (the token after a --flag).
224
272
  const requested = [];
@@ -227,6 +275,7 @@ async function main() {
227
275
  requested.push(argv[i]);
228
276
  }
229
277
  const ids = withDefaultSkillFloor(requested);
278
+ if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
230
279
  for (const t of targets) await applyInstall({ skillIds: ids, target: t });
231
280
  say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
232
281
  return void say(' ↻ Reload/restart your assistant so the new skill activates.');
@@ -236,6 +285,7 @@ async function main() {
236
285
  const without = argv.filter((a, i) => argv[i - 1] === '--without');
237
286
  let ids = skillsForProfile(loadManifest(), profile);
238
287
  ids = withDefaultSkillFloor(ids).filter((id) => !without.includes(id));
288
+ if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
239
289
  for (const t of targets) await applyInstall({ skillIds: ids, target: t });
240
290
  say(`✅ Profile '${profile}' installed for ${targets.join(', ')} (${ids.length} skills)`);
241
291
  printAgentHandoff();
@@ -577,11 +627,34 @@ async function main() {
577
627
  const removed = await uninstall({ skillIds: ids, target, dryRun: dry });
578
628
  return void say((dry ? 'Would remove:\n' : 'Removed:\n') + (removed.join('\n') || '(nothing)'));
579
629
  }
630
+ case 'repair': {
631
+ // One command, no flags, for someone who does not know what is wrong. Restorations
632
+ // happen on their own; anything that changes a decision is asked, one by one.
633
+ const dry = argv.includes('--dry-run');
634
+ const yes = argv.includes('--yes');
635
+ const found = diagnose({ target, invoked: true });
636
+ if (!found.length) return void say('Nothing to repair — this harness is healthy.');
637
+ say(`\nFound ${found.length} thing(s):\n`);
638
+ for (const f of found) say(` [${f.class === 'restore' ? 'fix' : 'ask'}] ${f.summary}\n → ${f.action}`);
639
+ const accept = async (f) => {
640
+ if (yes) return true;
641
+ if (!isInteractive()) return false;
642
+ return confirm(`\nApply: ${f.summary}`);
643
+ };
644
+ const r = await repair({ target, dryRun: dry, invoked: true, accept });
645
+ say('');
646
+ if (dry) return void say(`Dry run — nothing written. Would apply ${r.applied.length}.`);
647
+ say(`Repaired ${r.applied.length} thing(s).${r.backup ? ' A copy of the previous state was kept.' : ''}`);
648
+ for (const p of r.pending) say(` still pending (your call): ${p.summary}\n → ${p.action}`);
649
+ return void say(' ↻ Reload/restart your assistant.');
650
+ }
580
651
  case 'purge':
581
652
  return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
582
653
  default:
583
654
  say(`rsc: unknown command '${cmd}'.`);
584
- say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
655
+ say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
656
+ say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
657
+ say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
585
658
  process.exitCode = 1;
586
659
  }
587
660
  }
@@ -79,7 +79,31 @@ Not covered → one line, skill or agent. Rules: `skill-scout`.
79
79
 
80
80
  ---
81
81
 
82
- ## 3. First contact
82
+ ## 3. A harness that is broken fixes itself
83
+
84
+ You are injected into every session, so you are the only thing that can notice a broken
85
+ harness before its owner does — and its owner usually cannot, because the symptoms name
86
+ nothing they recognise. When any of these is true, act on it **once** in the session:
87
+
88
+ - the assistant sees no skills in a project that clearly has a harness;
89
+ - `.rsc.json` exists and what it declares is not what is installed;
90
+ - the same hook seems to run several times;
91
+ - the harness is wired for an assistant that is not the one running.
92
+
93
+ Run `npx @ericrisco/rsc doctor`, and say in one line what is wrong **as a symptom**, not as
94
+ a cause. Then:
95
+
96
+ - Anything that only puts the harness back to what was already declared — dangling links, a
97
+ repeated hook, a layout no version uses — say you are fixing it and run
98
+ `npx @ericrisco/rsc repair`. Restoring is not deciding, and a recoverable copy is kept.
99
+ - Anything that would change a decision — moving to another assistant, adopting a skill a
100
+ teammate added, disarming a gate — **ask first**. A `git pull` never rewrites someone's
101
+ machine.
102
+
103
+ Offer once. A "no" holds for the session; a new session may look again, because the problem
104
+ has not gone away. Never mention it when the harness is healthy.
105
+
106
+ ## 4. First contact
83
107
 
84
108
  Before handling the first request of a session, check the workspace:
85
109
 
@@ -9,6 +9,9 @@ import { linkOrCopy } from './index.js';
9
9
  // multiple assistants sharing one file never duplicate it.
10
10
  const MARK_START = '<!-- rsc-suggest:start -->';
11
11
  const MARK_END = '<!-- rsc-suggest:end -->';
12
+ // The exact separator wireHook writes before an appended block, so unwireHook can take
13
+ // back precisely what it gave and leave the rest of the file untouched.
14
+ const SEAM = '\n\n';
12
15
 
13
16
  export function writeSkill(id, fromDir, toPath) {
14
17
  return linkOrCopy(fromDir, toPath);
@@ -21,7 +24,9 @@ export function wireHook(paths, sourceMd) {
21
24
  if (doc.includes(MARK_START)) {
22
25
  doc = doc.replace(new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`), block);
23
26
  } else {
24
- doc += `\n\n${block}\n`;
27
+ // Append with a fixed seam, and record nothing else. unwireHook removes exactly this
28
+ // seam and this block, which is what lets the file come back byte-identical.
29
+ doc += `${SEAM}${block}\n`;
25
30
  }
26
31
  mkdirSync(dirname(paths.hookTarget), { recursive: true });
27
32
  writeFileSync(paths.hookTarget, doc);
@@ -34,9 +39,17 @@ export function unwireHook(paths) {
34
39
  if (!existsSync(paths.hookTarget)) return [];
35
40
  const doc = readFileSync(paths.hookTarget, 'utf8');
36
41
  if (!doc.includes(MARK_START)) return [];
37
- const cleaned = doc
38
- .replace(new RegExp(`\\n*${MARK_START}[\\s\\S]*?${MARK_END}\\n*`), '\n')
39
- .replace(/\n{3,}/g, '\n\n');
42
+ // Remove the block and the seam that introduced it — nothing more.
43
+ //
44
+ // This used to end with a document-wide `\n{3,}` -> `\n\n` pass, which tidied blank
45
+ // lines the author had put hundreds of lines away from anything of ours. In #249 that
46
+ // file was the project's hand-written constitution: it had already received 95 lines it
47
+ // never asked for, and giving it back reformatted is the second half of the same damage.
48
+ // A slightly larger gap is cosmetic; rewriting someone's document is not.
49
+ const cleaned = doc.replace(
50
+ new RegExp(`${SEAM.replace(/\n/g, '\\n')}?${MARK_START}[\\s\\S]*?${MARK_END}\\n?`),
51
+ '',
52
+ );
40
53
  writeFileSync(paths.hookTarget, cleaned);
41
54
  return [paths.hookTarget];
42
55
  }
package/targets/claude.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { linkOrCopy } from './index.js';
5
5
 
@@ -70,6 +70,15 @@ export function unwireHook(paths) {
70
70
  // entry (the old `cat …/suggest/SKILL.md` form, or a previous `.sh`/`.mjs` script
71
71
  // form) is dropped before we add the current one — idempotent, migrating legacy and
72
72
  // bash-era hooks in place. Other (non-rsc) SessionStart hooks are preserved.
73
+ // Commands name the project through ${CLAUDE_PROJECT_DIR}, never through the absolute
74
+ // path of the folder it happened to be installed in. Baking that path in is what made a
75
+ // harness unshareable: a committed settings.json was broken in every other checkout, and
76
+ // even renaming your own folder broke it. The variable is resolved by the client at run
77
+ // time, so the same bytes work on every machine — which is the precondition for the
78
+ // manifest being worth committing at all.
79
+ const P = '${CLAUDE_PROJECT_DIR}';
80
+ const at = (...seg) => `${P}/${seg.join('/')}`;
81
+
73
82
  export function wireHook(paths) {
74
83
  const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
75
84
  mkdirSync(dirname(scriptDest), { recursive: true });
@@ -79,7 +88,8 @@ export function wireHook(paths) {
79
88
  copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
80
89
 
81
90
  const suggestMd = `${paths.skillDir('suggest')}/SKILL.md`;
82
- const cmd = `node "${scriptDest}" "${suggestMd}" "${paths.projectRoot}"`;
91
+ const suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
92
+ const cmd = `node "${at('.rsc', 'session-start.mjs')}" "${suggestRel}" "${P}"`;
83
93
 
84
94
  const file = paths.hookTarget;
85
95
  const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
@@ -99,7 +109,7 @@ export function wireHook(paths) {
99
109
  // any prior rsc worklog-checkpoint entry (.sh or .mjs) dropped first.
100
110
  const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.mjs');
101
111
  copyFileSync(join(HERE, 'worklog-checkpoint.mjs'), wlDest);
102
- const wlCmd = `node "${wlDest}" "${paths.projectRoot}"`;
112
+ const wlCmd = `node "${at('.rsc', 'worklog-checkpoint.mjs')}" "${P}"`;
103
113
  for (const event of ['PreCompact', 'SessionEnd']) {
104
114
  settings.hooks[event] ||= [];
105
115
  settings.hooks[event] = settings.hooks[event].filter(
@@ -118,7 +128,7 @@ export function wireHook(paths) {
118
128
  // sello.mjs is ship-guard's sibling import (hooks are materialized file-by-file,
119
129
  // so the deterministic sello core must land next to the guard that loads it).
120
130
  copyFileSync(join(HERE, 'sello.mjs'), join(paths.projectRoot, '.rsc', 'sello.mjs'));
121
- const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
131
+ const sgCmd = `node "${at('.rsc', 'ship-guard.mjs')}" "${P}"`;
122
132
  settings.hooks.PreToolUse ||= [];
123
133
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
124
134
  (e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
@@ -132,7 +142,7 @@ export function wireHook(paths) {
132
142
  // node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
133
143
  const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
134
144
  copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
135
- const dgCmd = `node "${dgDest}" "${paths.projectRoot}"`;
145
+ const dgCmd = `node "${at('.rsc', 'danger-guard.mjs')}" "${P}"`;
136
146
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
137
147
  (e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
138
148
  );
@@ -147,7 +157,7 @@ export function wireHook(paths) {
147
157
  // .rsc/.no-gitmoji.
148
158
  const gmDest = join(paths.projectRoot, '.rsc', 'gitmoji-guard.mjs');
149
159
  copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
150
- const gmCmd = `node "${gmDest}" "${paths.projectRoot}"`;
160
+ const gmCmd = `node "${at('.rsc', 'gitmoji-guard.mjs')}" "${P}"`;
151
161
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
152
162
  (e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
153
163
  );
@@ -163,7 +173,7 @@ export function wireHook(paths) {
163
173
  // (non-rsc) UserPromptSubmit hooks are preserved.
164
174
  const fgDest = join(paths.projectRoot, '.rsc', 'userprompt-gate.mjs');
165
175
  copyFileSync(join(HERE, 'userprompt-gate.mjs'), fgDest);
166
- const fgCmd = `node "${fgDest}" "${paths.projectRoot}"`;
176
+ const fgCmd = `node "${at('.rsc', 'userprompt-gate.mjs')}" "${P}"`;
167
177
  settings.hooks.UserPromptSubmit ||= [];
168
178
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
169
179
  (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
package/targets/index.js CHANGED
@@ -1,9 +1,10 @@
1
- import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, cpSync, readFileSync } from 'node:fs';
2
2
  import { join, dirname, relative } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import * as claudeAdapter from './claude.js';
5
5
  import * as cursorAdapter from './cursor.js';
6
6
  import * as mdAdapter from './_md-block.js';
7
+ import { readManifest } from '../scripts/lib/manifest-file.js';
7
8
 
8
9
  // Project-local single source of truth. Real skill files live here exactly once;
9
10
  // every assistant gets a lightweight pointer (symlink) back to it — no duplication.
@@ -83,28 +84,74 @@ export const TARGETS = [
83
84
  { id: 'aider', label: 'Aider', hint: 'CONVENTIONS.md' },
84
85
  ];
85
86
 
86
- // Best-effort default for the wizard's pre-mark. Unique config dirs win; the
87
- // shared AGENTS.md / GEMINI.md fall through to codex / gemini.
87
+ // Which assistants are actually installed here, read from the state file each one
88
+ // writes. This is evidence: only an install of ours writes it. Everything below
89
+ // (detectTarget) is inference from files a human — or our own `harness` skill —
90
+ // may have written for unrelated reasons. Order follows SPEC so the ambiguity
91
+ // message is deterministic. A missing, empty or unreadable state means "not
92
+ // installed", never an error: a half-written file must not break resolution.
93
+ export function installedTargets(cwd = process.cwd()) {
94
+ return TARGET_IDS.filter((id) => {
95
+ try {
96
+ const state = JSON.parse(readFileSync(targetPaths(id, undefined, cwd).stateFile, 'utf8'));
97
+ return Object.keys(state.skills || {}).length > 0;
98
+ } catch { return false; }
99
+ });
100
+ }
101
+
102
+ // Inference, used only when there is no evidence. Unique config dirs win.
103
+ //
104
+ // Two rules here are scar tissue from issue #249. Claude Code had NO signal at
105
+ // all — it was merely the final fallthrough — so any repo with a root AGENTS.md
106
+ // resolved to codex even with .claude/ full of skills. And AGENTS.md is the
107
+ // weakest signal on purpose: our own `harness` skill writes one into every repo
108
+ // it equips, so treating it as a strong hint made the harness poison its own
109
+ // detection the moment it ran once.
88
110
  export function detectTarget(cwd = process.cwd()) {
89
- const dir = (d) => existsSync(join(cwd, d));
90
- if (dir('.cursor')) return 'cursor';
91
- if (dir('.windsurf')) return 'windsurf';
92
- if (dir('.clinerules')) return 'cline';
93
- if (dir('.roo')) return 'roo';
94
- if (dir('.continue')) return 'continue';
95
- if (dir('.junie')) return 'junie';
96
- if (dir('.kiro')) return 'kiro';
97
- if (dir('.zed')) return 'zed';
98
- if (dir('.opencode')) return 'opencode';
99
- if (dir('.amp')) return 'amp';
100
- if (dir('.jules')) return 'jules';
101
- if (dir('.antigravity')) return 'antigravity';
102
- if (existsSync(join(cwd, '.github', 'copilot-instructions.md'))) return 'copilot';
103
- if (dir('.codex') || dir('AGENTS.md')) return 'codex';
104
- if (dir('.gemini') || dir('GEMINI.md')) return 'gemini';
111
+ const has = (p) => existsSync(join(cwd, p));
112
+ if (has('.cursor')) return 'cursor';
113
+ if (has('.windsurf')) return 'windsurf';
114
+ if (has('.clinerules')) return 'cline';
115
+ if (has('.roo')) return 'roo';
116
+ if (has('.continue')) return 'continue';
117
+ if (has('.junie')) return 'junie';
118
+ if (has('.kiro')) return 'kiro';
119
+ if (has('.zed')) return 'zed';
120
+ if (has('.opencode')) return 'opencode';
121
+ if (has('.amp')) return 'amp';
122
+ if (has('.jules')) return 'jules';
123
+ if (has('.antigravity')) return 'antigravity';
124
+ if (has(join('.github', 'copilot-instructions.md'))) return 'copilot';
125
+ if (has('.claude') || has('CLAUDE.md')) return 'claude';
126
+ if (has('.codex')) return 'codex';
127
+ if (has('.gemini') || has('GEMINI.md')) return 'gemini';
128
+ if (has('AGENTS.md')) return 'codex'; // weakest: our own harness writes this
105
129
  return 'claude';
106
130
  }
107
131
 
132
+ // The single point of truth for "which assistant are we acting on". Every command
133
+ // goes through here — including doctor, which used to resolve on its own and so
134
+ // could report a different target than the one just installed into.
135
+ //
136
+ // Ambiguity is a distinct outcome, not a value the caller might mistake for an id:
137
+ // returning a sentinel string would let a forgetful caller pass it to targetPaths()
138
+ // and die with `unknown target`. `ids` empty + `ambiguous` set cannot be used by
139
+ // accident.
140
+ export function resolveTargets({ cwd = process.cwd(), flagValue } = {}) {
141
+ if (typeof flagValue === 'string' && flagValue.trim()) {
142
+ const ids = flagValue.split(',').map((s) => s.trim()).filter(Boolean);
143
+ return { ids, ambiguous: null, source: 'flag' };
144
+ }
145
+ // The committed manifest is what the team decided, so it outranks what happens to be
146
+ // on this machine. Two targets here is not ambiguity — it is the team saying "both".
147
+ const declared = readManifest(cwd)?.targets || [];
148
+ if (declared.length) return { ids: declared, ambiguous: null, source: 'manifest' };
149
+ const found = installedTargets(cwd);
150
+ if (found.length === 1) return { ids: found, ambiguous: null, source: 'evidence' };
151
+ if (found.length > 1) return { ids: [], ambiguous: found, source: 'evidence' };
152
+ return { ids: [detectTarget(cwd)], ambiguous: null, source: 'heuristic' };
153
+ }
154
+
108
155
  export function targetPaths(target, home = homedir(), cwd = process.cwd()) {
109
156
  const s = SPEC[target];
110
157
  if (!s) throw new Error(`unknown target ${target}`);