@damiangilgonzalez/harness 1.3.12 → 1.3.13
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 +31 -9
- package/manifest.json +1 -1
- package/package.json +3 -2
- package/scripts/doctor.js +109 -2
- package/scripts/install-apply.js +127 -7
- package/scripts/lib/capabilities.js +19 -2
- package/scripts/lib/divergence.js +8 -1
- package/scripts/lib/onboarding-apply.js +18 -1
- package/scripts/lib/onboarding.js +27 -2
- package/scripts/lib/versions.js +31 -0
- package/scripts/rsc.js +66 -16
- package/skills/animar/SKILL.md +1 -0
- package/skills/claude-project-setup/SKILL.md +1 -0
- package/skills/code-review/SKILL.md +1 -0
- package/skills/diseno-apple/SKILL.md +1 -0
- package/skills/diseno-landing/SKILL.md +1 -0
- package/skills/domain-modeling/SKILL.md +1 -0
- package/skills/flujo-interfaz/SKILL.md +1 -0
- package/skills/grill-me/SKILL.md +1 -0
- package/skills/grill-with-docs/SKILL.md +1 -0
- package/skills/grilling/SKILL.md +1 -0
- package/skills/handoff/SKILL.md +1 -0
- package/skills/implement/SKILL.md +1 -0
- package/skills/ingeniero-diseno-web/SKILL.md +1 -0
- package/skills/leyes-de-percepcion/SKILL.md +1 -0
- package/skills/leyes-de-retencion/SKILL.md +1 -0
- package/skills/mejor-accesibilidad/SKILL.md +1 -0
- package/skills/mejor-colores/SKILL.md +1 -0
- package/skills/mejor-layout/SKILL.md +1 -0
- package/skills/mejor-redaccion/SKILL.md +1 -0
- package/skills/mejor-tipografia/SKILL.md +1 -0
- package/skills/mejor-ui/SKILL.md +1 -0
- package/skills/muscle-memory/SKILL.md +1 -0
- package/skills/prototype/SKILL.md +1 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/revision-de-cambios/SKILL.md +1 -0
- package/skills/revision-interfaz/SKILL.md +1 -0
- package/skills/sitios-calidad-premio/SKILL.md +1 -0
- package/skills/suggest/SKILL.md +10 -9
- package/skills/tastemaker/SKILL.md +1 -0
- package/skills/teach/SKILL.md +1 -0
- package/skills/to-questionnaire/SKILL.md +1 -0
- package/skills/to-spec/SKILL.md +1 -0
- package/skills/to-tickets/SKILL.md +1 -0
- package/skills/video-a-superprompt/SKILL.md +1 -0
- package/skills/vocabulario-animacion/SKILL.md +1 -0
- package/skills/wait-what/SKILL.md +1 -0
- package/skills/wayfinder/SKILL.md +1 -0
- package/skills/write-adr/SKILL.md +1 -0
- package/skills/writing-for-agents/SKILL.md +1 -0
- package/targets/_md-block.js +8 -1
- package/targets/agents-md-shadow.js +153 -0
- package/targets/agents.js +9 -2
- package/targets/claude.js +52 -11
- package/targets/clone-bootstrap.mjs +454 -0
- package/targets/gitmoji-guard.mjs +30 -3
- package/targets/opt-outs.js +40 -0
- package/targets/session-memory-adapter.mjs +70 -14
- package/targets/session-memory-core.mjs +39 -7
- package/targets/session-start.mjs +63 -10
- package/targets/worktree-reaper.mjs +174 -10
|
@@ -0,0 +1,454 @@
|
|
|
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 = '@damiangilgonzalez/harness';
|
|
34
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `.no-*` switches that are the TEAM's decision, and therefore the only ones a manifest may
|
|
38
|
+
* speak about. Canonical copy: `targets/opt-outs.js`, which carries the reasoning.
|
|
39
|
+
*
|
|
40
|
+
* Duplicated on purpose and not by oversight. This file is COMMITTED into user repos and runs with
|
|
41
|
+
* the package possibly not installed, so it cannot import anything — the same constraint that makes
|
|
42
|
+
* `sello.mjs` a materialized sibling. A test holds the two copies equal, because duplication that
|
|
43
|
+
* nothing checks is just drift with a head start.
|
|
44
|
+
*/
|
|
45
|
+
export const PROJECT_OPT_OUTS = [
|
|
46
|
+
'audit', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'ship-guard', 'worktree-cleanup',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validated at the parse boundary AND again here, on purpose.
|
|
51
|
+
*
|
|
52
|
+
* These functions are exported and they are the ones that build the sentence a person is told to run
|
|
53
|
+
* EXACTLY. A single validation point is only as good as every caller reaching it, and the caller that
|
|
54
|
+
* skips it is the one nobody remembers writing. Checking again where the string is used costs one
|
|
55
|
+
* regex and removes the possibility entirely.
|
|
56
|
+
*/
|
|
57
|
+
const safeVersion = (v) => (typeof v === 'string' && SEMVER.test(v) ? v : null);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What the project declares, as a value — never as an exception.
|
|
61
|
+
* A corrupt manifest is a state we report, not a crash we propagate (spec AC#20).
|
|
62
|
+
*/
|
|
63
|
+
export function readManifest(root) {
|
|
64
|
+
const path = join(root, '.rsc.json');
|
|
65
|
+
if (!existsSync(path)) return { state: 'absent' };
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(path, 'utf8');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { state: 'unreadable', reason: err?.code ?? 'unreadable' };
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return { state: 'unreadable', reason: 'not valid JSON' };
|
|
77
|
+
}
|
|
78
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
79
|
+
return { state: 'unreadable', reason: 'not an object' };
|
|
80
|
+
}
|
|
81
|
+
// Ids, and only ids. `skills: [{id:"orient"}]` is valid JSON and used to throw inside `join()` —
|
|
82
|
+
// before the delegate ran, so a mounted and perfectly healthy harness lost its whole always-on layer
|
|
83
|
+
// over a manifest someone hand-edited. This file is COMMITTED into user repos, which means old
|
|
84
|
+
// copies of it live on forever in projects nobody will update; it has to be maximally suspicious of
|
|
85
|
+
// the only input it gets.
|
|
86
|
+
// An allowlist, not a type check. `.rsc.json` travels through `git pull`, so its contents are
|
|
87
|
+
// written by anyone who can open a pull request — and this feature now copies them into the text
|
|
88
|
+
// the model reads. A newline closes rsc's own `=====` frame and opens a forged one; a `;` turns the
|
|
89
|
+
// command we tell someone to run EXACTLY into two commands; a `..` walks out of the project and
|
|
90
|
+
// turns the divergence notice into an oracle for what exists on the reviewer's disk.
|
|
91
|
+
//
|
|
92
|
+
// None of that is what a skill id is. A skill id is a short lowercase name, so that is all that is
|
|
93
|
+
// accepted, and anything else is simply not there.
|
|
94
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
95
|
+
const ids = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && ID.test(x)) : []);
|
|
96
|
+
const skills = ids(parsed.skills);
|
|
97
|
+
const own = ids(parsed.ownSkills);
|
|
98
|
+
// A manifest with nothing declared is not a clone waiting to be equipped; there is nothing to offer.
|
|
99
|
+
if (!skills.length && !own.length) return { state: 'absent' };
|
|
100
|
+
return {
|
|
101
|
+
state: 'declared',
|
|
102
|
+
skills,
|
|
103
|
+
own,
|
|
104
|
+
// Same reasoning, and it matters more here: this string is pasted into the command we tell a
|
|
105
|
+
// person to run *exactly*. A version, or nothing — "no version pinned" is a true and harmless
|
|
106
|
+
// thing to say, and it is what an unusable value becomes.
|
|
107
|
+
catalogVersion: SEMVER.test(String(parsed.catalogVersion ?? '')) ? parsed.catalogVersion : null,
|
|
108
|
+
targets: Array.isArray(parsed.targets) ? parsed.targets : [],
|
|
109
|
+
// Same allowlist, same reason, and it bites harder here: these names are pasted into the
|
|
110
|
+
// notice a model reads. A switch is a plain lowercase name or it is not a switch — and only
|
|
111
|
+
// the ones the team is allowed to decide for everybody get through at all.
|
|
112
|
+
optOuts: ids(parsed.optOuts).filter((n) => PROJECT_OPT_OUTS.includes(n)).sort(),
|
|
113
|
+
tier: ['balanced', 'heavy'].includes(parsed.tier) ? parsed.tier : null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Is what is installed still what the project declares?
|
|
119
|
+
*
|
|
120
|
+
* `.rsc/skills/<id>` is the canonical home of a skill's content on every assistant — the per-target
|
|
121
|
+
* directories (`.claude/skills/`, `.codex/rsc/`, …) are links into it — so one rule answers this for
|
|
122
|
+
* all seventeen without teaching this file a single target's layout.
|
|
123
|
+
*
|
|
124
|
+
* Only what is DECLARED AND MISSING is reported. Not the reverse: an extra directory could equally be
|
|
125
|
+
* a skill someone wrote by hand, and this repo has already broken a person's own work once by
|
|
126
|
+
* assuming otherwise. When it cannot be told apart, it is left alone.
|
|
127
|
+
*/
|
|
128
|
+
export function evaluateHarness(root, manifest) {
|
|
129
|
+
if (manifest.state !== 'declared') {
|
|
130
|
+
return { verdict: 'unknown', missing: [], ownMissing: [], optOutsMissing: [], tierDiffers: null };
|
|
131
|
+
}
|
|
132
|
+
// Catalog skills: `.rsc/skills/<id>` is where their content lands, and it is the same on all
|
|
133
|
+
// seventeen assistants — the per-target directories are links into it.
|
|
134
|
+
const fromCatalog = (id) => existsSync(join(root, '.rsc', 'skills', id));
|
|
135
|
+
// Own skills are a different thing in a different place, and conflating them is how a healthy
|
|
136
|
+
// project ends up nagged every session about work that is already correct. `.rsc/skills/` is filled
|
|
137
|
+
// by copying FROM THE CATALOG, and an own skill is by definition not in the catalog, so it can never
|
|
138
|
+
// appear there — looking for it there reports it missing forever, and the `sync` we would prescribe
|
|
139
|
+
// could never fix it. They live where their assistant keeps skills, which is what this repo's own
|
|
140
|
+
// `divergence()` already checks.
|
|
141
|
+
const OWN_DIRS = [
|
|
142
|
+
['.claude', 'skills'], ['.codex', 'rsc'], ['.cursor', 'rules'], ['.windsurf', 'rules'],
|
|
143
|
+
['.clinerules'], ['.roo', 'rules'], ['.continue', 'rules'], ['.junie'], ['.kiro', 'steering'],
|
|
144
|
+
['.antigravity'], ['.opencode'], ['.rsc', 'skills'],
|
|
145
|
+
];
|
|
146
|
+
const fromTeam = (name) => OWN_DIRS.some((dir) => existsSync(join(root, ...dir, name)));
|
|
147
|
+
const missing = manifest.skills.filter((id) => !fromCatalog(id));
|
|
148
|
+
const ownMissing = manifest.own.filter((name) => !fromTeam(name));
|
|
149
|
+
// A gate the team disarmed that is still armed here. Declared-and-missing only, exactly as for
|
|
150
|
+
// skills above: a marker this machine has and the manifest does not could be a decision somebody
|
|
151
|
+
// is still making, and this file does not tell people what to think about their own work.
|
|
152
|
+
const optOutsMissing = manifest.optOuts.filter((n) => !existsSync(join(root, '.rsc', `.no-${n}`)));
|
|
153
|
+
const tierDiffers = manifest.tier && manifest.tier !== localTier(root) ? manifest.tier : null;
|
|
154
|
+
if (!missing.length && !ownMissing.length && !optOutsMissing.length && !tierDiffers) {
|
|
155
|
+
return { verdict: 'current', missing: [], ownMissing: [], optOutsMissing: [], tierDiffers: null };
|
|
156
|
+
}
|
|
157
|
+
return { verdict: 'behind', missing, ownMissing, optOutsMissing, tierDiffers };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The tier this machine actually runs at — `balanced` is the documented default, not a guess. */
|
|
161
|
+
function localTier(root) {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(readFileSync(join(root, '.rsc', 'developer.json'), 'utf8')).tier === 'heavy'
|
|
164
|
+
? 'heavy' : 'balanced';
|
|
165
|
+
} catch { return 'balanced'; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What to say when the harness works but no longer matches what the team decided — the other half of
|
|
170
|
+
* the feature: building a clone and bringing a stale one up to date are the same transaction, and
|
|
171
|
+
* both converge on the MANIFEST, never on the newest release.
|
|
172
|
+
*
|
|
173
|
+
* The own skills are named separately and deliberately kept out of the sentence that follows the
|
|
174
|
+
* command: rsc does not install them, their version is the commit, and listing them next to a thing
|
|
175
|
+
* that installs would be a promise nothing keeps.
|
|
176
|
+
*/
|
|
177
|
+
export function composeDivergence(manifest, evaluation) {
|
|
178
|
+
const version = safeVersion(manifest.catalogVersion);
|
|
179
|
+
const command = version ? `npx ${PACKAGE}@${version} sync` : `npx ${PACKAGE} sync`;
|
|
180
|
+
let text =
|
|
181
|
+
'===== rsc =====\n' +
|
|
182
|
+
'This project declares a harness that no longer matches what is built here — someone changed\n' +
|
|
183
|
+
'it and git brought the change; nothing is broken.\n';
|
|
184
|
+
if (evaluation.missing.length) {
|
|
185
|
+
text += `MISSING, declared in .rsc.json: ${evaluation.missing.join(', ')}.\n`;
|
|
186
|
+
}
|
|
187
|
+
// Named as the convention they are, never as the file that implements them: "the team disarmed
|
|
188
|
+
// the commit-message check" is something a person can agree or disagree with; ".no-gitmoji is
|
|
189
|
+
// absent" is a fact about a directory they have never opened.
|
|
190
|
+
if (evaluation.optOutsMissing?.length) {
|
|
191
|
+
text += `DISARMED BY THE TEAM, still armed here: ${evaluation.optOutsMissing.join(', ')}.\n`;
|
|
192
|
+
}
|
|
193
|
+
if (evaluation.tierDiffers) {
|
|
194
|
+
text += `DECLARED developer tier: ${evaluation.tierDiffers} — this machine runs the default.\n`;
|
|
195
|
+
}
|
|
196
|
+
// Before the command, never after: anything sitting under the action reads as part of what the
|
|
197
|
+
// action installs, and these are the one thing rsc will not install. Their version is the commit.
|
|
198
|
+
if (evaluation.ownMissing.length) {
|
|
199
|
+
text +=
|
|
200
|
+
`ALSO DECLARED, written by the team: ${evaluation.ownMissing.join(', ')} — these arrive through\n` +
|
|
201
|
+
'git, not through any command, and rsc never installs or overwrites them.\n';
|
|
202
|
+
}
|
|
203
|
+
return (
|
|
204
|
+
text +
|
|
205
|
+
'ACTION: mention it in one line and carry on with what the user asked. To converge, run:\n' +
|
|
206
|
+
` ${command}\n` +
|
|
207
|
+
'===============\n'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The text a person sees, built from the manifest alone.
|
|
213
|
+
* Pure: it decides nothing about disk and writes nothing, so it can be tested without one.
|
|
214
|
+
*/
|
|
215
|
+
export function composeOffer(manifest) {
|
|
216
|
+
if (manifest.state === 'absent') return { offers: false, text: '' };
|
|
217
|
+
if (manifest.state === 'unreadable') {
|
|
218
|
+
return {
|
|
219
|
+
offers: false,
|
|
220
|
+
text:
|
|
221
|
+
'===== rsc =====\n' +
|
|
222
|
+
`This project declares a harness, but its manifest cannot be read (${manifest.reason}).\n` +
|
|
223
|
+
'Nothing has been installed and nothing was changed. Someone on the team needs to fix\n' +
|
|
224
|
+
'.rsc.json before the harness can be rebuilt here.\n' +
|
|
225
|
+
'===============\n',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// The version is named, never guessed: what gets installed is what the team pinned, and a person
|
|
229
|
+
// accepting an install on a repo they just cloned is entitled to read the exact thing first
|
|
230
|
+
// (spec AC#21). No pin is itself a fact worth showing, not a blank to fill in.
|
|
231
|
+
//
|
|
232
|
+
// And it has to be IN THE COMMAND, not only in the prose above it. `npx @damiangilgonzalez/harness sync`
|
|
233
|
+
// resolves to the latest published package, so an offer phrased that way hands a clone whatever
|
|
234
|
+
// shipped since — the exact divergence the pin exists to prevent, introduced by the feature meant
|
|
235
|
+
// to honour it. Pinned, the command is reproducible three months from now (spec AC#6).
|
|
236
|
+
const version = safeVersion(manifest.catalogVersion);
|
|
237
|
+
const pin = version ? `${PACKAGE}@${version}` : `${PACKAGE} (no version pinned)`;
|
|
238
|
+
const command = version ? `npx ${PACKAGE}@${version} sync` : `npx ${PACKAGE} sync`;
|
|
239
|
+
const count = manifest.skills.length;
|
|
240
|
+
const own = manifest.own.length ? `, plus ${manifest.own.length} written by the team (never overwritten)` : '';
|
|
241
|
+
return {
|
|
242
|
+
offers: true,
|
|
243
|
+
text:
|
|
244
|
+
'===== rsc =====\n' +
|
|
245
|
+
'This project declares a harness that is not built on this machine, so the assistant\n' +
|
|
246
|
+
"cannot see any of the project's skills right now. Nothing is broken and nothing was\n" +
|
|
247
|
+
'changed — the harness simply does not travel through git, by design.\n' +
|
|
248
|
+
'\n' +
|
|
249
|
+
`WOULD INSTALL: ${pin} — ${count} skill(s) as pinned in .rsc.json${own}.\n` +
|
|
250
|
+
'It writes .rsc/ and the skill entries; it never touches anything written by hand.\n' +
|
|
251
|
+
'\n' +
|
|
252
|
+
'ACTION: ask the user whether to build it, in one line, and continue with their request\n' +
|
|
253
|
+
'either way — this must not hold up what they asked for. If there is nobody to ask (CI, an\n' +
|
|
254
|
+
'unattended run), do NOT install: say it is missing and carry on. On a yes, run EXACTLY:\n' +
|
|
255
|
+
` ${command}\n` +
|
|
256
|
+
'(the version is the one this project pinned. Do not substitute `@latest`: a release nobody\n' +
|
|
257
|
+
' on this team adopted is not an upgrade, it is two people quietly drifting apart.)\n' +
|
|
258
|
+
"On a no, create .rsc/.no-harness so this is not offered again on this machine.\n" +
|
|
259
|
+
'===============\n',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Delegate to the real hook script, preserving what it believes about how it was invoked.
|
|
265
|
+
* `gitmoji-guard` only runs its main block when `process.argv[1]` is its own path, so the splice is
|
|
266
|
+
* load-bearing, not tidiness: without it the guard loads and silently does nothing. The four
|
|
267
|
+
* 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
|
|
268
|
+
* wiring used to hand it directly — anything less and every hook would need to learn about us.
|
|
269
|
+
*/
|
|
270
|
+
async function delegate(target, ownArgc) {
|
|
271
|
+
// `realpathSync` and not `target` as given: the delegate may run its own identity check, and
|
|
272
|
+
// `gitmoji-guard` does exactly that. Its `import.meta.url` comes back from the loader with
|
|
273
|
+
// symlinks resolved, so handing it the raw path makes its main block silently not run — the guard
|
|
274
|
+
// loads, denies nothing, and reports success. Same trap as `sameFile` below, one level down.
|
|
275
|
+
// One resolved value for both: argv[1] and the import specifier must agree, or a delegate that
|
|
276
|
+
// checks its own identity loads and then does nothing — which under `--preserve-symlinks` is
|
|
277
|
+
// exactly what happened.
|
|
278
|
+
const resolved = realpathSync(target);
|
|
279
|
+
process.argv.splice(1, ownArgc + 1, resolved);
|
|
280
|
+
await import(pathToFileURL(resolved).href);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* @param {string} target the real hook script the wiring would have called
|
|
285
|
+
* @param {string} root the project root, as the client resolved it
|
|
286
|
+
* @param {boolean} announce whether this hook is the one allowed to speak (SessionStart only)
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* The protections a missing harness takes away, named by the thing a person is about to do rather
|
|
290
|
+
* than by the guard that is gone — "you are about to commit and the commit checks are not here" is
|
|
291
|
+
* actionable; "ship-guard is not installed" is not.
|
|
292
|
+
*
|
|
293
|
+
* RECOGNISING one of these is judgement and is declared NON-BINDING (P2): the list is deliberately
|
|
294
|
+
* short and will miss things, and missing one costs a reminder, never a failure. What IS binding,
|
|
295
|
+
* and what the tests hold, is everything around it — that it reminds at most once, that it never
|
|
296
|
+
* denies a tool call, and that it stays silent for ordinary work.
|
|
297
|
+
*/
|
|
298
|
+
const PROTECTED = [
|
|
299
|
+
// `git -C <path> commit` is not an edge case: it is the form this workspace's own CLAUDE.md
|
|
300
|
+
// mandates, so without it the reminder would essentially never fire where it was written.
|
|
301
|
+
{ re: /\bgit\s+(-C\s+\S+\s+)?(commit|cz)\b/, what: 'commit' },
|
|
302
|
+
{ re: /\bgit\s+(-C\s+\S+\s+)?push\b/, what: 'push' },
|
|
303
|
+
{ re: /\bgit\s+(-C\s+\S+\s+)?merge\b/, what: 'merge' },
|
|
304
|
+
{ re: /\bgit\s+(-C\s+\S+\s+)?(switch|checkout)\s+(main|master)\b/, what: 'switch to the trunk' },
|
|
305
|
+
{ re: /\bnpm\s+publish\b/, what: 'publish' },
|
|
306
|
+
{ re: /\brm\s+-rf\b/, what: 'delete files irreversibly' },
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* "Have we already reminded them in this project?" — kept in the OS temp directory, keyed by the
|
|
311
|
+
* project path, and NEVER inside the project. The user's tree stays untouched until they consent,
|
|
312
|
+
* which is the whole point of the first offer (spec AC#2); a note to ourselves about how many times
|
|
313
|
+
* we have spoken is not their file to carry.
|
|
314
|
+
*/
|
|
315
|
+
function alreadyReminded(root) {
|
|
316
|
+
// Keyed on the RESOLVED path: the same project reached through a symlinked alias is the same
|
|
317
|
+
// project, and keying on the raw string warned twice.
|
|
318
|
+
let key = root;
|
|
319
|
+
try {
|
|
320
|
+
key = realpathSync(root);
|
|
321
|
+
} catch { /* unresolvable is still a usable key */ }
|
|
322
|
+
// A per-user directory at 0700, not a bare name in a world-writable one. On Linux and in CI
|
|
323
|
+
// containers `os.tmpdir()` is `/tmp` at 1777: the sticky bit stops anyone deleting our file, but
|
|
324
|
+
// nothing stops them CREATING it first — and a planted empty file silences the one risk reminder
|
|
325
|
+
// this design promises, while a planted DANGLING symlink turns our write into a file created at a
|
|
326
|
+
// path they chose, owned by the person we were trying to help.
|
|
327
|
+
const dir = join(tmpdir(), `rsc-${process.getuid ? process.getuid() : 'u'}`);
|
|
328
|
+
const mark = join(dir, `offer-${createHash('sha256').update(key).digest('hex').slice(0, 16)}`);
|
|
329
|
+
try {
|
|
330
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
331
|
+
} catch { /* fall through: the write below is what actually decides */ }
|
|
332
|
+
try {
|
|
333
|
+
// `wx` is the whole fix: create-or-fail, and it refuses to follow a symlink. Success means WE
|
|
334
|
+
// made it and this is the first reminder. Failure means it already existed — planted or genuine,
|
|
335
|
+
// and either way the honest reading is "do not speak again".
|
|
336
|
+
writeFileSync(mark, '', { flag: 'wx' });
|
|
337
|
+
return false;
|
|
338
|
+
} catch {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readStdin() {
|
|
344
|
+
try {
|
|
345
|
+
return readFileSync(0, 'utf8');
|
|
346
|
+
} catch {
|
|
347
|
+
return '';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The second and last offer: only when they are about to do the thing the absent harness guarded. */
|
|
352
|
+
function remindOnProtectedAction(root) {
|
|
353
|
+
let command = '';
|
|
354
|
+
try {
|
|
355
|
+
const payload = JSON.parse(readStdin() || '{}');
|
|
356
|
+
command = String(payload?.tool_input?.command ?? '');
|
|
357
|
+
} catch { return; }
|
|
358
|
+
const hit = PROTECTED.find(({ re }) => re.test(command));
|
|
359
|
+
if (!hit) return;
|
|
360
|
+
// Read BEFORE claiming the one reminder this project gets: a run with nothing to say must not spend
|
|
361
|
+
// it. Otherwise a project whose manifest arrives later by `git pull` has already used up its budget
|
|
362
|
+
// on a session where it could not have said anything.
|
|
363
|
+
const manifest = readManifest(root);
|
|
364
|
+
if (manifest.state !== 'declared') return;
|
|
365
|
+
if (alreadyReminded(root)) return;
|
|
366
|
+
const version = safeVersion(manifest.catalogVersion);
|
|
367
|
+
const pin = version ? `@${version}` : '';
|
|
368
|
+
// The envelope, not bare stdout. A `PreToolUse` hook's plain stdout is transcript-only, so the one
|
|
369
|
+
// message this path is allowed to send would have gone to a place nobody looks. Every sibling guard
|
|
370
|
+
// in this repo already wraps its output this way. And no `permissionDecision`: informing someone is
|
|
371
|
+
// not denying them, and nothing here may ever cost a person their tool call.
|
|
372
|
+
process.stdout.write(JSON.stringify({
|
|
373
|
+
hookSpecificOutput: {
|
|
374
|
+
hookEventName: 'PreToolUse',
|
|
375
|
+
additionalContext:
|
|
376
|
+
`rsc: you are about to ${hit.what}, and this project's checks for that are not built on ` +
|
|
377
|
+
'this machine. Nothing is blocked, and this is the last time it will be mentioned. ' +
|
|
378
|
+
`To build them: npx ${PACKAGE}${pin} sync`,
|
|
379
|
+
},
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* @param {string} target the real hook script the wiring would have called
|
|
385
|
+
* @param {string} root the project root, as the client resolved it
|
|
386
|
+
* @param {'announce'|'quiet'|'guard'} mode which hook this is, and therefore what it may say
|
|
387
|
+
*/
|
|
388
|
+
export async function bootstrap(target, root, mode, ownArgc = 3) {
|
|
389
|
+
const mounted = existsSync(target);
|
|
390
|
+
|
|
391
|
+
// Declining is a decision, and it is respected whole: no opening offer, no divergence note, and no
|
|
392
|
+
// risk reminder either. Ignoring the offer is a different thing entirely — that leaves the door open.
|
|
393
|
+
const declined = existsSync(join(root, '.rsc', '.no-harness'));
|
|
394
|
+
|
|
395
|
+
if (mounted) {
|
|
396
|
+
// The harness works. It may still be out of date, and a manifest that arrived by `git pull` is
|
|
397
|
+
// exactly the case that used to pass in silence — half the feature missing. Say it, then get out
|
|
398
|
+
// of the way: being behind is not a reason to stop working.
|
|
399
|
+
if (mode === 'announce' && !declined) {
|
|
400
|
+
const manifest = readManifest(root);
|
|
401
|
+
const evaluation = evaluateHarness(root, manifest);
|
|
402
|
+
if (evaluation.verdict === 'behind') process.stdout.write(composeDivergence(manifest, evaluation));
|
|
403
|
+
}
|
|
404
|
+
return delegate(target, ownArgc);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (declined) return;
|
|
408
|
+
if (mode === 'guard') return remindOnProtectedAction(root);
|
|
409
|
+
// Only one hook per session opens with the offer, so the person is told once and not seven times —
|
|
410
|
+
// settled by WHICH hook this is, with no marker in their tree (spec AC#2).
|
|
411
|
+
if (mode !== 'announce') return;
|
|
412
|
+
const { text } = composeOffer(readManifest(root));
|
|
413
|
+
if (text) process.stdout.write(text);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* "Was this file run directly?" — and the answer must survive a symlink.
|
|
418
|
+
*
|
|
419
|
+
* The idiomatic `import.meta.url === pathToFileURL(process.argv[1]).href` is wrong here, and wrong
|
|
420
|
+
* in the most expensive way: `import.meta.url` is what the ESM loader resolved (symlinks RESOLVED),
|
|
421
|
+
* while `process.argv[1]` is the raw string the client passed (symlinks INTACT). One symlinked
|
|
422
|
+
* component anywhere in the project path — `/tmp` and `/var` on macOS, or the ordinary
|
|
423
|
+
* `~/code -> /Volumes/external/code` — and they differ, this block never runs, node exits 0 having
|
|
424
|
+
* printed nothing, and EVERY hook becomes a silent no-op. Including the danger guard.
|
|
425
|
+
*
|
|
426
|
+
* It would even look correct: "zero bytes in the healthy case" is satisfied, for entirely the wrong
|
|
427
|
+
* reason, while `doctor` keeps reporting every hook as properly wired.
|
|
428
|
+
*/
|
|
429
|
+
function sameFile(a, b) {
|
|
430
|
+
if (!a || !b) return false;
|
|
431
|
+
try {
|
|
432
|
+
return realpathSync(a) === realpathSync(b);
|
|
433
|
+
} catch {
|
|
434
|
+
return a === b;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Argument order is ours, then the delegate's untouched: <mode> <root> <target> [the real args…].
|
|
439
|
+
if (sameFile(fileURLToPath(import.meta.url), process.argv[1])) {
|
|
440
|
+
const [mode, root, target] = process.argv.slice(2);
|
|
441
|
+
// Fail open on the exit code, always: the harness is a convenience and never costs someone their
|
|
442
|
+
// turn. But failing open is not the same as failing SILENT. A blanket catch here would swallow a
|
|
443
|
+
// genuine crash inside any of the six delegated scripts, and a guard that crashed would become
|
|
444
|
+
// indistinguishable from a guard that allowed — which is how a safety guard stops being one
|
|
445
|
+
// without anybody finding out. So: absence is expected and stays quiet; anything else says so on
|
|
446
|
+
// stderr and still exits 0.
|
|
447
|
+
try {
|
|
448
|
+
await bootstrap(target, root ?? process.cwd(), mode);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
if (err?.code !== 'ERR_MODULE_NOT_FOUND') {
|
|
451
|
+
process.stderr.write(`rsc: hook failed and was ignored — ${err?.message ?? err}\n`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
// read. Anything it cannot read with certainty (editor commit, -F file, --amend
|
|
19
19
|
// --no-edit, an unparseable command) is ALLOWED: a guard that guesses is a guard that
|
|
20
20
|
// gets turned off. Every deny names its recovery. Opt out with .rsc/.no-gitmoji.
|
|
21
|
-
import {
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
25
|
// The official gitmoji set (gitmoji.dev/api/gitmojis), as data: [emoji, code, semver, meaning].
|
|
@@ -222,7 +222,34 @@ export function denyMessage(message) {
|
|
|
222
222
|
|
|
223
223
|
// ---- hook entrypoint -----------------------------------------------------------
|
|
224
224
|
// Skipped when imported by a test (P2: the mechanism is testable without a subprocess).
|
|
225
|
-
|
|
225
|
+
/**
|
|
226
|
+
* "Was this file run directly?" — the same question `clone-bootstrap.mjs` answers, and the same two
|
|
227
|
+
* traps, so the same answer. Written out here rather than imported because hooks are materialized
|
|
228
|
+
* file by file under `.rsc/`: an import would be a second file to copy for six lines.
|
|
229
|
+
*
|
|
230
|
+
* `process.argv[1]` MAY NOT EXIST. Under `node -e`, `--input-type=module` or the REPL there is no
|
|
231
|
+
* script path, and `pathToFileURL(undefined)` throws — at module load, so merely IMPORTING this
|
|
232
|
+
* file takes the importer down with it. A module that cannot be imported is a module nobody can
|
|
233
|
+
* build a tool on top of, and people do exactly that with this package.
|
|
234
|
+
*
|
|
235
|
+
* SYMLINKS MAKE THE TWO SIDES DISAGREE. `import.meta.url` is what the loader resolved (symlinks
|
|
236
|
+
* RESOLVED); `process.argv[1]` is the raw string the client passed (symlinks INTACT). One
|
|
237
|
+
* symlinked component — `/tmp` and `/var` on macOS, or an ordinary `~/code -> /Volumes/…` — and a
|
|
238
|
+
* string compare says "no", the main block never runs, node exits 0 having printed nothing, and
|
|
239
|
+
* the hook becomes a silent no-op that `doctor` still reports as wired.
|
|
240
|
+
*/
|
|
241
|
+
function isMainModule(metaUrl) {
|
|
242
|
+
const invoked = process.argv[1];
|
|
243
|
+
if (!invoked) return false;
|
|
244
|
+
const self = fileURLToPath(metaUrl);
|
|
245
|
+
try {
|
|
246
|
+
return realpathSync(self) === realpathSync(invoked);
|
|
247
|
+
} catch {
|
|
248
|
+
return self === invoked;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isMainModule(import.meta.url)) {
|
|
226
253
|
const root = process.argv[2] || process.cwd();
|
|
227
254
|
const allow = () => process.exit(0);
|
|
228
255
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Which `.no-*` switches are the TEAM's decision and which are THIS MACHINE's.
|
|
2
|
+
//
|
|
3
|
+
// `project-manifest` moved disarmed gates into the committed declaration because they are
|
|
4
|
+
// decisions and decisions should not evaporate on `git clone`. What it did not settle is that
|
|
5
|
+
// not every switch is the same kind of decision, and one of them is actively dangerous to share:
|
|
6
|
+
//
|
|
7
|
+
// `.no-harness` means "do not offer to build the harness ON THIS MACHINE" — the bootstrap says
|
|
8
|
+
// exactly that when it offers it. Let it travel and one person declining, once, would silence
|
|
9
|
+
// the offer in every future clone: a whole team losing the harness because somebody's laptop
|
|
10
|
+
// said no. The same reasoning covers an MCP connection (consent is individual), a check that
|
|
11
|
+
// compares two scopes of one machine, and a nudge that can only fire in a project with no
|
|
12
|
+
// `.git/` — which a clone, by construction, is not.
|
|
13
|
+
//
|
|
14
|
+
// A table and not a chain of ifs, so a test can iterate it and `clone-bootstrap.mjs` — which is
|
|
15
|
+
// committed, standalone and cannot import from the package — can be held against it instead of
|
|
16
|
+
// drifting away from it in silence.
|
|
17
|
+
|
|
18
|
+
/** Decisions of the project: they belong in `.rsc.json` and are rebuilt in a clone. */
|
|
19
|
+
export const PROJECT_OPT_OUTS = [
|
|
20
|
+
'audit', // cadence of this project's skill audit
|
|
21
|
+
'claudemd-check', // the root CLAUDE.md context budget
|
|
22
|
+
'danger-guard', // foot-gun denial, keyed to the committed user profile
|
|
23
|
+
'feature-gate', // per-turn re-injection of the SDD gate
|
|
24
|
+
'gitmoji', // the commit-message convention
|
|
25
|
+
'ship-guard', // branch/trunk discipline
|
|
26
|
+
'worktree-cleanup', // the reaper's notice (it only ever names, never acts)
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Decisions of one machine: never written to the manifest, never applied from it. */
|
|
30
|
+
export const MACHINE_OPT_OUTS = [
|
|
31
|
+
'context7', // an MCP connection is machine wiring, and its consent is individual
|
|
32
|
+
'git', // only fires with no .git/, which a clone cannot be
|
|
33
|
+
'harness', // "not offered again ON THIS MACHINE" — the bootstrap's own words
|
|
34
|
+
'scope-check',// compares the project and home scopes OF THIS MACHINE
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const isProjectOptOut = (name) => PROJECT_OPT_OUTS.includes(name);
|
|
38
|
+
|
|
39
|
+
/** The project-level subset of a manifest's `optOuts`, unknown names dropped. */
|
|
40
|
+
export const projectOptOuts = (list) => [...new Set((list || []).filter(isProjectOptOut))].sort();
|