@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/agent-hooks.mjs
ADDED
|
@@ -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
|
+
}
|