agentic-sdd-framework 1.4.0

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.
Files changed (57) hide show
  1. package/.agents/AGENTS.template.md +59 -0
  2. package/.agents/CONTEXT.template.md +41 -0
  3. package/.agents/ENTRYPOINT.template.md +31 -0
  4. package/.agents/skills/ast-navigator/SKILL.md +31 -0
  5. package/.agents/skills/ast-navigator/adapters/ast-grep.md +18 -0
  6. package/.agents/skills/ast-navigator/adapters/graphify.md +19 -0
  7. package/.agents/skills/ast-navigator/adapters/lsp.md +16 -0
  8. package/.agents/skills/ast-navigator/adapters/ripgrep.md +19 -0
  9. package/.agents/skills/auditor-executor-protocol/SKILL.md +410 -0
  10. package/.agents/skills/auditor-executor-protocol/references/autonomous-mode.md +144 -0
  11. package/.agents/skills/auditor-executor-protocol/references/failure-modes-and-example.md +103 -0
  12. package/.agents/skills/auditor-executor-protocol/references/handoffs.md +133 -0
  13. package/.agents/skills/auditor-executor-protocol/references/tasks-and-gates.md +81 -0
  14. package/.agents/skills/no-ai-slop/LICENSE +21 -0
  15. package/.agents/skills/no-ai-slop/SKILL.md +52 -0
  16. package/.agents/skills/strategic-cto/SKILL.md +54 -0
  17. package/CHANGELOG.md +117 -0
  18. package/LICENSE +21 -0
  19. package/README.md +244 -0
  20. package/docs/SPEC_TEMPLATE.md +78 -0
  21. package/docs/decisions/ADR_TEMPLATE.md +49 -0
  22. package/docs/guidelines/AST_NAVIGATION.md +51 -0
  23. package/docs/guides/AGENT_CREDENTIALS.md +75 -0
  24. package/docs/guides/GITHUB_CLI_SETUP.md +74 -0
  25. package/docs/incidents/0000-00-00-incident-template.md +35 -0
  26. package/docs/roadmap/templates/compliance-log.md +37 -0
  27. package/docs/roadmap/templates/execution-guide.md +75 -0
  28. package/docs/roadmap/templates/plan-of-record.md +49 -0
  29. package/package.json +49 -0
  30. package/scripts/check-copy-slop.js +120 -0
  31. package/scripts/check-file-size.js +66 -0
  32. package/scripts/check-spec.js +201 -0
  33. package/scripts/check-system-prerequisites.js +133 -0
  34. package/scripts/check-versions.js +50 -0
  35. package/scripts/dev/fuzz-spec-markup.js +123 -0
  36. package/scripts/dev/set-npm-publish-token.sh +40 -0
  37. package/scripts/dev/sync-vendored.js +94 -0
  38. package/scripts/install-git-hooks.js +103 -0
  39. package/scripts/lib/cli.js +60 -0
  40. package/scripts/lib/config.js +111 -0
  41. package/scripts/lib/git.js +211 -0
  42. package/scripts/lib/markdown.js +46 -0
  43. package/scripts/lib/provision.js +323 -0
  44. package/scripts/lib/runner.js +70 -0
  45. package/scripts/lib/sdd.config.schema.json +213 -0
  46. package/scripts/lib/slop-patterns.js +57 -0
  47. package/scripts/lib/spec-markup.js +346 -0
  48. package/scripts/lib/spec.js +226 -0
  49. package/scripts/lib/state.js +107 -0
  50. package/scripts/lib/vendor/README.md +11 -0
  51. package/scripts/lib/vendor/markdown-it.LICENSE +22 -0
  52. package/scripts/lib/vendor/markdown-it.min.js +3 -0
  53. package/scripts/quality-gate.js +151 -0
  54. package/scripts/sdd-init.js +245 -0
  55. package/scripts/sdd-verify.js +176 -0
  56. package/scripts/verify-no-secrets.js +216 -0
  57. package/sdd.config.json +33 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * scripts/lib/spec-markup.js
3
+ *
4
+ * Reads the Lite specification the way it renders: the Markdown is parsed with a CommonMark
5
+ * parser (markdown-it, vendored in ./vendor), and tasks, fields, and the verification gate
6
+ * are taken from the token tree, so a line counts only if it renders as what it claims to be.
7
+ *
8
+ * A thin subset is enforced on top, for the places where GitHub's renderer (cmark-gfm) and
9
+ * markdown-it could still differ, and for text that can be disguised:
10
+ * - no raw HTML (HTML comments included), no link reference definitions or footnotes;
11
+ * - no HTML entities, invisible characters, or non-ASCII whitespace outside code;
12
+ * - indentation with spaces, not tabs, outside code;
13
+ * - Status, Verification Command, Expected Output and Last Verified are written exactly as
14
+ * in the template; any other line that reads as one of them (the name followed by a
15
+ * colon, or the name emphasized, at the start of a line, compared after folding case,
16
+ * punctuation, and look-alike letters) is an error, as is text that reads as a task
17
+ * checkbox without being one.
18
+ * - a line right after a list item, indented 4 or more spaces but fewer than that item's
19
+ * own content column, and starting with ">", "#", or a fence: markdown-it and cmark-gfm
20
+ * can disagree on whether it continues the item's last paragraph or opens indented code.
21
+ * Code (fenced or indented) is verbatim and never checked against these rules.
22
+ */
23
+
24
+ const MarkdownIt = require('./vendor/markdown-it.min.js');
25
+
26
+ const md = new MarkdownIt('default', { html: true, linkify: false });
27
+ // Keep entities and escapes as separate tokens (text_join would merge them into plain text
28
+ // and hide that an entity was used).
29
+ md.core.ruler.disable('text_join');
30
+
31
+ const BOM = '\uFEFF';
32
+ // Non-ASCII whitespace, form feed and vertical tab, and invisible or bidirectional
33
+ // formatting characters.
34
+ const INVISIBLE_RE = /[\f\v\u00A0\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180E\u1680\u2000-\u200F\u2028-\u202F\u205F-\u206F\u3000\u3164\uFEFF\uFFA0]/;
35
+ // Letters that look like Latin ones (Cyrillic, Greek, Armenian, IPA, small capitals), and
36
+ // colon look-alikes, folded before comparing.
37
+ const CONFUSABLES = new Map(Object.entries({
38
+ '\u0430': 'a', '\u0435': 'e', '\u043E': 'o', '\u0440': 'p', '\u0441': 'c', '\u0443': 'y', '\u0445': 'x', '\u0455': 's',
39
+ '\u0456': 'i', '\u0458': 'j', '\u0501': 'd', '\u04CF': 'l', '\u0442': 't', '\u0432': 'b', '\u043A': 'k', '\u043C': 'm',
40
+ '\u043D': 'h', '\u057D': 'u', '\u03C5': 'u', '\u03C4': 't', '\u03B9': 'i', '\u03BF': 'o', '\u03B1': 'a', '\u03BD': 'v',
41
+ '\u03BA': 'k', '\u03C1': 'p', '\u03B5': 'e', '\u0251': 'a', '\u0261': 'g', '\u0131': 'i', '\u0237': 'j', '\u0269': 'i',
42
+ '\u028F': 'y', '\u1D1B': 't', '\uA731': 's', '\u1D1C': 'u', '\u1D00': 'a', '\u1D07': 'e', '\u1D0F': 'o', '\u0280': 'r',
43
+ '\u02D0': ':', '\uA789': ':', '\u0589': ':', '\u2236': ':', '\u02F8': ':', '\u05C3': ':', '\u0703': ':', '\u0704': ':'
44
+ }));
45
+ // A text reduced to lowercase ASCII letters, digits and colons, look-alikes folded.
46
+ const skeleton = text => [...text.normalize('NFKC').toLowerCase()].map(c => CONFUSABLES.get(c) || c).join('').replace(/[^a-z0-9:]/g, '');
47
+
48
+ const FIELDS = [
49
+ { name: 'Status', key: 'status' },
50
+ { name: 'Verification Command', key: 'verificationcommand' },
51
+ { name: 'Expected Output', key: 'expectedoutput' },
52
+ { name: 'Last Verified', key: 'lastverified' }
53
+ ];
54
+ const LIST_ITEM_RE = /^( *)([*+-]|\d{1,9}[.)])( {1,4}|$)/;
55
+ const CHECKBOX_RE = /^\[([ xX])\](?=[ \t]|$)/;
56
+ // A footnote definition at the start of a line, after any blockquote or list markers.
57
+ const FOOTNOTE_RE = /^(?:[ \t]*(?:>|[*+-]|\d{1,9}[.)])?)*[ \t]*\[\^[^\]]*\]:/;
58
+ const RAW_HTML = 'raw HTML (HTML comments included) is not supported in the specification: it can hide or change what renders. Use Markdown, or put it in a code span.';
59
+ const TABLE_NOT_SUPPORTED = 'GFM tables are not supported in the specification. Use a list instead.';
60
+
61
+ // Nests the flat token stream: every *_open token becomes a node holding its children.
62
+ function tokenTree(tokens) {
63
+ const root = { type: 'root', children: [], parent: null };
64
+ let current = root;
65
+ for (const token of tokens) {
66
+ if (token.nesting === 1) {
67
+ const node = { type: token.type.replace(/_open$/, ''), token, children: [], parent: current };
68
+ current.children.push(node);
69
+ current = node;
70
+ } else if (token.nesting === -1) {
71
+ current = current.parent;
72
+ } else {
73
+ current.children.push({ type: token.type, token, children: [], parent: current });
74
+ }
75
+ }
76
+ return root;
77
+ }
78
+
79
+ function* walk(node) {
80
+ for (const child of node.children) {
81
+ yield child;
82
+ yield* walk(child);
83
+ }
84
+ }
85
+
86
+ function inside(node, types) {
87
+ for (let p = node.parent; p; p = p.parent) if (types.includes(p.type)) return true;
88
+ return false;
89
+ }
90
+
91
+ // The rendered text of an inline token, one entry per rendered line, with the text of an
92
+ // emphasis that opens the line (null when the line does not open with one).
93
+ function renderedLines(inline) {
94
+ const lines = [{ text: '', emphasis: null }];
95
+ let depth = 0;
96
+ let started = false;
97
+ for (const child of inline.children || []) {
98
+ const line = lines[lines.length - 1];
99
+ if (child.type === 'softbreak' || child.type === 'hardbreak') {
100
+ lines.push({ text: '', emphasis: null });
101
+ depth = 0;
102
+ started = false;
103
+ } else if (/^(strong|em|s)_open$/.test(child.type)) {
104
+ if (!started && depth === 0) line.emphasis = '';
105
+ depth++;
106
+ } else if (/^(strong|em|s)_close$/.test(child.type)) {
107
+ depth = Math.max(0, depth - 1);
108
+ if (depth === 0) started = true;
109
+ } else {
110
+ const text = child.content || '';
111
+ if (line.emphasis !== null && depth > 0 && !started) line.emphasis += text;
112
+ if (depth === 0 && text.trim() !== '') started = true;
113
+ line.text += text;
114
+ }
115
+ }
116
+ return lines;
117
+ }
118
+
119
+ // The field a rendered line reads as, if any. A checkbox literal ("[x] ", "[ ] ") at the
120
+ // start is stripped first: its letter (the "x" of a checked box) would otherwise land in
121
+ // the skeleton and hide a task whose title reads as a field, such as "[x] **Status:** ...".
122
+ function fieldOf({ text, emphasis }) {
123
+ const shape = skeleton(text.replace(/^\[[ xX]\]\s*/, ''));
124
+ return FIELDS.find(f => shape.startsWith(`${f.key}:`) || (emphasis !== null && skeleton(emphasis).startsWith(f.key))) || null;
125
+ }
126
+
127
+ function firstInline(item) {
128
+ const first = item.children[0];
129
+ return first && first.type === 'paragraph' ? first.children.find(c => c.type === 'inline') : null;
130
+ }
131
+
132
+ function firstCode(node) {
133
+ for (const child of walk(node)) if (child.type === 'fence' || child.type === 'code_block') return child.token;
134
+ return null;
135
+ }
136
+
137
+ const codeText = token => token.content.replace(/\n$/, '');
138
+
139
+ // Trims trailing blank lines from a [start, end) line range.
140
+ function trimRange([start, end], lines) {
141
+ let stop = end;
142
+ while (stop > start + 1 && /^[ \t]*$/.test(lines[stop - 1] || '')) stop--;
143
+ return [start, stop];
144
+ }
145
+
146
+ // The column where a list item's own content starts: past every list marker at the start of
147
+ // its first line. Usually one marker, but a list can start nested on the same source line
148
+ // ("* * ...", "1. - ..."), so every marker up to the content is consumed, not only the first.
149
+ function contentColumn(line) {
150
+ let column = 0;
151
+ let rest = line;
152
+ let marker;
153
+ while ((marker = rest.match(LIST_ITEM_RE))) {
154
+ const consumed = marker[1].length + marker[2].length + (marker[3] ? marker[3].length : 1);
155
+ column += consumed;
156
+ rest = rest.slice(consumed);
157
+ }
158
+ return column;
159
+ }
160
+
161
+ // A line indented 4 or more spaces but fewer than the content column of the list item that
162
+ // ends right before it, and that would start a blockquote, heading, or fenced code once its
163
+ // indentation is read as one of those (rather than as part of the code that indentation would
164
+ // otherwise open): cmark-gfm reads it as continuing that item's last paragraph (lazy
165
+ // continuation); markdown-it reads it as indented code, ending the item there instead. Every
166
+ // case the tenth review found this way hid a task or swapped the verification command, so it
167
+ // is rejected rather than guessed at either way.
168
+ const AMBIGUOUS_CONTINUATION_RE = /^(?:>|#|```|~~~)/;
169
+ function checkAmbiguousContinuations(tree, lines, at) {
170
+ const flagged = new Set();
171
+ for (const node of walk(tree)) {
172
+ if (node.type !== 'list_item' || !node.token.map) continue;
173
+ const boundary = node.token.map[1];
174
+ if (boundary >= lines.length || flagged.has(boundary)) continue;
175
+ const line = lines[boundary];
176
+ const indent = (line.match(/^ */) || [''])[0].length;
177
+ const column = contentColumn(lines[node.token.map[0]]);
178
+ if (indent < 4 || indent >= column || !AMBIGUOUS_CONTINUATION_RE.test(line.slice(indent))) continue;
179
+ flagged.add(boundary);
180
+ at(boundary, `this line is indented ${indent} space(s): less than the ${column} the list item above needs for its own content, but 4 or more. GitHub and the parser can disagree on whether it continues that item or starts indented code. Indent it to column ${column} to keep it in the item, or under 4 spaces to end the item.`);
181
+ }
182
+ }
183
+
184
+ // The line of the first list item nested under `node` (at any depth) that is itself a
185
+ // task, or null. A task nested under the Evidence item (indented to its content column) is
186
+ // not part of the evidence: it must keep its own place when evidence is rewritten.
187
+ function firstNestedTaskLine(node) {
188
+ for (const child of walk(node)) {
189
+ if (child.type !== 'inline') continue;
190
+ const paragraph = child.parent.type === 'paragraph' ? child.parent : null;
191
+ const li = paragraph && paragraph.parent.type === 'list_item' && paragraph.parent.children[0] === paragraph ? paragraph.parent : null;
192
+ if (li && li !== node && CHECKBOX_RE.test(child.token.content)) return li.token.map[0];
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function readTask(item, inline, lines) {
198
+ const [, mark] = inline.content.match(CHECKBOX_RE);
199
+ const rest = inline.content.replace(CHECKBOX_RE, '').split('\n')[0].trim();
200
+ const bold = rest.match(/^\*\*([A-Za-z0-9_.-]+):\*\*\s*(.*)$/);
201
+ const range = trimRange(item.token.map, lines);
202
+ const task = {
203
+ id: bold ? bold[1] : `line ${range[0] + 1}`,
204
+ line: range[0],
205
+ checked: mark !== ' ',
206
+ title: (bold ? bold[2] : rest).trim(),
207
+ evidence: { header: '', text: '', code: null, range: null },
208
+ quoted: inside(item, ['blockquote']),
209
+ blockEnd: range[1]
210
+ };
211
+ // Evidence: the first item of the task's own sub-lists whose text starts with the label.
212
+ for (const list of item.children.filter(c => c.type === 'bullet_list' || c.type === 'ordered_list')) {
213
+ const evidence = list.children.find(sub => {
214
+ const first = firstInline(sub);
215
+ return first && /^\*\*Evidence:\*\*/.test(first.token.content);
216
+ });
217
+ if (!evidence) continue;
218
+ // A task nested under Evidence (see firstNestedTaskLine) ends the evidence's own
219
+ // content: its text, code and range stop there, so rewriting evidence cannot delete it.
220
+ const nestedTaskLine = firstNestedTaskLine(evidence);
221
+ const ownEnd = nestedTaskLine !== null ? nestedTaskLine : evidence.token.map[1];
222
+ const first = firstInline(evidence).token;
223
+ const [head, ...more] = first.content.split('\n');
224
+ const header = head.replace(/^\*\*Evidence:\*\*/, '').trim();
225
+ const text = [header, ...more];
226
+ let code = null;
227
+ for (const node of walk(evidence)) {
228
+ if (!node.token || !node.token.map || node.token.map[0] >= ownEnd) continue;
229
+ if (node.type === 'inline' && node.token !== first) text.push(node.token.content);
230
+ if (node.type === 'fence' || node.type === 'code_block') {
231
+ text.push(codeText(node.token));
232
+ if (code === null) code = node.token;
233
+ }
234
+ }
235
+ task.evidence = {
236
+ header,
237
+ text: text.filter(t => t.trim() !== '').join('\n').trim(),
238
+ code: code ? codeText(code) : null,
239
+ range: trimRange([evidence.token.map[0], ownEnd], lines)
240
+ };
241
+ break;
242
+ }
243
+ return task;
244
+ }
245
+
246
+ /*
247
+ * The specification as it renders: tasks with their evidence, the lines that read as
248
+ * fields, and the problems found by the subset checks. `lines` are the normalized source
249
+ * lines (LF; a BOM stays on line 0 so line numbers match the file).
250
+ */
251
+ function readDocument(lines) {
252
+ const env = {};
253
+ const tree = tokenTree(md.parse(lines.join('\n').replace(/^\uFEFF/, ''), env));
254
+ const problems = [];
255
+ const at = (line, what) => problems.push(`Line ${line + 1}: ${what}`);
256
+
257
+ // Code is verbatim: note its lines so the source checks skip them. Tabs are still
258
+ // checked where they decide the code's own indentation (fence lines, the columns a fence
259
+ // strips from its content, the margin of indented code), since renderers expand them
260
+ // differently there.
261
+ const code = new Set();
262
+ const tabbed = 'a tab decides the indentation of this code block, which renderers expand differently. Indent with spaces.';
263
+ for (const node of walk(tree)) {
264
+ if ((node.type !== 'fence' && node.type !== 'code_block') || !node.token.map) continue;
265
+ const [first, end] = node.token.map;
266
+ for (let i = first; i < end; i++) code.add(i);
267
+ if (node.type === 'code_block') {
268
+ for (let i = first; i < end; i++) if (/^[ >]*\t/.test(lines[i])) at(i, tabbed);
269
+ continue;
270
+ }
271
+ const indent = lines[first].match(/^[ >]*/)[0].length;
272
+ if (/^[ >]*\t/.test(lines[first]) || /^[ >]*\t/.test(lines[end - 1] || '')) at(first, tabbed);
273
+ for (let i = first + 1; i < end - 1; i++) if (lines[i].slice(0, indent).includes('\t')) at(i, tabbed);
274
+ // An unclosed fence runs to the end of its container (CommonMark, not an error there),
275
+ // but the template requires closed fences: later lines (a field, a task) would silently
276
+ // become its content instead of rendering as themselves.
277
+ const ch = node.token.markup[0];
278
+ const closeRe = new RegExp(`^[ >]*${ch === '`' ? '`' : '~'}{${node.token.markup.length},}[ \\t]*$`);
279
+ if (!closeRe.test(lines[end - 1] || '')) at(first, 'this fenced code block has no closing fence: it runs to the end of its container, and later lines are read as its content. Close it with a matching fence.');
280
+ }
281
+ lines.forEach((line, i) => {
282
+ if (code.has(i)) return;
283
+ const text = i === 0 && line.startsWith(BOM) ? line.slice(1) : line;
284
+ if (/^[ >]*\t/.test(text)) at(i, 'indent with spaces, not tabs (Markdown expands a tab to 4 columns).');
285
+ if (INVISIBLE_RE.test(text)) at(i, 'non-ASCII whitespace or an invisible character can disguise text. Use plain spaces and remove invisible characters.');
286
+ // GitHub's footnotes can interrupt a paragraph, where markdown-it reads plain text.
287
+ if (FOOTNOTE_RE.test(text)) at(i, 'footnotes are not supported (GitHub and the parser read them differently). Use an inline link or a sentence.');
288
+ });
289
+ const references = Object.keys(env.references || {});
290
+ if (references.length > 0) {
291
+ problems.push(`Link reference definitions and footnotes are not supported (they render nothing and can hide lines): ${references.map(r => `[${r}]`).join(', ')}. Use inline links: [text](url).`);
292
+ }
293
+ checkAmbiguousContinuations(tree, lines, at);
294
+
295
+ const tasks = [];
296
+ const fieldLines = [];
297
+ for (const node of walk(tree)) {
298
+ if (node.type === 'html_block') at(node.token.map[0], RAW_HTML);
299
+ // Table cells have no source map (their content can span or be split across the
300
+ // row's source line), so they cannot be tied to a line: reject tables instead of
301
+ // guessing at one.
302
+ if (node.type === 'table') at(node.token.map[0], TABLE_NOT_SUPPORTED);
303
+ if (node.type !== 'inline') continue;
304
+ const inline = node.token;
305
+ if (!inline.map) continue;
306
+ const start = inline.map[0];
307
+ for (const child of inline.children || []) {
308
+ if (child.type === 'html_inline') at(start, RAW_HTML);
309
+ if (child.info === 'entity') at(start, `HTML entities (such as ${child.markup}) can disguise text. Write the character itself.`);
310
+ }
311
+ const paragraph = node.parent.type === 'paragraph' ? node.parent : null;
312
+ const item = paragraph && paragraph.parent.type === 'list_item' && paragraph.parent.children[0] === paragraph ? paragraph.parent : null;
313
+ const isTask = Boolean(item) && CHECKBOX_RE.test(inline.content);
314
+ const sourceLines = inline.content.split('\n');
315
+ renderedLines(inline).forEach((rendered, k) => {
316
+ const field = fieldOf(rendered);
317
+ if (field) fieldLines.push({ field, node, item: k === 0 ? item : null, line: start + k, source: (sourceLines[k] || '').trimEnd(), rendered });
318
+ // A list marker can precede the checkbox here too ("* [ ] ..."), not just the
319
+ // checkbox alone: an over-indented line following a blockquote or list item can
320
+ // render as continuation text that still reads as a whole task, marker included.
321
+ if (!(isTask && k === 0) && /^\s*(?:[*+-]|\d{1,9}[.)])?\s*\[[ xX]\]/.test(rendered.text)) {
322
+ at(start + k, 'this reads as a task checkbox but is not the first line of a list item ("* [ ] **T1:** ..."), so it is not a task. Fix the list marker or remove the brackets.');
323
+ }
324
+ });
325
+ if (isTask) tasks.push(readTask(item, inline, lines));
326
+ }
327
+ return { tree, tasks, fieldLines, problems };
328
+ }
329
+
330
+ // The "Verification Gate" section: its top-level nodes and its [from, to) line range.
331
+ function gateSection(tree, lines) {
332
+ const top = tree.children;
333
+ const heading = n => n.type === 'heading';
334
+ const start = top.findIndex(n => heading(n) && n.token.tag === 'h2'
335
+ && /^(?:\d+\.\s*)?Verification Gate\b/i.test(n.children[0].token.content));
336
+ if (start === -1) return null;
337
+ let end = top.findIndex((n, i) => i > start && heading(n) && Number(n.token.tag.slice(1)) <= 2);
338
+ if (end === -1) end = top.length;
339
+ return {
340
+ nodes: top.slice(start + 1, end),
341
+ from: top[start].token.map[0],
342
+ to: end < top.length ? top[end].token.map[0] : lines.length
343
+ };
344
+ }
345
+
346
+ module.exports = { LIST_ITEM_RE, FIELDS, skeleton, readDocument, gateSection, firstCode, codeText, walk, inside };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * scripts/lib/spec.js
3
+ *
4
+ * Parser and writers for the Lite Mode specification (docs/SPEC.md, from
5
+ * docs/SPEC_TEMPLATE.md): status, task checklist with evidence, verification gate.
6
+ *
7
+ * The spec is read as it renders (see spec-markup.js): a task is a list item whose first
8
+ * paragraph starts with a checkbox (bullets or numbered, also inside blockquotes); its ID is
9
+ * the bold `**T1:**` prefix when present, otherwise "line N". Its evidence is the
10
+ * `**Evidence:**` item of its sub-list, and the command is the first code block rendered in
11
+ * the `**Verification Command:**` item. Markup outside the supported subset is reported as a
12
+ * problem. Line endings are normalized before parsing and preserved when writing.
13
+ *
14
+ * Records written by sdd-verify carry integrity hashes: task evidence covers its date, exit
15
+ * code and transcript; "Last Verified" covers every field plus the verification command and
16
+ * expected output. Editing a record by hand is detected. The hashes are not keyed, so they
17
+ * cannot stop someone who deliberately recomputes them; the authoritative check is running
18
+ * `sdd-verify` again, for example in CI.
19
+ */
20
+
21
+ const crypto = require('crypto');
22
+ const { normalizeEol, detectEol } = require('./markdown');
23
+ const { LIST_ITEM_RE, readDocument, gateSection, firstCode, codeText } = require('./spec-markup');
24
+
25
+ const STATUSES = ['draft', 'in progress', 'completed'];
26
+
27
+ // A template placeholder is a value that is nothing but one bracketed phrase, such as
28
+ // "[command to run tests or validation scripts]". Real commands like `[ -f x ] && make`
29
+ // contain more than a single bracketed span.
30
+ const isPlaceholder = text => /^\[[^\]]*\]$/.test(text.trim());
31
+
32
+ const TEMPLATE_STATUS = 'Draft | In Progress | Completed';
33
+ const LAST_VERIFIED_VALUE_RE =
34
+ /^(\d{4}-\d{2}-\d{2}) (PASS|FAIL) \(commit ([^,()]+), exit ([^,()]+), state ([0-9a-f]{16}), check ([0-9a-f]{16})\)$/;
35
+ const RECORDED_EVIDENCE_RE = /^sdd-verify (\d{4}-\d{2}-\d{2}), exit (-?\d+|timeout|signal \w+), sha256 ([0-9a-f]{16})$/;
36
+ const GATE_FIELDS = ['Verification Command', 'Expected Output', 'Last Verified'];
37
+
38
+ const shortHash = text => crypto.createHash('sha256').update(text).digest('hex').slice(0, 16);
39
+
40
+ // Integrity hash of task evidence: date, exit code and transcript together.
41
+ function evidenceHash(date, exit, transcript) {
42
+ return shortHash(`${date}\n${exit}\n${transcript}`);
43
+ }
44
+
45
+ // Integrity hash of a Last Verified record, bound to the command and expected output.
46
+ function lastVerifiedCheck({ date, result, commit, exit, state }, command, expected) {
47
+ return shortHash([date, result, commit, exit, state, command, expected].join('\n'));
48
+ }
49
+
50
+ function parseLastVerified(value) {
51
+ const match = value.match(LAST_VERIFIED_VALUE_RE);
52
+ if (!match) return null;
53
+ return { date: match[1], result: match[2], commit: match[3], exit: match[4], state: match[5], check: match[6] };
54
+ }
55
+
56
+ function formatLastVerified(fields, command, expected) {
57
+ const check = lastVerifiedCheck(fields, command, expected);
58
+ return `${fields.date} ${fields.result} (commit ${fields.commit}, exit ${fields.exit}, state ${fields.state}, check ${check})`;
59
+ }
60
+
61
+ // The Status: exactly one line reads as a Status, written "**Status:** <value>" in a
62
+ // top-level paragraph. Its value is taken as rendered.
63
+ function readStatus(fieldLines, problems) {
64
+ const found = fieldLines.filter(f => f.field.name === 'Status');
65
+ if (found.length === 0) return { status: null, problem: 'No "**Status:**" line.' };
66
+ if (found.length > 1) {
67
+ return { status: null, problem: `${found.length} lines read as a Status (lines ${found.map(f => f.line + 1).join(', ')}); keep exactly one.` };
68
+ }
69
+ const [line] = found;
70
+ const topLevel = line.node.parent.type === 'paragraph' && line.node.parent.parent.type === 'root';
71
+ if (!topLevel || !/^\*\*Status:\*\* \S/.test(line.source)) {
72
+ problems.push(`Line ${line.line + 1}: write the Status line exactly as in the template ("**Status:** <value>" at the start of a line, outside lists and blockquotes).`);
73
+ return { status: null, problem: null };
74
+ }
75
+ const text = line.rendered.text;
76
+ const raw = text.slice(text.indexOf(':') + 1).trim();
77
+ if (raw === TEMPLATE_STATUS) return { status: null, problem: null }; // untouched template
78
+ const value = raw.toLowerCase();
79
+ if (STATUSES.includes(value)) return { status: value, problem: null };
80
+ return { status: null, problem: `Unknown status "${raw}". Use Draft, In Progress, or Completed.` };
81
+ }
82
+
83
+ // The gate fields: each one at most once, as a top-level list item of the Verification Gate
84
+ // section that starts with "**<Field>:**". Returns the list item node of each.
85
+ function readGateFields(fieldLines, section, problems) {
86
+ const items = {};
87
+ for (const name of GATE_FIELDS) {
88
+ const found = fieldLines.filter(f => f.field.name === name);
89
+ if (found.length > 1) problems.push(`${found.length} lines read as "${name}" (lines ${found.map(f => f.line + 1).join(', ')}); keep exactly one, in the Verification Gate section.`);
90
+ for (const line of found) {
91
+ const list = line.item && line.item.parent;
92
+ const exact = line.item && section && section.nodes.includes(list) && line.source.startsWith(`**${name}:**`);
93
+ if (!exact) {
94
+ problems.push(`Line ${line.line + 1}: write the ${name} line exactly as in the template ("* **${name}:**" as a top-level list item of the Verification Gate section).`);
95
+ } else if (found.length === 1) {
96
+ items[name] = line;
97
+ }
98
+ }
99
+ }
100
+ return items;
101
+ }
102
+
103
+ function parseSpec(rawText) {
104
+ const lines = normalizeEol(rawText).split('\n');
105
+ const doc = readDocument(lines);
106
+ const problems = [...doc.problems];
107
+ const section = gateSection(doc.tree, lines);
108
+ const fields = readGateFields(doc.fieldLines, section, problems);
109
+ let gate = null;
110
+ if (section) {
111
+ const codeOf = name => {
112
+ const code = fields[name] ? firstCode(fields[name].item) : null;
113
+ return code ? codeText(code) : null;
114
+ };
115
+ const command = codeOf('Verification Command');
116
+ const expected = codeOf('Expected Output');
117
+ const last = fields['Last Verified'];
118
+ const lastValue = last ? last.source.slice('**Last Verified:**'.length).trim() : '';
119
+ gate = {
120
+ command: command && !isPlaceholder(command) ? command.trim() : '',
121
+ expected: expected && !isPlaceholder(expected) ? expected.trim() : '',
122
+ lastVerified: lastValue && !isPlaceholder(lastValue) ? lastValue : '',
123
+ lastVerifiedParsed: parseLastVerified(lastValue),
124
+ lastVerifiedLine: last ? last.line : null
125
+ };
126
+ if (gate.lastVerifiedParsed) {
127
+ gate.lastVerifiedParsed.intact =
128
+ lastVerifiedCheck(gate.lastVerifiedParsed, gate.command, gate.expected) === gate.lastVerifiedParsed.check;
129
+ }
130
+ }
131
+ const tasks = doc.tasks.map(task => {
132
+ const { header, code, range } = task.evidence;
133
+ const text = isPlaceholder(header) ? task.evidence.text.replace(header, '').trim() : task.evidence.text;
134
+ const recordedMatch = header.match(RECORDED_EVIDENCE_RE);
135
+ const recorded = recordedMatch
136
+ ? {
137
+ date: recordedMatch[1],
138
+ exit: recordedMatch[2],
139
+ hash: recordedMatch[3],
140
+ intact: code !== null && evidenceHash(recordedMatch[1], recordedMatch[2], code) === recordedMatch[3]
141
+ }
142
+ : null;
143
+ return { ...task, evidence: { text, recorded, range } };
144
+ });
145
+ const { status, problem } = readStatus(doc.fieldLines, problems);
146
+ return { status, statusProblem: problem, tasks, gate, gateRange: section ? [section.from, section.to] : null, hiddenProblems: problems };
147
+ }
148
+
149
+ function withEol(original, normalizedLines) {
150
+ return normalizedLines.join(detectEol(original));
151
+ }
152
+
153
+ // Adds or replaces the "Last Verified" bullet inside the verification gate section.
154
+ function withLastVerified(rawText, value) {
155
+ const lines = normalizeEol(rawText).split('\n');
156
+ const spec = parseSpec(rawText);
157
+ if (!spec.gateRange) throw new Error('No "Verification Gate" section found.');
158
+ if (spec.gate.lastVerifiedLine !== null) {
159
+ const at = spec.gate.lastVerifiedLine;
160
+ lines[at] = lines[at].replace(/\*\*Last Verified:\*\*.*$/, `**Last Verified:** ${value}`);
161
+ } else {
162
+ const [from, to] = spec.gateRange;
163
+ let insertAt = to;
164
+ while (insertAt > from + 1 && lines[insertAt - 1].trim() === '') insertAt--;
165
+ lines.splice(insertAt, 0, `* **Last Verified:** ${value}`);
166
+ }
167
+ return withEol(rawText, lines);
168
+ }
169
+
170
+ // Evidence recorded by `sdd-verify --task`: a header with the exit code and a hash of
171
+ // the fenced transcript, so later edits to the transcript are detectable.
172
+ function formatRecordedEvidence({ date, exit, transcript, indent }) {
173
+ const pad = ' '.repeat(indent);
174
+ const content = transcript.replace(/\s+$/, '');
175
+ // A backtick fence longer than any backtick run in the content cannot close early.
176
+ const longestRun = Math.max(0, ...(content.match(/`+/g) || []).map(run => run.length));
177
+ const fenceMarker = '`'.repeat(Math.max(3, longestRun + 1));
178
+ return [
179
+ `${pad}* **Evidence:** sdd-verify ${date}, exit ${exit}, sha256 ${evidenceHash(date, exit, content)}`,
180
+ `${pad} ${fenceMarker}text`,
181
+ ...content.split('\n').map(l => (l ? `${pad} ${l}` : '')),
182
+ `${pad} ${fenceMarker}`
183
+ ];
184
+ }
185
+
186
+ // The column where a task's own content ("[ ] **T1:** ...") starts: past every list marker
187
+ // at the start of its line. Usually one marker ("* " is 2, "1. " is 3, "10. " is 4), but a
188
+ // list item can nest its whole list on one source line ("* * [ ]", "1. - [ ]"), so every
189
+ // marker up to the checkbox is consumed, not only the first.
190
+ function taskContentColumn(line) {
191
+ let column = 0;
192
+ let rest = line;
193
+ let marker;
194
+ while ((marker = rest.match(LIST_ITEM_RE))) {
195
+ const consumed = marker[1].length + marker[2].length + (marker[3] ? marker[3].length : 1);
196
+ column += consumed;
197
+ rest = rest.slice(consumed);
198
+ }
199
+ return column;
200
+ }
201
+
202
+ // Replaces (or adds) the Evidence field of a task and checks its box.
203
+ function withTaskEvidence(rawText, taskId, evidence) {
204
+ const lines = normalizeEol(rawText).split('\n');
205
+ const task = parseSpec(rawText).tasks.find(t => t.id === taskId);
206
+ if (!task) throw new Error(`Task ${taskId} not found in the specification.`);
207
+ if (task.quoted) throw new Error(`Task ${taskId} is inside a blockquote; move it out before recording evidence.`);
208
+ // Evidence goes at the item's own content column, so it renders inside the list item.
209
+ const indent = taskContentColumn(lines[task.line]);
210
+ const block = formatRecordedEvidence({ ...evidence, indent });
211
+ if (task.evidence.range) {
212
+ const [start, end] = task.evidence.range;
213
+ let stop = end;
214
+ while (stop > start + 1 && lines[stop - 1].trim() === '') stop--;
215
+ lines.splice(start, stop - start, ...block);
216
+ } else {
217
+ lines.splice(task.blockEnd, 0, ...block);
218
+ }
219
+ lines[task.line] = lines[task.line].slice(0, indent) + lines[task.line].slice(indent).replace(/^\[ \]/, '[x]');
220
+ return withEol(rawText, lines);
221
+ }
222
+
223
+ module.exports = {
224
+ STATUSES, isPlaceholder, shortHash, parseSpec, parseLastVerified, formatLastVerified, lastVerifiedCheck,
225
+ evidenceHash, withLastVerified, withTaskEvidence, formatRecordedEvidence
226
+ };