@sienklogic/plan-build-run 2.19.1 → 2.19.2

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 (116) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CLAUDE.md +29 -16
  3. package/README.md +3 -3
  4. package/dashboard/server/index.js +10 -1
  5. package/dashboard/server/routes/agents.js +23 -2
  6. package/dashboard/server/routes/health.js +7 -4
  7. package/dashboard/server/routes/telemetry.js +20 -1
  8. package/dashboard/server/services/planning-reader.js +3 -17
  9. package/package.json +1 -1
  10. package/plan-build-run/bin/config-schema.json +23 -145
  11. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  12. package/plugins/pbr/agents/advisor-researcher.md +1 -0
  13. package/plugins/pbr/agents/debugger.md +0 -4
  14. package/plugins/pbr/agents/researcher.md +0 -4
  15. package/plugins/pbr/agents/synthesizer.md +0 -4
  16. package/plugins/pbr/dist/check-config-change.js +0 -7
  17. package/plugins/pbr/dist/check-cross-plugin-sync.js +1 -1
  18. package/plugins/pbr/dist/check-plan-format.js +0 -32
  19. package/plugins/pbr/dist/check-roadmap-sync.js +15 -11
  20. package/plugins/pbr/dist/check-subagent-output.js +4 -60
  21. package/plugins/pbr/dist/check-summary-gate.js +3 -14
  22. package/plugins/pbr/dist/feedback-loop.js +12 -29
  23. package/plugins/pbr/dist/hook-server.js +58 -6
  24. package/plugins/pbr/dist/milestone-learnings.js +6 -56
  25. package/plugins/pbr/dist/pbr-tools.js +8 -91
  26. package/plugins/pbr/dist/post-bash-triage.js +5 -63
  27. package/plugins/pbr/dist/post-hoc.js +3 -52
  28. package/plugins/pbr/dist/post-write-dispatch.js +0 -36
  29. package/plugins/pbr/dist/pre-bash-dispatch.js +1 -7
  30. package/plugins/pbr/dist/pre-task-dispatch.js +0 -28
  31. package/plugins/pbr/dist/progress-tracker.js +2 -27
  32. package/plugins/pbr/dist/session-cleanup.js +1 -31
  33. package/plugins/pbr/dist/status-line.js +13 -11
  34. package/plugins/pbr/dist/suggest-compact.js +2 -10
  35. package/plugins/pbr/dist/validate-commit.js +8 -64
  36. package/plugins/pbr/dist/validate-task.js +0 -30
  37. package/plugins/pbr/references/config-reference.md +0 -96
  38. package/plugins/pbr/scripts/audit-checks/si-agent-hook-config-checks.js +2 -72
  39. package/plugins/pbr/scripts/audit-checks/workflow-compliance.js +5 -41
  40. package/plugins/pbr/scripts/check-config-change.js +0 -7
  41. package/plugins/pbr/scripts/check-cross-plugin-sync.js +1 -1
  42. package/plugins/pbr/scripts/check-plan-format.js +0 -32
  43. package/plugins/pbr/scripts/check-roadmap-sync.js +15 -11
  44. package/plugins/pbr/scripts/check-subagent-output.js +4 -60
  45. package/plugins/pbr/scripts/check-summary-gate.js +3 -14
  46. package/plugins/pbr/scripts/config-schema.json +16 -129
  47. package/plugins/pbr/scripts/feedback-loop.js +12 -29
  48. package/plugins/pbr/scripts/hook-server.js +58 -6
  49. package/plugins/pbr/scripts/lib/config.js +4 -11
  50. package/plugins/pbr/scripts/lib/contextual-help.js +5 -29
  51. package/plugins/pbr/scripts/lib/format-validators.js +1 -26
  52. package/plugins/pbr/scripts/lib/frontmatter.js +4 -4
  53. package/plugins/pbr/scripts/lib/gates/rich-agent-context.js +13 -19
  54. package/plugins/pbr/scripts/lib/health.js +4 -5
  55. package/plugins/pbr/scripts/lib/help.js +3 -54
  56. package/plugins/pbr/scripts/lib/phase.js +2 -4
  57. package/plugins/pbr/scripts/lib/pre-commit-checks.js +1 -1
  58. package/plugins/pbr/scripts/lib/pre-research.js +10 -17
  59. package/plugins/pbr/scripts/lib/roadmap.js +11 -35
  60. package/plugins/pbr/scripts/lib/smart-next-task.js +11 -20
  61. package/plugins/pbr/scripts/lib/spot-check.js +3 -106
  62. package/plugins/pbr/scripts/lib/state.js +25 -130
  63. package/plugins/pbr/scripts/lib/verify.js +56 -46
  64. package/plugins/pbr/scripts/milestone-learnings.js +6 -56
  65. package/plugins/pbr/scripts/pbr-tools.js +8 -91
  66. package/plugins/pbr/scripts/post-bash-triage.js +5 -63
  67. package/plugins/pbr/scripts/post-hoc.js +3 -52
  68. package/plugins/pbr/scripts/post-write-dispatch.js +0 -36
  69. package/plugins/pbr/scripts/pre-bash-dispatch.js +1 -7
  70. package/plugins/pbr/scripts/pre-task-dispatch.js +0 -28
  71. package/plugins/pbr/scripts/progress-tracker.js +2 -27
  72. package/plugins/pbr/scripts/session-cleanup.js +1 -31
  73. package/plugins/pbr/scripts/status-line.js +13 -11
  74. package/plugins/pbr/scripts/suggest-compact.js +2 -10
  75. package/plugins/pbr/scripts/test/state.test.js +5 -13
  76. package/plugins/pbr/scripts/validate-commit.js +8 -64
  77. package/plugins/pbr/scripts/validate-task.js +0 -30
  78. package/plugins/pbr/skills/begin/SKILL.md +1 -0
  79. package/plugins/pbr/skills/begin/templates/config.json.tmpl +0 -4
  80. package/plugins/pbr/skills/build/SKILL.md +6 -6
  81. package/plugins/pbr/skills/config/SKILL.md +1 -0
  82. package/plugins/pbr/skills/help/SKILL.md +1 -0
  83. package/plugins/pbr/skills/pause/SKILL.md +1 -0
  84. package/plugins/pbr/skills/profile-user/SKILL.md +1 -0
  85. package/plugins/pbr/skills/quick/SKILL.md +2 -1
  86. package/plugins/pbr/skills/resume/SKILL.md +1 -0
  87. package/plugins/pbr/skills/scan/SKILL.md +1 -0
  88. package/plugins/pbr/skills/setup/SKILL.md +1 -0
  89. package/plugins/pbr/skills/shared/state-update.md +2 -2
  90. package/plugins/pbr/skills/status/SKILL.md +1 -0
  91. package/plugins/pbr/references/behavioral-contexts.md +0 -53
  92. package/plugins/pbr/scripts/lib/autonomy.js +0 -91
  93. package/plugins/pbr/scripts/lib/circuit-state.js +0 -133
  94. package/plugins/pbr/scripts/lib/completion.js +0 -377
  95. package/plugins/pbr/scripts/lib/hypothesis-runner.js +0 -127
  96. package/plugins/pbr/scripts/lib/local-llm/client.js +0 -237
  97. package/plugins/pbr/scripts/lib/local-llm/health.js +0 -12
  98. package/plugins/pbr/scripts/lib/local-llm/index.js +0 -89
  99. package/plugins/pbr/scripts/lib/local-llm/metrics.js +0 -20
  100. package/plugins/pbr/scripts/lib/local-llm/operations/classify-artifact.js +0 -4
  101. package/plugins/pbr/scripts/lib/local-llm/operations/classify-commit.js +0 -4
  102. package/plugins/pbr/scripts/lib/local-llm/operations/classify-error.js +0 -4
  103. package/plugins/pbr/scripts/lib/local-llm/operations/classify-file-intent.js +0 -4
  104. package/plugins/pbr/scripts/lib/local-llm/operations/score-source.js +0 -72
  105. package/plugins/pbr/scripts/lib/local-llm/operations/summarize-context.js +0 -62
  106. package/plugins/pbr/scripts/lib/local-llm/operations/triage-test-output.js +0 -12
  107. package/plugins/pbr/scripts/lib/local-llm/operations/validate-task.js +0 -4
  108. package/plugins/pbr/scripts/lib/local-llm/router.js +0 -101
  109. package/plugins/pbr/scripts/lib/local-llm/shadow.js +0 -60
  110. package/plugins/pbr/scripts/lib/local-llm/threshold-tuner.js +0 -118
  111. package/plugins/pbr/scripts/lib/team-composer.js +0 -87
  112. package/plugins/pbr/scripts/lib/team-coordinator.js +0 -153
  113. package/plugins/pbr/scripts/lib/template.js +0 -222
  114. package/plugins/pbr/scripts/lib/test-cache.js +0 -54
  115. package/plugins/pbr/scripts/lib/trust-gate.js +0 -84
  116. package/plugins/pbr/scripts/lib/wiring-check.js +0 -196
@@ -25,8 +25,6 @@ const fs = require('fs');
25
25
  const path = require('path');
26
26
  const { logHook } = require('./hook-logger');
27
27
  const { logEvent } = require('./event-logger');
28
- const { resolveConfig } = require('./lib/local-llm/health');
29
- const { classifyArtifact } = require('./lib/local-llm/operations/classify-artifact');
30
28
 
31
29
  // Import all validators from extracted module
32
30
  const {
@@ -49,20 +47,6 @@ const {
49
47
  validateContext
50
48
  } = require('./lib/format-validators');
51
49
 
52
- /**
53
- * Load and resolve the local_llm config block from .planning/config.json.
54
- * Returns a resolved config (always safe to use -- disabled by default on error).
55
- */
56
- function loadLocalLlmConfig() {
57
- try {
58
- const configPath = path.join(process.cwd(), '.planning', 'config.json');
59
- const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
60
- return resolveConfig(parsed.local_llm);
61
- } catch (_e) {
62
- return resolveConfig(undefined);
63
- }
64
- }
65
-
66
50
  async function main() {
67
51
  let input = '';
68
52
 
@@ -119,22 +103,6 @@ async function main() {
119
103
  result.warnings.push(`Plan file "${basename}" uses non-standard naming. Expected format: PLAN-{NN}.md or PLAN.md. Phase-prefixed names (e.g., "10-02-PLAN.md") bypass some validation checks.`);
120
104
  }
121
105
 
122
- // LLM advisory enrichment -- advisory only, never blocks
123
- if ((isPlan || isSummary) && result.errors.length === 0) {
124
- try {
125
- const llmConfig = loadLocalLlmConfig();
126
- const planningDir = path.join(process.cwd(), '.planning');
127
- const fileType = isPlan ? 'PLAN' : 'SUMMARY';
128
- const llmResult = await classifyArtifact(llmConfig, planningDir, content, fileType, data.session_id);
129
- if (llmResult && llmResult.classification) {
130
- const llmNote = `Local LLM: ${fileType} classified as "${llmResult.classification}" (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)${llmResult.reason ? ' — ' + llmResult.reason : ''}`;
131
- result.warnings.push(llmNote);
132
- }
133
- } catch (_llmErr) {
134
- // Never propagate LLM errors
135
- }
136
- }
137
-
138
106
  const eventType = isPlan ? 'plan-validated' : isVerification ? 'verification-validated' : isRoadmap ? 'roadmap-validated' : isLearnings ? 'learnings-validated' : isConfig ? 'config-validated' : isResearch ? 'research-validated' : isContext ? 'context-validated' : 'summary-validated';
139
107
 
140
108
  // Detect Write vs Edit: Write = full creation/overwrite (likely first attempt)
@@ -17,6 +17,7 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const { logHook } = require('./hook-logger');
19
19
  const { logEvent } = require('./event-logger');
20
+ const { extractFrontmatter } = require('./lib/frontmatter');
20
21
 
21
22
  const LIFECYCLE_STATUSES = ['planned', 'built', 'partial', 'verified'];
22
23
 
@@ -197,29 +198,32 @@ function main() {
197
198
 
198
199
  /**
199
200
  * Extract current phase number and status from STATE.md.
200
- * Handles common formats:
201
- * "**Phase**: 03 - slug-name"
202
- * "Phase: 3"
203
- * "Current phase: 03-slug-name"
204
- * "**Status**: planned"
205
- * "Phase status: built"
201
+ * Tries frontmatter first (v2), falls back to body regex (legacy).
202
+ * Delegates frontmatter parsing to canonical extractFrontmatter.
203
+ * Returns { phase, status } or null if unparseable.
206
204
  */
207
- function parseState(content) {
205
+ const parseState = (content) => {
206
+ // Try frontmatter first (v2 format)
207
+ const fm = extractFrontmatter(content);
208
+ if (fm.current_phase && fm.status) {
209
+ return {
210
+ phase: normalizePhaseNum(String(fm.current_phase)),
211
+ status: fm.status.toLowerCase()
212
+ };
213
+ }
214
+ // Fallback: body regex for legacy formats
208
215
  const phaseMatch = content.match(
209
216
  /\*{0,2}(?:Current\s+)?Phase\*{0,2}:\s*(\d+(?:\.\d+)?)/i
210
217
  );
211
-
212
218
  const statusMatch = content.match(
213
219
  /\*{0,2}(?:Phase\s+)?Status\*{0,2}:\s*["']?(\w+)["']?/i
214
220
  );
215
-
216
221
  if (!phaseMatch || !statusMatch) return null;
217
-
218
222
  return {
219
223
  phase: normalizePhaseNum(phaseMatch[1]),
220
224
  status: statusMatch[1].toLowerCase()
221
225
  };
222
- }
226
+ };
223
227
 
224
228
  /**
225
229
  * Find the status for a given phase in ROADMAP.md's Phase Overview table.
@@ -21,8 +21,6 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { logHook } = require('./hook-logger');
23
23
  const { KNOWN_AGENTS, sessionLoad } = require('./pbr-tools');
24
- const { resolveConfig } = require('./lib/local-llm/health');
25
- const { classifyError } = require('./lib/local-llm/operations/classify-error');
26
24
  const { resolveSessionPath } = require('./lib/core');
27
25
  const { logEvent } = require('./event-logger');
28
26
  const { recordOutcome } = require('./trust-tracker');
@@ -102,15 +100,6 @@ function readStdin() {
102
100
  return {};
103
101
  }
104
102
 
105
- function loadLocalLlmConfig(cwd) {
106
- try {
107
- const configPath = path.join(cwd, '.planning', 'config.json');
108
- const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
109
- return resolveConfig(parsed.local_llm);
110
- } catch (_) {
111
- return resolveConfig(undefined);
112
- }
113
- }
114
103
 
115
104
  async function main() {
116
105
  const data = readStdin();
@@ -248,22 +237,8 @@ async function main() {
248
237
  agent_type: agentType,
249
238
  warnings: skillWarnings
250
239
  });
251
- // LLM error classification -- advisory enrichment
252
- let llmCategoryNote = '';
253
- try {
254
- const llmConfig = loadLocalLlmConfig(cwd);
255
- const errorText = (data.tool_output || '').substring(0, 500);
256
- if (errorText) {
257
- const llmResult = await classifyError(llmConfig, planningDir, errorText, agentType, data.session_id);
258
- if (llmResult && llmResult.category) {
259
- llmCategoryNote = `\nLLM error category: ${llmResult.category} (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)`;
260
- }
261
- }
262
- } catch (_llmErr) {
263
- // Never propagate
264
- }
265
240
  const msg = `Warning: Agent ${agentType} completed but no ${outputSpec.description} was found.\nSkill-specific warnings:\n` +
266
- skillWarnings.map(w => `- ${w}`).join('\n') + llmCategoryNote;
241
+ skillWarnings.map(w => `- ${w}`).join('\n');
267
242
  process.stdout.write(JSON.stringify({ additionalContext: msg }));
268
243
  } else if (genericMissing) {
269
244
  logHook('check-subagent-output', 'PostToolUse', 'warning', {
@@ -271,22 +246,8 @@ async function main() {
271
246
  expected: outputSpec.description,
272
247
  found: 'none'
273
248
  });
274
- // LLM error classification -- advisory enrichment
275
- let llmCategoryNote = '';
276
- try {
277
- const llmConfig = loadLocalLlmConfig(cwd);
278
- const errorText = (data.tool_output || '').substring(0, 500);
279
- if (errorText) {
280
- const llmResult = await classifyError(llmConfig, planningDir, errorText, agentType, data.session_id);
281
- if (llmResult && llmResult.category) {
282
- llmCategoryNote = `\nLLM error category: ${llmResult.category} (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)`;
283
- }
284
- }
285
- } catch (_llmErr) {
286
- // Never propagate
287
- }
288
249
  const output = {
289
- additionalContext: `[WARN] Agent ${agentType} completed but no ${outputSpec.description} was found. Likely causes: (1) agent hit an error mid-run, (2) wrong working directory. To fix: re-run the parent skill — the executor gate will block until the output is present. Check the Task() output above for error details.` + llmCategoryNote
250
+ additionalContext: `[WARN] Agent ${agentType} completed but no ${outputSpec.description} was found. Likely causes: (1) agent hit an error mid-run, (2) wrong working directory. To fix: re-run the parent skill — the executor gate will block until the output is present. Check the Task() output above for error details.`
290
251
  };
291
252
  process.stdout.write(JSON.stringify(output));
292
253
  } else if (skillWarnings.length > 0) {
@@ -412,32 +373,15 @@ async function handleHttp(reqBody) {
412
373
  }
413
374
  }
414
375
 
415
- // LLM classification helper (advisory, never throws)
416
- async function getLlmNote() {
417
- try {
418
- const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
419
- const llmConfig = loadLocalLlmConfig(cwd);
420
- const errorText = (data.tool_output || '').substring(0, 500);
421
- if (!errorText) return '';
422
- const llmResult = await classifyError(llmConfig, planningDir, errorText, agentType, data.session_id);
423
- if (llmResult && llmResult.category) {
424
- return `\nLLM error category: ${llmResult.category} (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)`;
425
- }
426
- } catch (_e) { /* never propagate */ }
427
- return '';
428
- }
429
-
430
376
  if (genericMissing && skillWarnings.length > 0) {
431
377
  logHook('check-subagent-output', 'PostToolUse', 'skill-warning', { skill: activeSkill, agent_type: agentType, warnings: skillWarnings });
432
- const llmCategoryNote = await getLlmNote();
433
378
  const msg = `Warning: Agent ${agentType} completed but no ${outputSpec.description} was found.\nSkill-specific warnings:\n` +
434
- skillWarnings.map(w => `- ${w}`).join('\n') + llmCategoryNote;
379
+ skillWarnings.map(w => `- ${w}`).join('\n');
435
380
  return { additionalContext: msg };
436
381
  } else if (genericMissing) {
437
382
  logHook('check-subagent-output', 'PostToolUse', 'warning', { agent_type: agentType, expected: outputSpec.description, found: 'none' });
438
- const llmCategoryNote = await getLlmNote();
439
383
  return {
440
- additionalContext: `[WARN] Agent ${agentType} completed but no ${outputSpec.description} was found. Likely causes: (1) agent hit an error mid-run, (2) wrong working directory. To fix: re-run the parent skill — the executor gate will block until the output is present. Check the Task() output above for error details.` + llmCategoryNote
384
+ additionalContext: `[WARN] Agent ${agentType} completed but no ${outputSpec.description} was found. Likely causes: (1) agent hit an error mid-run, (2) wrong working directory. To fix: re-run the parent skill — the executor gate will block until the output is present. Check the Task() output above for error details.`
441
385
  };
442
386
  } else if (skillWarnings.length > 0) {
443
387
  logHook('check-subagent-output', 'PostToolUse', 'skill-warning', { skill: activeSkill, agent_type: agentType, warnings: skillWarnings });
@@ -27,24 +27,13 @@
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
29
  const { logHook } = require('./hook-logger');
30
+ const { extractFrontmatter } = require('./lib/frontmatter');
30
31
 
31
32
  // Statuses that indicate a phase has been executed
32
33
  const ADVANCED_STATUSES = ['built', 'verified', 'complete'];
33
34
 
34
- /**
35
- * Extract YAML frontmatter values from markdown content.
36
- * Returns an object with parsed key-value pairs.
37
- */
38
- function parseFrontmatter(content) {
39
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
40
- if (!match) return {};
41
- const result = {};
42
- for (const line of match[1].split(/\r?\n/)) {
43
- const kv = line.match(/^(\w[\w_]*):\s*"?([^"\r\n]*)"?$/);
44
- if (kv) result[kv[1]] = kv[2].trim();
45
- }
46
- return result;
47
- }
35
+ // Re-export extractFrontmatter as parseFrontmatter for backward compat (tests import it)
36
+ const parseFrontmatter = extractFrontmatter;
48
37
 
49
38
  /**
50
39
  * Check if a SUMMARY file exists for the given phase directory.
@@ -20,36 +20,19 @@
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
+ const { extractFrontmatter } = require('./lib/frontmatter');
23
24
 
24
25
  /**
25
- * Parse YAML-ish frontmatter from markdown content.
26
- * Lightweight parser no dependency on pbr-tools to avoid circular deps.
26
+ * Parse frontmatter and body from markdown content.
27
+ * Delegates YAML parsing to canonical extractFrontmatter.
27
28
  * @param {string} content - Full file content
28
29
  * @returns {{ frontmatter: object, body: string }}
29
30
  */
30
- function parseFrontmatter(content) {
31
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
32
- if (!match) return { frontmatter: {}, body: content };
33
-
34
- const fm = {};
35
- const lines = match[1].split(/\r?\n/);
36
- for (const line of lines) {
37
- const kvMatch = line.match(/^(\w[\w_]*):\s*(.+)/);
38
- if (kvMatch) {
39
- const key = kvMatch[1];
40
- let val = kvMatch[2].trim();
41
- // Parse numbers
42
- if (/^\d+$/.test(val)) val = parseInt(val, 10);
43
- // Strip quotes
44
- if (typeof val === 'string' && val.startsWith('"') && val.endsWith('"')) {
45
- val = val.slice(1, -1);
46
- }
47
- fm[key] = val;
48
- }
49
- }
50
-
51
- const body = content.slice(match[0].length).trim();
52
- return { frontmatter: fm, body };
31
+ function extractFrontmatterWithBody(content) {
32
+ const frontmatter = extractFrontmatter(content);
33
+ const match = content.match(/^---\r?\n[\s\S]*?\r?\n---/);
34
+ const body = match ? content.slice(match[0].length).trim() : content;
35
+ return { frontmatter, body };
53
36
  }
54
37
 
55
38
  /**
@@ -69,7 +52,7 @@ function extractFeedback(phaseDir) {
69
52
  return null;
70
53
  }
71
54
 
72
- const { frontmatter, body } = parseFrontmatter(content);
55
+ const { frontmatter, body } = extractFrontmatterWithBody(content);
73
56
 
74
57
  const status = frontmatter.status;
75
58
  if (!status) return null;
@@ -77,9 +60,9 @@ function extractFeedback(phaseDir) {
77
60
  // No feedback needed if verification passed
78
61
  if (status === 'passed' || status === 'all_passed') return null;
79
62
 
80
- const attempt = typeof frontmatter.attempt === 'number' ? frontmatter.attempt : 1;
81
- const passed = typeof frontmatter.must_haves_passed === 'number' ? frontmatter.must_haves_passed : 0;
82
- const total = typeof frontmatter.must_haves_total === 'number' ? frontmatter.must_haves_total : 1;
63
+ const attempt = frontmatter.attempt ? parseInt(frontmatter.attempt, 10) || 1 : 1;
64
+ const passed = frontmatter.must_haves_passed ? parseInt(frontmatter.must_haves_passed, 10) || 0 : 0;
65
+ const total = frontmatter.must_haves_total ? parseInt(frontmatter.must_haves_total, 10) || 1 : 1;
83
66
  const pass_rate = total > 0 ? passed / total : 0;
84
67
 
85
68
  // Parse gap sections from body
@@ -89,15 +89,23 @@ function readEventLogTail(logFile, maxLines) {
89
89
  if (maxLines === undefined) maxLines = 500;
90
90
  try {
91
91
  if (!fs.existsSync(logFile)) return [];
92
- const content = fs.readFileSync(logFile, 'utf8');
93
- const lines = content.split('\n').filter(l => l.trim().length > 0);
92
+ const stat = fs.statSync(logFile);
93
+ if (stat.size === 0) return [];
94
+ // Read only the last chunk instead of the entire file
95
+ const CHUNK_SIZE = 64 * 1024; // 64KB should hold 500+ lines
96
+ const start = Math.max(0, stat.size - CHUNK_SIZE);
97
+ const fd = fs.openSync(logFile, 'r');
98
+ const buffer = Buffer.alloc(Math.min(CHUNK_SIZE, stat.size));
99
+ fs.readSync(fd, buffer, 0, buffer.length, start);
100
+ fs.closeSync(fd);
101
+ const lines = buffer.toString('utf8').split('\n').filter(l => l.trim().length > 0);
94
102
  const tail = lines.slice(-maxLines);
95
103
  const events = [];
96
104
  for (const line of tail) {
97
105
  try {
98
106
  events.push(JSON.parse(line));
99
107
  } catch (_e) {
100
- // Skip malformed lines
108
+ // Skip malformed lines (including partial first line from mid-file read)
101
109
  }
102
110
  }
103
111
  return events;
@@ -106,6 +114,23 @@ function readEventLogTail(logFile, maxLines) {
106
114
  }
107
115
  }
108
116
 
117
+ /**
118
+ * Rotate the event log if it exceeds 2MB — truncate to last 1000 lines.
119
+ * Called once at server startup to prevent unbounded log growth.
120
+ */
121
+ function rotateEventLog(planningDir) {
122
+ if (!planningDir) return;
123
+ const logFile = path.join(planningDir, '.hook-events.jsonl');
124
+ try {
125
+ const stat = fs.statSync(logFile);
126
+ if (stat.size > 2 * 1024 * 1024) { // 2MB
127
+ const content = fs.readFileSync(logFile, 'utf8');
128
+ const lines = content.split('\n').filter(l => l.trim().length > 0);
129
+ fs.writeFileSync(logFile, lines.slice(-1000).join('\n') + '\n');
130
+ }
131
+ } catch (_e) { /* best-effort — file may not exist yet */ }
132
+ }
133
+
109
134
  /** Append a JSON event object as a single line to .planning/.hook-events.jsonl */
110
135
  function appendEvent(planningDir, eventObj) {
111
136
  if (!planningDir) return;
@@ -176,6 +201,30 @@ function mergeContext(...fns) {
176
201
  };
177
202
  }
178
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // PreToolUse response translation
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Translate PreToolUse block responses into Claude Code's hookSpecificOutput format.
210
+ * Handlers return { decision: 'block', reason: '...' } but Claude Code expects
211
+ * { hookSpecificOutput: { hookEventName, permissionDecision, permissionDecisionReason } }.
212
+ */
213
+ function translatePreToolUseResponse(event, result) {
214
+ if (!result || event !== 'PreToolUse') return result;
215
+ if (result.decision === 'block' && result.reason) {
216
+ return {
217
+ hookSpecificOutput: {
218
+ hookEventName: 'PreToolUse',
219
+ permissionDecision: 'deny',
220
+ permissionDecisionReason: result.reason
221
+ }
222
+ };
223
+ }
224
+ // Allow decisions or non-blocking results pass through unchanged
225
+ return result;
226
+ }
227
+
179
228
  // ---------------------------------------------------------------------------
180
229
  // Handler routing table (dynamic Map populated by register/initRoutes)
181
230
  // ---------------------------------------------------------------------------
@@ -377,7 +426,7 @@ function createServer(planningDir) {
377
426
  }
378
427
  const duration_ms = Date.now() - dispatchStart;
379
428
  appendEvent(planningDir, { ts: new Date().toISOString(), type: 'dispatch', event, tool, hook: `${event}:${tool}`, duration_ms, transport: 'http' });
380
- let finalResult = dispatchResult || {};
429
+ let finalResult = translatePreToolUseResponse(event, dispatchResult) || {};
381
430
  if (duration_ms >= 100) {
382
431
  const alertMsg = `HOOK PERFORMANCE ALERT: ${event}:${tool} took ${duration_ms}ms (threshold: 100ms). Check handler for blocking I/O.`;
383
432
  if (finalResult.additionalContext) {
@@ -431,7 +480,7 @@ function createServer(planningDir) {
431
480
  }
432
481
  const legacyDurationMs = Date.now() - legacyDispatchStart;
433
482
  appendEvent(planningDir, { ts: new Date().toISOString(), type: 'dispatch', event, tool, hook: `${event}:${tool}`, duration_ms: legacyDurationMs, transport: 'http' });
434
- let legacyFinalResult = legacyDispatchResult || {};
483
+ let legacyFinalResult = translatePreToolUseResponse(event, legacyDispatchResult) || {};
435
484
  if (legacyDurationMs >= 100) {
436
485
  const alertMsg = `HOOK PERFORMANCE ALERT: ${event}:${tool} took ${legacyDurationMs}ms (threshold: 100ms). Check handler for blocking I/O.`;
437
486
  if (legacyFinalResult.additionalContext) {
@@ -573,6 +622,9 @@ function main() {
573
622
  cache.config = configLoad(planningDir);
574
623
  } catch (_e) { /* best-effort */ }
575
624
 
625
+ // Rotate event log if oversized (before any new events are appended)
626
+ rotateEventLog(planningDir);
627
+
576
628
  initRoutes();
577
629
 
578
630
  const server = createServer(planningDir);
@@ -601,6 +653,6 @@ function main() {
601
653
  process.on('SIGINT', shutdown);
602
654
  }
603
655
 
604
- module.exports = { createServer, appendEvent, readEventLogTail, mergeContext, lazyHandler, resolveHandler, register, initRoutes, triggerShutdown, tryNextPort, normalizeMsysPath, DEFAULT_PORT };
656
+ module.exports = { createServer, appendEvent, readEventLogTail, rotateEventLog, mergeContext, lazyHandler, resolveHandler, register, initRoutes, triggerShutdown, tryNextPort, normalizeMsysPath, translatePreToolUseResponse, DEFAULT_PORT };
605
657
 
606
658
  if (require.main === module || process.argv[1] === __filename) { main(); }
@@ -19,61 +19,11 @@ const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { logHook } = require('./hook-logger');
21
21
  const { learningsIngest, copyToGlobal } = require('./lib/learnings');
22
+ const { extractFrontmatter } = require('./lib/frontmatter');
22
23
 
23
24
  // --- Helpers ---
24
25
 
25
- /**
26
- * Parse YAML frontmatter from a markdown file.
27
- * Returns an object with string/array field values, or null if no frontmatter.
28
- * Only handles simple YAML: scalar strings and dash-list arrays.
29
- * @param {string} content
30
- * @returns {object|null}
31
- */
32
- function parseFrontmatter(content) {
33
- // Normalize line endings
34
- const normalized = content.replace(/\r\n/g, '\n');
35
- const match = normalized.match(/^---\n([\s\S]*?)\n---/);
36
- if (!match) return null;
37
-
38
- const yaml = match[1];
39
- const result = {};
40
- const lines = yaml.split('\n');
41
- let currentKey = null;
42
-
43
- for (const line of lines) {
44
- // List item (must check before key match so " - item" doesn't match as key)
45
- const listMatch = line.match(/^\s+-\s+"?([^"]+?)"?\s*$/);
46
- if (listMatch) {
47
- if (currentKey !== null) {
48
- if (!Array.isArray(result[currentKey])) {
49
- result[currentKey] = [];
50
- }
51
- result[currentKey].push(listMatch[1].trim());
52
- }
53
- continue;
54
- }
55
-
56
- // Key: value pair
57
- const kvMatch = line.match(/^(\w[\w_-]*):\s*(.*)/);
58
- if (kvMatch) {
59
- currentKey = kvMatch[1];
60
- const rawVal = kvMatch[2].trim();
61
-
62
- if (rawVal === '' || rawVal === '[]') {
63
- // Empty scalar or empty inline array — may be followed by list items
64
- result[currentKey] = [];
65
- } else if (rawVal.startsWith('[')) {
66
- // Inline array (basic): [a, b]
67
- const inner = rawVal.slice(1, rawVal.lastIndexOf(']'));
68
- result[currentKey] = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
69
- } else {
70
- result[currentKey] = rawVal.replace(/^["']|["']$/g, '');
71
- }
72
- }
73
- }
74
-
75
- return result;
76
- }
26
+ // parseFrontmatter replaced by extractFrontmatter from lib/frontmatter.js
77
27
 
78
28
  /**
79
29
  * Extract learning entries from a SUMMARY.md file's frontmatter.
@@ -82,8 +32,8 @@ function parseFrontmatter(content) {
82
32
  * @returns {object[]} array of raw learning entry objects
83
33
  */
84
34
  function extractLearningsFromSummary(summaryContent, sourceProject) {
85
- const fm = parseFrontmatter(summaryContent);
86
- if (!fm) return [];
35
+ const fm = extractFrontmatter(summaryContent);
36
+ if (!fm || Object.keys(fm).length === 0) return [];
87
37
 
88
38
  const entries = [];
89
39
 
@@ -476,7 +426,7 @@ async function main() {
476
426
  if (!fs.existsSync(verPath)) continue;
477
427
  try {
478
428
  const verContent = fs.readFileSync(verPath, 'utf8');
479
- const fm = parseFrontmatter(verContent);
429
+ const fm = extractFrontmatter(verContent);
480
430
  if (!fm) continue;
481
431
  // Extract gaps from frontmatter
482
432
  const gaps = Array.isArray(fm.gaps) ? fm.gaps : [];
@@ -566,4 +516,4 @@ if (require.main === module || process.argv[1] === __filename) {
566
516
  });
567
517
  }
568
518
 
569
- module.exports = { extractLearningsFromSummary, findSummaryFiles, parseFrontmatter, aggregateToKnowledge, countExistingRows, itemExists, insertTableRow, KNOWLEDGE_TEMPLATE };
519
+ module.exports = { extractLearningsFromSummary, findSummaryFiles, extractFrontmatter, aggregateToKnowledge, countExistingRows, itemExists, insertTableRow, KNOWLEDGE_TEMPLATE };