@ericrisco/rsc 1.0.1 → 1.0.3
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/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +30 -0
- package/scripts/install-apply.js +1 -0
- package/scripts/rsc.js +149 -1
- package/skills/huggingface/SKILL.md +3 -2
- package/skills/review/SKILL.md +45 -0
- package/skills/ship/SKILL.md +9 -0
- package/targets/claude.js +3 -0
- package/targets/sello.mjs +398 -0
- package/targets/ship-guard.mjs +21 -2
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -6,6 +6,35 @@ import { readState } from './lib/state.js';
|
|
|
6
6
|
import { loadManifest } from './lib/manifest.js';
|
|
7
7
|
import { listBackups } from './lib/backups.js';
|
|
8
8
|
import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
|
|
9
|
+
import { isEnabled, checkSello, readSello, countFindings, readConfig, validateRiskConfig } from '../targets/sello.mjs';
|
|
10
|
+
|
|
11
|
+
// The sello's health, surfaced where the user already looks (spec: non-blocking
|
|
12
|
+
// findings live in the project and are SUMMARIZED here, never nagged about).
|
|
13
|
+
// An INERT gate must never read as an armed one — a config that silently disables
|
|
14
|
+
// enforcement is reported here, loudly.
|
|
15
|
+
function selloStatus(root) {
|
|
16
|
+
try {
|
|
17
|
+
if (!isEnabled(root)) return { enabled: false };
|
|
18
|
+
const sello = readSello(root);
|
|
19
|
+
const verdict = checkSello(root);
|
|
20
|
+
const out = {
|
|
21
|
+
enabled: true,
|
|
22
|
+
check: verdict.code,
|
|
23
|
+
status: sello.missing ? 'none' : sello.corrupt ? 'corrupt' : sello.status,
|
|
24
|
+
nonBlockingFindings: countFindings(root),
|
|
25
|
+
};
|
|
26
|
+
if (verdict.warning) out.warning = verdict.warning;
|
|
27
|
+
if (verdict.code === 'no-trunk') out.inert = 'no-trunk';
|
|
28
|
+
if (existsSync(join(root, '.rsc', '.no-ship-guard'))) {
|
|
29
|
+
out.note = 'ship-guard branch-hygiene rules are opted out (.rsc/.no-ship-guard); the sello itself still enforces.';
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const { lowered } = validateRiskConfig(readConfig(root) || {});
|
|
33
|
+
if (lowered.length) out.loweredClasses = lowered;
|
|
34
|
+
} catch (e) { out.configError = e.message; }
|
|
35
|
+
return out;
|
|
36
|
+
} catch { return { enabled: false }; }
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
export function doctor({ target, home, cwd }) {
|
|
11
40
|
const root = cwd || process.cwd();
|
|
@@ -25,6 +54,7 @@ export function doctor({ target, home, cwd }) {
|
|
|
25
54
|
latest: backups[0]?.id || null,
|
|
26
55
|
},
|
|
27
56
|
contextBudget: contextBudget({ target, home, cwd }),
|
|
57
|
+
sello: selloStatus(root),
|
|
28
58
|
};
|
|
29
59
|
for (const [id, e] of Object.entries(state.skills)) {
|
|
30
60
|
for (const f of e.files) if (!existsSync(f)) report.missing.push(`${id}:${f}`);
|
package/scripts/install-apply.js
CHANGED
package/scripts/rsc.js
CHANGED
|
@@ -327,6 +327,154 @@ async function main() {
|
|
|
327
327
|
say('Use: npx @ericrisco/rsc registry refresh | registry status');
|
|
328
328
|
return;
|
|
329
329
|
}
|
|
330
|
+
case 'sello': {
|
|
331
|
+
// Deterministic transitions of the sello (review receipt). The `review` skill
|
|
332
|
+
// orchestrates the lenses; every state change and every check runs HERE, token-free.
|
|
333
|
+
const S = await import('../targets/sello.mjs');
|
|
334
|
+
const root = S.resolveRoot(process.cwd());
|
|
335
|
+
const sub = argv[1];
|
|
336
|
+
const paths = S.selloPaths(root);
|
|
337
|
+
// flag() yields boolean true for a valueless flag; every string flag below
|
|
338
|
+
// goes through this so `--lenses` with no value is a usage error, not a crash.
|
|
339
|
+
const str = (name) => { const v = flag(name); return typeof v === 'string' ? v : undefined; };
|
|
340
|
+
switch (sub) {
|
|
341
|
+
case 'on':
|
|
342
|
+
case 'off': {
|
|
343
|
+
const cfg = S.readConfig(root) || {};
|
|
344
|
+
// `off` never validates: it is the recovery advertised by every deny
|
|
345
|
+
// message, so a broken config must not be able to lock it away.
|
|
346
|
+
if (sub === 'on') {
|
|
347
|
+
try { S.validateRiskConfig(cfg); } catch (e) { say(e.message); process.exitCode = 1; return; }
|
|
348
|
+
}
|
|
349
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
350
|
+
const { dirname } = await import('node:path');
|
|
351
|
+
mkdirSync(dirname(paths.config), { recursive: true });
|
|
352
|
+
writeFileSync(paths.config, JSON.stringify({ ...cfg, enabled: sub === 'on' }, null, 2) + '\n');
|
|
353
|
+
say(sub === 'on'
|
|
354
|
+
? '✅ sello ON for this project. Risk-0 changes (docs/copy) still pass silently; everything else needs a review before commit/push/PR.'
|
|
355
|
+
: '✅ sello OFF for this project. Delivery behaves exactly as before.');
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
case 'freeze': {
|
|
359
|
+
const cfg = S.readConfig(root);
|
|
360
|
+
const candidate = S.computeCandidate(root, cfg);
|
|
361
|
+
if (!candidate) { say(S.MESSAGES.noTrunk()); process.exitCode = 1; return; }
|
|
362
|
+
const paths2 = Object.keys(candidate.files);
|
|
363
|
+
if (!paths2.length) { say('sello: nothing to freeze — the working tree matches the trunk base.'); return; }
|
|
364
|
+
const risk = S.classifyRisk(paths2, cfg);
|
|
365
|
+
if (risk.configError) say(`⚠️ ${risk.configError}\n (using the default risk table until it is fixed)`);
|
|
366
|
+
const required = S.lensesRequired(risk.tier, cfg);
|
|
367
|
+
S.writeSello(root, { status: 'frozen', ...candidate, risk, required, lenses: [], createdAt: new Date().toISOString() });
|
|
368
|
+
say(`🧊 frozen: ${paths2.length} file(s), risk tier ${risk.tier} → ${required} lens(es) required.`);
|
|
369
|
+
for (const [p, c] of Object.entries(risk.classes)) if (c !== 'default' && c !== 'docs') say(` ${p} → ${c}`);
|
|
370
|
+
if (risk.lowered?.length) say(` (lowered by config: ${risk.lowered.join(', ')})`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
case 'approve':
|
|
374
|
+
case 'block': {
|
|
375
|
+
const sello = S.readSello(root);
|
|
376
|
+
if (sello.missing || sello.corrupt || !sello.status) { say(S.MESSAGES.notFrozen()); process.exitCode = 1; return; }
|
|
377
|
+
const cfg = S.readConfig(root);
|
|
378
|
+
const current = S.computeCandidate(root, cfg);
|
|
379
|
+
const changed = current && Object.keys({ ...sello.files, ...current.files })
|
|
380
|
+
.some((p) => sello.files[p] !== current.files[p]);
|
|
381
|
+
if (sub === 'approve' && (changed || sello.base !== current?.base)) {
|
|
382
|
+
say(S.MESSAGES.staleFreeze()); process.exitCode = 1; return;
|
|
383
|
+
}
|
|
384
|
+
const lenses = (str('lenses') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
385
|
+
const required = sello.required ?? S.lensesRequired(sello.risk?.tier ?? 1, cfg);
|
|
386
|
+
// An incomplete panel must not seal silently (spec error path): the user
|
|
387
|
+
// may still accept the gap, but only on purpose.
|
|
388
|
+
if (sub === 'approve' && lenses.length < required && !argv.includes('--accept-partial-lenses')) {
|
|
389
|
+
say(S.MESSAGES.partialLenses(lenses.length, required)); process.exitCode = 1; return;
|
|
390
|
+
}
|
|
391
|
+
const note = str('note');
|
|
392
|
+
if (note) S.appendFindings(root, [`${new Date().toISOString().slice(0, 10)} ${note}`]);
|
|
393
|
+
const status = sub === 'approve' ? 'approved' : 'blocked';
|
|
394
|
+
const next = { ...sello, status, lenses, reason: str('reason') };
|
|
395
|
+
S.writeSello(root, next);
|
|
396
|
+
S.appendLog(root, {
|
|
397
|
+
at: new Date().toISOString(), status, base: sello.base, risk: sello.risk?.tier,
|
|
398
|
+
files: Object.keys(sello.files).length, lenses, digest: S.candidateDigest(sello),
|
|
399
|
+
partialLenses: lenses.length < required || undefined, reason: str('reason'),
|
|
400
|
+
});
|
|
401
|
+
say(sub === 'approve'
|
|
402
|
+
? `✅ sealed with ${lenses.length}/${required} lens(es)${lenses.length ? `: ${lenses.join(', ')}` : ''}.`
|
|
403
|
+
: '⛔ blocked — fix and re-review.');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
case 'budget': {
|
|
407
|
+
const sello = S.readSello(root);
|
|
408
|
+
if (sello.missing || sello.corrupt) { say(S.MESSAGES.notFrozen()); process.exitCode = 1; return; }
|
|
409
|
+
const lines = Number(flag('lines'));
|
|
410
|
+
if (!Number.isFinite(lines) || lines <= 0) { say('Use: rsc sello budget --lines <N>'); process.exitCode = 1; return; }
|
|
411
|
+
S.writeSello(root, { ...sello, budget: { lines, declaredAt: new Date().toISOString() } });
|
|
412
|
+
say(`📏 fix budget declared: ${lines} line(s). budget-check will hold the fix to it.`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
case 'budget-check': {
|
|
416
|
+
const sello = S.readSello(root);
|
|
417
|
+
if (sello.missing || sello.corrupt || !sello.budget) { say('sello: no declared budget. Recover: run `rsc sello budget --lines <N>` BEFORE fixing.'); process.exitCode = 1; return; }
|
|
418
|
+
const current = S.computeCandidate(root, S.readConfig(root));
|
|
419
|
+
const spent = S.budgetSpent(sello, current || { numstat: {} });
|
|
420
|
+
if (spent <= sello.budget.lines) { say(`✅ within budget: ~${spent}/${sello.budget.lines} line(s).`); return; }
|
|
421
|
+
const justify = str('justify');
|
|
422
|
+
if (typeof justify === 'string' && justify.trim()) {
|
|
423
|
+
S.writeSello(root, { ...sello, budget: { ...sello.budget, exceeded: spent, justification: justify } });
|
|
424
|
+
S.appendFindings(root, [`budget exceeded (~${spent}/${sello.budget.lines}): ${justify}`]);
|
|
425
|
+
say(`⚠️ over budget (~${spent}/${sello.budget.lines}) — justification recorded.`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
say(S.MESSAGES.overBudget(spent, sello.budget.lines));
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
case 'check': {
|
|
433
|
+
const verdict = S.checkSello(root);
|
|
434
|
+
if (verdict.warning) say(`⚠️ ${verdict.warning}`);
|
|
435
|
+
if (verdict.ok) { say(`✅ sello check: ${verdict.code}`); return; }
|
|
436
|
+
say(verdict.message);
|
|
437
|
+
process.exitCode = 1;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
case 'report': {
|
|
441
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
442
|
+
say(existsSync(paths.findings) ? readFileSync(paths.findings, 'utf8') : '(no non-blocking findings recorded)');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
case 'status': {
|
|
446
|
+
const { existsSync } = await import('node:fs');
|
|
447
|
+
const { join } = await import('node:path');
|
|
448
|
+
const enabled = S.isEnabled(root);
|
|
449
|
+
const verdict = S.checkSello(root);
|
|
450
|
+
const sello = S.readSello(root);
|
|
451
|
+
const cfg = S.readConfig(root);
|
|
452
|
+
let lowered = [];
|
|
453
|
+
let configError;
|
|
454
|
+
try { ({ lowered } = S.validateRiskConfig(cfg || {})); } catch (e) { configError = e.message; }
|
|
455
|
+
say(JSON.stringify({
|
|
456
|
+
enabled,
|
|
457
|
+
check: verdict.code,
|
|
458
|
+
// An inert gate must never look armed: name every reason it is standing down.
|
|
459
|
+
inert: enabled && ['no-trunk', 'disabled'].includes(verdict.code) ? verdict.code : undefined,
|
|
460
|
+
shipGuardOptOut: existsSync(join(root, '.rsc', '.no-ship-guard')) || undefined,
|
|
461
|
+
warning: verdict.warning,
|
|
462
|
+
configError,
|
|
463
|
+
loweredClasses: lowered.length ? lowered : undefined,
|
|
464
|
+
status: sello.missing ? 'none' : sello.corrupt ? 'corrupt' : sello.status,
|
|
465
|
+
risk: sello.risk?.tier,
|
|
466
|
+
lenses: sello.lenses,
|
|
467
|
+
lensesRequired: sello.required,
|
|
468
|
+
budget: sello.budget,
|
|
469
|
+
nonBlockingFindings: S.countFindings(root),
|
|
470
|
+
}, null, 2));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
default:
|
|
474
|
+
say('Use: npx @ericrisco/rsc sello on|off|status|freeze|approve --lenses a,b [--accept-partial-lenses]|block --reason "…"|budget --lines N|budget-check [--justify "…"]|check|report');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
330
478
|
case 'uninstall': {
|
|
331
479
|
const dry = argv.includes('--dry-run');
|
|
332
480
|
// `uninstall --all` is an alias for a full purge.
|
|
@@ -339,7 +487,7 @@ async function main() {
|
|
|
339
487
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
340
488
|
default:
|
|
341
489
|
say(`rsc: unknown command '${cmd}'.`);
|
|
342
|
-
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | sync | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
|
|
490
|
+
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
|
|
343
491
|
}
|
|
344
492
|
}
|
|
345
493
|
|
|
@@ -19,8 +19,9 @@ Hugging Face is three surfaces, and you should always know which one you are on:
|
|
|
19
19
|
|
|
20
20
|
The whole skill is choosing the right surface for the job and proving it works: a 200 router
|
|
21
21
|
response, a live endpoint URL, a pushed repo commit. If the model is open and the workflow
|
|
22
|
-
lives on huggingface.co, you are in the right place.
|
|
23
|
-
|
|
22
|
+
lives on huggingface.co, you are in the right place. Operating the GPU box yourself is
|
|
23
|
+
[`../ollama/SKILL.md`](../ollama/SKILL.md) (your machine) or [`../runpod/SKILL.md`](../runpod/SKILL.md)
|
|
24
|
+
(a rented box); training weights is [`../finetuning/SKILL.md`](../finetuning/SKILL.md).
|
|
24
25
|
|
|
25
26
|
## Decision: how should I run this model?
|
|
26
27
|
|
package/skills/review/SKILL.md
CHANGED
|
@@ -102,6 +102,51 @@ End every review you give with one of three, and nothing mushy in between:
|
|
|
102
102
|
- **`APPROVE WITH NITS`** — mergeable; the nits are the author's call.
|
|
103
103
|
- **`CHANGES REQUESTED`** — one or more blockers/should-fix. List exactly what unblocks it.
|
|
104
104
|
|
|
105
|
+
### The sello — when this project opted in
|
|
106
|
+
|
|
107
|
+
If `.rsc/sello-config.json` has `enabled: true`, the review's verdict is **sealed to the exact
|
|
108
|
+
bytes reviewed**, and the ship gate refuses commit/push/PR on anything else.
|
|
109
|
+
|
|
110
|
+
**What the sello does and does not prove.** It binds bytes, not intent: it guarantees *what ships
|
|
111
|
+
is what was reviewed*, never *the review was good*. You are the one calling `sello approve`, so it
|
|
112
|
+
is self-attested — drift protection between review and delivery, not tamper-evidence. Sealing
|
|
113
|
+
without actually running the lenses produces a valid sello and a worthless one.
|
|
114
|
+
|
|
115
|
+
Every state transition is deterministic CLI, never tokens:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
1. npx @ericrisco/rsc sello freeze → hashes the candidate, prints risk tier + lens count
|
|
119
|
+
2. Run the lenses (below), filter findings, decide
|
|
120
|
+
3a. approved → npx @ericrisco/rsc sello approve --lenses correctness,security,tests
|
|
121
|
+
3b. blocked → npx @ericrisco/rsc sello block --reason "<the blocking finding>"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`approve` refuses to seal with fewer lenses than the tier requires — pass them all, or accept the
|
|
125
|
+
gap deliberately with `--accept-partial-lenses` (it is recorded). Every approval also appends to
|
|
126
|
+
`.rsc/sello-log.jsonl`, so what shipped under which verdict survives the next freeze.
|
|
127
|
+
|
|
128
|
+
**Lenses by risk tier** — tier 0 never reaches you (the gate passes docs/copy silently);
|
|
129
|
+
tier 1 → run the single most relevant pass from the table above yourself; tier 2 → dispatch
|
|
130
|
+
**three parallel fresh-context subagents** (correctness · security · tests-as-evidence), each
|
|
131
|
+
given only the diff and told to *refute* readiness, not confirm it. Fresh context is the point:
|
|
132
|
+
a reviewer who inherits the implementer's context inherits its blind spots.
|
|
133
|
+
|
|
134
|
+
**A finding blocks only if it survives all three filters** — no exceptions, and eagerness to
|
|
135
|
+
find something is not evidence:
|
|
136
|
+
|
|
137
|
+
1. **Causal** — introduced by *this* change. Pre-existing defects → note with
|
|
138
|
+
`--note "<finding>"` (they land in `.rsc/sello-findings.md`, surfaced by `doctor`) and
|
|
139
|
+
suggest an issue; they never block this delivery.
|
|
140
|
+
2. **Severity** — only `blocker` blocks. `should-fix`/`nit` → `--note`.
|
|
141
|
+
3. **Evidence** — a `repro` or a concrete failure scenario. A suspicion without one is a
|
|
142
|
+
`[question]`, and questions don't block.
|
|
143
|
+
|
|
144
|
+
**Fixing a blocker is budgeted, one attempt.** Before touching anything: estimate the fix and
|
|
145
|
+
declare it — `npx @ericrisco/rsc sello budget --lines <N>`. After the fix:
|
|
146
|
+
`sello budget-check` (over budget → justify with `--justify "…"` or shrink; an unexplained
|
|
147
|
+
overrun is how over-engineering enters disguised as a fix). Then `sello freeze` + re-review
|
|
148
|
+
**only the divergence**, and approve. Still broken after one attempt → stop, hand it to the human.
|
|
149
|
+
|
|
105
150
|
---
|
|
106
151
|
|
|
107
152
|
## RECEIVING a review
|
package/skills/ship/SKILL.md
CHANGED
|
@@ -72,6 +72,15 @@ the command), and can be disabled per project with `.rsc/.no-ship-guard`. It gua
|
|
|
72
72
|
commit → push step; opening the PR is still this skill's job (and its hard rule). If the guard
|
|
73
73
|
blocks you, do not work around it — run ship.
|
|
74
74
|
|
|
75
|
+
The same guard also enforces the **sello** where the project opted in (`.rsc/sello-config.json`
|
|
76
|
+
with `enabled: true`): commit, push and PR are denied unless the change's exact bytes match the
|
|
77
|
+
sealed, approved review — one byte of drift, a moved base, or a missing review on a risk>0 change
|
|
78
|
+
all block, and every denial names its way out (re-run `review`, or `npx @ericrisco/rsc sello off`).
|
|
79
|
+
Risk-0 changes (docs/copy) always pass silently. Off by default; the flow lives in the `review`
|
|
80
|
+
skill. Note `.rsc/.no-ship-guard` opts out of the branch-hygiene rules above but **not** of the
|
|
81
|
+
sello, which has its own switch. The sello binds bytes, not intent — it proves what ships is what
|
|
82
|
+
was reviewed, never that the review was any good.
|
|
83
|
+
|
|
75
84
|
## The three landing options — always present exactly three
|
|
76
85
|
|
|
77
86
|
This mirrors the harness "siempre 3 opciones" pattern. Gather the one fact that changes the answer (does this repo use PRs / require review on `main`?), then present **exactly three** with an honest recommendation matched to the workflow and the accompaniment level.
|
package/targets/claude.js
CHANGED
|
@@ -90,6 +90,9 @@ export function wireHook(paths) {
|
|
|
90
90
|
// (non-rsc) PreToolUse hooks are preserved.
|
|
91
91
|
const sgDest = join(paths.projectRoot, '.rsc', 'ship-guard.mjs');
|
|
92
92
|
copyFileSync(join(HERE, 'ship-guard.mjs'), sgDest);
|
|
93
|
+
// sello.mjs is ship-guard's sibling import (hooks are materialized file-by-file,
|
|
94
|
+
// so the deterministic sello core must land next to the guard that loads it).
|
|
95
|
+
copyFileSync(join(HERE, 'sello.mjs'), join(paths.projectRoot, '.rsc', 'sello.mjs'));
|
|
93
96
|
const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
|
|
94
97
|
settings.hooks.PreToolUse ||= [];
|
|
95
98
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// sello.mjs — the deterministic core of the "sello" (review receipt) system.
|
|
2
|
+
//
|
|
3
|
+
// A sello binds a review verdict to the EXACT bytes that were reviewed. The ship
|
|
4
|
+
// gate (ship-guard.mjs) refuses to commit/push/PR anything whose bytes differ from
|
|
5
|
+
// what was sealed. Opt-in per project via .rsc/sello-config.json — absent or
|
|
6
|
+
// disabled, nothing here runs and the harness behaves exactly as before.
|
|
7
|
+
//
|
|
8
|
+
// TRUST MODEL — read this before believing a sello. It binds BYTES, not intent:
|
|
9
|
+
// it proves "what ships is what was reviewed", never "the review was any good".
|
|
10
|
+
// `sello approve` is an unauthenticated local command and the reviewing agent is
|
|
11
|
+
// the one that calls it, so the sello is SELF-ATTESTED: it is drift protection
|
|
12
|
+
// between review and delivery, not tamper-evidence against the agent or the user.
|
|
13
|
+
// Anyone who can write .rsc/ can seal anything — which is why `sello off` is a
|
|
14
|
+
// documented one-liner rather than something to defeat.
|
|
15
|
+
//
|
|
16
|
+
// Design rules (constitution):
|
|
17
|
+
// - P1: everything in this file is an algorithm — no tokens, no judgment.
|
|
18
|
+
// - P2: every gate has a test (tests/sello.test.js), including the risk table itself.
|
|
19
|
+
// - P4: the sello is the artifact that makes an approval binding to a candidate.
|
|
20
|
+
// - P6: every deny message carries its own `Recover:` line, asserted by test.
|
|
21
|
+
// - P7: risk-0 changes (docs/copy) pass in complete silence.
|
|
22
|
+
//
|
|
23
|
+
// Materialized to .rsc/sello.mjs as a sibling of ship-guard.mjs (hooks are copied
|
|
24
|
+
// file-by-file, so imports must be sibling-relative — same pattern as hook-once.mjs).
|
|
25
|
+
|
|
26
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { join, dirname } from 'node:path';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------- paths & config
|
|
32
|
+
|
|
33
|
+
// The sello's own state can never be part of the candidate it seals: writing the
|
|
34
|
+
// sello would mutate the change being sealed, so `freeze` → `approve` could never
|
|
35
|
+
// converge (a permanent, unrecoverable delivery lockout). Excluded whether the
|
|
36
|
+
// project tracks .rsc/ or not.
|
|
37
|
+
export const SELLO_STATE_PATHS = [
|
|
38
|
+
'.rsc/sello.json', '.rsc/sello-config.json', '.rsc/sello-findings.md', '.rsc/sello-log.jsonl',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export function selloPaths(root) {
|
|
42
|
+
return {
|
|
43
|
+
config: join(root, '.rsc', 'sello-config.json'),
|
|
44
|
+
state: join(root, '.rsc', 'sello.json'),
|
|
45
|
+
findings: join(root, '.rsc', 'sello-findings.md'),
|
|
46
|
+
log: join(root, '.rsc', 'sello-log.jsonl'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readConfig(root) {
|
|
51
|
+
try {
|
|
52
|
+
const cfg = JSON.parse(readFileSync(selloPaths(root).config, 'utf8'));
|
|
53
|
+
return cfg && typeof cfg === 'object' ? cfg : null;
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isEnabled(root) {
|
|
58
|
+
const cfg = readConfig(root);
|
|
59
|
+
return !!(cfg && cfg.enabled === true);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The CLI can run from any subdirectory; the guard always runs from the project
|
|
63
|
+
// root. Both must compute the same candidate, so both resolve to the git toplevel.
|
|
64
|
+
export function resolveRoot(root) {
|
|
65
|
+
const r = spawnSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
66
|
+
return r.status === 0 ? (r.stdout || '').trim() || root : root;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- risk table
|
|
70
|
+
//
|
|
71
|
+
// Risk is classified by WHAT a path touches, never by how many lines changed.
|
|
72
|
+
// Tiers: 0 = nulo (docs/copy — silent), 1 = normal (one lens), 2 = alto (full panel).
|
|
73
|
+
// A path is scored by the HIGHEST tier of every row it matches — first-match-wins
|
|
74
|
+
// would let the docs row shadow a floor class (`credentials.txt`, `.claude/CLAUDE.md`)
|
|
75
|
+
// and hand a silent pass to exactly what the floor exists to guard.
|
|
76
|
+
|
|
77
|
+
export const RISK_TABLE = [
|
|
78
|
+
{ class: 'docs', tier: 0, pattern: '(^|/)(docs|doc)/|\\.(md|mdx|markdown|txt|rst)$|(^|/)LICENSE$|(^|/)CHANGELOG' },
|
|
79
|
+
{ class: 'secrets', tier: 2, pattern: '(^|/)\\.env(\\.|$)|(^|/)(secrets?|credentials?)(/|\\.|-|$)|\\.(pem|key|p12|pfx)$|(^|/)[^/]*(secret|credential)[^/]*$' },
|
|
80
|
+
{ class: 'auth', tier: 2, pattern: '(^|/)(auth|authn|authz|login|session|oauth|sso)(/|\\.|-)' },
|
|
81
|
+
{ class: 'ci', tier: 2, pattern: '(^|/)\\.github/workflows/|(^|/)\\.gitlab-ci\\.yml$|(^|/)Jenkinsfile$' },
|
|
82
|
+
{ class: 'migrations', tier: 2, pattern: '(^|/)migrations?/' },
|
|
83
|
+
{ class: 'billing', tier: 2, pattern: '(^|/)(billing|payments?|checkout|stripe)(/|\\.|-)' },
|
|
84
|
+
{ class: 'harness', tier: 2, pattern: '(^|/)\\.rsc/|(^|/)\\.claude/|(^|/)CLAUDE\\.md$' },
|
|
85
|
+
];
|
|
86
|
+
export const FLOOR_CLASSES = ['secrets', 'harness'];
|
|
87
|
+
export const DEFAULT_TIER = 1;
|
|
88
|
+
// User-supplied patterns are compiled inside a PreToolUse hook, so a catastrophic
|
|
89
|
+
// one would hang the user's own delivery. Bounded, not sandboxed — the same file
|
|
90
|
+
// holds the kill switch, so this is a foot-gun guard, not a security boundary.
|
|
91
|
+
export const MAX_PATTERN_LEN = 200;
|
|
92
|
+
export const MAX_RAISE_RULES = 64;
|
|
93
|
+
|
|
94
|
+
// A project override may RAISE freely and may LOWER only explicitly (naming a known
|
|
95
|
+
// class), never below tier 1, and never a floor class. Throws with a recoverable
|
|
96
|
+
// message; callers fall back to the DEFAULT table, never to a flat tier.
|
|
97
|
+
export function validateRiskConfig(cfg) {
|
|
98
|
+
const risk = cfg?.risk;
|
|
99
|
+
if (!risk) return { table: RISK_TABLE, lowered: [] };
|
|
100
|
+
const lowered = [];
|
|
101
|
+
const extra = [];
|
|
102
|
+
const raise = risk.raise || [];
|
|
103
|
+
if (raise.length > MAX_RAISE_RULES) {
|
|
104
|
+
throw new Error(`sello: too many "raise" rules (${raise.length} > ${MAX_RAISE_RULES}). ${MESSAGES.badRiskConfig}`);
|
|
105
|
+
}
|
|
106
|
+
for (const r of raise) {
|
|
107
|
+
if (!r?.pattern || typeof r.pattern !== 'string') {
|
|
108
|
+
throw new Error(`sello: a "raise" entry has no pattern. ${MESSAGES.badRiskConfig}`);
|
|
109
|
+
}
|
|
110
|
+
if (r.pattern.length > MAX_PATTERN_LEN) {
|
|
111
|
+
throw new Error(`sello: pattern longer than ${MAX_PATTERN_LEN} chars. ${MESSAGES.badRiskConfig}`);
|
|
112
|
+
}
|
|
113
|
+
try { new RegExp(r.pattern); }
|
|
114
|
+
catch (e) { throw new Error(`sello: uncompilable pattern ${JSON.stringify(r.pattern)} (${e.message}). ${MESSAGES.badRiskConfig}`); }
|
|
115
|
+
extra.push({ class: r.class || 'custom', tier: r.tier === 1 ? 1 : 2, pattern: r.pattern });
|
|
116
|
+
}
|
|
117
|
+
for (const l of risk.lower || []) {
|
|
118
|
+
const cls = typeof l === 'string' ? l : l?.class;
|
|
119
|
+
if (FLOOR_CLASSES.includes(cls)) {
|
|
120
|
+
throw new Error(`sello: the "${cls}" class can never be lowered — secrets and the harness's own configuration keep their risk tier under every configuration. ${MESSAGES.badRiskConfig}`);
|
|
121
|
+
}
|
|
122
|
+
if (!RISK_TABLE.some((row) => row.class === cls)) {
|
|
123
|
+
throw new Error(`sello: cannot lower unknown class "${cls}". ${MESSAGES.badRiskConfig}`);
|
|
124
|
+
}
|
|
125
|
+
lowered.push(cls);
|
|
126
|
+
}
|
|
127
|
+
const table = [
|
|
128
|
+
...extra,
|
|
129
|
+
...RISK_TABLE.map((row) => (lowered.includes(row.class) ? { ...row, tier: 1 } : row)),
|
|
130
|
+
];
|
|
131
|
+
return { table, lowered };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// → {tier, classes: {path: class}, lowered, configError} — a path scores the MAX
|
|
135
|
+
// tier of every row it matches; unmatched paths land on DEFAULT_TIER.
|
|
136
|
+
export function classifyRisk(paths, cfg = null) {
|
|
137
|
+
let table = RISK_TABLE;
|
|
138
|
+
let lowered = [];
|
|
139
|
+
let configError = null;
|
|
140
|
+
try { ({ table, lowered } = validateRiskConfig(cfg || {})); }
|
|
141
|
+
catch (e) { configError = e.message; table = RISK_TABLE; lowered = []; }
|
|
142
|
+
|
|
143
|
+
const compiled = table.map((r) => ({ ...r, re: new RegExp(r.pattern) }));
|
|
144
|
+
const classes = {};
|
|
145
|
+
let tier = 0;
|
|
146
|
+
for (const p of paths) {
|
|
147
|
+
let best = -1;
|
|
148
|
+
let cls = 'default';
|
|
149
|
+
for (const row of compiled) {
|
|
150
|
+
if (row.re.test(p) && row.tier > best) { best = row.tier; cls = row.class; }
|
|
151
|
+
}
|
|
152
|
+
if (best < 0) { best = DEFAULT_TIER; cls = 'default'; }
|
|
153
|
+
classes[p] = cls;
|
|
154
|
+
if (best > tier) tier = best;
|
|
155
|
+
}
|
|
156
|
+
return { tier, classes, lowered, configError };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// How many lenses a tier requires (capped by an explicit config knob, floor 1).
|
|
160
|
+
export function lensesRequired(tier, cfg = null) {
|
|
161
|
+
const base = tier === 0 ? 0 : tier === 1 ? 1 : 3;
|
|
162
|
+
const cap = Number(cfg?.lenses);
|
|
163
|
+
if (base > 0 && Number.isFinite(cap) && cap >= 1) return Math.min(base, Math.floor(cap));
|
|
164
|
+
return base;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------- candidate
|
|
168
|
+
|
|
169
|
+
// core.quotePath makes git escape non-ASCII paths ("caf\303\251.js"); the escaped
|
|
170
|
+
// name matches no file on disk, so the file would hash as deleted and any later
|
|
171
|
+
// mutation of it would sail through the gate. -z gives raw NUL-separated paths.
|
|
172
|
+
function git(root, ...args) {
|
|
173
|
+
const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], {
|
|
174
|
+
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
175
|
+
});
|
|
176
|
+
return r.status === 0 ? (r.stdout || '') : null;
|
|
177
|
+
}
|
|
178
|
+
const nulList = (out) => (out || '').split('\0').filter(Boolean);
|
|
179
|
+
|
|
180
|
+
export function trunkRef(root, cfg = null) {
|
|
181
|
+
const configured = typeof cfg?.trunk === 'string' ? [cfg.trunk, `origin/${cfg.trunk}`] : [];
|
|
182
|
+
for (const ref of [...configured, 'origin/main', 'origin/master', 'main', 'master']) {
|
|
183
|
+
if (git(root, 'rev-parse', '--verify', '--quiet', ref) !== null) return ref;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The candidate is the change vs the trunk merge-base: every path that differs
|
|
189
|
+
// between that base and the working tree (committed on the branch, staged, unstaged
|
|
190
|
+
// or untracked), each hashed byte-for-byte. Deleted paths hash to "D". Symlinks hash
|
|
191
|
+
// their target string, never the pointed-at bytes (an out-of-repo link must not be
|
|
192
|
+
// read, and a link to a FIFO would hang the hook).
|
|
193
|
+
export function computeCandidate(rawRoot, cfg = null) {
|
|
194
|
+
const root = resolveRoot(rawRoot);
|
|
195
|
+
const trunk = trunkRef(root, cfg);
|
|
196
|
+
if (!trunk) return null;
|
|
197
|
+
const head = git(root, 'rev-parse', 'HEAD');
|
|
198
|
+
const base = head ? (git(root, 'merge-base', 'HEAD', trunk) || '').trim() : null;
|
|
199
|
+
if (!base) return null;
|
|
200
|
+
|
|
201
|
+
const names = new Set();
|
|
202
|
+
for (const n of nulList(git(root, 'diff', '--name-only', '-z', base))) names.add(n);
|
|
203
|
+
for (const n of nulList(git(root, 'ls-files', '--others', '--exclude-standard', '-z'))) names.add(n);
|
|
204
|
+
for (const p of SELLO_STATE_PATHS) names.delete(p);
|
|
205
|
+
|
|
206
|
+
const files = {};
|
|
207
|
+
const numstat = {};
|
|
208
|
+
for (const name of [...names].sort()) {
|
|
209
|
+
const abs = join(root, name);
|
|
210
|
+
let st = null;
|
|
211
|
+
try { st = lstatSync(abs); } catch { st = null; }
|
|
212
|
+
if (!st) { files[name] = 'D'; continue; }
|
|
213
|
+
if (st.isSymbolicLink()) {
|
|
214
|
+
files[name] = 'L:' + createHash('sha256').update(readlinkSync(abs)).digest('hex');
|
|
215
|
+
} else if (st.isFile()) {
|
|
216
|
+
files[name] = createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
217
|
+
} else {
|
|
218
|
+
files[name] = 'S'; // socket/fifo/device — never read, but its presence counts
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const line of (git(root, 'diff', '--numstat', base) || '').split('\n')) {
|
|
222
|
+
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
223
|
+
if (m) numstat[m[3]] = (m[1] === '-' ? 0 : Number(m[1])) + (m[2] === '-' ? 0 : Number(m[2]));
|
|
224
|
+
}
|
|
225
|
+
// Untracked files never appear in numstat, so a fix hidden in a new file would
|
|
226
|
+
// cost zero budget. Count their lines.
|
|
227
|
+
for (const n of nulList(git(root, 'ls-files', '--others', '--exclude-standard', '-z'))) {
|
|
228
|
+
if (SELLO_STATE_PATHS.includes(n) || numstat[n] !== undefined) continue;
|
|
229
|
+
try {
|
|
230
|
+
const st = lstatSync(join(root, n));
|
|
231
|
+
if (st.isFile()) numstat[n] = readFileSync(join(root, n), 'utf8').split('\n').length;
|
|
232
|
+
} catch { /* unreadable → not counted */ }
|
|
233
|
+
}
|
|
234
|
+
return { base, files, numstat };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function candidateDigest(candidate) {
|
|
238
|
+
const body = Object.keys(candidate.files).sort().map((p) => `${p}:${candidate.files[p]}`).join('\n');
|
|
239
|
+
return createHash('sha256').update(`${candidate.base}\n${body}`).digest('hex');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------- sello state
|
|
243
|
+
|
|
244
|
+
export function readSello(root) {
|
|
245
|
+
const p = selloPaths(resolveRoot(root)).state;
|
|
246
|
+
if (!existsSync(p)) return { missing: true };
|
|
247
|
+
try {
|
|
248
|
+
const s = JSON.parse(readFileSync(p, 'utf8'));
|
|
249
|
+
if (!s || typeof s !== 'object' || !s.files || !s.base) return { corrupt: true };
|
|
250
|
+
return s;
|
|
251
|
+
} catch { return { corrupt: true }; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function writeSello(rawRoot, sello) {
|
|
255
|
+
const p = selloPaths(resolveRoot(rawRoot)).state;
|
|
256
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
257
|
+
writeFileSync(p, JSON.stringify(sello, null, 2) + '\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A durable record of what was delivered under which verdict — the sello file
|
|
261
|
+
// itself is overwritten by the next freeze, so without this the trail dies.
|
|
262
|
+
export function appendLog(rawRoot, entry) {
|
|
263
|
+
const p = selloPaths(resolveRoot(rawRoot)).log;
|
|
264
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
265
|
+
appendFileSync(p, JSON.stringify(entry) + '\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function appendFindings(rawRoot, lines) {
|
|
269
|
+
if (!lines?.length) return;
|
|
270
|
+
const p = selloPaths(resolveRoot(rawRoot)).findings;
|
|
271
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
272
|
+
const header = existsSync(p) ? '' : '# sello — hallazgos no bloqueantes\n\nSe acumulan aquí; `rsc sello report` los muestra. No interrumpen nada.\n';
|
|
273
|
+
const flat = lines.map((l) => `- ${String(l).replace(/\s*\n\s*/g, ' ')}`);
|
|
274
|
+
appendFileSync(p, header + flat.join('\n') + '\n');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function countFindings(rawRoot) {
|
|
278
|
+
const p = selloPaths(resolveRoot(rawRoot)).findings;
|
|
279
|
+
if (!existsSync(p)) return 0;
|
|
280
|
+
return readFileSync(p, 'utf8').split('\n').filter((l) => l.startsWith('- ')).length;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------- delivery detection
|
|
284
|
+
//
|
|
285
|
+
// Does this shell command actually DELIVER? Two failure modes to avoid, both real:
|
|
286
|
+
// denying `grep -rn "git push" docs/` (the words inside a string are not a delivery)
|
|
287
|
+
// and allowing `git -C . commit` (a first-class agent pattern — cwd resets between
|
|
288
|
+
// Bash calls, so agents pass -C routinely).
|
|
289
|
+
// Flags that may sit between the binary and the subcommand: --long, --long=value,
|
|
290
|
+
// and single-letter flags that take a separate value (-C path, -c k=v, -R owner/repo).
|
|
291
|
+
const GIT_OPTS = '(?:-{1,2}[\\w-]+(?:=\\S+)?\\s+|-[A-Za-z]\\s+\\S+\\s+)*';
|
|
292
|
+
const GIT_DELIVERY = new RegExp(`\\bgit\\s+${GIT_OPTS}(?:commit|push)(?![\\w-])`);
|
|
293
|
+
const GH_DELIVERY = new RegExp(`\\bgh\\s+${GIT_OPTS}pr\\s+${GIT_OPTS}(?:create|merge)(?![\\w-])`);
|
|
294
|
+
|
|
295
|
+
export function isDeliveryCommand(command) {
|
|
296
|
+
if (typeof command !== 'string' || !command) return false;
|
|
297
|
+
// Blank out quoted strings so a command that merely MENTIONS the words is not
|
|
298
|
+
// mistaken for one that runs them.
|
|
299
|
+
const bare = command.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
300
|
+
return GIT_DELIVERY.test(bare) || GH_DELIVERY.test(bare);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------- messages (P6)
|
|
304
|
+
//
|
|
305
|
+
// Every message that can DENY an action carries a `Recover:` line naming the exact
|
|
306
|
+
// way out. tests/sello.test.js iterates MESSAGES and asserts this for every entry —
|
|
307
|
+
// a block without its recovery is the one thing this system may never produce.
|
|
308
|
+
|
|
309
|
+
export const MESSAGES = {
|
|
310
|
+
noReview: (tier) =>
|
|
311
|
+
`sello: this change is risk tier ${tier} and has no review. ` +
|
|
312
|
+
`Recover: run the \`review\` skill (it freezes, reviews and approves the sello), or \`npx @ericrisco/rsc sello off\` to turn the sello off for this project.`,
|
|
313
|
+
diverged: (paths) =>
|
|
314
|
+
`sello: the change no longer matches what was reviewed — ${paths.slice(0, 5).join(', ')}${paths.length > 5 ? ` (+${paths.length - 5} more)` : ''} differ from the sealed bytes. ` +
|
|
315
|
+
`Recover: re-run the \`review\` skill on the divergence (only what changed is re-reviewed), or \`npx @ericrisco/rsc sello off\` for this project.`,
|
|
316
|
+
baseMoved: () =>
|
|
317
|
+
`sello: the sello belongs to a different base — the branch was rebased/merged, or this sello was left over from a change that already shipped. Either way nothing has reviewed the current candidate. ` +
|
|
318
|
+
`Recover: re-run the \`review\` skill (only the divergence is re-reviewed), or \`npx @ericrisco/rsc sello off\` for this project.`,
|
|
319
|
+
corrupt: () =>
|
|
320
|
+
`sello: the sello file is unreadable, which counts as "no review" — nothing is ever approved by default. ` +
|
|
321
|
+
`Recover: re-run the \`review\` skill to produce a fresh sello, or \`npx @ericrisco/rsc sello off\` for this project.`,
|
|
322
|
+
blocked: (reason) =>
|
|
323
|
+
`sello: the review left this change BLOCKED${reason ? ` (${reason})` : ''}. ` +
|
|
324
|
+
`Recover: fix the blocking findings and re-run the \`review\` skill, or \`npx @ericrisco/rsc sello off\` for this project.`,
|
|
325
|
+
notFrozen: () =>
|
|
326
|
+
`sello: nothing is frozen yet, so there is nothing to approve. ` +
|
|
327
|
+
`Recover: run \`npx @ericrisco/rsc sello freeze\` first, then review, then approve.`,
|
|
328
|
+
staleFreeze: () =>
|
|
329
|
+
`sello: the change mutated after it was frozen — approving now would seal bytes nobody reviewed. ` +
|
|
330
|
+
`Recover: run \`npx @ericrisco/rsc sello freeze\` again and re-review the divergence.`,
|
|
331
|
+
overBudget: (spent, budget) =>
|
|
332
|
+
`sello: the fix touched ~${spent} line(s) but the declared budget was ${budget} — an unexplained overrun is how over-engineering gets in. ` +
|
|
333
|
+
`Recover: re-run with --justify "<why the budget was not enough>" to record the reason, or shrink the fix.`,
|
|
334
|
+
partialLenses: (given, required) =>
|
|
335
|
+
`sello: risk tier needs ${required} lens(es) but only ${given} were recorded — an incomplete panel must not seal silently. ` +
|
|
336
|
+
`Recover: run the missing lenses and pass them all to --lenses, or accept the gap on purpose with --accept-partial-lenses.`,
|
|
337
|
+
badRiskConfig:
|
|
338
|
+
'Recover: fix .rsc/sello-config.json — "raise" entries need a valid pattern under 200 chars; "lower" only accepts known classes and never "secrets" or "harness". `npx @ericrisco/rsc sello off` always works, even with a broken config.',
|
|
339
|
+
riskUnknown: () =>
|
|
340
|
+
`sello: the risk of this change could not be evaluated, so it is treated as risk tier 1 (never silently as 0). ` +
|
|
341
|
+
`Recover: check that this is a git repository with a reachable trunk (origin/main, main, or "trunk" in .rsc/sello-config.json), then retry.`,
|
|
342
|
+
noTrunk: () =>
|
|
343
|
+
`sello: no trunk branch found (looked for origin/main, origin/master, main, master), so the gate cannot compute what changed and is standing down. ` +
|
|
344
|
+
`Recover: set {"trunk": "<your branch>"} in .rsc/sello-config.json so the sello knows what to diff against.`,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------- the check
|
|
348
|
+
//
|
|
349
|
+
// The single verdict function the guard, the CLI and the tests all share.
|
|
350
|
+
// → {ok: true, code} | {ok: false, code, message}
|
|
351
|
+
|
|
352
|
+
export function checkSello(rawRoot) {
|
|
353
|
+
const root = resolveRoot(rawRoot);
|
|
354
|
+
const cfg = readConfig(root);
|
|
355
|
+
if (!cfg || cfg.enabled !== true) return { ok: true, code: 'disabled' };
|
|
356
|
+
|
|
357
|
+
const candidate = computeCandidate(root, cfg);
|
|
358
|
+
if (!candidate) {
|
|
359
|
+
// Fail open, but never silently: doctor/status surface this so an inert gate
|
|
360
|
+
// in a develop-based repo cannot masquerade as an armed one.
|
|
361
|
+
return { ok: true, code: 'no-trunk', warning: MESSAGES.noTrunk() };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const paths = Object.keys(candidate.files);
|
|
365
|
+
if (paths.length === 0) return { ok: true, code: 'clean' };
|
|
366
|
+
|
|
367
|
+
const risk = classifyRisk(paths, cfg);
|
|
368
|
+
// P7 first, before any sello state is consulted: a docs-only change must never
|
|
369
|
+
// be blocked by a leftover sello from some other change.
|
|
370
|
+
if (risk.tier === 0) return { ok: true, code: 'risk0' };
|
|
371
|
+
|
|
372
|
+
const sello = readSello(root);
|
|
373
|
+
if (sello.missing) return { ok: false, code: 'no-review', message: MESSAGES.noReview(risk.tier) };
|
|
374
|
+
if (sello.corrupt) return { ok: false, code: 'corrupt', message: MESSAGES.corrupt() };
|
|
375
|
+
if (sello.status === 'blocked') return { ok: false, code: 'blocked', message: MESSAGES.blocked(sello.reason) };
|
|
376
|
+
if (sello.status !== 'approved') return { ok: false, code: 'no-review', message: MESSAGES.noReview(risk.tier) };
|
|
377
|
+
if (sello.base !== candidate.base) return { ok: false, code: 'base-moved', message: MESSAGES.baseMoved() };
|
|
378
|
+
|
|
379
|
+
const diverged = [];
|
|
380
|
+
const sealed = sello.files || {};
|
|
381
|
+
for (const p of new Set([...Object.keys(sealed), ...paths])) {
|
|
382
|
+
if (sealed[p] !== candidate.files[p]) diverged.push(p);
|
|
383
|
+
}
|
|
384
|
+
if (diverged.length) return { ok: false, code: 'diverged', message: MESSAGES.diverged(diverged) };
|
|
385
|
+
return { ok: true, code: 'sealed' };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Fix-budget accounting: |numstat delta| vs the frozen totals (untracked files
|
|
389
|
+
// included — see computeCandidate). Approximate by construction: a same-size
|
|
390
|
+
// rewrite costs 0 and a moved hunk double-counts. It bounds sprawl, not edits.
|
|
391
|
+
export function budgetSpent(frozen, current) {
|
|
392
|
+
let spent = 0;
|
|
393
|
+
const all = new Set([...Object.keys(frozen.numstat || {}), ...Object.keys(current.numstat || {})]);
|
|
394
|
+
for (const p of all) {
|
|
395
|
+
spent += Math.abs((current.numstat?.[p] || 0) - (frozen.numstat?.[p] || 0));
|
|
396
|
+
}
|
|
397
|
+
return spent;
|
|
398
|
+
}
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -31,8 +31,9 @@ function deny(reason) {
|
|
|
31
31
|
process.exit(0);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
34
|
+
// "Not a git repo" means: nothing to enforce. (The .no-ship-guard opt-out is checked
|
|
35
|
+
// AFTER the sello below — it opts out of THIS guard's branch-hygiene rules, not of a
|
|
36
|
+
// different feature that happens to share the hook. The sello has its own switch.)
|
|
36
37
|
if (!existsSync(join(root, '.git'))) allow();
|
|
37
38
|
|
|
38
39
|
// Read the tool call. Only Bash commands can move branches.
|
|
@@ -42,6 +43,24 @@ if ((input.tool_name || input.toolName) !== 'Bash') allow();
|
|
|
42
43
|
const command = input.tool_input?.command || input.toolInput?.command || '';
|
|
43
44
|
if (typeof command !== 'string' || !command) allow();
|
|
44
45
|
|
|
46
|
+
// ---- sello gate (opt-in) ----------------------------------------------------
|
|
47
|
+
// When the sello is ON for this project, delivery commands (commit, push, PR)
|
|
48
|
+
// must match the sealed bytes. OFF (the default) → this block is a no-op and the
|
|
49
|
+
// guard behaves exactly as before. checkSello itself fails open on environment
|
|
50
|
+
// problems and denies only on real divergence/no-review/corruption (spec).
|
|
51
|
+
// Deliberately BEFORE the .no-ship-guard opt-out: that file disables this guard's
|
|
52
|
+
// branch-hygiene rules; the sello is a separate opt-in with its own `sello off`.
|
|
53
|
+
try {
|
|
54
|
+
const { checkSello, isEnabled, isDeliveryCommand } = await import(new URL('./sello.mjs', import.meta.url));
|
|
55
|
+
if (isDeliveryCommand(command) && isEnabled(root)) {
|
|
56
|
+
const verdict = checkSello(root);
|
|
57
|
+
if (!verdict.ok) deny(verdict.message);
|
|
58
|
+
}
|
|
59
|
+
} catch { /* sello lib missing/unloadable → nothing to enforce, fail open */ }
|
|
60
|
+
|
|
61
|
+
// Ship-guard's own opt-out (branch hygiene only — the sello above already ran).
|
|
62
|
+
if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
|
|
63
|
+
|
|
45
64
|
// Does this command try to land on / move to the trunk?
|
|
46
65
|
const TRUNK = /\bgit\s+(?:checkout|switch)\s+(?:-{1,2}\S+\s+)*(?:main|master)\b/;
|
|
47
66
|
const MERGE = /\bgit\s+merge\b/;
|