@sienklogic/plan-build-run 2.61.0 → 2.62.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.
- package/CHANGELOG.md +19 -0
- package/package.json +2 -2
- package/plugins/copilot-pbr/hooks/hooks.json +24 -48
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/hooks/hooks.json +12 -32
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/hooks/hooks.json +12 -32
- package/plugins/pbr/scripts/check-config-change.js +26 -1
- package/plugins/pbr/scripts/check-subagent-output.js +89 -1
- package/plugins/pbr/scripts/config-schema.json +24 -0
- package/plugins/pbr/scripts/context-bridge.js +59 -0
- package/plugins/pbr/scripts/context-budget-check.js +65 -1
- package/plugins/pbr/scripts/enforce-pbr-workflow.js +2 -1
- package/plugins/pbr/scripts/event-handler.js +49 -1
- package/plugins/pbr/scripts/hook-server-client.js +213 -0
- package/plugins/pbr/scripts/hook-server.js +334 -0
- package/plugins/pbr/scripts/instructions-loaded.js +32 -1
- package/plugins/pbr/scripts/log-subagent.js +75 -1
- package/plugins/pbr/scripts/log-tool-failure.js +37 -0
- package/plugins/pbr/scripts/post-bash-triage.js +20 -1
- package/plugins/pbr/scripts/post-write-dispatch.js +117 -88
- package/plugins/pbr/scripts/pre-bash-dispatch.js +7 -0
- package/plugins/pbr/scripts/progress-tracker.js +112 -3
- package/plugins/pbr/scripts/run-hook.js +45 -8
- package/plugins/pbr/scripts/session-cleanup.js +36 -1
- package/plugins/pbr/scripts/suggest-compact.js +3 -1
- package/plugins/pbr/scripts/task-completed.js +35 -1
- package/plugins/pbr/scripts/track-context-budget.js +167 -117
- package/plugins/pbr/scripts/worktree-create.js +49 -1
- package/plugins/pbr/scripts/worktree-remove.js +46 -1
|
@@ -549,5 +549,93 @@ async function main() {
|
|
|
549
549
|
process.exit(0);
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
|
|
552
|
+
/**
|
|
553
|
+
* HTTP handler for hook-server.js integration.
|
|
554
|
+
* Called as handleHttp(reqBody, cache) where reqBody = { event, tool, data, planningDir, cache }.
|
|
555
|
+
* Must NOT call process.exit().
|
|
556
|
+
* @param {{ data: object, planningDir: string }} reqBody
|
|
557
|
+
* @returns {Promise<{ additionalContext: string }|null>}
|
|
558
|
+
*/
|
|
559
|
+
async function handleHttp(reqBody) {
|
|
560
|
+
const data = reqBody.data || {};
|
|
561
|
+
const planningDir = reqBody.planningDir;
|
|
562
|
+
if (!planningDir || !fs.existsSync(planningDir)) return null;
|
|
563
|
+
|
|
564
|
+
const agentType = data.agent_type || data.tool_input?.subagent_type || data.subagent_type || '';
|
|
565
|
+
const outputSpec = AGENT_OUTPUTS[agentType];
|
|
566
|
+
if (!outputSpec) {
|
|
567
|
+
const shortName = agentType.startsWith('pbr:') ? agentType.slice(4) : agentType;
|
|
568
|
+
if (KNOWN_AGENTS && KNOWN_AGENTS.includes && KNOWN_AGENTS.includes(shortName)) {
|
|
569
|
+
logHook('check-subagent-output', 'PostToolUse', 'missing-output-spec', {
|
|
570
|
+
agent_type: agentType,
|
|
571
|
+
message: `Agent ${agentType} is in KNOWN_AGENTS but has no AGENT_OUTPUTS entry. Add one to check-subagent-output.js.`
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let activeSkill = sessionLoad(planningDir).activeSkill || '';
|
|
578
|
+
if (!activeSkill) {
|
|
579
|
+
try { activeSkill = fs.readFileSync(path.join(planningDir, '.active-skill'), 'utf8').trim(); } catch (_) { /* legacy file missing */ }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const found = outputSpec.check(planningDir);
|
|
583
|
+
const genericMissing = found.length === 0 && !outputSpec.noFileExpected;
|
|
584
|
+
const skillWarnings = [];
|
|
585
|
+
|
|
586
|
+
if (!activeSkill && agentType !== 'pbr:general' && agentType !== 'pbr:plan-checker' && agentType !== 'pbr:integration-checker') {
|
|
587
|
+
skillWarnings.push('.active-skill file is missing — the orchestrating skill never wrote it. This means skill-workflow guards were inactive for this entire operation. CRITICAL: Write the skill name to .planning/.active-skill BEFORE spawning agents.');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (agentType === 'pbr:executor' || agentType === 'pbr:verifier') {
|
|
591
|
+
const roadmapWarning = checkRoadmapStaleness(planningDir);
|
|
592
|
+
if (roadmapWarning) skillWarnings.push(roadmapWarning);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (found._stale && (agentType === 'pbr:researcher' || agentType === 'pbr:synthesizer')) {
|
|
596
|
+
const label = agentType === 'pbr:researcher' ? 'Researcher' : 'Synthesizer';
|
|
597
|
+
skillWarnings.push(`${label} output may be stale — no recent output files detected.`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const skillCheckKey = `${activeSkill}:${agentType}`;
|
|
601
|
+
const skillCheck = SKILL_CHECKS[skillCheckKey];
|
|
602
|
+
if (skillCheck) skillCheck.check(planningDir, found, skillWarnings);
|
|
603
|
+
|
|
604
|
+
// LLM classification helper (advisory, never throws)
|
|
605
|
+
async function getLlmNote() {
|
|
606
|
+
try {
|
|
607
|
+
const cwd = process.env.PBR_PROJECT_ROOT || process.cwd();
|
|
608
|
+
const llmConfig = loadLocalLlmConfig(cwd);
|
|
609
|
+
const errorText = (data.tool_output || '').substring(0, 500);
|
|
610
|
+
if (!errorText) return '';
|
|
611
|
+
const llmResult = await classifyError(llmConfig, planningDir, errorText, agentType, data.session_id);
|
|
612
|
+
if (llmResult && llmResult.category) {
|
|
613
|
+
return `\nLLM error category: ${llmResult.category} (confidence: ${(llmResult.confidence * 100).toFixed(0)}%)`;
|
|
614
|
+
}
|
|
615
|
+
} catch (_e) { /* never propagate */ }
|
|
616
|
+
return '';
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (genericMissing && skillWarnings.length > 0) {
|
|
620
|
+
logHook('check-subagent-output', 'PostToolUse', 'skill-warning', { skill: activeSkill, agent_type: agentType, warnings: skillWarnings });
|
|
621
|
+
const llmCategoryNote = await getLlmNote();
|
|
622
|
+
const msg = `Warning: Agent ${agentType} completed but no ${outputSpec.description} was found.\nSkill-specific warnings:\n` +
|
|
623
|
+
skillWarnings.map(w => `- ${w}`).join('\n') + llmCategoryNote;
|
|
624
|
+
return { additionalContext: msg };
|
|
625
|
+
} else if (genericMissing) {
|
|
626
|
+
logHook('check-subagent-output', 'PostToolUse', 'warning', { agent_type: agentType, expected: outputSpec.description, found: 'none' });
|
|
627
|
+
const llmCategoryNote = await getLlmNote();
|
|
628
|
+
return {
|
|
629
|
+
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
|
|
630
|
+
};
|
|
631
|
+
} else if (skillWarnings.length > 0) {
|
|
632
|
+
logHook('check-subagent-output', 'PostToolUse', 'skill-warning', { skill: activeSkill, agent_type: agentType, warnings: skillWarnings });
|
|
633
|
+
return { additionalContext: 'Skill-specific warnings:\n' + skillWarnings.map(w => `- ${w}`).join('\n') };
|
|
634
|
+
} else {
|
|
635
|
+
logHook('check-subagent-output', 'PostToolUse', 'verified', { agent_type: agentType, found: found });
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
module.exports = { AGENT_OUTPUTS, SKILL_CHECKS, findInPhaseDir, findInQuickDir, checkSummaryCommits, isRecent, getCurrentPhase, checkRoadmapStaleness, handleHttp };
|
|
553
641
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -311,6 +311,30 @@
|
|
|
311
311
|
},
|
|
312
312
|
"additionalProperties": false
|
|
313
313
|
},
|
|
314
|
+
"hook_server": {
|
|
315
|
+
"type": "object",
|
|
316
|
+
"description": "Persistent HTTP hook server settings. When enabled, hooks POST to the server instead of spawning per-hook processes.",
|
|
317
|
+
"properties": {
|
|
318
|
+
"enabled": {
|
|
319
|
+
"type": "boolean",
|
|
320
|
+
"default": false,
|
|
321
|
+
"description": "When true, hook-server-client.js routes hook events to the persistent hook-server.js process."
|
|
322
|
+
},
|
|
323
|
+
"port": {
|
|
324
|
+
"type": "integer",
|
|
325
|
+
"minimum": 1024,
|
|
326
|
+
"maximum": 65535,
|
|
327
|
+
"default": 19836,
|
|
328
|
+
"description": "TCP port the hook server listens on (127.0.0.1 only)."
|
|
329
|
+
},
|
|
330
|
+
"event_log": {
|
|
331
|
+
"type": "boolean",
|
|
332
|
+
"default": true,
|
|
333
|
+
"description": "When true, all hook events are appended to .planning/.hook-events.jsonl."
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
"additionalProperties": false
|
|
337
|
+
},
|
|
314
338
|
"local_llm": {
|
|
315
339
|
"type": "object",
|
|
316
340
|
"properties": {
|
|
@@ -239,6 +239,21 @@ function main() {
|
|
|
239
239
|
source: 'bridge'
|
|
240
240
|
});
|
|
241
241
|
process.stdout.write(JSON.stringify(output));
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// For Write|Edit tools, also run suggest-compact check in-process
|
|
246
|
+
// (eliminates a separate Node process spawn per Write/Edit).
|
|
247
|
+
// Detect Write|Edit by presence of file_path in tool_input.
|
|
248
|
+
const toolInput = data.tool_input || {};
|
|
249
|
+
if (toolInput.file_path || toolInput.path) {
|
|
250
|
+
try {
|
|
251
|
+
const { checkCompaction } = require('./suggest-compact');
|
|
252
|
+
const compactResult = checkCompaction(planningDir, cwd);
|
|
253
|
+
if (compactResult) {
|
|
254
|
+
process.stdout.write(JSON.stringify(compactResult));
|
|
255
|
+
}
|
|
256
|
+
} catch (_e) { /* best-effort — never block on compact check */ }
|
|
242
257
|
}
|
|
243
258
|
|
|
244
259
|
process.exit(0);
|
|
@@ -249,6 +264,49 @@ function main() {
|
|
|
249
264
|
});
|
|
250
265
|
}
|
|
251
266
|
|
|
267
|
+
/**
|
|
268
|
+
* HTTP handler for hook-server.js.
|
|
269
|
+
* Called directly instead of spawning a subprocess.
|
|
270
|
+
*
|
|
271
|
+
* @param {Object} reqBody - Full hook request body { event, tool, data, planningDir, cache }
|
|
272
|
+
* @param {Object} _cache - Server in-memory cache (unused by this handler)
|
|
273
|
+
* @returns {{ additionalContext: string }|null}
|
|
274
|
+
*/
|
|
275
|
+
function handleHttp(reqBody, _cache) {
|
|
276
|
+
try {
|
|
277
|
+
const planningDir = reqBody.planningDir;
|
|
278
|
+
const data = reqBody.data || {};
|
|
279
|
+
if (!planningDir || !fs.existsSync(planningDir)) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const { output } = updateBridge(planningDir, data);
|
|
284
|
+
|
|
285
|
+
if (output) {
|
|
286
|
+
logHook('context-bridge', 'PostToolUse', 'warn', {
|
|
287
|
+
percent: output.additionalContext.match(/(\d+)%/)?.[1],
|
|
288
|
+
source: 'bridge'
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// For Write|Edit tools, also run suggest-compact check in-process
|
|
293
|
+
const toolInput = data.tool_input || {};
|
|
294
|
+
if (!output && (toolInput.file_path || toolInput.path)) {
|
|
295
|
+
try {
|
|
296
|
+
const { checkCompaction } = require('./suggest-compact');
|
|
297
|
+
const compactResult = checkCompaction(planningDir, reqBody.planningDir ? reqBody.planningDir.replace(/[/\\]\.planning$/, '') : process.cwd());
|
|
298
|
+
if (compactResult) {
|
|
299
|
+
return compactResult;
|
|
300
|
+
}
|
|
301
|
+
} catch (_e) { /* best-effort — never block on compact check */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return output || null;
|
|
305
|
+
} catch (_e) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
252
310
|
module.exports = {
|
|
253
311
|
getTier,
|
|
254
312
|
loadBridge,
|
|
@@ -256,6 +314,7 @@ module.exports = {
|
|
|
256
314
|
estimateFromHeuristic,
|
|
257
315
|
shouldWarn,
|
|
258
316
|
updateBridge,
|
|
317
|
+
handleHttp,
|
|
259
318
|
TIERS,
|
|
260
319
|
TIER_MESSAGES,
|
|
261
320
|
DEBOUNCE_INTERVAL,
|
|
@@ -298,5 +298,69 @@ function buildRecoveryContext(activeOp, roadmapSummary, currentPlan, configHighl
|
|
|
298
298
|
return parts.length > 2 ? parts.join('\n') : '';
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
/**
|
|
302
|
+
* handleHttp — hook-server.js interface.
|
|
303
|
+
* reqBody = { event, tool, data, planningDir, cache }
|
|
304
|
+
* Returns { additionalContext: "..." } or null. Never calls process.exit().
|
|
305
|
+
*/
|
|
306
|
+
function handleHttp(reqBody) {
|
|
307
|
+
const planningDir = reqBody && reqBody.planningDir;
|
|
308
|
+
const stateFile = planningDir && path.join(planningDir, 'STATE.md');
|
|
309
|
+
|
|
310
|
+
if (!stateFile || !fs.existsSync(stateFile)) return null;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
let content = fs.readFileSync(stateFile, 'utf8');
|
|
314
|
+
const timestamp = new Date().toISOString();
|
|
315
|
+
|
|
316
|
+
const activeOp = readActiveOperation(planningDir);
|
|
317
|
+
const roadmapSummary = readRoadmapSummary(planningDir);
|
|
318
|
+
const currentPlan = readCurrentPlan(planningDir, content);
|
|
319
|
+
const configHighlights = readConfigHighlights(planningDir);
|
|
320
|
+
const recentErrors = readRecentErrors(planningDir, 3);
|
|
321
|
+
const recentAgents = readRecentAgents(planningDir, 5);
|
|
322
|
+
|
|
323
|
+
const continuityParts = [
|
|
324
|
+
`Last session: ${timestamp}`,
|
|
325
|
+
'Compaction occurred: context was auto-compacted at this point'
|
|
326
|
+
];
|
|
327
|
+
if (activeOp) continuityParts.push(`Active operation at compaction: ${activeOp}`);
|
|
328
|
+
if (roadmapSummary) continuityParts.push(`Roadmap progress:\n${roadmapSummary}`);
|
|
329
|
+
if (currentPlan) continuityParts.push(`Current plan: ${currentPlan}`);
|
|
330
|
+
if (configHighlights) continuityParts.push(`Config: ${configHighlights}`);
|
|
331
|
+
if (recentErrors.length > 0) continuityParts.push(`Recent errors:\n${recentErrors.map(e => ' - ' + e).join('\n')}`);
|
|
332
|
+
if (recentAgents.length > 0) continuityParts.push(`Recent agents: ${recentAgents.join(', ')}`);
|
|
333
|
+
continuityParts.push('Note: Some conversation context may have been lost. Check STATE.md and SUMMARY.md files for ground truth.');
|
|
334
|
+
|
|
335
|
+
const continuityHeader = '## Session Continuity';
|
|
336
|
+
const continuityContent = continuityParts.join('\n');
|
|
337
|
+
|
|
338
|
+
if (content.includes(continuityHeader)) {
|
|
339
|
+
content = content.replace(
|
|
340
|
+
/## Session Continuity[\s\S]*?(?=\n## |\n---|\s*$)/,
|
|
341
|
+
() => `${continuityHeader}\n${continuityContent}\n`
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
content = content.trimEnd() + `\n\n${continuityHeader}\n${continuityContent}\n`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
lockedFileUpdate(stateFile, () => content);
|
|
348
|
+
|
|
349
|
+
const recoveryContext = buildRecoveryContext(activeOp, roadmapSummary, currentPlan, configHighlights, recentErrors, recentAgents);
|
|
350
|
+
if (recoveryContext) {
|
|
351
|
+
logHook('context-budget-check', 'PreCompact', 'saved', {
|
|
352
|
+
stateFile: 'STATE.md',
|
|
353
|
+
hasRoadmap: !!roadmapSummary,
|
|
354
|
+
hasPlan: !!currentPlan,
|
|
355
|
+
hasConfig: !!configHighlights
|
|
356
|
+
});
|
|
357
|
+
return { additionalContext: recoveryContext };
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
logHook('context-budget-check', 'PreCompact', 'error', { error: e.message });
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = { readRoadmapSummary, readCurrentPlan, readConfigHighlights, buildRecoveryContext, readRecentErrors, readRecentAgents, handleHttp };
|
|
302
366
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const { logHook } = require('./hook-logger');
|
|
24
|
-
const { sessionLoad } = require('./pbr-tools');
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
26
|
* Load the enforcement configuration from .planning/config.json.
|
|
@@ -75,6 +74,7 @@ function checkUnmanagedSourceWrite(data) {
|
|
|
75
74
|
if (!fs.existsSync(planningDir)) return null;
|
|
76
75
|
|
|
77
76
|
// Skip if a PBR skill is active — try .session.json first, fall back to legacy .active-skill
|
|
77
|
+
const { sessionLoad } = require('./pbr-tools');
|
|
78
78
|
let activeSkill = sessionLoad(planningDir).activeSkill || '';
|
|
79
79
|
if (!activeSkill) {
|
|
80
80
|
try { activeSkill = fs.readFileSync(path.join(planningDir, '.active-skill'), 'utf8').trim(); } catch (_) { /* legacy file missing */ }
|
|
@@ -240,6 +240,7 @@ function checkUnmanagedCommit(data) {
|
|
|
240
240
|
if (!fs.existsSync(planningDir)) return null;
|
|
241
241
|
|
|
242
242
|
// Skip if a PBR skill is active — try .session.json first, fall back to legacy .active-skill
|
|
243
|
+
const { sessionLoad } = require('./pbr-tools');
|
|
243
244
|
let activeSkillCommit = sessionLoad(planningDir).activeSkill || '';
|
|
244
245
|
if (!activeSkillCommit) {
|
|
245
246
|
try { activeSkillCommit = fs.readFileSync(path.join(planningDir, '.active-skill'), 'utf8').trim(); } catch (_) { /* legacy file missing */ }
|
|
@@ -158,5 +158,53 @@ function main() {
|
|
|
158
158
|
process.exit(0);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
/**
|
|
162
|
+
* HTTP handler for hook-server.js integration.
|
|
163
|
+
* Called as handleHttp(reqBody, cache) where reqBody = { event, tool, data, planningDir, ... }.
|
|
164
|
+
* Must NOT call process.exit().
|
|
165
|
+
* @param {{ data: object, planningDir: string }} reqBody
|
|
166
|
+
* @returns {{ additionalContext: string }|null}
|
|
167
|
+
*/
|
|
168
|
+
function handleHttp(reqBody) {
|
|
169
|
+
const data = reqBody.data || {};
|
|
170
|
+
|
|
171
|
+
if (!isExecutorAgent(data)) return null;
|
|
172
|
+
|
|
173
|
+
const agentType = data.agent_type || data.subagent_type;
|
|
174
|
+
logHook('event-handler', 'SubagentStop', 'executor-complete', { agent_type: agentType });
|
|
175
|
+
logEvent('workflow', 'executor-complete', { agent_type: agentType });
|
|
176
|
+
|
|
177
|
+
const planningDir = reqBody.planningDir;
|
|
178
|
+
if (!planningDir || !fs.existsSync(planningDir)) return null;
|
|
179
|
+
|
|
180
|
+
if (!shouldAutoVerify(planningDir)) {
|
|
181
|
+
logHook('event-handler', 'SubagentStop', 'skip-verify', { reason: 'config/depth' });
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const stateInfo = getPhaseFromState(planningDir);
|
|
186
|
+
if (!stateInfo || stateInfo.status !== 'building') {
|
|
187
|
+
logHook('event-handler', 'SubagentStop', 'skip-verify', {
|
|
188
|
+
reason: stateInfo ? `status=${stateInfo.status}` : 'no-state'
|
|
189
|
+
});
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
writeAutoVerifySignal(planningDir, stateInfo.phase);
|
|
194
|
+
|
|
195
|
+
const lastMsg = data.last_assistant_message || '';
|
|
196
|
+
let verifyHint = '';
|
|
197
|
+
if (lastMsg) {
|
|
198
|
+
const lowerMsg = lastMsg.toLowerCase();
|
|
199
|
+
if (lowerMsg.includes('error') || lowerMsg.includes('failed') || lowerMsg.includes('warning')) {
|
|
200
|
+
verifyHint = ' Note: executor output mentions errors/warnings — verification should pay close attention.';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
additionalContext: `Executor complete. Auto-verification queued for Phase ${stateInfo.phase}.${verifyHint}`
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { isExecutorAgent, shouldAutoVerify, getPhaseFromState, handleHttp };
|
|
162
210
|
if (require.main === module || process.argv[1] === __filename) { main(); }
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-server-client.js — Thin HTTP client for the PBR hook server.
|
|
5
|
+
*
|
|
6
|
+
* Reads hook input from stdin (JSON), POSTs it to the local hook server,
|
|
7
|
+
* and relays the server response to stdout.
|
|
8
|
+
*
|
|
9
|
+
* Fail-open by design: if the server is not reachable or any error occurs,
|
|
10
|
+
* the client exits 0 (allowing the hook to pass through silently).
|
|
11
|
+
*
|
|
12
|
+
* Usage (called by hooks.json commands):
|
|
13
|
+
* node hook-server-client.js <hook-name> [port]
|
|
14
|
+
*
|
|
15
|
+
* Arguments:
|
|
16
|
+
* argv[2] Hook name (e.g. "track-context-budget")
|
|
17
|
+
* argv[3] Server port (default: 19836)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const http = require('http');
|
|
23
|
+
const net = require('net');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PORT = 19836;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// HOOK_EVENT_MAP: maps hook script names to { event, tool } pairs
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const HOOK_EVENT_MAP = {
|
|
32
|
+
'track-context-budget': { event: 'PostToolUse', tool: 'Read' },
|
|
33
|
+
'context-bridge': { event: 'PostToolUse', tool: 'Write' },
|
|
34
|
+
'post-write-dispatch': { event: 'PostToolUse', tool: 'Write' },
|
|
35
|
+
'post-bash-triage': { event: 'PostToolUse', tool: 'Bash' },
|
|
36
|
+
'check-subagent-output':{ event: 'PostToolUse', tool: 'Task' },
|
|
37
|
+
'log-tool-failure': { event: 'PostToolUseFailure', tool: '*' },
|
|
38
|
+
'log-subagent-start': { event: 'SubagentStart', tool: '*' },
|
|
39
|
+
'log-subagent': { event: 'SubagentStop', tool: '*' },
|
|
40
|
+
'event-handler': { event: 'SubagentStop', tool: '*' },
|
|
41
|
+
'task-completed': { event: 'TaskCompleted', tool: '*' },
|
|
42
|
+
'context-budget-check': { event: 'PreCompact', tool: '*' },
|
|
43
|
+
'instructions-loaded': { event: 'InstructionsLoaded', tool: '*' },
|
|
44
|
+
'check-config-change': { event: 'ConfigChange', tool: '*' },
|
|
45
|
+
'session-cleanup': { event: 'SessionEnd', tool: '*' },
|
|
46
|
+
'worktree-create': { event: 'WorktreeCreate', tool: '*' },
|
|
47
|
+
'worktree-remove': { event: 'WorktreeRemove', tool: '*' }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// TCP probe — check if server port is open before committing to an HTTP call
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Probe whether the given port is accepting connections on 127.0.0.1.
|
|
56
|
+
* @param {number} port
|
|
57
|
+
* @param {number} [timeoutMs=200]
|
|
58
|
+
* @returns {Promise<boolean>}
|
|
59
|
+
*/
|
|
60
|
+
function probePort(port, timeoutMs) {
|
|
61
|
+
timeoutMs = timeoutMs || 200;
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const socket = new net.Socket();
|
|
64
|
+
let settled = false;
|
|
65
|
+
|
|
66
|
+
function done(result) {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
socket.destroy();
|
|
70
|
+
resolve(result);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
socket.setTimeout(timeoutMs);
|
|
74
|
+
socket.connect(port, '127.0.0.1', () => done(true));
|
|
75
|
+
socket.on('error', () => done(false));
|
|
76
|
+
socket.on('timeout', () => done(false));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// HTTP POST to hook server
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* POST payload to http://127.0.0.1:{port}/hook
|
|
86
|
+
* @param {number} port
|
|
87
|
+
* @param {string} body - JSON string
|
|
88
|
+
* @param {number} [timeoutMs=200]
|
|
89
|
+
* @returns {Promise<string>} Response body text
|
|
90
|
+
*/
|
|
91
|
+
function postHook(port, body, timeoutMs) {
|
|
92
|
+
timeoutMs = timeoutMs || 200;
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const options = {
|
|
95
|
+
hostname: '127.0.0.1',
|
|
96
|
+
port,
|
|
97
|
+
path: '/hook',
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
'Content-Length': Buffer.byteLength(body)
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const req = http.request(options, (res) => {
|
|
106
|
+
let data = '';
|
|
107
|
+
res.setEncoding('utf8');
|
|
108
|
+
res.on('data', chunk => { data += chunk; });
|
|
109
|
+
res.on('end', () => resolve(data));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
req.setTimeout(timeoutMs, () => {
|
|
113
|
+
req.destroy();
|
|
114
|
+
reject(new Error('request timeout'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
req.on('error', reject);
|
|
118
|
+
req.write(body);
|
|
119
|
+
req.end();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Main
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async function main() {
|
|
128
|
+
const hookName = process.argv[2] || '';
|
|
129
|
+
const port = parseInt(process.argv[3], 10) || DEFAULT_PORT;
|
|
130
|
+
|
|
131
|
+
// Read stdin
|
|
132
|
+
let stdinData = '';
|
|
133
|
+
process.stdin.setEncoding('utf8');
|
|
134
|
+
for await (const chunk of process.stdin) {
|
|
135
|
+
stdinData += chunk;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Parse stdin as JSON (fail-open on bad input)
|
|
139
|
+
let inputData;
|
|
140
|
+
try {
|
|
141
|
+
inputData = JSON.parse(stdinData);
|
|
142
|
+
} catch (_e) {
|
|
143
|
+
process.exit(0);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Resolve event/tool from hook name
|
|
148
|
+
const mapping = HOOK_EVENT_MAP[hookName];
|
|
149
|
+
if (!mapping) {
|
|
150
|
+
// Unknown hook — pass through silently
|
|
151
|
+
process.exit(0);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Probe port before attempting HTTP call
|
|
156
|
+
let reachable = false;
|
|
157
|
+
try {
|
|
158
|
+
reachable = await probePort(port, 200);
|
|
159
|
+
} catch (_e) {
|
|
160
|
+
reachable = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!reachable) {
|
|
164
|
+
// Server not running — fail-open
|
|
165
|
+
process.exit(0);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build request payload
|
|
170
|
+
const payload = JSON.stringify({
|
|
171
|
+
event: mapping.event,
|
|
172
|
+
tool: mapping.tool,
|
|
173
|
+
data: inputData
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// POST to hook server
|
|
177
|
+
let responseText = '';
|
|
178
|
+
try {
|
|
179
|
+
responseText = await postHook(port, payload, 200);
|
|
180
|
+
} catch (_e) {
|
|
181
|
+
process.exit(0);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Parse and relay response
|
|
186
|
+
let response;
|
|
187
|
+
try {
|
|
188
|
+
response = JSON.parse(responseText);
|
|
189
|
+
} catch (_e) {
|
|
190
|
+
process.exit(0);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Relay additionalContext or decision/reason to Claude Code via stdout
|
|
195
|
+
if (response.additionalContext || response.decision !== undefined) {
|
|
196
|
+
const out = {};
|
|
197
|
+
if (response.additionalContext) out.additionalContext = response.additionalContext;
|
|
198
|
+
if (response.decision !== undefined) {
|
|
199
|
+
out.decision = response.decision;
|
|
200
|
+
if (response.reason !== undefined) out.reason = response.reason;
|
|
201
|
+
}
|
|
202
|
+
process.stdout.write(JSON.stringify(out));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
main().catch(() => {
|
|
209
|
+
// Fail-open on any unhandled error
|
|
210
|
+
process.exit(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
module.exports = { probePort, postHook, HOOK_EVENT_MAP, DEFAULT_PORT };
|