@raidiant/notifai 10.1.0 → 10.1.1

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 (97) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +8 -1
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +245 -87
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send.d.ts +1 -1
  32. package/dist/commands-send.js +121 -35
  33. package/dist/commands-send.js.map +1 -1
  34. package/dist/commands-update.d.ts +10 -0
  35. package/dist/commands-update.js +141 -0
  36. package/dist/commands-update.js.map +1 -0
  37. package/dist/commands.d.ts +2 -1
  38. package/dist/commands.js +2 -1
  39. package/dist/commands.js.map +1 -1
  40. package/dist/hook-adapter.d.ts +2 -0
  41. package/dist/hook-adapter.js +12 -1
  42. package/dist/hook-adapter.js.map +1 -1
  43. package/dist/hooks.d.ts +43 -2
  44. package/dist/hooks.js +163 -21
  45. package/dist/hooks.js.map +1 -1
  46. package/dist/main-run.d.ts +1 -0
  47. package/dist/main-run.js +42 -0
  48. package/dist/main-run.js.map +1 -0
  49. package/dist/main.js +10 -31
  50. package/dist/main.js.map +1 -1
  51. package/dist/node-floor.d.ts +18 -0
  52. package/dist/node-floor.js +35 -0
  53. package/dist/node-floor.js.map +1 -0
  54. package/dist/program.d.ts +2 -1
  55. package/dist/program.js +22 -5
  56. package/dist/program.js.map +1 -1
  57. package/dist/question-settlement-process.d.ts +15 -0
  58. package/dist/question-settlement-process.js +35 -0
  59. package/dist/question-settlement-process.js.map +1 -0
  60. package/dist/readiness.d.ts +25 -1
  61. package/dist/readiness.js +32 -2
  62. package/dist/readiness.js.map +1 -1
  63. package/dist/setup-destinations.d.ts +23 -0
  64. package/dist/setup-destinations.js +44 -0
  65. package/dist/setup-destinations.js.map +1 -0
  66. package/package.json +2 -2
  67. package/src/cli-bin.test.ts +145 -7
  68. package/src/cli-bin.ts +176 -10
  69. package/src/cli-update-contract.ts +9 -0
  70. package/src/cli-update.test.ts +125 -0
  71. package/src/client.ts +7 -0
  72. package/src/codex-wake.ts +5 -1
  73. package/src/commands-auth.ts +44 -11
  74. package/src/commands-core.ts +18 -4
  75. package/src/commands-devices.ts +23 -16
  76. package/src/commands-doctor.ts +116 -55
  77. package/src/commands-hooks.ts +66 -16
  78. package/src/commands-init.ts +265 -88
  79. package/src/commands-send.ts +145 -39
  80. package/src/commands-update.ts +180 -0
  81. package/src/commands.test.ts +518 -49
  82. package/src/commands.ts +2 -0
  83. package/src/hook-adapter.ts +14 -0
  84. package/src/hooks.test.ts +275 -0
  85. package/src/hooks.ts +270 -18
  86. package/src/main-run.ts +45 -0
  87. package/src/main.ts +9 -33
  88. package/src/node-floor.test.ts +27 -0
  89. package/src/node-floor.ts +37 -0
  90. package/src/program.test.ts +35 -0
  91. package/src/program.ts +28 -4
  92. package/src/question-settlement-process.ts +47 -0
  93. package/src/readiness.ts +35 -2
  94. package/src/setup-destinations.test.ts +41 -0
  95. package/src/setup-destinations.ts +50 -0
  96. package/src/skill.test.ts +11 -0
  97. package/src/commands-devices.test.ts +0 -15
@@ -2,10 +2,12 @@ import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notif
2
2
  import { execFileSync } from 'node:child_process'
3
3
  import { createHash } from 'node:crypto'
4
4
  import {
5
+ chmodSync,
5
6
  existsSync,
6
7
  mkdirSync,
7
8
  mkdtempSync,
8
9
  readFileSync,
10
+ realpathSync,
9
11
  readdirSync,
10
12
  rmSync,
11
13
  symlinkSync,
@@ -59,6 +61,7 @@ import {
59
61
  repliesCommand,
60
62
  sendCommand,
61
63
  statusCommand,
64
+ updateCliCommand,
62
65
  agentSessionRenameCommand,
63
66
  type CommandDeps,
64
67
  type CommandIo,
@@ -75,15 +78,21 @@ import {
75
78
  QUESTION_STOP_TIMEOUT_SECONDS,
76
79
  settingsFile,
77
80
  } from './install-hooks.js'
78
- import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
81
+ import {
82
+ inspectQuestionState,
83
+ readSessionState,
84
+ writeProjectSession,
85
+ writeSessionState,
86
+ } from './hooks.js'
79
87
  import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
80
88
  import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.js'
81
89
  import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
82
90
  import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
83
91
  import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
84
- import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
92
+ import { hookAdapterPath, inspectHookAdapter, installHookAdapter } from './hook-adapter.js'
85
93
  import type { Tone } from './ui/theme.js'
86
94
  import { projectBinding, projectEnabled } from './project-enablement.js'
95
+ import { firstRequiredBlocker } from './readiness.js'
87
96
 
88
97
  afterEach(() => {
89
98
  resetLatestPublishedCliVersionForTest()
@@ -469,8 +478,8 @@ describe('command contracts', () => {
469
478
 
470
479
  expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
471
480
  expect(io.outLines).toEqual([
472
- 'No active plan or temporary Alpha access for this account.',
473
- 'next: Open https://test.notifai.invalid/support to request Alpha access, then retry.',
481
+ 'This account does not have access to Notifai yet.',
482
+ 'next: Open https://test.notifai.invalid/setup/access to set up access, then retry.',
474
483
  ])
475
484
  })
476
485
 
@@ -2125,6 +2134,10 @@ describe('command contracts', () => {
2125
2134
  closed: [],
2126
2135
  })
2127
2136
  expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
2137
+ expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
2138
+ found: true,
2139
+ question: { state: 'withdrawn', submitted: false, request_id: null },
2140
+ })
2128
2141
  })
2129
2142
 
2130
2143
  it('close withdraws one unpushed question by stable id from another checkout', async () => {
@@ -2182,6 +2195,7 @@ describe('command contracts', () => {
2182
2195
  { question: 'Unpushed?', question_id: 'q_local' },
2183
2196
  {
2184
2197
  question: 'Already on a device?',
2198
+ question_id: 'q_live',
2185
2199
  request_id: receipt.request_id,
2186
2200
  collapse_key: 'question-live',
2187
2201
  device_ids: ['dev_iphone'],
@@ -2198,6 +2212,14 @@ describe('command contracts', () => {
2198
2212
  closed: [receipt.request_id],
2199
2213
  })
2200
2214
  expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
2215
+ expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
2216
+ found: true,
2217
+ question: { state: 'withdrawn', submitted: false, request_id: null },
2218
+ })
2219
+ expect(inspectQuestionState('q_live', deps.env)).toMatchObject({
2220
+ found: true,
2221
+ question: { state: 'retired', submitted: true, request_id: receipt.request_id },
2222
+ })
2201
2223
  })
2202
2224
 
2203
2225
  it('close requires a request id or --pending', async () => {
@@ -2609,6 +2631,86 @@ describe('credential origin pinning', () => {
2609
2631
  'next: Ask the account owner for Alpha access.',
2610
2632
  'After access is granted, run `notifai init` again.',
2611
2633
  ])
2634
+ expect(io.errLines.join('\n')).not.toMatch(/Pairing expired/i)
2635
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
2636
+ expect(now).toBeLessThan(10_000)
2637
+ })
2638
+
2639
+ it('reports the access blocker to whoever asked for the sign-in', async () => {
2640
+ const io = new CapturedIo()
2641
+ let now = 0
2642
+ const client = {
2643
+ beginPairing: async () => ({
2644
+ pairing_id: 'pair_test',
2645
+ code: 'ABCD-EFGH',
2646
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2647
+ expires_at: new Date(10_000).toISOString(),
2648
+ poll_interval_seconds: 1,
2649
+ }),
2650
+ pollPairing: async () => ({
2651
+ status: 'no_active_plan',
2652
+ next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
2653
+ }),
2654
+ } as unknown as ApiClient
2655
+ const deps: CommandDeps = {
2656
+ ...makeDeps(io, client),
2657
+ now: () => now,
2658
+ sleep: async (milliseconds: number) => {
2659
+ now += milliseconds
2660
+ },
2661
+ }
2662
+
2663
+ let blocked: { title: string; detail: string } | null = null
2664
+ expect(
2665
+ await loginCommand(deps, { open: false }, (state) => {
2666
+ blocked = state
2667
+ }),
2668
+ ).toBe(EXIT.auth)
2669
+ // Handing the blocker over hands over the errand. Saying it here too gave
2670
+ // the caller's close a second, differently worded copy to contradict.
2671
+ expect(io.errLines).toEqual([])
2672
+ // Without this the caller falls back to the state it held before the
2673
+ // attempt — "not paired … run `notifai init`" — and prints it under the
2674
+ // correct line, in contradiction with it.
2675
+ expect(blocked).toMatchObject({
2676
+ id: 'auth',
2677
+ title: 'Access',
2678
+ status: 'gap',
2679
+ detail: 'this account does not have access to Notifai yet',
2680
+ remedy: {
2681
+ by: 'user-elsewhere',
2682
+ summary: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
2683
+ },
2684
+ })
2685
+ })
2686
+
2687
+ it('reports expiry only when pairing times out without a no-access mark', async () => {
2688
+ const io = new CapturedIo()
2689
+ let now = 0
2690
+ const client = {
2691
+ beginPairing: async () => ({
2692
+ pairing_id: 'pair_test',
2693
+ code: 'ABCD-EFGH',
2694
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
2695
+ expires_at: new Date(10_000).toISOString(),
2696
+ poll_interval_seconds: 1,
2697
+ }),
2698
+ pollPairing: async () => ({ status: 'expired' }),
2699
+ } as unknown as ApiClient
2700
+ const deps: CommandDeps = {
2701
+ ...makeDeps(io, client),
2702
+ now: () => now,
2703
+ sleep: async (milliseconds: number) => {
2704
+ now += milliseconds
2705
+ },
2706
+ }
2707
+
2708
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
2709
+ expect(io.errLines).toEqual([
2710
+ 'Pairing expired before it was approved. Run `notifai init` again.',
2711
+ ])
2712
+ expect(io.errLines.join('\n')).not.toMatch(/no active plan/i)
2713
+ expect(io.errLines.join('\n')).not.toContain('notifai login')
2612
2714
  })
2613
2715
 
2614
2716
  it('keeps notification titles out of send logs', async () => {
@@ -2705,6 +2807,96 @@ describe('delivery evidence status', () => {
2705
2807
  }
2706
2808
  }
2707
2809
 
2810
+ it('reports every local question state by q identity without creating or submitting anything', async () => {
2811
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-question-status-'))
2812
+ const env = {
2813
+ XDG_CONFIG_HOME: path.join(root, 'config'),
2814
+ XDG_STATE_HOME: path.join(root, 'state'),
2815
+ }
2816
+ writeSessionState('question-states', env, {
2817
+ pending: [
2818
+ { question: 'Local?', question_id: 'q_local' },
2819
+ {
2820
+ question: 'Frozen?',
2821
+ question_id: 'q_frozen',
2822
+ submission: {
2823
+ request_id: 'req_frozen',
2824
+ idempotency_key: 'idem-frozen',
2825
+ collapse_key: 'collapse-frozen',
2826
+ device_ids: ['dev_status_test'],
2827
+ draft: {} as never,
2828
+ owner_deadline_at: Date.now() + 60_000,
2829
+ },
2830
+ },
2831
+ { question: 'Live?', question_id: 'q_live', request_id: 'req_live' },
2832
+ ],
2833
+ question_history: [
2834
+ { question_id: 'q_answered', state: 'answered', request_id: 'req_answered' },
2835
+ { question_id: 'q_withdrawn', state: 'withdrawn' },
2836
+ { question_id: 'q_retired', state: 'retired', request_id: 'req_retired' },
2837
+ ],
2838
+ })
2839
+ const evidenceCalls: string[] = []
2840
+ const client = {
2841
+ evidence: async (requestId: string) => {
2842
+ evidenceCalls.push(requestId)
2843
+ return { ...snapshot({ state: 'unknown', observed_at: null, latency_ms: null }), request_id: requestId }
2844
+ },
2845
+ } as unknown as ApiClient
2846
+ const io = new CapturedIo()
2847
+ const deps = { ...makeDeps(io, client), cwd: root, env }
2848
+
2849
+ const expected = [
2850
+ ['q_local', 'local', false, null, null],
2851
+ ['q_frozen', 'frozen', null, null, 'req_frozen'],
2852
+ ['q_live', 'live', true, 'req_live', null],
2853
+ ['q_answered', 'answered', true, 'req_answered', null],
2854
+ ['q_withdrawn', 'withdrawn', false, null, null],
2855
+ ['q_retired', 'retired', true, 'req_retired', null],
2856
+ ] as const
2857
+ for (const [questionId, state, submitted, requestId, frozenRequestId] of expected) {
2858
+ io.outLines = []
2859
+ expect(await statusCommand(deps, questionId, { json: true })).toBe(EXIT.ok)
2860
+ expect(JSON.parse(io.outLines.join('\n'))).toMatchObject({
2861
+ question_id: questionId,
2862
+ state,
2863
+ submitted,
2864
+ request_id: requestId,
2865
+ frozen_request_id: frozenRequestId,
2866
+ recovery: {
2867
+ inspect: `notifai status ${questionId}`,
2868
+ close: `notifai close ${questionId}`,
2869
+ register_replacement: false,
2870
+ },
2871
+ })
2872
+ }
2873
+ expect(evidenceCalls).toEqual(['req_live', 'req_answered', 'req_retired'])
2874
+ })
2875
+
2876
+ it('states plainly that a local registration has no submission or Provider Acceptance', async () => {
2877
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-local-question-status-'))
2878
+ const env = { XDG_STATE_HOME: path.join(root, 'state') }
2879
+ writeSessionState('local-question', env, {
2880
+ pending: [{ question: 'Ship?', question_id: 'q_local_plain' }],
2881
+ })
2882
+ const io = new CapturedIo()
2883
+ const client = {
2884
+ evidence: async () => {
2885
+ throw new Error('local status must not call the service')
2886
+ },
2887
+ } as unknown as ApiClient
2888
+
2889
+ expect(
2890
+ await statusCommand({ ...makeDeps(io, client), cwd: root, env }, 'q_local_plain', {}),
2891
+ ).toBe(EXIT.ok)
2892
+ const said = io.outLines.join('\n')
2893
+ expect(said).toContain('question q_local_plain — local')
2894
+ expect(said).toContain('not submitted')
2895
+ expect(said).toContain('only a local registration')
2896
+ expect(said).toContain('Provider Acceptance: not available')
2897
+ expect(said).toContain('do not register a replacement')
2898
+ })
2899
+
2708
2900
  it('calls an unobserved first-minute receipt unknown rather than failed', async () => {
2709
2901
  const io = new CapturedIo()
2710
2902
  const client = {
@@ -4150,7 +4342,7 @@ describe('interactive command UX', () => {
4150
4342
 
4151
4343
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
4152
4344
  expect(io.outLines).toContain('A newer Notifai is available.')
4153
- expect(io.outLines).toContain('npm install -g @raidiant/notifai')
4345
+ expect(io.outLines).toContain(updateCliCommand(deps))
4154
4346
  })
4155
4347
  })
4156
4348
 
@@ -4161,7 +4353,6 @@ describe('compatibility-first update guidance', () => {
4161
4353
  status: 'ready' | 'optional-gap' | 'gap'
4162
4354
  detail: string
4163
4355
  exit: number
4164
- humanLines: string[]
4165
4356
  }> = [
4166
4357
  {
4167
4358
  name: 'current',
@@ -4169,7 +4360,6 @@ describe('compatibility-first update guidance', () => {
4169
4360
  status: 'ready',
4170
4361
  detail: 'Notifai can send notifications.',
4171
4362
  exit: EXIT.ok,
4172
- humanLines: [],
4173
4363
  },
4174
4364
  {
4175
4365
  name: 'optional newer release',
@@ -4182,10 +4372,6 @@ describe('compatibility-first update guidance', () => {
4182
4372
  status: 'optional-gap',
4183
4373
  detail: 'A newer Notifai is available.',
4184
4374
  exit: EXIT.ok,
4185
- humanLines: [
4186
- 'A newer Notifai is available.',
4187
- 'npm install -g @raidiant/notifai',
4188
- ],
4189
4375
  },
4190
4376
  {
4191
4377
  name: 'scheduled Sunset',
@@ -4200,10 +4386,6 @@ describe('compatibility-first update guidance', () => {
4200
4386
  status: 'optional-gap',
4201
4387
  detail: 'Update Notifai soon to keep sending notifications.',
4202
4388
  exit: EXIT.ok,
4203
- humanLines: [
4204
- 'Update Notifai soon to keep sending notifications.',
4205
- 'npm install -g @raidiant/notifai',
4206
- ],
4207
4389
  },
4208
4390
  {
4209
4391
  name: 'required update',
@@ -4218,16 +4400,12 @@ describe('compatibility-first update guidance', () => {
4218
4400
  status: 'gap',
4219
4401
  detail: "Notifai can't send notifications until you update.",
4220
4402
  exit: EXIT.failed,
4221
- humanLines: [
4222
- "Notifai can't send notifications until you update.",
4223
- 'npm install -g @raidiant/notifai',
4224
- ],
4225
4403
  },
4226
4404
  ]
4227
4405
 
4228
4406
  it.each(cases)(
4229
4407
  'maps $name policy to closed human copy and structured JSON',
4230
- async ({ support, status, detail, exit, humanLines }) => {
4408
+ async ({ support, status, detail, exit }) => {
4231
4409
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
4232
4410
  const client = {
4233
4411
  health: async () => true,
@@ -4248,7 +4426,9 @@ describe('compatibility-first update guidance', () => {
4248
4426
  expect(
4249
4427
  await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
4250
4428
  ).toBe(exit)
4251
- expect(humanIo.outLines).toEqual(humanLines)
4429
+ expect(humanIo.outLines).toEqual(
4430
+ status === 'ready' ? [] : [detail, updateCliCommand(deps)],
4431
+ )
4252
4432
  expect(humanIo.errLines).toEqual([])
4253
4433
  expect(humanIo.outros).toEqual(
4254
4434
  status === 'ready' ? ['Everything looks good'] : [],
@@ -4278,7 +4458,7 @@ describe('compatibility-first update guidance', () => {
4278
4458
  states: [{ status, detail }],
4279
4459
  })
4280
4460
  expect(payload.states[0]?.remedy?.command ?? null).toBe(
4281
- status === 'ready' ? null : 'npm install -g @raidiant/notifai',
4461
+ status === 'ready' ? null : updateCliCommand(deps),
4282
4462
  )
4283
4463
  expect(jsonIo.errLines).toEqual([])
4284
4464
 
@@ -4347,7 +4527,7 @@ describe('compatibility-first update guidance', () => {
4347
4527
  await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
4348
4528
  ).toBe(EXIT.ok)
4349
4529
  expect(io.outLines).toEqual(['The service is being updated; try again later.'])
4350
- expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
4530
+ expect(io.outLines.join('\n')).not.toContain('notifai update')
4351
4531
 
4352
4532
  const sendIo = new CapturedIo()
4353
4533
  expect(
@@ -4560,7 +4740,7 @@ describe('init', () => {
4560
4740
  }),
4561
4741
  ).toBe(EXIT.failed)
4562
4742
  expect(io.outLines).toContain("Notifai can't send notifications until you update.")
4563
- expect(io.outLines).toContain('npm install -g @raidiant/notifai')
4743
+ expect(io.outLines).toContain(updateCliCommand(deps))
4564
4744
  expect(io.outros).toEqual([])
4565
4745
  expect(submissions).toBe(0)
4566
4746
 
@@ -4580,10 +4760,54 @@ describe('init', () => {
4580
4760
  expect(io.outLines).toContain(
4581
4761
  'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
4582
4762
  )
4583
- expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
4763
+ expect(io.outLines).not.toContain('notifai update')
4584
4764
  expect(io.errLines).toEqual([])
4585
4765
  })
4586
4766
 
4767
+ it('retargets Question Routing while the post-update init resumes', async () => {
4768
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-adapter-'))
4769
+ const io = new CapturedIo()
4770
+ const nativeSkills: NativeSkills = {
4771
+ add: async () => 0,
4772
+ remove: async () => 0,
4773
+ list: async () => ({ skills: [] }),
4774
+ }
4775
+ const deps = setupReadyDeps(io, cwd, nativeSkills, { submit: 0 })
4776
+ const oldArtifact = path.join(cwd, 'old', 'dist', 'main.js')
4777
+ const prefix = path.join(cwd, 'current')
4778
+ const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai')
4779
+ const currentArtifact = path.join(packageRoot, 'dist', 'main.js')
4780
+ const currentCommand = path.join(prefix, 'bin', 'notifai')
4781
+ mkdirSync(path.dirname(oldArtifact), { recursive: true })
4782
+ mkdirSync(path.dirname(currentArtifact), { recursive: true })
4783
+ mkdirSync(path.dirname(currentCommand), { recursive: true })
4784
+ writeFileSync(oldArtifact, '#!/usr/bin/env node\n')
4785
+ writeFileSync(currentArtifact, '#!/usr/bin/env node\n')
4786
+ writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: RELEASE_REF.slice(1) }))
4787
+ chmodSync(oldArtifact, 0o755)
4788
+ chmodSync(currentArtifact, 0o755)
4789
+ symlinkSync(path.relative(path.dirname(currentCommand), currentArtifact), currentCommand)
4790
+ deps.env = { ...deps.env, PATH: path.dirname(currentCommand) }
4791
+ deps.hookInstallTarget = { execPath: process.execPath, scriptPath: currentArtifact }
4792
+
4793
+ installHookAdapter({ execPath: process.execPath, scriptPath: oldArtifact }, deps.hookAdapterHome)
4794
+ applyPlan(path.join(cwd, '.codex', 'hooks.json'), {
4795
+ hooks: buildHookConfig({
4796
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
4797
+ harness: 'codex',
4798
+ }),
4799
+ })
4800
+
4801
+ expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({ scriptPath: oldArtifact })
4802
+ expect(
4803
+ await initCommand(deps, { hooks: false, setupScope: 'project', skills: false }),
4804
+ ).toBe(EXIT.ok)
4805
+ expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({
4806
+ scriptPath: realpathSync(currentArtifact),
4807
+ })
4808
+ expect(io.outLines.join('\n')).not.toContain('notifai hooks install')
4809
+ })
4810
+
4587
4811
  it('surfaces one next step, not the whole remaining list', async () => {
4588
4812
  // The behavioural core of the design: someone handed five things to do
4589
4813
  // does none of them. Signing in gates the device check, so naming both
@@ -4626,19 +4850,23 @@ describe('init', () => {
4626
4850
  )
4627
4851
  })
4628
4852
 
4629
- it('does not let an unattended readiness pass silently choose project scope', async () => {
4853
+ it('closes what a process can close when no scope was passed, and asks nothing', async () => {
4630
4854
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
4631
4855
  const io = new CapturedIo()
4632
4856
  const deps = { ...makeDeps(io, {} as ApiClient), cwd }
4633
4857
 
4634
4858
  expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.failed)
4635
- expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
4859
+ // Naming this checkout is not skill or hook placement, so it no longer
4860
+ // waits on a scope question nobody can answer here.
4861
+ expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project = "')
4636
4862
  const result = JSON.parse(io.outLines[0] ?? '{}') as {
4637
- states: Array<{ id: string; status: string }>
4863
+ states: Array<{ id: string; status: string; remedy: { command?: string } | null }>
4638
4864
  }
4639
- expect(result.states.find((state) => state.id === 'project')).toMatchObject({
4640
- status: 'optional-gap',
4641
- })
4865
+ expect(result.states.find((state) => state.id === 'project')).toMatchObject({ status: 'ready' })
4866
+ // The run stops where a human genuinely is required, and never hands back
4867
+ // the command that produced this output as the thing to do about it.
4868
+ const project = result.states.find((state) => state.id === 'project')
4869
+ expect(project?.remedy).toBeNull()
4642
4870
  expect(io.errLines).toEqual([])
4643
4871
  })
4644
4872
 
@@ -5096,7 +5324,11 @@ describe('init', () => {
5096
5324
  expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
5097
5325
  expect(io.outLines.join('\n')).not.toContain('Installed claude-code hooks')
5098
5326
  expect(io.outLines.join('\n')).not.toContain('Installed codex hooks')
5099
- expect(io.outLines.join('\n')).toContain('Next: Codex hook trust')
5327
+ // A hook diagnostic is a report line. The run it appears in still reaches
5328
+ // the delivery proof, which is the thing `init` is there to produce.
5329
+ expect(io.outLines.join('\n')).toContain('Companion Receipt')
5330
+ expect(io.outLines.join('\n')).toContain('All set.')
5331
+ expect(io.outLines.join('\n')).not.toContain('Next: Codex hook trust')
5100
5332
  })
5101
5333
 
5102
5334
  it('lets a human keep a subset of the detected harnesses', async () => {
@@ -5231,11 +5463,11 @@ describe('init', () => {
5231
5463
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
5232
5464
  const out = io.outLines.join('\n')
5233
5465
  expect(out).toContain('Next: Your devices')
5234
- expect(out).toContain('https://test.notifai.invalid/support')
5466
+ // One focused destination for this errand, never the omnibus help page.
5467
+ expect(out).toContain('https://test.notifai.invalid/setup/companion')
5468
+ expect(out).not.toContain('/support')
5235
5469
  expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
5236
- expect(out).toContain(
5237
- 'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
5238
- )
5470
+ expect(out).toContain('no active Companion App registered yet')
5239
5471
  expect(out.match(/^Next:/gm)).toHaveLength(1)
5240
5472
  })
5241
5473
 
@@ -5350,6 +5582,9 @@ describe('init', () => {
5350
5582
  },
5351
5583
  }
5352
5584
 
5585
+ // The terminal asks which phone, in those words, and the destination it
5586
+ // opens is already about that answer — nobody chooses twice.
5587
+ io.selectAnswer = 'iphone'
5353
5588
  expect(
5354
5589
  await initCommand(deps, {
5355
5590
  hooks: false,
@@ -5358,10 +5593,11 @@ describe('init', () => {
5358
5593
  }),
5359
5594
  ).toBe(EXIT.ok)
5360
5595
  expect(io.prompts).toEqual([
5361
- 'Open install instructions in your browser?',
5362
- 'Wait here while you finish that on your device?',
5596
+ 'Where do you want to receive notifications?',
5597
+ 'Open those steps in your browser?',
5598
+ 'Wait here while you finish that on your phone?',
5363
5599
  ])
5364
- expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
5600
+ expect(io.openedUrls).toEqual(['https://test.notifai.invalid/setup/companion?platform=iphone'])
5365
5601
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
5366
5602
  expect(io.spinnerEvents).toContain(
5367
5603
  'message:Waiting for a Companion App to sign in and register…',
@@ -5375,6 +5611,11 @@ describe('init', () => {
5375
5611
  expect(submitCalls).toBe(1)
5376
5612
  expect(submittedDraft?.draft).not.toHaveProperty('event')
5377
5613
  expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
5614
+ // The proof is a real send that has to be seen: a normal banner and sound,
5615
+ // not a silent arrival in Notification Center a first-time User never
5616
+ // looks at. Its sound is left to the server's kind/Project/Account layers.
5617
+ expect(submittedDraft?.draft.platform).toEqual({ ios: { interruption_level: 'active' } })
5618
+ expect(submittedDraft?.draft.kind).toBe('done')
5378
5619
 
5379
5620
  io.outLines = []
5380
5621
  io.prompts = []
@@ -5481,7 +5722,8 @@ describe('init', () => {
5481
5722
  ).toBe(EXIT.failed)
5482
5723
  const out = io.outLines.join('\n')
5483
5724
  expect(out).toContain('Next: Your devices')
5484
- expect(out).toContain('https://test.notifai.invalid/support')
5725
+ expect(out).toContain('https://test.notifai.invalid/setup/companion')
5726
+ expect(out).not.toContain('/support')
5485
5727
  expect(out).toContain('sign in with the same email as this account (agent@example.com)')
5486
5728
  expect(io.openedUrls).toEqual([])
5487
5729
  })
@@ -5632,6 +5874,57 @@ describe('init', () => {
5632
5874
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
5633
5875
  })
5634
5876
 
5877
+ it('writes the first notification about the User, not about the check', async () => {
5878
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-copy-'))
5879
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5880
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "orders-api"\n')
5881
+ const io = new CapturedIo()
5882
+ let submitted: SubmitNotificationRequestT | null = null
5883
+ const client = {
5884
+ health: async () => true,
5885
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
5886
+ listDevices: async () => ({ devices: [readyIphone] }),
5887
+ submit: async (body: SubmitNotificationRequestT) => {
5888
+ submitted = body
5889
+ return setupReceipt()
5890
+ },
5891
+ evidence: async (requestId: string) =>
5892
+ setupEvidence(requestId, {
5893
+ state: 'observed',
5894
+ observed_at: '2026-08-05T18:00:02.000Z',
5895
+ latency_ms: 1_000,
5896
+ }),
5897
+ } as unknown as ApiClient
5898
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
5899
+
5900
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
5901
+ const presentation = submitted!.draft.presentation
5902
+
5903
+ // A title says what is now true for the reader, alone on a lock screen,
5904
+ // and carries neither the kind nor the Project — both travel as their own
5905
+ // fields.
5906
+ expect(presentation.title).toBe('Your agents can reach you')
5907
+ expect(presentation.title.length).toBeLessThanOrEqual(40)
5908
+ expect(presentation.title).not.toMatch(/orders-api|done/i)
5909
+
5910
+ // None of the vocabulary of the check that produced it: the reader has not
5911
+ // met "verification", a "proof", or the distinction between a real
5912
+ // notification and any other kind.
5913
+ const copy = `${presentation.title} ${presentation.body}`
5914
+ expect(copy).not.toMatch(/verification|verify|proof|receipt|real notification|test/i)
5915
+ // And nothing about where it landed or what to do to it.
5916
+ expect(copy).not.toMatch(/iPhone|Android|phone|tap|swipe|banner|lock screen/i)
5917
+
5918
+ expect(presentation.body).toContain('orders-api')
5919
+ expect(presentation.body).toMatch(/^Notifai setup is finished/)
5920
+
5921
+ // Still the same bar: a real send whose Companion Receipt was observed.
5922
+ expect(io.outLines.join('\n')).toContain(
5923
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone.",
5924
+ )
5925
+ expect(io.outLines.join('\n')).toContain('All set.')
5926
+ })
5927
+
5635
5928
  it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
5636
5929
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
5637
5930
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
@@ -5670,7 +5963,12 @@ describe('init', () => {
5670
5963
  mode: 'selected',
5671
5964
  device_ids: [readyAndroid.device_id],
5672
5965
  })
5673
- expect(submitted?.draft.platform).toEqual({ android: { sound: null } })
5966
+ // Android has no caller-selected interruption level, so a configured Apple
5967
+ // preference must not reach it — and nothing else is stamped either, so the
5968
+ // server's kind sound applies rather than the old hard silence.
5969
+ expect(submitted?.draft.platform).toBeUndefined()
5970
+ expect(JSON.stringify(submitted?.draft)).not.toContain('interruption_level')
5971
+ expect(submitted?.draft.kind).toBe('done')
5674
5972
  expect(io.outLines.join('\n')).toContain(
5675
5973
  "Companion Receipt (the app's delivery confirmation) observed from Pixel.",
5676
5974
  )
@@ -5702,7 +6000,7 @@ describe('init', () => {
5702
6000
  expect(submitCalls).toBe(0)
5703
6001
  const out = io.outLines.join('\n')
5704
6002
  expect(out).toContain('Next: Your devices')
5705
- expect(out).toContain('no active Companion device registered yet')
6003
+ expect(out).toContain('no active Companion App registered yet')
5706
6004
  expect(out).not.toContain('All set.')
5707
6005
  expect(out).not.toMatch(/Companion Receipt observed/i)
5708
6006
 
@@ -5719,13 +6017,159 @@ describe('init', () => {
5719
6017
  ),
5720
6018
  ).toBe(EXIT.failed)
5721
6019
  const doctorOut = doctorIo.outLines.join('\n')
5722
- expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
5723
- expect(doctorOut).toContain('no active Companion device registered yet')
6020
+ expect(doctorOut).toMatch(/FAIL\s+Your devices: no active Companion App registered yet/)
5724
6021
  expect(doctorOut).toContain(
5725
6022
  'Delivery proof: not checked — no iPhone or Android Companion App is ready',
5726
6023
  )
5727
6024
  })
5728
6025
 
6026
+ it('says access is already requested rather than asking for it again', async () => {
6027
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-requested-'))
6028
+ const io = new CapturedIo()
6029
+ const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
6030
+ const client = {
6031
+ health: async () => true,
6032
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6033
+ listDevices: async () => {
6034
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
6035
+ },
6036
+ accessStatus: async () => ({
6037
+ status: 'no_active_plan',
6038
+ reason: 'no_active_grant',
6039
+ expires_at: null,
6040
+ email: 'waiting@example.test',
6041
+ public_v1_cutover: false,
6042
+ }),
6043
+ accessRequest: async () => ({
6044
+ request: {
6045
+ status: 'requested',
6046
+ requested_at: '2026-08-21T09:15:00.000Z',
6047
+ updated_at: '2026-08-21T09:15:00.000Z',
6048
+ },
6049
+ }),
6050
+ } as unknown as ApiClient
6051
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
6052
+
6053
+ const readiness = await assessReadiness(deps)
6054
+ const auth = readiness.states.find((state) => state.id === 'auth')
6055
+ // The middle value. Without it, someone who asked in August is told to ask
6056
+ // again on every run, forever, for a wait no command of theirs can shorten.
6057
+ expect(auth?.detail).toContain('access requested on 2026-08-21')
6058
+ expect(auth?.detail).toContain('waiting@example.test')
6059
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere' })
6060
+ expect(JSON.stringify(auth)).not.toContain('/setup/access')
6061
+
6062
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
6063
+ const out = io.outLines.join('\n')
6064
+ expect(out).toContain('nothing is needed from you')
6065
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
6066
+ })
6067
+
6068
+ it('asks for access only when this account has never asked', async () => {
6069
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-never-asked-'))
6070
+ const io = new CapturedIo()
6071
+ const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
6072
+ const client = {
6073
+ health: async () => true,
6074
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6075
+ listDevices: async () => {
6076
+ throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
6077
+ },
6078
+ accessStatus: async () => ({
6079
+ status: 'no_active_plan',
6080
+ reason: 'no_active_grant',
6081
+ expires_at: null,
6082
+ email: 'fresh@example.test',
6083
+ public_v1_cutover: false,
6084
+ }),
6085
+ accessRequest: async () => ({ request: null }),
6086
+ } as unknown as ApiClient
6087
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
6088
+
6089
+ const auth = (await assessReadiness(deps)).states.find((state) => state.id === 'auth')
6090
+ expect(auth?.detail).toContain('does not have access to Notifai yet')
6091
+ expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
6092
+ })
6093
+
6094
+ it('does not let a duplicate agent-skill install stand in front of the proof', async () => {
6095
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-'))
6096
+ const io = new CapturedIo()
6097
+ const calls = { submit: 0 }
6098
+ const nativeSkills: NativeSkills = {
6099
+ add: async () => 0,
6100
+ remove: async () => 0,
6101
+ // Both scopes installed: the harness lists two, which is a real problem
6102
+ // and a human decision — and still not a reason to withhold the proof.
6103
+ list: async (scope) => ({ skills: [managedSkill(scope ?? 'project', cwd)] }),
6104
+ }
6105
+ const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
6106
+
6107
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
6108
+ const readiness = await assessReadiness(deps)
6109
+ expect(readiness.states.find((state) => state.id === 'skill')?.status).toBe('gap')
6110
+ expect(firstRequiredBlocker(readiness)).toBeNull()
6111
+ expect(calls.submit).toBe(1)
6112
+ expect(io.outLines.join('\n')).toContain('All set.')
6113
+ })
6114
+
6115
+ it('closes on the blocker that stopped the run, not the state before it', async () => {
6116
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-login-access-'))
6117
+ const io = new InteractiveIo()
6118
+ let now = 0
6119
+ const client = {
6120
+ health: async () => true,
6121
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
6122
+ beginPairing: async () => ({
6123
+ pairing_id: 'pair_test',
6124
+ code: 'ABCD-EFGH',
6125
+ approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
6126
+ expires_at: new Date(10_000).toISOString(),
6127
+ poll_interval_seconds: 1,
6128
+ }),
6129
+ pollPairing: async () => ({
6130
+ status: 'no_active_plan',
6131
+ next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
6132
+ }),
6133
+ } as unknown as ApiClient
6134
+ const deps: CommandDeps = {
6135
+ ...makeDeps(io, client),
6136
+ cwd,
6137
+ env: isolatedEnv(cwd),
6138
+ now: () => now,
6139
+ sleep: async (milliseconds: number) => {
6140
+ now += milliseconds
6141
+ },
6142
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
6143
+ }
6144
+
6145
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
6146
+ const out = io.outLines.join('\n')
6147
+ // One next step, and it is the one that actually stopped the run. The
6148
+ // pairing state that preceded it would send the reader straight back into
6149
+ // the command that just failed, for a reason it never names.
6150
+ expect(out.match(/^Next:/gm)).toHaveLength(1)
6151
+ expect(out).toContain('Next: Access')
6152
+ expect(out).toContain('https://app.notifai.sh/setup/access')
6153
+ expect(out).not.toContain('not paired with your account')
6154
+
6155
+ // …and the wall is stated once across the whole visit. It used to arrive
6156
+ // three ways — a lowercase `next:` from the pairing failure, an "after
6157
+ // access … run `notifai init`" remedy, and this close — so a stopped User
6158
+ // had to work out which of three destinations-and-commands was theirs.
6159
+ const said = [
6160
+ ...io.outLines,
6161
+ ...io.errLines,
6162
+ ...io.notes.map((note) => note.message),
6163
+ ...io.spinnerEvents,
6164
+ ].join('\n')
6165
+ expect(said.match(/setup\/access/g)).toHaveLength(1)
6166
+ expect(said.match(/does not have access/g)).toHaveLength(1)
6167
+ expect(said).not.toContain('After access is granted')
6168
+ expect(said).not.toContain('next: ')
6169
+ // Rerunning is how they resume, not how they get access.
6170
+ expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
6171
+ })
6172
+
5729
6173
  it('treats a revoked credential as the one blocker and points back to pairing', async () => {
5730
6174
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
5731
6175
  const io = new CapturedIo()
@@ -5778,7 +6222,7 @@ describe('init', () => {
5778
6222
  const auth = readiness.states.find((state) => state.id === 'auth')
5779
6223
  expect(auth).toMatchObject({
5780
6224
  status: 'gap',
5781
- detail: 'no active plan or temporary Alpha access (rafael@example.test)',
6225
+ detail: 'this account does not have access to Notifai yet (rafael@example.test)',
5782
6226
  remedy: { by: 'user-elsewhere', summary: nextAction },
5783
6227
  })
5784
6228
  expect(auth?.remedy && 'command' in auth.remedy ? auth.remedy.command : undefined).toBeUndefined()
@@ -5823,7 +6267,7 @@ describe('init', () => {
5823
6267
  const readiness = await assessReadiness(deps)
5824
6268
  const auth = readiness.states.find((state) => state.id === 'auth')
5825
6269
  expect(auth?.status).toBe('gap')
5826
- expect(auth?.detail).toContain('no active plan')
6270
+ expect(auth?.detail).toContain('does not have access to Notifai yet')
5827
6271
  expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
5828
6272
  expect(JSON.stringify(auth)).not.toContain('pair it again')
5829
6273
  })
@@ -6291,7 +6735,7 @@ describe('asking before the hooks have ever run', () => {
6291
6735
  io.outLines = []
6292
6736
 
6293
6737
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
6294
- expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
6738
+ expect(io.outLines.some((line) => line.startsWith('Question registered locally (q_'))).toBe(true)
6295
6739
  expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
6296
6740
  session_id: 'codex-current-thread',
6297
6741
  harness: 'codex',
@@ -6299,6 +6743,28 @@ describe('asking before the hooks have ever run', () => {
6299
6743
  expect(
6300
6744
  readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
6301
6745
  ).toBe('Gentle Salmon')
6746
+
6747
+ io.outLines = []
6748
+ expect(askCommand(deps, 'Wait?', { json: true })).toBe(EXIT.ok)
6749
+ const registered = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
6750
+ expect(registered).toMatchObject({
6751
+ registered: true,
6752
+ state: 'local',
6753
+ submitted: false,
6754
+ request_id: null,
6755
+ provider_acceptance: 'not_available',
6756
+ status: `notifai status ${registered.question_id as string}`,
6757
+ close: `notifai close ${registered.question_id as string}`,
6758
+ })
6759
+ expect(inspectQuestionState(registered.question_id as string, env)).toMatchObject({
6760
+ found: true,
6761
+ question: {
6762
+ question_id: registered.question_id,
6763
+ state: 'local',
6764
+ submitted: false,
6765
+ request_id: null,
6766
+ },
6767
+ })
6302
6768
  })
6303
6769
 
6304
6770
  it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
@@ -6835,8 +7301,11 @@ describe('asking before the hooks have ever run', () => {
6835
7301
 
6836
7302
  const readiness = await assessReadiness(deps)
6837
7303
  const fired = readiness.states.find((state) => state.id === 'hooks-fired')
6838
- expect(fired?.status).toBe('gap')
7304
+ // Reported, never a blocker: the only thing that closes this is a turn
7305
+ // ending, which the agent standing inside that turn cannot supply.
7306
+ expect(fired?.status).toBe('optional-gap')
6839
7307
  expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
7308
+ expect(firstRequiredBlocker(readiness)?.id).not.toBe('hooks-fired')
6840
7309
  })
6841
7310
 
6842
7311
  it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
@@ -7410,7 +7879,7 @@ describe('a server behind this CLI', () => {
7410
7879
 
7411
7880
  const said = io.outLines.concat(io.errLines).join(' ')
7412
7881
  expect(said).toMatch(/service is being updated/)
7413
- expect(said).not.toContain('npm install -g @raidiant/notifai')
7882
+ expect(said).not.toContain('notifai update')
7414
7883
  expect(said).not.toMatch(/Protocol version|schema v/i)
7415
7884
 
7416
7885
  const sendIo = new CapturedIo()