@raidiant/notifai 0.1.8 → 0.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.
- package/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +118 -37
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +7 -5
- package/dist/hooks.js +60 -19
- 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 +9 -9
- package/src/commands.test.ts +108 -13
- package/src/commands.ts +144 -37
- package/src/hooks.test.ts +102 -4
- package/src/hooks.ts +79 -24
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/dist/send.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLI_SOUNDS, COLLAPSE_KEY_MAX_BYTES, INTERRUPTION_LEVELS, NOTIFICATION_SCHEMA_VERSION, NOTIFICATION_KINDS, PLATFORMS, } from '@raidiant/notifai-protocol';
|
|
1
|
+
import { CLI_SOUNDS, COLLAPSE_KEY_MAX_BYTES, INTERRUPTION_LEVELS, NOTIFICATION_SCHEMA_VERSION, QUESTION_TEXT_MAX_LENGTH, NOTIFICATION_KINDS, PLATFORMS, } from '@raidiant/notifai-protocol';
|
|
2
2
|
/** Merge flags over resolved config into a Notification Request draft. */
|
|
3
3
|
export function buildDraft(config, flags) {
|
|
4
4
|
if (!flags.title || !flags.body)
|
|
@@ -79,6 +79,21 @@ export function buildDraft(config, flags) {
|
|
|
79
79
|
error: CHOICE_USAGE,
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
83
|
+
return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' };
|
|
84
|
+
}
|
|
85
|
+
// The banner body doubles as the question text on a single-question send, so
|
|
86
|
+
// it inherits the question's readable-in-full bound; context beyond that
|
|
87
|
+
// belongs in --detail, which never rides the banner.
|
|
88
|
+
if (flags.reply &&
|
|
89
|
+
flags.questions === undefined &&
|
|
90
|
+
flags.body.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: `A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
|
|
94
|
+
'characters and put the longer context in --detail.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
82
97
|
const draft = {
|
|
83
98
|
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
84
99
|
...(flags.event !== undefined ? { event: flags.event } : {}),
|
|
@@ -103,7 +118,14 @@ export function buildDraft(config, flags) {
|
|
|
103
118
|
? {
|
|
104
119
|
reply: {
|
|
105
120
|
expires_in_seconds: flags.replyWindow ?? 3600,
|
|
106
|
-
|
|
121
|
+
questions: flags.questions ?? [
|
|
122
|
+
{
|
|
123
|
+
id: 'q1',
|
|
124
|
+
text: flags.body,
|
|
125
|
+
...(choices !== null ? { choices } : {}),
|
|
126
|
+
...(flags.replyMulti ? { multi: true } : {}),
|
|
127
|
+
},
|
|
128
|
+
],
|
|
107
129
|
},
|
|
108
130
|
}
|
|
109
131
|
: {}),
|
|
@@ -152,23 +174,18 @@ export function receiptExitCode(receipt) {
|
|
|
152
174
|
return receipt.overall === 'provider_rejected_all' ? 1 : 0;
|
|
153
175
|
}
|
|
154
176
|
/**
|
|
155
|
-
*
|
|
177
|
+
* One flag occurrence, one answer: `--choice Staging --choice Production` ->
|
|
178
|
+
* `[{id:'staging',label:'Staging'}, ...]`.
|
|
156
179
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* agent meant two.
|
|
180
|
+
* Commas are ordinary characters. The old single-value comma split produced
|
|
181
|
+
* buttons the agent never intended ("Nothing, I'll review" became two answers)
|
|
182
|
+
* and nothing downstream could detect it, so the delimiter form is gone
|
|
183
|
+
* rather than guarded: a label is a label.
|
|
162
184
|
*/
|
|
163
185
|
export function parseChoices(value) {
|
|
164
|
-
if (value === undefined)
|
|
165
|
-
return null;
|
|
166
|
-
const raw = Array.isArray(value) ? value : [value];
|
|
167
|
-
if (raw.length === 0)
|
|
186
|
+
if (value === undefined || value.length === 0)
|
|
168
187
|
return null;
|
|
169
|
-
const labels = (
|
|
170
|
-
.map((part) => part.trim())
|
|
171
|
-
.filter((part) => part.length > 0);
|
|
188
|
+
const labels = value.map((part) => part.trim()).filter((part) => part.length > 0);
|
|
172
189
|
if (labels.length < 2 || labels.length > 6)
|
|
173
190
|
return 'invalid';
|
|
174
191
|
const choices = labels.map((label) => ({ id: slugify(label), label }));
|
|
@@ -179,36 +196,9 @@ export function parseChoices(value) {
|
|
|
179
196
|
return choices;
|
|
180
197
|
}
|
|
181
198
|
/** Message shared by every surface that accepts choice labels. */
|
|
182
|
-
export const CHOICE_USAGE = '
|
|
183
|
-
'
|
|
184
|
-
|
|
185
|
-
* Catches the split that looked like it worked.
|
|
186
|
-
*
|
|
187
|
-
* `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
|
|
188
|
-
* buttons rather than three, because the third label contained a comma. Nothing
|
|
189
|
-
* downstream can detect it — the labels are all well-formed — and the CLI
|
|
190
|
-
* cheerfully echoed the wrong list back, so the user saw buttons the agent
|
|
191
|
-
* never intended.
|
|
192
|
-
*
|
|
193
|
-
* The tell is a comma followed by a space. A delimiter list is written
|
|
194
|
-
* "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
|
|
195
|
-
* will occasionally be wrong, which is exactly why this warns and names the
|
|
196
|
-
* escape hatch rather than rejecting: the split may well have been intended.
|
|
197
|
-
*/
|
|
198
|
-
export function ambiguousChoiceSplit(value) {
|
|
199
|
-
if (value === undefined || Array.isArray(value))
|
|
200
|
-
return null;
|
|
201
|
-
if (!/,\s/.test(value))
|
|
202
|
-
return null;
|
|
203
|
-
const parts = value
|
|
204
|
-
.split(',')
|
|
205
|
-
.map((part) => part.trim())
|
|
206
|
-
.filter((part) => part.length > 0);
|
|
207
|
-
return (`Heads up: "${value}" was split on commas into ${parts.length} answers ` +
|
|
208
|
-
`(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
|
|
209
|
-
'pass --choice once per answer instead.');
|
|
210
|
-
}
|
|
211
|
-
function slugify(label) {
|
|
199
|
+
export const CHOICE_USAGE = 'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
200
|
+
'e.g. --choice "Yes, ship it" --choice "Not yet".';
|
|
201
|
+
export function slugify(label) {
|
|
212
202
|
return label
|
|
213
203
|
.toLowerCase()
|
|
214
204
|
.replace(/[^a-z0-9]+/g, '-')
|
package/dist/send.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"send.js","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,sBAAsB,EACtB,mBAAmB,EACnB,2BAA2B,
|
|
1
|
+
{"version":3,"file":"send.js","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidiant/notifai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Send native phone notifications from agents and local programs",
|
|
6
6
|
"bin": {
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"src",
|
|
12
12
|
"tsconfig.json"
|
|
13
13
|
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
14
19
|
"dependencies": {
|
|
15
20
|
"@clack/prompts": "^1.7.0",
|
|
16
|
-
"@raidiant/notifai-protocol": "0.
|
|
21
|
+
"@raidiant/notifai-protocol": "0.2.0",
|
|
17
22
|
"commander": "^14.0.0",
|
|
18
23
|
"smol-toml": "^1.4.0"
|
|
19
24
|
},
|
|
@@ -47,10 +52,5 @@
|
|
|
47
52
|
"ai",
|
|
48
53
|
"claude",
|
|
49
54
|
"codex"
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
"build": "tsc -p tsconfig.json",
|
|
53
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
54
|
-
"test": "vitest run"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
55
|
+
]
|
|
56
|
+
}
|
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 () => {
|
|
@@ -1147,7 +1151,7 @@ describe('init', () => {
|
|
|
1147
1151
|
source: 'Raidiant-io/notifai',
|
|
1148
1152
|
sourceType: 'github',
|
|
1149
1153
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1150
|
-
ref: 'v0.
|
|
1154
|
+
ref: 'v0.2.0',
|
|
1151
1155
|
}
|
|
1152
1156
|
}
|
|
1153
1157
|
|
|
@@ -1250,7 +1254,7 @@ describe('init', () => {
|
|
|
1250
1254
|
})
|
|
1251
1255
|
|
|
1252
1256
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1253
|
-
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.
|
|
1257
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.2.0')
|
|
1254
1258
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1255
1259
|
})
|
|
1256
1260
|
|
|
@@ -1273,7 +1277,7 @@ describe('init', () => {
|
|
|
1273
1277
|
source: 'Raidiant-io/notifai',
|
|
1274
1278
|
sourceType: 'github',
|
|
1275
1279
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1276
|
-
ref: 'v0.
|
|
1280
|
+
ref: 'v0.2.0',
|
|
1277
1281
|
},
|
|
1278
1282
|
],
|
|
1279
1283
|
}),
|
|
@@ -2173,7 +2177,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2173
2177
|
source: 'Raidiant-io/notifai',
|
|
2174
2178
|
sourceType: 'github',
|
|
2175
2179
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2176
|
-
ref: 'v0.
|
|
2180
|
+
ref: 'v0.2.0',
|
|
2177
2181
|
},
|
|
2178
2182
|
]
|
|
2179
2183
|
: [],
|
|
@@ -2486,7 +2490,97 @@ describe('a server behind this CLI', () => {
|
|
|
2486
2490
|
})
|
|
2487
2491
|
})
|
|
2488
2492
|
|
|
2489
|
-
|
|
2493
|
+
describe('question sets', () => {
|
|
2494
|
+
it('maps --reply-multi into the single question', async () => {
|
|
2495
|
+
const io = new CapturedIo()
|
|
2496
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
2497
|
+
const client = {
|
|
2498
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
2499
|
+
submitted = body
|
|
2500
|
+
return receipt
|
|
2501
|
+
},
|
|
2502
|
+
replies: async () => replyResponse([reply]),
|
|
2503
|
+
} as unknown as ApiClient
|
|
2504
|
+
|
|
2505
|
+
expect(
|
|
2506
|
+
await sendCommand(makeDeps(io, client), {
|
|
2507
|
+
title: 'Question',
|
|
2508
|
+
body: 'Which fronts?',
|
|
2509
|
+
reply: true,
|
|
2510
|
+
replyTimeout: 30,
|
|
2511
|
+
replyChoice: ['CLI', 'Server', 'Apps'],
|
|
2512
|
+
replyMulti: true,
|
|
2513
|
+
}),
|
|
2514
|
+
).toBe(EXIT.ok)
|
|
2515
|
+
expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
|
|
2516
|
+
text: 'Which fronts?',
|
|
2517
|
+
multi: true,
|
|
2518
|
+
})
|
|
2519
|
+
expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
|
|
2520
|
+
})
|
|
2521
|
+
|
|
2522
|
+
it('rejects a question body too long for the answering surface', async () => {
|
|
2523
|
+
const io = new CapturedIo()
|
|
2524
|
+
let submitCalls = 0
|
|
2525
|
+
const client = {
|
|
2526
|
+
submit: async () => {
|
|
2527
|
+
submitCalls += 1
|
|
2528
|
+
return receipt
|
|
2529
|
+
},
|
|
2530
|
+
} as unknown as ApiClient
|
|
2531
|
+
|
|
2532
|
+
expect(
|
|
2533
|
+
await sendCommand(makeDeps(io, client), {
|
|
2534
|
+
title: 'Question',
|
|
2535
|
+
body: 'x'.repeat(501),
|
|
2536
|
+
reply: true,
|
|
2537
|
+
replyTimeout: 30,
|
|
2538
|
+
}),
|
|
2539
|
+
).toBe(EXIT.usage)
|
|
2540
|
+
expect(submitCalls).toBe(0)
|
|
2541
|
+
expect(io.errLines.join(' ')).toContain('--detail')
|
|
2542
|
+
})
|
|
2543
|
+
|
|
2544
|
+
it('generates unique question ids when texts collide', () => {
|
|
2545
|
+
const built = buildQuestions(
|
|
2546
|
+
{
|
|
2547
|
+
form: JSON.stringify({
|
|
2548
|
+
questions: [{ text: 'Ready?' }, { text: 'Ready?' }],
|
|
2549
|
+
}),
|
|
2550
|
+
},
|
|
2551
|
+
undefined,
|
|
2552
|
+
)
|
|
2553
|
+
expect(built).toMatchObject({ ok: true })
|
|
2554
|
+
if (built.ok) {
|
|
2555
|
+
expect(built.questions.map((question) => question.id)).toEqual(['ready', 'q2'])
|
|
2556
|
+
}
|
|
2557
|
+
})
|
|
2558
|
+
|
|
2559
|
+
it('rejects forms outside the documented shape', () => {
|
|
2560
|
+
expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
|
|
2561
|
+
expect(
|
|
2562
|
+
buildQuestions({ form: JSON.stringify({ questions: [] }) }, undefined),
|
|
2563
|
+
).toMatchObject({ ok: false })
|
|
2564
|
+
expect(
|
|
2565
|
+
buildQuestions(
|
|
2566
|
+
{ form: JSON.stringify({ questions: Array.from({ length: 5 }, (_, i) => ({ text: `Q${i}?` })) }) },
|
|
2567
|
+
undefined,
|
|
2568
|
+
),
|
|
2569
|
+
).toMatchObject({ ok: false, error: expect.stringContaining('1-4') })
|
|
2570
|
+
expect(
|
|
2571
|
+
buildQuestions(
|
|
2572
|
+
{ form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },
|
|
2573
|
+
undefined,
|
|
2574
|
+
),
|
|
2575
|
+
).toMatchObject({ ok: false, error: expect.stringContaining('--choice') })
|
|
2576
|
+
// --form replaces the flag surface; mixing them is a usage error.
|
|
2577
|
+
expect(
|
|
2578
|
+
buildQuestions({ form: JSON.stringify({ questions: [{ text: 'Q?' }] }) }, 'Also a question?'),
|
|
2579
|
+
).toMatchObject({ ok: false })
|
|
2580
|
+
})
|
|
2581
|
+
})
|
|
2582
|
+
|
|
2583
|
+
/** Latest-reply-wins, and never silently: a correction is named as one. */
|
|
2490
2584
|
describe('a second device that disagrees', () => {
|
|
2491
2585
|
function view(overrides: Partial<ReplyView>): ReplyView {
|
|
2492
2586
|
return {
|
|
@@ -2496,27 +2590,28 @@ describe('a second device that disagrees', () => {
|
|
|
2496
2590
|
device_id: 'dev',
|
|
2497
2591
|
device_name: 'iPhone',
|
|
2498
2592
|
text: 'Yes',
|
|
2499
|
-
|
|
2593
|
+
answers: [],
|
|
2594
|
+
source: null,
|
|
2500
2595
|
created_at: new Date().toISOString(),
|
|
2501
2596
|
...overrides,
|
|
2502
2597
|
}
|
|
2503
2598
|
}
|
|
2504
2599
|
|
|
2505
|
-
it('says
|
|
2600
|
+
it('says the latest answer counted and which were superseded', () => {
|
|
2506
2601
|
const said = contradictingAnswer([
|
|
2507
2602
|
view({ seq: 1, device_name: 'iPhone', text: 'Yes' }),
|
|
2508
2603
|
view({ seq: 2, device_name: 'FurankuMac', text: 'No' }),
|
|
2509
2604
|
])
|
|
2510
|
-
expect(said).toContain('"
|
|
2511
|
-
expect(said).toContain('
|
|
2512
|
-
expect(said).toMatch(/
|
|
2605
|
+
expect(said).toContain('"No" from FurankuMac')
|
|
2606
|
+
expect(said).toContain('iPhone')
|
|
2607
|
+
expect(said).toMatch(/corrects an earlier one/)
|
|
2513
2608
|
})
|
|
2514
2609
|
|
|
2515
2610
|
it('is silent when the second answer agrees', () => {
|
|
2516
2611
|
expect(
|
|
2517
2612
|
contradictingAnswer([
|
|
2518
|
-
view({ seq: 1, device_name: 'iPhone', text: 'Ship it',
|
|
2519
|
-
view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it',
|
|
2613
|
+
view({ seq: 1, device_name: 'iPhone', text: 'Ship it', answers: [{ question_id: 'q1', choice_ids: ['ship'], text: null }] }),
|
|
2614
|
+
view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it', answers: [{ question_id: 'q1', choice_ids: ['ship'], text: null }] }),
|
|
2520
2615
|
]),
|
|
2521
2616
|
).toBeNull()
|
|
2522
2617
|
})
|
package/src/commands.ts
CHANGED
|
@@ -6,12 +6,15 @@ import path from 'node:path'
|
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
7
7
|
import {
|
|
8
8
|
CAPABILITIES_V1,
|
|
9
|
+
QUESTION_TEXT_MAX_LENGTH,
|
|
10
|
+
REPLY_MAX_QUESTIONS,
|
|
9
11
|
REPLY_MAX_WINDOW_SECONDS,
|
|
10
12
|
validateDraft,
|
|
11
13
|
type EvidenceSnapshot,
|
|
12
14
|
type AccountAccessResponse,
|
|
13
15
|
type ListRepliesResponse,
|
|
14
16
|
type Platform,
|
|
17
|
+
type QuestionT,
|
|
15
18
|
type ReplyView,
|
|
16
19
|
type RoutableDevice,
|
|
17
20
|
type SubmissionReceipt,
|
|
@@ -82,11 +85,11 @@ import {
|
|
|
82
85
|
} from './opencode-plugin.js'
|
|
83
86
|
import {
|
|
84
87
|
CHOICE_USAGE,
|
|
85
|
-
ambiguousChoiceSplit,
|
|
86
88
|
buildDraft,
|
|
87
89
|
formatReceipt,
|
|
88
90
|
parseChoices,
|
|
89
91
|
receiptExitCode,
|
|
92
|
+
slugify,
|
|
90
93
|
type SendFlags,
|
|
91
94
|
} from './send.js'
|
|
92
95
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
@@ -770,28 +773,27 @@ function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
|
|
|
770
773
|
}
|
|
771
774
|
|
|
772
775
|
/**
|
|
773
|
-
* The
|
|
776
|
+
* The correction note for a wait that saw more than one answer.
|
|
774
777
|
*
|
|
775
|
-
* A
|
|
776
|
-
* answer
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
* answer between the first reply landing and its retirement arriving.
|
|
778
|
+
* A later reply that conflicts with an earlier one is a correction, so the
|
|
779
|
+
* latest answer is the one that counts. The server retires the question on
|
|
780
|
+
* the other devices the moment one answers, which makes this rare; it is
|
|
781
|
+
* still reachable, because a device can answer between the first reply
|
|
782
|
+
* landing and its retirement arriving.
|
|
781
783
|
*
|
|
782
|
-
* Ordered by seq, so `replies
|
|
784
|
+
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
783
785
|
*/
|
|
784
786
|
export function contradictingAnswer(replies: ReplyView[]): string | null {
|
|
785
|
-
const winner = replies
|
|
787
|
+
const winner = replies.at(-1)
|
|
786
788
|
if (winner === undefined || replies.length < 2) return null
|
|
787
|
-
const
|
|
788
|
-
.
|
|
789
|
-
|
|
790
|
-
if (
|
|
791
|
-
const names = [...new Set(
|
|
789
|
+
const key = (reply: ReplyView) =>
|
|
790
|
+
reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text
|
|
791
|
+
const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner))
|
|
792
|
+
if (corrected.length === 0) return null
|
|
793
|
+
const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ')
|
|
792
794
|
return (
|
|
793
|
-
`note: "${winner.text}" from ${winner.device_name} is the answer that counts —
|
|
794
|
-
|
|
795
|
+
`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
|
|
796
|
+
`answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`
|
|
795
797
|
)
|
|
796
798
|
}
|
|
797
799
|
|
|
@@ -1096,17 +1098,119 @@ function rejectedPaths(details: unknown): string[] {
|
|
|
1096
1098
|
}
|
|
1097
1099
|
|
|
1098
1100
|
export interface AskFlags {
|
|
1099
|
-
choice?: string
|
|
1101
|
+
choice?: string[]
|
|
1102
|
+
/** The single question is multi-select: several answers may be chosen. */
|
|
1103
|
+
multi?: boolean
|
|
1104
|
+
/** Long-form markdown context; shown in the app, never on the banner. */
|
|
1105
|
+
detail?: string
|
|
1106
|
+
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
1107
|
+
form?: string
|
|
1100
1108
|
session?: string
|
|
1101
1109
|
}
|
|
1102
1110
|
|
|
1111
|
+
/** The `--form` document: what an agent writes to ask several things at once. */
|
|
1112
|
+
interface AskFormQuestion {
|
|
1113
|
+
text: string
|
|
1114
|
+
choices?: string[]
|
|
1115
|
+
multi?: boolean
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Turn ask input into the question set that will ride the push. Everything is
|
|
1120
|
+
* validated here, at registration, because the push happens inside a hook
|
|
1121
|
+
* where a rejection becomes a stderr note the agent never reads.
|
|
1122
|
+
*/
|
|
1123
|
+
export function buildQuestions(
|
|
1124
|
+
flags: AskFlags,
|
|
1125
|
+
question: string | undefined,
|
|
1126
|
+
): { ok: true; questions: QuestionT[]; detail?: string } | { ok: false; error: string } {
|
|
1127
|
+
if (flags.form !== undefined) {
|
|
1128
|
+
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
1129
|
+
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
|
|
1130
|
+
}
|
|
1131
|
+
let parsed: unknown
|
|
1132
|
+
try {
|
|
1133
|
+
parsed = JSON.parse(flags.form)
|
|
1134
|
+
} catch {
|
|
1135
|
+
return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' }
|
|
1136
|
+
}
|
|
1137
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { questions?: unknown }).questions)) {
|
|
1138
|
+
return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
|
|
1139
|
+
}
|
|
1140
|
+
const form = parsed as { questions: unknown[]; detail?: unknown }
|
|
1141
|
+
if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
|
|
1142
|
+
return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
|
|
1143
|
+
}
|
|
1144
|
+
if (form.detail !== undefined && typeof form.detail !== 'string') {
|
|
1145
|
+
return { ok: false, error: '"detail" must be a markdown string.' }
|
|
1146
|
+
}
|
|
1147
|
+
const questions: QuestionT[] = []
|
|
1148
|
+
const usedIds = new Set<string>()
|
|
1149
|
+
for (const [index, entry] of form.questions.entries()) {
|
|
1150
|
+
if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
|
|
1151
|
+
return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
|
|
1152
|
+
}
|
|
1153
|
+
const spec = entry as AskFormQuestion
|
|
1154
|
+
const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds)
|
|
1155
|
+
if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
|
|
1156
|
+
questions.push(built.question)
|
|
1157
|
+
}
|
|
1158
|
+
return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) }
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (question === undefined || question.trim() === '') {
|
|
1162
|
+
return { ok: false, error: 'The question cannot be empty.' }
|
|
1163
|
+
}
|
|
1164
|
+
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
|
|
1165
|
+
if ('error' in built) return { ok: false, error: built.error }
|
|
1166
|
+
return {
|
|
1167
|
+
ok: true,
|
|
1168
|
+
questions: [built.question],
|
|
1169
|
+
...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function buildOneQuestion(
|
|
1174
|
+
text: string,
|
|
1175
|
+
choiceLabels: string[] | undefined,
|
|
1176
|
+
multi: boolean,
|
|
1177
|
+
index: number,
|
|
1178
|
+
usedIds: Set<string>,
|
|
1179
|
+
): { question: QuestionT } | { error: string } {
|
|
1180
|
+
const trimmed = text.trim()
|
|
1181
|
+
if (trimmed === '') return { error: 'the question text cannot be empty.' }
|
|
1182
|
+
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
1183
|
+
return {
|
|
1184
|
+
error:
|
|
1185
|
+
`a question must be readable where it is answered: keep it within ` +
|
|
1186
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const choices = parseChoices(choiceLabels)
|
|
1190
|
+
if (choices === 'invalid') return { error: CHOICE_USAGE }
|
|
1191
|
+
if (multi && choices === null) {
|
|
1192
|
+
return { error: '--multi needs answers to select between; add --choice.' }
|
|
1193
|
+
}
|
|
1194
|
+
let id = slugify(trimmed)
|
|
1195
|
+
if (id === '' || usedIds.has(id)) id = `q${index + 1}`
|
|
1196
|
+
usedIds.add(id)
|
|
1197
|
+
return {
|
|
1198
|
+
question: {
|
|
1199
|
+
id,
|
|
1200
|
+
text: trimmed,
|
|
1201
|
+
...(choices !== null ? { choices } : {}),
|
|
1202
|
+
...(multi ? { multi: true } : {}),
|
|
1203
|
+
},
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1103
1207
|
/**
|
|
1104
1208
|
* Registers a question for turn-end routing under the user's presence config.
|
|
1105
1209
|
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
1106
1210
|
* default presence gate, recent keyboard or mouse activity keeps it local;
|
|
1107
1211
|
* `require_idle = false` intentionally permits a push while the user is active.
|
|
1108
1212
|
*/
|
|
1109
|
-
export function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number {
|
|
1213
|
+
export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
|
|
1110
1214
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
1111
1215
|
// markers identify the active owner, while the UserPromptSubmit hook leaves
|
|
1112
1216
|
// a pointer keyed on the project directory with the hook's canonical id.
|
|
@@ -1155,16 +1259,12 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
1155
1259
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1156
1260
|
return EXIT.usage
|
|
1157
1261
|
}
|
|
1158
|
-
if (question.trim() === '') {
|
|
1159
|
-
deps.io.err('The question cannot be empty.')
|
|
1160
|
-
return EXIT.usage
|
|
1161
|
-
}
|
|
1162
1262
|
// Validate here, not at push time. The push happens inside a hook, where a
|
|
1163
1263
|
// rejection becomes a stderr note the agent never reads — so a malformed
|
|
1164
|
-
//
|
|
1165
|
-
const
|
|
1166
|
-
if (
|
|
1167
|
-
deps.io.err(
|
|
1264
|
+
// question set would look like it registered fine and then silently never ask.
|
|
1265
|
+
const built = buildQuestions(flags, question)
|
|
1266
|
+
if (!built.ok) {
|
|
1267
|
+
deps.io.err(built.error)
|
|
1168
1268
|
return EXIT.usage
|
|
1169
1269
|
}
|
|
1170
1270
|
try {
|
|
@@ -1172,8 +1272,9 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
1172
1272
|
sessionId,
|
|
1173
1273
|
deps.env,
|
|
1174
1274
|
{
|
|
1175
|
-
question:
|
|
1176
|
-
|
|
1275
|
+
question: built.questions[0]!.text,
|
|
1276
|
+
questions: built.questions,
|
|
1277
|
+
...(built.detail !== undefined ? { detail: built.detail } : {}),
|
|
1177
1278
|
},
|
|
1178
1279
|
(deps.now ?? Date.now)(),
|
|
1179
1280
|
)
|
|
@@ -1181,14 +1282,20 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
1181
1282
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1182
1283
|
return EXIT.failed
|
|
1183
1284
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1285
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
1286
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1287
|
+
if (entry.choices !== undefined) {
|
|
1288
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
|
|
1289
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
|
|
1290
|
+
} else if (built.questions.length > 1) {
|
|
1291
|
+
deps.io.out(`${prefix}${entry.text} — free text`)
|
|
1292
|
+
}
|
|
1186
1293
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1294
|
+
deps.io.out(
|
|
1295
|
+
built.questions.length > 1
|
|
1296
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
|
|
1297
|
+
: 'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
1298
|
+
)
|
|
1192
1299
|
return EXIT.ok
|
|
1193
1300
|
}
|
|
1194
1301
|
|
|
@@ -1729,7 +1836,7 @@ export async function configSetCommand(
|
|
|
1729
1836
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1730
1837
|
* Keep this immutable and public because the command is printed to users.
|
|
1731
1838
|
*/
|
|
1732
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
1839
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0'
|
|
1733
1840
|
|
|
1734
1841
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
1735
1842
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|