@sienklogic/plan-build-run 2.41.0 → 2.42.1

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 CHANGED
@@ -5,6 +5,33 @@ 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.42.1](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.42.0...plan-build-run-v2.42.1) (2026-02-28)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **48-02:** use atomicWrite from core.js in circuit-state.js ([b294fe9](https://github.com/SienkLogic/plan-build-run/commit/b294fe96b4a9384293850180560184b3ac2cf8fc))
14
+
15
+ ## [2.42.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.41.0...plan-build-run-v2.42.0) (2026-02-28)
16
+
17
+
18
+ ### Features
19
+
20
+ * **48-02:** add localhost-only validation for local_llm.endpoint in configValidate ([511e038](https://github.com/SienkLogic/plan-build-run/commit/511e038bc5821518d261ac49d3098ac8281970cd))
21
+ * **48-02:** add persistent cross-process circuit breaker in lib/circuit-state.js ([85ee2fd](https://github.com/SienkLogic/plan-build-run/commit/85ee2fdb07ee9357c467d80f2ec02b6af2e0378e))
22
+ * **48-02:** replace busy-wait loop with Atomics.wait in lockedFileUpdate ([0099aa2](https://github.com/SienkLogic/plan-build-run/commit/0099aa2b172b7705bba2c834be8601d225667b10))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **48-03:** remove unused imports causing ESLint failures in CI ([f459004](https://github.com/SienkLogic/plan-build-run/commit/f4590048e4847e802ace067b47d10d9c865d0f21))
28
+ * **48-03:** remove unused variable in run-hook.test.js to pass ESLint ([2b553b1](https://github.com/SienkLogic/plan-build-run/commit/2b553b1df752231f9e78246fe6f4d6326f872f87))
29
+
30
+
31
+ ### Documentation
32
+
33
+ * **48-03:** add bootstrap documentation and drift-detection test for hooks.json ([d29c015](https://github.com/SienkLogic/plan-build-run/commit/d29c01524bdbc2b01741f4c634b8e3855a3fa0b0))
34
+
8
35
  ## [2.41.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.40.1...plan-build-run-v2.41.0) (2026-02-28)
9
36
 
10
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.41.0",
3
+ "version": "2.42.1",
4
4
  "description": "Plan it, Build it, Run it — structured development workflow for Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.41.0",
4
+ "version": "2.42.1",
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",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.41.0",
4
+ "version": "2.42.1",
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.41.0",
3
+ "version": "2.42.1",
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",
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "$schema": "../scripts/hooks-schema.json",
3
3
  "description": "Plan-Build-Run workflow hooks for state tracking, validation, and auto-continuation",
4
+ "$bootstrap": {
5
+ "why": "Claude Code expands ${CLAUDE_PLUGIN_ROOT} before shell execution. On Windows+Git Bash this produces an MSYS path like /d/Repos/... which Node.js cannot resolve. The one-liner converts /d/Repos/... to D:\\Repos\\... before requiring run-hook.js.",
6
+ "pattern": "node -e \"var r=process.env.CLAUDE_PLUGIN_ROOT||'',m=r.match(/^\\/([a-zA-Z])\\/(.*)/);if(m)r=m[1]+String.fromCharCode(58)+String.fromCharCode(92)+m[2];require(require('path').resolve(r,'scripts','run-hook.js'))\" {script}.js",
7
+ "run-hook": "scripts/run-hook.js handles MSYS path fix, argv normalization, and require() isolation"
8
+ },
4
9
  "hooks": {
5
10
  "SessionStart": [
6
11
  {
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/circuit-state.js — Persistent cross-process circuit breaker state.
5
+ *
6
+ * Stores circuit state in .planning/logs/local-llm-circuit.json so multiple
7
+ * hook processes share the same failure counts across process boundaries.
8
+ *
9
+ * Entries expire after STALE_TTL_MS (30 minutes) since the last failure.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { atomicWrite } = require('./core');
15
+
16
+ const STALE_TTL_MS = 30 * 60 * 1000; // 30 minutes
17
+ const STATE_FILENAME = 'local-llm-circuit.json';
18
+
19
+ /**
20
+ * Returns the path to the circuit state file.
21
+ * @param {string} planningDir
22
+ * @returns {string}
23
+ */
24
+ function _statePath(planningDir) {
25
+ return path.join(planningDir, 'logs', STATE_FILENAME);
26
+ }
27
+
28
+ /**
29
+ * Load circuit state from disk. Returns an empty object on any error.
30
+ * Prunes entries whose last_failure timestamp is older than STALE_TTL_MS.
31
+ *
32
+ * @param {string} planningDir - Path to .planning directory
33
+ * @returns {object} Map of operationType -> { failures, disabled, last_failure }
34
+ */
35
+ function loadCircuitState(planningDir) {
36
+ const statePath = _statePath(planningDir);
37
+ let raw = {};
38
+ try {
39
+ if (!fs.existsSync(statePath)) return {};
40
+ raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
41
+ } catch (_e) {
42
+ return {};
43
+ }
44
+
45
+ const now = Date.now();
46
+ const pruned = {};
47
+ for (const [opType, entry] of Object.entries(raw)) {
48
+ if (entry && typeof entry.last_failure === 'number') {
49
+ if (now - entry.last_failure < STALE_TTL_MS) {
50
+ pruned[opType] = entry;
51
+ }
52
+ // else: stale — drop it
53
+ }
54
+ }
55
+ return pruned;
56
+ }
57
+
58
+ /**
59
+ * Atomically write circuit state to disk.
60
+ * Uses a temp-file + rename pattern to avoid partial writes.
61
+ *
62
+ * @param {string} planningDir - Path to .planning directory
63
+ * @param {object} state - State object to write
64
+ */
65
+ function saveCircuitState(planningDir, state) {
66
+ const logsDir = path.join(planningDir, 'logs');
67
+ try {
68
+ if (!fs.existsSync(logsDir)) {
69
+ fs.mkdirSync(logsDir, { recursive: true });
70
+ }
71
+ const statePath = _statePath(planningDir);
72
+ atomicWrite(statePath, JSON.stringify(state, null, 2));
73
+ } catch (_e) {
74
+ // Best-effort — never crash the calling hook
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Returns true if the circuit is open (disabled) for the given operation type,
80
+ * based on persistent state.
81
+ *
82
+ * @param {string} planningDir
83
+ * @param {string} operationType
84
+ * @param {number} maxFailures
85
+ * @returns {boolean}
86
+ */
87
+ function isDisabledPersistent(planningDir, operationType, maxFailures) {
88
+ const state = loadCircuitState(planningDir);
89
+ const entry = state[operationType];
90
+ if (!entry) return false;
91
+ return entry.disabled === true || entry.failures >= maxFailures;
92
+ }
93
+
94
+ /**
95
+ * Records a failure for the given operation type in persistent state.
96
+ * Marks the circuit disabled when maxFailures is reached.
97
+ *
98
+ * @param {string} planningDir
99
+ * @param {string} operationType
100
+ * @param {number} maxFailures
101
+ */
102
+ function recordFailurePersistent(planningDir, operationType, maxFailures) {
103
+ const state = loadCircuitState(planningDir);
104
+ const entry = state[operationType] || { failures: 0, disabled: false, last_failure: 0 };
105
+ entry.failures += 1;
106
+ entry.last_failure = Date.now();
107
+ if (entry.failures >= maxFailures) {
108
+ entry.disabled = true;
109
+ }
110
+ state[operationType] = entry;
111
+ saveCircuitState(planningDir, state);
112
+ }
113
+
114
+ /**
115
+ * Resets the persistent circuit state for the given operation type.
116
+ *
117
+ * @param {string} planningDir
118
+ * @param {string} operationType
119
+ */
120
+ function resetCircuitPersistent(planningDir, operationType) {
121
+ const state = loadCircuitState(planningDir);
122
+ delete state[operationType];
123
+ saveCircuitState(planningDir, state);
124
+ }
125
+
126
+ module.exports = {
127
+ loadCircuitState,
128
+ saveCircuitState,
129
+ isDisabledPersistent,
130
+ recordFailurePersistent,
131
+ resetCircuitPersistent,
132
+ STALE_TTL_MS
133
+ };
@@ -92,6 +92,23 @@ function configValidate(preloadedConfig, planningDir) {
92
92
  warnings.push(`config.json schema is outdated. Run: node pbr-tools.js migrate`);
93
93
  }
94
94
 
95
+ // Local LLM endpoint must be localhost-only for security
96
+ if (config.local_llm && config.local_llm.enabled === true && config.local_llm.endpoint) {
97
+ try {
98
+ const parsed = new URL(config.local_llm.endpoint);
99
+ const hostname = parsed.hostname.toLowerCase();
100
+ const localhostNames = ['localhost', '127.0.0.1', '::1', '[::1]'];
101
+ if (!localhostNames.includes(hostname)) {
102
+ errors.push(
103
+ `local_llm.endpoint must be a localhost address (localhost, 127.0.0.1, or ::1). ` +
104
+ `Got: "${hostname}". Non-localhost endpoints are not supported for security reasons.`
105
+ );
106
+ }
107
+ } catch (_urlErr) {
108
+ errors.push(`local_llm.endpoint is not a valid URL: "${config.local_llm.endpoint}"`);
109
+ }
110
+ }
111
+
95
112
  // Semantic conflict detection — logical contradictions that pass schema validation
96
113
  // Clear contradictions -> errors; ambiguous/preference issues -> warnings
97
114
  if (config.mode === 'autonomous' && config.gates) {
@@ -395,9 +395,11 @@ function lockedFileUpdate(filePath, updateFn, opts = {}) {
395
395
  if (attempt < retries - 1) {
396
396
  // Wait and retry
397
397
  const waitMs = retryDelayMs * (attempt + 1);
398
- const start = Date.now();
399
- while (Date.now() - start < waitMs) {
400
- // Busy wait (synchronous context)
398
+ try {
399
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
400
+ } catch (_atomicsErr) {
401
+ const end = Date.now() + waitMs;
402
+ while (Date.now() < end) { /* last-resort fallback */ }
401
403
  }
402
404
  continue;
403
405
  }
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Advisory checks for validate-task.
5
+ * These return warning strings rather than blocking the tool call.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { readActiveSkill } = require('./helpers');
11
+
12
+ /**
13
+ * Advisory check: when pbr:debugger is spawned and .active-skill is 'debug',
14
+ * warn if .planning/debug/ directory does not exist.
15
+ * Returns a warning string or null.
16
+ * @param {object} data - hook data with tool_input
17
+ * @returns {string|null}
18
+ */
19
+ function checkDebuggerAdvisory(data) {
20
+ const subagentType = data.tool_input?.subagent_type || '';
21
+ if (subagentType !== 'pbr:debugger') return null;
22
+
23
+ // Only advise when spawned from the debug skill
24
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
25
+ const planningDir = path.join(cwd, '.planning');
26
+ const activeSkill = readActiveSkill(planningDir);
27
+ if (activeSkill !== 'debug') return null;
28
+
29
+ const debugDir = path.join(planningDir, 'debug');
30
+ if (!fs.existsSync(debugDir)) {
31
+ return 'Debugger advisory: .planning/debug/ does not exist. Create it before spawning the debugger so output has a target location.';
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Advisory check: when pbr:executor is spawned in build context,
38
+ * warn if .checkpoint-manifest.json is missing from the phase directory.
39
+ * Returns a warning string or null.
40
+ * @param {object} data - hook data with tool_input
41
+ * @returns {string|null}
42
+ */
43
+ function checkCheckpointManifest(data) {
44
+ const toolInput = data.tool_input || {};
45
+ const subagentType = toolInput.subagent_type || '';
46
+
47
+ if (subagentType !== 'pbr:executor') return null;
48
+
49
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
50
+ const planningDir = path.join(cwd, '.planning');
51
+
52
+ const activeSkill = readActiveSkill(planningDir);
53
+ if (activeSkill !== 'build') return null;
54
+
55
+ // Find current phase dir
56
+ const stateFile = path.join(planningDir, 'STATE.md');
57
+ try {
58
+ const state = fs.readFileSync(stateFile, 'utf8');
59
+ const phaseMatch = state.match(/Phase:\s*(\d+)\s+of\s+\d+/);
60
+ if (!phaseMatch) return null;
61
+
62
+ const currentPhase = phaseMatch[1].padStart(2, '0');
63
+ const phasesDir = path.join(planningDir, 'phases');
64
+ if (!fs.existsSync(phasesDir)) return null;
65
+
66
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(currentPhase + '-'));
67
+ if (dirs.length === 0) return null;
68
+
69
+ const phaseDir = path.join(phasesDir, dirs[0]);
70
+ const manifestFile = path.join(phaseDir, '.checkpoint-manifest.json');
71
+ if (!fs.existsSync(manifestFile)) {
72
+ return 'Build advisory: .checkpoint-manifest.json not found in phase directory. The build skill should write this before spawning executors. To fix: Run /pbr:health to regenerate checkpoint manifest.';
73
+ }
74
+ } catch (_e) {
75
+ return null;
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Advisory check: when any pbr:* agent is being spawned, warn if
83
+ * .planning/.active-skill doesn't exist. Without this file, all
84
+ * skill-specific enforcement is silently disabled.
85
+ * Returns a warning string or null.
86
+ * @param {object} data - hook data with tool_input
87
+ * @returns {string|null}
88
+ */
89
+ function checkActiveSkillIntegrity(data) {
90
+ const toolInput = data.tool_input || {};
91
+ const subagentType = toolInput.subagent_type || '';
92
+
93
+ if (typeof subagentType !== 'string' || !subagentType.startsWith('pbr:')) return null;
94
+
95
+ // Advisory agents that run without an active skill context — exempt from .active-skill checks
96
+ const EXEMPT_AGENTS = ['pbr:researcher', 'pbr:synthesizer', 'pbr:audit', 'pbr:dev-sync', 'pbr:general'];
97
+ if (EXEMPT_AGENTS.includes(subagentType)) return null;
98
+
99
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
100
+ const planningDir = path.join(cwd, '.planning');
101
+
102
+ // Only check if .planning/ exists (PBR project)
103
+ if (!fs.existsSync(planningDir)) return null;
104
+
105
+ const activeSkillFile = path.join(planningDir, '.active-skill');
106
+ if (!fs.existsSync(activeSkillFile)) {
107
+ return 'Active-skill integrity: .planning/.active-skill not found. Skill-specific enforcement is disabled. The invoking skill should write this file. To fix: Wait for the current skill to finish, or delete .planning/.active-skill if stale.';
108
+ }
109
+
110
+ // Stale lock detection: warn if .active-skill is older than 2 hours
111
+ try {
112
+ const stat = fs.statSync(activeSkillFile);
113
+ const ageMs = Date.now() - stat.mtimeMs;
114
+ const TWO_HOURS = 2 * 60 * 60 * 1000;
115
+ if (ageMs > TWO_HOURS) {
116
+ const ageHours = Math.round(ageMs / (60 * 60 * 1000));
117
+ const skill = fs.readFileSync(activeSkillFile, 'utf8').trim();
118
+ return `Active-skill integrity: .planning/.active-skill is ${ageHours}h old (skill: "${skill}"). This may be a stale lock from a crashed session. Run /pbr:health to diagnose, or delete .planning/.active-skill if the previous session is no longer running.`;
119
+ }
120
+ } catch (_e) { /* best-effort */ }
121
+
122
+ return null;
123
+ }
124
+
125
+ module.exports = { checkDebuggerAdvisory, checkCheckpointManifest, checkActiveSkillIntegrity };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: build dependency verification check.
5
+ * When active skill is "build" and pbr:executor is spawned, verify
6
+ * that dependent phases (from ROADMAP.md) have VERIFICATION.md.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { readActiveSkill } = require('./helpers');
12
+
13
+ /**
14
+ * Blocking check: when the active skill is "build" and an executor is being
15
+ * spawned, verify that dependent phases (from ROADMAP.md) have VERIFICATION.md.
16
+ * Returns { block: true, reason: "..." } if blocked, or null if OK.
17
+ * @param {object} data - hook data with tool_input
18
+ * @returns {{ block: boolean, reason: string }|null}
19
+ */
20
+ function checkBuildDependencyGate(data) {
21
+ const toolInput = data.tool_input || {};
22
+ const subagentType = toolInput.subagent_type || '';
23
+
24
+ // Only gate pbr:executor
25
+ if (subagentType !== 'pbr:executor') return null;
26
+
27
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
28
+ const planningDir = path.join(cwd, '.planning');
29
+
30
+ // Only gate when active skill is "build"
31
+ const activeSkill = readActiveSkill(planningDir);
32
+ if (activeSkill !== 'build') return null;
33
+
34
+ // Read STATE.md for current phase (as integer for roadmap matching)
35
+ let currentPhase;
36
+ try {
37
+ const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8');
38
+ const phaseMatch = state.match(/Phase:\s*(\d+)/);
39
+ if (!phaseMatch) return null;
40
+ currentPhase = parseInt(phaseMatch[1], 10);
41
+ } catch (_e) {
42
+ return null;
43
+ }
44
+
45
+ // Read ROADMAP.md, find current phase section, check dependencies
46
+ const roadmapFile = path.join(planningDir, 'ROADMAP.md');
47
+ try {
48
+ const roadmap = fs.readFileSync(roadmapFile, 'utf8');
49
+
50
+ // Find ### Phase N: section
51
+ const phaseRegex = new RegExp(`### Phase ${currentPhase}:[\\s\\S]*?(?=### Phase \\d|$)`);
52
+ const phaseSection = roadmap.match(phaseRegex);
53
+ if (!phaseSection) return null;
54
+
55
+ // Look for **Depends on:** line
56
+ const depMatch = phaseSection[0].match(/\*\*Depends on:\*\*\s*(.*)/);
57
+ if (!depMatch) return null;
58
+
59
+ const depLine = depMatch[1].trim();
60
+ if (!depLine || /^none$/i.test(depLine)) return null;
61
+
62
+ // Parse phase numbers from "Phase 1", "Phase 1, Phase 2", etc.
63
+ const depPhases = [];
64
+ const depRegex = /Phase\s+(\d+)/gi;
65
+ let match;
66
+ while ((match = depRegex.exec(depLine)) !== null) {
67
+ depPhases.push(parseInt(match[1], 10));
68
+ }
69
+
70
+ if (depPhases.length === 0) return null;
71
+
72
+ // Check each dependent phase has VERIFICATION.md
73
+ const phasesDir = path.join(planningDir, 'phases');
74
+ if (!fs.existsSync(phasesDir)) return null;
75
+
76
+ for (const depPhase of depPhases) {
77
+ const paddedPhase = String(depPhase).padStart(2, '0');
78
+ const pDirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(paddedPhase + '-'));
79
+ if (pDirs.length === 0) {
80
+ return {
81
+ block: true,
82
+ reason: `Build dependency gate: dependent phase ${paddedPhase} lacks VERIFICATION.md.\n\nPhase ${currentPhase} depends on phase ${paddedPhase}, which must be verified before building can proceed.\n\nRun /pbr:review ${paddedPhase} to verify the dependency phase first.`
83
+ };
84
+ }
85
+ const hasVerification = fs.existsSync(path.join(phasesDir, pDirs[0], 'VERIFICATION.md'));
86
+ if (!hasVerification) {
87
+ return {
88
+ block: true,
89
+ reason: `Build dependency gate: dependent phase ${paddedPhase} lacks VERIFICATION.md.\n\nPhase ${currentPhase} depends on phase ${paddedPhase}, which must be verified before building can proceed.\n\nRun /pbr:review ${paddedPhase} to verify the dependency phase first.`
90
+ };
91
+ }
92
+ }
93
+ } catch (_e) {
94
+ return null;
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ module.exports = { checkBuildDependencyGate };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: build executor PLAN.md check.
5
+ * When active skill is "build" and pbr:executor is spawned, verify
6
+ * that a PLAN*.md exists in the current phase directory.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { readActiveSkill, readCurrentPhase } = require('./helpers');
12
+
13
+ /**
14
+ * Blocking check: when the active skill is "build" and an executor is being
15
+ * spawned, verify that a PLAN*.md exists in the current phase directory.
16
+ * Returns { block: true, reason: "..." } if blocked, or null if OK.
17
+ * @param {object} data - hook data with tool_input
18
+ * @returns {{ block: boolean, reason: string }|null}
19
+ */
20
+ function checkBuildExecutorGate(data) {
21
+ const toolInput = data.tool_input || {};
22
+ const subagentType = toolInput.subagent_type || '';
23
+
24
+ // Only gate pbr:executor
25
+ if (subagentType !== 'pbr:executor') return null;
26
+
27
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
28
+ const planningDir = path.join(cwd, '.planning');
29
+
30
+ // Only gate when active skill is "build"
31
+ const activeSkill = readActiveSkill(planningDir);
32
+ if (activeSkill !== 'build') return null;
33
+
34
+ // Read STATE.md for current phase
35
+ const currentPhase = readCurrentPhase(planningDir);
36
+ if (!currentPhase) return null;
37
+
38
+ try {
39
+ const phasesDir = path.join(planningDir, 'phases');
40
+ if (!fs.existsSync(phasesDir)) {
41
+ return {
42
+ block: true,
43
+ reason: 'Cannot spawn executor: .planning/phases/ directory does not exist.\n\nThe build skill requires a phases directory with at least one PLAN.md before an executor can run.\n\nRun /pbr:plan {N} to create plans first.'
44
+ };
45
+ }
46
+
47
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(currentPhase + '-'));
48
+ if (dirs.length === 0) {
49
+ return {
50
+ block: true,
51
+ reason: `Cannot spawn executor: no phase directory found for phase ${currentPhase}.\n\nThe build skill needs a phase directory (e.g., .planning/phases/${currentPhase}-slug/) containing PLAN.md files.\n\nRun /pbr:plan ${currentPhase} to create plans first.`
52
+ };
53
+ }
54
+
55
+ const phaseDir = path.join(phasesDir, dirs[0]);
56
+ const files = fs.readdirSync(phaseDir);
57
+ const hasPlan = files.some(f => {
58
+ if (!/^PLAN.*\.md$/i.test(f)) return false;
59
+ try {
60
+ return fs.statSync(path.join(phaseDir, f)).size > 0;
61
+ } catch (_e) {
62
+ return false;
63
+ }
64
+ });
65
+
66
+ if (!hasPlan) {
67
+ return {
68
+ block: true,
69
+ reason: `Cannot spawn executor: no PLAN.md found in .planning/phases/${dirs[0]}/.\n\nThe phase directory exists but contains no PLAN.md files. The executor needs at least one non-empty PLAN.md to work from.\n\nRun /pbr:plan ${currentPhase} to create plans first.`
70
+ };
71
+ }
72
+ } catch (_e) {
73
+ return null;
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ module.exports = { checkBuildExecutorGate };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared helpers for gate modules.
5
+ * Centralizes repeated .active-skill reads and STATE.md phase parsing.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Read the current .active-skill value.
13
+ * Returns the trimmed string, or null if the file doesn't exist or can't be read.
14
+ * @param {string} planningDir - path to .planning directory
15
+ * @returns {string|null}
16
+ */
17
+ function readActiveSkill(planningDir) {
18
+ try {
19
+ return fs.readFileSync(path.join(planningDir, '.active-skill'), 'utf8').trim();
20
+ } catch (_e) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Parse STATE.md for the current phase string (zero-padded, e.g. "01").
27
+ * Returns the padded phase string, or null if not found.
28
+ * @param {string} planningDir - path to .planning directory
29
+ * @returns {string|null}
30
+ */
31
+ function readCurrentPhase(planningDir) {
32
+ try {
33
+ const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8');
34
+ const phaseMatch = state.match(/Phase:\s*(\d+)/);
35
+ if (!phaseMatch) return null;
36
+ return phaseMatch[1].padStart(2, '0');
37
+ } catch (_e) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Parse STATE.md for the current phase as an integer.
44
+ * Checks frontmatter current_phase first, then falls back to body Phase: line.
45
+ * Returns the integer, or null if not found.
46
+ * @param {string} planningDir - path to .planning directory
47
+ * @returns {number|null}
48
+ */
49
+ function readCurrentPhaseInt(planningDir) {
50
+ try {
51
+ const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8');
52
+ const fmMatch = state.match(/current_phase:\s*(\d+)/);
53
+ if (fmMatch) return parseInt(fmMatch[1], 10);
54
+ const bodyMatch = state.match(/Phase:\s*(\d+)/);
55
+ if (bodyMatch) return parseInt(bodyMatch[1], 10);
56
+ return null;
57
+ } catch (_e) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ module.exports = { readActiveSkill, readCurrentPhase, readCurrentPhaseInt };