@sienklogic/plan-build-run 2.7.0 → 2.8.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 +9 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/setup.sh +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/scripts/check-plan-format.js +89 -7
- package/plugins/pbr/scripts/check-subagent-output.js +79 -3
- package/plugins/pbr/scripts/post-write-dispatch.js +8 -1
- package/plugins/pbr/scripts/validate-task.js +365 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to Plan-Build-Run will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.8.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.7.0...plan-build-run-v2.8.0) (2026-02-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **03-01:** add review verifier, milestone complete, and build dependency gates ([bda474d](https://github.com/SienkLogic/plan-build-run/commit/bda474d8b88b128464df375d62de9acdeb9dff05))
|
|
14
|
+
* **04-01:** add post-artifact validation for begin/plan/build and VERIFICATION.md ([3cb4bc1](https://github.com/SienkLogic/plan-build-run/commit/3cb4bc1c0f277c6beca99f7c336fba5e7376f9ec))
|
|
15
|
+
* **05-01:** add STATE.md validation, checkpoint manifest check, and active-skill integrity warning ([d780d97](https://github.com/SienkLogic/plan-build-run/commit/d780d97e620915cb05e70372ce8c9d6003fd1ac8))
|
|
16
|
+
|
|
8
17
|
## [2.7.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.6.0...plan-build-run-v2.7.0) (2026-02-19)
|
|
9
18
|
|
|
10
19
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
3
|
"displayName": "Plan-Build-Run",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.8.0",
|
|
5
5
|
"description": "Plan-Build-Run — Structured development workflow for GitHub Copilot CLI. Solves context rot through disciplined agent delegation, structured planning, atomic execution, and goal-backward verification.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "SienkLogic",
|
|
@@ -37,7 +37,7 @@ fi
|
|
|
37
37
|
if command -v copilot &> /dev/null; then
|
|
38
38
|
echo "Found Copilot CLI. Installing plugin via 'copilot plugin install'..."
|
|
39
39
|
echo ""
|
|
40
|
-
copilot plugin install
|
|
40
|
+
copilot plugin install "$PLUGIN_DIR"
|
|
41
41
|
echo ""
|
|
42
42
|
echo "Plugin installed successfully via Copilot CLI."
|
|
43
43
|
else
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
3
|
"displayName": "Plan-Build-Run",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.8.0",
|
|
5
5
|
"description": "Plan-Build-Run — Structured development workflow for Cursor. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "SienkLogic",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pbr",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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",
|
|
@@ -42,7 +42,9 @@ function main() {
|
|
|
42
42
|
const isPlan = basename.endsWith('PLAN.md');
|
|
43
43
|
const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const isVerification = basename === 'VERIFICATION.md';
|
|
46
|
+
|
|
47
|
+
if (!isPlan && !isSummary && !isVerification) {
|
|
46
48
|
process.exit(0);
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -53,9 +55,11 @@ function main() {
|
|
|
53
55
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
54
56
|
const result = isPlan
|
|
55
57
|
? validatePlan(content, filePath)
|
|
56
|
-
:
|
|
58
|
+
: isVerification
|
|
59
|
+
? validateVerification(content, filePath)
|
|
60
|
+
: validateSummary(content, filePath);
|
|
57
61
|
|
|
58
|
-
const eventType = isPlan ? 'plan-validated' : 'summary-validated';
|
|
62
|
+
const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : 'summary-validated';
|
|
59
63
|
|
|
60
64
|
if (result.errors.length > 0) {
|
|
61
65
|
// Structural errors — block and force correction
|
|
@@ -231,16 +235,19 @@ function checkPlanWrite(data) {
|
|
|
231
235
|
const basename = path.basename(filePath);
|
|
232
236
|
const isPlan = basename.endsWith('PLAN.md');
|
|
233
237
|
const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
|
|
238
|
+
const isVerification = basename === 'VERIFICATION.md';
|
|
234
239
|
|
|
235
|
-
if (!isPlan && !isSummary) return null;
|
|
240
|
+
if (!isPlan && !isSummary && !isVerification) return null;
|
|
236
241
|
if (!fs.existsSync(filePath)) return null;
|
|
237
242
|
|
|
238
243
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
239
244
|
const result = isPlan
|
|
240
245
|
? validatePlan(content, filePath)
|
|
241
|
-
:
|
|
246
|
+
: isVerification
|
|
247
|
+
? validateVerification(content, filePath)
|
|
248
|
+
: validateSummary(content, filePath);
|
|
242
249
|
|
|
243
|
-
const eventType = isPlan ? 'plan-validated' : 'summary-validated';
|
|
250
|
+
const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : 'summary-validated';
|
|
244
251
|
|
|
245
252
|
if (result.errors.length > 0) {
|
|
246
253
|
logHook('check-plan-format', 'PostToolUse', 'block', { file: basename, errors: result.errors });
|
|
@@ -266,5 +273,80 @@ function checkPlanWrite(data) {
|
|
|
266
273
|
return null;
|
|
267
274
|
}
|
|
268
275
|
|
|
269
|
-
|
|
276
|
+
function validateState(content, _filePath) {
|
|
277
|
+
const errors = [];
|
|
278
|
+
const warnings = [];
|
|
279
|
+
|
|
280
|
+
// STATE.md uses warnings (not errors) because it's written by multiple hooks
|
|
281
|
+
// and auto-sync processes. Blocking would create feedback loops.
|
|
282
|
+
if (!content.startsWith('---')) {
|
|
283
|
+
warnings.push('Missing YAML frontmatter');
|
|
284
|
+
} else {
|
|
285
|
+
const frontmatterEnd = content.indexOf('---', 3);
|
|
286
|
+
if (frontmatterEnd === -1) {
|
|
287
|
+
warnings.push('Unclosed YAML frontmatter');
|
|
288
|
+
} else {
|
|
289
|
+
const frontmatter = content.substring(3, frontmatterEnd);
|
|
290
|
+
const requiredFields = ['version', 'current_phase', 'total_phases', 'phase_slug', 'status'];
|
|
291
|
+
for (const field of requiredFields) {
|
|
292
|
+
if (!frontmatter.includes(`${field}:`)) {
|
|
293
|
+
warnings.push(`Frontmatter missing "${field}" field`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { errors, warnings };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function validateVerification(content, _filePath) {
|
|
303
|
+
const errors = [];
|
|
304
|
+
const warnings = [];
|
|
305
|
+
|
|
306
|
+
if (!content.startsWith('---')) {
|
|
307
|
+
errors.push('Missing YAML frontmatter');
|
|
308
|
+
} else {
|
|
309
|
+
const frontmatterEnd = content.indexOf('---', 3);
|
|
310
|
+
if (frontmatterEnd === -1) {
|
|
311
|
+
errors.push('Unclosed YAML frontmatter');
|
|
312
|
+
} else {
|
|
313
|
+
const frontmatter = content.substring(3, frontmatterEnd);
|
|
314
|
+
const requiredFields = ['status', 'phase', 'checked_at', 'must_haves_checked', 'must_haves_passed', 'must_haves_failed'];
|
|
315
|
+
for (const field of requiredFields) {
|
|
316
|
+
if (!frontmatter.includes(`${field}:`)) {
|
|
317
|
+
errors.push(`Frontmatter missing "${field}" field`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { errors, warnings };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Separate STATE.md validation for use by dispatchers.
|
|
328
|
+
* Kept separate from checkPlanWrite because STATE.md routing in the
|
|
329
|
+
* dispatcher must happen AFTER roadmap sync (which also triggers on STATE.md).
|
|
330
|
+
*/
|
|
331
|
+
function checkStateWrite(data) {
|
|
332
|
+
const filePath = data.tool_input?.file_path || data.tool_input?.path || '';
|
|
333
|
+
const basename = path.basename(filePath);
|
|
334
|
+
if (basename !== 'STATE.md') return null;
|
|
335
|
+
if (!fs.existsSync(filePath)) return null;
|
|
336
|
+
|
|
337
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
338
|
+
const result = validateState(content, filePath);
|
|
339
|
+
|
|
340
|
+
if (result.warnings.length > 0) {
|
|
341
|
+
logHook('check-plan-format', 'PostToolUse', 'warn', { file: basename, warnings: result.warnings });
|
|
342
|
+
logEvent('workflow', 'state-validated', { file: basename, status: 'warn', warningCount: result.warnings.length });
|
|
343
|
+
return { output: { additionalContext: `${basename} warnings:\n${result.warnings.map(i => ` - ${i}`).join('\n')}` } };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
logHook('check-plan-format', 'PostToolUse', 'pass', { file: basename });
|
|
347
|
+
logEvent('workflow', 'state-validated', { file: basename, status: 'pass' });
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = { validatePlan, validateSummary, validateVerification, validateState, checkPlanWrite, checkStateWrite };
|
|
270
352
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -188,6 +188,30 @@ function findInQuickDir(planningDir, pattern) {
|
|
|
188
188
|
return matches;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
function checkSummaryCommits(planningDir, foundFiles, warnings) {
|
|
192
|
+
// Look for SUMMARY files in found list
|
|
193
|
+
const summaryFiles = foundFiles.filter(f => /SUMMARY/i.test(f));
|
|
194
|
+
for (const relPath of summaryFiles) {
|
|
195
|
+
try {
|
|
196
|
+
const fullPath = path.join(planningDir, relPath);
|
|
197
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
198
|
+
// Parse frontmatter for commits field
|
|
199
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
200
|
+
if (!fmMatch) continue;
|
|
201
|
+
const fm = fmMatch[1];
|
|
202
|
+
const commitsMatch = fm.match(/commits:\s*(\[.*?\]|.*)/);
|
|
203
|
+
if (!commitsMatch) {
|
|
204
|
+
warnings.push(`${relPath}: No "commits" field in frontmatter. Executor should record commit hashes.`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const commitsVal = commitsMatch[1].trim();
|
|
208
|
+
if (commitsVal === '[]' || commitsVal === '' || commitsVal === '~' || commitsVal === 'null') {
|
|
209
|
+
warnings.push(`${relPath}: "commits" field is empty. Executor may have failed to commit changes.`);
|
|
210
|
+
}
|
|
211
|
+
} catch (_e) { /* best-effort */ }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
191
215
|
function readStdin() {
|
|
192
216
|
try {
|
|
193
217
|
const input = fs.readFileSync(0, 'utf8').trim();
|
|
@@ -217,20 +241,72 @@ function main() {
|
|
|
217
241
|
process.exit(0);
|
|
218
242
|
}
|
|
219
243
|
|
|
244
|
+
// Read active skill
|
|
245
|
+
let activeSkill = '';
|
|
246
|
+
try {
|
|
247
|
+
activeSkill = fs.readFileSync(path.join(planningDir, '.active-skill'), 'utf8').trim();
|
|
248
|
+
} catch (_e) { /* no active skill */ }
|
|
249
|
+
|
|
220
250
|
// Check for expected outputs
|
|
221
251
|
const found = outputSpec.check(planningDir);
|
|
222
252
|
|
|
223
|
-
|
|
253
|
+
const genericMissing = found.length === 0 && !outputSpec.noFileExpected;
|
|
254
|
+
|
|
255
|
+
// Skill-specific post-completion validation
|
|
256
|
+
const skillWarnings = [];
|
|
257
|
+
|
|
258
|
+
// GAP-04: Begin planner must produce core files
|
|
259
|
+
if (activeSkill === 'begin' && agentType === 'pbr:planner') {
|
|
260
|
+
const coreFiles = ['REQUIREMENTS.md', 'ROADMAP.md', 'STATE.md'];
|
|
261
|
+
for (const f of coreFiles) {
|
|
262
|
+
if (!fs.existsSync(path.join(planningDir, f))) {
|
|
263
|
+
skillWarnings.push(`Begin planner: ${f} was not created. The project may be in an incomplete state.`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// GAP-05: Plan researcher should produce phase-level RESEARCH.md
|
|
269
|
+
if (activeSkill === 'plan' && agentType === 'pbr:researcher') {
|
|
270
|
+
const phaseResearch = findInPhaseDir(planningDir, /^RESEARCH\.md$/i);
|
|
271
|
+
if (found.length === 0 && phaseResearch.length === 0) {
|
|
272
|
+
skillWarnings.push('Plan researcher: No research output found in .planning/research/ or in the phase directory.');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// GAP-06: Build executor SUMMARY should have commits
|
|
277
|
+
if (activeSkill === 'build' && agentType === 'pbr:executor') {
|
|
278
|
+
checkSummaryCommits(planningDir, found, skillWarnings);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Output logic: avoid duplicating warnings
|
|
282
|
+
if (genericMissing && skillWarnings.length > 0) {
|
|
283
|
+
logHook('check-subagent-output', 'PostToolUse', 'skill-warning', {
|
|
284
|
+
skill: activeSkill,
|
|
285
|
+
agent_type: agentType,
|
|
286
|
+
warnings: skillWarnings
|
|
287
|
+
});
|
|
288
|
+
const msg = `Warning: Agent ${agentType} completed but no ${outputSpec.description} was found.\nSkill-specific warnings:\n` +
|
|
289
|
+
skillWarnings.map(w => `- ${w}`).join('\n');
|
|
290
|
+
process.stdout.write(JSON.stringify({ additionalContext: msg }));
|
|
291
|
+
} else if (genericMissing) {
|
|
224
292
|
logHook('check-subagent-output', 'PostToolUse', 'warning', {
|
|
225
293
|
agent_type: agentType,
|
|
226
294
|
expected: outputSpec.description,
|
|
227
295
|
found: 'none'
|
|
228
296
|
});
|
|
229
|
-
|
|
230
297
|
const output = {
|
|
231
298
|
additionalContext: `Warning: Agent ${agentType} completed but no ${outputSpec.description} was found. The agent may have failed silently. Check agent output for errors.`
|
|
232
299
|
};
|
|
233
300
|
process.stdout.write(JSON.stringify(output));
|
|
301
|
+
} else if (skillWarnings.length > 0) {
|
|
302
|
+
logHook('check-subagent-output', 'PostToolUse', 'skill-warning', {
|
|
303
|
+
skill: activeSkill,
|
|
304
|
+
agent_type: agentType,
|
|
305
|
+
warnings: skillWarnings
|
|
306
|
+
});
|
|
307
|
+
process.stdout.write(JSON.stringify({
|
|
308
|
+
additionalContext: 'Skill-specific warnings:\n' + skillWarnings.map(w => `- ${w}`).join('\n')
|
|
309
|
+
}));
|
|
234
310
|
} else {
|
|
235
311
|
logHook('check-subagent-output', 'PostToolUse', 'verified', {
|
|
236
312
|
agent_type: agentType,
|
|
@@ -241,5 +317,5 @@ function main() {
|
|
|
241
317
|
process.exit(0);
|
|
242
318
|
}
|
|
243
319
|
|
|
244
|
-
module.exports = { AGENT_OUTPUTS, findInPhaseDir, findInQuickDir };
|
|
320
|
+
module.exports = { AGENT_OUTPUTS, findInPhaseDir, findInQuickDir, checkSummaryCommits };
|
|
245
321
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* 0 = always (PostToolUse hooks are advisory)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const { checkPlanWrite } = require('./check-plan-format');
|
|
21
|
+
const { checkPlanWrite, checkStateWrite } = require('./check-plan-format');
|
|
22
22
|
const { checkSync } = require('./check-roadmap-sync');
|
|
23
23
|
const { checkStateSync } = require('./check-state-sync');
|
|
24
24
|
|
|
@@ -48,6 +48,13 @@ function main() {
|
|
|
48
48
|
process.exit(0);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// STATE.md frontmatter validation (after roadmap sync, advisory only)
|
|
52
|
+
const stateResult = checkStateWrite(data);
|
|
53
|
+
if (stateResult) {
|
|
54
|
+
process.stdout.write(JSON.stringify(stateResult.output));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
// State sync check (SUMMARY/VERIFICATION → STATE.md + ROADMAP.md)
|
|
52
59
|
const stateSyncResult = checkStateSync(data);
|
|
53
60
|
if (stateSyncResult) {
|
|
@@ -306,6 +306,330 @@ function checkReviewPlannerGate(data) {
|
|
|
306
306
|
return null;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Blocking check: when the active skill is "review" and a verifier is being
|
|
311
|
+
* spawned, verify that a SUMMARY*.md exists in the current phase directory.
|
|
312
|
+
* Returns { block: true, reason: "..." } if blocked, or null if OK.
|
|
313
|
+
*/
|
|
314
|
+
function checkReviewVerifierGate(data) {
|
|
315
|
+
const toolInput = data.tool_input || {};
|
|
316
|
+
const subagentType = toolInput.subagent_type || '';
|
|
317
|
+
|
|
318
|
+
// Only gate pbr:verifier
|
|
319
|
+
if (subagentType !== 'pbr:verifier') return null;
|
|
320
|
+
|
|
321
|
+
const cwd = process.cwd();
|
|
322
|
+
const planningDir = path.join(cwd, '.planning');
|
|
323
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
324
|
+
|
|
325
|
+
// Only gate when active skill is "review"
|
|
326
|
+
try {
|
|
327
|
+
const activeSkill = fs.readFileSync(activeSkillFile, 'utf8').trim();
|
|
328
|
+
if (activeSkill !== 'review') return null;
|
|
329
|
+
} catch (_e) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Read STATE.md for current phase
|
|
334
|
+
const stateFile = path.join(planningDir, 'STATE.md');
|
|
335
|
+
try {
|
|
336
|
+
const state = fs.readFileSync(stateFile, 'utf8');
|
|
337
|
+
const phaseMatch = state.match(/Phase:\s*(\d+)/);
|
|
338
|
+
if (!phaseMatch) return null;
|
|
339
|
+
|
|
340
|
+
const currentPhase = phaseMatch[1].padStart(2, '0');
|
|
341
|
+
const phasesDir = path.join(planningDir, 'phases');
|
|
342
|
+
if (!fs.existsSync(phasesDir)) return null;
|
|
343
|
+
|
|
344
|
+
const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(currentPhase + '-'));
|
|
345
|
+
if (dirs.length === 0) return null;
|
|
346
|
+
|
|
347
|
+
const phaseDir = path.join(phasesDir, dirs[0]);
|
|
348
|
+
const files = fs.readdirSync(phaseDir);
|
|
349
|
+
const hasSummary = files.some(f => {
|
|
350
|
+
if (!/^SUMMARY/i.test(f)) return false;
|
|
351
|
+
try {
|
|
352
|
+
return fs.statSync(path.join(phaseDir, f)).size > 0;
|
|
353
|
+
} catch (_e) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!hasSummary) {
|
|
359
|
+
return {
|
|
360
|
+
block: true,
|
|
361
|
+
reason: 'Review verifier gate: Cannot spawn verifier without SUMMARY.md in phase directory. Run /pbr:build first.'
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
} catch (_e) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Blocking check: when the active skill is "milestone" and a general/planner agent
|
|
373
|
+
* is being spawned for a "complete" operation, verify all milestone phases have VERIFICATION.md.
|
|
374
|
+
* Returns { block: true, reason: "..." } if blocked, or null if OK.
|
|
375
|
+
*/
|
|
376
|
+
function checkMilestoneCompleteGate(data) {
|
|
377
|
+
const toolInput = data.tool_input || {};
|
|
378
|
+
const subagentType = toolInput.subagent_type || '';
|
|
379
|
+
const description = toolInput.description || '';
|
|
380
|
+
|
|
381
|
+
const cwd = process.cwd();
|
|
382
|
+
const planningDir = path.join(cwd, '.planning');
|
|
383
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
384
|
+
|
|
385
|
+
// Only gate when active skill is "milestone"
|
|
386
|
+
try {
|
|
387
|
+
const activeSkill = fs.readFileSync(activeSkillFile, 'utf8').trim();
|
|
388
|
+
if (activeSkill !== 'milestone') return null;
|
|
389
|
+
} catch (_e) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Only gate pbr:general and pbr:planner
|
|
394
|
+
if (subagentType !== 'pbr:general' && subagentType !== 'pbr:planner') return null;
|
|
395
|
+
|
|
396
|
+
// Only gate "complete" operations
|
|
397
|
+
if (!/complete/i.test(description)) return null;
|
|
398
|
+
|
|
399
|
+
// Read STATE.md for current phase
|
|
400
|
+
const stateFile = path.join(planningDir, 'STATE.md');
|
|
401
|
+
let currentPhase;
|
|
402
|
+
try {
|
|
403
|
+
const state = fs.readFileSync(stateFile, 'utf8');
|
|
404
|
+
const fmMatch = state.match(/current_phase:\s*(\d+)/);
|
|
405
|
+
if (fmMatch) {
|
|
406
|
+
currentPhase = parseInt(fmMatch[1], 10);
|
|
407
|
+
} else {
|
|
408
|
+
const bodyMatch = state.match(/Phase:\s*(\d+)/);
|
|
409
|
+
if (bodyMatch) currentPhase = parseInt(bodyMatch[1], 10);
|
|
410
|
+
}
|
|
411
|
+
if (!currentPhase) return null;
|
|
412
|
+
} catch (_e) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Read ROADMAP.md and find the milestone containing the current phase
|
|
417
|
+
const roadmapFile = path.join(planningDir, 'ROADMAP.md');
|
|
418
|
+
try {
|
|
419
|
+
const roadmap = fs.readFileSync(roadmapFile, 'utf8');
|
|
420
|
+
|
|
421
|
+
// Split into milestone sections
|
|
422
|
+
const milestoneSections = roadmap.split(/^## Milestone:/m).slice(1);
|
|
423
|
+
|
|
424
|
+
for (const section of milestoneSections) {
|
|
425
|
+
// Parse phase numbers from table rows
|
|
426
|
+
const phaseNumbers = [];
|
|
427
|
+
const tableRowRegex = /^\|\s*(\d+)\s*\|/gm;
|
|
428
|
+
let match;
|
|
429
|
+
while ((match = tableRowRegex.exec(section)) !== null) {
|
|
430
|
+
phaseNumbers.push(parseInt(match[1], 10));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Check if current phase is in this milestone
|
|
434
|
+
if (!phaseNumbers.includes(currentPhase)) continue;
|
|
435
|
+
|
|
436
|
+
// Found the right milestone — check all phases have VERIFICATION.md
|
|
437
|
+
const phasesDir = path.join(planningDir, 'phases');
|
|
438
|
+
if (!fs.existsSync(phasesDir)) return null;
|
|
439
|
+
|
|
440
|
+
for (const phaseNum of phaseNumbers) {
|
|
441
|
+
const paddedPhase = String(phaseNum).padStart(2, '0');
|
|
442
|
+
const pDirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(paddedPhase + '-'));
|
|
443
|
+
if (pDirs.length === 0) {
|
|
444
|
+
return {
|
|
445
|
+
block: true,
|
|
446
|
+
reason: `Milestone complete gate: Phase ${paddedPhase} directory not found. All milestone phases must be verified before completing milestone.`
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const hasVerification = fs.existsSync(path.join(phasesDir, pDirs[0], 'VERIFICATION.md'));
|
|
450
|
+
if (!hasVerification) {
|
|
451
|
+
return {
|
|
452
|
+
block: true,
|
|
453
|
+
reason: `Milestone complete gate: Phase ${paddedPhase} (${pDirs[0]}) lacks VERIFICATION.md. All milestone phases must be verified before completing milestone.`
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// All phases verified
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
} catch (_e) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Blocking check: when the active skill is "build" and an executor is being
|
|
470
|
+
* spawned, verify that dependent phases (from ROADMAP.md) have VERIFICATION.md.
|
|
471
|
+
* Returns { block: true, reason: "..." } if blocked, or null if OK.
|
|
472
|
+
*/
|
|
473
|
+
function checkBuildDependencyGate(data) {
|
|
474
|
+
const toolInput = data.tool_input || {};
|
|
475
|
+
const subagentType = toolInput.subagent_type || '';
|
|
476
|
+
|
|
477
|
+
// Only gate pbr:executor
|
|
478
|
+
if (subagentType !== 'pbr:executor') return null;
|
|
479
|
+
|
|
480
|
+
const cwd = process.cwd();
|
|
481
|
+
const planningDir = path.join(cwd, '.planning');
|
|
482
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
483
|
+
|
|
484
|
+
// Only gate when active skill is "build"
|
|
485
|
+
try {
|
|
486
|
+
const activeSkill = fs.readFileSync(activeSkillFile, 'utf8').trim();
|
|
487
|
+
if (activeSkill !== 'build') return null;
|
|
488
|
+
} catch (_e) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Read STATE.md for current phase
|
|
493
|
+
const stateFile = path.join(planningDir, 'STATE.md');
|
|
494
|
+
let currentPhase;
|
|
495
|
+
try {
|
|
496
|
+
const state = fs.readFileSync(stateFile, 'utf8');
|
|
497
|
+
const phaseMatch = state.match(/Phase:\s*(\d+)/);
|
|
498
|
+
if (!phaseMatch) return null;
|
|
499
|
+
currentPhase = parseInt(phaseMatch[1], 10);
|
|
500
|
+
} catch (_e) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Read ROADMAP.md, find current phase section, check dependencies
|
|
505
|
+
const roadmapFile = path.join(planningDir, 'ROADMAP.md');
|
|
506
|
+
try {
|
|
507
|
+
const roadmap = fs.readFileSync(roadmapFile, 'utf8');
|
|
508
|
+
|
|
509
|
+
// Find ### Phase N: section
|
|
510
|
+
const phaseRegex = new RegExp(`### Phase ${currentPhase}:[\\s\\S]*?(?=### Phase \\d|$)`);
|
|
511
|
+
const phaseSection = roadmap.match(phaseRegex);
|
|
512
|
+
if (!phaseSection) return null;
|
|
513
|
+
|
|
514
|
+
// Look for **Depends on:** line
|
|
515
|
+
const depMatch = phaseSection[0].match(/\*\*Depends on:\*\*\s*(.*)/);
|
|
516
|
+
if (!depMatch) return null;
|
|
517
|
+
|
|
518
|
+
const depLine = depMatch[1].trim();
|
|
519
|
+
if (!depLine || /^none$/i.test(depLine)) return null;
|
|
520
|
+
|
|
521
|
+
// Parse phase numbers from "Phase 1", "Phase 1, Phase 2", etc.
|
|
522
|
+
const depPhases = [];
|
|
523
|
+
const depRegex = /Phase\s+(\d+)/gi;
|
|
524
|
+
let match;
|
|
525
|
+
while ((match = depRegex.exec(depLine)) !== null) {
|
|
526
|
+
depPhases.push(parseInt(match[1], 10));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (depPhases.length === 0) return null;
|
|
530
|
+
|
|
531
|
+
// Check each dependent phase has VERIFICATION.md
|
|
532
|
+
const phasesDir = path.join(planningDir, 'phases');
|
|
533
|
+
if (!fs.existsSync(phasesDir)) return null;
|
|
534
|
+
|
|
535
|
+
for (const depPhase of depPhases) {
|
|
536
|
+
const paddedPhase = String(depPhase).padStart(2, '0');
|
|
537
|
+
const pDirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(paddedPhase + '-'));
|
|
538
|
+
if (pDirs.length === 0) {
|
|
539
|
+
return {
|
|
540
|
+
block: true,
|
|
541
|
+
reason: `Build dependency gate: Dependent phase ${paddedPhase} lacks VERIFICATION.md. Run /pbr:review on dependent phases first.`
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const hasVerification = fs.existsSync(path.join(phasesDir, pDirs[0], 'VERIFICATION.md'));
|
|
545
|
+
if (!hasVerification) {
|
|
546
|
+
return {
|
|
547
|
+
block: true,
|
|
548
|
+
reason: `Build dependency gate: Dependent phase ${paddedPhase} lacks VERIFICATION.md. Run /pbr:review on dependent phases first.`
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} catch (_e) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Advisory check: when active skill is "build" and an executor is being
|
|
561
|
+
* spawned, warn if .checkpoint-manifest.json is missing in the phase dir.
|
|
562
|
+
* Returns a warning string or null.
|
|
563
|
+
*/
|
|
564
|
+
function checkCheckpointManifest(data) {
|
|
565
|
+
const toolInput = data.tool_input || {};
|
|
566
|
+
const subagentType = toolInput.subagent_type || '';
|
|
567
|
+
|
|
568
|
+
if (subagentType !== 'pbr:executor') return null;
|
|
569
|
+
|
|
570
|
+
const cwd = process.cwd();
|
|
571
|
+
const planningDir = path.join(cwd, '.planning');
|
|
572
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const activeSkill = fs.readFileSync(activeSkillFile, 'utf8').trim();
|
|
576
|
+
if (activeSkill !== 'build') return null;
|
|
577
|
+
} catch (_e) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Find current phase dir
|
|
582
|
+
const stateFile = path.join(planningDir, 'STATE.md');
|
|
583
|
+
try {
|
|
584
|
+
const state = fs.readFileSync(stateFile, 'utf8');
|
|
585
|
+
const phaseMatch = state.match(/Phase:\s*(\d+)\s+of\s+\d+/);
|
|
586
|
+
if (!phaseMatch) return null;
|
|
587
|
+
|
|
588
|
+
const currentPhase = phaseMatch[1].padStart(2, '0');
|
|
589
|
+
const phasesDir = path.join(planningDir, 'phases');
|
|
590
|
+
if (!fs.existsSync(phasesDir)) return null;
|
|
591
|
+
|
|
592
|
+
const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(currentPhase + '-'));
|
|
593
|
+
if (dirs.length === 0) return null;
|
|
594
|
+
|
|
595
|
+
const phaseDir = path.join(phasesDir, dirs[0]);
|
|
596
|
+
const manifestFile = path.join(phaseDir, '.checkpoint-manifest.json');
|
|
597
|
+
if (!fs.existsSync(manifestFile)) {
|
|
598
|
+
return 'Build advisory: .checkpoint-manifest.json not found in phase directory. The build skill should write this before spawning executors.';
|
|
599
|
+
}
|
|
600
|
+
} catch (_e) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Advisory check: when any pbr:* agent is being spawned, warn if
|
|
609
|
+
* .planning/.active-skill doesn't exist. Without this file, all
|
|
610
|
+
* skill-specific enforcement is silently disabled.
|
|
611
|
+
* Returns a warning string or null.
|
|
612
|
+
*/
|
|
613
|
+
function checkActiveSkillIntegrity(data) {
|
|
614
|
+
const toolInput = data.tool_input || {};
|
|
615
|
+
const subagentType = toolInput.subagent_type || '';
|
|
616
|
+
|
|
617
|
+
if (typeof subagentType !== 'string' || !subagentType.startsWith('pbr:')) return null;
|
|
618
|
+
|
|
619
|
+
const cwd = process.cwd();
|
|
620
|
+
const planningDir = path.join(cwd, '.planning');
|
|
621
|
+
|
|
622
|
+
// Only check if .planning/ exists (PBR project)
|
|
623
|
+
if (!fs.existsSync(planningDir)) return null;
|
|
624
|
+
|
|
625
|
+
const activeSkillFile = path.join(planningDir, '.active-skill');
|
|
626
|
+
if (!fs.existsSync(activeSkillFile)) {
|
|
627
|
+
return 'Active-skill integrity: .planning/.active-skill not found. Skill-specific enforcement is disabled. The invoking skill should write this file.';
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
309
633
|
function main() {
|
|
310
634
|
let input = '';
|
|
311
635
|
|
|
@@ -363,8 +687,48 @@ function main() {
|
|
|
363
687
|
return;
|
|
364
688
|
}
|
|
365
689
|
|
|
690
|
+
// Blocking gate: review verifier needs SUMMARY.md
|
|
691
|
+
const reviewVerifierGate = checkReviewVerifierGate(data);
|
|
692
|
+
if (reviewVerifierGate && reviewVerifierGate.block) {
|
|
693
|
+
logHook('validate-task', 'PreToolUse', 'blocked', { reason: reviewVerifierGate.reason });
|
|
694
|
+
process.stdout.write(JSON.stringify({
|
|
695
|
+
decision: 'block',
|
|
696
|
+
reason: reviewVerifierGate.reason
|
|
697
|
+
}));
|
|
698
|
+
process.exit(2);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Blocking gate: milestone complete needs all phases verified
|
|
703
|
+
const milestoneGate = checkMilestoneCompleteGate(data);
|
|
704
|
+
if (milestoneGate && milestoneGate.block) {
|
|
705
|
+
logHook('validate-task', 'PreToolUse', 'blocked', { reason: milestoneGate.reason });
|
|
706
|
+
process.stdout.write(JSON.stringify({
|
|
707
|
+
decision: 'block',
|
|
708
|
+
reason: milestoneGate.reason
|
|
709
|
+
}));
|
|
710
|
+
process.exit(2);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Blocking gate: build dependency check
|
|
715
|
+
const buildDepGate = checkBuildDependencyGate(data);
|
|
716
|
+
if (buildDepGate && buildDepGate.block) {
|
|
717
|
+
logHook('validate-task', 'PreToolUse', 'blocked', { reason: buildDepGate.reason });
|
|
718
|
+
process.stdout.write(JSON.stringify({
|
|
719
|
+
decision: 'block',
|
|
720
|
+
reason: buildDepGate.reason
|
|
721
|
+
}));
|
|
722
|
+
process.exit(2);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
366
726
|
// Advisory warnings
|
|
367
727
|
const warnings = checkTask(data);
|
|
728
|
+
const manifestWarning = checkCheckpointManifest(data);
|
|
729
|
+
if (manifestWarning) warnings.push(manifestWarning);
|
|
730
|
+
const activeSkillWarning = checkActiveSkillIntegrity(data);
|
|
731
|
+
if (activeSkillWarning) warnings.push(activeSkillWarning);
|
|
368
732
|
|
|
369
733
|
if (warnings.length > 0) {
|
|
370
734
|
for (const warning of warnings) {
|
|
@@ -383,5 +747,5 @@ function main() {
|
|
|
383
747
|
});
|
|
384
748
|
}
|
|
385
749
|
|
|
386
|
-
module.exports = { checkTask, checkQuickExecutorGate, checkBuildExecutorGate, checkPlanExecutorGate, checkReviewPlannerGate, KNOWN_AGENTS, MAX_DESCRIPTION_LENGTH };
|
|
750
|
+
module.exports = { checkTask, checkQuickExecutorGate, checkBuildExecutorGate, checkPlanExecutorGate, checkReviewPlannerGate, checkReviewVerifierGate, checkMilestoneCompleteGate, checkBuildDependencyGate, checkCheckpointManifest, checkActiveSkillIntegrity, KNOWN_AGENTS, MAX_DESCRIPTION_LENGTH };
|
|
387
751
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|