@sienklogic/plan-build-run 2.40.1 → 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.
Files changed (30) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/plugins/copilot-pbr/agents/dev-sync.agent.md +8 -0
  4. package/plugins/copilot-pbr/plugin.json +1 -1
  5. package/plugins/copilot-pbr/references/plan-authoring.md +37 -0
  6. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  7. package/plugins/cursor-pbr/agents/dev-sync.md +8 -0
  8. package/plugins/cursor-pbr/references/plan-authoring.md +37 -0
  9. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  10. package/plugins/pbr/agents/dev-sync.md +8 -0
  11. package/plugins/pbr/hooks/hooks.json +5 -0
  12. package/plugins/pbr/references/plan-authoring.md +37 -0
  13. package/plugins/pbr/scripts/check-plan-format.js +2 -2
  14. package/plugins/pbr/scripts/lib/circuit-state.js +134 -0
  15. package/plugins/pbr/scripts/lib/config.js +17 -0
  16. package/plugins/pbr/scripts/lib/core.js +5 -3
  17. package/plugins/pbr/scripts/lib/gates/advisories.js +125 -0
  18. package/plugins/pbr/scripts/lib/gates/build-dependency.js +100 -0
  19. package/plugins/pbr/scripts/lib/gates/build-executor.js +79 -0
  20. package/plugins/pbr/scripts/lib/gates/helpers.js +62 -0
  21. package/plugins/pbr/scripts/lib/gates/milestone-complete.js +120 -0
  22. package/plugins/pbr/scripts/lib/gates/plan-executor.js +36 -0
  23. package/plugins/pbr/scripts/lib/gates/quick-executor.js +76 -0
  24. package/plugins/pbr/scripts/lib/gates/review-planner.js +61 -0
  25. package/plugins/pbr/scripts/lib/gates/review-verifier.js +69 -0
  26. package/plugins/pbr/scripts/lib/state.js +14 -2
  27. package/plugins/pbr/scripts/local-llm/client.js +29 -6
  28. package/plugins/pbr/scripts/pbr-tools.js +1 -1
  29. package/plugins/pbr/scripts/run-hook.js +3 -0
  30. package/plugins/pbr/scripts/validate-task.js +10 -605
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ 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.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.41.0...plan-build-run-v2.42.0) (2026-02-28)
9
+
10
+
11
+ ### Features
12
+
13
+ * **48-02:** add localhost-only validation for local_llm.endpoint in configValidate ([511e038](https://github.com/SienkLogic/plan-build-run/commit/511e038bc5821518d261ac49d3098ac8281970cd))
14
+ * **48-02:** add persistent cross-process circuit breaker in lib/circuit-state.js ([85ee2fd](https://github.com/SienkLogic/plan-build-run/commit/85ee2fdb07ee9357c467d80f2ec02b6af2e0378e))
15
+ * **48-02:** replace busy-wait loop with Atomics.wait in lockedFileUpdate ([0099aa2](https://github.com/SienkLogic/plan-build-run/commit/0099aa2b172b7705bba2c834be8601d225667b10))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **48-03:** remove unused imports causing ESLint failures in CI ([f459004](https://github.com/SienkLogic/plan-build-run/commit/f4590048e4847e802ace067b47d10d9c865d0f21))
21
+ * **48-03:** remove unused variable in run-hook.test.js to pass ESLint ([2b553b1](https://github.com/SienkLogic/plan-build-run/commit/2b553b1df752231f9e78246fe6f4d6326f872f87))
22
+
23
+
24
+ ### Documentation
25
+
26
+ * **48-03:** add bootstrap documentation and drift-detection test for hooks.json ([d29c015](https://github.com/SienkLogic/plan-build-run/commit/d29c01524bdbc2b01741f4c634b8e3855a3fa0b0))
27
+
28
+ ## [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)
29
+
30
+
31
+ ### Features
32
+
33
+ * **47-01:** expand stateUpdate to cover all 9 STATE.md frontmatter fields ([de6a6b3](https://github.com/SienkLogic/plan-build-run/commit/de6a6b3b3f577c436396927a4b5511b5163a527c))
34
+ * **47-02:** add [@file](https://github.com/file): escape hatch tests and documentation ([6634f50](https://github.com/SienkLogic/plan-build-run/commit/6634f504c7e64f25caaecb0b06561c3a03b88d35))
35
+
36
+
37
+ ### Bug Fixes
38
+
39
+ * **47-01:** update stale state update usage hint to list all 9 fields ([e0d0e39](https://github.com/SienkLogic/plan-build-run/commit/e0d0e39fa11c8a210b7f44f1e311f80ce761b6c6))
40
+
41
+
42
+ ### Documentation
43
+
44
+ * **46-02:** add agent contract compliance audit for phase 46 ([78bca43](https://github.com/SienkLogic/plan-build-run/commit/78bca43bb1c37375c9b7bd3704d874d957f61b26))
45
+
8
46
  ## [2.40.1](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.40.0...plan-build-run-v2.40.1) (2026-02-27)
9
47
 
10
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.40.1",
3
+ "version": "2.42.0",
4
4
  "description": "Plan it, Build it, Run it — structured development workflow for Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -6,6 +6,14 @@ infer: true
6
6
  target: "github-copilot"
7
7
  ---
8
8
 
9
+ <files_to_read>
10
+ CRITICAL: If your spawn prompt contains a files_to_read block,
11
+ you MUST Read every listed file BEFORE any other action.
12
+ Skipping this causes hallucinated context and broken output.
13
+ </files_to_read>
14
+
15
+ > Default files: the changed pbr/ file path(s) provided in the spawn prompt
16
+
9
17
  # Cross-Plugin Sync Agent
10
18
 
11
19
  You are **dev-sync**, a specialized agent for the Plan-Build-Run project. Your sole job is to take changes made in `plugins/pbr/` and apply the equivalent changes to `plugins/cursor-pbr/` and `plugins/copilot-pbr/`, adjusting for each derivative's format requirements.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.40.1",
4
+ "version": "2.42.0",
5
5
  "description": "Plan-Build-Run — Structured development workflow for GitHub Copilot CLI. Solves context rot through disciplined agent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -201,6 +201,43 @@ Two plans CONFLICT if their `files_modified` lists overlap. Conflicting plans:
201
201
 
202
202
  ---
203
203
 
204
+ ## @file: Output Pattern (Large Payloads)
205
+
206
+ When a `pbr-tools` CLI command produces output exceeding 50,000 characters, the tool writes
207
+ the JSON payload to a temporary file instead of emitting it inline. It then prints a single
208
+ line of the form:
209
+
210
+ ```
211
+ @file:/tmp/pbr-1234567890.json
212
+ ```
213
+
214
+ This prevents stdout overflow in environments with limited buffer sizes (hooks, Task() runners).
215
+
216
+ ### Verify Step Pattern
217
+
218
+ If a plan's `<verify>` step calls `pbr-tools` and inspects the output, guard against the
219
+ `@file:` case:
220
+
221
+ ```bash
222
+ OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js state load)
223
+ if echo "$OUT" | grep -q '^@file:'; then
224
+ OUT=$(cat "${OUT#@file:}")
225
+ fi
226
+ echo "$OUT" | grep '"status"'
227
+ ```
228
+
229
+ ### Agent Prompt Handling
230
+
231
+ When an agent receives `@file:` output from a spawned tool call, it must read the referenced
232
+ file to obtain the actual JSON payload. The `@file:` prefix is a signal — not a path fragment
233
+ to be appended to another command.
234
+
235
+ Plan actions that spawn `pbr-tools` subcommands should instruct the agent:
236
+
237
+ > If the output starts with `@file:`, read the file at that path to get the full JSON response.
238
+
239
+ ---
240
+
204
241
  ## Context Fidelity Checklist
205
242
 
206
243
  Before writing plan files, verify context compliance:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.40.1",
4
+ "version": "2.42.0",
5
5
  "description": "Plan-Build-Run — Structured development workflow for Cursor. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -5,6 +5,14 @@ model: sonnet
5
5
  readonly: false
6
6
  ---
7
7
 
8
+ <files_to_read>
9
+ CRITICAL: If your spawn prompt contains a files_to_read block,
10
+ you MUST Read every listed file BEFORE any other action.
11
+ Skipping this causes hallucinated context and broken output.
12
+ </files_to_read>
13
+
14
+ > Default files: the changed pbr/ file path(s) provided in the spawn prompt
15
+
8
16
  # Cross-Plugin Sync Agent
9
17
 
10
18
  You are **dev-sync**, a specialized agent for the Plan-Build-Run project. Your sole job is to take changes made in `plugins/pbr/` and apply the equivalent changes to `plugins/cursor-pbr/` and `plugins/copilot-pbr/`, adjusting for each derivative's format requirements.
@@ -201,6 +201,43 @@ Two plans CONFLICT if their `files_modified` lists overlap. Conflicting plans:
201
201
 
202
202
  ---
203
203
 
204
+ ## @file: Output Pattern (Large Payloads)
205
+
206
+ When a `pbr-tools` CLI command produces output exceeding 50,000 characters, the tool writes
207
+ the JSON payload to a temporary file instead of emitting it inline. It then prints a single
208
+ line of the form:
209
+
210
+ ```
211
+ @file:/tmp/pbr-1234567890.json
212
+ ```
213
+
214
+ This prevents stdout overflow in environments with limited buffer sizes (hooks, Task() runners).
215
+
216
+ ### Verify Step Pattern
217
+
218
+ If a plan's `<verify>` step calls `pbr-tools` and inspects the output, guard against the
219
+ `@file:` case:
220
+
221
+ ```bash
222
+ OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js state load)
223
+ if echo "$OUT" | grep -q '^@file:'; then
224
+ OUT=$(cat "${OUT#@file:}")
225
+ fi
226
+ echo "$OUT" | grep '"status"'
227
+ ```
228
+
229
+ ### Agent Prompt Handling
230
+
231
+ When an agent receives `@file:` output from a spawned tool call, it must read the referenced
232
+ file to obtain the actual JSON payload. The `@file:` prefix is a signal — not a path fragment
233
+ to be appended to another command.
234
+
235
+ Plan actions that spawn `pbr-tools` subcommands should instruct the agent:
236
+
237
+ > If the output starts with `@file:`, read the file at that path to get the full JSON response.
238
+
239
+ ---
240
+
204
241
  ## Context Fidelity Checklist
205
242
 
206
243
  Before writing plan files, verify context compliance:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbr",
3
- "version": "2.40.1",
3
+ "version": "2.42.0",
4
4
  "description": "Plan-Build-Run — Structured development workflow for Claude Code. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
5
5
  "author": {
6
6
  "name": "SienkLogic",
@@ -12,6 +12,14 @@ tools:
12
12
  - Grep
13
13
  ---
14
14
 
15
+ <files_to_read>
16
+ CRITICAL: If your spawn prompt contains a files_to_read block,
17
+ you MUST Read every listed file BEFORE any other action.
18
+ Skipping this causes hallucinated context and broken output.
19
+ </files_to_read>
20
+
21
+ > Default files: the changed pbr/ file path(s) provided in the spawn prompt
22
+
15
23
  # Cross-Plugin Sync Agent
16
24
 
17
25
  You are **dev-sync**, a specialized agent for the Plan-Build-Run project. Your sole job is to take changes made in `plugins/pbr/` and apply the equivalent changes to `plugins/cursor-pbr/` and `plugins/copilot-pbr/`, adjusting for each derivative's format requirements.
@@ -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
  {
@@ -200,6 +200,43 @@ Two plans CONFLICT if their `files_modified` lists overlap. Conflicting plans:
200
200
 
201
201
  ---
202
202
 
203
+ ## @file: Output Pattern (Large Payloads)
204
+
205
+ When a `pbr-tools` CLI command produces output exceeding 50,000 characters, the tool writes
206
+ the JSON payload to a temporary file instead of emitting it inline. It then prints a single
207
+ line of the form:
208
+
209
+ ```
210
+ @file:/tmp/pbr-1234567890.json
211
+ ```
212
+
213
+ This prevents stdout overflow in environments with limited buffer sizes (hooks, Task() runners).
214
+
215
+ ### Verify Step Pattern
216
+
217
+ If a plan's `<verify>` step calls `pbr-tools` and inspects the output, guard against the
218
+ `@file:` case:
219
+
220
+ ```bash
221
+ OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js state load)
222
+ if echo "$OUT" | grep -q '^@file:'; then
223
+ OUT=$(cat "${OUT#@file:}")
224
+ fi
225
+ echo "$OUT" | grep '"status"'
226
+ ```
227
+
228
+ ### Agent Prompt Handling
229
+
230
+ When an agent receives `@file:` output from a spawned tool call, it must read the referenced
231
+ file to obtain the actual JSON payload. The `@file:` prefix is a signal — not a path fragment
232
+ to be appended to another command.
233
+
234
+ Plan actions that spawn `pbr-tools` subcommands should instruct the agent:
235
+
236
+ > If the output starts with `@file:`, read the file at that path to get the full JSON response.
237
+
238
+ ---
239
+
203
240
  ## Context Fidelity Checklist
204
241
 
205
242
  Before writing plan files, verify context compliance:
@@ -56,7 +56,7 @@ async function main() {
56
56
 
57
57
  // Determine file type
58
58
  const basename = path.basename(filePath);
59
- const isPlan = /PLAN.*\.md$/i.test(basename);
59
+ const isPlan = /^PLAN.*\.md$/.test(basename);
60
60
  const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
61
61
  const isVerification = basename === 'VERIFICATION.md';
62
62
  const isRoadmap = basename === 'ROADMAP.md';
@@ -276,7 +276,7 @@ function validateSummary(content, _filePath) {
276
276
  async function checkPlanWrite(data) {
277
277
  const filePath = data.tool_input?.file_path || data.tool_input?.path || '';
278
278
  const basename = path.basename(filePath);
279
- const isPlan = /PLAN.*\.md$/i.test(basename);
279
+ const isPlan = /^PLAN.*\.md$/.test(basename);
280
280
  const isSummary = basename.includes('SUMMARY') && basename.endsWith('.md');
281
281
  const isVerification = basename === 'VERIFICATION.md';
282
282
  const isRoadmap = basename === 'ROADMAP.md';
@@ -0,0 +1,134 @@
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
+
15
+ const STALE_TTL_MS = 30 * 60 * 1000; // 30 minutes
16
+ const STATE_FILENAME = 'local-llm-circuit.json';
17
+
18
+ /**
19
+ * Returns the path to the circuit state file.
20
+ * @param {string} planningDir
21
+ * @returns {string}
22
+ */
23
+ function _statePath(planningDir) {
24
+ return path.join(planningDir, 'logs', STATE_FILENAME);
25
+ }
26
+
27
+ /**
28
+ * Load circuit state from disk. Returns an empty object on any error.
29
+ * Prunes entries whose last_failure timestamp is older than STALE_TTL_MS.
30
+ *
31
+ * @param {string} planningDir - Path to .planning directory
32
+ * @returns {object} Map of operationType -> { failures, disabled, last_failure }
33
+ */
34
+ function loadCircuitState(planningDir) {
35
+ const statePath = _statePath(planningDir);
36
+ let raw = {};
37
+ try {
38
+ if (!fs.existsSync(statePath)) return {};
39
+ raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
40
+ } catch (_e) {
41
+ return {};
42
+ }
43
+
44
+ const now = Date.now();
45
+ const pruned = {};
46
+ for (const [opType, entry] of Object.entries(raw)) {
47
+ if (entry && typeof entry.last_failure === 'number') {
48
+ if (now - entry.last_failure < STALE_TTL_MS) {
49
+ pruned[opType] = entry;
50
+ }
51
+ // else: stale — drop it
52
+ }
53
+ }
54
+ return pruned;
55
+ }
56
+
57
+ /**
58
+ * Atomically write circuit state to disk.
59
+ * Uses a temp-file + rename pattern to avoid partial writes.
60
+ *
61
+ * @param {string} planningDir - Path to .planning directory
62
+ * @param {object} state - State object to write
63
+ */
64
+ function saveCircuitState(planningDir, state) {
65
+ const logsDir = path.join(planningDir, 'logs');
66
+ try {
67
+ if (!fs.existsSync(logsDir)) {
68
+ fs.mkdirSync(logsDir, { recursive: true });
69
+ }
70
+ const statePath = _statePath(planningDir);
71
+ const tmpPath = statePath + '.tmp.' + process.pid;
72
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf8');
73
+ fs.renameSync(tmpPath, statePath);
74
+ } catch (_e) {
75
+ // Best-effort — never crash the calling hook
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Returns true if the circuit is open (disabled) for the given operation type,
81
+ * based on persistent state.
82
+ *
83
+ * @param {string} planningDir
84
+ * @param {string} operationType
85
+ * @param {number} maxFailures
86
+ * @returns {boolean}
87
+ */
88
+ function isDisabledPersistent(planningDir, operationType, maxFailures) {
89
+ const state = loadCircuitState(planningDir);
90
+ const entry = state[operationType];
91
+ if (!entry) return false;
92
+ return entry.disabled === true || entry.failures >= maxFailures;
93
+ }
94
+
95
+ /**
96
+ * Records a failure for the given operation type in persistent state.
97
+ * Marks the circuit disabled when maxFailures is reached.
98
+ *
99
+ * @param {string} planningDir
100
+ * @param {string} operationType
101
+ * @param {number} maxFailures
102
+ */
103
+ function recordFailurePersistent(planningDir, operationType, maxFailures) {
104
+ const state = loadCircuitState(planningDir);
105
+ const entry = state[operationType] || { failures: 0, disabled: false, last_failure: 0 };
106
+ entry.failures += 1;
107
+ entry.last_failure = Date.now();
108
+ if (entry.failures >= maxFailures) {
109
+ entry.disabled = true;
110
+ }
111
+ state[operationType] = entry;
112
+ saveCircuitState(planningDir, state);
113
+ }
114
+
115
+ /**
116
+ * Resets the persistent circuit state for the given operation type.
117
+ *
118
+ * @param {string} planningDir
119
+ * @param {string} operationType
120
+ */
121
+ function resetCircuitPersistent(planningDir, operationType) {
122
+ const state = loadCircuitState(planningDir);
123
+ delete state[operationType];
124
+ saveCircuitState(planningDir, state);
125
+ }
126
+
127
+ module.exports = {
128
+ loadCircuitState,
129
+ saveCircuitState,
130
+ isDisabledPersistent,
131
+ recordFailurePersistent,
132
+ resetCircuitPersistent,
133
+ STALE_TTL_MS
134
+ };
@@ -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 };