thoth-agents 0.4.1 → 0.5.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 (117) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +86 -58
  3. package/dist/agents/prompt-sections.d.ts +5 -3
  4. package/dist/agents/prompt-utils.d.ts +1 -1
  5. package/dist/agents/worker.d.ts +2 -0
  6. package/dist/{chunk-STCURXAQ.js → chunk-MRFCA3U2.js} +33 -67
  7. package/dist/{chunk-EFGSGPYM.js → chunk-OW6I7ZUU.js} +391 -416
  8. package/dist/chunk-V7HSGRMB.js +1016 -0
  9. package/dist/{chunk-2D7E7GMP.js → chunk-VUNYH263.js} +1650 -380
  10. package/dist/cli/claude-code-paths.d.ts +2 -2
  11. package/dist/cli/codex-install.d.ts +5 -2
  12. package/dist/cli/codex-paths.d.ts +2 -2
  13. package/dist/cli/index.js +47 -11
  14. package/dist/cli/model-defaults.d.ts +4 -0
  15. package/dist/cli/operations/pi.d.ts +1 -0
  16. package/dist/cli/pi-effort.d.ts +1 -1
  17. package/dist/cli/pi-external-package.d.ts +24 -0
  18. package/dist/cli/pi-git-source.d.ts +1 -0
  19. package/dist/cli/pi-install.d.ts +92 -15
  20. package/dist/cli/pi-managed-write.d.ts +3 -0
  21. package/dist/cli/pi-model-config.d.ts +17 -0
  22. package/dist/cli/pi-native-probe.d.ts +2 -0
  23. package/dist/cli/pi-paths.d.ts +2 -0
  24. package/dist/cli/pi-resources.d.ts +3 -1
  25. package/dist/cli/pi-tool-config.d.ts +32 -0
  26. package/dist/cli/tui/index.js +32 -7
  27. package/dist/cli/tui/operations.d.ts +3 -1
  28. package/dist/cli/types.d.ts +2 -0
  29. package/dist/config/constants.d.ts +4 -8
  30. package/dist/config/schema.d.ts +7 -36
  31. package/dist/harness/adapters/claude-code.d.ts +2 -4
  32. package/dist/harness/core/agent-pack.d.ts +52 -42
  33. package/dist/harness/core/memory-governance.d.ts +2 -2
  34. package/dist/harness/core/sdd.d.ts +159 -319
  35. package/dist/harness/writers/pi-agent.d.ts +3 -1
  36. package/dist/index.js +12 -6
  37. package/dist/pi/tools-panel.d.ts +51 -0
  38. package/dist/pi.d.ts +44 -2
  39. package/dist/pi.js +471 -9
  40. package/dist/utils/subprocess.d.ts +1 -0
  41. package/package.json +18 -4
  42. package/pi/.thoth-agents-assets.json +5 -6
  43. package/pi/agents/thoth-designer.md +23 -8
  44. package/pi/agents/thoth-explorer.md +29 -9
  45. package/pi/agents/thoth-librarian.md +30 -8
  46. package/pi/agents/thoth-oracle.md +18 -10
  47. package/pi/agents/thoth-worker.md +96 -0
  48. package/plugin/.claude-plugin/.thoth-agents-plugin-assets.json +7 -12
  49. package/plugin/.claude-plugin/plugin.json +2 -2
  50. package/plugin/.codex-plugin/plugin.json +2 -2
  51. package/plugin/agents/designer.md +13 -5
  52. package/plugin/agents/explorer.md +16 -5
  53. package/plugin/agents/librarian.md +15 -4
  54. package/plugin/agents/oracle.md +13 -7
  55. package/plugin/agents/orchestrator.md +82 -91
  56. package/plugin/agents/worker.md +76 -0
  57. package/plugin/skills/plan-reviewer/SKILL.md +39 -141
  58. package/plugin/skills/thoth-archive/SKILL.md +50 -47
  59. package/plugin/skills/thoth-archive/scripts/archive.mjs +287 -654
  60. package/plugin/skills/thoth-constitution/SKILL.md +23 -42
  61. package/plugin/skills/thoth-constitution/templates/constitution.md +54 -44
  62. package/plugin/skills/thoth-init/SKILL.md +19 -29
  63. package/plugin/skills/thoth-init/scripts/init.mjs +73 -66
  64. package/plugin/skills/thoth-sdd/SKILL.md +164 -117
  65. package/plugin/skills/thoth-sdd/references/phases/checklist.md +2 -22
  66. package/plugin/skills/thoth-sdd/references/phases/clarify.md +14 -12
  67. package/plugin/skills/thoth-sdd/references/phases/converge.md +2 -13
  68. package/plugin/skills/thoth-sdd/references/phases/explore.md +3 -8
  69. package/plugin/skills/thoth-sdd/references/phases/implement.md +58 -57
  70. package/plugin/skills/thoth-sdd/references/phases/plan.md +2 -18
  71. package/plugin/skills/thoth-sdd/references/phases/specify.md +2 -33
  72. package/plugin/skills/thoth-sdd/references/phases/tasks.md +4 -45
  73. package/plugin/skills/thoth-sdd/references/phases/verify.md +4 -22
  74. package/plugin/skills/thoth-sdd/scripts/validate.mjs +610 -1232
  75. package/plugin/skills/thoth-sdd/templates/change.md +94 -0
  76. package/skills/README.md +11 -13
  77. package/skills/plan-reviewer/SKILL.md +39 -141
  78. package/skills/thoth-archive/SKILL.md +50 -47
  79. package/skills/thoth-archive/scripts/archive.mjs +287 -654
  80. package/skills/thoth-constitution/SKILL.md +23 -42
  81. package/skills/thoth-constitution/templates/constitution.md +54 -44
  82. package/skills/thoth-init/SKILL.md +19 -29
  83. package/skills/thoth-init/scripts/init.mjs +73 -66
  84. package/skills/thoth-sdd/SKILL.md +164 -117
  85. package/skills/thoth-sdd/references/phases/checklist.md +2 -22
  86. package/skills/thoth-sdd/references/phases/clarify.md +14 -12
  87. package/skills/thoth-sdd/references/phases/converge.md +2 -13
  88. package/skills/thoth-sdd/references/phases/explore.md +3 -8
  89. package/skills/thoth-sdd/references/phases/implement.md +58 -57
  90. package/skills/thoth-sdd/references/phases/plan.md +2 -18
  91. package/skills/thoth-sdd/references/phases/specify.md +2 -33
  92. package/skills/thoth-sdd/references/phases/tasks.md +4 -45
  93. package/skills/thoth-sdd/references/phases/verify.md +4 -22
  94. package/skills/thoth-sdd/scripts/validate.mjs +610 -1232
  95. package/skills/thoth-sdd/templates/change.md +94 -0
  96. package/thoth-agents.schema.json +9 -77
  97. package/dist/agents/deep.d.ts +0 -2
  98. package/dist/agents/quick.d.ts +0 -2
  99. package/dist/chunk-2U5MBLLV.js +0 -312
  100. package/pi/agents/thoth-deep.md +0 -81
  101. package/pi/agents/thoth-quick.md +0 -81
  102. package/plugin/agents/deep.md +0 -68
  103. package/plugin/agents/quick.md +0 -68
  104. package/plugin/skills/plan-reviewer/templates/plan-review.md +0 -65
  105. package/plugin/skills/thoth-sdd/templates/archive-report.md +0 -25
  106. package/plugin/skills/thoth-sdd/templates/checklist.md +0 -27
  107. package/plugin/skills/thoth-sdd/templates/plan.md +0 -46
  108. package/plugin/skills/thoth-sdd/templates/spec.md +0 -62
  109. package/plugin/skills/thoth-sdd/templates/tasks.md +0 -58
  110. package/plugin/skills/thoth-sdd/templates/verify-report.md +0 -28
  111. package/skills/plan-reviewer/templates/plan-review.md +0 -65
  112. package/skills/thoth-sdd/templates/archive-report.md +0 -25
  113. package/skills/thoth-sdd/templates/checklist.md +0 -27
  114. package/skills/thoth-sdd/templates/plan.md +0 -46
  115. package/skills/thoth-sdd/templates/spec.md +0 -62
  116. package/skills/thoth-sdd/templates/tasks.md +0 -58
  117. package/skills/thoth-sdd/templates/verify-report.md +0 -28
@@ -1,1389 +1,767 @@
1
1
  #!/usr/bin/env node
2
-
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { join, posix, resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs';
4
+ import {
5
+ basename,
6
+ dirname,
7
+ isAbsolute,
8
+ join,
9
+ parse,
10
+ relative,
11
+ resolve,
12
+ sep,
13
+ } from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
5
15
  import {
6
- CAPABILITY_PATTERN,
7
16
  parseCanonicalSpec,
8
17
  parseRequirementDelta,
9
18
  preflightRequirementDeltas,
10
19
  } from './durable-deltas.mjs';
11
20
 
12
- function parseArgs(argv) {
13
- const values = { route: 'full', through: 'ready', json: false };
14
- for (let index = 0; index < argv.length; index += 1) {
15
- const argument = argv[index];
16
- if (argument === '--json') values.json = true;
17
- else if (argument === '--change') values.change = argv[++index];
18
- else if (argument === '--route') values.route = argv[++index];
19
- else if (argument === '--through') values.through = argv[++index];
20
- else throw new Error(`Unknown argument: ${argument}`);
21
- }
22
- if (!values.change) throw new Error('--change is required');
23
- if (!['direct', 'accelerated', 'full'].includes(values.route)) {
24
- throw new Error('--route must be direct, accelerated, or full');
25
- }
26
- if (
27
- !['specify', 'plan', 'tasks', 'checklist', 'ready', 'closeout'].includes(
28
- values.through,
29
- )
30
- ) {
31
- throw new Error(
32
- '--through must be specify, plan, tasks, checklist, ready, or closeout',
33
- );
34
- }
35
- return values;
36
- }
37
-
38
- function issue(code, artifact, message) {
39
- return { code, artifact, message };
21
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
22
+ const gates = [
23
+ 'explore',
24
+ 'specify',
25
+ 'clarify',
26
+ 'plan',
27
+ 'tasks',
28
+ 'checklist',
29
+ 'ready',
30
+ 'verify',
31
+ 'closeout',
32
+ ];
33
+ const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
34
+ const reservedWindowsName = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
35
+ const issue = (code, message, recordName = 'record') => ({
36
+ code,
37
+ artifact: recordName,
38
+ message,
39
+ });
40
+
41
+ function fail(code, message) {
42
+ const error = new Error(message);
43
+ error.code = code;
44
+ throw error;
40
45
  }
41
46
 
42
- function readArtifact(root, relativePath, errors, required = true) {
43
- const path = join(root, relativePath);
44
- if (!existsSync(path)) {
45
- if (required) {
46
- errors.push(
47
- issue(
48
- 'SDD-ARTIFACT-MISSING',
49
- relativePath,
50
- `Required artifact is missing: ${relativePath}`,
51
- ),
52
- );
53
- }
54
- return undefined;
47
+ function lstatMaybe(path) {
48
+ try {
49
+ return lstatSync(path);
50
+ } catch (error) {
51
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
52
+ throw error;
55
53
  }
56
- return readFileSync(path, 'utf8');
57
54
  }
58
55
 
59
- function valuesFor(pattern, content) {
60
- return [...content.matchAll(pattern)].map((match) => match[1]);
56
+ export function isValidSddChangeId(id) {
57
+ return (
58
+ typeof id === 'string' &&
59
+ slugPattern.test(id) &&
60
+ !reservedWindowsName.test(id)
61
+ );
61
62
  }
62
63
 
63
- function sectionContent(content, heading) {
64
- const expression = new RegExp(`^##\\s+${heading}\\s*$`, 'im');
65
- const match = expression.exec(content);
66
- if (!match || match.index === undefined) return undefined;
67
- const start = match.index + match[0].length;
68
- const remainder = content.slice(start);
69
- const nextHeading = /^##\s+/m.exec(remainder);
70
- return remainder.slice(0, nextHeading?.index ?? remainder.length).trim();
64
+ function isIsoDate(value) {
65
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '')) return false;
66
+ const date = new Date(`${value}T00:00:00.000Z`);
67
+ return (
68
+ !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value
69
+ );
71
70
  }
72
71
 
73
- function checkUniqueSequential(ids, prefix, artifact, errors) {
74
- if (new Set(ids).size !== ids.length) {
75
- errors.push(
76
- issue(
77
- `SDD-${prefix}-UNIQUE`,
78
- artifact,
79
- `${prefix} identifiers must be unique.`,
80
- ),
81
- );
82
- }
83
- const numbers = ids.map((id) => Number.parseInt(id, 10));
84
- if (numbers.some((value, index) => value !== index + 1)) {
85
- errors.push(
86
- issue(
87
- `SDD-${prefix}-SEQUENCE`,
88
- artifact,
89
- `${prefix} identifiers must start at 001 and remain sequential.`,
90
- ),
91
- );
72
+ export function assertNoSymlinkAncestors(path) {
73
+ const absolute = resolve(path);
74
+ const { root } = parse(absolute);
75
+ let cursor = root;
76
+ for (const part of absolute.slice(root.length).split(sep).filter(Boolean)) {
77
+ cursor = join(cursor, part);
78
+ if (lstatMaybe(cursor)?.isSymbolicLink()) {
79
+ fail('SDD-SYMLINK', `Symlinked path component is not allowed: ${cursor}`);
80
+ }
92
81
  }
93
82
  }
94
83
 
95
- function parseAffectedCapabilities(intentSection) {
96
- const match = /^\*\*Affected capabilities\*\*:\s*(.+)$/im.exec(
97
- intentSection ?? '',
98
- );
99
- if (!match) return undefined;
100
- const value = match[1].replace(/<br>\s*$/i, '').trim();
101
- if (/^None\.?$/i.test(value)) return [];
102
- const capabilities = valuesFor(/`([^`]+)`/g, value);
103
- return capabilities.length > 0 &&
104
- capabilities.every((item) => CAPABILITY_PATTERN.test(item))
105
- ? capabilities
106
- : undefined;
107
- }
108
-
109
- function contractCandidateLines(content, prefix) {
110
- const pattern = new RegExp(`^\\s*-\\s+(?:\\*\\*)?${prefix}\\b`, 'i');
111
- return content.split(/\r?\n/).filter((line) => pattern.test(line));
112
- }
84
+ export function resolveSddChangeLocation(change) {
85
+ const changeRoot = resolve(change);
86
+ assertNoSymlinkAncestors(changeRoot);
87
+ const parent = dirname(changeRoot);
88
+ let projectRoot;
89
+ let id;
90
+ let archiveDate;
91
+ let archived = false;
113
92
 
114
- function validateSpec(content, errors) {
115
- const artifact = 'spec.md';
116
- const intentSection = sectionContent(content, 'Intent and scope');
117
- const affectedCapabilities = parseAffectedCapabilities(intentSection);
118
93
  if (
119
- intentSection === undefined ||
120
- !/^\*\*Why\*\*:\s*\S.+$/im.test(intentSection) ||
121
- !/^\*\*Impact\*\*:\s*\S.+$/im.test(intentSection) ||
122
- affectedCapabilities === undefined
94
+ basename(parent) === 'changes' &&
95
+ basename(dirname(parent)) === '.thoth'
123
96
  ) {
124
- errors.push(
125
- issue(
126
- 'SDD-SPEC-INTENT',
127
- artifact,
128
- 'Intent and scope must define Why, Impact, and valid affected capabilities or None.',
129
- ),
97
+ projectRoot = dirname(dirname(parent));
98
+ id = basename(changeRoot);
99
+ } else if (
100
+ basename(parent) === 'archive' &&
101
+ basename(dirname(parent)) === 'changes' &&
102
+ basename(dirname(dirname(parent))) === '.thoth'
103
+ ) {
104
+ archived = true;
105
+ projectRoot = dirname(dirname(dirname(dirname(changeRoot))));
106
+ const match = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(basename(changeRoot));
107
+ if (!match || !isIsoDate(match[1])) {
108
+ fail(
109
+ 'SDD-CHANGE-PATH',
110
+ 'Archived change directory must use YYYY-MM-DD-<id>',
111
+ );
112
+ }
113
+ archiveDate = match[1];
114
+ id = match[2];
115
+ } else {
116
+ fail(
117
+ 'SDD-CHANGE-PATH',
118
+ 'Change must be an immediate .thoth/changes/<id> or dated archive child',
130
119
  );
131
120
  }
132
121
 
133
- const userStoriesSection = sectionContent(content, 'User stories');
134
- const edgeCasesSection = sectionContent(content, 'Edge cases');
135
- const functionalRequirementsSection = sectionContent(
136
- content,
137
- 'Functional requirements',
138
- );
139
- const successCriteriaSection = sectionContent(content, 'Success criteria');
140
- const dependenciesSection = sectionContent(content, 'Dependencies');
141
- const missingSections = [
142
- ['User stories', userStoriesSection],
143
- ['Edge cases', edgeCasesSection],
144
- ['Functional requirements', functionalRequirementsSection],
145
- ['Success criteria', successCriteriaSection],
146
- ['Dependencies', dependenciesSection],
147
- ]
148
- .filter(([, section]) => section === undefined)
149
- .map(([heading]) => heading);
150
- if (missingSections.length > 0) {
151
- errors.push(
152
- issue(
153
- 'SDD-SPEC-SECTIONS',
154
- artifact,
155
- `Canonical specification sections are missing: ${missingSections.join(', ')}.`,
156
- ),
157
- );
122
+ if (!isValidSddChangeId(id)) {
123
+ fail('SDD-CHANGE-ID', `Unsafe or reserved change ID: ${id}`);
158
124
  }
125
+ const recordPath = join(changeRoot, `${id}.md`);
126
+ assertNoSymlinkAncestors(recordPath);
127
+ return { projectRoot, changeRoot, id, recordPath, archived, archiveDate };
128
+ }
159
129
 
160
- const storyMatches = [
161
- ...(userStoriesSection ?? '').matchAll(
162
- /^###\s+US(\d+)\s+-.*\(Priority:\s*P\d+\)/gim,
163
- ),
164
- ];
165
- const storyIds = storyMatches.map((match) => match[1]);
166
- const storyCoverage = new Set();
167
- if (storyIds.length === 0) {
168
- errors.push(
169
- issue(
170
- 'SDD-SPEC-STORIES',
171
- artifact,
172
- 'Add prioritized US# stories with an independent test and acceptance scenarios.',
173
- ),
174
- );
175
- } else {
176
- checkUniqueSequential(storyIds, 'STORY', artifact, errors);
177
- for (const [index, match] of storyMatches.entries()) {
178
- const start = match.index ?? 0;
179
- const next = storyMatches[index + 1]?.index;
180
- const end = next ?? userStoriesSection.length;
181
- const story = userStoriesSection.slice(start, end);
182
- if (!/\*\*Independent test\*\*:\s*\S.+/i.test(story)) {
183
- errors.push(
184
- issue(
185
- 'SDD-SPEC-INDEPENDENCE',
186
- artifact,
187
- `US${match[1]} must describe an independent test.`,
188
- ),
189
- );
190
- }
191
- const covers = /^\*\*Covers\*\*:\s*(\S.+)$/im.exec(story);
192
- const coveredIds = covers?.[1].match(/\b(?:FR|SC)-\d{3}\b/g) ?? [];
193
- if (
194
- !coveredIds.some((id) => id.startsWith('FR-')) ||
195
- !coveredIds.some((id) => id.startsWith('SC-'))
196
- ) {
197
- errors.push(
198
- issue(
199
- 'SDD-SPEC-STORY-COVERAGE',
200
- artifact,
201
- `US${match[1]} must map to its FR-### and SC-### contracts.`,
202
- ),
203
- );
204
- }
205
- for (const id of coveredIds) storyCoverage.add(id);
206
- if (
207
- !/\*\*Given\*\*[\s\S]+?\*\*When\*\*[\s\S]+?\*\*Then\*\*\s+\S/i.test(
208
- story,
209
- )
210
- ) {
211
- errors.push(
212
- issue(
213
- 'SDD-SPEC-ACCEPTANCE',
214
- artifact,
215
- `US${match[1]} must include a Given/When/Then acceptance scenario.`,
216
- ),
217
- );
218
- }
219
- }
220
- }
130
+ function section(text, name, { trim = true } = {}) {
131
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
132
+ const match = new RegExp(`^## ${escaped}\\s*$`, 'im').exec(text);
133
+ if (!match) return undefined;
134
+ const tail = text.slice(match.index + match[0].length);
135
+ const content = tail.slice(0, /^## /m.exec(tail)?.index ?? tail.length);
136
+ return trim ? content.trim() : content;
137
+ }
221
138
 
222
- const functionalRequirements = [
223
- ...(functionalRequirementsSection ?? '').matchAll(
224
- /^- \*\*FR-(\d{3})\s+—\s+(.+?)\*\*:\s*`\[(.+?)\]`\s+(\S.+)$/gim,
225
- ),
226
- ];
227
- const successCriteria = [
228
- ...(successCriteriaSection ?? '').matchAll(
229
- /^- \*\*SC-(\d{3})\*\*\s+`\[(buildable|outcome)\]`:\s*(\S.+)$/gim,
230
- ),
231
- ];
232
- const frIds = functionalRequirements.map((match) => match[1]);
233
- const scIds = successCriteria.map((match) => match[1]);
234
- const rawFrCandidates = contractCandidateLines(content, 'FR');
235
- const rawScCandidates = contractCandidateLines(content, 'SC');
236
- if (rawFrCandidates.length !== functionalRequirements.length) {
237
- errors.push(
238
- issue(
239
- 'SDD-SPEC-FR-FORMAT',
240
- artifact,
241
- 'Every FR-### must use the canonical named requirement, delta metadata, and normative statement format.',
242
- ),
243
- );
244
- }
245
- const durableDeltas = [];
246
- if (frIds.length === 0) {
247
- errors.push(issue('SDD-SPEC-FR', artifact, 'Add FR-### requirements.'));
248
- } else {
249
- checkUniqueSequential(frIds, 'FR', artifact, errors);
250
- for (const match of functionalRequirements) {
251
- if (!/\b(?:MUST|SHALL)\b/.test(match[4])) {
252
- errors.push(
253
- issue(
254
- 'SDD-SPEC-FR-NORMATIVE',
255
- artifact,
256
- `FR-${match[1]} must contain an observable MUST or SHALL statement.`,
257
- ),
258
- );
259
- }
260
- const delta = parseRequirementDelta(match[3]);
261
- if (!delta) {
262
- errors.push(
263
- issue(
264
- 'SDD-SPEC-FR-DELTA',
265
- artifact,
266
- `FR-${match[1]} must declare INTERNAL, ADDED, MODIFIED, REMOVED, or RENAMED metadata.`,
267
- ),
268
- );
269
- } else if (
270
- delta.capability &&
271
- !affectedCapabilities?.includes(delta.capability)
272
- ) {
273
- errors.push(
274
- issue(
275
- 'SDD-SPEC-CAPABILITY-COVERAGE',
276
- artifact,
277
- `FR-${match[1]} targets undeclared capability ${delta.capability}.`,
278
- ),
279
- );
280
- } else if (delta.operation !== 'INTERNAL') {
281
- durableDeltas.push({
282
- title: match[2].trim(),
283
- ...delta,
284
- });
285
- }
139
+ function localFile(root, name) {
140
+ if (
141
+ !name ||
142
+ isAbsolute(name) ||
143
+ name.includes('\\') ||
144
+ !/^[a-zA-Z0-9_./-]+$/.test(name)
145
+ )
146
+ throw new Error('Invalid reviewed source path');
147
+ const target = resolve(root, name);
148
+ const rel = relative(root, target);
149
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
150
+ throw new Error('Reviewed source escapes project');
151
+ assertNoSymlinkAncestors(target);
152
+ const stat = lstatMaybe(target);
153
+ if (!stat?.isFile() || stat.isSymbolicLink())
154
+ throw new Error('Reviewed source must be a regular file');
155
+ return target;
156
+ }
157
+
158
+ function parseDeltas(text, errors, recordName) {
159
+ const lines = text.split(/\r?\n/).filter((line) => line.trim());
160
+ if (lines.length === 1 && lines[0] === '- None.') return [];
161
+ const deltas = [];
162
+ for (let index = 0; index < lines.length; index++) {
163
+ const line = lines[index];
164
+ const match = /^- `([^`]+)` \*\*(.+?)\*\* — (\S.+)$/.exec(line);
165
+ const metadata = match && parseRequirementDelta(match[1]);
166
+ if (
167
+ !metadata ||
168
+ metadata.operation === 'INTERNAL' ||
169
+ !match[2].trim() ||
170
+ !match[3].trim()
171
+ ) {
172
+ errors.push(
173
+ issue('SDD-DELTA-FORMAT', `Invalid durable delta: ${line}`, recordName),
174
+ );
175
+ continue;
286
176
  }
287
- }
288
- if (rawScCandidates.length !== successCriteria.length) {
289
- errors.push(
290
- issue(
291
- 'SDD-SPEC-SC-TYPE',
292
- artifact,
293
- 'Every SC-### must be classified as buildable or outcome.',
294
- ),
177
+ const scenario = /^ {2}- GIVEN (.+); WHEN (.+); THEN (.+)\.$/.exec(
178
+ lines[index + 1] ?? '',
295
179
  );
296
- }
297
- if (scIds.length === 0) {
298
- errors.push(
299
- issue('SDD-SPEC-SC', artifact, 'Add measurable SC-### criteria.'),
300
- );
301
- } else {
302
- checkUniqueSequential(scIds, 'SC', artifact, errors);
303
- const measurable =
304
- /(?:\b\d+(?:\.\d+)?\b|%|\b(?:all|every|none|zero|under|over|within|at least|at most|no more|fewer|pass(?:es)?|fail(?:s)?|accept(?:s)?|reject(?:s)?|preserve(?:s)?)\b)/i;
305
- if (successCriteria.some((match) => !measurable.test(match[3]))) {
180
+ if (scenario) index++;
181
+ if (
182
+ metadata.operation !== 'REMOVED' &&
183
+ (!scenario || scenario.slice(1).some((value) => !value.trim()))
184
+ ) {
306
185
  errors.push(
307
186
  issue(
308
- 'SDD-SPEC-SC-MEASURABLE',
309
- artifact,
310
- 'Every SC-### definition must state a structurally measurable outcome.',
187
+ 'SDD-DELTA-SCENARIO',
188
+ `Concrete GIVEN/WHEN/THEN scenario is required for ${match[2]}`,
189
+ recordName,
311
190
  ),
312
191
  );
313
192
  }
193
+ if (metadata.operation === 'REMOVED' && scenario) {
194
+ errors.push(
195
+ issue(
196
+ 'SDD-DELTA-SCENARIO',
197
+ 'Removed requirements do not define new scenarios',
198
+ recordName,
199
+ ),
200
+ );
201
+ }
202
+ deltas.push({
203
+ ...metadata,
204
+ title: match[2],
205
+ statement: match[3],
206
+ scenario: scenario?.slice(1),
207
+ });
314
208
  }
315
- if (!/^##\s+Assumptions\s*$/im.test(content)) {
316
- errors.push(
317
- issue('SDD-SPEC-ASSUMPTIONS', artifact, 'Add an Assumptions section.'),
318
- );
319
- }
320
- if (!/^##\s+Out of scope\s*$/im.test(content)) {
321
- errors.push(
322
- issue('SDD-SPEC-OUT-OF-SCOPE', artifact, 'Add an Out of scope section.'),
323
- );
324
- }
325
-
326
- const knownContracts = new Set([
327
- ...frIds.map((id) => `FR-${id}`),
328
- ...scIds.map((id) => `SC-${id}`),
329
- ]);
330
- const unknownCoverage = [...storyCoverage].filter(
331
- (id) => !knownContracts.has(id),
332
- );
333
- const uncoveredContracts = [...knownContracts].filter(
334
- (id) => !storyCoverage.has(id),
335
- );
336
- if (unknownCoverage.length > 0 || uncoveredContracts.length > 0) {
209
+ if (!lines.length) {
337
210
  errors.push(
338
211
  issue(
339
- 'SDD-SPEC-STORY-COVERAGE',
340
- artifact,
341
- `Story traceability is incomplete. Unknown: ${unknownCoverage.join(', ') || 'none'}; uncovered: ${uncoveredContracts.join(', ') || 'none'}.`,
212
+ 'SDD-DELTA-FORMAT',
213
+ 'Declare deltas or explicitly use - None.',
214
+ recordName,
342
215
  ),
343
216
  );
344
217
  }
345
-
346
- return {
347
- storyIds,
348
- frIds,
349
- scIds,
350
- buildableScIds: successCriteria
351
- .filter((match) => match[2].toLowerCase() === 'buildable')
352
- .map((match) => match[1]),
353
- outcomeScIds: successCriteria
354
- .filter((match) => match[2].toLowerCase() === 'outcome')
355
- .map((match) => match[1]),
356
- durableDeltas,
357
- };
218
+ return deltas;
358
219
  }
359
220
 
360
- function validateDurableDeltaIntent(specRoot, deltas, errors, warnings) {
361
- const byCapability = new Map();
362
- for (const delta of deltas) {
363
- const current = byCapability.get(delta.capability) ?? [];
364
- current.push(delta);
365
- byCapability.set(delta.capability, current);
366
- }
367
-
368
- for (const [capability, capabilityDeltas] of byCapability) {
369
- const path = join(specRoot, capability, 'spec.md');
370
- const present = existsSync(path);
371
- let canonical;
221
+ function preflightDeltas(root, deltas, errors, warnings, recordName) {
222
+ const groups = new Map();
223
+ for (const delta of deltas)
224
+ groups.set(delta.capability, [
225
+ ...(groups.get(delta.capability) ?? []),
226
+ delta,
227
+ ]);
228
+ for (const [capability, entries] of groups) {
229
+ const path = join(root, '.thoth', 'specs', capability, 'spec.md');
230
+ const present = Boolean(lstatMaybe(path));
372
231
  try {
373
- canonical = present
374
- ? parseCanonicalSpec(readFileSync(path, 'utf8'))
375
- : { requirements: new Map() };
232
+ assertNoSymlinkAncestors(path);
233
+ const stat = present ? lstatSync(path) : undefined;
234
+ if (present && (!stat.isFile() || stat.isSymbolicLink()))
235
+ throw new Error('Not a regular canonical specification');
236
+ const requirements = present
237
+ ? parseCanonicalSpec(readFileSync(path, 'utf8')).requirements
238
+ : new Map();
239
+ const result = preflightRequirementDeltas({
240
+ capability,
241
+ present,
242
+ requirements,
243
+ deltas: entries,
244
+ });
245
+ errors.push(
246
+ ...result.errors.map((entry) =>
247
+ issue(entry.code, entry.message, recordName),
248
+ ),
249
+ );
250
+ warnings.push(...result.warnings);
376
251
  } catch (error) {
377
252
  errors.push(
378
253
  issue(
379
- 'SDD-SPEC-DELTA-BASELINE',
380
- 'spec.md',
381
- `${capability} canonical baseline is invalid: ${error instanceof Error ? error.message : String(error)}`,
254
+ 'SDD-DELTA-BASELINE',
255
+ `${capability}: ${error.message}`,
256
+ recordName,
382
257
  ),
383
258
  );
384
- continue;
385
259
  }
386
-
387
- const result = preflightRequirementDeltas({
388
- capability,
389
- present,
390
- requirements: canonical.requirements,
391
- deltas: capabilityDeltas,
392
- });
393
- errors.push(
394
- ...result.errors.map((entry) =>
395
- issue(entry.code, 'spec.md', entry.message),
396
- ),
397
- );
398
- warnings.push(
399
- ...result.warnings.map((entry) =>
400
- issue(entry.code, 'spec.md', entry.message),
401
- ),
402
- );
403
260
  }
404
261
  }
405
262
 
406
- function constitutionEntries(content) {
407
- if (content === undefined) return [];
408
- return content
409
- .split(/\r?\n/)
410
- .filter((line) => /^- /.test(line))
411
- .map((line) => {
412
- const match =
413
- /^- (?:\*\*)?(.+?)(?:\*\*)?:\s*(PASS|JUSTIFIED EXCEPTION|FAIL)\s+[—-]\s+(\S.+)$/i.exec(
414
- line,
415
- );
416
- return match
417
- ? {
418
- name: match[1].trim(),
419
- status: match[2].toUpperCase(),
420
- evidence: match[3],
421
- }
422
- : undefined;
423
- });
263
+ function settled(text) {
264
+ return (
265
+ Boolean(text?.trim()) &&
266
+ !/\b(?:pending|unresolved|unknown|ask|tbd|todo)\b/i.test(text)
267
+ );
424
268
  }
425
269
 
426
- function validatePlan(content, errors, constitution) {
427
- const pre = sectionContent(content, 'Constitution Check \\(pre-design\\)');
428
- const post = sectionContent(content, 'Constitution Check \\(post-design\\)');
429
- if (pre === undefined) {
430
- errors.push(
431
- issue(
432
- 'SDD-PLAN-CONSTITUTION-PRE',
433
- 'plan.md',
434
- 'Record the pre-design Constitution Check.',
435
- ),
436
- );
437
- }
438
- if (post === undefined) {
439
- errors.push(
440
- issue(
441
- 'SDD-PLAN-CONSTITUTION-POST',
442
- 'plan.md',
443
- 'Record the post-design Constitution Check.',
444
- ),
445
- );
446
- }
447
-
448
- const preEntries = constitutionEntries(pre);
449
- const postEntries = constitutionEntries(post);
270
+ function coverageErrors(text, ids, errors, recordName) {
271
+ const tasks = section(text, 'Tasks') ?? '';
272
+ const rows = tasks
273
+ .split(/\r?\n/)
274
+ .filter((line) => /^- \[[^\]]*\]/.test(line));
450
275
  if (
451
- (pre !== undefined &&
452
- (preEntries.length === 0 ||
453
- preEntries.some(
454
- (entry) => !entry || !isConcreteEvidence(entry.evidence),
455
- ))) ||
456
- (post !== undefined &&
457
- (postEntries.length === 0 ||
458
- postEntries.some(
459
- (entry) => !entry || !isConcreteEvidence(entry.evidence),
460
- )))
276
+ !rows.length ||
277
+ rows.some(
278
+ (line) =>
279
+ !/^- \[[ x]\] AC-\d+: \S.+/.test(line) ||
280
+ !settled(line.slice(line.indexOf(':') + 1)),
281
+ ) ||
282
+ ids.some((id) => !rows.some((line) => line.includes(` ${id}:`))) ||
283
+ rows.some((line) => !ids.some((id) => line.includes(` ${id}:`)))
461
284
  ) {
462
285
  errors.push(
463
286
  issue(
464
- 'SDD-PLAN-CONSTITUTION-EVIDENCE',
465
- 'plan.md',
466
- 'Every Constitution Check entry must include a status and concrete evidence.',
287
+ 'SDD-TASK-COVERAGE',
288
+ 'Tasks must cover only known acceptance with concrete work',
289
+ recordName,
467
290
  ),
468
291
  );
469
292
  }
293
+ return rows;
294
+ }
470
295
 
471
- const preNames = preEntries
472
- .filter(Boolean)
473
- .map(({ name }) => name.toLowerCase());
474
- const postNames = postEntries
475
- .filter(Boolean)
476
- .map(({ name }) => name.toLowerCase());
477
- const expectedNames = constitution
478
- ? valuesFor(/^###\s+(?:\d+|[IVXLCDM]+)\.\s+(.+)$/gim, constitution).map(
479
- (name) => name.trim().toLowerCase(),
480
- )
481
- : [];
482
- const coverage = expectedNames.length > 0 ? expectedNames : preNames;
296
+ export function validate({ change, through }) {
297
+ if (!gates.includes(through)) throw new Error('Invalid SDD validation gate');
298
+ const errors = [];
299
+ const warnings = [];
300
+ const specBaselines = [];
301
+ let location;
302
+ try {
303
+ location = resolveSddChangeLocation(change);
304
+ const rootStat = lstatMaybe(location.changeRoot);
305
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink())
306
+ throw new Error('Change directory must be a regular directory');
307
+ const entries = readdirSync(location.changeRoot);
308
+ for (const entry of entries)
309
+ if (entry !== `${location.id}.md`)
310
+ errors.push(
311
+ issue(
312
+ 'SDD-AUXILIARY-ARTIFACT',
313
+ `Only ${location.id}.md is allowed in a change directory; found ${entry}`,
314
+ `${location.id}.md`,
315
+ ),
316
+ );
317
+ } catch (error) {
318
+ const code = error.code?.startsWith('SDD-')
319
+ ? error.code
320
+ : 'SDD-CHANGE-PATH';
321
+ return {
322
+ valid: false,
323
+ through,
324
+ changeRoot: resolve(change),
325
+ errors: [issue(code, error.message)],
326
+ warnings,
327
+ deltas: [],
328
+ };
329
+ }
330
+
331
+ const { projectRoot, changeRoot, id, recordPath, archived, archiveDate } =
332
+ location;
333
+ let text;
334
+ try {
335
+ const stat = lstatMaybe(recordPath);
336
+ if (!stat?.isFile() || stat.isSymbolicLink())
337
+ throw new Error(`${id}.md must be a regular file`);
338
+ text = readFileSync(recordPath, 'utf8');
339
+ } catch (error) {
340
+ errors.push(issue('SDD-CHANGE-MISSING', error.message, `${id}.md`));
341
+ return {
342
+ valid: false,
343
+ through,
344
+ projectRoot,
345
+ changeRoot,
346
+ changeId: id,
347
+ recordPath,
348
+ archived,
349
+ archiveDate,
350
+ errors,
351
+ warnings,
352
+ deltas: [],
353
+ };
354
+ }
355
+
356
+ const recordName = `${id}.md`;
483
357
  if (
484
- coverage.some(
485
- (name) => !preNames.includes(name) || !postNames.includes(name),
486
- ) ||
487
- preNames.some((name) => !postNames.includes(name)) ||
488
- postNames.some((name) => !preNames.includes(name))
358
+ !new RegExp(
359
+ `^# Change: ${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
360
+ 'm',
361
+ ).test(text) ||
362
+ !/^\*\*Classification\*\*: substantial$/m.test(text) ||
363
+ !/^\*\*Scope\*\*: (?:local|coordinated|cross-cutting)$/m.test(text) ||
364
+ !/^\*\*Uncertainty\*\*: (?:low|medium|high)$/m.test(text) ||
365
+ !/^\*\*Risk\*\*: (?:low|medium|high)$/m.test(text)
489
366
  ) {
490
367
  errors.push(
491
368
  issue(
492
- 'SDD-PLAN-CONSTITUTION-COVERAGE',
493
- 'plan.md',
494
- 'Pre-design and post-design checks must cover the same active Constitution principles.',
369
+ 'SDD-HEADER',
370
+ 'Record identity, substantial classification, scope, uncertainty, and risk are required',
371
+ recordName,
495
372
  ),
496
373
  );
497
374
  }
498
- if (
499
- [...preEntries, ...postEntries]
500
- .filter(Boolean)
501
- .some(({ status }) => status === 'FAIL')
502
- ) {
375
+ const headingList = [...text.matchAll(/^## ([^\r\n]+)$/gm)].map((match) =>
376
+ match[1].trim().toLowerCase(),
377
+ );
378
+ if (new Set(headingList).size !== headingList.length) {
503
379
  errors.push(
504
380
  issue(
505
- 'SDD-PLAN-CONSTITUTION-FAIL',
506
- 'plan.md',
507
- 'A FAIL Constitution status blocks downstream task generation.',
381
+ 'SDD-DUPLICATE-SECTION',
382
+ 'Canonical record sections must be unique',
383
+ recordName,
508
384
  ),
509
385
  );
510
386
  }
511
- }
512
387
 
513
- function exactTaskPath(line) {
514
- const description = line.split(' | Verify:', 1)[0];
515
- const spans = [...description.matchAll(/`([^`\r\n]+)`/g)];
516
- if (spans.length !== 1) return undefined;
517
-
518
- const rawPath = spans[0][1].trim().replaceAll('\\', '/');
388
+ const needed = [];
389
+ if (through === 'explore') needed.push('Exploration');
390
+ else needed.push('Exploration', 'Intent', 'Non-goals', 'Acceptance');
519
391
  if (
520
- rawPath.length === 0 ||
521
- /^\[.*\]$/.test(rawPath) ||
522
- /[*?[\]{}]/.test(rawPath) ||
523
- rawPath.startsWith('!') ||
524
- rawPath === '~' ||
525
- rawPath.startsWith('~/') ||
526
- rawPath.startsWith('/') ||
527
- /^[a-z][a-z0-9+.-]*:/i.test(rawPath)
528
- ) {
529
- return undefined;
530
- }
531
-
532
- const normalized = posix.normalize(rawPath);
533
- if (
534
- normalized === '.' ||
535
- normalized === '..' ||
536
- normalized.startsWith('../')
537
- ) {
538
- return undefined;
539
- }
540
- return normalized.toLowerCase();
541
- }
542
-
543
- function taskDependencyEdges(content) {
544
- return [...content.matchAll(/\bT\d{3}(?:\s*->\s*T\d{3})+\b/g)].flatMap(
545
- ([chain]) => {
546
- const ids = chain.match(/\bT\d{3}\b/g) ?? [];
547
- return ids.slice(1).map((target, index) => ({
548
- source: ids[index],
549
- target,
550
- }));
551
- },
552
- );
553
- }
554
-
555
- function parseParallelGroups(content) {
556
- const lines = content.split(/\r?\n/);
557
- const headingIndexes = lines
558
- .map((line, index) => (/^###\s+Group\s+P\d+\s*$/.test(line) ? index : -1))
559
- .filter((index) => index >= 0);
560
- const groups = [];
561
- let valid = headingIndexes.length > 0;
562
-
392
+ [
393
+ 'clarify',
394
+ 'plan',
395
+ 'tasks',
396
+ 'checklist',
397
+ 'ready',
398
+ 'verify',
399
+ 'closeout',
400
+ ].includes(through)
401
+ )
402
+ needed.push('Clarifications', 'Decisions');
563
403
  if (
564
- lines
565
- .slice(0, headingIndexes[0] ?? lines.length)
566
- .some((line) => line.trim())
567
- ) {
568
- valid = false;
569
- }
570
-
571
- for (const [groupIndex, start] of headingIndexes.entries()) {
572
- const end = headingIndexes[groupIndex + 1] ?? lines.length;
573
- const groupId = /^###\s+Group\s+P(\d+)\s*$/.exec(lines[start])?.[1];
574
- const body = lines.slice(start + 1, end).filter((line) => line.trim());
575
- const lanes = [];
576
- let prerequisites;
577
- let barrier;
578
- let rationale;
579
- for (const line of body) {
580
- const lane =
581
- /^- Lane L(\d+): (T\d{3}(?: -> T\d{3})*)(?: \| Owner: (\S+))?\s*$/.exec(
582
- line,
583
- );
584
- if (lane) {
585
- lanes.push({
586
- number: Number.parseInt(lane[1], 10),
587
- taskIds: lane[2].split(' -> '),
588
- owner: lane[3],
589
- });
590
- continue;
591
- }
592
- const prerequisiteLine =
593
- /^- Prerequisites: (None|T\d{3}(?:, T\d{3})*)\s*$/.exec(line);
594
- if (prerequisiteLine && prerequisites === undefined) {
595
- prerequisites =
596
- prerequisiteLine[1] === 'None' ? [] : prerequisiteLine[1].split(', ');
597
- continue;
598
- }
599
- const barrierLine = /^- Barrier: (Final verification|T\d{3})\s*$/.exec(
600
- line,
404
+ ['plan', 'tasks', 'checklist', 'ready', 'verify', 'closeout'].includes(
405
+ through,
406
+ )
407
+ )
408
+ needed.push('Durable deltas', 'Plan');
409
+ if (['tasks', 'ready', 'verify', 'closeout'].includes(through))
410
+ needed.push('Tasks');
411
+ for (const name of needed) {
412
+ const content = section(text, name);
413
+ if (!settled(content)) {
414
+ errors.push(
415
+ issue(
416
+ 'SDD-SECTION',
417
+ `${name} is missing, incomplete, or unresolved`,
418
+ recordName,
419
+ ),
601
420
  );
602
- if (barrierLine && barrier === undefined) {
603
- barrier = barrierLine[1];
604
- continue;
605
- }
606
- const rationaleLine = /^- Rationale: (\S.*)$/.exec(line);
607
- if (rationaleLine && rationale === undefined) {
608
- rationale = rationaleLine[1];
609
- continue;
610
- }
611
- valid = false;
612
- }
613
- if (
614
- groupId === undefined ||
615
- lanes.length < 2 ||
616
- rationale === undefined ||
617
- !isConcreteEvidence(rationale) ||
618
- lanes.some((lane, index) => lane.number !== index + 1)
619
- ) {
620
- valid = false;
621
421
  }
622
- groups.push({
623
- number: Number.parseInt(groupId ?? '0', 10),
624
- lanes,
625
- prerequisites,
626
- barrier,
627
- rationale,
628
- });
629
422
  }
630
- if (groups.some((group, index) => group.number !== index + 1)) valid = false;
631
- return { groups, valid };
632
- }
633
423
 
634
- function validateTasks(content, errors, spec) {
635
- const artifact = 'tasks.md';
636
- const taskLines = content
424
+ const acceptance = section(text, 'Acceptance') ?? '';
425
+ const candidates = acceptance
637
426
  .split(/\r?\n/)
638
- .filter((line) => /^- \[[ x~]\]/.test(line));
639
- const taskShape =
640
- /^- \[[ x~]\] T(\d{3})(?: \[P\])?(?: \[US(\d+)\])? .+`[^`\r\n]+`(?: \| Verify: \S.+)?\s*$/;
427
+ .filter((line) => /^- AC-/.test(line));
428
+ const clauses = candidates.map((line) => /^- (AC-\d+): (\S.+)$/.exec(line));
641
429
  if (
642
- taskLines.length === 0 ||
643
- taskLines.some((line) => !taskShape.test(line) || !exactTaskPath(line))
430
+ !clauses.length ||
431
+ clauses.some((match) => !match || !settled(match[2])) ||
432
+ new Set(clauses.map((match) => match?.[1])).size !== clauses.length
644
433
  ) {
645
434
  errors.push(
646
435
  issue(
647
- 'SDD-TASK-FORMAT',
648
- artifact,
649
- 'Every task must use T### [P?] [US#?], a description, and exactly one literal repository-relative path.',
436
+ 'SDD-ACCEPTANCE',
437
+ 'Acceptance requires unique, concrete AC identifiers',
438
+ recordName,
650
439
  ),
651
440
  );
652
441
  }
653
- const parsedTasks = taskLines
654
- .map((line) => ({ line, match: taskShape.exec(line) }))
655
- .filter(({ match }) => match !== null);
656
- if (parsedTasks.length > 0) {
657
- const taskIds = parsedTasks.map(({ match }) => match[1]);
658
- checkUniqueSequential(taskIds, 'TASK', artifact, errors);
442
+ const ids = clauses.filter(Boolean).map((match) => match[1]);
443
+ if (['tasks', 'ready', 'verify', 'closeout'].includes(through)) {
444
+ const rows = coverageErrors(text, ids, errors, recordName);
445
+ if (
446
+ ['verify', 'closeout'].includes(through) &&
447
+ rows.some((line) => !line.startsWith('- [x]'))
448
+ )
449
+ errors.push(
450
+ issue('SDD-TASK-INCOMPLETE', 'All tasks must be complete', recordName),
451
+ );
659
452
  }
660
453
  if (
661
- taskLines.length > 0 &&
662
- taskLines.some((line) => !/ \| Verify: \S.+\s*$/.test(line))
454
+ [
455
+ 'clarify',
456
+ 'plan',
457
+ 'tasks',
458
+ 'checklist',
459
+ 'ready',
460
+ 'verify',
461
+ 'closeout',
462
+ ].includes(through)
663
463
  ) {
664
- errors.push(
665
- issue(
666
- 'SDD-TASK-VERIFICATION',
667
- artifact,
668
- 'Every task must state an observable Verify outcome.',
669
- ),
670
- );
671
- }
672
-
673
- if (spec) {
674
- const storyTags = new Set(
675
- parsedTasks
676
- .map(({ match }) => match[2])
677
- .filter((value) => value !== undefined),
678
- );
679
- const missing = [
680
- ...spec.storyIds
681
- .filter((id) => !storyTags.has(String(Number.parseInt(id, 10))))
682
- .map((id) => `US${Number.parseInt(id, 10)}`),
683
- ...spec.frIds
684
- .filter(
685
- (id) => !new RegExp(`\\bFR-${id}\\b`).test(taskLines.join('\n')),
686
- )
687
- .map((id) => `FR-${id}`),
688
- ...spec.buildableScIds
689
- .filter(
690
- (id) => !new RegExp(`\\bSC-${id}\\b`).test(taskLines.join('\n')),
691
- )
692
- .map((id) => `SC-${id}`),
693
- ];
694
- const unknownStories = [...storyTags]
695
- .filter(
696
- (id) =>
697
- !spec.storyIds.some(
698
- (storyId) =>
699
- Number.parseInt(storyId, 10) === Number.parseInt(id, 10),
464
+ for (const name of ['Clarifications', 'Decisions']) {
465
+ if (!settled(section(text, name))) {
466
+ errors.push(
467
+ issue(
468
+ 'SDD-DECISION',
469
+ 'Material choices must be settled before classification proceeds',
470
+ recordName,
700
471
  ),
701
- )
702
- .map((id) => `US${id}`);
703
- if (missing.length > 0 || unknownStories.length > 0) {
704
- errors.push(
705
- issue(
706
- 'SDD-TASK-COVERAGE',
707
- artifact,
708
- `Task coverage is incomplete. Missing: ${missing.join(', ') || 'none'}; unknown stories: ${unknownStories.join(', ') || 'none'}.`,
709
- ),
710
- );
472
+ );
473
+ }
711
474
  }
712
475
  }
713
- if (!/^##\s+MVP scope\s*$/im.test(content)) {
714
- errors.push(
715
- issue(
716
- 'SDD-TASK-MVP',
717
- artifact,
718
- 'Identify the independently testable MVP.',
719
- ),
720
- );
721
- }
722
- if (!/^##\s+Dependencies\s*$/im.test(content)) {
723
- errors.push(
724
- issue('SDD-TASK-DEPENDENCIES', artifact, 'Document task dependencies.'),
725
- );
726
- }
727
- const parallelSection = sectionContent(
728
- content,
729
- 'Parallel execution(?: examples)?',
730
- );
731
- const taskRecords = parsedTasks.map(({ line, match }) => ({
732
- id: `T${match[1]}`,
733
- parallel: /\[P\]/.test(line),
734
- path: exactTaskPath(line),
735
- }));
736
- const tasksById = new Map(taskRecords.map((task) => [task.id, task]));
737
- const hasParallelTask = taskRecords.some((task) => task.parallel);
738
- const noParallelMatch = /^-\s+None:\s*(\S.*)$/im.exec(parallelSection ?? '');
739
- const explainsNoParallelWork =
740
- noParallelMatch !== null && isConcreteEvidence(noParallelMatch[1]);
741
- const pathsOverlap = (left, right) =>
742
- left === right ||
743
- left.startsWith(`${right}/`) ||
744
- right.startsWith(`${left}/`);
745
- const noneOnly =
746
- explainsNoParallelWork &&
747
- parallelSection?.trim() === noParallelMatch?.[0].trim();
748
- const { groups, valid: validGroupGrammar } = parseParallelGroups(
749
- parallelSection ?? '',
750
- );
751
- const declaredDependencies = taskDependencyEdges(
752
- sectionContent(content, 'Dependencies') ?? '',
753
- );
754
- const hasGroups = groups.length > 0;
476
+
477
+ const deltaSection = section(text, 'Durable deltas');
478
+ const deltas = deltaSection
479
+ ? parseDeltas(deltaSection, errors, recordName)
480
+ : [];
755
481
  if (
756
- parallelSection === undefined ||
757
- (hasParallelTask && (!hasGroups || explainsNoParallelWork)) ||
758
- (!hasParallelTask && !noneOnly) ||
759
- (explainsNoParallelWork && !noneOnly)
482
+ deltaSection &&
483
+ ['plan', 'tasks', 'checklist', 'ready', 'verify', 'closeout'].includes(
484
+ through,
485
+ )
760
486
  ) {
761
- errors.push(
762
- issue(
763
- 'SDD-TASK-PARALLEL',
764
- artifact,
765
- 'Declare strict parallel groups for [P] tasks, or provide one exclusive evidence-backed None reason.',
766
- ),
767
- );
768
- }
769
- if (hasGroups && !validGroupGrammar) {
770
- errors.push(
771
- issue(
772
- 'SDD-TASK-LANE-GRAMMAR',
773
- artifact,
774
- 'Groups and lanes must use unique sequential IDs, at least two lanes, and every required field.',
775
- ),
776
- );
777
- }
778
-
779
- if (hasGroups && validGroupGrammar) {
780
- const memberships = groups.flatMap((group) =>
781
- group.lanes.flatMap((lane) =>
782
- lane.taskIds.map((taskId) => ({ group, lane, taskId })),
783
- ),
784
- );
785
- const membershipCounts = new Map();
786
- for (const { taskId } of memberships) {
787
- membershipCounts.set(taskId, (membershipCounts.get(taskId) ?? 0) + 1);
788
- }
789
- const invalidMembership =
790
- memberships.some(({ taskId }) => !tasksById.get(taskId)?.parallel) ||
791
- taskRecords.some(
792
- (task) => task.parallel && membershipCounts.get(task.id) !== 1,
793
- );
794
- if (invalidMembership) {
487
+ preflightDeltas(projectRoot, deltas, errors, warnings, recordName);
488
+ }
489
+
490
+ if (['verify', 'closeout'].includes(through)) {
491
+ const authorizationHeading = /^## Authorization\s*$/m.exec(text);
492
+ const auth = section(text, 'Authorization') ?? '';
493
+ const reviewDisposition = /^\*\*Plan review\*\*: (SKIPPED|OKAY)$/m.exec(
494
+ auth,
495
+ )?.[1];
496
+ const selectionLines =
497
+ auth.match(/^\*\*Plan review selection\*\*:.*$/gm) ?? [];
498
+ const reviewSelection =
499
+ selectionLines.length === 1
500
+ ? /^\*\*Plan review selection\*\*: (EXPLICIT_REVIEW|EXPLICIT_SKIP|DEFAULT_REVIEW_AFTER_3)$/.exec(
501
+ selectionLines[0],
502
+ )?.[1]
503
+ : undefined;
504
+ if (
505
+ !authorizationHeading ||
506
+ !reviewDisposition ||
507
+ !/^\*\*Implementation\*\*: AUTHORIZED$/m.test(auth)
508
+ ) {
795
509
  errors.push(
796
510
  issue(
797
- 'SDD-TASK-LANE-MEMBERSHIP',
798
- artifact,
799
- 'Every [P] task must belong to exactly one lane, and lane members must be known [P] tasks.',
511
+ 'SDD-AUTHORIZATION',
512
+ 'Plan-review disposition and separate implementation authorization are required',
513
+ recordName,
800
514
  ),
801
515
  );
802
516
  }
803
-
804
517
  if (
805
- groups.some((group) =>
806
- group.lanes.some(
807
- (lane) => !['designer', 'quick', 'deep'].includes(lane.owner),
808
- ),
518
+ !(
519
+ (reviewDisposition === 'SKIPPED' &&
520
+ reviewSelection === 'EXPLICIT_SKIP') ||
521
+ (reviewDisposition === 'OKAY' &&
522
+ ['EXPLICIT_REVIEW', 'DEFAULT_REVIEW_AFTER_3'].includes(
523
+ reviewSelection,
524
+ ))
809
525
  )
810
526
  ) {
811
527
  errors.push(
812
528
  issue(
813
- 'SDD-TASK-LANE-OWNER',
814
- artifact,
815
- 'Every lane owner must be designer, quick, or deep.',
529
+ 'SDD-REVIEW-SELECTION',
530
+ 'Plan-review selection must support the recorded disposition',
531
+ recordName,
816
532
  ),
817
533
  );
818
534
  }
819
-
820
- for (const group of groups) {
821
- const rationale = group.rationale ?? '';
822
- const hasPathIndependenceEvidence =
823
- /\b(?:path|file|surface)s?\b/i.test(rationale) &&
824
- /\b(?:disjoint|non[- ]?overlap(?:ping)?)\b/i.test(rationale);
825
- const hasDependencyIndependenceEvidence =
826
- /\b(?:dependenc(?:y|ies)|consum(?:e|es|ing)|peer output|data flow)\b/i.test(
827
- rationale,
828
- );
829
- if (!hasPathIndependenceEvidence || !hasDependencyIndependenceEvidence) {
535
+ const verificationText =
536
+ section(text, 'Verification', { trim: false }) ?? '';
537
+ const verified = verificationText.trim();
538
+ if (through === 'closeout') {
539
+ const prefix = text.slice(0, authorizationHeading?.index ?? text.length);
540
+ if (
541
+ !/^\*\*Reviewer\*\*: oracle$/m.test(verified) ||
542
+ !/^\*\*Independent from implementer\*\*: Yes$/m.test(verified) ||
543
+ !/^\*\*Verdict\*\*: PASS$/m.test(verified)
544
+ ) {
830
545
  errors.push(
831
546
  issue(
832
- 'SDD-TASK-LANE-RATIONALE',
833
- artifact,
834
- 'Parallel rationale must state concrete path-disjointness and cross-lane dependency evidence for Oracle review.',
547
+ 'SDD-VERIFICATION',
548
+ 'Fresh independent Oracle PASS is required',
549
+ recordName,
835
550
  ),
836
551
  );
837
552
  }
838
-
839
- const memberIds = new Set(group.lanes.flatMap((lane) => lane.taskIds));
840
- const prerequisites = group.prerequisites ?? [];
841
- if (
842
- group.prerequisites === undefined ||
843
- new Set(prerequisites).size !== prerequisites.length ||
844
- prerequisites.some(
845
- (taskId) => !tasksById.has(taskId) || memberIds.has(taskId),
846
- )
847
- ) {
553
+ const digestLines =
554
+ verificationText.match(/^\*\*Reviewed record SHA-256\*\*:.*$/gm) ?? [];
555
+ const reviewedDigest =
556
+ digestLines.length === 1
557
+ ? /^\*\*Reviewed record SHA-256\*\*: ([a-f0-9]{64})$/.exec(
558
+ digestLines[0],
559
+ )?.[1]
560
+ : undefined;
561
+ if (reviewedDigest !== sha(prefix)) {
848
562
  errors.push(
849
563
  issue(
850
- 'SDD-TASK-LANE-PREREQUISITE',
851
- artifact,
852
- 'Prerequisites must be unique known tasks outside the group.',
564
+ 'SDD-VERIFICATION-STALE',
565
+ 'Reviewed record does not match the content before Authorization',
566
+ recordName,
853
567
  ),
854
568
  );
855
569
  }
856
-
857
- const memberNumbers = [...memberIds].map((id) =>
858
- Number.parseInt(id.slice(1), 10),
859
- );
860
- const barrierNumber = /^T\d{3}$/.test(group.barrier ?? '')
861
- ? Number.parseInt(group.barrier.slice(1), 10)
862
- : undefined;
570
+ const rows = verified
571
+ .split(/\r?\n/)
572
+ .filter((line) => /^- AC-/.test(line));
863
573
  if (
864
- group.barrier !== 'Final verification' &&
865
- (barrierNumber === undefined ||
866
- !tasksById.has(group.barrier) ||
867
- memberIds.has(group.barrier) ||
868
- prerequisites.includes(group.barrier) ||
869
- memberNumbers.some((number) => barrierNumber <= number))
574
+ rows.length !== ids.length ||
575
+ ids.some(
576
+ (id) =>
577
+ !rows.some((row) =>
578
+ new RegExp(
579
+ `^- ${id}: PASS \\| (?!none|TBD)\\S.+ \\| (?!none|TBD)\\S.+$`,
580
+ 'i',
581
+ ).test(row),
582
+ ),
583
+ )
870
584
  ) {
871
585
  errors.push(
872
586
  issue(
873
- 'SDD-TASK-LANE-BARRIER',
874
- artifact,
875
- 'A task barrier must be a known downstream non-member task, or use exact Final verification.',
587
+ 'SDD-VERIFICATION-COVERAGE',
588
+ 'Every accepted outcome needs concrete PASS check and evidence',
589
+ recordName,
876
590
  ),
877
591
  );
878
592
  }
879
-
880
- const laneByTask = new Map(
881
- group.lanes.flatMap((lane) =>
882
- lane.taskIds.map((taskId) => [taskId, lane.number]),
593
+ }
594
+ const sources = verified
595
+ .split(/\r?\n/)
596
+ .filter((line) => line.startsWith('- Source:'));
597
+ if (!sources.length) {
598
+ errors.push(
599
+ issue(
600
+ 'SDD-VERIFICATION-SOURCE',
601
+ 'Reviewed source digests are required',
602
+ recordName,
883
603
  ),
884
604
  );
885
- if (
886
- declaredDependencies.some(
887
- ({ source, target }) =>
888
- laneByTask.has(source) &&
889
- laneByTask.has(target) &&
890
- laneByTask.get(source) !== laneByTask.get(target),
891
- )
892
- ) {
605
+ }
606
+ const affectedSpecs = new Map(
607
+ [...new Set(deltas.map((delta) => delta.capability))].map(
608
+ (capability) => [`.thoth/specs/${capability}/spec.md`, capability],
609
+ ),
610
+ );
611
+ const reviewedSources = [];
612
+ for (const line of sources) {
613
+ const match = /^- Source: ([^ |]+) \| sha256:([a-f0-9]{64})$/.exec(line);
614
+ const absent = /^- Source: ([^ |]+) \| absent$/.exec(line);
615
+ if (match) {
616
+ const source = {
617
+ line,
618
+ path: match[1],
619
+ state: 'present',
620
+ sha256: match[2],
621
+ valid: false,
622
+ };
623
+ reviewedSources.push(source);
624
+ try {
625
+ if (
626
+ sha(readFileSync(localFile(projectRoot, source.path))) !==
627
+ source.sha256
628
+ )
629
+ throw new Error('Missing, invalid or stale source');
630
+ source.valid = true;
631
+ } catch (error) {
632
+ errors.push(
633
+ issue(
634
+ affectedSpecs.has(source.path)
635
+ ? 'SDD-VERIFICATION-SPEC-BASELINE'
636
+ : 'SDD-VERIFICATION-SOURCE',
637
+ `${line}: ${error.message}`,
638
+ recordName,
639
+ ),
640
+ );
641
+ }
642
+ } else if (absent && affectedSpecs.has(absent[1])) {
643
+ reviewedSources.push({
644
+ line,
645
+ path: absent[1],
646
+ state: 'absent',
647
+ valid: true,
648
+ });
649
+ } else {
893
650
  errors.push(
894
651
  issue(
895
- 'SDD-TASK-LANE-DEPENDENCY',
896
- artifact,
897
- 'Declared dependencies cannot connect different lanes in one group.',
652
+ 'SDD-VERIFICATION-SOURCE',
653
+ `${line}: Missing, invalid or stale source`,
654
+ recordName,
898
655
  ),
899
656
  );
900
657
  }
901
-
902
- const lanePaths = group.lanes.map((lane) =>
903
- lane.taskIds.map((taskId) => tasksById.get(taskId)?.path),
658
+ }
659
+ for (const [specPath, capability] of affectedSpecs) {
660
+ const entries = reviewedSources.filter(
661
+ (source) => source.path === specPath,
904
662
  );
905
- const overlaps = lanePaths.some((paths, index) =>
906
- lanePaths
907
- .slice(index + 1)
908
- .some((otherPaths) =>
909
- paths.some(
910
- (path) =>
911
- path !== undefined &&
912
- otherPaths.some(
913
- (other) => other !== undefined && pathsOverlap(path, other),
914
- ),
915
- ),
663
+ if (entries.length !== 1) {
664
+ errors.push(
665
+ issue(
666
+ 'SDD-VERIFICATION-SPEC-BASELINE',
667
+ `${specPath}: exactly one reviewed canonical baseline is required`,
668
+ recordName,
916
669
  ),
917
- );
918
- if (overlaps) {
670
+ );
671
+ continue;
672
+ }
673
+ const [baseline] = entries;
674
+ const target = resolve(projectRoot, specPath);
675
+ try {
676
+ assertNoSymlinkAncestors(target);
677
+ if (baseline.state === 'absent') {
678
+ if (lstatMaybe(target))
679
+ throw new Error(
680
+ 'Reviewed-absent canonical specification now exists',
681
+ );
682
+ let parent = dirname(target);
683
+ while (
684
+ parent !== projectRoot &&
685
+ parent.startsWith(`${projectRoot}${sep}`)
686
+ ) {
687
+ const stat = lstatMaybe(parent);
688
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
689
+ throw new Error(
690
+ 'Canonical specification parent is not a directory',
691
+ );
692
+ parent = dirname(parent);
693
+ }
694
+ specBaselines.push({ capability, path: specPath, state: 'absent' });
695
+ } else if (baseline.valid) {
696
+ specBaselines.push({
697
+ capability,
698
+ path: specPath,
699
+ state: 'present',
700
+ sha256: baseline.sha256,
701
+ });
702
+ }
703
+ } catch (error) {
919
704
  errors.push(
920
705
  issue(
921
- 'SDD-TASK-LANE-OVERLAP',
922
- artifact,
923
- 'Mutable task paths cannot overlap across lanes in one group.',
706
+ 'SDD-VERIFICATION-SPEC-BASELINE',
707
+ `${specPath}: ${error.message}`,
708
+ recordName,
924
709
  ),
925
710
  );
926
711
  }
927
712
  }
928
- }
929
- }
930
-
931
- function validateChecklist(content, errors, spec, requireComplete) {
932
- const artifact = 'checklists/requirements.md';
933
- const activationMatch = /^\*\*Activation reason\*\*:\s*(\S.*)$/im.exec(
934
- content,
935
- );
936
- if (activationMatch === null || !isConcreteEvidence(activationMatch[1])) {
937
- errors.push(
938
- issue(
939
- 'SDD-CHECKLIST-ACTIVATION',
940
- artifact,
941
- 'Record the concrete risk or ambiguity that activated the checklist.',
942
- ),
943
- );
944
- }
945
- const checklistLines = content
946
- .split(/\r?\n/)
947
- .filter((line) => /^- \[[ x~]\]/.test(line));
948
- const checklistShape = /^- \[([ x~])\] CHK(\d{3})\b.+$/i;
949
- const parsed = checklistLines
950
- .map((line) => checklistShape.exec(line))
951
- .filter((match) => match !== null);
952
- if (
953
- checklistLines.length === 0 ||
954
- checklistLines.some((line) => !checklistShape.test(line))
955
- ) {
956
- errors.push(
957
- issue(
958
- 'SDD-CHECKLIST-ID',
959
- artifact,
960
- 'Checklist items must use CHK### IDs.',
961
- ),
962
- );
963
- }
964
- if (parsed.length > 0) {
965
- checkUniqueSequential(
966
- parsed.map((match) => match[2]),
967
- 'CHECKLIST',
968
- artifact,
969
- errors,
970
- );
971
- }
972
- if (requireComplete && parsed.some((match) => match[1] !== 'x')) {
973
- errors.push(
974
- issue(
975
- 'SDD-CHECKLIST-INCOMPLETE',
976
- artifact,
977
- 'Final validation requires every checklist item to be complete.',
978
- ),
979
- );
980
- }
981
- const initial = sectionContent(content, 'Initial validation');
982
- const domainLenses = sectionContent(content, 'Domain lenses');
983
- const revalidation = sectionContent(content, 'Revalidation');
984
- const noopRevalidationMatch =
985
- revalidation === undefined
986
- ? null
987
- : /^-\s+Not required:\s*(\S.*)$/im.exec(revalidation);
988
- const explicitNoopRevalidation =
989
- noopRevalidationMatch !== null &&
990
- isConcreteEvidence(noopRevalidationMatch[1]);
991
- const hasRevalidationItems =
992
- revalidation !== undefined &&
993
- /^- \[[ x~]\] CHK\d{3}\b/im.test(revalidation);
994
- const hasDomainItems =
995
- domainLenses !== undefined &&
996
- /^- \[[ x~]\] CHK\d{3}\b/im.test(domainLenses);
997
- const noDomainLensesMatch =
998
- domainLenses === undefined
999
- ? null
1000
- : /^-\s+None:\s*(\S.*)$/im.exec(domainLenses);
1001
- const explicitNoDomainLenses =
1002
- noDomainLensesMatch !== null && isConcreteEvidence(noDomainLensesMatch[1]);
1003
- const missingDimensions = [
1004
- 'Completeness',
1005
- 'Clarity',
1006
- 'Consistency',
1007
- 'Measurability',
1008
- 'Coverage',
1009
- ].filter(
1010
- (dimension) => !new RegExp(`\\[${dimension}\\]`, 'i').test(initial ?? ''),
1011
- );
1012
- if (missingDimensions.length > 0) {
1013
- errors.push(
1014
- issue(
1015
- 'SDD-CHECKLIST-TAXONOMY',
1016
- artifact,
1017
- `Missing requirement-quality dimensions: ${missingDimensions.join(', ')}.`,
1018
- ),
1019
- );
1020
- }
1021
- if (
1022
- domainLenses === undefined ||
1023
- (!hasDomainItems && !explicitNoDomainLenses) ||
1024
- (hasDomainItems && explicitNoDomainLenses)
1025
- ) {
1026
- errors.push(
1027
- issue(
1028
- 'SDD-CHECKLIST-DOMAIN-LENSES',
1029
- artifact,
1030
- 'Add applicable domain-lens checks or an evidence-backed None decision.',
1031
- ),
1032
- );
1033
- }
1034
- if (
1035
- initial === undefined ||
1036
- revalidation === undefined ||
1037
- (!hasRevalidationItems && !explicitNoopRevalidation) ||
1038
- (hasRevalidationItems && explicitNoopRevalidation)
1039
- ) {
1040
- errors.push(
1041
- issue(
1042
- 'SDD-CHECKLIST-REVALIDATION',
1043
- artifact,
1044
- 'Record initial validation plus checked revalidation or an evidence-backed no-op.',
1045
- ),
1046
- );
1047
- }
1048
- if (spec) {
1049
- const missing = [
1050
- ...spec.storyIds
1051
- .filter(
1052
- (id) =>
1053
- !new RegExp(`\\bUS${Number.parseInt(id, 10)}\\b`).test(content),
1054
- )
1055
- .map((id) => `US${Number.parseInt(id, 10)}`),
1056
- ...spec.frIds
1057
- .filter((id) => !new RegExp(`\\bFR-${id}\\b`).test(content))
1058
- .map((id) => `FR-${id}`),
1059
- ...spec.scIds
1060
- .filter((id) => !new RegExp(`\\bSC-${id}\\b`).test(content))
1061
- .map((id) => `SC-${id}`),
1062
- ];
1063
- if (missing.length > 0) {
713
+ if (
714
+ through === 'closeout' &&
715
+ !/^\*\*Archive\*\*: READY$/m.test(section(text, 'Closeout') ?? '')
716
+ ) {
1064
717
  errors.push(
1065
- issue(
1066
- 'SDD-CHECKLIST-COVERAGE',
1067
- artifact,
1068
- `Checklist coverage is missing: ${missing.join(', ')}.`,
1069
- ),
718
+ issue('SDD-ARCHIVE-READINESS', 'Archive must be READY', recordName),
1070
719
  );
1071
720
  }
1072
721
  }
1073
- }
1074
-
1075
- function validateCloseoutTasks(content, errors) {
1076
- if (/^- \[(?: |~)\] T\d{3}\b/m.test(content)) {
1077
- errors.push(
1078
- issue(
1079
- 'SDD-CLOSEOUT-TASKS',
1080
- 'tasks.md',
1081
- 'Closeout requires every task to be complete.',
1082
- ),
1083
- );
1084
- }
1085
- }
1086
-
1087
- function parseComplianceMatrix(content) {
1088
- const matrix = sectionContent(content, 'Compliance matrix') ?? '';
1089
- const candidates = matrix
1090
- .split(/\r?\n/)
1091
- .filter((line) => /^\s*\|\s*(?:FR|SC)-/i.test(line));
1092
- const entries = [
1093
- ...matrix.matchAll(
1094
- /^\|\s*((?:FR|SC)-\d{3})(?:\s+`?\[(?:buildable|outcome)\]`?)?\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*(PASS|RISK|FAIL)\s*\|\s*$/gim,
1095
- ),
1096
- ].map((match) => ({
1097
- id: match[1].toUpperCase(),
1098
- evidence: match[2],
1099
- check: match[3],
1100
- result: match[4].toUpperCase(),
1101
- }));
1102
- return { entries, malformed: candidates.length !== entries.length };
1103
- }
1104
-
1105
- function isConcreteEvidence(value) {
1106
- const normalized = value.replaceAll('`', '').trim();
1107
- return (
1108
- normalized.length > 0 &&
1109
- !/^\[.*\]$/.test(normalized) &&
1110
- !/^(?:N\/?A|NONE|PENDING|TBD|NOT YET|-)[.!]?$/i.test(normalized)
1111
- );
1112
- }
1113
-
1114
- function hasExplicitResidualRisk(content, id) {
1115
- const residualRisks = sectionContent(content, 'Residual risks') ?? '';
1116
- const match = new RegExp(`^-\\s+${id}\\s*:\\s*(\\S.*)$`, 'im').exec(
1117
- residualRisks,
1118
- );
1119
- return match !== null && isConcreteEvidence(match[1]);
1120
- }
1121
-
1122
- function hasBlockingCriticalFinding(content) {
1123
- const findings = sectionContent(content, 'Findings') ?? '';
1124
- return findings.split(/\r?\n/).some((line) => {
1125
- if (!/\bCRITICAL\b/i.test(line)) return false;
1126
- if (/\bRESOLVED\b/i.test(line)) return false;
1127
- return !/\b(?:NO|ZERO)\s+(?:OPEN\s+)?CRITICAL\b/i.test(line);
1128
- });
1129
- }
1130
-
1131
- function hasConcreteReviewDimensions(content) {
1132
- const dimensions = sectionContent(content, 'Review dimensions') ?? '';
1133
- return ['Completeness', 'Correctness', 'Coherence'].every((dimension) => {
1134
- const match = new RegExp(
1135
- `^-\\s*\\*\\*${dimension}\\*\\*:\\s*(\\S.*)$`,
1136
- 'im',
1137
- ).exec(dimensions);
1138
- return match !== null && isConcreteEvidence(match[1]);
1139
- });
1140
- }
1141
-
1142
- function validateVerifyReport(content, errors, spec) {
1143
- const artifact = 'verify-report.md';
1144
- if (
1145
- !/^\*\*Reviewer\*\*:\s*oracle(?:<br>)?\s*$/im.test(content) ||
1146
- !/^\*\*Independent from implementer\*\*:\s*Yes(?:<br>)?\s*$/im.test(content)
1147
- ) {
1148
- errors.push(
1149
- issue(
1150
- 'SDD-VERIFY-INDEPENDENCE',
1151
- artifact,
1152
- 'Closeout requires an oracle reviewer independent from the implementer.',
1153
- ),
1154
- );
1155
- }
1156
- if (!/^\*\*Verdict\*\*:\s*PASS\s*$/im.test(content)) {
1157
- errors.push(
1158
- issue(
1159
- 'SDD-VERIFY-VERDICT',
1160
- artifact,
1161
- 'Closeout requires an explicit PASS verdict.',
1162
- ),
1163
- );
1164
- }
1165
- if (hasBlockingCriticalFinding(content)) {
1166
- errors.push(
1167
- issue(
1168
- 'SDD-VERIFY-CRITICAL',
1169
- artifact,
1170
- 'Unresolved CRITICAL verification findings block closeout.',
1171
- ),
1172
- );
1173
- }
1174
-
1175
- if (!hasConcreteReviewDimensions(content)) {
1176
- errors.push(
1177
- issue(
1178
- 'SDD-VERIFY-DIMENSIONS',
1179
- artifact,
1180
- 'Oracle must judge completeness, correctness, and coherence separately.',
1181
- ),
1182
- );
1183
- }
1184
-
1185
- const matrix = parseComplianceMatrix(content);
1186
- const knownIds = new Set([
1187
- ...spec.frIds.map((id) => `FR-${id}`),
1188
- ...spec.scIds.map((id) => `SC-${id}`),
1189
- ]);
1190
- const rowIds = matrix.entries.map(({ id }) => id);
1191
- const duplicateIds = rowIds.filter(
1192
- (id, index) => rowIds.indexOf(id) !== index,
1193
- );
1194
- const unknownIds = rowIds.filter((id) => !knownIds.has(id));
1195
- const failedIds = matrix.entries
1196
- .filter(({ result }) => result === 'FAIL')
1197
- .map(({ id }) => id);
1198
- if (
1199
- matrix.malformed ||
1200
- duplicateIds.length > 0 ||
1201
- unknownIds.length > 0 ||
1202
- failedIds.length > 0
1203
- ) {
1204
- errors.push(
1205
- issue(
1206
- 'SDD-VERIFY-MATRIX',
1207
- artifact,
1208
- `Compliance matrix rows must be canonical, unique, known, and non-FAIL. Duplicates: ${[...new Set(duplicateIds)].join(', ') || 'none'}; unknown: ${[...new Set(unknownIds)].join(', ') || 'none'}; failed: ${[...new Set(failedIds)].join(', ') || 'none'}.`,
1209
- ),
1210
- );
1211
- }
1212
- const rows = new Map(matrix.entries.map((entry) => [entry.id, entry]));
1213
- const required = [
1214
- ...spec.frIds.map((id) => `FR-${id}`),
1215
- ...spec.buildableScIds.map((id) => `SC-${id}`),
1216
- ];
1217
- const missing = required.filter((id) => {
1218
- const row = rows.get(id);
1219
- return (
1220
- row?.result !== 'PASS' ||
1221
- !isConcreteEvidence(row.evidence) ||
1222
- !isConcreteEvidence(row.check)
1223
- );
1224
- });
1225
- if (missing.length > 0) {
1226
- errors.push(
1227
- issue(
1228
- 'SDD-VERIFY-COVERAGE',
1229
- artifact,
1230
- `Verification evidence is missing for: ${missing.join(', ')}.`,
1231
- ),
1232
- );
1233
- }
1234
-
1235
- const unresolvedOutcomes = spec.outcomeScIds
1236
- .map((id) => `SC-${id}`)
1237
- .filter((id) => {
1238
- const row = rows.get(id);
1239
- if (!row) return true;
1240
- if (row.result === 'PASS') {
1241
- return (
1242
- !isConcreteEvidence(row.evidence) || !isConcreteEvidence(row.check)
1243
- );
1244
- }
1245
- return row.result !== 'RISK' || !hasExplicitResidualRisk(content, id);
1246
- });
1247
- if (unresolvedOutcomes.length > 0) {
1248
- errors.push(
1249
- issue(
1250
- 'SDD-VERIFY-OUTCOME',
1251
- artifact,
1252
- `Outcome criteria require observed PASS evidence or an explicit residual RISK: ${unresolvedOutcomes.join(', ')}.`,
1253
- ),
1254
- );
1255
- }
1256
- }
1257
-
1258
- function validateArchiveReport(content, errors) {
1259
- const artifact = 'archive-report.md';
1260
- const valid =
1261
- /^\*\*Status\*\*:\s*READY(?:<br>)?\s*$/im.test(content) &&
1262
- /^\*\*Oracle verdict\*\*:\s*PASS(?:<br>)?\s*$/im.test(content) &&
1263
- /^\*\*Archive path\*\*:\s*`openspec\/changes\/archive\/YYYY-MM-DD-\[feature\]\/`(?:<br>)?\s*$/im.test(
1264
- content,
1265
- ) &&
1266
- /verify-report\.md/i.test(
1267
- sectionContent(content, 'Verification lineage') ?? '',
1268
- ) &&
1269
- /^- Pending: archive applies declared durable deltas transactionally\.\s*$/im.test(
1270
- sectionContent(content, 'Canonical specification sync') ?? '',
1271
- );
1272
- if (!valid) {
1273
- errors.push(
1274
- issue(
1275
- 'SDD-ARCHIVE-REPORT',
1276
- artifact,
1277
- 'Archive report must be READY with oracle PASS, lineage, sync status, and the dated target placeholder.',
1278
- ),
1279
- );
1280
- }
1281
- }
1282
-
1283
- function validate({ change, route, through }) {
1284
- const root = resolve(change);
1285
- const errors = [];
1286
- const warnings = [];
1287
-
1288
- if (route === 'direct') {
1289
- return { valid: true, route, through, changeRoot: root, errors, warnings };
1290
- }
1291
-
1292
- const spec = readArtifact(root, 'spec.md', errors);
1293
- const requiresPlan = [
1294
- 'plan',
1295
- 'checklist',
1296
- 'tasks',
1297
- 'ready',
1298
- 'closeout',
1299
- ].includes(through);
1300
- const requiresTasks = ['tasks', 'ready', 'closeout'].includes(through);
1301
- const plan = requiresPlan ? readArtifact(root, 'plan.md', errors) : undefined;
1302
- const tasks = requiresTasks
1303
- ? readArtifact(root, 'tasks.md', errors)
1304
- : undefined;
1305
- const checklist = readArtifact(
1306
- root,
1307
- 'checklists/requirements.md',
1308
- errors,
1309
- through === 'checklist',
1310
- );
1311
-
1312
- const specContract =
1313
- spec === undefined ? undefined : validateSpec(spec, errors);
1314
- if (specContract !== undefined) {
1315
- validateDurableDeltaIntent(
1316
- join(root, '..', '..', 'specs'),
1317
- specContract.durableDeltas,
1318
- errors,
1319
- warnings,
1320
- );
1321
- }
1322
- const constitution = readArtifact(
1323
- join(root, '..', '..'),
1324
- 'memory/constitution.md',
1325
- errors,
1326
- requiresPlan,
1327
- );
1328
- if (plan !== undefined) validatePlan(plan, errors, constitution);
1329
- if (tasks !== undefined) validateTasks(tasks, errors, specContract);
1330
- if (checklist !== undefined) {
1331
- validateChecklist(
1332
- checklist,
1333
- errors,
1334
- specContract,
1335
- ['checklist', 'ready', 'closeout'].includes(through),
1336
- );
1337
- } else if (route === 'full' && ['ready', 'closeout'].includes(through)) {
1338
- warnings.push({
1339
- code: 'SDD-CHECKLIST-CONDITIONAL',
1340
- artifact: 'checklists/requirements.md',
1341
- message:
1342
- 'No requirements checklist exists; confirm that risk did not activate the conditional checklist gate.',
1343
- });
1344
- }
1345
-
1346
- if (
1347
- through === 'closeout' &&
1348
- tasks !== undefined &&
1349
- specContract !== undefined
1350
- ) {
1351
- validateCloseoutTasks(tasks, errors);
1352
- const verifyReport = readArtifact(root, 'verify-report.md', errors);
1353
- const archiveReport = readArtifact(root, 'archive-report.md', errors);
1354
- if (verifyReport !== undefined) {
1355
- validateVerifyReport(verifyReport, errors, specContract);
1356
- }
1357
- if (archiveReport !== undefined) {
1358
- validateArchiveReport(archiveReport, errors);
1359
- }
1360
- }
1361
722
 
1362
723
  return {
1363
724
  valid: errors.length === 0,
1364
- route,
1365
725
  through,
1366
- changeRoot: root,
726
+ projectRoot,
727
+ changeRoot,
728
+ changeId: id,
729
+ recordPath,
730
+ archived,
731
+ archiveDate,
1367
732
  errors,
1368
733
  warnings,
734
+ deltas,
735
+ specBaselines,
1369
736
  };
1370
737
  }
1371
738
 
1372
- try {
1373
- const options = parseArgs(process.argv.slice(2));
1374
- const report = validate(options);
1375
- const output = options.json
1376
- ? JSON.stringify(report)
1377
- : report.valid
1378
- ? 'SDD artifacts are structurally valid.'
1379
- : report.errors
1380
- .map((error) => `${error.code} (${error.artifact}): ${error.message}`)
1381
- .join('\n');
1382
- process.stdout.write(`${output}\n`);
1383
- process.exitCode = report.valid ? 0 : 1;
1384
- } catch (error) {
1385
- process.stderr.write(
1386
- `${error instanceof Error ? error.message : String(error)}\n`,
1387
- );
1388
- process.exitCode = 2;
739
+ function args(argv) {
740
+ const options = {};
741
+ for (let i = 0; i < argv.length; i++) {
742
+ if (argv[i] === '--json') options.json = true;
743
+ else if (['--change', '--through'].includes(argv[i]))
744
+ options[argv[i].slice(2)] = argv[++i];
745
+ else throw new Error(`Unknown argument: ${argv[i]}`);
746
+ }
747
+ if (!options.change || !options.through)
748
+ throw new Error('--change and --through are required');
749
+ return options;
750
+ }
751
+
752
+ if (
753
+ process.argv[1] &&
754
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
755
+ ) {
756
+ try {
757
+ const options = args(process.argv.slice(2));
758
+ const result = validate(options);
759
+ process.stdout.write(
760
+ `${options.json ? JSON.stringify(result) : result.valid ? 'Substantial SDD record is structurally valid.' : result.errors.map((entry) => `${entry.code}: ${entry.message}`).join('\n')}\n`,
761
+ );
762
+ process.exitCode = result.valid ? 0 : 1;
763
+ } catch (error) {
764
+ process.stderr.write(`${error.message}\n`);
765
+ process.exitCode = 2;
766
+ }
1389
767
  }