@sienklogic/plan-build-run 2.46.0 → 2.48.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 (29) 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/build/SKILL.md +15 -116
  5. package/plugins/copilot-pbr/skills/milestone/SKILL.md +3 -81
  6. package/plugins/copilot-pbr/skills/milestone/templates/edge-cases.md +54 -0
  7. package/plugins/copilot-pbr/skills/plan/SKILL.md +6 -76
  8. package/plugins/copilot-pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
  9. package/plugins/copilot-pbr/skills/shared/error-reporting.md +59 -0
  10. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  11. package/plugins/cursor-pbr/skills/build/SKILL.md +15 -116
  12. package/plugins/cursor-pbr/skills/milestone/SKILL.md +3 -81
  13. package/plugins/cursor-pbr/skills/milestone/templates/edge-cases.md +54 -0
  14. package/plugins/cursor-pbr/skills/plan/SKILL.md +6 -76
  15. package/plugins/cursor-pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
  16. package/plugins/cursor-pbr/skills/shared/error-reporting.md +59 -0
  17. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  18. package/plugins/pbr/scripts/lib/build.js +454 -0
  19. package/plugins/pbr/scripts/pbr-tools.js +55 -1
  20. package/plugins/pbr/skills/build/SKILL.md +40 -164
  21. package/plugins/pbr/skills/build/templates/continuation-prompt.md.tmpl +26 -0
  22. package/plugins/pbr/skills/build/templates/executor-prompt.md.tmpl +55 -0
  23. package/plugins/pbr/skills/build/templates/inline-verifier-prompt.md.tmpl +18 -0
  24. package/plugins/pbr/skills/milestone/SKILL.md +3 -81
  25. package/plugins/pbr/skills/milestone/templates/edge-cases.md +54 -0
  26. package/plugins/pbr/skills/milestone/templates/integration-checker-prompt.md.tmpl +25 -0
  27. package/plugins/pbr/skills/plan/SKILL.md +13 -93
  28. package/plugins/pbr/skills/plan/templates/completion-output.md.tmpl +27 -0
  29. package/plugins/pbr/skills/shared/error-reporting.md +59 -0
@@ -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.