@ericrisco/rsc 4.0.1 → 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 +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +2 -1
- package/scripts/lib/risk.js +3 -0
- package/scripts/lib/spec-gate.js +1 -202
- package/skills/ftd/SKILL.md +2 -0
- package/skills/implement/SKILL.md +2 -2
- package/skills/implement/scripts/task-brief +11 -2
- package/skills/specify/SKILL.md +2 -0
- package/targets/asdd-gate.mjs +89 -0
- package/targets/branch-guard.mjs +12 -3
- package/targets/claude.js +6 -2
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/opt-outs.js +1 -0
- package/targets/ship-guard.mjs +11 -0
- package/targets/spec-completeness.mjs +216 -0
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/install-apply.js
CHANGED
|
@@ -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'),
|
|
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 }) {
|
package/scripts/lib/risk.js
CHANGED
|
@@ -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`] : []),
|
package/scripts/lib/spec-gate.js
CHANGED
|
@@ -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
|
-
|
|
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/skills/ftd/SKILL.md
CHANGED
|
@@ -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
|
|
@@ -258,8 +258,8 @@ each dispatch self-sufficient and make its return machine-checkable:
|
|
|
258
258
|
- **Carry the task, not your context.** Hand the worker its task brief, its **Interfaces** block
|
|
259
259
|
(`Consumes`/`Produces`, exact signatures — from `tasks`), and the plan's **§0 Global Constraints**.
|
|
260
260
|
Anything not in those three is invisible to it. Prefer **file paths over pasted text** —
|
|
261
|
-
`scripts/task-brief <plan> <N>` (N as `1`, `001` or `T001`; reads the `tasks` table row
|
|
262
|
-
`### T1` detail block) and `scripts/review-package` — pasted briefs and diffs stay resident in
|
|
261
|
+
`scripts/task-brief <plan> <N>` (N as `1`, `001` or `T001`; reads the `tasks` table row, its `**T001 — Interfaces**` block and
|
|
262
|
+
any `### T1` detail block) and `scripts/review-package` — pasted briefs and diffs stay resident in
|
|
263
263
|
your context and get re-read every turn.
|
|
264
264
|
- **Native isolation leaves worktrees behind.** A subagent dispatched with worktree isolation
|
|
265
265
|
works in `.claude/worktrees/`. `rsc risk` ignores that folder, but after you cherry-pick the
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Two task shapes are read, because `tasks` prescribes a table and older plans used headings:
|
|
7
7
|
# - a table row `| T002 | ... |` → the table's header, its separator and that row;
|
|
8
|
-
# - a heading `### T2 …` / `## T002 …` → the block up to the next ## / ### heading
|
|
8
|
+
# - a heading `### T2 …` / `## T002 …` → the block up to the next ## / ### heading;
|
|
9
|
+
# - a bold block `**T002 — Interfaces**` → that line and what follows up to a blank line.
|
|
9
10
|
# Both are printed when both exist (the row is the contract, the heading its detail).
|
|
10
11
|
# If task N is not found, exits nonzero with a clear message.
|
|
11
12
|
|
|
@@ -42,7 +43,7 @@ esac
|
|
|
42
43
|
|
|
43
44
|
CONTENT="$(awk -v n="$N" '
|
|
44
45
|
function idnum(s) { sub(/^[Tt]/, "", s); return s + 0 }
|
|
45
|
-
BEGIN { n = n + 0; printing = 0; found = 0; head = ""; sep = "" }
|
|
46
|
+
BEGIN { n = n + 0; printing = 0; bold = 0; found = 0; head = ""; sep = "" }
|
|
46
47
|
# Table rows: remember the latest header + separator, print the matching row.
|
|
47
48
|
/^\|/ {
|
|
48
49
|
split($0, cell, "|"); id = cell[2]; gsub(/[[:space:]]/, "", id)
|
|
@@ -52,6 +53,14 @@ CONTENT="$(awk -v n="$N" '
|
|
|
52
53
|
else { head = $0; sep = "" }
|
|
53
54
|
if (!printing) next
|
|
54
55
|
}
|
|
56
|
+
# A bold block `**T004 — Interfaces**` (what `tasks` writes under the table): the line and every
|
|
57
|
+
# line after it up to the first blank one. It is the task-local contract an isolated worker needs.
|
|
58
|
+
bold && /^[[:space:]]*$/ { bold = 0 }
|
|
59
|
+
bold { print; next }
|
|
60
|
+
/^\*\*[Tt][0-9]+/ {
|
|
61
|
+
t = $0; sub(/^\*\*/, "", t); sub(/[^Tt0-9].*$/, "", t)
|
|
62
|
+
if (idnum(t) == n) { bold = 1; found = 1; print ""; print; next }
|
|
63
|
+
}
|
|
55
64
|
/^#{2,3}[[:space:]]/ {
|
|
56
65
|
if (printing) { printing = 0 }
|
|
57
66
|
if (match($0, /^#{2,3}[[:space:]]+[Tt][0-9]+([[:space:](]|-|$)/)) {
|
package/skills/specify/SKILL.md
CHANGED
|
@@ -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/branch-guard.mjs
CHANGED
|
@@ -274,17 +274,26 @@ async function trunkDenial({ root, here, sessionId, self, trunk, policy }) {
|
|
|
274
274
|
const branch = others.length
|
|
275
275
|
? '`git worktree add .worktrees/<branch> -b feat/<what>` and commit inside `.worktrees/<branch>/` (another session is working in this folder)'
|
|
276
276
|
: '`git switch -c feat/<what>` in this same folder, no worktree';
|
|
277
|
-
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so this
|
|
277
|
+
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so this change was not made. ` +
|
|
278
278
|
`Do not choose for the person: ask them in one line whether to open a branch for this change or to unlock "${trunk}". ` +
|
|
279
279
|
`Branch → ${branch}; it reaches "${trunk}" through a pull request. ` +
|
|
280
|
-
'Unlock → only on their explicit answer, `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json), then commit again.'
|
|
280
|
+
'Unlock → only on their explicit answer, `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json), then commit again. ' +
|
|
281
|
+
AUTOPILOT_LANDING;
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
// An autopilot run has nobody to ask, and the 4.0.1 test run read "ask them" as a reason to look for
|
|
285
|
+
// another way in. Landing a finished branch is `ship`'s job, and with the trunk closed it has exactly
|
|
286
|
+
// one legal path per case — this says which, so the denial ends the attempt instead of starting one.
|
|
287
|
+
const AUTOPILOT_LANDING = 'In autopilot (nobody to ask) and landing a finished feature branch: do not merge another way — ' +
|
|
288
|
+
'with a remote, push the branch and open a pull request; without one, park it: write the exact merge command for the person ' +
|
|
289
|
+
'in `02-DOCS/wiki/sdd/progress/<slug>.md` and stop (the `ship` skill, "No remote").';
|
|
290
|
+
|
|
283
291
|
function refDenial({ trunk, policy, how = '`git update-ref` and a forced `git branch`' }) {
|
|
284
292
|
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so "${trunk}" was not moved — ` +
|
|
285
293
|
`${how} land${how.includes(' and ') ? '' : 's'} work on it with no review. ` +
|
|
286
294
|
`Work reaches "${trunk}" through a pull request: push the branch and open one (the \`ship\` skill). ` +
|
|
287
|
-
`Do not choose for the person: if they want "${trunk}" moved by hand, ask them in one line; only on their explicit answer, \`npx @ericrisco/rsc main unlock\` (a project decision, saved in .rsc.json)
|
|
295
|
+
`Do not choose for the person: if they want "${trunk}" moved by hand, ask them in one line; only on their explicit answer, \`npx @ericrisco/rsc main unlock\` (a project decision, saved in .rsc.json). ` +
|
|
296
|
+
AUTOPILOT_LANDING;
|
|
288
297
|
}
|
|
289
298
|
|
|
290
299
|
async function isolationDenial({ root, here, sessionId, self }) {
|
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
|
/**
|
package/targets/opt-outs.js
CHANGED
|
@@ -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/ship-guard.mjs
CHANGED
|
@@ -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
|
+
|