@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.
- package/CHANGELOG.md +46 -0
- package/README.md +5 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +174 -58
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +14 -5
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +72 -5
- package/dist/hooks.js +407 -79
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +44 -17
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +185 -51
- package/src/commands.ts +198 -56
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +16 -5
- package/src/config.test.ts +41 -9
- package/src/config.ts +13 -0
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +358 -21
- package/src/hooks.ts +457 -97
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +57 -23
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/send.ts
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
type MediaItemT,
|
|
13
13
|
type NotificationDraftT,
|
|
14
14
|
NOTIFICATION_KINDS,
|
|
15
|
+
DEFAULT_NOTIFICATION_KIND,
|
|
16
|
+
defaultSoundForKind,
|
|
17
|
+
type NotificationKind,
|
|
15
18
|
PLATFORMS,
|
|
16
19
|
type Platform,
|
|
17
20
|
type QuestionT,
|
|
@@ -173,10 +176,24 @@ export function buildDraft(
|
|
|
173
176
|
const targets: NotificationDraftT['targets'] =
|
|
174
177
|
deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
|
|
175
178
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
if (
|
|
180
|
+
flags.kind !== undefined &&
|
|
181
|
+
!NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
|
|
182
|
+
) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Kind states what happened; the sound table turns that truth into the
|
|
190
|
+
// attention it deserves, so an honest sender gets the right sound without
|
|
191
|
+
// reasoning about audio. An explicit --sound and the user's saved preference
|
|
192
|
+
// both outrank it, and `none` still means silent.
|
|
193
|
+
const kindForSound: NotificationKind = flags.reply
|
|
194
|
+
? 'question'
|
|
195
|
+
: ((flags.kind as NotificationKind | undefined) ?? DEFAULT_NOTIFICATION_KIND)
|
|
196
|
+
const sound = flags.sound ?? config.sound.value ?? defaultSoundForKind(kindForSound)
|
|
180
197
|
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
|
|
181
198
|
return {
|
|
182
199
|
ok: false,
|
|
@@ -211,16 +228,6 @@ export function buildDraft(
|
|
|
211
228
|
options.custom_data = data
|
|
212
229
|
}
|
|
213
230
|
|
|
214
|
-
if (
|
|
215
|
-
flags.kind !== undefined &&
|
|
216
|
-
!NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
|
|
217
|
-
) {
|
|
218
|
-
return {
|
|
219
|
-
ok: false,
|
|
220
|
-
error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
231
|
const ttl = flags.ttl ?? config.ttl_seconds.value
|
|
225
232
|
const collapse = flags.collapseKey ?? config.collapse_key.value
|
|
226
233
|
if (
|
|
@@ -242,7 +249,7 @@ export function buildDraft(
|
|
|
242
249
|
if (choices === 'invalid') {
|
|
243
250
|
return {
|
|
244
251
|
ok: false,
|
|
245
|
-
error:
|
|
252
|
+
error: REPLY_CHOICE_USAGE,
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
if (flags.replyMulti && choices === null && flags.questions === undefined) {
|
|
@@ -284,7 +291,7 @@ export function buildDraft(
|
|
|
284
291
|
...(flags.reply
|
|
285
292
|
? {
|
|
286
293
|
reply: {
|
|
287
|
-
expires_in_seconds: flags.replyWindow ??
|
|
294
|
+
expires_in_seconds: flags.replyWindow ?? config.reply_window_seconds.value,
|
|
288
295
|
questions: flags.questions ?? [
|
|
289
296
|
{
|
|
290
297
|
id: 'q1',
|
|
@@ -362,10 +369,19 @@ export function parseChoices(
|
|
|
362
369
|
}
|
|
363
370
|
|
|
364
371
|
/** Message shared by every surface that accepts choice labels. */
|
|
372
|
+
/** `ask` spells this flag `--choice`. */
|
|
365
373
|
export const CHOICE_USAGE =
|
|
366
374
|
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
367
375
|
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
368
376
|
|
|
377
|
+
/**
|
|
378
|
+
* `send` spells the same flag `--reply-choice`, and a remedy that names the
|
|
379
|
+
* other command's flag reproduces the error it is trying to fix.
|
|
380
|
+
*/
|
|
381
|
+
export const REPLY_CHOICE_USAGE =
|
|
382
|
+
'Offer 2-6 answers, one --reply-choice flag per answer, unique once slugified — ' +
|
|
383
|
+
'e.g. --reply-choice "Yes, ship it" --reply-choice "Not yet".'
|
|
384
|
+
|
|
369
385
|
export function slugify(label: string): string {
|
|
370
386
|
return label
|
|
371
387
|
.toLowerCase()
|
package/src/skill.test.ts
CHANGED
|
@@ -1,175 +1,228 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { NOTIFICATION_KINDS } from '@raidiant/notifai-protocol'
|
|
4
|
+
import { GATE_REASONS } from './hooks.js'
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/**
|
|
7
|
+
* These tests assert what the skill has to teach, not the sentences it teaches
|
|
8
|
+
* it in — as far as that is achievable. Pinning wording makes every
|
|
9
|
+
* improvement to the guidance look like a regression, which is how guidance
|
|
10
|
+
* goes stale: the cheapest way to keep a suite green becomes leaving the prose
|
|
11
|
+
* alone.
|
|
12
|
+
*
|
|
13
|
+
* What is genuinely structural: section order, the vocabularies the CLI can
|
|
14
|
+
* actually emit (`NOTIFICATION_KINDS`, `GATE_REASONS`), the token budget, that
|
|
15
|
+
* every example would pass the CLI's own validation, and that every command an
|
|
16
|
+
* agent needs is reachable.
|
|
17
|
+
*
|
|
18
|
+
* Some assertions do match on phrases, and honestly so: a rule like "never say
|
|
19
|
+
* where the answer must arrive" has no structural signature, and the cost of
|
|
20
|
+
* it silently disappearing is higher than the cost of rewording a regex. Those
|
|
21
|
+
* are matched case-insensitively and loosely on purpose.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const skillPath = new URL('../../../skills/notifai/SKILL.md', import.meta.url)
|
|
25
|
+
const skill = readFileSync(skillPath, 'utf8')
|
|
5
26
|
const harnessReference = readFileSync(
|
|
6
27
|
new URL('../../../skills/notifai/references/harness-setup.md', import.meta.url),
|
|
7
28
|
'utf8',
|
|
8
29
|
)
|
|
9
30
|
|
|
31
|
+
/** Fenced blocks, so examples can be checked as commands rather than as text. */
|
|
32
|
+
const examples = [...skill.matchAll(/```(?:bash|json)?\n([\s\S]*?)```/g)].map((match) => match[1]!)
|
|
33
|
+
const shellExamples = examples.flatMap((block) =>
|
|
34
|
+
block
|
|
35
|
+
.replace(/\\\n\s*/g, ' ')
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter((line) => line.startsWith('notifai ')),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const section = (heading: string): string => {
|
|
42
|
+
const start = skill.indexOf(heading)
|
|
43
|
+
if (start < 0) return ''
|
|
44
|
+
const next = skill.indexOf('\n## ', start + heading.length)
|
|
45
|
+
return skill.slice(start, next < 0 ? undefined : next)
|
|
46
|
+
}
|
|
47
|
+
|
|
10
48
|
describe('Notifai agent skill', () => {
|
|
11
|
-
it('
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
it('separates semantic kind from native-banner attention', () => {
|
|
17
|
-
expect(skill).toContain('--kind done')
|
|
18
|
-
expect(skill).toMatch(/Kind never chooses a native-banner sound\s+or\s+interruption level/)
|
|
19
|
-
expect(skill).toContain('lets the destination use its normal behavior')
|
|
20
|
-
expect(skill).not.toContain('Kind profiles')
|
|
21
|
-
expect(skill).not.toMatch(/\| `failed` \| `alert`/)
|
|
22
|
-
expect(skill).not.toMatch(/\| `blocked` \| `attention`/)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('makes the agent the setup wizard', () => {
|
|
26
|
-
expect(skill.indexOf('## Set Notifai up')).toBeGreaterThan(skill.indexOf('## When to notify'))
|
|
27
|
-
expect(skill.indexOf('## Set Notifai up')).toBeLessThan(skill.indexOf('## Compose and send'))
|
|
28
|
-
expect(skill).toContain('Never tell the user to run')
|
|
29
|
-
expect(skill).toContain('notifai init')
|
|
30
|
-
expect(skill).toContain('notifai hooks install')
|
|
31
|
-
expect(skill).toContain('notifai doctor')
|
|
32
|
-
expect(skill).toContain('notifai login')
|
|
33
|
-
expect(skill).toContain('Ask the user only for decisions a process cannot make')
|
|
34
|
-
expect(skill).not.toContain('relay the exact action')
|
|
35
|
-
expect(harnessReference).toContain('install hooks yourself')
|
|
36
|
-
expect(harnessReference).toContain('run `notifai login` yourself')
|
|
37
|
-
expect(harnessReference).not.toContain('the user must run `notifai login`')
|
|
38
|
-
expect(harnessReference).not.toContain('do not install them without being asked')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('teaches one Markdown body, ordered media, and substance-only titles', () => {
|
|
42
|
-
for (const required of [
|
|
43
|
-
'one canonical Markdown body',
|
|
44
|
-
'--body-file <path|->',
|
|
45
|
-
'media:1',
|
|
46
|
-
'maximum 8',
|
|
47
|
-
'Never repeat the type or Project in the title',
|
|
48
|
-
'All 42 tests passed',
|
|
49
|
-
]) {
|
|
50
|
-
expect(skill).toContain(required)
|
|
51
|
-
}
|
|
52
|
-
expect(skill).not.toContain('--detail')
|
|
53
|
-
expect(skill).not.toContain('Done ·')
|
|
54
|
-
expect(skill).not.toContain('Failed ·')
|
|
55
|
-
expect(skill).not.toContain('Question ·')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('treats --sound and --level as overrides, not completeness', () => {
|
|
59
|
-
expect(skill).toMatch(/Do not pass `--sound` or `--level` unless/)
|
|
60
|
-
const examples = [...skill.matchAll(/```(?:bash)?\n([\s\S]*?)```/g)].map((match) => match[1])
|
|
61
|
-
const sendExamples = examples.filter((example) => example.includes('notifai send'))
|
|
62
|
-
expect(sendExamples.length).toBeGreaterThan(0)
|
|
63
|
-
for (const example of sendExamples) {
|
|
64
|
-
expect(example).not.toMatch(/(^|\s)--sound\b/)
|
|
65
|
-
expect(example).not.toMatch(/(^|\s)--level\b/)
|
|
66
|
-
}
|
|
49
|
+
it('fits inside the budget a skill is actually read within', () => {
|
|
50
|
+
// Long skills are truncated in exactly the long sessions that need them.
|
|
51
|
+
// The old skill sat at ~4,800 tokens; this ceiling keeps a working margin.
|
|
52
|
+
const approximateTokens = skill.length / 4
|
|
53
|
+
expect(approximateTokens).toBeLessThan(4_400)
|
|
67
54
|
})
|
|
68
55
|
|
|
69
|
-
it('
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
'Do not mint an id',
|
|
80
|
-
'exit status is nonzero',
|
|
81
|
-
]) {
|
|
82
|
-
expect(skill).toContain(required)
|
|
56
|
+
it('decides whether to notify before it composes anything', () => {
|
|
57
|
+
const decide = skill.search(/^## .*notify/im)
|
|
58
|
+
const compose = skill.search(/^## Send/im)
|
|
59
|
+
expect(decide).toBeGreaterThan(0)
|
|
60
|
+
expect(decide).toBeLessThan(compose)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('names every notification kind the CLI accepts', () => {
|
|
64
|
+
for (const kind of NOTIFICATION_KINDS) {
|
|
65
|
+
expect(skill).toContain(`\`${kind}\``)
|
|
83
66
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"user's answer",
|
|
105
|
-
]) {
|
|
106
|
-
expect(askGuidance).toContain(required)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('teaches kind as required, truthful, and attention-bearing', () => {
|
|
70
|
+
const send = section('## Send')
|
|
71
|
+
expect(send).toMatch(/`--kind` is required/i)
|
|
72
|
+
// The rule that replaced "kind never chooses attention": since kind now
|
|
73
|
+
// selects the sound, the only defensible instruction is to declare the
|
|
74
|
+
// true one.
|
|
75
|
+
expect(send).toMatch(/declare the kind\s+that is true/i)
|
|
76
|
+
expect(skill).not.toMatch(/kind never chooses/i)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps every send example valid against the CLI it documents', () => {
|
|
80
|
+
const sends = shellExamples.filter((line) => line.startsWith('notifai send'))
|
|
81
|
+
expect(sends.length).toBeGreaterThan(0)
|
|
82
|
+
for (const example of sends) {
|
|
83
|
+
// Exactly the boundary rule `sendCommand` enforces.
|
|
84
|
+
expect(example, example).toMatch(/--kind |--reply\b/)
|
|
85
|
+
// Attention overrides belong to the user, so no example models one.
|
|
86
|
+
expect(example, example).not.toMatch(/(^|\s)--(sound|level)\b/)
|
|
107
87
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
expect(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('teaches the summary line, the one canonical body, and ordered evidence', () => {
|
|
91
|
+
const send = section('## Send')
|
|
92
|
+
expect(send).toContain('--subtitle')
|
|
93
|
+
expect(send).toContain('--body-file')
|
|
94
|
+
expect(send).toMatch(/one canonical Markdown body/i)
|
|
95
|
+
expect(send).toContain('media:1')
|
|
96
|
+
// Type and project are structured fields, never title text.
|
|
97
|
+
expect(send).toMatch(/never\s+put the kind or the project in it/i)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('separates how long the command waits from how long an answer is accepted', () => {
|
|
101
|
+
const ask = section('## Ask a question')
|
|
102
|
+
expect(ask).toContain('--reply-timeout')
|
|
103
|
+
expect(ask).toContain('--reply-window')
|
|
104
|
+
expect(ask).toMatch(/exit code 3/i)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('makes registration the middle of the asking turn, not the end', () => {
|
|
108
|
+
const ask = section('## Ask a question')
|
|
109
|
+
expect(ask).toMatch(/registering is not the end of the turn/i)
|
|
110
|
+
// The pre-commitment, and the route-neutrality that makes it survive.
|
|
111
|
+
expect(ask).toMatch(/what each answer will make you do/i)
|
|
112
|
+
expect(ask).toMatch(/never say where the answer must arrive/i)
|
|
113
|
+
expect(ask).toMatch(/tell me here/i)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('requires an acknowledgement before any resumed work', () => {
|
|
117
|
+
const answers = section('## When the answer arrives')
|
|
118
|
+
const acknowledge = answers.indexOf('notifai acknowledge')
|
|
119
|
+
const resume = answers.search(/then resume/i)
|
|
120
|
+
expect(acknowledge).toBeGreaterThanOrEqual(0)
|
|
121
|
+
expect(resume).toBeGreaterThan(acknowledge)
|
|
122
|
+
expect(answers).toMatch(/acknowledge before you resume/i)
|
|
123
|
+
// Unconditional: no "if required" escape hatch survives the decision that
|
|
124
|
+
// the user always learns their reply was read.
|
|
125
|
+
expect(answers).not.toMatch(/if .{0,24}acknowledgement is required/i)
|
|
126
|
+
expect(answers).toMatch(/concrete/i)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('keeps the relay honest about transport, authorship, and its limits', () => {
|
|
130
|
+
const answers = section('## When the answer arrives')
|
|
131
|
+
expect(answers).toMatch(/latest reply/i)
|
|
132
|
+
expect(answers).toMatch(/another session/i)
|
|
133
|
+
expect(answers).toMatch(/permission prompt/i)
|
|
134
|
+
expect(answers).toMatch(/without asking them to confirm again/i)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('recovers an answer it was never handed', () => {
|
|
138
|
+
expect(skill).toContain('notifai replies --pending')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('makes the agent the operator and the human only the human', () => {
|
|
142
|
+
const setup = section('## Set Notifai up')
|
|
143
|
+
expect(setup).toMatch(/never tell the\s+user to run a command you could have run/i)
|
|
144
|
+
expect(setup).toMatch(/browser/i)
|
|
145
|
+
expect(setup).toMatch(/companion/i)
|
|
146
|
+
expect(setup).toContain('notifai doctor --json')
|
|
147
|
+
expect(setup).toContain('notifai init')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('offers npx only as a pinned fallback behind a real install', () => {
|
|
151
|
+
const global = skill.indexOf('npm install -g @raidiant/notifai')
|
|
152
|
+
const npx = skill.indexOf('npx --yes @raidiant/notifai@')
|
|
153
|
+
expect(global).toBeGreaterThan(0)
|
|
154
|
+
expect(global).toBeLessThan(npx)
|
|
155
|
+
expect(skill).toMatch(/never as the first suggestion/i)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('gives the agent an exit-status branch for every documented outcome', () => {
|
|
159
|
+
for (const code of ['| 0 |', '| 1 |', '| 2 |', '| 3 |', '| 4 |', '| 5 |']) {
|
|
160
|
+
expect(skill).toContain(code)
|
|
139
161
|
}
|
|
162
|
+
expect(skill).toMatch(/`notifai <command> --help` is the authoritative list/i)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('points at the record when something did not happen, and calls it private', () => {
|
|
166
|
+
const check = section('## Check what happened')
|
|
167
|
+
expect(check).toContain('notifai logs')
|
|
168
|
+
expect(check).toContain('hook.gate')
|
|
169
|
+
expect(check).toMatch(/never leaves the machine/i)
|
|
140
170
|
})
|
|
141
171
|
|
|
142
|
-
it('
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
172
|
+
it('advertises exactly the gate reasons the code can emit', () => {
|
|
173
|
+
// The drift this replaces: the skill advertised `already-asked` and
|
|
174
|
+
// `no-devices` for months, neither of which was ever emitted, while
|
|
175
|
+
// `answered`, `acknowledgement-required` and `proceeding` went undocumented.
|
|
176
|
+
// Reading the vocabulary from the code makes that impossible to repeat.
|
|
177
|
+
const check = section('## Check what happened')
|
|
178
|
+
const documented = [...check.matchAll(/`([a-z]+(?:-[a-z]+)*)`/g)].map((m) => m[1]!)
|
|
179
|
+
const advertised = new Set(documented.filter((word) => GATE_REASONS.includes(word as never)))
|
|
180
|
+
for (const reason of GATE_REASONS) {
|
|
181
|
+
// `elapsed` is debug-only bookkeeping, not a routing verdict an agent acts on.
|
|
182
|
+
if (reason === 'elapsed') continue
|
|
183
|
+
expect(advertised, `gate reason ${reason} is not documented`).toContain(reason)
|
|
184
|
+
}
|
|
185
|
+
for (const word of documented) {
|
|
186
|
+
if (!word.includes('-')) continue
|
|
187
|
+
const looksLikeAReason = /^(no|already|continuation|delivery|claimed|notifications|acknowledgement)-/.test(word)
|
|
188
|
+
if (!looksLikeAReason) continue
|
|
189
|
+
expect(GATE_REASONS, `the skill advertises "${word}", which nothing emits`).toContain(word)
|
|
190
|
+
}
|
|
148
191
|
})
|
|
149
192
|
|
|
150
|
-
it('
|
|
151
|
-
|
|
152
|
-
expect(
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
expect(skill).toContain('do not present npx as the default')
|
|
193
|
+
it('distinguishes acceptance, receipt, and unknown when reporting delivery', () => {
|
|
194
|
+
const check = section('## Check what happened')
|
|
195
|
+
expect(check).toMatch(/provider acceptance/i)
|
|
196
|
+
expect(check).toMatch(/companion receipt/i)
|
|
197
|
+
expect(check).toMatch(/unknown/i)
|
|
156
198
|
})
|
|
157
199
|
|
|
158
|
-
it('
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
it('covers every command an agent needs to reach the user and recover', () => {
|
|
201
|
+
for (const command of [
|
|
202
|
+
'notifai send',
|
|
203
|
+
'notifai ask',
|
|
204
|
+
'notifai replies',
|
|
205
|
+
'notifai acknowledge',
|
|
206
|
+
'notifai close',
|
|
207
|
+
'notifai doctor',
|
|
208
|
+
'notifai init',
|
|
209
|
+
'notifai config show',
|
|
210
|
+
'notifai logs',
|
|
211
|
+
'notifai status',
|
|
212
|
+
]) {
|
|
213
|
+
expect(skill, `${command} is unreachable from the skill`).toContain(command)
|
|
214
|
+
}
|
|
167
215
|
})
|
|
168
216
|
|
|
169
217
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
170
218
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
219
|
+
expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
|
|
171
220
|
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
172
221
|
expect(harnessReference).toContain(`**${harness}:**`)
|
|
173
222
|
}
|
|
223
|
+
// The agent runs what a process can run; the human only does the human part.
|
|
224
|
+
expect(harnessReference).toContain('install hooks yourself')
|
|
225
|
+
expect(harnessReference).toMatch(/run `notifai login` yourself/i)
|
|
226
|
+
expect(harnessReference).toMatch(/only the user can approve/i)
|
|
174
227
|
})
|
|
175
228
|
})
|
package/src/ui/help.ts
CHANGED
|
@@ -27,7 +27,6 @@ export const GROUP = {
|
|
|
27
27
|
daily: 'Everyday:',
|
|
28
28
|
agent: 'For agents and scripts:',
|
|
29
29
|
advanced: 'Account and wiring:',
|
|
30
|
-
help: 'Help:',
|
|
31
30
|
} as const
|
|
32
31
|
|
|
33
32
|
/** Option groups for `send`, which carries thirty flags. */
|
|
@@ -45,7 +44,6 @@ const GROUP_ORDER: readonly string[] = [
|
|
|
45
44
|
GROUP.daily,
|
|
46
45
|
GROUP.agent,
|
|
47
46
|
GROUP.advanced,
|
|
48
|
-
GROUP.help,
|
|
49
47
|
]
|
|
50
48
|
|
|
51
49
|
function groupRank(heading: string): number {
|
|
@@ -117,7 +115,14 @@ export function rootHelpFooter(): string {
|
|
|
117
115
|
)}`,
|
|
118
116
|
` ${style.code('notifai init')} ${style.dim(`${glyph.arrow} set this project up, step by step`)}`,
|
|
119
117
|
'',
|
|
120
|
-
style.
|
|
118
|
+
style.heading('First call, for an agent'),
|
|
119
|
+
` ${style.code('notifai send --kind done --title "All 42 tests passed" --body "Finished in 3m 10s. Next: review the release candidate."')}`,
|
|
120
|
+
` ${style.code('notifai ask "Deploy migration 0007 to production?" --choice Deploy --choice Hold')}`,
|
|
121
|
+
` ${style.dim(`${glyph.arrow} ${style.code('ask')} needs question routing installed; ${style.code('notifai doctor --json')} says whether it is.`)}`,
|
|
122
|
+
'',
|
|
123
|
+
style.dim(
|
|
124
|
+
`Every command takes ${style.code('--help')}. Where a command has machine-readable output it takes ${style.code('--json')}.`,
|
|
125
|
+
),
|
|
121
126
|
style.dim(`Settings are explained by ${style.code('notifai config show')} and ${style.code('notifai config explain <key>')}.`),
|
|
122
127
|
]
|
|
123
128
|
return lines.join('\n')
|
package/src/ui/theme.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const unicode: boolean =
|
|
|
25
25
|
process.env['ConEmuTask'] === '{cmd::Cmder}'
|
|
26
26
|
|
|
27
27
|
/** True when 24-bit colour is available, which the banner gradient needs. */
|
|
28
|
-
|
|
28
|
+
function supportsTrueColor(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
29
|
if (!pc.isColorSupported) return false
|
|
30
30
|
const colorterm = env['COLORTERM'] ?? ''
|
|
31
31
|
if (/^(truecolor|24bit)$/i.test(colorterm)) return true
|
|
@@ -63,7 +63,7 @@ function mix(a: Rgb, b: Rgb, t: number): Rgb {
|
|
|
63
63
|
* Sample the brand ramp at `t` in [0, 1]. Multi-stop so the gradient keeps the
|
|
64
64
|
* icon's coral midtones instead of washing straight from pink to magenta.
|
|
65
65
|
*/
|
|
66
|
-
|
|
66
|
+
function rampAt(t: number, ramp: readonly string[] = BRAND_RAMP): string {
|
|
67
67
|
const clamped = Math.min(1, Math.max(0, t))
|
|
68
68
|
const span = ramp.length - 1
|
|
69
69
|
const scaled = clamped * span
|
|
@@ -83,20 +83,6 @@ const ANSI_GLOBAL = /\[[0-9;]*m/g
|
|
|
83
83
|
const ANSI_SPLIT = /(\[[0-9;]*m)/
|
|
84
84
|
const ANSI_ONLY = /^\[[0-9;]*m$/
|
|
85
85
|
|
|
86
|
-
/**
|
|
87
|
-
* Paint `text` across the brand ramp, one escape per character.
|
|
88
|
-
*
|
|
89
|
-
* Falls back to a single flat brand colour without truecolor, and to the bare
|
|
90
|
-
* string with colour off — a gradient is the most decorative thing this CLI
|
|
91
|
-
* does, so it must also be the first thing to degrade quietly.
|
|
92
|
-
*/
|
|
93
|
-
export function gradient(text: string, ramp: readonly string[] = BRAND_RAMP): string {
|
|
94
|
-
if (!pc.isColorSupported) return text
|
|
95
|
-
if (!supportsTrueColor()) return pc.magenta(text)
|
|
96
|
-
const chars = [...text]
|
|
97
|
-
const last = Math.max(1, chars.length - 1)
|
|
98
|
-
return chars.map((char, i) => (char === ' ' ? char : `${rampAt(i / last, ramp)}${char}`)).join('') + RESET
|
|
99
|
-
}
|
|
100
86
|
|
|
101
87
|
/**
|
|
102
88
|
* Paint each line of a block so the gradient runs left-to-right across the
|