@dotdotgod/cli 0.1.44 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/plan.mjs +77 -93
package/package.json
CHANGED
package/src/commands/plan.mjs
CHANGED
|
@@ -20,12 +20,14 @@ export {
|
|
|
20
20
|
PLAN_STAGE_DIRECTORIES,
|
|
21
21
|
};
|
|
22
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
23
|
function normalizeHeading(value) {
|
|
26
24
|
return String(value ?? '').trim().toLowerCase().replace(/`/g, '').replace(/[\s/_-]+/g, ' ');
|
|
27
25
|
}
|
|
28
26
|
|
|
27
|
+
function escapeRegExp(value) {
|
|
28
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
}
|
|
30
|
+
|
|
29
31
|
function projectRelative(root, file) {
|
|
30
32
|
return relative(root, file).replace(/\\/g, '/');
|
|
31
33
|
}
|
|
@@ -67,16 +69,6 @@ function contentHasValue(content) {
|
|
|
67
69
|
return stripped.length > 0;
|
|
68
70
|
}
|
|
69
71
|
|
|
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
72
|
function readMarkdownFiles(stageDir) {
|
|
81
73
|
if (!existsSync(stageDir)) return [];
|
|
82
74
|
return readdirSync(stageDir, { withFileTypes: true })
|
|
@@ -264,33 +256,21 @@ function validateMarkdownNames(root, stageName, stageDir, blockers) {
|
|
|
264
256
|
}
|
|
265
257
|
}
|
|
266
258
|
|
|
267
|
-
function
|
|
268
|
-
const
|
|
269
|
-
return
|
|
259
|
+
function sectionContainsField(content, labels) {
|
|
260
|
+
const text = removeCodeBlocks(content);
|
|
261
|
+
return labels.some((label) => new RegExp(`(?:^|\\b)${escapeRegExp(label)}\\s*:\\s*\\S`, 'im').test(text));
|
|
270
262
|
}
|
|
271
263
|
|
|
272
|
-
function
|
|
273
|
-
return content.split(/\r?\n/).some((line) =>
|
|
274
|
-
const normalized = line.toLowerCase();
|
|
275
|
-
if (!normalized.includes('pending')) return false;
|
|
276
|
-
return /\byes\b|\brequired\b/.test(normalized);
|
|
277
|
-
});
|
|
264
|
+
function hasUncheckedChecklistItem(content) {
|
|
265
|
+
return content.split(/\r?\n/).some((line) => /^\s*[-*+]\s*\[\s\]/.test(line));
|
|
278
266
|
}
|
|
279
267
|
|
|
280
|
-
function
|
|
281
|
-
return content.split(/\r?\n/).some((line) =>
|
|
282
|
-
const normalized = line.toLowerCase();
|
|
283
|
-
if (/^\s*-\s*\[\s\]/.test(line)) return true;
|
|
284
|
-
return /status\s*:\s*(open|research[-_ ]requested|plan[-_ ]revision[-_ ]requested|unresolved|pending)/.test(normalized);
|
|
285
|
-
});
|
|
268
|
+
function hasOpenStructuredStatus(content) {
|
|
269
|
+
return content.split(/\r?\n/).some((line) => /^\s*(?:[-*+]\s*)?(?:Status|DecisionState|Decision State)\s*:\s*(?:open|blocked|unresolved|pending|research[-_ ]requested|plan[-_ ]revision[-_ ]requested)\s*$/i.test(line));
|
|
286
270
|
}
|
|
287
271
|
|
|
288
|
-
function
|
|
289
|
-
return content
|
|
290
|
-
const normalized = line.toLowerCase();
|
|
291
|
-
if (/^\s*-\s*\[\s\]/.test(line)) return true;
|
|
292
|
-
return /\b(unresolved|pending|unknown|needs user|needs confirmation)\b/.test(normalized);
|
|
293
|
-
});
|
|
272
|
+
function hasUnresolvedStructuredItem(content) {
|
|
273
|
+
return hasUncheckedChecklistItem(content) || hasOpenStructuredStatus(content);
|
|
294
274
|
}
|
|
295
275
|
|
|
296
276
|
function workstreamSplitDecision(content) {
|
|
@@ -298,36 +278,32 @@ function workstreamSplitDecision(content) {
|
|
|
298
278
|
return match?.[1].toLowerCase();
|
|
299
279
|
}
|
|
300
280
|
|
|
281
|
+
function fieldPattern(label, flags = 'im') {
|
|
282
|
+
return new RegExp(`^\\s*(?:[-*+]\\s*)?${escapeRegExp(label)}\\s*:\\s*(\\S.*)$`, flags);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function fieldHasValue(content, label) {
|
|
286
|
+
return Boolean(fieldPattern(label).exec(removeCodeBlocks(content))?.[1]?.trim());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function fieldOccurrences(content, label) {
|
|
290
|
+
return [...removeCodeBlocks(content).matchAll(fieldPattern(label, 'img'))].length;
|
|
291
|
+
}
|
|
292
|
+
|
|
301
293
|
function hasNoSplitRationale(content) {
|
|
302
|
-
return
|
|
294
|
+
return fieldHasValue(content, 'No-split rationale');
|
|
303
295
|
}
|
|
304
296
|
|
|
305
297
|
function hasMultipleWorkstreams(content) {
|
|
306
|
-
|
|
307
|
-
const explicitIds = new Set([...normalized.matchAll(/\b(?:workstream|ws)\s*[-:#]?\s*([a-z0-9]+)\b/g)].map((match) => match[1]));
|
|
308
|
-
if (explicitIds.size >= 2) return true;
|
|
309
|
-
const workstreamMentions = normalized.match(/\bworkstreams?\b/g) ?? [];
|
|
310
|
-
return workstreamMentions.length >= 2 && /\b(?:multiple|parallel|split)\b/.test(normalized);
|
|
298
|
+
return fieldOccurrences(content, 'Workstream ID') >= 2;
|
|
311
299
|
}
|
|
312
300
|
|
|
313
301
|
function hasActionableWorkstreamFields(content) {
|
|
314
|
-
|
|
315
|
-
const fieldGroups = [
|
|
316
|
-
['purpose', 'objective'],
|
|
317
|
-
['required context', 'read'],
|
|
318
|
-
['allowed edit'],
|
|
319
|
-
['forbidden edit', 'do not edit', 'do-not edit'],
|
|
320
|
-
['task'],
|
|
321
|
-
['validation', 'verify'],
|
|
322
|
-
['handoff output', 'output'],
|
|
323
|
-
['dependenc'],
|
|
324
|
-
];
|
|
325
|
-
return fieldGroups.every((group) => group.some((term) => normalized.includes(term)));
|
|
302
|
+
return ['Workstream ID', 'Purpose', 'Required context', 'Allowed edits', 'Forbidden edits', 'Tasks', 'Validation', 'Handoff output', 'Dependencies'].every((label) => fieldHasValue(content, label));
|
|
326
303
|
}
|
|
327
304
|
|
|
328
305
|
function hasActionableIntegrationFields(content) {
|
|
329
|
-
|
|
330
|
-
return ['sequence', 'integrat'].every((term) => normalized.includes(term)) && ['validation', 'verify', 'handoff'].some((term) => normalized.includes(term));
|
|
306
|
+
return ['Step', 'Validation', 'Handoff'].every((label) => fieldHasValue(content, label));
|
|
331
307
|
}
|
|
332
308
|
|
|
333
309
|
function validateWorkstreamHandoffContract(root, files, stageName, blockers) {
|
|
@@ -344,9 +320,9 @@ function validateWorkstreamHandoffContract(root, files, stageName, blockers) {
|
|
|
344
320
|
if (decision === 'no') {
|
|
345
321
|
if (!hasNoSplitRationale(handoff.content)) {
|
|
346
322
|
valid = false;
|
|
347
|
-
addBlocker(blockers, 'missing-workstream-no-split-rationale', 'Workstream Handoff with `Split decision: no` must include
|
|
323
|
+
addBlocker(blockers, 'missing-workstream-no-split-rationale', 'Workstream Handoff with `Split decision: no` must include `No-split rationale:`.', { path: relHandoff, stage: stageName, section: 'Workstream Handoff' });
|
|
348
324
|
}
|
|
349
|
-
if (!todo || !contentHasValue(todo.content)
|
|
325
|
+
if (!todo || !contentHasValue(todo.content)) valid = false;
|
|
350
326
|
return valid;
|
|
351
327
|
}
|
|
352
328
|
|
|
@@ -356,15 +332,15 @@ function validateWorkstreamHandoffContract(root, files, stageName, blockers) {
|
|
|
356
332
|
const splitContent = [map?.content, workstreams?.content, integration?.content].filter(Boolean).join('\n');
|
|
357
333
|
if (!hasMultipleWorkstreams(splitContent)) {
|
|
358
334
|
valid = false;
|
|
359
|
-
addBlocker(blockers, 'incomplete-workstream-split', 'Split Stage 05 handoff must
|
|
335
|
+
addBlocker(blockers, 'incomplete-workstream-split', 'Split Stage 05 handoff must include at least two `Workstream ID:` fields in Workstream Map, Workstreams, or Integration Sequence.', { path: projectRelative(root, map?.file ?? workstreams?.file ?? integration?.file ?? handoff.file), stage: stageName, section: 'Workstream Map' });
|
|
360
336
|
}
|
|
361
337
|
if (!workstreams || !hasActionableWorkstreamFields(workstreams.content)) {
|
|
362
338
|
valid = false;
|
|
363
|
-
addBlocker(blockers, 'incomplete-workstream-split', 'Workstreams must
|
|
339
|
+
addBlocker(blockers, 'incomplete-workstream-split', 'Workstreams must include non-empty structured fields: Workstream ID, Purpose, Required context, Allowed edits, Forbidden edits, Tasks, Validation, Handoff output, and Dependencies.', { path: projectRelative(root, workstreams?.file ?? handoff.file), stage: stageName, section: 'Workstreams' });
|
|
364
340
|
}
|
|
365
341
|
if (!integration || !hasActionableIntegrationFields(integration.content)) {
|
|
366
342
|
valid = false;
|
|
367
|
-
addBlocker(blockers, 'incomplete-workstream-split', 'Integration Sequence must
|
|
343
|
+
addBlocker(blockers, 'incomplete-workstream-split', 'Integration Sequence must include non-empty structured fields: Step, Validation, and Handoff.', { path: projectRelative(root, integration?.file ?? handoff.file), stage: stageName, section: 'Integration Sequence' });
|
|
368
344
|
}
|
|
369
345
|
return valid;
|
|
370
346
|
}
|
|
@@ -378,25 +354,42 @@ function normalizeChecklistCategory(value) {
|
|
|
378
354
|
return normalizeHeading(value).replace(/\s+/g, ' ');
|
|
379
355
|
}
|
|
380
356
|
|
|
381
|
-
function
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
357
|
+
function checklistStatusChecked(value) {
|
|
358
|
+
return /\[[xX]\]/.test(value) || /\bcompleted\b/i.test(value);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseChecklistLine(line, lineNumber) {
|
|
362
|
+
const trimmed = line.trim();
|
|
363
|
+
if (!trimmed) return undefined;
|
|
364
|
+
if (/^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test(trimmed)) return undefined;
|
|
365
|
+
|
|
366
|
+
const canonical = /^\s*[-*+]\s*\[([^\]])\]\s*([^:]+):\s*(.*?)\s*$/.exec(line);
|
|
367
|
+
if (canonical) return { line, lineNumber, checked: canonical[1].trim().toLowerCase() === 'x', category: canonical[2].trim(), evidence: canonical[3].trim(), malformed: false };
|
|
368
|
+
|
|
369
|
+
const checkedDash = /^\s*[-*+]\s*\[([^\]])\]\s*(.+?)\s*[—–-]\s*(.*?)\s*$/.exec(line);
|
|
370
|
+
if (checkedDash) return { line, lineNumber, checked: checkedDash[1].trim().toLowerCase() === 'x', category: checkedDash[2].trim(), evidence: checkedDash[3].trim(), malformed: false };
|
|
371
|
+
|
|
372
|
+
const completedPrefix = /^\s*[-*+]\s*completed\s*[—–-]\s*\[([xX])\]\s*(.+?)(?::\s*(.*?))?\s*$/.exec(line);
|
|
373
|
+
if (completedPrefix) return { line, lineNumber, checked: true, category: completedPrefix[2].trim(), evidence: (completedPrefix[3] ?? 'completed').trim(), malformed: false };
|
|
374
|
+
|
|
375
|
+
const table = /^\s*\|(.+)\|\s*$/.exec(line);
|
|
376
|
+
if (table) {
|
|
377
|
+
const cells = table[1].split('|').map((cell) => cell.trim()).filter(Boolean);
|
|
378
|
+
if (cells.length >= 2 && !/^item$/i.test(cells[0]) && !/^category$/i.test(cells[0])) return { line, lineNumber, checked: checklistStatusChecked(cells.slice(1).join(' | ')), category: cells[0], evidence: cells.slice(1).join(' | '), malformed: false };
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const colonStatus = /^\s*([^:|]+):\s*(completed\s*[—–-]\s*\[[xX]\]|\[[xX]\]|completed)(?:\s*[—–:\-]\s*(.*?))?\s*$/.exec(line);
|
|
383
|
+
if (colonStatus) return { line, lineNumber, checked: checklistStatusChecked(colonStatus[2]), category: colonStatus[1].trim(), evidence: (colonStatus[3] ?? colonStatus[2]).trim(), malformed: false };
|
|
384
|
+
|
|
385
|
+
return /^\s*[-*+]\s*/.test(line) ? { line, lineNumber, malformed: true } : undefined;
|
|
385
386
|
}
|
|
386
387
|
|
|
387
388
|
function parseChecklistRows(content) {
|
|
388
|
-
return content
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
line,
|
|
393
|
-
lineNumber: index + 1,
|
|
394
|
-
checked: match[1].trim().toLowerCase() === 'x',
|
|
395
|
-
category: match[2].trim(),
|
|
396
|
-
evidence: match[3].trim(),
|
|
397
|
-
malformed: false,
|
|
398
|
-
};
|
|
399
|
-
}).filter((row) => row.malformed || row.category);
|
|
389
|
+
return content
|
|
390
|
+
.split(/\r?\n/)
|
|
391
|
+
.map((line, index) => parseChecklistLine(line, index + 1))
|
|
392
|
+
.filter((row) => row?.malformed || row?.category);
|
|
400
393
|
}
|
|
401
394
|
|
|
402
395
|
function validateConstructionChecklist(root, files, stageName, blockers) {
|
|
@@ -415,22 +408,22 @@ function validateConstructionChecklist(root, files, stageName, blockers) {
|
|
|
415
408
|
for (const row of rows) {
|
|
416
409
|
if (row.malformed) {
|
|
417
410
|
valid = false;
|
|
418
|
-
addBlocker(blockers, 'malformed-checklist-item', `Malformed construction checklist item at line ${row.lineNumber}: expected "- [x] Category: evidence".`, { path: relFound, stage: stageName, section: header });
|
|
411
|
+
addBlocker(blockers, 'malformed-checklist-item', `Malformed construction checklist item at line ${row.lineNumber}: expected canonical "- [x] Category: evidence" or a completed table/status row with non-empty evidence.`, { path: relFound, stage: stageName, section: header });
|
|
419
412
|
continue;
|
|
420
413
|
}
|
|
421
|
-
if (!row.checked
|
|
414
|
+
if (!row.checked) {
|
|
422
415
|
valid = false;
|
|
423
|
-
addBlocker(blockers, 'open-checklist-item', `Construction checklist item is
|
|
416
|
+
addBlocker(blockers, 'open-checklist-item', `Construction checklist item is unchecked: ${row.category}`, { path: relFound, stage: stageName, section: header });
|
|
424
417
|
}
|
|
425
|
-
if (!contentHasValue(row.evidence)
|
|
418
|
+
if (!contentHasValue(row.evidence)) {
|
|
426
419
|
valid = false;
|
|
427
|
-
addBlocker(blockers, '
|
|
420
|
+
addBlocker(blockers, 'empty-checklist-item', `Construction checklist item has no evidence: ${row.category}`, { path: relFound, stage: stageName, section: header });
|
|
428
421
|
}
|
|
429
422
|
}
|
|
430
423
|
for (const category of requiredCategories) {
|
|
431
424
|
if (!byCategory.has(normalizeChecklistCategory(category))) {
|
|
432
425
|
valid = false;
|
|
433
|
-
addBlocker(blockers, 'missing-checklist-item', `Missing construction checklist item: ${category}
|
|
426
|
+
addBlocker(blockers, 'missing-checklist-item', `Missing construction checklist item: ${category}. Preferred format: "- [x] ${category}: evidence".`, { path: relFound, stage: stageName, section: header });
|
|
434
427
|
}
|
|
435
428
|
}
|
|
436
429
|
return valid;
|
|
@@ -531,32 +524,23 @@ export function validatePlanArtifact(planPath, options = {}) {
|
|
|
531
524
|
addBlocker(blockers, 'empty-section', `Required checkpoint section has no content: ## ${header}`, { path: relFound, stage: stageName, section: header });
|
|
532
525
|
continue;
|
|
533
526
|
}
|
|
534
|
-
if (isPlaceholderContent(found.content)) {
|
|
535
|
-
stageValid = false;
|
|
536
|
-
addBlocker(blockers, 'placeholder-section', `Required checkpoint section only contains placeholder content: ## ${header}`, { path: relFound, stage: stageName, section: header });
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
527
|
summary.requiredSections.valid += 1;
|
|
540
528
|
|
|
541
529
|
if (header === 'Atomic Tasks') {
|
|
542
|
-
if (!
|
|
530
|
+
if (!sectionContainsField(found.content, ['Acceptance', 'Acceptance criteria'])) {
|
|
543
531
|
stageValid = false;
|
|
544
532
|
addBlocker(blockers, 'missing-atomic-acceptance', 'Atomic Tasks must define acceptance criteria.', { path: relFound, stage: stageName, section: header });
|
|
545
533
|
}
|
|
546
|
-
if (!
|
|
534
|
+
if (!sectionContainsField(found.content, ['Verification'])) {
|
|
547
535
|
stageValid = false;
|
|
548
536
|
addBlocker(blockers, 'missing-atomic-verification', 'Atomic Tasks must define verification.', { path: relFound, stage: stageName, section: header });
|
|
549
537
|
}
|
|
550
538
|
}
|
|
551
|
-
if (header === '
|
|
552
|
-
stageValid = false;
|
|
553
|
-
addBlocker(blockers, 'pending-workstream', 'Required role/area workstream is pending.', { path: relFound, stage: stageName, section: header });
|
|
554
|
-
}
|
|
555
|
-
if (header === 'Discussion Queue' && hasUnresolvedDiscussion(found.content)) {
|
|
539
|
+
if (header === 'Discussion Queue' && hasUnresolvedStructuredItem(found.content)) {
|
|
556
540
|
stageValid = false;
|
|
557
541
|
addBlocker(blockers, 'unresolved-discussion', 'Discussion Queue has unresolved items.', { path: relFound, stage: stageName, section: header });
|
|
558
542
|
}
|
|
559
|
-
if (header === 'Assumptions' &&
|
|
543
|
+
if (header === 'Assumptions' && hasUnresolvedStructuredItem(found.content)) {
|
|
560
544
|
stageValid = false;
|
|
561
545
|
addBlocker(blockers, 'unresolved-assumption', 'Assumptions contain unresolved items.', { path: relFound, stage: stageName, section: header });
|
|
562
546
|
}
|