@ericrisco/rsc 4.0.3 → 4.0.5

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": "4.0.3",
2
+ "version": "4.0.5",
3
3
  "counts": {
4
4
  "skills": 274,
5
5
  "agents": 34,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -122,6 +122,22 @@ export function round(root, task, { now = new Date().toISOString() } = {}) {
122
122
  return { ok: true, round: n, message: `task "${task}": review round ${n} of ${MAX_ROUNDS}` };
123
123
  }
124
124
 
125
+ // Records that a review panel ran on a task: which lenses, distinct. Self-attested, like the sello's
126
+ // approve — it binds the claim to a task, it does not prove the review was good. The ship guard reads
127
+ // it: a tier-2 branch does not leave the machine without a panel of the required size on its spec.
128
+ export function panel(root, task, lenses, { now = new Date().toISOString() } = {}) {
129
+ if (!QUALIFIED_TASK.test(task)) {
130
+ return { ok: false, message: `rsc: qualify the task with its spec slug, e.g. \`review ledger panel <slug>/closing --lenses correctness,security,tests\`.` };
131
+ }
132
+ const list = [...new Set(String(lenses || '').split(',').map((l) => l.trim().toLowerCase()).filter(Boolean))];
133
+ if (!list.length) return { ok: false, message: 'rsc: name the lenses that ran: --lenses correctness,security,tests' };
134
+ // Bound to the commit reviewed: code committed after it is code no panel has seen.
135
+ const r = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true });
136
+ const sha = r.status === 0 ? r.stdout.trim() : null;
137
+ append(root, [{ type: 'panel', task, lenses: list, sha, at: now }]);
138
+ return { ok: true, message: `task "${task}": panel of ${list.length} recorded (${list.join(', ')})${sha ? ` on ${sha.slice(0, 7)}` : ''}` };
139
+ }
140
+
125
141
  export function stats(root) {
126
142
  const ledger = readLedger(root);
127
143
  const findings = ledger.filter((e) => e.type === 'finding');
@@ -6,7 +6,7 @@
6
6
  // Spec: 02-DOCS/wiki/sdd/specs/asdd-v4.md.
7
7
 
8
8
  import { spawnSync } from 'node:child_process';
9
- import { NESTED_WORKTREES, SELLO_STATE_PATHS, classifyRisk, lensesRequired, readEffectiveConfig, trunkRef } from '../../targets/sello.mjs';
9
+ import { NESTED_WORKTREES, SELLO_STATE_PATHS, classifyChange, lensesRequired, readEffectiveConfig, trunkRef } from '../../targets/sello.mjs';
10
10
 
11
11
  function git(root, args) {
12
12
  const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
@@ -31,18 +31,22 @@ export function changedPaths(root, base = null) {
31
31
  .filter((n) => !SELLO_STATE_PATHS.includes(n)));
32
32
  let names = diff(mb);
33
33
  let shown = ref;
34
+ let from = mb;
34
35
  // Working straight on the trunk (FTD on an open main), the merge-base IS HEAD, and a change
35
36
  // already committed would read as no change at all: tier 0, no reviewer, no escalation. With
36
37
  // nothing uncommitted, the last commit is the change being asked about.
37
38
  if (!base && mb === head.trim() && !names.size && git(root, ['rev-parse', '--verify', '--quiet', 'HEAD~1']) !== null) {
38
39
  names = diff('HEAD~1');
39
40
  shown = 'HEAD~1 (on the trunk: the last commit)';
41
+ from = 'HEAD~1';
40
42
  }
41
- return { ok: true, base: shown, paths: [...names].sort() };
43
+ return { ok: true, base: shown, from, paths: [...names].sort() };
42
44
  }
43
45
 
44
- export function assess(paths, cfg = null) {
45
- const { tier, classes, configError } = classifyRisk(paths, cfg);
46
+ // `texts` is what the change adds per path (sello.changeText): content signals raise a path that its
47
+ // name alone would leave at tier 1 — auth code in `src/app.js`.
48
+ export function assess(paths, cfg = null, texts = {}) {
49
+ const { tier, classes, configError, content } = classifyChange(paths, texts, cfg);
46
50
  return {
47
51
  tier,
48
52
  classes: [...new Set(Object.values(classes))].sort(),
@@ -51,6 +55,7 @@ export function assess(paths, cfg = null) {
51
55
  // Tier 2 is ASDD work, whatever lane it started in: the ship guard refuses to push it without a spec.
52
56
  escalate: tier >= 2 ? 'asdd' : null,
53
57
  paths: classes,
58
+ content,
54
59
  ...(configError ? { configError } : {}),
55
60
  };
56
61
  }
@@ -65,6 +70,7 @@ export function render(r) {
65
70
  ...(r.base ? [`base: ${r.base}`] : []),
66
71
  ...(r.escalate ? ['escalate: asdd — tier 2 needs a spec linked to this branch (`branch: <name>` in its frontmatter) that passes `npx @ericrisco/rsc spec gate`; push and PR are refused until it exists. Write it now, before more code.'] : []),
67
72
  ...(r.configError ? [`config ignored: ${r.configError}`] : []),
73
+ ...(r.content || []).slice(0, 10).map((h) => ` content: ${h.class} in ${h.path} ("${h.match}")`),
68
74
  ...top.slice(0, 20).map(([p, c]) => ` ${c.padEnd(10)} ${p}`),
69
75
  ...(top.length > 20 ? [` … ${top.length - 20} more`] : []),
70
76
  ].join('\n');
package/scripts/rsc.js CHANGED
@@ -1262,13 +1262,17 @@ async function main() {
1262
1262
  .map((p) => rel(root, res(process.cwd(), p)).split('\\').join('/'));
1263
1263
  let paths = given;
1264
1264
  let against = null;
1265
+ let from = null;
1265
1266
  if (!given.length) {
1266
1267
  const c = K.changedPaths(root, base || null);
1267
1268
  if (!c.ok) { say(c.message); process.exitCode = 1; return; }
1268
1269
  paths = c.paths;
1269
1270
  against = c.base;
1271
+ from = c.from;
1270
1272
  }
1271
- const r = { ...K.assess(paths, S.readEffectiveConfig(root)), ...(against ? { base: against } : {}) };
1273
+ // Given paths are read whole (a forecast or a single file); a diff contributes its added lines.
1274
+ const texts = S.changeText(root, from, paths);
1275
+ const r = { ...K.assess(paths, S.readEffectiveConfig(root), texts), ...(against ? { base: against } : {}) };
1272
1276
  say(argv.includes('--json') ? JSON.stringify(r, null, 2) : K.render(r));
1273
1277
  return;
1274
1278
  }
@@ -1279,7 +1283,7 @@ async function main() {
1279
1283
  const USE = [
1280
1284
  'Use: npx @ericrisco/rsc review consolidate [report.md|findings.json ...] [--json] [--window <lines>]',
1281
1285
  ' Without files it reads stdin. Each lens report carries its findings in ```json findings blocks.',
1282
- ' npx @ericrisco/rsc review ledger record <findings.json> [--task <id>] | outcome <id> <fixed|false|intentional|irrelevant|parked> | round <slug>/<task> | stats [--json]',
1286
+ ' npx @ericrisco/rsc review ledger record <findings.json> [--task <id>] | outcome <id> <fixed|false|intentional|irrelevant|parked> | round <slug>/<task> | panel <slug>/<task> --lenses a,b,c | stats [--json]',
1283
1287
  ' npx @ericrisco/rsc review escapes [--since <ref>] [--json]',
1284
1288
  ].join('\n');
1285
1289
  if (argv[1] === 'ledger' || argv[1] === 'escapes') {
@@ -1328,6 +1332,13 @@ async function main() {
1328
1332
  if (!r.ok) process.exitCode = 1;
1329
1333
  return;
1330
1334
  }
1335
+ if (sub === 'panel') {
1336
+ if (!a || a.startsWith('--')) return fail(USE);
1337
+ const r = L.panel(root, a, str('lenses'));
1338
+ if (!r.ok) return fail(r.message);
1339
+ say(r.message);
1340
+ return;
1341
+ }
1331
1342
  if (sub === 'stats') {
1332
1343
  const s = L.stats(root);
1333
1344
  say(json ? JSON.stringify(s, null, 2) : L.renderStats(s));
@@ -1532,7 +1543,7 @@ async function main() {
1532
1543
  if (!candidate) { say(S.MESSAGES.noTrunk()); process.exitCode = 1; return; }
1533
1544
  const paths2 = Object.keys(candidate.files);
1534
1545
  if (!paths2.length) { say('sello: nothing to freeze — the working tree matches the trunk base.'); return; }
1535
- const risk = S.classifyRisk(paths2, cfg);
1546
+ const risk = S.classifyCandidate(root, candidate, cfg);
1536
1547
  if (risk.configError) say(`⚠️ ${risk.configError}\n (using the default risk table until it is fixed)`);
1537
1548
  const required = S.lensesRequired(risk.tier, cfg);
1538
1549
  S.writeSello(root, { status: 'frozen', ...candidate, risk, required, lenses: [], createdAt: new Date().toISOString() });
@@ -51,7 +51,7 @@ Every spec answers four questions, and `spec:gate` refuses one dated 2026-11-03
51
51
 
52
52
  ### The risk decides, never the agent
53
53
 
54
- `npx @ericrisco/rsc risk [--base <ref>] [paths…]` classifies a change by **what it touches** (the sello's path table; works with the sello off): tier 0 docs/copy → no reviewer; tier 1 ordinary code → one fresh-context reviewer; tier 2 auth, billing, CI workflows, migrations, secrets, harness config → the three-refuter panel **and human approval**. An agent's approval never counts as a human's.
54
+ `npx @ericrisco/rsc risk [--base <ref>] [paths…]` classifies a change by **what it touches and what it adds** (the sello's path table plus fixed content patterns — password hashing, session cookies, payments, schema changes, secrets — so auth code in `src/app.js` is still auth; works with the sello off): tier 0 docs/copy → no reviewer; tier 1 ordinary code → one fresh-context reviewer; tier 2 auth, billing, CI workflows, migrations, secrets, harness config → the three-refuter panel **and human approval**. An agent's approval never counts as a human's. **Enforced at push:** a tier-2 branch leaves the machine only with a spec linked to it that passes `rsc spec gate` **and** a panel recorded with `rsc review ledger panel <slug>/<task> --lenses correctness,security,tests`.
55
55
 
56
56
  ### The loop converges
57
57
 
@@ -61,7 +61,7 @@ Every spec answers four questions, and `spec:gate` refuses one dated 2026-11-03
61
61
 
62
62
  Tier 2, a contradiction with a recorded decision, a gap that would make the build unsafe, or no convergence in 3 rounds: the task goes to the **decision queue** (`## Decision queue` in `progress/<slug>.md`) with its evidence, and the agent **continues with the independent tasks**. The run never waits at 2 a.m. for an answer.
63
63
 
64
- Every entry carries a kind, one line each:
64
+ Every entry carries a kind, one line each, in exactly this form (no bold, no other bullet):
65
65
 
66
66
  ```markdown
67
67
  - [blocking] Q3 — <what must be decided>, <evidence>, <task>
@@ -102,6 +102,8 @@ Autopilot does not stop the run for questions — it **parks**: real ambiguity,
102
102
  - `implement` parks `blocking` items and continues with the independent tasks.
103
103
  - `ship` lands by the rule below.
104
104
 
105
+ **Never hand the turn back mid-run.** No "¿sigo?" / "shall I continue?": not between phases, not while reviewers run. Background reviewers are waited for (their completion notice, or run them in the foreground); ending the turn to wait ends a headless run. A later "continue" from the person resumes the run; it is **not** approval for a tier-2 merge, which needs an explicit yes to that merge.
106
+
105
107
  **Landing — there is always a legal path.** A remote exists → open a PR; self-merge only when `rsc risk` says tier 0-1, CI is green and no `blocking` entry is open; `review-after` entries go into the PR body. No remote → **park**: leave the branch committed, write the explainer, the open entries and the exact merge command for the human into `progress/<slug>.md`, and stop. Never merge locally around the branch guard: when `rsc main status` says `closed: true`, the guard denies it.
106
108
 
107
109
  Narrate in the `orient` voice: name each stage as it passes and show its artifact, one line each.
@@ -115,7 +115,7 @@ review of the task, this first one included. Round 1 is the task's first review,
115
115
  | --- | --- |
116
116
  | 0 | none — the controls (e) still run |
117
117
  | 1 | one fresh-context reviewer; frozen brief in `references/per-task-review.md` |
118
- | 2 | the three refuters (`refuter-correctness` · `refuter-security` · `refuter-tests`) in parallel, as `../review/SKILL.md` runs them |
118
+ | 2 | the three refuters (`refuter-correctness` · `refuter-security` · `refuter-tests`) in parallel, as `../review/SKILL.md` runs them; then `review ledger panel <slug>/<task> --lenses correctness,security,tests` — the push gate checks it. Wait for every reviewer inside the turn; never end the turn to ask whether to continue |
119
119
 
120
120
  Every reviewer gets the **spec**, the **diff as a file** (`scripts/review-package <BASE> <HEAD>`, BASE
121
121
  = the task's *recorded* base sha, never `HEAD~1`), the **surrounding code** at that sha (not just the
@@ -11,7 +11,7 @@ origin: risco
11
11
 
12
12
  Tests prove the code works, not that it does what was asked. Review answers that other question: **is the diff correct, well-scoped and faithful to the spec — or did it pass for the wrong reasons?** In ASDD (`../asdd/SKILL.md`) it runs in two modes:
13
13
 
14
- - **Per task** — called from `implement`'s loop after each task's self-check. Depth comes from `npx @ericrisco/rsc risk [--base <ref>] [paths…]` (same path table as the sello, works with the sello off): **tier 0** → no review; **tier 1** → one fresh-context reviewer (a subagent that never saw the implementation conversation); **tier 2** → the three-refuter panel below, and the branch will wait for human approval at ship.
14
+ - **Per task** — called from `implement`'s loop after each task's self-check. Depth comes from `npx @ericrisco/rsc risk [--base <ref>] [paths…]` (same path table as the sello, works with the sello off): **tier 0** → no review; **tier 1** → one fresh-context reviewer (a subagent that never saw the implementation conversation); **tier 2** → the three-refuter panel below, recorded after it runs with `npx @ericrisco/rsc review ledger panel <slug>/<task> --lenses correctness,security,tests` (the ship guard refuses to push a tier-2 branch without it — one reviewer is not a panel), and the branch will wait for human approval at ship. The tier reads the diff's content too: auth code in a generic file is tier 2.
15
15
  - **Closing review** — once, before `ship`: the whole branch against the spec. Forty individually-correct tasks can still fail to be the feature together: a criterion nobody owned, two tasks that each assumed the other handled an edge, scope that crept in a slice at a time. Read the spec's acceptance criteria and "must respect" list against `git diff <base>...HEAD`, not the task reviews. Its record — tier, counts, verdict, each finding with its outcome — goes to `02-DOCS/wiki/sdd/reviews/<slug>.md`.
16
16
 
17
17
  This skill owns **two roles that share one discipline**:
@@ -246,6 +246,7 @@ In the `orient` voice: the explainer, the checklist result in one line, the thre
246
246
  | "I'll add `Co-Authored-By: Claude` / a 'Generated with Claude Code' footer to be transparent" | It forges the record. The work is the responsible person's — no AI trailer, ever. Strip it. |
247
247
  | "Review didn't formally approve but it's obviously fine" | No verdict = not ready. Ship runs on APPROVE only. Route back to review. |
248
248
  | "Tier 2, but the three refuters approved — I'll self-merge" | An agent's approval never counts as a human's. Tier 2 waits for human approval. |
249
+ | "Tier 2, one reviewer was enough here" | The push gate wants the panel recorded (`review ledger panel … --lenses correctness,security,tests`). Run the three, record them, then push. |
249
250
  | "The tree has a couple of stray edits, they're harmless" | A dirty tree means the merge is not the reviewed diff. Clean it or stash it first. |
250
251
  | "`main` is protected but I'll just force-merge, I'm sure" | Protected means PR. Don't bypass the gate the repo deliberately set. |
251
252
  | "No remote and the guard denies the merge — I'll merge another way" | The denial is the answer. Park the branch with the merge command for the human. |
@@ -53,6 +53,37 @@ export function linkedSpecs(root, branch, specCompleteness) {
53
53
  });
54
54
  }
55
55
 
56
+ // A panel still covers HEAD when its commit is an ancestor of HEAD and this branch's own commits since
57
+ // changed no code. Not code, by the same rules the gate applies above: documentation, the harness's
58
+ // own state (.rsc/ journals, nested worktrees) and harness configuration. Commits that came from the
59
+ // trunk (a merge of main into the branch) are not this branch's work and are not counted.
60
+ function reviewedStill(root, sha, trunk, sello, cfg) {
61
+ if (!sha) return false;
62
+ if (spawnSync('git', ['-C', root, 'merge-base', '--is-ancestor', sha, 'HEAD'], { windowsHide: true }).status !== 0) return false;
63
+ const since = [...new Set((git(root, 'log', '--no-merges', '--no-renames', '--format=', '--name-only', `${sha}..HEAD`, `^${trunk}`) || '')
64
+ .split('\n').map((l) => l.trim()).filter(Boolean))]
65
+ .filter((n) => !sello.SELLO_STATE_PATHS.includes(n) && !sello.NESTED_WORKTREES.test(n));
66
+ const { classes } = sello.classifyRisk(since, cfg);
67
+ return Object.values(classes).every((c) => c === 'docs' || c === 'harness');
68
+ }
69
+
70
+ // Largest panel recorded in the review ledger for a task of any of these specs (`<slug>/<task>`) that
71
+ // still covers HEAD.
72
+ function panelSize(root, slugs, stillValid = () => true) {
73
+ let text = '';
74
+ try { text = readFileSync(join(root, '.rsc', 'review-ledger.jsonl'), 'utf8'); } catch { return 0; }
75
+ let best = 0;
76
+ for (const line of text.split('\n')) {
77
+ let e;
78
+ try { e = JSON.parse(line); } catch { continue; }
79
+ if (e?.type !== 'panel' || typeof e.task !== 'string' || !Array.isArray(e.lenses)) continue;
80
+ if (!slugs.some((s) => e.task.toLowerCase().replace(/_/g, '-').startsWith(`${s.toLowerCase().replace(/_/g, '-')}/`))) continue;
81
+ if (!stillValid(e.sha)) continue;
82
+ best = Math.max(best, new Set(e.lenses.map((l) => String(l).toLowerCase())).size);
83
+ }
84
+ return best;
85
+ }
86
+
56
87
  /**
57
88
  * null when the command may go ahead; the denial text (with its way out, P6) when it may not.
58
89
  * `lib` carries the sibling modules so tests and the hook load them the same way.
@@ -71,12 +102,26 @@ export function asddGate(root, command, { sello, specCompleteness }) {
71
102
  ...(git(root, 'diff', '--name-only', '--no-renames', '-z', base) || '').split('\0'),
72
103
  ...(git(root, 'ls-files', '--others', '--exclude-standard', '-z') || '').split('\0').filter((n) => !sello.NESTED_WORKTREES.test(n)),
73
104
  ].filter(Boolean))].filter((n) => !sello.SELLO_STATE_PATHS.includes(n));
74
- const { tier, classes } = sello.classifyRisk(paths, cfg);
105
+ const { tier, classes } = sello.classifyChange(paths, sello.changeText(root, base, paths), cfg);
75
106
  if (tier < 2) return null;
76
107
  const tierTwo = [...new Set(Object.values(classes))].filter((c) => c !== 'default' && c !== 'docs');
77
108
  if (tierTwo.length && tierTwo.every((c) => c === 'harness')) return null;
78
109
  const specs = linkedSpecs(root, branch, specCompleteness);
79
- if (specs.some((s) => s.ok)) return null;
110
+ const passing = specs.filter((s) => s.ok);
111
+ if (passing.length) {
112
+ // The spec exists; now the review it requires. A tier-2 change gets the full panel (three lenses
113
+ // by default), recorded against one of its linked specs in the review ledger.
114
+ const required = sello.lensesRequired(2, cfg);
115
+ const slugs = passing.map((s) => s.path.split('/').pop().slice(0, -3));
116
+ const best = panelSize(root, slugs, (sha) => reviewedStill(root, sha, trunk, sello, cfg));
117
+ if (best >= required) return null;
118
+ return `Branch "${branch}" is risk tier 2 and its spec passes, but no review panel of ${required} lens(es) is recorded for it` +
119
+ `${best ? ` (largest still valid: ${best})` : ''}. Tier 2 is reviewed by the full panel, not by one reviewer, and a panel covers ` +
120
+ 'the commit it was recorded on: code committed after it needs the panel again (the record is self-attested, like the sello). ' +
121
+ `Recover: run the ${required} fresh-context refuters (correctness, security, tests) on the branch, then record it — ` +
122
+ `\`npx @ericrisco/rsc review ledger panel ${slugs[0]}/closing --lenses correctness,security,tests\` — and push again. ` +
123
+ 'To turn this gate off for the project: create `.rsc/.no-asdd-gate`.';
124
+ }
80
125
  const why = [...new Set(Object.values(classes).filter((c) => c !== 'default' && c !== 'docs'))].join(', ');
81
126
  const found = specs.length
82
127
  ? `The linked spec does not pass the gate: ${specs.map((s) => `${s.path} (missing: ${[...s.missing, ...s.empty].join(', ') || '—'})`).join('; ')}. `
package/targets/claude.js CHANGED
@@ -45,6 +45,8 @@ export function hookWiringOf(entry) {
45
45
  // plus the legacy cat-form) from settings.json, across all events. User hooks and
46
46
  // other settings are preserved. Empty event arrays (and an empty hooks object) are
47
47
  // pruned so we don't leave noise behind.
48
+ const ATTRIBUTION_MARKER = '.rsc/attribution-default';
49
+
48
50
  export function unwireHook(paths) {
49
51
  // The bootstrap lives in the COMMITTED tree, so leaving it behind does not just orphan a file the
50
52
  // way a stray `.rsc/` entry would — it leaves an executable in something the user pushes. Nothing
@@ -62,7 +64,18 @@ export function unwireHook(paths) {
62
64
  if (!existsSync(file)) return removed;
63
65
  let settings;
64
66
  try { settings = JSON.parse(readFileSync(file, 'utf8')); } catch { return removed; }
65
- if (!settings.hooks) return removed;
67
+ // wireHook's attribution default goes back out with it — only when wireHook wrote it (the marker)
68
+ // and it is still untouched. The same value set by the person before rsc stays theirs.
69
+ const marker = join(paths.projectRoot, ATTRIBUTION_MARKER);
70
+ const a = settings.attribution;
71
+ const ours = existsSync(marker) && a && Object.keys(a).length === 2 && a.commit === '' && a.pr === '';
72
+ if (ours) delete settings.attribution;
73
+ try { rmSync(marker, { force: true }); } catch { /* cleanup never fails the uninstall */ }
74
+ if (!settings.hooks) {
75
+ if (!ours) return removed;
76
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
77
+ return [...removed, file];
78
+ }
66
79
  for (const event of Object.keys(settings.hooks)) {
67
80
  settings.hooks[event] = (settings.hooks[event] || []).filter((e) => {
68
81
  const s = hookWiringOf(e);
@@ -319,6 +332,15 @@ export function wireHook(paths, sourceMd, policy = {}) {
319
332
  }
320
333
  if (!settings.hooks.PreToolUse.length) delete settings.hooks.PreToolUse;
321
334
 
335
+ // Authorship is the responsible person, never an AI trailer (constitution P8). Claude Code's own
336
+ // default adds a Co-Authored-By line to commits and a footer to PRs, and the 4.0.4 run had to rebase
337
+ // them out by hand. Empty strings hide both. A choice already in the file is the person's and stays.
338
+ if (settings.attribution === undefined && settings.includeCoAuthoredBy === undefined) {
339
+ settings.attribution = { commit: '', pr: '' };
340
+ mkdirSync(join(paths.projectRoot, '.rsc'), { recursive: true });
341
+ writeFileSync(join(paths.projectRoot, ATTRIBUTION_MARKER), '');
342
+ }
343
+
322
344
  mkdirSync(dirname(file), { recursive: true });
323
345
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
324
346
 
package/targets/sello.mjs CHANGED
@@ -202,6 +202,97 @@ export function classifyRisk(paths, cfg = null) {
202
202
  return { tier, classes, lowered, configError };
203
203
  }
204
204
 
205
+ // ---------------------------------------------------------------- content signals
206
+ // A path table only sees code that lives where its name says. A real 4.0.3 run put a whole login —
207
+ // password hashing, session cookies — into `src/app.js`: tier 1 by path, so no gate and no panel would
208
+ // have fired had the spec not happened to be called `login.md`. These rows read WHAT the change adds:
209
+ // code that hashes passwords, sets session cookies, moves money, alters a schema or carries a secret
210
+ // is high risk wherever it lives. Still deterministic (P1): fixed patterns over the added lines, never
211
+ // a judgement. Documentation is not scanned — a spec that talks about passwords is not auth code.
212
+ export const CONTENT_RISK = [
213
+ // Operations, not words: hashing or verifying a password, issuing a session or token. A login form's
214
+ // `type="password"` or an i18n string is not auth code (closing review, 4.0.4).
215
+ { class: 'auth', scope: 'code', pattern: String.raw`\b(?:bcrypt(?:js)?|argon2(?:id|i|d)?)\.(?:hash|compare|verify)(?:Sync)?\s*\(|\b(?:scrypt|scryptSync|pbkdf2|pbkdf2Sync|timingSafeEqual|createHmac)\s*\(|\bjwt\.(?:sign|verify)\s*\(|\b(?:password_hash|password_verify)\s*\(|\bpassport\.(?:use|authenticate)\s*\(|\b(?:getServerSession|verifyIdToken|signInWith[A-Z]\w*)\s*\(|\bres\.cookie\s*\(\s*(?:['"\x60](?:sid|session|token|auth)|[A-Z_]*(?:SESSION|SID|TOKEN|AUTH))|\bpromisify\s*\(\s*(?:crypto\.)?(?:scrypt|pbkdf2)\b|\b(?:jwtVerify|SignJWT)\b|\breq\.session\.(?:\w+\s*=[^=]|regenerate\s*\()|\bcreateSession\s*\(|set-cookie['"\x60]?\s*[,:]\s*['"\x60]?\s*\x60?(?:sid|session|token|auth)\w*=` },
216
+ { class: 'billing', scope: 'code', pattern: String.raw`\b(?:paymentIntents|charges|refunds|subscriptions|checkout\.sessions|invoices)\.(?:create|update|confirm|capture)\s*\(|\bprocess\.env\.(?:STRIPE|PAYPAL|BRAINTREE|ADYEN)_\w+` },
217
+ { class: 'migrations', scope: 'code', pattern: String.raw`\b(?:ALTER\s+TABLE|DROP\s+(?:TABLE|COLUMN)|CREATE\s+TABLE|ADD\s+COLUMN|RENAME\s+COLUMN)\b` },
218
+ // A secret is a leak wherever it is added — tests, config and docs included.
219
+ { class: 'secrets', scope: 'all', pattern: String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\b[sr]k_live_[0-9a-zA-Z]{16,}|\bgh[pousr]_[0-9A-Za-z]{30,}` },
220
+ ];
221
+ // What `code` rows never read: tests and fixtures, lockfiles, minified bundles, docs, and comment lines.
222
+ const NOT_CODE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock|go\.sum)$|\.min\.(?:js|css)$/;
223
+ const COMMENT_LINE = /^\s*(?:\/\/|#|\*|\/\*|<!--|--\s)/;
224
+ const CONTENT_MAX_BYTES = 1024 * 1024;
225
+
226
+ // The text a change ADDS, per path: added lines of the diff against `base` for tracked files, the
227
+ // whole file for untracked ones (or for every path when there is no base — an explicit `rsc risk
228
+ // <paths>`). One whole-tree diff, filtered after: a pathspec per file overflows the argument list on a
229
+ // large change, and a large change must not get LESS scrutiny than a small one. Unreadable or huge
230
+ // files are skipped: the path row still scores them.
231
+ export function changeText(rawRoot, base, paths) {
232
+ const root = resolveRoot(rawRoot);
233
+ const out = {};
234
+ const wanted = new Set(paths);
235
+ const readWhole = (p) => {
236
+ try {
237
+ const st = lstatSync(join(root, p));
238
+ if (st.isFile() && st.size <= CONTENT_MAX_BYTES) out[p] = readFileSync(join(root, p), 'utf8');
239
+ } catch { /* gone or unreadable */ }
240
+ };
241
+ if (!base) { paths.forEach(readWhole); return out; }
242
+ const tracked = new Set(nulList(git(root, 'ls-files', '-z')));
243
+ const diff = git(root, 'diff', '-U0', '--no-renames', '--no-color', base) || '';
244
+ let cur = null;
245
+ let header = false;
246
+ for (const line of diff.split('\n')) {
247
+ if (line.startsWith('diff --git ')) { header = true; cur = null; continue; }
248
+ if (header) {
249
+ // git quotes a path with a tab, newline or quote even under core.quotePath=false: "b/tab\tx.js".
250
+ const q = /^\+\+\+ "b\/((?:[^"\\]|\\.)*)"$/.exec(line);
251
+ const m = q ? [null, q[1].replace(/\\([tn"\\])/g, (_, c) => ({ t: '\t', n: '\n', '"': '"', '\\': '\\' })[c])]
252
+ : /^\+\+\+ b\/(.+?)\t?$/.exec(line);
253
+ if (m && wanted.has(m[1])) { cur = m[1]; out[cur] ||= ''; }
254
+ if (line.startsWith('@@')) header = false;
255
+ continue;
256
+ }
257
+ if (line.startsWith('@@')) continue;
258
+ if (cur && line.startsWith('+')) out[cur] += `${line.slice(1)}\n`;
259
+ }
260
+ paths.filter((p) => !tracked.has(p)).forEach(readWhole);
261
+ return out;
262
+ }
263
+
264
+ // Path rows and content rows together. A path scores the higher of the two; `content` lists each
265
+ // content hit (path, class, the matched text) so the reader can see why. Lowered classes apply to
266
+ // content rows too; floor classes stay at tier 2.
267
+ export function classifyChange(paths, texts = {}, cfg = null) {
268
+ const byPath = classifyRisk(paths, cfg);
269
+ const lowered = byPath.lowered || [];
270
+ const content = [];
271
+ let { tier } = byPath;
272
+ const classes = { ...byPath.classes };
273
+ const rowTier = (cls) => (lowered.includes(cls) && !FLOOR_CLASSES.includes(cls) ? 1 : 2);
274
+ const docs = new RegExp(RISK_TABLE.find((r) => r.class === 'docs').pattern);
275
+ for (const [p, text] of Object.entries(texts)) {
276
+ if (!text) continue;
277
+ const codeText = docs.test(p) || NOT_CODE.test(p) ? '' : text.split('\n').filter((l) => !COMMENT_LINE.test(l)).join('\n');
278
+ for (const row of CONTENT_RISK) {
279
+ const m = new RegExp(row.pattern, row.scope === 'all' ? '' : 'i').exec(row.scope === 'all' ? text : codeText);
280
+ if (!m) continue;
281
+ const t = rowTier(row.class);
282
+ content.push({ path: p, class: row.class, match: m[0].slice(0, 60) });
283
+ if (t > classifyRisk([p], cfg).tier) classes[p] = row.class;
284
+ if (t > tier) tier = t;
285
+ break;
286
+ }
287
+ }
288
+ return { ...byPath, tier, classes, content };
289
+ }
290
+
291
+ export function classifyCandidate(rawRoot, candidate, cfg = null) {
292
+ const paths = Object.keys(candidate.files);
293
+ return classifyChange(paths, changeText(rawRoot, candidate.base, paths), cfg);
294
+ }
295
+
205
296
  // How many lenses a tier requires (capped by an explicit config knob, floor 1).
206
297
  export function lensesRequired(tier, cfg = null) {
207
298
  const base = tier === 0 ? 0 : tier === 1 ? 1 : 3;
@@ -418,7 +509,7 @@ export function checkSello(rawRoot) {
418
509
  const paths = Object.keys(candidate.files);
419
510
  if (paths.length === 0) return { ok: true, code: 'clean' };
420
511
 
421
- const risk = classifyRisk(paths, cfg);
512
+ const risk = classifyCandidate(root, candidate, cfg);
422
513
  // P7 first, before any sello state is consulted: a docs-only change must never
423
514
  // be blocked by a leftover sello from some other change.
424
515
  if (risk.tier === 0) return { ok: true, code: 'risk0' };