@raidiant/notifai 8.0.0 → 9.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 (145) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-doctor.d.ts +1 -0
  24. package/dist/commands-doctor.js +71 -15
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-guidance.d.ts +7 -3
  27. package/dist/commands-guidance.js +63 -28
  28. package/dist/commands-guidance.js.map +1 -1
  29. package/dist/commands-hooks.d.ts +9 -4
  30. package/dist/commands-hooks.js +430 -32
  31. package/dist/commands-hooks.js.map +1 -1
  32. package/dist/commands-init.d.ts +10 -18
  33. package/dist/commands-init.js +114 -32
  34. package/dist/commands-init.js.map +1 -1
  35. package/dist/commands-send-support.d.ts +17 -2
  36. package/dist/commands-send-support.js +61 -5
  37. package/dist/commands-send-support.js.map +1 -1
  38. package/dist/commands-send.js +60 -11
  39. package/dist/commands-send.js.map +1 -1
  40. package/dist/commands-setup-proof.d.ts +8 -0
  41. package/dist/commands-setup-proof.js +31 -2
  42. package/dist/commands-setup-proof.js.map +1 -1
  43. package/dist/commands-skill.d.ts +11 -1
  44. package/dist/commands-skill.js +61 -11
  45. package/dist/commands-skill.js.map +1 -1
  46. package/dist/commands.d.ts +1 -1
  47. package/dist/commands.js.map +1 -1
  48. package/dist/config-schema.d.ts +1 -1
  49. package/dist/config-schema.js +25 -0
  50. package/dist/config-schema.js.map +1 -1
  51. package/dist/config.d.ts +24 -3
  52. package/dist/config.js +37 -5
  53. package/dist/config.js.map +1 -1
  54. package/dist/credentials.d.ts +10 -1
  55. package/dist/credentials.js +25 -12
  56. package/dist/credentials.js.map +1 -1
  57. package/dist/guidance-content.d.ts +20 -0
  58. package/dist/guidance-content.js +73 -8
  59. package/dist/guidance-content.js.map +1 -1
  60. package/dist/guidance.d.ts +21 -0
  61. package/dist/guidance.js +73 -18
  62. package/dist/guidance.js.map +1 -1
  63. package/dist/hook-adapter.js +37 -3
  64. package/dist/hook-adapter.js.map +1 -1
  65. package/dist/hooks.d.ts +68 -7
  66. package/dist/hooks.js +231 -85
  67. package/dist/hooks.js.map +1 -1
  68. package/dist/install-hooks.d.ts +10 -18
  69. package/dist/install-hooks.js +110 -23
  70. package/dist/install-hooks.js.map +1 -1
  71. package/dist/interactive.d.ts +1 -1
  72. package/dist/interactive.js +1 -1
  73. package/dist/interactive.js.map +1 -1
  74. package/dist/logging.d.ts +8 -1
  75. package/dist/logging.js +43 -2
  76. package/dist/logging.js.map +1 -1
  77. package/dist/main.js +2 -13
  78. package/dist/main.js.map +1 -1
  79. package/dist/native-skills.d.ts +19 -0
  80. package/dist/native-skills.js +30 -8
  81. package/dist/native-skills.js.map +1 -1
  82. package/dist/opencode-plugin.js +14 -1
  83. package/dist/opencode-plugin.js.map +1 -1
  84. package/dist/program.js +13 -8
  85. package/dist/program.js.map +1 -1
  86. package/dist/send.d.ts +8 -1
  87. package/dist/send.js +17 -1
  88. package/dist/send.js.map +1 -1
  89. package/dist/session-activation.d.ts +15 -0
  90. package/dist/session-activation.js +35 -0
  91. package/dist/session-activation.js.map +1 -0
  92. package/dist/url-policy.d.ts +85 -0
  93. package/dist/url-policy.js +369 -0
  94. package/dist/url-policy.js.map +1 -0
  95. package/package.json +3 -3
  96. package/src/atomic-file.ts +36 -2
  97. package/src/cli-bin.test.ts +38 -0
  98. package/src/cli-bin.ts +67 -0
  99. package/src/cli-release.test.ts +46 -0
  100. package/src/cli-release.ts +78 -0
  101. package/src/client.test.ts +95 -0
  102. package/src/client.ts +16 -3
  103. package/src/commands-auth.ts +32 -3
  104. package/src/commands-config.ts +25 -2
  105. package/src/commands-core.ts +38 -3
  106. package/src/commands-doctor.ts +99 -13
  107. package/src/commands-guidance.ts +75 -29
  108. package/src/commands-hooks.ts +503 -34
  109. package/src/commands-init.ts +139 -38
  110. package/src/commands-send-support.test.ts +156 -0
  111. package/src/commands-send-support.ts +67 -5
  112. package/src/commands-send.ts +73 -10
  113. package/src/commands-setup-proof.ts +36 -2
  114. package/src/commands-skill.ts +78 -15
  115. package/src/commands.test.ts +1083 -101
  116. package/src/commands.ts +1 -0
  117. package/src/config-schema.ts +28 -1
  118. package/src/config.test.ts +1 -1
  119. package/src/config.ts +49 -5
  120. package/src/credentials.test.ts +71 -3
  121. package/src/credentials.ts +29 -15
  122. package/src/guidance-content.ts +74 -8
  123. package/src/guidance.test.ts +42 -11
  124. package/src/guidance.ts +100 -18
  125. package/src/hook-adapter.test.ts +113 -1
  126. package/src/hook-adapter.ts +37 -3
  127. package/src/hooks.test.ts +746 -165
  128. package/src/hooks.ts +299 -124
  129. package/src/install-hooks.test.ts +135 -16
  130. package/src/install-hooks.ts +113 -25
  131. package/src/interactive.ts +1 -1
  132. package/src/logging.test.ts +66 -3
  133. package/src/logging.ts +45 -2
  134. package/src/main.ts +2 -14
  135. package/src/native-skills.test.ts +41 -1
  136. package/src/native-skills.ts +40 -6
  137. package/src/opencode-plugin.ts +14 -1
  138. package/src/program.test.ts +37 -2
  139. package/src/program.ts +40 -12
  140. package/src/send.test.ts +28 -0
  141. package/src/send.ts +28 -2
  142. package/src/session-activation.ts +45 -0
  143. package/src/skill.test.ts +120 -4
  144. package/src/trust-policy.test.ts +701 -0
  145. package/src/url-policy.ts +399 -0
@@ -63,17 +63,26 @@ import {
63
63
  import {
64
64
  applyPlan,
65
65
  buildHookConfig,
66
+ hookCommand,
66
67
  codexHookIdentityHash,
67
68
  codexTrustKey,
68
69
  findInstallations,
70
+ handlerEvent,
71
+ settingsFile,
69
72
  } from './install-hooks.js'
70
73
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
71
74
  import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
72
75
  import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
73
- import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
76
+ import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
77
+ import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
78
+ import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
74
79
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
75
80
  import type { Tone } from './ui/theme.js'
76
81
 
82
+ afterEach(() => {
83
+ resetLatestPublishedCliVersionForTest()
84
+ })
85
+
77
86
  /**
78
87
  * The release tag this build pins the agent skill to.
79
88
  *
@@ -328,6 +337,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
328
337
  hookAdapterHome: path.join(testRoot, 'home'),
329
338
  cwd: os.tmpdir(),
330
339
  clientFactory: () => withCompatibilityDefaults(client),
340
+ fetchImpl: async () => new Response('{}', { status: 503 }),
331
341
  }
332
342
  }
333
343
 
@@ -382,6 +392,8 @@ const reply: ReplyView = {
382
392
  device_id: 'dev_test',
383
393
  device_name: 'iPhone',
384
394
  text: 'yes, after the migration',
395
+ answers: [],
396
+ source: null,
385
397
  created_at: '2026-08-01T18:01:00.000Z',
386
398
  }
387
399
 
@@ -573,6 +585,108 @@ describe('command contracts', () => {
573
585
  expect(io.errLines.join('\n')).toContain('project')
574
586
  })
575
587
 
588
+ it('prints the exact idempotency key needed after an ambiguous submit failure', async () => {
589
+ const io = new CapturedIo()
590
+ let attemptedKey = ''
591
+ const client = {
592
+ submit: async (body: SubmitNotificationRequestT) => {
593
+ attemptedKey = body.idempotency_key
594
+ throw new NetworkError('Connection closed before a response arrived')
595
+ },
596
+ } as unknown as ApiClient
597
+
598
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
599
+ const deps = makeDeps(io, client)
600
+ deps.env = {
601
+ XDG_CONFIG_HOME: path.join(root, 'config'),
602
+ XDG_STATE_HOME: path.join(root, 'state'),
603
+ }
604
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
605
+ expect(
606
+ await sendCommand(deps, {
607
+ kind: 'done',
608
+ title: 'Checks finished',
609
+ body: 'All checks passed.',
610
+ }),
611
+ ).toBe(EXIT.network)
612
+
613
+ expect(attemptedKey).toMatch(/^cli-/)
614
+ expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
615
+ expect(
616
+ readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
617
+ ).toMatchObject({
618
+ idempotency_key: attemptedKey,
619
+ kind: 'done',
620
+ title_chars: 'Checks finished'.length,
621
+ session_id: null,
622
+ })
623
+ rmSync(root, { recursive: true, force: true })
624
+ })
625
+
626
+ it('prints the same generated key for an ambiguous server failure', async () => {
627
+ const io = new CapturedIo()
628
+ let attemptedKey = ''
629
+ const client = {
630
+ submit: async (body: SubmitNotificationRequestT) => {
631
+ attemptedKey = body.idempotency_key
632
+ throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
633
+ },
634
+ } as unknown as ApiClient
635
+
636
+ expect(
637
+ await sendCommand(makeDeps(io, client), {
638
+ kind: 'failed',
639
+ title: 'Validation could not finish',
640
+ body: 'The service failed while accepting the result.',
641
+ }),
642
+ ).toBe(EXIT.network)
643
+ expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
644
+ })
645
+
646
+ it('routes a send with no active Companion devices into the setup coordinator', async () => {
647
+ const io = new CapturedIo()
648
+ let attemptedKey = ''
649
+ const client = {
650
+ submit: async (body: SubmitNotificationRequestT) => {
651
+ attemptedKey = body.idempotency_key
652
+ throw new ApiCallError(
653
+ 409,
654
+ 'no_active_devices',
655
+ 'No enabled active Device Installation can receive this request.',
656
+ 'Open the Companion App and enable notifications.',
657
+ )
658
+ },
659
+ } as unknown as ApiClient
660
+
661
+ expect(
662
+ await sendCommand(makeDeps(io, client), {
663
+ kind: 'done',
664
+ title: 'Checks finished',
665
+ body: 'All checks passed.',
666
+ }),
667
+ ).toBe(EXIT.failed)
668
+
669
+ expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
670
+ expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
671
+ expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
672
+ })
673
+
674
+ it('routes a send from an unpaired machine into the same setup coordinator', async () => {
675
+ const io = new CapturedIo()
676
+ const deps = makeDeps(io, {} as ApiClient)
677
+ deps.store.load = () => null
678
+
679
+ expect(
680
+ await sendCommand(deps, {
681
+ kind: 'done',
682
+ title: 'Checks finished',
683
+ body: 'All checks passed.',
684
+ }),
685
+ ).toBe(EXIT.auth)
686
+
687
+ expect(io.errLines.join('\n')).toMatch(/`notifai init`.*login.*device setup/i)
688
+ })
689
+
576
690
  it('authors and validates an Android-specific Notification Request', async () => {
577
691
  const io = new CapturedIo()
578
692
  let submitted: SubmitNotificationRequestT | undefined
@@ -1209,8 +1323,11 @@ describe('command contracts', () => {
1209
1323
  request_id: receipt.request_id,
1210
1324
  sequence: reply.seq,
1211
1325
  device: reply.device_name,
1212
- text: reply.text,
1326
+ text_chars: reply.text.length,
1327
+ answers_count: reply.answers.length,
1213
1328
  })
1329
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
1330
+ expect(raw).not.toContain(reply.text)
1214
1331
  })
1215
1332
 
1216
1333
  it('backs off and retries a transient network error while waiting', async () => {
@@ -1501,6 +1618,10 @@ describe('command contracts', () => {
1501
1618
  'acknowledgement.attempted',
1502
1619
  'acknowledgement.outcome',
1503
1620
  ])
1621
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
1622
+ expect(raw).not.toContain('I will deploy staging now.')
1623
+ expect(events[0]?.data).toMatchObject({ characters: 'I will deploy staging now.'.length })
1624
+ expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
1504
1625
  })
1505
1626
 
1506
1627
  it('clears a matching active-session obligation after acknowledgement success', async () => {
@@ -1719,6 +1840,113 @@ describe('command contracts', () => {
1719
1840
  })
1720
1841
  })
1721
1842
 
1843
+ it('close --pending withdraws an unpushed registration so a later Stop cannot send it', async () => {
1844
+ const io = new CapturedIo()
1845
+ const client = {
1846
+ closeReplies: async () => {
1847
+ throw new Error('unpushed questions must not call the server')
1848
+ },
1849
+ } as unknown as ApiClient
1850
+ const deps = makeDeps(io, client)
1851
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-unpushed-'))
1852
+ deps.cwd = root
1853
+ deps.env = {
1854
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1855
+ XDG_STATE_HOME: path.join(root, 'state'),
1856
+ }
1857
+ writeSessionState('close-unpushed', deps.env, {
1858
+ pending: [{ question: 'Ship it?', question_id: 'q_local' }],
1859
+ })
1860
+ writeProjectSession(root, deps.env, 'close-unpushed', Date.now(), 'codex')
1861
+
1862
+ expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
1863
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1864
+ session_id: 'close-unpushed',
1865
+ withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
1866
+ closed: [],
1867
+ })
1868
+ expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
1869
+ })
1870
+
1871
+ it('close withdraws one unpushed question by the ask id', async () => {
1872
+ const io = new CapturedIo()
1873
+ const client = {
1874
+ closeReplies: async () => {
1875
+ throw new Error('unpushed questions must not call the server')
1876
+ },
1877
+ } as unknown as ApiClient
1878
+ const deps = makeDeps(io, client)
1879
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-one-unpushed-'))
1880
+ deps.cwd = root
1881
+ deps.env = {
1882
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1883
+ XDG_STATE_HOME: path.join(root, 'state'),
1884
+ }
1885
+ writeSessionState('close-one', deps.env, {
1886
+ pending: [
1887
+ { question: 'Ship it?', question_id: 'q_local' },
1888
+ { question: 'Keep this?', question_id: 'q_keep' },
1889
+ ],
1890
+ })
1891
+ writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
1892
+
1893
+ expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
1894
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1895
+ session_id: 'close-one',
1896
+ withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
1897
+ closed: [],
1898
+ })
1899
+ expect(readSessionState('close-one', deps.env).pending?.map((entry) => entry.question_id)).toEqual([
1900
+ 'q_keep',
1901
+ ])
1902
+ })
1903
+
1904
+ it('close --pending closes live questions and leaves nothing for a later Stop to push', async () => {
1905
+ const io = new CapturedIo()
1906
+ const closed: string[] = []
1907
+ const client = {
1908
+ closeReplies: async (requestId: string) => {
1909
+ closed.push(requestId)
1910
+ return replyResponse([])
1911
+ },
1912
+ } as unknown as ApiClient
1913
+ const deps = makeDeps(io, client)
1914
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-pending-mix-'))
1915
+ deps.cwd = root
1916
+ deps.env = {
1917
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1918
+ XDG_STATE_HOME: path.join(root, 'state'),
1919
+ }
1920
+ writeSessionState('close-mix', deps.env, {
1921
+ pending: [
1922
+ { question: 'Unpushed?', question_id: 'q_local' },
1923
+ {
1924
+ question: 'Already on a device?',
1925
+ request_id: receipt.request_id,
1926
+ collapse_key: 'question-live',
1927
+ device_ids: ['dev_iphone'],
1928
+ },
1929
+ ],
1930
+ })
1931
+ writeProjectSession(root, deps.env, 'close-mix', Date.now(), 'codex')
1932
+
1933
+ expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
1934
+ expect(closed).toEqual([receipt.request_id])
1935
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1936
+ session_id: 'close-mix',
1937
+ withdrawn: [{ question: 'Unpushed?', question_id: 'q_local' }],
1938
+ closed: [receipt.request_id],
1939
+ })
1940
+ expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
1941
+ })
1942
+
1943
+ it('close requires a request id or --pending', async () => {
1944
+ const io = new CapturedIo()
1945
+ const client = {} as unknown as ApiClient
1946
+ expect(await closeCommand(makeDeps(io, client), undefined, {})).toBe(EXIT.usage)
1947
+ expect(io.errLines.join('\n')).toContain('Pass a request id or --pending.')
1948
+ })
1949
+
1722
1950
  it('passes the replies cursor and prints replies for later retrieval', async () => {
1723
1951
  const io = new CapturedIo()
1724
1952
  let requested: { waitSeconds: number; afterSeq: number } | undefined
@@ -1797,8 +2025,9 @@ describe('command contracts', () => {
1797
2025
  writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
1798
2026
 
1799
2027
  expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
1800
- expect(io.outLines[0]).toBe('pending request req_first')
1801
- expect(io.outLines[1]).toContain('yes, after the migration')
2028
+ expect(io.outLines[0]).toBe('unpushed question: Not yet asked?')
2029
+ expect(io.outLines.join('\n')).toContain('pending request req_first')
2030
+ expect(io.outLines.join('\n')).toContain('yes, after the migration')
1802
2031
  expect(io.outLines.join('\n')).toContain('req_second')
1803
2032
  })
1804
2033
 
@@ -1995,13 +2224,188 @@ describe('command contracts', () => {
1995
2224
  })
1996
2225
  })
1997
2226
 
2227
+ describe('credential origin pinning', () => {
2228
+ it('never sends the machine bearer to a flag or env origin override', async () => {
2229
+ const io = new CapturedIo()
2230
+ const seen: { baseUrl: string; bearer: string | null }[] = []
2231
+ const client = {
2232
+ submit: async () => receipt,
2233
+ } as unknown as ApiClient
2234
+ const deps = makeDeps(io, client)
2235
+ deps.env = { ...deps.env, NOTIFAI_BASE_URL: 'https://attacker.example' }
2236
+ deps.clientFactory = (baseUrl, bearer) => {
2237
+ seen.push({ baseUrl, bearer })
2238
+ return withCompatibilityDefaults(client)
2239
+ }
2240
+
2241
+ expect(
2242
+ await sendCommand(deps, {
2243
+ kind: 'update',
2244
+ title: 'T',
2245
+ body: 'B',
2246
+ baseUrl: 'https://attacker.flag',
2247
+ }),
2248
+ ).toBe(EXIT.ok)
2249
+
2250
+ expect(seen).toEqual([
2251
+ {
2252
+ baseUrl: 'https://test.notifai.invalid',
2253
+ bearer: 'Bearer nfm_mac_test.test-secret',
2254
+ },
2255
+ ])
2256
+ expect(io.errLines.join('\n')).toContain('Ignoring base_url from flag')
2257
+ expect(io.errLines.join('\n')).not.toContain('test-secret')
2258
+ expect(io.errLines.join('\n')).not.toContain('nfm_')
2259
+ expect(io.errLines.join('\n')).not.toContain('https://attacker.example')
2260
+ expect(io.errLines.join('\n')).not.toContain('https://attacker.flag')
2261
+ })
2262
+
2263
+ it('lets unsigned-in login target an origin override', async () => {
2264
+ const io = new CapturedIo()
2265
+ const seen: { baseUrl: string; bearer: string | null }[] = []
2266
+ let beginBody: Parameters<ApiClient['beginPairing']>[0] | null = null
2267
+ let now = 0
2268
+ const client = {
2269
+ beginPairing: async (body: Parameters<ApiClient['beginPairing']>[0]) => {
2270
+ beginBody = body
2271
+ return {
2272
+ pairing_id: 'pair_test',
2273
+ code: 'ABCD-EFGH',
2274
+ approve_url: 'https://selfhost.example/pair/ABCD-EFGH',
2275
+ expires_at: new Date(10_000).toISOString(),
2276
+ poll_interval_seconds: 1,
2277
+ }
2278
+ },
2279
+ pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
2280
+ } as unknown as ApiClient
2281
+ const saved: { baseUrl?: string } = {}
2282
+ const deps: CommandDeps = {
2283
+ ...makeDeps(io, client),
2284
+ now: () => now,
2285
+ sleep: async (milliseconds: number) => {
2286
+ now += milliseconds
2287
+ },
2288
+ store: {
2289
+ load: () => null,
2290
+ save: (credential) => {
2291
+ saved.baseUrl = credential.baseUrl
2292
+ },
2293
+ clear: () => {},
2294
+ describe: () => 'test credential store',
2295
+ },
2296
+ clientFactory: (baseUrl, bearer) => {
2297
+ seen.push({ baseUrl, bearer })
2298
+ return withCompatibilityDefaults(client)
2299
+ },
2300
+ }
2301
+
2302
+ expect(await loginCommand(deps, { baseUrl: 'https://selfhost.example', open: false })).toBe(
2303
+ EXIT.ok,
2304
+ )
2305
+ expect(seen[0]).toEqual({ baseUrl: 'https://selfhost.example', bearer: null })
2306
+ expect(saved.baseUrl).toBe('https://selfhost.example')
2307
+ expect(beginBody).toMatchObject({
2308
+ machine_name: expect.any(String),
2309
+ credential_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2310
+ poll_verifier_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2311
+ confirmation_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2312
+ })
2313
+ expect(io.outLines[1]).toMatch(
2314
+ /^Approve this machine at: https:\/\/selfhost\.example\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
2315
+ )
2316
+ })
2317
+
2318
+ it('stops login when the approving account has no product access', async () => {
2319
+ const io = new CapturedIo()
2320
+ let now = 0
2321
+ const client = {
2322
+ beginPairing: async () => ({
2323
+ pairing_id: 'pair_test',
2324
+ code: 'ABCD-EFGH',
2325
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2326
+ expires_at: new Date(10_000).toISOString(),
2327
+ poll_interval_seconds: 1,
2328
+ }),
2329
+ pollPairing: async () => ({
2330
+ status: 'no_active_plan',
2331
+ next_action: 'Ask the account owner for Alpha access.',
2332
+ }),
2333
+ } as unknown as ApiClient
2334
+ const deps: CommandDeps = {
2335
+ ...makeDeps(io, client),
2336
+ now: () => now,
2337
+ sleep: async (milliseconds: number) => {
2338
+ now += milliseconds
2339
+ },
2340
+ }
2341
+
2342
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
2343
+ expect(io.errLines).toEqual([
2344
+ 'This account has no active plan or temporary Alpha access.',
2345
+ 'next: Ask the account owner for Alpha access.',
2346
+ 'After access is granted, run `notifai login` again.',
2347
+ ])
2348
+ })
2349
+
2350
+ it('keeps notification titles out of send logs', async () => {
2351
+ const io = new CapturedIo()
2352
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-log-'))
2353
+ const client = { submit: async () => receipt } as unknown as ApiClient
2354
+ const deps = makeDeps(io, client)
2355
+ deps.env = {
2356
+ XDG_CONFIG_HOME: path.join(root, 'config'),
2357
+ XDG_STATE_HOME: path.join(root, 'state'),
2358
+ }
2359
+ deps.logger = createLogger({ env: deps.env, cmd: 'send', settings: { level: 'debug' } })
2360
+
2361
+ expect(
2362
+ await sendCommand(deps, {
2363
+ kind: 'update',
2364
+ title: 'SECRET_SEND_TITLE',
2365
+ body: 'SECRET_SEND_BODY',
2366
+ }),
2367
+ ).toBe(EXIT.ok)
2368
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
2369
+ expect(raw).not.toContain('SECRET_SEND_TITLE')
2370
+ expect(raw).not.toContain('SECRET_SEND_BODY')
2371
+ const submitted = readLogRecords(deps.env, { event: ['send.submitted'] }).records
2372
+ expect(submitted[0]?.data).toMatchObject({
2373
+ request_id: receipt.request_id,
2374
+ title_chars: 'SECRET_SEND_TITLE'.length,
2375
+ })
2376
+ })
2377
+
2378
+ it('refuses a shell-escaped backslash-n body unless the literal flag is set', async () => {
2379
+ const io = new CapturedIo()
2380
+ expect(
2381
+ await sendCommand(makeDeps(io, {} as ApiClient), {
2382
+ kind: 'update',
2383
+ title: 'Escaped',
2384
+ body: 'line1\\nline2',
2385
+ }),
2386
+ ).toBe(EXIT.usage)
2387
+ expect(io.errLines.join('\n')).toContain('--body-file -')
2388
+ expect(io.errLines.join('\n')).toContain('--literal-backslash-n')
2389
+
2390
+ io.errLines = []
2391
+ const client = { submit: async () => receipt } as unknown as ApiClient
2392
+ expect(
2393
+ await sendCommand(makeDeps(io, client), {
2394
+ kind: 'update',
2395
+ title: 'Escaped',
2396
+ body: 'line1\\nline2',
2397
+ literalBackslashN: true,
2398
+ }),
2399
+ ).toBe(EXIT.ok)
2400
+ })
2401
+ })
2402
+
1998
2403
  describe('delivery evidence status', () => {
1999
2404
  function snapshot(
2000
2405
  companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
2001
2406
  ): EvidenceSnapshot {
2002
2407
  return {
2003
2408
  request_id: 'req_status_test',
2004
- event: 'tests_passed',
2005
2409
  accepted_at: '2026-08-05T13:05:48.000Z',
2006
2410
  overall: 'provider_accepted_all',
2007
2411
  deliveries: [
@@ -2097,6 +2501,53 @@ describe('delivery evidence status', () => {
2097
2501
  expect(said).toContain('Reply received: yes')
2098
2502
  expect(said).toContain('answers are on the server')
2099
2503
  })
2504
+
2505
+ it('treats an ordinary status Companion Receipt as doctor delivery proof', async () => {
2506
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-status-proof-'))
2507
+ const evidence = snapshot({
2508
+ state: 'observed',
2509
+ observed_at: '2026-08-05T13:17:17.000Z',
2510
+ latency_ms: 1_000,
2511
+ })
2512
+ const device = {
2513
+ device_id: evidence.deliveries[0]!.device_id,
2514
+ display_name: 'iPhone',
2515
+ platform: 'ios' as const,
2516
+ permission_status: 'authorized',
2517
+ registration_healthy: true,
2518
+ app_version: '0.1.0',
2519
+ app_build: '42',
2520
+ os_version: '19.0',
2521
+ capabilities: ['answer'] as const,
2522
+ support: currentSupport,
2523
+ support_state: 'current' as const,
2524
+ derived_status: 'working' as const,
2525
+ status_message: null,
2526
+ last_seen_at: '2026-08-05T18:00:00.000Z',
2527
+ }
2528
+ const client = {
2529
+ health: async () => true,
2530
+ listDevices: async () => ({ devices: [device] }),
2531
+ accessStatus: async () => ({
2532
+ status: 'active',
2533
+ reason: 'alpha_grant',
2534
+ expires_at: null,
2535
+ email: 'proof@example.com',
2536
+ }),
2537
+ evidence: async () => evidence,
2538
+ } as unknown as ApiClient
2539
+ const io = new CapturedIo()
2540
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
2541
+
2542
+ expect(await statusCommand(deps, evidence.request_id, { json: true })).toBe(EXIT.ok)
2543
+
2544
+ const doctorIo = new CapturedIo()
2545
+ expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.ok)
2546
+ const report = JSON.parse(doctorIo.outLines.join('\n')) as {
2547
+ states: { id: string; status: string }[]
2548
+ }
2549
+ expect(report.states.find((state) => state.id === 'proof')).toMatchObject({ status: 'ready' })
2550
+ })
2100
2551
  })
2101
2552
 
2102
2553
  describe('Cursor hook commands', () => {
@@ -2122,12 +2573,17 @@ describe('Cursor hook commands', () => {
2122
2573
  expect(Object.keys(installed.hooks).sort()).toEqual([
2123
2574
  'beforeSubmitPrompt',
2124
2575
  'sessionEnd',
2576
+ 'sessionStart',
2125
2577
  'stop',
2126
2578
  ])
2127
2579
  expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
2128
2580
  'hook user-prompt-submit --owner notifai --harness cursor',
2129
2581
  )
2130
2582
  expect(installed.hooks['stop']?.[0]).toMatchObject({
2583
+ command: expect.stringContaining('hook activation-stop --owner notifai --harness cursor'),
2584
+ loop_limit: 1,
2585
+ })
2586
+ expect(installed.hooks['stop']?.[1]).toMatchObject({
2131
2587
  command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
2132
2588
  loop_limit: 3,
2133
2589
  })
@@ -2163,7 +2619,7 @@ describe('Cursor hook commands', () => {
2163
2619
  expect(io.outLines).toContain(
2164
2620
  `ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
2165
2621
  )
2166
- expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
2622
+ expect(io.outLines.some((line) => line.includes('Cursor: start one fresh conversation'))).toBe(true)
2167
2623
  })
2168
2624
 
2169
2625
  it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
@@ -2242,13 +2698,7 @@ describe('Codex hook representation', () => {
2242
2698
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
2243
2699
  })
2244
2700
 
2245
- /**
2246
- * The foreign `hooks.json` stays byte-identical and Notifai lands inline.
2247
- * Codex will now warn that the layer uses both representations; the install
2248
- * output says why, because that warning is Codex reporting a merge, not a
2249
- * fault, and a silent one would read as Notifai's bug.
2250
- */
2251
- it('leaves a foreign hooks.json alone and installs inline instead', () => {
2701
+ it('joins a populated hooks.json without introducing a second representation', () => {
2252
2702
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
2253
2703
  const layer = path.join(cwd, '.codex')
2254
2704
  const json = path.join(layer, 'hooks.json')
@@ -2266,17 +2716,12 @@ describe('Codex hook representation', () => {
2266
2716
 
2267
2717
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2268
2718
 
2269
- const beforeJson = readFileSync(json, 'utf8')
2270
- expect(beforeJson).toContain('gdh-stop')
2271
- expect(beforeJson).not.toContain('--owner notifai')
2272
- const after = readFileSync(toml, 'utf8')
2273
- // The user's file arrives back as the file they wrote, plus our hooks.
2274
- expect(after).toContain(beforeToml.trimEnd())
2275
- expect(after).toContain('--owner notifai')
2276
- expect(io.outLines.join('\n')).toContain(toml)
2277
- expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
2278
- expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
2279
- expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
2719
+ const afterJson = readFileSync(json, 'utf8')
2720
+ expect(afterJson).toContain('gdh-stop')
2721
+ expect(afterJson).toContain('--owner notifai')
2722
+ expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
2723
+ expect(io.outLines.join('\n')).toContain(json)
2724
+ expect(io.outLines.join('\n')).not.toMatch(/both representations|not a Notifai fault/i)
2280
2725
  })
2281
2726
 
2282
2727
  it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
@@ -2293,7 +2738,51 @@ describe('Codex hook representation', () => {
2293
2738
  expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
2294
2739
  })
2295
2740
 
2296
- /**
2741
+ it('leaves config.toml byte-identical on a no-op reinstall', () => {
2742
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-noop-install-'))
2743
+ const io = new CapturedIo()
2744
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2745
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2746
+ const toml = path.join(cwd, '.codex', 'config.toml')
2747
+ const before = readFileSync(toml, 'utf8')
2748
+ io.outLines = []
2749
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2750
+ expect(readFileSync(toml, 'utf8')).toBe(before)
2751
+ })
2752
+
2753
+ it('deletes an emptied hooks.json instead of leaving an empty residue', () => {
2754
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-empty-json-'))
2755
+ const layer = path.join(cwd, '.codex')
2756
+ const json = path.join(layer, 'hooks.json')
2757
+ mkdirSync(layer, { recursive: true })
2758
+ applyPlan(json, {
2759
+ hooks: {
2760
+ Stop: [{ hooks: [{ type: 'command', command: hookCommand(scriptPath, 'stop', 'codex') }] }],
2761
+ },
2762
+ })
2763
+ const io = new CapturedIo()
2764
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2765
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
2766
+ expect(existsSync(json)).toBe(false)
2767
+ })
2768
+
2769
+ it('global install does not leave a dead project .codex after uninstalling that layer', () => {
2770
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-global-migrate-'))
2771
+ const io = new CapturedIo()
2772
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2773
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2774
+ expect(existsSync(path.join(cwd, '.codex'))).toBe(true)
2775
+ expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
2776
+ EXIT.ok,
2777
+ )
2778
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
2779
+ const leftover = existsSync(path.join(cwd, '.codex'))
2780
+ ? readdirSync(path.join(cwd, '.codex'))
2781
+ : []
2782
+ expect(leftover.every((name) => name !== 'hooks.json')).toBe(true)
2783
+ })
2784
+
2785
+ /**
2297
2786
  * Notifai in one file, someone else's hooks in the other: every handler
2298
2787
  * fires once, so doctor has nothing to report and the foreign file is not
2299
2788
  * ours to rewrite. Doctor used to fail here — the check read "two files" as
@@ -2386,7 +2875,7 @@ describe('harness activation guidance', () => {
2386
2875
  const execPath = process.execPath
2387
2876
  const scriptPath = fileURLToPath(import.meta.url)
2388
2877
 
2389
- it('does not require a Claude Code restart for project hook files', () => {
2878
+ it('requires a fresh Claude Code session so SessionStart can activate it', () => {
2390
2879
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
2391
2880
  const io = new CapturedIo()
2392
2881
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
@@ -2397,11 +2886,11 @@ describe('harness activation guidance', () => {
2397
2886
 
2398
2887
  const output = io.outLines.join('\n')
2399
2888
  expect(output).toContain('Installed claude-code hooks in')
2400
- expect(output).toContain('Send one Claude Code prompt, then check `notifai doctor`.')
2889
+ expect(output).toContain('Start one fresh Claude Code session, send one prompt, then run `notifai doctor`.')
2401
2890
  expect(output).not.toMatch(/timeout|asynchronous|600s/i)
2402
2891
  })
2403
2892
 
2404
- it('does not invent a Codex hook trust gate', () => {
2893
+ it('names Codex trust and fresh-session activation in the correct order', () => {
2405
2894
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
2406
2895
  const io = new CapturedIo()
2407
2896
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
@@ -2411,8 +2900,8 @@ describe('harness activation guidance', () => {
2411
2900
  )
2412
2901
 
2413
2902
  const output = io.outLines.join('\n')
2414
- expect(output).toContain('Send one Codex prompt, then check `notifai doctor`.')
2415
- expect(output).not.toMatch(/trust|approve/i)
2903
+ expect(output).toContain('Approve the Notifai handlers in `/hooks` if Codex asks')
2904
+ expect(output).toMatch(/approve[\s\S]*start one fresh Codex session[\s\S]*`notifai doctor`/i)
2416
2905
  })
2417
2906
 
2418
2907
  it('names the installed harness in the close, never a different one', () => {
@@ -2424,7 +2913,7 @@ describe('harness activation guidance', () => {
2424
2913
 
2425
2914
  const output = io.outLines.join('\n')
2426
2915
  expect(output).toContain('Installed cursor hooks in')
2427
- expect(output).toContain('Send one Cursor prompt, then check `notifai doctor`.')
2916
+ expect(output).toContain('Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.')
2428
2917
  expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
2429
2918
  expect(output).not.toMatch(/timeout|worktree|fails closed/i)
2430
2919
  })
@@ -2487,7 +2976,7 @@ describe('harness activation guidance', () => {
2487
2976
  )
2488
2977
 
2489
2978
  expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
2490
- expect(io.outLines.join('\n')).toContain('Send one OpenCode prompt, then check `notifai doctor`.')
2979
+ expect(io.outLines.join('\n')).toContain('Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.')
2491
2980
  expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
2492
2981
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
2493
2982
  const plugin = readFileSync(pluginFile, 'utf8')
@@ -2632,6 +3121,141 @@ describe('stable hook installation', () => {
2632
3121
  findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
2633
3122
  ).toEqual([expect.objectContaining({ global: true })])
2634
3123
  })
3124
+
3125
+ it('refreshes stale global lifecycle coverage instead of declining a project install', () => {
3126
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-stale-'))
3127
+ const io = new CapturedIo()
3128
+ const env = isolatedEnv(cwd)
3129
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3130
+ const execPath = process.execPath
3131
+ const scriptPath = fileURLToPath(import.meta.url)
3132
+
3133
+ expect(
3134
+ hooksInstallCommand(deps, {
3135
+ harness: 'cursor',
3136
+ global: true,
3137
+ execPath,
3138
+ scriptPath,
3139
+ }),
3140
+ ).toBe(EXIT.ok)
3141
+ const globalFile = settingsFile('cursor', true, cwd, env)
3142
+ const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3143
+ hooks: { stop: Array<{ command?: string }> }
3144
+ }
3145
+ settings.hooks.stop = settings.hooks.stop.filter(
3146
+ (handler) => !handler.command?.includes(' hook activation-stop '),
3147
+ )
3148
+ writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
3149
+
3150
+ io.outLines = []
3151
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
3152
+ expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
3153
+ const installation = findInstallations(cwd, env, deps.hookAdapterHome).find(
3154
+ (item) => item.harness === 'cursor' && item.global,
3155
+ )
3156
+ expect(installation?.handlers.map((handler) => handlerEvent(handler.command))).toContain(
3157
+ 'activation-stop',
3158
+ )
3159
+ expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
3160
+ })
3161
+
3162
+ it('refreshes a stale global Stop shape instead of declining a project install', () => {
3163
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-shape-'))
3164
+ const io = new CapturedIo()
3165
+ const env = isolatedEnv(cwd)
3166
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3167
+ const execPath = process.execPath
3168
+ const scriptPath = fileURLToPath(import.meta.url)
3169
+
3170
+ expect(
3171
+ hooksInstallCommand(deps, {
3172
+ harness: 'cursor',
3173
+ global: true,
3174
+ execPath,
3175
+ scriptPath,
3176
+ }),
3177
+ ).toBe(EXIT.ok)
3178
+ const globalFile = settingsFile('cursor', true, cwd, env)
3179
+ const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3180
+ hooks: { stop: Array<{ command?: string; timeout?: number }> }
3181
+ }
3182
+ const stop = settings.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))
3183
+ expect(stop).toBeDefined()
3184
+ stop!.timeout = 1
3185
+ writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
3186
+
3187
+ io.outLines = []
3188
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
3189
+ expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
3190
+ const refreshed = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3191
+ hooks: { stop: Array<{ command?: string; timeout?: number }> }
3192
+ }
3193
+ expect(
3194
+ refreshed.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))?.timeout,
3195
+ ).toBeGreaterThan(1)
3196
+ expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
3197
+ })
3198
+
3199
+ it('consolidates duplicate global Codex representations before declining a project install', () => {
3200
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-codex-duplicate-'))
3201
+ const io = new CapturedIo()
3202
+ const env = isolatedEnv(cwd)
3203
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3204
+ const execPath = process.execPath
3205
+ const scriptPath = fileURLToPath(import.meta.url)
3206
+
3207
+ expect(
3208
+ hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
3209
+ ).toBe(EXIT.ok)
3210
+ const json = path.join(env.CODEX_HOME!, 'hooks.json')
3211
+ applyPlan(json, {
3212
+ hooks: {
3213
+ ...buildHookConfig({ adapterPath: hookAdapterPath(deps.hookAdapterHome), harness: 'codex' }),
3214
+ PostToolUse: [{ hooks: [{ type: 'command', command: 'foreign-post-tool' }] }],
3215
+ },
3216
+ })
3217
+
3218
+ io.outLines = []
3219
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3220
+ expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
3221
+ expect(readFileSync(json, 'utf8')).toContain('foreign-post-tool')
3222
+ expect(readFileSync(json, 'utf8')).not.toContain('--owner notifai')
3223
+ expect(
3224
+ findInstallations(cwd, env, deps.hookAdapterHome).filter(
3225
+ (installation) => installation.harness === 'codex',
3226
+ ),
3227
+ ).toEqual([expect.objectContaining({ global: true })])
3228
+ expect(io.outLines.join('\n')).toMatch(/Installed codex hooks/)
3229
+ })
3230
+
3231
+ it('refreshes an obsolete global OpenCode plugin instead of treating empty event requirements as coverage', () => {
3232
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-opencode-stale-'))
3233
+ const io = new CapturedIo()
3234
+ const env = isolatedEnv(cwd)
3235
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3236
+ const execPath = process.execPath
3237
+ const scriptPath = fileURLToPath(import.meta.url)
3238
+
3239
+ expect(
3240
+ hooksInstallCommand(deps, {
3241
+ harness: 'opencode',
3242
+ global: true,
3243
+ execPath,
3244
+ scriptPath,
3245
+ }),
3246
+ ).toBe(EXIT.ok)
3247
+ const globalFile = settingsFile('opencode', true, cwd, env)
3248
+ const current = readFileSync(globalFile, 'utf8')
3249
+ writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 10', 'const ADAPTER_VERSION = 8'))
3250
+
3251
+ io.outLines = []
3252
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3253
+ expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
3254
+ const refreshed = readFileSync(globalFile, 'utf8')
3255
+ expect(refreshed).toContain('const ADAPTER_VERSION = 10')
3256
+ expect(refreshed).toContain('experimental.chat.system.transform')
3257
+ expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
3258
+ })
2635
3259
  })
2636
3260
 
2637
3261
  describe('projectSlugFrom', () => {
@@ -2967,7 +3591,7 @@ describe('interactive command UX', () => {
2967
3591
  beginPairing: async () => ({
2968
3592
  pairing_id: 'pair_test',
2969
3593
  code: 'ABCD-EFGH',
2970
- approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
3594
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2971
3595
  expires_at: new Date(10_000).toISOString(),
2972
3596
  poll_interval_seconds: 1,
2973
3597
  }),
@@ -2997,7 +3621,9 @@ describe('interactive command UX', () => {
2997
3621
  expect(io.notes).toEqual([
2998
3622
  {
2999
3623
  title: 'Approve this machine',
3000
- message: 'Code: ABCD-EFGH\nhttps://test.notifai.invalid/pair/ABCD-EFGH',
3624
+ message: expect.stringMatching(
3625
+ /^Code: ABCD-EFGH\nhttps:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
3626
+ ),
3001
3627
  },
3002
3628
  ])
3003
3629
  expect(io.spinnerEvents).toEqual([
@@ -3016,7 +3642,7 @@ describe('interactive command UX', () => {
3016
3642
  beginPairing: async () => ({
3017
3643
  pairing_id: 'pair_test',
3018
3644
  code: 'ABCD-EFGH',
3019
- approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
3645
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
3020
3646
  expires_at: new Date(10_000).toISOString(),
3021
3647
  poll_interval_seconds: 1,
3022
3648
  }),
@@ -3033,7 +3659,9 @@ describe('interactive command UX', () => {
3033
3659
  expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
3034
3660
  expect(io.outLines.slice(0, 3)).toEqual([
3035
3661
  'Pairing code: ABCD-EFGH',
3036
- 'Approve this machine at: https://test.notifai.invalid/pair/ABCD-EFGH',
3662
+ expect.stringMatching(
3663
+ /^Approve this machine at: https:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
3664
+ ),
3037
3665
  'Waiting for approval…',
3038
3666
  ])
3039
3667
  })
@@ -3174,6 +3802,26 @@ describe('interactive command UX', () => {
3174
3802
  expect(io.intros).toEqual([])
3175
3803
  expect(io.checks).toEqual([])
3176
3804
  })
3805
+
3806
+ it('consults npm latest from an interactive doctor without failing the run', async () => {
3807
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-registry-'))
3808
+ const io = new InteractiveIo()
3809
+ const client = {
3810
+ health: async () => true,
3811
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3812
+ } as unknown as ApiClient
3813
+ const deps: CommandDeps = {
3814
+ ...makeDeps(io, client),
3815
+ cwd,
3816
+ env: isolatedEnv(cwd),
3817
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3818
+ fetchImpl: async () => new Response(JSON.stringify({ latest: '99.0.0' }), { status: 200 }),
3819
+ }
3820
+
3821
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
3822
+ expect(io.outLines).toContain('A newer Notifai is available.')
3823
+ expect(io.outLines).toContain('npm install -g @raidiant/notifai')
3824
+ })
3177
3825
  })
3178
3826
 
3179
3827
  describe('compatibility-first update guidance', () => {
@@ -3421,7 +4069,6 @@ describe('init', () => {
3421
4069
  ): EvidenceSnapshot {
3422
4070
  return {
3423
4071
  request_id: requestId,
3424
- event: 'setup_verified',
3425
4072
  accepted_at: '2026-08-05T18:00:00.000Z',
3426
4073
  overall: 'provider_accepted_all',
3427
4074
  deliveries: [
@@ -3659,18 +4306,22 @@ describe('init', () => {
3659
4306
  } as unknown as ApiClient
3660
4307
  const nativeSkills: NativeSkills = {
3661
4308
  add: async () => 0,
4309
+ remove: async () => 0,
3662
4310
  list: async (scope) => ({
3663
- skills: [
3664
- {
3665
- name: 'notifai',
3666
- scope,
3667
- path: path.join(cwd, '.agents', 'skills', 'notifai'),
3668
- source: 'Raidiant-io/notifai',
3669
- sourceType: 'github',
3670
- sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
3671
- ref: RELEASE_REF,
3672
- },
3673
- ],
4311
+ skills:
4312
+ scope === 'project'
4313
+ ? [
4314
+ {
4315
+ name: 'notifai',
4316
+ scope,
4317
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
4318
+ source: 'Raidiant-io/notifai',
4319
+ sourceType: 'github',
4320
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
4321
+ ref: RELEASE_REF,
4322
+ },
4323
+ ]
4324
+ : [],
3674
4325
  }),
3675
4326
  }
3676
4327
  const readiness = await assessReadiness(
@@ -3696,9 +4347,10 @@ describe('init', () => {
3696
4347
  const calls: SkillScope[] = []
3697
4348
  const nativeSkills: NativeSkills = {
3698
4349
  add: async () => 0,
4350
+ remove: async () => 0,
3699
4351
  list: async (selected) => {
3700
4352
  calls.push(selected)
3701
- return { skills: [managedSkill(selected, cwd)] }
4353
+ return { skills: selected === scope ? [managedSkill(selected, cwd)] : [] }
3702
4354
  },
3703
4355
  }
3704
4356
 
@@ -3710,7 +4362,7 @@ describe('init', () => {
3710
4362
  status: 'ready',
3711
4363
  detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
3712
4364
  })
3713
- expect(calls).toEqual([scope])
4365
+ expect(calls).toEqual(['project', 'global'])
3714
4366
  },
3715
4367
  )
3716
4368
 
@@ -3726,8 +4378,12 @@ describe('init', () => {
3726
4378
  } as unknown as ApiClient
3727
4379
  const nativeSkills: NativeSkills = {
3728
4380
  add: async () => 0,
4381
+ remove: async () => 0,
3729
4382
  list: async (selected) => ({
3730
- skills: [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }],
4383
+ skills:
4384
+ selected === scope
4385
+ ? [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }]
4386
+ : [],
3731
4387
  }),
3732
4388
  }
3733
4389
 
@@ -3742,6 +4398,192 @@ describe('init', () => {
3742
4398
  },
3743
4399
  )
3744
4400
 
4401
+ it('reports a duplicate notifai skill across project and global scope', async () => {
4402
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-duplicate-'))
4403
+ const io = new CapturedIo()
4404
+ const client = {
4405
+ health: async () => true,
4406
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4407
+ listDevices: async () => ({ devices: [] }),
4408
+ } as unknown as ApiClient
4409
+ const nativeSkills: NativeSkills = {
4410
+ add: async () => 0,
4411
+ remove: async () => 0,
4412
+ list: async (scope) => ({
4413
+ skills: [
4414
+ {
4415
+ ...managedSkill(scope, cwd),
4416
+ ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
4417
+ path:
4418
+ scope === 'project'
4419
+ ? path.join(cwd, '.agents', 'skills', 'notifai')
4420
+ : path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
4421
+ },
4422
+ ],
4423
+ }),
4424
+ }
4425
+
4426
+ const readiness = await assessReadiness({ ...makeDeps(io, client), cwd, nativeSkills })
4427
+ const skill = readiness.states.find((state) => state.id === 'skill')
4428
+ expect(skill).toMatchObject({
4429
+ status: 'gap',
4430
+ detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
4431
+ technical: {
4432
+ project: {
4433
+ ref: RELEASE_REF,
4434
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
4435
+ current: true,
4436
+ },
4437
+ global: {
4438
+ ref: 'v0.2.1',
4439
+ path: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
4440
+ current: false,
4441
+ },
4442
+ resolution: 'both-listed',
4443
+ },
4444
+ })
4445
+
4446
+ expect(await doctorCommand({ ...makeDeps(io, client), cwd, nativeSkills }, { json: true })).toBe(
4447
+ EXIT.failed,
4448
+ )
4449
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as {
4450
+ ok: boolean
4451
+ states: Array<{ id: string; status: string; detail: string }>
4452
+ }
4453
+ expect(payload.ok).toBe(false)
4454
+ expect(payload.states.find((state) => state.id === 'skill')).toMatchObject({
4455
+ status: 'gap',
4456
+ detail: expect.stringContaining('harness lists both'),
4457
+ })
4458
+ })
4459
+
4460
+ it('does not treat a current pin as ready when a stale duplicate is also installed', async () => {
4461
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-shadow-'))
4462
+ const io = new CapturedIo()
4463
+ const client = {
4464
+ health: async () => true,
4465
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4466
+ listDevices: async () => ({ devices: [] }),
4467
+ } as unknown as ApiClient
4468
+ const nativeSkills: NativeSkills = {
4469
+ add: async () => 0,
4470
+ remove: async () => 0,
4471
+ list: async (scope) => ({
4472
+ skills: [
4473
+ {
4474
+ ...managedSkill(scope, cwd),
4475
+ ref: scope === 'global' ? RELEASE_REF : 'v0.2.1',
4476
+ },
4477
+ ],
4478
+ }),
4479
+ }
4480
+
4481
+ const readiness = await assessReadiness(
4482
+ { ...makeDeps(io, client), cwd, nativeSkills },
4483
+ { skillScope: 'global' },
4484
+ )
4485
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
4486
+ status: 'gap',
4487
+ detail: `project (v0.2.1) and global (${RELEASE_REF}) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
4488
+ })
4489
+ })
4490
+
4491
+ it('uninstalls the other skill scope before installing the chosen one', async () => {
4492
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-prevented-'))
4493
+ const io = new CapturedIo()
4494
+ const removed: SkillScope[] = []
4495
+ const added: Array<SkillScope | undefined> = []
4496
+ let globalPresent = true
4497
+ const nativeSkills: NativeSkills = {
4498
+ add: async (options) => {
4499
+ added.push(options.scope)
4500
+ return 0
4501
+ },
4502
+ remove: async (options) => {
4503
+ removed.push(options.scope)
4504
+ if (options.scope === 'global') globalPresent = false
4505
+ return 0
4506
+ },
4507
+ list: async (scope) => ({
4508
+ skills: scope === 'global' && globalPresent ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
4509
+ }),
4510
+ }
4511
+
4512
+ expect(
4513
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4514
+ skills: true,
4515
+ setupScope: 'project',
4516
+ hooks: false,
4517
+ }),
4518
+ ).toBe(EXIT.ok)
4519
+ expect(removed).toEqual(['global'])
4520
+ expect(added).toEqual(['project'])
4521
+ })
4522
+
4523
+ it('clears a pre-existing skill duplicate by keeping the chosen setup scope', async () => {
4524
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-clear-'))
4525
+ const io = new CapturedIo()
4526
+ const removed: SkillScope[] = []
4527
+ const present = new Set<SkillScope>(['project', 'global'])
4528
+ const nativeSkills: NativeSkills = {
4529
+ add: async (options) => {
4530
+ if (options.scope !== undefined) present.add(options.scope)
4531
+ return 0
4532
+ },
4533
+ remove: async (options) => {
4534
+ removed.push(options.scope)
4535
+ present.delete(options.scope)
4536
+ return 0
4537
+ },
4538
+ list: async (scope) => ({
4539
+ skills: present.has(scope)
4540
+ ? [
4541
+ {
4542
+ ...managedSkill(scope, cwd),
4543
+ ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
4544
+ },
4545
+ ]
4546
+ : [],
4547
+ }),
4548
+ }
4549
+
4550
+ expect(
4551
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4552
+ skills: true,
4553
+ setupScope: 'project',
4554
+ hooks: false,
4555
+ }),
4556
+ ).toBe(EXIT.ok)
4557
+ expect(removed).toEqual(['global'])
4558
+ expect([...present]).toEqual(['project'])
4559
+ })
4560
+
4561
+ it('updates the existing skill scope instead of adding a second copy', async () => {
4562
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-update-existing-'))
4563
+ const io = new InteractiveIo()
4564
+ const received: Array<SkillScope | undefined> = []
4565
+ const installed = true
4566
+ const nativeSkills: NativeSkills = {
4567
+ add: async (options) => {
4568
+ received.push(options.scope)
4569
+ return 0
4570
+ },
4571
+ remove: async () => 0,
4572
+ list: async (scope) => ({
4573
+ skills: installed && scope === 'global' ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
4574
+ }),
4575
+ }
4576
+
4577
+ expect(
4578
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4579
+ skills: true,
4580
+ hooks: false,
4581
+ }),
4582
+ ).toBe(EXIT.ok)
4583
+ expect(received).toEqual(['global'])
4584
+ expect(io.outLines.join('\n')).toContain('global scope')
4585
+ })
4586
+
3745
4587
  it('requires an explicit skill scope before unattended installation', async () => {
3746
4588
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
3747
4589
  const io = new CapturedIo()
@@ -3751,6 +4593,7 @@ describe('init', () => {
3751
4593
  addCalls += 1
3752
4594
  return 0
3753
4595
  },
4596
+ remove: async () => 0,
3754
4597
  list: async () => ({ skills: [] }),
3755
4598
  }
3756
4599
 
@@ -3758,7 +4601,7 @@ describe('init', () => {
3758
4601
  EXIT.usage,
3759
4602
  )
3760
4603
  expect(addCalls).toBe(0)
3761
- expect(io.errLines.join('\n')).toContain('--skills-scope project')
4604
+ expect(io.errLines.join('\n')).toContain('--setup-scope project')
3762
4605
  })
3763
4606
 
3764
4607
  it('rejects an invalid unattended skill scope instead of guessing', async () => {
@@ -3786,6 +4629,7 @@ describe('init', () => {
3786
4629
  installed = true
3787
4630
  return 0
3788
4631
  },
4632
+ remove: async () => 0,
3789
4633
  list: async (selected) => ({
3790
4634
  skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
3791
4635
  }),
@@ -3802,27 +4646,82 @@ describe('init', () => {
3802
4646
  },
3803
4647
  )
3804
4648
 
3805
- it('lets the native interactive flow choose scope and resumes after cancellation', async () => {
3806
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-cancelled-'))
4649
+ it('asks one setup-scope question and passes that scope to the native installer', async () => {
4650
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-setup-scope-ask-'))
3807
4651
  const io = new InteractiveIo()
4652
+ io.selectAnswer = 'project'
3808
4653
  const calls: { submit: number } = { submit: 0 }
3809
- let receivedScope: SkillScope | undefined = 'global'
4654
+ let receivedScope: SkillScope | undefined
3810
4655
  const nativeSkills: NativeSkills = {
3811
4656
  add: async (options) => {
3812
4657
  receivedScope = options.scope
3813
4658
  return 0
3814
4659
  },
4660
+ remove: async () => 0,
3815
4661
  list: async () => ({ skills: [] }),
3816
4662
  }
3817
4663
 
3818
4664
  expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
3819
4665
  EXIT.ok,
3820
4666
  )
3821
- expect(receivedScope).toBeUndefined()
4667
+ expect(io.prompts[0]).toContain('this project only, or to every project on this machine')
4668
+ expect(receivedScope).toBe('project')
3822
4669
  expect(calls.submit).toBe(1)
3823
4670
  expect(io.outLines.join('\n')).toContain('All set.')
3824
4671
  })
3825
4672
 
4673
+ it('accepts --setup-scope as the unattended skill, hooks, and config scope', async () => {
4674
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-flag-'))
4675
+ const io = new CapturedIo()
4676
+ let receivedScope: SkillScope | undefined
4677
+ const nativeSkills: NativeSkills = {
4678
+ add: async (options) => {
4679
+ receivedScope = options.scope
4680
+ return 0
4681
+ },
4682
+ remove: async () => 0,
4683
+ list: async (scope) => ({
4684
+ skills: receivedScope === scope ? [managedSkill(scope, cwd)] : [],
4685
+ }),
4686
+ }
4687
+
4688
+ expect(
4689
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4690
+ skills: true,
4691
+ setupScope: 'project',
4692
+ hooks: false,
4693
+ }),
4694
+ ).toBe(EXIT.ok)
4695
+ expect(receivedScope).toBe('project')
4696
+ expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(true)
4697
+ })
4698
+
4699
+ it('stamps project identity outside the repo when setup-scope is global', async () => {
4700
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-global-config-'))
4701
+ const io = new CapturedIo()
4702
+ const deps: CommandDeps = {
4703
+ ...makeDeps(io, {} as ApiClient),
4704
+ cwd,
4705
+ env: isolatedEnv(cwd),
4706
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
4707
+ }
4708
+
4709
+ expect(await initCommand(deps, { setupScope: 'global', skills: false, hooks: false })).toBe(EXIT.failed)
4710
+ expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
4711
+ expect(readFileSync(personalProjectConfigPath(cwd, deps.env), 'utf8')).toMatch(/project = "/)
4712
+ })
4713
+
4714
+ it('rejects disagreeing --setup-scope and --skills-scope', async () => {
4715
+ const io = new CapturedIo()
4716
+ expect(
4717
+ await initCommand(
4718
+ { ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-scope-conflict-')) },
4719
+ { skills: true, setupScope: 'project', skillsScope: 'global' },
4720
+ ),
4721
+ ).toBe(EXIT.usage)
4722
+ expect(io.errLines.join('\n')).toContain('disagree')
4723
+ })
4724
+
3826
4725
  it('installs every detected project harness when --hooks is set', async () => {
3827
4726
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
3828
4727
  mkdirSync(path.join(cwd, '.claude'))
@@ -3831,6 +4730,7 @@ describe('init', () => {
3831
4730
  const calls = { submit: 0 }
3832
4731
  const nativeSkills: NativeSkills = {
3833
4732
  add: async () => 0,
4733
+ remove: async () => 0,
3834
4734
  list: async () => ({ skills: [] }),
3835
4735
  }
3836
4736
  const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
@@ -3855,6 +4755,7 @@ describe('init', () => {
3855
4755
  const calls = { submit: 0 }
3856
4756
  const nativeSkills: NativeSkills = {
3857
4757
  add: async () => 0,
4758
+ remove: async () => 0,
3858
4759
  list: async () => ({ skills: [] }),
3859
4760
  }
3860
4761
  const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
@@ -3872,6 +4773,7 @@ describe('init', () => {
3872
4773
  const calls: { submit: number } = { submit: 0 }
3873
4774
  const nativeSkills: NativeSkills = {
3874
4775
  add: async () => 1,
4776
+ remove: async () => 0,
3875
4777
  list: async () => ({ skills: [] }),
3876
4778
  }
3877
4779
 
@@ -4012,6 +4914,7 @@ describe('init', () => {
4012
4914
  nativeSkills: {
4013
4915
  list: async () => ({ skills: [] }),
4014
4916
  add: async () => 0,
4917
+ remove: async () => 0,
4015
4918
  } satisfies NativeSkills,
4016
4919
  }
4017
4920
 
@@ -4109,7 +5012,7 @@ describe('init', () => {
4109
5012
  )
4110
5013
  expect(io.outLines.join('\n')).toContain('All set.')
4111
5014
  expect(submitCalls).toBe(1)
4112
- expect(submittedDraft?.draft.event).toBe('setup_verified')
5015
+ expect(submittedDraft?.draft).not.toHaveProperty('event')
4113
5016
  expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
4114
5017
 
4115
5018
  io.outLines = []
@@ -4267,6 +5170,52 @@ describe('init', () => {
4267
5170
  expect(io.outLines.join('\n')).toContain('All set.')
4268
5171
  })
4269
5172
 
5173
+ it('replaces a stale unknown setup proof instead of rechecking it forever', async () => {
5174
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-stale-'))
5175
+ const io = new CapturedIo()
5176
+ let submitCalls = 0
5177
+ const client = {
5178
+ health: async () => true,
5179
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5180
+ listDevices: async () => ({ devices: [readyIphone] }),
5181
+ submit: async () => {
5182
+ submitCalls += 1
5183
+ return setupReceipt('req_fresh')
5184
+ },
5185
+ evidence: async (requestId: string) =>
5186
+ setupEvidence(
5187
+ requestId,
5188
+ requestId === 'req_fresh'
5189
+ ? {
5190
+ state: 'observed',
5191
+ observed_at: '2026-08-25T00:00:02.000Z',
5192
+ latency_ms: 1_000,
5193
+ }
5194
+ : { state: 'unknown', observed_at: null, latency_ms: null },
5195
+ ),
5196
+ } as unknown as ApiClient
5197
+ const now = Date.parse('2026-08-25T00:00:00.000Z')
5198
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5199
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "stale-proof"\n')
5200
+ const deps: CommandDeps = {
5201
+ ...makeDeps(io, client),
5202
+ cwd,
5203
+ env: isolatedEnv(cwd),
5204
+ now: () => now,
5205
+ }
5206
+ writeSetupProof(deps, {
5207
+ request_id: 'req_old',
5208
+ device_id: readyIphone.device_id,
5209
+ project: 'stale-proof',
5210
+ started_at: new Date(now - SETUP_PROOF_STALE_MS - 1).toISOString(),
5211
+ })
5212
+
5213
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
5214
+ expect(submitCalls).toBe(1)
5215
+ expect(io.outLines.join('\n')).toContain('older than 24h without a Companion Receipt')
5216
+ expect(io.outLines.join('\n')).toContain('All set.')
5217
+ })
5218
+
4270
5219
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
4271
5220
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
4272
5221
  const io = new CapturedIo()
@@ -4386,42 +5335,6 @@ describe('init', () => {
4386
5335
  )
4387
5336
  })
4388
5337
 
4389
- it('stops login when the approval page reports no Alpha access', async () => {
4390
- const io = new CapturedIo()
4391
- let now = 0
4392
- let polls = 0
4393
- const client = {
4394
- beginPairing: async () => ({
4395
- pairing_id: 'pair_no_plan',
4396
- code: 'NOPE-PLAN',
4397
- approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
4398
- expires_at: new Date(60_000).toISOString(),
4399
- poll_interval_seconds: 1,
4400
- }),
4401
- pollPairing: async () => {
4402
- polls += 1
4403
- return {
4404
- status: 'no_active_plan' as const,
4405
- next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
4406
- }
4407
- },
4408
- } as unknown as ApiClient
4409
- const deps = {
4410
- ...makeDeps(io, client),
4411
- now: () => now,
4412
- sleep: async (milliseconds: number) => {
4413
- now += milliseconds
4414
- },
4415
- }
4416
-
4417
- expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
4418
- expect(polls).toBe(1)
4419
- expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
4420
- expect(io.errLines.join('\n')).toContain(
4421
- 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
4422
- )
4423
- })
4424
-
4425
5338
  it('treats a revoked credential as the one blocker and points back to pairing', async () => {
4426
5339
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
4427
5340
  const io = new CapturedIo()
@@ -4600,6 +5513,54 @@ describe('readiness assessment cost', () => {
4600
5513
  detail: `installed from ${SKILLS_SOURCE} in the project scope`,
4601
5514
  })
4602
5515
  })
5516
+
5517
+ it('detects a lock-file duplicate across project and global scope', async () => {
5518
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-duplicate-'))
5519
+ const env = { ...isolatedEnv(cwd), PATH: '/nonexistent' }
5520
+ writeFileSync(
5521
+ path.join(cwd, 'skills-lock.json'),
5522
+ `${JSON.stringify({
5523
+ skills: {
5524
+ notifai: {
5525
+ source: 'Raidiant-io/notifai',
5526
+ sourceType: 'github',
5527
+ ref: RELEASE_REF,
5528
+ },
5529
+ },
5530
+ })}\n`,
5531
+ )
5532
+ mkdirSync(path.join(env.XDG_STATE_HOME!, 'skills'), { recursive: true })
5533
+ writeFileSync(
5534
+ path.join(env.XDG_STATE_HOME!, 'skills', '.skill-lock.json'),
5535
+ `${JSON.stringify({
5536
+ skills: {
5537
+ notifai: {
5538
+ source: 'Raidiant-io/notifai',
5539
+ sourceType: 'github',
5540
+ ref: 'v0.2.1',
5541
+ },
5542
+ },
5543
+ })}\n`,
5544
+ )
5545
+ const io = new CapturedIo()
5546
+ const client = {
5547
+ health: async () => true,
5548
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5549
+ listDevices: async () => ({ devices: [] }),
5550
+ } as unknown as ApiClient
5551
+
5552
+ const readiness = await assessReadiness({
5553
+ ...makeDeps(io, client),
5554
+ cwd,
5555
+ env,
5556
+ nativeSkills: realNativeSkills,
5557
+ })
5558
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
5559
+ status: 'gap',
5560
+ detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
5561
+ technical: { resolution: 'both-listed' },
5562
+ })
5563
+ })
4603
5564
  })
4604
5565
 
4605
5566
  describe('an outage is not an answer', () => {
@@ -4800,9 +5761,7 @@ describe('asking before the hooks have ever run', () => {
4800
5761
  io.outLines = []
4801
5762
 
4802
5763
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
4803
- expect(io.outLines).toContain(
4804
- 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
4805
- )
5764
+ expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
4806
5765
  expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
4807
5766
  session_id: 'codex-current-thread',
4808
5767
  harness: 'codex',
@@ -5024,7 +5983,7 @@ describe('asking before the hooks have ever run', () => {
5024
5983
  const fired = readiness.states.find((state) => state.id === 'hooks-fired')
5025
5984
  expect(fired?.status).toBe('optional-gap')
5026
5985
  expect(fired?.detail).toMatch(/Claude Code/)
5027
- expect(fired?.detail).toMatch(/Codex: send one prompt/)
5986
+ expect(fired?.detail).toMatch(/Codex: approve[\s\S]*start one fresh session/i)
5028
5987
 
5029
5988
  io.errLines = []
5030
5989
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
@@ -5144,6 +6103,7 @@ describe('asking before the hooks have ever run', () => {
5144
6103
  } as unknown as ApiClient
5145
6104
  const nativeSkills: NativeSkills = {
5146
6105
  add: async () => 0,
6106
+ remove: async () => 0,
5147
6107
  list: async (scope) => ({
5148
6108
  skills:
5149
6109
  scope === 'global'
@@ -5447,6 +6407,28 @@ describe('asking before the hooks have ever run', () => {
5447
6407
  expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
5448
6408
  })
5449
6409
 
6410
+ it('diagnoses an older hook install that lacks proactive lifecycle handlers', async () => {
6411
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-missing-lifecycle-'))
6412
+ const io = new PlainInteractiveIo()
6413
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
6414
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6415
+ EXIT.ok,
6416
+ )
6417
+ const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
6418
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
6419
+ hooks: Record<string, unknown>
6420
+ }
6421
+ delete settings.hooks.SessionStart
6422
+ delete settings.hooks.SubagentStart
6423
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
6424
+
6425
+ const readiness = await assessReadiness(deps)
6426
+ const current = readiness.states.find((state) => state.id === 'hooks-stale')
6427
+ expect(current?.status).toBe('gap')
6428
+ expect(current?.detail).toMatch(/missing session-start, subagent-start/i)
6429
+ expect(current?.detail).toMatch(/notifai hooks install/i)
6430
+ })
6431
+
5450
6432
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
5451
6433
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
5452
6434
  const io = new PlainInteractiveIo()
@@ -5598,7 +6580,7 @@ describe('asking before the hooks have ever run', () => {
5598
6580
  expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
5599
6581
  })
5600
6582
 
5601
- it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
6583
+ it('tells Claude Code to start fresh so activation is model-visible', () => {
5602
6584
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
5603
6585
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
5604
6586
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
@@ -5612,11 +6594,11 @@ describe('asking before the hooks have ever run', () => {
5612
6594
 
5613
6595
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5614
6596
  const said = io.errLines.join(' ')
5615
- expect(said).toMatch(/project hook files reload without a restart/i)
6597
+ expect(said).toMatch(/Claude Code: start one fresh session, send one prompt/i)
5616
6598
  expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
5617
6599
  })
5618
6600
 
5619
- it('gives Cursor its prompt-and-doctor activation path', () => {
6601
+ it('gives Cursor its fresh-conversation and first-Stop activation path', () => {
5620
6602
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
5621
6603
  const io = new CapturedIo()
5622
6604
  const deps = {
@@ -5629,7 +6611,7 @@ describe('asking before the hooks have ever run', () => {
5629
6611
  io.outLines = []
5630
6612
 
5631
6613
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5632
- expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
6614
+ expect(io.errLines.join(' ')).toMatch(/Cursor: start one fresh conversation[\s\S]*finish its first turn[\s\S]*`notifai doctor`/)
5633
6615
  })
5634
6616
 
5635
6617
  it('keeps OpenCode activation separate from unsupported answer continuation', () => {