@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.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/skills/build/SKILL.md +15 -116
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +1 -29
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/skills/build/SKILL.md +15 -116
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +1 -29
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/scripts/check-state-sync.js +1 -1
- package/plugins/pbr/scripts/lib/context.js +218 -0
- package/plugins/pbr/scripts/lib/core.js +1 -1
- package/plugins/pbr/scripts/lib/init.js +126 -1
- package/plugins/pbr/scripts/lib/phase.js +203 -5
- package/plugins/pbr/scripts/lib/reference.js +236 -0
- package/plugins/pbr/scripts/lib/state.js +1 -1
- package/plugins/pbr/scripts/pbr-tools.js +60 -4
- package/plugins/pbr/skills/build/SKILL.md +15 -116
- package/plugins/pbr/skills/build/templates/continuation-prompt.md.tmpl +26 -0
- package/plugins/pbr/skills/build/templates/executor-prompt.md.tmpl +55 -0
- package/plugins/pbr/skills/build/templates/inline-verifier-prompt.md.tmpl +18 -0
- package/plugins/pbr/skills/milestone/SKILL.md +1 -29
- package/plugins/pbr/skills/milestone/templates/integration-checker-prompt.md.tmpl +25 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
};
|