@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands-devices.d.ts +2 -1
  3. package/dist/commands-devices.js +20 -8
  4. package/dist/commands-devices.js.map +1 -1
  5. package/dist/commands-doctor.js +14 -12
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.d.ts +23 -0
  8. package/dist/commands-guidance.js +128 -0
  9. package/dist/commands-guidance.js.map +1 -0
  10. package/dist/commands-hooks.d.ts +7 -1
  11. package/dist/commands-hooks.js +3 -6
  12. package/dist/commands-hooks.js.map +1 -1
  13. package/dist/commands-init.js +25 -22
  14. package/dist/commands-init.js.map +1 -1
  15. package/dist/commands-send-support.js +5 -3
  16. package/dist/commands-send-support.js.map +1 -1
  17. package/dist/commands-send.d.ts +0 -1
  18. package/dist/commands-send.js +45 -28
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands.d.ts +1 -0
  21. package/dist/commands.js +1 -0
  22. package/dist/commands.js.map +1 -1
  23. package/dist/config-schema.js +6 -15
  24. package/dist/config-schema.js.map +1 -1
  25. package/dist/config.d.ts +1 -3
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/guidance-content.d.ts +30 -0
  29. package/dist/guidance-content.js +175 -0
  30. package/dist/guidance-content.js.map +1 -0
  31. package/dist/guidance.d.ts +48 -0
  32. package/dist/guidance.js +118 -0
  33. package/dist/guidance.js.map +1 -0
  34. package/dist/hooks.js +7 -7
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/interactive.d.ts +1 -1
  37. package/dist/interactive.js +13 -8
  38. package/dist/interactive.js.map +1 -1
  39. package/dist/main.js +3 -474
  40. package/dist/main.js.map +1 -1
  41. package/dist/program.d.ts +49 -0
  42. package/dist/program.js +571 -0
  43. package/dist/program.js.map +1 -0
  44. package/dist/send.d.ts +4 -9
  45. package/dist/send.js +48 -28
  46. package/dist/send.js.map +1 -1
  47. package/dist/session-labels.d.ts +9 -3
  48. package/dist/session-labels.js +38 -7
  49. package/dist/session-labels.js.map +1 -1
  50. package/package.json +5 -3
  51. package/src/commands-devices.ts +28 -9
  52. package/src/commands-doctor.ts +17 -12
  53. package/src/commands-guidance.ts +170 -0
  54. package/src/commands-hooks.ts +10 -8
  55. package/src/commands-init.ts +31 -22
  56. package/src/commands-send-support.ts +5 -3
  57. package/src/commands-send.ts +50 -28
  58. package/src/commands.test.ts +374 -63
  59. package/src/commands.ts +5 -0
  60. package/src/config-schema.ts +6 -17
  61. package/src/config.test.ts +56 -1
  62. package/src/config.ts +0 -5
  63. package/src/guidance-content.ts +190 -0
  64. package/src/guidance.test.ts +268 -0
  65. package/src/guidance.ts +151 -0
  66. package/src/hooks.test.ts +37 -5
  67. package/src/hooks.ts +7 -7
  68. package/src/interactive.ts +13 -8
  69. package/src/main.ts +4 -600
  70. package/src/program.test.ts +202 -0
  71. package/src/program.ts +723 -0
  72. package/src/send.ts +53 -32
  73. package/src/session-labels.test.ts +120 -3
  74. package/src/session-labels.ts +38 -7
  75. package/src/skill.test.ts +37 -12
@@ -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, and the house rules agents read before notifying.',
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 iPhone at all',
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 from here arrive',
140
+ summary: 'Set how insistently notifications reach Apple devices',
141
141
  detail:
142
- 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind chooses the sound, never this.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` is accepted and marks the notification urgent, but Focus breakthrough is not available yet — run `notifai capabilities` for what a platform actually honours.',
143
- unsetMeans: 'not specified; the destination uses its normal behavior',
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 phone.\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.',
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: {
@@ -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: { ios: { sound: 'default' }, macos: { sound: 'default' } },
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
+ })