@raidiant/notifai 0.1.7 → 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.
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ /** The two scopes offered by the skills installer. */
7
+ export type SkillScope = 'project' | 'global'
8
+
9
+ /** The installer-managed evidence needed by Notifai readiness. */
10
+ export interface NativeSkill {
11
+ name: string
12
+ scope: SkillScope
13
+ path: string
14
+ source: string | null
15
+ sourceType: string | null
16
+ sourceUrl: string | null
17
+ ref: string | null
18
+ }
19
+
20
+ export interface SkillsListResult {
21
+ skills: NativeSkill[]
22
+ error?: string
23
+ }
24
+
25
+ export interface SkillsAddOptions {
26
+ source: string
27
+ skill: string
28
+ scope?: SkillScope
29
+ cwd: string
30
+ env: NodeJS.ProcessEnv
31
+ }
32
+
33
+ export interface NativeSkills {
34
+ /** Launch the native interactive `npx skills add` flow. */
35
+ add(options: SkillsAddOptions): Promise<number>
36
+ /** Ask the native installer for its managed install inventory. */
37
+ list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
38
+ }
39
+
40
+ interface JsonSkill {
41
+ name?: unknown
42
+ path?: unknown
43
+ scope?: unknown
44
+ source?: unknown
45
+ sourceType?: unknown
46
+ sourceUrl?: unknown
47
+ }
48
+
49
+ interface LockEntry {
50
+ source?: unknown
51
+ sourceType?: unknown
52
+ sourceUrl?: unknown
53
+ ref?: unknown
54
+ }
55
+
56
+ interface LockFile {
57
+ skills?: Record<string, LockEntry>
58
+ }
59
+
60
+ function skillLockPath(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): string {
61
+ if (scope === 'project') return path.join(cwd, 'skills-lock.json')
62
+ const stateHome = env['XDG_STATE_HOME']
63
+ const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
64
+ return stateHome !== undefined && stateHome !== ''
65
+ ? path.join(stateHome, 'skills', '.skill-lock.json')
66
+ : path.join(home, '.agents', '.skill-lock.json')
67
+ }
68
+
69
+ function readLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): LockFile {
70
+ const file = skillLockPath(scope, cwd, env)
71
+ if (!existsSync(file)) return {}
72
+ try {
73
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as LockFile
74
+ return parsed && typeof parsed === 'object' ? parsed : {}
75
+ } catch {
76
+ return {}
77
+ }
78
+ }
79
+
80
+ function lockEntryFor(lock: LockFile, name: string): LockEntry | undefined {
81
+ const direct = lock.skills?.[name]
82
+ if (direct) return direct
83
+ const normalized = name.toLowerCase().replace(/[\s_]+/g, '-')
84
+ const key = Object.keys(lock.skills ?? {}).find(
85
+ (candidate) => candidate.toLowerCase().replace(/[\s_]+/g, '-') === normalized,
86
+ )
87
+ return key === undefined ? undefined : lock.skills?.[key]
88
+ }
89
+
90
+ function jsonArray(stdout: string): unknown[] | null {
91
+ const start = stdout.indexOf('[')
92
+ const end = stdout.lastIndexOf(']')
93
+ if (start < 0 || end < start) return null
94
+ try {
95
+ const parsed: unknown = JSON.parse(stdout.slice(start, end + 1))
96
+ return Array.isArray(parsed) ? parsed : null
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ function managedSkills(stdout: string, scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] | null {
103
+ const entries = jsonArray(stdout)
104
+ if (entries === null) return null
105
+ const lock = readLock(scope, cwd, env)
106
+ return entries.flatMap((entry): NativeSkill[] => {
107
+ if (entry === null || typeof entry !== 'object') return []
108
+ const value = entry as JsonSkill
109
+ if (
110
+ typeof value.name !== 'string' ||
111
+ typeof value.path !== 'string' ||
112
+ value.scope !== scope
113
+ ) {
114
+ return []
115
+ }
116
+ const lockEntry = lockEntryFor(lock, value.name)
117
+ return [
118
+ {
119
+ name: value.name,
120
+ scope,
121
+ path: value.path,
122
+ source: typeof value.source === 'string' ? value.source : null,
123
+ sourceType: typeof value.sourceType === 'string' ? value.sourceType : null,
124
+ sourceUrl: typeof value.sourceUrl === 'string' ? value.sourceUrl : null,
125
+ ref: typeof lockEntry?.ref === 'string' ? lockEntry.ref : null,
126
+ },
127
+ ]
128
+ })
129
+ }
130
+
131
+ function run(
132
+ args: string[],
133
+ options: { cwd: string; env: NodeJS.ProcessEnv; stdio: 'inherit' | 'capture' },
134
+ ): Promise<{ code: number; stdout: string }> {
135
+ return new Promise((resolve) => {
136
+ const child = spawn('npx', args, {
137
+ cwd: options.cwd,
138
+ env: options.env,
139
+ stdio: options.stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'ignore'],
140
+ })
141
+ if (options.stdio === 'capture') {
142
+ let stdout = ''
143
+ child.stdout?.on('data', (chunk: Buffer | string) => {
144
+ stdout += chunk.toString()
145
+ })
146
+ child.on('error', () => resolve({ code: 1, stdout }))
147
+ child.on('exit', (code) => resolve({ code: code ?? 1, stdout }))
148
+ return
149
+ }
150
+ child.on('error', () => resolve({ code: 1, stdout: '' }))
151
+ child.on('exit', (code) => resolve({ code: code ?? 1, stdout: '' }))
152
+ })
153
+ }
154
+
155
+ /** The only process/filesystem adapter Notifai needs for the external installer. */
156
+ export const nativeSkills: NativeSkills = {
157
+ async add(options) {
158
+ const args = ['-y', 'skills', 'add', options.source, '--skill', options.skill]
159
+ if (options.scope === 'global') args.push('--global')
160
+ // An explicit scope is the unattended contract. Native `--yes` keeps all
161
+ // remaining installer prompts non-interactive after the scope is chosen.
162
+ if (options.scope !== undefined) args.push('--yes')
163
+ return (await run(args, { cwd: options.cwd, env: options.env, stdio: 'inherit' })).code
164
+ },
165
+
166
+ async list(scope, cwd, env) {
167
+ const result = await run(
168
+ ['-y', 'skills', 'list', '--json', ...(scope === 'global' ? ['--global'] : [])],
169
+ { cwd, env, stdio: 'capture' },
170
+ )
171
+ if (result.code !== 0) return { skills: [], error: `npx skills list exited with code ${result.code}` }
172
+ const skills = managedSkills(result.stdout, scope, cwd, env)
173
+ return skills === null
174
+ ? { skills: [], error: 'npx skills list returned invalid JSON' }
175
+ : { skills }
176
+ },
177
+ }
@@ -30,7 +30,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
30
30
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
31
31
 
32
32
  /** Bump when an installed generated file must be rewritten to remain functional. */
33
- const OPENCODE_ADAPTER_VERSION = 2
33
+ const OPENCODE_ADAPTER_VERSION = 4
34
34
 
35
35
  export function opencodePluginDir(
36
36
  global: boolean,
@@ -88,7 +88,7 @@ function runHook(event, envelope) {
88
88
  return new Promise((resolve) => {
89
89
  let child
90
90
  try {
91
- child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai"], {
91
+ child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
92
92
  env: process.env,
93
93
  shell: false,
94
94
  stdio: ["pipe", "pipe", "pipe"],
@@ -128,20 +128,51 @@ function runHook(event, envelope) {
128
128
  })
129
129
  }
130
130
 
131
+ function additionalContext(stdout) {
132
+ if (typeof stdout !== "string" || stdout.trim() === "") return null
133
+ try {
134
+ const parsed = JSON.parse(stdout)
135
+ const context = parsed?.hookSpecificOutput?.additionalContext
136
+ return typeof context === "string" && context.length > 0 ? context : null
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
131
142
  export const NotifAIPlugin = async ({ directory }) => {
132
143
  const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
133
144
 
134
145
  return {
146
+ /** Exact active-harness identity for shell commands such as \`notifai ask\`. */
147
+ "shell.env": async (input, output) => {
148
+ output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
149
+ const sessionID = input?.sessionID
150
+ if (typeof sessionID === "string" && sessionID.length > 0) {
151
+ output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
152
+ }
153
+ },
154
+
135
155
  /**
136
156
  * Presence. The user typing is the signal every harness shares, and it is
137
157
  * also what publishes the project -> session pointer \`notifai ask\` reads.
138
158
  */
139
- "chat.message": async (input) => {
140
- await runHook("user-prompt-submit", {
159
+ "chat.message": async (input, output) => {
160
+ const stdout = await runHook("user-prompt-submit", {
141
161
  session_id: input?.sessionID ?? input?.info?.sessionID,
142
162
  cwd,
143
163
  hook_event_name: "UserPromptSubmit",
144
164
  })
165
+ const context = additionalContext(stdout)
166
+ if (context !== null) {
167
+ output.parts.push({
168
+ id: "prt_notifai_" + Date.now(),
169
+ sessionID: input.sessionID,
170
+ messageID: output.message.id,
171
+ type: "text",
172
+ text: context,
173
+ synthetic: true,
174
+ })
175
+ }
145
176
  },
146
177
 
147
178
  /** Session lifecycle events are delivered through OpenCode's event bus. */
@@ -185,17 +216,19 @@ export function isOurOpencodePlugin(contents: string): boolean {
185
216
  */
186
217
  export function opencodePluginTarget(
187
218
  contents: string,
188
- ): { exec: string; script: string; current: boolean } | null {
219
+ ): { exec: string; script: string; current: boolean; timeoutSeconds?: number } | null {
189
220
  if (!isOurOpencodePlugin(contents)) return null
190
221
  const exec = /^const EXEC = (".*")$/m.exec(contents)?.[1]
191
222
  const script = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
192
223
  const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
224
+ const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
193
225
  if (exec === undefined || script === undefined) return null
194
226
  try {
195
227
  return {
196
228
  exec: JSON.parse(exec) as string,
197
229
  script: JSON.parse(script) as string,
198
230
  current: version === OPENCODE_ADAPTER_VERSION,
231
+ ...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
199
232
  }
200
233
  } catch {
201
234
  return null
package/src/send.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  CLI_SOUNDS,
3
+ COLLAPSE_KEY_MAX_BYTES,
3
4
  INTERRUPTION_LEVELS,
4
5
  NOTIFICATION_SCHEMA_VERSION,
6
+ QUESTION_TEXT_MAX_LENGTH,
5
7
  type IosOptionsT,
6
8
  type LifecycleT,
7
9
  type MacosOptionsT,
@@ -9,6 +11,7 @@ import {
9
11
  NOTIFICATION_KINDS,
10
12
  PLATFORMS,
11
13
  type Platform,
14
+ type QuestionT,
12
15
  type SubmissionReceipt,
13
16
  } from '@raidiant/notifai-protocol'
14
17
  import type { CliConfig } from './config.js'
@@ -31,11 +34,8 @@ export interface SendFlags {
31
34
  ttl?: number
32
35
  collapseKey?: string
33
36
  sound?: string
34
- badge?: number
35
37
  threadId?: string
36
38
  level?: string
37
- relevance?: number
38
- targetContentId?: string
39
39
  data?: string[]
40
40
  image?: string
41
41
  /** Enable the inline reply action for this Notification Request. */
@@ -43,10 +43,17 @@ export interface SendFlags {
43
43
  /** How long the server accepts a reply after submission. */
44
44
  replyWindow?: number
45
45
  /**
46
- * Answer labels; turns the reply into a closed question. One value splits on
47
- * commas; repeating the flag passes each label through verbatim.
46
+ * Answer labels; turns the reply into a closed question. One flag occurrence
47
+ * per label, always — a label is a label, whatever characters it contains.
48
48
  */
49
- replyChoice?: string | string[]
49
+ replyChoice?: string[]
50
+ /** The user may select several of the offered answers. */
51
+ replyMulti?: boolean
52
+ /**
53
+ * A full question set, overriding the single question the flags describe.
54
+ * Not a user-facing flag: `ask --form` and the hooks build it.
55
+ */
56
+ questions?: QuestionT[]
50
57
  /** Platform whose optional notification fields these flags configure. */
51
58
  platform?: string
52
59
  /**
@@ -72,14 +79,21 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
72
79
  const targets: NotificationDraftT['targets'] =
73
80
  deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
74
81
 
75
- const sound = flags.sound ?? config.sound.value
82
+ const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
83
+ const profile = KIND_PROFILES[effectiveKind as keyof typeof KIND_PROFILES]
84
+ const configuredSound = config.sound.source === 'default' ? undefined : config.sound.value
85
+ const sound = flags.sound ?? configuredSound ?? profile?.sound
76
86
  if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound as CliSound)) {
77
87
  return {
78
88
  ok: false,
79
89
  error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
80
90
  }
81
91
  }
82
- const level = flags.level ?? config.interruption_level.value
92
+ const configuredLevel =
93
+ config.interruption_level.source === 'default'
94
+ ? undefined
95
+ : config.interruption_level.value
96
+ const level = flags.level ?? configuredLevel ?? profile?.level
83
97
  if (
84
98
  level !== null &&
85
99
  level !== undefined &&
@@ -93,13 +107,10 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
93
107
  else if (sound !== null && sound !== undefined) {
94
108
  options.sound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
95
109
  }
96
- if (flags.badge !== undefined) options.badge = flags.badge
97
110
  if (flags.threadId !== undefined) options.thread_id = flags.threadId
98
111
  if (level !== null && level !== undefined) {
99
112
  options.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
100
113
  }
101
- if (flags.relevance !== undefined) options.relevance_score = flags.relevance
102
- if (flags.targetContentId !== undefined) options.target_content_id = flags.targetContentId
103
114
  if (flags.data?.length) {
104
115
  const data: Record<string, string> = {}
105
116
  for (const pair of flags.data) {
@@ -122,6 +133,16 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
122
133
 
123
134
  const ttl = flags.ttl ?? config.ttl_seconds.value
124
135
  const collapse = flags.collapseKey ?? config.collapse_key.value
136
+ if (
137
+ collapse !== null &&
138
+ collapse !== undefined &&
139
+ Buffer.byteLength(collapse, 'utf8') > COLLAPSE_KEY_MAX_BYTES
140
+ ) {
141
+ return {
142
+ ok: false,
143
+ error: `--collapse-key must be at most ${COLLAPSE_KEY_MAX_BYTES} UTF-8 bytes.`,
144
+ }
145
+ }
125
146
 
126
147
  const project = flags.project ?? config.project.value
127
148
  // Harnesses can export NOTIFAI_SESSION once instead of passing the flag
@@ -137,6 +158,24 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
137
158
  error: CHOICE_USAGE,
138
159
  }
139
160
  }
161
+ if (flags.replyMulti && choices === null && flags.questions === undefined) {
162
+ return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
163
+ }
164
+ // The banner body doubles as the question text on a single-question send, so
165
+ // it inherits the question's readable-in-full bound; context beyond that
166
+ // belongs in --detail, which never rides the banner.
167
+ if (
168
+ flags.reply &&
169
+ flags.questions === undefined &&
170
+ flags.body.length > QUESTION_TEXT_MAX_LENGTH
171
+ ) {
172
+ return {
173
+ ok: false,
174
+ error:
175
+ `A question must be readable where it is answered: keep it within ${QUESTION_TEXT_MAX_LENGTH} ` +
176
+ 'characters and put the longer context in --detail.',
177
+ }
178
+ }
140
179
 
141
180
  const draft: NotificationDraftT = {
142
181
  schema_version: NOTIFICATION_SCHEMA_VERSION,
@@ -161,20 +200,35 @@ export function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild {
161
200
  ...(flags.reply
162
201
  ? {
163
202
  reply: {
164
- expires_in_seconds: flags.replyWindow ?? 86400,
165
- ...(choices !== null ? { kind: 'choice' as const, choices } : {}),
203
+ expires_in_seconds: flags.replyWindow ?? 3600,
204
+ questions: flags.questions ?? [
205
+ {
206
+ id: 'q1',
207
+ text: flags.body,
208
+ ...(choices !== null ? { choices } : {}),
209
+ ...(flags.replyMulti ? { multi: true } : {}),
210
+ },
211
+ ],
166
212
  },
167
213
  }
168
214
  : {}),
169
215
  ...(Object.keys(options).length > 0
170
- ? platform === 'ios'
171
- ? { platform: { ios: options } }
172
- : { platform: { macos: options } }
216
+ ? flags.platform === undefined
217
+ ? { platform: { ios: options, macos: options } }
218
+ : platform === 'ios'
219
+ ? { platform: { ios: options } }
220
+ : { platform: { macos: options } }
173
221
  : {}),
174
222
  }
175
223
  return { ok: true, draft, platform }
176
224
  }
177
225
 
226
+ const KIND_PROFILES = {
227
+ update: { sound: 'none', level: 'passive' },
228
+ done: { sound: 'done', level: 'passive' },
229
+ question: { sound: 'attention', level: 'active' },
230
+ } as const
231
+
178
232
  function resolvePlatform(value: string | undefined): Platform | null {
179
233
  if (value === undefined) return 'ios'
180
234
  return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
@@ -209,23 +263,19 @@ export function receiptExitCode(receipt: SubmissionReceipt): number {
209
263
  }
210
264
 
211
265
  /**
212
- * `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
266
+ * One flag occurrence, one answer: `--choice Staging --choice Production` ->
267
+ * `[{id:'staging',label:'Staging'}, ...]`.
213
268
  *
214
- * A single occurrence splits on commas, which keeps the common one-liner
215
- * short. Repeating the flag is the escape hatch, and the only way to write a
216
- * label that itself contains a comma: passing `"Yes, ship it,No"` as one value
217
- * silently yields three choices, and nothing downstream can detect that the
218
- * agent meant two.
269
+ * Commas are ordinary characters. The old single-value comma split produced
270
+ * buttons the agent never intended ("Nothing, I'll review" became two answers)
271
+ * and nothing downstream could detect it, so the delimiter form is gone
272
+ * rather than guarded: a label is a label.
219
273
  */
220
274
  export function parseChoices(
221
- value: string | string[] | undefined,
275
+ value: string[] | undefined,
222
276
  ): { id: string; label: string }[] | null | 'invalid' {
223
- if (value === undefined) return null
224
- const raw = Array.isArray(value) ? value : [value]
225
- if (raw.length === 0) return null
226
- const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
227
- .map((part) => part.trim())
228
- .filter((part) => part.length > 0)
277
+ if (value === undefined || value.length === 0) return null
278
+ const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
229
279
  if (labels.length < 2 || labels.length > 6) return 'invalid'
230
280
  const choices = labels.map((label) => ({ id: slugify(label), label }))
231
281
  if (choices.some((choice) => choice.id === '')) return 'invalid'
@@ -235,38 +285,10 @@ export function parseChoices(
235
285
 
236
286
  /** Message shared by every surface that accepts choice labels. */
237
287
  export const CHOICE_USAGE =
238
- 'Choices must be 2-6 non-empty labels, unique once slugified. One value splits on ' +
239
- 'commas ("Staging,Production"); repeat the flag when a label contains a comma.'
240
-
241
- /**
242
- * Catches the split that looked like it worked.
243
- *
244
- * `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
245
- * buttons rather than three, because the third label contained a comma. Nothing
246
- * downstream can detect it — the labels are all well-formed — and the CLI
247
- * cheerfully echoed the wrong list back, so the user saw buttons the agent
248
- * never intended.
249
- *
250
- * The tell is a comma followed by a space. A delimiter list is written
251
- * "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
252
- * will occasionally be wrong, which is exactly why this warns and names the
253
- * escape hatch rather than rejecting: the split may well have been intended.
254
- */
255
- export function ambiguousChoiceSplit(value: string | string[] | undefined): string | null {
256
- if (value === undefined || Array.isArray(value)) return null
257
- if (!/,\s/.test(value)) return null
258
- const parts = value
259
- .split(',')
260
- .map((part) => part.trim())
261
- .filter((part) => part.length > 0)
262
- return (
263
- `Heads up: "${value}" was split on commas into ${parts.length} answers ` +
264
- `(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
265
- 'pass --choice once per answer instead.'
266
- )
267
- }
288
+ 'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
289
+ 'e.g. --choice "Yes, ship it" --choice "Not yet".'
268
290
 
269
- function slugify(label: string): string {
291
+ export function slugify(label: string): string {
270
292
  return label
271
293
  .toLowerCase()
272
294
  .replace(/[^a-z0-9]+/g, '-')
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const skill = readFileSync(new URL('../../../skills/notifai/SKILL.md', import.meta.url), 'utf8')
5
+ const harnessReference = readFileSync(
6
+ new URL('../../../skills/notifai/references/harness-setup.md', import.meta.url),
7
+ 'utf8',
8
+ )
9
+
10
+ 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('teaches kind profiles and a correctly classified completion', () => {
17
+ expect(skill).toContain('--kind done')
18
+ expect(skill).toContain('| `update` (default) | `none` | `passive` |')
19
+ expect(skill).toContain('| `done` | `done` | `passive` |')
20
+ expect(skill).toContain('| question (`--reply`) | `attention` | `active` |')
21
+ })
22
+
23
+ it('documents reply lifecycle, routing, session minting, and doctor exits', () => {
24
+ for (const required of [
25
+ '--reply-choice',
26
+ 'notifai close <request_id>',
27
+ 'notifai devices',
28
+ '--device',
29
+ '--all',
30
+ "require('node:crypto').randomUUID()",
31
+ 'exit status is nonzero',
32
+ ]) {
33
+ expect(skill).toContain(required)
34
+ }
35
+ })
36
+
37
+ it('moves per-harness mechanics into progressive disclosure', () => {
38
+ expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
39
+ for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
40
+ expect(harnessReference).toContain(`**${harness}:**`)
41
+ }
42
+ })
43
+ })