pan-wizard 2.9.1 → 3.4.1

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 (58) hide show
  1. package/README.md +8 -8
  2. package/agents/pan-conductor.md +189 -0
  3. package/agents/pan-counterfactual.md +112 -0
  4. package/agents/pan-debugger.md +15 -1
  5. package/agents/pan-document_code.md +21 -0
  6. package/agents/pan-executor.md +16 -0
  7. package/agents/pan-hardener.md +113 -0
  8. package/agents/pan-integration-checker.md +2 -0
  9. package/agents/pan-knowledge.md +81 -0
  10. package/agents/pan-meta-reviewer.md +91 -0
  11. package/agents/pan-plan-checker.md +2 -0
  12. package/agents/pan-previewer.md +98 -0
  13. package/agents/pan-project-researcher.md +4 -4
  14. package/agents/pan-reviewer.md +2 -0
  15. package/agents/pan-verifier.md +2 -0
  16. package/bin/install-lib.cjs +197 -0
  17. package/bin/install.js +1999 -1959
  18. package/commands/pan/cost.md +132 -0
  19. package/commands/pan/exec-phase.md +15 -0
  20. package/commands/pan/focus-auto.md +18 -0
  21. package/commands/pan/focus-exec.md +10 -1
  22. package/commands/pan/knowledge.md +129 -0
  23. package/commands/pan/map-codebase.md +15 -0
  24. package/commands/pan/mcp-bridge.md +145 -0
  25. package/commands/pan/plan-phase.md +11 -0
  26. package/commands/pan/preview.md +114 -0
  27. package/commands/pan/profile.md +37 -0
  28. package/commands/pan/review-deep.md +128 -0
  29. package/commands/pan/verify-phase.md +11 -0
  30. package/commands/pan/what-if.md +146 -0
  31. package/hooks/dist/pan-cost-logger.js +102 -0
  32. package/hooks/dist/pan-statusline.js +154 -108
  33. package/package.json +1 -1
  34. package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
  35. package/pan-wizard-core/bin/lib/bus.cjs +251 -0
  36. package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
  37. package/pan-wizard-core/bin/lib/constants.cjs +39 -0
  38. package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
  39. package/pan-wizard-core/bin/lib/core.cjs +91 -6
  40. package/pan-wizard-core/bin/lib/cost.cjs +359 -0
  41. package/pan-wizard-core/bin/lib/focus.cjs +100 -2
  42. package/pan-wizard-core/bin/lib/init.cjs +5 -5
  43. package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
  44. package/pan-wizard-core/bin/lib/memory.cjs +252 -0
  45. package/pan-wizard-core/bin/lib/phase.cjs +40 -13
  46. package/pan-wizard-core/bin/lib/preview.cjs +480 -0
  47. package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
  48. package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
  49. package/pan-wizard-core/bin/lib/state.cjs +2 -2
  50. package/pan-wizard-core/bin/lib/verify.cjs +34 -1
  51. package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
  52. package/pan-wizard-core/bin/pan-tools.cjs +239 -4
  53. package/pan-wizard-core/templates/playbook.md +53 -0
  54. package/pan-wizard-core/templates/preview-report.md +93 -0
  55. package/pan-wizard-core/templates/roadmap.md +24 -24
  56. package/pan-wizard-core/templates/state.md +12 -9
  57. package/pan-wizard-core/workflows/plan-phase.md +1 -1
  58. package/scripts/build-hooks.js +2 -1
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, loadConfig, output, error, toPosix, isGitRepo, execGit } = require('./core.cjs');
8
8
  const { extractFrontmatter } = require('./frontmatter.cjs');
9
9
  const { writeStateMd, readStateSafe } = require('./state.cjs');
10
+ const { enumerateRoadmapPhases } = require('./roadmap.cjs');
10
11
  const { PLANNING_DIR, PHASES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile, isSummaryFile, getPlanId, PHASE_DIR_RE, ARCHIVE_DIR_RE } = require('./constants.cjs');
11
12
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, parsePhaseDir, fileAccessible } = require('./utils.cjs');
12
13
 
@@ -298,8 +299,10 @@ function buildPlanIndex(phaseDir, planFiles, summaryFiles) {
298
299
  frontmatter = extractFrontmatter(content);
299
300
  } catch { continue; }
300
301
 
301
- const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
302
- const taskCount = taskMatches.length;
302
+ // Count tasks: XML <task> tags (current format) or legacy ## Task N headings
303
+ const xmlTaskMatches = content.match(/<task\b/gi) || [];
304
+ const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
305
+ const taskCount = xmlTaskMatches.length || mdTaskMatches.length;
303
306
  const wave = parseInt(frontmatter.wave, 10) || 1;
304
307
 
305
308
  let autonomous = true;
@@ -308,9 +311,18 @@ function buildPlanIndex(phaseDir, planFiles, summaryFiles) {
308
311
  }
309
312
  if (!autonomous) hasCheckpoints = true;
310
313
 
314
+ // files_modified supports both underscore (YAML standard) and hyphen (legacy) key
315
+ const rawFiles = frontmatter.files_modified || frontmatter['files-modified'];
311
316
  let filesModified = [];
312
- if (frontmatter['files-modified']) {
313
- filesModified = Array.isArray(frontmatter['files-modified']) ? frontmatter['files-modified'] : [frontmatter['files-modified']];
317
+ if (rawFiles) {
318
+ filesModified = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
319
+ }
320
+
321
+ // Objective: prefer frontmatter field, fall back to <objective> XML body tag
322
+ let objective = frontmatter.objective || null;
323
+ if (!objective) {
324
+ const objMatch = content.match(/<objective>\s*([\s\S]*?)\s*<\/objective>/i);
325
+ if (objMatch) objective = objMatch[1].split('\n')[0].trim();
314
326
  }
315
327
 
316
328
  const hasSummary = completedPlanIds.has(planId);
@@ -318,7 +330,7 @@ function buildPlanIndex(phaseDir, planFiles, summaryFiles) {
318
330
 
319
331
  plans.push({
320
332
  id: planId, wave, autonomous,
321
- objective: frontmatter.objective || null,
333
+ objective,
322
334
  files_modified: filesModified,
323
335
  task_count: taskCount,
324
336
  has_summary: hasSummary,
@@ -905,20 +917,20 @@ function markPhaseCompleteInRoadmap(cwd, phaseNum, _phaseName, planCount, summar
905
917
  );
906
918
  roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
907
919
 
908
- // Progress table: update Status to Complete, add date
920
+ // Progress table: update Plans Complete, Status, and Date columns
909
921
  const phaseEscaped = escapeRegex(phaseNum);
910
922
  const tablePattern = new RegExp(
911
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
923
+ `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
912
924
  'i'
913
925
  );
914
926
  roadmapContent = roadmapContent.replace(
915
927
  tablePattern,
916
- `$1 Complete $2 ${today} $3`
928
+ `$1 ${summaryCount}/${planCount} $2 Complete $3 ${today} $4`
917
929
  );
918
930
 
919
931
  // Update plan count in phase section
920
932
  const planCountPattern = new RegExp(
921
- `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
933
+ `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?(?:\\*\\*Plans:\\*\\*|\\*\\*Plans\\*\\*:)\\s*)[^\\n]+`,
922
934
  'i'
923
935
  );
924
936
  roadmapContent = roadmapContent.replace(
@@ -949,7 +961,7 @@ function markRequirementsCompleteForPhase(cwd, phaseNum, roadmapContent) {
949
961
  try {
950
962
  const reqReadContent = fs.readFileSync(reqPath, 'utf-8');
951
963
  const reqMatch = roadmapContent.match(
952
- new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
964
+ new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?(?:\\*\\*Requirements:\\*\\*|\\*\\*Requirements\\*\\*:)\\s*([^\\n]+)`, 'i')
953
965
  );
954
966
  if (!reqMatch) return;
955
967
 
@@ -1071,7 +1083,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
1071
1083
  // Update roadmap.md and requirements.md
1072
1084
  const roadmapResult = markPhaseCompleteInRoadmap(cwd, phaseNum, phaseInfo.phase_name, planCount, summaryCount);
1073
1085
 
1074
- // Find next phase
1086
+ // Find next phase — check disk directories first, fall back to roadmap
1075
1087
  let nextPhaseNum = null;
1076
1088
  let nextPhaseName = null;
1077
1089
  let isLastPhase = true;
@@ -1084,7 +1096,6 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
1084
1096
  for (const dir of dirs) {
1085
1097
  const dirMatch = dir.match(PHASE_DIR_RE);
1086
1098
  if (dirMatch) {
1087
- // comparePhaseNum > 0 means dirMatch[1] comes after phaseNum
1088
1099
  if (comparePhaseNum(dirMatch[1], phaseNum) > 0) {
1089
1100
  nextPhaseNum = dirMatch[1];
1090
1101
  nextPhaseName = dirMatch[2] || null;
@@ -1094,7 +1105,23 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
1094
1105
  }
1095
1106
  }
1096
1107
  } catch {
1097
- // Phases directory unreadable; assume this is the last phase
1108
+ // Phases directory unreadable; fall through to roadmap lookup
1109
+ }
1110
+
1111
+ // If no next directory found, look in roadmap for the next planned phase
1112
+ if (isLastPhase) {
1113
+ try {
1114
+ const roadmapContent = fs.readFileSync(path.join(planningPath(cwd), ROADMAP_FILE), 'utf-8');
1115
+ const roadmapPhases = enumerateRoadmapPhases(roadmapContent);
1116
+ for (const rp of roadmapPhases) {
1117
+ if (comparePhaseNum(rp.number, phaseNum) > 0) {
1118
+ nextPhaseNum = rp.number;
1119
+ nextPhaseName = rp.name || null;
1120
+ isLastPhase = false;
1121
+ break;
1122
+ }
1123
+ }
1124
+ } catch { /* roadmap unreadable; this truly is the last phase */ }
1098
1125
  }
1099
1126
 
1100
1127
  // Update state.md
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Preview — foresight data layer (Spec B v2 Y-1, v3.1).
3
+ *
4
+ * Three builders that gather structured inputs for the pan-previewer agent:
5
+ * - buildPhasePreview(cwd, phaseNum) — blast radius of one phase
6
+ * - buildPhaseDependencyGraph(cwd) — mermaid DAG + parallel batches
7
+ * - buildMilestoneETA(cwd) — completion forecast with bottleneck
8
+ *
9
+ * Each builder is deterministic: it reads files from .planning/ and emits
10
+ * JSON the agent analyzes. The agent is where actual *reasoning* happens.
11
+ * The data layer's job is to hand the agent a clean, structured payload.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const {
17
+ output,
18
+ error,
19
+ safeReadFile,
20
+ findPhaseInternal,
21
+ getRoadmapPhaseInternal,
22
+ toPosix,
23
+ } = require('./core.cjs');
24
+ const {
25
+ PLANNING_DIR,
26
+ ROADMAP_FILE,
27
+ STATE_FILE,
28
+ PHASES_DIR,
29
+ PHASE_DIR_RE,
30
+ isPlanFile,
31
+ isSummaryFile,
32
+ } = require('./constants.cjs');
33
+ const { planningPath, phasesPath } = require('./utils.cjs');
34
+ const { extractFrontmatter } = require('./frontmatter.cjs');
35
+ const { countRoadmapPhases } = require('./verify.cjs');
36
+
37
+ // ─── Shared helpers ─────────────────────────────────────────────────────────
38
+
39
+ const RISK_KEYWORDS = {
40
+ drop: /\b(drop\s+(table|column|index)|DROP\s+TABLE|rm\s+-rf)\b/i,
41
+ delete: /\b(delete\s+from|remove\s+the|unlink|rmdir)\b/i,
42
+ migrate: /\b(migration|migrate|alter\s+table|rename\s+table)\b/i,
43
+ rename: /\b(rename\s+(file|variable|function|class)|refactor.*rename)\b/i,
44
+ breaking: /\b(breaking\s+change|incompatible|deprecat)/i,
45
+ auth: /\b(authentication|authorization|credentials|secret|password|token|api.?key)\b/i,
46
+ };
47
+
48
+ /** Extract file-ish paths mentioned in a markdown blob (backtick-wrapped or prose). */
49
+ function extractFilePaths(text) {
50
+ const paths = new Set();
51
+ // Backtick-wrapped: `path/to/file.ext`
52
+ const backtickRe = /`([a-zA-Z0-9_\-./]+\.[a-zA-Z0-9]+)`/g;
53
+ let m;
54
+ while ((m = backtickRe.exec(text)) !== null) {
55
+ const p = m[1];
56
+ if (p.includes('/') && !p.startsWith('http')) paths.add(p);
57
+ }
58
+ // Bare prose paths like `src/foo.js` or `tests/bar.test.cjs` (more conservative).
59
+ const bareRe = /\b((?:src|tests|lib|agents|commands|hooks|pan-wizard-core|docs|scripts|bin)\/[a-zA-Z0-9_\-./]+\.[a-zA-Z0-9]+)\b/g;
60
+ while ((m = bareRe.exec(text)) !== null) {
61
+ paths.add(m[1]);
62
+ }
63
+ return [...paths].sort();
64
+ }
65
+
66
+ /** Detect risk signals in plan/summary text. Returns a {keyword: boolean} map plus a 1-10 score. */
67
+ function detectRiskSignals(text) {
68
+ const signals = {};
69
+ let weight = 0;
70
+ const weights = { drop: 3, delete: 2, migrate: 2, rename: 1, breaking: 2, auth: 1 };
71
+ for (const [key, re] of Object.entries(RISK_KEYWORDS)) {
72
+ const hit = re.test(text);
73
+ signals[key] = hit;
74
+ if (hit) weight += weights[key] || 1;
75
+ }
76
+ // Normalize to 1-10. Empty text → 1, all signals hit → ~10.
77
+ const score = Math.max(1, Math.min(10, Math.round(weight + 1)));
78
+ return { signals, risk_score: score };
79
+ }
80
+
81
+ /** Estimate days between two ISO timestamps. */
82
+ function daysBetween(startIso, endIso) {
83
+ try {
84
+ const ms = new Date(endIso).getTime() - new Date(startIso).getTime();
85
+ return ms / (1000 * 60 * 60 * 24);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // ─── Y-1 phase mode: buildPhasePreview ──────────────────────────────────────
92
+
93
+ /**
94
+ * Gather blast-radius inputs for a single phase.
95
+ *
96
+ * @param {string} cwd - Project root
97
+ * @param {string|number} phaseNum - Phase identifier (e.g. "07" or 7)
98
+ * @returns {Object} Structured preview payload
99
+ */
100
+ function buildPhasePreview(cwd, phaseNum) {
101
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
102
+ if (!phaseInfo || !phaseInfo.found) {
103
+ return { error: `Phase ${phaseNum} not found in .planning/phases/` };
104
+ }
105
+
106
+ const phaseDir = path.join(cwd, phaseInfo.directory);
107
+ const planFiles = (phaseInfo.plans || []).sort();
108
+ const summaryFiles = (phaseInfo.summaries || []).sort();
109
+
110
+ const planTexts = [];
111
+ const planContents = [];
112
+ for (const f of planFiles) {
113
+ const content = safeReadFile(path.join(phaseDir, f));
114
+ if (content) {
115
+ planTexts.push(content);
116
+ planContents.push({ file: f, content });
117
+ }
118
+ }
119
+ const combined = planTexts.join('\n\n');
120
+
121
+ const files_mentioned = extractFilePaths(combined);
122
+ const { signals: risk_signals, risk_score } = detectRiskSignals(combined);
123
+
124
+ // Count test file mentions.
125
+ const testFiles = files_mentioned.filter(p =>
126
+ p.includes('.test.') || p.startsWith('tests/') || p.endsWith('.spec.js') || p.endsWith('.spec.ts')
127
+ );
128
+
129
+ // Roadmap context: is this phase already completed?
130
+ const roadmapPhase = getRoadmapPhaseInternal(cwd, phaseNum);
131
+ let status;
132
+ if (summaryFiles.length === 0) {
133
+ status = 'planned';
134
+ } else if (phaseInfo.incomplete_plans && phaseInfo.incomplete_plans.length > 0) {
135
+ status = 'in_progress';
136
+ } else {
137
+ status = 'completed';
138
+ }
139
+
140
+ return {
141
+ phase: String(phaseNum),
142
+ phase_name: phaseInfo.name || (roadmapPhase && roadmapPhase.phase_name) || null,
143
+ directory: toPosix(phaseInfo.directory),
144
+ status,
145
+ plan_count: planFiles.length,
146
+ summary_count: summaryFiles.length,
147
+ goal: roadmapPhase ? roadmapPhase.goal : null,
148
+ files_mentioned,
149
+ test_files_mentioned: testFiles,
150
+ files_mentioned_count: files_mentioned.length,
151
+ test_files_count: testFiles.length,
152
+ risk_signals,
153
+ risk_score,
154
+ plans: planContents.map(p => ({ file: p.file, bytes: Buffer.byteLength(p.content, 'utf-8') })),
155
+ };
156
+ }
157
+
158
+ // ─── Y-1 phases mode: buildPhaseDependencyGraph ─────────────────────────────
159
+
160
+ /**
161
+ * Produce a dependency graph + mermaid source + parallel-batch recommendation.
162
+ *
163
+ * Dependency detection:
164
+ * - plan frontmatter `depends_on: [phase:NN, phase:MM]` (explicit)
165
+ * - mentions of prior phases in plan text (heuristic, flagged as "hidden")
166
+ *
167
+ * @param {string} cwd - Project root
168
+ * @returns {Object}
169
+ */
170
+ function buildPhaseDependencyGraph(cwd) {
171
+ const roadmapContent = safeReadFile(path.join(planningPath(cwd), ROADMAP_FILE));
172
+ if (!roadmapContent) {
173
+ return { error: 'roadmap.md not found' };
174
+ }
175
+
176
+ const counts = countRoadmapPhases(roadmapContent);
177
+ const phaseList = extractPhaseListFromRoadmap(roadmapContent);
178
+
179
+ // Build {num → {name, status, explicit_deps, hidden_deps}}
180
+ const graph = {};
181
+ const phaseDirs = {};
182
+ const phasesRoot = phasesPath(cwd);
183
+ let dirs = [];
184
+ try {
185
+ dirs = fs.readdirSync(phasesRoot).filter(d => PHASE_DIR_RE.test(d));
186
+ } catch { /* no phases dir */ }
187
+ for (const d of dirs) {
188
+ const m = d.match(/^(\d+(?:\.\d+)?)-/);
189
+ if (m) {
190
+ // Index both the zero-padded and the stripped form so lookups from the
191
+ // roadmap (which may be "1" or "01") both resolve.
192
+ phaseDirs[m[1]] = d;
193
+ const stripped = String(Number(m[1].split('.')[0])) + (m[1].includes('.') ? '.' + m[1].split('.')[1] : '');
194
+ phaseDirs[stripped] = d;
195
+ }
196
+ }
197
+
198
+ for (const p of phaseList) {
199
+ graph[p.num] = {
200
+ num: p.num,
201
+ name: p.name,
202
+ status: p.completed ? 'completed' : 'planned',
203
+ explicit_deps: [],
204
+ hidden_deps: [],
205
+ };
206
+
207
+ // Read plan files for depends_on frontmatter + prior-phase mentions.
208
+ const dir = phaseDirs[p.num];
209
+ if (!dir) continue;
210
+ const fullDir = path.join(phasesRoot, dir);
211
+ let files = [];
212
+ try { files = fs.readdirSync(fullDir).filter(isPlanFile); } catch { continue; }
213
+
214
+ const phaseText = files.map(f => safeReadFile(path.join(fullDir, f)) || '').join('\n');
215
+
216
+ // Explicit via frontmatter. Parse depends_on as freeform string (either
217
+ // inline `[phase:1, phase:2]`, or block-list). Extract all digit runs.
218
+ const fmMatch = phaseText.match(/^---\n([\s\S]*?)\n---/);
219
+ if (fmMatch) {
220
+ const depsLine = fmMatch[1].match(/^depends_on:\s*(.+)$/m);
221
+ if (depsLine) {
222
+ const digitRuns = depsLine[1].match(/\d+(?:\.\d+)?/g) || [];
223
+ for (const d of digitRuns) {
224
+ if (d !== p.num && !graph[p.num].explicit_deps.includes(d)) {
225
+ graph[p.num].explicit_deps.push(d);
226
+ }
227
+ }
228
+ }
229
+ // Also support block-list form:
230
+ // depends_on:
231
+ // - phase:1
232
+ // - phase:2
233
+ const blockMatch = fmMatch[1].match(/^depends_on:\s*\n((?:\s*-\s*.+\n?)*)/m);
234
+ if (blockMatch && !depsLine) {
235
+ const items = blockMatch[1].match(/^\s*-\s*(.+)$/gm) || [];
236
+ for (const item of items) {
237
+ const d = item.match(/\d+(?:\.\d+)?/);
238
+ if (d && d[0] !== p.num && !graph[p.num].explicit_deps.includes(d[0])) {
239
+ graph[p.num].explicit_deps.push(d[0]);
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ // Hidden via mentions: "phase N", "from phase N", "as in phase N".
246
+ const mentionRe = /\bphase\s+(\d+(?:\.\d+)?)/gi;
247
+ let mm;
248
+ const mentions = new Set();
249
+ while ((mm = mentionRe.exec(phaseText)) !== null) {
250
+ if (mm[1] !== p.num) mentions.add(mm[1]);
251
+ }
252
+ graph[p.num].hidden_deps = [...mentions].filter(d => !graph[p.num].explicit_deps.includes(d));
253
+ }
254
+
255
+ // Compute parallel batches via simple topo-like grouping.
256
+ const parallel_batches = computeParallelBatches(graph);
257
+
258
+ // Generate mermaid source.
259
+ const mermaid = generateMermaid(graph);
260
+
261
+ return {
262
+ phase_count: counts.planned,
263
+ completed_count: counts.completed,
264
+ phases: Object.values(graph),
265
+ parallel_batches,
266
+ mermaid,
267
+ hidden_coupling_count: Object.values(graph)
268
+ .reduce((sum, p) => sum + p.hidden_deps.length, 0),
269
+ };
270
+ }
271
+
272
+ function extractPhaseListFromRoadmap(content) {
273
+ const phases = [];
274
+ const re = /- \[([ x])\]\s*(?:\*\*)?Phase\s+(\d+(?:\.\d+)?)\s*[:\-—]?\s*([^\n*]+?)(?:\*\*)?\s*$/gim;
275
+ let m;
276
+ while ((m = re.exec(content)) !== null) {
277
+ phases.push({
278
+ num: m[2],
279
+ name: m[3].trim(),
280
+ completed: m[1] === 'x',
281
+ });
282
+ }
283
+ return phases;
284
+ }
285
+
286
+ function computeParallelBatches(graph) {
287
+ // Phases that share no explicit dependency can run in parallel.
288
+ // Kahn's algorithm: pick phases with no unresolved explicit deps.
289
+ const remaining = new Set(Object.keys(graph));
290
+ const resolved = new Set();
291
+ const batches = [];
292
+
293
+ let guard = 100;
294
+ while (remaining.size > 0 && guard-- > 0) {
295
+ const batch = [];
296
+ for (const num of remaining) {
297
+ const unresolvedDeps = graph[num].explicit_deps.filter(d => !resolved.has(d) && remaining.has(d));
298
+ if (unresolvedDeps.length === 0) batch.push(num);
299
+ }
300
+ if (batch.length === 0) {
301
+ // Cycle or dangling dep — dump the rest into a single batch.
302
+ batches.push([...remaining].sort());
303
+ break;
304
+ }
305
+ batch.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
306
+ batches.push(batch);
307
+ for (const n of batch) { resolved.add(n); remaining.delete(n); }
308
+ }
309
+ return batches;
310
+ }
311
+
312
+ function generateMermaid(graph) {
313
+ const lines = ['graph TD'];
314
+ for (const [num, node] of Object.entries(graph)) {
315
+ const label = `P${num.replace('.', '_')}["${num}: ${node.name.slice(0, 30).replace(/"/g, '')}"]`;
316
+ const cls = node.status === 'completed' ? ':::done' : '';
317
+ lines.push(` ${label}${cls}`);
318
+ }
319
+ for (const [num, node] of Object.entries(graph)) {
320
+ for (const dep of node.explicit_deps) {
321
+ lines.push(` P${dep.replace('.', '_')} --> P${num.replace('.', '_')}`);
322
+ }
323
+ for (const dep of node.hidden_deps) {
324
+ lines.push(` P${dep.replace('.', '_')} -.-> P${num.replace('.', '_')}`);
325
+ }
326
+ }
327
+ lines.push(' classDef done fill:#d4edda,stroke:#28a745');
328
+ return lines.join('\n');
329
+ }
330
+
331
+ // ─── Y-1 milestone mode: buildMilestoneETA ──────────────────────────────────
332
+
333
+ /**
334
+ * Forecast milestone completion based on historical phase durations.
335
+ *
336
+ * @param {string} cwd - Project root
337
+ * @returns {Object}
338
+ */
339
+ function buildMilestoneETA(cwd) {
340
+ const roadmapContent = safeReadFile(path.join(planningPath(cwd), ROADMAP_FILE));
341
+ if (!roadmapContent) {
342
+ return { error: 'roadmap.md not found' };
343
+ }
344
+
345
+ const phaseList = extractPhaseListFromRoadmap(roadmapContent);
346
+ const completed = phaseList.filter(p => p.completed);
347
+ const remaining = phaseList.filter(p => !p.completed);
348
+
349
+ // Sample phase durations from summary frontmatter or commit metadata.
350
+ const durations = sampleCompletedPhaseDurations(cwd, completed);
351
+ const avg = durations.length > 0
352
+ ? durations.reduce((a, b) => a + b, 0) / durations.length
353
+ : 5.0; // default 5 days per phase when no history
354
+
355
+ const avgDays = Math.round(avg * 10) / 10;
356
+ const remainingDays = remaining.length * avgDays;
357
+ const etaDate = new Date(Date.now() + remainingDays * 24 * 60 * 60 * 1000)
358
+ .toISOString().slice(0, 10);
359
+
360
+ // Confidence shrinks with small sample size and high variance.
361
+ const confidence = durations.length >= 5
362
+ ? 80
363
+ : durations.length >= 3
364
+ ? 65
365
+ : durations.length >= 1
366
+ ? 50
367
+ : 35;
368
+
369
+ // Bottleneck: phase with the most plans / largest slug, heuristic.
370
+ const bottleneck = findBottleneckCandidate(cwd, remaining);
371
+
372
+ // Current milestone from state.md.
373
+ const stateContent = safeReadFile(path.join(planningPath(cwd), STATE_FILE)) || '';
374
+ const msMatch = stateContent.match(/\*\*Current Milestone:\*\*\s*(\S+)/);
375
+ const currentMilestone = msMatch ? msMatch[1] : null;
376
+
377
+ return {
378
+ current_milestone: currentMilestone,
379
+ phases_total: phaseList.length,
380
+ phases_completed: completed.length,
381
+ phases_remaining: remaining.length,
382
+ avg_phase_duration_days: avgDays,
383
+ velocity_phases_per_week: avg > 0 ? Math.round((7 / avg) * 100) / 100 : 0,
384
+ sample_size: durations.length,
385
+ eta_date: etaDate,
386
+ confidence_pct: confidence,
387
+ bottleneck,
388
+ };
389
+ }
390
+
391
+ function findPhaseDir(dirs, num) {
392
+ // Match "1" against "01-foo" or "1-foo"; match "1.1" against "01.1-foo" or "1.1-foo".
393
+ const padded = num.includes('.')
394
+ ? num.split('.').map((s, i) => i === 0 ? s.padStart(2, '0') : s).join('.')
395
+ : num.padStart(2, '0');
396
+ return dirs.find(d => d.startsWith(`${num}-`) || d.startsWith(`${padded}-`));
397
+ }
398
+
399
+ function sampleCompletedPhaseDurations(cwd, completedPhases) {
400
+ const durations = [];
401
+ const phasesRoot = phasesPath(cwd);
402
+ let dirs = [];
403
+ try { dirs = fs.readdirSync(phasesRoot); } catch { return durations; }
404
+ for (const p of completedPhases) {
405
+ const dir = findPhaseDir(dirs, p.num);
406
+ if (!dir) continue;
407
+ // Read the latest summary.md for `completed` / `started` fields.
408
+ const fullDir = path.join(phasesRoot, dir);
409
+ let files = [];
410
+ try { files = fs.readdirSync(fullDir).filter(isSummaryFile).sort(); } catch { continue; }
411
+ if (files.length === 0) continue;
412
+ const content = safeReadFile(path.join(fullDir, files[files.length - 1]));
413
+ if (!content) continue;
414
+ const fm = extractFrontmatter(content);
415
+ if (!fm || Object.keys(fm).length === 0) continue;
416
+ const started = fm.started || fm.start || fm.created;
417
+ const completed_at = fm.completed || fm.finished || fm.done;
418
+ if (started && completed_at) {
419
+ const d = daysBetween(started, completed_at);
420
+ if (d && d > 0 && d < 60) durations.push(d);
421
+ }
422
+ }
423
+ return durations;
424
+ }
425
+
426
+ function findBottleneckCandidate(cwd, remainingPhases) {
427
+ if (remainingPhases.length === 0) return null;
428
+ const phasesRoot = phasesPath(cwd);
429
+ let biggest = null;
430
+ let biggestPlanCount = 0;
431
+ let dirs = [];
432
+ try { dirs = fs.readdirSync(phasesRoot); } catch { return null; }
433
+ for (const p of remainingPhases) {
434
+ const dir = findPhaseDir(dirs, p.num);
435
+ if (!dir) continue;
436
+ const fullDir = path.join(phasesRoot, dir);
437
+ let planCount = 0;
438
+ try {
439
+ planCount = fs.readdirSync(fullDir).filter(isPlanFile).length;
440
+ } catch { /* skip */ }
441
+ if (planCount > biggestPlanCount) {
442
+ biggestPlanCount = planCount;
443
+ biggest = { phase: p.num, name: p.name, plan_count: planCount };
444
+ }
445
+ }
446
+ if (!biggest) return null;
447
+ return { ...biggest, reason: `${biggestPlanCount} plan files — largest among remaining phases` };
448
+ }
449
+
450
+ // ─── CLI wrappers ───────────────────────────────────────────────────────────
451
+
452
+ function cmdPreviewPhase(cwd, phaseNum, raw) {
453
+ if (!phaseNum) error('Usage: preview phase <N>');
454
+ output(buildPhasePreview(cwd, phaseNum), raw);
455
+ }
456
+
457
+ function cmdPreviewPhases(cwd, raw) {
458
+ output(buildPhaseDependencyGraph(cwd), raw);
459
+ }
460
+
461
+ function cmdPreviewMilestone(cwd, raw) {
462
+ output(buildMilestoneETA(cwd), raw);
463
+ }
464
+
465
+ module.exports = {
466
+ buildPhasePreview,
467
+ buildPhaseDependencyGraph,
468
+ buildMilestoneETA,
469
+ extractFilePaths,
470
+ detectRiskSignals,
471
+ extractPhaseListFromRoadmap,
472
+ computeParallelBatches,
473
+ generateMermaid,
474
+ sampleCompletedPhaseDurations,
475
+ findBottleneckCandidate,
476
+ cmdPreviewPhase,
477
+ cmdPreviewPhases,
478
+ cmdPreviewMilestone,
479
+ RISK_KEYWORDS,
480
+ };