@sienklogic/plan-build-run 2.41.0 → 2.42.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.
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: milestone complete verification check.
5
+ * When active skill is "milestone" and a general/planner agent is spawned
6
+ * for a "complete" operation, verify all milestone phases have VERIFICATION.md.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { readActiveSkill, readCurrentPhaseInt } = require('./helpers');
12
+
13
+ /**
14
+ * Parse VERIFICATION.md frontmatter to extract status field.
15
+ * Returns the status string or 'unknown' if not parseable.
16
+ * @param {string} filePath - path to VERIFICATION.md
17
+ * @returns {string}
18
+ */
19
+ function getVerificationStatus(filePath) {
20
+ try {
21
+ const content = fs.readFileSync(filePath, 'utf8');
22
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
23
+ if (!fmMatch) return 'unknown';
24
+ const statusMatch = fmMatch[1].match(/^status:\s*(\S+)/m);
25
+ return statusMatch ? statusMatch[1] : 'unknown';
26
+ } catch (_e) {
27
+ return 'unknown';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Blocking check: when the active skill is "milestone" and a general/planner agent
33
+ * is being spawned for a "complete" operation, verify all milestone phases have VERIFICATION.md.
34
+ * Returns { block: true, reason: "..." } if blocked, or null if OK.
35
+ * @param {object} data - hook data with tool_input
36
+ * @returns {{ block: boolean, reason: string }|null}
37
+ */
38
+ function checkMilestoneCompleteGate(data) {
39
+ const toolInput = data.tool_input || {};
40
+ const subagentType = toolInput.subagent_type || '';
41
+ const description = toolInput.description || '';
42
+
43
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
44
+ const planningDir = path.join(cwd, '.planning');
45
+
46
+ // Only gate when active skill is "milestone"
47
+ const activeSkill = readActiveSkill(planningDir);
48
+ if (activeSkill !== 'milestone') return null;
49
+
50
+ // Only gate pbr:general and pbr:planner
51
+ if (subagentType !== 'pbr:general' && subagentType !== 'pbr:planner') return null;
52
+
53
+ // Only gate "complete" operations
54
+ if (!/complete/i.test(description)) return null;
55
+
56
+ // Read STATE.md for current phase
57
+ const currentPhase = readCurrentPhaseInt(planningDir);
58
+ if (!currentPhase) return null;
59
+
60
+ // Read ROADMAP.md and find the milestone containing the current phase
61
+ const roadmapFile = path.join(planningDir, 'ROADMAP.md');
62
+ try {
63
+ const roadmap = fs.readFileSync(roadmapFile, 'utf8');
64
+
65
+ // Split into milestone sections
66
+ const milestoneSections = roadmap.split(/^## Milestone:/m).slice(1);
67
+
68
+ for (const section of milestoneSections) {
69
+ // Parse phase numbers from table rows
70
+ const phaseNumbers = [];
71
+ const tableRowRegex = /^\|\s*(\d+)\s*\|/gm;
72
+ let match;
73
+ while ((match = tableRowRegex.exec(section)) !== null) {
74
+ phaseNumbers.push(parseInt(match[1], 10));
75
+ }
76
+
77
+ // Check if current phase is in this milestone
78
+ if (!phaseNumbers.includes(currentPhase)) continue;
79
+
80
+ // Found the right milestone — check all phases have VERIFICATION.md
81
+ const phasesDir = path.join(planningDir, 'phases');
82
+ if (!fs.existsSync(phasesDir)) return null;
83
+
84
+ for (const phaseNum of phaseNumbers) {
85
+ const paddedPhase = String(phaseNum).padStart(2, '0');
86
+ const pDirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(paddedPhase + '-'));
87
+ if (pDirs.length === 0) {
88
+ return {
89
+ block: true,
90
+ reason: `Milestone complete gate: phase ${paddedPhase} directory not found.\n\nAll milestone phases must exist and have a passing VERIFICATION.md before the milestone can be completed.\n\nRun /pbr:review ${paddedPhase} to verify the phase (it must reach status: passed).`
91
+ };
92
+ }
93
+ const verificationFile = path.join(phasesDir, pDirs[0], 'VERIFICATION.md');
94
+ const hasVerification = fs.existsSync(verificationFile);
95
+ if (!hasVerification) {
96
+ return {
97
+ block: true,
98
+ reason: `Milestone complete gate: phase ${paddedPhase} (${pDirs[0]}) lacks VERIFICATION.md.\n\nAll milestone phases must have a passing VERIFICATION.md before the milestone can be completed.\n\nRun /pbr:review ${paddedPhase} to verify the phase (it must reach status: passed).`
99
+ };
100
+ }
101
+ const verStatus = getVerificationStatus(verificationFile);
102
+ if (verStatus === 'gaps_found') {
103
+ return {
104
+ block: true,
105
+ reason: `Milestone complete gate: phase ${paddedPhase} VERIFICATION.md has status: gaps_found.\n\nAll gaps must be closed before the milestone can be completed. The verifier found issues that need resolution.\n\nRun /pbr:review ${paddedPhase} to close gaps (phase must reach status: passed).`
106
+ };
107
+ }
108
+ }
109
+
110
+ // All phases verified
111
+ return null;
112
+ }
113
+ } catch (_e) {
114
+ return null;
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ module.exports = { checkMilestoneCompleteGate, getVerificationStatus };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: plan skill executor block.
5
+ * The plan skill should never spawn executors.
6
+ */
7
+
8
+ const path = require('path');
9
+ const { readActiveSkill } = require('./helpers');
10
+
11
+ /**
12
+ * Blocking check: when the active skill is "plan", block executor spawning.
13
+ * Returns { block: true, reason: "..." } if blocked, or null if OK.
14
+ * @param {object} data - hook data with tool_input
15
+ * @returns {{ block: boolean, reason: string }|null}
16
+ */
17
+ function checkPlanExecutorGate(data) {
18
+ const toolInput = data.tool_input || {};
19
+ const subagentType = toolInput.subagent_type || '';
20
+
21
+ // Only gate pbr:executor
22
+ if (subagentType !== 'pbr:executor') return null;
23
+
24
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
25
+ const planningDir = path.join(cwd, '.planning');
26
+
27
+ const activeSkill = readActiveSkill(planningDir);
28
+ if (activeSkill !== 'plan') return null;
29
+
30
+ return {
31
+ block: true,
32
+ reason: 'Plan skill cannot spawn executors.\n\nThe plan skill creates plans; the build skill executes them. Spawning an executor from the plan skill violates the separation of concerns.\n\nRun /pbr:build to execute plans instead.'
33
+ };
34
+ }
35
+
36
+ module.exports = { checkPlanExecutorGate };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: quick executor PLAN.md check.
5
+ * When active skill is "quick" and pbr:executor is spawned, verify
6
+ * that at least one .planning/quick/{NNN}-{slug}/PLAN.md exists.
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 "quick" and an executor is being
15
+ * spawned, verify that at least one .planning/quick/{NNN}-{slug}/PLAN.md exists.
16
+ * Returns { block: true, reason: "..." } if the executor should be blocked,
17
+ * or null if it's OK to proceed.
18
+ * @param {object} data - hook data with tool_input
19
+ * @returns {{ block: boolean, reason: string }|null}
20
+ */
21
+ function checkQuickExecutorGate(data) {
22
+ const toolInput = data.tool_input || {};
23
+ const subagentType = toolInput.subagent_type || '';
24
+
25
+ // Only gate pbr:executor
26
+ if (subagentType !== 'pbr:executor') return null;
27
+
28
+ const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
29
+ const planningDir = path.join(cwd, '.planning');
30
+
31
+ // Only gate when active skill is "quick"
32
+ const activeSkill = readActiveSkill(planningDir);
33
+ if (activeSkill !== 'quick') return null;
34
+
35
+ // Check for any PLAN.md in .planning/quick/*/
36
+ const quickDir = path.join(planningDir, 'quick');
37
+ if (!fs.existsSync(quickDir)) {
38
+ return {
39
+ block: true,
40
+ reason: 'Cannot spawn executor: .planning/quick/ directory does not exist.\n\nThe quick skill must create the task directory and PLAN.md before an executor can run (Steps 4-6).\n\nRe-run /pbr:quick to create the quick task directory and PLAN.md.'
41
+ };
42
+ }
43
+
44
+ try {
45
+ const dirs = fs.readdirSync(quickDir).filter(d => {
46
+ return /^\d{3}-/.test(d) && fs.statSync(path.join(quickDir, d)).isDirectory();
47
+ });
48
+
49
+ // Look for the most recent quick task dir that has a PLAN.md
50
+ const hasPlan = dirs.some(d => {
51
+ const planFile = path.join(quickDir, d, 'PLAN.md');
52
+ try {
53
+ const stat = fs.statSync(planFile);
54
+ return stat.size > 0;
55
+ } catch (_e) {
56
+ return false;
57
+ }
58
+ });
59
+
60
+ if (!hasPlan) {
61
+ return {
62
+ block: true,
63
+ reason: 'Cannot spawn executor: no PLAN.md found in any .planning/quick/*/ directory.\n\nThe quick skill must write a non-empty PLAN.md inside .planning/quick/{NNN}-{slug}/ before an executor can run (Steps 4-6).\n\nRe-run /pbr:quick to create the quick task directory and PLAN.md.'
64
+ };
65
+ }
66
+ } catch (_e) {
67
+ return {
68
+ block: true,
69
+ reason: 'Cannot spawn executor: failed to read .planning/quick/ directory.\n\nThe directory exists but could not be read, possibly due to a permissions issue or filesystem error.\n\nRe-run /pbr:quick to recreate the quick task directory and PLAN.md.'
70
+ };
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ module.exports = { checkQuickExecutorGate };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: review planner VERIFICATION.md check.
5
+ * When active skill is "review" and pbr:planner is spawned, verify
6
+ * that a VERIFICATION.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 "review" and a planner is being
15
+ * spawned, verify that a VERIFICATION.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 checkReviewPlannerGate(data) {
21
+ const toolInput = data.tool_input || {};
22
+ const subagentType = toolInput.subagent_type || '';
23
+
24
+ // Only gate pbr:planner
25
+ if (subagentType !== 'pbr:planner') 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 "review"
31
+ const activeSkill = readActiveSkill(planningDir);
32
+ if (activeSkill !== 'review') 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)) return null;
41
+
42
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(currentPhase + '-'));
43
+ if (dirs.length === 0) return null;
44
+
45
+ const phaseDir = path.join(phasesDir, dirs[0]);
46
+ const hasVerification = fs.existsSync(path.join(phaseDir, 'VERIFICATION.md'));
47
+
48
+ if (!hasVerification) {
49
+ return {
50
+ block: true,
51
+ reason: 'Review planner gate: cannot spawn planner without VERIFICATION.md.\n\nGap closure requires an existing VERIFICATION.md to identify which gaps need closing. Without it, the planner has no input.\n\nRun /pbr:review {N} to create VERIFICATION.md first.'
52
+ };
53
+ }
54
+ } catch (_e) {
55
+ return null;
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ module.exports = { checkReviewPlannerGate };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gate: review verifier SUMMARY.md check.
5
+ * When active skill is "review" and pbr:verifier is spawned, verify
6
+ * that a SUMMARY*.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 "review" and a verifier is being
15
+ * spawned, verify that a SUMMARY*.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 checkReviewVerifierGate(data) {
21
+ const toolInput = data.tool_input || {};
22
+ const subagentType = toolInput.subagent_type || '';
23
+
24
+ // Only gate pbr:verifier
25
+ if (subagentType !== 'pbr:verifier') 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 "review"
31
+ const activeSkill = readActiveSkill(planningDir);
32
+ if (activeSkill !== 'review') 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)) return null;
41
+
42
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(currentPhase + '-'));
43
+ if (dirs.length === 0) return null;
44
+
45
+ const phaseDir = path.join(phasesDir, dirs[0]);
46
+ const files = fs.readdirSync(phaseDir);
47
+ const hasSummary = files.some(f => {
48
+ if (!/^SUMMARY/i.test(f)) return false;
49
+ try {
50
+ return fs.statSync(path.join(phaseDir, f)).size > 0;
51
+ } catch (_e) {
52
+ return false;
53
+ }
54
+ });
55
+
56
+ if (!hasSummary) {
57
+ return {
58
+ block: true,
59
+ reason: 'Review verifier gate: cannot spawn verifier without SUMMARY.md.\n\nThe verifier checks executor output against the plan. Without a SUMMARY.md, there is nothing to verify.\n\nRun /pbr:build {N} to create SUMMARY.md first.'
60
+ };
61
+ }
62
+ } catch (_e) {
63
+ return null;
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ module.exports = { checkReviewVerifierGate };
@@ -1,6 +1,12 @@
1
1
  /* global fetch, AbortSignal, performance */
2
2
  'use strict';
3
3
 
4
+ const {
5
+ isDisabledPersistent,
6
+ recordFailurePersistent,
7
+ resetCircuitPersistent
8
+ } = require('../lib/circuit-state');
9
+
4
10
  // Circuit breaker: Map<operationType, { failures: number, disabled: boolean }>
5
11
  const circuitState = new Map();
6
12
 
@@ -109,6 +115,7 @@ function resetCircuit(operationType) {
109
115
  * @param {string} operationType - operation identifier for circuit breaker tracking
110
116
  * @param {object} [options={}] - optional parameters
111
117
  * @param {boolean} [options.logprobs] - if true, request logprobs from the API
118
+ * @param {string} [options.planningDir] - optional .planning directory for persistent circuit breaker state
112
119
  * @returns {Promise<{ content: string, latency_ms: number, tokens: number, logprobsData: Array<{token: string, logprob: number}>|null }>}
113
120
  */
114
121
  async function complete(config, prompt, operationType, options = {}) {
@@ -119,12 +126,19 @@ async function complete(config, prompt, operationType, options = {}) {
119
126
  const numCtx = (config.advanced && config.advanced.num_ctx) || 4096;
120
127
  const keepAlive = (config.advanced && config.advanced.keep_alive) || '30m';
121
128
  const maxFailures = (config.advanced && config.advanced.disable_after_failures) || 3;
129
+ const planningDir = options.planningDir || null;
122
130
 
131
+ // Check in-memory circuit first (fast path), then persistent state
123
132
  if (isDisabled(operationType, maxFailures)) {
124
133
  const err = new Error('Circuit open for operation: ' + operationType);
125
134
  err.type = 'circuit_open';
126
135
  throw err;
127
136
  }
137
+ if (planningDir && isDisabledPersistent(planningDir, operationType, maxFailures)) {
138
+ const err = new Error('Circuit open for operation: ' + operationType);
139
+ err.type = 'circuit_open';
140
+ throw err;
141
+ }
128
142
 
129
143
  const bodyObj = {
130
144
  model,
@@ -185,6 +199,7 @@ async function complete(config, prompt, operationType, options = {}) {
185
199
  if (isConnRefused) {
186
200
  // Server not running — no point retrying
187
201
  recordFailure(operationType, maxFailures);
202
+ if (planningDir) recordFailurePersistent(planningDir, operationType, maxFailures);
188
203
  throw err;
189
204
  }
190
205
 
@@ -197,18 +212,26 @@ async function complete(config, prompt, operationType, options = {}) {
197
212
  }
198
213
 
199
214
  // Final attempt or non-retryable error
200
- if (attempt === totalAttempts - 1) {
201
- recordFailure(operationType, maxFailures);
202
- } else {
203
- recordFailure(operationType, maxFailures);
204
- }
215
+ recordFailure(operationType, maxFailures);
216
+ if (planningDir) recordFailurePersistent(planningDir, operationType, maxFailures);
205
217
  throw err;
206
218
  }
207
219
  }
208
220
 
209
221
  // Should not reach here, but guard anyway
210
222
  recordFailure(operationType, maxFailures);
223
+ if (planningDir) recordFailurePersistent(planningDir, operationType, maxFailures);
211
224
  throw lastErr;
212
225
  }
213
226
 
214
- module.exports = { tryParseJSON, categorizeError, isDisabled, recordFailure, resetCircuit, complete };
227
+ module.exports = {
228
+ tryParseJSON,
229
+ categorizeError,
230
+ isDisabled,
231
+ recordFailure,
232
+ resetCircuit,
233
+ complete,
234
+ isDisabledPersistent,
235
+ recordFailurePersistent,
236
+ resetCircuitPersistent
237
+ };
@@ -60,7 +60,10 @@ if (invokedViaEval) {
60
60
 
61
61
  // When required as a module from -e bootstrap, export a runner function
62
62
  if (typeof module !== 'undefined' && module.exports) {
63
+ const BOOTSTRAP_SNIPPET = "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'))\"";
63
64
  module.exports = runScript;
65
+ module.exports.BOOTSTRAP_SNIPPET = BOOTSTRAP_SNIPPET;
66
+ module.exports.runScript = runScript;
64
67
  }
65
68
 
66
69
  // If we have a script name, run it immediately