@raidiant/notifai 0.5.0 → 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 (74) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/claude-wake.d.ts +101 -0
  4. package/dist/claude-wake.js +397 -0
  5. package/dist/claude-wake.js.map +1 -0
  6. package/dist/codex-wake.d.ts +84 -0
  7. package/dist/codex-wake.js +238 -0
  8. package/dist/codex-wake.js.map +1 -0
  9. package/dist/commands.d.ts +31 -11
  10. package/dist/commands.js +679 -232
  11. package/dist/commands.js.map +1 -1
  12. package/dist/config-schema.d.ts +3 -3
  13. package/dist/config-schema.js +11 -49
  14. package/dist/config-schema.js.map +1 -1
  15. package/dist/config.d.ts +23 -45
  16. package/dist/config.js +61 -39
  17. package/dist/config.js.map +1 -1
  18. package/dist/harnesses.d.ts +4 -13
  19. package/dist/harnesses.js +12 -17
  20. package/dist/harnesses.js.map +1 -1
  21. package/dist/hook-adapter.d.ts +10 -1
  22. package/dist/hook-adapter.js +79 -10
  23. package/dist/hook-adapter.js.map +1 -1
  24. package/dist/hooks.d.ts +119 -65
  25. package/dist/hooks.js +330 -285
  26. package/dist/hooks.js.map +1 -1
  27. package/dist/install-hooks.d.ts +76 -24
  28. package/dist/install-hooks.js +301 -125
  29. package/dist/install-hooks.js.map +1 -1
  30. package/dist/interactive.d.ts +32 -1
  31. package/dist/interactive.js +165 -51
  32. package/dist/interactive.js.map +1 -1
  33. package/dist/main.js +14 -21
  34. package/dist/main.js.map +1 -1
  35. package/dist/native-skills.d.ts +1 -1
  36. package/dist/native-skills.js +23 -58
  37. package/dist/native-skills.js.map +1 -1
  38. package/dist/pack-feedback-slice.d.ts +35 -0
  39. package/dist/pack-feedback-slice.js +126 -0
  40. package/dist/pack-feedback-slice.js.map +1 -0
  41. package/dist/readiness.d.ts +20 -0
  42. package/dist/readiness.js +24 -0
  43. package/dist/readiness.js.map +1 -1
  44. package/dist/release.d.ts +31 -0
  45. package/dist/release.js +52 -0
  46. package/dist/release.js.map +1 -0
  47. package/dist/ui/theme.d.ts +1 -1
  48. package/package.json +11 -10
  49. package/src/commands.test.ts +386 -36
  50. package/src/commands.ts +473 -182
  51. package/src/config-schema.ts +2 -17
  52. package/src/config.test.ts +29 -4
  53. package/src/config.ts +67 -11
  54. package/src/harnesses.ts +7 -0
  55. package/src/hook-adapter.test.ts +43 -1
  56. package/src/hook-adapter.ts +83 -11
  57. package/src/hooks.test.ts +4 -5
  58. package/src/install-hooks.test.ts +154 -2
  59. package/src/install-hooks.ts +231 -44
  60. package/src/interactive.test.ts +338 -0
  61. package/src/interactive.ts +221 -49
  62. package/src/main.ts +14 -20
  63. package/src/native-skills.test.ts +75 -0
  64. package/src/native-skills.ts +31 -75
  65. package/src/pack-feedback-slice.test.ts +212 -0
  66. package/src/pack-feedback-slice.ts +187 -0
  67. package/src/readiness.test.ts +25 -0
  68. package/src/readiness.ts +38 -0
  69. package/src/release.ts +51 -0
  70. package/src/skill.test.ts +43 -0
  71. package/src/ui/config-view.test.ts +5 -4
  72. package/dist/idle.d.ts +0 -16
  73. package/dist/idle.js +0 -44
  74. package/dist/idle.js.map +0 -1
@@ -50,12 +50,28 @@ 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'
58
58
 
59
+ /**
60
+ * The release tag this build pins the agent skill to.
61
+ *
62
+ * Read from the manifest for the same reason the CLI itself does: a fixture
63
+ * carrying a literal tag stops matching the moment the version is bumped, and
64
+ * a test that has to be hand-edited on every release is a copy of the constant
65
+ * it is supposed to be checking.
66
+ */
67
+ const RELEASE_REF = `v${
68
+ (
69
+ JSON.parse(
70
+ readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
71
+ ) as { version: string }
72
+ ).version
73
+ }`
74
+
59
75
  class CapturedIo implements CommandIo {
60
76
  outLines: string[] = []
61
77
  errLines: string[] = []
@@ -107,6 +123,17 @@ class InteractiveIo extends CapturedIo {
107
123
  return this.selectAnswer
108
124
  }
109
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
+
110
137
  async intro(title: string) {
111
138
  this.intros.push(title)
112
139
  }
@@ -387,7 +414,9 @@ describe('command contracts', () => {
387
414
  expect(source).not.toContain(".option('--badge")
388
415
  expect(source).not.toContain(".option('--relevance")
389
416
  expect(source).not.toContain(".option('--target-content-id")
390
- 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')
391
420
  })
392
421
 
393
422
  it('rejects a question nobody will wait for', async () => {
@@ -1237,6 +1266,86 @@ describe('Cursor hook commands', () => {
1237
1266
  })
1238
1267
  })
1239
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
+
1240
1349
  describe('harness activation guidance', () => {
1241
1350
  const execPath = process.execPath
1242
1351
  const scriptPath = fileURLToPath(import.meta.url)
@@ -1250,9 +1359,10 @@ describe('harness activation guidance', () => {
1250
1359
  hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath }),
1251
1360
  ).toBe(EXIT.ok)
1252
1361
 
1253
- expect(io.outLines.join('\n')).toContain(
1254
- 'Claude Code reloads project hook files without a restart.',
1255
- )
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)
1256
1366
  })
1257
1367
 
1258
1368
  it('does not invent a Codex hook trust gate', () => {
@@ -1269,6 +1379,52 @@ describe('harness activation guidance', () => {
1269
1379
  expect(output).not.toMatch(/trust|approve/i)
1270
1380
  })
1271
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
+
1272
1428
  it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
1273
1429
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
1274
1430
  const io = new CapturedIo()
@@ -1294,8 +1450,9 @@ describe('harness activation guidance', () => {
1294
1450
  EXIT.ok,
1295
1451
  )
1296
1452
 
1297
- expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
1298
- 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/)
1299
1456
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
1300
1457
  const plugin = readFileSync(pluginFile, 'utf8')
1301
1458
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
@@ -1586,10 +1743,36 @@ describe('config surfaces', () => {
1586
1743
  const io = new CapturedIo()
1587
1744
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1588
1745
  expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
1589
- expect(io.outLines[0]).toMatch(/^base_url = "/)
1746
+ expect(io.outLines[0]).toMatch(/^wait_seconds = /)
1590
1747
  for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
1591
1748
  })
1592
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
+
1593
1776
  it('explains each setting once a human is at the terminal', () => {
1594
1777
  const io = new InteractiveIo()
1595
1778
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
@@ -1771,11 +1954,11 @@ describe('interactive command UX', () => {
1771
1954
  }
1772
1955
 
1773
1956
  expect(await configSetCommand(deps, 'sound', 'done', {})).toBe(EXIT.ok)
1957
+ const localFile = personalProjectConfigPath(cwd, deps.env)
1774
1958
  expect(io.prompts[0]).toBe('Where should this setting live?')
1775
- expect(io.prompts[1]).toContain(path.join(cwd, '.notifai', 'config.local.toml'))
1776
- expect(readFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'utf8')).toContain(
1777
- 'sound = "done"',
1778
- )
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)
1779
1962
  })
1780
1963
 
1781
1964
  it('bypasses interactive config selection with --yes and uses the global default', async () => {
@@ -1798,13 +1981,14 @@ describe('interactive command UX', () => {
1798
1981
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
1799
1982
  const io = new InteractiveIo()
1800
1983
  io.selectAnswer = 'local'
1801
- 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)
1802
1986
  mkdirSync(path.dirname(localFile), { recursive: true })
1803
1987
  writeFileSync(localFile, 'sound = "done"\n')
1804
1988
  const deps = {
1805
1989
  ...makeDeps(io, {} as ApiClient),
1806
1990
  cwd,
1807
- env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
1991
+ env,
1808
1992
  }
1809
1993
 
1810
1994
  expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
@@ -1974,7 +2158,7 @@ describe('init', () => {
1974
2158
  source: 'Raidiant-io/notifai',
1975
2159
  sourceType: 'github',
1976
2160
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1977
- ref: 'v0.5.0',
2161
+ ref: RELEASE_REF,
1978
2162
  }
1979
2163
  }
1980
2164
 
@@ -2004,13 +2188,21 @@ describe('init', () => {
2004
2188
  cwd,
2005
2189
  env: isolatedEnv(cwd),
2006
2190
  nativeSkills,
2191
+ hookInstallTarget: {
2192
+ execPath: process.execPath,
2193
+ scriptPath: fileURLToPath(import.meta.url),
2194
+ },
2007
2195
  }
2008
2196
  }
2009
2197
 
2010
2198
  it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
2011
2199
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
2012
2200
  const io = new CapturedIo()
2013
- 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
+ }
2014
2206
 
2015
2207
  expect(await initCommand(deps, {})).toBe(EXIT.failed)
2016
2208
  const configPath = path.join(cwd, '.notifai', 'config.toml')
@@ -2022,9 +2214,10 @@ describe('init', () => {
2022
2214
 
2023
2215
  io.outLines = []
2024
2216
  expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
2025
- // Idempotent: the second run re-derives the same slug and says so as a
2026
- // settled state rather than repeating the write.
2027
- 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:')
2028
2221
  expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
2029
2222
  })
2030
2223
 
@@ -2076,8 +2269,9 @@ describe('init', () => {
2076
2269
  expect(io.errLines).toEqual([])
2077
2270
  })
2078
2271
 
2079
- it('pins the skill installer to the tagged public release syntax', () => {
2080
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.5.0')
2272
+ it('pins the skill installer to the tagged release this build actually is', () => {
2273
+ expect(SKILLS_SOURCE).toBe(`Raidiant-io/notifai#${RELEASE_REF}`)
2274
+ // `#` selects a Git ref; `@` would select a skill name instead.
2081
2275
  expect(SKILLS_SOURCE).not.toContain('@v')
2082
2276
  })
2083
2277
 
@@ -2100,7 +2294,7 @@ describe('init', () => {
2100
2294
  source: 'Raidiant-io/notifai',
2101
2295
  sourceType: 'github',
2102
2296
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2103
- ref: 'v0.5.0',
2297
+ ref: RELEASE_REF,
2104
2298
  },
2105
2299
  ],
2106
2300
  }),
@@ -2255,6 +2449,48 @@ describe('init', () => {
2255
2449
  expect(io.outLines.join('\n')).toContain('All set.')
2256
2450
  })
2257
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
+
2258
2494
  it('reports an optional native installer failure without blocking remaining setup', async () => {
2259
2495
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
2260
2496
  const io = new InteractiveIo()
@@ -2489,9 +2725,6 @@ describe('init', () => {
2489
2725
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
2490
2726
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
2491
2727
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
2492
- expect(io.outLines.join('\n')).toContain(
2493
- "Companion Receipt (the app's delivery confirmation) observed from iPhone",
2494
- )
2495
2728
  expect(io.outLines.join('\n')).toContain('All set.')
2496
2729
  expect(submitCalls).toBe(1)
2497
2730
  expect(submittedDraft?.draft.event).toBe('setup_verified')
@@ -2647,9 +2880,7 @@ describe('init', () => {
2647
2880
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
2648
2881
  expect(submitCalls).toBe(2)
2649
2882
  expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
2650
- expect(io.outLines.join('\n')).toContain(
2651
- "Companion Receipt (the app's delivery confirmation) observed from iPhone",
2652
- )
2883
+ expect(io.outLines.join('\n')).toContain('All set.')
2653
2884
  })
2654
2885
 
2655
2886
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
@@ -2820,6 +3051,126 @@ describe('init', () => {
2820
3051
  })
2821
3052
  })
2822
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
+
2823
3174
  describe('an outage is not an answer', () => {
2824
3175
  /**
2825
3176
  * The dangerous shape: the first poll succeeds, then connectivity drops and
@@ -3237,7 +3588,7 @@ describe('asking before the hooks have ever run', () => {
3237
3588
  source: 'Raidiant-io/notifai',
3238
3589
  sourceType: 'github',
3239
3590
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
3240
- ref: 'v0.5.0',
3591
+ ref: RELEASE_REF,
3241
3592
  },
3242
3593
  ]
3243
3594
  : [],
@@ -3490,13 +3841,12 @@ describe('asking before the hooks have ever run', () => {
3490
3841
 
3491
3842
  it('shows resolved question-routing values with their winning config sources', async () => {
3492
3843
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
3493
- mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3494
- writeFileSync(
3495
- path.join(cwd, '.notifai', 'config.local.toml'),
3496
- 'ask_notifications = false\nask_grace_seconds = 90\n',
3497
- )
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')
3498
3848
  const io = new CapturedIo()
3499
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
3849
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3500
3850
 
3501
3851
  await doctorCommand(deps, {})
3502
3852