@ericrisco/rsc 1.3.6 → 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 +26 -6
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +30 -0
- package/scripts/lib/onboarding.js +36 -2
- package/skills/suggest/SKILL.md +6 -4
- package/targets/claude.js +36 -7
- package/targets/clone-bootstrap.mjs +410 -0
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
|
-
|
|
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
|
|
300
|
+
npx @ericrisco/rsc@<catalogVersion> sync
|
|
288
301
|
```
|
|
289
302
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
package/package.json
CHANGED
package/scripts/install-apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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() : [];
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|