@raidiant/notifai 0.5.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +371 -157
  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 +54 -17
  19. package/dist/install-hooks.js +258 -97
  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 +364 -31
  37. package/src/commands.ts +450 -177
  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 +154 -2
  46. package/src/install-hooks.ts +231 -44
  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
@@ -50,8 +50,8 @@ import {
50
50
  findInstallations,
51
51
  } from './install-hooks.js'
52
52
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
53
- import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
54
- import { CONFIG_KEYS, loadConfig } from './config.js'
53
+ import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
54
+ import { CONFIG_KEYS, loadConfig, personalProjectConfigPath } from './config.js'
55
55
  import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
56
56
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
57
57
  import type { Tone } from './ui/theme.js'
@@ -123,6 +123,17 @@ class InteractiveIo extends CapturedIo {
123
123
  return this.selectAnswer
124
124
  }
125
125
 
126
+ multiselectAnswer: string[] | null = null
127
+
128
+ async multiselect(
129
+ message: string,
130
+ _options: { value: string; label: string; hint?: string }[],
131
+ _initial?: string[],
132
+ ): Promise<string[] | null> {
133
+ this.prompts.push(message)
134
+ return this.multiselectAnswer
135
+ }
136
+
126
137
  async intro(title: string) {
127
138
  this.intros.push(title)
128
139
  }
@@ -403,7 +414,9 @@ describe('command contracts', () => {
403
414
  expect(source).not.toContain(".option('--badge")
404
415
  expect(source).not.toContain(".option('--relevance")
405
416
  expect(source).not.toContain(".option('--target-content-id")
406
- expect(source).toContain('Kind profiles')
417
+ expect(source).toContain('Kind profiles apply automatically')
418
+ expect(source).toContain('Pass --sound or --level only to override')
419
+ expect(source).toContain('override kind profile / saved config')
407
420
  })
408
421
 
409
422
  it('rejects a question nobody will wait for', async () => {
@@ -1253,6 +1266,86 @@ describe('Cursor hook commands', () => {
1253
1266
  })
1254
1267
  })
1255
1268
 
1269
+ describe('Codex hook representation', () => {
1270
+ const execPath = process.execPath
1271
+ const scriptPath = fileURLToPath(import.meta.url)
1272
+
1273
+ function writeInlineStop(repo: string, command: string): string {
1274
+ const file = path.join(repo, '.codex', 'config.toml')
1275
+ mkdirSync(path.dirname(file), { recursive: true })
1276
+ writeFileSync(
1277
+ file,
1278
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', `command = "${command}"`, ''].join(
1279
+ '\n',
1280
+ ),
1281
+ )
1282
+ return file
1283
+ }
1284
+
1285
+ it('installs into existing inline [hooks] and does not create hooks.json', () => {
1286
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-inline-install-'))
1287
+ const toml = writeInlineStop(cwd, 'gdh-stop')
1288
+ const io = new CapturedIo()
1289
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1290
+
1291
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1292
+
1293
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
1294
+ const text = readFileSync(toml, 'utf8')
1295
+ expect(text).toContain('gdh-stop')
1296
+ expect(text).toContain('--owner notifai')
1297
+ expect(text).toContain('[[hooks.UserPromptSubmit]]')
1298
+ expect(io.outLines.join('\n')).toContain(toml)
1299
+ expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
1300
+ })
1301
+
1302
+ it('collapses a leftover hooks.json onto inline [hooks] and says both Stops will run', async () => {
1303
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-collapse-'))
1304
+ const toml = writeInlineStop(cwd, 'gdh-stop')
1305
+ const json = path.join(cwd, '.codex', 'hooks.json')
1306
+ const env = isolatedEnv(cwd)
1307
+ const io = new CapturedIo()
1308
+ const client = {
1309
+ health: async () => false,
1310
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1311
+ listDevices: async () => ({ devices: [] }),
1312
+ } as unknown as ApiClient
1313
+ const deps = { ...makeDeps(io, client), cwd, env }
1314
+
1315
+ applyPlan(json, {
1316
+ hooks: buildHookConfig({
1317
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
1318
+ harness: 'codex',
1319
+ }),
1320
+ })
1321
+
1322
+ io.outLines = []
1323
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1324
+ const before = io.outLines.join('\n')
1325
+ expect(before).toMatch(/hooks \(codex representation\)/)
1326
+ expect(before).toMatch(/prefer a single representation/)
1327
+ expect(before).toMatch(/Stop handlers will both (run|fire)/i)
1328
+ expect(before).toContain('notifai hooks install --harness codex')
1329
+
1330
+ io.outLines = []
1331
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1332
+ expect(existsSync(json)).toBe(false)
1333
+ expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1334
+ expect(readFileSync(toml, 'utf8')).toContain('--owner notifai')
1335
+ expect(io.outLines.join('\n')).toMatch(/collaps/i)
1336
+
1337
+ const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
1338
+ (installation) => installation.harness === 'codex',
1339
+ )
1340
+ expect(installations).toHaveLength(1)
1341
+ expect(installations[0]?.file).toBe(toml)
1342
+
1343
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
1344
+ expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1345
+ expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
1346
+ })
1347
+ })
1348
+
1256
1349
  describe('harness activation guidance', () => {
1257
1350
  const execPath = process.execPath
1258
1351
  const scriptPath = fileURLToPath(import.meta.url)
@@ -1266,9 +1359,10 @@ describe('harness activation guidance', () => {
1266
1359
  hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath }),
1267
1360
  ).toBe(EXIT.ok)
1268
1361
 
1269
- expect(io.outLines.join('\n')).toContain(
1270
- 'Claude Code reloads project hook files without a restart.',
1271
- )
1362
+ const output = io.outLines.join('\n')
1363
+ expect(output).toContain('Installed claude-code hooks in')
1364
+ expect(output).toContain('Send one Claude Code prompt, then check `notifai doctor`.')
1365
+ expect(output).not.toMatch(/timeout|asynchronous|600s/i)
1272
1366
  })
1273
1367
 
1274
1368
  it('does not invent a Codex hook trust gate', () => {
@@ -1285,6 +1379,52 @@ describe('harness activation guidance', () => {
1285
1379
  expect(output).not.toMatch(/trust|approve/i)
1286
1380
  })
1287
1381
 
1382
+ it('names the installed harness in the close, never a different one', () => {
1383
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-close-'))
1384
+ const io = new CapturedIo()
1385
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd }
1386
+
1387
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
1388
+
1389
+ const output = io.outLines.join('\n')
1390
+ expect(output).toContain('Installed cursor hooks in')
1391
+ expect(output).toContain('Send one Cursor prompt, then check `notifai doctor`.')
1392
+ expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
1393
+ expect(output).not.toMatch(/timeout|worktree|fails closed/i)
1394
+ })
1395
+
1396
+ it('pins hooks to npx when that is how this CLI is running', () => {
1397
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-npx-hooks-'))
1398
+ const npmCli = path.join(cwd, 'npm-cli.js')
1399
+ writeFileSync(npmCli, '')
1400
+ const io = new CapturedIo()
1401
+ const deps = {
1402
+ ...makeDeps(io, {} as ApiClient),
1403
+ cwd,
1404
+ env: {
1405
+ npm_command: 'exec',
1406
+ npm_execpath: npmCli,
1407
+ HOME: path.join(cwd, 'home'),
1408
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
1409
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1410
+ },
1411
+ }
1412
+
1413
+ expect(
1414
+ hooksInstallCommand(deps, {
1415
+ harness: 'claude-code',
1416
+ execPath: process.execPath,
1417
+ scriptPath: path.join(cwd, '_npx', 'hash', 'node_modules', '@raidiant', 'notifai', 'dist', 'main.js'),
1418
+ }),
1419
+ ).toBe(EXIT.ok)
1420
+
1421
+ const inspected = inspectHookAdapter(deps.hookAdapterHome)
1422
+ expect(inspected.problems).toEqual([])
1423
+ expect(inspected.target && 'spec' in inspected.target ? inspected.target.spec : null).toMatch(
1424
+ /^@raidiant\/notifai@/,
1425
+ )
1426
+ })
1427
+
1288
1428
  it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
1289
1429
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
1290
1430
  const io = new CapturedIo()
@@ -1310,8 +1450,9 @@ describe('harness activation guidance', () => {
1310
1450
  EXIT.ok,
1311
1451
  )
1312
1452
 
1313
- expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
1314
- expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
1453
+ expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
1454
+ expect(io.outLines.join('\n')).toContain('Send one OpenCode prompt, then check `notifai doctor`.')
1455
+ expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
1315
1456
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
1316
1457
  const plugin = readFileSync(pluginFile, 'utf8')
1317
1458
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
@@ -1602,10 +1743,36 @@ describe('config surfaces', () => {
1602
1743
  const io = new CapturedIo()
1603
1744
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1604
1745
  expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
1605
- expect(io.outLines[0]).toMatch(/^base_url = "/)
1746
+ expect(io.outLines[0]).toMatch(/^wait_seconds = /)
1606
1747
  for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
1607
1748
  })
1608
1749
 
1750
+ it('gives agents the current value, source, and one-line meaning', () => {
1751
+ const io = new CapturedIo()
1752
+ expect(configShowCommand(configDeps(io), { json: true })).toBe(EXIT.ok)
1753
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<
1754
+ string,
1755
+ { value: unknown; source: string; summary: string }
1756
+ >
1757
+ expect(parsed['base_url']).toBeUndefined()
1758
+ expect(parsed['ttl_seconds']).toEqual({
1759
+ value: 86400,
1760
+ source: 'default',
1761
+ summary: expect.stringContaining('deliver'),
1762
+ })
1763
+ expect(parsed['ask_grace_seconds']?.value).toBe(0)
1764
+ expect(parsed['ask_grace_seconds']?.summary).toBeTruthy()
1765
+ expect(Object.keys(parsed).sort()).toEqual([...CONFIG_KEYS].sort())
1766
+ })
1767
+
1768
+ it('refuses to treat the service origin as a setting', async () => {
1769
+ const io = new CapturedIo()
1770
+ expect(await configSetCommand(configDeps(io), 'base_url', 'https://attacker.example', { yes: true })).toBe(
1771
+ EXIT.usage,
1772
+ )
1773
+ expect(io.errLines[0]).toBe('Unknown setting "base_url".')
1774
+ })
1775
+
1609
1776
  it('explains each setting once a human is at the terminal', () => {
1610
1777
  const io = new InteractiveIo()
1611
1778
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
@@ -1787,11 +1954,11 @@ describe('interactive command UX', () => {
1787
1954
  }
1788
1955
 
1789
1956
  expect(await configSetCommand(deps, 'sound', 'done', {})).toBe(EXIT.ok)
1957
+ const localFile = personalProjectConfigPath(cwd, deps.env)
1790
1958
  expect(io.prompts[0]).toBe('Where should this setting live?')
1791
- expect(io.prompts[1]).toContain(path.join(cwd, '.notifai', 'config.local.toml'))
1792
- expect(readFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'utf8')).toContain(
1793
- 'sound = "done"',
1794
- )
1959
+ expect(io.prompts[1]).toContain(localFile)
1960
+ expect(readFileSync(localFile, 'utf8')).toContain('sound = "done"')
1961
+ expect(existsSync(path.join(cwd, '.notifai', 'config.local.toml'))).toBe(false)
1795
1962
  })
1796
1963
 
1797
1964
  it('bypasses interactive config selection with --yes and uses the global default', async () => {
@@ -1814,13 +1981,14 @@ describe('interactive command UX', () => {
1814
1981
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
1815
1982
  const io = new InteractiveIo()
1816
1983
  io.selectAnswer = 'local'
1817
- const localFile = path.join(cwd, '.notifai', 'config.local.toml')
1984
+ const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
1985
+ const localFile = personalProjectConfigPath(cwd, env)
1818
1986
  mkdirSync(path.dirname(localFile), { recursive: true })
1819
1987
  writeFileSync(localFile, 'sound = "done"\n')
1820
1988
  const deps = {
1821
1989
  ...makeDeps(io, {} as ApiClient),
1822
1990
  cwd,
1823
- env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
1991
+ env,
1824
1992
  }
1825
1993
 
1826
1994
  expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
@@ -2020,13 +2188,21 @@ describe('init', () => {
2020
2188
  cwd,
2021
2189
  env: isolatedEnv(cwd),
2022
2190
  nativeSkills,
2191
+ hookInstallTarget: {
2192
+ execPath: process.execPath,
2193
+ scriptPath: fileURLToPath(import.meta.url),
2194
+ },
2023
2195
  }
2024
2196
  }
2025
2197
 
2026
2198
  it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
2027
2199
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
2028
2200
  const io = new CapturedIo()
2029
- const deps = { ...makeDeps(io, {} as ApiClient), cwd }
2201
+ const deps: CommandDeps = {
2202
+ ...makeDeps(io, {} as ApiClient),
2203
+ cwd,
2204
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
2205
+ }
2030
2206
 
2031
2207
  expect(await initCommand(deps, {})).toBe(EXIT.failed)
2032
2208
  const configPath = path.join(cwd, '.notifai', 'config.toml')
@@ -2038,9 +2214,10 @@ describe('init', () => {
2038
2214
 
2039
2215
  io.outLines = []
2040
2216
  expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
2041
- // Idempotent: the second run re-derives the same slug and says so as a
2042
- // settled state rather than repeating the write.
2043
- expect(io.outLines.join('\n')).toContain('Project identity: "my-project-')
2217
+ // Idempotent: the second run re-derives the same slug and does not reprint
2218
+ // doctor's ready-state dump.
2219
+ expect(io.outLines.join('\n')).toMatch(/^Next:/m)
2220
+ expect(io.outLines.join('\n')).not.toContain('Project identity:')
2044
2221
  expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
2045
2222
  })
2046
2223
 
@@ -2272,6 +2449,48 @@ describe('init', () => {
2272
2449
  expect(io.outLines.join('\n')).toContain('All set.')
2273
2450
  })
2274
2451
 
2452
+ it('installs every detected project harness when --hooks is set', async () => {
2453
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
2454
+ mkdirSync(path.join(cwd, '.claude'))
2455
+ mkdirSync(path.join(cwd, '.codex'))
2456
+ const io = new CapturedIo()
2457
+ const calls = { submit: 0 }
2458
+ const nativeSkills: NativeSkills = {
2459
+ add: async () => 0,
2460
+ list: async () => ({ skills: [] }),
2461
+ }
2462
+ const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
2463
+ await initCommand(deps, { hooks: true, skills: false })
2464
+ const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
2465
+ (installation) => installation.harness,
2466
+ )
2467
+ expect(wired).toEqual(['claude-code', 'codex'])
2468
+ expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
2469
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(true)
2470
+ expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
2471
+ expect(io.outLines.join('\n')).toContain('Installed codex hooks')
2472
+ })
2473
+
2474
+ it('lets a human keep a subset of the detected harnesses', async () => {
2475
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-pick-'))
2476
+ mkdirSync(path.join(cwd, '.claude'))
2477
+ mkdirSync(path.join(cwd, '.codex'))
2478
+ const io = new InteractiveIo()
2479
+ io.multiselectAnswer = ['claude-code']
2480
+ const calls = { submit: 0 }
2481
+ const nativeSkills: NativeSkills = {
2482
+ add: async () => 0,
2483
+ list: async () => ({ skills: [] }),
2484
+ }
2485
+ const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
2486
+ expect(await initCommand(deps, { skills: false })).toBe(EXIT.ok)
2487
+ expect(io.prompts.some((prompt) => prompt.includes('Which agent harnesses'))).toBe(true)
2488
+ const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
2489
+ (installation) => installation.harness,
2490
+ )
2491
+ expect(wired).toEqual(['claude-code'])
2492
+ })
2493
+
2275
2494
  it('reports an optional native installer failure without blocking remaining setup', async () => {
2276
2495
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
2277
2496
  const io = new InteractiveIo()
@@ -2506,9 +2725,6 @@ describe('init', () => {
2506
2725
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
2507
2726
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
2508
2727
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
2509
- expect(io.outLines.join('\n')).toContain(
2510
- "Companion Receipt (the app's delivery confirmation) observed from iPhone",
2511
- )
2512
2728
  expect(io.outLines.join('\n')).toContain('All set.')
2513
2729
  expect(submitCalls).toBe(1)
2514
2730
  expect(submittedDraft?.draft.event).toBe('setup_verified')
@@ -2664,9 +2880,7 @@ describe('init', () => {
2664
2880
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
2665
2881
  expect(submitCalls).toBe(2)
2666
2882
  expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
2667
- expect(io.outLines.join('\n')).toContain(
2668
- "Companion Receipt (the app's delivery confirmation) observed from iPhone",
2669
- )
2883
+ expect(io.outLines.join('\n')).toContain('All set.')
2670
2884
  })
2671
2885
 
2672
2886
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
@@ -2837,6 +3051,126 @@ describe('init', () => {
2837
3051
  })
2838
3052
  })
2839
3053
 
3054
+ describe('readiness assessment cost', () => {
3055
+ it('does not re-probe the service after init closes a local-only gap', async () => {
3056
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-local-reassess-'))
3057
+ const io = new CapturedIo()
3058
+ let healthCalls = 0
3059
+ const client = {
3060
+ health: async () => {
3061
+ healthCalls += 1
3062
+ return true
3063
+ },
3064
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3065
+ } as unknown as ApiClient
3066
+ const deps: CommandDeps = {
3067
+ ...makeDeps(io, client),
3068
+ cwd,
3069
+ env: isolatedEnv(cwd),
3070
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3071
+ }
3072
+
3073
+ expect(await initCommand(deps, {})).toBe(EXIT.failed)
3074
+ expect(healthCalls).toBe(1)
3075
+ expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project =')
3076
+ })
3077
+
3078
+ it('reuses remote probes when only local state could have changed', async () => {
3079
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-local-only-'))
3080
+ const io = new CapturedIo()
3081
+ let healthCalls = 0
3082
+ let deviceCalls = 0
3083
+ let evidenceCalls = 0
3084
+ const client = {
3085
+ health: async () => {
3086
+ healthCalls += 1
3087
+ return true
3088
+ },
3089
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3090
+ listDevices: async () => {
3091
+ deviceCalls += 1
3092
+ return { devices: [] }
3093
+ },
3094
+ accessStatus: async () => ({ email: 'user@example.com' }),
3095
+ evidence: async () => {
3096
+ evidenceCalls += 1
3097
+ throw new Error('unused')
3098
+ },
3099
+ } as unknown as ApiClient
3100
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
3101
+
3102
+ const previous = await assessReadiness(deps)
3103
+ expect(healthCalls).toBe(1)
3104
+ expect(deviceCalls).toBe(1)
3105
+
3106
+ const next = await assessReadiness(deps, { previous, refresh: ['local'] })
3107
+ expect(healthCalls).toBe(1)
3108
+ expect(deviceCalls).toBe(1)
3109
+ expect(evidenceCalls).toBe(0)
3110
+ expect(next.states.find((state) => state.id === 'server')).toEqual(
3111
+ previous.states.find((state) => state.id === 'server'),
3112
+ )
3113
+ })
3114
+
3115
+ it('renders a supplied doctor report without probing again', async () => {
3116
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'doctor-reuse-'))
3117
+ const io = new CapturedIo()
3118
+ let healthCalls = 0
3119
+ const client = {
3120
+ health: async () => {
3121
+ healthCalls += 1
3122
+ return true
3123
+ },
3124
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3125
+ } as unknown as ApiClient
3126
+ const deps: CommandDeps = {
3127
+ ...makeDeps(io, client),
3128
+ cwd,
3129
+ env: isolatedEnv(cwd),
3130
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3131
+ }
3132
+
3133
+ const readiness = await assessReadiness(deps)
3134
+ expect(healthCalls).toBe(1)
3135
+ expect(await doctorCommand(deps, { json: true }, { readiness })).toBe(EXIT.failed)
3136
+ expect(healthCalls).toBe(1)
3137
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toHaveProperty('states')
3138
+ })
3139
+
3140
+ it('treats a lock-file pin as installed without asking npx', async () => {
3141
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-'))
3142
+ writeFileSync(
3143
+ path.join(cwd, 'skills-lock.json'),
3144
+ `${JSON.stringify({
3145
+ skills: {
3146
+ notifai: {
3147
+ source: 'Raidiant-io/notifai',
3148
+ sourceType: 'github',
3149
+ ref: RELEASE_REF,
3150
+ },
3151
+ },
3152
+ })}\n`,
3153
+ )
3154
+ const io = new CapturedIo()
3155
+ const client = {
3156
+ health: async () => true,
3157
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3158
+ listDevices: async () => ({ devices: [] }),
3159
+ } as unknown as ApiClient
3160
+
3161
+ const readiness = await assessReadiness({
3162
+ ...makeDeps(io, client),
3163
+ cwd,
3164
+ env: { ...isolatedEnv(cwd), PATH: '/nonexistent' },
3165
+ nativeSkills: realNativeSkills,
3166
+ })
3167
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
3168
+ status: 'ready',
3169
+ detail: `installed from ${SKILLS_SOURCE} in the project scope`,
3170
+ })
3171
+ })
3172
+ })
3173
+
2840
3174
  describe('an outage is not an answer', () => {
2841
3175
  /**
2842
3176
  * The dangerous shape: the first poll succeeds, then connectivity drops and
@@ -3507,13 +3841,12 @@ describe('asking before the hooks have ever run', () => {
3507
3841
 
3508
3842
  it('shows resolved question-routing values with their winning config sources', async () => {
3509
3843
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
3510
- mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3511
- writeFileSync(
3512
- path.join(cwd, '.notifai', 'config.local.toml'),
3513
- 'ask_notifications = false\nask_grace_seconds = 90\n',
3514
- )
3844
+ const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
3845
+ const localFile = personalProjectConfigPath(cwd, env)
3846
+ mkdirSync(path.dirname(localFile), { recursive: true })
3847
+ writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
3515
3848
  const io = new CapturedIo()
3516
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
3849
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3517
3850
 
3518
3851
  await doctorCommand(deps, {})
3519
3852