@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.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/skills/build/SKILL.md +15 -116
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +3 -81
- 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/build/SKILL.md +15 -116
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +3 -81
- 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 +40 -164
- package/plugins/pbr/skills/build/templates/continuation-prompt.md.tmpl +26 -0
- package/plugins/pbr/skills/build/templates/executor-prompt.md.tmpl +55 -0
- package/plugins/pbr/skills/build/templates/inline-verifier-prompt.md.tmpl +18 -0
- package/plugins/pbr/skills/milestone/SKILL.md +3 -81
- package/plugins/pbr/skills/milestone/templates/edge-cases.md +54 -0
- package/plugins/pbr/skills/milestone/templates/integration-checker-prompt.md.tmpl +25 -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
|
@@ -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.
|