@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +1 -1
  3. package/plugins/copilot-pbr/agents/executor.agent.md +1 -0
  4. package/plugins/copilot-pbr/hooks/hooks.json +24 -0
  5. package/plugins/copilot-pbr/plugin.json +1 -1
  6. package/plugins/copilot-pbr/skills/build/SKILL.md +3 -3
  7. package/plugins/copilot-pbr/skills/continue/SKILL.md +8 -2
  8. package/plugins/copilot-pbr/skills/import/SKILL.md +2 -0
  9. package/plugins/copilot-pbr/skills/milestone/SKILL.md +2 -0
  10. package/plugins/copilot-pbr/skills/pause/SKILL.md +7 -1
  11. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  12. package/plugins/cursor-pbr/agents/executor.md +1 -0
  13. package/plugins/cursor-pbr/hooks/hooks.json +20 -0
  14. package/plugins/cursor-pbr/skills/build/SKILL.md +3 -3
  15. package/plugins/cursor-pbr/skills/continue/SKILL.md +8 -2
  16. package/plugins/cursor-pbr/skills/import/SKILL.md +2 -0
  17. package/plugins/cursor-pbr/skills/milestone/SKILL.md +2 -0
  18. package/plugins/cursor-pbr/skills/pause/SKILL.md +7 -1
  19. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  20. package/plugins/pbr/agents/executor.md +1 -0
  21. package/plugins/pbr/hooks/hooks.json +20 -0
  22. package/plugins/pbr/scripts/auto-continue.js +26 -2
  23. package/plugins/pbr/scripts/block-skill-self-read.js +72 -0
  24. package/plugins/pbr/scripts/check-agent-state-write.js +63 -0
  25. package/plugins/pbr/scripts/check-cross-plugin-sync.js +93 -0
  26. package/plugins/pbr/scripts/check-dangerous-commands.js +2 -2
  27. package/plugins/pbr/scripts/check-plan-format.js +111 -23
  28. package/plugins/pbr/scripts/check-roadmap-sync.js +140 -1
  29. package/plugins/pbr/scripts/check-state-sync.js +57 -3
  30. package/plugins/pbr/scripts/check-summary-gate.js +1 -1
  31. package/plugins/pbr/scripts/post-write-dispatch.js +47 -0
  32. package/plugins/pbr/scripts/pre-write-dispatch.js +9 -1
  33. package/plugins/pbr/scripts/session-cleanup.js +3 -4
  34. package/plugins/pbr/scripts/validate-task.js +14 -19
  35. package/plugins/pbr/skills/build/SKILL.md +3 -3
  36. package/plugins/pbr/skills/continue/SKILL.md +8 -2
  37. package/plugins/pbr/skills/import/SKILL.md +2 -0
  38. package/plugins/pbr/skills/milestone/SKILL.md +2 -0
  39. 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}\n\nCommand: ${command.substring(0, 150)}`
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: 'CRITICAL: Use Read + Write tools for JSON files, not shell text manipulation. Shell tools can corrupt JSON structure.'
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
- : validateSummary(content, filePath);
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 parts = [`${basename} has structural errors that must be fixed:`];
78
- parts.push(...result.errors.map(i => ` - ${i}`));
79
-
80
- if (result.warnings.length > 0) {
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: parts.join('\n')
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 parts = [`${basename} has structural errors that must be fixed:`];
258
- parts.push(...result.errors.map(i => ` - ${i}`));
259
- if (result.warnings.length > 0) {
260
- parts.push('', 'Warnings (non-blocking):');
261
- parts.push(...result.warnings.map(i => ` - ${i}`));
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
- module.exports = { validatePlan, validateSummary, validateVerification, validateState, checkPlanWrite, checkStateWrite, syncStateBody };
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
- module.exports = { parseState, getRoadmapPhaseStatus, checkSync, parseRoadmapPhases, checkFilesystemDrift };
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 VERIFICATION write
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
- logEvent('workflow', 'state-sync', { phase: phaseNum, trigger: isSummary ? 'summary' : 'verification', updates: messages });
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', 'building'];
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
- // Skill workflow check firstcan block
75
+ // Agent STATE.md write blockermost 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
- if (tryRemove(path.join(planningDir, '.auto-next'))) {
217
- cleaned.push('.auto-next');
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
  }