@raidiant/notifai 2.0.1 → 3.0.1

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/src/commands.ts CHANGED
@@ -102,7 +102,6 @@ import {
102
102
  applyPlan,
103
103
  buildCursorHookConfig,
104
104
  buildHookConfig,
105
- collapseUnusedCodexRepresentation,
106
105
  codexLayerDir,
107
106
  codexLayerPaths,
108
107
  codexRepresentationProblems,
@@ -121,6 +120,7 @@ import {
121
120
  removeCursorHooks,
122
121
  removeHooks,
123
122
  settingsFile,
123
+ withCodexLayerTransaction,
124
124
  type Harness,
125
125
  type Installation,
126
126
  } from './install-hooks.js'
@@ -2382,12 +2382,17 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2382
2382
  return EXIT.failed
2383
2383
  }
2384
2384
  }
2385
- const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
2385
+ const codexPaths =
2386
+ harness === 'codex'
2387
+ ? codexLayerPaths(wantGlobal, deps.cwd, deps.env, hookPlatform)
2388
+ : null
2389
+ const settingsTarget =
2390
+ codexPaths?.configToml ?? settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
2386
2391
 
2387
2392
  // OpenCode's adapter is a generated plugin module rather than a handler
2388
2393
  // merged into a settings document, so it owns the whole file.
2389
2394
  if (harness === 'opencode') {
2390
- return installOpencodePlugin(deps, file, {
2395
+ return installOpencodePlugin(deps, settingsTarget, {
2391
2396
  adapterPath,
2392
2397
  timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
2393
2398
  platform: hookPlatform,
@@ -2397,8 +2402,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2397
2402
 
2398
2403
  if (harness === 'cursor') {
2399
2404
  try {
2400
- withTargetFileLock(file, () => {
2401
- const document = loadCursorSettings(file)
2405
+ withTargetFileLock(settingsTarget, () => {
2406
+ const document = loadCursorSettings(settingsTarget)
2402
2407
  const result = mergeCursorHooks(
2403
2408
  document,
2404
2409
  buildCursorHookConfig({
@@ -2409,43 +2414,40 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2409
2414
  }),
2410
2415
  scriptPath,
2411
2416
  )
2412
- applyPlan(file, result.document)
2417
+ applyPlan(settingsTarget, result.document)
2413
2418
  return result
2414
2419
  })
2415
2420
  } catch (err) {
2416
2421
  deps.io.err(String(err))
2417
2422
  return EXIT.failed
2418
2423
  }
2419
- printHooksInstallClose(deps, harness, file)
2424
+ printHooksInstallClose(deps, harness, settingsTarget)
2420
2425
  return EXIT.ok
2421
2426
  }
2422
2427
 
2423
- let foreignStopCount = 0
2424
- let collapsedFrom: string | null = null
2428
+ const installInto = (file: string): { file: string; foreignStopCount: number } => {
2429
+ const document = loadSettings(file)
2430
+ const foreignStopCount = foreignStopHandlers(document).length
2431
+ const result = mergeHooks(
2432
+ document,
2433
+ buildHookConfig({
2434
+ adapterPath,
2435
+ harness,
2436
+ platform: hookPlatform,
2437
+ nodePath,
2438
+ }),
2439
+ scriptPath,
2440
+ )
2441
+ applyPlan(file, result.document)
2442
+ return { file, foreignStopCount }
2443
+ }
2444
+
2445
+ let installed: { file: string; foreignStopCount: number }
2425
2446
  try {
2426
- withTargetFileLock(file, () => {
2427
- const document = loadSettings(file)
2428
- foreignStopCount = foreignStopHandlers(document).length
2429
- const result = mergeHooks(
2430
- document,
2431
- buildHookConfig({
2432
- adapterPath,
2433
- harness,
2434
- platform: hookPlatform,
2435
- nodePath,
2436
- }),
2437
- scriptPath,
2438
- )
2439
- applyPlan(file, result.document)
2440
- return result
2441
- })
2442
- if (harness === 'codex') {
2443
- collapsedFrom = collapseUnusedCodexRepresentation(
2444
- file,
2445
- scriptPath,
2446
- codexLayerPaths(flags.global ?? false, deps.cwd, deps.env),
2447
- )
2448
- }
2447
+ installed =
2448
+ codexPaths === null
2449
+ ? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
2450
+ : withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget))
2449
2451
  } catch (err) {
2450
2452
  deps.io.err(String(err))
2451
2453
  return EXIT.failed
@@ -2455,17 +2457,14 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2455
2457
  const layer = flags.global ? null : codexLayerDir(deps.cwd)
2456
2458
  if (layer !== null) mkdirSync(layer, { recursive: true })
2457
2459
  }
2458
- printHooksInstallClose(deps, harness, file)
2459
- if (collapsedFrom !== null) {
2460
- deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`)
2461
- }
2462
- if (foreignStopCount > 0) {
2460
+ printHooksInstallClose(deps, harness, installed.file)
2461
+ if (installed.foreignStopCount > 0) {
2463
2462
  deps.io.out(
2464
2463
  "This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
2465
2464
  )
2466
2465
  }
2467
2466
  if (harness === 'codex') {
2468
- for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
2467
+ for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
2469
2468
  deps.io.out(problem)
2470
2469
  }
2471
2470
  }
@@ -2522,13 +2521,13 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
2522
2521
  const harness = resolveHarness(deps, flags.harness)
2523
2522
  if (!harness) return EXIT.usage
2524
2523
  const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
2525
- const file = settingsFile(
2526
- harness,
2527
- flags.global ?? false,
2528
- deps.cwd,
2529
- deps.env,
2530
- deps.hookPlatform,
2531
- )
2524
+ const global = flags.global ?? false
2525
+ const codexPaths =
2526
+ harness === 'codex'
2527
+ ? codexLayerPaths(global, deps.cwd, deps.env, deps.hookPlatform)
2528
+ : null
2529
+ const file =
2530
+ codexPaths?.configToml ?? settingsFile(harness, global, deps.cwd, deps.env, deps.hookPlatform)
2532
2531
  if (harness === 'opencode') {
2533
2532
  try {
2534
2533
  return withTargetFileLock(file, () => {
@@ -2576,32 +2575,48 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
2576
2575
  )
2577
2576
  return EXIT.ok
2578
2577
  }
2579
- const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env)
2580
- const existing = files.filter((candidate) => existsSync(candidate))
2581
- if (existing.length === 0) {
2582
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
2583
- return EXIT.ok
2584
- }
2585
- let removedAny = false
2586
- try {
2578
+ const removeInstalledHooks = (): { existing: string[]; removedAny: boolean } => {
2579
+ const files =
2580
+ codexPaths === null
2581
+ ? hookDefinitionFiles(harness, global, deps.cwd, deps.env, deps.hookPlatform)
2582
+ : [codexPaths.hooksJson, codexPaths.configToml]
2583
+ const existing = files.filter((candidate) => existsSync(candidate))
2584
+ let removedAny = false
2587
2585
  for (const candidate of existing) {
2588
- const stripped = withTargetFileLock(candidate, () => {
2586
+ const removeFromCandidate = () => {
2589
2587
  const document = loadSettings(candidate)
2590
2588
  const result = removeHooks(document, scriptPath)
2591
- applyPlan(candidate, result.document)
2589
+ if (result.replaced.length > 0) applyPlan(candidate, result.document)
2592
2590
  return result
2593
- })
2591
+ }
2592
+ const stripped =
2593
+ codexPaths === null
2594
+ ? withTargetFileLock(candidate, removeFromCandidate)
2595
+ : removeFromCandidate()
2594
2596
  if (stripped.replaced.length > 0) {
2595
2597
  removedAny = true
2596
2598
  deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
2597
2599
  }
2598
2600
  }
2601
+ return { existing, removedAny }
2602
+ }
2603
+
2604
+ let result: { existing: string[]; removedAny: boolean }
2605
+ try {
2606
+ result =
2607
+ codexPaths === null
2608
+ ? removeInstalledHooks()
2609
+ : withCodexLayerTransaction(codexPaths, removeInstalledHooks)
2599
2610
  } catch (err) {
2600
2611
  deps.io.err(String(err))
2601
2612
  return EXIT.failed
2602
2613
  }
2603
- if (!removedAny) {
2604
- deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`)
2614
+ if (result.existing.length === 0) {
2615
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
2616
+ return EXIT.ok
2617
+ }
2618
+ if (!result.removedAny) {
2619
+ deps.io.out(`No Notifai hooks found in ${result.existing.join(', ')}`)
2605
2620
  }
2606
2621
  return EXIT.ok
2607
2622
  }
@@ -4896,23 +4911,16 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4896
4911
  })
4897
4912
  }
4898
4913
 
4899
- const representationProblems = codexRepresentationProblems(deps.cwd, deps.env)
4914
+ const representationProblems = codexRepresentationProblems(
4915
+ deps.cwd,
4916
+ deps.env,
4917
+ deps.hookPlatform,
4918
+ )
4900
4919
  if (representationProblems.length > 0) {
4901
- const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
4902
- ? 'notifai hooks install --harness codex --global'
4903
- : 'notifai hooks install --harness codex'
4904
4920
  checks.push({
4905
4921
  name: 'hooks (codex representation)',
4906
4922
  ok: false,
4907
4923
  detail: representationProblems.join('; '),
4908
- ...(representationProblems.some((problem) => problem.includes('to collapse onto'))
4909
- ? {
4910
- remedy: {
4911
- summary: 'collapse this Codex layer onto a single hook file',
4912
- command: collapseCommand,
4913
- },
4914
- }
4915
- : {}),
4916
4924
  })
4917
4925
  }
4918
4926
 
package/src/hooks.test.ts CHANGED
@@ -158,6 +158,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
158
158
  }
159
159
  },
160
160
  createMediaUpload: notUsed,
161
+ finalizeMediaUpload: notUsed,
161
162
  uploadMedia: notUsed,
162
163
  health: async () => true,
163
164
  submit: async (body) => {
@@ -253,15 +253,12 @@ describe('settings locations', () => {
253
253
  expect(settingsFile('claude-code', true, '/repo', {})).toBe(
254
254
  path.join(process.env['HOME']!, '.claude', 'settings.json'),
255
255
  )
256
- expect(settingsFile('codex', true, '/repo', {})).toBe(
257
- path.join(process.env['HOME']!, '.codex', 'hooks.json'),
258
- )
259
256
  })
260
257
 
261
- it('uses the dedicated Codex hooks file for a new layer', () => {
262
- expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
258
+ it('uses inline Codex hooks for a new layer', () => {
259
+ expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
263
260
  expect(settingsFile('codex', true, '/repo', {})).toBe(
264
- path.join(os.homedir(), '.codex', 'hooks.json'),
261
+ path.join(os.homedir(), '.codex', 'config.toml'),
265
262
  )
266
263
  })
267
264
 
@@ -273,67 +270,24 @@ describe('settings locations', () => {
273
270
  expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
274
271
  })
275
272
 
276
- it('keeps a Notifai-only inline representation so existing trust remains valid', () => {
277
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-ours-toml-'))
278
- mkdirSync(path.join(repo, '.codex'), { recursive: true })
279
- applyPlan(path.join(repo, '.codex', 'config.toml'), { hooks: ours() })
280
-
281
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
282
- })
283
-
284
- it('stays on hooks.json when that file already holds someone else\'s hooks', () => {
273
+ it('stays on hooks.json when it alone has hooks, even if config.toml already exists', () => {
285
274
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
286
- mkdirSync(path.join(repo, '.codex'), { recursive: true })
287
- applyPlan(path.join(repo, '.codex', 'hooks.json'), {
275
+ const layer = path.join(repo, '.codex')
276
+ mkdirSync(layer, { recursive: true })
277
+ writeFileSync(
278
+ path.join(layer, 'config.toml'),
279
+ `[hooks.state."${layer}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
280
+ )
281
+ applyPlan(path.join(layer, 'hooks.json'), {
288
282
  hooks: {
289
283
  Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
290
284
  },
291
285
  })
292
286
 
293
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
294
- })
295
-
296
- it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
297
- const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
298
- mkdirSync(path.join(repo, '.codex'), { recursive: true })
299
- writeFileSync(
300
- path.join(repo, '.codex', 'config.toml'),
301
- [
302
- 'model = "gpt-5.6"',
303
- '',
304
- '[[hooks.PostToolUse]]',
305
- 'matcher = "Bash"',
306
- '',
307
- '[[hooks.PostToolUse.hooks]]',
308
- 'type = "command"',
309
- 'command = "gdh-guard"',
310
- '',
311
- '[[hooks.Stop]]',
312
- '',
313
- '[[hooks.Stop.hooks]]',
314
- 'type = "command"',
315
- 'command = "gdh-stop"',
316
- '',
317
- ].join('\n'),
318
- )
319
-
320
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
321
- })
322
-
323
- it('does not treat the Codex trust store as an inline hook representation', () => {
324
- const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
325
- mkdirSync(path.join(home, '.codex'), { recursive: true })
326
- writeFileSync(
327
- path.join(home, '.codex', 'config.toml'),
328
- `[hooks.state."${home}/.codex/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
329
- )
330
-
331
- expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
332
- path.join(home, '.codex', 'hooks.json'),
333
- )
287
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
334
288
  })
335
289
 
336
- it('preserves foreign inline hooks when both representations exist', () => {
290
+ it('does not migrate our hooks when both representations already exist', () => {
337
291
  const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
338
292
  const layer = path.join(repo, '.codex')
339
293
  mkdirSync(layer, { recursive: true })
@@ -345,7 +299,7 @@ describe('settings locations', () => {
345
299
  )
346
300
  applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
347
301
 
348
- expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
302
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
349
303
  })
350
304
  })
351
305
 
@@ -371,7 +325,7 @@ describe('a Codex install run inside a git worktree', () => {
371
325
  it('targets the main repository, which is the file Codex actually reads', () => {
372
326
  const { main, worktree } = repoWithWorktree()
373
327
 
374
- expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'hooks.json'))
328
+ expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
375
329
  expect(codexProjectRoot(worktree)).toBe(main)
376
330
  })
377
331
 
@@ -438,7 +392,7 @@ describe('a Codex install run inside a git worktree', () => {
438
392
 
439
393
  it('follows the active harness home for global hook install', () => {
440
394
  expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
441
- '/managed/codex/hooks.json',
395
+ '/managed/codex/config.toml',
442
396
  )
443
397
  expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
444
398
  '/managed/claude/settings.json',
@@ -3,13 +3,13 @@ import {
3
3
  lstatSync,
4
4
  readFileSync,
5
5
  realpathSync,
6
- rmSync,
7
6
  statSync,
8
7
  } from 'node:fs'
9
8
  import { createHash } from 'node:crypto'
10
9
  import path from 'node:path'
11
10
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
12
11
  import { atomicWriteFileSync } from './atomic-file.js'
12
+ import { withTargetFileLock } from './file-lock.js'
13
13
  import {
14
14
  hookAdapterPath,
15
15
  hookHostPlatform,
@@ -41,8 +41,9 @@ const OPENCODE_EVENTS = [
41
41
  * with an optional timeout — so one generator serves both. Only the file
42
42
  * location and the event set differ. Codex supports either a dedicated
43
43
  * `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
44
- * We default to the dedicated file so installing hooks never rewrites Codex's
45
- * main configuration and trust store. One representation per layer, never both.
44
+ * We default a hook-empty layer to inline config and preserve whichever single
45
+ * representation a populated layer already uses. One representation per layer,
46
+ * never both.
46
47
  *
47
48
  * Cursor's native format is flat and lower-camel-cased, while OpenCode's
48
49
  * extension point is a JavaScript plugin module. Each therefore has a bounded
@@ -492,37 +493,32 @@ export interface CodexLayerInspection {
492
493
  writeTarget: string
493
494
  dual: boolean
494
495
  overlappingEvents: string[]
495
- canCollapse: boolean
496
496
  }
497
497
 
498
498
  /**
499
499
  * Which representation this Codex layer already uses.
500
500
  *
501
501
  * Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
502
- * and runs every matching handler. Prefer the dedicated file for a new layer;
502
+ * and runs every matching handler. Use inline config for a hook-empty layer and
503
503
  * preserve an existing single representation so installing Notifai never
504
- * invents a dual layer. `[hooks.state]` is the trust store, not a hook
505
- * definition, so it does not count as a representation.
504
+ * invents a dual layer. When the layer is already dual, keep our handlers in
505
+ * the representation that already contains them instead of silently migrating
506
+ * them. `[hooks.state]` is the trust store, not a hook definition, so it does
507
+ * not count as a representation.
506
508
  */
507
509
  export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
508
510
  const jsonDocument = tryLoadSettings(paths.hooksJson)
509
511
  const tomlDocument = tryLoadSettings(paths.configToml)
510
512
  const jsonEvents = hookEventNames(jsonDocument?.hooks)
511
513
  const tomlEvents = hookEventNames(tomlDocument?.hooks)
512
- const jsonForeign = documentHasForeignHandlers(jsonDocument)
513
- const tomlForeign = documentHasForeignHandlers(tomlDocument)
514
- const jsonOurs = documentHasOurHandlers(jsonDocument)
515
- const tomlOurs = documentHasOurHandlers(tomlDocument)
516
514
  const dual = jsonEvents.length > 0 && tomlEvents.length > 0
517
- let writeTarget = paths.hooksJson
518
- if (tomlEvents.length > 0 && jsonEvents.length === 0) {
519
- writeTarget = paths.configToml
515
+ let writeTarget = paths.configToml
516
+ if (jsonEvents.length > 0 && tomlEvents.length === 0) {
517
+ writeTarget = paths.hooksJson
520
518
  } else if (dual) {
521
- if (tomlForeign && !jsonForeign) writeTarget = paths.configToml
522
- else if (jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
523
- else if (!jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
524
- else if (tomlOurs && !jsonOurs) writeTarget = paths.configToml
525
- else writeTarget = paths.hooksJson
519
+ const jsonOurs = documentHasOurHandlers(jsonDocument)
520
+ const tomlOurs = documentHasOurHandlers(tomlDocument)
521
+ if (jsonOurs && !tomlOurs) writeTarget = paths.hooksJson
526
522
  }
527
523
  return {
528
524
  paths,
@@ -530,11 +526,23 @@ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection
530
526
  tomlEvents,
531
527
  writeTarget,
532
528
  dual,
533
- overlappingEvents: jsonEvents.filter((event) => tomlEvents.includes(event)),
534
- canCollapse: dual && !(jsonForeign && tomlForeign),
529
+ overlappingEvents: dual
530
+ ? jsonEvents.filter((event) => tomlEvents.includes(event))
531
+ : [],
535
532
  }
536
533
  }
537
534
 
535
+ /**
536
+ * Serialize one Notifai transaction across both representations in a Codex
537
+ * layer, then inspect that layer only after acquiring the shared anchor.
538
+ */
539
+ export function withCodexLayerTransaction<T>(
540
+ paths: CodexLayerPaths,
541
+ action: (inspection: CodexLayerInspection) => T,
542
+ ): T {
543
+ return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)))
544
+ }
545
+
538
546
  /** Every file in this layer Codex might already be reading hooks from. */
539
547
  export function hookDefinitionFiles(
540
548
  harness: Harness,
@@ -548,47 +556,20 @@ export function hookDefinitionFiles(
548
556
  return [paths.hooksJson, paths.configToml]
549
557
  }
550
558
 
551
- /**
552
- * Drop the unused representation after writing the one this layer already had.
553
- *
554
- * Only safe when the discarded file has no one else's handlers. `hooks.json`
555
- * is deleted once it has no events left so Codex stops warning; `config.toml`
556
- * is never deleted.
557
- */
558
- export function collapseUnusedCodexRepresentation(
559
- keepFile: string,
560
- scriptPath: string,
561
- paths: CodexLayerPaths,
562
- ): string | null {
563
- const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson
564
- if (!existsSync(other)) return null
565
- const document = tryLoadSettings(other)
566
- if (document === null || documentHasForeignHandlers(document)) return null
567
- const stripped = removeHooks(document, scriptPath)
568
- if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
569
- rmSync(other)
570
- return other
571
- }
572
- applyPlan(other, stripped.document)
573
- return other
574
- }
575
-
576
559
  export function codexRepresentationProblems(
577
560
  cwd: string,
578
561
  env: NodeJS.ProcessEnv = process.env,
562
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
579
563
  ): string[] {
580
564
  return [false, true].flatMap((global) => {
581
- const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env))
565
+ const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
582
566
  if (!layer.dual) return []
583
567
  const overlap =
584
568
  layer.overlappingEvents.length > 0
585
569
  ? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
586
570
  : ' Matching handlers from both files all run.'
587
- const collapse = layer.canCollapse
588
- ? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
589
- : ' Both files have other hooks, so Notifai cannot collapse them.'
590
571
  return [
591
- `loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}${collapse}`,
572
+ `loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap} Notifai preserves existing representations instead of migrating them automatically.`,
592
573
  ]
593
574
  })
594
575
  }
@@ -608,11 +589,6 @@ function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
608
589
  .map(([event]) => event)
609
590
  }
610
591
 
611
- function documentHasForeignHandlers(document: SettingsDocument | null): boolean {
612
- if (document === null) return false
613
- return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command))
614
- }
615
-
616
592
  function documentHasOurHandlers(document: SettingsDocument | null): boolean {
617
593
  if (document === null) return false
618
594
  return locateHandlers(document).length > 0