@sienklogic/plan-build-run 2.45.0 → 2.46.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to Plan-Build-Run will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.46.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.45.0...plan-build-run-v2.46.0) (2026-03-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * **51-01:** implement initStateBundle in lib/init.js ([0e1b103](https://github.com/SienkLogic/plan-build-run/commit/0e1b1038953c974266f36af9a8ecd08e5f54dc13))
14
+ * **51-01:** wire state-bundle into pbr-tools.js dispatch ([7092b58](https://github.com/SienkLogic/plan-build-run/commit/7092b58aa3376bc858bfa7ff69b68e0e3b96b9f1))
15
+ * **51-02:** add milestoneStats function and milestone-stats CLI command ([d962ad2](https://github.com/SienkLogic/plan-build-run/commit/d962ad27510ece26b14a2439f50c9d5dde21a491))
16
+ * **51-03:** create lib/reference.js with listHeadings, extractSection, resolveReferencePath, referenceGet ([5f5e340](https://github.com/SienkLogic/plan-build-run/commit/5f5e340381d61aa450ef2afebaa0d6c8b3b55546))
17
+ * **51-03:** wire reference subcommand into pbr-tools.js dispatch with --section and --list flags ([b638ed9](https://github.com/SienkLogic/plan-build-run/commit/b638ed9d8a7ecefec56bd5ae88d555ad9dd5e906))
18
+ * **51-04:** create lib/context.js with contextTriage function ([e77982e](https://github.com/SienkLogic/plan-build-run/commit/e77982ed220fb17fe911e1e3d6323570b8309044))
19
+ * **51-04:** wire context-triage dispatch into pbr-tools.js ([2f6fcb4](https://github.com/SienkLogic/plan-build-run/commit/2f6fcb4a8212f43fa84d3b01a74234cee46fe80a))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **51-04:** remove unused test imports to fix lint errors ([a7e90e0](https://github.com/SienkLogic/plan-build-run/commit/a7e90e0533448d8be4a13a74711b52da0ec993d0))
25
+ * **tools:** fix planIndex regex to match PLAN-NN.md naming convention ([a4aadc1](https://github.com/SienkLogic/plan-build-run/commit/a4aadc1f95baeaa285b722d3d88ea607a4addd59))
26
+
8
27
  ## [2.45.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.44.0...plan-build-run-v2.45.0) (2026-03-01)
9
28
 
10
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.45.0",
3
+ "version": "2.46.0",
4
4
  "description": "Plan it, Build it, Run it — structured development workflow for Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.45.0",
4
+ "version": "2.46.0",
5
5
  "description": "Plan-Build-Run — Structured development workflow for GitHub Copilot CLI. Solves context rot through disciplined agent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.45.0",
4
+ "version": "2.46.0",
5
5
  "description": "Plan-Build-Run — Structured development workflow for Cursor. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbr",
3
- "version": "2.45.0",
3
+ "version": "2.46.0",
4
4
  "description": "Plan-Build-Run — Structured development workflow for Claude Code. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
5
5
  "author": {
6
6
  "name": "SienkLogic",
@@ -67,7 +67,7 @@ function extractPhaseNum(dirName) {
67
67
  function countPhaseArtifacts(phaseDir) {
68
68
  try {
69
69
  const files = fs.readdirSync(phaseDir);
70
- const plans = files.filter(f => /-PLAN\.md$/.test(f));
70
+ const plans = files.filter(f => /PLAN.*\.md$/i.test(f));
71
71
  const summaries = files.filter(f => /SUMMARY.*\.md$/.test(f) || /.*SUMMARY.*\.md$/.test(f));
72
72
 
73
73
  // Filter for summaries that have status: complete in frontmatter
@@ -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
  };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * reference.js — Targeted reference document access for Plan-Build-Run agents.
5
+ *
6
+ * Enables surgical extraction of specific sections from reference documents,
7
+ * reducing token usage from 1,500-3,500 tokens (full file) to 50-200 tokens (section only).
8
+ *
9
+ * Exported functions:
10
+ * listHeadings(content) — Extract all H2/H3 headings
11
+ * extractSection(content, query) — Extract a section by heading query
12
+ * resolveReferencePath(name, pluginRoot) — Resolve a ref name to a file path
13
+ * referenceGet(name, options, pluginRoot) — Main entry point
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ /**
20
+ * Extract all H2 and H3 headings from markdown content.
21
+ * Handles CRLF line endings (Windows compatibility).
22
+ *
23
+ * @param {string} content - Markdown content
24
+ * @returns {Array<{level: number, heading: string}>}
25
+ */
26
+ function listHeadings(content) {
27
+ const headings = [];
28
+ const regex = /^(#{2,3})\s+(.+?)\r?$/gm;
29
+ let match;
30
+ while ((match = regex.exec(content)) !== null) {
31
+ headings.push({
32
+ level: match[1].length,
33
+ heading: match[2].replace(/\r$/, '')
34
+ });
35
+ }
36
+ return headings;
37
+ }
38
+
39
+ /**
40
+ * Extract a section matching the query using 4-tier fuzzy matching.
41
+ * Matching tiers (applied in order):
42
+ * 1. Exact match (case-insensitive)
43
+ * 2. Starts-with match
44
+ * 3. Contains match
45
+ * 4. Word-boundary: all words in query appear in heading
46
+ *
47
+ * For H2: captures content until next H2 or end of file.
48
+ * For H3: captures content until next H3, next H2, or end of file.
49
+ *
50
+ * @param {string} content - Markdown content
51
+ * @returns {{ heading: string, level: number, content: string, char_count: number } | null}
52
+ */
53
+ function extractSection(content, query) {
54
+ // Collect all headings with their character offsets
55
+ const headings = [];
56
+ const regex = /^(#{2,3})\s+(.+?)\r?$/gm;
57
+ let match;
58
+ while ((match = regex.exec(content)) !== null) {
59
+ headings.push({
60
+ level: match[1].length,
61
+ heading: match[2].replace(/\r$/, ''),
62
+ index: match.index,
63
+ fullMatch: match[0]
64
+ });
65
+ }
66
+
67
+ if (headings.length === 0) return null;
68
+
69
+ const q = query.toLowerCase();
70
+
71
+ // 4-tier fuzzy matching
72
+ let matched = null;
73
+
74
+ // Tier 1: Exact match
75
+ matched = headings.find(h => h.heading.toLowerCase() === q);
76
+
77
+ // Tier 2: Starts-with
78
+ if (!matched) {
79
+ matched = headings.find(h => h.heading.toLowerCase().startsWith(q));
80
+ }
81
+
82
+ // Tier 3: Contains
83
+ if (!matched) {
84
+ matched = headings.find(h => h.heading.toLowerCase().includes(q));
85
+ }
86
+
87
+ // Tier 4: Word-boundary — all words in query appear in heading
88
+ if (!matched) {
89
+ const words = q.split(/\s+/).filter(Boolean);
90
+ matched = headings.find(h => {
91
+ const hl = h.heading.toLowerCase();
92
+ return words.every(w => hl.includes(w));
93
+ });
94
+ }
95
+
96
+ if (!matched) return null;
97
+
98
+ // Determine section boundaries
99
+ const startIdx = matched.index;
100
+ // Find where the section content starts (after the heading line)
101
+ const headingEnd = startIdx + matched.fullMatch.length;
102
+ // Skip the newline after the heading
103
+ const contentStart = headingEnd + (content[headingEnd] === '\r' ? 2 : content[headingEnd] === '\n' ? 1 : 0);
104
+
105
+ // Find where section ends
106
+ let endIdx = content.length;
107
+
108
+ if (matched.level === 2) {
109
+ // H2: ends at next H2 or end of file
110
+ const nextH2 = /\n## /g;
111
+ nextH2.lastIndex = contentStart;
112
+ const nextMatch = nextH2.exec(content);
113
+ if (nextMatch) endIdx = nextMatch.index;
114
+ } else {
115
+ // H3: ends at next H3 or H2 or end of file
116
+ const nextSection = /\n#{2,3} /g;
117
+ nextSection.lastIndex = contentStart;
118
+ const nextMatch = nextSection.exec(content);
119
+ if (nextMatch) endIdx = nextMatch.index;
120
+ }
121
+
122
+ const sectionContent = content.slice(startIdx, endIdx).trimEnd();
123
+
124
+ return {
125
+ heading: matched.heading,
126
+ level: matched.level,
127
+ content: sectionContent,
128
+ char_count: sectionContent.length
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Resolve a reference name to its full file path.
134
+ *
135
+ * @param {string} name - Reference name (with or without .md extension)
136
+ * @param {string} pluginRoot - Plugin root directory
137
+ * @returns {string | { error: string, available: string[] }}
138
+ */
139
+ function resolveReferencePath(name, pluginRoot) {
140
+ // Strip .md extension if present
141
+ const baseName = name.endsWith('.md') ? name.slice(0, -3) : name;
142
+ const refPath = path.join(pluginRoot, 'references', baseName + '.md');
143
+
144
+ if (fs.existsSync(refPath)) {
145
+ return refPath;
146
+ }
147
+
148
+ // File not found — list available references
149
+ const refsDir = path.join(pluginRoot, 'references');
150
+ let available = [];
151
+ try {
152
+ available = fs.readdirSync(refsDir)
153
+ .filter(f => f.endsWith('.md'))
154
+ .map(f => f.slice(0, -3));
155
+ } catch (_e) {
156
+ // references dir doesn't exist
157
+ }
158
+
159
+ return {
160
+ error: `Reference '${baseName}' not found in ${refsDir}`,
161
+ available
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Strip YAML frontmatter from content if present in first 5 lines.
167
+ * @param {string} content
168
+ * @returns {string}
169
+ */
170
+ function stripFrontmatter(content) {
171
+ const lines = content.split(/\r?\n/);
172
+ if (lines[0] !== '---') return content;
173
+ // Find closing ---
174
+ for (let i = 1; i < Math.min(lines.length, 200); i++) {
175
+ if (lines[i] === '---') {
176
+ // Return everything after the closing ---
177
+ return lines.slice(i + 1).join('\n').replace(/^\n+/, '');
178
+ }
179
+ }
180
+ return content;
181
+ }
182
+
183
+ /**
184
+ * Main entry point for reference access.
185
+ *
186
+ * @param {string} name - Reference name (e.g. "plan-format")
187
+ * @param {{ section?: string, list?: boolean }} options
188
+ * @param {string} pluginRoot - Plugin root directory
189
+ * @returns {object}
190
+ */
191
+ function referenceGet(name, options, pluginRoot) {
192
+ const opts = options || {};
193
+
194
+ // Resolve file path
195
+ const resolved = resolveReferencePath(name, pluginRoot);
196
+ if (typeof resolved === 'object' && resolved.error) {
197
+ return resolved;
198
+ }
199
+
200
+ // Read content
201
+ let content;
202
+ try {
203
+ content = fs.readFileSync(resolved, 'utf8');
204
+ } catch (e) {
205
+ return { error: `Cannot read reference file: ${e.message}` };
206
+ }
207
+
208
+ // Strip YAML frontmatter if present in first 5 lines
209
+ const firstLines = content.split(/\r?\n/).slice(0, 5).join('\n');
210
+ if (firstLines.includes('---')) {
211
+ content = stripFrontmatter(content);
212
+ }
213
+
214
+ // --list flag: return available headings
215
+ if (opts.list) {
216
+ return { name, headings: listHeadings(content) };
217
+ }
218
+
219
+ // --section flag: extract specific section
220
+ if (opts.section) {
221
+ const result = extractSection(content, opts.section);
222
+ if (!result) {
223
+ const available = listHeadings(content);
224
+ return {
225
+ error: `Section '${opts.section}' not found in reference '${name}'`,
226
+ available
227
+ };
228
+ }
229
+ return { name, section: opts.section, ...result };
230
+ }
231
+
232
+ // No flags: return full content
233
+ return { name, content, char_count: content.length };
234
+ }
235
+
236
+ module.exports = { listHeadings, extractSection, resolveReferencePath, referenceGet };
@@ -246,7 +246,7 @@ function stateCheckProgress(planningDir) {
246
246
 
247
247
  for (const entry of entries) {
248
248
  const phaseDir = path.join(phasesDir, entry.name);
249
- const plans = findFiles(phaseDir, /-PLAN\.md$/);
249
+ const plans = findFiles(phaseDir, /PLAN.*\.md$/i);
250
250
  const summaries = findFiles(phaseDir, /^SUMMARY-.*\.md$/);
251
251
  const verification = fs.existsSync(path.join(phaseDir, 'VERIFICATION.md'));
252
252
 
@@ -105,7 +105,8 @@ const {
105
105
  phaseInfo: _phaseInfo,
106
106
  phaseAdd: _phaseAdd,
107
107
  phaseRemove: _phaseRemove,
108
- phaseList: _phaseList
108
+ phaseList: _phaseList,
109
+ milestoneStats: _milestoneStats
109
110
  } = require('./lib/phase');
110
111
 
111
112
  const {
@@ -114,7 +115,8 @@ const {
114
115
  initQuick: _initQuick,
115
116
  initVerifyWork: _initVerifyWork,
116
117
  initResume: _initResume,
117
- initProgress: _initProgress
118
+ initProgress: _initProgress,
119
+ initStateBundle: _initStateBundle
118
120
  } = require('./lib/init');
119
121
 
120
122
  const {
@@ -143,6 +145,14 @@ const {
143
145
  checkDeferralThresholds: _checkDeferralThresholds
144
146
  } = require('./lib/learnings');
145
147
 
148
+ const {
149
+ referenceGet: _referenceGet
150
+ } = require('./lib/reference');
151
+
152
+ const {
153
+ contextTriage: _contextTriage
154
+ } = require('./lib/context');
155
+
146
156
  // --- Local LLM imports (not extracted — separate module tree) ---
147
157
  const { resolveConfig, checkHealth } = require('./local-llm/health');
148
158
  const { classifyArtifact } = require('./local-llm/operations/classify-artifact');
@@ -235,6 +245,10 @@ function phaseList() {
235
245
  return _phaseList(planningDir);
236
246
  }
237
247
 
248
+ function milestoneStats(version) {
249
+ return _milestoneStats(version, planningDir);
250
+ }
251
+
238
252
  function initExecutePhase(phaseNum) {
239
253
  return _initExecutePhase(phaseNum, planningDir);
240
254
  }
@@ -259,6 +273,10 @@ function initProgress() {
259
273
  return _initProgress(planningDir);
260
274
  }
261
275
 
276
+ function stateBundle(phaseNum) {
277
+ return _initStateBundle(phaseNum, planningDir);
278
+ }
279
+
262
280
  function historyAppend(entry, dir) {
263
281
  return _historyAppend(entry, dir || planningDir);
264
282
  }
@@ -291,6 +309,20 @@ function spotCheck(phaseDir, planId) {
291
309
  return _spotCheck(planningDir, phaseDir, planId);
292
310
  }
293
311
 
312
+ function referenceGet(name, options) {
313
+ // Resolve plugin root — try CLAUDE_PLUGIN_ROOT env, then walk up from __dirname
314
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..');
315
+ // Fix MSYS paths on Windows (same pattern as run-hook.js)
316
+ let root = pluginRoot;
317
+ const msysMatch = root.match(/^\/([a-zA-Z])\/(.*)/);
318
+ if (msysMatch) root = msysMatch[1] + ':' + path.sep + msysMatch[2];
319
+ return _referenceGet(name, options, root);
320
+ }
321
+
322
+ function contextTriage(options) {
323
+ return _contextTriage(options, planningDir);
324
+ }
325
+
294
326
  // --- validateProject stays here (cross-cutting across modules) ---
295
327
 
296
328
  /**
@@ -633,6 +665,10 @@ async function main() {
633
665
  output(initResume());
634
666
  } else if (command === "init" && subcommand === "progress") {
635
667
  output(initProgress());
668
+ } else if (command === 'state-bundle') {
669
+ const phaseNum = args[1];
670
+ if (!phaseNum) error('Usage: pbr-tools.js state-bundle <phase-number>');
671
+ output(stateBundle(phaseNum));
636
672
  // --- State patch/advance/metric ---
637
673
  } else if (command === "state" && subcommand === "patch") {
638
674
  const jsonStr = args[2];
@@ -729,10 +765,30 @@ async function main() {
729
765
  error('Usage: spot-check <phaseSlug> <planId>');
730
766
  }
731
767
  output(spotCheck(phaseSlug, planId));
768
+ } else if (command === 'context-triage') {
769
+ const options = {};
770
+ const agentsIdx = args.indexOf('--agents-done');
771
+ if (agentsIdx !== -1) options.agentsDone = parseInt(args[agentsIdx + 1], 10);
772
+ const plansIdx = args.indexOf('--plans-total');
773
+ if (plansIdx !== -1) options.plansTotal = parseInt(args[plansIdx + 1], 10);
774
+ const stepIdx = args.indexOf('--step');
775
+ if (stepIdx !== -1) options.currentStep = args[stepIdx + 1];
776
+ output(contextTriage(options));
777
+ } else if (command === 'reference') {
778
+ const name = args[1];
779
+ if (!name) error('Usage: pbr-tools.js reference <name> [--section <heading>] [--list]');
780
+ const listFlag = args.includes('--list');
781
+ const sectionIdx = args.indexOf('--section');
782
+ const section = sectionIdx !== -1 ? args.slice(sectionIdx + 1).join(' ') : null;
783
+ output(referenceGet(name, { section: section, list: listFlag }));
784
+ } else if (command === 'milestone-stats') {
785
+ const version = args[1];
786
+ if (!version) error('Usage: pbr-tools.js milestone-stats <version>');
787
+ output(milestoneStats(version));
732
788
  } else if (command === 'validate-project') {
733
789
  output(validateProject());
734
790
  } else {
735
- error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update|patch|advance-plan|record-metric, config validate|load-defaults|save-defaults|resolve-depth, validate-project, migrate [--dry-run] [--force], init execute-phase|plan-phase|quick|verify-work|resume|progress, plan-index, frontmatter, must-haves, phase-info, phase add|remove|list, roadmap update-status|update-plans, history append|load, todo list|get|add|done, event, llm health|status|classify|score-source|classify-error|summarize|metrics [--session <ISO>]|adjust-thresholds, learnings ingest|query|check-thresholds`);
791
+ error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update|patch|advance-plan|record-metric, config validate|load-defaults|save-defaults|resolve-depth, validate-project, migrate [--dry-run] [--force], init execute-phase|plan-phase|quick|verify-work|resume|progress, state-bundle <phase>, plan-index, frontmatter, must-haves, phase-info, phase add|remove|list, roadmap update-status|update-plans, history append|load, todo list|get|add|done, event, llm health|status|classify|score-source|classify-error|summarize|metrics [--session <ISO>]|adjust-thresholds, learnings ingest|query|check-thresholds, milestone-stats <version>, context-triage [--agents-done N] [--plans-total N] [--step NAME]`);
736
792
  }
737
793
  } catch (e) {
738
794
  error(e.message);
@@ -740,6 +796,6 @@ async function main() {
740
796
  }
741
797
 
742
798
  if (require.main === module || process.argv[1] === __filename) { main().catch(err => { process.stderr.write(err.message + '\n'); process.exit(1); }); }
743
- module.exports = { KNOWN_AGENTS, initExecutePhase, initPlanPhase, initQuick, initVerifyWork, initResume, initProgress, statePatch, stateAdvancePlan, stateRecordMetric, parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition, writeActiveSkill, validateProject, phaseAdd, phaseRemove, phaseList, loadUserDefaults, saveUserDefaults, mergeUserDefaults, USER_DEFAULTS_PATH, todoList, todoGet, todoAdd, todoDone, migrate, spotCheck };
799
+ module.exports = { KNOWN_AGENTS, initExecutePhase, initPlanPhase, initQuick, initVerifyWork, initResume, initProgress, initStateBundle: stateBundle, stateBundle, statePatch, stateAdvancePlan, stateRecordMetric, parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition, writeActiveSkill, validateProject, phaseAdd, phaseRemove, phaseList, loadUserDefaults, saveUserDefaults, mergeUserDefaults, USER_DEFAULTS_PATH, todoList, todoGet, todoAdd, todoDone, migrate, spotCheck, referenceGet, milestoneStats, contextTriage };
744
800
  // NOTE: validateProject, phaseAdd, phaseRemove, phaseList were previously CLI-only (not exported).
745
801
  // They are now exported for testability. This is additive and backwards-compatible.