@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
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
- // Kind is semantic status metadata for Companion presentation. It never
177
- // chooses native-banner attention; only an explicit flag or saved preference
178
- // may add sound or interruption fields to the platform payload.
179
- const sound = flags.sound ?? config.sound.value
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: CHOICE_USAGE,
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 ?? 3600,
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
- const skill = readFileSync(new URL('../../../skills/notifai/SKILL.md', import.meta.url), 'utf8')
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('leads with the notification decision before message composition', () => {
12
- expect(skill.indexOf('## When to notify')).toBeGreaterThan(0)
13
- expect(skill.indexOf('## When to notify')).toBeLessThan(skill.indexOf('## Compose and send'))
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('documents reply lifecycle, routing, inferred source context, and doctor exits', () => {
70
- for (const required of [
71
- '--reply-choice',
72
- 'notifai close <request_id>',
73
- 'notifai devices',
74
- '--device',
75
- '--all',
76
- 'Project identity from the invocation directory',
77
- '--session-id',
78
- 'NOTIFAI_SESSION_ID',
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
- expect(skill).not.toContain("require('node:crypto').randomUUID()")
85
- expect(skill).not.toMatch(/`NOTIFAI_SESSION`/)
86
- })
87
-
88
- it('turns a registered question into a same-turn work-resumption commitment', () => {
89
- const askGuidance = skill.slice(
90
- skill.indexOf('### Register a turn-ending question'),
91
- skill.indexOf('## Verify delivery and readiness'),
92
- )
93
-
94
- for (const required of [
95
- 'same turn',
96
- 'each offered answer',
97
- 'unexpected typed answer',
98
- 'work you resume',
99
- 'never as approval',
100
- 'permission prompt',
101
- 'interactive picker',
102
- 'question identity',
103
- 'question text',
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
- expect(askGuidance).toMatch(/without asking the user to\s+confirm again/)
109
- })
110
-
111
- it('requires concrete Agent Acknowledgements before resumed work or turn end', () => {
112
- const askGuidance = skill.slice(
113
- skill.indexOf('### Register a turn-ending question'),
114
- skill.indexOf('## Verify delivery and readiness'),
115
- )
116
- const requirement = askGuidance.indexOf('agent_acknowledgement_required')
117
- const command = askGuidance.indexOf('notifai acknowledge <request_id> --text')
118
- const resume = askGuidance.indexOf('After sending every required Agent Acknowledgement')
119
-
120
- expect(requirement).toBeGreaterThanOrEqual(0)
121
- expect(command).toBeGreaterThan(requirement)
122
- expect(resume).toBeGreaterThan(command)
123
- for (const required of [
124
- 'immediately',
125
- 'before doing any',
126
- 'of the resumed work',
127
- 'before ending the turn',
128
- 'must be non-empty',
129
- 'concrete work you will do',
130
- 'because of their reply',
131
- 'bare “acknowledged”',
132
- 'one for every request id',
133
- 'disabled',
134
- 'requires no',
135
- 'command',
136
- 'route-neutral and truthful',
137
- ]) {
138
- expect(askGuidance).toContain(required)
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('teaches agents to report config JSON fields instead of paraphrasing defaults', () => {
143
- expect(skill).toContain('notifai config show --json')
144
- expect(skill).toContain('{ value, source, summary }')
145
- expect(skill).toContain('Do not paraphrase')
146
- expect(skill).not.toContain('config.local.toml')
147
- expect(skill).toContain('outside the repository')
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('mentions npx only as a pinned alternative to a real install', () => {
151
- expect(skill).toContain('npm install -g @raidiant/notifai')
152
- expect(skill.indexOf('npm install -g @raidiant/notifai')).toBeLessThan(
153
- skill.indexOf('npx --yes @raidiant/notifai@'),
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('tells the agent where to look when something did not happen', () => {
159
- // The log's whole value is diagnostic, so the skill has to name the case
160
- // that produces it — a question that was registered and never travelled.
161
- expect(skill).toContain('notifai logs')
162
- expect(skill).toContain('--request <id>')
163
- expect(skill).toContain('hook.gate')
164
- expect(skill).toContain('notifications-off')
165
- // And that it is the user's data, on the user's machine.
166
- expect(skill).toContain('stays on this machine')
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.dim(`Every command takes ${style.code('--help')}. Machine-readable output: ${style.code('--json')}.`),
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
- export function supportsTrueColor(env: NodeJS.ProcessEnv = process.env): boolean {
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
- export function rampAt(t: number, ramp: readonly string[] = BRAND_RAMP): string {
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