@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,651 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve, dirname, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ const DEFAULT_TIMEOUT_MS = 60_000;
9
+ const DEFAULT_HOOK_SCHEMA = "https://json-schema.org/draft/2020-12/schema";
10
+ const LEGACY_BRIDGE_SNIPPET = "scripts/openfleet/agent-hook-bridge.mjs";
11
+ const DEFAULT_BRIDGE_SCRIPT_PATH = resolve(__dirname, "agent-hook-bridge.mjs");
12
+
13
+ function getHookNodeBinary() {
14
+ const configured = String(process.env.CODEX_MONITOR_HOOK_NODE_BIN || "").trim();
15
+ return configured || "node";
16
+ }
17
+
18
+ function getHookBridgeScriptPath() {
19
+ const configured = String(
20
+ process.env.CODEX_MONITOR_HOOK_BRIDGE_PATH || "",
21
+ ).trim();
22
+ return configured || DEFAULT_BRIDGE_SCRIPT_PATH;
23
+ }
24
+
25
+ export const HOOK_PROFILES = Object.freeze([
26
+ "strict",
27
+ "balanced",
28
+ "lightweight",
29
+ "none",
30
+ ]);
31
+
32
+ const PRESET_FLAGS = Object.freeze({
33
+ strict: {
34
+ includeSessionHooks: true,
35
+ includePreCommit: true,
36
+ includePrePush: true,
37
+ includeTaskComplete: true,
38
+ },
39
+ balanced: {
40
+ includeSessionHooks: true,
41
+ includePreCommit: false,
42
+ includePrePush: true,
43
+ includeTaskComplete: true,
44
+ },
45
+ lightweight: {
46
+ includeSessionHooks: true,
47
+ includePreCommit: false,
48
+ includePrePush: false,
49
+ includeTaskComplete: false,
50
+ },
51
+ none: {
52
+ includeSessionHooks: false,
53
+ includePreCommit: false,
54
+ includePrePush: false,
55
+ includeTaskComplete: false,
56
+ },
57
+ });
58
+
59
+ const PRESET_COMMANDS = Object.freeze({
60
+ SessionStart: Object.freeze([
61
+ {
62
+ id: "session-start-log",
63
+ command:
64
+ 'echo "[hook] Agent session started: task=${VE_TASK_ID} sdk=${VE_SDK} branch=${VE_BRANCH_NAME}"',
65
+ description: "Log agent session start for audit trail",
66
+ blocking: false,
67
+ timeout: 10_000,
68
+ },
69
+ ]),
70
+ SessionStop: Object.freeze([
71
+ {
72
+ id: "session-stop-log",
73
+ command:
74
+ 'echo "[hook] Agent session ended: task=${VE_TASK_ID} sdk=${VE_SDK}"',
75
+ description: "Log agent session end for audit trail",
76
+ blocking: false,
77
+ timeout: 10_000,
78
+ },
79
+ ]),
80
+ PrePush: Object.freeze([
81
+ {
82
+ id: "prepush-go-vet",
83
+ command: "go vet ./...",
84
+ description: "Run go vet before push",
85
+ blocking: true,
86
+ timeout: 120_000,
87
+ },
88
+ {
89
+ id: "prepush-go-build",
90
+ command: "go build ./...",
91
+ description: "Verify Go build succeeds before push",
92
+ blocking: true,
93
+ timeout: 300_000,
94
+ },
95
+ ]),
96
+ PreCommit: Object.freeze([
97
+ {
98
+ id: "precommit-gofmt",
99
+ command: "gofmt -l .",
100
+ description: "Check Go formatting before commit",
101
+ blocking: false,
102
+ timeout: 30_000,
103
+ },
104
+ ]),
105
+ TaskComplete: Object.freeze([
106
+ {
107
+ id: "task-complete-audit",
108
+ command: 'echo "[hook] Task completed: ${VE_TASK_ID} — ${VE_TASK_TITLE}"',
109
+ description: "Audit log for task completion",
110
+ blocking: false,
111
+ timeout: 10_000,
112
+ },
113
+ ]),
114
+ });
115
+
116
+ function parseBoolean(value, defaultValue = false) {
117
+ if (value == null || value === "") return defaultValue;
118
+ const raw = String(value).trim().toLowerCase();
119
+ if (["1", "true", "yes", "y", "on"].includes(raw)) return true;
120
+ if (["0", "false", "no", "n", "off"].includes(raw)) return false;
121
+ return defaultValue;
122
+ }
123
+
124
+ function quoteArg(arg) {
125
+ const text = String(arg ?? "");
126
+ if (!text) return "''";
127
+ if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text;
128
+ return `'${text.replace(/'/g, `'\\''`)}'`;
129
+ }
130
+
131
+ function buildShellCommand(args) {
132
+ return args.map((item) => quoteArg(item)).join(" ");
133
+ }
134
+
135
+ function makeBridgeCommandTokens(agent, event) {
136
+ return [
137
+ getHookNodeBinary(),
138
+ getHookBridgeScriptPath(),
139
+ "--agent",
140
+ agent,
141
+ "--event",
142
+ event,
143
+ ];
144
+ }
145
+
146
+ function isBridgeCommandString(command) {
147
+ return String(command || "").includes("agent-hook-bridge.mjs");
148
+ }
149
+
150
+ function isPortableNodeCommandToken(token) {
151
+ const normalized = String(token || "").trim().toLowerCase();
152
+ return normalized === "node";
153
+ }
154
+
155
+ function isPortableBridgeScriptToken(token) {
156
+ const raw = String(token || "");
157
+ return raw === DEFAULT_BRIDGE_SCRIPT_PATH || raw === LEGACY_BRIDGE_SNIPPET;
158
+ }
159
+
160
+ function isCopilotBridgeCommandPortable(commandTokens) {
161
+ if (!Array.isArray(commandTokens) || commandTokens.length < 2) return false;
162
+ if (!isBridgeCommandString(commandTokens.join(" "))) return false;
163
+ return (
164
+ isPortableNodeCommandToken(commandTokens[0]) &&
165
+ isPortableBridgeScriptToken(commandTokens[1])
166
+ );
167
+ }
168
+
169
+ function deepClone(value) {
170
+ return JSON.parse(JSON.stringify(value));
171
+ }
172
+
173
+ function normalizeProfile(profile) {
174
+ const raw = String(profile || "")
175
+ .trim()
176
+ .toLowerCase();
177
+ if (HOOK_PROFILES.includes(raw)) return raw;
178
+ return "strict";
179
+ }
180
+
181
+ function normalizeOverrideCommands(rawValue) {
182
+ if (rawValue == null) return null;
183
+ const raw = String(rawValue).trim();
184
+ if (!raw) return null;
185
+ const lowered = raw.toLowerCase();
186
+ if (["none", "off", "disable", "disabled"].includes(lowered)) {
187
+ return [];
188
+ }
189
+ const commands = raw
190
+ .split(/\s*;;\s*|\r?\n/)
191
+ .map((part) => part.trim())
192
+ .filter(Boolean);
193
+ return commands;
194
+ }
195
+
196
+ function mapCommandsToHooks(event, commands) {
197
+ return commands.map((command, idx) => {
198
+ const defaults = PRESET_COMMANDS[event]?.[0] || {};
199
+ const idBase = event.toLowerCase().replace(/[^a-z0-9]+/g, "-");
200
+ return {
201
+ id: `${idBase}-custom-${idx + 1}`,
202
+ command,
203
+ description:
204
+ defaults.description || `Custom ${event} hook command #${idx + 1}`,
205
+ blocking:
206
+ event === "PrePush" ||
207
+ event === "PreCommit" ||
208
+ event === "PrePR" ||
209
+ defaults.blocking ||
210
+ false,
211
+ sdks: ["*"],
212
+ timeout: defaults.timeout || DEFAULT_TIMEOUT_MS,
213
+ };
214
+ });
215
+ }
216
+
217
+ export function normalizeHookTargets(value) {
218
+ if (Array.isArray(value)) {
219
+ const arr = value
220
+ .map((item) => String(item).trim().toLowerCase())
221
+ .filter(Boolean);
222
+ return [
223
+ ...new Set(
224
+ arr.filter((item) => ["codex", "claude", "copilot"].includes(item)),
225
+ ),
226
+ ];
227
+ }
228
+
229
+ const raw = String(value || "")
230
+ .split(",")
231
+ .map((item) => item.trim().toLowerCase())
232
+ .filter(Boolean);
233
+
234
+ const unique = [...new Set(raw)];
235
+ const filtered = unique.filter((item) =>
236
+ ["codex", "claude", "copilot", "all"].includes(item),
237
+ );
238
+
239
+ if (filtered.includes("all")) return ["codex", "claude", "copilot"];
240
+ if (filtered.length === 0) return ["codex", "claude", "copilot"];
241
+ return filtered;
242
+ }
243
+
244
+ export function buildHookScaffoldOptionsFromEnv(env = process.env) {
245
+ const profile = normalizeProfile(env.CODEX_MONITOR_HOOK_PROFILE);
246
+ return {
247
+ enabled: parseBoolean(env.CODEX_MONITOR_HOOKS_ENABLED, true),
248
+ profile,
249
+ targets: normalizeHookTargets(env.CODEX_MONITOR_HOOK_TARGETS),
250
+ overwriteExisting: parseBoolean(env.CODEX_MONITOR_HOOKS_OVERWRITE, false),
251
+ commands: {
252
+ SessionStart: normalizeOverrideCommands(
253
+ env.CODEX_MONITOR_HOOK_SESSION_START,
254
+ ),
255
+ SessionStop: normalizeOverrideCommands(
256
+ env.CODEX_MONITOR_HOOK_SESSION_STOP,
257
+ ),
258
+ PrePush: normalizeOverrideCommands(env.CODEX_MONITOR_HOOK_PREPUSH),
259
+ PreCommit: normalizeOverrideCommands(env.CODEX_MONITOR_HOOK_PRECOMMIT),
260
+ TaskComplete: normalizeOverrideCommands(
261
+ env.CODEX_MONITOR_HOOK_TASK_COMPLETE,
262
+ ),
263
+ },
264
+ };
265
+ }
266
+
267
+ export function buildCanonicalHookConfig(options = {}) {
268
+ const profile = normalizeProfile(options.profile);
269
+ const flags = { ...PRESET_FLAGS[profile] };
270
+ const commandOverrides = options.commands || {};
271
+
272
+ const hooks = {};
273
+
274
+ if (flags.includeSessionHooks) {
275
+ hooks.SessionStart = deepClone(PRESET_COMMANDS.SessionStart).map(
276
+ (item) => ({
277
+ ...item,
278
+ sdks: ["*"],
279
+ }),
280
+ );
281
+ hooks.SessionStop = deepClone(PRESET_COMMANDS.SessionStop).map((item) => ({
282
+ ...item,
283
+ sdks: ["*"],
284
+ }));
285
+ }
286
+ if (flags.includePrePush) {
287
+ hooks.PrePush = deepClone(PRESET_COMMANDS.PrePush).map((item) => ({
288
+ ...item,
289
+ sdks: ["*"],
290
+ }));
291
+ }
292
+ if (flags.includePreCommit) {
293
+ hooks.PreCommit = deepClone(PRESET_COMMANDS.PreCommit).map((item) => ({
294
+ ...item,
295
+ sdks: ["*"],
296
+ }));
297
+ }
298
+ if (flags.includeTaskComplete) {
299
+ hooks.TaskComplete = deepClone(PRESET_COMMANDS.TaskComplete).map(
300
+ (item) => ({
301
+ ...item,
302
+ sdks: ["*"],
303
+ }),
304
+ );
305
+ }
306
+
307
+ for (const event of [
308
+ "SessionStart",
309
+ "SessionStop",
310
+ "PrePush",
311
+ "PreCommit",
312
+ "TaskComplete",
313
+ ]) {
314
+ const override = commandOverrides[event];
315
+ if (override === null || override === undefined) continue;
316
+ if (override.length === 0) {
317
+ delete hooks[event];
318
+ continue;
319
+ }
320
+ hooks[event] = mapCommandsToHooks(event, override);
321
+ }
322
+
323
+ return {
324
+ $schema: DEFAULT_HOOK_SCHEMA,
325
+ description:
326
+ "Agent lifecycle hooks for openfleet. Compatible with Codex, Claude Code, and Copilot CLI.",
327
+ hooks,
328
+ meta: {
329
+ profile,
330
+ generatedBy: "openfleet setup",
331
+ generatedAt: new Date().toISOString(),
332
+ },
333
+ };
334
+ }
335
+
336
+ function createCopilotHookConfig() {
337
+ return {
338
+ version: 1,
339
+ sessionStart: [
340
+ {
341
+ type: "command",
342
+ command: makeBridgeCommandTokens("copilot", "sessionStart"),
343
+ timeout: 60,
344
+ },
345
+ ],
346
+ sessionEnd: [
347
+ {
348
+ type: "command",
349
+ command: makeBridgeCommandTokens("copilot", "sessionEnd"),
350
+ timeout: 60,
351
+ },
352
+ ],
353
+ preToolUse: {
354
+ "*": [
355
+ {
356
+ type: "command",
357
+ command: makeBridgeCommandTokens("copilot", "preToolUse"),
358
+ timeout: 300,
359
+ },
360
+ ],
361
+ },
362
+ postToolUse: {
363
+ "*": [
364
+ {
365
+ type: "command",
366
+ command: makeBridgeCommandTokens("copilot", "postToolUse"),
367
+ timeout: 120,
368
+ },
369
+ ],
370
+ },
371
+ };
372
+ }
373
+
374
+ function createClaudeHookConfig() {
375
+ return {
376
+ hooks: {
377
+ UserPromptSubmit: [
378
+ {
379
+ matcher: "",
380
+ hooks: [
381
+ {
382
+ type: "command",
383
+ command: buildShellCommand(
384
+ makeBridgeCommandTokens("claude", "UserPromptSubmit"),
385
+ ),
386
+ },
387
+ ],
388
+ },
389
+ ],
390
+ PreToolUse: [
391
+ {
392
+ matcher: "Bash",
393
+ hooks: [
394
+ {
395
+ type: "command",
396
+ command: buildShellCommand(
397
+ makeBridgeCommandTokens("claude", "PreToolUse"),
398
+ ),
399
+ },
400
+ ],
401
+ },
402
+ ],
403
+ PostToolUse: [
404
+ {
405
+ matcher: "Bash",
406
+ hooks: [
407
+ {
408
+ type: "command",
409
+ command: buildShellCommand(
410
+ makeBridgeCommandTokens("claude", "PostToolUse"),
411
+ ),
412
+ },
413
+ ],
414
+ },
415
+ ],
416
+ Stop: [
417
+ {
418
+ matcher: "",
419
+ hooks: [
420
+ {
421
+ type: "command",
422
+ command: buildShellCommand(
423
+ makeBridgeCommandTokens("claude", "Stop"),
424
+ ),
425
+ },
426
+ ],
427
+ },
428
+ ],
429
+ },
430
+ };
431
+ }
432
+
433
+ function loadJson(path) {
434
+ if (!existsSync(path)) return null;
435
+ try {
436
+ return JSON.parse(readFileSync(path, "utf8"));
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+
442
+ function writeJson(path, data) {
443
+ mkdirSync(dirname(path), { recursive: true });
444
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
445
+ }
446
+
447
+ function mergeClaudeSettings(existing, generated) {
448
+ const base =
449
+ existing && typeof existing === "object" && !Array.isArray(existing)
450
+ ? { ...existing }
451
+ : {};
452
+
453
+ const existingHooks =
454
+ base.hooks && typeof base.hooks === "object" && !Array.isArray(base.hooks)
455
+ ? base.hooks
456
+ : {};
457
+
458
+ const mergedHooks = { ...existingHooks };
459
+ for (const [event, generatedEntries] of Object.entries(generated.hooks)) {
460
+ let existingEntries = Array.isArray(mergedHooks[event])
461
+ ? [...mergedHooks[event]]
462
+ : [];
463
+
464
+ existingEntries = existingEntries.filter((entry) => {
465
+ if (!entry || typeof entry !== "object") return true;
466
+ const commands = Array.isArray(entry.hooks)
467
+ ? entry.hooks.map((h) => String(h?.command || ""))
468
+ : [];
469
+ const hasLegacyBridge = commands.some((cmd) => isBridgeCommandString(cmd));
470
+ return !hasLegacyBridge;
471
+ });
472
+
473
+ for (const generatedEntry of generatedEntries) {
474
+ const exists = existingEntries.some((entry) => {
475
+ if (!entry || typeof entry !== "object") return false;
476
+ const sameMatcher =
477
+ String(entry.matcher || "") === String(generatedEntry.matcher || "");
478
+ if (!sameMatcher) return false;
479
+
480
+ const entryCommands = Array.isArray(entry.hooks)
481
+ ? entry.hooks.map((h) => String(h?.command || ""))
482
+ : [];
483
+ const generatedCommands = generatedEntry.hooks.map((h) =>
484
+ String(h.command || ""),
485
+ );
486
+ return generatedCommands.every((cmd) => entryCommands.includes(cmd));
487
+ });
488
+ if (!exists) {
489
+ existingEntries.push(generatedEntry);
490
+ }
491
+ }
492
+
493
+ mergedHooks[event] = existingEntries;
494
+ }
495
+
496
+ base.hooks = mergedHooks;
497
+ return base;
498
+ }
499
+
500
+ function hasLegacyBridgeInCopilotConfig(config) {
501
+ if (!config || typeof config !== "object") return false;
502
+ const events = [
503
+ ...((Array.isArray(config.sessionStart) && config.sessionStart) || []),
504
+ ...((Array.isArray(config.sessionEnd) && config.sessionEnd) || []),
505
+ ...Object.values(config.preToolUse || {}).flatMap((hooks) =>
506
+ Array.isArray(hooks) ? hooks : [],
507
+ ),
508
+ ...Object.values(config.postToolUse || {}).flatMap((hooks) =>
509
+ Array.isArray(hooks) ? hooks : [],
510
+ ),
511
+ ];
512
+
513
+ let foundAnyBridgeHook = false;
514
+ for (const entry of events) {
515
+ const command = entry?.command;
516
+ if (Array.isArray(command) && command.some((part) => isBridgeCommandString(part))) {
517
+ foundAnyBridgeHook = true;
518
+ if (!isCopilotBridgeCommandPortable(command)) return true;
519
+ continue;
520
+ }
521
+
522
+ if (typeof command === "string" && isBridgeCommandString(command)) {
523
+ foundAnyBridgeHook = true;
524
+ return true;
525
+ }
526
+ }
527
+
528
+ if (!foundAnyBridgeHook) return false;
529
+
530
+ const scan = (value) => {
531
+ if (typeof value === "string") {
532
+ return value.includes(LEGACY_BRIDGE_SNIPPET);
533
+ }
534
+ if (Array.isArray(value)) {
535
+ return value.some((item) => scan(item));
536
+ }
537
+ if (!value || typeof value !== "object") return false;
538
+ return Object.values(value).some((item) => scan(item));
539
+ };
540
+ return scan(config);
541
+ }
542
+
543
+ function buildDisableEnv(hookConfig) {
544
+ const hasPrePush = Array.isArray(hookConfig.hooks?.PrePush);
545
+ const hasTaskComplete = Array.isArray(hookConfig.hooks?.TaskComplete);
546
+
547
+ return {
548
+ CODEX_MONITOR_HOOKS_BUILTINS_MODE:
549
+ hasPrePush || hasTaskComplete ? "auto" : "off",
550
+ CODEX_MONITOR_HOOKS_DISABLE_PREPUSH: hasPrePush ? "0" : "1",
551
+ CODEX_MONITOR_HOOKS_DISABLE_TASK_COMPLETE: hasTaskComplete ? "0" : "1",
552
+ };
553
+ }
554
+
555
+ export function scaffoldAgentHookFiles(repoRoot, options = {}) {
556
+ const root = resolve(repoRoot || process.cwd());
557
+ const targets = normalizeHookTargets(options.targets);
558
+ const overwriteExisting = parseBoolean(options.overwriteExisting, false);
559
+ const enabled = parseBoolean(options.enabled, true);
560
+
561
+ const result = {
562
+ enabled,
563
+ profile: normalizeProfile(options.profile),
564
+ targets,
565
+ written: [],
566
+ updated: [],
567
+ skipped: [],
568
+ warnings: [],
569
+ env: {},
570
+ };
571
+
572
+ if (!enabled) {
573
+ result.env = {
574
+ CODEX_MONITOR_HOOKS_BUILTINS_MODE: "off",
575
+ CODEX_MONITOR_HOOKS_DISABLE_PREPUSH: "1",
576
+ CODEX_MONITOR_HOOKS_DISABLE_TASK_COMPLETE: "1",
577
+ };
578
+ return result;
579
+ }
580
+
581
+ const codexHookConfig = buildCanonicalHookConfig(options);
582
+ result.env = buildDisableEnv(codexHookConfig);
583
+
584
+ if (targets.includes("codex")) {
585
+ const codexPath = resolve(root, ".codex", "hooks.json");
586
+ const existedBefore = existsSync(codexPath);
587
+ if (existedBefore && !overwriteExisting) {
588
+ result.skipped.push(relative(root, codexPath));
589
+ } else {
590
+ writeJson(codexPath, codexHookConfig);
591
+ if (existedBefore) {
592
+ result.updated.push(relative(root, codexPath));
593
+ } else {
594
+ result.written.push(relative(root, codexPath));
595
+ }
596
+ }
597
+ }
598
+
599
+ if (targets.includes("copilot")) {
600
+ const copilotPath = resolve(
601
+ root,
602
+ ".github",
603
+ "hooks",
604
+ "openfleet.hooks.json",
605
+ );
606
+ const config = createCopilotHookConfig();
607
+ const existedBefore = existsSync(copilotPath);
608
+ const existingCopilot = loadJson(copilotPath);
609
+ const forceLegacyMigration =
610
+ hasLegacyBridgeInCopilotConfig(existingCopilot);
611
+
612
+ if (existedBefore && !overwriteExisting && !forceLegacyMigration) {
613
+ result.skipped.push(relative(root, copilotPath));
614
+ } else {
615
+ writeJson(copilotPath, config);
616
+ if (existedBefore) {
617
+ result.updated.push(relative(root, copilotPath));
618
+ if (forceLegacyMigration) {
619
+ result.warnings.push(
620
+ `${relative(root, copilotPath)} contained legacy bridge path and was auto-updated`,
621
+ );
622
+ }
623
+ } else {
624
+ result.written.push(relative(root, copilotPath));
625
+ }
626
+ }
627
+ }
628
+
629
+ if (targets.includes("claude")) {
630
+ const claudePath = resolve(root, ".claude", "settings.local.json");
631
+ const generated = createClaudeHookConfig();
632
+ const existedBefore = existsSync(claudePath);
633
+ const existing = loadJson(claudePath);
634
+
635
+ if (existing === null && existsSync(claudePath)) {
636
+ result.warnings.push(
637
+ `${relative(root, claudePath)} exists but is not valid JSON; skipped`,
638
+ );
639
+ } else {
640
+ const merged = mergeClaudeSettings(existing, generated);
641
+ writeJson(claudePath, merged);
642
+ if (existedBefore) {
643
+ result.updated.push(relative(root, claudePath));
644
+ } else {
645
+ result.written.push(relative(root, claudePath));
646
+ }
647
+ }
648
+ }
649
+
650
+ return result;
651
+ }