@ericrisco/rsc 4.0.2 → 4.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": "4.0.2",
2
+ "version": "4.0.4",
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.2",
3
+ "version": "4.0.4",
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": {
@@ -103,7 +103,8 @@ export function generatedHookFiles({ target, cwd, policy }) {
103
103
  join(cwd, '.rsc', 'ship-guard.mjs'),
104
104
  join(cwd, '.rsc', 'branch-guard.mjs'),
105
105
  join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
106
- join(cwd, '.rsc', 'sello.mjs'), ...(laneGate ? [join(cwd, '.rsc', 'ftd-nudge.mjs')] : [])];
106
+ join(cwd, '.rsc', 'sello.mjs'), join(cwd, '.rsc', 'asdd-gate.mjs'), join(cwd, '.rsc', 'spec-completeness.mjs'),
107
+ ...(laneGate ? [join(cwd, '.rsc', 'ftd-nudge.mjs')] : [])];
107
108
  }
108
109
 
109
110
  export function managedPathsForInstall({ skillIds, agentIds = [], target, home, cwd, policy }) {
@@ -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,24 +31,31 @@ 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(),
49
53
  reviewers: lensesRequired(tier, cfg),
50
54
  humanApproval: tier >= 2,
55
+ // Tier 2 is ASDD work, whatever lane it started in: the ship guard refuses to push it without a spec.
56
+ escalate: tier >= 2 ? 'asdd' : null,
51
57
  paths: classes,
58
+ content,
52
59
  ...(configError ? { configError } : {}),
53
60
  };
54
61
  }
@@ -61,7 +68,9 @@ export function render(r) {
61
68
  `reviewers: ${r.reviewers}`,
62
69
  `human approval: ${r.humanApproval ? 'required' : 'not required'}`,
63
70
  ...(r.base ? [`base: ${r.base}`] : []),
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.'] : []),
64
72
  ...(r.configError ? [`config ignored: ${r.configError}`] : []),
73
+ ...(r.content || []).slice(0, 10).map((h) => ` content: ${h.class} in ${h.path} ("${h.match}")`),
65
74
  ...top.slice(0, 20).map(([p, c]) => ` ${c.padEnd(10)} ${p}`),
66
75
  ...(top.length > 20 ? [` … ${top.length - 20} more`] : []),
67
76
  ].join('\n');
@@ -9,210 +9,9 @@
9
9
  // the problem `specify` exists to solve, not something a parser can do, and a gate that implies
10
10
  // otherwise is worse than one that admits its edge.
11
11
 
12
- import { parseFrontmatter } from './frontmatter.js';
13
12
 
14
- // The two families below are NOT retroactive. The corpus is 34 specs written before this rule
15
- // existed, and none of them can answer "what if we don't build it" honestly after the fact; a gate
16
- // that turns 34 files red gets switched off within a week. So the requirement starts on a date, and
17
- // the date is derived from the file's own `timestamp:` — not from a list of exempted specs, which
18
- // would be exactly the parallel accounting P3 forbids.
19
- export const NEW_FAMILIES_SINCE = '2026-09-06';
13
+ export { NEW_FAMILIES_SINCE, ASDD_FAMILIES_SINCE, OPEN_POINT_TYPES, specCompleteness } from '../../targets/spec-completeness.mjs';
20
14
 
21
- // ASDD (4.0) makes a spec prove itself: four questions a long autonomous run is checked against.
22
- // Same date rule as above, for the same reason — the corpus before it cannot answer them honestly.
23
- export const ASDD_FAMILIES_SINCE = '2026-11-03';
24
-
25
- // The four states a point can be in. Order is the table's order in the skill body.
26
- export const OPEN_POINT_TYPES = [
27
- 'pregunta abierta', // formulable now, unanswered → clarify asks it
28
- 'suposición tomada', // we decided, with the basis written → clarify validates it
29
- 'decisión diferida', // sharp, out of this cycle on purpose → clarify leaves it
30
- 'área no formulable', // known to be coming, not yet sharp → clarify notes it
31
- ];
32
-
33
- // The corpus is Spanish, the template is English, and both carry drifting parentheticals
34
- // ("Objetivos (resultado)", "Criterios de aceptación (binarios)"). Matching literal strings would
35
- // be useless, so each family is a set of normalised prefixes.
36
- const FAMILIES = {
37
- problem: { prefixes: ['problema & por que', 'problema y por que', 'problem & why'] },
38
- goals: { prefixes: ['objetivos', 'goals'] },
39
- nongoals: { prefixes: ['no-objetivos', 'no objetivos', 'non-goals', 'fuera de alcance', 'out of scope'] },
40
- users: { prefixes: ['usuarios & contexto', 'usuarios y contexto', 'users & context'] },
41
- behaviour: { prefixes: ['comportamiento', 'behaviour', 'behavior'] },
42
- acceptance: { prefixes: ['criterios de aceptacion', 'acceptance criteria'] },
43
- open: {
44
- prefixes: ['puntos a clarificar', 'puntos de clarify', 'puntos que siguen abiertos', 'points to clarify'],
45
- },
46
- // A spec that cannot say what happens if nobody builds it has not been interrogated, only
47
- // de-risked. `since` is what keeps that demand off the specs written before it existed.
48
- inaction: {
49
- prefixes: [
50
- 'que pasa si no lo construimos', '¿que pasa si no lo construimos',
51
- 'coste de no construirlo', 'el coste de no construirlo',
52
- 'cost of not building', 'the cost of not building',
53
- ],
54
- since: NEW_FAMILIES_SINCE,
55
- },
56
- cheapest: {
57
- prefixes: ['la alternativa mas barata', 'alternativa mas barata', 'cheapest alternative', 'the cheapest alternative'],
58
- since: NEW_FAMILIES_SINCE,
59
- },
60
- can: { prefixes: ['que podra hacer el usuario', 'what the user can do'], since: ASDD_FAMILIES_SINCE },
61
- result: { prefixes: ['que resultado obtiene', 'what result they get'], since: ASDD_FAMILIES_SINCE },
62
- respect: { prefixes: ['que debe respetar', 'what it must respect'], since: ASDD_FAMILIES_SINCE },
63
- proof: { prefixes: ['como lo demuestra', 'how it proves it'], since: ASDD_FAMILIES_SINCE },
64
- };
65
-
66
- const UNCHECKED = [
67
- 'que ninguna sección contenga una suposición sin marcar (no es detectable por máquina; es el juicio que la fase existe para ejercer)',
68
- 'que el contenido de cada sección sea correcto, y no sólo presente',
69
- 'que los criterios de aceptación sean de verdad binarios',
70
- // Added with the status check below, for the same reason the three above exist: a green that is
71
- // narrower than the rule it appears to enforce is the decorative gate all over again.
72
- 'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
73
- 'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
74
- // Sin esta línea, el verde sería más estrecho que la regla que aparenta imponer — que es la
75
- // definición de puerta decorativa, sólo que por omisión en vez de por ausencia.
76
- `las specs anteriores a ${NEW_FAMILIES_SINCE} — quedan exentas de "qué pasa si no lo construimos" y de "la alternativa más barata", que no pueden rellenarse a posteriori`,
77
- `las specs anteriores a ${ASDD_FAMILIES_SINCE} — quedan exentas de las cuatro preguntas de ASDD (qué podrá hacer el usuario, qué resultado obtiene, qué debe respetar, cómo lo demuestra)`,
78
- ];
79
-
80
- function fold(s) {
81
- return s
82
- .normalize('NFD')
83
- .replace(/[̀-ͯ]/g, '')
84
- .toLowerCase()
85
- .trim();
86
- }
87
-
88
- function familyOf(heading) {
89
- const h = fold(heading);
90
- for (const [family, { prefixes }] of Object.entries(FAMILIES)) {
91
- if (prefixes.some((p) => h.startsWith(p))) return family;
92
- }
93
- return null;
94
- }
95
-
96
- // A heading with nothing under it is not a filled section. Neither is one holding only the
97
- // template's own italic/angle-bracket guidance, which is what a hurried pass leaves behind.
98
- function isSubstantive(line) {
99
- const t = line.trim();
100
- if (!t) return false;
101
- if (/^<.*>$/.test(t)) return false; // <guidance from the template>
102
- if (/^\*[^*].*\*$/.test(t) && t.length < 200) return false; // *italic guidance*
103
- if (/^(-|\*)\s*$/.test(t)) return false; // an empty bullet
104
- return true;
105
- }
106
-
107
- // The template's guidance does not fit on one line, and a section still holding it is a section
108
- // nobody filled in. The single-line check above never saw those: `<What concretely happens if` does
109
- // not end in `>`, so it read as content and an untouched template passed the gate green.
110
- //
111
- // Only a block that actually CLOSES counts as guidance. A stray `<` that never finds its `>` — a
112
- // latency budget written `<400ms`, a generic in a code sample — stays content, because guessing
113
- // wrong in that direction hides real prose.
114
- function guidanceLines(lines) {
115
- const skip = new Set();
116
- for (let i = 0; i < lines.length; i += 1) {
117
- const t = lines[i].trim();
118
- if (!t.startsWith('<') || t.endsWith('>')) continue;
119
- for (let j = i + 1; j < lines.length; j += 1) {
120
- const u = lines[j].trim();
121
- if (u === '' || u.startsWith('#')) break; // the block ended without closing: not guidance
122
- if (u.endsWith('>')) {
123
- for (let k = i; k <= j; k += 1) skip.add(k);
124
- i = j;
125
- break;
126
- }
127
- }
128
- }
129
- return skip;
130
- }
131
-
132
- function hasSubstance(lines) {
133
- const guidance = guidanceLines(lines);
134
- return lines.some((line, i) => !guidance.has(i) && isSubstantive(line));
135
- }
136
-
137
- // The day this spec was written, as YYYY-MM-DD, or null when there is nothing trustworthy to read.
138
- // Null always means EXEMPT: a spec we cannot date is not a spec we get to fail. Three ways to land
139
- // there, and all three are safe — the CLI itself calls specCompleteness('') to print `unchecked`,
140
- // and parseFrontmatter throws on that.
141
- function specDay(markdown) {
142
- try {
143
- const raw = parseFrontmatter(markdown).timestamp;
144
- if (typeof raw !== 'string') return null;
145
- const day = raw.slice(0, 10); // ISO order is lexicographic order; no timezone maths needed
146
- return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- function requiredFamilies(markdown) {
153
- const day = specDay(markdown);
154
- return Object.entries(FAMILIES)
155
- .filter(([, { since }]) => !since || (day !== null && day >= since))
156
- .map(([family]) => family);
157
- }
158
-
159
- function parseOpenPoints(lines) {
160
- const points = [];
161
- for (const line of lines) {
162
- const m = line.match(/^\s*[-*]\s+(?:\[[ x]\]\s*)?(.*)$/);
163
- if (!m || !m[1].trim()) continue;
164
- const body = m[1];
165
- const declared = OPEN_POINT_TYPES.find((t) => {
166
- const re = new RegExp(`^\\*\\*\\s*${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\*\\*`, 'i');
167
- return re.test(fold(body)) || re.test(body);
168
- });
169
- points.push({
170
- type: declared || OPEN_POINT_TYPES[0], // the costliest default: treat it as a real question
171
- typed: Boolean(declared),
172
- text: body.trim(),
173
- });
174
- }
175
- return points;
176
- }
177
-
178
- /**
179
- * @param {string} markdown a written spec file
180
- * @returns {{ok: boolean, missing: string[], empty: string[],
181
- * openPoints: Array<{type: string, typed: boolean, text: string}>,
182
- * untyped: string[], unchecked: string[]}}
183
- */
184
- export function specCompleteness(markdown) {
185
- const lines = String(markdown ?? '').split('\n');
186
- const sections = new Map(); // family -> body lines
187
-
188
- let current = null;
189
- for (const line of lines) {
190
- const h = line.match(/^##\s+(.*)$/);
191
- if (h) {
192
- const fam = familyOf(h[1]);
193
- current = fam;
194
- if (fam && !sections.has(fam)) sections.set(fam, []);
195
- continue;
196
- }
197
- if (current && sections.has(current)) sections.get(current).push(line);
198
- }
199
-
200
- const families = requiredFamilies(markdown);
201
- const missing = families.filter((f) => !sections.has(f));
202
- const empty = families.filter((f) => sections.has(f) && !hasSubstance(sections.get(f)));
203
-
204
- const openPoints = sections.has('open') ? parseOpenPoints(sections.get('open')) : [];
205
- const untyped = openPoints.filter((p) => !p.typed).map((p) => p.text);
206
-
207
- return {
208
- ok: missing.length === 0 && empty.length === 0,
209
- missing,
210
- empty,
211
- openPoints,
212
- untyped,
213
- unchecked: UNCHECKED,
214
- };
215
- }
216
15
 
217
16
  // ─────────────────────────────────────────────────────────────────────────────
218
17
  // Does the status still match the repository?
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
 
@@ -74,6 +74,8 @@ The lane inherits ASDD's per-task loop, cut to its floor:
74
74
  **Escalate to `asdd`** when `rsc risk` says **tier 2**, or when the change contradicts a decision
75
75
  already recorded in `decisions.md`: the feature document becomes its spec input, as below.
76
76
 
77
+ **This escalation is enforced, not advised.** A tier-2 branch cannot be pushed or opened as a PR until a spec linked to it (`branch: <name>` in its frontmatter) passes `npx @ericrisco/rsc spec gate`. Time pressure does not change the lane: escalate the moment `rsc risk` says tier 2, before writing more code.
78
+
77
79
  ## Isolation
78
80
 
79
81
  Where the work runs follows the project's decision (asked at install: «main» or «ramas y PR»; rsc
@@ -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 |
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. |
@@ -199,6 +199,8 @@ empty sections by name, exits non-zero on any `FAIL`, and prints what it does **
199
199
  unmarked assumption is yours to catch, because no parser can. A red gate means the spec is not
200
200
  closed: name the section that failed and fix it before step 9.
201
201
 
202
+ When the work already lives on a branch, put `branch: <name>` in the spec's frontmatter: for a tier-2 change (`rsc risk`) the ship guard refuses push and PR until a spec linked to the branch passes the gate.
203
+
202
204
  ### Fresh-eyes spec review (step 8, scaled to the stakes)
203
205
 
204
206
  The author's own context is blind to its own gaps — the same mind that wrote the spec self-reviews it
@@ -0,0 +1,134 @@
1
+ // ASDD escalation gate — the deterministic half of "tier 2 in FTD means ASDD".
2
+ //
3
+ // A real 4.0.2 run took a login (auth → tier 2) down the small-change lane: `rsc risk` said tier 2,
4
+ // the ftd skill said "escalate to asdd", and the agent decided not to — with no spec, so the user
5
+ // registration it added was never recorded as an assumption anyone would review. A rule that bends
6
+ // under time pressure is prose (P2). This makes it a mechanism at the one moment that matters: before
7
+ // a tier-2 branch leaves the machine (`git push`, `gh pr create|merge`), a spec linked to the branch
8
+ // must exist and pass the spec gate.
9
+ //
10
+ // A spec is linked to the branch when its frontmatter says `branch: <branch>`, or when its file name
11
+ // (the slug) IS the branch's last segment (`feat/login` ↔ `specs/login.md`; `_` and `-` alike). Exact,
12
+ // never a substring: `api.md` must not vouch for `feat/rapid-login`.
13
+ // A branch whose tier-2 paths are all harness configuration (an rsc install or upgrade, CLAUDE.md) is
14
+ // not feature work and needs no spec — it still needs a human, as every tier 2 does.
15
+ // Fail-open on any environment problem; opt out per project with `.rsc/.no-asdd-gate`.
16
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { expand } from './shell-unwrap.mjs';
20
+
21
+ // Options that take a value must swallow it, or `gh -R owner/repo pr create` reads `owner/repo` as
22
+ // the subcommand and slips past.
23
+ const OPTS = String.raw`(?:(?:-[CcR]\s+\S+|--(?:repo|git-dir|work-tree)\s+\S+|--?\S+)\s+)*`;
24
+ const PUSH_OR_PR = new RegExp(String.raw`\bgit\s+${OPTS}push(?![\w-])|\bgh\s+${OPTS}pr\s+${OPTS}(?:create|merge)(?![\w-])`);
25
+ export const SPECS_DIR = join('02-DOCS', 'wiki', 'sdd', 'specs');
26
+
27
+ // Quoted text is blanked so a mention is not a push; and the segments a shell really runs
28
+ // (`bash -c "git push"`, `eval …`) are judged too — the same unwrapping the sello uses.
29
+ export function isPushOrPr(command) {
30
+ if (typeof command !== 'string' || !command) return false;
31
+ const runs = (c) => PUSH_OR_PR.test(c.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""'));
32
+ return runs(command) || expand(command).some(runs);
33
+ }
34
+
35
+ function git(root, ...args) {
36
+ const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
37
+ return r.status === 0 ? (r.stdout || '') : null;
38
+ }
39
+
40
+ const branchOf = (text) => (/^---\r?\n[\s\S]*?^branch:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$[\s\S]*?^---/m.exec(text) || [])[1] || null;
41
+
42
+ /** The specs linked to `branch`: `{ path, ok, missing, empty }` each. */
43
+ export function linkedSpecs(root, branch, specCompleteness) {
44
+ let files = [];
45
+ try { files = readdirSync(join(root, SPECS_DIR)).filter((f) => f.endsWith('.md') && !f.endsWith('.plan.md')); } catch { return []; }
46
+ const norm = (x) => x.toLowerCase().replace(/_/g, '-');
47
+ const last = norm(branch.split('/').pop());
48
+ return files.flatMap((f) => {
49
+ const text = readFileSync(join(root, SPECS_DIR, f), 'utf8');
50
+ if (branchOf(text) !== branch && norm(f.slice(0, -3)) !== last) return [];
51
+ const r = specCompleteness(text);
52
+ return [{ path: join(SPECS_DIR, f).split('\\').join('/'), ok: r.ok, missing: r.missing, empty: r.empty }];
53
+ });
54
+ }
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
+
87
+ /**
88
+ * null when the command may go ahead; the denial text (with its way out, P6) when it may not.
89
+ * `lib` carries the sibling modules so tests and the hook load them the same way.
90
+ */
91
+ export function asddGate(root, command, { sello, specCompleteness }) {
92
+ if (!isPushOrPr(command)) return null;
93
+ if (existsSync(join(root, '.rsc', '.no-asdd-gate'))) return null;
94
+ const branch = (git(root, 'rev-parse', '--abbrev-ref', 'HEAD') || '').trim();
95
+ if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') return null;
96
+ const cfg = sello.readEffectiveConfig(root);
97
+ const trunk = sello.trunkRef(root, cfg);
98
+ if (!trunk) return null;
99
+ const base = (git(root, 'merge-base', 'HEAD', trunk) || '').trim();
100
+ if (!base) return null;
101
+ const paths = [...new Set([
102
+ ...(git(root, 'diff', '--name-only', '--no-renames', '-z', base) || '').split('\0'),
103
+ ...(git(root, 'ls-files', '--others', '--exclude-standard', '-z') || '').split('\0').filter((n) => !sello.NESTED_WORKTREES.test(n)),
104
+ ].filter(Boolean))].filter((n) => !sello.SELLO_STATE_PATHS.includes(n));
105
+ const { tier, classes } = sello.classifyChange(paths, sello.changeText(root, base, paths), cfg);
106
+ if (tier < 2) return null;
107
+ const tierTwo = [...new Set(Object.values(classes))].filter((c) => c !== 'default' && c !== 'docs');
108
+ if (tierTwo.length && tierTwo.every((c) => c === 'harness')) return null;
109
+ const specs = linkedSpecs(root, branch, specCompleteness);
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
+ }
125
+ const why = [...new Set(Object.values(classes).filter((c) => c !== 'default' && c !== 'docs'))].join(', ');
126
+ const found = specs.length
127
+ ? `The linked spec does not pass the gate: ${specs.map((s) => `${s.path} (missing: ${[...s.missing, ...s.empty].join(', ') || '—'})`).join('; ')}. `
128
+ : `No spec in ${SPECS_DIR.split('\\').join('/')}/ is linked to "${branch}". `;
129
+ return `Branch "${branch}" is risk tier 2 (${why || 'tier 2'}): tier 2 is ASDD work, and it does not leave this machine without an approved spec. ` +
130
+ found +
131
+ 'Recover: write the spec with the `specify` skill (the four questions: what the user can do, what result they get, what it must respect, how it proves it; ' +
132
+ `plus out of scope), put \`branch: ${branch}\` in its frontmatter, check it with \`npx @ericrisco/rsc spec gate <spec>\`, and push again. ` +
133
+ 'Anything decided without the person goes to the decision queue as review-after. To turn this gate off for the project: create `.rsc/.no-asdd-gate`.';
134
+ }
package/targets/claude.js CHANGED
@@ -187,6 +187,9 @@ export function wireHook(paths, sourceMd, policy = {}) {
187
187
  // sello.mjs is ship-guard's sibling import (hooks are materialized file-by-file,
188
188
  // so the deterministic sello core must land next to the guard that loads it).
189
189
  copyFileSync(join(HERE, 'sello.mjs'), join(paths.projectRoot, '.rsc', 'sello.mjs'));
190
+ // The ASDD escalation gate and the spec section check it runs — siblings of the guard, same reason.
191
+ copyFileSync(join(HERE, 'asdd-gate.mjs'), join(paths.projectRoot, '.rsc', 'asdd-gate.mjs'));
192
+ copyFileSync(join(HERE, 'spec-completeness.mjs'), join(paths.projectRoot, '.rsc', 'spec-completeness.mjs'));
190
193
  const sgCmd = viaBootstrap('guard', at('.rsc', 'ship-guard.mjs'), `"${P}"`);
191
194
  settings.hooks.PreToolUse ||= [];
192
195
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
@@ -240,7 +243,8 @@ export function wireHook(paths, sourceMd, policy = {}) {
240
243
  (e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
241
244
  );
242
245
  settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
243
- written.push(sgDest, gmDest, fgDest, bgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
246
+ written.push(sgDest, gmDest, fgDest, bgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'),
247
+ join(paths.projectRoot, '.rsc', 'asdd-gate.mjs'), join(paths.projectRoot, '.rsc', 'spec-completeness.mjs'));
244
248
  } else {
245
249
  for (const event of ['PreToolUse', 'UserPromptSubmit']) {
246
250
  if (!settings.hooks[event]) continue;
@@ -251,7 +255,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
251
255
  });
252
256
  if (!settings.hooks[event].length) delete settings.hooks[event];
253
257
  }
254
- const gone = ['ship-guard.mjs', 'branch-guard.mjs', 'gitmoji-guard.mjs', 'sello.mjs', ...(laneGate ? [] : ['trunk-policy.mjs', 'userprompt-gate.mjs'])];
258
+ const gone = ['ship-guard.mjs', 'branch-guard.mjs', 'gitmoji-guard.mjs', 'sello.mjs', 'asdd-gate.mjs', 'spec-completeness.mjs', ...(laneGate ? [] : ['trunk-policy.mjs', 'userprompt-gate.mjs'])];
255
259
  for (const name of gone) rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
256
260
  if (laneGate) {
257
261
  // Same per-turn gate as a governed project: the lane, and where code changes go here.
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
43
43
  * nothing checks is just drift with a head start.
44
44
  */
45
45
  export const PROJECT_OPT_OUTS = [
46
- 'audit', 'auto-update', 'claudemd-check', 'copy-guard', 'danger-guard', 'feature-gate', 'ftd-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
46
+ 'asdd-gate', 'audit', 'auto-update', 'claudemd-check', 'copy-guard', 'danger-guard', 'feature-gate', 'ftd-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
47
47
  ];
48
48
 
49
49
  /**
@@ -17,6 +17,7 @@
17
17
 
18
18
  /** Decisions of the project: they belong in `.rsc.json` and are rebuilt in a clone. */
19
19
  export const PROJECT_OPT_OUTS = [
20
+ 'asdd-gate', // a tier-2 branch cannot be pushed without a spec linked to it (ship guard)
20
21
  'audit', // cadence of this project's skill audit
21
22
  'auto-update', // installing new rsc releases alone: one machine ahead moves the team's catalogVersion
22
23
  'claudemd-check', // the root CLAUDE.md context budget
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' };
@@ -61,6 +61,17 @@ try {
61
61
  }
62
62
  } catch { /* sello lib missing/unloadable → nothing to enforce, fail open */ }
63
63
 
64
+ // ---- ASDD escalation gate ---------------------------------------------------
65
+ // A tier-2 branch does not leave the machine (push, PR) without a spec linked to it that passes the
66
+ // spec gate. Its own opt-out (.rsc/.no-asdd-gate); fail-open if a sibling is missing.
67
+ try {
68
+ const sello = await import(new URL('./sello.mjs', import.meta.url));
69
+ const { specCompleteness } = await import(new URL('./spec-completeness.mjs', import.meta.url));
70
+ const { asddGate } = await import(new URL('./asdd-gate.mjs', import.meta.url));
71
+ const verdict = asddGate(root, command, { sello, specCompleteness });
72
+ if (verdict) deny(verdict);
73
+ } catch { /* sibling missing/unloadable → nothing to enforce, fail open */ }
74
+
64
75
  // Ship-guard's own opt-out (branch hygiene only — the sello above already ran).
65
76
  if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
66
77
 
@@ -0,0 +1,216 @@
1
+ // The spec exit gate's section check, as a self-contained module. It lives in targets/ because it is
2
+ // installed next to the hooks (`.rsc/`) — the ship guard needs it in a user project, where scripts/lib
3
+ // does not exist — and scripts/lib/spec-gate.js re-exports it, so there is one implementation.
4
+
5
+ // `timestamp:` from the leading frontmatter block, quotes stripped. Anything else → null (exempt),
6
+ // which is what the full frontmatter parser yielded for these specs.
7
+ function frontmatterTimestamp(markdown) {
8
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(markdown ?? ''));
9
+ if (!fm) return null;
10
+ const m = /^timestamp:[ \t]*(.+?)[ \t]*$/m.exec(fm[1]);
11
+ return m ? m[1].replace(/^(['"])(.*)\1$/, '$2') : null;
12
+ }
13
+
14
+ // The two families below are NOT retroactive. The corpus is 34 specs written before this rule
15
+ // existed, and none of them can answer "what if we don't build it" honestly after the fact; a gate
16
+ // that turns 34 files red gets switched off within a week. So the requirement starts on a date, and
17
+ // the date is derived from the file's own `timestamp:` — not from a list of exempted specs, which
18
+ // would be exactly the parallel accounting P3 forbids.
19
+ export const NEW_FAMILIES_SINCE = '2026-09-06';
20
+
21
+ // ASDD (4.0) makes a spec prove itself: four questions a long autonomous run is checked against.
22
+ // Same date rule as above, for the same reason — the corpus before it cannot answer them honestly.
23
+ export const ASDD_FAMILIES_SINCE = '2026-11-03';
24
+
25
+ // The four states a point can be in. Order is the table's order in the skill body.
26
+ export const OPEN_POINT_TYPES = [
27
+ 'pregunta abierta', // formulable now, unanswered → clarify asks it
28
+ 'suposición tomada', // we decided, with the basis written → clarify validates it
29
+ 'decisión diferida', // sharp, out of this cycle on purpose → clarify leaves it
30
+ 'área no formulable', // known to be coming, not yet sharp → clarify notes it
31
+ ];
32
+
33
+ // The corpus is Spanish, the template is English, and both carry drifting parentheticals
34
+ // ("Objetivos (resultado)", "Criterios de aceptación (binarios)"). Matching literal strings would
35
+ // be useless, so each family is a set of normalised prefixes.
36
+ const FAMILIES = {
37
+ problem: { prefixes: ['problema & por que', 'problema y por que', 'problem & why'] },
38
+ goals: { prefixes: ['objetivos', 'goals'] },
39
+ nongoals: { prefixes: ['no-objetivos', 'no objetivos', 'non-goals', 'fuera de alcance', 'out of scope'] },
40
+ users: { prefixes: ['usuarios & contexto', 'usuarios y contexto', 'users & context'] },
41
+ behaviour: { prefixes: ['comportamiento', 'behaviour', 'behavior'] },
42
+ acceptance: { prefixes: ['criterios de aceptacion', 'acceptance criteria'] },
43
+ open: {
44
+ prefixes: ['puntos a clarificar', 'puntos de clarify', 'puntos que siguen abiertos', 'points to clarify'],
45
+ },
46
+ // A spec that cannot say what happens if nobody builds it has not been interrogated, only
47
+ // de-risked. `since` is what keeps that demand off the specs written before it existed.
48
+ inaction: {
49
+ prefixes: [
50
+ 'que pasa si no lo construimos', '¿que pasa si no lo construimos',
51
+ 'coste de no construirlo', 'el coste de no construirlo',
52
+ 'cost of not building', 'the cost of not building',
53
+ ],
54
+ since: NEW_FAMILIES_SINCE,
55
+ },
56
+ cheapest: {
57
+ prefixes: ['la alternativa mas barata', 'alternativa mas barata', 'cheapest alternative', 'the cheapest alternative'],
58
+ since: NEW_FAMILIES_SINCE,
59
+ },
60
+ can: { prefixes: ['que podra hacer el usuario', 'what the user can do'], since: ASDD_FAMILIES_SINCE },
61
+ result: { prefixes: ['que resultado obtiene', 'what result they get'], since: ASDD_FAMILIES_SINCE },
62
+ respect: { prefixes: ['que debe respetar', 'what it must respect'], since: ASDD_FAMILIES_SINCE },
63
+ proof: { prefixes: ['como lo demuestra', 'how it proves it'], since: ASDD_FAMILIES_SINCE },
64
+ };
65
+
66
+ const UNCHECKED = [
67
+ 'que ninguna sección contenga una suposición sin marcar (no es detectable por máquina; es el juicio que la fase existe para ejercer)',
68
+ 'que el contenido de cada sección sea correcto, y no sólo presente',
69
+ 'que los criterios de aceptación sean de verdad binarios',
70
+ // Added with the status check below, for the same reason the three above exist: a green that is
71
+ // narrower than the rule it appears to enforce is the decorative gate all over again.
72
+ 'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
73
+ 'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
74
+ // Sin esta línea, el verde sería más estrecho que la regla que aparenta imponer — que es la
75
+ // definición de puerta decorativa, sólo que por omisión en vez de por ausencia.
76
+ `las specs anteriores a ${NEW_FAMILIES_SINCE} — quedan exentas de "qué pasa si no lo construimos" y de "la alternativa más barata", que no pueden rellenarse a posteriori`,
77
+ `las specs anteriores a ${ASDD_FAMILIES_SINCE} — quedan exentas de las cuatro preguntas de ASDD (qué podrá hacer el usuario, qué resultado obtiene, qué debe respetar, cómo lo demuestra)`,
78
+ ];
79
+
80
+ function fold(s) {
81
+ return s
82
+ .normalize('NFD')
83
+ .replace(/[̀-ͯ]/g, '')
84
+ .toLowerCase()
85
+ .trim();
86
+ }
87
+
88
+ function familyOf(heading) {
89
+ const h = fold(heading);
90
+ for (const [family, { prefixes }] of Object.entries(FAMILIES)) {
91
+ if (prefixes.some((p) => h.startsWith(p))) return family;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // A heading with nothing under it is not a filled section. Neither is one holding only the
97
+ // template's own italic/angle-bracket guidance, which is what a hurried pass leaves behind.
98
+ function isSubstantive(line) {
99
+ const t = line.trim();
100
+ if (!t) return false;
101
+ if (/^<.*>$/.test(t)) return false; // <guidance from the template>
102
+ if (/^\*[^*].*\*$/.test(t) && t.length < 200) return false; // *italic guidance*
103
+ if (/^(-|\*)\s*$/.test(t)) return false; // an empty bullet
104
+ return true;
105
+ }
106
+
107
+ // The template's guidance does not fit on one line, and a section still holding it is a section
108
+ // nobody filled in. The single-line check above never saw those: `<What concretely happens if` does
109
+ // not end in `>`, so it read as content and an untouched template passed the gate green.
110
+ //
111
+ // Only a block that actually CLOSES counts as guidance. A stray `<` that never finds its `>` — a
112
+ // latency budget written `<400ms`, a generic in a code sample — stays content, because guessing
113
+ // wrong in that direction hides real prose.
114
+ function guidanceLines(lines) {
115
+ const skip = new Set();
116
+ for (let i = 0; i < lines.length; i += 1) {
117
+ const t = lines[i].trim();
118
+ if (!t.startsWith('<') || t.endsWith('>')) continue;
119
+ for (let j = i + 1; j < lines.length; j += 1) {
120
+ const u = lines[j].trim();
121
+ if (u === '' || u.startsWith('#')) break; // the block ended without closing: not guidance
122
+ if (u.endsWith('>')) {
123
+ for (let k = i; k <= j; k += 1) skip.add(k);
124
+ i = j;
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ return skip;
130
+ }
131
+
132
+ function hasSubstance(lines) {
133
+ const guidance = guidanceLines(lines);
134
+ return lines.some((line, i) => !guidance.has(i) && isSubstantive(line));
135
+ }
136
+
137
+ // The day this spec was written, as YYYY-MM-DD, or null when there is nothing trustworthy to read.
138
+ // Null always means EXEMPT: a spec we cannot date is not a spec we get to fail. Three ways to land
139
+ // there, and all three are safe — the CLI itself calls specCompleteness('') to print `unchecked`,
140
+ // and parseFrontmatter throws on that.
141
+ function specDay(markdown) {
142
+ try {
143
+ const raw = frontmatterTimestamp(markdown);
144
+ if (typeof raw !== 'string') return null;
145
+ const day = raw.slice(0, 10); // ISO order is lexicographic order; no timezone maths needed
146
+ return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function requiredFamilies(markdown) {
153
+ const day = specDay(markdown);
154
+ return Object.entries(FAMILIES)
155
+ .filter(([, { since }]) => !since || (day !== null && day >= since))
156
+ .map(([family]) => family);
157
+ }
158
+
159
+ function parseOpenPoints(lines) {
160
+ const points = [];
161
+ for (const line of lines) {
162
+ const m = line.match(/^\s*[-*]\s+(?:\[[ x]\]\s*)?(.*)$/);
163
+ if (!m || !m[1].trim()) continue;
164
+ const body = m[1];
165
+ const declared = OPEN_POINT_TYPES.find((t) => {
166
+ const re = new RegExp(`^\\*\\*\\s*${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\*\\*`, 'i');
167
+ return re.test(fold(body)) || re.test(body);
168
+ });
169
+ points.push({
170
+ type: declared || OPEN_POINT_TYPES[0], // the costliest default: treat it as a real question
171
+ typed: Boolean(declared),
172
+ text: body.trim(),
173
+ });
174
+ }
175
+ return points;
176
+ }
177
+
178
+ /**
179
+ * @param {string} markdown a written spec file
180
+ * @returns {{ok: boolean, missing: string[], empty: string[],
181
+ * openPoints: Array<{type: string, typed: boolean, text: string}>,
182
+ * untyped: string[], unchecked: string[]}}
183
+ */
184
+ export function specCompleteness(markdown) {
185
+ const lines = String(markdown ?? '').split('\n');
186
+ const sections = new Map(); // family -> body lines
187
+
188
+ let current = null;
189
+ for (const line of lines) {
190
+ const h = line.match(/^##\s+(.*)$/);
191
+ if (h) {
192
+ const fam = familyOf(h[1]);
193
+ current = fam;
194
+ if (fam && !sections.has(fam)) sections.set(fam, []);
195
+ continue;
196
+ }
197
+ if (current && sections.has(current)) sections.get(current).push(line);
198
+ }
199
+
200
+ const families = requiredFamilies(markdown);
201
+ const missing = families.filter((f) => !sections.has(f));
202
+ const empty = families.filter((f) => sections.has(f) && !hasSubstance(sections.get(f)));
203
+
204
+ const openPoints = sections.has('open') ? parseOpenPoints(sections.get('open')) : [];
205
+ const untyped = openPoints.filter((p) => !p.typed).map((p) => p.text);
206
+
207
+ return {
208
+ ok: missing.length === 0 && empty.length === 0,
209
+ missing,
210
+ empty,
211
+ openPoints,
212
+ untyped,
213
+ unchecked: UNCHECKED,
214
+ };
215
+ }
216
+