@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +1 -1
  3. package/plugins/copilot-pbr/plugin.json +1 -1
  4. package/plugins/copilot-pbr/skills/milestone/SKILL.md +2 -52
  5. package/plugins/copilot-pbr/skills/milestone/templates/edge-cases.md +54 -0
  6. package/plugins/copilot-pbr/skills/plan/SKILL.md +6 -76
  7. package/plugins/copilot-pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
  8. package/plugins/copilot-pbr/skills/shared/error-reporting.md +59 -0
  9. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  10. package/plugins/cursor-pbr/skills/milestone/SKILL.md +2 -52
  11. package/plugins/cursor-pbr/skills/milestone/templates/edge-cases.md +54 -0
  12. package/plugins/cursor-pbr/skills/plan/SKILL.md +6 -76
  13. package/plugins/cursor-pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
  14. package/plugins/cursor-pbr/skills/shared/error-reporting.md +59 -0
  15. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  16. package/plugins/pbr/scripts/lib/build.js +454 -0
  17. package/plugins/pbr/scripts/pbr-tools.js +55 -1
  18. package/plugins/pbr/skills/build/SKILL.md +25 -53
  19. package/plugins/pbr/skills/milestone/SKILL.md +2 -52
  20. package/plugins/pbr/skills/milestone/templates/audit-output.md.tmpl +76 -0
  21. package/plugins/pbr/skills/milestone/templates/complete-output.md.tmpl +32 -0
  22. package/plugins/pbr/skills/milestone/templates/edge-cases.md +54 -0
  23. package/plugins/pbr/skills/milestone/templates/gaps-output.md.tmpl +25 -0
  24. package/plugins/pbr/skills/milestone/templates/new-output.md.tmpl +29 -0
  25. package/plugins/pbr/skills/plan/SKILL.md +13 -93
  26. package/plugins/pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
  27. package/plugins/pbr/skills/shared/error-reporting.md +59 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbr",
3
- "version": "2.47.0",
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
- 1. Read each PLAN.md file's `dependency_fingerprints` field (if present)
88
- 2. For each fingerprinted dependency, check the current SUMMARY.md file (length + modification time)
89
- 3. If any fingerprint doesn't match: the dependency phase was re-built after this plan was created
90
- 4. Use AskUserQuestion (pattern: stale-continue from `skills/shared/gate-prompts.md`):
91
- question: "Plan {plan_id} may be stale — dependency phase {M} was re-built after this plan was created."
92
- header: "Stale"
93
- options:
94
- - label: "Continue anyway" description: "Proceed with existing plans (may still be valid)"
95
- - label: "Re-plan" description: "Stop and re-plan with `/pbr:plan {N}` (recommended)"
96
- If "Re-plan" or "Other": stop and suggest `/pbr:plan {N}`
97
- If "Continue anyway": proceed with existing plans
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: Write .checkpoint-manifest.json NOW before entering the wave loop.**
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
- ```json
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
- This file tracks execution progress for crash recovery and rollback. On resume after compaction, read this manifest to determine where execution left off and which plans still need work.
199
+ After each wave completes, update the manifest:
220
200
 
221
- Update the manifest after each wave completes:
222
- - Move completed plan IDs into `checkpoints_resolved`
223
- - Advance the `wave` counter
224
- - Record commit SHAs in `commit_log` (array of `{ plan, sha, timestamp }` objects)
225
- - Update `last_good_commit` to the SHA of the last successfully verified commit
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
- Before writing any STATE.md update, verify these three gates for every plan in the wave:
570
- 1. SUMMARY file exists at the expected path
571
- 2. SUMMARY file is not empty (file size > 0)
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 the STATE.md update until ALL gates pass. If any gate fails:
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