@raidiant/notifai 8.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 (168) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. package/src/url-policy.ts +399 -0
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ accidentalEscapedNewlineMessage,
4
+ hasAccidentalEscapedNewlines,
5
+ rejectAccidentalEscapedNewlines,
6
+ } from './send.js'
7
+
8
+ describe('accidental escaped newlines', () => {
9
+ it('catches a shell-quoted backslash-n body', () => {
10
+ expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
11
+ expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
12
+ accidentalEscapedNewlineMessage(),
13
+ )
14
+ })
15
+
16
+ it('preserves real multiline Markdown', () => {
17
+ expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
18
+ expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
19
+ })
20
+
21
+ it('lets an explicit flag keep visible backslash-n text', () => {
22
+ expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
23
+ })
24
+
25
+ it('does not treat an escaped backslash plus n as accidental', () => {
26
+ expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
27
+ })
28
+ })
package/src/send.ts CHANGED
@@ -31,7 +31,6 @@ export interface SendFlags {
31
31
  /** The one canonical Markdown body. */
32
32
  body: string
33
33
  subtitle?: string
34
- event?: string
35
34
  /** What this notification is; absent means update. */
36
35
  kind?: string
37
36
  project?: string
@@ -80,6 +79,34 @@ export interface SendFlags {
80
79
  * questions and their retirements ride the wire as what they are.
81
80
  */
82
81
  lifecycle?: LifecycleT
82
+ /**
83
+ * Allow the two-character sequence backslash-n to be sent as visible text.
84
+ * Without this, a likely accidental escaped newline is refused.
85
+ */
86
+ literalBackslashN?: boolean
87
+ }
88
+
89
+ /** A backslash-n pair that was not itself escaped as a literal backslash. */
90
+ const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
91
+
92
+ export function hasAccidentalEscapedNewlines(body: string): boolean {
93
+ return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
94
+ }
95
+
96
+ export function accidentalEscapedNewlineMessage(): string {
97
+ return [
98
+ 'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
99
+ 'To send a real line break, pass a multiline --body or --body-file -.',
100
+ 'If you meant the visible characters \\n, pass --literal-backslash-n.',
101
+ ].join('\n')
102
+ }
103
+
104
+ export function rejectAccidentalEscapedNewlines(
105
+ body: string | undefined,
106
+ allowLiteral: boolean | undefined,
107
+ ): string | null {
108
+ if (allowLiteral === true || body === undefined) return null
109
+ return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
83
110
  }
84
111
 
85
112
  export type DraftBuild =
@@ -309,7 +336,6 @@ export function buildDraft(
309
336
 
310
337
  const draft: NotificationDraftT = {
311
338
  schema_version: NOTIFICATION_SCHEMA_VERSION,
312
- ...(flags.event !== undefined ? { event: flags.event } : {}),
313
339
  ...(flags.kind !== undefined
314
340
  ? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
315
341
  : {}),
@@ -0,0 +1,62 @@
1
+ import type { HookHarness } from './hooks.js'
2
+ import { Buffer } from 'node:buffer'
3
+ import { GUIDANCE_CONTEXT_MAX_BYTES, boundedEffectiveGuidance } from './guidance-render.js'
4
+
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
+ }
24
+
25
+ /** One lifecycle meaning, encoded for each harness output contract. */
26
+ export function sessionActivationOutput(
27
+ harness: HookHarness | undefined,
28
+ hookEventName: 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit',
29
+ cwd: string = process.cwd(),
30
+ env: NodeJS.ProcessEnv = process.env,
31
+ ): string | undefined {
32
+ const context = hookEventName === 'SubagentStart'
33
+ ? WORKER_ACTIVATION_CONTEXT
34
+ : rootActivationContext(cwd, env)
35
+ if (harness === 'opencode') return context
36
+ if (harness === 'cursor') {
37
+ return JSON.stringify({ additional_context: context })
38
+ }
39
+ if (harness === 'claude-code' || harness === 'codex') {
40
+ return JSON.stringify({
41
+ hookSpecificOutput: {
42
+ hookEventName,
43
+ additionalContext: context,
44
+ },
45
+ })
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ /**
51
+ * Cursor currently accepts sessionStart context without reliably delivering it
52
+ * to the model. Its native post-Stop follow-up is the narrow fallback and is
53
+ * claimed once per conversation by the hook-state layer.
54
+ */
55
+ export function cursorStopActivationOutput(
56
+ cwd: string = process.cwd(),
57
+ env: NodeJS.ProcessEnv = process.env,
58
+ ): string {
59
+ return JSON.stringify({
60
+ followup_message: rootActivationContext(cwd, env),
61
+ })
62
+ }
package/src/skill.test.ts CHANGED
@@ -23,6 +23,7 @@ import { GATE_REASONS } from './hooks.js'
23
23
 
24
24
  const skillPath = new URL('../../../skills/notifai/SKILL.md', import.meta.url)
25
25
  const skill = readFileSync(skillPath, 'utf8')
26
+ const description = skill.match(/^description:\s*(.+)$/m)?.[1] ?? ''
26
27
  const harnessReference = readFileSync(
27
28
  new URL('../../../skills/notifai/references/harness-setup.md', import.meta.url),
28
29
  'utf8',
@@ -46,6 +47,32 @@ const section = (heading: string): string => {
46
47
  }
47
48
 
48
49
  describe('Notifai agent skill', () => {
50
+ it('routes proactively in ordinary sessions without waiting for the user to name Notifai', () => {
51
+ // The frontmatter description is the only skill text available during
52
+ // automatic selection. If the ordinary-session trigger lives only in the
53
+ // body, agents doing unrelated work never load it and therefore never see
54
+ // the completion rule.
55
+ expect(description).toMatch(/every agent session/i)
56
+ expect(description).toMatch(/even when the user does not mention Notifai/i)
57
+ expect(description).toMatch(/guidance/i)
58
+
59
+ const decide = section('## Decide whether to notify')
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
+ expect(decide).toMatch(/parent owns User-visible Notification Requests/i)
63
+ expect(decide).toMatch(/unless\s+it explicitly delegates/i)
64
+ expect(decide).toMatch(/workers[\s\S]{0,100}do not send independently/i)
65
+ })
66
+
67
+ it('preserves the original Agent Event across setup and ambiguous delivery', () => {
68
+ expect(skill).toMatch(
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
+ )
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
+ })
75
+
49
76
  it('fits inside the budget a skill is actually read within', () => {
50
77
  // Long skills are truncated in exactly the long sessions that need them.
51
78
  // The old skill sat at ~4,800 tokens; this ceiling keeps a working margin.
@@ -81,7 +108,74 @@ describe('Notifai agent skill', () => {
81
108
  for (const topic of ['when-to-notify', 'titles', 'bodies', 'questions', 'acknowledgements']) {
82
109
  expect(decide).toContain(`\`${topic}\``)
83
110
  }
84
- expect(decide).toMatch(/outranks the shipped\s+default and your judgement/i)
111
+ const authorityOrder = ['from=you', 'from=this repository', 'from=shipped default'].map(
112
+ (marker) => decide.indexOf(marker),
113
+ )
114
+ expect(authorityOrder[0]).toBeGreaterThan(decide.indexOf('notifai guidance'))
115
+ expect(authorityOrder[1]).toBeGreaterThan(authorityOrder[0]!)
116
+ expect(authorityOrder[2]).toBeGreaterThan(authorityOrder[1]!)
117
+ })
118
+
119
+ it('separates repository policy from the User and makes non-exfiltration absolute', () => {
120
+ const decide = section('## Decide whether to notify')
121
+ const markers = ['from=you', 'from=this repository', 'from=shipped default']
122
+ const markerPositions = markers.map((marker) => decide.indexOf(marker))
123
+ expect(markerPositions.every((position) => position >= 0)).toBe(true)
124
+
125
+ const numberedLimits = [
126
+ ...decide.matchAll(/^\d+\. \*\*([^*]+)\.\*\*([\s\S]*?)(?=^\d+\. |\n\n)/gm),
127
+ ]
128
+ expect(numberedLimits.map((match) => match[1]?.toLowerCase())).toEqual([
129
+ 'non-exfiltration',
130
+ 'repository authority',
131
+ ])
132
+
133
+ const normalize = (value: string | undefined): string =>
134
+ (value ?? '').toLowerCase().replace(/\s+/g, ' ')
135
+ const nonExfiltration = normalize(numberedLimits[0]?.[2])
136
+ for (const protectedClass of [
137
+ 'credential',
138
+ 'token',
139
+ 'key',
140
+ 'password',
141
+ 'environment',
142
+ 'configuration',
143
+ 'guidance',
144
+ 'log',
145
+ ]) {
146
+ expect(nonExfiltration, protectedClass).toContain(protectedClass)
147
+ }
148
+ for (const outboundField of [
149
+ 'notification request',
150
+ 'question',
151
+ 'choice',
152
+ 'acknowledgement',
153
+ 'image',
154
+ ]) {
155
+ expect(nonExfiltration, outboundField).toContain(outboundField)
156
+ }
157
+
158
+ const authority = normalize(numberedLimits[1]?.[2])
159
+ for (const protectedAuthority of [
160
+ 'standing word',
161
+ 'settings',
162
+ 'guidance',
163
+ 'cli',
164
+ 'origins',
165
+ 'direct user instruction',
166
+ ]) {
167
+ expect(authority, protectedAuthority).toContain(protectedAuthority)
168
+ }
169
+
170
+ const obligations = normalize(decide.slice(numberedLimits[1]?.index ?? 0))
171
+ const refuse = obligations.indexOf('refuse')
172
+ const report = obligations.indexOf('tell the user')
173
+ const noOutboundReport = obligations.search(
174
+ /(?:do not|never|cannot)[^.]{0,120}notification request/,
175
+ )
176
+ expect(refuse).toBeGreaterThanOrEqual(0)
177
+ expect(report).toBeGreaterThan(refuse)
178
+ expect(noOutboundReport).toBeGreaterThan(report)
85
179
  })
86
180
 
87
181
  it('names every notification kind the CLI accepts', () => {
@@ -100,6 +194,15 @@ describe('Notifai agent skill', () => {
100
194
  expect(skill).not.toMatch(/kind never chooses/i)
101
195
  })
102
196
 
197
+ it('routes a blocker through a question whenever the User response resumes the work', () => {
198
+ // `blocked` and `question` are mutually exclusive on the wire. The skill
199
+ // must teach the selection boundary or an imperative such as "unlock it"
200
+ // becomes an unanswerable one-way notification.
201
+ const selection = `${section('## Send')}\n${section('## Ask a question')}`
202
+ expect(selection).toMatch(/work needs a User response[\s\S]{0,160}answerable\s+question/i)
203
+ expect(selection).toMatch(/one-way blocked[\s\S]{0,160}no\s+User\s+reply\s+would\s+resume/i)
204
+ })
205
+
103
206
  it('keeps every send example valid against the CLI it documents', () => {
104
207
  const sends = shellExamples.filter((line) => line.startsWith('notifai send'))
105
208
  expect(sends.length).toBeGreaterThan(0)
@@ -121,17 +224,16 @@ describe('Notifai agent skill', () => {
121
224
  expect(send).toMatch(/kind and the project travel as their own\s+fields, never in it/i)
122
225
  })
123
226
 
124
- 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', () => {
125
228
  const send = section('## Send')
126
229
  expect(send).toContain('--session-label')
127
- expect(send).toMatch(/first Notification\s+Request/i)
128
230
  expect(send).toMatch(/2-6 words/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)
231
+ expect(send).toMatch(/when the current\s+environment exposes an exact session/i)
232
+ expect(send).toMatch(/without an exact session[\s\S]{0,160}usage\s+error/i)
233
+ expect(send).toMatch(/omit `--session-label`/i)
132
234
  expect(send).toMatch(/name the\s+environment supplies wins/i)
133
235
  expect(send).toMatch(/freezes one semantic name/i)
134
- 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)
135
237
  // The one exception to permanence: garbage names are recoverable.
136
238
  expect(send).toMatch(/generated fallback name[\s\S]*replaced by a later\s+semantic name/i)
137
239
  expect(send).toMatch(/never pass\s+`--session-id`/i)
@@ -155,6 +257,12 @@ describe('Notifai agent skill', () => {
155
257
  expect(ask).toMatch(/exit code 3/i)
156
258
  })
157
259
 
260
+ it('teaches that closed choices appear after pressing and holding', () => {
261
+ const ask = section('## Ask a question')
262
+ expect(ask).toMatch(/closed choices appear after pressing and holding the\s+notification/i)
263
+ expect(skill).not.toMatch(/choices are buttons on the banner/i)
264
+ })
265
+
158
266
  it('makes registration the middle of the asking turn, not the end', () => {
159
267
  const ask = section('## Ask a question')
160
268
  expect(ask).toMatch(/registering is not the end of the turn/i)
@@ -189,13 +297,20 @@ describe('Notifai agent skill', () => {
189
297
  expect(skill).toContain('notifai replies --pending')
190
298
  })
191
299
 
300
+ it('teaches retirement of a registration that has not been pushed yet', () => {
301
+ expect(skill).toContain('notifai close --pending')
302
+ expect(skill).toContain('question_id')
303
+ expect(skill).toMatch(/answer\s+in the conversation/i)
304
+ })
305
+
192
306
  it('makes the agent the operator and the human only the human', () => {
193
307
  const setup = section('## Set Notifai up')
194
308
  expect(setup).toMatch(/never tell the\s+user to run a command you could have run/i)
195
309
  expect(setup).toMatch(/browser/i)
196
310
  expect(setup).toMatch(/companion/i)
197
- expect(setup).toContain('notifai doctor --json')
198
- 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)
313
+ expect(setup).toContain('--setup-scope')
199
314
  })
200
315
 
201
316
  it('offers npx only as a pinned fallback behind a real install', () => {
@@ -255,7 +370,6 @@ describe('Notifai agent skill', () => {
255
370
  'notifai replies',
256
371
  'notifai acknowledge',
257
372
  'notifai close',
258
- 'notifai doctor',
259
373
  'notifai init',
260
374
  'notifai config show',
261
375
  'notifai guidance',
@@ -273,7 +387,7 @@ describe('Notifai agent skill', () => {
273
387
  expect(harnessReference).toContain(`**${harness}:**`)
274
388
  }
275
389
  // The agent runs what a process can run; the human only does the human part.
276
- expect(harnessReference).toContain('install hooks yourself')
390
+ expect(harnessReference).toMatch(/let init install and assess\s+the hooks/i)
277
391
  expect(harnessReference).toMatch(/run `notifai login` yourself/i)
278
392
  expect(harnessReference).toMatch(/only the user can approve/i)
279
393
  })