@raidiant/notifai 8.0.0 → 9.1.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 (168) hide show
  1. package/CHANGELOG.md +48 -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/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. 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
- import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
73
- import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
75
+ import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.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,131 @@ describe('command contracts', () => {
573
585
  expect(io.errLines.join('\n')).toContain('project')
574
586
  })
575
587
 
588
+ it('persists one opaque retry attempt 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('exact semantic send with `--retry`')
615
+ expect(io.errLines.join('\n')).toMatch(/Opaque retry attempt: sat_/)
616
+ expect(
617
+ readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
618
+ ).toMatchObject({
619
+ idempotency_key: attemptedKey,
620
+ attempt_id: expect.stringMatching(/^sat_/),
621
+ replay: false,
622
+ })
623
+ rmSync(root, { recursive: true, force: true })
624
+ })
625
+
626
+ it('prints an opaque retry instruction for an ambiguous server failure', async () => {
627
+ const io = new CapturedIo()
628
+ const client = {
629
+ submit: async () => {
630
+ throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
631
+ },
632
+ } as unknown as ApiClient
633
+
634
+ expect(
635
+ await sendCommand(makeDeps(io, client), {
636
+ kind: 'failed',
637
+ title: 'Validation could not finish',
638
+ body: 'The service failed while accepting the result.',
639
+ }),
640
+ ).toBe(EXIT.network)
641
+ expect(io.errLines.join('\n')).toContain('exact semantic send with `--retry`')
642
+ })
643
+
644
+ it('reuses the opaque attempt key only after an explicit exact semantic retry', async () => {
645
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-retry-'))
646
+ const io = new CapturedIo()
647
+ const keys: string[] = []
648
+ let calls = 0
649
+ const client = {
650
+ submit: async (body: SubmitNotificationRequestT) => {
651
+ keys.push(body.idempotency_key)
652
+ calls += 1
653
+ if (calls === 1) throw new NetworkError('killed after an ambiguous boundary')
654
+ return { ...receipt, replayed: true }
655
+ },
656
+ } as unknown as ApiClient
657
+ const deps = {
658
+ ...makeDeps(io, client),
659
+ cwd: root,
660
+ env: { XDG_CONFIG_HOME: path.join(root, 'config'), XDG_STATE_HOME: path.join(root, 'state') },
661
+ }
662
+ const semantic = { kind: 'done', title: 'Recovery completed', body: 'The exact work is done.' }
663
+
664
+ expect(await sendCommand(deps, semantic)).toBe(EXIT.network)
665
+ expect(await sendCommand(deps, { ...semantic, retry: true })).toBe(EXIT.ok)
666
+ expect(keys).toHaveLength(2)
667
+ expect(keys[1]).toBe(keys[0])
668
+ expect(await sendCommand(deps, { ...semantic, retry: true })).toBe(EXIT.usage)
669
+ })
670
+
671
+ it('routes a send with no active Companion devices into the setup coordinator', async () => {
672
+ const io = new CapturedIo()
673
+ const client = {
674
+ submit: async () => {
675
+ throw new ApiCallError(
676
+ 409,
677
+ 'no_active_devices',
678
+ 'No enabled active Device Installation can receive this request.',
679
+ 'Open the Companion App and enable notifications.',
680
+ )
681
+ },
682
+ } as unknown as ApiClient
683
+
684
+ expect(
685
+ await sendCommand(makeDeps(io, client), {
686
+ kind: 'done',
687
+ title: 'Checks finished',
688
+ body: 'All checks passed.',
689
+ }),
690
+ ).toBe(EXIT.failed)
691
+
692
+ expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
693
+ expect(io.errLines.join('\n')).toContain('exact original Notification Request with `--retry`')
694
+ expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
695
+ })
696
+
697
+ it('routes a send from an unpaired machine into the same setup coordinator', async () => {
698
+ const io = new CapturedIo()
699
+ const deps = makeDeps(io, {} as ApiClient)
700
+ deps.store.load = () => null
701
+
702
+ expect(
703
+ await sendCommand(deps, {
704
+ kind: 'done',
705
+ title: 'Checks finished',
706
+ body: 'All checks passed.',
707
+ }),
708
+ ).toBe(EXIT.auth)
709
+
710
+ expect(io.errLines.join('\n')).toMatch(/`notifai init`.*login.*device setup/i)
711
+ })
712
+
576
713
  it('authors and validates an Android-specific Notification Request', async () => {
577
714
  const io = new CapturedIo()
578
715
  let submitted: SubmitNotificationRequestT | undefined
@@ -1032,11 +1169,13 @@ describe('command contracts', () => {
1032
1169
  expect(grammar).not.toContain(".option('--detail")
1033
1170
  expect(grammar).not.toContain(".option('--session <")
1034
1171
  expect(grammar).toContain(".option('--body-file")
1035
- expect(grammar).toContain(".option('--session-id")
1036
1172
  expect(grammar).toContain("'--session-label <text>'")
1037
1173
  expect(grammar).toContain("'--image <path|url|media_id>'")
1038
1174
  expect(grammar).toContain("'--image-alt <text>'")
1039
1175
  }
1176
+ expect(sendGrammar).toContain(".option('--session-id")
1177
+ expect(sendGrammar).toContain(".option('--retry'")
1178
+ expect(askGrammar).not.toContain(".option('--session-id")
1040
1179
  // Kind now selects the sound, so the help must say so — and must not carry
1041
1180
  // the retired separation it replaced.
1042
1181
  expect(source).toContain('Kind is required, and it selects the sound')
@@ -1209,8 +1348,11 @@ describe('command contracts', () => {
1209
1348
  request_id: receipt.request_id,
1210
1349
  sequence: reply.seq,
1211
1350
  device: reply.device_name,
1212
- text: reply.text,
1351
+ text_chars: reply.text.length,
1352
+ answers_count: reply.answers.length,
1213
1353
  })
1354
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
1355
+ expect(raw).not.toContain(reply.text)
1214
1356
  })
1215
1357
 
1216
1358
  it('backs off and retries a transient network error while waiting', async () => {
@@ -1501,9 +1643,13 @@ describe('command contracts', () => {
1501
1643
  'acknowledgement.attempted',
1502
1644
  'acknowledgement.outcome',
1503
1645
  ])
1646
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
1647
+ expect(raw).not.toContain('I will deploy staging now.')
1648
+ expect(events[0]?.data).toMatchObject({ characters: 'I will deploy staging now.'.length })
1649
+ expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
1504
1650
  })
1505
1651
 
1506
- it('clears a matching active-session obligation after acknowledgement success', async () => {
1652
+ it('clears the stable-id session obligation after acknowledgement from another checkout', async () => {
1507
1653
  const io = new CapturedIo()
1508
1654
  const client = {
1509
1655
  putAgentAcknowledgement: async () => ({
@@ -1526,6 +1672,7 @@ describe('command contracts', () => {
1526
1672
  acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
1527
1673
  })
1528
1674
  writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
1675
+ deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-other-checkout-'))
1529
1676
 
1530
1677
  expect(
1531
1678
  await acknowledgeCommand(deps, receipt.request_id, {
@@ -1719,6 +1866,114 @@ describe('command contracts', () => {
1719
1866
  })
1720
1867
  })
1721
1868
 
1869
+ it('close --pending withdraws an unpushed registration so a later Stop cannot send it', async () => {
1870
+ const io = new CapturedIo()
1871
+ const client = {
1872
+ closeReplies: async () => {
1873
+ throw new Error('unpushed questions must not call the server')
1874
+ },
1875
+ } as unknown as ApiClient
1876
+ const deps = makeDeps(io, client)
1877
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-unpushed-'))
1878
+ deps.cwd = root
1879
+ deps.env = {
1880
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1881
+ XDG_STATE_HOME: path.join(root, 'state'),
1882
+ }
1883
+ writeSessionState('close-unpushed', deps.env, {
1884
+ pending: [{ question: 'Ship it?', question_id: 'q_local' }],
1885
+ })
1886
+ writeProjectSession(root, deps.env, 'close-unpushed', Date.now(), 'codex')
1887
+
1888
+ expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
1889
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1890
+ session_id: 'close-unpushed',
1891
+ withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
1892
+ closed: [],
1893
+ })
1894
+ expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
1895
+ })
1896
+
1897
+ it('close withdraws one unpushed question by stable id from another checkout', async () => {
1898
+ const io = new CapturedIo()
1899
+ const client = {
1900
+ closeReplies: async () => {
1901
+ throw new Error('unpushed questions must not call the server')
1902
+ },
1903
+ } as unknown as ApiClient
1904
+ const deps = makeDeps(io, client)
1905
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-one-unpushed-'))
1906
+ deps.cwd = root
1907
+ deps.env = {
1908
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1909
+ XDG_STATE_HOME: path.join(root, 'state'),
1910
+ }
1911
+ writeSessionState('close-one', deps.env, {
1912
+ pending: [
1913
+ { question: 'Ship it?', question_id: 'q_local' },
1914
+ { question: 'Keep this?', question_id: 'q_keep' },
1915
+ ],
1916
+ })
1917
+ writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
1918
+ deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-other-checkout-'))
1919
+
1920
+ expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
1921
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1922
+ session_id: 'close-one',
1923
+ withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
1924
+ closed: [],
1925
+ })
1926
+ expect(readSessionState('close-one', deps.env).pending?.map((entry) => entry.question_id)).toEqual([
1927
+ 'q_keep',
1928
+ ])
1929
+ })
1930
+
1931
+ it('close --pending closes live questions and leaves nothing for a later Stop to push', async () => {
1932
+ const io = new CapturedIo()
1933
+ const closed: string[] = []
1934
+ const client = {
1935
+ closeReplies: async (requestId: string) => {
1936
+ closed.push(requestId)
1937
+ return replyResponse([])
1938
+ },
1939
+ } as unknown as ApiClient
1940
+ const deps = makeDeps(io, client)
1941
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-pending-mix-'))
1942
+ deps.cwd = root
1943
+ deps.env = {
1944
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1945
+ XDG_STATE_HOME: path.join(root, 'state'),
1946
+ }
1947
+ writeSessionState('close-mix', deps.env, {
1948
+ pending: [
1949
+ { question: 'Unpushed?', question_id: 'q_local' },
1950
+ {
1951
+ question: 'Already on a device?',
1952
+ request_id: receipt.request_id,
1953
+ collapse_key: 'question-live',
1954
+ device_ids: ['dev_iphone'],
1955
+ },
1956
+ ],
1957
+ })
1958
+ writeProjectSession(root, deps.env, 'close-mix', Date.now(), 'codex')
1959
+
1960
+ expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
1961
+ expect(closed).toEqual([receipt.request_id])
1962
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1963
+ session_id: 'close-mix',
1964
+ withdrawn: [{ question: 'Unpushed?', question_id: 'q_local' }],
1965
+ closed: [receipt.request_id],
1966
+ })
1967
+ expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
1968
+ })
1969
+
1970
+ it('close requires a request id or --pending', async () => {
1971
+ const io = new CapturedIo()
1972
+ const client = {} as unknown as ApiClient
1973
+ expect(await closeCommand(makeDeps(io, client), undefined, {})).toBe(EXIT.usage)
1974
+ expect(io.errLines.join('\n')).toContain('Pass a request id or --pending.')
1975
+ })
1976
+
1722
1977
  it('passes the replies cursor and prints replies for later retrieval', async () => {
1723
1978
  const io = new CapturedIo()
1724
1979
  let requested: { waitSeconds: number; afterSeq: number } | undefined
@@ -1746,7 +2001,7 @@ describe('command contracts', () => {
1746
2001
  expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
1747
2002
  })
1748
2003
 
1749
- it('resolves replies --pending through the project session pointer and prints its id', async () => {
2004
+ it('resolves replies --pending by exact session from another checkout', async () => {
1750
2005
  const io = new CapturedIo()
1751
2006
  const client = {
1752
2007
  replies: async () => replyResponse([reply]),
@@ -1757,11 +2012,15 @@ describe('command contracts', () => {
1757
2012
  deps.env = {
1758
2013
  XDG_CONFIG_HOME: path.join(root, 'config'),
1759
2014
  XDG_STATE_HOME: path.join(root, 'state'),
2015
+ CODEX_THREAD_ID: 'pending-session',
1760
2016
  }
1761
2017
  writeSessionState('pending-session', deps.env, {
2018
+ harness: 'codex',
2019
+ last_prompt_at: Date.now(),
1762
2020
  pending: [{ question: 'Deploy?', request_id: receipt.request_id }],
1763
2021
  })
1764
2022
  writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
2023
+ deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-other-checkout-'))
1765
2024
 
1766
2025
  expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
1767
2026
  expect(io.outLines).toEqual([
@@ -1797,8 +2056,9 @@ describe('command contracts', () => {
1797
2056
  writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
1798
2057
 
1799
2058
  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')
2059
+ expect(io.outLines[0]).toBe('unpushed question: Not yet asked?')
2060
+ expect(io.outLines.join('\n')).toContain('pending request req_first')
2061
+ expect(io.outLines.join('\n')).toContain('yes, after the migration')
1802
2062
  expect(io.outLines.join('\n')).toContain('req_second')
1803
2063
  })
1804
2064
 
@@ -1995,13 +2255,188 @@ describe('command contracts', () => {
1995
2255
  })
1996
2256
  })
1997
2257
 
2258
+ describe('credential origin pinning', () => {
2259
+ it('never sends the machine bearer to a flag or env origin override', async () => {
2260
+ const io = new CapturedIo()
2261
+ const seen: { baseUrl: string; bearer: string | null }[] = []
2262
+ const client = {
2263
+ submit: async () => receipt,
2264
+ } as unknown as ApiClient
2265
+ const deps = makeDeps(io, client)
2266
+ deps.env = { ...deps.env, NOTIFAI_BASE_URL: 'https://attacker.example' }
2267
+ deps.clientFactory = (baseUrl, bearer) => {
2268
+ seen.push({ baseUrl, bearer })
2269
+ return withCompatibilityDefaults(client)
2270
+ }
2271
+
2272
+ expect(
2273
+ await sendCommand(deps, {
2274
+ kind: 'update',
2275
+ title: 'T',
2276
+ body: 'B',
2277
+ baseUrl: 'https://attacker.flag',
2278
+ }),
2279
+ ).toBe(EXIT.ok)
2280
+
2281
+ expect(seen).toEqual([
2282
+ {
2283
+ baseUrl: 'https://test.notifai.invalid',
2284
+ bearer: 'Bearer nfm_mac_test.test-secret',
2285
+ },
2286
+ ])
2287
+ expect(io.errLines.join('\n')).toContain('Ignoring base_url from flag')
2288
+ expect(io.errLines.join('\n')).not.toContain('test-secret')
2289
+ expect(io.errLines.join('\n')).not.toContain('nfm_')
2290
+ expect(io.errLines.join('\n')).not.toContain('https://attacker.example')
2291
+ expect(io.errLines.join('\n')).not.toContain('https://attacker.flag')
2292
+ })
2293
+
2294
+ it('lets unsigned-in login target an origin override', async () => {
2295
+ const io = new CapturedIo()
2296
+ const seen: { baseUrl: string; bearer: string | null }[] = []
2297
+ let beginBody: Parameters<ApiClient['beginPairing']>[0] | null = null
2298
+ let now = 0
2299
+ const client = {
2300
+ beginPairing: async (body: Parameters<ApiClient['beginPairing']>[0]) => {
2301
+ beginBody = body
2302
+ return {
2303
+ pairing_id: 'pair_test',
2304
+ code: 'ABCD-EFGH',
2305
+ approve_url: 'https://selfhost.example/pair/ABCD-EFGH',
2306
+ expires_at: new Date(10_000).toISOString(),
2307
+ poll_interval_seconds: 1,
2308
+ }
2309
+ },
2310
+ pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
2311
+ } as unknown as ApiClient
2312
+ const saved: { baseUrl?: string } = {}
2313
+ const deps: CommandDeps = {
2314
+ ...makeDeps(io, client),
2315
+ now: () => now,
2316
+ sleep: async (milliseconds: number) => {
2317
+ now += milliseconds
2318
+ },
2319
+ store: {
2320
+ load: () => null,
2321
+ save: (credential) => {
2322
+ saved.baseUrl = credential.baseUrl
2323
+ },
2324
+ clear: () => {},
2325
+ describe: () => 'test credential store',
2326
+ },
2327
+ clientFactory: (baseUrl, bearer) => {
2328
+ seen.push({ baseUrl, bearer })
2329
+ return withCompatibilityDefaults(client)
2330
+ },
2331
+ }
2332
+
2333
+ expect(await loginCommand(deps, { baseUrl: 'https://selfhost.example', open: false })).toBe(
2334
+ EXIT.ok,
2335
+ )
2336
+ expect(seen[0]).toEqual({ baseUrl: 'https://selfhost.example', bearer: null })
2337
+ expect(saved.baseUrl).toBe('https://selfhost.example')
2338
+ expect(beginBody).toMatchObject({
2339
+ machine_name: expect.any(String),
2340
+ credential_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2341
+ poll_verifier_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2342
+ confirmation_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2343
+ })
2344
+ expect(io.outLines[1]).toMatch(
2345
+ /^Approve this machine at: https:\/\/selfhost\.example\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
2346
+ )
2347
+ })
2348
+
2349
+ it('stops login when the approving account has no product access', async () => {
2350
+ const io = new CapturedIo()
2351
+ let now = 0
2352
+ const client = {
2353
+ beginPairing: async () => ({
2354
+ pairing_id: 'pair_test',
2355
+ code: 'ABCD-EFGH',
2356
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2357
+ expires_at: new Date(10_000).toISOString(),
2358
+ poll_interval_seconds: 1,
2359
+ }),
2360
+ pollPairing: async () => ({
2361
+ status: 'no_active_plan',
2362
+ next_action: 'Ask the account owner for Alpha access.',
2363
+ }),
2364
+ } as unknown as ApiClient
2365
+ const deps: CommandDeps = {
2366
+ ...makeDeps(io, client),
2367
+ now: () => now,
2368
+ sleep: async (milliseconds: number) => {
2369
+ now += milliseconds
2370
+ },
2371
+ }
2372
+
2373
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
2374
+ expect(io.errLines).toEqual([
2375
+ 'This account has no active plan or temporary Alpha access.',
2376
+ 'next: Ask the account owner for Alpha access.',
2377
+ 'After access is granted, run `notifai login` again.',
2378
+ ])
2379
+ })
2380
+
2381
+ it('keeps notification titles out of send logs', async () => {
2382
+ const io = new CapturedIo()
2383
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-log-'))
2384
+ const client = { submit: async () => receipt } as unknown as ApiClient
2385
+ const deps = makeDeps(io, client)
2386
+ deps.env = {
2387
+ XDG_CONFIG_HOME: path.join(root, 'config'),
2388
+ XDG_STATE_HOME: path.join(root, 'state'),
2389
+ }
2390
+ deps.logger = createLogger({ env: deps.env, cmd: 'send', settings: { level: 'debug' } })
2391
+
2392
+ expect(
2393
+ await sendCommand(deps, {
2394
+ kind: 'update',
2395
+ title: 'SECRET_SEND_TITLE',
2396
+ body: 'SECRET_SEND_BODY',
2397
+ }),
2398
+ ).toBe(EXIT.ok)
2399
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
2400
+ expect(raw).not.toContain('SECRET_SEND_TITLE')
2401
+ expect(raw).not.toContain('SECRET_SEND_BODY')
2402
+ const submitted = readLogRecords(deps.env, { event: ['send.submitted'] }).records
2403
+ expect(submitted[0]?.data).toMatchObject({
2404
+ request_id: receipt.request_id,
2405
+ title_chars: 'SECRET_SEND_TITLE'.length,
2406
+ })
2407
+ })
2408
+
2409
+ it('refuses a shell-escaped backslash-n body unless the literal flag is set', async () => {
2410
+ const io = new CapturedIo()
2411
+ expect(
2412
+ await sendCommand(makeDeps(io, {} as ApiClient), {
2413
+ kind: 'update',
2414
+ title: 'Escaped',
2415
+ body: 'line1\\nline2',
2416
+ }),
2417
+ ).toBe(EXIT.usage)
2418
+ expect(io.errLines.join('\n')).toContain('--body-file -')
2419
+ expect(io.errLines.join('\n')).toContain('--literal-backslash-n')
2420
+
2421
+ io.errLines = []
2422
+ const client = { submit: async () => receipt } as unknown as ApiClient
2423
+ expect(
2424
+ await sendCommand(makeDeps(io, client), {
2425
+ kind: 'update',
2426
+ title: 'Escaped',
2427
+ body: 'line1\\nline2',
2428
+ literalBackslashN: true,
2429
+ }),
2430
+ ).toBe(EXIT.ok)
2431
+ })
2432
+ })
2433
+
1998
2434
  describe('delivery evidence status', () => {
1999
2435
  function snapshot(
2000
2436
  companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
2001
2437
  ): EvidenceSnapshot {
2002
2438
  return {
2003
2439
  request_id: 'req_status_test',
2004
- event: 'tests_passed',
2005
2440
  accepted_at: '2026-08-05T13:05:48.000Z',
2006
2441
  overall: 'provider_accepted_all',
2007
2442
  deliveries: [
@@ -2097,18 +2532,65 @@ describe('delivery evidence status', () => {
2097
2532
  expect(said).toContain('Reply received: yes')
2098
2533
  expect(said).toContain('answers are on the server')
2099
2534
  })
2100
- })
2101
-
2102
- describe('Cursor hook commands', () => {
2103
- const execPath = process.execPath
2104
- const scriptPath = fileURLToPath(import.meta.url)
2105
-
2106
- it('installs native Cursor hooks with bounded chained answer continuations', () => {
2107
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
2108
- const io = new CapturedIo()
2109
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2110
2535
 
2111
- expect(
2536
+ it('treats an ordinary status Companion Receipt as doctor delivery proof', async () => {
2537
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-status-proof-'))
2538
+ const evidence = snapshot({
2539
+ state: 'observed',
2540
+ observed_at: '2026-08-05T13:17:17.000Z',
2541
+ latency_ms: 1_000,
2542
+ })
2543
+ const device = {
2544
+ device_id: evidence.deliveries[0]!.device_id,
2545
+ display_name: 'iPhone',
2546
+ platform: 'ios' as const,
2547
+ permission_status: 'authorized',
2548
+ registration_healthy: true,
2549
+ app_version: '0.1.0',
2550
+ app_build: '42',
2551
+ os_version: '19.0',
2552
+ capabilities: ['answer'] as const,
2553
+ support: currentSupport,
2554
+ support_state: 'current' as const,
2555
+ derived_status: 'working' as const,
2556
+ status_message: null,
2557
+ last_seen_at: '2026-08-05T18:00:00.000Z',
2558
+ }
2559
+ const client = {
2560
+ health: async () => true,
2561
+ listDevices: async () => ({ devices: [device] }),
2562
+ accessStatus: async () => ({
2563
+ status: 'active',
2564
+ reason: 'alpha_grant',
2565
+ expires_at: null,
2566
+ email: 'proof@example.com',
2567
+ }),
2568
+ evidence: async () => evidence,
2569
+ } as unknown as ApiClient
2570
+ const io = new CapturedIo()
2571
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
2572
+
2573
+ expect(await statusCommand(deps, evidence.request_id, { json: true })).toBe(EXIT.ok)
2574
+
2575
+ const doctorIo = new CapturedIo()
2576
+ expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.ok)
2577
+ const report = JSON.parse(doctorIo.outLines.join('\n')) as {
2578
+ states: { id: string; status: string }[]
2579
+ }
2580
+ expect(report.states.find((state) => state.id === 'proof')).toMatchObject({ status: 'ready' })
2581
+ })
2582
+ })
2583
+
2584
+ describe('Cursor hook commands', () => {
2585
+ const execPath = process.execPath
2586
+ const scriptPath = fileURLToPath(import.meta.url)
2587
+
2588
+ it('installs native Cursor hooks with bounded chained answer continuations', () => {
2589
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
2590
+ const io = new CapturedIo()
2591
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2592
+
2593
+ expect(
2112
2594
  hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
2113
2595
  ).toBe(EXIT.ok)
2114
2596
 
@@ -2122,12 +2604,17 @@ describe('Cursor hook commands', () => {
2122
2604
  expect(Object.keys(installed.hooks).sort()).toEqual([
2123
2605
  'beforeSubmitPrompt',
2124
2606
  'sessionEnd',
2607
+ 'sessionStart',
2125
2608
  'stop',
2126
2609
  ])
2127
2610
  expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
2128
2611
  'hook user-prompt-submit --owner notifai --harness cursor',
2129
2612
  )
2130
2613
  expect(installed.hooks['stop']?.[0]).toMatchObject({
2614
+ command: expect.stringContaining('hook activation-stop --owner notifai --harness cursor'),
2615
+ loop_limit: 1,
2616
+ })
2617
+ expect(installed.hooks['stop']?.[1]).toMatchObject({
2131
2618
  command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
2132
2619
  loop_limit: 3,
2133
2620
  })
@@ -2163,7 +2650,7 @@ describe('Cursor hook commands', () => {
2163
2650
  expect(io.outLines).toContain(
2164
2651
  `ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
2165
2652
  )
2166
- expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
2653
+ expect(io.outLines.some((line) => line.includes('Cursor: start one fresh conversation'))).toBe(true)
2167
2654
  })
2168
2655
 
2169
2656
  it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
@@ -2242,13 +2729,7 @@ describe('Codex hook representation', () => {
2242
2729
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
2243
2730
  })
2244
2731
 
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', () => {
2732
+ it('joins a populated hooks.json without introducing a second representation', () => {
2252
2733
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
2253
2734
  const layer = path.join(cwd, '.codex')
2254
2735
  const json = path.join(layer, 'hooks.json')
@@ -2266,17 +2747,12 @@ describe('Codex hook representation', () => {
2266
2747
 
2267
2748
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2268
2749
 
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)
2750
+ const afterJson = readFileSync(json, 'utf8')
2751
+ expect(afterJson).toContain('gdh-stop')
2752
+ expect(afterJson).toContain('--owner notifai')
2753
+ expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
2754
+ expect(io.outLines.join('\n')).toContain(json)
2755
+ expect(io.outLines.join('\n')).not.toMatch(/both representations|not a Notifai fault/i)
2280
2756
  })
2281
2757
 
2282
2758
  it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
@@ -2293,6 +2769,50 @@ describe('Codex hook representation', () => {
2293
2769
  expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
2294
2770
  })
2295
2771
 
2772
+ it('leaves config.toml byte-identical on a no-op reinstall', () => {
2773
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-noop-install-'))
2774
+ const io = new CapturedIo()
2775
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2776
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2777
+ const toml = path.join(cwd, '.codex', 'config.toml')
2778
+ const before = readFileSync(toml, 'utf8')
2779
+ io.outLines = []
2780
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2781
+ expect(readFileSync(toml, 'utf8')).toBe(before)
2782
+ })
2783
+
2784
+ it('deletes an emptied hooks.json instead of leaving an empty residue', () => {
2785
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-empty-json-'))
2786
+ const layer = path.join(cwd, '.codex')
2787
+ const json = path.join(layer, 'hooks.json')
2788
+ mkdirSync(layer, { recursive: true })
2789
+ applyPlan(json, {
2790
+ hooks: {
2791
+ Stop: [{ hooks: [{ type: 'command', command: hookCommand(scriptPath, 'stop', 'codex') }] }],
2792
+ },
2793
+ })
2794
+ const io = new CapturedIo()
2795
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2796
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
2797
+ expect(existsSync(json)).toBe(false)
2798
+ })
2799
+
2800
+ it('global install does not leave a dead project .codex after uninstalling that layer', () => {
2801
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-global-migrate-'))
2802
+ const io = new CapturedIo()
2803
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2804
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2805
+ expect(existsSync(path.join(cwd, '.codex'))).toBe(true)
2806
+ expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
2807
+ EXIT.ok,
2808
+ )
2809
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
2810
+ const leftover = existsSync(path.join(cwd, '.codex'))
2811
+ ? readdirSync(path.join(cwd, '.codex'))
2812
+ : []
2813
+ expect(leftover.every((name) => name !== 'hooks.json')).toBe(true)
2814
+ })
2815
+
2296
2816
  /**
2297
2817
  * Notifai in one file, someone else's hooks in the other: every handler
2298
2818
  * fires once, so doctor has nothing to report and the foreign file is not
@@ -2386,7 +2906,7 @@ describe('harness activation guidance', () => {
2386
2906
  const execPath = process.execPath
2387
2907
  const scriptPath = fileURLToPath(import.meta.url)
2388
2908
 
2389
- it('does not require a Claude Code restart for project hook files', () => {
2909
+ it('requires a fresh Claude Code session so SessionStart can activate it', () => {
2390
2910
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
2391
2911
  const io = new CapturedIo()
2392
2912
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
@@ -2397,11 +2917,11 @@ describe('harness activation guidance', () => {
2397
2917
 
2398
2918
  const output = io.outLines.join('\n')
2399
2919
  expect(output).toContain('Installed claude-code hooks in')
2400
- expect(output).toContain('Send one Claude Code prompt, then check `notifai doctor`.')
2920
+ expect(output).toContain('Start one fresh Claude Code session, send one prompt, then run `notifai doctor`.')
2401
2921
  expect(output).not.toMatch(/timeout|asynchronous|600s/i)
2402
2922
  })
2403
2923
 
2404
- it('does not invent a Codex hook trust gate', () => {
2924
+ it('names Codex trust and fresh-session activation in the correct order', () => {
2405
2925
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
2406
2926
  const io = new CapturedIo()
2407
2927
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
@@ -2411,8 +2931,8 @@ describe('harness activation guidance', () => {
2411
2931
  )
2412
2932
 
2413
2933
  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)
2934
+ expect(output).toContain('Approve the Notifai handlers in `/hooks` if Codex asks')
2935
+ expect(output).toMatch(/approve[\s\S]*start one fresh Codex session[\s\S]*`notifai doctor`/i)
2416
2936
  })
2417
2937
 
2418
2938
  it('names the installed harness in the close, never a different one', () => {
@@ -2424,7 +2944,7 @@ describe('harness activation guidance', () => {
2424
2944
 
2425
2945
  const output = io.outLines.join('\n')
2426
2946
  expect(output).toContain('Installed cursor hooks in')
2427
- expect(output).toContain('Send one Cursor prompt, then check `notifai doctor`.')
2947
+ expect(output).toContain('Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.')
2428
2948
  expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
2429
2949
  expect(output).not.toMatch(/timeout|worktree|fails closed/i)
2430
2950
  })
@@ -2487,7 +3007,7 @@ describe('harness activation guidance', () => {
2487
3007
  )
2488
3008
 
2489
3009
  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`.')
3010
+ expect(io.outLines.join('\n')).toContain('Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.')
2491
3011
  expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
2492
3012
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
2493
3013
  const plugin = readFileSync(pluginFile, 'utf8')
@@ -2632,6 +3152,141 @@ describe('stable hook installation', () => {
2632
3152
  findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
2633
3153
  ).toEqual([expect.objectContaining({ global: true })])
2634
3154
  })
3155
+
3156
+ it('refreshes stale global lifecycle coverage instead of declining a project install', () => {
3157
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-stale-'))
3158
+ const io = new CapturedIo()
3159
+ const env = isolatedEnv(cwd)
3160
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3161
+ const execPath = process.execPath
3162
+ const scriptPath = fileURLToPath(import.meta.url)
3163
+
3164
+ expect(
3165
+ hooksInstallCommand(deps, {
3166
+ harness: 'cursor',
3167
+ global: true,
3168
+ execPath,
3169
+ scriptPath,
3170
+ }),
3171
+ ).toBe(EXIT.ok)
3172
+ const globalFile = settingsFile('cursor', true, cwd, env)
3173
+ const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3174
+ hooks: { stop: Array<{ command?: string }> }
3175
+ }
3176
+ settings.hooks.stop = settings.hooks.stop.filter(
3177
+ (handler) => !handler.command?.includes(' hook activation-stop '),
3178
+ )
3179
+ writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
3180
+
3181
+ io.outLines = []
3182
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
3183
+ expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
3184
+ const installation = findInstallations(cwd, env, deps.hookAdapterHome).find(
3185
+ (item) => item.harness === 'cursor' && item.global,
3186
+ )
3187
+ expect(installation?.handlers.map((handler) => handlerEvent(handler.command))).toContain(
3188
+ 'activation-stop',
3189
+ )
3190
+ expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
3191
+ })
3192
+
3193
+ it('refreshes a stale global Stop shape instead of declining a project install', () => {
3194
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-shape-'))
3195
+ const io = new CapturedIo()
3196
+ const env = isolatedEnv(cwd)
3197
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3198
+ const execPath = process.execPath
3199
+ const scriptPath = fileURLToPath(import.meta.url)
3200
+
3201
+ expect(
3202
+ hooksInstallCommand(deps, {
3203
+ harness: 'cursor',
3204
+ global: true,
3205
+ execPath,
3206
+ scriptPath,
3207
+ }),
3208
+ ).toBe(EXIT.ok)
3209
+ const globalFile = settingsFile('cursor', true, cwd, env)
3210
+ const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3211
+ hooks: { stop: Array<{ command?: string; timeout?: number }> }
3212
+ }
3213
+ const stop = settings.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))
3214
+ expect(stop).toBeDefined()
3215
+ stop!.timeout = 1
3216
+ writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
3217
+
3218
+ io.outLines = []
3219
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
3220
+ expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
3221
+ const refreshed = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3222
+ hooks: { stop: Array<{ command?: string; timeout?: number }> }
3223
+ }
3224
+ expect(
3225
+ refreshed.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))?.timeout,
3226
+ ).toBeGreaterThan(1)
3227
+ expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
3228
+ })
3229
+
3230
+ it('consolidates duplicate global Codex representations before declining a project install', () => {
3231
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-codex-duplicate-'))
3232
+ const io = new CapturedIo()
3233
+ const env = isolatedEnv(cwd)
3234
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3235
+ const execPath = process.execPath
3236
+ const scriptPath = fileURLToPath(import.meta.url)
3237
+
3238
+ expect(
3239
+ hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
3240
+ ).toBe(EXIT.ok)
3241
+ const json = path.join(env.CODEX_HOME!, 'hooks.json')
3242
+ applyPlan(json, {
3243
+ hooks: {
3244
+ ...buildHookConfig({ adapterPath: hookAdapterPath(deps.hookAdapterHome), harness: 'codex' }),
3245
+ PostToolUse: [{ hooks: [{ type: 'command', command: 'foreign-post-tool' }] }],
3246
+ },
3247
+ })
3248
+
3249
+ io.outLines = []
3250
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3251
+ expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
3252
+ expect(readFileSync(json, 'utf8')).toContain('foreign-post-tool')
3253
+ expect(readFileSync(json, 'utf8')).not.toContain('--owner notifai')
3254
+ expect(
3255
+ findInstallations(cwd, env, deps.hookAdapterHome).filter(
3256
+ (installation) => installation.harness === 'codex',
3257
+ ),
3258
+ ).toEqual([expect.objectContaining({ global: true })])
3259
+ expect(io.outLines.join('\n')).toMatch(/Installed codex hooks/)
3260
+ })
3261
+
3262
+ it('refreshes an obsolete global OpenCode plugin instead of treating empty event requirements as coverage', () => {
3263
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-opencode-stale-'))
3264
+ const io = new CapturedIo()
3265
+ const env = isolatedEnv(cwd)
3266
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3267
+ const execPath = process.execPath
3268
+ const scriptPath = fileURLToPath(import.meta.url)
3269
+
3270
+ expect(
3271
+ hooksInstallCommand(deps, {
3272
+ harness: 'opencode',
3273
+ global: true,
3274
+ execPath,
3275
+ scriptPath,
3276
+ }),
3277
+ ).toBe(EXIT.ok)
3278
+ const globalFile = settingsFile('opencode', true, cwd, env)
3279
+ const current = readFileSync(globalFile, 'utf8')
3280
+ writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 11', 'const ADAPTER_VERSION = 10'))
3281
+
3282
+ io.outLines = []
3283
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3284
+ expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
3285
+ const refreshed = readFileSync(globalFile, 'utf8')
3286
+ expect(refreshed).toContain('const ADAPTER_VERSION = 11')
3287
+ expect(refreshed).toContain('experimental.chat.system.transform')
3288
+ expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
3289
+ })
2635
3290
  })
2636
3291
 
2637
3292
  describe('projectSlugFrom', () => {
@@ -2967,7 +3622,7 @@ describe('interactive command UX', () => {
2967
3622
  beginPairing: async () => ({
2968
3623
  pairing_id: 'pair_test',
2969
3624
  code: 'ABCD-EFGH',
2970
- approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
3625
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2971
3626
  expires_at: new Date(10_000).toISOString(),
2972
3627
  poll_interval_seconds: 1,
2973
3628
  }),
@@ -2997,7 +3652,9 @@ describe('interactive command UX', () => {
2997
3652
  expect(io.notes).toEqual([
2998
3653
  {
2999
3654
  title: 'Approve this machine',
3000
- message: 'Code: ABCD-EFGH\nhttps://test.notifai.invalid/pair/ABCD-EFGH',
3655
+ message: expect.stringMatching(
3656
+ /^Code: ABCD-EFGH\nhttps:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
3657
+ ),
3001
3658
  },
3002
3659
  ])
3003
3660
  expect(io.spinnerEvents).toEqual([
@@ -3016,7 +3673,7 @@ describe('interactive command UX', () => {
3016
3673
  beginPairing: async () => ({
3017
3674
  pairing_id: 'pair_test',
3018
3675
  code: 'ABCD-EFGH',
3019
- approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
3676
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
3020
3677
  expires_at: new Date(10_000).toISOString(),
3021
3678
  poll_interval_seconds: 1,
3022
3679
  }),
@@ -3033,7 +3690,9 @@ describe('interactive command UX', () => {
3033
3690
  expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
3034
3691
  expect(io.outLines.slice(0, 3)).toEqual([
3035
3692
  'Pairing code: ABCD-EFGH',
3036
- 'Approve this machine at: https://test.notifai.invalid/pair/ABCD-EFGH',
3693
+ expect.stringMatching(
3694
+ /^Approve this machine at: https:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
3695
+ ),
3037
3696
  'Waiting for approval…',
3038
3697
  ])
3039
3698
  })
@@ -3174,6 +3833,26 @@ describe('interactive command UX', () => {
3174
3833
  expect(io.intros).toEqual([])
3175
3834
  expect(io.checks).toEqual([])
3176
3835
  })
3836
+
3837
+ it('consults npm latest from an interactive doctor without failing the run', async () => {
3838
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-registry-'))
3839
+ const io = new InteractiveIo()
3840
+ const client = {
3841
+ health: async () => true,
3842
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3843
+ } as unknown as ApiClient
3844
+ const deps: CommandDeps = {
3845
+ ...makeDeps(io, client),
3846
+ cwd,
3847
+ env: isolatedEnv(cwd),
3848
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3849
+ fetchImpl: async () => new Response(JSON.stringify({ latest: '99.0.0' }), { status: 200 }),
3850
+ }
3851
+
3852
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
3853
+ expect(io.outLines).toContain('A newer Notifai is available.')
3854
+ expect(io.outLines).toContain('npm install -g @raidiant/notifai')
3855
+ })
3177
3856
  })
3178
3857
 
3179
3858
  describe('compatibility-first update guidance', () => {
@@ -3421,7 +4100,6 @@ describe('init', () => {
3421
4100
  ): EvidenceSnapshot {
3422
4101
  return {
3423
4102
  request_id: requestId,
3424
- event: 'setup_verified',
3425
4103
  accepted_at: '2026-08-05T18:00:00.000Z',
3426
4104
  overall: 'provider_accepted_all',
3427
4105
  deliveries: [
@@ -3659,18 +4337,22 @@ describe('init', () => {
3659
4337
  } as unknown as ApiClient
3660
4338
  const nativeSkills: NativeSkills = {
3661
4339
  add: async () => 0,
4340
+ remove: async () => 0,
3662
4341
  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
- ],
4342
+ skills:
4343
+ scope === 'project'
4344
+ ? [
4345
+ {
4346
+ name: 'notifai',
4347
+ scope,
4348
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
4349
+ source: 'Raidiant-io/notifai',
4350
+ sourceType: 'github',
4351
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
4352
+ ref: RELEASE_REF,
4353
+ },
4354
+ ]
4355
+ : [],
3674
4356
  }),
3675
4357
  }
3676
4358
  const readiness = await assessReadiness(
@@ -3696,9 +4378,10 @@ describe('init', () => {
3696
4378
  const calls: SkillScope[] = []
3697
4379
  const nativeSkills: NativeSkills = {
3698
4380
  add: async () => 0,
4381
+ remove: async () => 0,
3699
4382
  list: async (selected) => {
3700
4383
  calls.push(selected)
3701
- return { skills: [managedSkill(selected, cwd)] }
4384
+ return { skills: selected === scope ? [managedSkill(selected, cwd)] : [] }
3702
4385
  },
3703
4386
  }
3704
4387
 
@@ -3710,7 +4393,7 @@ describe('init', () => {
3710
4393
  status: 'ready',
3711
4394
  detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
3712
4395
  })
3713
- expect(calls).toEqual([scope])
4396
+ expect(calls).toEqual(['project', 'global'])
3714
4397
  },
3715
4398
  )
3716
4399
 
@@ -3726,8 +4409,12 @@ describe('init', () => {
3726
4409
  } as unknown as ApiClient
3727
4410
  const nativeSkills: NativeSkills = {
3728
4411
  add: async () => 0,
4412
+ remove: async () => 0,
3729
4413
  list: async (selected) => ({
3730
- skills: [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }],
4414
+ skills:
4415
+ selected === scope
4416
+ ? [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }]
4417
+ : [],
3731
4418
  }),
3732
4419
  }
3733
4420
 
@@ -3742,6 +4429,192 @@ describe('init', () => {
3742
4429
  },
3743
4430
  )
3744
4431
 
4432
+ it('reports a duplicate notifai skill across project and global scope', async () => {
4433
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-duplicate-'))
4434
+ const io = new CapturedIo()
4435
+ const client = {
4436
+ health: async () => true,
4437
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4438
+ listDevices: async () => ({ devices: [] }),
4439
+ } as unknown as ApiClient
4440
+ const nativeSkills: NativeSkills = {
4441
+ add: async () => 0,
4442
+ remove: async () => 0,
4443
+ list: async (scope) => ({
4444
+ skills: [
4445
+ {
4446
+ ...managedSkill(scope, cwd),
4447
+ ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
4448
+ path:
4449
+ scope === 'project'
4450
+ ? path.join(cwd, '.agents', 'skills', 'notifai')
4451
+ : path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
4452
+ },
4453
+ ],
4454
+ }),
4455
+ }
4456
+
4457
+ const readiness = await assessReadiness({ ...makeDeps(io, client), cwd, nativeSkills })
4458
+ const skill = readiness.states.find((state) => state.id === 'skill')
4459
+ expect(skill).toMatchObject({
4460
+ status: 'gap',
4461
+ 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.`,
4462
+ technical: {
4463
+ project: {
4464
+ ref: RELEASE_REF,
4465
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
4466
+ current: true,
4467
+ },
4468
+ global: {
4469
+ ref: 'v0.2.1',
4470
+ path: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
4471
+ current: false,
4472
+ },
4473
+ resolution: 'both-listed',
4474
+ },
4475
+ })
4476
+
4477
+ expect(await doctorCommand({ ...makeDeps(io, client), cwd, nativeSkills }, { json: true })).toBe(
4478
+ EXIT.failed,
4479
+ )
4480
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as {
4481
+ ok: boolean
4482
+ states: Array<{ id: string; status: string; detail: string }>
4483
+ }
4484
+ expect(payload.ok).toBe(false)
4485
+ expect(payload.states.find((state) => state.id === 'skill')).toMatchObject({
4486
+ status: 'gap',
4487
+ detail: expect.stringContaining('harness lists both'),
4488
+ })
4489
+ })
4490
+
4491
+ it('does not treat a current pin as ready when a stale duplicate is also installed', async () => {
4492
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-shadow-'))
4493
+ const io = new CapturedIo()
4494
+ const client = {
4495
+ health: async () => true,
4496
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4497
+ listDevices: async () => ({ devices: [] }),
4498
+ } as unknown as ApiClient
4499
+ const nativeSkills: NativeSkills = {
4500
+ add: async () => 0,
4501
+ remove: async () => 0,
4502
+ list: async (scope) => ({
4503
+ skills: [
4504
+ {
4505
+ ...managedSkill(scope, cwd),
4506
+ ref: scope === 'global' ? RELEASE_REF : 'v0.2.1',
4507
+ },
4508
+ ],
4509
+ }),
4510
+ }
4511
+
4512
+ const readiness = await assessReadiness(
4513
+ { ...makeDeps(io, client), cwd, nativeSkills },
4514
+ { skillScope: 'global' },
4515
+ )
4516
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
4517
+ status: 'gap',
4518
+ 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.`,
4519
+ })
4520
+ })
4521
+
4522
+ it('uninstalls the other skill scope before installing the chosen one', async () => {
4523
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-prevented-'))
4524
+ const io = new CapturedIo()
4525
+ const removed: SkillScope[] = []
4526
+ const added: Array<SkillScope | undefined> = []
4527
+ let globalPresent = true
4528
+ const nativeSkills: NativeSkills = {
4529
+ add: async (options) => {
4530
+ added.push(options.scope)
4531
+ return 0
4532
+ },
4533
+ remove: async (options) => {
4534
+ removed.push(options.scope)
4535
+ if (options.scope === 'global') globalPresent = false
4536
+ return 0
4537
+ },
4538
+ list: async (scope) => ({
4539
+ skills: scope === 'global' && globalPresent ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
4540
+ }),
4541
+ }
4542
+
4543
+ expect(
4544
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4545
+ skills: true,
4546
+ setupScope: 'project',
4547
+ hooks: false,
4548
+ }),
4549
+ ).toBe(EXIT.ok)
4550
+ expect(removed).toEqual(['global'])
4551
+ expect(added).toEqual(['project'])
4552
+ })
4553
+
4554
+ it('clears a pre-existing skill duplicate by keeping the chosen setup scope', async () => {
4555
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-clear-'))
4556
+ const io = new CapturedIo()
4557
+ const removed: SkillScope[] = []
4558
+ const present = new Set<SkillScope>(['project', 'global'])
4559
+ const nativeSkills: NativeSkills = {
4560
+ add: async (options) => {
4561
+ if (options.scope !== undefined) present.add(options.scope)
4562
+ return 0
4563
+ },
4564
+ remove: async (options) => {
4565
+ removed.push(options.scope)
4566
+ present.delete(options.scope)
4567
+ return 0
4568
+ },
4569
+ list: async (scope) => ({
4570
+ skills: present.has(scope)
4571
+ ? [
4572
+ {
4573
+ ...managedSkill(scope, cwd),
4574
+ ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
4575
+ },
4576
+ ]
4577
+ : [],
4578
+ }),
4579
+ }
4580
+
4581
+ expect(
4582
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4583
+ skills: true,
4584
+ setupScope: 'project',
4585
+ hooks: false,
4586
+ }),
4587
+ ).toBe(EXIT.ok)
4588
+ expect(removed).toEqual(['global'])
4589
+ expect([...present]).toEqual(['project'])
4590
+ })
4591
+
4592
+ it('updates the existing skill scope instead of adding a second copy', async () => {
4593
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-update-existing-'))
4594
+ const io = new InteractiveIo()
4595
+ const received: Array<SkillScope | undefined> = []
4596
+ const installed = true
4597
+ const nativeSkills: NativeSkills = {
4598
+ add: async (options) => {
4599
+ received.push(options.scope)
4600
+ return 0
4601
+ },
4602
+ remove: async () => 0,
4603
+ list: async (scope) => ({
4604
+ skills: installed && scope === 'global' ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
4605
+ }),
4606
+ }
4607
+
4608
+ expect(
4609
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4610
+ skills: true,
4611
+ hooks: false,
4612
+ }),
4613
+ ).toBe(EXIT.ok)
4614
+ expect(received).toEqual(['global'])
4615
+ expect(io.outLines.join('\n')).toContain('global scope')
4616
+ })
4617
+
3745
4618
  it('requires an explicit skill scope before unattended installation', async () => {
3746
4619
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
3747
4620
  const io = new CapturedIo()
@@ -3751,6 +4624,7 @@ describe('init', () => {
3751
4624
  addCalls += 1
3752
4625
  return 0
3753
4626
  },
4627
+ remove: async () => 0,
3754
4628
  list: async () => ({ skills: [] }),
3755
4629
  }
3756
4630
 
@@ -3758,7 +4632,7 @@ describe('init', () => {
3758
4632
  EXIT.usage,
3759
4633
  )
3760
4634
  expect(addCalls).toBe(0)
3761
- expect(io.errLines.join('\n')).toContain('--skills-scope project')
4635
+ expect(io.errLines.join('\n')).toContain('--setup-scope project')
3762
4636
  })
3763
4637
 
3764
4638
  it('rejects an invalid unattended skill scope instead of guessing', async () => {
@@ -3786,6 +4660,7 @@ describe('init', () => {
3786
4660
  installed = true
3787
4661
  return 0
3788
4662
  },
4663
+ remove: async () => 0,
3789
4664
  list: async (selected) => ({
3790
4665
  skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
3791
4666
  }),
@@ -3802,27 +4677,82 @@ describe('init', () => {
3802
4677
  },
3803
4678
  )
3804
4679
 
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-'))
4680
+ it('asks one setup-scope question and passes that scope to the native installer', async () => {
4681
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-setup-scope-ask-'))
3807
4682
  const io = new InteractiveIo()
4683
+ io.selectAnswer = 'project'
3808
4684
  const calls: { submit: number } = { submit: 0 }
3809
- let receivedScope: SkillScope | undefined = 'global'
4685
+ let receivedScope: SkillScope | undefined
3810
4686
  const nativeSkills: NativeSkills = {
3811
4687
  add: async (options) => {
3812
4688
  receivedScope = options.scope
3813
4689
  return 0
3814
4690
  },
4691
+ remove: async () => 0,
3815
4692
  list: async () => ({ skills: [] }),
3816
4693
  }
3817
4694
 
3818
4695
  expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
3819
4696
  EXIT.ok,
3820
4697
  )
3821
- expect(receivedScope).toBeUndefined()
4698
+ expect(io.prompts[0]).toContain('this project only, or to every project on this machine')
4699
+ expect(receivedScope).toBe('project')
3822
4700
  expect(calls.submit).toBe(1)
3823
4701
  expect(io.outLines.join('\n')).toContain('All set.')
3824
4702
  })
3825
4703
 
4704
+ it('accepts --setup-scope as the unattended skill, hooks, and config scope', async () => {
4705
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-flag-'))
4706
+ const io = new CapturedIo()
4707
+ let receivedScope: SkillScope | undefined
4708
+ const nativeSkills: NativeSkills = {
4709
+ add: async (options) => {
4710
+ receivedScope = options.scope
4711
+ return 0
4712
+ },
4713
+ remove: async () => 0,
4714
+ list: async (scope) => ({
4715
+ skills: receivedScope === scope ? [managedSkill(scope, cwd)] : [],
4716
+ }),
4717
+ }
4718
+
4719
+ expect(
4720
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4721
+ skills: true,
4722
+ setupScope: 'project',
4723
+ hooks: false,
4724
+ }),
4725
+ ).toBe(EXIT.ok)
4726
+ expect(receivedScope).toBe('project')
4727
+ expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(true)
4728
+ })
4729
+
4730
+ it('stamps project identity outside the repo when setup-scope is global', async () => {
4731
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-global-config-'))
4732
+ const io = new CapturedIo()
4733
+ const deps: CommandDeps = {
4734
+ ...makeDeps(io, {} as ApiClient),
4735
+ cwd,
4736
+ env: isolatedEnv(cwd),
4737
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
4738
+ }
4739
+
4740
+ expect(await initCommand(deps, { setupScope: 'global', skills: false, hooks: false })).toBe(EXIT.failed)
4741
+ expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
4742
+ expect(readFileSync(personalProjectConfigPath(cwd, deps.env), 'utf8')).toMatch(/project = "/)
4743
+ })
4744
+
4745
+ it('rejects disagreeing --setup-scope and --skills-scope', async () => {
4746
+ const io = new CapturedIo()
4747
+ expect(
4748
+ await initCommand(
4749
+ { ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-scope-conflict-')) },
4750
+ { skills: true, setupScope: 'project', skillsScope: 'global' },
4751
+ ),
4752
+ ).toBe(EXIT.usage)
4753
+ expect(io.errLines.join('\n')).toContain('disagree')
4754
+ })
4755
+
3826
4756
  it('installs every detected project harness when --hooks is set', async () => {
3827
4757
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
3828
4758
  mkdirSync(path.join(cwd, '.claude'))
@@ -3831,6 +4761,7 @@ describe('init', () => {
3831
4761
  const calls = { submit: 0 }
3832
4762
  const nativeSkills: NativeSkills = {
3833
4763
  add: async () => 0,
4764
+ remove: async () => 0,
3834
4765
  list: async () => ({ skills: [] }),
3835
4766
  }
3836
4767
  const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
@@ -3855,6 +4786,7 @@ describe('init', () => {
3855
4786
  const calls = { submit: 0 }
3856
4787
  const nativeSkills: NativeSkills = {
3857
4788
  add: async () => 0,
4789
+ remove: async () => 0,
3858
4790
  list: async () => ({ skills: [] }),
3859
4791
  }
3860
4792
  const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
@@ -3872,6 +4804,7 @@ describe('init', () => {
3872
4804
  const calls: { submit: number } = { submit: 0 }
3873
4805
  const nativeSkills: NativeSkills = {
3874
4806
  add: async () => 1,
4807
+ remove: async () => 0,
3875
4808
  list: async () => ({ skills: [] }),
3876
4809
  }
3877
4810
 
@@ -4012,6 +4945,7 @@ describe('init', () => {
4012
4945
  nativeSkills: {
4013
4946
  list: async () => ({ skills: [] }),
4014
4947
  add: async () => 0,
4948
+ remove: async () => 0,
4015
4949
  } satisfies NativeSkills,
4016
4950
  }
4017
4951
 
@@ -4109,7 +5043,7 @@ describe('init', () => {
4109
5043
  )
4110
5044
  expect(io.outLines.join('\n')).toContain('All set.')
4111
5045
  expect(submitCalls).toBe(1)
4112
- expect(submittedDraft?.draft.event).toBe('setup_verified')
5046
+ expect(submittedDraft?.draft).not.toHaveProperty('event')
4113
5047
  expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
4114
5048
 
4115
5049
  io.outLines = []
@@ -4267,6 +5201,52 @@ describe('init', () => {
4267
5201
  expect(io.outLines.join('\n')).toContain('All set.')
4268
5202
  })
4269
5203
 
5204
+ it('replaces a stale unknown setup proof instead of rechecking it forever', async () => {
5205
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-stale-'))
5206
+ const io = new CapturedIo()
5207
+ let submitCalls = 0
5208
+ const client = {
5209
+ health: async () => true,
5210
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5211
+ listDevices: async () => ({ devices: [readyIphone] }),
5212
+ submit: async () => {
5213
+ submitCalls += 1
5214
+ return setupReceipt('req_fresh')
5215
+ },
5216
+ evidence: async (requestId: string) =>
5217
+ setupEvidence(
5218
+ requestId,
5219
+ requestId === 'req_fresh'
5220
+ ? {
5221
+ state: 'observed',
5222
+ observed_at: '2026-08-25T00:00:02.000Z',
5223
+ latency_ms: 1_000,
5224
+ }
5225
+ : { state: 'unknown', observed_at: null, latency_ms: null },
5226
+ ),
5227
+ } as unknown as ApiClient
5228
+ const now = Date.parse('2026-08-25T00:00:00.000Z')
5229
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5230
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "stale-proof"\n')
5231
+ const deps: CommandDeps = {
5232
+ ...makeDeps(io, client),
5233
+ cwd,
5234
+ env: isolatedEnv(cwd),
5235
+ now: () => now,
5236
+ }
5237
+ writeSetupProof(deps, {
5238
+ request_id: 'req_old',
5239
+ device_id: readyIphone.device_id,
5240
+ project: 'stale-proof',
5241
+ started_at: new Date(now - SETUP_PROOF_STALE_MS - 1).toISOString(),
5242
+ })
5243
+
5244
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
5245
+ expect(submitCalls).toBe(1)
5246
+ expect(io.outLines.join('\n')).toContain('older than 24h without a Companion Receipt')
5247
+ expect(io.outLines.join('\n')).toContain('All set.')
5248
+ })
5249
+
4270
5250
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
4271
5251
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
4272
5252
  const io = new CapturedIo()
@@ -4386,42 +5366,6 @@ describe('init', () => {
4386
5366
  )
4387
5367
  })
4388
5368
 
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
5369
  it('treats a revoked credential as the one blocker and points back to pairing', async () => {
4426
5370
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
4427
5371
  const io = new CapturedIo()
@@ -4446,7 +5390,7 @@ describe('init', () => {
4446
5390
  expect(out.match(/^Next:/gm)).toHaveLength(1)
4447
5391
  })
4448
5392
 
4449
- it('scopes proof to each project worktree even on the same paired machine', async () => {
5393
+ it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
4450
5394
  const stateRoot = mkdtempSync(path.join(os.tmpdir(), 'init-worktrees-state-'))
4451
5395
  let submitCalls = 0
4452
5396
  const client = {
@@ -4478,7 +5422,62 @@ describe('init', () => {
4478
5422
  ).toBe(EXIT.ok)
4479
5423
  }
4480
5424
 
4481
- expect(submitCalls).toBe(2)
5425
+ expect(submitCalls).toBe(1)
5426
+ })
5427
+
5428
+ it('returns one authoritative structured final readiness without duplicate probes', async () => {
5429
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-json-ready-'))
5430
+ const io = new InteractiveIo()
5431
+ let healthCalls = 0
5432
+ let compatibilityCalls = 0
5433
+ let deviceCalls = 0
5434
+ const client = {
5435
+ health: async () => {
5436
+ healthCalls += 1
5437
+ return true
5438
+ },
5439
+ compatibility: async () => {
5440
+ compatibilityCalls += 1
5441
+ return currentCompatibility
5442
+ },
5443
+ listDevices: async () => {
5444
+ deviceCalls += 1
5445
+ return { devices: [readyIphone] }
5446
+ },
5447
+ evidence: async (requestId: string) =>
5448
+ setupEvidence(requestId, {
5449
+ state: 'observed',
5450
+ observed_at: '2026-08-25T00:00:02.000Z',
5451
+ latency_ms: 1_000,
5452
+ }),
5453
+ } as unknown as ApiClient
5454
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5455
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "json-ready"\n')
5456
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
5457
+ writeSetupProof(deps, {
5458
+ request_id: 'req_json_ready',
5459
+ device_id: readyIphone.device_id,
5460
+ project: 'json-ready',
5461
+ started_at: '2026-08-25T00:00:00.000Z',
5462
+ })
5463
+
5464
+ expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.ok)
5465
+ expect(io.prompts).toEqual([])
5466
+ expect(io.outLines).toHaveLength(1)
5467
+ const result = JSON.parse(io.outLines[0] ?? '{}') as {
5468
+ ready: boolean
5469
+ can_send: boolean
5470
+ question_routing_ready: boolean
5471
+ states: Array<{ id: string; status: string; remedy: unknown }>
5472
+ }
5473
+ expect(result.ready).toBe(true)
5474
+ expect(result.can_send).toBe(true)
5475
+ expect(result.question_routing_ready).toBe(false)
5476
+ expect(result.states.length).toBeGreaterThan(8)
5477
+ expect(result.states.every((state) => 'remedy' in state)).toBe(true)
5478
+ expect(healthCalls).toBe(1)
5479
+ expect(compatibilityCalls).toBe(1)
5480
+ expect(deviceCalls).toBe(1)
4482
5481
  })
4483
5482
  })
4484
5483
 
@@ -4600,6 +5599,54 @@ describe('readiness assessment cost', () => {
4600
5599
  detail: `installed from ${SKILLS_SOURCE} in the project scope`,
4601
5600
  })
4602
5601
  })
5602
+
5603
+ it('detects a lock-file duplicate across project and global scope', async () => {
5604
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-duplicate-'))
5605
+ const env = { ...isolatedEnv(cwd), PATH: '/nonexistent' }
5606
+ writeFileSync(
5607
+ path.join(cwd, 'skills-lock.json'),
5608
+ `${JSON.stringify({
5609
+ skills: {
5610
+ notifai: {
5611
+ source: 'Raidiant-io/notifai',
5612
+ sourceType: 'github',
5613
+ ref: RELEASE_REF,
5614
+ },
5615
+ },
5616
+ })}\n`,
5617
+ )
5618
+ mkdirSync(path.join(env.XDG_STATE_HOME!, 'skills'), { recursive: true })
5619
+ writeFileSync(
5620
+ path.join(env.XDG_STATE_HOME!, 'skills', '.skill-lock.json'),
5621
+ `${JSON.stringify({
5622
+ skills: {
5623
+ notifai: {
5624
+ source: 'Raidiant-io/notifai',
5625
+ sourceType: 'github',
5626
+ ref: 'v0.2.1',
5627
+ },
5628
+ },
5629
+ })}\n`,
5630
+ )
5631
+ const io = new CapturedIo()
5632
+ const client = {
5633
+ health: async () => true,
5634
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5635
+ listDevices: async () => ({ devices: [] }),
5636
+ } as unknown as ApiClient
5637
+
5638
+ const readiness = await assessReadiness({
5639
+ ...makeDeps(io, client),
5640
+ cwd,
5641
+ env,
5642
+ nativeSkills: realNativeSkills,
5643
+ })
5644
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
5645
+ status: 'gap',
5646
+ 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.`,
5647
+ technical: { resolution: 'both-listed' },
5648
+ })
5649
+ })
4603
5650
  })
4604
5651
 
4605
5652
  describe('an outage is not an answer', () => {
@@ -4750,8 +5797,8 @@ describe('asking before the hooks have ever run', () => {
4750
5797
 
4751
5798
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
4752
5799
  const said = io.errLines.join(' ')
4753
- expect(said).toMatch(/active Codex session/i)
4754
- expect(said).toContain('notifai hooks install --harness codex')
5800
+ expect(said).toMatch(/Codex hooks are not installed/i)
5801
+ expect(said).toContain('notifai init --json')
4755
5802
  expect(said).not.toMatch(/Claude Code: send one new prompt|OpenCode: restart/)
4756
5803
  })
4757
5804
 
@@ -4768,12 +5815,13 @@ describe('asking before the hooks have ever run', () => {
4768
5815
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
4769
5816
 
4770
5817
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
4771
- writeSessionState('codex-other-thread', env, { last_prompt_at: 42 })
5818
+ trustInstalledCodexHooks(cwd, env)
5819
+ writeSessionState('codex-other-thread', env, { harness: 'codex', last_prompt_at: 42 })
4772
5820
  writeProjectSession(cwd, env, 'codex-other-thread', 42, 'codex')
4773
5821
  io.outLines = []
4774
5822
 
4775
5823
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
4776
- expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
5824
+ expect(io.errLines.join(' ')).toMatch(/exact Codex session has not fired UserPromptSubmit/i)
4777
5825
  expect(io.outLines).not.toContain(
4778
5826
  'Question registered. Ask it in the conversation as usual and end your turn.',
4779
5827
  )
@@ -4793,16 +5841,14 @@ describe('asking before the hooks have ever run', () => {
4793
5841
 
4794
5842
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
4795
5843
  trustInstalledCodexHooks(cwd, env)
4796
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
5844
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
4797
5845
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
4798
- writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
5846
+ writeSessionState('claude-concurrent', env, { harness: 'claude-code', last_prompt_at: 43, last_stop_at: 43 })
4799
5847
  writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
4800
5848
  io.outLines = []
4801
5849
 
4802
5850
  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
- )
5851
+ expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
4806
5852
  expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
4807
5853
  session_id: 'codex-current-thread',
4808
5854
  harness: 'codex',
@@ -4812,6 +5858,49 @@ describe('asking before the hooks have ever run', () => {
4812
5858
  ).toBe('Gentle Salmon')
4813
5859
  })
4814
5860
 
5861
+ it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
5862
+ const first = scratchDir('notifai-exact-session-first-')
5863
+ const second = scratchDir('notifai-exact-session-second-')
5864
+ const io = new CapturedIo()
5865
+ const env = {
5866
+ ...isolatedEnv(first),
5867
+ CLAUDECODE: '1',
5868
+ CLAUDE_CODE_SESSION_ID: 'claude-cross-checkout',
5869
+ }
5870
+ const firstDeps = { ...makeDeps(io, {} as ApiClient), cwd: first, env, now: () => 42 }
5871
+ expect(
5872
+ hooksInstallCommand(firstDeps, {
5873
+ harness: 'claude-code',
5874
+ execPath,
5875
+ scriptPath,
5876
+ }),
5877
+ ).toBe(EXIT.ok)
5878
+ writeSessionState('claude-cross-checkout', env, {
5879
+ harness: 'claude-code',
5880
+ activation_cwd: first,
5881
+ last_prompt_at: 42,
5882
+ last_stop_at: 41,
5883
+ })
5884
+ writeProjectSession(first, env, 'claude-cross-checkout', 42, 'claude-code')
5885
+ mkdirSync(path.join(second, '.notifai'), { recursive: true })
5886
+ writeFileSync(
5887
+ path.join(second, '.notifai', 'config.toml'),
5888
+ 'project = "second-project"\nask_notifications = false\n',
5889
+ )
5890
+ mkdirSync(path.dirname(sessionConfigPath('claude-cross-checkout', env)), { recursive: true })
5891
+ writeFileSync(sessionConfigPath('claude-cross-checkout', env), 'ask_notifications = true\n')
5892
+ io.outLines = []
5893
+
5894
+ const secondDeps = { ...firstDeps, cwd: second }
5895
+ expect(askCommand(secondDeps, 'Use this checkout?', {})).toBe(EXIT.ok)
5896
+ const pending = readSessionState('claude-cross-checkout', env).pending?.[0]
5897
+ expect(pending?.project).toBe('second-project')
5898
+ expect(pending?.source).toMatchObject({
5899
+ session_id: 'claude-cross-checkout',
5900
+ harness: 'claude-code',
5901
+ })
5902
+ })
5903
+
4815
5904
  it('uploads ask images before registration and freezes canonical body media', async () => {
4816
5905
  const cwd = scratchDir('notifai-ask-media-')
4817
5906
  const first = path.join(cwd, 'first.png')
@@ -4846,7 +5935,7 @@ describe('asking before the hooks have ever run', () => {
4846
5935
 
4847
5936
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
4848
5937
  trustInstalledCodexHooks(cwd, env)
4849
- writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
5938
+ writeSessionState('codex-media-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
4850
5939
  writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
4851
5940
  io.outLines = []
4852
5941
 
@@ -4897,7 +5986,7 @@ describe('asking before the hooks have ever run', () => {
4897
5986
  hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
4898
5987
  ).toBe(EXIT.ok)
4899
5988
  trustInstalledCodexHooks(cwd, env)
4900
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
5989
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
4901
5990
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
4902
5991
  io.outLines = []
4903
5992
 
@@ -4927,9 +6016,9 @@ describe('asking before the hooks have ever run', () => {
4927
6016
  trustInstalledCodexHooks(cwd, env)
4928
6017
  // Both sessions are live here. The parent fired when its own turn began;
4929
6018
  // the child fired for the turn that is running this command.
4930
- writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
6019
+ writeSessionState('codex-orchestrator', env, { harness: 'codex', last_prompt_at: 40, last_stop_at: 39 })
4931
6020
  writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
4932
- writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
6021
+ writeSessionState('claude-current', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
4933
6022
  writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
4934
6023
  io.outLines = []
4935
6024
 
@@ -4962,7 +6051,7 @@ describe('asking before the hooks have ever run', () => {
4962
6051
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
4963
6052
  EXIT.ok,
4964
6053
  )
4965
- writeSessionState('orca-claude-question', env, { last_prompt_at: 42, last_stop_at: 41 })
6054
+ writeSessionState('orca-claude-question', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
4966
6055
  writeProjectSession(cwd, env, 'orca-claude-question', 42, 'claude-code')
4967
6056
  io.outLines = []
4968
6057
 
@@ -4990,7 +6079,7 @@ describe('asking before the hooks have ever run', () => {
4990
6079
  hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
4991
6080
  ).toBe(EXIT.ok)
4992
6081
  trustInstalledCodexHooks(cwd, env)
4993
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6082
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
4994
6083
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
4995
6084
 
4996
6085
  const readiness = await assessReadiness(deps)
@@ -5024,11 +6113,11 @@ describe('asking before the hooks have ever run', () => {
5024
6113
  const fired = readiness.states.find((state) => state.id === 'hooks-fired')
5025
6114
  expect(fired?.status).toBe('optional-gap')
5026
6115
  expect(fired?.detail).toMatch(/Claude Code/)
5027
- expect(fired?.detail).toMatch(/Codex: send one prompt/)
6116
+ expect(fired?.detail).toMatch(/Codex: approve[\s\S]*start one fresh session/i)
5028
6117
 
5029
6118
  io.errLines = []
5030
6119
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5031
- expect(io.errLines.join(' ')).toMatch(/Several harness markers are present/i)
6120
+ expect(io.errLines.join(' ')).toMatch(/Several harness sessions could own this shell/i)
5032
6121
  })
5033
6122
 
5034
6123
  it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
@@ -5045,7 +6134,7 @@ describe('asking before the hooks have ever run', () => {
5045
6134
 
5046
6135
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
5047
6136
  trustInstalledCodexHooks(cwd, env)
5048
- writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6137
+ writeSessionState('codex-commitment-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5049
6138
  writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
5050
6139
  io.outLines = []
5051
6140
 
@@ -5081,7 +6170,7 @@ describe('asking before the hooks have ever run', () => {
5081
6170
 
5082
6171
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
5083
6172
  trustInstalledCodexHooks(cwd, env)
5084
- writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6173
+ writeSessionState('codex-free-text-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5085
6174
  writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
5086
6175
  io.outLines = []
5087
6176
 
@@ -5113,7 +6202,7 @@ describe('asking before the hooks have ever run', () => {
5113
6202
  configFile,
5114
6203
  readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
5115
6204
  )
5116
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6205
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5117
6206
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
5118
6207
  io.outLines = []
5119
6208
 
@@ -5144,6 +6233,7 @@ describe('asking before the hooks have ever run', () => {
5144
6233
  } as unknown as ApiClient
5145
6234
  const nativeSkills: NativeSkills = {
5146
6235
  add: async () => 0,
6236
+ remove: async () => 0,
5147
6237
  list: async (scope) => ({
5148
6238
  skills:
5149
6239
  scope === 'global'
@@ -5187,12 +6277,12 @@ describe('asking before the hooks have ever run', () => {
5187
6277
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
5188
6278
  EXIT.ok,
5189
6279
  )
5190
- writeSessionState('claude-other', env, { last_prompt_at: 42 })
6280
+ writeSessionState('claude-other', env, { harness: 'claude-code', last_prompt_at: 42 })
5191
6281
  writeProjectSession(cwd, env, 'claude-other', 42, 'claude-code')
5192
6282
  io.outLines = []
5193
6283
 
5194
6284
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5195
- expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
6285
+ expect(io.errLines.join(' ')).toMatch(/exact Claude Code session has not fired UserPromptSubmit/i)
5196
6286
  })
5197
6287
 
5198
6288
  it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
@@ -5210,7 +6300,7 @@ describe('asking before the hooks have ever run', () => {
5210
6300
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
5211
6301
  EXIT.ok,
5212
6302
  )
5213
- writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
6303
+ writeSessionState('claude-parent-loop', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
5214
6304
  writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
5215
6305
  io.outLines = []
5216
6306
 
@@ -5231,7 +6321,7 @@ describe('asking before the hooks have ever run', () => {
5231
6321
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
5232
6322
  EXIT.ok,
5233
6323
  )
5234
- writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
6324
+ writeSessionState('claude-last-writer', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
5235
6325
  writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
5236
6326
  io.outLines = []
5237
6327
 
@@ -5260,7 +6350,7 @@ describe('asking before the hooks have ever run', () => {
5260
6350
  }),
5261
6351
  })
5262
6352
  trustInstalledCodexHooks(cwd, env)
5263
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6353
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5264
6354
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
5265
6355
  io.outLines = []
5266
6356
 
@@ -5283,7 +6373,7 @@ describe('asking before the hooks have ever run', () => {
5283
6373
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
5284
6374
  EXIT.ok,
5285
6375
  )
5286
- writeSessionState('claude-current', env, { last_prompt_at: 42 })
6376
+ writeSessionState('claude-current', env, { harness: 'claude-code', last_prompt_at: 42 })
5287
6377
  writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
5288
6378
  io.outLines = []
5289
6379
 
@@ -5308,12 +6398,12 @@ describe('asking before the hooks have ever run', () => {
5308
6398
  }
5309
6399
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
5310
6400
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
5311
- writeSessionState('opencode-other', env, { last_prompt_at: 42 })
6401
+ writeSessionState('opencode-other', env, { harness: 'opencode', last_prompt_at: 42 })
5312
6402
  writeProjectSession(cwd, env, 'opencode-other', 42, 'opencode')
5313
6403
  io.outLines = []
5314
6404
 
5315
6405
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5316
- expect(io.errLines.join(' ')).toMatch(/active OpenCode session:.*has not published/i)
6406
+ expect(io.errLines.join(' ')).toMatch(/no proven answer continuation/i)
5317
6407
  })
5318
6408
 
5319
6409
  it('rejects OpenCode before registration even with an exact matching pointer', () => {
@@ -5328,7 +6418,7 @@ describe('asking before the hooks have ever run', () => {
5328
6418
  }
5329
6419
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
5330
6420
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
5331
- writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
6421
+ writeSessionState('opencode-current', env, { harness: 'opencode', last_prompt_at: 42, last_stop_at: 41 })
5332
6422
  writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
5333
6423
  io.outLines = []
5334
6424
 
@@ -5347,7 +6437,7 @@ describe('asking before the hooks have ever run', () => {
5347
6437
  }
5348
6438
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
5349
6439
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
5350
- writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
6440
+ writeSessionState('cursor-live', env, { harness: 'cursor', last_prompt_at: 42, last_stop_at: 41 })
5351
6441
  writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
5352
6442
  io.outLines = []
5353
6443
 
@@ -5370,7 +6460,7 @@ describe('asking before the hooks have ever run', () => {
5370
6460
  }
5371
6461
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
5372
6462
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
5373
- writeSessionState('codex-live', env, { last_prompt_at: 42 })
6463
+ writeSessionState('codex-live', env, { harness: 'codex', last_prompt_at: 42 })
5374
6464
  writeProjectSession(cwd, env, 'codex-live', 42, 'codex')
5375
6465
  io.outLines = []
5376
6466
 
@@ -5401,7 +6491,7 @@ describe('asking before the hooks have ever run', () => {
5401
6491
 
5402
6492
  const pointer = readiness.states.find((s) => s.id === 'hooks-active-session')
5403
6493
  expect(pointer?.status).toBe('optional-gap')
5404
- expect(pointer?.detail).toMatch(/has not published a live pointer/)
6494
+ expect(pointer?.detail).toMatch(/has not published exact lifecycle state/)
5405
6495
  expect(pointer?.remedy?.summary).toMatch(/send one Claude Code prompt/)
5406
6496
  expect(pointer?.remedy?.by === 'user-here' ? pointer.remedy.command : '').toBe(
5407
6497
  'notifai doctor',
@@ -5447,6 +6537,28 @@ describe('asking before the hooks have ever run', () => {
5447
6537
  expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
5448
6538
  })
5449
6539
 
6540
+ it('diagnoses an older hook install that lacks proactive lifecycle handlers', async () => {
6541
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-missing-lifecycle-'))
6542
+ const io = new PlainInteractiveIo()
6543
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
6544
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6545
+ EXIT.ok,
6546
+ )
6547
+ const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
6548
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
6549
+ hooks: Record<string, unknown>
6550
+ }
6551
+ delete settings.hooks.SessionStart
6552
+ delete settings.hooks.SubagentStart
6553
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
6554
+
6555
+ const readiness = await assessReadiness(deps)
6556
+ const current = readiness.states.find((state) => state.id === 'hooks-stale')
6557
+ expect(current?.status).toBe('gap')
6558
+ expect(current?.detail).toMatch(/missing session-start, subagent-start/i)
6559
+ expect(current?.detail).toMatch(/notifai hooks install/i)
6560
+ })
6561
+
5450
6562
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
5451
6563
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
5452
6564
  const io = new PlainInteractiveIo()
@@ -5598,7 +6710,7 @@ describe('asking before the hooks have ever run', () => {
5598
6710
  expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
5599
6711
  })
5600
6712
 
5601
- it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
6713
+ it('returns a stable exact-session failure when lifecycle context is absent', () => {
5602
6714
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
5603
6715
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
5604
6716
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
@@ -5610,13 +6722,18 @@ describe('asking before the hooks have ever run', () => {
5610
6722
  const io = new CapturedIo()
5611
6723
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_STATE_HOME: cwd } }
5612
6724
 
5613
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5614
- const said = io.errLines.join(' ')
5615
- expect(said).toMatch(/project hook files reload without a restart/i)
5616
- expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
6725
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6726
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6727
+ ok: false,
6728
+ registered: false,
6729
+ code: 'session_identity_missing',
6730
+ check_id: 'exact_session',
6731
+ exit_code: EXIT.usage,
6732
+ remedy: expect.stringMatching(/exact session|blocking/i),
6733
+ })
5617
6734
  })
5618
6735
 
5619
- it('gives Cursor its prompt-and-doctor activation path', () => {
6736
+ it('does not guess a Cursor conversation when lifecycle context is absent', () => {
5620
6737
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
5621
6738
  const io = new CapturedIo()
5622
6739
  const deps = {
@@ -5628,11 +6745,14 @@ describe('asking before the hooks have ever run', () => {
5628
6745
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
5629
6746
  io.outLines = []
5630
6747
 
5631
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5632
- expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
6748
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6749
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6750
+ code: 'session_identity_missing',
6751
+ check_id: 'exact_session',
6752
+ })
5633
6753
  })
5634
6754
 
5635
- it('keeps OpenCode activation separate from unsupported answer continuation', () => {
6755
+ it('does not infer OpenCode from an installed adapter alone', () => {
5636
6756
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
5637
6757
  const io = new CapturedIo()
5638
6758
  const deps = {
@@ -5647,13 +6767,14 @@ describe('asking before the hooks have ever run', () => {
5647
6767
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
5648
6768
  io.outLines = []
5649
6769
 
5650
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5651
- const said = io.errLines.join(' ')
5652
- expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
5653
- expect(said).toContain('non-blocking question continuation is intentionally unsupported')
6770
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6771
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6772
+ code: 'session_identity_missing',
6773
+ check_id: 'exact_session',
6774
+ })
5654
6775
  })
5655
6776
 
5656
- it('says to install when nothing is installed at all', () => {
6777
+ it('does not turn missing harness identity into an installation guess', () => {
5657
6778
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
5658
6779
  const io = new CapturedIo()
5659
6780
  const deps = {
@@ -5668,8 +6789,11 @@ describe('asking before the hooks have ever run', () => {
5668
6789
  },
5669
6790
  }
5670
6791
 
5671
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5672
- expect(io.errLines.join(' ')).toMatch(/hooks install/)
6792
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6793
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6794
+ code: 'session_identity_missing',
6795
+ check_id: 'exact_session',
6796
+ })
5673
6797
  })
5674
6798
  })
5675
6799