@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.
- package/CHANGELOG.md +7 -0
- 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-doctor.js +17 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.js +3 -29
- 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 +11 -1
- package/dist/commands-hooks.js +97 -141
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +2 -0
- package/dist/commands-init.js +41 -21
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +62 -14
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +4 -3
- package/dist/commands-setup-proof.js +29 -14
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +1 -1
- package/dist/commands.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/hooks.d.ts +17 -2
- package/dist/hooks.js +59 -7
- package/dist/hooks.js.map +1 -1
- package/dist/opencode-plugin.js +16 -5
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +21 -12
- 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/session-activation.d.ts +4 -8
- package/dist/session-activation.js +25 -14
- package/dist/session-activation.js.map +1 -1
- package/package.json +1 -1
- package/src/command-session.ts +38 -0
- package/src/commands-doctor.ts +18 -28
- package/src/commands-guidance.ts +3 -38
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +217 -170
- package/src/commands-init.ts +46 -24
- package/src/commands-send.ts +70 -14
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +33 -14
- package/src/commands.test.ts +209 -67
- package/src/commands.ts +1 -0
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +28 -0
- package/src/hooks.test.ts +38 -7
- package/src/hooks.ts +69 -5
- package/src/install-hooks.test.ts +3 -3
- package/src/opencode-plugin.ts +16 -5
- package/src/program.ts +22 -12
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/session-activation.ts +34 -17
- 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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const
|
|
9
|
-
'Notifai
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
'
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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(/
|
|
61
|
-
expect(decide).toMatch(/read
|
|
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]
|
|
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(/
|
|
72
|
-
expect(skill).toMatch(/`no_active_devices`[\s\S]*repeat the
|
|
73
|
-
expect(skill).toMatch(/verification Notification[\s\S]*does not deliver
|
|
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('
|
|
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(/
|
|
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(/
|
|
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
|
|
313
|
-
expect(setup).
|
|
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).
|
|
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
|
})
|