@raidiant/notifai 0.1.8 → 0.2.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.
- package/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/src/commands.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
|
14
14
|
import {
|
|
15
15
|
askCommand,
|
|
16
16
|
accessStatusCommand,
|
|
17
|
+
buildQuestions,
|
|
17
18
|
assessReadiness,
|
|
18
19
|
capabilitiesCommand,
|
|
19
20
|
configSetCommand,
|
|
@@ -379,7 +380,10 @@ describe('command contracts', () => {
|
|
|
379
380
|
replyTimeout: 30,
|
|
380
381
|
}),
|
|
381
382
|
).toBe(EXIT.ok)
|
|
382
|
-
expect(submitted?.draft.reply).toEqual({
|
|
383
|
+
expect(submitted?.draft.reply).toEqual({
|
|
384
|
+
expires_in_seconds: 3_600,
|
|
385
|
+
questions: [{ id: 'q1', text: 'Deploy?' }],
|
|
386
|
+
})
|
|
383
387
|
})
|
|
384
388
|
|
|
385
389
|
it('rejects --reply-timeout 0, the other spelling of nobody waiting', async () => {
|
|
@@ -630,7 +634,7 @@ describe('command contracts', () => {
|
|
|
630
634
|
XDG_STATE_HOME: path.join(root, 'state'),
|
|
631
635
|
}
|
|
632
636
|
writeSessionState('pending-session', deps.env, {
|
|
633
|
-
pending: { question: 'Deploy?', request_id: receipt.request_id },
|
|
637
|
+
pending: [{ question: 'Deploy?', request_id: receipt.request_id }],
|
|
634
638
|
})
|
|
635
639
|
writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
|
|
636
640
|
|
|
@@ -640,6 +644,36 @@ describe('command contracts', () => {
|
|
|
640
644
|
'reply from iPhone: yes, after the migration',
|
|
641
645
|
])
|
|
642
646
|
})
|
|
647
|
+
|
|
648
|
+
it('walks every delivered pending question in queue order', async () => {
|
|
649
|
+
const io = new CapturedIo()
|
|
650
|
+
const client = {
|
|
651
|
+
replies: async (requestId: string) =>
|
|
652
|
+
requestId === 'req_first'
|
|
653
|
+
? replyResponse([reply])
|
|
654
|
+
: replyResponse([]),
|
|
655
|
+
} as unknown as ApiClient
|
|
656
|
+
const deps = makeDeps(io, client)
|
|
657
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-multi-'))
|
|
658
|
+
deps.cwd = root
|
|
659
|
+
deps.env = {
|
|
660
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
661
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
662
|
+
}
|
|
663
|
+
writeSessionState('pending-multi', deps.env, {
|
|
664
|
+
pending: [
|
|
665
|
+
{ question: 'Deploy?', request_id: 'req_first' },
|
|
666
|
+
{ question: 'Which region?', request_id: 'req_second' },
|
|
667
|
+
{ question: 'Not yet asked?' },
|
|
668
|
+
],
|
|
669
|
+
})
|
|
670
|
+
writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
|
|
671
|
+
|
|
672
|
+
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
673
|
+
expect(io.outLines[0]).toBe('pending request req_first')
|
|
674
|
+
expect(io.outLines[1]).toContain('yes, after the migration')
|
|
675
|
+
expect(io.outLines.join('\n')).toContain('req_second')
|
|
676
|
+
})
|
|
643
677
|
})
|
|
644
678
|
|
|
645
679
|
describe('delivery evidence status', () => {
|
|
@@ -1147,7 +1181,7 @@ describe('init', () => {
|
|
|
1147
1181
|
source: 'Raidiant-io/notifai',
|
|
1148
1182
|
sourceType: 'github',
|
|
1149
1183
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1150
|
-
ref: 'v0.1
|
|
1184
|
+
ref: 'v0.2.1',
|
|
1151
1185
|
}
|
|
1152
1186
|
}
|
|
1153
1187
|
|
|
@@ -1250,7 +1284,7 @@ describe('init', () => {
|
|
|
1250
1284
|
})
|
|
1251
1285
|
|
|
1252
1286
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1253
|
-
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.1
|
|
1287
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.2.1')
|
|
1254
1288
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1255
1289
|
})
|
|
1256
1290
|
|
|
@@ -1273,7 +1307,7 @@ describe('init', () => {
|
|
|
1273
1307
|
source: 'Raidiant-io/notifai',
|
|
1274
1308
|
sourceType: 'github',
|
|
1275
1309
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1276
|
-
ref: 'v0.1
|
|
1310
|
+
ref: 'v0.2.1',
|
|
1277
1311
|
},
|
|
1278
1312
|
],
|
|
1279
1313
|
}),
|
|
@@ -2173,7 +2207,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2173
2207
|
source: 'Raidiant-io/notifai',
|
|
2174
2208
|
sourceType: 'github',
|
|
2175
2209
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2176
|
-
ref: 'v0.1
|
|
2210
|
+
ref: 'v0.2.1',
|
|
2177
2211
|
},
|
|
2178
2212
|
]
|
|
2179
2213
|
: [],
|
|
@@ -2233,7 +2267,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2233
2267
|
io.outLines = []
|
|
2234
2268
|
|
|
2235
2269
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2236
|
-
expect(readSessionState('claude-parent-loop', env).pending?.question).toBe('Ship it?')
|
|
2270
|
+
expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
|
|
2237
2271
|
})
|
|
2238
2272
|
|
|
2239
2273
|
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
@@ -2271,7 +2305,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2271
2305
|
io.outLines = []
|
|
2272
2306
|
|
|
2273
2307
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2274
|
-
expect(readSessionState('cursor-live', env).pending?.question).toBe('Ship it?')
|
|
2308
|
+
expect(readSessionState('cursor-live', env).pending?.[0]?.question).toBe('Ship it?')
|
|
2275
2309
|
})
|
|
2276
2310
|
|
|
2277
2311
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
@@ -2295,6 +2329,39 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2295
2329
|
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
|
|
2296
2330
|
})
|
|
2297
2331
|
|
|
2332
|
+
it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
|
|
2333
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-unfired-pointer-'))
|
|
2334
|
+
const io = new CapturedIo()
|
|
2335
|
+
const env = {
|
|
2336
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2337
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2338
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
2339
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2340
|
+
CLAUDECODE: '1',
|
|
2341
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
2342
|
+
}
|
|
2343
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2344
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2345
|
+
EXIT.ok,
|
|
2346
|
+
)
|
|
2347
|
+
// Hooks installed seconds ago, nothing has fired: the exact state a
|
|
2348
|
+
// fresh-project agent `init` runs from. Neither pointer state may block
|
|
2349
|
+
// the walk, and neither may prescribe a reinstall.
|
|
2350
|
+
const readiness = await assessReadiness(deps)
|
|
2351
|
+
|
|
2352
|
+
const pointer = readiness.states.find((s) => s.id === 'hooks-active-session')
|
|
2353
|
+
expect(pointer?.status).toBe('optional-gap')
|
|
2354
|
+
expect(pointer?.detail).toMatch(/has not published a live pointer/)
|
|
2355
|
+
expect(pointer?.remedy?.summary).toMatch(/send one Claude Code prompt/)
|
|
2356
|
+
expect(pointer?.remedy?.by === 'user-here' ? pointer.remedy.command : '').toBe(
|
|
2357
|
+
'notifai doctor',
|
|
2358
|
+
)
|
|
2359
|
+
|
|
2360
|
+
const fired = readiness.states.find((s) => s.id === 'hooks-fired')
|
|
2361
|
+
expect(fired?.status).toBe('optional-gap')
|
|
2362
|
+
expect(fired?.remedy?.summary).not.toMatch(/hooks install/)
|
|
2363
|
+
})
|
|
2364
|
+
|
|
2298
2365
|
it('shows resolved question-routing values with their winning config sources', async () => {
|
|
2299
2366
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
|
|
2300
2367
|
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
@@ -2486,7 +2553,97 @@ describe('a server behind this CLI', () => {
|
|
|
2486
2553
|
})
|
|
2487
2554
|
})
|
|
2488
2555
|
|
|
2489
|
-
|
|
2556
|
+
describe('question sets', () => {
|
|
2557
|
+
it('maps --reply-multi into the single question', async () => {
|
|
2558
|
+
const io = new CapturedIo()
|
|
2559
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
2560
|
+
const client = {
|
|
2561
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
2562
|
+
submitted = body
|
|
2563
|
+
return receipt
|
|
2564
|
+
},
|
|
2565
|
+
replies: async () => replyResponse([reply]),
|
|
2566
|
+
} as unknown as ApiClient
|
|
2567
|
+
|
|
2568
|
+
expect(
|
|
2569
|
+
await sendCommand(makeDeps(io, client), {
|
|
2570
|
+
title: 'Question',
|
|
2571
|
+
body: 'Which fronts?',
|
|
2572
|
+
reply: true,
|
|
2573
|
+
replyTimeout: 30,
|
|
2574
|
+
replyChoice: ['CLI', 'Server', 'Apps'],
|
|
2575
|
+
replyMulti: true,
|
|
2576
|
+
}),
|
|
2577
|
+
).toBe(EXIT.ok)
|
|
2578
|
+
expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
|
|
2579
|
+
text: 'Which fronts?',
|
|
2580
|
+
multi: true,
|
|
2581
|
+
})
|
|
2582
|
+
expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
|
|
2583
|
+
})
|
|
2584
|
+
|
|
2585
|
+
it('rejects a question body too long for the answering surface', async () => {
|
|
2586
|
+
const io = new CapturedIo()
|
|
2587
|
+
let submitCalls = 0
|
|
2588
|
+
const client = {
|
|
2589
|
+
submit: async () => {
|
|
2590
|
+
submitCalls += 1
|
|
2591
|
+
return receipt
|
|
2592
|
+
},
|
|
2593
|
+
} as unknown as ApiClient
|
|
2594
|
+
|
|
2595
|
+
expect(
|
|
2596
|
+
await sendCommand(makeDeps(io, client), {
|
|
2597
|
+
title: 'Question',
|
|
2598
|
+
body: 'x'.repeat(501),
|
|
2599
|
+
reply: true,
|
|
2600
|
+
replyTimeout: 30,
|
|
2601
|
+
}),
|
|
2602
|
+
).toBe(EXIT.usage)
|
|
2603
|
+
expect(submitCalls).toBe(0)
|
|
2604
|
+
expect(io.errLines.join(' ')).toContain('--detail')
|
|
2605
|
+
})
|
|
2606
|
+
|
|
2607
|
+
it('generates unique question ids when texts collide', () => {
|
|
2608
|
+
const built = buildQuestions(
|
|
2609
|
+
{
|
|
2610
|
+
form: JSON.stringify({
|
|
2611
|
+
questions: [{ text: 'Ready?' }, { text: 'Ready?' }],
|
|
2612
|
+
}),
|
|
2613
|
+
},
|
|
2614
|
+
undefined,
|
|
2615
|
+
)
|
|
2616
|
+
expect(built).toMatchObject({ ok: true })
|
|
2617
|
+
if (built.ok) {
|
|
2618
|
+
expect(built.questions.map((question) => question.id)).toEqual(['ready', 'q2'])
|
|
2619
|
+
}
|
|
2620
|
+
})
|
|
2621
|
+
|
|
2622
|
+
it('rejects forms outside the documented shape', () => {
|
|
2623
|
+
expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
|
|
2624
|
+
expect(
|
|
2625
|
+
buildQuestions({ form: JSON.stringify({ questions: [] }) }, undefined),
|
|
2626
|
+
).toMatchObject({ ok: false })
|
|
2627
|
+
expect(
|
|
2628
|
+
buildQuestions(
|
|
2629
|
+
{ form: JSON.stringify({ questions: Array.from({ length: 5 }, (_, i) => ({ text: `Q${i}?` })) }) },
|
|
2630
|
+
undefined,
|
|
2631
|
+
),
|
|
2632
|
+
).toMatchObject({ ok: false, error: expect.stringContaining('1-4') })
|
|
2633
|
+
expect(
|
|
2634
|
+
buildQuestions(
|
|
2635
|
+
{ form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },
|
|
2636
|
+
undefined,
|
|
2637
|
+
),
|
|
2638
|
+
).toMatchObject({ ok: false, error: expect.stringContaining('--choice') })
|
|
2639
|
+
// --form replaces the flag surface; mixing them is a usage error.
|
|
2640
|
+
expect(
|
|
2641
|
+
buildQuestions({ form: JSON.stringify({ questions: [{ text: 'Q?' }] }) }, 'Also a question?'),
|
|
2642
|
+
).toMatchObject({ ok: false })
|
|
2643
|
+
})
|
|
2644
|
+
})
|
|
2645
|
+
|
|
2646
|
+
/** Latest-reply-wins, and never silently: a correction is named as one. */
|
|
2490
2647
|
describe('a second device that disagrees', () => {
|
|
2491
2648
|
function view(overrides: Partial<ReplyView>): ReplyView {
|
|
2492
2649
|
return {
|
|
@@ -2496,27 +2653,28 @@ describe('a second device that disagrees', () => {
|
|
|
2496
2653
|
device_id: 'dev',
|
|
2497
2654
|
device_name: 'iPhone',
|
|
2498
2655
|
text: 'Yes',
|
|
2499
|
-
|
|
2656
|
+
answers: [],
|
|
2657
|
+
source: null,
|
|
2500
2658
|
created_at: new Date().toISOString(),
|
|
2501
2659
|
...overrides,
|
|
2502
2660
|
}
|
|
2503
2661
|
}
|
|
2504
2662
|
|
|
2505
|
-
it('says
|
|
2663
|
+
it('says the latest answer counted and which were superseded', () => {
|
|
2506
2664
|
const said = contradictingAnswer([
|
|
2507
2665
|
view({ seq: 1, device_name: 'iPhone', text: 'Yes' }),
|
|
2508
2666
|
view({ seq: 2, device_name: 'FurankuMac', text: 'No' }),
|
|
2509
2667
|
])
|
|
2510
|
-
expect(said).toContain('"
|
|
2511
|
-
expect(said).toContain('
|
|
2512
|
-
expect(said).toMatch(/
|
|
2668
|
+
expect(said).toContain('"No" from FurankuMac')
|
|
2669
|
+
expect(said).toContain('iPhone')
|
|
2670
|
+
expect(said).toMatch(/corrects an earlier one/)
|
|
2513
2671
|
})
|
|
2514
2672
|
|
|
2515
2673
|
it('is silent when the second answer agrees', () => {
|
|
2516
2674
|
expect(
|
|
2517
2675
|
contradictingAnswer([
|
|
2518
|
-
view({ seq: 1, device_name: 'iPhone', text: 'Ship it',
|
|
2519
|
-
view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it',
|
|
2676
|
+
view({ seq: 1, device_name: 'iPhone', text: 'Ship it', answers: [{ question_id: 'q1', choice_ids: ['ship'], text: null }] }),
|
|
2677
|
+
view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it', answers: [{ question_id: 'q1', choice_ids: ['ship'], text: null }] }),
|
|
2520
2678
|
]),
|
|
2521
2679
|
).toBeNull()
|
|
2522
2680
|
})
|