@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +2 -2
  3. package/plugins/copilot-pbr/hooks/hooks.json +24 -48
  4. package/plugins/copilot-pbr/plugin.json +1 -1
  5. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  6. package/plugins/cursor-pbr/hooks/hooks.json +12 -32
  7. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  8. package/plugins/pbr/hooks/hooks.json +12 -32
  9. package/plugins/pbr/scripts/check-config-change.js +26 -1
  10. package/plugins/pbr/scripts/check-subagent-output.js +89 -1
  11. package/plugins/pbr/scripts/config-schema.json +24 -0
  12. package/plugins/pbr/scripts/context-bridge.js +59 -0
  13. package/plugins/pbr/scripts/context-budget-check.js +65 -1
  14. package/plugins/pbr/scripts/enforce-pbr-workflow.js +2 -1
  15. package/plugins/pbr/scripts/event-handler.js +49 -1
  16. package/plugins/pbr/scripts/hook-server-client.js +213 -0
  17. package/plugins/pbr/scripts/hook-server.js +334 -0
  18. package/plugins/pbr/scripts/instructions-loaded.js +32 -1
  19. package/plugins/pbr/scripts/log-subagent.js +75 -1
  20. package/plugins/pbr/scripts/log-tool-failure.js +37 -0
  21. package/plugins/pbr/scripts/post-bash-triage.js +20 -1
  22. package/plugins/pbr/scripts/post-write-dispatch.js +117 -88
  23. package/plugins/pbr/scripts/pre-bash-dispatch.js +7 -0
  24. package/plugins/pbr/scripts/progress-tracker.js +112 -3
  25. package/plugins/pbr/scripts/run-hook.js +45 -8
  26. package/plugins/pbr/scripts/session-cleanup.js +36 -1
  27. package/plugins/pbr/scripts/suggest-compact.js +3 -1
  28. package/plugins/pbr/scripts/task-completed.js +35 -1
  29. package/plugins/pbr/scripts/track-context-budget.js +167 -117
  30. package/plugins/pbr/scripts/worktree-create.js +49 -1
  31. 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
- module.exports = { AGENT_OUTPUTS, SKILL_CHECKS, findInPhaseDir, findInQuickDir, checkSummaryCommits, isRecent, getCurrentPhase, checkRoadmapStaleness };
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
- module.exports = { readRoadmapSummary, readCurrentPlan, readConfigHighlights, buildRecoveryContext, readRecentErrors, readRecentAgents };
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
- module.exports = { isExecutorAgent, shouldAutoVerify, getPhaseFromState };
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 };