agent-switchboard 0.4.16 → 0.4.18

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/README.md CHANGED
@@ -38,9 +38,10 @@ Library entries are agent-agnostic Markdown files (or directories for skills, JS
38
38
  | Commands | ✓ | ✓\* | ✓ | ✓ | ✓ | | |
39
39
  | Agents | ✓ | ✓ | ✓ | | ✓ | | |
40
40
  | Skills | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
41
- | Hooks | ✓ | | | | | | |
41
+ | Hooks | ✓ | ✓† | | | | | |
42
42
 
43
- \* Codex commands use deprecated `~/.codex/prompts/`; prefer skills instead. Trae column applies to both `trae` and `trae-cn` variants.
43
+ \* Codex commands use deprecated `~/.codex/prompts/`; prefer skills instead.
44
+ † ASB currently distributes Codex hooks as command handlers. It writes `~/.codex/hooks.json` or `<project>/.codex/hooks.json`, filters unsupported hook entries, and reports config, trust, and review prerequisites. Trae column applies to both `trae` and `trae-cn` variants.
44
45
 
45
46
  Cursor rules are composed into a single `asb-rules.mdc` file at `~/.cursor/rules/` with `alwaysApply: true`.
46
47
 
@@ -93,7 +94,7 @@ Library content lives under `~/.agent-switchboard/` and agent configs are update
93
94
  | `asb command` | Interactive command selector |
94
95
  | `asb agent` | Interactive agent selector |
95
96
  | `asb skill` | Interactive skill selector |
96
- | `asb hook` | Interactive hook selector (Claude Code only) |
97
+ | `asb hook` | Interactive hook selector (Claude Code and Codex) |
97
98
  | `asb sync` | Push all libraries + MCP to applications (no UI) |
98
99
  | `asb <lib> load` | Import files from a platform into the library |
99
100
  | `asb <lib> list` | Show inventory, enabled state, and sync timestamps |
@@ -316,7 +317,7 @@ Entire directories are copied to each agent's skill location. Deactivated skills
316
317
 
317
318
  ### Hooks
318
319
 
319
- JSON-based hook definitions distributed to Claude Code's `settings.json`. Two storage formats:
320
+ JSON-based hook definitions distributed to Claude Code's `settings.json` and Codex's `hooks.json`. Two storage formats:
320
321
 
321
322
  - **Single file**: `~/.agent-switchboard/hooks/<id>.json`
322
323
  - **Bundle**: `~/.agent-switchboard/hooks/<id>/hook.json` plus script files
@@ -327,7 +328,12 @@ asb hook load /path/to/hook.json # import a JSON file
327
328
  asb hook load /path/to/hook-dir/ # import a bundle directory
328
329
  ```
329
330
 
330
- Bundle scripts are copied to `~/.claude/hooks/asb/<id>/` and the `${HOOK_DIR}` placeholder in commands is resolved to the absolute path at distribution time.
331
+ Bundle scripts are copied to the target agent's ASB hook bundle directory and the `${HOOK_DIR}` placeholder in commands is resolved to the absolute path at distribution time:
332
+
333
+ - Claude Code: `~/.claude/hooks/asb/<id>/`
334
+ - Codex: `~/.codex/hooks/asb/<id>/` or `<project>/.codex/hooks/asb/<id>/`
335
+
336
+ Codex hook sync writes `~/.codex/hooks.json` for global scope or `<project>/.codex/hooks.json` for project scope. ASB emits command handlers for `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SessionStart`, `UserPromptSubmit`, and `Stop`; unsupported events and non-command handler types are filtered from Codex output and reported in sync results. Codex uses `[features].hooks` in `~/.codex/config.toml` (enabled by default when absent; legacy `[features].codex_hooks` is accepted for compatibility). Project-scoped hooks require the project to be trusted, and new or changed Codex hooks must be reviewed from `/hooks` in Codex before they run.
331
337
 
332
338
  ## Plugins
333
339
 
@@ -5,8 +5,8 @@
5
5
  * <project>/.codex/hooks.json (project scope). Unlike Claude Code which
6
6
  * embeds hooks inside settings.json, Codex uses a dedicated file.
7
7
  *
8
- * Codex only supports: command handlers, 5 event types, no ${HOOK_DIR}
9
- * runtime expansion. ASB must filter unsupported entries and rewrite
8
+ * ASB currently distributes command handlers to Codex and Codex has no
9
+ * ${HOOK_DIR} runtime expansion. ASB must filter unsupported entries and rewrite
10
10
  * bundle paths to absolute paths before writing.
11
11
  */
12
12
  import type { ConfigScope } from '../config/scope.js';
@@ -5,22 +5,26 @@
5
5
  * <project>/.codex/hooks.json (project scope). Unlike Claude Code which
6
6
  * embeds hooks inside settings.json, Codex uses a dedicated file.
7
7
  *
8
- * Codex only supports: command handlers, 5 event types, no ${HOOK_DIR}
9
- * runtime expansion. ASB must filter unsupported entries and rewrite
8
+ * ASB currently distributes command handlers to Codex and Codex has no
9
+ * ${HOOK_DIR} runtime expansion. ASB must filter unsupported entries and rewrite
10
10
  * bundle paths to absolute paths before writing.
11
11
  */
12
+ import { createHash } from 'node:crypto';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
15
- import { ensureTrustEntry } from '../agents/codex.js';
16
+ import { parse as parseToml } from '@iarna/toml';
16
17
  import { getCodexConfigPath, getCodexDir, getCodexHooksJsonPath, getProjectCodexDir, getProjectCodexHooksJsonPath, } from '../config/paths.js';
17
- import { distributeBundle } from '../library/distribute-bundle.js';
18
- import { ensureParentDir, rmDirRecursive } from '../library/fs.js';
18
+ import { assertNoSymlinkAncestor, assertUsableBundleRoot, distributeBundle, } from '../library/distribute-bundle.js';
19
+ import { ensureParentDir } from '../library/fs.js';
19
20
  import { listHookBundleFiles } from './library.js';
20
21
  import { HOOK_DIR_PLACEHOLDER } from './schema.js';
21
22
  const CODEX_SUPPORTED_EVENTS = new Set([
22
23
  'PreToolUse',
24
+ 'PermissionRequest',
23
25
  'PostToolUse',
26
+ 'PreCompact',
27
+ 'PostCompact',
24
28
  'SessionStart',
25
29
  'UserPromptSubmit',
26
30
  'Stop',
@@ -49,6 +53,13 @@ function resolveHooksBundleParentDir(scope) {
49
53
  }
50
54
  return path.join(getCodexDir(), 'hooks', ASB_HOOKS_SUBDIR);
51
55
  }
56
+ function resolveHooksBundleSafetyRoot(scope) {
57
+ const projectRoot = scope?.project?.trim();
58
+ if (projectRoot && projectRoot.length > 0) {
59
+ return getProjectCodexDir(projectRoot);
60
+ }
61
+ return getCodexDir();
62
+ }
52
63
  function resolveHookBundleTargetDir(entry, scope) {
53
64
  return path.join(resolveHooksBundleParentDir(scope), entry.id);
54
65
  }
@@ -61,16 +72,40 @@ function preferHomeVar(command) {
61
72
  return command;
62
73
  return command.replaceAll(home, '$HOME');
63
74
  }
75
+ function formatUnsupportedReason(diagnostic) {
76
+ const parts = [];
77
+ if (diagnostic.unsupportedEvents.length > 0) {
78
+ parts.push(`unsupported events: ${diagnostic.unsupportedEvents.join(', ')}`);
79
+ }
80
+ if (diagnostic.unsupportedHandlerTypes.length > 0) {
81
+ parts.push(`unsupported handler types: ${diagnostic.unsupportedHandlerTypes.join(', ')}`);
82
+ }
83
+ const prefix = diagnostic.fullyFiltered
84
+ ? 'hook has no Codex-compatible command handlers after filtering'
85
+ : 'hook was partially filtered for Codex compatibility';
86
+ return `${prefix} (${parts.join('; ')})`;
87
+ }
64
88
  function filterForCodex(entries) {
65
89
  const result = [];
90
+ const diagnostics = [];
66
91
  for (const entry of entries) {
67
92
  const filteredHooks = {};
93
+ const unsupportedEvents = new Set();
94
+ const unsupportedHandlerTypes = new Set();
68
95
  for (const [event, groups] of Object.entries(entry.hooks)) {
69
- if (!CODEX_SUPPORTED_EVENTS.has(event))
96
+ if (!CODEX_SUPPORTED_EVENTS.has(event)) {
97
+ unsupportedEvents.add(event);
70
98
  continue;
99
+ }
71
100
  const filteredGroups = [];
72
101
  for (const group of groups) {
73
- const filteredHandlers = (group.hooks ?? []).filter((h) => CODEX_SUPPORTED_HANDLER_TYPES.has(h.type ?? ''));
102
+ const filteredHandlers = (group.hooks ?? []).filter((h) => {
103
+ const type = h.type ?? 'unknown';
104
+ const supported = CODEX_SUPPORTED_HANDLER_TYPES.has(type);
105
+ if (!supported)
106
+ unsupportedHandlerTypes.add(type);
107
+ return supported;
108
+ });
74
109
  if (filteredHandlers.length > 0) {
75
110
  filteredGroups.push({ ...group, hooks: filteredHandlers });
76
111
  }
@@ -82,8 +117,16 @@ function filterForCodex(entries) {
82
117
  if (Object.keys(filteredHooks).length > 0) {
83
118
  result.push({ entry, hooks: filteredHooks });
84
119
  }
120
+ if (unsupportedEvents.size > 0 || unsupportedHandlerTypes.size > 0) {
121
+ diagnostics.push({
122
+ entryId: entry.id,
123
+ unsupportedEvents: [...unsupportedEvents].sort(),
124
+ unsupportedHandlerTypes: [...unsupportedHandlerTypes].sort(),
125
+ fullyFiltered: Object.keys(filteredHooks).length === 0,
126
+ });
127
+ }
85
128
  }
86
- return result;
129
+ return { entries: result, diagnostics };
87
130
  }
88
131
  // ---------------------------------------------------------------------------
89
132
  // hooks.json I/O
@@ -93,7 +136,7 @@ function readHooksJson(filePath) {
93
136
  if (fs.existsSync(filePath)) {
94
137
  return {
95
138
  ok: true,
96
- data: JSON.parse(fs.readFileSync(filePath, 'utf-8')),
139
+ data: parseHooksJsonRoot(JSON.parse(fs.readFileSync(filePath, 'utf-8'))),
97
140
  };
98
141
  }
99
142
  }
@@ -102,6 +145,12 @@ function readHooksJson(filePath) {
102
145
  }
103
146
  return { ok: true, data: {} };
104
147
  }
148
+ function parseHooksJsonRoot(value) {
149
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
150
+ throw new Error('hooks.json has invalid shape: root must be an object');
151
+ }
152
+ return value;
153
+ }
105
154
  function writeHooksJson(filePath, data) {
106
155
  ensureParentDir(filePath);
107
156
  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
@@ -109,7 +158,7 @@ function writeHooksJson(filePath, data) {
109
158
  // ---------------------------------------------------------------------------
110
159
  // Config merge
111
160
  // ---------------------------------------------------------------------------
112
- function rewriteHookDir(hooks, distributedDir) {
161
+ function rewriteHookDir(hooks, distributedDir, bundleHash) {
113
162
  const result = {};
114
163
  for (const [event, groups] of Object.entries(hooks)) {
115
164
  result[event] = groups.map((group) => ({
@@ -119,17 +168,33 @@ function rewriteHookDir(hooks, distributedDir) {
119
168
  return handler;
120
169
  return {
121
170
  ...handler,
122
- command: preferHomeVar(handler.command
171
+ command: preferHomeVar(annotateBundleCommand(handler.command
123
172
  .replaceAll(HOOK_DIR_PLACEHOLDER, distributedDir)
124
173
  .replaceAll(CLAUDE_PLUGIN_ROOT_HOOKS_PREFIX, distributedDir)
125
- .replaceAll(CLAUDE_PLUGIN_ROOT_HOOKS_PREFIX_WINDOWS, distributedDir)),
174
+ .replaceAll(CLAUDE_PLUGIN_ROOT_HOOKS_PREFIX_WINDOWS, distributedDir), bundleHash)),
126
175
  };
127
176
  }),
128
177
  }));
129
178
  }
130
179
  return result;
131
180
  }
132
- function mergeHooksIntoFile(fileData, filteredEntries, scope) {
181
+ function annotateBundleCommand(command, bundleHash) {
182
+ if (!bundleHash)
183
+ return command;
184
+ return `${command}\n# asb-bundle-sha256=${bundleHash}`;
185
+ }
186
+ function computeBundleHash(entry) {
187
+ const hash = createHash('sha256');
188
+ const files = listHookBundleFiles(entry).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
189
+ for (const file of files) {
190
+ hash.update(file.relativePath);
191
+ hash.update('\0');
192
+ hash.update(fs.readFileSync(file.sourcePath));
193
+ hash.update('\0');
194
+ }
195
+ return hash.digest('hex');
196
+ }
197
+ function mergeHooksIntoFile(fileData, filteredEntries, scope, bundleHashes) {
133
198
  const existingHooks = (fileData.hooks ?? {});
134
199
  // Remove previously ASB-managed matcher groups
135
200
  const cleanedHooks = {};
@@ -141,7 +206,7 @@ function mergeHooksIntoFile(fileData, filteredEntries, scope) {
141
206
  // Merge filtered entries
142
207
  for (const { entry, hooks } of filteredEntries) {
143
208
  const resolvedHooks = entry.isBundle
144
- ? rewriteHookDir(hooks, resolveHookBundleTargetDir(entry, scope))
209
+ ? rewriteHookDir(hooks, resolveHookBundleTargetDir(entry, scope), bundleHashes?.get(entry.id))
145
210
  : hooks;
146
211
  for (const [event, groups] of Object.entries(resolvedHooks)) {
147
212
  if (!cleanedHooks[event])
@@ -152,7 +217,12 @@ function mergeHooksIntoFile(fileData, filteredEntries, scope) {
152
217
  }
153
218
  }
154
219
  fileData.hooks = cleanedHooks;
155
- fileData[ASB_MANAGED_KEY] = filteredEntries.map((f) => f.entry.id);
220
+ if (filteredEntries.length > 0) {
221
+ fileData[ASB_MANAGED_KEY] = filteredEntries.map((f) => f.entry.id);
222
+ }
223
+ else {
224
+ delete fileData[ASB_MANAGED_KEY];
225
+ }
156
226
  }
157
227
  // ---------------------------------------------------------------------------
158
228
  // Orphan cleanup for bundles
@@ -160,19 +230,24 @@ function mergeHooksIntoFile(fileData, filteredEntries, scope) {
160
230
  function cleanOrphanBundleDirs(activeIds, scope, dryRun) {
161
231
  const parentDir = resolveHooksBundleParentDir(scope);
162
232
  const results = [];
163
- if (!fs.existsSync(parentDir))
233
+ const parentError = getOrphanBundleParentError(scope);
234
+ if (parentError) {
235
+ results.push(parentError);
236
+ return results;
237
+ }
238
+ if (!lstatIfExists(parentDir))
164
239
  return results;
165
240
  try {
166
241
  const entries = fs.readdirSync(parentDir, { withFileTypes: true });
167
242
  for (const entry of entries) {
168
- if (!entry.isDirectory())
243
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
169
244
  continue;
170
245
  if (activeIds.has(entry.name))
171
246
  continue;
172
247
  const dirPath = path.join(parentDir, entry.name);
173
248
  try {
174
249
  if (!dryRun)
175
- rmDirRecursive(dirPath);
250
+ removeHookBundlePath(dirPath);
176
251
  results.push({
177
252
  platform: 'codex',
178
253
  targetDir: dirPath,
@@ -193,70 +268,163 @@ function cleanOrphanBundleDirs(activeIds, scope, dryRun) {
193
268
  }
194
269
  }
195
270
  }
196
- catch {
197
- // Ignore directory read errors
271
+ catch (error) {
272
+ const msg = error instanceof Error ? error.message : String(error);
273
+ results.push({
274
+ platform: 'codex',
275
+ targetDir: parentDir,
276
+ status: 'error',
277
+ error: `Failed to scan orphan parent: ${msg}`,
278
+ });
198
279
  }
199
280
  return results;
200
281
  }
201
- // ---------------------------------------------------------------------------
202
- // Codex prerequisites: feature flag + project trust
203
- // ---------------------------------------------------------------------------
204
- function ensureCodexHooksFeature(results) {
205
- const configPath = getCodexConfigPath();
206
- if (!fs.existsSync(configPath))
282
+ function getOrphanBundleParentError(scope) {
283
+ const parentDir = resolveHooksBundleParentDir(scope);
284
+ try {
285
+ const safetyRoot = resolveHooksBundleSafetyRoot(scope);
286
+ assertUsableBundleRoot(safetyRoot);
287
+ assertNoSymlinkAncestor(safetyRoot, parentDir);
288
+ }
289
+ catch (error) {
290
+ const msg = error instanceof Error ? error.message : String(error);
291
+ return {
292
+ platform: 'codex',
293
+ targetDir: parentDir,
294
+ status: 'error',
295
+ error: `Failed to scan orphan parent: ${msg}`,
296
+ };
297
+ }
298
+ const stat = lstatIfExists(parentDir);
299
+ if (!stat)
300
+ return undefined;
301
+ if (!stat.isDirectory()) {
302
+ return {
303
+ platform: 'codex',
304
+ targetDir: parentDir,
305
+ status: 'error',
306
+ error: `Failed to scan orphan parent: bundle root exists and is not a directory: ${parentDir}`,
307
+ };
308
+ }
309
+ return undefined;
310
+ }
311
+ function lstatIfExists(filePath) {
312
+ try {
313
+ return fs.lstatSync(filePath);
314
+ }
315
+ catch (error) {
316
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
317
+ return undefined;
318
+ }
319
+ throw error;
320
+ }
321
+ }
322
+ function removeHookBundlePath(targetPath) {
323
+ const stat = lstatIfExists(targetPath);
324
+ if (!stat)
207
325
  return;
208
- const content = fs.readFileSync(configPath, 'utf-8');
209
- if (content.includes('codex_hooks') && content.includes('true'))
326
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
327
+ const entries = fs.readdirSync(targetPath, { withFileTypes: true });
328
+ for (const entry of entries) {
329
+ removeHookBundlePath(path.join(targetPath, entry.name));
330
+ }
331
+ fs.rmdirSync(targetPath);
210
332
  return;
211
- // Feature not explicitly enabled - add warning result
212
- results.push({
213
- platform: 'codex',
214
- filePath: configPath,
215
- status: 'written',
216
- reason: 'codex_hooks feature flag may not be enabled in config.toml',
217
- });
333
+ }
334
+ fs.unlinkSync(targetPath);
218
335
  }
219
- function ensureProjectTrust(projectRoot, results) {
220
- const globalPath = getCodexConfigPath();
221
- const globalContent = fs.existsSync(globalPath) ? fs.readFileSync(globalPath, 'utf-8') : '';
222
- const trustResult = ensureTrustEntry(globalContent, projectRoot);
223
- if (trustResult.warning) {
224
- results.push({
225
- platform: 'codex',
226
- filePath: globalPath,
227
- status: 'skipped',
228
- reason: trustResult.warning,
229
- });
336
+ function readCodexConfigToml() {
337
+ const configPath = getCodexConfigPath();
338
+ if (!fs.existsSync(configPath))
339
+ return { ok: true, filePath: configPath, data: {} };
340
+ try {
341
+ const data = parseToml(fs.readFileSync(configPath, 'utf-8'));
342
+ return { ok: true, filePath: configPath, data };
230
343
  }
231
- if (trustResult.changed) {
232
- ensureParentDir(globalPath);
233
- fs.writeFileSync(globalPath, trustResult.content, 'utf-8');
344
+ catch (error) {
345
+ return {
346
+ ok: false,
347
+ filePath: configPath,
348
+ error: error instanceof Error ? error.message : String(error),
349
+ };
350
+ }
351
+ }
352
+ function asBoolean(value) {
353
+ return typeof value === 'boolean' ? value : undefined;
354
+ }
355
+ function getHooksFeatureValue(features) {
356
+ return asBoolean(features?.hooks) ?? asBoolean(features?.codex_hooks);
357
+ }
358
+ function getObject(value) {
359
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
360
+ return undefined;
361
+ return value;
362
+ }
363
+ function getEffectiveHooksEnabled(config) {
364
+ const baseValue = getHooksFeatureValue(getObject(config.features));
365
+ const profileName = typeof config.profile === 'string' ? config.profile : undefined;
366
+ const profiles = getObject(config.profiles);
367
+ const profileConfig = profileName ? getObject(profiles?.[profileName]) : undefined;
368
+ const profileValue = getHooksFeatureValue(getObject(profileConfig?.features));
369
+ return profileValue ?? baseValue ?? true;
370
+ }
371
+ function addCodexHooksFeatureResult(config, results) {
372
+ const effectiveHooksEnabled = getEffectiveHooksEnabled(config.data);
373
+ if (!effectiveHooksEnabled) {
234
374
  results.push({
235
375
  platform: 'codex',
236
- filePath: globalPath,
237
- status: 'written',
238
- reason: 'added project trust entry',
376
+ filePath: config.filePath,
377
+ status: 'conflict',
378
+ reason: 'features.hooks is disabled; enable it before Codex hooks can run',
239
379
  });
240
380
  }
241
381
  }
382
+ function addProjectTrustResult(config, projectRoot, results) {
383
+ const projects = getObject(config.data.projects);
384
+ const project = getObject(projects?.[path.resolve(projectRoot)]);
385
+ const trustLevel = project?.trust_level;
386
+ if (trustLevel === 'trusted')
387
+ return;
388
+ const reason = typeof trustLevel === 'string'
389
+ ? `project is not trusted (trust_level="${trustLevel}"); Codex will ignore project hooks`
390
+ : 'project is not trusted; Codex will ignore project hooks until trust_level = "trusted" is configured';
391
+ results.push({ platform: 'codex', filePath: config.filePath, status: 'conflict', reason });
392
+ }
393
+ function addReviewResult(hooksJsonPath, results) {
394
+ results.push({
395
+ platform: 'codex',
396
+ filePath: hooksJsonPath,
397
+ status: 'conflict',
398
+ reason: 'open /hooks in Codex to review new or modified hooks before they can run',
399
+ });
400
+ }
401
+ function addConfigParseResult(config, results) {
402
+ results.push({
403
+ platform: 'codex',
404
+ filePath: config.filePath,
405
+ status: 'conflict',
406
+ reason: `Cannot parse config.toml to verify Codex hook prerequisites: ${config.error}`,
407
+ });
408
+ }
242
409
  export function distributeCodexHooks(options) {
243
410
  const { scope, selected, dryRun = false, projectMode } = options;
244
411
  if (scope?.project && projectMode === 'none') {
245
412
  return { results: [] };
246
413
  }
247
414
  const results = [];
248
- // Ensure Codex hooks feature flag is enabled
249
- if (!dryRun) {
250
- ensureCodexHooksFeature(results);
251
- }
252
- // Ensure project trust for project-scoped distribution
253
- if (!dryRun && scope?.project) {
254
- ensureProjectTrust(scope.project, results);
255
- }
256
- // Filter entries for Codex compatibility
257
- const filteredEntries = filterForCodex(selected);
258
415
  // Pre-validate hooks.json before making any filesystem changes
259
416
  const hooksJsonPath = resolveHooksJsonPath(scope);
417
+ const filterResult = filterForCodex(selected);
418
+ const filteredEntries = filterResult.entries;
419
+ for (const diagnostic of filterResult.diagnostics) {
420
+ results.push({
421
+ platform: 'codex',
422
+ filePath: hooksJsonPath,
423
+ status: 'skipped',
424
+ reason: formatUnsupportedReason(diagnostic),
425
+ entryId: diagnostic.entryId,
426
+ });
427
+ }
260
428
  const fileResult = readHooksJson(hooksJsonPath);
261
429
  if (!fileResult.ok) {
262
430
  results.push({
@@ -293,55 +461,142 @@ export function distributeCodexHooks(options) {
293
461
  }
294
462
  }
295
463
  }
464
+ if (filteredEntries.length > 0) {
465
+ const config = readCodexConfigToml();
466
+ if (!config.ok) {
467
+ addConfigParseResult(config, results);
468
+ }
469
+ else {
470
+ addCodexHooksFeatureResult(config, results);
471
+ if (scope?.project)
472
+ addProjectTrustResult(config, scope.project, results);
473
+ }
474
+ }
296
475
  // Phase 1: Copy bundle files (only after validation passes)
297
476
  const bundleEntries = filteredEntries.filter((f) => f.entry.isBundle).map((f) => f.entry);
477
+ const activeBundleIds = new Set(bundleEntries.map((e) => e.id));
478
+ const bundleHashes = new Map();
298
479
  if (bundleEntries.length > 0) {
299
480
  const bundleOutcome = distributeBundle({
300
481
  section: 'hooks',
301
482
  selected: bundleEntries,
302
483
  platforms: ['codex'],
303
484
  resolveTargetDir: (_p, entry) => resolveHookBundleTargetDir(entry, scope),
485
+ resolveBundleRootDir: () => resolveHooksBundleSafetyRoot(scope),
304
486
  listFiles: listHookBundleFiles,
305
487
  getId: (entry) => entry.id,
306
488
  scope,
307
489
  dryRun,
308
490
  });
309
491
  results.push(...bundleOutcome.results);
492
+ if (bundleOutcome.results.some((result) => result.status === 'error' || result.status === 'conflict')) {
493
+ return { results };
494
+ }
495
+ for (const entry of bundleEntries) {
496
+ try {
497
+ bundleHashes.set(entry.id, computeBundleHash(entry));
498
+ }
499
+ catch (error) {
500
+ const msg = error instanceof Error ? error.message : String(error);
501
+ results.push({
502
+ platform: 'codex',
503
+ filePath: hooksJsonPath,
504
+ status: 'error',
505
+ error: `Failed to hash bundle ${entry.id}: ${msg}`,
506
+ entryId: entry.id,
507
+ });
508
+ return { results };
509
+ }
510
+ }
310
511
  }
311
- // Clean up orphan bundle directories
312
- const activeBundleIds = new Set(bundleEntries.map((e) => e.id));
313
- results.push(...cleanOrphanBundleDirs(activeBundleIds, scope, dryRun));
512
+ const cleanupParentError = getOrphanBundleParentError(scope);
513
+ if (cleanupParentError) {
514
+ results.push(cleanupParentError);
515
+ return { results };
516
+ }
517
+ const appendCleanupResults = () => {
518
+ const cleanupResults = cleanOrphanBundleDirs(activeBundleIds, scope, dryRun);
519
+ results.push(...cleanupResults);
520
+ return cleanupResults.some((result) => result.status === 'error');
521
+ };
314
522
  // Phase 2: Merge hook configs into hooks.json
315
523
  const previouslyManaged = (fileData[ASB_MANAGED_KEY] ?? []);
316
524
  // Check for existing ASB groups
317
525
  const existingHooks = (fileData.hooks ?? {});
318
526
  const hasAsbGroups = Object.values(existingHooks).some((groups) => groups.some((g) => g._asb_source === true));
319
527
  if (filteredEntries.length === 0 && previouslyManaged.length === 0 && !hasAsbGroups) {
528
+ appendCleanupResults();
320
529
  return { results };
321
530
  }
322
531
  const before = JSON.stringify(fileData);
323
- mergeHooksIntoFile(fileData, filteredEntries, scope);
532
+ mergeHooksIntoFile(fileData, filteredEntries, scope, bundleHashes);
533
+ const preserveCleanupMarker = filteredEntries.length === 0 && previouslyManaged.length > 0;
534
+ if (preserveCleanupMarker) {
535
+ fileData[ASB_MANAGED_KEY] = previouslyManaged;
536
+ }
324
537
  // Clean up empty state: if no hooks remain, remove the file entirely
325
538
  const mergedHooks = fileData.hooks;
326
539
  const totalGroups = Object.values(mergedHooks).reduce((sum, groups) => sum + groups.length, 0);
327
540
  const hasNoHooks = totalGroups === 0;
328
541
  if (hasNoHooks && filteredEntries.length === 0) {
329
- // Remove ASB tracking keys
330
- delete fileData[ASB_MANAGED_KEY];
542
+ if (!preserveCleanupMarker) {
543
+ delete fileData[ASB_MANAGED_KEY];
544
+ }
331
545
  // If file has only hooks (now empty) and ASB keys, consider deleting
332
546
  const remainingKeys = Object.keys(fileData).filter((k) => k !== 'hooks');
333
547
  if (remainingKeys.length === 0 && fs.existsSync(hooksJsonPath) && !dryRun) {
334
- fs.unlinkSync(hooksJsonPath);
335
- results.push({
336
- platform: 'codex',
337
- filePath: hooksJsonPath,
338
- status: 'deleted',
339
- reason: 'no hooks remain',
340
- });
548
+ try {
549
+ fs.unlinkSync(hooksJsonPath);
550
+ results.push({
551
+ platform: 'codex',
552
+ filePath: hooksJsonPath,
553
+ status: 'deleted',
554
+ reason: 'no hooks remain',
555
+ });
556
+ appendCleanupResults();
557
+ }
558
+ catch (error) {
559
+ const msg = error instanceof Error ? error.message : String(error);
560
+ results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'error', error: msg });
561
+ }
341
562
  return { results };
342
563
  }
343
564
  }
344
565
  const after = JSON.stringify(fileData);
566
+ const finalizeCleanupMarker = () => {
567
+ if (!preserveCleanupMarker || dryRun)
568
+ return;
569
+ delete fileData[ASB_MANAGED_KEY];
570
+ const finalHooks = fileData.hooks;
571
+ const finalTotalGroups = Object.values(finalHooks).reduce((sum, groups) => sum + groups.length, 0);
572
+ const finalRemainingKeys = Object.keys(fileData).filter((k) => k !== 'hooks');
573
+ try {
574
+ if (finalTotalGroups === 0 &&
575
+ finalRemainingKeys.length === 0 &&
576
+ fs.existsSync(hooksJsonPath)) {
577
+ fs.unlinkSync(hooksJsonPath);
578
+ results.push({
579
+ platform: 'codex',
580
+ filePath: hooksJsonPath,
581
+ status: 'deleted',
582
+ reason: 'no hooks remain',
583
+ });
584
+ }
585
+ else {
586
+ writeHooksJson(hooksJsonPath, fileData);
587
+ results.push({
588
+ platform: 'codex',
589
+ filePath: hooksJsonPath,
590
+ status: 'written',
591
+ reason: 'cleanup finalized',
592
+ });
593
+ }
594
+ }
595
+ catch (error) {
596
+ const msg = error instanceof Error ? error.message : String(error);
597
+ results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'error', error: msg });
598
+ }
599
+ };
345
600
  if (before === after) {
346
601
  results.push({
347
602
  platform: 'codex',
@@ -349,16 +604,25 @@ export function distributeCodexHooks(options) {
349
604
  status: 'skipped',
350
605
  reason: 'up-to-date',
351
606
  });
607
+ if (!appendCleanupResults())
608
+ finalizeCleanupMarker();
352
609
  }
353
610
  else if (dryRun) {
354
611
  const reason = filteredEntries.length === 0 ? 'hooks cleared' : `${filteredEntries.length} hook(s) merged`;
355
612
  results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'written', reason });
613
+ if (filteredEntries.length > 0)
614
+ addReviewResult(hooksJsonPath, results);
615
+ appendCleanupResults();
356
616
  }
357
617
  else {
358
618
  try {
359
619
  writeHooksJson(hooksJsonPath, fileData);
360
620
  const reason = filteredEntries.length === 0 ? 'hooks cleared' : `${filteredEntries.length} hook(s) merged`;
361
621
  results.push({ platform: 'codex', filePath: hooksJsonPath, status: 'written', reason });
622
+ if (filteredEntries.length > 0)
623
+ addReviewResult(hooksJsonPath, results);
624
+ if (!appendCleanupResults())
625
+ finalizeCleanupMarker();
362
626
  }
363
627
  catch (error) {
364
628
  const msg = error instanceof Error ? error.message : String(error);