@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 +19 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- 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/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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
3
|
"displayName": "Plan-Build-Run",
|
|
4
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
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 =>
|
|
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,
|
|
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
|
};
|
|
@@ -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,
|
|
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.
|