pan-wizard 3.35.0 → 3.36.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 (60) hide show
  1. package/README.md +11 -5
  2. package/agents/pan-designer.md +1 -1
  3. package/agents/pan-executor.md +25 -0
  4. package/agents/pan-hardener.md +2 -0
  5. package/agents/pan-phase-researcher.md +14 -0
  6. package/agents/pan-plan-checker.md +28 -1
  7. package/agents/pan-planner.md +11 -1
  8. package/agents/pan-previewer.md +2 -0
  9. package/agents/pan-project-researcher.md +8 -0
  10. package/agents/pan-reviewer.md +5 -0
  11. package/agents/pan-verifier.md +2 -2
  12. package/commands/pan/cost.md +21 -1
  13. package/commands/pan/preview.md +1 -1
  14. package/package.json +1 -1
  15. package/pan-wizard-core/bin/lib/config.cjs +10 -3
  16. package/pan-wizard-core/bin/lib/core.cjs +9 -4
  17. package/pan-wizard-core/bin/lib/cost.cjs +146 -0
  18. package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
  19. package/pan-wizard-core/bin/lib/findings.cjs +115 -6
  20. package/pan-wizard-core/bin/lib/git.cjs +1 -1
  21. package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
  22. package/pan-wizard-core/bin/lib/phase-gate.cjs +15 -9
  23. package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
  24. package/pan-wizard-core/bin/lib/phase.cjs +37 -9
  25. package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
  26. package/pan-wizard-core/bin/lib/preview.cjs +3 -0
  27. package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
  28. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
  29. package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
  30. package/pan-wizard-core/bin/lib/usage.cjs +104 -0
  31. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +1 -1
  32. package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
  33. package/pan-wizard-core/bin/lib/verify-scope.cjs +143 -2
  34. package/pan-wizard-core/bin/lib/verify.cjs +27 -27
  35. package/pan-wizard-core/bin/pan-tools.cjs +94 -6
  36. package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
  37. package/pan-wizard-core/references/design-methodology.md +1 -1
  38. package/pan-wizard-core/references/handoff-decisions.md +1 -1
  39. package/pan-wizard-core/templates/config.json +5 -1
  40. package/pan-wizard-core/templates/design.md +5 -4
  41. package/pan-wizard-core/templates/phase-prompt.md +7 -0
  42. package/pan-wizard-core/templates/research-project/architecture.md +6 -0
  43. package/pan-wizard-core/templates/research-project/features.md +6 -0
  44. package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
  45. package/pan-wizard-core/templates/research-project/stack.md +6 -0
  46. package/pan-wizard-core/templates/research.md +10 -0
  47. package/pan-wizard-core/templates/summary-complex.md +5 -1
  48. package/pan-wizard-core/templates/summary-minimal.md +4 -0
  49. package/pan-wizard-core/templates/summary-standard.md +5 -1
  50. package/pan-wizard-core/templates/summary.md +8 -1
  51. package/pan-wizard-core/workflows/exec-phase.md +43 -7
  52. package/pan-wizard-core/workflows/execute-plan.md +3 -1
  53. package/pan-wizard-core/workflows/help.md +103 -44
  54. package/pan-wizard-core/workflows/plan-phase.md +20 -2
  55. package/pan-wizard-core/workflows/remove-phase.md +11 -1
  56. package/pan-wizard-core/workflows/settings.md +17 -0
  57. package/pan-wizard-core/workflows/verify-phase.md +39 -46
  58. package/scripts/build-help.cjs +106 -0
  59. package/scripts/mutation-probe.cjs +7 -80
  60. package/scripts/test-surface.cjs +168 -6
@@ -12,6 +12,21 @@ const { writeStateMd, readStateSafe } = require('./state.cjs');
12
12
  const { ROADMAP_FILE, STATE_FILE, isSummaryFile } = require('./constants.cjs');
13
13
  const { planningPath, phasesPath, fileAccessible } = require('./utils.cjs');
14
14
 
15
+ /**
16
+ * A regex source matching a phase number as any roadmap writes it: `phase insert` writes
17
+ * padded decimals (`Phase 01.1`) and `phase add` unpadded integers (`Phase 3`), so "1.1"
18
+ * must match both `1.1` and `01.1` (found by H7's property test: removing 1.1 deleted its
19
+ * directory and left its roadmap section behind).
20
+ * @param {string} num - a phase number, padded or not ("1.1", "01.1", "3")
21
+ */
22
+ function phaseNumPattern(num) {
23
+ const [int, ...rest] = String(num).split('.');
24
+ return `0*${escapeRegex(String(parseInt(int, 10)))}${rest.map((d) => `\\.${escapeRegex(d)}`).join('')}`;
25
+ }
26
+
27
+ /** Write `next` in the style `matched` was written in: padded to two digits, or not. */
28
+ const samePadding = (matched, next) => (/^0\d/.test(matched) ? String(next).padStart(2, '0') : String(next));
29
+
15
30
  /**
16
31
  * Delete a phase directory from disk.
17
32
  * @param {string} phaseDir - Absolute path to the phase directory to remove
@@ -200,7 +215,7 @@ function removePhaseSection(content, phaseNum) {
200
215
  fenced.fill(true, top.open, i + 1);
201
216
  } else if (!top || f[2].trim()) stack.push({ fence: f[1], open: i });
202
217
  }
203
- const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${escapeRegex(phaseNum)}\\s*:`, 'i');
218
+ const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${phaseNumPattern(phaseNum)}\\s*:`, 'i');
204
219
  const start = lines.findIndex((l, i) => !fenced[i] && headRe.test(l));
205
220
  if (start === -1) return content;
206
221
  const levelEndRe = new RegExp(`^#{1,${lines[start].match(headRe)[1].length}}\\s`);
@@ -258,7 +273,7 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
258
273
  } catch { return; }
259
274
 
260
275
  roadmapContent = removePhaseSection(roadmapContent, phaseNum);
261
- const targetEscaped = escapeRegex(phaseNum);
276
+ const targetEscaped = phaseNumPattern(phaseNum);
262
277
 
263
278
  // Remove checkbox list items referencing this phase
264
279
  const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
@@ -291,14 +306,14 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
291
306
 
292
307
  // Phase headings: ## Phase 18: or ### Phase 18.1: -> ## Phase 17: / ### Phase 17.1:
293
308
  roadmapContent = roadmapContent.replace(
294
- new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}((?:\\.\\d+)?\\s*:)`, 'gi'),
295
- `$1${newStr}$2`
309
+ new RegExp(`(#{2,4}\\s*Phase\\s+)(0*)${oldStr}((?:\\.\\d+)?\\s*:)`, 'gi'),
310
+ (_, head, zeros, tail) => `${head}${samePadding(zeros + oldStr, newNum)}${tail}`
296
311
  );
297
312
 
298
313
  // Inline phase references: "Phase 18:", "Phase 18 " or "Phase 18.1:" -> "Phase 17..."
299
314
  roadmapContent = roadmapContent.replace(
300
- new RegExp(`(Phase\\s+)${oldStr}((?:\\.\\d+)?[:\\s])`, 'g'),
301
- `$1${newStr}$2`
315
+ new RegExp(`(Phase\\s+)(0*)${oldStr}((?:\\.\\d+)?[:\\s])`, 'g'),
316
+ (_, head, zeros, tail) => `${head}${samePadding(zeros + oldStr, newNum)}${tail}`
302
317
  );
303
318
 
304
319
  // Plan references in padded form: 18-01 / 18.1-01 -> 17-01 / 17.1-01, never inside a date (2026-10-01)
@@ -309,26 +324,65 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
309
324
 
310
325
  // Progress table row numbers: | 18. or | 18.1. -> | 17. / | 17.1.
311
326
  roadmapContent = roadmapContent.replace(
312
- new RegExp(`(\\|\\s*)${oldStr}((?:\\.\\d+)?)\\.\\s`, 'g'),
313
- `$1${newStr}$2. `
327
+ new RegExp(`(\\|\\s*)(0*)${oldStr}((?:\\.\\d+)?)\\.\\s`, 'g'),
328
+ (_, head, zeros, dec) => `${head}${samePadding(zeros + oldStr, newNum)}${dec}. `
314
329
  );
315
330
 
316
331
  // Depends-on references: "Depends on:** Phase 18" -> "Phase 17"
317
332
  roadmapContent = roadmapContent.replace(
318
- new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
319
- `$1${newStr}`
333
+ new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)(0*)${oldStr}\\b`, 'gi'),
334
+ (_, head, zeros) => `${head}${samePadding(zeros + oldStr, newNum)}`
320
335
  );
321
336
  }
337
+ } else {
338
+ // Decimal removal: the siblings above it move down one (06.3 -> 06.2), as their
339
+ // directories already did. Upward, so a renamed reference is never renamed twice.
340
+ const [baseStr, removedStr] = normalized.split('.');
341
+ const base = escapeRegex(String(parseInt(baseStr, 10)));
342
+ for (let dec = parseInt(removedStr, 10) + 1; dec <= 99; dec++) {
343
+ const was = `(0*${base})\\.${dec}`;
344
+ roadmapContent = roadmapContent
345
+ .replace(new RegExp(`(Phase\\s+)${was}([:\\s])`, 'gi'), (_, head, int, tail) => `${head}${int}.${dec - 1}${tail}`)
346
+ .replace(new RegExp(`(\\|\\s*)${was}\\.\\s`, 'g'), (_, head, int) => `${head}${int}.${dec - 1}. `)
347
+ .replace(new RegExp(`(?<![\\d.-])${was}-(\\d{2})(?!\\d)`, 'g'), (_, int, plan) => `${int}.${dec - 1}-${plan}`);
348
+ }
322
349
  }
323
350
 
324
351
  try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch { /* best-effort */ }
325
352
  }
326
353
 
354
+ /**
355
+ * The decimal phases inserted after an integer phase, from its directories and its
356
+ * roadmap headings (a phase can be in the roadmap before it has a directory).
357
+ * @param {string} phasesDir - the phases directory
358
+ * @param {string} roadmapPath - roadmap.md
359
+ * @param {number} baseInt - the integer phase
360
+ * @returns {{num: string, dir: string|null}[]} in numeric order, e.g. [{num: '3.1', dir: '03.1-fix'}]
361
+ */
362
+ function findDecimalPhases(phasesDir, roadmapPath, baseInt) {
363
+ const found = new Map();
364
+ const dirRe = new RegExp(`^0*${baseInt}\\.(\\d+)(?:-|$)`);
365
+ try {
366
+ for (const entry of fs.readdirSync(phasesDir, { withFileTypes: true })) {
367
+ const m = entry.isDirectory() && entry.name.match(dirRe);
368
+ if (m) found.set(Number(m[1]), entry.name);
369
+ }
370
+ } catch { /* no phases directory: the roadmap alone decides */ }
371
+ let text = '';
372
+ try { text = fs.readFileSync(roadmapPath, 'utf8'); } catch { /* unreadable: directories alone */ }
373
+ for (const m of text.matchAll(new RegExp(`^#{2,4}\\s*Phase\\s+0*${baseInt}\\.(\\d+)\\s*:`, 'gim'))) {
374
+ if (!found.has(Number(m[1]))) found.set(Number(m[1]), null);
375
+ }
376
+ return [...found.entries()].sort((a, b) => a[0] - b[0]).map(([dec, dir]) => ({ num: `${baseInt}.${dec}`, dir }));
377
+ }
378
+
327
379
  /**
328
380
  * Remove a phase directory, renumber subsequent phases, and update ROADMAP/STATE.
381
+ * An integer phase that still has decimal phases is refused unless `withDecimals`.
329
382
  * @param {string} cwd - Working directory path
330
383
  * @param {string} targetPhase - Phase number to remove
331
- * @param {Object} options - Options (force: skip executed-work check)
384
+ * @param {Object} options - Options (force: skip executed-work check; withDecimals: remove
385
+ * the integer phase's decimal phases with it)
332
386
  * @param {boolean} raw - If true, output raw value instead of JSON
333
387
  * @returns {void}
334
388
  */
@@ -393,6 +447,21 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
393
447
  }
394
448
  }
395
449
 
450
+ // An integer phase's decimal phases (3.1, 3.2 …) were inserted after it. Removing the
451
+ // integer alone orphaned them: they stayed on disk and in the roadmap and, once the
452
+ // phases above were renumbered, read as decimals of a different phase, listed before
453
+ // their new base. So they go with it only when asked; otherwise refuse, naming them.
454
+ const decimals = isDecimal ? [] : findDecimalPhases(phasesDir, roadmapPath, parseInt(normalized, 10));
455
+ if (decimals.length && !options.withDecimals) {
456
+ error(`Phase ${targetPhase} has decimal phases ${decimals.map((d) => d.num).join(', ')} inserted after it. Remove them first, or pass --with-decimals to remove them with it.`);
457
+ }
458
+ if (!force) {
459
+ for (const d of decimals) {
460
+ const executed = d.dir ? fs.readdirSync(path.join(phasesDir, d.dir)).filter(isSummaryFile).length : 0;
461
+ if (executed) error(`Phase ${d.num} has ${executed} executed plan(s). Use --force to remove anyway.`);
462
+ }
463
+ }
464
+
396
465
  // Check for executed work (summary.md files)
397
466
  if (targetDir && !force) {
398
467
  const targetPath = path.join(phasesDir, targetDir);
@@ -403,6 +472,16 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
403
472
  }
404
473
  }
405
474
 
475
+ // --with-decimals: every check has passed, so remove the decimals first, highest first,
476
+ // which leaves no sibling above any of them to renumber.
477
+ const removedDecimals = [];
478
+ for (const d of decimals.slice().reverse()) {
479
+ if (d.dir) removePhaseFromDisk(path.join(phasesDir, d.dir));
480
+ updateRoadmapAfterRemoval(cwd, d.num, true, normalizePhaseName(d.num));
481
+ updateStateAfterPhaseRemoval(cwd);
482
+ removedDecimals.unshift(d.num);
483
+ }
484
+
406
485
  // Delete target directory
407
486
  let removeWarning = null;
408
487
  if (targetDir) {
@@ -449,6 +528,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
449
528
  roadmap_updated: true,
450
529
  state_updated: stateUpdated,
451
530
  };
531
+ if (removedDecimals.length) result.removed_decimals = removedDecimals;
452
532
  if (renameError) result.rename_warning = renameError;
453
533
  if (removeWarning) result.remove_warning = removeWarning;
454
534
 
@@ -430,6 +430,30 @@ function appendPhaseChecklistEntry(content, phaseNum, name) {
430
430
  return lines.join('\n');
431
431
  }
432
432
 
433
+ /**
434
+ * Add an inserted phase's "## Phases" checklist entry after its base phase's entry and
435
+ * that phase's existing decimals, so the list stays in numeric order (appending put 3.1
436
+ * after Phase 4; found by H7's property test). Falls back to appending when the base
437
+ * phase has no checklist entry.
438
+ *
439
+ * @param {string} content - Full roadmap.md content
440
+ * @param {string} basePhase - The integer phase inserted after, without leading zeros
441
+ * @param {string} phaseNum - The inserted phase's number as it should appear
442
+ * @param {string} name - Phase name/description
443
+ * @returns {string} Updated content
444
+ */
445
+ function insertPhaseChecklistEntry(content, basePhase, phaseNum, name) {
446
+ const lines = content.split('\n');
447
+ const own = new RegExp(`^\\s*- \\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+0*${escapeRegex(basePhase)}(?:\\.\\d+)*:`, 'i');
448
+ let lastIdx = -1;
449
+ for (let i = 0; i < lines.length; i++) {
450
+ if (own.test(lines[i])) lastIdx = i;
451
+ }
452
+ if (lastIdx === -1) return appendPhaseChecklistEntry(content, phaseNum, name);
453
+ lines.splice(lastIdx + 1, 0, `- [ ] **Phase ${phaseNum}: ${name}**`);
454
+ return lines.join('\n');
455
+ }
456
+
433
457
  /**
434
458
  * Append a new phase to the end of the roadmap and create its directory.
435
459
  * @param {string} cwd - Working directory path
@@ -581,20 +605,24 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
581
605
  }
582
606
 
583
607
  const headerIdx = content.indexOf(headerMatch[0]);
584
- const afterHeader = content.slice(headerIdx + headerMatch[0].length);
585
- const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
586
-
587
- let insertIdx;
588
- if (nextPhaseMatch) {
589
- insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
590
- } else {
591
- insertIdx = content.length;
608
+ const bodyStart = headerIdx + headerMatch[0].length;
609
+ const afterHeader = content.slice(bodyStart);
610
+ // The new section goes before the next phase that is not one of this phase's decimals:
611
+ // after 06.1 and 06.2, not straight after Phase 6, or the roadmap (and `roadmap analyze`,
612
+ // which lists phases in roadmap order) would read 6, 6.3, 6.2, 6.1 (found by H7's
613
+ // property test).
614
+ const ownDecimal = new RegExp(`^#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}\\.\\d`, 'i');
615
+ const headingRe = /\n(#{2,4}\s+Phase\s+\d[^\n]*)/gi;
616
+ let insertIdx = content.length;
617
+ let heading;
618
+ while ((heading = headingRe.exec(afterHeader)) !== null) {
619
+ if (!ownDecimal.test(heading[1])) { insertIdx = bodyStart + heading.index; break; }
592
620
  }
593
621
 
594
622
  // Insert the detail section, then the matching checklist entry — same reason as
595
623
  // cmdPhaseAdd: without it the inserted phase never appears in the "## Phases" list.
596
624
  const withEntry = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
597
- const updatedContent = appendPhaseChecklistEntry(withEntry, decimalPhase, description);
625
+ const updatedContent = insertPhaseChecklistEntry(withEntry, unpadded, decimalPhase, description);
598
626
  try {
599
627
  fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
600
628
  } catch (e) {
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+ /**
3
+ * Plan intent checked in code: the parts of a plan check that are reading, not
4
+ * judgement, so the plan-checker is not spent on them.
5
+ *
6
+ * `verify review-focus <phase>` (program P4, M41). A plan names, under
7
+ * `## Review Focus`, at most five inputs or failure modes its requirements imply and
8
+ * no test exercises yet, each pinned to the task that owns the code and the test that
9
+ * task adds. A plan with none says so on one `None — <reason>` line. The reviewer gets
10
+ * the section through `review scope` (R1).
11
+ *
12
+ * `verify decisions <phase>` (program P2). The phase design records its decisions with
13
+ * ids (`DD-N`), and only the ones that pass the three-question test (hard to reverse,
14
+ * a reasonable engineer could choose otherwise, more than one task depends on it).
15
+ * Plans cite the ids they implement. A plan citing an id the design does not record
16
+ * relies on a decision nobody approved; a recorded decision no plan cites is approved
17
+ * design nobody builds. A design from before ids is `legacy`: a warning, never a refusal.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { output, error, toLf } = require('./core.cjs');
22
+ const { getPlanId } = require('./constants.cjs');
23
+ const { phaseFiles } = require('./verify-plan-set.cjs');
24
+ const { sectionBody } = require('./verify-scope.cjs');
25
+
26
+ const REVIEW_FOCUS_MAX = 5;
27
+ const DECISION_ID_RE = /\bDD-\d+\b/g;
28
+
29
+ function readText(file) {
30
+ try { return toLf(fs.readFileSync(file, 'utf-8')); } catch { return ''; }
31
+ }
32
+
33
+ /** The phase's plans as `{plan, file, text}`, or `{error, reason}`. */
34
+ function phasePlans(cwd, phase) {
35
+ const pf = phaseFiles(cwd, phase);
36
+ if (pf.error) return pf;
37
+ return { pf, plans: pf.planFiles.map((file) => ({ plan: getPlanId(file), file, text: readText(path.join(pf.dir, file)) })) };
38
+ }
39
+
40
+ // ─── Review Focus (P4) ───────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Text with HTML comments removed: repeated until nothing changes, since removing
44
+ * one can join the text around it into another, and an unclosed `<!--` hides the
45
+ * rest. A placeholder comment is not a Review Focus item.
46
+ */
47
+ function stripComments(s) {
48
+ let prev;
49
+ do { prev = s; s = s.replace(/<!--[\s\S]*?-->/g, ''); } while (s !== prev);
50
+ const open = s.indexOf('<!--');
51
+ return open === -1 ? s : s.slice(0, open);
52
+ }
53
+
54
+ /** Check one plan's `## Review Focus`. */
55
+ function reviewFocusOf(p) {
56
+ const body = sectionBody(p.text, 'Review Focus');
57
+ const out = { plan: p.plan, file: p.file, status: 'ok', items: 0 };
58
+ const errors = [];
59
+ if (body == null) {
60
+ errors.push({ code: 'review_focus_missing', plan: p.plan, detail: 'no `## Review Focus` section' });
61
+ return { ...out, status: 'missing', errors };
62
+ }
63
+ const text = stripComments(body).trim();
64
+ const items = text.split('\n').map((l) => l.match(/^\s*(?:[-*]|\d+[.)])\s+(.*\S)/)).filter(Boolean).map((m) => m[1]);
65
+ if (!items.length) {
66
+ // `None — <reason>`: the reason is what tells the checker the planner looked.
67
+ const none = text.match(/^none\b[\s—–:-]*(.*)$/im);
68
+ if (none && none[1].trim().split(/\s+/).length >= 3) return { ...out, status: 'none', errors };
69
+ errors.push({ code: 'review_focus_empty', plan: p.plan, detail: 'no items, and no `None — <reason>` line' });
70
+ return { ...out, status: 'invalid', errors };
71
+ }
72
+ out.items = items.length;
73
+ if (items.length > REVIEW_FOCUS_MAX) {
74
+ errors.push({ code: 'review_focus_too_many', plan: p.plan, detail: `${items.length} items; at most ${REVIEW_FOCUS_MAX}` });
75
+ }
76
+ const taskCount = (p.text.match(/<task\b/g) || []).length;
77
+ for (const item of items) {
78
+ const short = item.slice(0, 80);
79
+ const task = item.match(/\bTask\s+(\d+)\b/i);
80
+ if (!task) errors.push({ code: 'review_focus_no_task', plan: p.plan, detail: `names no owning task: ${short}` });
81
+ else if (Number(task[1]) < 1 || Number(task[1]) > taskCount) errors.push({ code: 'review_focus_unknown_task', plan: p.plan, detail: `Task ${task[1]} is not one of the plan's ${taskCount} tasks: ${short}` });
82
+ if (!/\btests?\b/i.test(item)) errors.push({ code: 'review_focus_no_test', plan: p.plan, detail: `names no test: ${short}` });
83
+ }
84
+ return { ...out, status: errors.length ? 'invalid' : 'ok', errors };
85
+ }
86
+
87
+ /**
88
+ * @returns {{phase, ok, plans: Array<{plan, file, status, items}>, errors}} or `{error, reason}`
89
+ */
90
+ function checkReviewFocus(cwd, phase) {
91
+ const pp = phasePlans(cwd, phase);
92
+ if (pp.error) return pp;
93
+ const results = pp.plans.map(reviewFocusOf);
94
+ const errors = results.flatMap((r) => r.errors);
95
+ return { phase: pp.pf.info.phase_number, ok: errors.length === 0, plans: results.map(({ errors: _e, ...r }) => r), errors };
96
+ }
97
+
98
+ // ─── Decision spine (P2) ─────────────────────────────────────────────────────
99
+
100
+ /** Decision ids a design records: the first cell of a table row, or the head of a list item. */
101
+ function recordedDecisions(text) {
102
+ const ids = [];
103
+ for (const line of text.split('\n')) {
104
+ const m = line.match(/^\s*\|\s*\**(DD-\d+)\**\s*\|/) || line.match(/^\s*(?:[-*]|\d+[.)])\s+\**(DD-\d+)\**\b/) || line.match(/^#{2,6}\s+(DD-\d+)\b/);
105
+ if (m) ids.push(m[1]);
106
+ }
107
+ return ids;
108
+ }
109
+
110
+ /**
111
+ * @returns {{phase, ok, status: 'checked'|'no_design'|'legacy', design, decisions, cited, errors, warnings}} or `{error, reason}`
112
+ */
113
+ function checkDecisions(cwd, phase) {
114
+ const pp = phasePlans(cwd, phase);
115
+ if (pp.error) return pp;
116
+ const base = { phase: pp.pf.info.phase_number, ok: true, design: null, decisions: [], cited: {}, errors: [], warnings: [] };
117
+ let designFile = null;
118
+ try { designFile = fs.readdirSync(pp.pf.dir).filter((f) => /-design\.md$/i.test(f)).sort()[0] || null; } catch { /* phaseFiles read it */ }
119
+ if (!designFile) return { ...base, status: 'no_design' };
120
+ const recorded = recordedDecisions(readText(path.join(pp.pf.dir, designFile)));
121
+ const design = path.posix.join(pp.pf.info.directory.replace(/\\/g, '/'), designFile);
122
+ if (!recorded.length) {
123
+ return { ...base, status: 'legacy', design, warnings: [{ code: 'decisions_without_ids', detail: `${designFile} records no DD-N decision ids; plans cannot cite them` }] };
124
+ }
125
+ const errors = [];
126
+ const seen = new Set();
127
+ for (const id of recorded) {
128
+ if (seen.has(id)) errors.push({ code: 'decision_duplicate', id, detail: `${id} is recorded more than once in ${designFile}` });
129
+ seen.add(id);
130
+ }
131
+ const cited = {};
132
+ for (const p of pp.plans) {
133
+ for (const id of new Set(p.text.match(DECISION_ID_RE) || [])) {
134
+ if (!seen.has(id)) errors.push({ code: 'decision_unrecorded', plan: p.plan, id, detail: `${p.plan} cites ${id}, which ${designFile} does not record` });
135
+ else (cited[id] = cited[id] || []).push(p.plan);
136
+ }
137
+ }
138
+ const decisions = [...seen].sort((a, b) => Number(a.slice(3)) - Number(b.slice(3)));
139
+ for (const id of decisions) {
140
+ if (!cited[id]) errors.push({ code: 'decision_uncited', id, detail: `${id} is recorded in ${designFile} and cited by no plan` });
141
+ }
142
+ return { ...base, ok: errors.length === 0, status: 'checked', design, decisions, cited, errors };
143
+ }
144
+
145
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
146
+
147
+ function cmdVerifyReviewFocus(cwd, phase, raw) {
148
+ if (!phase) error('Usage: verify review-focus <phase>');
149
+ const r = checkReviewFocus(cwd, phase);
150
+ if (r.error) { output(r, raw); return; }
151
+ output(r, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
152
+ }
153
+
154
+ function cmdVerifyDecisions(cwd, phase, raw) {
155
+ if (!phase) error('Usage: verify decisions <phase>');
156
+ const r = checkDecisions(cwd, phase);
157
+ if (r.error) { output(r, raw); return; }
158
+ output(r, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
159
+ }
160
+
161
+ module.exports = { REVIEW_FOCUS_MAX, checkReviewFocus, checkDecisions, recordedDecisions, cmdVerifyReviewFocus, cmdVerifyDecisions };
@@ -152,6 +152,9 @@ function buildPhasePreview(cwd, phaseNum) {
152
152
  risk_signals,
153
153
  risk_score,
154
154
  plans: planContents.map(p => ({ file: p.file, bytes: Buffer.byteLength(p.content, 'utf-8') })),
155
+ // p10/p50/p90 dollars for this phase's plans, from the project's own cost ledger
156
+ // (P6); this phase's rows are left out of its own samples.
157
+ cost_forecast: require('./cost.cjs').costForecast(cwd, { plans: planFiles.length, excludePhase: phaseInfo.phase_number || phaseNum }),
155
158
  };
156
159
  }
157
160
 
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ /**
3
+ * Research claims with provenance and freshness (program P3).
4
+ *
5
+ * A research file lists the claims a plan will rest on in a `## Claims` table:
6
+ *
7
+ * | Claim | Type | Source | Checked |
8
+ * | jose 6 supports EdDSA | version | https://github.com/panva/jose/releases | 2026-10-12 |
9
+ *
10
+ * `research lint <phase|path>` reads it. A claim with no source, or with no date (or
11
+ * a date that is not YYYY-MM-DD, or is in the future), is an error: nobody can tell
12
+ * where it came from or when it was true. A claim older than the freshness window for
13
+ * its type is `stale`, not an error: what a version or a price was two months ago
14
+ * says little about today, while a practice lasts for years. The plan-checker weighs
15
+ * stale claims against what the plans rest on.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { output, error, toLf, toPosix, findPhaseInternal } = require('./core.cjs');
20
+ const { sectionBody } = require('./verify-scope.cjs');
21
+
22
+ // Days a claim of each type stays fresh. Versions, prices and security advisories
23
+ // move monthly; an API surface quarterly; an engineering practice yearly.
24
+ const FRESHNESS_DAYS = Object.freeze({ version: 30, pricing: 30, security: 30, api: 90, general: 180, practice: 365 });
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
27
+ // A template placeholder (`[Official docs URL]`, `<url>`) or a stand-in is no source.
28
+ const PLACEHOLDER_RE = /^(\[.*\]|<.*>|-+|n\/?a|none|tbd|todo|\?+|unknown)$/i;
29
+
30
+ function utcDay(s) {
31
+ if (!DATE_RE.test(s)) return null;
32
+ const t = Date.parse(s + 'T00:00:00Z');
33
+ return Number.isNaN(t) ? null : t;
34
+ }
35
+
36
+ /** The research file a phase number or a path names, or `{error, reason}`. */
37
+ function resolveResearch(cwd, target) {
38
+ const asPath = path.resolve(cwd, String(target));
39
+ if (/\.md$/i.test(String(target))) {
40
+ if (!fs.existsSync(asPath)) return { error: 'file_not_found', reason: `no file at ${target}` };
41
+ return { abs: asPath };
42
+ }
43
+ const info = findPhaseInternal(cwd, target);
44
+ if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${target}` };
45
+ const dir = path.join(cwd, info.directory);
46
+ let files = [];
47
+ try { files = fs.readdirSync(dir); } catch { /* read as empty */ }
48
+ const file = files.filter((f) => /-research\.md$/i.test(f) || /^research\.md$/i.test(f)).sort()[0];
49
+ if (!file) return { error: 'no_research', reason: `phase ${target} has no research file` };
50
+ return { abs: path.join(dir, file) };
51
+ }
52
+
53
+ /** Table rows of the `## Claims` section as arrays of cells, header and rule dropped. */
54
+ function claimRows(body) {
55
+ const rows = body.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('|'))
56
+ .map((l) => l.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()));
57
+ if (!rows.length) return { header: null, rows: [] };
58
+ const header = rows[0].map((c) => c.toLowerCase());
59
+ return { header, rows: rows.slice(1).filter((r) => !r.every((c) => /^:?-+:?$/.test(c) || c === '')) };
60
+ }
61
+
62
+ /**
63
+ * @param {string} cwd
64
+ * @param {string} target a phase number, or a path to a research markdown file
65
+ * @param {{now?: string}} [opts] `now` (YYYY-MM-DD) fixes "today" for a reproducible lint
66
+ */
67
+ function lintResearch(cwd, target, opts = {}) {
68
+ const at = resolveResearch(cwd, target);
69
+ if (at.error) return at;
70
+ const rel = path.relative(path.resolve(cwd), at.abs);
71
+ const file = rel && !rel.startsWith('..') ? toPosix(rel) : toPosix(at.abs);
72
+ const now = opts.now ? utcDay(opts.now) : utcDay(new Date().toISOString().slice(0, 10));
73
+ if (now == null) return { error: 'invalid_now', reason: '--now must be YYYY-MM-DD' };
74
+
75
+ const text = toLf(fs.readFileSync(at.abs, 'utf-8'));
76
+ const base = { file, ok: true, claims: 0, errors: [], warnings: [], stale: [], windows: FRESHNESS_DAYS };
77
+ const body = sectionBody(text, 'Claims');
78
+ if (body == null) {
79
+ return { ...base, ok: false, errors: [{ code: 'claims_missing', detail: 'no `## Claims` table (claim · type · source · checked)' }] };
80
+ }
81
+ const { header, rows } = claimRows(body);
82
+ const col = (name, fallback) => {
83
+ const i = header ? header.findIndex((h) => h.startsWith(name)) : -1;
84
+ return i === -1 ? fallback : i;
85
+ };
86
+ const C = { claim: col('claim', 0), type: col('type', 1), source: col('source', 2), checked: col('checked', 3) };
87
+ const errors = [];
88
+ const warnings = [];
89
+ const stale = [];
90
+ rows.forEach((r, i) => {
91
+ const row = i + 1;
92
+ const claim = r[C.claim] || '';
93
+ const rawType = (r[C.type] || '').toLowerCase();
94
+ const source = r[C.source] || '';
95
+ const checked = r[C.checked] || '';
96
+ let type = rawType;
97
+ if (!Object.prototype.hasOwnProperty.call(FRESHNESS_DAYS, rawType)) {
98
+ warnings.push({ code: 'claim_unknown_type', row, claim, detail: `type "${rawType}" is not one of ${Object.keys(FRESHNESS_DAYS).join(', ')}; read as general` });
99
+ type = 'general';
100
+ }
101
+ if (!source || PLACEHOLDER_RE.test(source)) errors.push({ code: 'claim_no_source', row, claim, detail: 'no source' });
102
+ const day = utcDay(checked);
103
+ if (day == null) {
104
+ errors.push({ code: 'claim_no_date', row, claim, detail: checked ? `"${checked}" is not a YYYY-MM-DD date` : 'no checked date' });
105
+ return;
106
+ }
107
+ if (day > now) { errors.push({ code: 'claim_future_date', row, claim, detail: `checked ${checked}, after today` }); return; }
108
+ const age = Math.round((now - day) / DAY_MS);
109
+ if (age > FRESHNESS_DAYS[type]) stale.push({ row, claim, type, checked, age_days: age, window_days: FRESHNESS_DAYS[type] });
110
+ });
111
+ return { ...base, ok: errors.length === 0, claims: rows.length, errors, warnings, stale };
112
+ }
113
+
114
+ function cmdResearchLint(cwd, target, opts, raw) {
115
+ if (!target) error('Usage: research lint <phase|path> [--now YYYY-MM-DD]');
116
+ const r = lintResearch(cwd, target, opts);
117
+ if (r.error) { output(r, raw); return; }
118
+ output(r, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
119
+ }
120
+
121
+ module.exports = { FRESHNESS_DAYS, lintResearch, cmdResearchLint };
@@ -773,5 +773,6 @@ module.exports = {
773
773
  roadmapRequirementIds,
774
774
  phaseRequirementIds,
775
775
  phaseDependencies,
776
+ requirementLines,
776
777
  ROADMAP_SLICE_SUFFIX,
777
778
  };