@ericrisco/rsc 1.0.2 → 1.0.4

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