@sienklogic/plan-build-run 2.45.0 → 2.47.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.
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/context.js — Context triage for orchestrator decision-making.
5
+ *
6
+ * Provides contextTriage() which reads data from the context-bridge and
7
+ * context-tracker files to return a concrete PROCEED / CHECKPOINT / COMPACT
8
+ * recommendation, replacing vague LLM self-assessment with data-driven logic.
9
+ *
10
+ * Data sources (in priority order):
11
+ * 1. .context-budget.json — written by context-bridge.js, contains real % from Claude
12
+ * 2. .context-tracker — written by track-context-budget.js, heuristic char count
13
+ *
14
+ * Decision thresholds:
15
+ * Bridge (fresh): < 50% → PROCEED, 50-70% → CHECKPOINT, > 70% → COMPACT
16
+ * Heuristic: < 30k → PROCEED, 30k-60k → CHECKPOINT, > 60k → COMPACT
17
+ *
18
+ * Adjustments:
19
+ * Near completion (agentsDone/plansTotal > 0.8): relax one tier
20
+ * currentStep contains "finalize" or "cleanup": always PROCEED
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const BRIDGE_STALENESS_MS = 60 * 1000; // 60 seconds
27
+
28
+ /**
29
+ * Read and parse .context-budget.json.
30
+ * @param {string} planningDir
31
+ * @returns {{ percentage: number, tier: string, timestamp: string, chars_read: number, stale: boolean } | null}
32
+ */
33
+ function readBridgeData(planningDir) {
34
+ const bridgePath = path.join(planningDir, '.context-budget.json');
35
+ try {
36
+ const stat = fs.statSync(bridgePath);
37
+ const content = fs.readFileSync(bridgePath, 'utf8');
38
+ const data = JSON.parse(content);
39
+ const ageMs = Date.now() - stat.mtimeMs;
40
+ data.stale = ageMs > BRIDGE_STALENESS_MS;
41
+ return data;
42
+ } catch (_e) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Read and parse .context-tracker.
49
+ * @param {string} planningDir
50
+ * @returns {{ total_chars: number, unique_files: number } | null}
51
+ */
52
+ function readTrackerData(planningDir) {
53
+ const trackerPath = path.join(planningDir, '.context-tracker');
54
+ try {
55
+ const content = fs.readFileSync(trackerPath, 'utf8');
56
+ return JSON.parse(content);
57
+ } catch (_e) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Apply near-completion relaxation: relax CHECKPOINT → PROCEED or COMPACT → CHECKPOINT.
64
+ * @param {string} recommendation
65
+ * @returns {string} Possibly relaxed recommendation
66
+ */
67
+ function relaxTier(recommendation) {
68
+ if (recommendation === 'CHECKPOINT') return 'PROCEED';
69
+ if (recommendation === 'COMPACT') return 'CHECKPOINT';
70
+ return recommendation;
71
+ }
72
+
73
+ /**
74
+ * Main context triage function.
75
+ *
76
+ * @param {Object} options
77
+ * @param {number} [options.agentsDone] — How many agents/plans have completed
78
+ * @param {number} [options.plansTotal] — Total plans in current phase
79
+ * @param {string} [options.currentStep] — Name/description of the current step
80
+ * @param {string} [planningDir] — Override .planning/ directory path
81
+ * @returns {{
82
+ * recommendation: 'PROCEED'|'CHECKPOINT'|'COMPACT',
83
+ * reason: string,
84
+ * data_source: 'bridge'|'heuristic'|'stale_bridge',
85
+ * percentage: number|null,
86
+ * tier: string|null,
87
+ * agents_done: number|null,
88
+ * plans_total: number|null
89
+ * }}
90
+ */
91
+ function contextTriage(options, planningDir) {
92
+ options = options || {};
93
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
94
+ const pd = planningDir || path.join(cwd, '.planning');
95
+
96
+ const agentsDone = (typeof options.agentsDone === 'number' && !isNaN(options.agentsDone))
97
+ ? options.agentsDone : null;
98
+ const plansTotal = (typeof options.plansTotal === 'number' && !isNaN(options.plansTotal))
99
+ ? options.plansTotal : null;
100
+ const currentStep = options.currentStep || '';
101
+
102
+ // Cleanup/finalize override — always PROCEED
103
+ const isCleanup = /finalize|cleanup/i.test(currentStep);
104
+
105
+ // Read data sources
106
+ const bridge = readBridgeData(pd);
107
+ const tracker = readTrackerData(pd);
108
+
109
+ let recommendation;
110
+ let dataSource;
111
+ let percentage = null;
112
+ let tier = null;
113
+
114
+ if (bridge && !bridge.stale) {
115
+ // Fresh bridge data — authoritative
116
+ dataSource = 'bridge';
117
+ percentage = typeof bridge.percentage === 'number' ? bridge.percentage : null;
118
+ tier = bridge.tier || null;
119
+
120
+ if (percentage === null) {
121
+ // Bridge exists but has no percentage — fall through to heuristic
122
+ dataSource = 'heuristic';
123
+ } else if (percentage < 50) {
124
+ recommendation = 'PROCEED';
125
+ } else if (percentage <= 70) {
126
+ recommendation = 'CHECKPOINT';
127
+ } else {
128
+ recommendation = 'COMPACT';
129
+ }
130
+ }
131
+
132
+ if (!recommendation) {
133
+ // Stale bridge or missing bridge — use heuristic
134
+ if (bridge && bridge.stale) {
135
+ dataSource = 'stale_bridge';
136
+ // Use stale percentage as a hint for tier/percentage display
137
+ percentage = typeof bridge.percentage === 'number' ? bridge.percentage : null;
138
+ tier = bridge.tier || null;
139
+ } else {
140
+ dataSource = 'heuristic';
141
+ }
142
+
143
+ const totalChars = tracker && typeof tracker.total_chars === 'number'
144
+ ? tracker.total_chars : 0;
145
+
146
+ if (totalChars < 30000) {
147
+ recommendation = 'PROCEED';
148
+ } else if (totalChars <= 60000) {
149
+ recommendation = 'CHECKPOINT';
150
+ } else {
151
+ recommendation = 'COMPACT';
152
+ }
153
+
154
+ // Use tracker chars to estimate a pseudo-percentage for heuristic source
155
+ if (dataSource === 'heuristic' && percentage === null) {
156
+ // Map 0-100k chars to 0-100% for display purposes
157
+ percentage = Math.min(100, Math.round((totalChars / 100000) * 100));
158
+ tier = percentage < 30 ? 'PEAK'
159
+ : percentage < 50 ? 'GOOD'
160
+ : percentage < 70 ? 'DEGRADING'
161
+ : percentage < 85 ? 'POOR'
162
+ : 'CRITICAL';
163
+ }
164
+ }
165
+
166
+ // Cleanup override — before near-completion so it always wins
167
+ if (isCleanup) {
168
+ recommendation = 'PROCEED';
169
+ } else {
170
+ // Near-completion adjustment
171
+ const nearComplete = agentsDone !== null && plansTotal !== null && plansTotal > 0
172
+ && (agentsDone / plansTotal) > 0.8;
173
+ if (nearComplete) {
174
+ recommendation = relaxTier(recommendation);
175
+ }
176
+ }
177
+
178
+ // Build human-readable reason
179
+ const pctStr = percentage !== null ? `${percentage}%` : 'unknown';
180
+ const tierStr = tier || 'unknown';
181
+
182
+ let reason;
183
+ if (dataSource === 'bridge') {
184
+ reason = `Context at ${pctStr} (${tierStr} tier).`;
185
+ } else if (dataSource === 'stale_bridge') {
186
+ reason = `Bridge data is stale (>60s old). Using heuristic fallback. Last known: ${pctStr} (${tierStr}).`;
187
+ } else {
188
+ const charsStr = tracker ? `${Math.round((tracker.total_chars || 0) / 1000)}k chars read` : 'no read data';
189
+ reason = `No bridge data. Heuristic: ${charsStr} (estimated ${pctStr}).`;
190
+ }
191
+
192
+ if (agentsDone !== null && plansTotal !== null) {
193
+ reason += ` ${agentsDone} of ${plansTotal} plans complete.`;
194
+ }
195
+
196
+ if (isCleanup) {
197
+ reason += ' Cleanup/finalize step — not interrupting.';
198
+ } else if (agentsDone !== null && plansTotal !== null && plansTotal > 0 && (agentsDone / plansTotal) > 0.8) {
199
+ reason += ' Near completion — threshold relaxed.';
200
+ }
201
+
202
+ const safeLabel = recommendation === 'PROCEED' ? 'Safe to continue.'
203
+ : recommendation === 'CHECKPOINT' ? 'Suggest /pbr:pause after current agent completes.'
204
+ : 'Suggest running /compact immediately.';
205
+ reason += ' ' + safeLabel;
206
+
207
+ return {
208
+ recommendation,
209
+ reason,
210
+ data_source: dataSource,
211
+ percentage,
212
+ tier,
213
+ agents_done: agentsDone,
214
+ plans_total: plansTotal
215
+ };
216
+ }
217
+
218
+ module.exports = { contextTriage, readBridgeData, readTrackerData };
@@ -272,7 +272,7 @@ function calculateProgress(planningDir) {
272
272
 
273
273
  for (const entry of entries) {
274
274
  const dir = path.join(phasesDir, entry.name);
275
- const plans = findFiles(dir, /-PLAN\.md$/);
275
+ const plans = findFiles(dir, /PLAN.*\.md$/i);
276
276
  total += plans.length;
277
277
 
278
278
  const summaries = findFiles(dir, /^SUMMARY-.*\.md$/);
@@ -156,11 +156,136 @@ function initProgress(planningDir) {
156
156
  return { current_phase: state.current_phase, total_phases: state.phase_count, status: state.state ? state.state.status : null, phases: progress.phases, total_plans: progress.total_plans, completed_plans: progress.completed_plans, percentage: progress.percentage };
157
157
  }
158
158
 
159
+ /**
160
+ * Aggregate all state an orchestrator skill needs to begin work on a phase.
161
+ * Replaces 5-10 individual file reads with a single CLI call returning ~1,500 tokens of JSON.
162
+ *
163
+ * @param {string} phaseNum - Phase number
164
+ * @param {string} [planningDir] - Path to .planning directory
165
+ */
166
+ function initStateBundle(phaseNum, planningDir) {
167
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
168
+ const { parseYamlFrontmatter } = require('./core');
169
+
170
+ // 1. State
171
+ const stateResult = stateLoad(dir);
172
+ if (!stateResult.exists) return { error: 'No .planning/ directory found' };
173
+ const st = stateResult.state || {};
174
+ const state = {
175
+ current_phase: stateResult.current_phase,
176
+ status: st.status || stateResult.status || null,
177
+ progress: stateResult.progress,
178
+ total_phases: stateResult.phase_count || null,
179
+ last_activity: st.last_activity || null,
180
+ blockers: st.blockers || []
181
+ };
182
+
183
+ // 2. Config summary
184
+ const config = configLoad(dir) || {};
185
+ const depthProfile = resolveDepthProfile(config);
186
+ const models = config.models || {};
187
+ const config_summary = {
188
+ depth: depthProfile.depth,
189
+ mode: config.mode || 'interactive',
190
+ parallelization: config.parallelization || { enabled: false },
191
+ gates: config.gates || {},
192
+ features: config.features || {},
193
+ models: { executor: models.executor || 'sonnet', verifier: models.verifier || 'sonnet', planner: models.planner || 'sonnet' }
194
+ };
195
+
196
+ // 3. Phase info
197
+ const phaseResult = phaseInfo(phaseNum, dir);
198
+ if (phaseResult.error) return { error: phaseResult.error };
199
+ const phase = {
200
+ num: phaseNum,
201
+ dir: phaseResult.phase,
202
+ name: phaseResult.name,
203
+ goal: phaseResult.goal,
204
+ has_context: phaseResult.has_context,
205
+ status: phaseResult.filesystem_status,
206
+ plan_count: phaseResult.plan_count,
207
+ completed: phaseResult.completed
208
+ };
209
+
210
+ // 4. Plans
211
+ const plansResult = planIndex(phaseNum, dir);
212
+ const plans = (plansResult.plans || []).map(p => ({
213
+ file: p.file,
214
+ plan_id: p.plan_id,
215
+ wave: p.wave,
216
+ autonomous: p.autonomous,
217
+ has_summary: p.has_summary,
218
+ must_haves_count: p.must_haves_count,
219
+ depends_on: p.depends_on
220
+ }));
221
+ const waves = plansResult.waves || {};
222
+
223
+ // 5. Prior summaries — scan all phase directories for SUMMARY*.md, extract frontmatter only
224
+ const prior_summaries = [];
225
+ const phasesDir = path.join(dir, 'phases');
226
+ if (fs.existsSync(phasesDir)) {
227
+ const phaseDirs = fs.readdirSync(phasesDir).filter(d => {
228
+ try { return fs.statSync(path.join(phasesDir, d)).isDirectory(); } catch (_e) { return false; }
229
+ }).sort();
230
+ for (const pd of phaseDirs) {
231
+ if (prior_summaries.length >= 10) break;
232
+ const pdPath = path.join(phasesDir, pd);
233
+ let summaryFiles;
234
+ try { summaryFiles = fs.readdirSync(pdPath).filter(f => /^SUMMARY.*\.md$/i.test(f)).sort(); } catch (_e) { continue; }
235
+ for (const sf of summaryFiles) {
236
+ if (prior_summaries.length >= 10) break;
237
+ try {
238
+ const content = fs.readFileSync(path.join(pdPath, sf), 'utf8');
239
+ const fm = parseYamlFrontmatter(content);
240
+ if (fm && !fm.error) {
241
+ const entry = {
242
+ phase: fm.phase !== undefined ? fm.phase : null,
243
+ plan: fm.plan !== undefined ? fm.plan : null,
244
+ status: fm.status || null,
245
+ provides: fm.provides || [],
246
+ requires: fm.requires || [],
247
+ key_files: fm.key_files || []
248
+ };
249
+ if (fm.key_decisions !== undefined) entry.key_decisions = fm.key_decisions;
250
+ prior_summaries.push(entry);
251
+ }
252
+ } catch (_e) { /* skip unreadable */ }
253
+ }
254
+ }
255
+ }
256
+
257
+ // 6. Context file existence
258
+ const has_project_context = fs.existsSync(path.join(dir, 'CONTEXT.md'));
259
+ const has_phase_context = phaseResult.has_context || false;
260
+
261
+ // 7. Git state
262
+ let git = { branch: null, clean: null };
263
+ try {
264
+ const { execSync } = require('child_process');
265
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', timeout: 5000 }).trim();
266
+ const status = execSync('git status --porcelain', { encoding: 'utf8', timeout: 5000 }).trim();
267
+ git = { branch, clean: status === '' };
268
+ } catch (_e) { /* not a git repo */ }
269
+
270
+ return {
271
+ state,
272
+ config_summary,
273
+ phase,
274
+ plans,
275
+ waves,
276
+ prior_summaries,
277
+ git,
278
+ has_project_context,
279
+ has_phase_context
280
+ };
281
+ }
282
+
159
283
  module.exports = {
160
284
  initExecutePhase,
161
285
  initPlanPhase,
162
286
  initQuick,
163
287
  initVerifyWork,
164
288
  initResume,
165
- initProgress
289
+ initProgress,
290
+ initStateBundle
166
291
  };
@@ -54,7 +54,8 @@ function planIndex(phaseNum, planningDir) {
54
54
  }
55
55
 
56
56
  const fullDir = path.join(phasesDir, phaseDir.name);
57
- const planFiles = findFiles(fullDir, /-PLAN\.md$/);
57
+ // Match both PLAN-NN.md (current) and NN-PLAN.md / slug-NN-PLAN.md (legacy)
58
+ const planFiles = findFiles(fullDir, /PLAN.*\.md$/i);
58
59
 
59
60
  const plans = [];
60
61
  const waves = {};
@@ -65,7 +66,7 @@ function planIndex(phaseNum, planningDir) {
65
66
 
66
67
  const plan = {
67
68
  file,
68
- plan_id: fm.plan || file.replace(/-PLAN\.md$/, ''),
69
+ plan_id: fm.plan || file.replace(/^PLAN-?/i, '').replace(/-PLAN/i, '').replace(/\.md$/i, ''),
69
70
  wave: parseInt(fm.wave, 10) || 1,
70
71
  type: fm.type || 'unknown',
71
72
  autonomous: fm.autonomous !== false,
@@ -112,7 +113,8 @@ function mustHavesCollect(phaseNum, planningDir) {
112
113
  }
113
114
 
114
115
  const fullDir = path.join(phasesDir, phaseDir.name);
115
- const planFiles = findFiles(fullDir, /-PLAN\.md$/);
116
+ // Match both PLAN-NN.md (current) and NN-PLAN.md / slug-NN-PLAN.md (legacy)
117
+ const planFiles = findFiles(fullDir, /PLAN.*\.md$/i);
116
118
 
117
119
  const perPlan = {};
118
120
  const allTruths = new Set();
@@ -122,7 +124,7 @@ function mustHavesCollect(phaseNum, planningDir) {
122
124
  for (const file of planFiles) {
123
125
  const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
124
126
  const fm = parseYamlFrontmatter(content);
125
- const planId = fm.plan || file.replace(/-PLAN\.md$/, '');
127
+ const planId = fm.plan || file.replace(/^PLAN-?/i, '').replace(/-PLAN/i, '').replace(/\.md$/i, '');
126
128
  const mh = fm.must_haves || { truths: [], artifacts: [], key_links: [] };
127
129
 
128
130
  perPlan[planId] = mh;
@@ -353,6 +355,201 @@ function phaseList(planningDir) {
353
355
  return { phases };
354
356
  }
355
357
 
358
+ /**
359
+ * Extract frontmatter-only stats from all SUMMARY.md files across a milestone's phases.
360
+ * Never reads SUMMARY body content — only YAML frontmatter.
361
+ *
362
+ * Strategy for finding phases:
363
+ * 1. Check milestone archive: .planning/milestones/v{version}/phases/
364
+ * 2. Fall back to active phases dir: .planning/phases/
365
+ * matching phase numbers extracted from ROADMAP.md milestone section.
366
+ *
367
+ * @param {string} version - Milestone version string (e.g. "5.0", "6.0")
368
+ * @param {string} [planningDir] - Path to .planning directory
369
+ * @returns {object} Milestone stats with per-phase summaries and aggregated fields
370
+ */
371
+ function milestoneStats(version, planningDir) {
372
+ const dir = planningDir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
373
+
374
+ const phases = [];
375
+
376
+ // --- Strategy 1: Check milestone archive ---
377
+ const archivePhasesDir = path.join(dir, 'milestones', `v${version}`, 'phases');
378
+ if (fs.existsSync(archivePhasesDir)) {
379
+ const phaseDirs = fs.readdirSync(archivePhasesDir, { withFileTypes: true })
380
+ .filter(e => e.isDirectory() && /^\d+-/.test(e.name))
381
+ .sort((a, b) => a.name.localeCompare(b.name));
382
+
383
+ for (const phaseEntry of phaseDirs) {
384
+ const phaseData = _collectPhaseStats(path.join(archivePhasesDir, phaseEntry.name), phaseEntry.name);
385
+ if (phaseData) phases.push(phaseData);
386
+ }
387
+ } else {
388
+ // --- Strategy 2: Parse ROADMAP.md to find phase numbers for this milestone ---
389
+ const roadmapPath = path.join(dir, 'ROADMAP.md');
390
+ const phaseNums = _extractMilestonePhaseNums(roadmapPath, version);
391
+ const phasesDir = path.join(dir, 'phases');
392
+
393
+ if (fs.existsSync(phasesDir) && phaseNums.length > 0) {
394
+ const allDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
395
+ .filter(e => e.isDirectory() && /^\d+-/.test(e.name))
396
+ .sort((a, b) => a.name.localeCompare(b.name));
397
+
398
+ for (const phaseEntry of allDirs) {
399
+ const num = parseInt(phaseEntry.name.split('-')[0], 10);
400
+ if (phaseNums.includes(num)) {
401
+ const phaseData = _collectPhaseStats(path.join(phasesDir, phaseEntry.name), phaseEntry.name);
402
+ if (phaseData) phases.push(phaseData);
403
+ }
404
+ }
405
+ }
406
+ }
407
+
408
+ // --- Aggregate across all phases ---
409
+ const providesSet = new Set();
410
+ const keyFilesSet = new Set();
411
+ const patternsSet = new Set();
412
+ const allKeyDecisions = [];
413
+ const allDeferred = [];
414
+ const totalMetrics = { tasks_completed: 0, commits: 0, files_changed: 0 };
415
+
416
+ for (const phase of phases) {
417
+ for (const summary of phase.summaries) {
418
+ (summary.provides || []).forEach(v => providesSet.add(v));
419
+ (summary.key_files || []).forEach(v => keyFilesSet.add(v));
420
+ (summary.patterns || []).forEach(v => patternsSet.add(v));
421
+ (summary.key_decisions || []).forEach(v => allKeyDecisions.push(v));
422
+ (summary.deferred || []).forEach(v => allDeferred.push(v));
423
+ const m = summary.metrics || {};
424
+ totalMetrics.tasks_completed += parseInt(m.tasks_completed, 10) || 0;
425
+ totalMetrics.commits += parseInt(m.commits, 10) || 0;
426
+ totalMetrics.files_changed += parseInt(m.files_changed, 10) || 0;
427
+ }
428
+ }
429
+
430
+ return {
431
+ version,
432
+ phase_count: phases.length,
433
+ phases,
434
+ aggregated: {
435
+ all_provides: [...providesSet],
436
+ all_key_files: [...keyFilesSet],
437
+ all_key_decisions: allKeyDecisions,
438
+ all_patterns: [...patternsSet],
439
+ all_deferred: allDeferred,
440
+ total_metrics: totalMetrics
441
+ }
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Collect frontmatter-only stats from all SUMMARY*.md files in a single phase directory.
447
+ *
448
+ * @param {string} fullDir - Full path to phase directory
449
+ * @param {string} dirName - Directory name (e.g. "46-agent-contracts")
450
+ * @returns {object}
451
+ */
452
+ function _collectPhaseStats(fullDir, dirName) {
453
+ const numStr = dirName.split('-')[0];
454
+ const name = dirName.replace(/^\d+-/, '');
455
+
456
+ const summaryFiles = findFiles(fullDir, /^SUMMARY.*\.md$/i);
457
+ const summaries = [];
458
+
459
+ for (const file of summaryFiles) {
460
+ const filePath = path.join(fullDir, file);
461
+ const content = fs.readFileSync(filePath, 'utf8');
462
+
463
+ // Extract ONLY the YAML frontmatter — never read body content
464
+ const fm = parseYamlFrontmatter(content);
465
+
466
+ // Collect only the documented frontmatter fields
467
+ const entry = {};
468
+ const fields = ['phase', 'plan', 'status', 'provides', 'requires', 'key_files',
469
+ 'key_decisions', 'patterns', 'metrics', 'deferred', 'tags'];
470
+ for (const field of fields) {
471
+ if (fm[field] !== undefined) {
472
+ entry[field] = fm[field];
473
+ }
474
+ }
475
+ summaries.push(entry);
476
+ }
477
+
478
+ return {
479
+ number: numStr,
480
+ name,
481
+ summaries
482
+ };
483
+ }
484
+
485
+ /**
486
+ * Parse ROADMAP.md to find phase numbers belonging to a milestone version.
487
+ * Scans for milestone section headers containing the version string, then
488
+ * collects phase numbers from "### Phase N:" headings or "Phases N-M" text.
489
+ *
490
+ * @param {string} roadmapPath - Path to ROADMAP.md
491
+ * @param {string} version - Version string (e.g. "5.0")
492
+ * @returns {number[]} Array of phase numbers
493
+ */
494
+ function _extractMilestonePhaseNums(roadmapPath, version) {
495
+ if (!fs.existsSync(roadmapPath)) return [];
496
+
497
+ const content = fs.readFileSync(roadmapPath, 'utf8');
498
+ const lines = content.split(/\r?\n/);
499
+
500
+ const phaseNums = [];
501
+ let inMilestoneSection = false;
502
+
503
+ for (let i = 0; i < lines.length; i++) {
504
+ const line = lines[i];
505
+
506
+ // Detect milestone section header (## Milestone: ... (vX.Y) or containing version)
507
+ if (/^##\s+Milestone:/i.test(line)) {
508
+ inMilestoneSection = line.includes(`v${version}`) || line.includes(`(${version})`);
509
+ continue;
510
+ }
511
+
512
+ // If we hit a new ## section (not ###), exit the milestone section
513
+ if (/^##\s+[^#]/.test(line)) {
514
+ if (inMilestoneSection) break;
515
+ continue;
516
+ }
517
+
518
+ if (!inMilestoneSection) continue;
519
+
520
+ // Extract phase numbers from "### Phase N:" headings
521
+ const phaseHeading = line.match(/^###\s+Phase\s+(\d+)/i);
522
+ if (phaseHeading) {
523
+ phaseNums.push(parseInt(phaseHeading[1], 10));
524
+ continue;
525
+ }
526
+
527
+ // Extract from "Phases N-M" or "Phase N" text
528
+ const phaseRange = line.match(/Phases?\s+(\d+)(?:-(\d+))?/gi);
529
+ if (phaseRange) {
530
+ for (const match of phaseRange) {
531
+ const rangeMatch = match.match(/(\d+)(?:-(\d+))?/);
532
+ if (rangeMatch) {
533
+ const start = parseInt(rangeMatch[1], 10);
534
+ const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : start;
535
+ for (let n = start; n <= end; n++) {
536
+ if (!phaseNums.includes(n)) phaseNums.push(n);
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ // Row entries in phase overview tables: | 51 | CLI Foundation | ...
543
+ const tableRow = line.match(/^\|\s*(\d+)\s*\|/);
544
+ if (tableRow) {
545
+ const n = parseInt(tableRow[1], 10);
546
+ if (!phaseNums.includes(n)) phaseNums.push(n);
547
+ }
548
+ }
549
+
550
+ return phaseNums;
551
+ }
552
+
356
553
  module.exports = {
357
554
  frontmatter,
358
555
  planIndex,
@@ -360,5 +557,6 @@ module.exports = {
360
557
  phaseInfo,
361
558
  phaseAdd,
362
559
  phaseRemove,
363
- phaseList
560
+ phaseList,
561
+ milestoneStats
364
562
  };