@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.1",
2
+ "version": "1.0.3",
3
3
  "counts": {
4
4
  "skills": 258
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -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}`);
@@ -62,6 +62,7 @@ function generatedHookFiles({ target, cwd }) {
62
62
  join(cwd, '.rsc', 'danger-guard.mjs'),
63
63
  join(cwd, '.rsc', 'userprompt-gate.mjs'),
64
64
  join(cwd, '.rsc', 'hook-once.mjs'),
65
+ join(cwd, '.rsc', 'sello.mjs'),
65
66
  ];
66
67
  }
67
68
 
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. If you operate the GPU yourself, or the
23
- vendor is not HF, stop — see the boundaries at the bottom.
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
 
@@ -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
@@ -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
+ }
@@ -31,8 +31,9 @@ function deny(reason) {
31
31
  process.exit(0);
32
32
  }
33
33
 
34
- // Opt-out and "not a git repo" both mean: nothing to enforce.
35
- if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
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/;