@sienklogic/plan-build-run 2.47.0 → 2.49.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 +18 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +2 -52
- package/plugins/copilot-pbr/skills/milestone/templates/edge-cases.md +54 -0
- package/plugins/copilot-pbr/skills/plan/SKILL.md +6 -76
- package/plugins/copilot-pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
- package/plugins/copilot-pbr/skills/shared/error-reporting.md +59 -0
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +2 -52
- package/plugins/cursor-pbr/skills/milestone/templates/edge-cases.md +54 -0
- package/plugins/cursor-pbr/skills/plan/SKILL.md +6 -76
- package/plugins/cursor-pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
- package/plugins/cursor-pbr/skills/shared/error-reporting.md +59 -0
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/scripts/lib/build.js +454 -0
- package/plugins/pbr/scripts/pbr-tools.js +55 -1
- package/plugins/pbr/skills/build/SKILL.md +25 -53
- package/plugins/pbr/skills/milestone/SKILL.md +2 -52
- package/plugins/pbr/skills/milestone/templates/audit-output.md.tmpl +76 -0
- package/plugins/pbr/skills/milestone/templates/complete-output.md.tmpl +32 -0
- package/plugins/pbr/skills/milestone/templates/edge-cases.md +54 -0
- package/plugins/pbr/skills/milestone/templates/gaps-output.md.tmpl +25 -0
- package/plugins/pbr/skills/milestone/templates/new-output.md.tmpl +29 -0
- package/plugins/pbr/skills/plan/SKILL.md +13 -93
- package/plugins/pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
- package/plugins/pbr/skills/shared/error-reporting.md +59 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.49.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",
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/build.js — Build helper functions for Plan-Build-Run orchestrator.
|
|
5
|
+
*
|
|
6
|
+
* Provides deterministic CLI-callable utilities for the build skill, replacing
|
|
7
|
+
* inline procedural blocks that the LLM is prone to skipping or misimplementing.
|
|
8
|
+
*
|
|
9
|
+
* Exported functions:
|
|
10
|
+
* stalenessCheck(phaseSlug, planningDir) — Check if phase plans are stale
|
|
11
|
+
* summaryGate(phaseSlug, planId, planningDir) — Verify SUMMARY.md validity gates
|
|
12
|
+
* checkpointInit(phaseSlug, plans, planningDir) — Initialize checkpoint manifest
|
|
13
|
+
* checkpointUpdate(phaseSlug, opts, planningDir) — Update checkpoint manifest
|
|
14
|
+
* seedsMatch(phaseSlug, phaseNumber, planningDir) — Find matching seed files
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the .planning directory from the given planningDir or env/cwd fallback.
|
|
22
|
+
* @param {string} [planningDir]
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function resolvePlanningDir(planningDir) {
|
|
26
|
+
if (planningDir) return planningDir;
|
|
27
|
+
const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
|
|
28
|
+
return path.join(cwd, '.planning');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// stalenessCheck
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check whether any plans in a phase are stale relative to their dependencies.
|
|
37
|
+
*
|
|
38
|
+
* Staleness detection logic (two modes):
|
|
39
|
+
* 1. If any PLAN.md has a `dependency_fingerprints` field: compare the referenced
|
|
40
|
+
* SUMMARY.md files' current byte size and mtime to the stored values.
|
|
41
|
+
* 2. Fallback: read ROADMAP.md for the phase's `depends_on`, then compare
|
|
42
|
+
* the mtime of dependency SUMMARY.md files vs the current PLAN.md files.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} phaseSlug — Phase directory name (e.g. "52-skill-prompt-slimming")
|
|
45
|
+
* @param {string} [planningDir]
|
|
46
|
+
* @returns {{ stale: boolean, plans: Array<{ id: string, stale: boolean, reason: string }> }
|
|
47
|
+
* | { error: string }}
|
|
48
|
+
*/
|
|
49
|
+
function stalenessCheck(phaseSlug, planningDir) {
|
|
50
|
+
const pd = resolvePlanningDir(planningDir);
|
|
51
|
+
const phasesDir = path.join(pd, 'phases');
|
|
52
|
+
const phaseDir = path.join(phasesDir, phaseSlug);
|
|
53
|
+
|
|
54
|
+
if (!fs.existsSync(phaseDir)) {
|
|
55
|
+
return { error: 'Phase not found: ' + phaseSlug };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Find all PLAN-*.md files in the phase directory
|
|
59
|
+
let planFiles;
|
|
60
|
+
try {
|
|
61
|
+
planFiles = fs.readdirSync(phaseDir).filter(f => /^PLAN.*\.md$/i.test(f));
|
|
62
|
+
} catch (_e) {
|
|
63
|
+
return { error: 'Cannot read phase directory: ' + phaseSlug };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (planFiles.length === 0) {
|
|
67
|
+
return { stale: false, plans: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const planResults = [];
|
|
71
|
+
let anyStale = false;
|
|
72
|
+
|
|
73
|
+
for (const planFile of planFiles) {
|
|
74
|
+
const planPath = path.join(phaseDir, planFile);
|
|
75
|
+
let planContent;
|
|
76
|
+
try {
|
|
77
|
+
planContent = fs.readFileSync(planPath, 'utf8');
|
|
78
|
+
} catch (_e) {
|
|
79
|
+
planResults.push({ id: planFile, stale: false, reason: 'unreadable' });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Extract plan_id from frontmatter
|
|
84
|
+
const idMatch = planContent.match(/^plan:\s*["']?([^"'\n]+)["']?/m);
|
|
85
|
+
const planId = idMatch ? idMatch[1].trim() : planFile.replace(/\.md$/i, '');
|
|
86
|
+
|
|
87
|
+
// Check for dependency_fingerprints in frontmatter
|
|
88
|
+
const fpMatch = planContent.match(/^dependency_fingerprints:\s*\n([\s\S]*?)(?=\n\w|\n---)/m);
|
|
89
|
+
if (fpMatch) {
|
|
90
|
+
// Mode 1: fingerprint-based check
|
|
91
|
+
const staleResult = checkFingerprintStaleness(planId, fpMatch[1], pd);
|
|
92
|
+
if (staleResult.stale) anyStale = true;
|
|
93
|
+
planResults.push(staleResult);
|
|
94
|
+
} else {
|
|
95
|
+
// Mode 2: timestamp-based fallback — check once (not per-plan)
|
|
96
|
+
// We'll do it per plan but only if there's a depends_on
|
|
97
|
+
const depsMatch = planContent.match(/^depends_on:\s*\[([^\]]*)\]/m);
|
|
98
|
+
if (!depsMatch || depsMatch[1].trim() === '') {
|
|
99
|
+
planResults.push({ id: planId, stale: false, reason: 'no dependencies' });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const deps = depsMatch[1].split(',').map(d => d.trim().replace(/['"]/g, '')).filter(Boolean);
|
|
103
|
+
const staleResult = checkTimestampStaleness(planId, planPath, deps, pd, phasesDir);
|
|
104
|
+
if (staleResult.stale) anyStale = true;
|
|
105
|
+
planResults.push(staleResult);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { stale: anyStale, plans: planResults };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check staleness via dependency_fingerprints field.
|
|
114
|
+
* @param {string} planId
|
|
115
|
+
* @param {string} fingerprintBlock — raw YAML block content under dependency_fingerprints
|
|
116
|
+
* @param {string} pd — planningDir
|
|
117
|
+
* @returns {{ id: string, stale: boolean, reason: string }}
|
|
118
|
+
*/
|
|
119
|
+
function checkFingerprintStaleness(planId, fingerprintBlock, pd) {
|
|
120
|
+
// Parse entries like: - path: ...\n size: N\n mtime: N
|
|
121
|
+
const entries = [];
|
|
122
|
+
const entryRegex = /-\s+path:\s*(.+?)(?:\n|$)[\s\S]*?size:\s*(\d+)[\s\S]*?mtime:\s*(\d+)/g;
|
|
123
|
+
let m;
|
|
124
|
+
while ((m = entryRegex.exec(fingerprintBlock)) !== null) {
|
|
125
|
+
entries.push({ filePath: m[1].trim(), size: parseInt(m[2], 10), mtime: parseInt(m[3], 10) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const absPath = path.isAbsolute(entry.filePath)
|
|
130
|
+
? entry.filePath
|
|
131
|
+
: path.join(pd, '..', entry.filePath);
|
|
132
|
+
try {
|
|
133
|
+
const stat = fs.statSync(absPath);
|
|
134
|
+
if (stat.size !== entry.size || Math.round(stat.mtimeMs) !== entry.mtime) {
|
|
135
|
+
return { id: planId, stale: true, reason: `dependency ${entry.filePath} changed (size or mtime mismatch)` };
|
|
136
|
+
}
|
|
137
|
+
} catch (_e) {
|
|
138
|
+
return { id: planId, stale: true, reason: `dependency ${entry.filePath} not found` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { id: planId, stale: false, reason: 'fingerprints match' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check staleness via ROADMAP depends_on + timestamp comparison.
|
|
147
|
+
* @param {string} planId
|
|
148
|
+
* @param {string} planPath — absolute path to PLAN.md file
|
|
149
|
+
* @param {string[]} deps — dependency phase slugs/IDs
|
|
150
|
+
* @param {string} pd — planningDir
|
|
151
|
+
* @param {string} phasesDir
|
|
152
|
+
* @returns {{ id: string, stale: boolean, reason: string }}
|
|
153
|
+
*/
|
|
154
|
+
function checkTimestampStaleness(planId, planPath, deps, pd, phasesDir) {
|
|
155
|
+
let planMtime;
|
|
156
|
+
try {
|
|
157
|
+
planMtime = fs.statSync(planPath).mtimeMs;
|
|
158
|
+
} catch (_e) {
|
|
159
|
+
return { id: planId, stale: false, reason: 'cannot stat plan file' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const dep of deps) {
|
|
163
|
+
// Find the dependency phase directory
|
|
164
|
+
let depDir = null;
|
|
165
|
+
try {
|
|
166
|
+
const allDirs = fs.readdirSync(phasesDir);
|
|
167
|
+
// dep might be a plan ID like "51-01" — find phase dirs that start with the phase number
|
|
168
|
+
const phaseNumMatch = dep.match(/^(\d+)/);
|
|
169
|
+
if (phaseNumMatch) {
|
|
170
|
+
const phaseNum = phaseNumMatch[1].padStart(2, '0');
|
|
171
|
+
depDir = allDirs.find(d => d.startsWith(phaseNum + '-'));
|
|
172
|
+
}
|
|
173
|
+
if (!depDir) {
|
|
174
|
+
depDir = allDirs.find(d => d === dep || d.includes(dep));
|
|
175
|
+
}
|
|
176
|
+
} catch (_e) { continue; }
|
|
177
|
+
|
|
178
|
+
if (!depDir) continue;
|
|
179
|
+
|
|
180
|
+
const depPhaseDir = path.join(phasesDir, depDir);
|
|
181
|
+
let summaryFiles;
|
|
182
|
+
try {
|
|
183
|
+
summaryFiles = fs.readdirSync(depPhaseDir).filter(f => /^SUMMARY.*\.md$/i.test(f));
|
|
184
|
+
} catch (_e) { continue; }
|
|
185
|
+
|
|
186
|
+
for (const sf of summaryFiles) {
|
|
187
|
+
try {
|
|
188
|
+
const sfMtime = fs.statSync(path.join(depPhaseDir, sf)).mtimeMs;
|
|
189
|
+
if (sfMtime > planMtime) {
|
|
190
|
+
return { id: planId, stale: true, reason: `dependency phase ${depDir} was modified after planning (${sf} is newer)` };
|
|
191
|
+
}
|
|
192
|
+
} catch (_e) { continue; }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { id: planId, stale: false, reason: 'timestamps ok' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// summaryGate
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Verify that a SUMMARY.md file passes all three gates before STATE.md update.
|
|
205
|
+
*
|
|
206
|
+
* Gate 1: file exists
|
|
207
|
+
* Gate 2: file is non-empty (size > 0)
|
|
208
|
+
* Gate 3: file contains `---` delimiter AND a `status:` field
|
|
209
|
+
*
|
|
210
|
+
* @param {string} phaseSlug
|
|
211
|
+
* @param {string} planId
|
|
212
|
+
* @param {string} [planningDir]
|
|
213
|
+
* @returns {{ ok: boolean, gate: string|null, detail: string }}
|
|
214
|
+
*/
|
|
215
|
+
function summaryGate(phaseSlug, planId, planningDir) {
|
|
216
|
+
const pd = resolvePlanningDir(planningDir);
|
|
217
|
+
const summaryPath = path.join(pd, 'phases', phaseSlug, `SUMMARY-${planId}.md`);
|
|
218
|
+
|
|
219
|
+
// Gate 1: exists
|
|
220
|
+
if (!fs.existsSync(summaryPath)) {
|
|
221
|
+
return { ok: false, gate: 'exists', detail: `SUMMARY-${planId}.md not found in phase ${phaseSlug}` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Gate 2: non-empty
|
|
225
|
+
let stat;
|
|
226
|
+
try {
|
|
227
|
+
stat = fs.statSync(summaryPath);
|
|
228
|
+
} catch (_e) {
|
|
229
|
+
return { ok: false, gate: 'exists', detail: 'Cannot stat SUMMARY file' };
|
|
230
|
+
}
|
|
231
|
+
if (stat.size === 0) {
|
|
232
|
+
return { ok: false, gate: 'nonempty', detail: `SUMMARY-${planId}.md is empty` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Gate 3: valid frontmatter
|
|
236
|
+
let content;
|
|
237
|
+
try {
|
|
238
|
+
content = fs.readFileSync(summaryPath, 'utf8');
|
|
239
|
+
} catch (_e) {
|
|
240
|
+
return { ok: false, gate: 'valid-frontmatter', detail: 'Cannot read SUMMARY file' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const lines = content.split(/\r?\n/).slice(0, 30);
|
|
244
|
+
const hasDashes = lines.some(l => l.trim() === '---');
|
|
245
|
+
const hasStatus = lines.some(l => /^status\s*:/i.test(l.trim()));
|
|
246
|
+
|
|
247
|
+
if (!hasDashes || !hasStatus) {
|
|
248
|
+
return { ok: false, gate: 'valid-frontmatter', detail: `SUMMARY-${planId}.md missing frontmatter (needs --- delimiters and status: field)` };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { ok: true, gate: null, detail: 'all gates passed' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// checkpointInit
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Initialize the checkpoint manifest for a phase before entering the wave loop.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} phaseSlug
|
|
262
|
+
* @param {string|string[]} plans — comma-separated string or array of plan IDs
|
|
263
|
+
* @param {string} [planningDir]
|
|
264
|
+
* @returns {{ ok: boolean, path: string } | { error: string }}
|
|
265
|
+
*/
|
|
266
|
+
function checkpointInit(phaseSlug, plans, planningDir) {
|
|
267
|
+
const pd = resolvePlanningDir(planningDir);
|
|
268
|
+
const phaseDir = path.join(pd, 'phases', phaseSlug);
|
|
269
|
+
const manifestPath = path.join(phaseDir, '.checkpoint-manifest.json');
|
|
270
|
+
|
|
271
|
+
// Normalize plans to array
|
|
272
|
+
let planIds;
|
|
273
|
+
if (Array.isArray(plans)) {
|
|
274
|
+
planIds = plans.filter(Boolean);
|
|
275
|
+
} else if (typeof plans === 'string' && plans.trim()) {
|
|
276
|
+
planIds = plans.split(',').map(s => s.trim()).filter(Boolean);
|
|
277
|
+
} else {
|
|
278
|
+
planIds = [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const manifest = {
|
|
282
|
+
plans: planIds,
|
|
283
|
+
checkpoints_resolved: [],
|
|
284
|
+
checkpoints_pending: [],
|
|
285
|
+
wave: 1,
|
|
286
|
+
deferred: [],
|
|
287
|
+
commit_log: [],
|
|
288
|
+
last_good_commit: null
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
293
|
+
return { ok: true, path: manifestPath };
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return { error: 'Failed to write checkpoint manifest: ' + e.message };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// checkpointUpdate
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Update the checkpoint manifest after a wave completes.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} phaseSlug
|
|
307
|
+
* @param {{ wave: number, resolved: string, sha: string }} opts
|
|
308
|
+
* @param {string} [planningDir]
|
|
309
|
+
* @returns {{ ok: boolean } | { error: string }}
|
|
310
|
+
*/
|
|
311
|
+
function checkpointUpdate(phaseSlug, opts, planningDir) {
|
|
312
|
+
const pd = resolvePlanningDir(planningDir);
|
|
313
|
+
const phaseDir = path.join(pd, 'phases', phaseSlug);
|
|
314
|
+
const manifestPath = path.join(phaseDir, '.checkpoint-manifest.json');
|
|
315
|
+
|
|
316
|
+
let manifest;
|
|
317
|
+
try {
|
|
318
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
319
|
+
manifest = JSON.parse(raw);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
return { error: 'Cannot read checkpoint manifest: ' + e.message };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { wave, resolved, sha } = opts || {};
|
|
325
|
+
|
|
326
|
+
// Move resolved plan from plans → checkpoints_resolved
|
|
327
|
+
if (resolved) {
|
|
328
|
+
manifest.plans = (manifest.plans || []).filter(p => p !== resolved);
|
|
329
|
+
if (!manifest.checkpoints_resolved) manifest.checkpoints_resolved = [];
|
|
330
|
+
manifest.checkpoints_resolved.push(resolved);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Advance wave
|
|
334
|
+
if (typeof wave === 'number' && !isNaN(wave)) {
|
|
335
|
+
manifest.wave = wave;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Append to commit_log and update last_good_commit
|
|
339
|
+
if (sha) {
|
|
340
|
+
if (!manifest.commit_log) manifest.commit_log = [];
|
|
341
|
+
manifest.commit_log.push({ plan: resolved || null, sha, timestamp: new Date().toISOString() });
|
|
342
|
+
manifest.last_good_commit = sha;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
347
|
+
return { ok: true };
|
|
348
|
+
} catch (e) {
|
|
349
|
+
return { error: 'Failed to write checkpoint manifest: ' + e.message };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// seedsMatch
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Find seed files in .planning/seeds/ that match the given phase.
|
|
359
|
+
*
|
|
360
|
+
* A seed matches if ANY of these conditions are true:
|
|
361
|
+
* 1. trigger === phaseSlug (exact)
|
|
362
|
+
* 2. trigger is a substring of phaseSlug
|
|
363
|
+
* 3. trigger === String(phaseNumber)
|
|
364
|
+
* 4. trigger === '*'
|
|
365
|
+
*
|
|
366
|
+
* @param {string} phaseSlug — e.g. "03-authentication"
|
|
367
|
+
* @param {string|number} phaseNumber — e.g. "3" or 3
|
|
368
|
+
* @param {string} [planningDir]
|
|
369
|
+
* @returns {{ matched: Array<{ name: string, description: string, trigger: string, path: string }> }}
|
|
370
|
+
*/
|
|
371
|
+
function seedsMatch(phaseSlug, phaseNumber, planningDir) {
|
|
372
|
+
const pd = resolvePlanningDir(planningDir);
|
|
373
|
+
const seedsDir = path.join(pd, 'seeds');
|
|
374
|
+
|
|
375
|
+
if (!fs.existsSync(seedsDir)) {
|
|
376
|
+
return { matched: [] };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let seedFiles;
|
|
380
|
+
try {
|
|
381
|
+
seedFiles = fs.readdirSync(seedsDir).filter(f => f.endsWith('.md'));
|
|
382
|
+
} catch (_e) {
|
|
383
|
+
return { matched: [] };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const phaseNumStr = String(phaseNumber);
|
|
387
|
+
const matched = [];
|
|
388
|
+
|
|
389
|
+
for (const seedFile of seedFiles) {
|
|
390
|
+
const seedPath = path.join(seedsDir, seedFile);
|
|
391
|
+
let content;
|
|
392
|
+
try {
|
|
393
|
+
content = fs.readFileSync(seedPath, 'utf8');
|
|
394
|
+
} catch (_e) { continue; }
|
|
395
|
+
|
|
396
|
+
// Parse frontmatter
|
|
397
|
+
const fm = parseSeedFrontmatter(content);
|
|
398
|
+
if (!fm || !fm.trigger) continue;
|
|
399
|
+
|
|
400
|
+
const trigger = String(fm.trigger).replace(/^["']|["']$/g, '');
|
|
401
|
+
|
|
402
|
+
const matches =
|
|
403
|
+
trigger === phaseSlug ||
|
|
404
|
+
phaseSlug.includes(trigger) ||
|
|
405
|
+
trigger === phaseNumStr ||
|
|
406
|
+
trigger === '*';
|
|
407
|
+
|
|
408
|
+
if (matches) {
|
|
409
|
+
matched.push({
|
|
410
|
+
name: fm.name || seedFile,
|
|
411
|
+
description: fm.description || '',
|
|
412
|
+
trigger,
|
|
413
|
+
path: seedPath
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { matched };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Minimal YAML frontmatter parser for seed files.
|
|
423
|
+
* Extracts trigger, name, description fields.
|
|
424
|
+
* @param {string} content
|
|
425
|
+
* @returns {{ trigger?: string, name?: string, description?: string } | null}
|
|
426
|
+
*/
|
|
427
|
+
function parseSeedFrontmatter(content) {
|
|
428
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
429
|
+
if (!match) return null;
|
|
430
|
+
|
|
431
|
+
const block = match[1];
|
|
432
|
+
const result = {};
|
|
433
|
+
|
|
434
|
+
for (const line of block.split(/\r?\n/)) {
|
|
435
|
+
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
436
|
+
if (m) {
|
|
437
|
+
result[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Exports
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
module.exports = {
|
|
449
|
+
stalenessCheck,
|
|
450
|
+
summaryGate,
|
|
451
|
+
checkpointInit,
|
|
452
|
+
checkpointUpdate,
|
|
453
|
+
seedsMatch
|
|
454
|
+
};
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
* learnings query [--tags X] [--min-confidence Y] [--stack S] [--type T] — Query learnings
|
|
42
42
|
* learnings check-thresholds — Check deferral trigger conditions
|
|
43
43
|
* spot-check <phaseSlug> <planId> — Verify SUMMARY, key_files, and commits exist for a plan
|
|
44
|
+
* staleness-check <phase-slug> — Check if phase plans are stale vs dependencies
|
|
45
|
+
* summary-gate <phase-slug> <plan-id> — Verify SUMMARY.md exists, non-empty, valid frontmatter
|
|
46
|
+
* checkpoint init <phase-slug> [--plans "id1,id2"] — Initialize checkpoint manifest
|
|
47
|
+
* checkpoint update <phase-slug> --wave N --resolved id [--sha hash] — Update manifest
|
|
48
|
+
* seeds match <phase-slug> <phase-number> — Find matching seed files for a phase
|
|
44
49
|
*
|
|
45
50
|
* Environment: PBR_PROJECT_ROOT — Override project root directory (used when hooks fire from subagent cwd)
|
|
46
51
|
*/
|
|
@@ -153,6 +158,14 @@ const {
|
|
|
153
158
|
contextTriage: _contextTriage
|
|
154
159
|
} = require('./lib/context');
|
|
155
160
|
|
|
161
|
+
const {
|
|
162
|
+
stalenessCheck: _stalenessCheck,
|
|
163
|
+
summaryGate: _summaryGate,
|
|
164
|
+
checkpointInit: _checkpointInit,
|
|
165
|
+
checkpointUpdate: _checkpointUpdate,
|
|
166
|
+
seedsMatch: _seedsMatch
|
|
167
|
+
} = require('./lib/build');
|
|
168
|
+
|
|
156
169
|
// --- Local LLM imports (not extracted — separate module tree) ---
|
|
157
170
|
const { resolveConfig, checkHealth } = require('./local-llm/health');
|
|
158
171
|
const { classifyArtifact } = require('./local-llm/operations/classify-artifact');
|
|
@@ -323,6 +336,12 @@ function contextTriage(options) {
|
|
|
323
336
|
return _contextTriage(options, planningDir);
|
|
324
337
|
}
|
|
325
338
|
|
|
339
|
+
function stalenessCheck(phaseSlug) { return _stalenessCheck(phaseSlug, planningDir); }
|
|
340
|
+
function summaryGate(phaseSlug, planId) { return _summaryGate(phaseSlug, planId, planningDir); }
|
|
341
|
+
function checkpointInit(phaseSlug, plans) { return _checkpointInit(phaseSlug, plans, planningDir); }
|
|
342
|
+
function checkpointUpdate(phaseSlug, opts) { return _checkpointUpdate(phaseSlug, opts, planningDir); }
|
|
343
|
+
function seedsMatch(phaseSlug, phaseNum) { return _seedsMatch(phaseSlug, phaseNum, planningDir); }
|
|
344
|
+
|
|
326
345
|
// --- validateProject stays here (cross-cutting across modules) ---
|
|
327
346
|
|
|
328
347
|
/**
|
|
@@ -765,6 +784,41 @@ async function main() {
|
|
|
765
784
|
error('Usage: spot-check <phaseSlug> <planId>');
|
|
766
785
|
}
|
|
767
786
|
output(spotCheck(phaseSlug, planId));
|
|
787
|
+
} else if (command === 'staleness-check') {
|
|
788
|
+
const slug = args[1];
|
|
789
|
+
if (!slug) { error('Usage: staleness-check <phase-slug>'); process.exit(1); }
|
|
790
|
+
output(stalenessCheck(slug));
|
|
791
|
+
} else if (command === 'summary-gate') {
|
|
792
|
+
const [slug, planId] = args.slice(1);
|
|
793
|
+
if (!slug || !planId) { error('Usage: summary-gate <phase-slug> <plan-id>'); process.exit(1); }
|
|
794
|
+
output(summaryGate(slug, planId));
|
|
795
|
+
} else if (command === 'checkpoint') {
|
|
796
|
+
const sub = args[1];
|
|
797
|
+
const slug = args[2];
|
|
798
|
+
if (sub === 'init') {
|
|
799
|
+
const plans = args[3] || '';
|
|
800
|
+
output(checkpointInit(slug, plans));
|
|
801
|
+
} else if (sub === 'update') {
|
|
802
|
+
const waveIdx = args.indexOf('--wave');
|
|
803
|
+
const wave = waveIdx !== -1 ? parseInt(args[waveIdx + 1], 10) : 1;
|
|
804
|
+
const resolvedIdx = args.indexOf('--resolved');
|
|
805
|
+
const resolved = resolvedIdx !== -1 ? args[resolvedIdx + 1] : '';
|
|
806
|
+
const shaIdx = args.indexOf('--sha');
|
|
807
|
+
const sha = shaIdx !== -1 ? args[shaIdx + 1] : '';
|
|
808
|
+
output(checkpointUpdate(slug, { wave, resolved, sha }));
|
|
809
|
+
} else {
|
|
810
|
+
error('Usage: checkpoint init|update <phase-slug> [options]'); process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
} else if (command === 'seeds') {
|
|
813
|
+
const sub = args[1];
|
|
814
|
+
if (sub === 'match') {
|
|
815
|
+
const slug = args[2];
|
|
816
|
+
const num = args[3];
|
|
817
|
+
if (!slug) { error('Usage: seeds match <phase-slug> <phase-number>'); process.exit(1); }
|
|
818
|
+
output(seedsMatch(slug, num));
|
|
819
|
+
} else {
|
|
820
|
+
error('Usage: seeds match <phase-slug> <phase-number>'); process.exit(1);
|
|
821
|
+
}
|
|
768
822
|
} else if (command === 'context-triage') {
|
|
769
823
|
const options = {};
|
|
770
824
|
const agentsIdx = args.indexOf('--agents-done');
|
|
@@ -796,6 +850,6 @@ async function main() {
|
|
|
796
850
|
}
|
|
797
851
|
|
|
798
852
|
if (require.main === module || process.argv[1] === __filename) { main().catch(err => { process.stderr.write(err.message + '\n'); process.exit(1); }); }
|
|
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 };
|
|
853
|
+
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, stalenessCheck, summaryGate, checkpointInit, checkpointUpdate, seedsMatch };
|
|
800
854
|
// NOTE: validateProject, phaseAdd, phaseRemove, phaseList were previously CLI-only (not exported).
|
|
801
855
|
// They are now exported for testability. This is additive and backwards-compatible.
|
|
@@ -84,27 +84,17 @@ Reference: `skills/shared/config-loading.md` for the tooling shortcut and config
|
|
|
84
84
|
|
|
85
85
|
**Staleness check (dependency fingerprints):**
|
|
86
86
|
After validating prerequisites, check plan staleness:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
10. If plans have no `dependency_fingerprints` field, fall back to timestamp-based staleness detection:
|
|
99
|
-
a. Read `.planning/ROADMAP.md` and identify the current phase's dependencies (the `depends_on` field)
|
|
100
|
-
b. For each dependency phase, find its phase directory under `.planning/phases/`
|
|
101
|
-
c. Check if any SUMMARY.md files in the dependency phase directory have a modification timestamp newer than the current phase's PLAN.md files
|
|
102
|
-
d. If any upstream dependency was modified after planning, display a warning (do NOT block):
|
|
103
|
-
```
|
|
104
|
-
Warning: Phase {dep_phase} (dependency of Phase {N}) was modified after this phase was planned.
|
|
105
|
-
Plans may be based on outdated assumptions. Consider re-planning with `/pbr:plan {N}`.
|
|
106
|
-
```
|
|
107
|
-
e. This is advisory only — continue with the build after displaying the warning
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js staleness-check {phase-slug}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Returns `{ stale: bool, plans: [{id, stale, reason}] }`. If `stale: true` for any plan:
|
|
93
|
+
- Use AskUserQuestion (pattern: stale-continue from `skills/shared/gate-prompts.md`):
|
|
94
|
+
question: "Plan {plan_id} may be stale — {reason}"
|
|
95
|
+
options: ["Continue anyway", "Re-plan with /pbr:plan {N}"]
|
|
96
|
+
- If "Re-plan": stop. If "Continue anyway": proceed.
|
|
97
|
+
If `stale: false`: proceed silently.
|
|
108
98
|
|
|
109
99
|
**Validation errors — use branded error boxes:**
|
|
110
100
|
|
|
@@ -200,30 +190,19 @@ Validate wave consistency:
|
|
|
200
190
|
|
|
201
191
|
### Step 5b: Write Checkpoint Manifest (inline)
|
|
202
192
|
|
|
203
|
-
**CRITICAL:
|
|
204
|
-
|
|
205
|
-
Before entering the wave loop, write `.planning/phases/{NN}-{slug}/.checkpoint-manifest.json`:
|
|
193
|
+
**CRITICAL: Initialize checkpoint manifest NOW before entering the wave loop.**
|
|
206
194
|
|
|
207
|
-
```
|
|
208
|
-
{
|
|
209
|
-
"plans": ["02-01", "02-02", "02-03"],
|
|
210
|
-
"checkpoints_resolved": [],
|
|
211
|
-
"checkpoints_pending": [],
|
|
212
|
-
"wave": 1,
|
|
213
|
-
"deferred": [],
|
|
214
|
-
"commit_log": [],
|
|
215
|
-
"last_good_commit": null
|
|
216
|
-
}
|
|
195
|
+
```bash
|
|
196
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js checkpoint init {phase-slug} --plans "{comma-separated plan IDs}"
|
|
217
197
|
```
|
|
218
198
|
|
|
219
|
-
|
|
199
|
+
After each wave completes, update the manifest:
|
|
220
200
|
|
|
221
|
-
|
|
222
|
-
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
- Append any deferred items collected from executor SUMMARYs
|
|
201
|
+
```bash
|
|
202
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js checkpoint update {phase-slug} --wave {N} --resolved {plan-id} --sha {commit-sha}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
This tracks execution for crash recovery and rollback. Read `.checkpoint-manifest.json` on resume to reconstruct which plans are complete.
|
|
227
206
|
|
|
228
207
|
---
|
|
229
208
|
|
|
@@ -394,11 +373,6 @@ Use AskUserQuestion with the three options. Route:
|
|
|
394
373
|
- Also search SUMMARY.md for `## Self-Check: FAILED` marker — if present, warn before next wave
|
|
395
374
|
- Between waves: verify no file conflicts from parallel executors (`git status` for uncommitted changes)
|
|
396
375
|
|
|
397
|
-
**Additional wave spot-checks:**
|
|
398
|
-
- Check for `## Self-Check: FAILED` in SUMMARY.md — if present, warn user before proceeding to next wave
|
|
399
|
-
- Between waves: verify no file conflicts from parallel executors (check `git status` for uncommitted changes)
|
|
400
|
-
- If ANY spot-check fails, present user with: **Retry this plan** / **Continue to next wave** / **Abort build**
|
|
401
|
-
|
|
402
376
|
**Read executor deviations:**
|
|
403
377
|
|
|
404
378
|
After all executors in the wave complete, read all SUMMARY frontmatter and:
|
|
@@ -565,15 +539,13 @@ If `config.ci.gate_enabled` is `true` AND `config.git.branching` is not `none`:
|
|
|
565
539
|
After each wave completes (all plans in the wave are done, skipped, or aborted):
|
|
566
540
|
|
|
567
541
|
**SUMMARY gate — verify before updating STATE.md:**
|
|
542
|
+
For every plan in the wave, run:
|
|
568
543
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
3. SUMMARY file has a valid title and YAML frontmatter (contains `---` delimiters and a `status:` field)
|
|
544
|
+
```bash
|
|
545
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js summary-gate {phase-slug} {plan-id}
|
|
546
|
+
```
|
|
573
547
|
|
|
574
|
-
Block
|
|
575
|
-
- Warn user: "SUMMARY gate failed for plan {id}: {which gate}. Cannot update STATE.md."
|
|
576
|
-
- Ask user to retry the executor or manually inspect the SUMMARY file
|
|
548
|
+
Returns `{ ok: bool, gate: string, detail: string }`. Block STATE.md update until ALL plans return `ok: true`. If any fail, warn: "SUMMARY gate failed for plan {id}: {gate} — {detail}. Cannot update STATE.md."
|
|
577
549
|
|
|
578
550
|
Once gates pass, update `.planning/STATE.md`:
|
|
579
551
|
|