@ericrisco/rsc 1.0.3 → 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.3",
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.3",
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,7 +6,7 @@ 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';
9
+ import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
10
10
 
11
11
  // The sello's health, surfaced where the user already looks (spec: non-blocking
12
12
  // findings live in the project and are SUMMARIZED here, never nagged about).
@@ -19,6 +19,7 @@ function selloStatus(root) {
19
19
  const verdict = checkSello(root);
20
20
  const out = {
21
21
  enabled: true,
22
+ decidedBy: readEffectiveConfig(root)?.scope,
22
23
  check: verdict.code,
23
24
  status: sello.missing ? 'none' : sello.corrupt ? 'corrupt' : sello.status,
24
25
  nonBlockingFindings: countFindings(root),
@@ -29,7 +30,7 @@ function selloStatus(root) {
29
30
  out.note = 'ship-guard branch-hygiene rules are opted out (.rsc/.no-ship-guard); the sello itself still enforces.';
30
31
  }
31
32
  try {
32
- const { lowered } = validateRiskConfig(readConfig(root) || {});
33
+ const { lowered } = validateRiskConfig(readEffectiveConfig(root) || {});
33
34
  if (lowered.length) out.loweredClasses = lowered;
34
35
  } catch (e) { out.configError = e.message; }
35
36
  return out;
package/scripts/rsc.js CHANGED
@@ -340,7 +340,12 @@ async function main() {
340
340
  switch (sub) {
341
341
  case 'on':
342
342
  case 'off': {
343
- const cfg = S.readConfig(root) || {};
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
344
349
  // `off` never validates: it is the recovery advertised by every deny
345
350
  // message, so a broken config must not be able to lock it away.
346
351
  if (sub === 'on') {
@@ -348,15 +353,22 @@ async function main() {
348
353
  }
349
354
  const { writeFileSync, mkdirSync } = await import('node:fs');
350
355
  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');
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';
353
359
  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.');
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
+ }
356
368
  return;
357
369
  }
358
370
  case 'freeze': {
359
- const cfg = S.readConfig(root);
371
+ const cfg = S.readEffectiveConfig(root);
360
372
  const candidate = S.computeCandidate(root, cfg);
361
373
  if (!candidate) { say(S.MESSAGES.noTrunk()); process.exitCode = 1; return; }
362
374
  const paths2 = Object.keys(candidate.files);
@@ -374,7 +386,7 @@ async function main() {
374
386
  case 'block': {
375
387
  const sello = S.readSello(root);
376
388
  if (sello.missing || sello.corrupt || !sello.status) { say(S.MESSAGES.notFrozen()); process.exitCode = 1; return; }
377
- const cfg = S.readConfig(root);
389
+ const cfg = S.readEffectiveConfig(root);
378
390
  const current = S.computeCandidate(root, cfg);
379
391
  const changed = current && Object.keys({ ...sello.files, ...current.files })
380
392
  .some((p) => sello.files[p] !== current.files[p]);
@@ -415,7 +427,7 @@ async function main() {
415
427
  case 'budget-check': {
416
428
  const sello = S.readSello(root);
417
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; }
418
- const current = S.computeCandidate(root, S.readConfig(root));
430
+ const current = S.computeCandidate(root, S.readEffectiveConfig(root));
419
431
  const spent = S.budgetSpent(sello, current || { numstat: {} });
420
432
  if (spent <= sello.budget.lines) { say(`✅ within budget: ~${spent}/${sello.budget.lines} line(s).`); return; }
421
433
  const justify = str('justify');
@@ -448,12 +460,21 @@ async function main() {
448
460
  const enabled = S.isEnabled(root);
449
461
  const verdict = S.checkSello(root);
450
462
  const sello = S.readSello(root);
451
- const cfg = S.readConfig(root);
463
+ const cfg = S.readEffectiveConfig(root);
452
464
  let lowered = [];
453
465
  let configError;
454
466
  try { ({ lowered } = S.validateRiskConfig(cfg || {})); } catch (e) { configError = e.message; }
467
+ const globalCfg = S.readGlobalConfig();
468
+ const projectCfg = S.readConfig(root);
455
469
  say(JSON.stringify({
456
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
+ },
457
478
  check: verdict.code,
458
479
  // An inert gate must never look armed: name every reason it is standing down.
459
480
  inert: enabled && ['no-trunk', 'disabled'].includes(verdict.code) ? verdict.code : undefined,
@@ -471,7 +492,7 @@ async function main() {
471
492
  return;
472
493
  }
473
494
  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');
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');
475
496
  return;
476
497
  }
477
498
  }
@@ -104,8 +104,9 @@ End every review you give with one of three, and nothing mushy in between:
104
104
 
105
105
  ### The sello — when this project opted in
106
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.
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.
109
110
 
110
111
  **What the sello does and does not prove.** It binds bytes, not intent: it guarantees *what ships
111
112
  is what was reviewed*, never *the review was good*. You are the one calling `sello approve`, so it
@@ -72,8 +72,10 @@ 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
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
77
79
  sealed, approved review — one byte of drift, a moved base, or a missing review on a risk>0 change
78
80
  all block, and every denial names its way out (re-run `review`, or `npx @ericrisco/rsc sello off`).
79
81
  Risk-0 changes (docs/copy) always pass silently. Off by default; the flow lives in the `review`
package/targets/sello.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
27
27
  import { join, dirname } from 'node:path';
28
+ import { homedir } from 'node:os';
28
29
  import { createHash } from 'node:crypto';
29
30
  import { spawnSync } from 'node:child_process';
30
31
 
@@ -47,15 +48,49 @@ export function selloPaths(root) {
47
48
  };
48
49
  }
49
50
 
50
- export function readConfig(root) {
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) {
51
62
  try {
52
- const cfg = JSON.parse(readFileSync(selloPaths(root).config, 'utf8'));
63
+ const cfg = JSON.parse(readFileSync(path, 'utf8'));
53
64
  return cfg && typeof cfg === 'object' ? cfg : null;
54
65
  } catch { return null; }
55
66
  }
56
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
+
57
92
  export function isEnabled(root) {
58
- const cfg = readConfig(root);
93
+ const cfg = readEffectiveConfig(root);
59
94
  return !!(cfg && cfg.enabled === true);
60
95
  }
61
96
 
@@ -351,7 +386,7 @@ export const MESSAGES = {
351
386
 
352
387
  export function checkSello(rawRoot) {
353
388
  const root = resolveRoot(rawRoot);
354
- const cfg = readConfig(root);
389
+ const cfg = readEffectiveConfig(root);
355
390
  if (!cfg || cfg.enabled !== true) return { ok: true, code: 'disabled' };
356
391
 
357
392
  const candidate = computeCandidate(root, cfg);