@ericrisco/rsc 1.3.5 → 1.4.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
@@ -281,15 +281,33 @@ The harness travels by git, but not all of it — and the split is the point.
281
281
  | `02-DOCS/raw/worklog/.rsc-memory/` | Preferred session journal when a local wiki exists; protected with git's local exclude |
282
282
  | The skill entries rsc manages | Symlinks on macOS/Linux, real copies on Windows — two incompatible shapes of one thing |
283
283
 
284
- Whoever clones runs **one command** and ends up with the same harness:
284
+ Three files carry the harness through git: `.rsc.json` (what the team decided),
285
+ `.claude/settings.json` (the wiring), and `.claude/rsc-bootstrap.mjs` — the small file that
286
+ notices, in a clone, that the rest is not there yet. **Commit all three.** If your project
287
+ ignores the assistant's directory wholesale, rsc adds the lines that keep those files versioned —
288
+ verified against git, not against the pattern.
289
+
290
+ **And say it plainly, because committing it is the point:** `.claude/rsc-bootstrap.mjs` is code that
291
+ runs on every session and every shell call, so a pull request that edits *that file* runs on the
292
+ machine of whoever reviews the branch. That is true of `.claude/settings.json` already, and of any
293
+ hook-based harness — but it is worth knowing before you agree to commit a third one. Review changes
294
+ to it the way you would review a CI workflow.
295
+
296
+ Whoever clones runs **one command** and ends up with the same harness — at the version the
297
+ project pinned, which is the `catalogVersion` in its `.rsc.json`:
285
298
 
286
299
  ```bash
287
- npx @ericrisco/rsc@latest sync
300
+ npx @ericrisco/rsc@<catalogVersion> sync
288
301
  ```
289
302
 
290
- Same skills, same version — `.rsc.json` pins the catalog, so a teammate who clones in three
291
- months gets what you had, not what shipped since. Upgrading is a deliberate act, never a side
292
- effect of rebuilding.
303
+ **You do not have to know that, or find it.** Open the project and the assistant tells you, in
304
+ the first message: what is missing, what would be installed, and the exact command, naming the
305
+ pinned version. It asks; it does not install anything on its own and it does not hold up
306
+ whatever you sat down to do. Say no and it stops asking on that machine.
307
+
308
+ Not `@latest`, and the difference is the whole point of sharing: a teammate who clones in three
309
+ months gets what you had, not what shipped since. Upgrading is something a person decides, writes
310
+ into `.rsc.json`, and commits — and then it reaches everyone through git, like any other change.
293
311
 
294
312
  When someone changes the harness and you `git pull`, `rsc doctor` tells you what no longer
295
313
  matches. **Nothing is ever written to your machine by a pull** — you are told, and you decide.
@@ -309,10 +327,12 @@ Recognise any of these? They are all the same fix.
309
327
  | `"target": "codex"` when you work in Claude Code | |
310
328
  | `This target has no hook injection` and you did not expect that | |
311
329
  | Skills appear that you never asked for | |
312
- | You cloned a repo and your assistant sees no skills at all | |
313
330
  | A hook seems to run several times per turn | |
314
331
  | Template lines showed up inside your hand-written `AGENTS.md` | |
315
332
 
333
+ (A *fresh* clone is not in this table any more: nothing is broken there, the harness was simply
334
+ never built on that machine, and the assistant now says so itself — see **sharing by git** above.)
335
+
316
336
  ```bash
317
337
  npx @ericrisco/rsc@latest repair
318
338
  ```
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.3.5",
2
+ "version": "1.4.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.3.5",
3
+ "version": "1.4.0",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@
2
2
  import { rmSync, existsSync, cpSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, appendFileSync, readdirSync } from 'node:fs';
3
3
  import { join, dirname, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { planInstall } from './install-plan.js';
6
7
  import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
7
8
  import {
@@ -66,6 +67,9 @@ function ensureBase(id, cwd, baseVersions) {
66
67
  export function generatedHookFiles({ target, cwd, policy }) {
67
68
  if (target !== 'claude') return [];
68
69
  const lifecycle = [
70
+ // Committed, not machine state — and therefore the one entry here that, if omitted, leaves an
71
+ // executable behind in a tree the user pushes.
72
+ join(cwd, '.claude', 'rsc-bootstrap.mjs'),
69
73
  join(cwd, '.rsc', 'session-start.mjs'),
70
74
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
71
75
  join(cwd, '.rsc', 'hook-once.mjs'),
@@ -287,6 +291,32 @@ export function ignoreLocalState(cwd = process.cwd(), target) {
287
291
  // keeps this idempotent against a .gitignore a human wrote in their own spelling.
288
292
  const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
289
293
  const present = new Set(text.split('\n').map(norm));
294
+ // `.rsc/` is machine state and stays ignored. The two files below are the opposite: they are the
295
+ // whole reason a clone can help itself, and a project that excluded the assistant's directory
296
+ // wholesale (rsc's own repo does exactly that, so copying the example is likely) swallows both with
297
+ // no symptom at all.
298
+ //
299
+ // Asked of GIT, not of the pattern. The first attempt at this reasoned about spellings and shipped
300
+ // a negation that was INERT in every case it fired: git does not descend into an excluded
301
+ // directory, so `!.claude/rsc-bootstrap.mjs` under `.claude/` rescues nothing. Measured, not
302
+ // argued — and the lesson is the one this repo keeps relearning, that a rule believed and untrue is
303
+ // worse than no rule.
304
+ const RESCUE = ['.claude/rsc-bootstrap.mjs', '.claude/settings.json'];
305
+ const swallowed = RESCUE.filter((f) => {
306
+ try {
307
+ return spawnSync('git', ['check-ignore', '-q', '--', f], { cwd }).status === 0;
308
+ } catch {
309
+ return false;
310
+ }
311
+ });
312
+ if (swallowed.length) {
313
+ // Append-only, and this exact order is what makes it work at all: re-include the directory so git
314
+ // will descend into it, exclude everything inside it again, then rescue by name. Verified against
315
+ // git itself — `settings.local.json` and the skill entries stay ignored.
316
+ const dir = swallowed[0].split('/')[0];
317
+ for (const line of [`!${dir}/`, `${dir}/*`, ...swallowed.map((f) => `!${f}`)]) wanted.push(line);
318
+ }
319
+
290
320
  const add = wanted.filter((w) => !present.has(norm(w)));
291
321
  if (!add.length) return null;
292
322
 
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { TARGET_IDS } from '../../targets/index.js';
5
5
  import { resolveAgentNames } from '../../targets/agents.js';
6
6
  import { loadManifest, skillsForProfile } from './manifest.js';
7
+ import { readManifest } from './manifest-file.js';
7
8
  import { managedPathsForInstall } from '../install-apply.js';
8
9
 
9
10
  export const ONBOARDING_SCHEMA_VERSION = 1;
@@ -68,7 +69,17 @@ export function scanProject(root = process.cwd()) {
68
69
  let markdownCount = 0;
69
70
  let sourceFileCount = 0;
70
71
  const visit = (dir) => {
71
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
72
+ // A directory we are not allowed to open is a fact about that directory, not a reason to abandon
73
+ // the install. Reported from a real Windows box: the global harness died walking `Temp\WinSAT`,
74
+ // which Windows protects, on 1.3.2 and again on 1.3.6 — so it had never worked there at all.
75
+ // The safe form already existed in `detect-repo.js` and was simply not reused here.
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
79
+ } catch {
80
+ return;
81
+ }
82
+ for (const entry of entries) {
72
83
  if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
73
84
  const path = join(dir, entry.name);
74
85
  const rel = relative(absolute, path).split(sep).join('/');
@@ -175,7 +186,30 @@ export function buildOnboardingPlan(record, evidence) {
175
186
  const catalog = loadManifest();
176
187
  const catalogIds = new Set(catalog.skills.map((skill) => skill.id));
177
188
  const detectedSkills = (evidence.stacks || []).filter((stack) => catalogIds.has(stack));
178
- const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills])].sort();
189
+ // Skills the user asked for BY NAME are kept across an update; skills a profile handed out are not.
190
+ //
191
+ // Without this, an update recomputed the set from the profile and the detected stacks alone, and
192
+ // the install step then deleted from disk whatever was not in that fresh set. Reported from the
193
+ // field: six skills added with `rsc add` — seo-geo, astro, landing-copy, brand-identity, press-kit,
194
+ // article-writing — deleted by the update. And "volvió a borrar": every single time, because
195
+ // nothing ever taught the recomputation to look at the manifest.
196
+ //
197
+ // But preserving everything declared is too blunt, and a test older than this fix says so: choosing
198
+ // a narrower scope on purpose has to be able to REMOVE what the wider one installed. Both cases are
199
+ // "onboard ran again with a different record", so the difference cannot come from how it was
200
+ // invoked — it has to come from where the skill came from.
201
+ //
202
+ // And there it is, already in the catalog: a profile skill declares its profiles, and a skill you
203
+ // can only get by naming it declares none. So — what a profile gave you, a profile may take away;
204
+ // what you asked for by name, only you may. Filtered against the catalog on the way in, because
205
+ // preserving a declaration is not the same as trusting it.
206
+ //
207
+ // `readManifest` returns null on a first install, which is the most common path of all; reaching
208
+ // through that null would have broken every fresh install in order to protect updates.
209
+ const inAnyProfile = new Set(catalog.skills.filter((s) => (s.profiles || []).length).map((s) => s.id));
210
+ const declared = (readManifest(evidence.root ?? process.cwd())?.skills ?? [])
211
+ .filter((id) => catalogIds.has(id) && !inAnyProfile.has(id));
212
+ const skills = [...new Set([...skillsForProfile(catalog, profile), ...detectedSkills, ...declared])].sort();
179
213
  const baseAgents = needsSdd;
180
214
  const hooks = needsSdd;
181
215
  const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];
@@ -61,7 +61,7 @@ name it and offer it. This runs mid-conversation, not only at project start.
61
61
  `id available short description`.
62
62
  2. Pick the single best fit **by meaning**, the way you would match a request to a teammate's
63
63
  expertise — "mandar emails de bienvenida" → an email/outreach skill, though not one keyword
64
- overlaps; "login con Google" → an auth skill, not `flutter`. If nothing genuinely fits, say so
64
+ overlaps. If nothing genuinely fits, say so
65
65
  and move on: a tangential suggestion is worse than none.
66
66
  3. Ask once, plainly: "Para esto instalaría `<id>`, que aún no tienes. ¿La instalo? (sí/no)".
67
67
  4. On yes, run `npx @ericrisco/rsc add <id>`, then continue the original task.
@@ -88,7 +88,6 @@ You are injected into every session, so you are the only thing that can notice a
88
88
  harness before its owner does — and its owner usually cannot, because the symptoms name
89
89
  nothing they recognise. When any of these is true, act on it **once** in the session:
90
90
 
91
- - the assistant sees no skills in a project that clearly has a harness;
92
91
  - `.rsc.json` exists and what it declares is not what is installed;
93
92
  - the same hook seems to run several times;
94
93
  - the harness is wired for an assistant that is not the one running.
@@ -96,6 +95,10 @@ nothing they recognise. When any of these is true, act on it **once** in the ses
96
95
  Run `npx @ericrisco/rsc doctor`, and say in one line what is wrong **as a symptom**, not as
97
96
  a cause. Then:
98
97
 
98
+ - **Nothing built** — `.rsc.json` without `.rsc/`: a clone. Name
99
+ `npx @ericrisco/rsc@<catalogVersion in .rsc.json> sync`, that exact version, **never** `@latest`
100
+ — a release nobody here adopted is drift. Ask in one line, **keep working either way**; silence
101
+ is not a no, `.rsc/.no-harness` is.
99
102
  - Anything that only puts the harness back to what was already declared — dangling links, a
100
103
  repeated hook, a layout no version uses — say you are fixing it and run
101
104
  `npx @ericrisco/rsc repair`. Restoring is not deciding, and a recoverable copy is kept.
@@ -103,8 +106,7 @@ a cause. Then:
103
106
  teammate added, disarming a gate — **ask first**. A `git pull` never rewrites someone's
104
107
  machine.
105
108
 
106
- Offer once. A "no" holds for the session; a new session may look again, because the problem
107
- has not gone away. Never mention it when the harness is healthy.
109
+ Offer once per session. Never mention any of it when the harness is healthy.
108
110
 
109
111
  ## 4. First contact
110
112
 
@@ -117,6 +119,10 @@ Before handling the first request of a session, check the workspace:
117
119
  `.rsc/.no-harness`, confirm in one line, and never auto-start `init` in this repo again.
118
120
  - Once the profile exists, this gate is inert. Never re-onboard.
119
121
 
122
+ ## Explain without assuming
123
+
124
+ Define a term at first use; never give a command, flag or path without saying what it does.
125
+
120
126
  ## Orientación (siempre)
121
127
 
122
128
  Cierra cada turno con el **bloque-brújula** (📍 dónde estás · ✅ qué hiciste · 🧭 por qué · ➡️ siguiente,
package/targets/claude.js CHANGED
@@ -45,6 +45,13 @@ export function hookWiringOf(entry) {
45
45
  // other settings are preserved. Empty event arrays (and an empty hooks object) are
46
46
  // pruned so we don't leave noise behind.
47
47
  export function unwireHook(paths) {
48
+ // The bootstrap lives in the COMMITTED tree, so leaving it behind does not just orphan a file the
49
+ // way a stray `.rsc/` entry would — it leaves an executable in something the user pushes. Nothing
50
+ // else removes it: uninstall and purge only ever knew about `.rsc/`.
51
+ try {
52
+ rmSync(join(paths.projectRoot, '.claude', 'rsc-bootstrap.mjs'), { force: true });
53
+ } catch { /* never let cleanup be the thing that fails */ }
54
+
48
55
  const file = paths.hookTarget;
49
56
  if (!existsSync(file)) return [];
50
57
  let settings;
@@ -79,6 +86,23 @@ export function unwireHook(paths) {
79
86
  const P = '${CLAUDE_PROJECT_DIR}';
80
87
  const at = (...seg) => `${P}/${seg.join('/')}`;
81
88
 
89
+ // Everything below points at `.rsc/`, which git does not carry — so in a clone every one of these
90
+ // commands runs against a file that is not there and the client shows the module loader's stack
91
+ // trace instead of a hook. The fix is not to make each script survive its own absence (it cannot:
92
+ // the guards import only node builtins, so a missing entry file fails before a line of theirs runs).
93
+ // The fix is to point the wiring at a file that IS committed, and let it decide: delegate when the
94
+ // harness is mounted, speak once when it is not.
95
+ //
96
+ // The script's own path stays in the command as an argument, so every dedup/unwire needle
97
+ // (`.rsc/session-start.`, `.rsc/ship-guard.`, …) still matches and idempotency is untouched.
98
+ // Three modes, because a hook's event decides what it is allowed to say. `announce` is the one hook
99
+ // that may open with the offer (one per session, no marker needed). `guard` is a shell guard, and in
100
+ // an unbuilt harness it is the only place that can notice someone is about to do the thing the
101
+ // missing guard protected. `quiet` delegates and never speaks.
102
+ const BOOTSTRAP = '.claude/rsc-bootstrap.mjs';
103
+ const viaBootstrap = (mode, target, ...args) =>
104
+ [`node "${at(...BOOTSTRAP.split('/'))}"`, `"${mode}"`, `"${P}"`, `"${target}"`, ...args].join(' ');
105
+
82
106
  export function wireHook(paths, sourceMd, policy = {}) {
83
107
  const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
84
108
  mkdirSync(dirname(scriptDest), { recursive: true });
@@ -89,6 +113,11 @@ export function wireHook(paths, sourceMd, policy = {}) {
89
113
  // sibling too. Missing it does not break startup — the sweep is wrapped — it just goes silent.
90
114
  copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
91
115
  copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
116
+ // The one harness file that belongs in the committed tree rather than in `.rsc/`: it is the only
117
+ // thing standing between a clone and seven stack traces, so it has to survive `git clone`.
118
+ const bootstrapDest = join(paths.projectRoot, '.claude', 'rsc-bootstrap.mjs');
119
+ mkdirSync(dirname(bootstrapDest), { recursive: true });
120
+ copyFileSync(join(HERE, 'clone-bootstrap.mjs'), bootstrapDest);
92
121
 
93
122
  let suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
94
123
  const operationsSuggest = join(paths.projectRoot, '.rsc', 'suggest-always-on.md');
@@ -96,7 +125,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
96
125
  writeFileSync(operationsSuggest, '# rsc-suggest — always-on operations layer\n\nRead `02-DOCS/wiki/harness/user-profile.md` before acting. Use `orient` to keep the user situated and `suggest` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n');
97
126
  suggestRel = at('.rsc', 'suggest-always-on.md');
98
127
  } else if (existsSync(operationsSuggest)) rmSync(operationsSuggest, { force: true });
99
- const cmd = `node "${at('.rsc', 'session-start.mjs')}" "${suggestRel}" "${P}"`;
128
+ const cmd = viaBootstrap('announce', at('.rsc', 'session-start.mjs'), `"${suggestRel}"`, `"${P}"`);
100
129
 
101
130
  const file = paths.hookTarget;
102
131
  const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
@@ -115,9 +144,9 @@ export function wireHook(paths, sourceMd, policy = {}) {
115
144
  // the workspace has no harness wiki. Registered idempotently on both events, with
116
145
  // any prior rsc worklog-checkpoint entry (.sh or .mjs) dropped first.
117
146
  const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.mjs');
118
- const written = [paths.hookTarget, scriptDest, wlDest];
147
+ const written = [paths.hookTarget, scriptDest, wlDest, bootstrapDest];
119
148
  copyFileSync(join(HERE, 'worklog-checkpoint.mjs'), wlDest);
120
- const wlCmd = `node "${at('.rsc', 'worklog-checkpoint.mjs')}" "${P}"`;
149
+ const wlCmd = viaBootstrap('quiet', at('.rsc', 'worklog-checkpoint.mjs'), `"${P}"`);
121
150
  for (const event of ['PreCompact', 'SessionEnd']) {
122
151
  settings.hooks[event] ||= [];
123
152
  settings.hooks[event] = settings.hooks[event].filter(
@@ -137,7 +166,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
137
166
  // sello.mjs is ship-guard's sibling import (hooks are materialized file-by-file,
138
167
  // so the deterministic sello core must land next to the guard that loads it).
139
168
  copyFileSync(join(HERE, 'sello.mjs'), join(paths.projectRoot, '.rsc', 'sello.mjs'));
140
- const sgCmd = `node "${at('.rsc', 'ship-guard.mjs')}" "${P}"`;
169
+ const sgCmd = viaBootstrap('guard', at('.rsc', 'ship-guard.mjs'), `"${P}"`);
141
170
  settings.hooks.PreToolUse ||= [];
142
171
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
143
172
  (e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
@@ -151,7 +180,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
151
180
  // node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
152
181
  const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
153
182
  copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
154
- const dgCmd = `node "${at('.rsc', 'danger-guard.mjs')}" "${P}"`;
183
+ const dgCmd = viaBootstrap('guard', at('.rsc', 'danger-guard.mjs'), `"${P}"`);
155
184
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
156
185
  (e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
157
186
  );
@@ -166,7 +195,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
166
195
  // .rsc/.no-gitmoji.
167
196
  const gmDest = join(paths.projectRoot, '.rsc', 'gitmoji-guard.mjs');
168
197
  copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
169
- const gmCmd = `node "${at('.rsc', 'gitmoji-guard.mjs')}" "${P}"`;
198
+ const gmCmd = viaBootstrap('guard', at('.rsc', 'gitmoji-guard.mjs'), `"${P}"`);
170
199
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
171
200
  (e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
172
201
  );
@@ -182,7 +211,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
182
211
  // (non-rsc) UserPromptSubmit hooks are preserved.
183
212
  const fgDest = join(paths.projectRoot, '.rsc', 'userprompt-gate.mjs');
184
213
  copyFileSync(join(HERE, 'userprompt-gate.mjs'), fgDest);
185
- const fgCmd = `node "${at('.rsc', 'userprompt-gate.mjs')}" "${P}"`;
214
+ const fgCmd = viaBootstrap('quiet', at('.rsc', 'userprompt-gate.mjs'), `"${P}"`);
186
215
  settings.hooks.UserPromptSubmit ||= [];
187
216
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
188
217
  (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // The one piece of the harness that travels in git.
3
+ //
4
+ // `project-manifest` settled what gets committed: the manifest (what the team decided) travels, the
5
+ // wiring travels, and `.rsc/` does not — its contents are machine-shaped, symlinks on one OS and
6
+ // real copies on another, and committing either shape breaks the other. That boundary is right and
7
+ // this file does not move it.
8
+ //
9
+ // What it left behind is the hole this file fills: the committed wiring points into the directory
10
+ // that does not travel, so the first session in a clone runs seven hooks against seven files that
11
+ // are not there. The person does not get a warning, they get the module loader's stack trace — and
12
+ // three of the seven are shell guards, so they get it again on every shell call. `project-manifest`
13
+ // already promised the opposite ("quien clona se entera de que tiene que reconstruir sin que nadie
14
+ // se lo diga"); this is the promise being kept.
15
+ //
16
+ // So the wiring points HERE instead, and this file is committed. Two jobs, in this order:
17
+ //
18
+ // 1. The harness is mounted → delegate to the real script and say NOTHING of our own. The healthy
19
+ // case is almost everyone, every session, and it must cost zero bytes of context (P5 — this
20
+ // repo's own scar is 207 KB paid per turn). One existsSync is not paid in tokens; a line of
21
+ // output is.
22
+ // 2. The harness is not mounted → say what is wrong as a symptom, name what would be installed,
23
+ // and get out of the way. Never block the turn, never write anything first.
24
+ //
25
+ // It never throws. A bootstrap that can crash is the bug it exists to remove.
26
+
27
+ import { existsSync, readFileSync, realpathSync, writeFileSync, mkdirSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { tmpdir } from 'node:os';
30
+ import { createHash } from 'node:crypto';
31
+ import { pathToFileURL, fileURLToPath } from 'node:url';
32
+
33
+ const PACKAGE = '@ericrisco/rsc';
34
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
35
+
36
+ /**
37
+ * Validated at the parse boundary AND again here, on purpose.
38
+ *
39
+ * These functions are exported and they are the ones that build the sentence a person is told to run
40
+ * EXACTLY. A single validation point is only as good as every caller reaching it, and the caller that
41
+ * skips it is the one nobody remembers writing. Checking again where the string is used costs one
42
+ * regex and removes the possibility entirely.
43
+ */
44
+ const safeVersion = (v) => (typeof v === 'string' && SEMVER.test(v) ? v : null);
45
+
46
+ /**
47
+ * What the project declares, as a value — never as an exception.
48
+ * A corrupt manifest is a state we report, not a crash we propagate (spec AC#20).
49
+ */
50
+ export function readManifest(root) {
51
+ const path = join(root, '.rsc.json');
52
+ if (!existsSync(path)) return { state: 'absent' };
53
+ let raw;
54
+ try {
55
+ raw = readFileSync(path, 'utf8');
56
+ } catch (err) {
57
+ return { state: 'unreadable', reason: err?.code ?? 'unreadable' };
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ } catch {
63
+ return { state: 'unreadable', reason: 'not valid JSON' };
64
+ }
65
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
66
+ return { state: 'unreadable', reason: 'not an object' };
67
+ }
68
+ // Ids, and only ids. `skills: [{id:"orient"}]` is valid JSON and used to throw inside `join()` —
69
+ // before the delegate ran, so a mounted and perfectly healthy harness lost its whole always-on layer
70
+ // over a manifest someone hand-edited. This file is COMMITTED into user repos, which means old
71
+ // copies of it live on forever in projects nobody will update; it has to be maximally suspicious of
72
+ // the only input it gets.
73
+ // An allowlist, not a type check. `.rsc.json` travels through `git pull`, so its contents are
74
+ // written by anyone who can open a pull request — and this feature now copies them into the text
75
+ // the model reads. A newline closes rsc's own `=====` frame and opens a forged one; a `;` turns the
76
+ // command we tell someone to run EXACTLY into two commands; a `..` walks out of the project and
77
+ // turns the divergence notice into an oracle for what exists on the reviewer's disk.
78
+ //
79
+ // None of that is what a skill id is. A skill id is a short lowercase name, so that is all that is
80
+ // accepted, and anything else is simply not there.
81
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
82
+ const ids = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && ID.test(x)) : []);
83
+ const skills = ids(parsed.skills);
84
+ const own = ids(parsed.ownSkills);
85
+ // A manifest with nothing declared is not a clone waiting to be equipped; there is nothing to offer.
86
+ if (!skills.length && !own.length) return { state: 'absent' };
87
+ return {
88
+ state: 'declared',
89
+ skills,
90
+ own,
91
+ // Same reasoning, and it matters more here: this string is pasted into the command we tell a
92
+ // person to run *exactly*. A version, or nothing — "no version pinned" is a true and harmless
93
+ // thing to say, and it is what an unusable value becomes.
94
+ catalogVersion: SEMVER.test(String(parsed.catalogVersion ?? '')) ? parsed.catalogVersion : null,
95
+ targets: Array.isArray(parsed.targets) ? parsed.targets : [],
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Is what is installed still what the project declares?
101
+ *
102
+ * `.rsc/skills/<id>` is the canonical home of a skill's content on every assistant — the per-target
103
+ * directories (`.claude/skills/`, `.codex/rsc/`, …) are links into it — so one rule answers this for
104
+ * all seventeen without teaching this file a single target's layout.
105
+ *
106
+ * Only what is DECLARED AND MISSING is reported. Not the reverse: an extra directory could equally be
107
+ * a skill someone wrote by hand, and this repo has already broken a person's own work once by
108
+ * assuming otherwise. When it cannot be told apart, it is left alone.
109
+ */
110
+ export function evaluateHarness(root, manifest) {
111
+ if (manifest.state !== 'declared') return { verdict: 'unknown', missing: [], ownMissing: [] };
112
+ // Catalog skills: `.rsc/skills/<id>` is where their content lands, and it is the same on all
113
+ // seventeen assistants — the per-target directories are links into it.
114
+ const fromCatalog = (id) => existsSync(join(root, '.rsc', 'skills', id));
115
+ // Own skills are a different thing in a different place, and conflating them is how a healthy
116
+ // project ends up nagged every session about work that is already correct. `.rsc/skills/` is filled
117
+ // by copying FROM THE CATALOG, and an own skill is by definition not in the catalog, so it can never
118
+ // appear there — looking for it there reports it missing forever, and the `sync` we would prescribe
119
+ // could never fix it. They live where their assistant keeps skills, which is what this repo's own
120
+ // `divergence()` already checks.
121
+ const OWN_DIRS = [
122
+ ['.claude', 'skills'], ['.codex', 'rsc'], ['.cursor', 'rules'], ['.windsurf', 'rules'],
123
+ ['.clinerules'], ['.roo', 'rules'], ['.continue', 'rules'], ['.junie'], ['.kiro', 'steering'],
124
+ ['.antigravity'], ['.opencode'], ['.rsc', 'skills'],
125
+ ];
126
+ const fromTeam = (name) => OWN_DIRS.some((dir) => existsSync(join(root, ...dir, name)));
127
+ const missing = manifest.skills.filter((id) => !fromCatalog(id));
128
+ const ownMissing = manifest.own.filter((name) => !fromTeam(name));
129
+ if (!missing.length && !ownMissing.length) return { verdict: 'current', missing: [], ownMissing: [] };
130
+ return { verdict: 'behind', missing, ownMissing };
131
+ }
132
+
133
+ /**
134
+ * What to say when the harness works but no longer matches what the team decided — the other half of
135
+ * the feature: building a clone and bringing a stale one up to date are the same transaction, and
136
+ * both converge on the MANIFEST, never on the newest release.
137
+ *
138
+ * The own skills are named separately and deliberately kept out of the sentence that follows the
139
+ * command: rsc does not install them, their version is the commit, and listing them next to a thing
140
+ * that installs would be a promise nothing keeps.
141
+ */
142
+ export function composeDivergence(manifest, evaluation) {
143
+ const version = safeVersion(manifest.catalogVersion);
144
+ const command = version ? `npx ${PACKAGE}@${version} sync` : `npx ${PACKAGE} sync`;
145
+ let text =
146
+ '===== rsc =====\n' +
147
+ 'This project declares a harness that no longer matches what is built here — someone changed\n' +
148
+ 'it and git brought the change; nothing is broken.\n';
149
+ if (evaluation.missing.length) {
150
+ text += `MISSING, declared in .rsc.json: ${evaluation.missing.join(', ')}.\n`;
151
+ }
152
+ // Before the command, never after: anything sitting under the action reads as part of what the
153
+ // action installs, and these are the one thing rsc will not install. Their version is the commit.
154
+ if (evaluation.ownMissing.length) {
155
+ text +=
156
+ `ALSO DECLARED, written by the team: ${evaluation.ownMissing.join(', ')} — these arrive through\n` +
157
+ 'git, not through any command, and rsc never installs or overwrites them.\n';
158
+ }
159
+ return (
160
+ text +
161
+ 'ACTION: mention it in one line and carry on with what the user asked. To converge, run:\n' +
162
+ ` ${command}\n` +
163
+ '===============\n'
164
+ );
165
+ }
166
+
167
+ /**
168
+ * The text a person sees, built from the manifest alone.
169
+ * Pure: it decides nothing about disk and writes nothing, so it can be tested without one.
170
+ */
171
+ export function composeOffer(manifest) {
172
+ if (manifest.state === 'absent') return { offers: false, text: '' };
173
+ if (manifest.state === 'unreadable') {
174
+ return {
175
+ offers: false,
176
+ text:
177
+ '===== rsc =====\n' +
178
+ `This project declares a harness, but its manifest cannot be read (${manifest.reason}).\n` +
179
+ 'Nothing has been installed and nothing was changed. Someone on the team needs to fix\n' +
180
+ '.rsc.json before the harness can be rebuilt here.\n' +
181
+ '===============\n',
182
+ };
183
+ }
184
+ // The version is named, never guessed: what gets installed is what the team pinned, and a person
185
+ // accepting an install on a repo they just cloned is entitled to read the exact thing first
186
+ // (spec AC#21). No pin is itself a fact worth showing, not a blank to fill in.
187
+ //
188
+ // And it has to be IN THE COMMAND, not only in the prose above it. `npx @ericrisco/rsc sync`
189
+ // resolves to the latest published package, so an offer phrased that way hands a clone whatever
190
+ // shipped since — the exact divergence the pin exists to prevent, introduced by the feature meant
191
+ // to honour it. Pinned, the command is reproducible three months from now (spec AC#6).
192
+ const version = safeVersion(manifest.catalogVersion);
193
+ const pin = version ? `${PACKAGE}@${version}` : `${PACKAGE} (no version pinned)`;
194
+ const command = version ? `npx ${PACKAGE}@${version} sync` : `npx ${PACKAGE} sync`;
195
+ const count = manifest.skills.length;
196
+ const own = manifest.own.length ? `, plus ${manifest.own.length} written by the team (never overwritten)` : '';
197
+ return {
198
+ offers: true,
199
+ text:
200
+ '===== rsc =====\n' +
201
+ 'This project declares a harness that is not built on this machine, so the assistant\n' +
202
+ "cannot see any of the project's skills right now. Nothing is broken and nothing was\n" +
203
+ 'changed — the harness simply does not travel through git, by design.\n' +
204
+ '\n' +
205
+ `WOULD INSTALL: ${pin} — ${count} skill(s) as pinned in .rsc.json${own}.\n` +
206
+ 'It writes .rsc/ and the skill entries; it never touches anything written by hand.\n' +
207
+ '\n' +
208
+ 'ACTION: ask the user whether to build it, in one line, and continue with their request\n' +
209
+ 'either way — this must not hold up what they asked for. If there is nobody to ask (CI, an\n' +
210
+ 'unattended run), do NOT install: say it is missing and carry on. On a yes, run EXACTLY:\n' +
211
+ ` ${command}\n` +
212
+ '(the version is the one this project pinned. Do not substitute `@latest`: a release nobody\n' +
213
+ ' on this team adopted is not an upgrade, it is two people quietly drifting apart.)\n' +
214
+ "On a no, create .rsc/.no-harness so this is not offered again on this machine.\n" +
215
+ '===============\n',
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Delegate to the real hook script, preserving what it believes about how it was invoked.
221
+ * `gitmoji-guard` only runs its main block when `process.argv[1]` is its own path, so the splice is
222
+ * load-bearing, not tidiness: without it the guard loads and silently does nothing. The four
223
+ * The three arguments this file owns are dropped in the same move, together with our own path, so the delegate sees exactly the argv the
224
+ * wiring used to hand it directly — anything less and every hook would need to learn about us.
225
+ */
226
+ async function delegate(target, ownArgc) {
227
+ // `realpathSync` and not `target` as given: the delegate may run its own identity check, and
228
+ // `gitmoji-guard` does exactly that. Its `import.meta.url` comes back from the loader with
229
+ // symlinks resolved, so handing it the raw path makes its main block silently not run — the guard
230
+ // loads, denies nothing, and reports success. Same trap as `sameFile` below, one level down.
231
+ // One resolved value for both: argv[1] and the import specifier must agree, or a delegate that
232
+ // checks its own identity loads and then does nothing — which under `--preserve-symlinks` is
233
+ // exactly what happened.
234
+ const resolved = realpathSync(target);
235
+ process.argv.splice(1, ownArgc + 1, resolved);
236
+ await import(pathToFileURL(resolved).href);
237
+ }
238
+
239
+ /**
240
+ * @param {string} target the real hook script the wiring would have called
241
+ * @param {string} root the project root, as the client resolved it
242
+ * @param {boolean} announce whether this hook is the one allowed to speak (SessionStart only)
243
+ */
244
+ /**
245
+ * The protections a missing harness takes away, named by the thing a person is about to do rather
246
+ * than by the guard that is gone — "you are about to commit and the commit checks are not here" is
247
+ * actionable; "ship-guard is not installed" is not.
248
+ *
249
+ * RECOGNISING one of these is judgement and is declared NON-BINDING (P2): the list is deliberately
250
+ * short and will miss things, and missing one costs a reminder, never a failure. What IS binding,
251
+ * and what the tests hold, is everything around it — that it reminds at most once, that it never
252
+ * denies a tool call, and that it stays silent for ordinary work.
253
+ */
254
+ const PROTECTED = [
255
+ // `git -C <path> commit` is not an edge case: it is the form this workspace's own CLAUDE.md
256
+ // mandates, so without it the reminder would essentially never fire where it was written.
257
+ { re: /\bgit\s+(-C\s+\S+\s+)?(commit|cz)\b/, what: 'commit' },
258
+ { re: /\bgit\s+(-C\s+\S+\s+)?push\b/, what: 'push' },
259
+ { re: /\bgit\s+(-C\s+\S+\s+)?merge\b/, what: 'merge' },
260
+ { re: /\bgit\s+(-C\s+\S+\s+)?(switch|checkout)\s+(main|master)\b/, what: 'switch to the trunk' },
261
+ { re: /\bnpm\s+publish\b/, what: 'publish' },
262
+ { re: /\brm\s+-rf\b/, what: 'delete files irreversibly' },
263
+ ];
264
+
265
+ /**
266
+ * "Have we already reminded them in this project?" — kept in the OS temp directory, keyed by the
267
+ * project path, and NEVER inside the project. The user's tree stays untouched until they consent,
268
+ * which is the whole point of the first offer (spec AC#2); a note to ourselves about how many times
269
+ * we have spoken is not their file to carry.
270
+ */
271
+ function alreadyReminded(root) {
272
+ // Keyed on the RESOLVED path: the same project reached through a symlinked alias is the same
273
+ // project, and keying on the raw string warned twice.
274
+ let key = root;
275
+ try {
276
+ key = realpathSync(root);
277
+ } catch { /* unresolvable is still a usable key */ }
278
+ // A per-user directory at 0700, not a bare name in a world-writable one. On Linux and in CI
279
+ // containers `os.tmpdir()` is `/tmp` at 1777: the sticky bit stops anyone deleting our file, but
280
+ // nothing stops them CREATING it first — and a planted empty file silences the one risk reminder
281
+ // this design promises, while a planted DANGLING symlink turns our write into a file created at a
282
+ // path they chose, owned by the person we were trying to help.
283
+ const dir = join(tmpdir(), `rsc-${process.getuid ? process.getuid() : 'u'}`);
284
+ const mark = join(dir, `offer-${createHash('sha256').update(key).digest('hex').slice(0, 16)}`);
285
+ try {
286
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
287
+ } catch { /* fall through: the write below is what actually decides */ }
288
+ try {
289
+ // `wx` is the whole fix: create-or-fail, and it refuses to follow a symlink. Success means WE
290
+ // made it and this is the first reminder. Failure means it already existed — planted or genuine,
291
+ // and either way the honest reading is "do not speak again".
292
+ writeFileSync(mark, '', { flag: 'wx' });
293
+ return false;
294
+ } catch {
295
+ return true;
296
+ }
297
+ }
298
+
299
+ function readStdin() {
300
+ try {
301
+ return readFileSync(0, 'utf8');
302
+ } catch {
303
+ return '';
304
+ }
305
+ }
306
+
307
+ /** The second and last offer: only when they are about to do the thing the absent harness guarded. */
308
+ function remindOnProtectedAction(root) {
309
+ let command = '';
310
+ try {
311
+ const payload = JSON.parse(readStdin() || '{}');
312
+ command = String(payload?.tool_input?.command ?? '');
313
+ } catch { return; }
314
+ const hit = PROTECTED.find(({ re }) => re.test(command));
315
+ if (!hit) return;
316
+ // Read BEFORE claiming the one reminder this project gets: a run with nothing to say must not spend
317
+ // it. Otherwise a project whose manifest arrives later by `git pull` has already used up its budget
318
+ // on a session where it could not have said anything.
319
+ const manifest = readManifest(root);
320
+ if (manifest.state !== 'declared') return;
321
+ if (alreadyReminded(root)) return;
322
+ const version = safeVersion(manifest.catalogVersion);
323
+ const pin = version ? `@${version}` : '';
324
+ // The envelope, not bare stdout. A `PreToolUse` hook's plain stdout is transcript-only, so the one
325
+ // message this path is allowed to send would have gone to a place nobody looks. Every sibling guard
326
+ // in this repo already wraps its output this way. And no `permissionDecision`: informing someone is
327
+ // not denying them, and nothing here may ever cost a person their tool call.
328
+ process.stdout.write(JSON.stringify({
329
+ hookSpecificOutput: {
330
+ hookEventName: 'PreToolUse',
331
+ additionalContext:
332
+ `rsc: you are about to ${hit.what}, and this project's checks for that are not built on ` +
333
+ 'this machine. Nothing is blocked, and this is the last time it will be mentioned. ' +
334
+ `To build them: npx ${PACKAGE}${pin} sync`,
335
+ },
336
+ }));
337
+ }
338
+
339
+ /**
340
+ * @param {string} target the real hook script the wiring would have called
341
+ * @param {string} root the project root, as the client resolved it
342
+ * @param {'announce'|'quiet'|'guard'} mode which hook this is, and therefore what it may say
343
+ */
344
+ export async function bootstrap(target, root, mode, ownArgc = 3) {
345
+ const mounted = existsSync(target);
346
+
347
+ // Declining is a decision, and it is respected whole: no opening offer, no divergence note, and no
348
+ // risk reminder either. Ignoring the offer is a different thing entirely — that leaves the door open.
349
+ const declined = existsSync(join(root, '.rsc', '.no-harness'));
350
+
351
+ if (mounted) {
352
+ // The harness works. It may still be out of date, and a manifest that arrived by `git pull` is
353
+ // exactly the case that used to pass in silence — half the feature missing. Say it, then get out
354
+ // of the way: being behind is not a reason to stop working.
355
+ if (mode === 'announce' && !declined) {
356
+ const manifest = readManifest(root);
357
+ const evaluation = evaluateHarness(root, manifest);
358
+ if (evaluation.verdict === 'behind') process.stdout.write(composeDivergence(manifest, evaluation));
359
+ }
360
+ return delegate(target, ownArgc);
361
+ }
362
+
363
+ if (declined) return;
364
+ if (mode === 'guard') return remindOnProtectedAction(root);
365
+ // Only one hook per session opens with the offer, so the person is told once and not seven times —
366
+ // settled by WHICH hook this is, with no marker in their tree (spec AC#2).
367
+ if (mode !== 'announce') return;
368
+ const { text } = composeOffer(readManifest(root));
369
+ if (text) process.stdout.write(text);
370
+ }
371
+
372
+ /**
373
+ * "Was this file run directly?" — and the answer must survive a symlink.
374
+ *
375
+ * The idiomatic `import.meta.url === pathToFileURL(process.argv[1]).href` is wrong here, and wrong
376
+ * in the most expensive way: `import.meta.url` is what the ESM loader resolved (symlinks RESOLVED),
377
+ * while `process.argv[1]` is the raw string the client passed (symlinks INTACT). One symlinked
378
+ * component anywhere in the project path — `/tmp` and `/var` on macOS, or the ordinary
379
+ * `~/code -> /Volumes/external/code` — and they differ, this block never runs, node exits 0 having
380
+ * printed nothing, and EVERY hook becomes a silent no-op. Including the danger guard.
381
+ *
382
+ * It would even look correct: "zero bytes in the healthy case" is satisfied, for entirely the wrong
383
+ * reason, while `doctor` keeps reporting every hook as properly wired.
384
+ */
385
+ function sameFile(a, b) {
386
+ if (!a || !b) return false;
387
+ try {
388
+ return realpathSync(a) === realpathSync(b);
389
+ } catch {
390
+ return a === b;
391
+ }
392
+ }
393
+
394
+ // Argument order is ours, then the delegate's untouched: <mode> <root> <target> [the real args…].
395
+ if (sameFile(fileURLToPath(import.meta.url), process.argv[1])) {
396
+ const [mode, root, target] = process.argv.slice(2);
397
+ // Fail open on the exit code, always: the harness is a convenience and never costs someone their
398
+ // turn. But failing open is not the same as failing SILENT. A blanket catch here would swallow a
399
+ // genuine crash inside any of the six delegated scripts, and a guard that crashed would become
400
+ // indistinguishable from a guard that allowed — which is how a safety guard stops being one
401
+ // without anybody finding out. So: absence is expected and stays quiet; anything else says so on
402
+ // stderr and still exits 0.
403
+ try {
404
+ await bootstrap(target, root ?? process.cwd(), mode);
405
+ } catch (err) {
406
+ if (err?.code !== 'ERR_MODULE_NOT_FOUND') {
407
+ process.stderr.write(`rsc: hook failed and was ignored — ${err?.message ?? err}\n`);
408
+ }
409
+ }
410
+ }