@raidiant/notifai 4.0.0 → 5.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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +72 -5
  17. package/dist/hooks.js +407 -79
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
@@ -1,3 +1,4 @@
1
+ import { REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
1
2
  import {
2
3
  existsSync,
3
4
  mkdirSync,
@@ -11,6 +12,7 @@ import {
11
12
  import os from 'node:os'
12
13
  import path from 'node:path'
13
14
  import { fileURLToPath } from 'node:url'
15
+ import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH } from '@raidiant/notifai-protocol'
14
16
  import type {
15
17
  CapabilityDocument,
16
18
  EvidenceSnapshot,
@@ -261,6 +263,7 @@ const receipt: SubmissionReceipt = {
261
263
  request_id: 'req_reply_test',
262
264
  reply_expires_at: '2026-08-02T18:00:00.000Z',
263
265
  agent_acknowledgement_required: true,
266
+ agent_acknowledgement_text_required: true,
264
267
  replayed: false,
265
268
  overall: 'provider_accepted_all',
266
269
  deliveries: [
@@ -293,6 +296,7 @@ function replyResponse(
293
296
  replies: ReplyView[] = [],
294
297
  options: {
295
298
  required?: boolean
299
+ textRequired?: boolean
296
300
  acknowledgement?: ListRepliesResponse['agent_acknowledgement']
297
301
  } = {},
298
302
  ): ListRepliesResponse {
@@ -300,6 +304,7 @@ function replyResponse(
300
304
  request_id: receipt.request_id,
301
305
  reply_expires_at: '2026-08-02T18:00:00.000Z',
302
306
  agent_acknowledgement_required: options.required ?? true,
307
+ agent_acknowledgement_text_required: options.textRequired ?? true,
303
308
  agent_acknowledgement: options.acknowledgement ?? null,
304
309
  replies,
305
310
  }
@@ -378,7 +383,7 @@ describe('command contracts', () => {
378
383
  } as unknown as ApiClient
379
384
 
380
385
  expect(
381
- await sendCommand(makeDeps(io, client), {
386
+ await sendCommand(makeDeps(io, client), { kind: 'update',
382
387
  title: 'T',
383
388
  body: 'B',
384
389
  project: 'Invalid Project!',
@@ -456,7 +461,7 @@ describe('command contracts', () => {
456
461
  } as unknown as ApiClient
457
462
 
458
463
  expect(
459
- await sendCommand(makeDeps(new CapturedIo(), client), {
464
+ await sendCommand(makeDeps(new CapturedIo(), client), { kind: 'update',
460
465
  title: 'Visual comparison is ready',
461
466
  body:
462
467
  '![first](media:1) ![second](media:2) ![ready](media:med_existing)',
@@ -488,7 +493,7 @@ describe('command contracts', () => {
488
493
  } as unknown as ApiClient
489
494
 
490
495
  expect(
491
- await sendCommand(makeDeps(io, client), {
496
+ await sendCommand(makeDeps(io, client), { kind: 'update',
492
497
  title: 'Visual comparison',
493
498
  body: 'See it.',
494
499
  image: ['one.png'],
@@ -523,19 +528,19 @@ describe('command contracts', () => {
523
528
 
524
529
  it.each([
525
530
  {
526
- flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
531
+ flags: { kind: 'update', title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
527
532
  warning: /titles work best around 40/i,
528
533
  },
529
534
  {
530
- flags: { title: 'Done · build', body: 'All green.' },
535
+ flags: { kind: 'done', title: 'Done · build', body: 'All green.' },
531
536
  warning: /keep the title to the specific substance/i,
532
537
  },
533
538
  {
534
- flags: { title: 'Failed · build', body: 'One integration test failed.' },
539
+ flags: { kind: 'failed', title: 'Failed · build', body: 'One integration test failed.' },
535
540
  warning: /Put notification type in --kind/i,
536
541
  },
537
542
  {
538
- flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
543
+ flags: { kind: 'update', title: 'Update', body: 'Still relevant.', ttl: 259_201 },
539
544
  warning: /longer than 72 hours/i,
540
545
  },
541
546
  ])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
@@ -559,7 +564,7 @@ describe('command contracts', () => {
559
564
  cwd: root,
560
565
  }
561
566
 
562
- expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
567
+ expect(await sendCommand(deps, { kind: 'update', title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
563
568
  expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
564
569
  })
565
570
 
@@ -579,11 +584,11 @@ describe('command contracts', () => {
579
584
  expect(grammar).toContain("'--image <path|url|media_id>'")
580
585
  expect(grammar).toContain("'--image-alt <text>'")
581
586
  }
582
- expect(source).toContain('Kind describes status in the Companion Apps')
583
- expect(source).toContain('it never chooses banner sound or interruption level')
584
- expect(source).toContain('override saved sound')
587
+ // Kind now selects the sound, so the help must say so — and must not carry
588
+ // the retired separation it replaced.
589
+ expect(source).toContain('Kind is required, and it selects the sound')
590
+ expect(source).not.toContain('it never chooses banner sound or interruption level')
585
591
  expect(source).not.toContain('Kind profiles apply automatically')
586
- expect(source).not.toContain('sound alert level active')
587
592
  })
588
593
 
589
594
  it('rejects a question nobody will wait for', async () => {
@@ -662,8 +667,8 @@ describe('command contracts', () => {
662
667
  })
663
668
 
664
669
  it.each([
665
- { title: 'Deploy? ', body: 'Ready.' },
666
- { title: 'Deployment', body: 'Should I deploy?\n' },
670
+ { kind: 'update', title: 'Deploy? ', body: 'Ready.' },
671
+ { kind: 'update', title: 'Deployment', body: 'Should I deploy?\n' },
667
672
  ])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
668
673
  const io = new CapturedIo()
669
674
  const client = { submit: async () => receipt } as unknown as ApiClient
@@ -703,7 +708,7 @@ describe('command contracts', () => {
703
708
  } as unknown as ApiClient
704
709
 
705
710
  expect(
706
- await sendCommand(makeDeps(io, client), {
711
+ await sendCommand(makeDeps(io, client), { kind: 'update',
707
712
  title: 'Deploy?',
708
713
  body: 'Choose when ready.',
709
714
  replyChoice: ['Now', 'Later'],
@@ -720,7 +725,7 @@ describe('command contracts', () => {
720
725
  const client = { submit: async () => receipt } as unknown as ApiClient
721
726
 
722
727
  expect(
723
- await sendCommand(makeDeps(io, client), {
728
+ await sendCommand(makeDeps(io, client), { kind: 'update',
724
729
  title: 'Deployment',
725
730
  body: 'Should I deploy?',
726
731
  json: true,
@@ -942,6 +947,7 @@ describe('command contracts', () => {
942
947
  reply_expires_at: '2026-08-02T18:00:00.000Z',
943
948
  replies: [],
944
949
  agent_acknowledgement_required: true,
950
+ agent_acknowledgement_text_required: true,
945
951
  agent_acknowledgement: null,
946
952
  acknowledgement_command: null,
947
953
  degraded: false,
@@ -975,6 +981,7 @@ describe('command contracts', () => {
975
981
  reply_expires_at: '2026-08-02T18:00:00.000Z',
976
982
  replies: [reply],
977
983
  agent_acknowledgement_required: true,
984
+ agent_acknowledgement_text_required: true,
978
985
  agent_acknowledgement: null,
979
986
  acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
980
987
  degraded: false,
@@ -1088,9 +1095,11 @@ describe('command contracts', () => {
1088
1095
  })
1089
1096
 
1090
1097
  it.each([
1091
- { text: undefined, message: '--text is required' },
1092
- { text: ' ', message: '--text is required' },
1093
- { text: 'x'.repeat(1001), message: 'at most 1000 characters' },
1098
+ { text: ' ', message: 'must contain non-whitespace text' },
1099
+ {
1100
+ text: 'x'.repeat(AGENT_ACKNOWLEDGEMENT_MAX_LENGTH + 1),
1101
+ message: `at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters`,
1102
+ },
1094
1103
  ])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
1095
1104
  const io = new CapturedIo()
1096
1105
  let calls = 0
@@ -1108,6 +1117,60 @@ describe('command contracts', () => {
1108
1117
  expect(io.errLines.join('\n')).toContain(message)
1109
1118
  })
1110
1119
 
1120
+ it('records an acknowledgement with no text at all', async () => {
1121
+ const io = new CapturedIo()
1122
+ let submitted: { requestId: string; body: { text?: string } } | undefined
1123
+ const client = {
1124
+ putAgentAcknowledgement: async (requestId: string, body: { text?: string }) => {
1125
+ submitted = { requestId, body }
1126
+ return {
1127
+ status: 'recorded' as const,
1128
+ agent_acknowledgement: { text: '', created_at: '2026-08-13T12:01:00.000Z' },
1129
+ }
1130
+ },
1131
+ } as unknown as ApiClient
1132
+
1133
+ expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1134
+ // No empty string either: an omitted field is what "no text" means on the
1135
+ // wire, and the account's snapshot decides whether that was allowed.
1136
+ expect(submitted).toEqual({ requestId: receipt.request_id, body: {} })
1137
+ expect(io.outLines.join('\n')).toContain(`Agent Acknowledgement recorded for ${receipt.request_id}`)
1138
+ })
1139
+
1140
+ it('names the missing text, not a contract mismatch, when the account requires it', async () => {
1141
+ const io = new CapturedIo()
1142
+ const client = {
1143
+ putAgentAcknowledgement: async () => {
1144
+ throw new ApiCallError(
1145
+ 422,
1146
+ 'acknowledgement_text_required',
1147
+ 'This account requires Agent Acknowledgement text.',
1148
+ 'Re-send the acknowledgement with text saying what you will do because of the reply.',
1149
+ )
1150
+ },
1151
+ } as unknown as ApiClient
1152
+
1153
+ expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.failed)
1154
+ const err = io.errLines.join('\n')
1155
+ expect(err).toContain('acknowledgement_text_required')
1156
+ expect(err).toContain('next: Re-send the acknowledgement with text')
1157
+ // The contract held; only this body fell short of the account's choice.
1158
+ expect(err).not.toContain('disagree about the contract')
1159
+ })
1160
+
1161
+ it('asks for a text-free acknowledgement when the account turned text off', async () => {
1162
+ const io = new CapturedIo()
1163
+ const client = {
1164
+ replies: async () => replyResponse([reply], { textRequired: false }),
1165
+ } as unknown as ApiClient
1166
+
1167
+ expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
1168
+ const out = io.outLines.join('\n')
1169
+ expect(out).toContain('Agent Acknowledgement required.')
1170
+ expect(out).toContain(`notifai acknowledge ${receipt.request_id}\``)
1171
+ expect(out).not.toContain('--text')
1172
+ })
1173
+
1111
1174
  it.each([
1112
1175
  [new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
1113
1176
  [new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
@@ -1716,7 +1779,13 @@ describe('Codex hook representation', () => {
1716
1779
  expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
1717
1780
  })
1718
1781
 
1719
- it('installs into hooks.json when it alone already contains hooks', () => {
1782
+ /**
1783
+ * The foreign `hooks.json` stays byte-identical and Notifai lands inline.
1784
+ * Codex will now warn that the layer uses both representations; the install
1785
+ * output says why, because that warning is Codex reporting a merge, not a
1786
+ * fault, and a silent one would read as Notifai's bug.
1787
+ */
1788
+ it('leaves a foreign hooks.json alone and installs inline instead', () => {
1720
1789
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
1721
1790
  const layer = path.join(cwd, '.codex')
1722
1791
  const json = path.join(layer, 'hooks.json')
@@ -1734,11 +1803,17 @@ describe('Codex hook representation', () => {
1734
1803
 
1735
1804
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1736
1805
 
1737
- expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
1738
- expect(readFileSync(json, 'utf8')).toContain('gdh-stop')
1739
- expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
1740
- expect(io.outLines.join('\n')).toContain(json)
1741
- expect(io.outLines.join('\n')).not.toMatch(/loading hooks from both/i)
1806
+ const beforeJson = readFileSync(json, 'utf8')
1807
+ expect(beforeJson).toContain('gdh-stop')
1808
+ expect(beforeJson).not.toContain('--owner notifai')
1809
+ const after = readFileSync(toml, 'utf8')
1810
+ // The user's file arrives back as the file they wrote, plus our hooks.
1811
+ expect(after).toContain(beforeToml.trimEnd())
1812
+ expect(after).toContain('--owner notifai')
1813
+ expect(io.outLines.join('\n')).toContain(toml)
1814
+ expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
1815
+ expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
1816
+ expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
1742
1817
  })
1743
1818
 
1744
1819
  it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
@@ -1755,8 +1830,14 @@ describe('Codex hook representation', () => {
1755
1830
  expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
1756
1831
  })
1757
1832
 
1758
- it('does not migrate our hooks when both representations already exist', async () => {
1759
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
1833
+ /**
1834
+ * Notifai in one file, someone else's hooks in the other: every handler
1835
+ * fires once, so doctor has nothing to report and the foreign file is not
1836
+ * ours to rewrite. Doctor used to fail here — the check read "two files" as
1837
+ * "two Notifai copies" and pointed at configuration another program owns.
1838
+ */
1839
+ it('passes doctor when only the other representation is foreign', async () => {
1840
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-rep-'))
1760
1841
  const toml = writeInlineStop(cwd, 'gdh-stop')
1761
1842
  const json = path.join(cwd, '.codex', 'hooks.json')
1762
1843
  const env = isolatedEnv(cwd)
@@ -1776,12 +1857,12 @@ describe('Codex hook representation', () => {
1776
1857
  })
1777
1858
 
1778
1859
  io.outLines = []
1779
- expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1780
- const before = io.outLines.join('\n')
1781
- expect(before).toMatch(/hooks \(codex representation\)/)
1782
- expect(before).toMatch(/prefer a single representation/)
1783
- expect(before).toMatch(/Stop handlers will both (run|fire)/i)
1784
- expect(before).not.toContain('notifai hooks install --harness codex')
1860
+ await doctorCommand(deps, {})
1861
+ // Their assertions, this branch's check title.
1862
+ const report = io.outLines.join('\n')
1863
+ expect(report).not.toMatch(/FAIL {2}Codex hook representation/)
1864
+ expect(report).toMatch(/ok {4}Codex hook representation/)
1865
+ expect(report).toMatch(/Notifai will not modify it/)
1785
1866
 
1786
1867
  io.outLines = []
1787
1868
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
@@ -1789,7 +1870,7 @@ describe('Codex hook representation', () => {
1789
1870
  expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
1790
1871
  expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1791
1872
  expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
1792
- expect(io.outLines.join('\n')).toMatch(/loading hooks from both/i)
1873
+ expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
1793
1874
  expect(io.outLines.join('\n')).not.toMatch(/collaps/i)
1794
1875
 
1795
1876
  const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
@@ -1798,6 +1879,44 @@ describe('Codex hook representation', () => {
1798
1879
  expect(installations).toHaveLength(1)
1799
1880
  expect(installations[0]?.file).toBe(json)
1800
1881
  })
1882
+
1883
+ it('fails doctor when Notifai itself is installed in both representations', async () => {
1884
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
1885
+ const json = path.join(cwd, '.codex', 'hooks.json')
1886
+ const env = isolatedEnv(cwd)
1887
+ const io = new CapturedIo()
1888
+ const client = {
1889
+ health: async () => false,
1890
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1891
+ listDevices: async () => ({ devices: [] }),
1892
+ } as unknown as ApiClient
1893
+ const deps = { ...makeDeps(io, client), cwd, env }
1894
+
1895
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1896
+ applyPlan(json, {
1897
+ hooks: buildHookConfig({
1898
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
1899
+ harness: 'codex',
1900
+ }),
1901
+ })
1902
+
1903
+ io.outLines = []
1904
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1905
+ const reported = io.outLines.join('\n')
1906
+ expect(reported).toMatch(/Codex hook representation/)
1907
+ expect(reported).toMatch(/installed in both/i)
1908
+ expect(reported).toMatch(/notifies twice per turn/)
1909
+ expect(reported).toMatch(/notifai hooks uninstall --harness codex/)
1910
+
1911
+ // The named remedy has to actually clear it, including the copy in the
1912
+ // file Notifai would not have chosen to write.
1913
+ io.outLines = []
1914
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
1915
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1916
+ io.outLines = []
1917
+ await doctorCommand(deps, {})
1918
+ expect(io.outLines.join('\n')).not.toMatch(/Codex hook representation/)
1919
+ })
1801
1920
  })
1802
1921
 
1803
1922
  describe('harness activation guidance', () => {
@@ -1913,14 +2032,14 @@ describe('harness activation guidance', () => {
1913
2032
 
1914
2033
  io.outLines = []
1915
2034
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1916
- expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
2035
+ expect(io.outLines.join('\n')).toContain('How an answer returns')
1917
2036
  expect(io.outLines.join('\n')).toContain('no proven answer continuation')
1918
- expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
2037
+ expect(io.outLines.join('\n')).not.toContain('Hook adapter')
1919
2038
 
1920
2039
  writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
1921
2040
  io.outLines = []
1922
2041
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1923
- expect(io.outLines.join('\n')).toContain('hooks (adapter)')
2042
+ expect(io.outLines.join('\n')).toContain('Hook adapter')
1924
2043
  expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
1925
2044
  })
1926
2045
 
@@ -2019,7 +2138,7 @@ describe('stable hook installation', () => {
2019
2138
 
2020
2139
  io.outLines = []
2021
2140
  await doctorCommand(deps, {})
2022
- expect(io.outLines.join('\n')).not.toMatch(/hooks \(duplicates\)/)
2141
+ expect(io.outLines.join('\n')).not.toMatch(/Duplicate hook installs/)
2023
2142
 
2024
2143
  expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
2025
2144
  expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
@@ -4138,7 +4257,7 @@ describe('asking before the hooks have ever run', () => {
4138
4257
 
4139
4258
  io.outLines = []
4140
4259
  await doctorCommand(deps, {})
4141
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
4260
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
4142
4261
  expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
4143
4262
  })
4144
4263
 
@@ -4391,7 +4510,7 @@ describe('asking before the hooks have ever run', () => {
4391
4510
  io.outLines = []
4392
4511
 
4393
4512
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
4394
- expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
4513
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Routing for this harness.*active Claude Code/is)
4395
4514
  })
4396
4515
 
4397
4516
  it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
@@ -4460,7 +4579,7 @@ describe('asking before the hooks have ever run', () => {
4460
4579
  io.outLines = []
4461
4580
 
4462
4581
  await doctorCommand(deps, {})
4463
- expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
4582
+ expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
4464
4583
  })
4465
4584
 
4466
4585
  it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
@@ -4489,7 +4608,7 @@ describe('asking before the hooks have ever run', () => {
4489
4608
 
4490
4609
  await doctorCommand(deps, {})
4491
4610
  const out = io.outLines.join('\n')
4492
- expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
4611
+ expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
4493
4612
  expect(out).toContain('needs `async: true`')
4494
4613
  expect(out).toContain('kills the backgrounded waiter at its 600s default')
4495
4614
  })
@@ -4532,7 +4651,7 @@ describe('asking before the hooks have ever run', () => {
4532
4651
 
4533
4652
  await doctorCommand(deps, {})
4534
4653
  const out = io.outLines.join('\n')
4535
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
4654
+ expect(out).toMatch(/ok\s+Direct wake route/)
4536
4655
  expect(out).toContain(socket)
4537
4656
  expect(out).toContain('an answer can start a turn here without you')
4538
4657
  // Doctor never widens the user's inbound policy to make Notifai work: the
@@ -4549,10 +4668,10 @@ describe('asking before the hooks have ever run', () => {
4549
4668
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
4550
4669
  const out = io.outLines.join('\n')
4551
4670
  // Honest, and not a blocker: nothing is lost, it simply arrives later.
4552
- expect(out).toMatch(/--\s+hooks \(wake route\)/)
4671
+ expect(out).toMatch(/--\s+Direct wake route/)
4553
4672
  expect(out).toContain('`--bare` binds no inbox socket')
4554
4673
  expect(out).toContain("at this session's next turn")
4555
- expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
4674
+ expect(out).not.toMatch(/FAIL\s+Direct wake route/)
4556
4675
  })
4557
4676
 
4558
4677
  it('fails closed on an inbox protocol it does not recognise', async () => {
@@ -4600,7 +4719,7 @@ describe('asking before the hooks have ever run', () => {
4600
4719
  io.outLines = []
4601
4720
  await doctorCommand(deps, {})
4602
4721
  const out = io.outLines.join('\n')
4603
- expect(out).toMatch(/ok\s+hooks \(wake route\)/)
4722
+ expect(out).toMatch(/ok\s+Direct wake route/)
4604
4723
  expect(out).toContain('can prove a stopped thread unowned before resuming it')
4605
4724
  })
4606
4725
 
@@ -4782,7 +4901,7 @@ describe('command failures carrying server details', () => {
4782
4901
  { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4783
4902
  ])
4784
4903
 
4785
- expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
4904
+ expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4786
4905
  EXIT.failed,
4787
4906
  )
4788
4907
 
@@ -4799,7 +4918,7 @@ describe('command failures carrying server details', () => {
4799
4918
  { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4800
4919
  ])
4801
4920
 
4802
- expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
4921
+ expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4803
4922
  EXIT.failed,
4804
4923
  )
4805
4924
 
@@ -4812,7 +4931,7 @@ describe('command failures carrying server details', () => {
4812
4931
  it('prints exactly the code and message when the error carries no details', async () => {
4813
4932
  const io = new CapturedIo()
4814
4933
 
4815
- expect(await sendCommand(makeDeps(io, throwingClient(409)), { title: 'Deploy finished', body: 'All green.' })).toBe(
4934
+ expect(await sendCommand(makeDeps(io, throwingClient(409)), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
4816
4935
  EXIT.failed,
4817
4936
  )
4818
4937
 
@@ -4936,6 +5055,17 @@ describe('question sets', () => {
4936
5055
  }
4937
5056
  })
4938
5057
 
5058
+ it('reports a registration as data, with the ids an agent must branch on', () => {
5059
+ // The prose block is the densest guidance this CLI prints, but a choice id
5060
+ // cannot be read back out of prose.
5061
+ const built = buildQuestions(
5062
+ { choice: ['Deploy now', 'Hold'] },
5063
+ 'Deploy migration 0007 to production?',
5064
+ )
5065
+ if (!built.ok) throw new Error(built.error)
5066
+ expect(built.questions[0]?.choices?.map((choice) => choice.id)).toEqual(['deploy-now', 'hold'])
5067
+ })
5068
+
4939
5069
  it('rejects forms outside the documented shape', () => {
4940
5070
  expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
4941
5071
  expect(
@@ -4943,10 +5073,14 @@ describe('question sets', () => {
4943
5073
  ).toMatchObject({ ok: false })
4944
5074
  expect(
4945
5075
  buildQuestions(
4946
- { form: JSON.stringify({ questions: Array.from({ length: 5 }, (_, i) => ({ text: `Q${i}?` })) }) },
5076
+ {
5077
+ form: JSON.stringify({
5078
+ questions: Array.from({ length: REPLY_MAX_QUESTIONS + 1 }, (_, i) => ({ text: `Q${i}?` })),
5079
+ }),
5080
+ },
4947
5081
  undefined,
4948
5082
  ),
4949
- ).toMatchObject({ ok: false, error: expect.stringContaining('1-4') })
5083
+ ).toMatchObject({ ok: false, error: expect.stringContaining(`1-${REPLY_MAX_QUESTIONS}`) })
4950
5084
  expect(
4951
5085
  buildQuestions(
4952
5086
  { form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },