@virtengine/openfleet 0.25.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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,1188 @@
1
+ /**
2
+ * @module agent-hooks
3
+ * @description Comprehensive agent lifecycle hooks system for the openfleet
4
+ * orchestrator. Provides a configurable hook pipeline that fires at key points
5
+ * in the agent task lifecycle (session start/stop, tool use, git operations,
6
+ * PR creation, task completion).
7
+ *
8
+ * Hooks can be loaded from config files (.codex/hooks.json, .vscode/hooks.json,
9
+ * openfleet.config.json) or registered programmatically. Each hook targets
10
+ * one or more SDKs (codex, copilot, claude) and can be blocking or fire-and-forget.
11
+ *
12
+ * @example
13
+ * import { loadHooks, executeHooks, registerBuiltinHooks } from "./agent-hooks.mjs";
14
+ *
15
+ * await loadHooks(); // Load from config files
16
+ * registerBuiltinHooks(); // Register built-in quality gates
17
+ *
18
+ * const ctx = { taskId: "abc", branch: "ve/abc-fix-bug", sdk: "codex" };
19
+ * await executeHooks("SessionStart", ctx);
20
+ *
21
+ * const result = await executeBlockingHooks("PrePush", ctx);
22
+ * if (!result.passed) {
23
+ * console.error("Quality gates failed:", result.failures);
24
+ * }
25
+ */
26
+
27
+ import { spawnSync, spawn } from "node:child_process";
28
+ import { readFileSync, existsSync } from "node:fs";
29
+ import { resolve, dirname } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { randomUUID } from "node:crypto";
32
+ import { resolveRepoRoot } from "./repo-root.mjs";
33
+
34
+ // ── Constants ───────────────────────────────────────────────────────────────
35
+
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = dirname(__filename);
38
+
39
+ /** Repository root for the active workspace */
40
+ const REPO_ROOT = resolveRepoRoot();
41
+
42
+ /** Console log prefix */
43
+ export const TAG = "[agent-hooks]";
44
+
45
+ /** Default timeout for hook execution (60 seconds) */
46
+ const DEFAULT_TIMEOUT_MS = 60_000;
47
+
48
+ /** Maximum output captured per hook (64 KB) */
49
+ const MAX_OUTPUT_BYTES = 64 * 1024;
50
+
51
+ /** Whether we're running on Windows */
52
+ const IS_WINDOWS = process.platform === "win32";
53
+
54
+ /** Default max retries for retryable hooks */
55
+ const DEFAULT_MAX_RETRIES = 2;
56
+
57
+ /** Base delay between retries in ms (doubles each attempt) */
58
+ const RETRY_BASE_DELAY_MS = 500;
59
+
60
+ /** Transient exit codes that suggest retry may help */
61
+ const TRANSIENT_EXIT_CODES = new Set([124, 125, 126, 127, 128, 137, 143]);
62
+
63
+ // ── Hook Metrics ────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * @typedef {Object} HookMetricEntry
67
+ * @property {number} totalRuns - Total executions
68
+ * @property {number} successes - Successful executions
69
+ * @property {number} failures - Failed executions
70
+ * @property {number} retries - Total retry attempts
71
+ * @property {number} totalDurMs - Cumulative duration in ms
72
+ * @property {number} lastRunMs - Timestamp of last run
73
+ */
74
+
75
+ /** @type {Map<string, HookMetricEntry>} */
76
+ const _metrics = new Map();
77
+
78
+ function _recordMetric(hookId, success, durationMs, retried = false) {
79
+ if (!_metrics.has(hookId)) {
80
+ _metrics.set(hookId, {
81
+ totalRuns: 0,
82
+ successes: 0,
83
+ failures: 0,
84
+ retries: 0,
85
+ totalDurMs: 0,
86
+ lastRunMs: 0,
87
+ });
88
+ }
89
+ const m = _metrics.get(hookId);
90
+ m.totalRuns++;
91
+ if (success) m.successes++;
92
+ else m.failures++;
93
+ if (retried) m.retries++;
94
+ m.totalDurMs += durationMs;
95
+ m.lastRunMs = Date.now();
96
+ }
97
+
98
+ /**
99
+ * Get hook execution metrics. Useful for monitoring reliability improvements.
100
+ *
101
+ * @returns {Record<string, HookMetricEntry & { avgDurMs: number, failureRate: number }>}
102
+ */
103
+ export function getHookMetrics() {
104
+ const result = {};
105
+ for (const [id, m] of _metrics.entries()) {
106
+ result[id] = {
107
+ ...m,
108
+ avgDurMs: m.totalRuns > 0 ? Math.round(m.totalDurMs / m.totalRuns) : 0,
109
+ failureRate:
110
+ m.totalRuns > 0 ? Math.round((m.failures / m.totalRuns) * 10000) / 100 : 0,
111
+ };
112
+ }
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * Reset hook metrics. Useful for testing.
118
+ */
119
+ export function resetHookMetrics() {
120
+ _metrics.clear();
121
+ }
122
+
123
+ /**
124
+ * Valid hook event names matching VS Code / Claude Code naming conventions.
125
+ * @type {readonly string[]}
126
+ */
127
+ export const HOOK_EVENTS = Object.freeze([
128
+ "SessionStart",
129
+ "SessionStop",
130
+ "PreToolUse",
131
+ "PostToolUse",
132
+ "SubagentStart",
133
+ "SubagentStop",
134
+ "PrePush",
135
+ "PostPush",
136
+ "PreCommit",
137
+ "PostCommit",
138
+ "PrePR",
139
+ "PostPR",
140
+ "TaskComplete",
141
+ ]);
142
+
143
+ /**
144
+ * Canonical SDK names.
145
+ * @type {readonly string[]}
146
+ */
147
+ const VALID_SDKS = Object.freeze(["codex", "copilot", "claude"]);
148
+
149
+ /**
150
+ * Wildcard indicating a hook applies to all SDKs.
151
+ * @type {string}
152
+ */
153
+ const SDK_WILDCARD = "*";
154
+
155
+ function envFlag(name, defaultValue = false) {
156
+ const raw = process.env[name];
157
+ if (raw == null || raw === "") return defaultValue;
158
+ const normalized = String(raw).trim().toLowerCase();
159
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
160
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
161
+ return defaultValue;
162
+ }
163
+
164
+ // ── Types (JSDoc) ───────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * @typedef {Object} HookDefinition
168
+ * @property {string} id - Unique identifier (auto-generated if omitted)
169
+ * @property {string} command - Shell command to execute
170
+ * @property {string} [description] - Human-readable description
171
+ * @property {number} [timeout] - Timeout in milliseconds (default: 60000)
172
+ * @property {boolean} [blocking] - If true, failure stops the pipeline (default: false)
173
+ * @property {string[]} [sdks] - SDK filter: ["codex"], ["copilot","claude"], or ["*"] (default: ["*"])
174
+ * @property {Record<string,string>} [env] - Additional environment variables
175
+ * @property {boolean} [builtin] - Whether this is a built-in hook (not from config)
176
+ * @property {boolean} [retryable] - If true, retry on transient failures (default: false)
177
+ * @property {number} [maxRetries] - Max retry attempts (default: 2)
178
+ */
179
+
180
+ /**
181
+ * @typedef {Object} HookContext
182
+ * @property {string} [taskId] - Current task ID
183
+ * @property {string} [taskTitle] - Current task title
184
+ * @property {string} [branch] - Branch name
185
+ * @property {string} [worktreePath] - Worktree path
186
+ * @property {string} [sdk] - Active SDK name (codex/copilot/claude)
187
+ * @property {string} [event] - Hook event name (set automatically)
188
+ * @property {number} [timestamp] - Execution timestamp (set automatically)
189
+ * @property {string} [repoRoot] - Repository root path
190
+ * @property {Record<string,string>} [extra] - Additional context values
191
+ */
192
+
193
+ /**
194
+ * @typedef {Object} HookResult
195
+ * @property {string} id - Hook ID
196
+ * @property {string} command - Command that was executed
197
+ * @property {boolean} success - Whether the hook succeeded (exit code 0)
198
+ * @property {number} exitCode - Process exit code (-1 on timeout/error)
199
+ * @property {string} stdout - Captured stdout (truncated)
200
+ * @property {string} stderr - Captured stderr (truncated)
201
+ * @property {number} durationMs - Execution duration in milliseconds
202
+ * @property {string} [error] - Error message if hook failed to execute
203
+ */
204
+
205
+ /**
206
+ * @typedef {Object} BlockingHookResult
207
+ * @property {boolean} passed - True if all blocking hooks succeeded
208
+ * @property {HookResult[]} results - Results from all executed hooks
209
+ * @property {HookResult[]} failures - Only the hooks that failed
210
+ */
211
+
212
+ // ── Hook Registry ───────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Internal registry: event name → array of hook definitions.
216
+ * @type {Map<string, HookDefinition[]>}
217
+ */
218
+ const _registry = new Map();
219
+
220
+ /**
221
+ * Initialise registry with empty arrays for each valid event.
222
+ */
223
+ function _initRegistry() {
224
+ for (const event of HOOK_EVENTS) {
225
+ if (!_registry.has(event)) {
226
+ _registry.set(event, []);
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Reset the hook registry to empty state. Useful for testing.
233
+ */
234
+ export function resetHooks() {
235
+ _registry.clear();
236
+ _initRegistry();
237
+ _metrics.clear();
238
+ }
239
+
240
+ // Ensure the registry is initialised on module load.
241
+ _initRegistry();
242
+
243
+ // ── Config Loading ──────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Default config file search paths, resolved relative to the repo root.
247
+ * Searched in order; first existing file wins.
248
+ * @type {string[]}
249
+ */
250
+ const CONFIG_SEARCH_PATHS = [
251
+ ".codex/hooks.json",
252
+ ".vscode/hooks.json",
253
+ "scripts/openfleet/openfleet.config.json",
254
+ ];
255
+
256
+ /**
257
+ * Load hook definitions from a JSON config file.
258
+ *
259
+ * The file should contain a top-level `hooks` object whose keys are event names
260
+ * and values are arrays of {@link HookDefinition} objects.
261
+ *
262
+ * If no `configPath` is provided, the function searches the default locations
263
+ * (`.codex/hooks.json`, `.vscode/hooks.json`, `openfleet.config.json`).
264
+ * Hooks loaded from config are merged with (appended to) any programmatically
265
+ * registered hooks.
266
+ *
267
+ * @param {string} [configPath] - Absolute or repo-relative path to a hooks config file
268
+ * @returns {number} Number of hooks loaded
269
+ *
270
+ * @example
271
+ * loadHooks(); // Search default paths
272
+ * loadHooks(".codex/hooks.json"); // Explicit path
273
+ */
274
+ export function loadHooks(configPath) {
275
+ /** @type {string|null} */
276
+ let resolvedPath = null;
277
+
278
+ if (configPath) {
279
+ resolvedPath = resolve(REPO_ROOT, configPath);
280
+ if (!existsSync(resolvedPath)) {
281
+ console.warn(`${TAG} config file not found: ${resolvedPath}`);
282
+ return 0;
283
+ }
284
+ } else {
285
+ // Search default paths
286
+ for (const relPath of CONFIG_SEARCH_PATHS) {
287
+ const candidate = resolve(REPO_ROOT, relPath);
288
+ if (existsSync(candidate)) {
289
+ resolvedPath = candidate;
290
+ break;
291
+ }
292
+ }
293
+ if (!resolvedPath) {
294
+ console.log(
295
+ `${TAG} no hook config file found — using built-in hooks only`,
296
+ );
297
+ return 0;
298
+ }
299
+ }
300
+
301
+ let raw;
302
+ try {
303
+ raw = readFileSync(resolvedPath, "utf8");
304
+ } catch (err) {
305
+ console.error(
306
+ `${TAG} failed to read config file: ${resolvedPath}`,
307
+ err.message,
308
+ );
309
+ return 0;
310
+ }
311
+
312
+ let config;
313
+ try {
314
+ config = JSON.parse(raw);
315
+ } catch (err) {
316
+ console.error(
317
+ `${TAG} invalid JSON in config file: ${resolvedPath}`,
318
+ err.message,
319
+ );
320
+ return 0;
321
+ }
322
+
323
+ // Support both top-level { hooks: { ... } } and nested inside openfleet config
324
+ const hooksDef = config.hooks ?? config.agentHooks ?? null;
325
+ if (!hooksDef || typeof hooksDef !== "object") {
326
+ console.log(`${TAG} no "hooks" or "agentHooks" key in ${resolvedPath}`);
327
+ return 0;
328
+ }
329
+
330
+ let loaded = 0;
331
+
332
+ for (const [event, defs] of Object.entries(hooksDef)) {
333
+ if (!HOOK_EVENTS.includes(event)) {
334
+ console.warn(`${TAG} ignoring unknown hook event "${event}" in config`);
335
+ continue;
336
+ }
337
+
338
+ const hookArray = Array.isArray(defs) ? defs : [defs];
339
+ for (const def of hookArray) {
340
+ if (!def.command) {
341
+ console.warn(
342
+ `${TAG} skipping hook for "${event}" — missing "command" field`,
343
+ );
344
+ continue;
345
+ }
346
+
347
+ const hookDef = _normalizeHookDef(def);
348
+ registerHook(event, hookDef);
349
+ loaded++;
350
+ }
351
+ }
352
+
353
+ console.log(`${TAG} loaded ${loaded} hook(s) from ${resolvedPath}`);
354
+ return loaded;
355
+ }
356
+
357
+ // ── Registration ────────────────────────────────────────────────────────────
358
+
359
+ /**
360
+ * Register a hook for a specific event.
361
+ *
362
+ * @param {string} event - One of {@link HOOK_EVENTS}
363
+ * @param {HookDefinition} hookDef - Hook definition
364
+ * @returns {string} The hook's unique ID
365
+ * @throws {Error} If the event name is invalid
366
+ *
367
+ * @example
368
+ * const id = registerHook("PrePush", {
369
+ * command: "scripts/agent-preflight.ps1",
370
+ * blocking: true,
371
+ * timeout: 300000,
372
+ * });
373
+ */
374
+ export function registerHook(event, hookDef) {
375
+ if (!HOOK_EVENTS.includes(event)) {
376
+ throw new Error(
377
+ `${TAG} invalid hook event: "${event}". Valid events: ${HOOK_EVENTS.join(", ")}`,
378
+ );
379
+ }
380
+
381
+ const normalized = _normalizeHookDef(hookDef);
382
+
383
+ if (!_registry.has(event)) {
384
+ _registry.set(event, []);
385
+ }
386
+
387
+ // Prevent duplicate registration by ID
388
+ const existing = _registry.get(event);
389
+ const idx = existing.findIndex((h) => h.id === normalized.id);
390
+ if (idx >= 0) {
391
+ existing[idx] = normalized;
392
+ console.log(`${TAG} updated hook "${normalized.id}" for event "${event}"`);
393
+ } else {
394
+ existing.push(normalized);
395
+ console.log(
396
+ `${TAG} registered hook "${normalized.id}" for event "${event}"` +
397
+ (normalized.blocking ? " (blocking)" : ""),
398
+ );
399
+ }
400
+
401
+ return normalized.id;
402
+ }
403
+
404
+ /**
405
+ * Remove a previously registered hook by event and ID.
406
+ *
407
+ * @param {string} event - Hook event name
408
+ * @param {string} id - Hook ID to remove
409
+ * @returns {boolean} True if the hook was found and removed
410
+ */
411
+ export function unregisterHook(event, id) {
412
+ if (!_registry.has(event)) return false;
413
+
414
+ const hooks = _registry.get(event);
415
+ const idx = hooks.findIndex((h) => h.id === id);
416
+ if (idx < 0) return false;
417
+
418
+ hooks.splice(idx, 1);
419
+ console.log(`${TAG} unregistered hook "${id}" from event "${event}"`);
420
+ return true;
421
+ }
422
+
423
+ /**
424
+ * Get all registered hooks, optionally filtered by event.
425
+ *
426
+ * @param {string} [event] - If provided, only return hooks for this event
427
+ * @returns {Record<string, HookDefinition[]>|HookDefinition[]} All hooks or hooks for one event
428
+ */
429
+ export function getRegisteredHooks(event) {
430
+ if (event) {
431
+ if (!HOOK_EVENTS.includes(event)) {
432
+ throw new Error(`${TAG} invalid hook event: "${event}"`);
433
+ }
434
+ return [...(_registry.get(event) ?? [])];
435
+ }
436
+
437
+ /** @type {Record<string, HookDefinition[]>} */
438
+ const result = {};
439
+ for (const [ev, hooks] of _registry.entries()) {
440
+ if (hooks.length > 0) {
441
+ result[ev] = [...hooks];
442
+ }
443
+ }
444
+ return result;
445
+ }
446
+
447
+ // ── Hook Execution ──────────────────────────────────────────────────────────
448
+
449
+ /**
450
+ * Execute all hooks registered for an event (blocking and non-blocking).
451
+ *
452
+ * Blocking hooks run sequentially and their results are awaited.
453
+ * Non-blocking hooks run in parallel (fire-and-forget) — errors are logged but
454
+ * do not affect the return value.
455
+ *
456
+ * @param {string} event - Hook event name
457
+ * @param {HookContext} context - Execution context
458
+ * @returns {Promise<HookResult[]>} Results from all executed hooks
459
+ */
460
+ export async function executeHooks(event, context = {}) {
461
+ if (!HOOK_EVENTS.includes(event)) {
462
+ console.warn(`${TAG} executeHooks called with unknown event: "${event}"`);
463
+ return [];
464
+ }
465
+
466
+ const hooks = _getFilteredHooks(event, context.sdk);
467
+ if (hooks.length === 0) return [];
468
+
469
+ const enrichedCtx = _enrichContext(event, context);
470
+ const env = _buildEnv(enrichedCtx);
471
+
472
+ /** @type {HookResult[]} */
473
+ const results = [];
474
+
475
+ // Separate blocking and non-blocking hooks
476
+ const blocking = hooks.filter((h) => h.blocking);
477
+ const nonBlocking = hooks.filter((h) => !h.blocking);
478
+
479
+ // Run blocking hooks sequentially
480
+ for (const hook of blocking) {
481
+ const hookEnv = { ...env, ..._normalizeEnvValues(hook.env) };
482
+ const result = _executeHookSync(hook, enrichedCtx, hookEnv);
483
+ results.push(result);
484
+
485
+ if (!result.success) {
486
+ console.error(
487
+ `${TAG} blocking hook "${hook.id}" failed for event "${event}" ` +
488
+ `(exit ${result.exitCode}, ${result.durationMs}ms)`,
489
+ );
490
+ if (result.stderr) {
491
+ console.error(`${TAG} stderr: ${_truncate(result.stderr, 500)}`);
492
+ }
493
+ } else {
494
+ console.log(
495
+ `${TAG} blocking hook "${hook.id}" passed for event "${event}" (${result.durationMs}ms)`,
496
+ );
497
+ }
498
+ }
499
+
500
+ // Run non-blocking hooks in parallel (fire-and-forget)
501
+ const nonBlockingPromises = nonBlocking.map(async (hook) => {
502
+ const hookEnv = { ...env, ..._normalizeEnvValues(hook.env) };
503
+ try {
504
+ const result = await _executeHookAsync(hook, enrichedCtx, hookEnv);
505
+ results.push(result);
506
+
507
+ if (!result.success) {
508
+ console.warn(
509
+ `${TAG} non-blocking hook "${hook.id}" failed for event "${event}" ` +
510
+ `(exit ${result.exitCode})`,
511
+ );
512
+ }
513
+ } catch (err) {
514
+ console.warn(
515
+ `${TAG} non-blocking hook "${hook.id}" threw: ${err.message}`,
516
+ );
517
+ results.push({
518
+ id: hook.id,
519
+ command: hook.command,
520
+ success: false,
521
+ exitCode: -1,
522
+ stdout: "",
523
+ stderr: "",
524
+ durationMs: 0,
525
+ error: err.message,
526
+ });
527
+ }
528
+ });
529
+
530
+ // Wait for non-blocking hooks but don't let them block indefinitely
531
+ await Promise.allSettled(nonBlockingPromises);
532
+
533
+ return results;
534
+ }
535
+
536
+ /**
537
+ * Execute only the **blocking** hooks for an event and return a pass/fail result.
538
+ *
539
+ * This is the method to call before critical operations (push, commit, PR) where
540
+ * all quality gates must pass.
541
+ *
542
+ * @param {string} event - Hook event name
543
+ * @param {HookContext} context - Execution context
544
+ * @returns {Promise<BlockingHookResult>} Aggregated pass/fail with details
545
+ *
546
+ * @example
547
+ * const result = await executeBlockingHooks("PrePush", { taskId, branch, sdk: "codex" });
548
+ * if (!result.passed) {
549
+ * console.error("Blocked:", result.failures.map(f => f.error || f.stderr));
550
+ * }
551
+ */
552
+ export async function executeBlockingHooks(event, context = {}) {
553
+ if (!HOOK_EVENTS.includes(event)) {
554
+ console.warn(
555
+ `${TAG} executeBlockingHooks called with unknown event: "${event}"`,
556
+ );
557
+ return { passed: true, results: [], failures: [] };
558
+ }
559
+
560
+ const hooks = _getFilteredHooks(event, context.sdk).filter((h) => h.blocking);
561
+ if (hooks.length === 0) {
562
+ return { passed: true, results: [], failures: [] };
563
+ }
564
+
565
+ const enrichedCtx = _enrichContext(event, context);
566
+ const env = _buildEnv(enrichedCtx);
567
+
568
+ /** @type {HookResult[]} */
569
+ const results = [];
570
+ /** @type {HookResult[]} */
571
+ const failures = [];
572
+
573
+ for (const hook of hooks) {
574
+ const hookEnv = { ...env, ..._normalizeEnvValues(hook.env) };
575
+ const result = _executeHookSync(hook, enrichedCtx, hookEnv);
576
+ results.push(result);
577
+
578
+ if (!result.success) {
579
+ failures.push(result);
580
+ console.error(
581
+ `${TAG} BLOCKING FAILURE: hook "${hook.id}" for event "${event}" — ` +
582
+ `exit ${result.exitCode} (${result.durationMs}ms)`,
583
+ );
584
+ } else {
585
+ console.log(
586
+ `${TAG} blocking hook "${hook.id}" passed (${result.durationMs}ms)`,
587
+ );
588
+ }
589
+ }
590
+
591
+ const passed = failures.length === 0;
592
+
593
+ if (passed) {
594
+ console.log(
595
+ `${TAG} all ${results.length} blocking hook(s) passed for "${event}"`,
596
+ );
597
+ } else {
598
+ console.error(
599
+ `${TAG} ${failures.length}/${results.length} blocking hook(s) FAILED for "${event}"`,
600
+ );
601
+ }
602
+
603
+ return { passed, results, failures };
604
+ }
605
+
606
+ // ── Built-in Hooks ──────────────────────────────────────────────────────────
607
+
608
+ /**
609
+ * Register the default built-in hooks. These provide essential quality gates
610
+ * that run regardless of config file contents.
611
+ *
612
+ * Built-in hooks:
613
+ * - **PrePush** — Runs `scripts/agent-preflight.ps1` (Windows) or
614
+ * `scripts/agent-preflight.sh` (Unix) to validate quality gates.
615
+ * - **TaskComplete** — Runs a basic acceptance-criteria check via git log.
616
+ */
617
+ export function registerBuiltinHooks(options = {}) {
618
+ const modeRaw =
619
+ options.mode ??
620
+ process.env.CODEX_MONITOR_HOOKS_BUILTINS_MODE ??
621
+ process.env.VE_HOOK_BUILTINS_MODE ??
622
+ "force";
623
+ const mode = String(modeRaw).trim().toLowerCase();
624
+ if (mode === "off" || mode === "disabled" || mode === "none") {
625
+ console.log(`${TAG} built-in hooks disabled (mode=${mode})`);
626
+ return;
627
+ }
628
+
629
+ const hasCustomPrePush = (_registry.get("PrePush") ?? []).some(
630
+ (hook) => !hook.builtin,
631
+ );
632
+ const hasCustomTaskComplete = (_registry.get("TaskComplete") ?? []).some(
633
+ (hook) => !hook.builtin,
634
+ );
635
+ const skipPrePush =
636
+ envFlag("CODEX_MONITOR_HOOKS_DISABLE_PREPUSH", false) ||
637
+ envFlag("VE_HOOK_DISABLE_PREPUSH", false) ||
638
+ (mode === "auto" && hasCustomPrePush);
639
+ const skipTaskComplete =
640
+ envFlag("CODEX_MONITOR_HOOKS_DISABLE_TASK_COMPLETE", false) ||
641
+ envFlag("CODEX_MONITOR_HOOKS_DISABLE_VALIDATION", false) ||
642
+ envFlag("VE_HOOK_DISABLE_TASK_COMPLETE", false) ||
643
+ (mode === "auto" && hasCustomTaskComplete);
644
+
645
+ // ── PrePush: agent preflight quality gate ──
646
+ if (!skipPrePush) {
647
+ const preflightScript = IS_WINDOWS
648
+ ? "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/agent-preflight.ps1"
649
+ : "bash scripts/agent-preflight.sh";
650
+
651
+ registerHook("PrePush", {
652
+ id: "builtin-prepush-preflight",
653
+ command: preflightScript,
654
+ description: "Run agent preflight quality gates before push",
655
+ timeout: 300_000, // 5 minutes
656
+ blocking: true,
657
+ sdks: [SDK_WILDCARD],
658
+ builtin: true,
659
+ });
660
+ } else {
661
+ console.log(`${TAG} skipped built-in PrePush hook (mode=${mode})`);
662
+ }
663
+
664
+ // ── TaskComplete: verify at least one commit exists on the branch ──
665
+ if (!skipTaskComplete) {
666
+ registerHook("TaskComplete", {
667
+ id: "builtin-task-complete-validation",
668
+ command: _buildTaskCompleteCommand(),
669
+ description:
670
+ "Validate task produced at least one commit ahead of base branch",
671
+ timeout: 30_000, // 30 seconds
672
+ blocking: true,
673
+ sdks: [SDK_WILDCARD],
674
+ builtin: true,
675
+ });
676
+ } else {
677
+ console.log(`${TAG} skipped built-in TaskComplete hook (mode=${mode})`);
678
+ }
679
+
680
+ // ── SessionStart: worktree health check ──
681
+ // Verifies the worktree directory exists, is a valid git repo,
682
+ // and the expected branch is checked out. Retryable for transient git issues.
683
+ const skipHealthCheck = envFlag("CODEX_MONITOR_HOOKS_DISABLE_HEALTH_CHECK", false);
684
+ if (!skipHealthCheck) {
685
+ const healthCmd = IS_WINDOWS
686
+ ? 'powershell -NoProfile -Command "if (-not (Test-Path .git)) { if (-not (git rev-parse --git-dir 2>$null)) { Write-Error \'Not a git repository\'; exit 1 } }; git status --porcelain 2>&1 | Out-Null; if ($LASTEXITCODE -ne 0) { Write-Error \'git status failed\'; exit 1 }; Write-Host \'OK: worktree healthy\'"'
687
+ : "bash -c 'if ! git rev-parse --git-dir >/dev/null 2>&1; then echo \"Not a git repository\" >&2; exit 1; fi; git status --porcelain >/dev/null 2>&1; echo \"OK: worktree healthy\"'";
688
+
689
+ registerHook("SessionStart", {
690
+ id: "builtin-session-health-check",
691
+ command: healthCmd,
692
+ description: "Verify worktree is a healthy git repository at session start",
693
+ timeout: 15_000,
694
+ blocking: false,
695
+ sdks: [SDK_WILDCARD],
696
+ builtin: true,
697
+ retryable: true,
698
+ maxRetries: 2,
699
+ });
700
+ }
701
+
702
+ // ── PrePush: verify branch not stale (retryable) ──
703
+ if (!skipPrePush) {
704
+ const fetchCmd = IS_WINDOWS
705
+ ? 'powershell -NoProfile -Command "git fetch origin --quiet 2>&1 | Out-Null; Write-Host \'OK: fetch completed\'"'
706
+ : "bash -c 'git fetch origin --quiet 2>/dev/null; echo \"OK: fetch completed\"'";
707
+
708
+ registerHook("PrePush", {
709
+ id: "builtin-prepush-fetch",
710
+ command: fetchCmd,
711
+ description: "Fetch latest from origin before push to reduce rejections",
712
+ timeout: 60_000,
713
+ blocking: false,
714
+ sdks: [SDK_WILDCARD],
715
+ builtin: true,
716
+ retryable: true,
717
+ maxRetries: 2,
718
+ });
719
+ }
720
+
721
+ console.log(`${TAG} built-in hooks registered`);
722
+ }
723
+
724
+ /**
725
+ * Build the shell command for the TaskComplete validation hook.
726
+ * Checks that HEAD has at least one commit ahead of the merge-base with main.
727
+ *
728
+ * @returns {string}
729
+ */
730
+ function _buildTaskCompleteCommand() {
731
+ if (IS_WINDOWS) {
732
+ // Use -EncodedCommand to avoid quoting/parsing issues with ".." in PowerShell.
733
+ // We also gracefully fallback to local "main" if "origin/main" is unavailable.
734
+ const psScript = [
735
+ "$null = git show-ref --verify --quiet refs/remotes/origin/main",
736
+ "if ($LASTEXITCODE -eq 0) { $baseRef = 'origin/main' } else {",
737
+ " $null = git show-ref --verify --quiet refs/heads/main",
738
+ " if ($LASTEXITCODE -eq 0) { $baseRef = 'main' } else { Write-Error 'Neither origin/main nor main exists'; exit 1 }",
739
+ "}",
740
+ "$mergeBase = git merge-base HEAD $baseRef",
741
+ "if (-not $mergeBase) { Write-Error \"Could not determine merge-base with $baseRef\"; exit 1 }",
742
+ "$ahead = [int](git rev-list --count \"$mergeBase..HEAD\")",
743
+ "if ($ahead -lt 1) { Write-Error \"No commits ahead of $baseRef\"; exit 1 }",
744
+ "Write-Host \"OK: $ahead commit(s) ahead of $baseRef\"",
745
+ ].join("; ");
746
+ const encoded = Buffer.from(psScript, "utf16le").toString("base64");
747
+ return `powershell -NoProfile -EncodedCommand ${encoded}`;
748
+ }
749
+
750
+ // Bash one-liner
751
+ return [
752
+ "bash -c",
753
+ "'ahead=$(git rev-list --count $(git merge-base HEAD origin/main)..HEAD);",
754
+ 'if [ "$ahead" -lt 1 ]; then echo "No commits ahead of origin/main" >&2; exit 1;',
755
+ 'else echo "OK: $ahead commit(s) ahead of origin/main"; fi\'',
756
+ ].join(" ");
757
+ }
758
+
759
+ // ── Internal: Filtering ─────────────────────────────────────────────────────
760
+
761
+ /**
762
+ * Get hooks for an event, filtered by the active SDK.
763
+ *
764
+ * @param {string} event - Hook event name
765
+ * @param {string} [sdk] - Active SDK name; if omitted, all hooks are returned
766
+ * @returns {HookDefinition[]}
767
+ */
768
+ function _getFilteredHooks(event, sdk) {
769
+ const hooks = _registry.get(event) ?? [];
770
+ if (!sdk) return [...hooks];
771
+
772
+ const normalizedSdk = sdk.toLowerCase();
773
+ return hooks.filter((hook) => {
774
+ const sdks = hook.sdks ?? [SDK_WILDCARD];
775
+ return sdks.includes(SDK_WILDCARD) || sdks.includes(normalizedSdk);
776
+ });
777
+ }
778
+
779
+ // ── Internal: Context & Environment ─────────────────────────────────────────
780
+
781
+ /**
782
+ * Enrich a context object with defaults (event, timestamp, repoRoot).
783
+ *
784
+ * @param {string} event
785
+ * @param {HookContext} context
786
+ * @returns {HookContext}
787
+ */
788
+ function _enrichContext(event, context) {
789
+ return {
790
+ repoRoot: REPO_ROOT,
791
+ ...context,
792
+ event,
793
+ timestamp: Date.now(),
794
+ };
795
+ }
796
+
797
+ /**
798
+ * Build the environment variables map that is passed to every hook subprocess.
799
+ *
800
+ * @param {HookContext} ctx - Enriched hook context
801
+ * @returns {Record<string, string>}
802
+ */
803
+ function _buildEnv(ctx) {
804
+ /** @type {Record<string, string>} */
805
+ const env = {
806
+ ...process.env,
807
+ VE_HOOK_EVENT: ctx.event ?? "",
808
+ VE_TASK_ID: ctx.taskId ?? "",
809
+ VE_TASK_TITLE: ctx.taskTitle ?? "",
810
+ VE_TASK_DESCRIPTION: ctx.taskDescription ?? "",
811
+ VE_TITLE: ctx.taskTitle ?? "",
812
+ VE_DESCRIPTION: ctx.taskDescription ?? "",
813
+ VK_TITLE: ctx.taskTitle ?? "",
814
+ VK_DESCRIPTION: ctx.taskDescription ?? "",
815
+ VE_BRANCH_NAME: ctx.branch ?? "",
816
+ VE_WORKTREE_PATH: ctx.worktreePath ?? "",
817
+ VE_SDK: ctx.sdk ?? "",
818
+ VE_REPO_ROOT: ctx.repoRoot ?? REPO_ROOT,
819
+ VE_HOOK_BLOCKING: "false", // Overridden per-hook in execution
820
+ };
821
+
822
+ // Merge any extra context values as env vars
823
+ if (ctx.extra && typeof ctx.extra === "object") {
824
+ for (const [key, val] of Object.entries(ctx.extra)) {
825
+ env[`VE_HOOK_${key.toUpperCase()}`] = String(val ?? "");
826
+ }
827
+ }
828
+
829
+ return env;
830
+ }
831
+
832
+ // ── Internal: Synchronous Hook Execution ────────────────────────────────────
833
+
834
+ /**
835
+ * Determine if a hook result represents a transient failure worth retrying.
836
+ */
837
+ function _isTransientFailure(result) {
838
+ if (result.success) return false;
839
+ if (result.error && /timed out/i.test(result.error)) return false; // don't retry timeouts
840
+ if (result.error && /spawn/i.test(result.error)) return true; // ETXTBSY, etc.
841
+ if (TRANSIENT_EXIT_CODES.has(result.exitCode)) return true;
842
+ // Signal-killed processes are often transient (OOM, etc.)
843
+ if (result.exitCode > 128 && result.exitCode <= 159) return true;
844
+ return false;
845
+ }
846
+
847
+ /**
848
+ * Sleep helper for retry backoff.
849
+ */
850
+ function _sleepSync(ms) {
851
+ const end = Date.now() + ms;
852
+ while (Date.now() < end) {
853
+ // busy-wait (only used for short retry backoffs in sync context)
854
+ }
855
+ }
856
+
857
+ /**
858
+ * Execute a hook synchronously using `spawnSync`. Used for blocking hooks.
859
+ * Supports configurable retry with exponential backoff for retryable hooks.
860
+ *
861
+ * @param {HookDefinition} hook
862
+ * @param {HookContext} ctx
863
+ * @param {Record<string, string>} env
864
+ * @returns {HookResult}
865
+ */
866
+ function _executeHookSync(hook, ctx, env) {
867
+ const maxAttempts = hook.retryable ? (hook.maxRetries ?? DEFAULT_MAX_RETRIES) + 1 : 1;
868
+ let lastResult = null;
869
+ let retried = false;
870
+
871
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
872
+ const start = Date.now();
873
+ const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS;
874
+ const cwd = ctx.worktreePath || ctx.repoRoot || REPO_ROOT;
875
+
876
+ const hookEnv = {
877
+ ...env,
878
+ VE_HOOK_BLOCKING: "true",
879
+ VE_HOOK_ATTEMPT: String(attempt),
880
+ };
881
+
882
+ try {
883
+ const result = spawnSync(hook.command, {
884
+ cwd,
885
+ env: hookEnv,
886
+ encoding: "utf8",
887
+ timeout,
888
+ shell: true,
889
+ windowsHide: true,
890
+ maxBuffer: MAX_OUTPUT_BYTES,
891
+ });
892
+
893
+ const durationMs = Date.now() - start;
894
+ const exitCode = result.status ?? -1;
895
+
896
+ if (result.signal === "SIGTERM" || result.error?.code === "ETIMEDOUT") {
897
+ lastResult = {
898
+ id: hook.id,
899
+ command: hook.command,
900
+ success: false,
901
+ exitCode: -1,
902
+ stdout: _truncate(result.stdout ?? "", MAX_OUTPUT_BYTES),
903
+ stderr: _truncate(result.stderr ?? "", MAX_OUTPUT_BYTES),
904
+ durationMs,
905
+ error: `Hook timed out after ${timeout}ms`,
906
+ };
907
+ } else {
908
+ lastResult = {
909
+ id: hook.id,
910
+ command: hook.command,
911
+ success: exitCode === 0,
912
+ exitCode,
913
+ stdout: _truncate(result.stdout ?? "", MAX_OUTPUT_BYTES),
914
+ stderr: _truncate(result.stderr ?? "", MAX_OUTPUT_BYTES),
915
+ durationMs,
916
+ error: result.error ? result.error.message : undefined,
917
+ };
918
+ }
919
+ } catch (err) {
920
+ lastResult = {
921
+ id: hook.id,
922
+ command: hook.command,
923
+ success: false,
924
+ exitCode: -1,
925
+ stdout: "",
926
+ stderr: "",
927
+ durationMs: Date.now() - start,
928
+ error: `Failed to spawn hook: ${err.message}`,
929
+ };
930
+ }
931
+
932
+ // Success or no more retries
933
+ if (lastResult.success || attempt >= maxAttempts) {
934
+ _recordMetric(hook.id, lastResult.success, lastResult.durationMs, retried);
935
+ return lastResult;
936
+ }
937
+
938
+ // Only retry transient failures
939
+ if (!_isTransientFailure(lastResult)) {
940
+ _recordMetric(hook.id, false, lastResult.durationMs, retried);
941
+ return lastResult;
942
+ }
943
+
944
+ retried = true;
945
+ const backoff = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
946
+ console.warn(
947
+ `${TAG} hook "${hook.id}" failed (attempt ${attempt}/${maxAttempts}), retrying in ${backoff}ms`,
948
+ );
949
+ _sleepSync(backoff);
950
+ }
951
+
952
+ _recordMetric(hook.id, false, lastResult?.durationMs ?? 0, retried);
953
+ return lastResult;
954
+ }
955
+
956
+ // ── Internal: Asynchronous Hook Execution ───────────────────────────────────
957
+
958
+ /**
959
+ * Execute a single async attempt of a hook using `spawn`.
960
+ * @returns {Promise<HookResult>}
961
+ */
962
+ function _executeHookAsyncOnce(hook, ctx, env, attempt) {
963
+ return new Promise((resolvePromise) => {
964
+ const start = Date.now();
965
+ const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS;
966
+ const cwd = ctx.worktreePath || ctx.repoRoot || REPO_ROOT;
967
+
968
+ const hookEnv = {
969
+ ...env,
970
+ VE_HOOK_BLOCKING: "false",
971
+ VE_HOOK_ATTEMPT: String(attempt),
972
+ };
973
+
974
+ /** @type {string[]} */
975
+ const stdoutChunks = [];
976
+ /** @type {string[]} */
977
+ const stderrChunks = [];
978
+ let totalBytes = 0;
979
+ let settled = false;
980
+
981
+ /** @param {HookResult} result */
982
+ function settle(result) {
983
+ if (settled) return;
984
+ settled = true;
985
+ resolvePromise(result);
986
+ }
987
+
988
+ let child;
989
+ try {
990
+ child = spawn(hook.command, {
991
+ cwd,
992
+ env: hookEnv,
993
+ shell: true,
994
+ windowsHide: true,
995
+ stdio: ["ignore", "pipe", "pipe"],
996
+ });
997
+ } catch (err) {
998
+ settle({
999
+ id: hook.id,
1000
+ command: hook.command,
1001
+ success: false,
1002
+ exitCode: -1,
1003
+ stdout: "",
1004
+ stderr: "",
1005
+ durationMs: Date.now() - start,
1006
+ error: `Failed to spawn hook: ${err.message}`,
1007
+ });
1008
+ return;
1009
+ }
1010
+
1011
+ child.stdout?.on("data", (chunk) => {
1012
+ if (totalBytes < MAX_OUTPUT_BYTES) {
1013
+ stdoutChunks.push(chunk.toString("utf8"));
1014
+ totalBytes += chunk.length;
1015
+ }
1016
+ });
1017
+
1018
+ child.stderr?.on("data", (chunk) => {
1019
+ if (totalBytes < MAX_OUTPUT_BYTES) {
1020
+ stderrChunks.push(chunk.toString("utf8"));
1021
+ totalBytes += chunk.length;
1022
+ }
1023
+ });
1024
+
1025
+ const timer = setTimeout(() => {
1026
+ try {
1027
+ child.kill("SIGTERM");
1028
+ } catch {
1029
+ // Process may have already exited
1030
+ }
1031
+ settle({
1032
+ id: hook.id,
1033
+ command: hook.command,
1034
+ success: false,
1035
+ exitCode: -1,
1036
+ stdout: stdoutChunks.join(""),
1037
+ stderr: stderrChunks.join(""),
1038
+ durationMs: Date.now() - start,
1039
+ error: `Hook timed out after ${timeout}ms`,
1040
+ });
1041
+ }, timeout);
1042
+
1043
+ child.on("close", (code) => {
1044
+ clearTimeout(timer);
1045
+ settle({
1046
+ id: hook.id,
1047
+ command: hook.command,
1048
+ success: code === 0,
1049
+ exitCode: code ?? -1,
1050
+ stdout: stdoutChunks.join(""),
1051
+ stderr: stderrChunks.join(""),
1052
+ durationMs: Date.now() - start,
1053
+ });
1054
+ });
1055
+
1056
+ child.on("error", (err) => {
1057
+ clearTimeout(timer);
1058
+ settle({
1059
+ id: hook.id,
1060
+ command: hook.command,
1061
+ success: false,
1062
+ exitCode: -1,
1063
+ stdout: stdoutChunks.join(""),
1064
+ stderr: stderrChunks.join(""),
1065
+ durationMs: Date.now() - start,
1066
+ error: err.message,
1067
+ });
1068
+ });
1069
+ });
1070
+ }
1071
+
1072
+ /**
1073
+ * Execute a hook asynchronously with retry support. Used for non-blocking hooks.
1074
+ *
1075
+ * @param {HookDefinition} hook
1076
+ * @param {HookContext} ctx
1077
+ * @param {Record<string, string>} env
1078
+ * @returns {Promise<HookResult>}
1079
+ */
1080
+ async function _executeHookAsync(hook, ctx, env) {
1081
+ const maxAttempts = hook.retryable ? (hook.maxRetries ?? DEFAULT_MAX_RETRIES) + 1 : 1;
1082
+ let lastResult = null;
1083
+ let retried = false;
1084
+
1085
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1086
+ lastResult = await _executeHookAsyncOnce(hook, ctx, env, attempt);
1087
+
1088
+ if (lastResult.success || attempt >= maxAttempts) {
1089
+ _recordMetric(hook.id, lastResult.success, lastResult.durationMs, retried);
1090
+ return lastResult;
1091
+ }
1092
+
1093
+ if (!_isTransientFailure(lastResult)) {
1094
+ _recordMetric(hook.id, false, lastResult.durationMs, retried);
1095
+ return lastResult;
1096
+ }
1097
+
1098
+ retried = true;
1099
+ const backoff = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
1100
+ console.warn(
1101
+ `${TAG} async hook "${hook.id}" failed (attempt ${attempt}/${maxAttempts}), retrying in ${backoff}ms`,
1102
+ );
1103
+ await new Promise((r) => setTimeout(r, backoff));
1104
+ }
1105
+
1106
+ _recordMetric(hook.id, false, lastResult?.durationMs ?? 0, retried);
1107
+ return lastResult;
1108
+ }
1109
+
1110
+ // ── Internal: Normalisation Helpers ─────────────────────────────────────────
1111
+
1112
+ /**
1113
+ * Normalise a raw hook definition object, filling in defaults.
1114
+ *
1115
+ * @param {Partial<HookDefinition>} def
1116
+ * @returns {HookDefinition}
1117
+ */
1118
+ function _normalizeHookDef(def) {
1119
+ const id = def.id ?? `hook-${randomUUID().slice(0, 8)}`;
1120
+ const sdks = _normalizeSdks(def.sdks);
1121
+
1122
+ return {
1123
+ id,
1124
+ command: String(def.command ?? ""),
1125
+ description: def.description ?? "",
1126
+ timeout:
1127
+ typeof def.timeout === "number" && def.timeout > 0
1128
+ ? def.timeout
1129
+ : DEFAULT_TIMEOUT_MS,
1130
+ blocking: Boolean(def.blocking),
1131
+ sdks,
1132
+ env: def.env && typeof def.env === "object" ? { ...def.env } : {},
1133
+ builtin: Boolean(def.builtin),
1134
+ retryable: Boolean(def.retryable),
1135
+ maxRetries:
1136
+ typeof def.maxRetries === "number" && def.maxRetries >= 0
1137
+ ? def.maxRetries
1138
+ : DEFAULT_MAX_RETRIES,
1139
+ };
1140
+ }
1141
+
1142
+ /**
1143
+ * Normalise the SDKs array from a hook definition.
1144
+ *
1145
+ * @param {unknown} sdks
1146
+ * @returns {string[]}
1147
+ */
1148
+ function _normalizeSdks(sdks) {
1149
+ if (!sdks || !Array.isArray(sdks) || sdks.length === 0) {
1150
+ return [SDK_WILDCARD];
1151
+ }
1152
+
1153
+ const normalised = sdks
1154
+ .map((s) => String(s).toLowerCase().trim())
1155
+ .filter((s) => s === SDK_WILDCARD || VALID_SDKS.includes(s));
1156
+
1157
+ if (normalised.length === 0) return [SDK_WILDCARD];
1158
+ if (normalised.includes(SDK_WILDCARD)) return [SDK_WILDCARD];
1159
+ return [...new Set(normalised)];
1160
+ }
1161
+
1162
+ /**
1163
+ * Ensure all env values are strings (non-string values are coerced).
1164
+ *
1165
+ * @param {Record<string, unknown>} [env]
1166
+ * @returns {Record<string, string>}
1167
+ */
1168
+ function _normalizeEnvValues(env) {
1169
+ if (!env || typeof env !== "object") return {};
1170
+ /** @type {Record<string, string>} */
1171
+ const result = {};
1172
+ for (const [key, val] of Object.entries(env)) {
1173
+ result[key] = String(val ?? "");
1174
+ }
1175
+ return result;
1176
+ }
1177
+
1178
+ /**
1179
+ * Truncate a string to a maximum length, appending an ellipsis marker if truncated.
1180
+ *
1181
+ * @param {string} str
1182
+ * @param {number} maxLen
1183
+ * @returns {string}
1184
+ */
1185
+ function _truncate(str, maxLen) {
1186
+ if (!str || str.length <= maxLen) return str ?? "";
1187
+ return str.slice(0, maxLen) + "\n... (truncated)";
1188
+ }