@dotdotgod/cli 0.1.35 → 0.1.40

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.
@@ -0,0 +1,588 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { commandUsage } from '../cli/usage.mjs';
4
+ import { isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from '../docs/markdown.mjs';
5
+ import {
6
+ PLAN_BLOCKER_LABELS as BLOCKER_LABELS,
7
+ PLAN_CONSTRUCTION_CHECKLISTS,
8
+ PLAN_INTERNAL_STAGE_DIRECTORIES,
9
+ PLAN_INTERNAL_WORKSPACE,
10
+ PLAN_REQUIRED_HEADERS,
11
+ PLAN_STAGE_DIRECTORIES,
12
+ buildPlanValidationNextStagePrompt,
13
+ } from './plan-stage-contract.mjs';
14
+
15
+ export {
16
+ PLAN_CONSTRUCTION_CHECKLISTS,
17
+ PLAN_INTERNAL_STAGE_DIRECTORIES,
18
+ PLAN_INTERNAL_WORKSPACE,
19
+ PLAN_REQUIRED_HEADERS,
20
+ PLAN_STAGE_DIRECTORIES,
21
+ };
22
+
23
+ const PLACEHOLDER_RE = /^(?:[-*\s_`]*(?:tbd|todo|n\/a|placeholder|none)[.!?\s_`]*)+$/i;
24
+ const CHECKLIST_OPEN_RE = /\b(?:open|unresolved|pending|needs confirmation|needs user|unknown)\b/i;
25
+ function normalizeHeading(value) {
26
+ return String(value ?? '').trim().toLowerCase().replace(/`/g, '').replace(/[\s/_-]+/g, ' ');
27
+ }
28
+
29
+ function projectRelative(root, file) {
30
+ return relative(root, file).replace(/\\/g, '/');
31
+ }
32
+
33
+ function buildBlockerRepairPrompt(blocker) {
34
+ const target = [blocker.path, blocker.stage, blocker.section ? `## ${blocker.section}` : undefined].filter(Boolean).join(' / ');
35
+ const location = target ? ` in ${target}` : '';
36
+ return `Update the active plan artifact${location} to resolve this blocker: ${blocker.message}`;
37
+ }
38
+
39
+ function addBlocker(blockers, code, message, details = {}) {
40
+ const blocker = { code, message: message ?? BLOCKER_LABELS.get(code) ?? code, ...details };
41
+ blockers.push({ ...blocker, prompt: buildBlockerRepairPrompt(blocker) });
42
+ }
43
+
44
+ function parseHeadedSections(content) {
45
+ const sections = [];
46
+ const lines = removeCodeBlocks(content).split(/\r?\n/);
47
+ let current;
48
+ for (let index = 0; index < lines.length; index += 1) {
49
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
50
+ if (match) {
51
+ if (current) sections.push(current);
52
+ current = { level: match[1].length, title: match[2].trim(), contentLines: [], line: index + 1 };
53
+ } else if (current) {
54
+ current.contentLines.push(lines[index]);
55
+ }
56
+ }
57
+ if (current) sections.push(current);
58
+ return sections;
59
+ }
60
+
61
+ function contentHasValue(content) {
62
+ const stripped = content
63
+ .split(/\r?\n/)
64
+ .filter((line) => !/^\s*<!--/.test(line))
65
+ .join('\n')
66
+ .trim();
67
+ return stripped.length > 0;
68
+ }
69
+
70
+ function isPlaceholderContent(content) {
71
+ const stripped = content
72
+ .split(/\r?\n/)
73
+ .map((line) => line.replace(/^\s*[-*+]\s+/, '').trim())
74
+ .filter(Boolean)
75
+ .join(' ')
76
+ .trim();
77
+ return Boolean(stripped) && PLACEHOLDER_RE.test(stripped);
78
+ }
79
+
80
+ function readMarkdownFiles(stageDir) {
81
+ if (!existsSync(stageDir)) return [];
82
+ return readdirSync(stageDir, { withFileTypes: true })
83
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
84
+ .map((entry) => join(stageDir, entry.name));
85
+ }
86
+
87
+ function internalWorkspaceDir(taskDir) {
88
+ return join(taskDir, PLAN_INTERNAL_WORKSPACE);
89
+ }
90
+
91
+ export function internalStageFileName(stageName) {
92
+ const match = /^(\d{2})-(.+)$/.exec(stageName);
93
+ if (!match) return `${String(stageName).replace(/-/g, '_').toUpperCase()}.md`;
94
+ return `${match[1]}_${match[2].replace(/-/g, '_').toUpperCase()}.md`;
95
+ }
96
+
97
+ function internalStageFile(taskDir, stageName) {
98
+ return join(internalWorkspaceDir(taskDir), internalStageFileName(stageName));
99
+ }
100
+
101
+ function resolvePlanReadme(root, planPath) {
102
+ const absolutePlanPath = isAbsolute(planPath) ? planPath : resolve(root, planPath);
103
+ const taskDir = dirname(absolutePlanPath);
104
+ const taskSlug = basename(taskDir);
105
+ const relPlanPath = projectRelative(root, absolutePlanPath);
106
+ const expectedPath = `docs/plan/${taskSlug}/README.md`;
107
+ return { absolutePlanPath, taskDir, taskSlug, relPlanPath, expectedPath };
108
+ }
109
+
110
+ function discoverActivePlanReadmes(root) {
111
+ const planRoot = join(root, 'docs/plan');
112
+ if (!existsSync(planRoot)) return [];
113
+ return readdirSync(planRoot, { withFileTypes: true })
114
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
115
+ .map((entry) => join(planRoot, entry.name, 'README.md'))
116
+ .filter((file) => existsSync(file))
117
+ .sort((left, right) => left.localeCompare(right));
118
+ }
119
+
120
+ function resolveCheckpointPlanPath(root, planPath) {
121
+ if (planPath) return resolvePlanReadme(root, planPath);
122
+ const candidates = discoverActivePlanReadmes(root);
123
+ if (candidates.length !== 1) {
124
+ const suffix = candidates.length > 1
125
+ ? ` Found ${candidates.length} candidates; pass docs/plan/<task-slug>/README.md explicitly.`
126
+ : ' Pass docs/plan/<task-slug>/README.md explicitly.';
127
+ throw new Error(`Could not infer an active plan.${suffix}`);
128
+ }
129
+ return resolvePlanReadme(root, candidates[0]);
130
+ }
131
+
132
+ function buildStageCheckpointContent(stageName, now = new Date()) {
133
+ return `Stage: ${stageName}\nStatus: created\nUpdated: ${now.toISOString()}\n`;
134
+ }
135
+
136
+ export function createPlanStageCheckpoint(stageValue, options = {}) {
137
+ const root = resolve(options.root ?? process.cwd());
138
+ const stageName = resolvePlanValidationStage(stageValue);
139
+ if (!stageName) throw new Error(`Invalid plan stage: ${stageValue}`);
140
+
141
+ const plan = resolveCheckpointPlanPath(root, options.planPath);
142
+ if (plan.relPlanPath !== plan.expectedPath) {
143
+ throw new Error(`Plan file must be docs/plan/<task-slug>/README.md: ${plan.relPlanPath}`);
144
+ }
145
+ if (!isKebabCase(plan.taskSlug)) {
146
+ throw new Error(`Plan task directory must be kebab-case: ${plan.taskSlug}`);
147
+ }
148
+ if (!existsSync(plan.absolutePlanPath)) {
149
+ throw new Error(`Plan file does not exist: ${plan.relPlanPath}`);
150
+ }
151
+
152
+ const workspace = internalWorkspaceDir(plan.taskDir);
153
+ const checkpointPath = internalStageFile(plan.taskDir, stageName);
154
+ if (existsSync(checkpointPath)) {
155
+ throw new Error(`Plan stage checkpoint already exists: ${projectRelative(root, checkpointPath)}`);
156
+ }
157
+
158
+ mkdirSync(workspace, { recursive: true });
159
+ writeFileSync(checkpointPath, buildStageCheckpointContent(stageName, options.now));
160
+ return {
161
+ ok: true,
162
+ planPath: plan.relPlanPath,
163
+ stage: stageName,
164
+ path: projectRelative(root, checkpointPath),
165
+ };
166
+ }
167
+
168
+ function hasInternalWorkspace(taskDir) {
169
+ const workspace = internalWorkspaceDir(taskDir);
170
+ return existsSync(workspace) && statSync(workspace).isDirectory();
171
+ }
172
+
173
+ function discoverDurablePlanMarkdownFiles(taskDir) {
174
+ const results = [];
175
+ function walk(directory) {
176
+ if (!existsSync(directory)) return;
177
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
178
+ if (entry.name === PLAN_INTERNAL_WORKSPACE || entry.isSymbolicLink()) continue;
179
+ const entryPath = join(directory, entry.name);
180
+ if (entry.isDirectory()) {
181
+ walk(entryPath);
182
+ continue;
183
+ }
184
+ if (entry.isFile() && entry.name.endsWith('.md')) results.push(entryPath);
185
+ }
186
+ }
187
+ walk(taskDir);
188
+ return results.sort((left, right) => {
189
+ if (basename(left) === 'README.md' && dirname(left) === taskDir) return -1;
190
+ if (basename(right) === 'README.md' && dirname(right) === taskDir) return 1;
191
+ return left.localeCompare(right);
192
+ });
193
+ }
194
+
195
+ function readInternalStageState(taskDir, stageName) {
196
+ const file = internalStageFile(taskDir, stageName);
197
+ if (!existsSync(file)) return undefined;
198
+ const content = readFileSync(file, 'utf8');
199
+ return { file, content };
200
+ }
201
+
202
+ function validateInternalStageState(root, taskDir, stageName, blockers) {
203
+ const state = readInternalStageState(taskDir, stageName);
204
+ if (!state) return;
205
+ const stage = contentMatch(state.content, /^Stage:\s*(\S+)\s*$/m);
206
+ const status = contentMatch(state.content, /^Status:\s*(created|blocked|completed)\s*$/m);
207
+ const updated = contentMatch(state.content, /^Updated:\s*(\S+)\s*$/m);
208
+ const rel = projectRelative(root, state.file);
209
+ const looksLikeGeneratorState = /^Stage:\s*\S+\s*$/m.test(state.content) || /^Status:\s*\S+\s*$/m.test(state.content) || /^Updated:\s*\S+\s*$/m.test(state.content);
210
+ if (!looksLikeGeneratorState) return;
211
+ if (stage && stage !== stageName) addBlocker(blockers, 'missing-internal-stage', `Internal stage file has wrong Stage field: ${rel}`, { path: rel, stage: stageName });
212
+ if (!stage) addBlocker(blockers, 'missing-internal-stage', `Internal stage file is missing Stage: ${rel}`, { path: rel, stage: stageName });
213
+ if (!status) addBlocker(blockers, 'missing-internal-stage', `Internal stage file is missing valid Status: ${rel}`, { path: rel, stage: stageName });
214
+ if (!updated) addBlocker(blockers, 'missing-internal-stage', `Internal stage file is missing Updated: ${rel}`, { path: rel, stage: stageName });
215
+ }
216
+
217
+ function contentMatch(content, pattern) {
218
+ return pattern.exec(content)?.[1];
219
+ }
220
+
221
+ function findSection(files, header) {
222
+ const wanted = normalizeHeading(header);
223
+ let fallback;
224
+ for (const file of files) {
225
+ const content = readFileSync(file, 'utf8');
226
+ for (const section of parseHeadedSections(content)) {
227
+ if (section.level < 2 || normalizeHeading(section.title) !== wanted) continue;
228
+ const found = { file, section, content: section.contentLines.join('\n') };
229
+ const isTaskReadme = basename(file) === 'README.md';
230
+ if (isTaskReadme || contentHasValue(found.content)) return found;
231
+ fallback ??= found;
232
+ }
233
+ }
234
+ return fallback;
235
+ }
236
+
237
+ function validateMarkdownNames(root, stageName, stageDir, blockers) {
238
+ for (const file of readMarkdownFiles(stageDir)) {
239
+ const name = basename(file);
240
+ const valid = isUpperSnakeMarkdown(name);
241
+ if (!valid) {
242
+ addBlocker(blockers, 'invalid-markdown-name', `Invalid Markdown file name: ${projectRelative(root, file)}`, { path: projectRelative(root, file) });
243
+ }
244
+ }
245
+ }
246
+
247
+ function sectionContains(content, words) {
248
+ const normalized = content.toLowerCase();
249
+ return words.some((word) => normalized.includes(word));
250
+ }
251
+
252
+ function hasPendingRequiredWorkstream(content) {
253
+ return content.split(/\r?\n/).some((line) => {
254
+ const normalized = line.toLowerCase();
255
+ if (!normalized.includes('pending')) return false;
256
+ return /\byes\b|\brequired\b/.test(normalized);
257
+ });
258
+ }
259
+
260
+ function hasUnresolvedDiscussion(content) {
261
+ return content.split(/\r?\n/).some((line) => {
262
+ const normalized = line.toLowerCase();
263
+ if (/^\s*-\s*\[\s\]/.test(line)) return true;
264
+ return /status\s*:\s*(open|research[-_ ]requested|plan[-_ ]revision[-_ ]requested|unresolved|pending)/.test(normalized);
265
+ });
266
+ }
267
+
268
+ function hasUnresolvedAssumption(content) {
269
+ return content.split(/\r?\n/).some((line) => {
270
+ const normalized = line.toLowerCase();
271
+ if (/^\s*-\s*\[\s\]/.test(line)) return true;
272
+ return /\b(unresolved|pending|unknown|needs user|needs confirmation)\b/.test(normalized);
273
+ });
274
+ }
275
+
276
+ function constructionChecklistHeader(stageName) {
277
+ const match = /^(\d{2})-/.exec(stageName);
278
+ return `Stage ${match?.[1] ?? stageName} Construction Checklist`;
279
+ }
280
+
281
+ function normalizeChecklistCategory(value) {
282
+ return normalizeHeading(value).replace(/\s+/g, ' ');
283
+ }
284
+
285
+ function hasOpenChecklistEvidence(evidence) {
286
+ const normalized = evidence.toLowerCase();
287
+ if (/\bno\s+(?:open|unresolved|pending|unknown)\b/.test(normalized)) return false;
288
+ return CHECKLIST_OPEN_RE.test(normalized);
289
+ }
290
+
291
+ function parseChecklistRows(content) {
292
+ return content.split(/\r?\n/).map((line, index) => {
293
+ const match = /^\s*[-*+]\s*\[([^\]])\]\s*([^:]+):\s*(.*?)\s*$/.exec(line);
294
+ if (!match) return { line, lineNumber: index + 1, malformed: /^\s*[-*+]\s*/.test(line) && line.trim().length > 0 };
295
+ return {
296
+ line,
297
+ lineNumber: index + 1,
298
+ checked: match[1].trim().toLowerCase() === 'x',
299
+ category: match[2].trim(),
300
+ evidence: match[3].trim(),
301
+ malformed: false,
302
+ };
303
+ }).filter((row) => row.malformed || row.category);
304
+ }
305
+
306
+ function validateConstructionChecklist(root, files, stageName, blockers) {
307
+ const requiredCategories = PLAN_CONSTRUCTION_CHECKLISTS[stageName] ?? [];
308
+ if (requiredCategories.length === 0) return true;
309
+ const header = constructionChecklistHeader(stageName);
310
+ const found = findSection(files, header);
311
+ if (!found) {
312
+ addBlocker(blockers, 'missing-construction-checklist', `Missing construction checklist: ## ${header}`, { path: projectRelative(root, files[0]), stage: stageName, section: header });
313
+ return false;
314
+ }
315
+ let valid = true;
316
+ const relFound = projectRelative(root, found.file);
317
+ const rows = parseChecklistRows(found.content);
318
+ const byCategory = new Map(rows.filter((row) => row.category).map((row) => [normalizeChecklistCategory(row.category), row]));
319
+ for (const row of rows) {
320
+ if (row.malformed) {
321
+ valid = false;
322
+ addBlocker(blockers, 'malformed-checklist-item', `Malformed construction checklist item at line ${row.lineNumber}: expected "- [x] Category: evidence".`, { path: relFound, stage: stageName, section: header });
323
+ continue;
324
+ }
325
+ if (!row.checked || hasOpenChecklistEvidence(row.evidence)) {
326
+ valid = false;
327
+ addBlocker(blockers, 'open-checklist-item', `Construction checklist item is open: ${row.category}`, { path: relFound, stage: stageName, section: header });
328
+ }
329
+ if (!contentHasValue(row.evidence) || isPlaceholderContent(row.evidence)) {
330
+ valid = false;
331
+ addBlocker(blockers, 'placeholder-checklist-item', `Construction checklist item only contains placeholder content: ${row.category}`, { path: relFound, stage: stageName, section: header });
332
+ }
333
+ }
334
+ for (const category of requiredCategories) {
335
+ if (!byCategory.has(normalizeChecklistCategory(category))) {
336
+ valid = false;
337
+ addBlocker(blockers, 'missing-checklist-item', `Missing construction checklist item: ${category}`, { path: relFound, stage: stageName, section: header });
338
+ }
339
+ }
340
+ return valid;
341
+ }
342
+
343
+ export function buildPlanValidationRepairPrompt(result) {
344
+ if (result.ok) return undefined;
345
+ const lines = [
346
+ `Refine the active plan artifact so \`dotdotgod plan validate ${result.planPath} --json\` passes.`,
347
+ 'Address every blocker below by editing the active plan files, preserving user decisions and marking unresolved questions in the Discussion Queue instead of guessing.',
348
+ '',
349
+ 'Blockers:',
350
+ ];
351
+ for (const blocker of result.blockers) {
352
+ const location = [blocker.path, blocker.stage, blocker.section ? `## ${blocker.section}` : undefined].filter(Boolean).join(' / ');
353
+ lines.push(`- ${blocker.code}${location ? ` (${location})` : ''}: ${blocker.message}`);
354
+ }
355
+ lines.push('', 'After edits, rerun the plan validation command and summarize remaining blockers if any.');
356
+ return lines.join('\n');
357
+ }
358
+
359
+ function withPlanValidationRepairPrompt(result) {
360
+ if (result.ok) return result;
361
+ return { ...result, repairPrompt: buildPlanValidationRepairPrompt(result) };
362
+ }
363
+
364
+ function buildNextStageDetails(root, taskDir, relPlanPath, stageName) {
365
+ const stagePath = projectRelative(root, internalStageFile(taskDir, stageName));
366
+ return {
367
+ stage: stageName,
368
+ path: stagePath,
369
+ prompt: buildPlanValidationNextStagePrompt(relPlanPath, stageName, stagePath),
370
+ };
371
+ }
372
+
373
+ export function resolvePlanValidationStage(stageValue) {
374
+ const value = String(stageValue ?? '').trim();
375
+ if (!value) return undefined;
376
+ const matches = PLAN_INTERNAL_STAGE_DIRECTORIES.filter((stage) => stage === value || stage.startsWith(value));
377
+ return matches.length === 1 ? matches[0] : undefined;
378
+ }
379
+
380
+ function buildPlanValidationSummary(selectedStage, stagesForFullValidation = PLAN_STAGE_DIRECTORIES) {
381
+ const stages = selectedStage ? [selectedStage] : stagesForFullValidation;
382
+ return {
383
+ stages: { total: stages.length, present: 0, valid: 0, ...(selectedStage ? { selected: selectedStage } : {}) },
384
+ requiredSections: { total: stages.flatMap((stage) => PLAN_REQUIRED_HEADERS[stage] ?? []).length, present: 0, valid: 0 },
385
+ splitFiles: { detected: false },
386
+ };
387
+ }
388
+
389
+ export function validatePlanArtifact(planPath, options = {}) {
390
+ const root = resolve(options.root ?? process.cwd());
391
+ const plan = resolvePlanReadme(root, planPath);
392
+ const { absolutePlanPath, taskDir, taskSlug, relPlanPath } = plan;
393
+ const selectedStage = resolvePlanValidationStage(options.stage);
394
+ const blockers = [];
395
+ const warnings = [];
396
+
397
+ if (!existsSync(absolutePlanPath)) {
398
+ addBlocker(blockers, 'missing-plan', `Plan file does not exist: ${projectRelative(root, absolutePlanPath)}`, { path: projectRelative(root, absolutePlanPath) });
399
+ return withPlanValidationRepairPrompt({ ok: false, planPath: projectRelative(root, absolutePlanPath), blockers, warnings, summary: buildPlanValidationSummary(selectedStage) });
400
+ }
401
+
402
+ const expectedPrefix = plan.expectedPath;
403
+ if (relPlanPath !== expectedPrefix) {
404
+ addBlocker(blockers, 'invalid-plan-path', `Plan file must be docs/plan/<task-slug>/README.md: ${relPlanPath}`, { path: relPlanPath });
405
+ }
406
+ if (!isKebabCase(taskSlug)) {
407
+ addBlocker(blockers, 'invalid-task-slug', `Plan task directory must be kebab-case: ${taskSlug}`, { path: projectRelative(root, taskDir) });
408
+ }
409
+ if (!contentHasValue(readFileSync(absolutePlanPath, 'utf8'))) {
410
+ addBlocker(blockers, 'empty-plan', `Plan README is empty: ${relPlanPath}`, { path: relPlanPath });
411
+ }
412
+
413
+ const stagesForFullValidation = PLAN_INTERNAL_STAGE_DIRECTORIES;
414
+ const summary = buildPlanValidationSummary(selectedStage, stagesForFullValidation);
415
+ const stagesToValidate = selectedStage ? [selectedStage] : stagesForFullValidation;
416
+ const durableFiles = discoverDurablePlanMarkdownFiles(taskDir);
417
+ if (durableFiles.length > 1) summary.splitFiles.detected = true;
418
+ for (const stageName of stagesToValidate) {
419
+ validateInternalStageState(root, taskDir, stageName, blockers);
420
+ const files = durableFiles.length > 0 ? durableFiles : [absolutePlanPath];
421
+ const stageReadme = absolutePlanPath;
422
+ summary.stages.present += 1;
423
+ let stageValid = true;
424
+ for (const header of PLAN_REQUIRED_HEADERS[stageName]) {
425
+ const found = findSection(files, header);
426
+ if (!found) {
427
+ stageValid = false;
428
+ addBlocker(blockers, 'missing-section', `Missing required section: ## ${header}`, { path: projectRelative(root, stageReadme), stage: stageName, section: header });
429
+ continue;
430
+ }
431
+ summary.requiredSections.present += 1;
432
+ const relFound = projectRelative(root, found.file);
433
+ if (!contentHasValue(found.content)) {
434
+ stageValid = false;
435
+ addBlocker(blockers, 'empty-section', `Required section has no content: ## ${header}`, { path: relFound, stage: stageName, section: header });
436
+ continue;
437
+ }
438
+ if (isPlaceholderContent(found.content)) {
439
+ stageValid = false;
440
+ addBlocker(blockers, 'placeholder-section', `Required section only contains placeholder content: ## ${header}`, { path: relFound, stage: stageName, section: header });
441
+ continue;
442
+ }
443
+ summary.requiredSections.valid += 1;
444
+
445
+ if (header === 'Atomic Tasks') {
446
+ if (!sectionContains(found.content, ['acceptance'])) {
447
+ stageValid = false;
448
+ addBlocker(blockers, 'missing-atomic-acceptance', 'Atomic Tasks must define acceptance criteria.', { path: relFound, stage: stageName, section: header });
449
+ }
450
+ if (!sectionContains(found.content, ['verification', 'verify'])) {
451
+ stageValid = false;
452
+ addBlocker(blockers, 'missing-atomic-verification', 'Atomic Tasks must define verification.', { path: relFound, stage: stageName, section: header });
453
+ }
454
+ }
455
+ if (header === 'Role / Area Workstreams' && hasPendingRequiredWorkstream(found.content)) {
456
+ stageValid = false;
457
+ addBlocker(blockers, 'pending-workstream', 'Required role/area workstream is pending.', { path: relFound, stage: stageName, section: header });
458
+ }
459
+ if (header === 'Discussion Queue' && hasUnresolvedDiscussion(found.content)) {
460
+ stageValid = false;
461
+ addBlocker(blockers, 'unresolved-discussion', 'Discussion Queue has unresolved items.', { path: relFound, stage: stageName, section: header });
462
+ }
463
+ if (header === 'Assumptions' && hasUnresolvedAssumption(found.content)) {
464
+ stageValid = false;
465
+ addBlocker(blockers, 'unresolved-assumption', 'Assumptions contain unresolved items.', { path: relFound, stage: stageName, section: header });
466
+ }
467
+ }
468
+ if (!validateConstructionChecklist(root, files, stageName, blockers)) stageValid = false;
469
+ if (stageValid) summary.stages.valid += 1;
470
+ }
471
+
472
+ const result = { ok: blockers.length === 0, planPath: relPlanPath, blockers, warnings, summary, ...(selectedStage ? { stage: selectedStage } : {}) };
473
+ if (result.ok && selectedStage) {
474
+ const stageIndex = PLAN_INTERNAL_STAGE_DIRECTORIES.indexOf(selectedStage);
475
+ const nextStageName = stageIndex >= 0 ? PLAN_INTERNAL_STAGE_DIRECTORIES[stageIndex + 1] : undefined;
476
+ if (nextStageName) result.nextStage = buildNextStageDetails(root, taskDir, relPlanPath, nextStageName);
477
+ }
478
+ return withPlanValidationRepairPrompt(result);
479
+ }
480
+
481
+ export function formatPlanValidationText(result) {
482
+ const stageSuffix = result.stage ? ` (${result.stage})` : '';
483
+ if (result.ok) {
484
+ const lines = [`✅ Plan validation passed: ${result.planPath}${stageSuffix}`, `Stages: ${result.summary.stages.valid}/${result.summary.stages.total}`, `Required sections: ${result.summary.requiredSections.valid}/${result.summary.requiredSections.total}`];
485
+ if (result.nextStage?.prompt) lines.push('', 'Next stage:', result.nextStage.prompt);
486
+ return lines.join('\n');
487
+ }
488
+ const lines = [`❌ Plan validation failed: ${result.planPath}${stageSuffix}`, '', 'Blockers:'];
489
+ for (const blocker of result.blockers) {
490
+ lines.push(`- ${blocker.message}`);
491
+ }
492
+ if (result.repairPrompt) {
493
+ lines.push('', 'Agent prompt:', result.repairPrompt);
494
+ }
495
+ lines.push('', 'Next:', '- refine the plan before execution');
496
+ return lines.join('\n');
497
+ }
498
+
499
+ function runPlanValidate(args) {
500
+ const [planPath, ...rest] = args;
501
+ if (!planPath || planPath.startsWith('-')) {
502
+ console.error('Missing plan path.');
503
+ console.error(commandUsage('plan validate'));
504
+ process.exit(2);
505
+ }
506
+ const json = rest.includes('--json');
507
+ let stage;
508
+ let stageProvided = false;
509
+ for (let index = 0; index < rest.length; index += 1) {
510
+ const arg = rest[index];
511
+ if (arg === '--json') continue;
512
+ if (arg === '--stage') {
513
+ stageProvided = true;
514
+ stage = rest[index + 1];
515
+ if (!stage || stage.startsWith('-')) {
516
+ console.error('Missing plan validation stage.');
517
+ console.error(commandUsage('plan validate'));
518
+ process.exit(2);
519
+ }
520
+ index += 1;
521
+ continue;
522
+ }
523
+ console.error(`Unknown plan validate option: ${arg}`);
524
+ console.error(commandUsage('plan validate'));
525
+ process.exit(2);
526
+ }
527
+ if (stageProvided && !resolvePlanValidationStage(stage)) {
528
+ console.error(`Invalid plan validation stage: ${stage}`);
529
+ console.error(commandUsage('plan validate'));
530
+ process.exit(2);
531
+ }
532
+ const result = validatePlanArtifact(planPath, { stage });
533
+ if (json) console.log(JSON.stringify(result, null, 2));
534
+ else console.log(formatPlanValidationText(result));
535
+ process.exit(result.ok ? 0 : 1);
536
+ }
537
+
538
+ function runPlanStage(args) {
539
+ const [subcommand, stage, ...rest] = args;
540
+ if (subcommand !== 'create') {
541
+ console.error(`Unknown plan stage command: ${subcommand ?? ''}`.trim());
542
+ console.error(commandUsage('plan stage'));
543
+ process.exit(2);
544
+ }
545
+ if (!stage || stage.startsWith('-')) {
546
+ console.error('Missing plan stage.');
547
+ console.error(commandUsage('plan stage create'));
548
+ process.exit(2);
549
+ }
550
+ let json = false;
551
+ let planPath;
552
+ for (const arg of rest) {
553
+ if (arg === '--json') {
554
+ json = true;
555
+ continue;
556
+ }
557
+ if (arg.startsWith('-')) {
558
+ console.error(`Unknown plan stage create option: ${arg}`);
559
+ console.error(commandUsage('plan stage create'));
560
+ process.exit(2);
561
+ }
562
+ if (planPath) {
563
+ console.error(`Unexpected plan stage create argument: ${arg}`);
564
+ console.error(commandUsage('plan stage create'));
565
+ process.exit(2);
566
+ }
567
+ planPath = arg;
568
+ }
569
+ try {
570
+ const result = createPlanStageCheckpoint(stage, { planPath });
571
+ if (json) console.log(JSON.stringify(result, null, 2));
572
+ else console.log(`Created plan stage checkpoint: ${result.path}`);
573
+ process.exit(0);
574
+ } catch (error) {
575
+ console.error(error instanceof Error ? error.message : String(error));
576
+ console.error(commandUsage('plan stage create'));
577
+ process.exit(2);
578
+ }
579
+ }
580
+
581
+ export function runPlan(args) {
582
+ const [subcommand, ...rest] = args;
583
+ if (subcommand === 'validate') runPlanValidate(rest);
584
+ if (subcommand === 'stage') runPlanStage(rest);
585
+ console.error(`Unknown plan command: ${subcommand ?? ''}`.trim());
586
+ console.error(commandUsage('plan'));
587
+ process.exit(2);
588
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { usage } from '../cli/usage.mjs';
4
+ import { rel } from '../common/paths.mjs';
5
+ import { extractDotdotgodTraceabilityBlocks, syncTraceabilityLinksInContent, validateTraceabilityLinksRegion } from '../docs/traceability.mjs';
6
+
7
+ function collectDocsMarkdownFiles(root) {
8
+ const docs = join(root, 'docs');
9
+ const files = [];
10
+ const walk = (dir) => {
11
+ if (!existsSync(dir)) return;
12
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
13
+ const path = join(dir, entry.name);
14
+ if (entry.isDirectory()) walk(path);
15
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(path);
16
+ }
17
+ };
18
+ walk(docs);
19
+ return files;
20
+ }
21
+
22
+ export function parseTraceabilityOptions(argv) {
23
+ const sub = argv[0];
24
+ const action = argv[1];
25
+ if (sub !== 'links') usage(sub ? `Unknown traceability command: ${sub}` : 'Missing traceability command.', 'traceability');
26
+ const options = { root: '.', check: false, write: false, json: false };
27
+ for (let i = 1; i < argv.length; i += 1) {
28
+ const arg = argv[i];
29
+ if (arg === '--check') options.check = true;
30
+ else if (arg === '--write') options.write = true;
31
+ else if (arg === '--json') options.json = true;
32
+ else if (!arg.startsWith('-')) options.root = arg;
33
+ else usage(`Unknown option: ${arg}`, 'traceability links');
34
+ }
35
+ if (action === '--check' || action === '--write' || action === '--json') {
36
+ // no-op: action slot is an option, not a positional subcommand.
37
+ }
38
+ if (options.check && options.write) usage('Choose only one traceability links mode: --check or --write.', 'traceability links');
39
+ options.check = options.check || !options.write;
40
+ options.root = resolve(options.root);
41
+ return options;
42
+ }
43
+
44
+ export function runTraceability(argv) {
45
+ const options = parseTraceabilityOptions(argv);
46
+ const files = collectDocsMarkdownFiles(options.root);
47
+ const results = [];
48
+ const errors = [];
49
+ for (const file of files) {
50
+ const content = readFileSync(file, 'utf8');
51
+ for (const error of validateTraceabilityLinksRegion(content, options.root, file)) errors.push(error);
52
+ const blocks = extractDotdotgodTraceabilityBlocks(content).filter((block) => !block.error);
53
+ if (blocks.length === 0) continue;
54
+ const result = syncTraceabilityLinksInContent(content, blocks.at(-1).data, options.root, file);
55
+ if (!result.ok) {
56
+ for (const error of result.errors ?? []) errors.push(error);
57
+ continue;
58
+ }
59
+ if (options.write && result.changed) writeFileSync(file, result.content);
60
+ if (result.changed) results.push({ file: rel(options.root, file), changed: true });
61
+ }
62
+ const ok = errors.length === 0 && (!options.check || results.length === 0);
63
+ const payload = { ok, command: 'traceability links', root: options.root, mode: options.write ? 'write' : 'check', changed: results.length, files: results, errors };
64
+ if (options.json) console.log(JSON.stringify(payload, null, 2));
65
+ else if (errors.length > 0) {
66
+ for (const error of errors) console.log(`${error.line ? `${error.file}:${error.line}` : error.file} [${error.code}] ${error.message}`);
67
+ console.log(`\n❌ ${errors.length} traceability link error(s)`);
68
+ } else if (options.check && results.length > 0) {
69
+ for (const result of results) console.log(`${result.file} [TRACEABILITY_LINKS_STALE] generated traceability links are missing or stale.`);
70
+ console.log(`\n❌ ${results.length} traceability link file(s) need sync`);
71
+ } else if (options.write) console.log(`traceability links: synced ${results.length} file(s)`);
72
+ else console.log('traceability links: all generated sections are up to date');
73
+ process.exit(ok ? 0 : 1);
74
+ }