@sienklogic/plan-build-run 2.21.1 → 2.22.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 +35 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/agents/executor.agent.md +1 -0
- package/plugins/copilot-pbr/hooks/hooks.json +24 -0
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/skills/build/SKILL.md +3 -3
- package/plugins/copilot-pbr/skills/continue/SKILL.md +8 -2
- package/plugins/copilot-pbr/skills/import/SKILL.md +2 -0
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +2 -0
- package/plugins/copilot-pbr/skills/pause/SKILL.md +7 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/agents/executor.md +1 -0
- package/plugins/cursor-pbr/hooks/hooks.json +20 -0
- package/plugins/cursor-pbr/skills/build/SKILL.md +3 -3
- package/plugins/cursor-pbr/skills/continue/SKILL.md +8 -2
- package/plugins/cursor-pbr/skills/import/SKILL.md +2 -0
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +2 -0
- package/plugins/cursor-pbr/skills/pause/SKILL.md +7 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/executor.md +1 -0
- package/plugins/pbr/hooks/hooks.json +20 -0
- package/plugins/pbr/scripts/auto-continue.js +26 -2
- package/plugins/pbr/scripts/block-skill-self-read.js +72 -0
- package/plugins/pbr/scripts/check-agent-state-write.js +63 -0
- package/plugins/pbr/scripts/check-cross-plugin-sync.js +93 -0
- package/plugins/pbr/scripts/check-dangerous-commands.js +2 -2
- package/plugins/pbr/scripts/check-plan-format.js +111 -23
- package/plugins/pbr/scripts/check-roadmap-sync.js +140 -1
- package/plugins/pbr/scripts/check-state-sync.js +57 -3
- package/plugins/pbr/scripts/check-summary-gate.js +1 -1
- package/plugins/pbr/scripts/post-write-dispatch.js +47 -0
- package/plugins/pbr/scripts/pre-write-dispatch.js +9 -1
- package/plugins/pbr/scripts/session-cleanup.js +3 -4
- package/plugins/pbr/scripts/validate-task.js +14 -19
- package/plugins/pbr/skills/build/SKILL.md +3 -3
- package/plugins/pbr/skills/continue/SKILL.md +8 -2
- package/plugins/pbr/skills/import/SKILL.md +2 -0
- package/plugins/pbr/skills/milestone/SKILL.md +2 -0
- package/plugins/pbr/skills/pause/SKILL.md +7 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse Bash hook: Advisory warning when committing pbr skill/agent
|
|
5
|
+
* changes without corresponding cursor-pbr/copilot-pbr counterparts.
|
|
6
|
+
*
|
|
7
|
+
* Only fires on `git commit` commands. Scripts directory is excluded
|
|
8
|
+
* because cursor-pbr and copilot-pbr share scripts via ../pbr/scripts/.
|
|
9
|
+
*
|
|
10
|
+
* Exit codes:
|
|
11
|
+
* 0 = always (advisory only, never blocks)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
const { logHook } = require('./hook-logger');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if a git commit has cross-plugin sync drift.
|
|
19
|
+
* @param {Object} data - Parsed hook input
|
|
20
|
+
* @returns {null|{additionalContext: string}} null if clean, advisory if drift
|
|
21
|
+
*/
|
|
22
|
+
function checkCrossPluginSync(data) {
|
|
23
|
+
const command = data.tool_input?.command || '';
|
|
24
|
+
|
|
25
|
+
// Only check git commit commands
|
|
26
|
+
if (!/\bgit\s+commit\b/.test(command)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let stagedFiles;
|
|
31
|
+
try {
|
|
32
|
+
stagedFiles = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
|
|
33
|
+
} catch (_e) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Find pbr skill/agent files (not scripts — those are shared)
|
|
38
|
+
const pbrSyncFiles = stagedFiles.filter(f =>
|
|
39
|
+
/^plugins\/pbr\/(skills|agents)\//.test(f)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (pbrSyncFiles.length === 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check for missing counterparts
|
|
47
|
+
const missingCounterparts = [];
|
|
48
|
+
for (const pbrFile of pbrSyncFiles) {
|
|
49
|
+
const relativePath = pbrFile.replace(/^plugins\/pbr\//, '');
|
|
50
|
+
const cursorPath = `plugins/cursor-pbr/${relativePath}`;
|
|
51
|
+
const copilotPath = `plugins/copilot-pbr/${relativePath}`;
|
|
52
|
+
|
|
53
|
+
const hasCursor = stagedFiles.some(f => f === cursorPath);
|
|
54
|
+
const hasCopilot = stagedFiles.some(f => f === copilotPath);
|
|
55
|
+
|
|
56
|
+
if (!hasCursor || !hasCopilot) {
|
|
57
|
+
const missing = [];
|
|
58
|
+
if (!hasCursor) missing.push('cursor-pbr');
|
|
59
|
+
if (!hasCopilot) missing.push('copilot-pbr');
|
|
60
|
+
missingCounterparts.push(`${pbrFile} (missing: ${missing.join(', ')})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (missingCounterparts.length === 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const msg = `Advisory: Cross-plugin sync may be needed. Changed pbr files without cursor-pbr/copilot-pbr counterparts:\n${missingCounterparts.map(f => ` - ${f}`).join('\n')}`;
|
|
69
|
+
logHook('check-cross-plugin-sync', 'PreToolUse', 'warn', { missingCounterparts });
|
|
70
|
+
|
|
71
|
+
return { additionalContext: msg };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function main() {
|
|
75
|
+
let input = '';
|
|
76
|
+
process.stdin.setEncoding('utf8');
|
|
77
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
78
|
+
process.stdin.on('end', () => {
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(input);
|
|
81
|
+
const result = checkCrossPluginSync(data);
|
|
82
|
+
if (result) {
|
|
83
|
+
process.stdout.write(JSON.stringify(result));
|
|
84
|
+
}
|
|
85
|
+
} catch (_e) {
|
|
86
|
+
// Don't block on errors
|
|
87
|
+
}
|
|
88
|
+
process.exit(0);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { checkCrossPluginSync };
|
|
93
|
+
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -86,7 +86,7 @@ function checkDangerous(data) {
|
|
|
86
86
|
return {
|
|
87
87
|
output: {
|
|
88
88
|
decision: 'block',
|
|
89
|
-
reason: `Dangerous command blocked.\n\n${reason}
|
|
89
|
+
reason: `Dangerous command blocked.\n\n${reason} Command: ${command.substring(0, 150)}\n\nUse a safer alternative or ask the user for explicit confirmation before running destructive commands.`
|
|
90
90
|
},
|
|
91
91
|
exitCode: 2
|
|
92
92
|
};
|
|
@@ -146,7 +146,7 @@ function checkSkillSpecificBash(command) {
|
|
|
146
146
|
return {
|
|
147
147
|
output: {
|
|
148
148
|
decision: 'block',
|
|
149
|
-
reason: '
|
|
149
|
+
reason: 'JSON shell manipulation blocked.\n\nShell tools like sed, awk, and perl can corrupt JSON structure. The statusline skill must use structured tools for JSON editing.\n\nUse the Read and Write tools to modify JSON files instead of shell text manipulation.'
|
|
150
150
|
},
|
|
151
151
|
exitCode: 2
|
|
152
152
|
};
|
|
@@ -42,10 +42,10 @@ function main() {
|
|
|
42
42
|
const basename = path.basename(filePath);
|
|
43
43
|
const isPlan = basename.endsWith('PLAN.md');
|
|
44
44
|
const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
|
|
45
|
-
|
|
46
45
|
const isVerification = basename === 'VERIFICATION.md';
|
|
46
|
+
const isRoadmap = basename === 'ROADMAP.md';
|
|
47
47
|
|
|
48
|
-
if (!isPlan && !isSummary && !isVerification) {
|
|
48
|
+
if (!isPlan && !isSummary && !isVerification && !isRoadmap) {
|
|
49
49
|
process.exit(0);
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -58,9 +58,11 @@ function main() {
|
|
|
58
58
|
? validatePlan(content, filePath)
|
|
59
59
|
: isVerification
|
|
60
60
|
? validateVerification(content, filePath)
|
|
61
|
-
:
|
|
61
|
+
: isRoadmap
|
|
62
|
+
? validateRoadmap(content, filePath)
|
|
63
|
+
: validateSummary(content, filePath);
|
|
62
64
|
|
|
63
|
-
const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : 'summary-validated';
|
|
65
|
+
const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : isRoadmap ? 'roadmap-validated' : 'summary-validated';
|
|
64
66
|
|
|
65
67
|
if (result.errors.length > 0) {
|
|
66
68
|
// Structural errors — block and force correction
|
|
@@ -74,18 +76,14 @@ function main() {
|
|
|
74
76
|
errorCount: result.errors.length
|
|
75
77
|
});
|
|
76
78
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
parts.push('');
|
|
82
|
-
parts.push('Warnings (non-blocking):');
|
|
83
|
-
parts.push(...result.warnings.map(i => ` - ${i}`));
|
|
84
|
-
}
|
|
79
|
+
const summary = `${basename} has structural errors that must be fixed.`;
|
|
80
|
+
const explanation = result.errors.map(i => ` - ${i}`).join('\n') +
|
|
81
|
+
(result.warnings.length > 0 ? '\n\nWarnings (non-blocking):\n' + result.warnings.map(i => ` - ${i}`).join('\n') : '');
|
|
82
|
+
const remediation = 'Fix the listed issues and re-save the file.';
|
|
85
83
|
|
|
86
84
|
const output = {
|
|
87
85
|
decision: 'block',
|
|
88
|
-
reason:
|
|
86
|
+
reason: `${summary}\n\n${explanation}\n\n${remediation}`
|
|
89
87
|
};
|
|
90
88
|
process.stdout.write(JSON.stringify(output));
|
|
91
89
|
} else if (result.warnings.length > 0) {
|
|
@@ -237,8 +235,9 @@ function checkPlanWrite(data) {
|
|
|
237
235
|
const isPlan = basename.endsWith('PLAN.md');
|
|
238
236
|
const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
|
|
239
237
|
const isVerification = basename === 'VERIFICATION.md';
|
|
238
|
+
const isRoadmap = basename === 'ROADMAP.md';
|
|
240
239
|
|
|
241
|
-
if (!isPlan && !isSummary && !isVerification) return null;
|
|
240
|
+
if (!isPlan && !isSummary && !isVerification && !isRoadmap) return null;
|
|
242
241
|
if (!fs.existsSync(filePath)) return null;
|
|
243
242
|
|
|
244
243
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
@@ -246,21 +245,21 @@ function checkPlanWrite(data) {
|
|
|
246
245
|
? validatePlan(content, filePath)
|
|
247
246
|
: isVerification
|
|
248
247
|
? validateVerification(content, filePath)
|
|
248
|
+
: isRoadmap
|
|
249
|
+
? validateRoadmap(content, filePath)
|
|
249
250
|
: validateSummary(content, filePath);
|
|
250
251
|
|
|
251
|
-
const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : 'summary-validated';
|
|
252
|
+
const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : isRoadmap ? 'roadmap-validated' : 'summary-validated';
|
|
252
253
|
|
|
253
254
|
if (result.errors.length > 0) {
|
|
254
255
|
logHook('check-plan-format', 'PostToolUse', 'block', { file: basename, errors: result.errors });
|
|
255
256
|
logEvent('workflow', eventType, { file: basename, status: 'block', errorCount: result.errors.length });
|
|
256
257
|
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
return { output: { decision: 'block', reason: parts.join('\n') } };
|
|
258
|
+
const summary = `${basename} has structural errors that must be fixed.`;
|
|
259
|
+
const explanation = result.errors.map(i => ` - ${i}`).join('\n') +
|
|
260
|
+
(result.warnings.length > 0 ? '\n\nWarnings (non-blocking):\n' + result.warnings.map(i => ` - ${i}`).join('\n') : '');
|
|
261
|
+
const remediation = 'Fix the listed issues and re-save the file.';
|
|
262
|
+
return { output: { decision: 'block', reason: `${summary}\n\n${explanation}\n\n${remediation}` } };
|
|
264
263
|
}
|
|
265
264
|
|
|
266
265
|
if (result.warnings.length > 0) {
|
|
@@ -348,6 +347,12 @@ function checkStateWrite(data) {
|
|
|
348
347
|
result.warnings.push(bodyFixed.message);
|
|
349
348
|
}
|
|
350
349
|
|
|
350
|
+
// Line count advisory
|
|
351
|
+
const lineCount = content.split('\n').length;
|
|
352
|
+
if (lineCount > 150) {
|
|
353
|
+
result.warnings.push(`Advisory: STATE.md exceeds 150 lines (${lineCount} lines). Consider trimming stale session data.`);
|
|
354
|
+
}
|
|
355
|
+
|
|
351
356
|
if (result.warnings.length > 0) {
|
|
352
357
|
logHook('check-plan-format', 'PostToolUse', 'warn', { file: basename, warnings: result.warnings });
|
|
353
358
|
logEvent('workflow', 'state-validated', { file: basename, status: 'warn', warningCount: result.warnings.length });
|
|
@@ -424,5 +429,88 @@ function syncStateBody(content, filePath) {
|
|
|
424
429
|
}
|
|
425
430
|
}
|
|
426
431
|
|
|
427
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Validate ROADMAP.md structure. Returns advisory warnings only (never blocking errors).
|
|
434
|
+
*
|
|
435
|
+
* Checks:
|
|
436
|
+
* - Has a # Roadmap heading
|
|
437
|
+
* - Has at least one ## Milestone: section
|
|
438
|
+
* - Each milestone has **Phases:** line
|
|
439
|
+
* - Each ### Phase NN: has **Goal:**, **Provides:**, **Depends on:**
|
|
440
|
+
* - Progress table (if present) has valid markdown table syntax
|
|
441
|
+
*
|
|
442
|
+
* @param {string} content - Full ROADMAP.md content
|
|
443
|
+
* @param {string} _filePath - File path (unused)
|
|
444
|
+
* @returns {{ errors: string[], warnings: string[] }}
|
|
445
|
+
*/
|
|
446
|
+
function validateRoadmap(content, _filePath) {
|
|
447
|
+
const errors = [];
|
|
448
|
+
const warnings = [];
|
|
449
|
+
|
|
450
|
+
// Check for # Roadmap heading
|
|
451
|
+
if (!/^#\s+(Roadmap|ROADMAP)/m.test(content)) {
|
|
452
|
+
warnings.push('Missing "# Roadmap" heading');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Check for at least one ## Milestone: section
|
|
456
|
+
const milestoneMatches = content.match(/^##\s+Milestone:/gm);
|
|
457
|
+
if (!milestoneMatches || milestoneMatches.length === 0) {
|
|
458
|
+
warnings.push('No "## Milestone:" sections found');
|
|
459
|
+
} else {
|
|
460
|
+
// Check each milestone has **Phases:** line
|
|
461
|
+
// Split content by milestone sections
|
|
462
|
+
const milestoneBlocks = content.split(/^##\s+Milestone:/m).slice(1);
|
|
463
|
+
milestoneBlocks.forEach((block, idx) => {
|
|
464
|
+
if (!/\*\*Phases:\*\*/.test(block)) {
|
|
465
|
+
warnings.push(`Milestone ${idx + 1}: missing "**Phases:**" line`);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check each ### Phase NN: has Goal, Provides, Depends on
|
|
471
|
+
const phaseRegex = /^###\s+Phase\s+\d+:/gm;
|
|
472
|
+
const phaseMatches = content.match(phaseRegex);
|
|
473
|
+
if (phaseMatches) {
|
|
474
|
+
const phaseBlocks = content.split(/^###\s+Phase\s+\d+:/m).slice(1);
|
|
475
|
+
phaseBlocks.forEach((block, idx) => {
|
|
476
|
+
// Only check up to the next ### or ## heading
|
|
477
|
+
const nextHeading = block.search(/^#{2,3}\s+/m);
|
|
478
|
+
const section = nextHeading !== -1 ? block.substring(0, nextHeading) : block;
|
|
479
|
+
|
|
480
|
+
if (!/\*\*Goal:\*\*/.test(section)) {
|
|
481
|
+
warnings.push(`Phase ${idx + 1}: missing "**Goal:**"`);
|
|
482
|
+
}
|
|
483
|
+
if (!/\*\*Provides:\*\*/.test(section)) {
|
|
484
|
+
warnings.push(`Phase ${idx + 1}: missing "**Provides:**"`);
|
|
485
|
+
}
|
|
486
|
+
if (!/\*\*Depends on:\*\*/.test(section)) {
|
|
487
|
+
warnings.push(`Phase ${idx + 1}: missing "**Depends on:**"`);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Check Progress table syntax if present
|
|
493
|
+
const progressMatch = content.match(/^##\s+Progress/m);
|
|
494
|
+
if (progressMatch) {
|
|
495
|
+
const afterProgress = content.substring(progressMatch.index);
|
|
496
|
+
const headerLine = afterProgress.split('\n').find(l => l.includes('|') && /Plans\s*Complete/i.test(l));
|
|
497
|
+
if (headerLine) {
|
|
498
|
+
// Check for separator row after header
|
|
499
|
+
const lines = afterProgress.split('\n');
|
|
500
|
+
const headerIdx = lines.findIndex(l => l.includes('|') && /Plans\s*Complete/i.test(l));
|
|
501
|
+
if (headerIdx >= 0 && headerIdx + 1 < lines.length) {
|
|
502
|
+
const sepLine = lines[headerIdx + 1];
|
|
503
|
+
if (!/^\s*\|[\s-:|]+\|\s*$/.test(sepLine)) {
|
|
504
|
+
warnings.push('Progress table: missing or malformed separator row (expected |---|---|...)');
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
warnings.push('Progress table: header row with "Plans Complete" column not found');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { errors, warnings };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
module.exports = { validatePlan, validateSummary, validateVerification, validateState, validateRoadmap, checkPlanWrite, checkStateWrite, syncStateBody };
|
|
428
516
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -20,6 +20,78 @@ const { logEvent } = require('./event-logger');
|
|
|
20
20
|
|
|
21
21
|
const LIFECYCLE_STATUSES = ['planned', 'built', 'partial', 'verified'];
|
|
22
22
|
|
|
23
|
+
/** Ordered lifecycle statuses for regression detection. Higher index = more advanced. */
|
|
24
|
+
const STATUS_ORDER = ['planned', 'partial', 'built', 'verified'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determine if a STATE.md → ROADMAP.md mismatch is high-risk.
|
|
28
|
+
* High-risk scenarios:
|
|
29
|
+
* 1. Status regression: ROADMAP status is earlier in lifecycle than STATE status
|
|
30
|
+
* 2. Phase ordering gap: ROADMAP table skips phase numbers (e.g., 01 then 03)
|
|
31
|
+
*
|
|
32
|
+
* @param {string} stateContent - Contents of STATE.md
|
|
33
|
+
* @param {string} roadmapContent - Contents of ROADMAP.md
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
function isHighRisk(stateContent, roadmapContent) {
|
|
37
|
+
const stateInfo = parseState(stateContent);
|
|
38
|
+
if (!stateInfo || !stateInfo.phase || !stateInfo.status) return false;
|
|
39
|
+
|
|
40
|
+
// Check status regression
|
|
41
|
+
const roadmapStatus = getRoadmapPhaseStatus(roadmapContent, stateInfo.phase);
|
|
42
|
+
if (roadmapStatus) {
|
|
43
|
+
const stateIdx = STATUS_ORDER.indexOf(stateInfo.status);
|
|
44
|
+
const roadmapIdx = STATUS_ORDER.indexOf(roadmapStatus.toLowerCase());
|
|
45
|
+
if (stateIdx !== -1 && roadmapIdx !== -1 && roadmapIdx < stateIdx) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check phase ordering gaps
|
|
51
|
+
const phaseNumbers = getAllRoadmapPhaseNumbers(roadmapContent);
|
|
52
|
+
if (phaseNumbers.length >= 2) {
|
|
53
|
+
for (let i = 1; i < phaseNumbers.length; i++) {
|
|
54
|
+
if (phaseNumbers[i] - phaseNumbers[i - 1] > 1) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract all phase numbers from ROADMAP.md table, sorted ascending.
|
|
65
|
+
* @param {string} content - ROADMAP.md content
|
|
66
|
+
* @returns {number[]}
|
|
67
|
+
*/
|
|
68
|
+
function getAllRoadmapPhaseNumbers(content) {
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
const numbers = [];
|
|
71
|
+
let inTable = false;
|
|
72
|
+
let phaseColIndex = -1;
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (!inTable) {
|
|
76
|
+
if (line.includes('|') && /Phase/i.test(line) && /Status/i.test(line)) {
|
|
77
|
+
const cols = splitTableRow(line);
|
|
78
|
+
phaseColIndex = cols.findIndex(c => /^Phase$/i.test(c));
|
|
79
|
+
if (phaseColIndex !== -1) inTable = true;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (/^\s*\|[\s-:|]+\|\s*$/.test(line)) continue;
|
|
84
|
+
if (!line.includes('|')) break;
|
|
85
|
+
const cols = splitTableRow(line);
|
|
86
|
+
if (cols.length > phaseColIndex) {
|
|
87
|
+
const num = parseInt(normalizePhaseNum(cols[phaseColIndex]), 10);
|
|
88
|
+
if (!isNaN(num)) numbers.push(num);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return numbers.sort((a, b) => a - b);
|
|
93
|
+
}
|
|
94
|
+
|
|
23
95
|
function main() {
|
|
24
96
|
let input = '';
|
|
25
97
|
|
|
@@ -265,6 +337,17 @@ function checkSync(data) {
|
|
|
265
337
|
logEvent('workflow', 'roadmap-sync', {
|
|
266
338
|
phase: stateInfo.phase, stateStatus: stateInfo.status, roadmapStatus, status: 'out-of-sync'
|
|
267
339
|
});
|
|
340
|
+
|
|
341
|
+
// Block on high-risk regressions, advise on non-critical drift
|
|
342
|
+
if (isHighRisk(stateContent, roadmapContent)) {
|
|
343
|
+
return {
|
|
344
|
+
output: {
|
|
345
|
+
decision: 'block',
|
|
346
|
+
reason: `ROADMAP.md status regression detected for phase ${stateInfo.phase}.\n\nPhase ${stateInfo.phase} is "${stateInfo.status}" in STATE.md but "${roadmapStatus}" in ROADMAP.md. Writing a lower-lifecycle status to ROADMAP.md would corrupt milestone tracking.\n\nUpdate ROADMAP.md to match STATE.md status ("${stateInfo.status}") or fix STATE.md if the regression is intentional.`
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
268
351
|
return {
|
|
269
352
|
output: {
|
|
270
353
|
additionalContext: `CRITICAL: ROADMAP.md out of sync — Phase ${stateInfo.phase} is "${roadmapStatus}" in ROADMAP.md but "${stateInfo.status}" in STATE.md. Update the ROADMAP.md Progress table NOW before continuing. Run: \`node plugins/pbr/scripts/pbr-tools.js state load\` to check current state.`
|
|
@@ -348,5 +431,61 @@ function checkFilesystemDrift(roadmapContent, phasesDir) {
|
|
|
348
431
|
return warnings;
|
|
349
432
|
}
|
|
350
433
|
|
|
351
|
-
|
|
434
|
+
/**
|
|
435
|
+
* Validate ROADMAP.md after a milestone is marked complete.
|
|
436
|
+
* All phases in the roadmap table must be "Verified" or "Archived".
|
|
437
|
+
* If the milestone section is already collapsed (contains "COMPLETED"), passes.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} roadmapContent - ROADMAP.md content
|
|
440
|
+
* @param {string} completedMilestone - Milestone identifier (e.g., "v1.0")
|
|
441
|
+
* @returns {null|{decision: string, reason: string}} null if valid, blocking result if not
|
|
442
|
+
*/
|
|
443
|
+
function validatePostMilestone(roadmapContent, completedMilestone) {
|
|
444
|
+
// If milestone section is already collapsed/completed, pass
|
|
445
|
+
const collapsedPattern = new RegExp(`##\\s+Milestone\\s+${completedMilestone.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*COMPLETED`, 'i');
|
|
446
|
+
if (collapsedPattern.test(roadmapContent)) return null;
|
|
447
|
+
|
|
448
|
+
// Parse all phase statuses from the table
|
|
449
|
+
const lines = roadmapContent.split('\n');
|
|
450
|
+
let inTable = false;
|
|
451
|
+
let phaseColIndex = -1;
|
|
452
|
+
let statusColIndex = -1;
|
|
453
|
+
const unverified = [];
|
|
454
|
+
|
|
455
|
+
for (const line of lines) {
|
|
456
|
+
if (!inTable) {
|
|
457
|
+
if (line.includes('|') && /Phase/i.test(line) && /Status/i.test(line)) {
|
|
458
|
+
const cols = splitTableRow(line);
|
|
459
|
+
phaseColIndex = cols.findIndex(c => /^Phase$/i.test(c));
|
|
460
|
+
statusColIndex = cols.findIndex(c => /^Status$/i.test(c));
|
|
461
|
+
if (phaseColIndex !== -1 && statusColIndex !== -1) inTable = true;
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (/^\s*\|[\s-:|]+\|\s*$/.test(line)) continue;
|
|
466
|
+
if (!line.includes('|')) break;
|
|
467
|
+
|
|
468
|
+
const cols = splitTableRow(line);
|
|
469
|
+
if (cols.length <= Math.max(phaseColIndex, statusColIndex)) continue;
|
|
470
|
+
|
|
471
|
+
const phaseNum = normalizePhaseNum(cols[phaseColIndex]);
|
|
472
|
+
const status = cols[statusColIndex].toLowerCase().trim();
|
|
473
|
+
|
|
474
|
+
if (status !== 'verified' && status !== 'archived') {
|
|
475
|
+
unverified.push({ phase: phaseNum, status });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (unverified.length > 0) {
|
|
480
|
+
const details = unverified.map(u => `Phase ${u.phase} (${u.status})`).join(', ');
|
|
481
|
+
return {
|
|
482
|
+
decision: 'block',
|
|
483
|
+
reason: `Cannot complete milestone ${completedMilestone}: unverified phases remain.\n\nThe following phases are not yet Verified or Archived: ${details}. All phases must reach Verified or Archived status before milestone completion.\n\nRun /pbr:review on each unverified phase to advance it to Verified status.`
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
module.exports = { parseState, getRoadmapPhaseStatus, checkSync, parseRoadmapPhases, checkFilesystemDrift, isHighRisk, validatePostMilestone };
|
|
352
491
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -305,11 +305,12 @@ function checkStateSync(data) {
|
|
|
305
305
|
// Guard: skip STATE.md and ROADMAP.md writes (prevents circular trigger)
|
|
306
306
|
if (basename === 'STATE.md' || basename === 'ROADMAP.md') return null;
|
|
307
307
|
|
|
308
|
-
// Determine if this is a SUMMARY or
|
|
308
|
+
// Determine if this is a SUMMARY, VERIFICATION, or PLAN write
|
|
309
309
|
const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
|
|
310
310
|
const isVerification = basename === 'VERIFICATION.md';
|
|
311
|
+
const isPlan = basename.endsWith('PLAN.md') && !basename.includes('SUMMARY');
|
|
311
312
|
|
|
312
|
-
if (!isSummary && !isVerification) return null;
|
|
313
|
+
if (!isSummary && !isVerification && !isPlan) return null;
|
|
313
314
|
|
|
314
315
|
// Guard: must be inside .planning/phases/
|
|
315
316
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
@@ -501,10 +502,63 @@ function checkStateSync(data) {
|
|
|
501
502
|
}
|
|
502
503
|
}
|
|
503
504
|
|
|
505
|
+
if (isPlan) {
|
|
506
|
+
// Status ordering: only set Planning if current status is lower
|
|
507
|
+
const statusOrder = { 'not started': 0, '': 0, 'planning': 1, 'in progress': 2, 'complete': 3, 'needs fixes': 4 };
|
|
508
|
+
|
|
509
|
+
if (fs.existsSync(roadmapPath)) {
|
|
510
|
+
try {
|
|
511
|
+
const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
|
512
|
+
const hasProgressTable = /Plans\s*Complete/i.test(roadmapContent);
|
|
513
|
+
if (!hasProgressTable) {
|
|
514
|
+
messages.push(`ROADMAP.md: No Progress table found. Add a table with columns: | Phase | Plans Complete | Status | Completed | for the current milestone phases.`);
|
|
515
|
+
} else {
|
|
516
|
+
// Read current status from the Progress table for this phase
|
|
517
|
+
const lines = roadmapContent.split('\n');
|
|
518
|
+
const paddedPhase = phaseNum.padStart(2, '0');
|
|
519
|
+
let currentStatus = '';
|
|
520
|
+
let inTable = false;
|
|
521
|
+
for (const line of lines) {
|
|
522
|
+
if (!inTable) {
|
|
523
|
+
if (line.includes('|') && /Plans\s*Complete/i.test(line)) inTable = true;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (/^\s*\|[\s-:|]+\|\s*$/.test(line)) continue;
|
|
527
|
+
if (!line.includes('|')) break;
|
|
528
|
+
const parts = line.split('|');
|
|
529
|
+
if (parts.length < 5) continue;
|
|
530
|
+
const phaseCol = (parts[1] || '').trim();
|
|
531
|
+
const phaseMatch = phaseCol.match(/^(\d+)\./);
|
|
532
|
+
if (!phaseMatch) continue;
|
|
533
|
+
if (phaseMatch[1] === paddedPhase || String(parseInt(phaseMatch[1], 10)) === String(parseInt(phaseNum, 10))) {
|
|
534
|
+
currentStatus = (parts[3] || '').trim().toLowerCase();
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Only update to "Planning" if current status is lower
|
|
540
|
+
const currentOrder = statusOrder[currentStatus] !== undefined ? statusOrder[currentStatus] : 0;
|
|
541
|
+
const planningOrder = statusOrder['planning'];
|
|
542
|
+
if (currentOrder < planningOrder) {
|
|
543
|
+
const plansComplete = `${artifacts.completeSummaries}/${artifacts.plans}`;
|
|
544
|
+
const updatedRoadmap = updateProgressTable(roadmapContent, phaseNum, plansComplete, 'Planning', null);
|
|
545
|
+
if (updatedRoadmap !== roadmapContent) {
|
|
546
|
+
atomicWrite(roadmapPath, updatedRoadmap);
|
|
547
|
+
messages.push(`ROADMAP.md: Phase ${phaseNum} → Planning`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} catch (e) {
|
|
552
|
+
logHook('check-state-sync', 'PostToolUse', 'error', { reason: 'ROADMAP.md update failed', error: e.message });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
504
557
|
if (messages.length > 0) {
|
|
505
558
|
const msg = `Auto-synced tracking files: ${messages.join('; ')}`;
|
|
506
559
|
logHook('check-state-sync', 'PostToolUse', 'sync', { phase: phaseNum, updates: messages });
|
|
507
|
-
|
|
560
|
+
const trigger = isSummary ? 'summary' : isVerification ? 'verification' : 'plan';
|
|
561
|
+
logEvent('workflow', 'state-sync', { phase: phaseNum, trigger, updates: messages });
|
|
508
562
|
return { output: { additionalContext: msg } };
|
|
509
563
|
}
|
|
510
564
|
|
|
@@ -29,7 +29,7 @@ const path = require('path');
|
|
|
29
29
|
const { logHook } = require('./hook-logger');
|
|
30
30
|
|
|
31
31
|
// Statuses that indicate a phase has been executed
|
|
32
|
-
const ADVANCED_STATUSES = ['built', 'verified', 'complete'
|
|
32
|
+
const ADVANCED_STATUSES = ['built', 'verified', 'complete'];
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Extract YAML frontmatter values from markdown content.
|
|
@@ -22,6 +22,46 @@ 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
|
|
|
25
|
+
// Conditionally import validateRoadmap (may not exist yet if PLAN-01 hasn't landed)
|
|
26
|
+
let validateRoadmap;
|
|
27
|
+
try {
|
|
28
|
+
const cpf = require('./check-plan-format');
|
|
29
|
+
validateRoadmap = cpf.validateRoadmap || null;
|
|
30
|
+
} catch (_e) {
|
|
31
|
+
validateRoadmap = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate ROADMAP.md writes inside .planning/.
|
|
36
|
+
* @param {Object} data - Parsed hook input
|
|
37
|
+
* @returns {null|{output: Object}}
|
|
38
|
+
*/
|
|
39
|
+
function checkRoadmapWrite(data) {
|
|
40
|
+
const filePath = data.tool_input?.file_path || '';
|
|
41
|
+
if (!filePath.endsWith('ROADMAP.md')) return null;
|
|
42
|
+
|
|
43
|
+
// Only validate ROADMAP.md inside .planning/
|
|
44
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
45
|
+
if (!normalized.includes('.planning/') && !normalized.includes('.planning\\')) return null;
|
|
46
|
+
|
|
47
|
+
if (!validateRoadmap) return null;
|
|
48
|
+
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
if (!fs.existsSync(filePath)) return null;
|
|
51
|
+
|
|
52
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
53
|
+
const errors = validateRoadmap(content);
|
|
54
|
+
if (errors && errors.length > 0) {
|
|
55
|
+
return {
|
|
56
|
+
output: {
|
|
57
|
+
additionalContext: `[ROADMAP Validation] ${errors.join('; ')}`
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
function main() {
|
|
26
66
|
let input = '';
|
|
27
67
|
|
|
@@ -41,6 +81,13 @@ function main() {
|
|
|
41
81
|
process.exit(0);
|
|
42
82
|
}
|
|
43
83
|
|
|
84
|
+
// ROADMAP.md structural validation (before sync checks)
|
|
85
|
+
const roadmapResult = checkRoadmapWrite(data);
|
|
86
|
+
if (roadmapResult) {
|
|
87
|
+
process.stdout.write(JSON.stringify(roadmapResult.output));
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
44
91
|
// Roadmap sync check (STATE.md)
|
|
45
92
|
const syncResult = checkSync(data);
|
|
46
93
|
if (syncResult) {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
* 2 = blocked (workflow violation or phase boundary enforcement)
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
|
+
const { checkAgentStateWrite } = require('./check-agent-state-write');
|
|
60
61
|
const { checkWorkflow } = require('./check-skill-workflow');
|
|
61
62
|
const { checkSummaryGate } = require('./check-summary-gate');
|
|
62
63
|
const { checkBoundary } = require('./check-phase-boundary');
|
|
@@ -71,7 +72,14 @@ function main() {
|
|
|
71
72
|
try {
|
|
72
73
|
const data = JSON.parse(input);
|
|
73
74
|
|
|
74
|
-
//
|
|
75
|
+
// Agent STATE.md write blocker — most fundamental check
|
|
76
|
+
const agentResult = checkAgentStateWrite(data);
|
|
77
|
+
if (agentResult) {
|
|
78
|
+
process.stdout.write(JSON.stringify(agentResult.output));
|
|
79
|
+
process.exit(agentResult.exitCode || 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Skill workflow check — can block
|
|
75
83
|
const workflowResult = checkWorkflow(data);
|
|
76
84
|
if (workflowResult) {
|
|
77
85
|
process.stdout.write(JSON.stringify(workflowResult.output));
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* SessionEnd cleanup hook.
|
|
5
5
|
*
|
|
6
6
|
* Removes stale planning artifacts that shouldn't persist across sessions:
|
|
7
|
-
* - .planning/.auto-next (prevents confusion on next session start)
|
|
8
7
|
* - .planning/.active-operation (stale operation lock)
|
|
9
8
|
* - .planning/.active-skill (stale skill tracking)
|
|
10
9
|
*
|
|
@@ -213,9 +212,9 @@ function main() {
|
|
|
213
212
|
|
|
214
213
|
const cleaned = [];
|
|
215
214
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
// NOTE: .auto-next is intentionally NOT cleaned here — it is a one-shot
|
|
216
|
+
// signal consumed by auto-continue.js (Stop hook). SessionEnd cleanup
|
|
217
|
+
// races with the Stop hook and would delete the signal before it is read.
|
|
219
218
|
if (tryRemove(path.join(planningDir, '.active-operation'))) {
|
|
220
219
|
cleaned.push('.active-operation');
|
|
221
220
|
}
|