@raidiant/notifai 0.5.1 → 1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +385 -158
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.d.ts +1 -1
  7. package/dist/config-schema.js +2 -14
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/config.d.ts +19 -8
  10. package/dist/config.js +50 -7
  11. package/dist/config.js.map +1 -1
  12. package/dist/harnesses.d.ts +1 -0
  13. package/dist/harnesses.js +6 -0
  14. package/dist/harnesses.js.map +1 -1
  15. package/dist/hook-adapter.d.ts +10 -1
  16. package/dist/hook-adapter.js +79 -10
  17. package/dist/hook-adapter.js.map +1 -1
  18. package/dist/install-hooks.d.ts +60 -20
  19. package/dist/install-hooks.js +246 -100
  20. package/dist/install-hooks.js.map +1 -1
  21. package/dist/interactive.d.ts +32 -1
  22. package/dist/interactive.js +163 -47
  23. package/dist/interactive.js.map +1 -1
  24. package/dist/main.js +8 -8
  25. package/dist/main.js.map +1 -1
  26. package/dist/native-skills.d.ts +1 -1
  27. package/dist/native-skills.js +23 -58
  28. package/dist/native-skills.js.map +1 -1
  29. package/dist/pack-feedback-slice.d.ts +35 -0
  30. package/dist/pack-feedback-slice.js +126 -0
  31. package/dist/pack-feedback-slice.js.map +1 -0
  32. package/dist/readiness.d.ts +20 -0
  33. package/dist/readiness.js +24 -0
  34. package/dist/readiness.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/commands.test.ts +411 -42
  37. package/src/commands.ts +468 -178
  38. package/src/config-schema.ts +2 -17
  39. package/src/config.test.ts +29 -4
  40. package/src/config.ts +67 -11
  41. package/src/harnesses.ts +7 -0
  42. package/src/hook-adapter.test.ts +43 -1
  43. package/src/hook-adapter.ts +83 -11
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +180 -8
  46. package/src/install-hooks.ts +227 -47
  47. package/src/interactive.test.ts +338 -0
  48. package/src/interactive.ts +221 -49
  49. package/src/main.ts +8 -8
  50. package/src/native-skills.test.ts +75 -0
  51. package/src/native-skills.ts +31 -75
  52. package/src/pack-feedback-slice.test.ts +212 -0
  53. package/src/pack-feedback-slice.ts +187 -0
  54. package/src/readiness.test.ts +25 -0
  55. package/src/readiness.ts +38 -0
  56. package/src/skill.test.ts +43 -0
  57. package/src/ui/config-view.test.ts +5 -4
package/src/commands.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  configBounds,
37
37
  configDefaultValue,
38
38
  findProjectConfigPath,
39
- findProjectLocalConfigPath,
39
+ personalProjectConfigPath,
40
40
  globalConfigPath,
41
41
  loadConfig,
42
42
  sessionConfigPath,
@@ -46,7 +46,7 @@ import {
46
46
  type LogLevel,
47
47
  } from './config.js'
48
48
  import { acceptedValues, configInfo } from './config-schema.js'
49
- import { skillsSource } from './release.js'
49
+ import { packageVersion, skillsSource } from './release.js'
50
50
  import { atomicWriteFileSync } from './atomic-file.js'
51
51
  import { withTargetFileLock } from './file-lock.js'
52
52
  import {
@@ -57,8 +57,8 @@ import {
57
57
  import type { CredentialStore, MachineCredential } from './credentials.js'
58
58
  import {
59
59
  firstBlocker,
60
- openItems,
61
60
  type Readiness,
61
+ type ReadinessRefresh,
62
62
  type ReadinessState,
63
63
  type StateStatus,
64
64
  } from './readiness.js'
@@ -100,12 +100,18 @@ import {
100
100
  applyPlan,
101
101
  buildCursorHookConfig,
102
102
  buildHookConfig,
103
+ collapseUnusedCodexRepresentation,
103
104
  codexLayerDir,
105
+ codexLayerPaths,
106
+ codexRepresentationProblems,
104
107
  codexTrustProblems,
105
108
  codexProjectRoot,
106
109
  detectHarness,
110
+ detectedHarnesses,
107
111
  findInstallations,
108
112
  handlerEvent,
113
+ hookDefinitionFiles,
114
+ inspectCodexLayer,
109
115
  loadCursorSettings,
110
116
  loadSettings,
111
117
  mergeCursorHooks,
@@ -116,7 +122,7 @@ import {
116
122
  type Harness,
117
123
  type Installation,
118
124
  } from './install-hooks.js'
119
- import { HARNESS_CAPABILITIES } from './harnesses.js'
125
+ import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
120
126
  import {
121
127
  claudeWakeRoute,
122
128
  inspectClaudeInbox,
@@ -131,6 +137,8 @@ import {
131
137
  import {
132
138
  inspectHookAdapter,
133
139
  installHookAdapter,
140
+ isNpxAdapterTarget,
141
+ type HookAdapterTarget,
134
142
  } from './hook-adapter.js'
135
143
  import {
136
144
  isOurOpencodePlugin,
@@ -162,6 +170,12 @@ export interface CommandIo {
162
170
  */
163
171
  interactive?: boolean
164
172
  select?(message: string, options: { value: string; label: string; hint?: string }[]): Promise<string | null>
173
+ /** Interactive multi-select. `initial` are pre-selected values. */
174
+ multiselect?(
175
+ message: string,
176
+ options: { value: string; label: string; hint?: string }[],
177
+ initial?: string[],
178
+ ): Promise<string[] | null>
165
179
  intro?(title: string): Promise<void>
166
180
  outro?(message: string): Promise<void>
167
181
  note?(message: string, title?: string): Promise<void>
@@ -188,6 +202,8 @@ export interface CommandDeps {
188
202
  cwd: string
189
203
  /** Test seam; production fixes the hook adapter under os.homedir(). */
190
204
  hookAdapterHome?: string
205
+ /** Test seam; production uses this process's Node and CLI paths. */
206
+ hookInstallTarget?: HookAdapterTarget
191
207
  /** Test seam; production uses fetch against base_url. */
192
208
  clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
193
209
  /** Test seam for bounded polling without wall-clock sleeps. */
@@ -2125,20 +2141,97 @@ export interface HooksInstallFlags {
2125
2141
  scriptPath?: string
2126
2142
  }
2127
2143
 
2144
+ /** True when this process is `npx` / `npm exec`, not a global or linked install. */
2145
+ export function runningViaNpx(env: NodeJS.ProcessEnv, scriptPath: string): boolean {
2146
+ if (env['npm_command'] === 'exec') return true
2147
+ return scriptPath.includes(`${path.sep}_npx${path.sep}`)
2148
+ }
2149
+
2150
+ function fileHookInstallTarget(
2151
+ target: HookAdapterTarget | undefined,
2152
+ ): { execPath: string; scriptPath: string } | undefined {
2153
+ if (target === undefined || isNpxAdapterTarget(target)) return undefined
2154
+ return target
2155
+ }
2156
+
2157
+ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags): HookAdapterTarget {
2158
+ if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
2159
+ return deps.hookInstallTarget
2160
+ }
2161
+ const fileTarget = fileHookInstallTarget(deps.hookInstallTarget)
2162
+ const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath
2163
+ const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai'
2164
+ if (runningViaNpx(deps.env, scriptPath)) {
2165
+ const version = packageVersion()
2166
+ const npmCli = deps.env['npm_execpath']
2167
+ if (version === null) {
2168
+ throw new Error(
2169
+ 'Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
2170
+ )
2171
+ }
2172
+ if (typeof npmCli !== 'string' || npmCli === '') {
2173
+ throw new Error(
2174
+ 'This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
2175
+ )
2176
+ }
2177
+ return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` }
2178
+ }
2179
+ return { execPath, scriptPath }
2180
+ }
2181
+
2182
+ function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
2183
+ const label = HARNESS_LABELS[harness]
2184
+ if (deps.io.interactive === true && deps.io.note) {
2185
+ void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`)
2186
+ return
2187
+ }
2188
+ deps.io.out(`Installed ${harness} hooks in ${file}`)
2189
+ deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`)
2190
+ }
2191
+
2128
2192
  export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
2193
+ if (flags.harness === undefined) {
2194
+ const detected = detectedHarnesses(deps.cwd, deps.env)
2195
+ if (detected.length === 0) {
2196
+ deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
2197
+ return EXIT.usage
2198
+ }
2199
+ let ok = true
2200
+ for (const harness of detected) {
2201
+ if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok) ok = false
2202
+ }
2203
+ return ok ? EXIT.ok : EXIT.failed
2204
+ }
2129
2205
  const harness = resolveHarness(deps, flags.harness)
2130
2206
  if (!harness) return EXIT.usage
2131
- const execPath = flags.execPath ?? process.execPath
2132
- const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
2207
+ const adapterTarget = resolveHookAdapterTarget(deps, flags)
2208
+ const scriptPath =
2209
+ flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
2133
2210
  let adapterPath: string
2134
2211
  try {
2135
- adapterPath = installHookAdapter({ execPath, scriptPath }, deps.hookAdapterHome).path
2212
+ adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path
2136
2213
  } catch (err) {
2137
2214
  deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
2138
2215
  return EXIT.failed
2139
2216
  }
2140
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2141
- const file = settingsFile(harness, flags.global ?? false, deps.cwd, deps.env)
2217
+ const wantGlobal = flags.global === true
2218
+ const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome).filter(
2219
+ (installation) => installation.harness === harness,
2220
+ )
2221
+ const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
2222
+ if (!wantGlobal && otherScope.some((installation) => installation.global)) {
2223
+ const globalFile = otherScope.find((installation) => installation.global)?.file
2224
+ deps.io.out(
2225
+ `${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
2226
+ )
2227
+ return EXIT.ok
2228
+ }
2229
+ if (wantGlobal && otherScope.some((installation) => !installation.global)) {
2230
+ if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
2231
+ return EXIT.failed
2232
+ }
2233
+ }
2234
+ const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env)
2142
2235
 
2143
2236
  // OpenCode's adapter is a generated plugin module rather than a handler
2144
2237
  // merged into a settings document, so it owns the whole file.
@@ -2150,9 +2243,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2150
2243
  }
2151
2244
 
2152
2245
  if (harness === 'cursor') {
2153
- let merged: ReturnType<typeof mergeCursorHooks>
2154
2246
  try {
2155
- merged = withTargetFileLock(file, () => {
2247
+ withTargetFileLock(file, () => {
2156
2248
  const document = loadCursorSettings(file)
2157
2249
  const result = mergeCursorHooks(
2158
2250
  document,
@@ -2169,26 +2261,16 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2169
2261
  deps.io.err(String(err))
2170
2262
  return EXIT.failed
2171
2263
  }
2172
- deps.io.out(`Installed ${harness} hooks in ${file}`)
2173
- if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
2174
- if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
2175
- if (flags.global) {
2176
- deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
2177
- deps.io.out('start a new Cursor session and try one prompt again.')
2178
- } else {
2179
- deps.io.out('Send one Cursor prompt, then check `notifai doctor`. If the hook has not fired,')
2180
- deps.io.out('start a new Cursor session and try one prompt again.')
2181
- }
2182
- deps.io.out('Cursor hooks can emit a native follow-up, but its agent shell does not expose')
2183
- deps.io.out('the exact conversation id needed to register safely. `notifai ask` therefore')
2184
- deps.io.out('fails closed; use blocking `notifai send --reply` for questions.')
2264
+ printHooksInstallClose(deps, harness, file)
2185
2265
  return EXIT.ok
2186
2266
  }
2187
2267
 
2188
- let merged: ReturnType<typeof mergeHooks>
2268
+ let foreignStopCount = 0
2269
+ let collapsedFrom: string | null = null
2189
2270
  try {
2190
- merged = withTargetFileLock(file, () => {
2271
+ withTargetFileLock(file, () => {
2191
2272
  const document = loadSettings(file)
2273
+ foreignStopCount = foreignStopHandlers(document).length
2192
2274
  const result = mergeHooks(
2193
2275
  document,
2194
2276
  buildHookConfig({
@@ -2200,62 +2282,47 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
2200
2282
  applyPlan(file, result.document)
2201
2283
  return result
2202
2284
  })
2285
+ if (harness === 'codex') {
2286
+ collapsedFrom = collapseUnusedCodexRepresentation(
2287
+ file,
2288
+ scriptPath,
2289
+ codexLayerPaths(flags.global ?? false, deps.cwd, deps.env),
2290
+ )
2291
+ }
2203
2292
  } catch (err) {
2204
2293
  deps.io.err(String(err))
2205
2294
  return EXIT.failed
2206
2295
  }
2207
2296
 
2208
- deps.io.out(`Installed ${harness} hooks in ${file}`)
2209
- if (merged.replaced.length > 0) deps.io.out(` replaced: ${merged.replaced.join(', ')}`)
2210
- if (merged.added.length > 0) deps.io.out(` added: ${merged.added.join(', ')}`)
2211
- if (merged.removed.length > 0) {
2212
- deps.io.out(` removed: ${merged.removed.join(', ')} (this build no longer serves them)`)
2213
- }
2214
- deps.io.out('')
2215
- deps.io.out(
2216
- config.ask_grace_seconds.value === 0
2217
- ? 'A question registered with `notifai ask` goes to your devices immediately when the agent turn ends, whether or not you are at this machine.'
2218
- : `A question registered with \`notifai ask\` stays in the terminal for ${config.ask_grace_seconds.value}s from registration and then goes to your devices whether or not you are at this machine.`,
2219
- )
2220
2297
  if (harness === 'codex') {
2221
2298
  const layer = flags.global ? null : codexLayerDir(deps.cwd)
2222
- if (layer !== null) {
2223
- // Codex reads project hooks from the main repository but only looks when
2224
- // a `.codex` directory sits at or above cwd, so a worktree install has to
2225
- // write one file and create one directory in two different places. Doing
2226
- // it silently would leave the next person deriving this the hard way
2227
- //.
2228
- mkdirSync(layer, { recursive: true })
2229
- deps.io.out('')
2230
- deps.io.out('You are in a worktree. Codex reads project hooks from the main repository,')
2231
- deps.io.out(`so they were written to ${file}. ${layer} was created so this`)
2232
- deps.io.out('worktree discovers that file. Each other worktree needs its own `.codex`')
2233
- deps.io.out('directory; rerun this installer from that worktree to create it.')
2234
- }
2299
+ if (layer !== null) mkdirSync(layer, { recursive: true })
2235
2300
  }
2236
- deps.io.out('')
2237
- if (harness === 'claude-code') {
2238
- // Codex deliberately gets no counterpart line: its Stop definition is
2239
- // byte-identical to the one earlier builds wrote, so nothing about its
2240
- // trust state changed and saying so would invent a gate that is not there.
2301
+ printHooksInstallClose(deps, harness, file)
2302
+ if (collapsedFrom !== null) {
2303
+ deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`)
2304
+ }
2305
+ if (foreignStopCount > 0) {
2241
2306
  deps.io.out(
2242
- `The Stop handler is asynchronous: it returns at once, so your turn ends normally, and it waits for your answer out of band for up to ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s before delivering it back into this session. That timeout is written explicitly because Claude Code kills a background hook at its own 600s default and reports nothing when it does.`,
2307
+ "This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
2243
2308
  )
2244
- deps.io.out('')
2245
- }
2246
- if (harness === 'claude-code' && flags.global !== true) {
2247
- deps.io.out('Claude Code reloads project hook files without a restart. Send one new prompt,')
2248
- deps.io.out('then check `notifai doctor` to confirm that the hook fired.')
2249
- } else if (harness === 'claude-code') {
2250
- deps.io.out('Send one new Claude Code prompt, then check `notifai doctor`. If the hook has')
2251
- deps.io.out('not fired, start a new Claude Code session and try one prompt again.')
2252
- } else {
2253
- deps.io.out('Send one Codex prompt, then check `notifai doctor`. If the hook has not fired,')
2254
- deps.io.out('start a new Codex session and try one prompt again.')
2309
+ }
2310
+ if (harness === 'codex') {
2311
+ for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
2312
+ deps.io.out(problem)
2313
+ }
2255
2314
  }
2256
2315
  return EXIT.ok
2257
2316
  }
2258
2317
 
2318
+ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { command: string }[] }[]> }): { command: string }[] {
2319
+ const groups = document.hooks?.['Stop']
2320
+ if (!Array.isArray(groups)) return []
2321
+ return groups
2322
+ .flatMap((group) => group.hooks ?? [])
2323
+ .filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command))
2324
+ }
2325
+
2259
2326
  /**
2260
2327
  * Writes the OpenCode plugin, replacing any Notifai plugin already there —
2261
2328
  * including one a different checkout wrote, matched on the managed marker for
@@ -2285,16 +2352,7 @@ function installOpencodePlugin(
2285
2352
  deps.io.err(String(err))
2286
2353
  return EXIT.failed
2287
2354
  }
2288
- deps.io.out(`Installed the OpenCode plugin at ${file}`)
2289
- deps.io.out('')
2290
- deps.io.out('It maps chat.message to presence, session.idle to question escalation, and')
2291
- deps.io.out('session.deleted to local cleanup through the same `notifai hook` commands')
2292
- deps.io.out('the other harnesses run. Permission prompts stay in OpenCode.')
2293
- deps.io.out('OpenCode does not expose a proven exactly-once continuation after session.idle,')
2294
- deps.io.out('so `notifai ask` fails closed there. Use blocking `notifai send --reply` when')
2295
- deps.io.out('the answer must return to the agent without another human prompt.')
2296
- deps.io.out('')
2297
- deps.io.out('Restart OpenCode: it loads plugins once at start.')
2355
+ printHooksInstallClose(deps, 'opencode', file)
2298
2356
  return EXIT.ok
2299
2357
  }
2300
2358
 
@@ -2350,28 +2408,33 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
2350
2408
  )
2351
2409
  return EXIT.ok
2352
2410
  }
2353
- let stripped: ReturnType<typeof removeHooks> | null
2411
+ const files = hookDefinitionFiles(harness, flags.global ?? false, deps.cwd, deps.env)
2412
+ const existing = files.filter((candidate) => existsSync(candidate))
2413
+ if (existing.length === 0) {
2414
+ deps.io.out(`Nothing to remove: ${file} does not exist.`)
2415
+ return EXIT.ok
2416
+ }
2417
+ let removedAny = false
2354
2418
  try {
2355
- stripped = withTargetFileLock(file, () => {
2356
- if (!existsSync(file)) return null
2357
- const document = loadSettings(file)
2358
- const result = removeHooks(document, scriptPath)
2359
- applyPlan(file, result.document)
2360
- return result
2361
- })
2419
+ for (const candidate of existing) {
2420
+ const stripped = withTargetFileLock(candidate, () => {
2421
+ const document = loadSettings(candidate)
2422
+ const result = removeHooks(document, scriptPath)
2423
+ applyPlan(candidate, result.document)
2424
+ return result
2425
+ })
2426
+ if (stripped.replaced.length > 0) {
2427
+ removedAny = true
2428
+ deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
2429
+ }
2430
+ }
2362
2431
  } catch (err) {
2363
2432
  deps.io.err(String(err))
2364
2433
  return EXIT.failed
2365
2434
  }
2366
- if (stripped === null) {
2367
- deps.io.out(`Nothing to remove: ${file} does not exist.`)
2368
- return EXIT.ok
2435
+ if (!removedAny) {
2436
+ deps.io.out(`No Notifai hooks found in ${existing.join(', ')}`)
2369
2437
  }
2370
- deps.io.out(
2371
- stripped.replaced.length > 0
2372
- ? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
2373
- : `No Notifai hooks found in ${file}`,
2374
- )
2375
2438
  return EXIT.ok
2376
2439
  }
2377
2440
 
@@ -2386,6 +2449,13 @@ function assertOwnedRegularFile(file: string): void {
2386
2449
  }
2387
2450
  }
2388
2451
 
2452
+ const HARNESS_LABEL: Record<Harness, string> = {
2453
+ 'claude-code': 'Claude Code',
2454
+ codex: 'Codex',
2455
+ cursor: 'Cursor',
2456
+ opencode: 'OpenCode',
2457
+ }
2458
+
2389
2459
  function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
2390
2460
  if (requested !== undefined) {
2391
2461
  if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
@@ -2394,7 +2464,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
2394
2464
  )
2395
2465
  return null
2396
2466
  }
2397
- const detected = detectHarness(deps.cwd)
2467
+ const detected = detectHarness(deps.cwd, deps.env)
2398
2468
  if (!detected) {
2399
2469
  deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
2400
2470
  return null
@@ -2402,6 +2472,48 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
2402
2472
  return detected
2403
2473
  }
2404
2474
 
2475
+ /**
2476
+ * Which harnesses to wire. An explicit `--harness` still wins as a singleton.
2477
+ * Otherwise: every detected harness, or a human picker when detection is empty
2478
+ * or names more than one.
2479
+ */
2480
+ async function pickHarnessesToInstall(
2481
+ deps: CommandDeps,
2482
+ requested?: string,
2483
+ ): Promise<Harness[] | null> {
2484
+ if (requested !== undefined) {
2485
+ const harness = resolveHarness(deps, requested)
2486
+ return harness === null ? null : [harness]
2487
+ }
2488
+ const detected = detectedHarnesses(deps.cwd, deps.env)
2489
+ if (detected.length === 1) return detected
2490
+ if (deps.io.interactive === true && deps.io.multiselect) {
2491
+ const picked = await deps.io.multiselect(
2492
+ 'Which agent harnesses should Notifai wire here?',
2493
+ HARNESSES.map((name) => ({
2494
+ value: name,
2495
+ label: HARNESS_LABEL[name],
2496
+ ...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
2497
+ })),
2498
+ detected,
2499
+ )
2500
+ if (picked === null) return null
2501
+ const unknown = picked.filter((name) => !(HARNESSES as readonly string[]).includes(name))
2502
+ if (unknown.length > 0) {
2503
+ deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`)
2504
+ return null
2505
+ }
2506
+ return picked as Harness[]
2507
+ }
2508
+ if (detected.length === 0) {
2509
+ deps.io.err(
2510
+ `Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
2511
+ )
2512
+ return null
2513
+ }
2514
+ return detected
2515
+ }
2516
+
2405
2517
  // ---------------------------------------------------------------------------
2406
2518
  // config show / set / unset
2407
2519
  // ---------------------------------------------------------------------------
@@ -2413,7 +2525,14 @@ export function configShowCommand(
2413
2525
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
2414
2526
  if (flags.json) {
2415
2527
  const output = Object.fromEntries(
2416
- CONFIG_KEYS.map((key) => [key, { value: config[key].value, source: config[key].source }]),
2528
+ CONFIG_KEYS.map((key) => [
2529
+ key,
2530
+ {
2531
+ value: config[key].value,
2532
+ source: config[key].source,
2533
+ summary: configInfo(key).summary,
2534
+ },
2535
+ ]),
2417
2536
  )
2418
2537
  deps.io.out(JSON.stringify(output, null, 2))
2419
2538
  return EXIT.ok
@@ -2622,7 +2741,7 @@ async function configMutationTarget(
2622
2741
  const selected = await deps.io.select('Where should this setting live?', [
2623
2742
  { value: 'global', label: 'This machine', hint: 'applies across projects' },
2624
2743
  { value: 'project', label: 'This project (shared)', hint: '.notifai/config.toml' },
2625
- { value: 'local', label: 'This project (personal)', hint: 'keep config.local.toml gitignored' },
2744
+ { value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
2626
2745
  ])
2627
2746
  if (selected === null) {
2628
2747
  deps.io.err('No configuration layer selected.')
@@ -2634,7 +2753,7 @@ async function configMutationTarget(
2634
2753
  const targetPath = flags.session
2635
2754
  ? sessionConfigPath(flags.session, deps.env)
2636
2755
  : layer === 'local'
2637
- ? (findProjectLocalConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.local.toml'))
2756
+ ? personalProjectConfigPath(deps.cwd, deps.env)
2638
2757
  : layer === 'project'
2639
2758
  ? (findProjectConfigPath(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'config.toml'))
2640
2759
  : globalConfigPath(deps.env)
@@ -3127,8 +3246,8 @@ function observedCompanionReceipt(
3127
3246
  * gap, so re-running is how you check the setup as much as how you create it.
3128
3247
  * With a human at a terminal it walks them through the missing pieces; run by
3129
3248
  * an agent it never prompts — each optional step is answered by a flag, and
3130
- * whatever only the user can do (signing in, pairing a companion device) is printed as
3131
- * the exact command to hand back to them.
3249
+ * whatever only the user can do (signing in, pairing a companion device) is
3250
+ * printed as the next human action. An agent runs every CLI command itself.
3132
3251
  */
3133
3252
  /**
3134
3253
  * Close a gap the CLI is allowed to close on its own, without asking.
@@ -3159,21 +3278,13 @@ async function closeGap(
3159
3278
  }
3160
3279
 
3161
3280
  if (state.id === 'hooks') {
3162
- let harness = detectHarness(deps.cwd)
3163
- if (harness === null && deps.io.interactive === true && deps.io.select) {
3164
- const picked = await deps.io.select(
3165
- 'Which agent harness do you use here?',
3166
- HARNESSES.map((name) => ({ value: name, label: name })),
3167
- )
3168
- if (picked !== null) harness = picked as Harness
3169
- }
3170
- if (harness === null) {
3171
- deps.io.err(
3172
- `Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
3173
- )
3174
- return 'failed'
3281
+ const harnesses = await pickHarnessesToInstall(deps)
3282
+ if (harnesses === null || harnesses.length === 0) return 'failed'
3283
+ let ok = true
3284
+ for (const harness of harnesses) {
3285
+ if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
3175
3286
  }
3176
- return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed'
3287
+ return ok ? 'closed' : 'failed'
3177
3288
  }
3178
3289
 
3179
3290
  if (state.id === 'skill') {
@@ -3517,6 +3628,13 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
3517
3628
  return 'pending'
3518
3629
  }
3519
3630
 
3631
+ /** Closing a local gap cannot have changed the service, the keychain, or devices. */
3632
+ function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
3633
+ return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
3634
+ ? ['local']
3635
+ : undefined
3636
+ }
3637
+
3520
3638
  /** Whether an optional gap should be closed, given flags and who is watching. */
3521
3639
  function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlags): Promise<boolean> {
3522
3640
  // Naming the project is init's whole reason to touch the filesystem, costs
@@ -3576,12 +3694,13 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
3576
3694
  }
3577
3695
  await deps.io.intro?.('Notifai setup')
3578
3696
 
3579
- const reassess = () =>
3580
- assessReadiness(
3581
- deps,
3582
- flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope },
3583
- )
3584
- let readiness = await reassess()
3697
+ const skillOpts = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
3698
+ let readiness = await assessReadiness(deps, skillOpts)
3699
+ const reassess = (refresh?: readonly ReadinessRefresh[]) =>
3700
+ assessReadiness(deps, {
3701
+ ...skillOpts,
3702
+ ...(refresh === undefined ? {} : { previous: readiness, refresh }),
3703
+ })
3585
3704
  let failed = false
3586
3705
  const attempted = new Set<string>()
3587
3706
 
@@ -3612,7 +3731,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
3612
3731
  const result = await closeGap(deps, state, flags)
3613
3732
  if (result === 'failed') failed = true
3614
3733
  if (result === 'failed' && state.status === 'optional-gap') {
3615
- readiness = await reassess()
3734
+ readiness = await reassess(refreshAfterClose(state.id))
3616
3735
  advanced = true
3617
3736
  break
3618
3737
  }
@@ -3620,7 +3739,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
3620
3739
  stop = true
3621
3740
  break
3622
3741
  }
3623
- readiness = await reassess()
3742
+ readiness = await reassess(refreshAfterClose(state.id))
3624
3743
  advanced = true
3625
3744
  break
3626
3745
  }
@@ -3633,7 +3752,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
3633
3752
  stop = true
3634
3753
  break
3635
3754
  }
3636
- readiness = await reassess()
3755
+ readiness = await reassess(refreshAfterClose(state.id))
3637
3756
  advanced = true
3638
3757
  break
3639
3758
  }
@@ -3682,37 +3801,81 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
3682
3801
  if (stop || !advanced) break
3683
3802
  }
3684
3803
 
3685
- readiness = await reassess()
3686
- for (const state of readiness.states.filter((s) => s.status === 'ready')) {
3687
- deps.io.out(`${state.title}: ${state.detail}`)
3804
+ await printInitClose(deps, readiness, flags)
3805
+ const blocker = firstBlocker(readiness)
3806
+ if (blocker === null) return failed ? EXIT.failed : EXIT.ok
3807
+ return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
3808
+ }
3809
+
3810
+ function isHookSubstate(id: string): boolean {
3811
+ return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
3812
+ }
3813
+
3814
+ function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
3815
+ return readiness.states.filter((state) => {
3816
+ if (state.status !== 'optional-gap') return false
3817
+ if (isHookSubstate(state.id)) return false
3818
+ if (state.id === 'skill' && flags.skills === false) return false
3819
+ return true
3820
+ })
3821
+ }
3822
+
3823
+ function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
3824
+ for (const state of leftovers) {
3825
+ deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
3688
3826
  }
3827
+ }
3689
3828
 
3829
+ function questionsWillRoute(readiness: Readiness): boolean {
3830
+ const hooks = readiness.states.find((state) => state.id === 'hooks')
3831
+ const settings = readiness.states.find((state) => state.id === 'question-routing-settings')
3832
+ return hooks?.status === 'ready' && settings?.status !== 'gap'
3833
+ }
3834
+
3835
+ async function printInitClose(
3836
+ deps: CommandDeps,
3837
+ readiness: Readiness,
3838
+ flags: InitFlags,
3839
+ ): Promise<void> {
3690
3840
  const blocker = firstBlocker(readiness)
3841
+ const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
3842
+ const questions = questionsWillRoute(readiness)
3843
+ const leftovers = leftoverOptionals(readiness, flags)
3844
+
3845
+ if (deps.io.interactive === true) {
3846
+ if (blocker === null) {
3847
+ const lines = [
3848
+ canSend ? 'You can send notifications.' : 'You are signed in.',
3849
+ questions
3850
+ ? 'Questions will reach your devices.'
3851
+ : 'Questions stay in the terminal until hooks are installed.',
3852
+ ]
3853
+ await deps.io.note?.(lines.join('\n'), 'Ready')
3854
+ printOptionalLeftovers(deps, leftovers)
3855
+ deps.io.out('All set. Agents in this project can notify you and ask questions.')
3856
+ await deps.io.outro?.('All set ✨')
3857
+ return
3858
+ }
3859
+ await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next')
3860
+ deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
3861
+ deps.io.out(` ${remedyLine(blocker)}`)
3862
+ if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
3863
+ deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
3864
+ }
3865
+ await deps.io.outro?.('One step remains (above)')
3866
+ return
3867
+ }
3868
+
3691
3869
  if (blocker === null) {
3692
- const skipped = openItems(readiness).filter(
3693
- (state) => !(state.id === 'skill' && flags.skills === false),
3694
- )
3695
- for (const state of skipped) deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
3870
+ printOptionalLeftovers(deps, leftovers)
3696
3871
  deps.io.out('All set. Agents in this project can notify you and ask questions.')
3697
- await deps.io.outro?.('All set ✨')
3698
- return failed ? EXIT.failed : EXIT.ok
3872
+ return
3699
3873
  }
3700
-
3701
- // Exactly one. Everything else waits until this is done, because the next
3702
- // gap is frequently a consequence of this one and naming it now would send
3703
- // the reader off to fix something that is not actually wrong.
3704
- deps.io.out('')
3705
3874
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
3706
3875
  deps.io.out(` ${remedyLine(blocker)}`)
3707
- // Both "do it here" and "do it elsewhere" leave setup mid-journey; re-run is
3708
- // how the rest is recovered either way.
3709
3876
  if (blocker.remedy?.by === 'user-elsewhere' || blocker.remedy?.by === 'user-here') {
3710
3877
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
3711
3878
  }
3712
- await deps.io.outro?.('One step remains (above)')
3713
- // An agent must be able to branch on setup being blocked without parsing
3714
- // prose. A present human may deliberately leave and resume later.
3715
- return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
3716
3879
  }
3717
3880
 
3718
3881
  // ---------------------------------------------------------------------------
@@ -3758,6 +3921,66 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
3758
3921
  }
3759
3922
  }
3760
3923
 
3924
+ function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
3925
+ const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
3926
+ const projectSlug = config.project.value
3927
+ return projectSlug !== null
3928
+ ? {
3929
+ id: 'project',
3930
+ title: 'Project identity',
3931
+ status: 'ready',
3932
+ detail: `"${projectSlug}" (${config.project.source})`,
3933
+ }
3934
+ : {
3935
+ id: 'project',
3936
+ title: 'Project identity',
3937
+ // Not a blocker: a send without a project simply carries no project
3938
+ // identity. init always sets one because it is free and reversible,
3939
+ // but an unlabelled setup works, so this must not go red.
3940
+ status: 'optional-gap',
3941
+ detail: `not set in ${configPath} — sends from here carry no project identity`,
3942
+ remedy: {
3943
+ by: 'cli',
3944
+ summary: 'name this project after its directory',
3945
+ command: 'notifai init',
3946
+ },
3947
+ }
3948
+ }
3949
+
3950
+ function remoteStatesFrom(previous: Readiness): {
3951
+ credential: ReadinessState
3952
+ server: ReadinessState
3953
+ contract: ReadinessState
3954
+ auth: ReadinessState
3955
+ devices: ReadinessState
3956
+ proof: ReadinessState
3957
+ } | null {
3958
+ const pick = (id: string): ReadinessState | undefined => previous.states.find((state) => state.id === id)
3959
+ const credential = pick('credential')
3960
+ const server = pick('server')
3961
+ const contract = pick('contract')
3962
+ const auth = pick('auth')
3963
+ const devices = pick('devices')
3964
+ const proof = pick('proof')
3965
+ if (
3966
+ credential === undefined ||
3967
+ server === undefined ||
3968
+ contract === undefined ||
3969
+ auth === undefined ||
3970
+ devices === undefined ||
3971
+ proof === undefined
3972
+ ) {
3973
+ return null
3974
+ }
3975
+ return { credential, server, contract, auth, devices, proof }
3976
+ }
3977
+
3978
+ function remoteInvalidatedByConfig(previous: Readiness, config: CliConfig): boolean {
3979
+ if (config.base_url.source === 'default') return false
3980
+ const server = previous.states.find((state) => state.id === 'server')
3981
+ return server === undefined || !server.detail.includes(config.base_url.value)
3982
+ }
3983
+
3761
3984
  /**
3762
3985
  * Read the whole setup once, in dependency order.
3763
3986
  *
@@ -3769,38 +3992,46 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
3769
3992
  */
3770
3993
  export async function assessReadiness(
3771
3994
  deps: CommandDeps,
3772
- options: { skillScope?: SkillScope } = {},
3995
+ options: {
3996
+ skillScope?: SkillScope
3997
+ previous?: Readiness
3998
+ refresh?: readonly ReadinessRefresh[]
3999
+ } = {},
3773
4000
  ): Promise<Readiness> {
3774
4001
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
4002
+ const previous = options.previous
4003
+ const refresh = options.refresh
4004
+ if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
4005
+
4006
+ const reuseRemote =
4007
+ previous !== undefined &&
4008
+ refresh !== undefined &&
4009
+ !refresh.includes('remote') &&
4010
+ !remoteInvalidatedByConfig(previous, config)
4011
+ if (reuseRemote) {
4012
+ const reused = remoteStatesFrom(previous)
4013
+ if (reused !== null) {
4014
+ return {
4015
+ states: [
4016
+ projectReadiness(deps, config),
4017
+ reused.credential,
4018
+ reused.server,
4019
+ reused.contract,
4020
+ reused.auth,
4021
+ ...hookStates(deps),
4022
+ await skillReadiness(deps, options.skillScope),
4023
+ reused.devices,
4024
+ reused.proof,
4025
+ ],
4026
+ }
4027
+ }
4028
+ }
4029
+
3775
4030
  const states: ReadinessState[] = []
3776
4031
  let accountClient: ApiClient | null = null
3777
4032
  let accountDevices: RoutableDevice[] | null = null
3778
4033
 
3779
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
3780
- const projectSlug = config.project.value
3781
- states.push(
3782
- projectSlug !== null
3783
- ? {
3784
- id: 'project',
3785
- title: 'Project identity',
3786
- status: 'ready',
3787
- detail: `"${projectSlug}" (${config.project.source})`,
3788
- }
3789
- : {
3790
- id: 'project',
3791
- title: 'Project identity',
3792
- // Not a blocker: a send without a project simply carries no project
3793
- // identity. init always sets one because it is free and reversible,
3794
- // but an unlabelled setup works, so this must not go red.
3795
- status: 'optional-gap',
3796
- detail: `not set in ${configPath} — sends from here carry no project identity`,
3797
- remedy: {
3798
- by: 'cli',
3799
- summary: 'name this project after its directory',
3800
- command: 'notifai init',
3801
- },
3802
- },
3803
- )
4034
+ states.push(projectReadiness(deps, config))
3804
4035
 
3805
4036
  const credential = deps.store.load()
3806
4037
  states.push(
@@ -3843,10 +4074,10 @@ export async function assessReadiness(
3843
4074
  id: 'server',
3844
4075
  title: 'Service',
3845
4076
  status: 'gap',
3846
- detail: `cannot reach ${baseUrl} (${config.base_url.source})`,
4077
+ detail: `cannot reach ${baseUrl}`,
3847
4078
  remedy: {
3848
4079
  by: 'user-here',
3849
- summary: 'check your network, or the base_url shown above',
4080
+ summary: 'check your network',
3850
4081
  command: 'notifai doctor',
3851
4082
  },
3852
4083
  },
@@ -4072,8 +4303,12 @@ async function setupProofState(
4072
4303
  }
4073
4304
  }
4074
4305
 
4075
- export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
4076
- const readiness = await assessReadiness(deps)
4306
+ export async function doctorCommand(
4307
+ deps: CommandDeps,
4308
+ flags: { json?: boolean },
4309
+ options: { readiness?: Readiness } = {},
4310
+ ): Promise<number> {
4311
+ const readiness = options.readiness ?? (await assessReadiness(deps))
4077
4312
  const blocker = firstBlocker(readiness)
4078
4313
  const ok = blocker === null
4079
4314
 
@@ -4317,6 +4552,21 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4317
4552
  .join(', '),
4318
4553
  })
4319
4554
 
4555
+ const wired = new Set(installations.map((installation) => installation.harness))
4556
+ const unwired = detectedHarnesses(deps.cwd, deps.env).filter((harness) => !wired.has(harness))
4557
+ if (unwired.length > 0) {
4558
+ checks.push({
4559
+ name: 'hooks (detected)',
4560
+ ok: false,
4561
+ informational: true,
4562
+ detail: `${unwired.map((harness) => HARNESS_LABEL[harness]).join(', ')} detected on this machine but not wired`,
4563
+ remedy: {
4564
+ summary: 'install hooks for every detected harness',
4565
+ command: 'notifai hooks install',
4566
+ },
4567
+ })
4568
+ }
4569
+
4320
4570
  const { active, contested } = resolveActiveHarness(
4321
4571
  deps.env,
4322
4572
  deps.cwd,
@@ -4456,7 +4706,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4456
4706
  harness,
4457
4707
  installations: installations.filter((i) => i.harness === harness),
4458
4708
  }))
4459
- .filter((entry) => entry.installations.length > 1)
4709
+ .filter(
4710
+ (entry) =>
4711
+ entry.installations.some((installation) => installation.global) &&
4712
+ entry.installations.some((installation) => !installation.global),
4713
+ )
4460
4714
  if (duplicated.length > 0) {
4461
4715
  checks.push({
4462
4716
  name: 'hooks (duplicates)',
@@ -4470,6 +4724,26 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
4470
4724
  })
4471
4725
  }
4472
4726
 
4727
+ const representationProblems = codexRepresentationProblems(deps.cwd, deps.env)
4728
+ if (representationProblems.length > 0) {
4729
+ const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
4730
+ ? 'notifai hooks install --harness codex --global'
4731
+ : 'notifai hooks install --harness codex'
4732
+ checks.push({
4733
+ name: 'hooks (codex representation)',
4734
+ ok: false,
4735
+ detail: representationProblems.join('; '),
4736
+ ...(representationProblems.some((problem) => problem.includes('to collapse onto'))
4737
+ ? {
4738
+ remedy: {
4739
+ summary: 'collapse this Codex layer onto a single hook file',
4740
+ command: collapseCommand,
4741
+ },
4742
+ }
4743
+ : {}),
4744
+ })
4745
+ }
4746
+
4473
4747
  if (active !== null && activeInstallations.length > 0) {
4474
4748
  const admissionProblems = activeQuestionRouteProblems(deps, active, installations)
4475
4749
  checks.push({
@@ -4669,14 +4943,19 @@ function codexStrayWorktreeCheck(
4669
4943
  const layer = codexLayerDir(deps.cwd)
4670
4944
  if (layer === null) return null
4671
4945
  const root = codexProjectRoot(deps.cwd)
4672
- const stray = path.join(path.dirname(layer), '.codex', 'hooks.json')
4673
- if (!existsSync(path.join(root, '.codex', 'hooks.json'))) return null
4946
+ const project = inspectCodexLayer(codexLayerPaths(false, deps.cwd, deps.env))
4947
+ if (project.jsonEvents.length === 0 && project.tomlEvents.length === 0) return null
4948
+ const strayJson = path.join(path.dirname(layer), '.codex', 'hooks.json')
4949
+ const strayToml = path.join(path.dirname(layer), '.codex', 'config.toml')
4674
4950
  const problems: string[] = []
4675
4951
  if (!existsSync(layer)) {
4676
4952
  problems.push(`${layer} is missing, so Codex never looks for project hooks here`)
4677
4953
  }
4678
- if (existsSync(stray)) {
4679
- problems.push(`${stray} is never read — Codex reads ${root}/.codex/hooks.json instead`)
4954
+ if (existsSync(strayJson) && path.resolve(strayJson) !== path.resolve(project.paths.hooksJson)) {
4955
+ problems.push(`${strayJson} is never read — Codex reads ${project.writeTarget} instead`)
4956
+ }
4957
+ if (existsSync(strayToml) && path.resolve(strayToml) !== path.resolve(project.paths.configToml)) {
4958
+ problems.push(`${strayToml} is never read — Codex reads ${project.writeTarget} instead`)
4680
4959
  }
4681
4960
  return {
4682
4961
  name: 'hooks (codex worktree)',
@@ -4741,6 +5020,17 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
4741
5020
  const answer = await p.select({ message, options })
4742
5021
  return p.isCancel(answer) ? null : (answer as string)
4743
5022
  },
5023
+ multiselect: async (message, options, initial) => {
5024
+ if (!interactive()) return null
5025
+ const p = await clack()
5026
+ const answer = await p.multiselect({
5027
+ message,
5028
+ options,
5029
+ required: true,
5030
+ ...(initial !== undefined && initial.length > 0 ? { initialValues: initial } : {}),
5031
+ })
5032
+ return p.isCancel(answer) ? null : (answer as string[])
5033
+ },
4744
5034
  intro: async (title) => {
4745
5035
  if (!interactive()) return
4746
5036
  ;(await clack()).intro(title)