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