@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.
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +29 -16
- package/README.md +3 -3
- package/dashboard/server/index.js +10 -1
- package/dashboard/server/routes/agents.js +23 -2
- package/dashboard/server/routes/health.js +7 -4
- package/dashboard/server/routes/telemetry.js +20 -1
- package/dashboard/server/services/planning-reader.js +3 -17
- package/package.json +1 -1
- package/plan-build-run/bin/config-schema.json +23 -145
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/advisor-researcher.md +1 -0
- package/plugins/pbr/agents/debugger.md +0 -4
- package/plugins/pbr/agents/researcher.md +0 -4
- package/plugins/pbr/agents/synthesizer.md +0 -4
- package/plugins/pbr/dist/check-config-change.js +0 -7
- package/plugins/pbr/dist/check-cross-plugin-sync.js +1 -1
- package/plugins/pbr/dist/check-plan-format.js +0 -32
- package/plugins/pbr/dist/check-roadmap-sync.js +15 -11
- package/plugins/pbr/dist/check-subagent-output.js +4 -60
- package/plugins/pbr/dist/check-summary-gate.js +3 -14
- package/plugins/pbr/dist/feedback-loop.js +12 -29
- package/plugins/pbr/dist/hook-server.js +58 -6
- package/plugins/pbr/dist/milestone-learnings.js +6 -56
- package/plugins/pbr/dist/pbr-tools.js +8 -91
- package/plugins/pbr/dist/post-bash-triage.js +5 -63
- package/plugins/pbr/dist/post-hoc.js +3 -52
- package/plugins/pbr/dist/post-write-dispatch.js +0 -36
- package/plugins/pbr/dist/pre-bash-dispatch.js +1 -7
- package/plugins/pbr/dist/pre-task-dispatch.js +0 -28
- package/plugins/pbr/dist/progress-tracker.js +2 -27
- package/plugins/pbr/dist/session-cleanup.js +1 -31
- package/plugins/pbr/dist/status-line.js +13 -11
- package/plugins/pbr/dist/suggest-compact.js +2 -10
- package/plugins/pbr/dist/validate-commit.js +8 -64
- package/plugins/pbr/dist/validate-task.js +0 -30
- package/plugins/pbr/references/config-reference.md +0 -96
- package/plugins/pbr/scripts/audit-checks/si-agent-hook-config-checks.js +2 -72
- package/plugins/pbr/scripts/audit-checks/workflow-compliance.js +5 -41
- package/plugins/pbr/scripts/check-config-change.js +0 -7
- package/plugins/pbr/scripts/check-cross-plugin-sync.js +1 -1
- package/plugins/pbr/scripts/check-plan-format.js +0 -32
- package/plugins/pbr/scripts/check-roadmap-sync.js +15 -11
- package/plugins/pbr/scripts/check-subagent-output.js +4 -60
- package/plugins/pbr/scripts/check-summary-gate.js +3 -14
- package/plugins/pbr/scripts/config-schema.json +16 -129
- package/plugins/pbr/scripts/feedback-loop.js +12 -29
- package/plugins/pbr/scripts/hook-server.js +58 -6
- package/plugins/pbr/scripts/lib/config.js +4 -11
- package/plugins/pbr/scripts/lib/contextual-help.js +5 -29
- package/plugins/pbr/scripts/lib/format-validators.js +1 -26
- package/plugins/pbr/scripts/lib/frontmatter.js +4 -4
- package/plugins/pbr/scripts/lib/gates/rich-agent-context.js +13 -19
- package/plugins/pbr/scripts/lib/health.js +4 -5
- package/plugins/pbr/scripts/lib/help.js +3 -54
- package/plugins/pbr/scripts/lib/phase.js +2 -4
- package/plugins/pbr/scripts/lib/pre-commit-checks.js +1 -1
- package/plugins/pbr/scripts/lib/pre-research.js +10 -17
- package/plugins/pbr/scripts/lib/roadmap.js +11 -35
- package/plugins/pbr/scripts/lib/smart-next-task.js +11 -20
- package/plugins/pbr/scripts/lib/spot-check.js +3 -106
- package/plugins/pbr/scripts/lib/state.js +25 -130
- package/plugins/pbr/scripts/lib/verify.js +56 -46
- package/plugins/pbr/scripts/milestone-learnings.js +6 -56
- package/plugins/pbr/scripts/pbr-tools.js +8 -91
- package/plugins/pbr/scripts/post-bash-triage.js +5 -63
- package/plugins/pbr/scripts/post-hoc.js +3 -52
- package/plugins/pbr/scripts/post-write-dispatch.js +0 -36
- package/plugins/pbr/scripts/pre-bash-dispatch.js +1 -7
- package/plugins/pbr/scripts/pre-task-dispatch.js +0 -28
- package/plugins/pbr/scripts/progress-tracker.js +2 -27
- package/plugins/pbr/scripts/session-cleanup.js +1 -31
- package/plugins/pbr/scripts/status-line.js +13 -11
- package/plugins/pbr/scripts/suggest-compact.js +2 -10
- package/plugins/pbr/scripts/test/state.test.js +5 -13
- package/plugins/pbr/scripts/validate-commit.js +8 -64
- package/plugins/pbr/scripts/validate-task.js +0 -30
- package/plugins/pbr/skills/begin/SKILL.md +1 -0
- package/plugins/pbr/skills/begin/templates/config.json.tmpl +0 -4
- package/plugins/pbr/skills/build/SKILL.md +6 -6
- package/plugins/pbr/skills/config/SKILL.md +1 -0
- package/plugins/pbr/skills/help/SKILL.md +1 -0
- package/plugins/pbr/skills/pause/SKILL.md +1 -0
- package/plugins/pbr/skills/profile-user/SKILL.md +1 -0
- package/plugins/pbr/skills/quick/SKILL.md +2 -1
- package/plugins/pbr/skills/resume/SKILL.md +1 -0
- package/plugins/pbr/skills/scan/SKILL.md +1 -0
- package/plugins/pbr/skills/setup/SKILL.md +1 -0
- package/plugins/pbr/skills/shared/state-update.md +2 -2
- package/plugins/pbr/skills/status/SKILL.md +1 -0
- package/plugins/pbr/references/behavioral-contexts.md +0 -53
- package/plugins/pbr/scripts/lib/autonomy.js +0 -91
- package/plugins/pbr/scripts/lib/circuit-state.js +0 -133
- package/plugins/pbr/scripts/lib/completion.js +0 -377
- package/plugins/pbr/scripts/lib/hypothesis-runner.js +0 -127
- package/plugins/pbr/scripts/lib/local-llm/client.js +0 -237
- package/plugins/pbr/scripts/lib/local-llm/health.js +0 -12
- package/plugins/pbr/scripts/lib/local-llm/index.js +0 -89
- package/plugins/pbr/scripts/lib/local-llm/metrics.js +0 -20
- package/plugins/pbr/scripts/lib/local-llm/operations/classify-artifact.js +0 -4
- package/plugins/pbr/scripts/lib/local-llm/operations/classify-commit.js +0 -4
- package/plugins/pbr/scripts/lib/local-llm/operations/classify-error.js +0 -4
- package/plugins/pbr/scripts/lib/local-llm/operations/classify-file-intent.js +0 -4
- package/plugins/pbr/scripts/lib/local-llm/operations/score-source.js +0 -72
- package/plugins/pbr/scripts/lib/local-llm/operations/summarize-context.js +0 -62
- package/plugins/pbr/scripts/lib/local-llm/operations/triage-test-output.js +0 -12
- package/plugins/pbr/scripts/lib/local-llm/operations/validate-task.js +0 -4
- package/plugins/pbr/scripts/lib/local-llm/router.js +0 -101
- package/plugins/pbr/scripts/lib/local-llm/shadow.js +0 -60
- package/plugins/pbr/scripts/lib/local-llm/threshold-tuner.js +0 -118
- package/plugins/pbr/scripts/lib/team-composer.js +0 -87
- package/plugins/pbr/scripts/lib/team-coordinator.js +0 -153
- package/plugins/pbr/scripts/lib/template.js +0 -222
- package/plugins/pbr/scripts/lib/test-cache.js +0 -54
- package/plugins/pbr/scripts/lib/trust-gate.js +0 -84
- 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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
-
|
|
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')
|
|
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.`
|
|
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')
|
|
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.`
|
|
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
|
-
|
|
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
|
|
26
|
-
*
|
|
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
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 } =
|
|
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 =
|
|
81
|
-
const passed =
|
|
82
|
-
const total =
|
|
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
|
|
93
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
519
|
+
module.exports = { extractLearningsFromSummary, findSummaryFiles, extractFrontmatter, aggregateToKnowledge, countExistingRows, itemExists, insertTableRow, KNOWLEDGE_TEMPLATE };
|