@raidiant/notifai 7.0.2 → 8.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 (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands-devices.d.ts +2 -1
  3. package/dist/commands-devices.js +20 -8
  4. package/dist/commands-devices.js.map +1 -1
  5. package/dist/commands-doctor.js +14 -12
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.d.ts +23 -0
  8. package/dist/commands-guidance.js +128 -0
  9. package/dist/commands-guidance.js.map +1 -0
  10. package/dist/commands-hooks.d.ts +7 -1
  11. package/dist/commands-hooks.js +3 -6
  12. package/dist/commands-hooks.js.map +1 -1
  13. package/dist/commands-init.js +25 -22
  14. package/dist/commands-init.js.map +1 -1
  15. package/dist/commands-send-support.js +5 -3
  16. package/dist/commands-send-support.js.map +1 -1
  17. package/dist/commands-send.d.ts +0 -1
  18. package/dist/commands-send.js +45 -28
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands.d.ts +1 -0
  21. package/dist/commands.js +1 -0
  22. package/dist/commands.js.map +1 -1
  23. package/dist/config-schema.js +6 -15
  24. package/dist/config-schema.js.map +1 -1
  25. package/dist/config.d.ts +1 -3
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/guidance-content.d.ts +30 -0
  29. package/dist/guidance-content.js +175 -0
  30. package/dist/guidance-content.js.map +1 -0
  31. package/dist/guidance.d.ts +48 -0
  32. package/dist/guidance.js +118 -0
  33. package/dist/guidance.js.map +1 -0
  34. package/dist/hooks.js +7 -7
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/interactive.d.ts +1 -1
  37. package/dist/interactive.js +13 -8
  38. package/dist/interactive.js.map +1 -1
  39. package/dist/main.js +3 -474
  40. package/dist/main.js.map +1 -1
  41. package/dist/program.d.ts +49 -0
  42. package/dist/program.js +571 -0
  43. package/dist/program.js.map +1 -0
  44. package/dist/send.d.ts +4 -9
  45. package/dist/send.js +48 -28
  46. package/dist/send.js.map +1 -1
  47. package/dist/session-labels.d.ts +9 -3
  48. package/dist/session-labels.js +38 -7
  49. package/dist/session-labels.js.map +1 -1
  50. package/package.json +5 -3
  51. package/src/commands-devices.ts +28 -9
  52. package/src/commands-doctor.ts +17 -12
  53. package/src/commands-guidance.ts +170 -0
  54. package/src/commands-hooks.ts +10 -8
  55. package/src/commands-init.ts +31 -22
  56. package/src/commands-send-support.ts +5 -3
  57. package/src/commands-send.ts +50 -28
  58. package/src/commands.test.ts +374 -63
  59. package/src/commands.ts +5 -0
  60. package/src/config-schema.ts +6 -17
  61. package/src/config.test.ts +56 -1
  62. package/src/config.ts +0 -5
  63. package/src/guidance-content.ts +190 -0
  64. package/src/guidance.test.ts +268 -0
  65. package/src/guidance.ts +151 -0
  66. package/src/hooks.test.ts +37 -5
  67. package/src/hooks.ts +7 -7
  68. package/src/interactive.ts +13 -8
  69. package/src/main.ts +4 -600
  70. package/src/program.test.ts +202 -0
  71. package/src/program.ts +723 -0
  72. package/src/send.ts +53 -32
  73. package/src/session-labels.test.ts +120 -3
  74. package/src/session-labels.ts +38 -7
  75. package/src/skill.test.ts +37 -12
package/src/send.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  NOTIFICATION_SCHEMA_VERSION,
7
7
  QUESTION_TEXT_MAX_LENGTH,
8
8
  bannerExcerpt,
9
+ type AndroidOptionsT,
9
10
  type IosOptionsT,
10
11
  type LifecycleT,
11
12
  type MacosOptionsT,
@@ -44,7 +45,8 @@ export interface SendFlags {
44
45
  collapseKey?: string
45
46
  sound?: string
46
47
  threadId?: string
47
- level?: string
48
+ /** Apple interruption level; null is reserved for internal callers that must suppress config. */
49
+ level?: string | null
48
50
  data?: string[]
49
51
  /** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
50
52
  image?: string[]
@@ -63,9 +65,9 @@ export interface SendFlags {
63
65
  * Answer labels; turns the reply into a closed question. One flag occurrence
64
66
  * per label, always — a label is a label, whatever characters it contains.
65
67
  */
66
- replyChoice?: string[]
68
+ choice?: string[]
67
69
  /** The user may select several of the offered answers. */
68
- replyMulti?: boolean
70
+ multi?: boolean
69
71
  /**
70
72
  * A full question set, overriding the single question the flags describe.
71
73
  * Not a user-facing flag: `ask --form` and the hooks build it.
@@ -200,7 +202,8 @@ export function buildDraft(
200
202
  error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
201
203
  }
202
204
  }
203
- const level = flags.level ?? config.interruption_level.value
205
+ const level =
206
+ flags.level !== undefined ? flags.level : config.interruption_level.value
204
207
  if (
205
208
  level !== null &&
206
209
  level !== undefined &&
@@ -208,15 +211,30 @@ export function buildDraft(
208
211
  ) {
209
212
  return { ok: false, error: `Unknown interruption level "${level}".` }
210
213
  }
214
+ if (platform === 'android' && level !== null && level !== undefined) {
215
+ return {
216
+ ok: false,
217
+ error:
218
+ 'Android does not support caller-selected interruption levels. Remove --level or unset the configured interruption_level; kind, notification channels, and device settings own attention on Android.',
219
+ }
220
+ }
211
221
 
212
- const options: IosOptionsT & MacosOptionsT = {}
213
- if (sound === 'none') options.sound = null
214
- else if (sound !== null && sound !== undefined) {
215
- options.sound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
222
+ const appleOptions: IosOptionsT & MacosOptionsT = {}
223
+ const androidOptions: AndroidOptionsT = {}
224
+ if (sound === 'none') {
225
+ appleOptions.sound = null
226
+ androidOptions.sound = null
227
+ } else if (sound !== null && sound !== undefined) {
228
+ const semanticSound = sound as Exclude<(typeof CLI_SOUNDS)[number], 'none'>
229
+ appleOptions.sound = semanticSound
230
+ androidOptions.sound = semanticSound
231
+ }
232
+ if (flags.threadId !== undefined) {
233
+ appleOptions.thread_id = flags.threadId
234
+ androidOptions.thread_id = flags.threadId
216
235
  }
217
- if (flags.threadId !== undefined) options.thread_id = flags.threadId
218
236
  if (level !== null && level !== undefined) {
219
- options.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
237
+ appleOptions.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
220
238
  }
221
239
  if (flags.data?.length) {
222
240
  const data: Record<string, string> = {}
@@ -225,7 +243,8 @@ export function buildDraft(
225
243
  if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
226
244
  data[pair.slice(0, eq)] = pair.slice(eq + 1)
227
245
  }
228
- options.custom_data = data
246
+ appleOptions.custom_data = data
247
+ androidOptions.custom_data = data
229
248
  }
230
249
 
231
250
  const ttl = flags.ttl ?? config.ttl_seconds.value
@@ -245,15 +264,15 @@ export function buildDraft(
245
264
 
246
265
  // Ids are derived from labels so an agent writing a one-liner does not have
247
266
  // to invent them, but they stay the stable thing it branches on afterwards.
248
- const choices = parseChoices(flags.replyChoice)
267
+ const choices = parseChoices(flags.choice)
249
268
  if (choices === 'invalid') {
250
269
  return {
251
270
  ok: false,
252
- error: REPLY_CHOICE_USAGE,
271
+ error: CHOICE_USAGE,
253
272
  }
254
273
  }
255
- if (flags.replyMulti && choices === null && flags.questions === undefined) {
256
- return { ok: false, error: '--reply-multi needs answers to select between; add --reply-choice.' }
274
+ if (flags.multi && choices === null && flags.questions === undefined) {
275
+ return { ok: false, error: '--multi needs answers to select between; add --choice.' }
257
276
  }
258
277
  // A single reply question is the first readable block of the canonical body.
259
278
  // Context may follow after a blank line without changing what the user answers.
@@ -271,6 +290,23 @@ export function buildDraft(
271
290
  }
272
291
  }
273
292
 
293
+ const hasAppleOptions = Object.keys(appleOptions).length > 0
294
+ const hasAndroidOptions = Object.keys(androidOptions).length > 0
295
+ let platformOptions: NonNullable<NotificationDraftT['platform']> | undefined
296
+ if (flags.platform === undefined) {
297
+ if (hasAppleOptions || hasAndroidOptions) {
298
+ platformOptions = {
299
+ ...(hasAppleOptions ? { ios: appleOptions, macos: appleOptions } : {}),
300
+ ...(hasAndroidOptions ? { android: androidOptions } : {}),
301
+ }
302
+ }
303
+ } else if (platform === 'android') {
304
+ if (hasAndroidOptions) platformOptions = { android: androidOptions }
305
+ } else if (hasAppleOptions) {
306
+ platformOptions =
307
+ platform === 'ios' ? { ios: appleOptions } : { macos: appleOptions }
308
+ }
309
+
274
310
  const draft: NotificationDraftT = {
275
311
  schema_version: NOTIFICATION_SCHEMA_VERSION,
276
312
  ...(flags.event !== undefined ? { event: flags.event } : {}),
@@ -297,19 +333,13 @@ export function buildDraft(
297
333
  id: 'q1',
298
334
  text: derivedQuestion,
299
335
  ...(choices !== null ? { choices } : {}),
300
- ...(flags.replyMulti ? { multi: true } : {}),
336
+ ...(flags.multi ? { multi: true } : {}),
301
337
  },
302
338
  ],
303
339
  },
304
340
  }
305
341
  : {}),
306
- ...(Object.keys(options).length > 0
307
- ? flags.platform === undefined
308
- ? { platform: { ios: options, macos: options } }
309
- : platform === 'ios'
310
- ? { platform: { ios: options } }
311
- : { platform: { macos: options } }
312
- : {}),
342
+ ...(platformOptions !== undefined ? { platform: platformOptions } : {}),
313
343
  }
314
344
  return { ok: true, draft, platform }
315
345
  }
@@ -369,19 +399,10 @@ export function parseChoices(
369
399
  }
370
400
 
371
401
  /** Message shared by every surface that accepts choice labels. */
372
- /** `ask` spells this flag `--choice`. */
373
402
  export const CHOICE_USAGE =
374
403
  'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
375
404
  'e.g. --choice "Yes, ship it" --choice "Not yet".'
376
405
 
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
-
385
406
  export function slugify(label: string): string {
386
407
  return label
387
408
  .toLowerCase()
@@ -46,6 +46,38 @@ describe('semantic session labels', () => {
46
46
  ).toEqual({ ok: true, label: 'Semantic session names', source: 'explicit' })
47
47
  })
48
48
 
49
+ it('prefers the environment-supplied title over an explicit label, making the flag safe everywhere', () => {
50
+ // An agent must never need to know which environments name their own
51
+ // sessions: where one does, the explicit label is simply not used.
52
+ const { env, now } = fixture()
53
+ expect(
54
+ resolveSessionLabel({
55
+ env,
56
+ now,
57
+ sessionId: 'managed-session',
58
+ harness: 'opencode',
59
+ harnessLabel: 'NotifAI question lifecycle',
60
+ explicitLabel: 'Agent-chosen name',
61
+ }),
62
+ ).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
63
+ })
64
+
65
+ it('upgrades a frozen fallback to the environment title even when an explicit label rides along', () => {
66
+ const { env, now } = fixture()
67
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'late-title' })
68
+ expect(frozen).toMatchObject({ ok: true, source: 'fallback' })
69
+ expect(
70
+ resolveSessionLabel({
71
+ env,
72
+ now: now + 1,
73
+ sessionId: 'late-title',
74
+ harness: 'opencode',
75
+ harnessLabel: 'Worktree semantic title',
76
+ explicitLabel: 'Agent-chosen name',
77
+ }),
78
+ ).toEqual({ ok: true, label: 'Worktree semantic title', source: 'harness' })
79
+ })
80
+
49
81
  it('uses a trusted harness title when no explicit task label exists', () => {
50
82
  const { env, now } = fixture()
51
83
  expect(
@@ -59,7 +91,7 @@ describe('semantic session labels', () => {
59
91
  ).toEqual({ ok: true, label: 'NotifAI question lifecycle', source: 'harness' })
60
92
  })
61
93
 
62
- it('does not replace a frozen fallback when a managed semantic title appears later', () => {
94
+ it('replaces a frozen generated fallback when a managed semantic title appears later', () => {
63
95
  const { env, now } = fixture()
64
96
  const frozen = resolveSessionLabel({
65
97
  env,
@@ -77,6 +109,82 @@ describe('semantic session labels', () => {
77
109
  harness: 'claude-code',
78
110
  harnessLabel: 'Worker - semantic session implementation',
79
111
  }),
112
+ ).toEqual({
113
+ ok: true,
114
+ label: 'Worker - semantic session implementation',
115
+ source: 'harness',
116
+ })
117
+ })
118
+
119
+ it('replaces a frozen generated fallback with a later explicit task name, once', () => {
120
+ const { env, now } = fixture()
121
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'late-named', harness: 'codex' })
122
+ expect(frozen.ok && frozen.source).toBe('fallback')
123
+
124
+ expect(
125
+ resolveSessionLabel({
126
+ env,
127
+ now: now + 1_000,
128
+ sessionId: 'late-named',
129
+ harness: 'codex',
130
+ explicitLabel: 'Fix checkout retries',
131
+ }),
132
+ ).toEqual({ ok: true, label: 'Fix checkout retries', source: 'explicit' })
133
+ expect(
134
+ resolveSessionLabel({
135
+ env,
136
+ now: now + 2_000,
137
+ sessionId: 'late-named',
138
+ harness: 'codex',
139
+ explicitLabel: 'A different later name',
140
+ }),
141
+ ).toEqual({ ok: true, label: 'Fix checkout retries', source: 'explicit' })
142
+ })
143
+
144
+ it('disambiguates an upgraded name against every other stored session', () => {
145
+ const { env, now } = fixture()
146
+ expect(
147
+ resolveSessionLabel({
148
+ env,
149
+ now,
150
+ sessionId: 'other-session',
151
+ harness: 'codex',
152
+ explicitLabel: 'Release preparation',
153
+ }),
154
+ ).toEqual({ ok: true, label: 'Release preparation', source: 'explicit' })
155
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'upgraded', harness: 'codex' })
156
+ expect(frozen.ok && frozen.source).toBe('fallback')
157
+
158
+ expect(
159
+ resolveSessionLabel({
160
+ env,
161
+ now: now + 1_000,
162
+ sessionId: 'upgraded',
163
+ harness: 'codex',
164
+ explicitLabel: 'Release preparation',
165
+ }),
166
+ ).toEqual({ ok: true, label: 'Release preparation · Codex', source: 'explicit' })
167
+ })
168
+
169
+ it('rejects an invalid explicit name instead of silently keeping the fallback', () => {
170
+ const { env, now } = fixture()
171
+ const frozen = resolveSessionLabel({ env, now, sessionId: 'still-fallback', harness: 'codex' })
172
+ expect(frozen.ok && frozen.source).toBe('fallback')
173
+
174
+ expect(
175
+ resolveSessionLabel({
176
+ env,
177
+ now: now + 1_000,
178
+ sessionId: 'still-fallback',
179
+ harness: 'codex',
180
+ explicitLabel: 'x'.repeat(65),
181
+ }),
182
+ ).toEqual({
183
+ ok: false,
184
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) must be at most 64 characters.',
185
+ })
186
+ expect(
187
+ resolveSessionLabel({ env, now: now + 2_000, sessionId: 'still-fallback', harness: 'codex' }),
80
188
  ).toEqual(frozen)
81
189
  })
82
190
 
@@ -142,7 +250,7 @@ describe('semantic session labels', () => {
142
250
  expect(stored).not.toContain('opaque-thread-1234567890')
143
251
  })
144
252
 
145
- it('migrates a frozen date fallback without reopening semantic precedence', () => {
253
+ it('migrates a frozen date fallback when no semantic candidate exists', () => {
146
254
  const { env, now } = fixture()
147
255
  const file = path.join(stateDir(env), 'session-labels.json')
148
256
  expect(
@@ -164,10 +272,19 @@ describe('semantic session labels', () => {
164
272
  now: now + 1_000,
165
273
  sessionId: 'opaque-thread-1234567890',
166
274
  harness: 'codex',
167
- explicitLabel: 'Late semantic override',
168
275
  }),
169
276
  ).toEqual({ ok: true, label: 'Ivory Koala', source: 'fallback' })
170
277
  expect(readFileSync(file, 'utf8')).not.toContain('Codex session ·')
278
+
279
+ expect(
280
+ resolveSessionLabel({
281
+ env,
282
+ now: now + 2_000,
283
+ sessionId: 'opaque-thread-1234567890',
284
+ harness: 'codex',
285
+ explicitLabel: 'Late semantic override',
286
+ }),
287
+ ).toEqual({ ok: true, label: 'Late semantic override', source: 'explicit' })
171
288
  })
172
289
 
173
290
  it('disambiguates repeated semantic titles without changing session identity', () => {
@@ -289,9 +289,15 @@ function uniqueLabel(
289
289
  /**
290
290
  * Freeze the first accepted human name for one immutable session.
291
291
  *
292
- * Explicit agent/User input wins, then a title supplied by a trusted harness
293
- * adapter, then a generated fallback. Later sends reuse the frozen value
294
- * even when their branch, worktree, title candidate, or notification changes.
292
+ * A title supplied by a trusted harness adapter wins, then explicit
293
+ * agent/User input, then a generated fallback. The environment-first order is
294
+ * what makes `--session-label` safe to pass unconditionally: an agent never
295
+ * needs to know which environments name their own sessions, because where one
296
+ * does, the explicit label is simply not used. A frozen generated fallback is
297
+ * the one exception to permanence: it names nothing, so the first semantic
298
+ * candidate to arrive later — a trusted harness title first, then explicit —
299
+ * replaces it once. Semantic names then stay frozen even when their branch,
300
+ * worktree, title candidate, or notification changes.
295
301
  */
296
302
  export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResolution {
297
303
  const now = input.now ?? Date.now()
@@ -303,14 +309,39 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
303
309
  const store = readStore(file)
304
310
  const existing = store.sessions[key]
305
311
  if (existing !== undefined) {
306
- if (!isLegacyDateFallback(existing)) {
307
- return { ok: true, label: existing.label, source: existing.source }
308
- }
309
312
  const used = new Set(
310
313
  Object.entries(store.sessions)
311
314
  .filter(([storedKey]) => storedKey !== key)
312
315
  .map(([, record]) => collisionKey(record.label)),
313
316
  )
317
+ if (existing.source === 'fallback') {
318
+ const explicit = explicitCandidate(input.explicitLabel, input)
319
+ if (explicit !== null && 'error' in explicit) {
320
+ return { ok: false, error: explicit.error }
321
+ }
322
+ const native = harnessLabelIsPending(input.harnessLabel, input)
323
+ ? null
324
+ : harnessCandidate(input.harnessLabel, input)
325
+ const upgrade = native ?? explicit
326
+ if (upgrade !== null) {
327
+ const label = uniqueLabel(upgrade.label, upgrade.source, input.harness, now, used)
328
+ store.sessions[key] = {
329
+ label,
330
+ source: upgrade.source,
331
+ first_seen_at: existing.first_seen_at,
332
+ ...(input.harness === undefined
333
+ ? existing.harness === undefined
334
+ ? {}
335
+ : { harness: existing.harness }
336
+ : { harness: input.harness }),
337
+ }
338
+ writeStore(file, store)
339
+ return { ok: true, label, source: upgrade.source }
340
+ }
341
+ }
342
+ if (!isLegacyDateFallback(existing)) {
343
+ return { ok: true, label: existing.label, source: existing.source }
344
+ }
314
345
  const label = uniqueLabel(
315
346
  generatedSessionLabel(input.sessionId),
316
347
  'fallback',
@@ -337,7 +368,7 @@ export function resolveSessionLabel(input: SessionLabelInput): SessionLabelResol
337
368
  }
338
369
  }
339
370
  const candidate: SessionLabelCandidate =
340
- explicit ?? native ?? fallbackCandidate(input.sessionId)
371
+ native ?? explicit ?? fallbackCandidate(input.sessionId)
341
372
  const used = new Set(
342
373
  Object.values(store.sessions).map((record) => collisionKey(record.label)),
343
374
  )
package/src/skill.test.ts CHANGED
@@ -60,11 +60,11 @@ describe('Notifai agent skill', () => {
60
60
  expect(decide).toBeLessThan(compose)
61
61
  })
62
62
 
63
- it('keeps session instructions out of config and agent paraphrase out of criteria', () => {
63
+ it('keeps session instructions out of config and agent paraphrase out of guidance', () => {
64
64
  // The rule this pins: a conversational instruction is followed, not
65
- // persisted, and criteria are stored in the user's words verbatim. An
66
- // agent once turned "guide me when you need me" into a persisted
67
- // notify_criteria in its own wording, silently via --yes.
65
+ // persisted, and persisted guidance holds the user's words verbatim. An
66
+ // agent once turned "guide me when you need me" into a persisted standing
67
+ // instruction in its own wording, silently via --yes.
68
68
  const decide = section('## Decide whether to notify')
69
69
  expect(decide).toMatch(/never touches config/i)
70
70
  expect(decide).toMatch(/words\s+verbatim/i)
@@ -72,6 +72,18 @@ describe('Notifai agent skill', () => {
72
72
  expect(decide).toMatch(/`--yes` skips the\s+CLI's confirmation/i)
73
73
  })
74
74
 
75
+ it('sends the agent to the resolved guidance before it writes anything', () => {
76
+ // The writing guidance itself lives in `notifai guidance` (shipped topics,
77
+ // user-overridable per layer); the skill's job is to make reading it the
78
+ // first step and user layers authoritative.
79
+ const decide = section('## Decide whether to notify')
80
+ expect(decide).toContain('notifai guidance')
81
+ for (const topic of ['when-to-notify', 'titles', 'bodies', 'questions', 'acknowledgements']) {
82
+ expect(decide).toContain(`\`${topic}\``)
83
+ }
84
+ expect(decide).toMatch(/outranks the shipped\s+default and your judgement/i)
85
+ })
86
+
75
87
  it('names every notification kind the CLI accepts', () => {
76
88
  for (const kind of NOTIFICATION_KINDS) {
77
89
  expect(skill).toContain(`\`${kind}\``)
@@ -106,7 +118,7 @@ describe('Notifai agent skill', () => {
106
118
  expect(send).toMatch(/one canonical Markdown body/i)
107
119
  expect(send).toContain('media:1')
108
120
  // Type and project are structured fields, never title text.
109
- expect(send).toMatch(/never\s+put the kind or the project in it/i)
121
+ expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
110
122
  })
111
123
 
112
124
  it('names each immutable session once without asking the agent for its id', () => {
@@ -114,14 +126,26 @@ describe('Notifai agent skill', () => {
114
126
  expect(send).toContain('--session-label')
115
127
  expect(send).toMatch(/first Notification\s+Request/i)
116
128
  expect(send).toMatch(/2-6 words/i)
117
- expect(send).toMatch(/Managed OpenCode and Orca-managed Claude Code/i)
118
- expect(send).toMatch(/omit `--session-label` there/i)
119
- expect(send).toMatch(/Outside those managed sources/i)
120
- expect(send).toMatch(/Claude Code, Codex,\s+and Cursor/i)
121
- expect(send).toMatch(/freezes the first accepted name/i)
122
- expect(send).toMatch(/omit the\s+flag on later sends and questions/i)
129
+ // The rule is unconditional: the environment's name wins, so the flag is
130
+ // safe everywhere and the agent never branches on where it is running.
131
+ expect(send).toMatch(/safe in every environment/i)
132
+ expect(send).toMatch(/name the\s+environment supplies wins/i)
133
+ expect(send).toMatch(/freezes one semantic name/i)
134
+ expect(send).toMatch(/omit the flag on later sends and\s+questions/i)
135
+ // The one exception to permanence: garbage names are recoverable.
136
+ expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
123
137
  expect(send).toMatch(/never pass\s+`--session-id`/i)
124
- expect(send).toMatch(/identifier, hash, or filesystem path/i)
138
+ expect(send).toMatch(/identifier, hash, or\s+filesystem path/i)
139
+ })
140
+
141
+ it('never teaches by enumerating environments the CLI can detect itself', () => {
142
+ // "What if we had 24 different plugins, are we gonna list them all?" —
143
+ // guidance states one rule; the mechanism owns environment detection.
144
+ // Harness names may appear only in setup/routing material, where the
145
+ // harness IS the subject.
146
+ for (const heading of ['## Decide whether to notify', '## Send', '## Ask a question', '## When the answer arrives']) {
147
+ expect(section(heading), heading).not.toMatch(/OpenCode|Orca|Cursor|Codex/)
148
+ }
125
149
  })
126
150
 
127
151
  it('separates how long the command waits from how long an answer is accepted', () => {
@@ -234,6 +258,7 @@ describe('Notifai agent skill', () => {
234
258
  'notifai doctor',
235
259
  'notifai init',
236
260
  'notifai config show',
261
+ 'notifai guidance',
237
262
  'notifai logs',
238
263
  'notifai status',
239
264
  ]) {