@raidiant/notifai 0.2.0 → 0.3.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.
@@ -17,7 +17,9 @@ import {
17
17
  buildQuestions,
18
18
  assessReadiness,
19
19
  capabilitiesCommand,
20
+ configExplainCommand,
20
21
  configSetCommand,
22
+ configShowCommand,
21
23
  contradictingAnswer,
22
24
  describeHookFailure,
23
25
  doctorCommand,
@@ -38,6 +40,8 @@ import {
38
40
  import { applyPlan, buildHookConfig } from './install-hooks.js'
39
41
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
40
42
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
43
+ import { CONFIG_KEYS } from './config.js'
44
+ import type { Tone } from './ui/theme.js'
41
45
 
42
46
  class CapturedIo implements CommandIo {
43
47
  outLines: string[] = []
@@ -72,7 +76,7 @@ class InteractiveIo extends CapturedIo {
72
76
  intros: string[] = []
73
77
  outros: string[] = []
74
78
  spinnerEvents: string[] = []
75
- checks: { ok: boolean; message: string }[] = []
79
+ checks: { ok: boolean; message: string; tone?: Tone }[] = []
76
80
 
77
81
  override async confirm(question: string) {
78
82
  this.prompts.push(question)
@@ -111,8 +115,8 @@ class InteractiveIo extends CapturedIo {
111
115
  }
112
116
  }
113
117
 
114
- async check(ok: boolean, message: string) {
115
- this.checks.push({ ok, message })
118
+ async check(ok: boolean, message: string, tone?: Tone) {
119
+ this.checks.push({ ok, message, ...(tone === undefined ? {} : { tone }) })
116
120
  }
117
121
  }
118
122
 
@@ -634,7 +638,7 @@ describe('command contracts', () => {
634
638
  XDG_STATE_HOME: path.join(root, 'state'),
635
639
  }
636
640
  writeSessionState('pending-session', deps.env, {
637
- pending: { question: 'Deploy?', request_id: receipt.request_id },
641
+ pending: [{ question: 'Deploy?', request_id: receipt.request_id }],
638
642
  })
639
643
  writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
640
644
 
@@ -644,6 +648,36 @@ describe('command contracts', () => {
644
648
  'reply from iPhone: yes, after the migration',
645
649
  ])
646
650
  })
651
+
652
+ it('walks every delivered pending question in queue order', async () => {
653
+ const io = new CapturedIo()
654
+ const client = {
655
+ replies: async (requestId: string) =>
656
+ requestId === 'req_first'
657
+ ? replyResponse([reply])
658
+ : replyResponse([]),
659
+ } as unknown as ApiClient
660
+ const deps = makeDeps(io, client)
661
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-multi-'))
662
+ deps.cwd = root
663
+ deps.env = {
664
+ XDG_CONFIG_HOME: path.join(root, 'config'),
665
+ XDG_STATE_HOME: path.join(root, 'state'),
666
+ }
667
+ writeSessionState('pending-multi', deps.env, {
668
+ pending: [
669
+ { question: 'Deploy?', request_id: 'req_first' },
670
+ { question: 'Which region?', request_id: 'req_second' },
671
+ { question: 'Not yet asked?' },
672
+ ],
673
+ })
674
+ writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
675
+
676
+ expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
677
+ expect(io.outLines[0]).toBe('pending request req_first')
678
+ expect(io.outLines[1]).toContain('yes, after the migration')
679
+ expect(io.outLines.join('\n')).toContain('req_second')
680
+ })
647
681
  })
648
682
 
649
683
  describe('delivery evidence status', () => {
@@ -889,6 +923,67 @@ describe('projectSlugFrom', () => {
889
923
  })
890
924
  })
891
925
 
926
+ describe('config surfaces', () => {
927
+ function configDeps(io: CapturedIo): CommandDeps {
928
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
929
+ return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
930
+ }
931
+
932
+ it('gives an agent the same flat key = value output it always had', () => {
933
+ // The whole point of gating the readable rendering on a human terminal.
934
+ // Anything parsing this today must keep parsing it tomorrow.
935
+ const io = new CapturedIo()
936
+ expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
937
+ expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
938
+ expect(io.outLines[0]).toMatch(/^base_url = "/)
939
+ for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
940
+ })
941
+
942
+ it('explains each setting once a human is at the terminal', () => {
943
+ const io = new InteractiveIo()
944
+ expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
945
+ const text = io.outLines.join('\n')
946
+ expect(text).toContain('Questions & presence')
947
+ expect(text).toContain('Whether sitting at this keyboard holds a question back')
948
+ })
949
+
950
+ it('keeps --plain available to a human who wants the parseable form', () => {
951
+ const io = new InteractiveIo()
952
+ expect(configShowCommand(configDeps(io), { plain: true })).toBe(EXIT.ok)
953
+ expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
954
+ })
955
+
956
+ it('explains one setting, and says so in JSON when asked', () => {
957
+ const io = new InteractiveIo()
958
+ expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
959
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
960
+ expect(parsed['key']).toBe('require_idle')
961
+ expect(parsed['accepts']).toBe('true or false')
962
+ expect(parsed['detail']).toContain('question waits while you are using this machine')
963
+ })
964
+
965
+ it('rejects an unknown setting and points at the nearest real one', () => {
966
+ const io = new CapturedIo()
967
+ expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
968
+ expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
969
+ })
970
+
971
+ it('refuses an enum value the sender would later reject', async () => {
972
+ // `config set sound whatever` used to be written straight to disk: the
973
+ // typo only surfaced when a notification failed to carry the sound.
974
+ const io = new CapturedIo()
975
+ expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
976
+ EXIT.usage,
977
+ )
978
+ expect(io.errLines[0]).toContain('default, done, attention, alert, none')
979
+ })
980
+
981
+ it('still accepts a legal enum value', async () => {
982
+ const io = new CapturedIo()
983
+ expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
984
+ })
985
+ })
986
+
892
987
  describe('interactive command UX', () => {
893
988
  it('styles login pairing progress for a human terminal', async () => {
894
989
  const io = new InteractiveIo()
@@ -1022,7 +1117,9 @@ describe('interactive command UX', () => {
1022
1117
  )
1023
1118
  expect(io.errLines).toEqual([
1024
1119
  'ask_grace_seconds must be between 0 and 540.',
1025
- '"1.5" is not an integer.',
1120
+ // Names the key and its range: `"1.5" is not an integer` left the reader
1121
+ // to work out which of the two settings they had just mistyped.
1122
+ 'ask_grace_seconds takes a whole number from 0s–540s, not "1.5".',
1026
1123
  ])
1027
1124
  expect(existsSync(configFile)).toBe(false)
1028
1125
  })
@@ -1051,6 +1148,18 @@ describe('interactive command UX', () => {
1051
1148
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
1052
1149
  expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
1053
1150
  expect(io.outLines).toEqual([])
1151
+
1152
+ // Four tones, not two. A boolean has to round `optional-gap` and `unknown`
1153
+ // to pass or fail, and rounding them to pass put a tick beside things the
1154
+ // user had declined and beside things nothing had checked.
1155
+ const tone = (prefix: string): Tone | undefined =>
1156
+ io.checks.find((check) => check.message.startsWith(prefix))?.tone
1157
+ expect(tone('This machine:')).toBe('bad')
1158
+ expect(tone('Protocol version:')).toBe('ok')
1159
+ // Never evaluated: the account cannot be checked without a credential.
1160
+ expect(tone('Account:')).toBe('pending')
1161
+ // Legitimately declined rather than broken.
1162
+ expect(tone('Project identity:')).toBe('warn')
1054
1163
  })
1055
1164
 
1056
1165
  it('keeps doctor JSON as one machine-readable stdout document', async () => {
@@ -1151,7 +1260,7 @@ describe('init', () => {
1151
1260
  source: 'Raidiant-io/notifai',
1152
1261
  sourceType: 'github',
1153
1262
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1154
- ref: 'v0.2.0',
1263
+ ref: 'v0.3.0',
1155
1264
  }
1156
1265
  }
1157
1266
 
@@ -1254,7 +1363,7 @@ describe('init', () => {
1254
1363
  })
1255
1364
 
1256
1365
  it('pins the skill installer to the tagged public release syntax', () => {
1257
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.2.0')
1366
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.3.0')
1258
1367
  expect(SKILLS_SOURCE).not.toContain('@v')
1259
1368
  })
1260
1369
 
@@ -1277,7 +1386,7 @@ describe('init', () => {
1277
1386
  source: 'Raidiant-io/notifai',
1278
1387
  sourceType: 'github',
1279
1388
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1280
- ref: 'v0.2.0',
1389
+ ref: 'v0.3.0',
1281
1390
  },
1282
1391
  ],
1283
1392
  }),
@@ -2177,7 +2286,7 @@ describe('asking before the hooks have ever run', () => {
2177
2286
  source: 'Raidiant-io/notifai',
2178
2287
  sourceType: 'github',
2179
2288
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2180
- ref: 'v0.2.0',
2289
+ ref: 'v0.3.0',
2181
2290
  },
2182
2291
  ]
2183
2292
  : [],
@@ -2237,7 +2346,7 @@ describe('asking before the hooks have ever run', () => {
2237
2346
  io.outLines = []
2238
2347
 
2239
2348
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2240
- expect(readSessionState('claude-parent-loop', env).pending?.question).toBe('Ship it?')
2349
+ expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
2241
2350
  })
2242
2351
 
2243
2352
  it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
@@ -2275,7 +2384,7 @@ describe('asking before the hooks have ever run', () => {
2275
2384
  io.outLines = []
2276
2385
 
2277
2386
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2278
- expect(readSessionState('cursor-live', env).pending?.question).toBe('Ship it?')
2387
+ expect(readSessionState('cursor-live', env).pending?.[0]?.question).toBe('Ship it?')
2279
2388
  })
2280
2389
 
2281
2390
  it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
@@ -2299,6 +2408,39 @@ describe('asking before the hooks have ever run', () => {
2299
2408
  expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
2300
2409
  })
2301
2410
 
2411
+ it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
2412
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-unfired-pointer-'))
2413
+ const io = new CapturedIo()
2414
+ const env = {
2415
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2416
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2417
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2418
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2419
+ CLAUDECODE: '1',
2420
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
2421
+ }
2422
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2423
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2424
+ EXIT.ok,
2425
+ )
2426
+ // Hooks installed seconds ago, nothing has fired: the exact state a
2427
+ // fresh-project agent `init` runs from. Neither pointer state may block
2428
+ // the walk, and neither may prescribe a reinstall.
2429
+ const readiness = await assessReadiness(deps)
2430
+
2431
+ const pointer = readiness.states.find((s) => s.id === 'hooks-active-session')
2432
+ expect(pointer?.status).toBe('optional-gap')
2433
+ expect(pointer?.detail).toMatch(/has not published a live pointer/)
2434
+ expect(pointer?.remedy?.summary).toMatch(/send one Claude Code prompt/)
2435
+ expect(pointer?.remedy?.by === 'user-here' ? pointer.remedy.command : '').toBe(
2436
+ 'notifai doctor',
2437
+ )
2438
+
2439
+ const fired = readiness.states.find((s) => s.id === 'hooks-fired')
2440
+ expect(fired?.status).toBe('optional-gap')
2441
+ expect(fired?.remedy?.summary).not.toMatch(/hooks install/)
2442
+ })
2443
+
2302
2444
  it('shows resolved question-routing values with their winning config sources', async () => {
2303
2445
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
2304
2446
  mkdirSync(path.join(cwd, '.notifai'), { recursive: true })