@raidiant/notifai 9.0.0 → 9.2.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 (68) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-session.d.ts +14 -0
  3. package/dist/command-session.js +30 -0
  4. package/dist/command-session.js.map +1 -0
  5. package/dist/commands-doctor.js +17 -15
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.js +3 -29
  8. package/dist/commands-guidance.js.map +1 -1
  9. package/dist/commands-harness-context.js +21 -1
  10. package/dist/commands-harness-context.js.map +1 -1
  11. package/dist/commands-hooks.d.ts +11 -1
  12. package/dist/commands-hooks.js +98 -184
  13. package/dist/commands-hooks.js.map +1 -1
  14. package/dist/commands-init.d.ts +2 -0
  15. package/dist/commands-init.js +41 -21
  16. package/dist/commands-init.js.map +1 -1
  17. package/dist/commands-send.d.ts +1 -0
  18. package/dist/commands-send.js +62 -14
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands-setup-proof.d.ts +4 -3
  21. package/dist/commands-setup-proof.js +29 -14
  22. package/dist/commands-setup-proof.js.map +1 -1
  23. package/dist/commands.d.ts +1 -1
  24. package/dist/commands.js +1 -1
  25. package/dist/commands.js.map +1 -1
  26. package/dist/guidance-render.d.ts +20 -0
  27. package/dist/guidance-render.js +42 -0
  28. package/dist/guidance-render.js.map +1 -0
  29. package/dist/hooks.d.ts +16 -13
  30. package/dist/hooks.js +57 -32
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/opencode-plugin.js +32 -5
  33. package/dist/opencode-plugin.js.map +1 -1
  34. package/dist/program.js +21 -12
  35. package/dist/program.js.map +1 -1
  36. package/dist/readiness.d.ts +6 -0
  37. package/dist/readiness.js +37 -0
  38. package/dist/readiness.js.map +1 -1
  39. package/dist/send-attempts.d.ts +26 -0
  40. package/dist/send-attempts.js +139 -0
  41. package/dist/send-attempts.js.map +1 -0
  42. package/dist/session-activation.d.ts +4 -8
  43. package/dist/session-activation.js +25 -14
  44. package/dist/session-activation.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/command-session.ts +38 -0
  47. package/src/commands-doctor.ts +18 -28
  48. package/src/commands-guidance.ts +3 -38
  49. package/src/commands-harness-context.ts +20 -1
  50. package/src/commands-hooks.ts +219 -220
  51. package/src/commands-init.ts +46 -24
  52. package/src/commands-send.ts +70 -14
  53. package/src/commands-setup-proof.test.ts +72 -0
  54. package/src/commands-setup-proof.ts +33 -14
  55. package/src/commands.test.ts +209 -67
  56. package/src/commands.ts +1 -0
  57. package/src/guidance-render.ts +56 -0
  58. package/src/guidance.test.ts +28 -0
  59. package/src/hooks.test.ts +42 -137
  60. package/src/hooks.ts +67 -46
  61. package/src/install-hooks.test.ts +41 -16
  62. package/src/opencode-plugin.ts +35 -5
  63. package/src/program.ts +22 -12
  64. package/src/readiness.ts +44 -0
  65. package/src/send-attempts.test.ts +143 -0
  66. package/src/send-attempts.ts +188 -0
  67. package/src/session-activation.ts +35 -18
  68. package/src/skill.test.ts +22 -23
@@ -72,7 +72,7 @@ import {
72
72
  } from './install-hooks.js'
73
73
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
74
74
  import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
75
- import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
75
+ import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.js'
76
76
  import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
77
77
  import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
78
78
  import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
@@ -585,7 +585,7 @@ describe('command contracts', () => {
585
585
  expect(io.errLines.join('\n')).toContain('project')
586
586
  })
587
587
 
588
- it('prints the exact idempotency key needed after an ambiguous submit failure', async () => {
588
+ it('persists one opaque retry attempt after an ambiguous submit failure', async () => {
589
589
  const io = new CapturedIo()
590
590
  let attemptedKey = ''
591
591
  const client = {
@@ -611,24 +611,22 @@ describe('command contracts', () => {
611
611
  ).toBe(EXIT.network)
612
612
 
613
613
  expect(attemptedKey).toMatch(/^cli-/)
614
- expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
614
+ expect(io.errLines.join('\n')).toContain('exact semantic send with `--retry`')
615
+ expect(io.errLines.join('\n')).toMatch(/Opaque retry attempt: sat_/)
615
616
  expect(
616
617
  readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
617
618
  ).toMatchObject({
618
619
  idempotency_key: attemptedKey,
619
- kind: 'done',
620
- title_chars: 'Checks finished'.length,
621
- session_id: null,
620
+ attempt_id: expect.stringMatching(/^sat_/),
621
+ replay: false,
622
622
  })
623
623
  rmSync(root, { recursive: true, force: true })
624
624
  })
625
625
 
626
- it('prints the same generated key for an ambiguous server failure', async () => {
626
+ it('prints an opaque retry instruction for an ambiguous server failure', async () => {
627
627
  const io = new CapturedIo()
628
- let attemptedKey = ''
629
628
  const client = {
630
- submit: async (body: SubmitNotificationRequestT) => {
631
- attemptedKey = body.idempotency_key
629
+ submit: async () => {
632
630
  throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
633
631
  },
634
632
  } as unknown as ApiClient
@@ -640,15 +638,40 @@ describe('command contracts', () => {
640
638
  body: 'The service failed while accepting the result.',
641
639
  }),
642
640
  ).toBe(EXIT.network)
643
- expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
641
+ expect(io.errLines.join('\n')).toContain('exact semantic send with `--retry`')
644
642
  })
645
643
 
646
- it('routes a send with no active Companion devices into the setup coordinator', async () => {
644
+ it('reuses the opaque attempt key only after an explicit exact semantic retry', async () => {
645
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-retry-'))
647
646
  const io = new CapturedIo()
648
- let attemptedKey = ''
647
+ const keys: string[] = []
648
+ let calls = 0
649
649
  const client = {
650
650
  submit: async (body: SubmitNotificationRequestT) => {
651
- attemptedKey = body.idempotency_key
651
+ keys.push(body.idempotency_key)
652
+ calls += 1
653
+ if (calls === 1) throw new NetworkError('killed after an ambiguous boundary')
654
+ return { ...receipt, replayed: true }
655
+ },
656
+ } as unknown as ApiClient
657
+ const deps = {
658
+ ...makeDeps(io, client),
659
+ cwd: root,
660
+ env: { XDG_CONFIG_HOME: path.join(root, 'config'), XDG_STATE_HOME: path.join(root, 'state') },
661
+ }
662
+ const semantic = { kind: 'done', title: 'Recovery completed', body: 'The exact work is done.' }
663
+
664
+ expect(await sendCommand(deps, semantic)).toBe(EXIT.network)
665
+ expect(await sendCommand(deps, { ...semantic, retry: true })).toBe(EXIT.ok)
666
+ expect(keys).toHaveLength(2)
667
+ expect(keys[1]).toBe(keys[0])
668
+ expect(await sendCommand(deps, { ...semantic, retry: true })).toBe(EXIT.usage)
669
+ })
670
+
671
+ it('routes a send with no active Companion devices into the setup coordinator', async () => {
672
+ const io = new CapturedIo()
673
+ const client = {
674
+ submit: async () => {
652
675
  throw new ApiCallError(
653
676
  409,
654
677
  'no_active_devices',
@@ -667,7 +690,7 @@ describe('command contracts', () => {
667
690
  ).toBe(EXIT.failed)
668
691
 
669
692
  expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
670
- expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
693
+ expect(io.errLines.join('\n')).toContain('exact original Notification Request with `--retry`')
671
694
  expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
672
695
  })
673
696
 
@@ -1146,11 +1169,13 @@ describe('command contracts', () => {
1146
1169
  expect(grammar).not.toContain(".option('--detail")
1147
1170
  expect(grammar).not.toContain(".option('--session <")
1148
1171
  expect(grammar).toContain(".option('--body-file")
1149
- expect(grammar).toContain(".option('--session-id")
1150
1172
  expect(grammar).toContain("'--session-label <text>'")
1151
1173
  expect(grammar).toContain("'--image <path|url|media_id>'")
1152
1174
  expect(grammar).toContain("'--image-alt <text>'")
1153
1175
  }
1176
+ expect(sendGrammar).toContain(".option('--session-id")
1177
+ expect(sendGrammar).toContain(".option('--retry'")
1178
+ expect(askGrammar).not.toContain(".option('--session-id")
1154
1179
  // Kind now selects the sound, so the help must say so — and must not carry
1155
1180
  // the retired separation it replaced.
1156
1181
  expect(source).toContain('Kind is required, and it selects the sound')
@@ -1624,7 +1649,7 @@ describe('command contracts', () => {
1624
1649
  expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
1625
1650
  })
1626
1651
 
1627
- it('clears a matching active-session obligation after acknowledgement success', async () => {
1652
+ it('clears the stable-id session obligation after acknowledgement from another checkout', async () => {
1628
1653
  const io = new CapturedIo()
1629
1654
  const client = {
1630
1655
  putAgentAcknowledgement: async () => ({
@@ -1647,6 +1672,7 @@ describe('command contracts', () => {
1647
1672
  acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
1648
1673
  })
1649
1674
  writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
1675
+ deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-other-checkout-'))
1650
1676
 
1651
1677
  expect(
1652
1678
  await acknowledgeCommand(deps, receipt.request_id, {
@@ -1868,7 +1894,7 @@ describe('command contracts', () => {
1868
1894
  expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
1869
1895
  })
1870
1896
 
1871
- it('close withdraws one unpushed question by the ask id', async () => {
1897
+ it('close withdraws one unpushed question by stable id from another checkout', async () => {
1872
1898
  const io = new CapturedIo()
1873
1899
  const client = {
1874
1900
  closeReplies: async () => {
@@ -1889,6 +1915,7 @@ describe('command contracts', () => {
1889
1915
  ],
1890
1916
  })
1891
1917
  writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
1918
+ deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-other-checkout-'))
1892
1919
 
1893
1920
  expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
1894
1921
  expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
@@ -1974,7 +2001,7 @@ describe('command contracts', () => {
1974
2001
  expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
1975
2002
  })
1976
2003
 
1977
- it('resolves replies --pending through the project session pointer and prints its id', async () => {
2004
+ it('resolves replies --pending by exact session from another checkout', async () => {
1978
2005
  const io = new CapturedIo()
1979
2006
  const client = {
1980
2007
  replies: async () => replyResponse([reply]),
@@ -1985,11 +2012,15 @@ describe('command contracts', () => {
1985
2012
  deps.env = {
1986
2013
  XDG_CONFIG_HOME: path.join(root, 'config'),
1987
2014
  XDG_STATE_HOME: path.join(root, 'state'),
2015
+ CODEX_THREAD_ID: 'pending-session',
1988
2016
  }
1989
2017
  writeSessionState('pending-session', deps.env, {
2018
+ harness: 'codex',
2019
+ last_prompt_at: Date.now(),
1990
2020
  pending: [{ question: 'Deploy?', request_id: receipt.request_id }],
1991
2021
  })
1992
2022
  writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
2023
+ deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-other-checkout-'))
1993
2024
 
1994
2025
  expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
1995
2026
  expect(io.outLines).toEqual([
@@ -3246,13 +3277,13 @@ describe('stable hook installation', () => {
3246
3277
  ).toBe(EXIT.ok)
3247
3278
  const globalFile = settingsFile('opencode', true, cwd, env)
3248
3279
  const current = readFileSync(globalFile, 'utf8')
3249
- writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 10', 'const ADAPTER_VERSION = 8'))
3280
+ writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 12', 'const ADAPTER_VERSION = 11'))
3250
3281
 
3251
3282
  io.outLines = []
3252
3283
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
3253
3284
  expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
3254
3285
  const refreshed = readFileSync(globalFile, 'utf8')
3255
- expect(refreshed).toContain('const ADAPTER_VERSION = 10')
3286
+ expect(refreshed).toContain('const ADAPTER_VERSION = 12')
3256
3287
  expect(refreshed).toContain('experimental.chat.system.transform')
3257
3288
  expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
3258
3289
  })
@@ -5359,7 +5390,7 @@ describe('init', () => {
5359
5390
  expect(out.match(/^Next:/gm)).toHaveLength(1)
5360
5391
  })
5361
5392
 
5362
- it('scopes proof to each project worktree even on the same paired machine', async () => {
5393
+ it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
5363
5394
  const stateRoot = mkdtempSync(path.join(os.tmpdir(), 'init-worktrees-state-'))
5364
5395
  let submitCalls = 0
5365
5396
  const client = {
@@ -5391,7 +5422,62 @@ describe('init', () => {
5391
5422
  ).toBe(EXIT.ok)
5392
5423
  }
5393
5424
 
5394
- expect(submitCalls).toBe(2)
5425
+ expect(submitCalls).toBe(1)
5426
+ })
5427
+
5428
+ it('returns one authoritative structured final readiness without duplicate probes', async () => {
5429
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-json-ready-'))
5430
+ const io = new InteractiveIo()
5431
+ let healthCalls = 0
5432
+ let compatibilityCalls = 0
5433
+ let deviceCalls = 0
5434
+ const client = {
5435
+ health: async () => {
5436
+ healthCalls += 1
5437
+ return true
5438
+ },
5439
+ compatibility: async () => {
5440
+ compatibilityCalls += 1
5441
+ return currentCompatibility
5442
+ },
5443
+ listDevices: async () => {
5444
+ deviceCalls += 1
5445
+ return { devices: [readyIphone] }
5446
+ },
5447
+ evidence: async (requestId: string) =>
5448
+ setupEvidence(requestId, {
5449
+ state: 'observed',
5450
+ observed_at: '2026-08-25T00:00:02.000Z',
5451
+ latency_ms: 1_000,
5452
+ }),
5453
+ } as unknown as ApiClient
5454
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
5455
+ writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "json-ready"\n')
5456
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
5457
+ writeSetupProof(deps, {
5458
+ request_id: 'req_json_ready',
5459
+ device_id: readyIphone.device_id,
5460
+ project: 'json-ready',
5461
+ started_at: '2026-08-25T00:00:00.000Z',
5462
+ })
5463
+
5464
+ expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.ok)
5465
+ expect(io.prompts).toEqual([])
5466
+ expect(io.outLines).toHaveLength(1)
5467
+ const result = JSON.parse(io.outLines[0] ?? '{}') as {
5468
+ ready: boolean
5469
+ can_send: boolean
5470
+ question_routing_ready: boolean
5471
+ states: Array<{ id: string; status: string; remedy: unknown }>
5472
+ }
5473
+ expect(result.ready).toBe(true)
5474
+ expect(result.can_send).toBe(true)
5475
+ expect(result.question_routing_ready).toBe(false)
5476
+ expect(result.states.length).toBeGreaterThan(8)
5477
+ expect(result.states.every((state) => 'remedy' in state)).toBe(true)
5478
+ expect(healthCalls).toBe(1)
5479
+ expect(compatibilityCalls).toBe(1)
5480
+ expect(deviceCalls).toBe(1)
5395
5481
  })
5396
5482
  })
5397
5483
 
@@ -5711,8 +5797,8 @@ describe('asking before the hooks have ever run', () => {
5711
5797
 
5712
5798
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5713
5799
  const said = io.errLines.join(' ')
5714
- expect(said).toMatch(/active Codex session/i)
5715
- expect(said).toContain('notifai hooks install --harness codex')
5800
+ expect(said).toMatch(/Codex hooks are not installed/i)
5801
+ expect(said).toContain('notifai init --json')
5716
5802
  expect(said).not.toMatch(/Claude Code: send one new prompt|OpenCode: restart/)
5717
5803
  })
5718
5804
 
@@ -5729,12 +5815,13 @@ describe('asking before the hooks have ever run', () => {
5729
5815
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
5730
5816
 
5731
5817
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
5732
- writeSessionState('codex-other-thread', env, { last_prompt_at: 42 })
5818
+ trustInstalledCodexHooks(cwd, env)
5819
+ writeSessionState('codex-other-thread', env, { harness: 'codex', last_prompt_at: 42 })
5733
5820
  writeProjectSession(cwd, env, 'codex-other-thread', 42, 'codex')
5734
5821
  io.outLines = []
5735
5822
 
5736
5823
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5737
- expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
5824
+ expect(io.errLines.join(' ')).toMatch(/exact Codex session has not fired UserPromptSubmit/i)
5738
5825
  expect(io.outLines).not.toContain(
5739
5826
  'Question registered. Ask it in the conversation as usual and end your turn.',
5740
5827
  )
@@ -5754,9 +5841,9 @@ describe('asking before the hooks have ever run', () => {
5754
5841
 
5755
5842
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
5756
5843
  trustInstalledCodexHooks(cwd, env)
5757
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
5844
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5758
5845
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
5759
- writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
5846
+ writeSessionState('claude-concurrent', env, { harness: 'claude-code', last_prompt_at: 43, last_stop_at: 43 })
5760
5847
  writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
5761
5848
  io.outLines = []
5762
5849
 
@@ -5771,6 +5858,49 @@ describe('asking before the hooks have ever run', () => {
5771
5858
  ).toBe('Gentle Salmon')
5772
5859
  })
5773
5860
 
5861
+ it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
5862
+ const first = scratchDir('notifai-exact-session-first-')
5863
+ const second = scratchDir('notifai-exact-session-second-')
5864
+ const io = new CapturedIo()
5865
+ const env = {
5866
+ ...isolatedEnv(first),
5867
+ CLAUDECODE: '1',
5868
+ CLAUDE_CODE_SESSION_ID: 'claude-cross-checkout',
5869
+ }
5870
+ const firstDeps = { ...makeDeps(io, {} as ApiClient), cwd: first, env, now: () => 42 }
5871
+ expect(
5872
+ hooksInstallCommand(firstDeps, {
5873
+ harness: 'claude-code',
5874
+ execPath,
5875
+ scriptPath,
5876
+ }),
5877
+ ).toBe(EXIT.ok)
5878
+ writeSessionState('claude-cross-checkout', env, {
5879
+ harness: 'claude-code',
5880
+ activation_cwd: first,
5881
+ last_prompt_at: 42,
5882
+ last_stop_at: 41,
5883
+ })
5884
+ writeProjectSession(first, env, 'claude-cross-checkout', 42, 'claude-code')
5885
+ mkdirSync(path.join(second, '.notifai'), { recursive: true })
5886
+ writeFileSync(
5887
+ path.join(second, '.notifai', 'config.toml'),
5888
+ 'project = "second-project"\nask_notifications = false\n',
5889
+ )
5890
+ mkdirSync(path.dirname(sessionConfigPath('claude-cross-checkout', env)), { recursive: true })
5891
+ writeFileSync(sessionConfigPath('claude-cross-checkout', env), 'ask_notifications = true\n')
5892
+ io.outLines = []
5893
+
5894
+ const secondDeps = { ...firstDeps, cwd: second }
5895
+ expect(askCommand(secondDeps, 'Use this checkout?', {})).toBe(EXIT.ok)
5896
+ const pending = readSessionState('claude-cross-checkout', env).pending?.[0]
5897
+ expect(pending?.project).toBe('second-project')
5898
+ expect(pending?.source).toMatchObject({
5899
+ session_id: 'claude-cross-checkout',
5900
+ harness: 'claude-code',
5901
+ })
5902
+ })
5903
+
5774
5904
  it('uploads ask images before registration and freezes canonical body media', async () => {
5775
5905
  const cwd = scratchDir('notifai-ask-media-')
5776
5906
  const first = path.join(cwd, 'first.png')
@@ -5805,7 +5935,7 @@ describe('asking before the hooks have ever run', () => {
5805
5935
 
5806
5936
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
5807
5937
  trustInstalledCodexHooks(cwd, env)
5808
- writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
5938
+ writeSessionState('codex-media-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5809
5939
  writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
5810
5940
  io.outLines = []
5811
5941
 
@@ -5856,7 +5986,7 @@ describe('asking before the hooks have ever run', () => {
5856
5986
  hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
5857
5987
  ).toBe(EXIT.ok)
5858
5988
  trustInstalledCodexHooks(cwd, env)
5859
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
5989
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5860
5990
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
5861
5991
  io.outLines = []
5862
5992
 
@@ -5886,9 +6016,9 @@ describe('asking before the hooks have ever run', () => {
5886
6016
  trustInstalledCodexHooks(cwd, env)
5887
6017
  // Both sessions are live here. The parent fired when its own turn began;
5888
6018
  // the child fired for the turn that is running this command.
5889
- writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
6019
+ writeSessionState('codex-orchestrator', env, { harness: 'codex', last_prompt_at: 40, last_stop_at: 39 })
5890
6020
  writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
5891
- writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
6021
+ writeSessionState('claude-current', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
5892
6022
  writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
5893
6023
  io.outLines = []
5894
6024
 
@@ -5921,7 +6051,7 @@ describe('asking before the hooks have ever run', () => {
5921
6051
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
5922
6052
  EXIT.ok,
5923
6053
  )
5924
- writeSessionState('orca-claude-question', env, { last_prompt_at: 42, last_stop_at: 41 })
6054
+ writeSessionState('orca-claude-question', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
5925
6055
  writeProjectSession(cwd, env, 'orca-claude-question', 42, 'claude-code')
5926
6056
  io.outLines = []
5927
6057
 
@@ -5949,7 +6079,7 @@ describe('asking before the hooks have ever run', () => {
5949
6079
  hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
5950
6080
  ).toBe(EXIT.ok)
5951
6081
  trustInstalledCodexHooks(cwd, env)
5952
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6082
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
5953
6083
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
5954
6084
 
5955
6085
  const readiness = await assessReadiness(deps)
@@ -5987,7 +6117,7 @@ describe('asking before the hooks have ever run', () => {
5987
6117
 
5988
6118
  io.errLines = []
5989
6119
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
5990
- expect(io.errLines.join(' ')).toMatch(/Several harness markers are present/i)
6120
+ expect(io.errLines.join(' ')).toMatch(/Several harness sessions could own this shell/i)
5991
6121
  })
5992
6122
 
5993
6123
  it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
@@ -6004,7 +6134,7 @@ describe('asking before the hooks have ever run', () => {
6004
6134
 
6005
6135
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
6006
6136
  trustInstalledCodexHooks(cwd, env)
6007
- writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6137
+ writeSessionState('codex-commitment-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
6008
6138
  writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
6009
6139
  io.outLines = []
6010
6140
 
@@ -6040,7 +6170,7 @@ describe('asking before the hooks have ever run', () => {
6040
6170
 
6041
6171
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
6042
6172
  trustInstalledCodexHooks(cwd, env)
6043
- writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6173
+ writeSessionState('codex-free-text-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
6044
6174
  writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
6045
6175
  io.outLines = []
6046
6176
 
@@ -6072,7 +6202,7 @@ describe('asking before the hooks have ever run', () => {
6072
6202
  configFile,
6073
6203
  readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
6074
6204
  )
6075
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6205
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
6076
6206
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
6077
6207
  io.outLines = []
6078
6208
 
@@ -6147,12 +6277,12 @@ describe('asking before the hooks have ever run', () => {
6147
6277
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6148
6278
  EXIT.ok,
6149
6279
  )
6150
- writeSessionState('claude-other', env, { last_prompt_at: 42 })
6280
+ writeSessionState('claude-other', env, { harness: 'claude-code', last_prompt_at: 42 })
6151
6281
  writeProjectSession(cwd, env, 'claude-other', 42, 'claude-code')
6152
6282
  io.outLines = []
6153
6283
 
6154
6284
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6155
- expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
6285
+ expect(io.errLines.join(' ')).toMatch(/exact Claude Code session has not fired UserPromptSubmit/i)
6156
6286
  })
6157
6287
 
6158
6288
  it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
@@ -6170,7 +6300,7 @@ describe('asking before the hooks have ever run', () => {
6170
6300
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6171
6301
  EXIT.ok,
6172
6302
  )
6173
- writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
6303
+ writeSessionState('claude-parent-loop', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
6174
6304
  writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
6175
6305
  io.outLines = []
6176
6306
 
@@ -6191,7 +6321,7 @@ describe('asking before the hooks have ever run', () => {
6191
6321
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6192
6322
  EXIT.ok,
6193
6323
  )
6194
- writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
6324
+ writeSessionState('claude-last-writer', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
6195
6325
  writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
6196
6326
  io.outLines = []
6197
6327
 
@@ -6220,7 +6350,7 @@ describe('asking before the hooks have ever run', () => {
6220
6350
  }),
6221
6351
  })
6222
6352
  trustInstalledCodexHooks(cwd, env)
6223
- writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
6353
+ writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
6224
6354
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
6225
6355
  io.outLines = []
6226
6356
 
@@ -6243,7 +6373,7 @@ describe('asking before the hooks have ever run', () => {
6243
6373
  expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
6244
6374
  EXIT.ok,
6245
6375
  )
6246
- writeSessionState('claude-current', env, { last_prompt_at: 42 })
6376
+ writeSessionState('claude-current', env, { harness: 'claude-code', last_prompt_at: 42 })
6247
6377
  writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
6248
6378
  io.outLines = []
6249
6379
 
@@ -6268,12 +6398,12 @@ describe('asking before the hooks have ever run', () => {
6268
6398
  }
6269
6399
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6270
6400
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
6271
- writeSessionState('opencode-other', env, { last_prompt_at: 42 })
6401
+ writeSessionState('opencode-other', env, { harness: 'opencode', last_prompt_at: 42 })
6272
6402
  writeProjectSession(cwd, env, 'opencode-other', 42, 'opencode')
6273
6403
  io.outLines = []
6274
6404
 
6275
6405
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6276
- expect(io.errLines.join(' ')).toMatch(/active OpenCode session:.*has not published/i)
6406
+ expect(io.errLines.join(' ')).toMatch(/no proven answer continuation/i)
6277
6407
  })
6278
6408
 
6279
6409
  it('rejects OpenCode before registration even with an exact matching pointer', () => {
@@ -6288,7 +6418,7 @@ describe('asking before the hooks have ever run', () => {
6288
6418
  }
6289
6419
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6290
6420
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
6291
- writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
6421
+ writeSessionState('opencode-current', env, { harness: 'opencode', last_prompt_at: 42, last_stop_at: 41 })
6292
6422
  writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
6293
6423
  io.outLines = []
6294
6424
 
@@ -6307,7 +6437,7 @@ describe('asking before the hooks have ever run', () => {
6307
6437
  }
6308
6438
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6309
6439
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
6310
- writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
6440
+ writeSessionState('cursor-live', env, { harness: 'cursor', last_prompt_at: 42, last_stop_at: 41 })
6311
6441
  writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
6312
6442
  io.outLines = []
6313
6443
 
@@ -6330,7 +6460,7 @@ describe('asking before the hooks have ever run', () => {
6330
6460
  }
6331
6461
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
6332
6462
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
6333
- writeSessionState('codex-live', env, { last_prompt_at: 42 })
6463
+ writeSessionState('codex-live', env, { harness: 'codex', last_prompt_at: 42 })
6334
6464
  writeProjectSession(cwd, env, 'codex-live', 42, 'codex')
6335
6465
  io.outLines = []
6336
6466
 
@@ -6361,7 +6491,7 @@ describe('asking before the hooks have ever run', () => {
6361
6491
 
6362
6492
  const pointer = readiness.states.find((s) => s.id === 'hooks-active-session')
6363
6493
  expect(pointer?.status).toBe('optional-gap')
6364
- expect(pointer?.detail).toMatch(/has not published a live pointer/)
6494
+ expect(pointer?.detail).toMatch(/has not published exact lifecycle state/)
6365
6495
  expect(pointer?.remedy?.summary).toMatch(/send one Claude Code prompt/)
6366
6496
  expect(pointer?.remedy?.by === 'user-here' ? pointer.remedy.command : '').toBe(
6367
6497
  'notifai doctor',
@@ -6580,7 +6710,7 @@ describe('asking before the hooks have ever run', () => {
6580
6710
  expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
6581
6711
  })
6582
6712
 
6583
- it('tells Claude Code to start fresh so activation is model-visible', () => {
6713
+ it('returns a stable exact-session failure when lifecycle context is absent', () => {
6584
6714
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
6585
6715
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
6586
6716
  applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
@@ -6592,13 +6722,18 @@ describe('asking before the hooks have ever run', () => {
6592
6722
  const io = new CapturedIo()
6593
6723
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_STATE_HOME: cwd } }
6594
6724
 
6595
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6596
- const said = io.errLines.join(' ')
6597
- expect(said).toMatch(/Claude Code: start one fresh session, send one prompt/i)
6598
- expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
6725
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6726
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6727
+ ok: false,
6728
+ registered: false,
6729
+ code: 'session_identity_missing',
6730
+ check_id: 'exact_session',
6731
+ exit_code: EXIT.usage,
6732
+ remedy: expect.stringMatching(/exact session|blocking/i),
6733
+ })
6599
6734
  })
6600
6735
 
6601
- it('gives Cursor its fresh-conversation and first-Stop activation path', () => {
6736
+ it('does not guess a Cursor conversation when lifecycle context is absent', () => {
6602
6737
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
6603
6738
  const io = new CapturedIo()
6604
6739
  const deps = {
@@ -6610,11 +6745,14 @@ describe('asking before the hooks have ever run', () => {
6610
6745
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
6611
6746
  io.outLines = []
6612
6747
 
6613
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6614
- expect(io.errLines.join(' ')).toMatch(/Cursor: start one fresh conversation[\s\S]*finish its first turn[\s\S]*`notifai doctor`/)
6748
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6749
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6750
+ code: 'session_identity_missing',
6751
+ check_id: 'exact_session',
6752
+ })
6615
6753
  })
6616
6754
 
6617
- it('keeps OpenCode activation separate from unsupported answer continuation', () => {
6755
+ it('does not infer OpenCode from an installed adapter alone', () => {
6618
6756
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
6619
6757
  const io = new CapturedIo()
6620
6758
  const deps = {
@@ -6629,13 +6767,14 @@ describe('asking before the hooks have ever run', () => {
6629
6767
  expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
6630
6768
  io.outLines = []
6631
6769
 
6632
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6633
- const said = io.errLines.join(' ')
6634
- expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
6635
- expect(said).toContain('non-blocking question continuation is intentionally unsupported')
6770
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6771
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6772
+ code: 'session_identity_missing',
6773
+ check_id: 'exact_session',
6774
+ })
6636
6775
  })
6637
6776
 
6638
- it('says to install when nothing is installed at all', () => {
6777
+ it('does not turn missing harness identity into an installation guess', () => {
6639
6778
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
6640
6779
  const io = new CapturedIo()
6641
6780
  const deps = {
@@ -6650,8 +6789,11 @@ describe('asking before the hooks have ever run', () => {
6650
6789
  },
6651
6790
  }
6652
6791
 
6653
- expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
6654
- expect(io.errLines.join(' ')).toMatch(/hooks install/)
6792
+ expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
6793
+ expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
6794
+ code: 'session_identity_missing',
6795
+ check_id: 'exact_session',
6796
+ })
6655
6797
  })
6656
6798
  })
6657
6799
 
package/src/commands.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  hooksInstallCommand,
37
37
  hooksUninstallCommand,
38
38
  runningViaNpx,
39
+ reportAskFailure,
39
40
  type AskFlags,
40
41
  type BuiltQuestions,
41
42
  type HookEvent,