@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.
- package/CHANGELOG.md +48 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +83 -7
- package/dist/hooks.js +288 -90
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +11 -0
- package/dist/session-activation.js +46 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/command-session.ts +38 -0
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +70 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +778 -166
- package/src/hooks.ts +368 -129
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- package/src/trust-policy.test.ts +701 -0
- 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
|
+
}
|
package/src/send.test.ts
ADDED
|
@@ -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
|
-
|
|
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('
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
expect(send).toMatch(/
|
|
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(/
|
|
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
|
|
198
|
-
expect(setup).
|
|
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).
|
|
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
|
})
|