@ericrisco/rsc 4.0.2 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.0.2",
2
+ "version": "4.0.3",
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.3",
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 }) {
@@ -48,6 +48,8 @@ export function assess(paths, cfg = null) {
48
48
  classes: [...new Set(Object.values(classes))].sort(),
49
49
  reviewers: lensesRequired(tier, cfg),
50
50
  humanApproval: tier >= 2,
51
+ // Tier 2 is ASDD work, whatever lane it started in: the ship guard refuses to push it without a spec.
52
+ escalate: tier >= 2 ? 'asdd' : null,
51
53
  paths: classes,
52
54
  ...(configError ? { configError } : {}),
53
55
  };
@@ -61,6 +63,7 @@ export function render(r) {
61
63
  `reviewers: ${r.reviewers}`,
62
64
  `human approval: ${r.humanApproval ? 'required' : 'not required'}`,
63
65
  ...(r.base ? [`base: ${r.base}`] : []),
66
+ ...(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
67
  ...(r.configError ? [`config ignored: ${r.configError}`] : []),
65
68
  ...top.slice(0, 20).map(([p, c]) => ` ${c.padEnd(10)} ${p}`),
66
69
  ...(top.length > 20 ? [` … ${top.length - 20} more`] : []),
@@ -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?
@@ -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
@@ -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,89 @@
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
+ /**
57
+ * null when the command may go ahead; the denial text (with its way out, P6) when it may not.
58
+ * `lib` carries the sibling modules so tests and the hook load them the same way.
59
+ */
60
+ export function asddGate(root, command, { sello, specCompleteness }) {
61
+ if (!isPushOrPr(command)) return null;
62
+ if (existsSync(join(root, '.rsc', '.no-asdd-gate'))) return null;
63
+ const branch = (git(root, 'rev-parse', '--abbrev-ref', 'HEAD') || '').trim();
64
+ if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') return null;
65
+ const cfg = sello.readEffectiveConfig(root);
66
+ const trunk = sello.trunkRef(root, cfg);
67
+ if (!trunk) return null;
68
+ const base = (git(root, 'merge-base', 'HEAD', trunk) || '').trim();
69
+ if (!base) return null;
70
+ const paths = [...new Set([
71
+ ...(git(root, 'diff', '--name-only', '--no-renames', '-z', base) || '').split('\0'),
72
+ ...(git(root, 'ls-files', '--others', '--exclude-standard', '-z') || '').split('\0').filter((n) => !sello.NESTED_WORKTREES.test(n)),
73
+ ].filter(Boolean))].filter((n) => !sello.SELLO_STATE_PATHS.includes(n));
74
+ const { tier, classes } = sello.classifyRisk(paths, cfg);
75
+ if (tier < 2) return null;
76
+ const tierTwo = [...new Set(Object.values(classes))].filter((c) => c !== 'default' && c !== 'docs');
77
+ if (tierTwo.length && tierTwo.every((c) => c === 'harness')) return null;
78
+ const specs = linkedSpecs(root, branch, specCompleteness);
79
+ if (specs.some((s) => s.ok)) return null;
80
+ const why = [...new Set(Object.values(classes).filter((c) => c !== 'default' && c !== 'docs'))].join(', ');
81
+ const found = specs.length
82
+ ? `The linked spec does not pass the gate: ${specs.map((s) => `${s.path} (missing: ${[...s.missing, ...s.empty].join(', ') || '—'})`).join('; ')}. `
83
+ : `No spec in ${SPECS_DIR.split('\\').join('/')}/ is linked to "${branch}". `;
84
+ 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. ` +
85
+ found +
86
+ '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; ' +
87
+ `plus out of scope), put \`branch: ${branch}\` in its frontmatter, check it with \`npx @ericrisco/rsc spec gate <spec>\`, and push again. ` +
88
+ '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`.';
89
+ }
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
@@ -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
+