@raidiant/notifai 9.0.0 → 9.1.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 (68) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/command-session.d.ts +14 -0
  3. package/dist/command-session.js +30 -0
  4. package/dist/command-session.js.map +1 -0
  5. package/dist/commands-doctor.js +17 -15
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.js +3 -29
  8. package/dist/commands-guidance.js.map +1 -1
  9. package/dist/commands-harness-context.js +21 -1
  10. package/dist/commands-harness-context.js.map +1 -1
  11. package/dist/commands-hooks.d.ts +11 -1
  12. package/dist/commands-hooks.js +97 -141
  13. package/dist/commands-hooks.js.map +1 -1
  14. package/dist/commands-init.d.ts +2 -0
  15. package/dist/commands-init.js +41 -21
  16. package/dist/commands-init.js.map +1 -1
  17. package/dist/commands-send.d.ts +1 -0
  18. package/dist/commands-send.js +62 -14
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands-setup-proof.d.ts +4 -3
  21. package/dist/commands-setup-proof.js +29 -14
  22. package/dist/commands-setup-proof.js.map +1 -1
  23. package/dist/commands.d.ts +1 -1
  24. package/dist/commands.js +1 -1
  25. package/dist/commands.js.map +1 -1
  26. package/dist/guidance-render.d.ts +20 -0
  27. package/dist/guidance-render.js +42 -0
  28. package/dist/guidance-render.js.map +1 -0
  29. package/dist/hooks.d.ts +17 -2
  30. package/dist/hooks.js +59 -7
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/opencode-plugin.js +16 -5
  33. package/dist/opencode-plugin.js.map +1 -1
  34. package/dist/program.js +21 -12
  35. package/dist/program.js.map +1 -1
  36. package/dist/readiness.d.ts +6 -0
  37. package/dist/readiness.js +37 -0
  38. package/dist/readiness.js.map +1 -1
  39. package/dist/send-attempts.d.ts +26 -0
  40. package/dist/send-attempts.js +139 -0
  41. package/dist/send-attempts.js.map +1 -0
  42. package/dist/session-activation.d.ts +4 -8
  43. package/dist/session-activation.js +25 -14
  44. package/dist/session-activation.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/command-session.ts +38 -0
  47. package/src/commands-doctor.ts +18 -28
  48. package/src/commands-guidance.ts +3 -38
  49. package/src/commands-harness-context.ts +24 -1
  50. package/src/commands-hooks.ts +217 -170
  51. package/src/commands-init.ts +46 -24
  52. package/src/commands-send.ts +70 -14
  53. package/src/commands-setup-proof.test.ts +72 -0
  54. package/src/commands-setup-proof.ts +33 -14
  55. package/src/commands.test.ts +209 -67
  56. package/src/commands.ts +1 -0
  57. package/src/guidance-render.ts +56 -0
  58. package/src/guidance.test.ts +28 -0
  59. package/src/hooks.test.ts +38 -7
  60. package/src/hooks.ts +69 -5
  61. package/src/install-hooks.test.ts +3 -3
  62. package/src/opencode-plugin.ts +16 -5
  63. package/src/program.ts +22 -12
  64. package/src/readiness.ts +44 -0
  65. package/src/send-attempts.test.ts +143 -0
  66. package/src/send-attempts.ts +188 -0
  67. package/src/session-activation.ts +34 -17
  68. package/src/skill.test.ts +12 -14
@@ -0,0 +1,143 @@
1
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import type { NotificationDraftT } from '@raidiant/notifai-protocol'
6
+ import type { MachineCredential } from './credentials.js'
7
+ import {
8
+ SEND_ATTEMPT_TTL_MS,
9
+ beginSendAttempt,
10
+ semanticMediaIds,
11
+ sendDraftFingerprint,
12
+ settleSendAttempt,
13
+ } from './send-attempts.js'
14
+
15
+ const roots: string[] = []
16
+ afterEach(() => {
17
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
18
+ })
19
+
20
+ function fixture() {
21
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
22
+ roots.push(root)
23
+ const env = { XDG_STATE_HOME: path.join(root, 'state') }
24
+ const credential: MachineCredential = {
25
+ machineId: 'mac_attempt',
26
+ machineName: 'Attempt Machine',
27
+ baseUrl: 'https://app.notifai.test',
28
+ secret: 'private-machine-secret',
29
+ }
30
+ const draft = {
31
+ schema_version: 1,
32
+ kind: 'done',
33
+ project: 'private-project',
34
+ presentation: { title: 'Sensitive result', body: 'Private body text' },
35
+ targets: { mode: 'all' },
36
+ } as NotificationDraftT
37
+ return { root, env, credential, draft }
38
+ }
39
+
40
+ describe('opaque interrupted-send recovery', () => {
41
+ it('matches one exact semantic retry without storing notification content', () => {
42
+ const { env, credential, draft } = fixture()
43
+ const fingerprint = sendDraftFingerprint(draft, credential)
44
+ const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
45
+ expect(first.ok).toBe(true)
46
+ if (!first.ok) return
47
+
48
+ const files = readdirSync(path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts'))
49
+ expect(files).toHaveLength(1)
50
+ const file = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts', files[0]!)
51
+ const stored = readFileSync(file, 'utf8')
52
+ expect(stored).not.toContain('Sensitive result')
53
+ expect(stored).not.toContain('Private body text')
54
+ if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
55
+ if (process.platform !== 'win32') {
56
+ expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700)
57
+ }
58
+
59
+ const retry = beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })
60
+ expect(retry).toMatchObject({
61
+ ok: true,
62
+ attemptId: first.attemptId,
63
+ idempotencyKey: first.idempotencyKey,
64
+ replay: true,
65
+ })
66
+ settleSendAttempt(env, first.attemptId)
67
+ expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
68
+ ok: false,
69
+ code: 'retry_not_found',
70
+ })
71
+ })
72
+
73
+ it('does not follow a symlink presented as an attempt record', () => {
74
+ const { root, env, credential, draft } = fixture()
75
+ if (process.platform === 'win32') return
76
+ const fingerprint = sendDraftFingerprint(draft, credential)
77
+ const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
78
+ expect(first.ok).toBe(true)
79
+ if (!first.ok) return
80
+ const directory = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts')
81
+ const file = path.join(directory, `${first.attemptId}.json`)
82
+ const target = path.join(root, 'foreign.json')
83
+ writeFileSync(target, readFileSync(file, 'utf8'))
84
+ rmSync(file)
85
+ symlinkSync(target, file)
86
+
87
+ expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })).toMatchObject({
88
+ ok: false,
89
+ code: 'retry_not_found',
90
+ })
91
+ expect(readFileSync(target, 'utf8')).toContain(first.idempotencyKey)
92
+ })
93
+
94
+ it('refuses ambiguity instead of choosing between identical unresolved events', () => {
95
+ const { env, credential, draft } = fixture()
96
+ const fingerprint = sendDraftFingerprint(draft, credential)
97
+ beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
98
+ beginSendAttempt({ env, credential, fingerprint, retry: false, now: 11 })
99
+ expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
100
+ ok: false,
101
+ code: 'retry_ambiguous',
102
+ })
103
+ })
104
+
105
+ it('invalidates attempts after expiry or credential rotation', () => {
106
+ const { env, credential, draft } = fixture()
107
+ const fingerprint = sendDraftFingerprint(draft, credential)
108
+ beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
109
+ expect(
110
+ beginSendAttempt({
111
+ env,
112
+ credential,
113
+ fingerprint,
114
+ retry: true,
115
+ now: 10 + SEND_ATTEMPT_TTL_MS + 1,
116
+ }),
117
+ ).toMatchObject({ ok: false, code: 'retry_not_found' })
118
+
119
+ beginSendAttempt({ env, credential, fingerprint, retry: false, now: 20 })
120
+ const rotated = { ...credential, secret: 'rotated-secret' }
121
+ expect(
122
+ beginSendAttempt({
123
+ env,
124
+ credential: rotated,
125
+ fingerprint: sendDraftFingerprint(draft, rotated),
126
+ retry: true,
127
+ now: 21,
128
+ }),
129
+ ).toMatchObject({ ok: false, code: 'retry_not_found' })
130
+ })
131
+
132
+ it('fingerprints local media bytes without retaining paths or bytes', () => {
133
+ const { root } = fixture()
134
+ const file = path.join(root, 'private.png')
135
+ writeFileSync(file, 'first bytes')
136
+ const first = semanticMediaIds([file], root)
137
+ writeFileSync(file, 'different bytes')
138
+ const second = semanticMediaIds([file], root)
139
+ expect(first).not.toEqual(second)
140
+ expect(first[0]).not.toContain(file)
141
+ expect(first[0]).not.toContain('first bytes')
142
+ })
143
+ })
@@ -0,0 +1,188 @@
1
+ import { createHash, createHmac, randomBytes } from 'node:crypto'
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fstatSync,
7
+ lstatSync,
8
+ openSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ rmSync,
12
+ } from 'node:fs'
13
+ import path from 'node:path'
14
+ import type { NotificationDraftT } from '@raidiant/notifai-protocol'
15
+ import { atomicWriteFileSync } from './atomic-file.js'
16
+ import { stateDir } from './config.js'
17
+ import type { MachineCredential } from './credentials.js'
18
+
19
+ interface SendAttemptRecord {
20
+ version: 1
21
+ attempt_id: string
22
+ fingerprint: string
23
+ idempotency_key: string
24
+ credential_tag: string
25
+ machine_id: string
26
+ service: string
27
+ created_at: string
28
+ expires_at: string
29
+ }
30
+
31
+ export const SEND_ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000
32
+
33
+ /** Stable pre-upload media identities used only inside the keyed draft digest. */
34
+ export function semanticMediaIds(images: readonly string[], cwd: string): string[] {
35
+ return images.map((image) => {
36
+ if (image.startsWith('med_')) return image
37
+ const digest = createHash('sha256')
38
+ if (/^https?:\/\//i.test(image)) digest.update(`url\0${image}`)
39
+ else digest.update('file\0').update(readFileSync(path.resolve(cwd, image)))
40
+ return `med_retry_${digest.digest('base64url')}`
41
+ })
42
+ }
43
+
44
+ function directory(env: NodeJS.ProcessEnv): string {
45
+ return path.join(stateDir(env), 'send-attempts')
46
+ }
47
+
48
+ function credentialTag(credential: MachineCredential): string {
49
+ return createHmac('sha256', credential.secret)
50
+ .update('notifai-send-attempt-credential-v1')
51
+ .digest('base64url')
52
+ }
53
+
54
+ /** A keyed digest proves equality without persisting notification content. */
55
+ export function sendDraftFingerprint(
56
+ draft: NotificationDraftT,
57
+ credential: MachineCredential,
58
+ ): string {
59
+ return createHmac('sha256', credential.secret)
60
+ .update('notifai-send-draft-v1\0')
61
+ .update(JSON.stringify(draft))
62
+ .digest('base64url')
63
+ }
64
+
65
+ function parseRecord(file: string): SendAttemptRecord | null {
66
+ let descriptor: number | undefined
67
+ try {
68
+ const before = lstatSync(file)
69
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
70
+ if (before.isSymbolicLink() || !before.isFile() || (uid !== undefined && before.uid !== uid)) {
71
+ return null
72
+ }
73
+ const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW)
74
+ descriptor = openSync(file, flags)
75
+ const opened = fstatSync(descriptor)
76
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null
77
+ const value = JSON.parse(readFileSync(descriptor, 'utf8')) as Partial<SendAttemptRecord>
78
+ return value.version === 1 &&
79
+ typeof value.attempt_id === 'string' &&
80
+ typeof value.fingerprint === 'string' &&
81
+ typeof value.idempotency_key === 'string' &&
82
+ typeof value.credential_tag === 'string' &&
83
+ typeof value.machine_id === 'string' &&
84
+ typeof value.service === 'string' &&
85
+ typeof value.created_at === 'string' &&
86
+ typeof value.expires_at === 'string'
87
+ ? value as SendAttemptRecord
88
+ : null
89
+ } catch {
90
+ return null
91
+ } finally {
92
+ if (descriptor !== undefined) closeSync(descriptor)
93
+ }
94
+ }
95
+
96
+ function liveRecords(
97
+ env: NodeJS.ProcessEnv,
98
+ credential: MachineCredential,
99
+ now: number,
100
+ ): SendAttemptRecord[] {
101
+ const dir = directory(env)
102
+ if (!existsSync(dir)) return []
103
+ const directoryStat = lstatSync(dir)
104
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
105
+ if (
106
+ directoryStat.isSymbolicLink() ||
107
+ !directoryStat.isDirectory() ||
108
+ (uid !== undefined && directoryStat.uid !== uid)
109
+ ) return []
110
+ const tag = credentialTag(credential)
111
+ const records: SendAttemptRecord[] = []
112
+ for (const name of readdirSync(dir)) {
113
+ if (!name.endsWith('.json')) continue
114
+ const file = path.join(dir, name)
115
+ const record = parseRecord(file)
116
+ const expired = record === null || Date.parse(record.expires_at) <= now
117
+ const rotated = record !== null && (
118
+ record.machine_id !== credential.machineId ||
119
+ record.service !== credential.baseUrl ||
120
+ record.credential_tag !== tag
121
+ )
122
+ if (expired || rotated) {
123
+ rmSync(file, { force: true })
124
+ continue
125
+ }
126
+ records.push(record)
127
+ }
128
+ return records
129
+ }
130
+
131
+ export type BeginAttempt =
132
+ | { ok: true; attemptId: string; idempotencyKey: string; replay: boolean }
133
+ | { ok: false; code: 'retry_not_found' | 'retry_ambiguous'; message: string }
134
+
135
+ export function beginSendAttempt(options: {
136
+ env: NodeJS.ProcessEnv
137
+ credential: MachineCredential
138
+ fingerprint: string
139
+ retry: boolean
140
+ idempotencyKey?: string
141
+ now?: number
142
+ }): BeginAttempt {
143
+ const now = options.now ?? Date.now()
144
+ const live = liveRecords(options.env, options.credential, now)
145
+ if (options.retry) {
146
+ const matches = live.filter((record) => record.fingerprint === options.fingerprint)
147
+ if (matches.length === 0) {
148
+ return {
149
+ ok: false,
150
+ code: 'retry_not_found',
151
+ message: 'No unresolved opaque attempt matches this exact send on the current Approved Machine.',
152
+ }
153
+ }
154
+ if (matches.length > 1) {
155
+ return {
156
+ ok: false,
157
+ code: 'retry_ambiguous',
158
+ message: 'More than one unresolved opaque attempt matches this exact send; refusing to guess or create another notification.',
159
+ }
160
+ }
161
+ const match = matches[0]!
162
+ return { ok: true, attemptId: match.attempt_id, idempotencyKey: match.idempotency_key, replay: true }
163
+ }
164
+
165
+ const attemptId = `sat_${randomBytes(12).toString('base64url')}`
166
+ const idempotencyKey = options.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
167
+ const record: SendAttemptRecord = {
168
+ version: 1,
169
+ attempt_id: attemptId,
170
+ fingerprint: options.fingerprint,
171
+ idempotency_key: idempotencyKey,
172
+ credential_tag: credentialTag(options.credential),
173
+ machine_id: options.credential.machineId,
174
+ service: options.credential.baseUrl,
175
+ created_at: new Date(now).toISOString(),
176
+ expires_at: new Date(now + SEND_ATTEMPT_TTL_MS).toISOString(),
177
+ }
178
+ atomicWriteFileSync(
179
+ path.join(directory(options.env), `${attemptId}.json`),
180
+ `${JSON.stringify(record)}\n`,
181
+ { mode: 0o600, directoryMode: 0o700, requireCurrentUserOwner: true },
182
+ )
183
+ return { ok: true, attemptId, idempotencyKey, replay: false }
184
+ }
185
+
186
+ export function settleSendAttempt(env: NodeJS.ProcessEnv, attemptId: string): void {
187
+ rmSync(path.join(directory(env), `${attemptId}.json`), { force: true })
188
+ }
@@ -1,29 +1,46 @@
1
1
  import type { HookHarness } from './hooks.js'
2
+ import { Buffer } from 'node:buffer'
3
+ import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
2
4
 
3
- /**
4
- * Model-visible session bootstrap. It names the installed capability and its
5
- * authoritative sources without restating any notification policy that the
6
- * user may have overridden.
7
- */
8
- export const SESSION_ACTIVATION_CONTEXT =
9
- 'Notifai is active for this session, even when the user did not mention it. ' +
10
- 'Before beginning task work, use the Notifai skill and run `notifai guidance`; its resolved guidance governs notification decisions for Agent Events. ' +
11
- 'Missing skill or CLI readiness is handled through `notifai init` and its diagnosis. ' +
12
- 'For delegated work, the parent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent and do not send independently.'
5
+ const ROOT_OWNERSHIP =
6
+ 'Notifai is active for this session. The effective, provenance-marked guidance below governs Notification Requests. ' +
7
+ 'Missing CLI readiness is handled through `notifai init --json`. ' +
8
+ 'The root agent owns User-visible Notification Requests unless it explicitly delegates that ownership; workers report Agent Events to the parent.'
9
+
10
+ export const WORKER_ACTIVATION_CONTEXT =
11
+ 'Notifai worker context: report Agent Events to the parent; do not send User-visible Notification Requests unless the parent explicitly delegated that ownership. If delegated, run `notifai guidance` before composing one.'
12
+
13
+ export const MISSING_LIFECYCLE_GUIDANCE_CONTEXT =
14
+ 'Notifai is active, but lifecycle guidance could not be loaded. Run `notifai guidance` once before deciding whether or how to send a Notification Request.'
15
+
16
+ function rootActivationContext(cwd: string, env: NodeJS.ProcessEnv): string {
17
+ const guidance = boundedEffectiveGuidance({
18
+ cwd,
19
+ env,
20
+ maxBytes: GUIDANCE_CONTEXT_MAX_BYTES - Buffer.byteLength(`${ROOT_OWNERSHIP}\n\n`, 'utf8'),
21
+ })
22
+ return `${ROOT_OWNERSHIP}\n\n${guidance.ok ? guidance.content : guidance.fallback}`
23
+ }
13
24
 
14
25
  /** One lifecycle meaning, encoded for each harness output contract. */
15
26
  export function sessionActivationOutput(
16
27
  harness: HookHarness | undefined,
17
28
  hookEventName: 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit',
29
+ cwd: string = process.cwd(),
30
+ env: NodeJS.ProcessEnv = process.env,
18
31
  ): string | undefined {
32
+ const context = hookEventName === 'SubagentStart'
33
+ ? WORKER_ACTIVATION_CONTEXT
34
+ : rootActivationContext(cwd, env)
35
+ if (harness === 'opencode') return context
19
36
  if (harness === 'cursor') {
20
- return JSON.stringify({ additional_context: SESSION_ACTIVATION_CONTEXT })
37
+ return JSON.stringify({ additional_context: context })
21
38
  }
22
39
  if (harness === 'claude-code' || harness === 'codex') {
23
40
  return JSON.stringify({
24
41
  hookSpecificOutput: {
25
42
  hookEventName,
26
- additionalContext: SESSION_ACTIVATION_CONTEXT,
43
+ additionalContext: context,
27
44
  },
28
45
  })
29
46
  }
@@ -35,11 +52,11 @@ export function sessionActivationOutput(
35
52
  * to the model. Its native post-Stop follow-up is the narrow fallback and is
36
53
  * claimed once per conversation by the hook-state layer.
37
54
  */
38
- export function cursorStopActivationOutput(): string {
55
+ export function cursorStopActivationOutput(
56
+ cwd: string = process.cwd(),
57
+ env: NodeJS.ProcessEnv = process.env,
58
+ ): string {
39
59
  return JSON.stringify({
40
- followup_message: SESSION_ACTIVATION_CONTEXT.replace(
41
- 'Before beginning task work,',
42
- 'Before finalizing the Agent Event from the turn that just ended,',
43
- ),
60
+ followup_message: rootActivationContext(cwd, env),
44
61
  })
45
62
  }
package/src/skill.test.ts CHANGED
@@ -57,8 +57,8 @@ describe('Notifai agent skill', () => {
57
57
  expect(description).toMatch(/guidance/i)
58
58
 
59
59
  const decide = section('## Decide whether to notify')
60
- expect(decide).toMatch(/every session's first task turn/i)
61
- expect(decide).toMatch(/read guidance before judging an Agent\s+Event/i)
60
+ expect(decide).toMatch(/lifecycle context normally includes.*effective guidance/is)
61
+ expect(decide).toMatch(/context is absent.*read it once before judging an Agent\s+Event/is)
62
62
  expect(decide).toMatch(/parent owns User-visible Notification Requests/i)
63
63
  expect(decide).toMatch(/unless\s+it explicitly delegates/i)
64
64
  expect(decide).toMatch(/workers[\s\S]{0,100}do not send independently/i)
@@ -66,11 +66,11 @@ describe('Notifai agent skill', () => {
66
66
 
67
67
  it('preserves the original Agent Event across setup and ambiguous delivery', () => {
68
68
  expect(skill).toMatch(
69
- /interrupted or killed `send`[\s\S]*`send\.attempt`[\s\S]*session,\s+title length, kind, and time/i,
69
+ /interrupted or killed `send`[\s\S]*do not correlate redacted logs by title[\s\S]*re-run the exact semantic send with\s+`--retry`/i,
70
70
  )
71
- expect(skill).toMatch(/unambiguous match[\s\S]*otherwise report ambiguous delivery without retrying/i)
72
- expect(skill).toMatch(/`no_active_devices`[\s\S]*repeat the original send/i)
73
- expect(skill).toMatch(/verification Notification[\s\S]*does not deliver the original Agent Event/i)
71
+ expect(skill).toMatch(/opaque matching attempt[\s\S]*refuse ambiguity/i)
72
+ expect(skill).toMatch(/`no_active_devices`[\s\S]*repeat the\s+(?:exact\s+)?original send/i)
73
+ expect(skill).toMatch(/verification Notification[\s\S]*does not deliver\s+the original Agent Event/i)
74
74
  })
75
75
 
76
76
  it('fits inside the budget a skill is actually read within', () => {
@@ -224,17 +224,16 @@ describe('Notifai agent skill', () => {
224
224
  expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
225
225
  })
226
226
 
227
- it('names each immutable session once without asking the agent for its id', () => {
227
+ it('lets callers repeat one immutable semantic session name without supplying its id', () => {
228
228
  const send = section('## Send')
229
229
  expect(send).toContain('--session-label')
230
- expect(send).toMatch(/first Notification\s+Request/i)
231
230
  expect(send).toMatch(/2-6 words/i)
232
- expect(send).toMatch(/only when the current\s+environment exposes an exact session/i)
231
+ expect(send).toMatch(/when the current\s+environment exposes an exact session/i)
233
232
  expect(send).toMatch(/without an exact session[\s\S]{0,160}usage\s+error/i)
234
233
  expect(send).toMatch(/omit `--session-label`/i)
235
234
  expect(send).toMatch(/name the\s+environment supplies wins/i)
236
235
  expect(send).toMatch(/freezes one semantic name/i)
237
- expect(send).toMatch(/omit the flag on later sends and\s+questions/i)
236
+ expect(send).toMatch(/safe to repeat the same `--session-label` on every send and ask/i)
238
237
  // The one exception to permanence: garbage names are recoverable.
239
238
  expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
240
239
  expect(send).toMatch(/never pass\s+`--session-id`/i)
@@ -309,8 +308,8 @@ describe('Notifai agent skill', () => {
309
308
  expect(setup).toMatch(/never tell the\s+user to run a command you could have run/i)
310
309
  expect(setup).toMatch(/browser/i)
311
310
  expect(setup).toMatch(/companion/i)
312
- expect(setup).toContain('notifai doctor --json')
313
- expect(setup).toContain('notifai init')
311
+ expect(setup).toContain('notifai init --json')
312
+ expect(setup).toMatch(/do not follow a successful structured init\s+with doctor/i)
314
313
  expect(setup).toContain('--setup-scope')
315
314
  })
316
315
 
@@ -371,7 +370,6 @@ describe('Notifai agent skill', () => {
371
370
  'notifai replies',
372
371
  'notifai acknowledge',
373
372
  'notifai close',
374
- 'notifai doctor',
375
373
  'notifai init',
376
374
  'notifai config show',
377
375
  'notifai guidance',
@@ -389,7 +387,7 @@ describe('Notifai agent skill', () => {
389
387
  expect(harnessReference).toContain(`**${harness}:**`)
390
388
  }
391
389
  // The agent runs what a process can run; the human only does the human part.
392
- expect(harnessReference).toContain('install hooks yourself')
390
+ expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
393
391
  expect(harnessReference).toMatch(/run `notifai login` yourself/i)
394
392
  expect(harnessReference).toMatch(/only the user can approve/i)
395
393
  })