@raidiant/notifai 7.0.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -42,6 +42,7 @@ import {
42
42
  configUnsetCommand,
43
43
  contradictingAnswer,
44
44
  describeHookFailure,
45
+ devicesCommand,
45
46
  doctorCommand,
46
47
  EXIT,
47
48
  hooksInstallCommand,
@@ -62,17 +63,26 @@ import {
62
63
  import {
63
64
  applyPlan,
64
65
  buildHookConfig,
66
+ hookCommand,
65
67
  codexHookIdentityHash,
66
68
  codexTrustKey,
67
69
  findInstallations,
70
+ handlerEvent,
71
+ settingsFile,
68
72
  } from './install-hooks.js'
69
73
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
70
74
  import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
71
75
  import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
72
- import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
76
+ import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
77
+ import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
78
+ import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
73
79
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
74
80
  import type { Tone } from './ui/theme.js'
75
81
 
82
+ afterEach(() => {
83
+ resetLatestPublishedCliVersionForTest()
84
+ })
85
+
76
86
  /**
77
87
  * The release tag this build pins the agent skill to.
78
88
  *
@@ -129,6 +139,17 @@ const currentCompatibility: CompatibilityResponse = {
129
139
  replacement_available: false,
130
140
  rollout_complete: false,
131
141
  },
142
+ {
143
+ platform: 'android',
144
+ recommended_version: null,
145
+ recommended_build: null,
146
+ minimum_receive_build: null,
147
+ minimum_answer_build: null,
148
+ deprecation: null,
149
+ sunset: null,
150
+ replacement_available: false,
151
+ rollout_complete: false,
152
+ },
132
153
  ],
133
154
  server_capabilities: ['answer', 'agent_acknowledgement'],
134
155
  }
@@ -316,6 +337,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
316
337
  hookAdapterHome: path.join(testRoot, 'home'),
317
338
  cwd: os.tmpdir(),
318
339
  clientFactory: () => withCompatibilityDefaults(client),
340
+ fetchImpl: async () => new Response('{}', { status: 503 }),
319
341
  }
320
342
  }
321
343
 
@@ -370,6 +392,8 @@ const reply: ReplyView = {
370
392
  device_id: 'dev_test',
371
393
  device_name: 'iPhone',
372
394
  text: 'yes, after the migration',
395
+ answers: [],
396
+ source: null,
373
397
  created_at: '2026-08-01T18:01:00.000Z',
374
398
  }
375
399
 
@@ -453,6 +477,93 @@ describe('command contracts', () => {
453
477
  expect(io.outLines[0]).toBe('macos capability contract v1 (payload limit 4096 bytes)')
454
478
  })
455
479
 
480
+ it('passes the selected Android platform through to the capability client', async () => {
481
+ const io = new CapturedIo()
482
+ let requestedPlatform: string | undefined
483
+ const document = CAPABILITIES_V1.describe('android')!
484
+ const client = {
485
+ capabilities: async (platform?: string) => {
486
+ requestedPlatform = platform
487
+ return document
488
+ },
489
+ } as unknown as ApiClient
490
+
491
+ expect(await capabilitiesCommand(makeDeps(io, client), { platform: 'android' })).toBe(
492
+ EXIT.ok,
493
+ )
494
+ expect(requestedPlatform).toBe('android')
495
+ expect(io.outLines[0]).toBe(
496
+ 'android capability contract v1 (payload limit 4096 bytes)',
497
+ )
498
+ })
499
+
500
+ it('filters Device Installations by Android in human and JSON output', async () => {
501
+ const devices = [
502
+ {
503
+ device_id: 'dev_ios',
504
+ display_name: 'iPhone',
505
+ platform: 'ios' as const,
506
+ permission_status: 'authorized',
507
+ registration_healthy: true,
508
+ app_version: '1.0.0',
509
+ app_build: '1',
510
+ os_version: '19',
511
+ capabilities: ['answer'] as const,
512
+ support: currentSupport,
513
+ support_state: 'current' as const,
514
+ derived_status: 'working' as const,
515
+ status_message: null,
516
+ last_seen_at: null,
517
+ },
518
+ {
519
+ device_id: 'dev_android',
520
+ display_name: 'Pixel',
521
+ platform: 'android' as const,
522
+ permission_status: 'authorized',
523
+ registration_healthy: true,
524
+ app_version: '1.0.0',
525
+ app_build: '2',
526
+ os_version: '16',
527
+ capabilities: ['answer'] as const,
528
+ support: currentSupport,
529
+ support_state: 'current' as const,
530
+ derived_status: 'working' as const,
531
+ status_message: null,
532
+ last_seen_at: null,
533
+ },
534
+ ]
535
+ const client = { listDevices: async () => ({ devices }) } as unknown as ApiClient
536
+
537
+ const human = new CapturedIo()
538
+ expect(
539
+ await devicesCommand(makeDeps(human, client), { platform: 'android' }),
540
+ ).toBe(EXIT.ok)
541
+ expect(human.outLines).toEqual(['dev_android Pixel android Working'])
542
+
543
+ const json = new CapturedIo()
544
+ expect(
545
+ await devicesCommand(makeDeps(json, client), { platform: 'android', json: true }),
546
+ ).toBe(EXIT.ok)
547
+ expect(JSON.parse(json.outLines[0]!)['devices']).toEqual([devices[1]])
548
+ })
549
+
550
+ it('rejects an unknown Device Installation platform filter locally', async () => {
551
+ const io = new CapturedIo()
552
+ let calls = 0
553
+ const client = {
554
+ listDevices: async () => {
555
+ calls += 1
556
+ return { devices: [] }
557
+ },
558
+ } as unknown as ApiClient
559
+
560
+ expect(await devicesCommand(makeDeps(io, client), { platform: 'linux' })).toBe(
561
+ EXIT.usage,
562
+ )
563
+ expect(calls).toBe(0)
564
+ expect(io.errLines.join('\n')).toContain('ios or macos or android')
565
+ })
566
+
456
567
  it('rejects an invalid draft before calling submit', async () => {
457
568
  const io = new CapturedIo()
458
569
  let submitCalls = 0
@@ -474,6 +585,188 @@ describe('command contracts', () => {
474
585
  expect(io.errLines.join('\n')).toContain('project')
475
586
  })
476
587
 
588
+ it('prints the exact idempotency key needed after an ambiguous submit failure', async () => {
589
+ const io = new CapturedIo()
590
+ let attemptedKey = ''
591
+ const client = {
592
+ submit: async (body: SubmitNotificationRequestT) => {
593
+ attemptedKey = body.idempotency_key
594
+ throw new NetworkError('Connection closed before a response arrived')
595
+ },
596
+ } as unknown as ApiClient
597
+
598
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
599
+ const deps = makeDeps(io, client)
600
+ deps.env = {
601
+ XDG_CONFIG_HOME: path.join(root, 'config'),
602
+ XDG_STATE_HOME: path.join(root, 'state'),
603
+ }
604
+ deps.logger = createLogger({ env: deps.env, cmd: 'send' })
605
+ expect(
606
+ await sendCommand(deps, {
607
+ kind: 'done',
608
+ title: 'Checks finished',
609
+ body: 'All checks passed.',
610
+ }),
611
+ ).toBe(EXIT.network)
612
+
613
+ expect(attemptedKey).toMatch(/^cli-/)
614
+ expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
615
+ expect(
616
+ readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
617
+ ).toMatchObject({
618
+ idempotency_key: attemptedKey,
619
+ kind: 'done',
620
+ title_chars: 'Checks finished'.length,
621
+ session_id: null,
622
+ })
623
+ rmSync(root, { recursive: true, force: true })
624
+ })
625
+
626
+ it('prints the same generated key for an ambiguous server failure', async () => {
627
+ const io = new CapturedIo()
628
+ let attemptedKey = ''
629
+ const client = {
630
+ submit: async (body: SubmitNotificationRequestT) => {
631
+ attemptedKey = body.idempotency_key
632
+ throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
633
+ },
634
+ } as unknown as ApiClient
635
+
636
+ expect(
637
+ await sendCommand(makeDeps(io, client), {
638
+ kind: 'failed',
639
+ title: 'Validation could not finish',
640
+ body: 'The service failed while accepting the result.',
641
+ }),
642
+ ).toBe(EXIT.network)
643
+ expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
644
+ })
645
+
646
+ it('routes a send with no active Companion devices into the setup coordinator', async () => {
647
+ const io = new CapturedIo()
648
+ let attemptedKey = ''
649
+ const client = {
650
+ submit: async (body: SubmitNotificationRequestT) => {
651
+ attemptedKey = body.idempotency_key
652
+ throw new ApiCallError(
653
+ 409,
654
+ 'no_active_devices',
655
+ 'No enabled active Device Installation can receive this request.',
656
+ 'Open the Companion App and enable notifications.',
657
+ )
658
+ },
659
+ } as unknown as ApiClient
660
+
661
+ expect(
662
+ await sendCommand(makeDeps(io, client), {
663
+ kind: 'done',
664
+ title: 'Checks finished',
665
+ body: 'All checks passed.',
666
+ }),
667
+ ).toBe(EXIT.failed)
668
+
669
+ expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
670
+ expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
671
+ expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
672
+ })
673
+
674
+ it('routes a send from an unpaired machine into the same setup coordinator', async () => {
675
+ const io = new CapturedIo()
676
+ const deps = makeDeps(io, {} as ApiClient)
677
+ deps.store.load = () => null
678
+
679
+ expect(
680
+ await sendCommand(deps, {
681
+ kind: 'done',
682
+ title: 'Checks finished',
683
+ body: 'All checks passed.',
684
+ }),
685
+ ).toBe(EXIT.auth)
686
+
687
+ expect(io.errLines.join('\n')).toMatch(/`notifai init`.*login.*device setup/i)
688
+ })
689
+
690
+ it('authors and validates an Android-specific Notification Request', async () => {
691
+ const io = new CapturedIo()
692
+ let submitted: SubmitNotificationRequestT | undefined
693
+ const client = {
694
+ submit: async (body: SubmitNotificationRequestT) => {
695
+ submitted = body
696
+ return receipt
697
+ },
698
+ } as unknown as ApiClient
699
+
700
+ expect(
701
+ await sendCommand(makeDeps(io, client), {
702
+ kind: 'done',
703
+ title: 'Android build finished',
704
+ body: 'All checks passed.',
705
+ platform: 'android',
706
+ sound: 'none',
707
+ data: ['run_id=42'],
708
+ }),
709
+ ).toBe(EXIT.ok)
710
+ expect(submitted?.draft.platform).toEqual({
711
+ android: { sound: null, custom_data: { run_id: '42' } },
712
+ })
713
+ expect(io.errLines).toEqual([])
714
+ })
715
+
716
+ it('prints Android downgrade warnings before submitting', async () => {
717
+ const io = new CapturedIo()
718
+ let submitCalls = 0
719
+ const client = {
720
+ submit: async () => {
721
+ submitCalls += 1
722
+ return receipt
723
+ },
724
+ } as unknown as ApiClient
725
+
726
+ expect(
727
+ await sendCommand(makeDeps(io, client), {
728
+ kind: 'update',
729
+ title: 'Android evidence',
730
+ body: 'See the attached result.',
731
+ platform: 'android',
732
+ image: ['med_result'],
733
+ threadId: 'android-results',
734
+ }),
735
+ ).toBe(EXIT.ok)
736
+ expect(submitCalls).toBe(1)
737
+ expect(io.errLines).toEqual(
738
+ expect.arrayContaining([
739
+ expect.stringContaining('presentation.media'),
740
+ expect.stringContaining('platform.android.thread_id'),
741
+ ]),
742
+ )
743
+ })
744
+
745
+ it('rejects an Android interruption level before submission', async () => {
746
+ const io = new CapturedIo()
747
+ let submitCalls = 0
748
+ const client = {
749
+ submit: async () => {
750
+ submitCalls += 1
751
+ return receipt
752
+ },
753
+ } as unknown as ApiClient
754
+
755
+ expect(
756
+ await sendCommand(makeDeps(io, client), {
757
+ kind: 'update',
758
+ title: 'Android update',
759
+ body: 'Ready.',
760
+ platform: 'android',
761
+ level: 'active',
762
+ }),
763
+ ).toBe(EXIT.usage)
764
+ expect(submitCalls).toBe(0)
765
+ expect(io.errLines.join('\n')).toContain(
766
+ 'Android does not support caller-selected interruption levels',
767
+ )
768
+ })
769
+
477
770
  it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
478
771
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
479
772
  const io = new CapturedIo()
@@ -557,6 +850,48 @@ describe('command contracts', () => {
557
850
  })
558
851
  })
559
852
 
853
+ it('uses the exact Orca worktree title for an active Codex session send', async () => {
854
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
855
+ const io = new CapturedIo()
856
+ let submitted: SubmitNotificationRequestT | undefined
857
+ const client = {
858
+ submit: async (body: SubmitNotificationRequestT) => {
859
+ submitted = body
860
+ return receipt
861
+ },
862
+ } as unknown as ApiClient
863
+ const worktreeId = `repo-123::${cwd}`
864
+ const deps = {
865
+ ...makeDeps(io, client),
866
+ cwd,
867
+ env: {
868
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
869
+ XDG_STATE_HOME: path.join(cwd, 'state'),
870
+ CODEX_THREAD_ID: 'orca-codex-thread',
871
+ TERM_PROGRAM: 'Orca',
872
+ ORCA_WORKTREE_ID: worktreeId,
873
+ },
874
+ orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
875
+ expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
876
+ return 'Worker - semantic session implementation'
877
+ },
878
+ }
879
+
880
+ expect(
881
+ await sendCommand(deps, {
882
+ title: 'Resolver implemented',
883
+ body: 'The worktree title belongs to every harness Orca starts in it.',
884
+ kind: 'done',
885
+ }),
886
+ ).toBe(EXIT.ok)
887
+
888
+ expect(submitted?.draft.source).toMatchObject({
889
+ session_id: 'orca-codex-thread',
890
+ session_label: 'Worker - semantic session implementation',
891
+ harness: 'codex',
892
+ })
893
+ })
894
+
560
895
  it('falls back safely when Orca returns a private path as its title', async () => {
561
896
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-private-title-'))
562
897
  const io = new CapturedIo()
@@ -801,7 +1136,7 @@ describe('command contracts', () => {
801
1136
  })
802
1137
 
803
1138
  it('keeps only the current public notification-authoring flags', () => {
804
- const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
1139
+ const source = readFileSync(new URL('./program.ts', import.meta.url), 'utf8')
805
1140
  expect(source).not.toContain(".option('--badge")
806
1141
  expect(source).not.toContain(".option('--relevance")
807
1142
  expect(source).not.toContain(".option('--target-content-id")
@@ -823,33 +1158,6 @@ describe('command contracts', () => {
823
1158
  expect(source).not.toContain('Kind profiles apply automatically')
824
1159
  })
825
1160
 
826
- it('rejects a question nobody will wait for', async () => {
827
- // --reply asks; --no-block declares nothing will wait. The answer would be
828
- // captured server-side and then reachable only by hand, so the user taps a
829
- // real button and nothing happens — worse than never asking, because it
830
- // spends their attention and their trust in the channel.
831
- const io = new CapturedIo()
832
- let submitCalls = 0
833
- const client = {
834
- submit: async () => {
835
- submitCalls += 1
836
- return receipt
837
- },
838
- } as unknown as ApiClient
839
-
840
- expect(
841
- await sendCommand(makeDeps(io, client), {
842
- title: 'Question',
843
- body: 'Deploy?',
844
- reply: true,
845
- replyWindow: 3_600,
846
- noBlock: true,
847
- }),
848
- ).toBe(EXIT.usage)
849
- expect(submitCalls).toBe(0)
850
- expect(io.errLines.join('\n')).toContain('notifai ask')
851
- })
852
-
853
1161
  it('maps reply flags into the draft and waits for the answer', async () => {
854
1162
  const io = new CapturedIo()
855
1163
  let submitted: SubmitNotificationRequestT | undefined
@@ -907,7 +1215,7 @@ describe('command contracts', () => {
907
1215
 
908
1216
  expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
909
1217
  expect(io.errLines).toEqual([
910
- 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.',
1218
+ 'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --choice) so it can be answered from the notification.',
911
1219
  ])
912
1220
  })
913
1221
 
@@ -929,7 +1237,7 @@ describe('command contracts', () => {
929
1237
  expect(io.errLines).toEqual([])
930
1238
  })
931
1239
 
932
- it('rejects --reply-choice without the --reply action it configures', async () => {
1240
+ it('rejects --choice without the --reply action it configures', async () => {
933
1241
  const io = new CapturedIo()
934
1242
  let submitCalls = 0
935
1243
  const client = {
@@ -943,12 +1251,12 @@ describe('command contracts', () => {
943
1251
  await sendCommand(makeDeps(io, client), { kind: 'update',
944
1252
  title: 'Deploy?',
945
1253
  body: 'Choose when ready.',
946
- replyChoice: ['Now', 'Later'],
1254
+ choice: ['Now', 'Later'],
947
1255
  }),
948
1256
  ).toBe(EXIT.usage)
949
1257
  expect(submitCalls).toBe(0)
950
1258
  expect(io.errLines).toEqual([
951
- 'Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.',
1259
+ 'Use --reply with --reply-timeout, --reply-window, or --choice.',
952
1260
  ])
953
1261
  })
954
1262
 
@@ -1015,8 +1323,11 @@ describe('command contracts', () => {
1015
1323
  request_id: receipt.request_id,
1016
1324
  sequence: reply.seq,
1017
1325
  device: reply.device_name,
1018
- text: reply.text,
1326
+ text_chars: reply.text.length,
1327
+ answers_count: reply.answers.length,
1019
1328
  })
1329
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
1330
+ expect(raw).not.toContain(reply.text)
1020
1331
  })
1021
1332
 
1022
1333
  it('backs off and retries a transient network error while waiting', async () => {
@@ -1148,13 +1459,14 @@ describe('command contracts', () => {
1148
1459
  })
1149
1460
  })
1150
1461
 
1151
- it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
1462
+ it('prints one combined reply_result object on exit 3, nothing before the wait', async () => {
1152
1463
  const io = new CapturedIo()
1153
1464
  let now = 0
1154
1465
  const client = {
1155
1466
  submit: async () => receipt,
1156
1467
  replies: async (_requestId: string, options: { waitSeconds: number }) => {
1157
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
1468
+ // Nothing on stdout mid-wait: the single object arrives at the end.
1469
+ expect(io.outLines).toHaveLength(0)
1158
1470
  now += options.waitSeconds * 1_000
1159
1471
  return replyResponse()
1160
1472
  },
@@ -1170,11 +1482,12 @@ describe('command contracts', () => {
1170
1482
  json: true,
1171
1483
  }),
1172
1484
  ).toBe(EXIT.noReply)
1173
- expect(io.outLines).toHaveLength(2)
1485
+ expect(io.outLines).toHaveLength(1)
1174
1486
  // `degraded` is part of the shape on every reply wait, not only when it is
1175
1487
  // true: an agent must be able to read it without knowing it might be absent.
1176
- expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
1488
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1177
1489
  type: 'reply_result',
1490
+ receipt,
1178
1491
  request_id: receipt.request_id,
1179
1492
  reply_expires_at: '2026-08-02T18:00:00.000Z',
1180
1493
  replies: [],
@@ -1189,7 +1502,7 @@ describe('command contracts', () => {
1189
1502
  expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
1190
1503
  })
1191
1504
 
1192
- it('prints the receipt and reply result as two NDJSON records when answered', async () => {
1505
+ it('prints one combined reply_result object with the receipt when answered', async () => {
1193
1506
  const io = new CapturedIo()
1194
1507
  const client = {
1195
1508
  submit: async () => receipt,
@@ -1205,10 +1518,10 @@ describe('command contracts', () => {
1205
1518
  json: true,
1206
1519
  }),
1207
1520
  ).toBe(EXIT.ok)
1208
- expect(io.outLines).toHaveLength(2)
1209
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
1210
- expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
1521
+ expect(io.outLines).toHaveLength(1)
1522
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1211
1523
  type: 'reply_result',
1524
+ receipt,
1212
1525
  request_id: receipt.request_id,
1213
1526
  reply_expires_at: '2026-08-02T18:00:00.000Z',
1214
1527
  replies: [reply],
@@ -1220,6 +1533,41 @@ describe('command contracts', () => {
1220
1533
  })
1221
1534
  })
1222
1535
 
1536
+ it('still prints the one reply_result object when the wait itself faults', async () => {
1537
+ const io = new CapturedIo()
1538
+ const client = {
1539
+ submit: async () => receipt,
1540
+ replies: async () => {
1541
+ throw new ApiCallError(404, 'not_found', 'no such request')
1542
+ },
1543
+ } as unknown as ApiClient
1544
+
1545
+ expect(
1546
+ await sendCommand(makeDeps(io, client), {
1547
+ title: 'Question',
1548
+ body: 'Deploy?',
1549
+ reply: true,
1550
+ replyTimeout: 10,
1551
+ json: true,
1552
+ }),
1553
+ ).toBe(EXIT.failed)
1554
+ expect(io.outLines).toHaveLength(1)
1555
+ // The send is durable even though the wait failed; `degraded: true` says
1556
+ // "could not find out", not "no answer".
1557
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1558
+ type: 'reply_result',
1559
+ receipt,
1560
+ request_id: receipt.request_id,
1561
+ replies: [],
1562
+ agent_acknowledgement_required: true,
1563
+ agent_acknowledgement_text_required: true,
1564
+ agent_acknowledgement: null,
1565
+ acknowledgement_command: null,
1566
+ degraded: true,
1567
+ })
1568
+ expect(io.errLines.join('\n')).toContain('reply wait failed')
1569
+ })
1570
+
1223
1571
  it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
1224
1572
  const io = new CapturedIo()
1225
1573
  let submitted: { requestId: string; text: string } | undefined
@@ -1270,6 +1618,10 @@ describe('command contracts', () => {
1270
1618
  'acknowledgement.attempted',
1271
1619
  'acknowledgement.outcome',
1272
1620
  ])
1621
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
1622
+ expect(raw).not.toContain('I will deploy staging now.')
1623
+ expect(events[0]?.data).toMatchObject({ characters: 'I will deploy staging now.'.length })
1624
+ expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
1273
1625
  })
1274
1626
 
1275
1627
  it('clears a matching active-session obligation after acknowledgement success', async () => {
@@ -1488,6 +1840,113 @@ describe('command contracts', () => {
1488
1840
  })
1489
1841
  })
1490
1842
 
1843
+ it('close --pending withdraws an unpushed registration so a later Stop cannot send it', async () => {
1844
+ const io = new CapturedIo()
1845
+ const client = {
1846
+ closeReplies: async () => {
1847
+ throw new Error('unpushed questions must not call the server')
1848
+ },
1849
+ } as unknown as ApiClient
1850
+ const deps = makeDeps(io, client)
1851
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-unpushed-'))
1852
+ deps.cwd = root
1853
+ deps.env = {
1854
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1855
+ XDG_STATE_HOME: path.join(root, 'state'),
1856
+ }
1857
+ writeSessionState('close-unpushed', deps.env, {
1858
+ pending: [{ question: 'Ship it?', question_id: 'q_local' }],
1859
+ })
1860
+ writeProjectSession(root, deps.env, 'close-unpushed', Date.now(), 'codex')
1861
+
1862
+ expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
1863
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1864
+ session_id: 'close-unpushed',
1865
+ withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
1866
+ closed: [],
1867
+ })
1868
+ expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
1869
+ })
1870
+
1871
+ it('close withdraws one unpushed question by the ask id', async () => {
1872
+ const io = new CapturedIo()
1873
+ const client = {
1874
+ closeReplies: async () => {
1875
+ throw new Error('unpushed questions must not call the server')
1876
+ },
1877
+ } as unknown as ApiClient
1878
+ const deps = makeDeps(io, client)
1879
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-one-unpushed-'))
1880
+ deps.cwd = root
1881
+ deps.env = {
1882
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1883
+ XDG_STATE_HOME: path.join(root, 'state'),
1884
+ }
1885
+ writeSessionState('close-one', deps.env, {
1886
+ pending: [
1887
+ { question: 'Ship it?', question_id: 'q_local' },
1888
+ { question: 'Keep this?', question_id: 'q_keep' },
1889
+ ],
1890
+ })
1891
+ writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
1892
+
1893
+ expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
1894
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
1895
+ session_id: 'close-one',
1896
+ withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
1897
+ closed: [],
1898
+ })
1899
+ expect(readSessionState('close-one', deps.env).pending?.map((entry) => entry.question_id)).toEqual([
1900
+ 'q_keep',
1901
+ ])
1902
+ })
1903
+
1904
+ it('close --pending closes live questions and leaves nothing for a later Stop to push', async () => {
1905
+ const io = new CapturedIo()
1906
+ const closed: string[] = []
1907
+ const client = {
1908
+ closeReplies: async (requestId: string) => {
1909
+ closed.push(requestId)
1910
+ return replyResponse([])
1911
+ },
1912
+ } as unknown as ApiClient
1913
+ const deps = makeDeps(io, client)
1914
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-pending-mix-'))
1915
+ deps.cwd = root
1916
+ deps.env = {
1917
+ XDG_CONFIG_HOME: path.join(root, 'config'),
1918
+ XDG_STATE_HOME: path.join(root, 'state'),
1919
+ }
1920
+ writeSessionState('close-mix', deps.env, {
1921
+ pending: [
1922
+ { question: 'Unpushed?', question_id: 'q_local' },
1923
+ {
1924
+ question: 'Already on a device?',
1925
+ request_id: receipt.request_id,
1926
+ collapse_key: 'question-live',
1927
+ device_ids: ['dev_iphone'],
1928
+ },
1929
+ ],
1930
+ })
1931
+ writeProjectSession(root, deps.env, 'close-mix', Date.now(), 'codex')
1932
+
1933
+ expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
1934
+ expect(closed).toEqual([receipt.request_id])
1935
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
1936
+ session_id: 'close-mix',
1937
+ withdrawn: [{ question: 'Unpushed?', question_id: 'q_local' }],
1938
+ closed: [receipt.request_id],
1939
+ })
1940
+ expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
1941
+ })
1942
+
1943
+ it('close requires a request id or --pending', async () => {
1944
+ const io = new CapturedIo()
1945
+ const client = {} as unknown as ApiClient
1946
+ expect(await closeCommand(makeDeps(io, client), undefined, {})).toBe(EXIT.usage)
1947
+ expect(io.errLines.join('\n')).toContain('Pass a request id or --pending.')
1948
+ })
1949
+
1491
1950
  it('passes the replies cursor and prints replies for later retrieval', async () => {
1492
1951
  const io = new CapturedIo()
1493
1952
  let requested: { waitSeconds: number; afterSeq: number } | undefined
@@ -1566,8 +2025,9 @@ describe('command contracts', () => {
1566
2025
  writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
1567
2026
 
1568
2027
  expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
1569
- expect(io.outLines[0]).toBe('pending request req_first')
1570
- expect(io.outLines[1]).toContain('yes, after the migration')
2028
+ expect(io.outLines[0]).toBe('unpushed question: Not yet asked?')
2029
+ expect(io.outLines.join('\n')).toContain('pending request req_first')
2030
+ expect(io.outLines.join('\n')).toContain('yes, after the migration')
1571
2031
  expect(io.outLines.join('\n')).toContain('req_second')
1572
2032
  })
1573
2033
 
@@ -1764,13 +2224,188 @@ describe('command contracts', () => {
1764
2224
  })
1765
2225
  })
1766
2226
 
2227
+ describe('credential origin pinning', () => {
2228
+ it('never sends the machine bearer to a flag or env origin override', async () => {
2229
+ const io = new CapturedIo()
2230
+ const seen: { baseUrl: string; bearer: string | null }[] = []
2231
+ const client = {
2232
+ submit: async () => receipt,
2233
+ } as unknown as ApiClient
2234
+ const deps = makeDeps(io, client)
2235
+ deps.env = { ...deps.env, NOTIFAI_BASE_URL: 'https://attacker.example' }
2236
+ deps.clientFactory = (baseUrl, bearer) => {
2237
+ seen.push({ baseUrl, bearer })
2238
+ return withCompatibilityDefaults(client)
2239
+ }
2240
+
2241
+ expect(
2242
+ await sendCommand(deps, {
2243
+ kind: 'update',
2244
+ title: 'T',
2245
+ body: 'B',
2246
+ baseUrl: 'https://attacker.flag',
2247
+ }),
2248
+ ).toBe(EXIT.ok)
2249
+
2250
+ expect(seen).toEqual([
2251
+ {
2252
+ baseUrl: 'https://test.notifai.invalid',
2253
+ bearer: 'Bearer nfm_mac_test.test-secret',
2254
+ },
2255
+ ])
2256
+ expect(io.errLines.join('\n')).toContain('Ignoring base_url from flag')
2257
+ expect(io.errLines.join('\n')).not.toContain('test-secret')
2258
+ expect(io.errLines.join('\n')).not.toContain('nfm_')
2259
+ expect(io.errLines.join('\n')).not.toContain('https://attacker.example')
2260
+ expect(io.errLines.join('\n')).not.toContain('https://attacker.flag')
2261
+ })
2262
+
2263
+ it('lets unsigned-in login target an origin override', async () => {
2264
+ const io = new CapturedIo()
2265
+ const seen: { baseUrl: string; bearer: string | null }[] = []
2266
+ let beginBody: Parameters<ApiClient['beginPairing']>[0] | null = null
2267
+ let now = 0
2268
+ const client = {
2269
+ beginPairing: async (body: Parameters<ApiClient['beginPairing']>[0]) => {
2270
+ beginBody = body
2271
+ return {
2272
+ pairing_id: 'pair_test',
2273
+ code: 'ABCD-EFGH',
2274
+ approve_url: 'https://selfhost.example/pair/ABCD-EFGH',
2275
+ expires_at: new Date(10_000).toISOString(),
2276
+ poll_interval_seconds: 1,
2277
+ }
2278
+ },
2279
+ pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
2280
+ } as unknown as ApiClient
2281
+ const saved: { baseUrl?: string } = {}
2282
+ const deps: CommandDeps = {
2283
+ ...makeDeps(io, client),
2284
+ now: () => now,
2285
+ sleep: async (milliseconds: number) => {
2286
+ now += milliseconds
2287
+ },
2288
+ store: {
2289
+ load: () => null,
2290
+ save: (credential) => {
2291
+ saved.baseUrl = credential.baseUrl
2292
+ },
2293
+ clear: () => {},
2294
+ describe: () => 'test credential store',
2295
+ },
2296
+ clientFactory: (baseUrl, bearer) => {
2297
+ seen.push({ baseUrl, bearer })
2298
+ return withCompatibilityDefaults(client)
2299
+ },
2300
+ }
2301
+
2302
+ expect(await loginCommand(deps, { baseUrl: 'https://selfhost.example', open: false })).toBe(
2303
+ EXIT.ok,
2304
+ )
2305
+ expect(seen[0]).toEqual({ baseUrl: 'https://selfhost.example', bearer: null })
2306
+ expect(saved.baseUrl).toBe('https://selfhost.example')
2307
+ expect(beginBody).toMatchObject({
2308
+ machine_name: expect.any(String),
2309
+ credential_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2310
+ poll_verifier_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2311
+ confirmation_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
2312
+ })
2313
+ expect(io.outLines[1]).toMatch(
2314
+ /^Approve this machine at: https:\/\/selfhost\.example\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
2315
+ )
2316
+ })
2317
+
2318
+ it('stops login when the approving account has no product access', async () => {
2319
+ const io = new CapturedIo()
2320
+ let now = 0
2321
+ const client = {
2322
+ beginPairing: async () => ({
2323
+ pairing_id: 'pair_test',
2324
+ code: 'ABCD-EFGH',
2325
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2326
+ expires_at: new Date(10_000).toISOString(),
2327
+ poll_interval_seconds: 1,
2328
+ }),
2329
+ pollPairing: async () => ({
2330
+ status: 'no_active_plan',
2331
+ next_action: 'Ask the account owner for Alpha access.',
2332
+ }),
2333
+ } as unknown as ApiClient
2334
+ const deps: CommandDeps = {
2335
+ ...makeDeps(io, client),
2336
+ now: () => now,
2337
+ sleep: async (milliseconds: number) => {
2338
+ now += milliseconds
2339
+ },
2340
+ }
2341
+
2342
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
2343
+ expect(io.errLines).toEqual([
2344
+ 'This account has no active plan or temporary Alpha access.',
2345
+ 'next: Ask the account owner for Alpha access.',
2346
+ 'After access is granted, run `notifai login` again.',
2347
+ ])
2348
+ })
2349
+
2350
+ it('keeps notification titles out of send logs', async () => {
2351
+ const io = new CapturedIo()
2352
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-log-'))
2353
+ const client = { submit: async () => receipt } as unknown as ApiClient
2354
+ const deps = makeDeps(io, client)
2355
+ deps.env = {
2356
+ XDG_CONFIG_HOME: path.join(root, 'config'),
2357
+ XDG_STATE_HOME: path.join(root, 'state'),
2358
+ }
2359
+ deps.logger = createLogger({ env: deps.env, cmd: 'send', settings: { level: 'debug' } })
2360
+
2361
+ expect(
2362
+ await sendCommand(deps, {
2363
+ kind: 'update',
2364
+ title: 'SECRET_SEND_TITLE',
2365
+ body: 'SECRET_SEND_BODY',
2366
+ }),
2367
+ ).toBe(EXIT.ok)
2368
+ const raw = readFileSync(activeLogPath(deps.env), 'utf8')
2369
+ expect(raw).not.toContain('SECRET_SEND_TITLE')
2370
+ expect(raw).not.toContain('SECRET_SEND_BODY')
2371
+ const submitted = readLogRecords(deps.env, { event: ['send.submitted'] }).records
2372
+ expect(submitted[0]?.data).toMatchObject({
2373
+ request_id: receipt.request_id,
2374
+ title_chars: 'SECRET_SEND_TITLE'.length,
2375
+ })
2376
+ })
2377
+
2378
+ it('refuses a shell-escaped backslash-n body unless the literal flag is set', async () => {
2379
+ const io = new CapturedIo()
2380
+ expect(
2381
+ await sendCommand(makeDeps(io, {} as ApiClient), {
2382
+ kind: 'update',
2383
+ title: 'Escaped',
2384
+ body: 'line1\\nline2',
2385
+ }),
2386
+ ).toBe(EXIT.usage)
2387
+ expect(io.errLines.join('\n')).toContain('--body-file -')
2388
+ expect(io.errLines.join('\n')).toContain('--literal-backslash-n')
2389
+
2390
+ io.errLines = []
2391
+ const client = { submit: async () => receipt } as unknown as ApiClient
2392
+ expect(
2393
+ await sendCommand(makeDeps(io, client), {
2394
+ kind: 'update',
2395
+ title: 'Escaped',
2396
+ body: 'line1\\nline2',
2397
+ literalBackslashN: true,
2398
+ }),
2399
+ ).toBe(EXIT.ok)
2400
+ })
2401
+ })
2402
+
1767
2403
  describe('delivery evidence status', () => {
1768
2404
  function snapshot(
1769
2405
  companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
1770
2406
  ): EvidenceSnapshot {
1771
2407
  return {
1772
2408
  request_id: 'req_status_test',
1773
- event: 'tests_passed',
1774
2409
  accepted_at: '2026-08-05T13:05:48.000Z',
1775
2410
  overall: 'provider_accepted_all',
1776
2411
  deliveries: [
@@ -1866,6 +2501,53 @@ describe('delivery evidence status', () => {
1866
2501
  expect(said).toContain('Reply received: yes')
1867
2502
  expect(said).toContain('answers are on the server')
1868
2503
  })
2504
+
2505
+ it('treats an ordinary status Companion Receipt as doctor delivery proof', async () => {
2506
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-status-proof-'))
2507
+ const evidence = snapshot({
2508
+ state: 'observed',
2509
+ observed_at: '2026-08-05T13:17:17.000Z',
2510
+ latency_ms: 1_000,
2511
+ })
2512
+ const device = {
2513
+ device_id: evidence.deliveries[0]!.device_id,
2514
+ display_name: 'iPhone',
2515
+ platform: 'ios' as const,
2516
+ permission_status: 'authorized',
2517
+ registration_healthy: true,
2518
+ app_version: '0.1.0',
2519
+ app_build: '42',
2520
+ os_version: '19.0',
2521
+ capabilities: ['answer'] as const,
2522
+ support: currentSupport,
2523
+ support_state: 'current' as const,
2524
+ derived_status: 'working' as const,
2525
+ status_message: null,
2526
+ last_seen_at: '2026-08-05T18:00:00.000Z',
2527
+ }
2528
+ const client = {
2529
+ health: async () => true,
2530
+ listDevices: async () => ({ devices: [device] }),
2531
+ accessStatus: async () => ({
2532
+ status: 'active',
2533
+ reason: 'alpha_grant',
2534
+ expires_at: null,
2535
+ email: 'proof@example.com',
2536
+ }),
2537
+ evidence: async () => evidence,
2538
+ } as unknown as ApiClient
2539
+ const io = new CapturedIo()
2540
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
2541
+
2542
+ expect(await statusCommand(deps, evidence.request_id, { json: true })).toBe(EXIT.ok)
2543
+
2544
+ const doctorIo = new CapturedIo()
2545
+ expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.ok)
2546
+ const report = JSON.parse(doctorIo.outLines.join('\n')) as {
2547
+ states: { id: string; status: string }[]
2548
+ }
2549
+ expect(report.states.find((state) => state.id === 'proof')).toMatchObject({ status: 'ready' })
2550
+ })
1869
2551
  })
1870
2552
 
1871
2553
  describe('Cursor hook commands', () => {
@@ -1891,12 +2573,17 @@ describe('Cursor hook commands', () => {
1891
2573
  expect(Object.keys(installed.hooks).sort()).toEqual([
1892
2574
  'beforeSubmitPrompt',
1893
2575
  'sessionEnd',
2576
+ 'sessionStart',
1894
2577
  'stop',
1895
2578
  ])
1896
2579
  expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
1897
2580
  'hook user-prompt-submit --owner notifai --harness cursor',
1898
2581
  )
1899
2582
  expect(installed.hooks['stop']?.[0]).toMatchObject({
2583
+ command: expect.stringContaining('hook activation-stop --owner notifai --harness cursor'),
2584
+ loop_limit: 1,
2585
+ })
2586
+ expect(installed.hooks['stop']?.[1]).toMatchObject({
1900
2587
  command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
1901
2588
  loop_limit: 3,
1902
2589
  })
@@ -1932,7 +2619,7 @@ describe('Cursor hook commands', () => {
1932
2619
  expect(io.outLines).toContain(
1933
2620
  `ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
1934
2621
  )
1935
- expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
2622
+ expect(io.outLines.some((line) => line.includes('Cursor: start one fresh conversation'))).toBe(true)
1936
2623
  })
1937
2624
 
1938
2625
  it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
@@ -2011,13 +2698,7 @@ describe('Codex hook representation', () => {
2011
2698
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
2012
2699
  })
2013
2700
 
2014
- /**
2015
- * The foreign `hooks.json` stays byte-identical and Notifai lands inline.
2016
- * Codex will now warn that the layer uses both representations; the install
2017
- * output says why, because that warning is Codex reporting a merge, not a
2018
- * fault, and a silent one would read as Notifai's bug.
2019
- */
2020
- it('leaves a foreign hooks.json alone and installs inline instead', () => {
2701
+ it('joins a populated hooks.json without introducing a second representation', () => {
2021
2702
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
2022
2703
  const layer = path.join(cwd, '.codex')
2023
2704
  const json = path.join(layer, 'hooks.json')
@@ -2035,17 +2716,12 @@ describe('Codex hook representation', () => {
2035
2716
 
2036
2717
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2037
2718
 
2038
- const beforeJson = readFileSync(json, 'utf8')
2039
- expect(beforeJson).toContain('gdh-stop')
2040
- expect(beforeJson).not.toContain('--owner notifai')
2041
- const after = readFileSync(toml, 'utf8')
2042
- // The user's file arrives back as the file they wrote, plus our hooks.
2043
- expect(after).toContain(beforeToml.trimEnd())
2044
- expect(after).toContain('--owner notifai')
2045
- expect(io.outLines.join('\n')).toContain(toml)
2046
- expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
2047
- expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
2048
- expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
2719
+ const afterJson = readFileSync(json, 'utf8')
2720
+ expect(afterJson).toContain('gdh-stop')
2721
+ expect(afterJson).toContain('--owner notifai')
2722
+ expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
2723
+ expect(io.outLines.join('\n')).toContain(json)
2724
+ expect(io.outLines.join('\n')).not.toMatch(/both representations|not a Notifai fault/i)
2049
2725
  })
2050
2726
 
2051
2727
  it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
@@ -2062,6 +2738,50 @@ describe('Codex hook representation', () => {
2062
2738
  expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
2063
2739
  })
2064
2740
 
2741
+ it('leaves config.toml byte-identical on a no-op reinstall', () => {
2742
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-noop-install-'))
2743
+ const io = new CapturedIo()
2744
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2745
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2746
+ const toml = path.join(cwd, '.codex', 'config.toml')
2747
+ const before = readFileSync(toml, 'utf8')
2748
+ io.outLines = []
2749
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2750
+ expect(readFileSync(toml, 'utf8')).toBe(before)
2751
+ })
2752
+
2753
+ it('deletes an emptied hooks.json instead of leaving an empty residue', () => {
2754
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-empty-json-'))
2755
+ const layer = path.join(cwd, '.codex')
2756
+ const json = path.join(layer, 'hooks.json')
2757
+ mkdirSync(layer, { recursive: true })
2758
+ applyPlan(json, {
2759
+ hooks: {
2760
+ Stop: [{ hooks: [{ type: 'command', command: hookCommand(scriptPath, 'stop', 'codex') }] }],
2761
+ },
2762
+ })
2763
+ const io = new CapturedIo()
2764
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2765
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
2766
+ expect(existsSync(json)).toBe(false)
2767
+ })
2768
+
2769
+ it('global install does not leave a dead project .codex after uninstalling that layer', () => {
2770
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-global-migrate-'))
2771
+ const io = new CapturedIo()
2772
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
2773
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2774
+ expect(existsSync(path.join(cwd, '.codex'))).toBe(true)
2775
+ expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
2776
+ EXIT.ok,
2777
+ )
2778
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
2779
+ const leftover = existsSync(path.join(cwd, '.codex'))
2780
+ ? readdirSync(path.join(cwd, '.codex'))
2781
+ : []
2782
+ expect(leftover.every((name) => name !== 'hooks.json')).toBe(true)
2783
+ })
2784
+
2065
2785
  /**
2066
2786
  * Notifai in one file, someone else's hooks in the other: every handler
2067
2787
  * fires once, so doctor has nothing to report and the foreign file is not
@@ -2155,7 +2875,7 @@ describe('harness activation guidance', () => {
2155
2875
  const execPath = process.execPath
2156
2876
  const scriptPath = fileURLToPath(import.meta.url)
2157
2877
 
2158
- it('does not require a Claude Code restart for project hook files', () => {
2878
+ it('requires a fresh Claude Code session so SessionStart can activate it', () => {
2159
2879
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
2160
2880
  const io = new CapturedIo()
2161
2881
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
@@ -2166,11 +2886,11 @@ describe('harness activation guidance', () => {
2166
2886
 
2167
2887
  const output = io.outLines.join('\n')
2168
2888
  expect(output).toContain('Installed claude-code hooks in')
2169
- expect(output).toContain('Send one Claude Code prompt, then check `notifai doctor`.')
2889
+ expect(output).toContain('Start one fresh Claude Code session, send one prompt, then run `notifai doctor`.')
2170
2890
  expect(output).not.toMatch(/timeout|asynchronous|600s/i)
2171
2891
  })
2172
2892
 
2173
- it('does not invent a Codex hook trust gate', () => {
2893
+ it('names Codex trust and fresh-session activation in the correct order', () => {
2174
2894
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
2175
2895
  const io = new CapturedIo()
2176
2896
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
@@ -2180,8 +2900,8 @@ describe('harness activation guidance', () => {
2180
2900
  )
2181
2901
 
2182
2902
  const output = io.outLines.join('\n')
2183
- expect(output).toContain('Send one Codex prompt, then check `notifai doctor`.')
2184
- expect(output).not.toMatch(/trust|approve/i)
2903
+ expect(output).toContain('Approve the Notifai handlers in `/hooks` if Codex asks')
2904
+ expect(output).toMatch(/approve[\s\S]*start one fresh Codex session[\s\S]*`notifai doctor`/i)
2185
2905
  })
2186
2906
 
2187
2907
  it('names the installed harness in the close, never a different one', () => {
@@ -2193,7 +2913,7 @@ describe('harness activation guidance', () => {
2193
2913
 
2194
2914
  const output = io.outLines.join('\n')
2195
2915
  expect(output).toContain('Installed cursor hooks in')
2196
- expect(output).toContain('Send one Cursor prompt, then check `notifai doctor`.')
2916
+ expect(output).toContain('Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.')
2197
2917
  expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
2198
2918
  expect(output).not.toMatch(/timeout|worktree|fails closed/i)
2199
2919
  })
@@ -2256,7 +2976,7 @@ describe('harness activation guidance', () => {
2256
2976
  )
2257
2977
 
2258
2978
  expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
2259
- expect(io.outLines.join('\n')).toContain('Send one OpenCode prompt, then check `notifai doctor`.')
2979
+ expect(io.outLines.join('\n')).toContain('Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.')
2260
2980
  expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
2261
2981
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
2262
2982
  const plugin = readFileSync(pluginFile, 'utf8')
@@ -2401,6 +3121,141 @@ describe('stable hook installation', () => {
2401
3121
  findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
2402
3122
  ).toEqual([expect.objectContaining({ global: true })])
2403
3123
  })
3124
+
3125
+ it('refreshes stale global lifecycle coverage instead of declining a project install', () => {
3126
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-stale-'))
3127
+ const io = new CapturedIo()
3128
+ const env = isolatedEnv(cwd)
3129
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3130
+ const execPath = process.execPath
3131
+ const scriptPath = fileURLToPath(import.meta.url)
3132
+
3133
+ expect(
3134
+ hooksInstallCommand(deps, {
3135
+ harness: 'cursor',
3136
+ global: true,
3137
+ execPath,
3138
+ scriptPath,
3139
+ }),
3140
+ ).toBe(EXIT.ok)
3141
+ const globalFile = settingsFile('cursor', true, cwd, env)
3142
+ const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3143
+ hooks: { stop: Array<{ command?: string }> }
3144
+ }
3145
+ settings.hooks.stop = settings.hooks.stop.filter(
3146
+ (handler) => !handler.command?.includes(' hook activation-stop '),
3147
+ )
3148
+ writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
3149
+
3150
+ io.outLines = []
3151
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
3152
+ expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
3153
+ const installation = findInstallations(cwd, env, deps.hookAdapterHome).find(
3154
+ (item) => item.harness === 'cursor' && item.global,
3155
+ )
3156
+ expect(installation?.handlers.map((handler) => handlerEvent(handler.command))).toContain(
3157
+ 'activation-stop',
3158
+ )
3159
+ expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
3160
+ })
3161
+
3162
+ it('refreshes a stale global Stop shape instead of declining a project install', () => {
3163
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-shape-'))
3164
+ const io = new CapturedIo()
3165
+ const env = isolatedEnv(cwd)
3166
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3167
+ const execPath = process.execPath
3168
+ const scriptPath = fileURLToPath(import.meta.url)
3169
+
3170
+ expect(
3171
+ hooksInstallCommand(deps, {
3172
+ harness: 'cursor',
3173
+ global: true,
3174
+ execPath,
3175
+ scriptPath,
3176
+ }),
3177
+ ).toBe(EXIT.ok)
3178
+ const globalFile = settingsFile('cursor', true, cwd, env)
3179
+ const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3180
+ hooks: { stop: Array<{ command?: string; timeout?: number }> }
3181
+ }
3182
+ const stop = settings.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))
3183
+ expect(stop).toBeDefined()
3184
+ stop!.timeout = 1
3185
+ writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
3186
+
3187
+ io.outLines = []
3188
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
3189
+ expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
3190
+ const refreshed = JSON.parse(readFileSync(globalFile, 'utf8')) as {
3191
+ hooks: { stop: Array<{ command?: string; timeout?: number }> }
3192
+ }
3193
+ expect(
3194
+ refreshed.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))?.timeout,
3195
+ ).toBeGreaterThan(1)
3196
+ expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
3197
+ })
3198
+
3199
+ it('consolidates duplicate global Codex representations before declining a project install', () => {
3200
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-codex-duplicate-'))
3201
+ const io = new CapturedIo()
3202
+ const env = isolatedEnv(cwd)
3203
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3204
+ const execPath = process.execPath
3205
+ const scriptPath = fileURLToPath(import.meta.url)
3206
+
3207
+ expect(
3208
+ hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
3209
+ ).toBe(EXIT.ok)
3210
+ const json = path.join(env.CODEX_HOME!, 'hooks.json')
3211
+ applyPlan(json, {
3212
+ hooks: {
3213
+ ...buildHookConfig({ adapterPath: hookAdapterPath(deps.hookAdapterHome), harness: 'codex' }),
3214
+ PostToolUse: [{ hooks: [{ type: 'command', command: 'foreign-post-tool' }] }],
3215
+ },
3216
+ })
3217
+
3218
+ io.outLines = []
3219
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3220
+ expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
3221
+ expect(readFileSync(json, 'utf8')).toContain('foreign-post-tool')
3222
+ expect(readFileSync(json, 'utf8')).not.toContain('--owner notifai')
3223
+ expect(
3224
+ findInstallations(cwd, env, deps.hookAdapterHome).filter(
3225
+ (installation) => installation.harness === 'codex',
3226
+ ),
3227
+ ).toEqual([expect.objectContaining({ global: true })])
3228
+ expect(io.outLines.join('\n')).toMatch(/Installed codex hooks/)
3229
+ })
3230
+
3231
+ it('refreshes an obsolete global OpenCode plugin instead of treating empty event requirements as coverage', () => {
3232
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-opencode-stale-'))
3233
+ const io = new CapturedIo()
3234
+ const env = isolatedEnv(cwd)
3235
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3236
+ const execPath = process.execPath
3237
+ const scriptPath = fileURLToPath(import.meta.url)
3238
+
3239
+ expect(
3240
+ hooksInstallCommand(deps, {
3241
+ harness: 'opencode',
3242
+ global: true,
3243
+ execPath,
3244
+ scriptPath,
3245
+ }),
3246
+ ).toBe(EXIT.ok)
3247
+ const globalFile = settingsFile('opencode', true, cwd, env)
3248
+ const current = readFileSync(globalFile, 'utf8')
3249
+ writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 10', 'const ADAPTER_VERSION = 8'))
3250
+
3251
+ io.outLines = []
3252
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3253
+ expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
3254
+ const refreshed = readFileSync(globalFile, 'utf8')
3255
+ expect(refreshed).toContain('const ADAPTER_VERSION = 10')
3256
+ expect(refreshed).toContain('experimental.chat.system.transform')
3257
+ expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
3258
+ })
2404
3259
  })
2405
3260
 
2406
3261
  describe('projectSlugFrom', () => {
@@ -2736,7 +3591,7 @@ describe('interactive command UX', () => {
2736
3591
  beginPairing: async () => ({
2737
3592
  pairing_id: 'pair_test',
2738
3593
  code: 'ABCD-EFGH',
2739
- approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
3594
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2740
3595
  expires_at: new Date(10_000).toISOString(),
2741
3596
  poll_interval_seconds: 1,
2742
3597
  }),
@@ -2766,7 +3621,9 @@ describe('interactive command UX', () => {
2766
3621
  expect(io.notes).toEqual([
2767
3622
  {
2768
3623
  title: 'Approve this machine',
2769
- message: 'Code: ABCD-EFGH\nhttps://test.notifai.invalid/pair/ABCD-EFGH',
3624
+ message: expect.stringMatching(
3625
+ /^Code: ABCD-EFGH\nhttps:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
3626
+ ),
2770
3627
  },
2771
3628
  ])
2772
3629
  expect(io.spinnerEvents).toEqual([
@@ -2785,7 +3642,7 @@ describe('interactive command UX', () => {
2785
3642
  beginPairing: async () => ({
2786
3643
  pairing_id: 'pair_test',
2787
3644
  code: 'ABCD-EFGH',
2788
- approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
3645
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2789
3646
  expires_at: new Date(10_000).toISOString(),
2790
3647
  poll_interval_seconds: 1,
2791
3648
  }),
@@ -2802,7 +3659,9 @@ describe('interactive command UX', () => {
2802
3659
  expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
2803
3660
  expect(io.outLines.slice(0, 3)).toEqual([
2804
3661
  'Pairing code: ABCD-EFGH',
2805
- 'Approve this machine at: https://test.notifai.invalid/pair/ABCD-EFGH',
3662
+ expect.stringMatching(
3663
+ /^Approve this machine at: https:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
3664
+ ),
2806
3665
  'Waiting for approval…',
2807
3666
  ])
2808
3667
  })
@@ -2943,6 +3802,26 @@ describe('interactive command UX', () => {
2943
3802
  expect(io.intros).toEqual([])
2944
3803
  expect(io.checks).toEqual([])
2945
3804
  })
3805
+
3806
+ it('consults npm latest from an interactive doctor without failing the run', async () => {
3807
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-registry-'))
3808
+ const io = new InteractiveIo()
3809
+ const client = {
3810
+ health: async () => true,
3811
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3812
+ } as unknown as ApiClient
3813
+ const deps: CommandDeps = {
3814
+ ...makeDeps(io, client),
3815
+ cwd,
3816
+ env: isolatedEnv(cwd),
3817
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3818
+ fetchImpl: async () => new Response(JSON.stringify({ latest: '99.0.0' }), { status: 200 }),
3819
+ }
3820
+
3821
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
3822
+ expect(io.outLines).toContain('A newer Notifai is available.')
3823
+ expect(io.outLines).toContain('npm install -g @raidiant/notifai')
3824
+ })
2946
3825
  })
2947
3826
 
2948
3827
  describe('compatibility-first update guidance', () => {
@@ -3175,15 +4054,21 @@ describe('init', () => {
3175
4054
  display_name: 'Mac',
3176
4055
  platform: 'macos' as const,
3177
4056
  }
4057
+ const readyAndroid = {
4058
+ ...readyIphone,
4059
+ device_id: 'dev_android',
4060
+ display_name: 'Pixel',
4061
+ platform: 'android' as const,
4062
+ os_version: '16',
4063
+ }
3178
4064
 
3179
4065
  function setupEvidence(
3180
4066
  requestId: string,
3181
4067
  companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
3182
- device = readyIphone,
4068
+ device: typeof readyIphone | typeof readyAndroid = readyIphone,
3183
4069
  ): EvidenceSnapshot {
3184
4070
  return {
3185
4071
  request_id: requestId,
3186
- event: 'setup_verified',
3187
4072
  accepted_at: '2026-08-05T18:00:00.000Z',
3188
4073
  overall: 'provider_accepted_all',
3189
4074
  deliveries: [
@@ -3215,15 +4100,18 @@ describe('init', () => {
3215
4100
  }
3216
4101
  }
3217
4102
 
3218
- function setupReceipt(requestId = 'req_setup'): SubmissionReceipt {
4103
+ function setupReceipt(
4104
+ requestId = 'req_setup',
4105
+ device: typeof readyIphone | typeof readyAndroid = readyIphone,
4106
+ ): SubmissionReceipt {
3219
4107
  return {
3220
4108
  ...receipt,
3221
4109
  request_id: requestId,
3222
4110
  deliveries: [
3223
4111
  {
3224
4112
  ...receipt.deliveries[0]!,
3225
- device_id: readyIphone.device_id,
3226
- device_name: readyIphone.display_name,
4113
+ device_id: device.device_id,
4114
+ device_name: device.display_name,
3227
4115
  },
3228
4116
  ],
3229
4117
  }
@@ -3418,18 +4306,22 @@ describe('init', () => {
3418
4306
  } as unknown as ApiClient
3419
4307
  const nativeSkills: NativeSkills = {
3420
4308
  add: async () => 0,
4309
+ remove: async () => 0,
3421
4310
  list: async (scope) => ({
3422
- skills: [
3423
- {
3424
- name: 'notifai',
3425
- scope,
3426
- path: path.join(cwd, '.agents', 'skills', 'notifai'),
3427
- source: 'Raidiant-io/notifai',
3428
- sourceType: 'github',
3429
- sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
3430
- ref: RELEASE_REF,
3431
- },
3432
- ],
4311
+ skills:
4312
+ scope === 'project'
4313
+ ? [
4314
+ {
4315
+ name: 'notifai',
4316
+ scope,
4317
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
4318
+ source: 'Raidiant-io/notifai',
4319
+ sourceType: 'github',
4320
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
4321
+ ref: RELEASE_REF,
4322
+ },
4323
+ ]
4324
+ : [],
3433
4325
  }),
3434
4326
  }
3435
4327
  const readiness = await assessReadiness(
@@ -3455,51 +4347,242 @@ describe('init', () => {
3455
4347
  const calls: SkillScope[] = []
3456
4348
  const nativeSkills: NativeSkills = {
3457
4349
  add: async () => 0,
4350
+ remove: async () => 0,
3458
4351
  list: async (selected) => {
3459
4352
  calls.push(selected)
3460
- return { skills: [managedSkill(selected, cwd)] }
4353
+ return { skills: selected === scope ? [managedSkill(selected, cwd)] : [] }
3461
4354
  },
3462
4355
  }
3463
4356
 
3464
- const readiness = await assessReadiness(
3465
- { ...makeDeps(io, client), cwd, nativeSkills },
3466
- { skillScope: scope },
3467
- )
3468
- expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
3469
- status: 'ready',
3470
- detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
3471
- })
3472
- expect(calls).toEqual([scope])
3473
- },
3474
- )
4357
+ const readiness = await assessReadiness(
4358
+ { ...makeDeps(io, client), cwd, nativeSkills },
4359
+ { skillScope: scope },
4360
+ )
4361
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
4362
+ status: 'ready',
4363
+ detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
4364
+ })
4365
+ expect(calls).toEqual(['project', 'global'])
4366
+ },
4367
+ )
4368
+
4369
+ it.each(['project', 'global'] as const)(
4370
+ 'does not trust unmanaged same-path content in the %s scope',
4371
+ async (scope) => {
4372
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-unmanaged-${scope}-`))
4373
+ const io = new CapturedIo()
4374
+ const client = {
4375
+ health: async () => true,
4376
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4377
+ listDevices: async () => ({ devices: [] }),
4378
+ } as unknown as ApiClient
4379
+ const nativeSkills: NativeSkills = {
4380
+ add: async () => 0,
4381
+ remove: async () => 0,
4382
+ list: async (selected) => ({
4383
+ skills:
4384
+ selected === scope
4385
+ ? [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }]
4386
+ : [],
4387
+ }),
4388
+ }
4389
+
4390
+ const readiness = await assessReadiness(
4391
+ { ...makeDeps(io, client), cwd, nativeSkills },
4392
+ { skillScope: scope },
4393
+ )
4394
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
4395
+ status: 'optional-gap',
4396
+ detail: `not installed from ${SKILLS_SOURCE} in ${scope} scope`,
4397
+ })
4398
+ },
4399
+ )
4400
+
4401
+ it('reports a duplicate notifai skill across project and global scope', async () => {
4402
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-duplicate-'))
4403
+ const io = new CapturedIo()
4404
+ const client = {
4405
+ health: async () => true,
4406
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4407
+ listDevices: async () => ({ devices: [] }),
4408
+ } as unknown as ApiClient
4409
+ const nativeSkills: NativeSkills = {
4410
+ add: async () => 0,
4411
+ remove: async () => 0,
4412
+ list: async (scope) => ({
4413
+ skills: [
4414
+ {
4415
+ ...managedSkill(scope, cwd),
4416
+ ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
4417
+ path:
4418
+ scope === 'project'
4419
+ ? path.join(cwd, '.agents', 'skills', 'notifai')
4420
+ : path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
4421
+ },
4422
+ ],
4423
+ }),
4424
+ }
4425
+
4426
+ const readiness = await assessReadiness({ ...makeDeps(io, client), cwd, nativeSkills })
4427
+ const skill = readiness.states.find((state) => state.id === 'skill')
4428
+ expect(skill).toMatchObject({
4429
+ status: 'gap',
4430
+ detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
4431
+ technical: {
4432
+ project: {
4433
+ ref: RELEASE_REF,
4434
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
4435
+ current: true,
4436
+ },
4437
+ global: {
4438
+ ref: 'v0.2.1',
4439
+ path: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
4440
+ current: false,
4441
+ },
4442
+ resolution: 'both-listed',
4443
+ },
4444
+ })
4445
+
4446
+ expect(await doctorCommand({ ...makeDeps(io, client), cwd, nativeSkills }, { json: true })).toBe(
4447
+ EXIT.failed,
4448
+ )
4449
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as {
4450
+ ok: boolean
4451
+ states: Array<{ id: string; status: string; detail: string }>
4452
+ }
4453
+ expect(payload.ok).toBe(false)
4454
+ expect(payload.states.find((state) => state.id === 'skill')).toMatchObject({
4455
+ status: 'gap',
4456
+ detail: expect.stringContaining('harness lists both'),
4457
+ })
4458
+ })
4459
+
4460
+ it('does not treat a current pin as ready when a stale duplicate is also installed', async () => {
4461
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-shadow-'))
4462
+ const io = new CapturedIo()
4463
+ const client = {
4464
+ health: async () => true,
4465
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
4466
+ listDevices: async () => ({ devices: [] }),
4467
+ } as unknown as ApiClient
4468
+ const nativeSkills: NativeSkills = {
4469
+ add: async () => 0,
4470
+ remove: async () => 0,
4471
+ list: async (scope) => ({
4472
+ skills: [
4473
+ {
4474
+ ...managedSkill(scope, cwd),
4475
+ ref: scope === 'global' ? RELEASE_REF : 'v0.2.1',
4476
+ },
4477
+ ],
4478
+ }),
4479
+ }
4480
+
4481
+ const readiness = await assessReadiness(
4482
+ { ...makeDeps(io, client), cwd, nativeSkills },
4483
+ { skillScope: 'global' },
4484
+ )
4485
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
4486
+ status: 'gap',
4487
+ detail: `project (v0.2.1) and global (${RELEASE_REF}) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
4488
+ })
4489
+ })
4490
+
4491
+ it('uninstalls the other skill scope before installing the chosen one', async () => {
4492
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-prevented-'))
4493
+ const io = new CapturedIo()
4494
+ const removed: SkillScope[] = []
4495
+ const added: Array<SkillScope | undefined> = []
4496
+ let globalPresent = true
4497
+ const nativeSkills: NativeSkills = {
4498
+ add: async (options) => {
4499
+ added.push(options.scope)
4500
+ return 0
4501
+ },
4502
+ remove: async (options) => {
4503
+ removed.push(options.scope)
4504
+ if (options.scope === 'global') globalPresent = false
4505
+ return 0
4506
+ },
4507
+ list: async (scope) => ({
4508
+ skills: scope === 'global' && globalPresent ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
4509
+ }),
4510
+ }
4511
+
4512
+ expect(
4513
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4514
+ skills: true,
4515
+ setupScope: 'project',
4516
+ hooks: false,
4517
+ }),
4518
+ ).toBe(EXIT.ok)
4519
+ expect(removed).toEqual(['global'])
4520
+ expect(added).toEqual(['project'])
4521
+ })
3475
4522
 
3476
- it.each(['project', 'global'] as const)(
3477
- 'does not trust unmanaged same-path content in the %s scope',
3478
- async (scope) => {
3479
- const cwd = mkdtempSync(path.join(os.tmpdir(), `init-unmanaged-${scope}-`))
3480
- const io = new CapturedIo()
3481
- const client = {
3482
- health: async () => true,
3483
- capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3484
- listDevices: async () => ({ devices: [] }),
3485
- } as unknown as ApiClient
3486
- const nativeSkills: NativeSkills = {
3487
- add: async () => 0,
3488
- list: async (selected) => ({
3489
- skills: [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }],
3490
- }),
3491
- }
4523
+ it('clears a pre-existing skill duplicate by keeping the chosen setup scope', async () => {
4524
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-clear-'))
4525
+ const io = new CapturedIo()
4526
+ const removed: SkillScope[] = []
4527
+ const present = new Set<SkillScope>(['project', 'global'])
4528
+ const nativeSkills: NativeSkills = {
4529
+ add: async (options) => {
4530
+ if (options.scope !== undefined) present.add(options.scope)
4531
+ return 0
4532
+ },
4533
+ remove: async (options) => {
4534
+ removed.push(options.scope)
4535
+ present.delete(options.scope)
4536
+ return 0
4537
+ },
4538
+ list: async (scope) => ({
4539
+ skills: present.has(scope)
4540
+ ? [
4541
+ {
4542
+ ...managedSkill(scope, cwd),
4543
+ ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
4544
+ },
4545
+ ]
4546
+ : [],
4547
+ }),
4548
+ }
3492
4549
 
3493
- const readiness = await assessReadiness(
3494
- { ...makeDeps(io, client), cwd, nativeSkills },
3495
- { skillScope: scope },
3496
- )
3497
- expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
3498
- status: 'optional-gap',
3499
- detail: `not installed from ${SKILLS_SOURCE} in ${scope} scope`,
3500
- })
3501
- },
3502
- )
4550
+ expect(
4551
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4552
+ skills: true,
4553
+ setupScope: 'project',
4554
+ hooks: false,
4555
+ }),
4556
+ ).toBe(EXIT.ok)
4557
+ expect(removed).toEqual(['global'])
4558
+ expect([...present]).toEqual(['project'])
4559
+ })
4560
+
4561
+ it('updates the existing skill scope instead of adding a second copy', async () => {
4562
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-update-existing-'))
4563
+ const io = new InteractiveIo()
4564
+ const received: Array<SkillScope | undefined> = []
4565
+ const installed = true
4566
+ const nativeSkills: NativeSkills = {
4567
+ add: async (options) => {
4568
+ received.push(options.scope)
4569
+ return 0
4570
+ },
4571
+ remove: async () => 0,
4572
+ list: async (scope) => ({
4573
+ skills: installed && scope === 'global' ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
4574
+ }),
4575
+ }
4576
+
4577
+ expect(
4578
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4579
+ skills: true,
4580
+ hooks: false,
4581
+ }),
4582
+ ).toBe(EXIT.ok)
4583
+ expect(received).toEqual(['global'])
4584
+ expect(io.outLines.join('\n')).toContain('global scope')
4585
+ })
3503
4586
 
3504
4587
  it('requires an explicit skill scope before unattended installation', async () => {
3505
4588
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
@@ -3510,6 +4593,7 @@ describe('init', () => {
3510
4593
  addCalls += 1
3511
4594
  return 0
3512
4595
  },
4596
+ remove: async () => 0,
3513
4597
  list: async () => ({ skills: [] }),
3514
4598
  }
3515
4599
 
@@ -3517,7 +4601,7 @@ describe('init', () => {
3517
4601
  EXIT.usage,
3518
4602
  )
3519
4603
  expect(addCalls).toBe(0)
3520
- expect(io.errLines.join('\n')).toContain('--skills-scope project')
4604
+ expect(io.errLines.join('\n')).toContain('--setup-scope project')
3521
4605
  })
3522
4606
 
3523
4607
  it('rejects an invalid unattended skill scope instead of guessing', async () => {
@@ -3545,6 +4629,7 @@ describe('init', () => {
3545
4629
  installed = true
3546
4630
  return 0
3547
4631
  },
4632
+ remove: async () => 0,
3548
4633
  list: async (selected) => ({
3549
4634
  skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
3550
4635
  }),
@@ -3561,27 +4646,82 @@ describe('init', () => {
3561
4646
  },
3562
4647
  )
3563
4648
 
3564
- it('lets the native interactive flow choose scope and resumes after cancellation', async () => {
3565
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-cancelled-'))
4649
+ it('asks one setup-scope question and passes that scope to the native installer', async () => {
4650
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-setup-scope-ask-'))
3566
4651
  const io = new InteractiveIo()
4652
+ io.selectAnswer = 'project'
3567
4653
  const calls: { submit: number } = { submit: 0 }
3568
- let receivedScope: SkillScope | undefined = 'global'
4654
+ let receivedScope: SkillScope | undefined
3569
4655
  const nativeSkills: NativeSkills = {
3570
4656
  add: async (options) => {
3571
4657
  receivedScope = options.scope
3572
4658
  return 0
3573
4659
  },
4660
+ remove: async () => 0,
3574
4661
  list: async () => ({ skills: [] }),
3575
4662
  }
3576
4663
 
3577
4664
  expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
3578
4665
  EXIT.ok,
3579
4666
  )
3580
- expect(receivedScope).toBeUndefined()
4667
+ expect(io.prompts[0]).toContain('this project only, or to every project on this machine')
4668
+ expect(receivedScope).toBe('project')
3581
4669
  expect(calls.submit).toBe(1)
3582
4670
  expect(io.outLines.join('\n')).toContain('All set.')
3583
4671
  })
3584
4672
 
4673
+ it('accepts --setup-scope as the unattended skill, hooks, and config scope', async () => {
4674
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-flag-'))
4675
+ const io = new CapturedIo()
4676
+ let receivedScope: SkillScope | undefined
4677
+ const nativeSkills: NativeSkills = {
4678
+ add: async (options) => {
4679
+ receivedScope = options.scope
4680
+ return 0
4681
+ },
4682
+ remove: async () => 0,
4683
+ list: async (scope) => ({
4684
+ skills: receivedScope === scope ? [managedSkill(scope, cwd)] : [],
4685
+ }),
4686
+ }
4687
+
4688
+ expect(
4689
+ await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
4690
+ skills: true,
4691
+ setupScope: 'project',
4692
+ hooks: false,
4693
+ }),
4694
+ ).toBe(EXIT.ok)
4695
+ expect(receivedScope).toBe('project')
4696
+ expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(true)
4697
+ })
4698
+
4699
+ it('stamps project identity outside the repo when setup-scope is global', async () => {
4700
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-global-config-'))
4701
+ const io = new CapturedIo()
4702
+ const deps: CommandDeps = {
4703
+ ...makeDeps(io, {} as ApiClient),
4704
+ cwd,
4705
+ env: isolatedEnv(cwd),
4706
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
4707
+ }
4708
+
4709
+ expect(await initCommand(deps, { setupScope: 'global', skills: false, hooks: false })).toBe(EXIT.failed)
4710
+ expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
4711
+ expect(readFileSync(personalProjectConfigPath(cwd, deps.env), 'utf8')).toMatch(/project = "/)
4712
+ })
4713
+
4714
+ it('rejects disagreeing --setup-scope and --skills-scope', async () => {
4715
+ const io = new CapturedIo()
4716
+ expect(
4717
+ await initCommand(
4718
+ { ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-scope-conflict-')) },
4719
+ { skills: true, setupScope: 'project', skillsScope: 'global' },
4720
+ ),
4721
+ ).toBe(EXIT.usage)
4722
+ expect(io.errLines.join('\n')).toContain('disagree')
4723
+ })
4724
+
3585
4725
  it('installs every detected project harness when --hooks is set', async () => {
3586
4726
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
3587
4727
  mkdirSync(path.join(cwd, '.claude'))
@@ -3590,6 +4730,7 @@ describe('init', () => {
3590
4730
  const calls = { submit: 0 }
3591
4731
  const nativeSkills: NativeSkills = {
3592
4732
  add: async () => 0,
4733
+ remove: async () => 0,
3593
4734
  list: async () => ({ skills: [] }),
3594
4735
  }
3595
4736
  const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
@@ -3614,6 +4755,7 @@ describe('init', () => {
3614
4755
  const calls = { submit: 0 }
3615
4756
  const nativeSkills: NativeSkills = {
3616
4757
  add: async () => 0,
4758
+ remove: async () => 0,
3617
4759
  list: async () => ({ skills: [] }),
3618
4760
  }
3619
4761
  const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
@@ -3631,6 +4773,7 @@ describe('init', () => {
3631
4773
  const calls: { submit: number } = { submit: 0 }
3632
4774
  const nativeSkills: NativeSkills = {
3633
4775
  add: async () => 1,
4776
+ remove: async () => 0,
3634
4777
  list: async () => ({ skills: [] }),
3635
4778
  }
3636
4779
 
@@ -3735,7 +4878,9 @@ describe('init', () => {
3735
4878
  expect(out).toContain('Next: Your devices')
3736
4879
  expect(out).toContain('https://test.notifai.invalid/support')
3737
4880
  expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
3738
- expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
4881
+ expect(out).toContain(
4882
+ 'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
4883
+ )
3739
4884
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3740
4885
  })
3741
4886
 
@@ -3769,6 +4914,7 @@ describe('init', () => {
3769
4914
  nativeSkills: {
3770
4915
  list: async () => ({ skills: [] }),
3771
4916
  add: async () => 0,
4917
+ remove: async () => 0,
3772
4918
  } satisfies NativeSkills,
3773
4919
  }
3774
4920
 
@@ -3784,7 +4930,7 @@ describe('init', () => {
3784
4930
  })
3785
4931
 
3786
4932
  it.each([
3787
- ['denied', 'iPhone Settings'],
4933
+ ['denied', "the device's Settings"],
3788
4934
  ['not_determined', 'allow its notification prompt'],
3789
4935
  ])('gives one permission-specific next action for %s', async (permission, expected) => {
3790
4936
  const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
@@ -3804,12 +4950,12 @@ describe('init', () => {
3804
4950
 
3805
4951
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
3806
4952
  const out = io.outLines.join('\n')
3807
- expect(out).toContain(`iPhone (${permission})`)
4953
+ expect(out).toContain(`iPhone (ios, ${permission})`)
3808
4954
  expect(out).toContain(expected)
3809
4955
  expect(out.match(/^Next:/gm)).toHaveLength(1)
3810
4956
  })
3811
4957
 
3812
- it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
4958
+ it('ignores a dormant Mac installation while waiting for an active Companion, then proves the iPhone receipt', async () => {
3813
4959
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
3814
4960
  const io = new InteractiveIo()
3815
4961
  let now = 0
@@ -3856,7 +5002,9 @@ describe('init', () => {
3856
5002
  ])
3857
5003
  expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
3858
5004
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
3859
- expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
5005
+ expect(io.spinnerEvents).toContain(
5006
+ 'message:Waiting for a Companion App to sign in and register…',
5007
+ )
3860
5008
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
3861
5009
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
3862
5010
  expect(io.outLines.join('\n')).toContain(
@@ -3864,7 +5012,7 @@ describe('init', () => {
3864
5012
  )
3865
5013
  expect(io.outLines.join('\n')).toContain('All set.')
3866
5014
  expect(submitCalls).toBe(1)
3867
- expect(submittedDraft?.draft.event).toBe('setup_verified')
5015
+ expect(submittedDraft?.draft).not.toHaveProperty('event')
3868
5016
  expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
3869
5017
 
3870
5018
  io.outLines = []
@@ -4022,6 +5170,52 @@ describe('init', () => {
4022
5170
  expect(io.outLines.join('\n')).toContain('All set.')
4023
5171
  })
4024
5172
 
5173
+ it('replaces a stale unknown setup proof instead of rechecking it forever', async () => {
5174
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-stale-'))
5175
+ const io = new CapturedIo()
5176
+ let submitCalls = 0
5177
+ const client = {
5178
+ health: async () => true,
5179
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5180
+ listDevices: async () => ({ devices: [readyIphone] }),
5181
+ submit: async () => {
5182
+ submitCalls += 1
5183
+ return setupReceipt('req_fresh')
5184
+ },
5185
+ evidence: async (requestId: string) =>
5186
+ setupEvidence(
5187
+ requestId,
5188
+ requestId === 'req_fresh'
5189
+ ? {
5190
+ state: 'observed',
5191
+ observed_at: '2026-08-25T00:00:02.000Z',
5192
+ latency_ms: 1_000,
5193
+ }
5194
+ : { state: 'unknown', observed_at: null, latency_ms: null },
5195
+ ),
5196
+ } as unknown as ApiClient
5197
+ const now = Date.parse('2026-08-25T00:00:00.000Z')
5198
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5199
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "stale-proof"\n')
5200
+ const deps: CommandDeps = {
5201
+ ...makeDeps(io, client),
5202
+ cwd,
5203
+ env: isolatedEnv(cwd),
5204
+ now: () => now,
5205
+ }
5206
+ writeSetupProof(deps, {
5207
+ request_id: 'req_old',
5208
+ device_id: readyIphone.device_id,
5209
+ project: 'stale-proof',
5210
+ started_at: new Date(now - SETUP_PROOF_STALE_MS - 1).toISOString(),
5211
+ })
5212
+
5213
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
5214
+ expect(submitCalls).toBe(1)
5215
+ expect(io.outLines.join('\n')).toContain('older than 24h without a Companion Receipt')
5216
+ expect(io.outLines.join('\n')).toContain('All set.')
5217
+ })
5218
+
4025
5219
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
4026
5220
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
4027
5221
  const io = new CapturedIo()
@@ -4047,6 +5241,51 @@ describe('init', () => {
4047
5241
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
4048
5242
  })
4049
5243
 
5244
+ it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
5245
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
5246
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5247
+ writeFileSync(
5248
+ path.join(cwd, '.notifai', 'config.toml'),
5249
+ 'project = "android-project"\ninterruption_level = "passive"\n',
5250
+ )
5251
+ const io = new CapturedIo()
5252
+ let submitted: SubmitNotificationRequestT | null = null
5253
+ const client = {
5254
+ health: async () => true,
5255
+ listDevices: async () => ({ devices: [readyAndroid] }),
5256
+ submit: async (body: SubmitNotificationRequestT) => {
5257
+ submitted = body
5258
+ return setupReceipt('req_android_setup', readyAndroid)
5259
+ },
5260
+ evidence: async (requestId: string) =>
5261
+ setupEvidence(
5262
+ requestId,
5263
+ {
5264
+ state: 'observed',
5265
+ observed_at: '2026-08-05T18:00:02.000Z',
5266
+ latency_ms: 1_000,
5267
+ },
5268
+ readyAndroid,
5269
+ ),
5270
+ } as unknown as ApiClient
5271
+ const deps = {
5272
+ ...makeDeps(io, client),
5273
+ cwd,
5274
+ env: isolatedEnv(cwd),
5275
+ }
5276
+
5277
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
5278
+ expect(submitted?.draft.targets).toEqual({
5279
+ mode: 'selected',
5280
+ device_ids: [readyAndroid.device_id],
5281
+ })
5282
+ expect(submitted?.draft.platform).toEqual({ android: { sound: null } })
5283
+ expect(io.outLines.join('\n')).toContain(
5284
+ "Companion Receipt (the app's delivery confirmation) observed from Pixel.",
5285
+ )
5286
+ expect(io.outLines.join('\n')).toContain('All set.')
5287
+ })
5288
+
4050
5289
  it('does not treat a macOS-only installation as active-release readiness', async () => {
4051
5290
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
4052
5291
  const io = new CapturedIo()
@@ -4072,11 +5311,11 @@ describe('init', () => {
4072
5311
  expect(submitCalls).toBe(0)
4073
5312
  const out = io.outLines.join('\n')
4074
5313
  expect(out).toContain('Next: Your devices')
4075
- expect(out).toContain('no iPhone registered yet')
5314
+ expect(out).toContain('no active Companion device registered yet')
4076
5315
  expect(out).not.toContain('All set.')
4077
5316
  expect(out).not.toMatch(/Companion Receipt observed/i)
4078
5317
 
4079
- // A human doctor must render the same iPhone-readiness failure.
5318
+ // A human doctor must render the same active-Companion readiness failure.
4080
5319
  const doctorIo = new PlainInteractiveIo()
4081
5320
  expect(
4082
5321
  await doctorCommand(
@@ -4090,43 +5329,9 @@ describe('init', () => {
4090
5329
  ).toBe(EXIT.failed)
4091
5330
  const doctorOut = doctorIo.outLines.join('\n')
4092
5331
  expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
4093
- expect(doctorOut).toContain('no iPhone registered yet')
4094
- expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
4095
- })
4096
-
4097
- it('stops login when the approval page reports no Alpha access', async () => {
4098
- const io = new CapturedIo()
4099
- let now = 0
4100
- let polls = 0
4101
- const client = {
4102
- beginPairing: async () => ({
4103
- pairing_id: 'pair_no_plan',
4104
- code: 'NOPE-PLAN',
4105
- approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
4106
- expires_at: new Date(60_000).toISOString(),
4107
- poll_interval_seconds: 1,
4108
- }),
4109
- pollPairing: async () => {
4110
- polls += 1
4111
- return {
4112
- status: 'no_active_plan' as const,
4113
- next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
4114
- }
4115
- },
4116
- } as unknown as ApiClient
4117
- const deps = {
4118
- ...makeDeps(io, client),
4119
- now: () => now,
4120
- sleep: async (milliseconds: number) => {
4121
- now += milliseconds
4122
- },
4123
- }
4124
-
4125
- expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
4126
- expect(polls).toBe(1)
4127
- expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
4128
- expect(io.errLines.join('\n')).toContain(
4129
- 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
5332
+ expect(doctorOut).toContain('no active Companion device registered yet')
5333
+ expect(doctorOut).toContain(
5334
+ 'Delivery proof: not checked — no iPhone or Android Companion App is ready',
4130
5335
  )
4131
5336
  })
4132
5337
 
@@ -4308,6 +5513,54 @@ describe('readiness assessment cost', () => {
4308
5513
  detail: `installed from ${SKILLS_SOURCE} in the project scope`,
4309
5514
  })
4310
5515
  })
5516
+
5517
+ it('detects a lock-file duplicate across project and global scope', async () => {
5518
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-duplicate-'))
5519
+ const env = { ...isolatedEnv(cwd), PATH: '/nonexistent' }
5520
+ writeFileSync(
5521
+ path.join(cwd, 'skills-lock.json'),
5522
+ `${JSON.stringify({
5523
+ skills: {
5524
+ notifai: {
5525
+ source: 'Raidiant-io/notifai',
5526
+ sourceType: 'github',
5527
+ ref: RELEASE_REF,
5528
+ },
5529
+ },
5530
+ })}\n`,
5531
+ )
5532
+ mkdirSync(path.join(env.XDG_STATE_HOME!, 'skills'), { recursive: true })
5533
+ writeFileSync(
5534
+ path.join(env.XDG_STATE_HOME!, 'skills', '.skill-lock.json'),
5535
+ `${JSON.stringify({
5536
+ skills: {
5537
+ notifai: {
5538
+ source: 'Raidiant-io/notifai',
5539
+ sourceType: 'github',
5540
+ ref: 'v0.2.1',
5541
+ },
5542
+ },
5543
+ })}\n`,
5544
+ )
5545
+ const io = new CapturedIo()
5546
+ const client = {
5547
+ health: async () => true,
5548
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5549
+ listDevices: async () => ({ devices: [] }),
5550
+ } as unknown as ApiClient
5551
+
5552
+ const readiness = await assessReadiness({
5553
+ ...makeDeps(io, client),
5554
+ cwd,
5555
+ env,
5556
+ nativeSkills: realNativeSkills,
5557
+ })
5558
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
5559
+ status: 'gap',
5560
+ detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
5561
+ technical: { resolution: 'both-listed' },
5562
+ })
5563
+ })
4311
5564
  })
4312
5565
 
4313
5566
  describe('an outage is not an answer', () => {
@@ -4387,7 +5640,8 @@ describe('an outage is not an answer', () => {
4387
5640
  json: true,
4388
5641
  })
4389
5642
 
4390
- const payload = JSON.parse(io.outLines[1] ?? '{}') as { degraded: boolean }
5643
+ expect(io.outLines).toHaveLength(1)
5644
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
4391
5645
  expect(payload.degraded).toBe(true)
4392
5646
  })
4393
5647
 
@@ -4507,9 +5761,7 @@ describe('asking before the hooks have ever run', () => {
4507
5761
  io.outLines = []
4508
5762
 
4509
5763
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
4510
- expect(io.outLines).toContain(
4511
- 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
4512
- )
5764
+ expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
4513
5765
  expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
4514
5766
  session_id: 'codex-current-thread',
4515
5767
  harness: 'codex',
@@ -4568,7 +5820,7 @@ describe('asking before the hooks have ever run', () => {
4568
5820
  const pending = readSessionState('codex-media-thread', env).pending?.[0]
4569
5821
  expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
4570
5822
  expect(pending?.body).toBe(
4571
- 'Which visual should I use?\n\nCompare ![first](media:med_ask_1) with ![second](media:med_ask_2).',
5823
+ 'Compare ![first](media:med_ask_1) with ![second](media:med_ask_2).',
4572
5824
  )
4573
5825
  expect(pending?.media).toEqual([
4574
5826
  { media_id: 'med_ask_1', alt: 'First option' },
@@ -4731,7 +5983,7 @@ describe('asking before the hooks have ever run', () => {
4731
5983
  const fired = readiness.states.find((state) => state.id === 'hooks-fired')
4732
5984
  expect(fired?.status).toBe('optional-gap')
4733
5985
  expect(fired?.detail).toMatch(/Claude Code/)
4734
- expect(fired?.detail).toMatch(/Codex: send one prompt/)
5986
+ expect(fired?.detail).toMatch(/Codex: approve[\s\S]*start one fresh session/i)
4735
5987
 
4736
5988
  io.errLines = []
4737
5989
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
@@ -4851,6 +6103,7 @@ describe('asking before the hooks have ever run', () => {
4851
6103
  } as unknown as ApiClient
4852
6104
  const nativeSkills: NativeSkills = {
4853
6105
  add: async () => 0,
6106
+ remove: async () => 0,
4854
6107
  list: async (scope) => ({
4855
6108
  skills:
4856
6109
  scope === 'global'
@@ -5154,6 +6407,28 @@ describe('asking before the hooks have ever run', () => {
5154
6407
  expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
5155
6408
  })
5156
6409
 
6410
+ it('diagnoses an older hook install that lacks proactive lifecycle handlers', async () => {
6411
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-missing-lifecycle-'))
6412
+ const io = new PlainInteractiveIo()
6413
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
6414
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6415
+ EXIT.ok,
6416
+ )
6417
+ const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
6418
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
6419
+ hooks: Record<string, unknown>
6420
+ }
6421
+ delete settings.hooks.SessionStart
6422
+ delete settings.hooks.SubagentStart
6423
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
6424
+
6425
+ const readiness = await assessReadiness(deps)
6426
+ const current = readiness.states.find((state) => state.id === 'hooks-stale')
6427
+ expect(current?.status).toBe('gap')
6428
+ expect(current?.detail).toMatch(/missing session-start, subagent-start/i)
6429
+ expect(current?.detail).toMatch(/notifai hooks install/i)
6430
+ })
6431
+
5157
6432
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
5158
6433
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
5159
6434
  const io = new PlainInteractiveIo()
@@ -5305,7 +6580,7 @@ describe('asking before the hooks have ever run', () => {
5305
6580
  expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
5306
6581
  })
5307
6582
 
5308
- it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
6583
+ it('tells Claude Code to start fresh so activation is model-visible', () => {
5309
6584
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
5310
6585
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
5311
6586
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
@@ -5319,11 +6594,11 @@ describe('asking before the hooks have ever run', () => {
5319
6594
 
5320
6595
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5321
6596
  const said = io.errLines.join(' ')
5322
- expect(said).toMatch(/project hook files reload without a restart/i)
6597
+ expect(said).toMatch(/Claude Code: start one fresh session, send one prompt/i)
5323
6598
  expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
5324
6599
  })
5325
6600
 
5326
- it('gives Cursor its prompt-and-doctor activation path', () => {
6601
+ it('gives Cursor its fresh-conversation and first-Stop activation path', () => {
5327
6602
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
5328
6603
  const io = new CapturedIo()
5329
6604
  const deps = {
@@ -5336,7 +6611,7 @@ describe('asking before the hooks have ever run', () => {
5336
6611
  io.outLines = []
5337
6612
 
5338
6613
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5339
- expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
6614
+ expect(io.errLines.join(' ')).toMatch(/Cursor: start one fresh conversation[\s\S]*finish its first turn[\s\S]*`notifai doctor`/)
5340
6615
  })
5341
6616
 
5342
6617
  it('keeps OpenCode activation separate from unsupported answer continuation', () => {
@@ -5591,7 +6866,7 @@ describe('command failures carrying server details', () => {
5591
6866
  })
5592
6867
 
5593
6868
  describe('question sets', () => {
5594
- it('maps --reply-multi into the single question', async () => {
6869
+ it('maps --multi into the single question', async () => {
5595
6870
  const io = new CapturedIo()
5596
6871
  let submitted: SubmitNotificationRequestT | undefined
5597
6872
  const client = {
@@ -5608,8 +6883,8 @@ describe('question sets', () => {
5608
6883
  body: 'Which fronts?',
5609
6884
  reply: true,
5610
6885
  replyTimeout: 30,
5611
- replyChoice: ['CLI', 'Server', 'Apps'],
5612
- replyMulti: true,
6886
+ choice: ['CLI', 'Server', 'Apps'],
6887
+ multi: true,
5613
6888
  }),
5614
6889
  ).toBe(EXIT.ok)
5615
6890
  expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
@@ -5643,7 +6918,10 @@ describe('question sets', () => {
5643
6918
  expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
5644
6919
  })
5645
6920
 
5646
- it('composes one question before optional Markdown context', () => {
6921
+ it('keeps the question out of a body that carries context', () => {
6922
+ // The question already travels as the title and as structured questions;
6923
+ // repeating it in the body showed it twice on the lock screen and the
6924
+ // reply screen.
5647
6925
  expect(
5648
6926
  buildQuestions(
5649
6927
  { body: '## Why\nThe release window closes today.' },
@@ -5651,12 +6929,28 @@ describe('question sets', () => {
5651
6929
  ),
5652
6930
  ).toMatchObject({
5653
6931
  ok: true,
5654
- body: 'Which environment?\n\n## Why\nThe release window closes today.',
6932
+ body: '## Why\nThe release window closes today.',
5655
6933
  questions: [{ text: 'Which environment?' }],
5656
6934
  })
5657
6935
  })
5658
6936
 
5659
- it('composes numbered form questions before form context', () => {
6937
+ it('lets the question stand in for the body only when there is no context', () => {
6938
+ expect(buildQuestions({}, 'Which environment?')).toMatchObject({
6939
+ ok: true,
6940
+ body: 'Which environment?',
6941
+ })
6942
+ expect(
6943
+ buildQuestions(
6944
+ { form: JSON.stringify({ questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }] }) },
6945
+ undefined,
6946
+ ),
6947
+ ).toMatchObject({
6948
+ ok: true,
6949
+ body: '1. Deploy where?\n2. What should I monitor?',
6950
+ })
6951
+ })
6952
+
6953
+ it('keeps form questions out of a body that carries form context', () => {
5660
6954
  expect(
5661
6955
  buildQuestions(
5662
6956
  {
@@ -5669,8 +6963,7 @@ describe('question sets', () => {
5669
6963
  ),
5670
6964
  ).toMatchObject({
5671
6965
  ok: true,
5672
- body:
5673
- '1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
6966
+ body: '## Context\nTraffic is elevated.',
5674
6967
  })
5675
6968
  })
5676
6969