@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/config-schema.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[]
|
|
|
41
41
|
{
|
|
42
42
|
id: 'project',
|
|
43
43
|
title: 'This project',
|
|
44
|
-
blurb: 'How work in this directory identifies itself
|
|
44
|
+
blurb: 'How work in this directory identifies itself. House rules for agents live in `notifai guidance`.',
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
id: 'diagnostics',
|
|
@@ -83,7 +83,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
83
83
|
label: 'Send questions to my devices',
|
|
84
84
|
group: 'questions',
|
|
85
85
|
kind: 'boolean',
|
|
86
|
-
summary: 'Whether a registered question may reach your
|
|
86
|
+
summary: 'Whether a registered question may reach your Companion devices at all',
|
|
87
87
|
detail:
|
|
88
88
|
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
89
89
|
example: 'true',
|
|
@@ -137,10 +137,10 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
137
137
|
active: 'wakes the screen and plays the sound',
|
|
138
138
|
time_sensitive: 'marks the notification urgent; Focus breakthrough is not available yet',
|
|
139
139
|
},
|
|
140
|
-
summary: 'Set how insistently notifications
|
|
140
|
+
summary: 'Set how insistently notifications reach Apple devices',
|
|
141
141
|
detail:
|
|
142
|
-
'
|
|
143
|
-
unsetMeans: 'not specified;
|
|
142
|
+
'Apple interruption level controls how hard a notification tries to reach you in the operating system\'s terms. Setting this pins one Apple-only level for sends from here; kind chooses the sound, never this. Android does not accept caller-selected interruption levels: its product-owned channels and your device settings own attention.\n\nLeave this unset to omit the field and let each destination use its normal behavior. `time_sensitive` is accepted on Apple platforms and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities --platform <platform>` for the exact contract.',
|
|
143
|
+
unsetMeans: 'not specified; each destination uses its normal behavior',
|
|
144
144
|
},
|
|
145
145
|
devices: {
|
|
146
146
|
label: 'Default devices',
|
|
@@ -193,17 +193,6 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
193
193
|
unsetMeans: 'inferred from Git or the current directory',
|
|
194
194
|
example: 'my-app',
|
|
195
195
|
},
|
|
196
|
-
notify_criteria: {
|
|
197
|
-
label: 'House rules for agents',
|
|
198
|
-
group: 'project',
|
|
199
|
-
kind: 'string',
|
|
200
|
-
summary: 'Free text an agent reads before deciding whether to notify you',
|
|
201
|
-
detail:
|
|
202
|
-
'Your own guidance, in your own words, about what is worth a notification in this project — read by agents before they send. Something like "only tell me about failures and finished deploys; never per-file progress".\n\nIt is advice to the agent, not a filter the service enforces. Agents follow it as your literal standing word, so an agent may write it only when you ask for a preference to be remembered — and only in your words, never its own summary.',
|
|
203
|
-
unsetMeans: 'agents fall back to their default judgement',
|
|
204
|
-
example: 'Only failures and completed deploys',
|
|
205
|
-
},
|
|
206
|
-
|
|
207
196
|
log_level: {
|
|
208
197
|
label: 'Log level',
|
|
209
198
|
group: 'diagnostics',
|
|
@@ -217,7 +206,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
217
206
|
},
|
|
218
207
|
summary: 'How much this machine records about what it did',
|
|
219
208
|
detail:
|
|
220
|
-
'Notifai keeps a local record of what it did — every command, every notification, and in particular every decision a harness hook made about whether a question could leave the terminal. Hooks run headless, so without this there is no account of them at all: `notifai logs` is how an agent finds out afterwards why a question never reached your
|
|
209
|
+
'Notifai keeps a local record of what it did — every command, every notification, and in particular every decision a harness hook made about whether a question could leave the terminal. Hooks run headless, so without this there is no account of them at all: `notifai logs` is how an agent finds out afterwards why a question never reached your device.\n\nThe log never leaves this machine. It is not uploaded, and nothing sends it anywhere.\n\n`debug` also records which config layer won and the detail of each request, which is what to turn on when something is behaving in a way the ordinary log does not explain.',
|
|
221
210
|
example: 'info',
|
|
222
211
|
},
|
|
223
212
|
log_max_bytes: {
|
package/src/config.test.ts
CHANGED
|
@@ -201,7 +201,11 @@ describe('draft building', () => {
|
|
|
201
201
|
targets: { mode: 'all' },
|
|
202
202
|
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
203
203
|
// An unlabelled draft is ordinary news, and news has a sound.
|
|
204
|
-
platform: {
|
|
204
|
+
platform: {
|
|
205
|
+
ios: { sound: 'default' },
|
|
206
|
+
macos: { sound: 'default' },
|
|
207
|
+
android: { sound: 'default' },
|
|
208
|
+
},
|
|
205
209
|
})
|
|
206
210
|
})
|
|
207
211
|
|
|
@@ -218,6 +222,7 @@ describe('draft building', () => {
|
|
|
218
222
|
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
219
223
|
if (!build.ok) throw new Error(build.error)
|
|
220
224
|
expect(build.draft.platform?.ios?.sound).toBe(sound)
|
|
225
|
+
expect(build.draft.platform?.android?.sound).toBe(sound)
|
|
221
226
|
expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
|
|
222
227
|
})
|
|
223
228
|
|
|
@@ -239,6 +244,7 @@ describe('draft building', () => {
|
|
|
239
244
|
sound: 'alert',
|
|
240
245
|
interruption_level: 'active',
|
|
241
246
|
})
|
|
247
|
+
expect(configured.draft.platform?.android).toEqual({ sound: 'alert' })
|
|
242
248
|
|
|
243
249
|
const explicit = buildDraft(config, {
|
|
244
250
|
title: 'T',
|
|
@@ -252,6 +258,7 @@ describe('draft building', () => {
|
|
|
252
258
|
sound: null,
|
|
253
259
|
interruption_level: 'time_sensitive',
|
|
254
260
|
})
|
|
261
|
+
expect(explicit.draft.platform?.android).toEqual({ sound: null })
|
|
255
262
|
})
|
|
256
263
|
|
|
257
264
|
it('keeps a question answerable for a day by default', () => {
|
|
@@ -453,6 +460,10 @@ describe('draft building', () => {
|
|
|
453
460
|
custom_data: { run_id: '42', branch: 'main' },
|
|
454
461
|
})
|
|
455
462
|
expect(build.draft.platform?.macos).toEqual(build.draft.platform?.ios)
|
|
463
|
+
expect(build.draft.platform?.android).toEqual({
|
|
464
|
+
sound: null,
|
|
465
|
+
custom_data: { run_id: '42', branch: 'main' },
|
|
466
|
+
})
|
|
456
467
|
})
|
|
457
468
|
|
|
458
469
|
it('maps optional fields into the selected macOS platform slot', () => {
|
|
@@ -479,6 +490,50 @@ describe('draft building', () => {
|
|
|
479
490
|
})
|
|
480
491
|
})
|
|
481
492
|
|
|
493
|
+
it('maps supported optional fields into the selected Android platform slot', () => {
|
|
494
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
495
|
+
const build = buildDraft(config, {
|
|
496
|
+
title: 'T',
|
|
497
|
+
body: 'B',
|
|
498
|
+
platform: 'android',
|
|
499
|
+
sound: 'none',
|
|
500
|
+
threadId: 'mobile-builds',
|
|
501
|
+
data: ['run_id=42'],
|
|
502
|
+
})
|
|
503
|
+
if (!build.ok) throw new Error(build.error)
|
|
504
|
+
|
|
505
|
+
expect(build.platform).toBe('android')
|
|
506
|
+
expect(build.draft.platform).toEqual({
|
|
507
|
+
android: {
|
|
508
|
+
sound: null,
|
|
509
|
+
thread_id: 'mobile-builds',
|
|
510
|
+
custom_data: { run_id: '42' },
|
|
511
|
+
},
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('rejects explicit or configured interruption levels for Android', () => {
|
|
516
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
517
|
+
expect(
|
|
518
|
+
buildDraft(config, {
|
|
519
|
+
title: 'T',
|
|
520
|
+
body: 'B',
|
|
521
|
+
platform: 'android',
|
|
522
|
+
level: 'active',
|
|
523
|
+
}),
|
|
524
|
+
).toMatchObject({
|
|
525
|
+
ok: false,
|
|
526
|
+
error: expect.stringContaining('Android does not support caller-selected interruption levels'),
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
const configuredCase = setup({ projectToml: 'interruption_level = "passive"\n' })
|
|
530
|
+
const configured = loadConfig({ cwd: configuredCase.cwd, env: configuredCase.env })
|
|
531
|
+
expect(buildDraft(configured, { title: 'T', body: 'B', platform: 'android' })).toMatchObject({
|
|
532
|
+
ok: false,
|
|
533
|
+
error: expect.stringContaining('unset the configured interruption_level'),
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
|
|
482
537
|
it('limits collapse keys by UTF-8 bytes', () => {
|
|
483
538
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
484
539
|
expect(buildDraft(config, { title: 'T', body: 'B', collapseKey: '😀'.repeat(16) }).ok).toBe(
|
package/src/config.ts
CHANGED
|
@@ -61,8 +61,6 @@ export interface CliConfig {
|
|
|
61
61
|
interruption_level: ResolvedValue<CliInterruptionLevel | null>
|
|
62
62
|
/** Project identifier stamped on sends; typically set in .notifai/config.toml. */
|
|
63
63
|
project: ResolvedValue<string | null>
|
|
64
|
-
/** Free-text notification criteria agents consult before sending. */
|
|
65
|
-
notify_criteria: ResolvedValue<string | null>
|
|
66
64
|
/** Whether a registered agent question may reach companion devices at all. */
|
|
67
65
|
ask_notifications: ResolvedValue<boolean>
|
|
68
66
|
/**
|
|
@@ -105,7 +103,6 @@ export const CONFIG_KEYS = [
|
|
|
105
103
|
'sound',
|
|
106
104
|
'interruption_level',
|
|
107
105
|
'project',
|
|
108
|
-
'notify_criteria',
|
|
109
106
|
'ask_notifications',
|
|
110
107
|
'ask_grace_seconds',
|
|
111
108
|
'reply_window_seconds',
|
|
@@ -152,7 +149,6 @@ const DEFAULTS = {
|
|
|
152
149
|
sound: null,
|
|
153
150
|
interruption_level: null,
|
|
154
151
|
project: null,
|
|
155
|
-
notify_criteria: null,
|
|
156
152
|
// Installing the hooks is the opt-in; these exist to switch the behaviour
|
|
157
153
|
// back off for a project or a session without uninstalling anything.
|
|
158
154
|
ask_notifications: true,
|
|
@@ -427,7 +423,6 @@ export function loadConfig(options: {
|
|
|
427
423
|
sound: resolve('sound'),
|
|
428
424
|
interruption_level: resolve('interruption_level'),
|
|
429
425
|
project: resolve('project'),
|
|
430
|
-
notify_criteria: resolve('notify_criteria'),
|
|
431
426
|
ask_notifications: resolve('ask_notifications'),
|
|
432
427
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
433
428
|
reply_window_seconds: resolve('reply_window_seconds'),
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shipped notification-writing guidance: what agents follow when the user
|
|
3
|
+
* has not said otherwise.
|
|
4
|
+
*
|
|
5
|
+
* Each topic is a small Markdown document named for the moment an agent
|
|
6
|
+
* consults it. Users override a topic by placing a file with the same name in
|
|
7
|
+
* a guidance directory — resolution and layering live in `guidance.ts`, and
|
|
8
|
+
* the topics ship here as source rather than as packaged files so the packed
|
|
9
|
+
* CLI cannot lose them.
|
|
10
|
+
*
|
|
11
|
+
* The governing principle, stated once: the user hired the outcome, not the
|
|
12
|
+
* pipeline. Tests, coverage, review passes, hashes and exit codes are how the
|
|
13
|
+
* work got done; a notification carries what the work means — what is now
|
|
14
|
+
* finished, what went wrong, what needs them. A detail earns its place only
|
|
15
|
+
* when it changes what the user does next.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface GuidanceTopic {
|
|
19
|
+
/** Topic name; also the override filename, `<name>.md`. */
|
|
20
|
+
name: string
|
|
21
|
+
/** One line for lists and JSON output. */
|
|
22
|
+
summary: string
|
|
23
|
+
/** The Markdown the agent reads. */
|
|
24
|
+
content: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WHEN_TO_NOTIFY = `# When to notify
|
|
28
|
+
|
|
29
|
+
Notify when something changed for the user:
|
|
30
|
+
|
|
31
|
+
- Long-running work finished — succeeded or failed.
|
|
32
|
+
- You are blocked on something only they can give: a key, an approval, a
|
|
33
|
+
decision.
|
|
34
|
+
- You found something that needs their attention soon.
|
|
35
|
+
|
|
36
|
+
Never notify for:
|
|
37
|
+
|
|
38
|
+
- Routine progress: starting, still working, one more subtask done.
|
|
39
|
+
- Anything they will see in the terminal within seconds.
|
|
40
|
+
- A problem you hit and fixed yourself.
|
|
41
|
+
|
|
42
|
+
One notification per event. When a status changes, replace your stale
|
|
43
|
+
notification rather than stacking a new one.
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
const TITLES = `# Titles
|
|
47
|
+
|
|
48
|
+
The user hired the outcome, not the pipeline. A title says what the work means
|
|
49
|
+
to them — what is now finished, what went wrong, what needs them — in about 40
|
|
50
|
+
characters, understandable alone. Never put the kind or the project in it;
|
|
51
|
+
both travel as their own fields.
|
|
52
|
+
|
|
53
|
+
Good:
|
|
54
|
+
|
|
55
|
+
- \`Users can now create accounts\` — the capability, not the process behind it
|
|
56
|
+
- \`Password reset emails aren't sending\` — the failure as the user experiences it
|
|
57
|
+
- \`Refund flow needs your Stripe key\` — what is stuck and what unblocks it
|
|
58
|
+
- \`Found why checkout was flaky\` — the finding they were waiting on
|
|
59
|
+
- \`3 orders didn't import and need review\` — a count that earns its place
|
|
60
|
+
because acting on it is theirs to do
|
|
61
|
+
|
|
62
|
+
Bad:
|
|
63
|
+
|
|
64
|
+
- \`All 42 tests passed\` — machinery; say what work is done
|
|
65
|
+
- \`Adversarial review found 3 issues\` — process report; say what it means for
|
|
66
|
+
what ships
|
|
67
|
+
- \`Migration 0007 failed\` — internal identifier; say what the user lost
|
|
68
|
+
- \`Task complete\` / \`Build failed\` / \`Need input\` — no substance, and the
|
|
69
|
+
kind restated
|
|
70
|
+
- \`Exit code 1\` — mechanics, not meaning
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
const BODIES = `# Bodies
|
|
74
|
+
|
|
75
|
+
The first sentence is what the lock screen shows — make it say what this means
|
|
76
|
+
for the user. The rest is what changes for them and what is needed from them,
|
|
77
|
+
never how many tests ran, how long a step took, or which internal stage
|
|
78
|
+
produced it. Keep wording channel-neutral: no device names, no gestures.
|
|
79
|
+
|
|
80
|
+
Work finished:
|
|
81
|
+
|
|
82
|
+
> Account creation works end to end now: sign-up, email verification, and
|
|
83
|
+
> login are live on staging.
|
|
84
|
+
>
|
|
85
|
+
> Next I'll start on password reset unless you want something else first.
|
|
86
|
+
|
|
87
|
+
Work failed:
|
|
88
|
+
|
|
89
|
+
> The new pricing page isn't live. The deploy failed and I rolled it back, so
|
|
90
|
+
> the site still shows the old page — nothing is broken for users.
|
|
91
|
+
>
|
|
92
|
+
> The blocker is on my side and I'm fixing it; I'll retry and confirm when the
|
|
93
|
+
> page is up. Nothing needed from you.
|
|
94
|
+
|
|
95
|
+
Blocked:
|
|
96
|
+
|
|
97
|
+
> The refund flow is built and working against the test environment. Going
|
|
98
|
+
> live needs the Stripe key that only you can issue.
|
|
99
|
+
>
|
|
100
|
+
> That's the last open item — everything else is done.
|
|
101
|
+
|
|
102
|
+
Bad bodies: a build report (\`42/42 green, coverage 87%, rebased onto main\`)
|
|
103
|
+
where the news is "users can now create accounts"; the journey ("first I
|
|
104
|
+
tried…") instead of the result; a log with the conclusion at the bottom; "see
|
|
105
|
+
terminal for details".
|
|
106
|
+
|
|
107
|
+
Add a summary line only when the body is long enough that its first line is
|
|
108
|
+
not a fair summary of what is inside: one short line answering what the title
|
|
109
|
+
raises, such as \`Rolled back cleanly; production is untouched\` over a long
|
|
110
|
+
failure report.
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
const QUESTIONS = `# Questions
|
|
114
|
+
|
|
115
|
+
A question is answerable from the notification alone, in the user's terms, not
|
|
116
|
+
the machinery's. One askable sentence; reasoning and stakes follow as context.
|
|
117
|
+
Offer closed choices whose wording carries its own consequence.
|
|
118
|
+
|
|
119
|
+
Good:
|
|
120
|
+
|
|
121
|
+
- \`The schema change is ready. It touches live order data — deploy now or
|
|
122
|
+
wait for off-peak?\` with choices \`Deploy now\` / \`Wait for off-peak\`
|
|
123
|
+
- \`The API key in .env.example is live. Revoke it now, or wait until the
|
|
124
|
+
replacement is provisioned?\` with \`Revoke now\` / \`Wait for the new key\`
|
|
125
|
+
|
|
126
|
+
Bad:
|
|
127
|
+
|
|
128
|
+
- \`What should I do?\` — nothing to answer without the terminal
|
|
129
|
+
- \`Deploy to staging and notify the team?\` with \`Yes\` / \`No\` — two
|
|
130
|
+
decisions, one answer
|
|
131
|
+
- \`Retry with --force-with-lease?\` — machinery; the real decision is
|
|
132
|
+
\`Overwrite the remote branch, or keep both versions?\`
|
|
133
|
+
- Choices \`Option A\` / \`Option B\` — labels that point back into the body
|
|
134
|
+
- \`Reply here with your choice\` — never name where the answer must arrive
|
|
135
|
+
`
|
|
136
|
+
|
|
137
|
+
const ACKNOWLEDGEMENTS = `# Acknowledgements
|
|
138
|
+
|
|
139
|
+
An acknowledgement names the concrete work the reply sets in motion — only
|
|
140
|
+
work you will actually do, and nothing generic.
|
|
141
|
+
|
|
142
|
+
Good:
|
|
143
|
+
|
|
144
|
+
- \`Rolling out to staging now; I'll report the health checks.\`
|
|
145
|
+
- \`Holding the deploy. I'll re-raise it once staging is green.\`
|
|
146
|
+
- \`Shipping the fix; I'll confirm when password reset works again.\`
|
|
147
|
+
|
|
148
|
+
Bad:
|
|
149
|
+
|
|
150
|
+
- \`Acknowledged.\` / \`Got it!\` — a receipt that shows nothing was understood
|
|
151
|
+
- \`You chose "Deploy now".\` — echoes the reply instead of naming the work
|
|
152
|
+
- \`Deploying now, and I'll also refactor the retry logic.\` — promises work
|
|
153
|
+
the reply did not cause
|
|
154
|
+
`
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Topic order is reading order: decide first, then each thing an agent writes,
|
|
158
|
+
* in the order it writes them.
|
|
159
|
+
*/
|
|
160
|
+
export const SHIPPED_GUIDANCE: readonly GuidanceTopic[] = [
|
|
161
|
+
{
|
|
162
|
+
name: 'when-to-notify',
|
|
163
|
+
summary: 'What is worth a notification at all',
|
|
164
|
+
content: WHEN_TO_NOTIFY,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'titles',
|
|
168
|
+
summary: 'What a title carries, with examples',
|
|
169
|
+
content: TITLES,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'bodies',
|
|
173
|
+
summary: 'What a body and its summary line carry, with examples',
|
|
174
|
+
content: BODIES,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'questions',
|
|
178
|
+
summary: 'How a question and its choices are worded',
|
|
179
|
+
content: QUESTIONS,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'acknowledgements',
|
|
183
|
+
summary: 'How an acknowledgement is worded',
|
|
184
|
+
content: ACKNOWLEDGEMENTS,
|
|
185
|
+
},
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
export function shippedGuidanceTopic(name: string): GuidanceTopic | undefined {
|
|
189
|
+
return SHIPPED_GUIDANCE.find((topic) => topic.name === name)
|
|
190
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { CommandDeps } from './commands-core.js'
|
|
6
|
+
import {
|
|
7
|
+
guidanceSetCommand,
|
|
8
|
+
guidanceShowCommand,
|
|
9
|
+
guidanceUnsetCommand,
|
|
10
|
+
} from './commands-guidance.js'
|
|
11
|
+
import { SHIPPED_GUIDANCE, shippedGuidanceTopic } from './guidance-content.js'
|
|
12
|
+
import {
|
|
13
|
+
GUIDANCE_TOPIC_MAX_BYTES,
|
|
14
|
+
personalProjectGuidanceDir,
|
|
15
|
+
resolveGuidance,
|
|
16
|
+
} from './guidance.js'
|
|
17
|
+
|
|
18
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-guidance-'))
|
|
19
|
+
afterAll(() => rmSync(tmp, { recursive: true, force: true }))
|
|
20
|
+
|
|
21
|
+
function setup(options: {
|
|
22
|
+
global?: Record<string, string>
|
|
23
|
+
project?: Record<string, string>
|
|
24
|
+
projectLocal?: Record<string, string>
|
|
25
|
+
} = {}) {
|
|
26
|
+
const home = path.join(tmp, `case-${Math.random().toString(36).slice(2)}`)
|
|
27
|
+
const cwd = path.join(home, 'repo', 'nested')
|
|
28
|
+
mkdirSync(cwd, { recursive: true })
|
|
29
|
+
const env = { XDG_CONFIG_HOME: home, XDG_STATE_HOME: path.join(home, 'state') } as NodeJS.ProcessEnv
|
|
30
|
+
const write = (dir: string, files: Record<string, string>) => {
|
|
31
|
+
mkdirSync(dir, { recursive: true })
|
|
32
|
+
for (const [name, content] of Object.entries(files)) writeFileSync(path.join(dir, name), content)
|
|
33
|
+
}
|
|
34
|
+
if (options.global !== undefined) write(path.join(home, 'notifai', 'guidance'), options.global)
|
|
35
|
+
if (options.project !== undefined) write(path.join(home, 'repo', '.notifai', 'guidance'), options.project)
|
|
36
|
+
if (options.projectLocal !== undefined) write(personalProjectGuidanceDir(cwd, env), options.projectLocal)
|
|
37
|
+
return { env, cwd, home }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The shipped content: the contract clauses that used to live in SKILL.md.
|
|
42
|
+
// Like skill.test.ts, these assert what the guidance has to teach, not the
|
|
43
|
+
// sentences it teaches it in.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('shipped guidance content', () => {
|
|
47
|
+
it('ships the five topics in reading order', () => {
|
|
48
|
+
expect(SHIPPED_GUIDANCE.map((topic) => topic.name)).toEqual([
|
|
49
|
+
'when-to-notify',
|
|
50
|
+
'titles',
|
|
51
|
+
'bodies',
|
|
52
|
+
'questions',
|
|
53
|
+
'acknowledgements',
|
|
54
|
+
])
|
|
55
|
+
for (const topic of SHIPPED_GUIDANCE) {
|
|
56
|
+
expect(topic.content, topic.name).toMatch(/^# /)
|
|
57
|
+
expect(topic.summary, topic.name).not.toBe('')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('teaches when to notify and when not to, one notification per event', () => {
|
|
62
|
+
const content = shippedGuidanceTopic('when-to-notify')!.content
|
|
63
|
+
expect(content).toMatch(/finished — succeeded or failed/i)
|
|
64
|
+
expect(content).toMatch(/blocked on something only they can give/i)
|
|
65
|
+
expect(content).toMatch(/needs their attention soon/i)
|
|
66
|
+
expect(content).toMatch(/routine progress/i)
|
|
67
|
+
expect(content).toMatch(/terminal within seconds/i)
|
|
68
|
+
expect(content).toMatch(/one notification per event/i)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('teaches outcome-altitude titles that stand alone, without kind or project', () => {
|
|
72
|
+
const content = shippedGuidanceTopic('titles')!.content
|
|
73
|
+
expect(content).toMatch(/hired the outcome, not the pipeline/i)
|
|
74
|
+
expect(content).toMatch(/understandable alone/i)
|
|
75
|
+
expect(content).toMatch(/40\s*characters/i)
|
|
76
|
+
expect(content).toMatch(/never put the kind or the project in it/i)
|
|
77
|
+
// Both example lists survive: good models the altitude, bad names the traps.
|
|
78
|
+
expect(content).toMatch(/machinery/i)
|
|
79
|
+
expect(content).toContain('Task complete')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('teaches the leading sentence, user-facing detail only, and channel neutrality', () => {
|
|
83
|
+
const content = shippedGuidanceTopic('bodies')!.content
|
|
84
|
+
expect(content).toMatch(/first sentence is what the lock screen shows/i)
|
|
85
|
+
expect(content).toMatch(/never how many tests ran/i)
|
|
86
|
+
expect(content).toMatch(/channel-neutral/i)
|
|
87
|
+
expect(content).toMatch(/summary line only when/i)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('teaches questions answerable alone, with meaningful choices and no route', () => {
|
|
91
|
+
const content = shippedGuidanceTopic('questions')!.content
|
|
92
|
+
expect(content).toMatch(/answerable from the notification alone/i)
|
|
93
|
+
expect(content).toMatch(/closed choices/i)
|
|
94
|
+
expect(content).toMatch(/never name where the answer must arrive/i)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('teaches acknowledgements that name the concrete work and only that', () => {
|
|
98
|
+
const content = shippedGuidanceTopic('acknowledgements')!.content
|
|
99
|
+
expect(content).toMatch(/concrete work the reply sets in motion/i)
|
|
100
|
+
expect(content).toMatch(/only\s+work you will actually do/i)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Resolution across layers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe('guidance layers', () => {
|
|
109
|
+
it('serves the shipped defaults when no layer overrides them', () => {
|
|
110
|
+
const { env, cwd } = setup()
|
|
111
|
+
const resolved = resolveGuidance({ cwd, env })
|
|
112
|
+
expect(resolved.map((topic) => topic.name)).toEqual(SHIPPED_GUIDANCE.map((topic) => topic.name))
|
|
113
|
+
for (const topic of resolved) expect(topic.source).toBe('default')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('lets a layer replace one topic and orders project-local over project over global', () => {
|
|
117
|
+
const { env, cwd } = setup({
|
|
118
|
+
global: { 'titles.md': 'global titles\n', 'bodies.md': 'global bodies\n' },
|
|
119
|
+
project: { 'titles.md': 'project titles\n' },
|
|
120
|
+
projectLocal: { 'titles.md': 'personal titles\n' },
|
|
121
|
+
})
|
|
122
|
+
const resolved = resolveGuidance({ cwd, env })
|
|
123
|
+
const byName = new Map(resolved.map((topic) => [topic.name, topic]))
|
|
124
|
+
expect(byName.get('titles')).toMatchObject({
|
|
125
|
+
content: 'personal titles\n',
|
|
126
|
+
source: expect.stringMatching(/^project-local:/),
|
|
127
|
+
})
|
|
128
|
+
expect(byName.get('bodies')).toMatchObject({
|
|
129
|
+
content: 'global bodies\n',
|
|
130
|
+
source: expect.stringMatching(/^global:/),
|
|
131
|
+
})
|
|
132
|
+
// A replaced topic keeps its place in reading order.
|
|
133
|
+
expect(resolved.map((topic) => topic.name)).toEqual(SHIPPED_GUIDANCE.map((topic) => topic.name))
|
|
134
|
+
// Untouched topics still come from the ship.
|
|
135
|
+
expect(byName.get('when-to-notify')!.source).toBe('default')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('appends user topics that match no shipped name, in name order', () => {
|
|
139
|
+
const { env, cwd } = setup({
|
|
140
|
+
project: { 'house-rules.md': 'always name the branch\n', 'deploys.md': 'deploy rules\n' },
|
|
141
|
+
})
|
|
142
|
+
const names = resolveGuidance({ cwd, env }).map((topic) => topic.name)
|
|
143
|
+
expect(names.slice(SHIPPED_GUIDANCE.length)).toEqual(['deploys', 'house-rules'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('drops files that are not topics and empty overrides', () => {
|
|
147
|
+
const { env, cwd } = setup({
|
|
148
|
+
project: {
|
|
149
|
+
'notes.txt': 'not markdown\n',
|
|
150
|
+
'Bad Name.md': 'invalid topic name\n',
|
|
151
|
+
'titles.md': ' \n',
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
const resolved = resolveGuidance({ cwd, env })
|
|
155
|
+
expect(resolved.map((topic) => topic.name)).toEqual(SHIPPED_GUIDANCE.map((topic) => topic.name))
|
|
156
|
+
// The empty override does not win; the shipped topic still applies.
|
|
157
|
+
expect(resolved.find((topic) => topic.name === 'titles')!.source).toBe('default')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('truncates an oversized topic instead of reading it whole into context', () => {
|
|
161
|
+
const { env, cwd } = setup({
|
|
162
|
+
project: { 'titles.md': `start-${'x'.repeat(GUIDANCE_TOPIC_MAX_BYTES)}` },
|
|
163
|
+
})
|
|
164
|
+
const titles = resolveGuidance({ cwd, env }).find((topic) => topic.name === 'titles')!
|
|
165
|
+
expect(titles.content.length).toBeLessThan(GUIDANCE_TOPIC_MAX_BYTES + 200)
|
|
166
|
+
expect(titles.content).toMatch(/\[Truncated:/)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Commands
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
interface CapturedIo {
|
|
175
|
+
out: string[]
|
|
176
|
+
err: string[]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function makeDeps(env: NodeJS.ProcessEnv, cwd: string, confirmAnswer = false) {
|
|
180
|
+
const captured: CapturedIo = { out: [], err: [] }
|
|
181
|
+
const deps = {
|
|
182
|
+
io: {
|
|
183
|
+
out: (line: string) => captured.out.push(line),
|
|
184
|
+
err: (line: string) => captured.err.push(line),
|
|
185
|
+
confirm: async () => confirmAnswer,
|
|
186
|
+
openUrl: () => {},
|
|
187
|
+
},
|
|
188
|
+
store: {
|
|
189
|
+
load: () => null,
|
|
190
|
+
save: () => {},
|
|
191
|
+
clear: () => {},
|
|
192
|
+
describe: () => 'test credential store',
|
|
193
|
+
},
|
|
194
|
+
env,
|
|
195
|
+
cwd,
|
|
196
|
+
} as unknown as CommandDeps
|
|
197
|
+
return { deps, captured }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
describe('guidance commands', () => {
|
|
201
|
+
it('shows every topic under a provenance comment', () => {
|
|
202
|
+
const { env, cwd } = setup({ project: { 'titles.md': 'my titles\n' } })
|
|
203
|
+
const { deps, captured } = makeDeps(env, cwd)
|
|
204
|
+
expect(guidanceShowCommand(deps, {})).toBe(0)
|
|
205
|
+
const output = captured.out.join('\n')
|
|
206
|
+
expect(output).toMatch(/<!-- when-to-notify · shipped default -->/)
|
|
207
|
+
expect(output).toMatch(/<!-- titles · this project \(shared\) · .*titles\.md -->/)
|
|
208
|
+
expect(output).toContain('my titles')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('emits machine-readable topics with name, source, summary, and content', () => {
|
|
212
|
+
const { env, cwd } = setup()
|
|
213
|
+
const { deps, captured } = makeDeps(env, cwd)
|
|
214
|
+
expect(guidanceShowCommand(deps, { json: true })).toBe(0)
|
|
215
|
+
const topics = JSON.parse(captured.out.join('\n')) as { name: string; source: string }[]
|
|
216
|
+
expect(topics.map((topic) => topic.name)).toEqual(SHIPPED_GUIDANCE.map((topic) => topic.name))
|
|
217
|
+
expect(topics.every((topic) => topic.source === 'default')).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('writes a topic to the chosen layer and resolution picks it up', async () => {
|
|
221
|
+
const { env, cwd, home } = setup()
|
|
222
|
+
const { deps } = makeDeps(env, cwd)
|
|
223
|
+
const code = await guidanceSetCommand(deps, 'titles', 'Prefix titles with 🚀', {
|
|
224
|
+
project: true,
|
|
225
|
+
yes: true,
|
|
226
|
+
})
|
|
227
|
+
expect(code).toBe(0)
|
|
228
|
+
const file = path.join(cwd, '.notifai', 'guidance', 'titles.md')
|
|
229
|
+
expect(readFileSync(file, 'utf8')).toBe('Prefix titles with 🚀\n')
|
|
230
|
+
expect(resolveGuidance({ cwd, env })).toContainEqual(
|
|
231
|
+
expect.objectContaining({ name: 'titles', content: 'Prefix titles with 🚀\n' }),
|
|
232
|
+
)
|
|
233
|
+
expect(home).toBeTruthy()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('holds the confirmation gate: no --yes and no consent means no write', async () => {
|
|
237
|
+
const { env, cwd } = setup()
|
|
238
|
+
const { deps, captured } = makeDeps(env, cwd, false)
|
|
239
|
+
const code = await guidanceSetCommand(deps, 'titles', 'in my own words', {})
|
|
240
|
+
expect(code).toBe(2)
|
|
241
|
+
expect(captured.err.join('\n')).toMatch(/not confirmed/i)
|
|
242
|
+
expect(resolveGuidance({ cwd, env }).find((topic) => topic.name === 'titles')!.source).toBe(
|
|
243
|
+
'default',
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('rejects a name that is not a topic and rejects an oversized write', async () => {
|
|
248
|
+
const { env, cwd } = setup()
|
|
249
|
+
const { deps, captured } = makeDeps(env, cwd)
|
|
250
|
+
expect(await guidanceSetCommand(deps, 'Not A Topic', 'text', { yes: true })).toBe(2)
|
|
251
|
+
expect(captured.err.join('\n')).toMatch(/not a topic name/i)
|
|
252
|
+
expect(
|
|
253
|
+
await guidanceSetCommand(deps, 'titles', 'x'.repeat(GUIDANCE_TOPIC_MAX_BYTES + 1), {
|
|
254
|
+
yes: true,
|
|
255
|
+
}),
|
|
256
|
+
).toBe(2)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('unsets an override so the inherited guidance applies again', async () => {
|
|
260
|
+
const { env, cwd } = setup({ project: { 'titles.md': 'override\n' } })
|
|
261
|
+
const { deps } = makeDeps(env, cwd)
|
|
262
|
+
expect(await guidanceUnsetCommand(deps, 'titles', { project: true, yes: true })).toBe(0)
|
|
263
|
+
expect(existsSync(path.join(cwd, '..', '.notifai', 'guidance', 'titles.md'))).toBe(false)
|
|
264
|
+
expect(resolveGuidance({ cwd, env }).find((topic) => topic.name === 'titles')!.source).toBe(
|
|
265
|
+
'default',
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
})
|