@raidiant/notifai 3.0.1 → 4.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.
@@ -107,14 +107,14 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
107
107
  choiceHints: {
108
108
  default: 'the standard notification tone',
109
109
  done: 'the completion chime',
110
- attention: 'used for questions that want an answer',
110
+ attention: 'a distinct attention tone',
111
111
  alert: 'the most insistent tone',
112
112
  none: 'silent — arrives without a sound',
113
113
  },
114
- summary: 'Override the sound every notification uses',
114
+ summary: 'Set the sound notifications from here use',
115
115
  detail:
116
- 'Normally the sound comes from what the notification is: progress updates are silent, completions get the done chime, questions get the attention tone. Setting this pins one sound for everything sent from here, overriding those profiles.\n\nA common use is `none`, to make a noisy project completely silent.',
117
- unsetMeans: 'each notification uses the sound for its kind',
116
+ 'Setting this pins one sound for every notification sent from here. Kind remains semantic status metadata and never changes native-banner attention.\n\nLeave this unset to omit the sound field and let the destination use its normal behavior. A common explicit preference is `none`, to make a noisy project completely silent.',
117
+ unsetMeans: 'not specified; the destination uses its normal behavior',
118
118
  },
119
119
  interruption_level: {
120
120
  label: 'Interruption level',
@@ -126,10 +126,10 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
126
126
  active: 'wakes the screen and plays the sound',
127
127
  time_sensitive: 'may break through Focus modes',
128
128
  },
129
- summary: 'Override how insistently notifications arrive',
129
+ summary: 'Set how insistently notifications from here arrive',
130
130
  detail:
131
- 'How hard a notification tries to reach you, in the operating system\'s terms. Like the sound, this is normally decided by what the notification is; setting it pins one level for everything.\n\n`time_sensitive` can pierce a Focus mode, so it is worth reserving for the cases that genuinely earn it.',
132
- unsetMeans: 'each notification uses the level for its kind',
131
+ '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 never changes it.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` can pierce a Focus mode, so reserve it for cases that genuinely earn it.',
132
+ unsetMeans: 'not specified; the destination uses its normal behavior',
133
133
  },
134
134
  devices: {
135
135
  label: 'Default devices',
@@ -178,8 +178,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
178
178
  kind: 'string',
179
179
  summary: 'The name this project reports itself as',
180
180
  detail:
181
- 'Stamped on every notification sent from this directory, and how the companion apps group and filter what arrives. Normally set once in `.notifai/config.toml` and committed, so everyone working on the repository reports the same name.',
182
- unsetMeans: 'notifications arrive without a project',
181
+ 'Stamped on every notification sent from this directory, and how the companion apps group and filter what arrives. When unset, the CLI infers a stable identifier from Git (shared across linked worktrees) or the current directory. Set it in `.notifai/config.toml` only when that inferred name is not the identity you want.',
182
+ unsetMeans: 'inferred from Git or the current directory',
183
183
  example: 'my-app',
184
184
  },
185
185
  notify_criteria: {
@@ -98,6 +98,25 @@ describe('harness config layers', () => {
98
98
  })
99
99
  })
100
100
 
101
+ it('uses NOTIFAI_SESSION_ID for the session layer and ignores the deleted env name', () => {
102
+ const { env, cwd } = setup({
103
+ sessionToml: 'ask_grace_seconds = 45\n',
104
+ sessionId: 'exact-session',
105
+ })
106
+ expect(
107
+ loadConfig({
108
+ cwd,
109
+ env: { ...env, NOTIFAI_SESSION_ID: 'exact-session' },
110
+ }).ask_grace_seconds.value,
111
+ ).toBe(45)
112
+ expect(
113
+ loadConfig({
114
+ cwd,
115
+ env: { ...env, NOTIFAI_SESSION: 'exact-session' },
116
+ }).ask_grace_seconds.value,
117
+ ).toBe(0)
118
+ })
119
+
101
120
  it('keeps hostile or colliding session ids inside their own file', () => {
102
121
  const { env, cwd } = setup({
103
122
  sessionToml: 'ask_grace_seconds = 99\n',
@@ -172,28 +191,23 @@ describe('draft building', () => {
172
191
  presentation: { title: 'T', body: 'B' },
173
192
  targets: { mode: 'all' },
174
193
  delivery: { ttl_seconds: 86400, collapse_key: null },
175
- platform: {
176
- ios: { sound: null, interruption_level: 'passive' },
177
- macos: { sound: null, interruption_level: 'passive' },
178
- },
179
194
  })
180
195
  })
181
196
 
182
197
  it.each([
183
- { flags: {}, sound: null, level: 'passive' },
184
- { flags: { kind: 'done' }, sound: 'done', level: 'passive' },
185
- { flags: { reply: true }, sound: 'attention', level: 'active' },
186
- ])('derives the $sound / $level kind profile from $flags', ({ flags, sound, level }) => {
198
+ {},
199
+ { kind: 'done' },
200
+ { kind: 'failed' },
201
+ { kind: 'blocked' },
202
+ { reply: true },
203
+ ])('never derives sound or interruption level from kind: %j', (flags) => {
187
204
  const config = loadConfig({ cwd: base.cwd, env: base.env })
188
205
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
189
206
  if (!build.ok) throw new Error(build.error)
190
- expect(build.draft.platform).toEqual({
191
- ios: { sound, interruption_level: level },
192
- macos: { sound, interruption_level: level },
193
- })
207
+ expect(build.draft.platform).toBeUndefined()
194
208
  })
195
209
 
196
- it('orders explicit flags over user config over the kind profile', () => {
210
+ it('orders explicit flags over saved user config', () => {
197
211
  const { env, cwd } = setup({
198
212
  projectToml: 'sound = "alert"\ninterruption_level = "active"\n',
199
213
  })
@@ -227,15 +241,129 @@ describe('draft building', () => {
227
241
  expect(build.draft.reply?.expires_in_seconds).toBe(3_600)
228
242
  })
229
243
 
230
- it('stamps the project from flag or project config', () => {
244
+ it('orders explicit and configured Project identity over inference', () => {
231
245
  const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
232
246
  const config = loadConfig({ cwd, env })
233
- const fromConfig = buildDraft(config, { title: 'T', body: 'B' })
247
+ const fromConfig = buildDraft(
248
+ config,
249
+ { title: 'T', body: 'B' },
250
+ { inferredProject: 'inferred-app' },
251
+ )
234
252
  if (!fromConfig.ok) throw new Error(fromConfig.error)
235
253
  expect(fromConfig.draft.project).toBe('my-app')
236
- const fromFlag = buildDraft(config, { title: 'T', body: 'B', project: 'other-app' })
254
+ const fromFlag = buildDraft(
255
+ config,
256
+ { title: 'T', body: 'B', project: 'other-app' },
257
+ { inferredProject: 'inferred-app' },
258
+ )
237
259
  if (!fromFlag.ok) throw new Error(fromFlag.error)
238
260
  expect(fromFlag.draft.project).toBe('other-app')
261
+
262
+ const inferredCase = setup({})
263
+ const unset = loadConfig({ cwd: inferredCase.cwd, env: inferredCase.env })
264
+ const inferred = buildDraft(
265
+ unset,
266
+ { title: 'T', body: 'B' },
267
+ { inferredProject: 'inferred-app' },
268
+ )
269
+ if (!inferred.ok) throw new Error(inferred.error)
270
+ expect(inferred.draft.project).toBe('inferred-app')
271
+ })
272
+
273
+ it('carries Source Context without copying authoring overrides into legacy fields', () => {
274
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
275
+ const build = buildDraft(
276
+ config,
277
+ { title: 'T', body: 'B', sessionId: 'opaque', sessionLabel: 'Visible' },
278
+ {
279
+ source: {
280
+ session_id: 'opaque',
281
+ session_label: 'Visible',
282
+ harness: 'codex',
283
+ branch: 'main',
284
+ worktree: 'topic',
285
+ },
286
+ },
287
+ )
288
+ if (!build.ok) throw new Error(build.error)
289
+ expect(build.draft.source).toEqual({
290
+ session_id: 'opaque',
291
+ session_label: 'Visible',
292
+ harness: 'codex',
293
+ branch: 'main',
294
+ worktree: 'topic',
295
+ })
296
+ expect(build.draft).not.toHaveProperty('session')
297
+ })
298
+
299
+ it('rewrites positional media references and preserves canonical ids', () => {
300
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
301
+ const build = buildDraft(config, {
302
+ title: 'Visual result',
303
+ body: '![first](media:1) then ![second](media:2) and ![existing](media:med_keep)',
304
+ image: ['med_first', 'med_second', 'med_attached'],
305
+ imageAlt: ['First image', 'Second image'],
306
+ })
307
+ if (!build.ok) throw new Error(build.error)
308
+ expect(build.draft.presentation.body).toBe(
309
+ '![first](media:med_first) then ![second](media:med_second) and ![existing](media:med_keep)',
310
+ )
311
+ expect(build.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
312
+ expect(build.draft.presentation.media).toEqual([
313
+ { media_id: 'med_first', alt: 'First image' },
314
+ { media_id: 'med_second', alt: 'Second image' },
315
+ { media_id: 'med_attached' },
316
+ ])
317
+ })
318
+
319
+ it('preserves sparse alt text in an internal ordered media manifest', () => {
320
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
321
+ const build = buildDraft(config, {
322
+ title: 'Visual result',
323
+ body: '![second](media:2)',
324
+ media: [
325
+ { media_id: 'med_first' },
326
+ { media_id: 'med_second', alt: 'Second image' },
327
+ ],
328
+ })
329
+ if (!build.ok) throw new Error(build.error)
330
+ expect(build.draft.presentation.body).toBe('![second](media:med_second)')
331
+ expect(build.draft.presentation.media).toEqual([
332
+ { media_id: 'med_first' },
333
+ { media_id: 'med_second', alt: 'Second image' },
334
+ ])
335
+ })
336
+
337
+ it('names an out-of-range positional media reference', () => {
338
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
339
+ const build = buildDraft(config, {
340
+ title: 'Visual result',
341
+ body: '![missing](media:3)',
342
+ image: ['med_first', 'med_second'],
343
+ })
344
+ expect(build).toEqual({
345
+ ok: false,
346
+ error: 'Body reference "media:3" has no matching --image occurrence.',
347
+ })
348
+ })
349
+
350
+ it('validates media cardinality and alt pairing', () => {
351
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
352
+ expect(
353
+ buildDraft(config, {
354
+ title: 'T',
355
+ body: 'B',
356
+ image: Array.from({ length: 9 }, (_, index) => `med_${index}`),
357
+ }),
358
+ ).toMatchObject({ ok: false, error: expect.stringContaining('at most 8') })
359
+ expect(
360
+ buildDraft(config, {
361
+ title: 'T',
362
+ body: 'B',
363
+ image: ['med_one'],
364
+ imageAlt: ['one', 'two'],
365
+ }),
366
+ ).toMatchObject({ ok: false, error: expect.stringContaining('2 --image-alt') })
239
367
  })
240
368
 
241
369
  it('carries --kind and rejects one outside the vocabulary', () => {
package/src/config.ts CHANGED
@@ -380,7 +380,7 @@ export function loadConfig(options: {
380
380
  const projectRaw = projectPath ? readTomlFile(projectPath) : {}
381
381
  const projectLocalPath = findProjectLocalConfigPath(cwd, env)
382
382
  const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
383
- const sessionId = options.sessionId ?? env['NOTIFAI_SESSION']
383
+ const sessionId = options.sessionId ?? env['NOTIFAI_SESSION_ID']
384
384
  const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
385
385
  const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
386
386
 
package/src/hooks.test.ts CHANGED
@@ -14,11 +14,12 @@ import {
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { PassThrough } from 'node:stream'
17
- import type {
18
- ListRepliesResponse,
19
- ReplyView,
20
- SubmissionReceipt,
21
- SubmitNotificationRequestT,
17
+ import {
18
+ sessionLabelFromId,
19
+ type ListRepliesResponse,
20
+ type ReplyView,
21
+ type SubmissionReceipt,
22
+ type SubmitNotificationRequestT,
22
23
  } from '@raidiant/notifai-protocol'
23
24
  import { describe, expect, it, vi } from 'vitest'
24
25
  import { ApiCallError, type ApiClient } from './client.js'
@@ -54,11 +55,46 @@ import {
54
55
  readMatchingProjectSessionPointer,
55
56
  readProjectSession,
56
57
  readSessionState,
57
- registerQuestion,
58
+ registerQuestion as persistQuestion,
58
59
  writeProjectSession,
59
- writeSessionState,
60
+ writeSessionState as persistSessionState,
61
+ type PendingQuestion,
62
+ type SessionState,
60
63
  } from './hooks.js'
61
64
 
65
+ /** New-format test fixtures always carry the canonical body explicitly. */
66
+ function registerQuestion(
67
+ sessionId: string,
68
+ env: NodeJS.ProcessEnv,
69
+ question: PendingQuestion,
70
+ now?: number,
71
+ ): void {
72
+ persistQuestion(
73
+ sessionId,
74
+ env,
75
+ { ...question, body: question.body ?? question.question },
76
+ now,
77
+ )
78
+ }
79
+
80
+ function writeSessionState(
81
+ sessionId: string,
82
+ env: NodeJS.ProcessEnv,
83
+ state: SessionState,
84
+ ): void {
85
+ persistSessionState(sessionId, env, {
86
+ ...state,
87
+ ...(state.pending === undefined
88
+ ? {}
89
+ : {
90
+ pending: state.pending.map((entry) => ({
91
+ ...entry,
92
+ body: entry.body ?? entry.question,
93
+ })),
94
+ }),
95
+ })
96
+ }
97
+
62
98
  class CapturedIo implements CommandIo {
63
99
  outLines: string[] = []
64
100
  errLines: string[] = []
@@ -432,16 +468,38 @@ describe('pushing a registered question', () => {
432
468
  device_ids: ['dev_iphone', 'dev_mac'],
433
469
  })
434
470
  expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
435
- title: 'Question',
471
+ title: 'Ship it?',
436
472
  body: 'Ship it?',
437
473
  })
438
- expect(h.recorder.submitted[0]?.draft.platform).toEqual({
439
- ios: { sound: 'attention', interruption_level: 'active' },
440
- macos: { sound: 'attention', interruption_level: 'active' },
441
- })
474
+ expect(h.recorder.submitted[0]?.draft.platform).toBeUndefined()
475
+ })
476
+
477
+ it('keeps sparse alt text paired with its original media item', async () => {
478
+ const h = harness([reply({ text: 'Yes' })])
479
+ writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
480
+ registerQuestion(
481
+ 'media-order',
482
+ h.env,
483
+ {
484
+ question: 'Which image should ship?',
485
+ body: 'Compare ![the second image](media:med_second).',
486
+ media: [
487
+ { media_id: 'med_first' },
488
+ { media_id: 'med_second', alt: 'Second image' },
489
+ ],
490
+ },
491
+ NOW,
492
+ )
493
+
494
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'media-order' }))
495
+
496
+ expect(h.recorder.submitted[0]?.draft.presentation.media).toEqual([
497
+ { media_id: 'med_first' },
498
+ { media_id: 'med_second', alt: 'Second image' },
499
+ ])
442
500
  })
443
501
 
444
- it('names the resolved project in the pushed question title', async () => {
502
+ it('keeps Project identity out of the substantive question title', async () => {
445
503
  const h = harness([reply({ text: 'Yes' })])
446
504
  const projectDir = path.join(h.deps.cwd, '.notifai')
447
505
  mkdirSync(projectDir, { recursive: true })
@@ -450,11 +508,17 @@ describe('pushing a registered question', () => {
450
508
  'project = "notifai-cli"\nask_grace_seconds = 0\n',
451
509
  )
452
510
  writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
453
- registerQuestion('project-title', h.env, { question: 'Ship it?' }, NOW)
511
+ registerQuestion(
512
+ 'project-title',
513
+ h.env,
514
+ { question: 'Ship it?', project: 'notifai-cli' },
515
+ NOW,
516
+ )
454
517
 
455
518
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
456
519
 
457
- expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
520
+ expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Ship it?')
521
+ expect(h.recorder.submitted[0]?.draft.project).toBe('notifai-cli')
458
522
  })
459
523
  })
460
524
 
@@ -1900,7 +1964,6 @@ describe('a question that outlives its session', () => {
1900
1964
  question: 'Old?',
1901
1965
  state: 'expired',
1902
1966
  }],
1903
- undefined,
1904
1967
  NOW - 25 * 3600 * 1000,
1905
1968
  )
1906
1969
  const drained = await drainOrphanRetirements(
@@ -1962,7 +2025,7 @@ describe('ask registration', () => {
1962
2025
  const h = harness()
1963
2026
  // Inside a hook, a rejection is only a stderr note the agent never reads —
1964
2027
  // so it would look registered and then silently never ask.
1965
- expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
2028
+ expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], sessionId: 'a1' })).toBe(EXIT.usage)
1966
2029
  expect(readSessionState('a1', h.env).pending).toBeUndefined()
1967
2030
  })
1968
2031
 
@@ -1993,7 +2056,7 @@ describe('ask registration', () => {
1993
2056
  { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
1994
2057
  { text: 'Anything to watch?' },
1995
2058
  ],
1996
- detail: '## Context\nThe long story.',
2059
+ body: '## Context\nThe long story.',
1997
2060
  }) },
1998
2061
  undefined,
1999
2062
  )
@@ -2002,18 +2065,18 @@ describe('ask registration', () => {
2002
2065
  registerQuestion('form1', h.env, {
2003
2066
  question: built.questions[0]!.text,
2004
2067
  questions: built.questions,
2005
- ...(built.detail === undefined ? {} : { detail: built.detail }),
2068
+ body: built.body,
2006
2069
  })
2007
2070
  const pending = readSessionState('form1', h.env).pending?.[0]
2008
2071
  expect(pending?.questions).toHaveLength(2)
2009
- expect(pending?.detail).toContain('long story')
2072
+ expect(pending?.body).toContain('long story')
2010
2073
 
2011
2074
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
2012
2075
  const draft = h.recorder.submitted[0]?.draft
2013
- // The banner leads with the first question and admits the rest exists;
2014
- // the full set rides the payload for the answering card.
2015
- expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
2016
- expect(draft?.presentation.detail).toContain('long story')
2076
+ expect(draft?.presentation.title).toBe('Deploy where?')
2077
+ expect(draft?.presentation.body).toBe(
2078
+ '1. Deploy where?\n2. Anything to watch?\n\n## Context\nThe long story.',
2079
+ )
2017
2080
  expect(draft?.reply?.questions).toEqual([
2018
2081
  {
2019
2082
  id: 'deploy-where',
@@ -2108,7 +2171,7 @@ describe('ask registration', () => {
2108
2171
 
2109
2172
  it('does not let an explicit session bypass exact active-harness proof', () => {
2110
2173
  const h = harness()
2111
- expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
2174
+ expect(askCommand(h.deps, 'Ship it?', { sessionId: 'guessed' })).toBe(EXIT.usage)
2112
2175
  expect(readSessionState('guessed', h.env).pending).toBeUndefined()
2113
2176
  })
2114
2177
 
@@ -2529,21 +2592,46 @@ describe('malformed input', () => {
2529
2592
  })
2530
2593
 
2531
2594
  describe('telling concurrent agents apart', () => {
2532
- it('stamps the harness session on the question it pushes', async () => {
2533
- // The hook has always known session_id and never passed it on, so two
2534
- // agents in separate worktrees produced identical notifications and the
2535
- // user could answer the wrong one's question.
2595
+ it('stamps structured Source Context on the question it pushes', async () => {
2536
2596
  const h = harness([])
2537
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2597
+ registerQuestion(
2598
+ 'sess-abc',
2599
+ h.env,
2600
+ {
2601
+ question: 'Ship it?',
2602
+ source: {
2603
+ session_id: 'sess-abc',
2604
+ session_label: sessionLabelFromId('sess-abc'),
2605
+ harness: 'claude-code',
2606
+ },
2607
+ },
2608
+ NOW,
2609
+ )
2538
2610
 
2539
2611
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2540
2612
 
2541
- expect(h.recorder.submitted[0]?.draft.session).toBe('sess-abc')
2613
+ expect(h.recorder.submitted[0]?.draft.source).toEqual({
2614
+ session_id: 'sess-abc',
2615
+ session_label: sessionLabelFromId('sess-abc'),
2616
+ harness: 'claude-code',
2617
+ })
2542
2618
  })
2543
2619
 
2544
2620
  it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
2545
2621
  const h = harness([reply({ text: 'Yes' })])
2546
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2622
+ registerQuestion(
2623
+ 'sess-abc',
2624
+ h.env,
2625
+ {
2626
+ question: 'Ship it?',
2627
+ source: {
2628
+ session_id: 'sess-abc',
2629
+ session_label: sessionLabelFromId('sess-abc'),
2630
+ harness: 'claude-code',
2631
+ },
2632
+ },
2633
+ NOW,
2634
+ )
2547
2635
 
2548
2636
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2549
2637
  h.recorder.acknowledged ??= new Set()
@@ -2555,17 +2643,35 @@ describe('telling concurrent agents apart', () => {
2555
2643
  )
2556
2644
 
2557
2645
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
2558
- expect(retirement?.draft.session).toBe('sess-abc')
2646
+ expect(retirement?.draft.source).toEqual({
2647
+ session_id: 'sess-abc',
2648
+ session_label: sessionLabelFromId('sess-abc'),
2649
+ harness: 'claude-code',
2650
+ })
2559
2651
  })
2560
2652
 
2561
- it('prefers a name the user chose over the harness UUID', async () => {
2653
+ it('preserves a human session label without displaying the opaque id', async () => {
2562
2654
  const h = harness([])
2563
- h.env['NOTIFAI_SESSION'] = 'migration-worktree'
2564
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2655
+ registerQuestion(
2656
+ 'sess-abc',
2657
+ h.env,
2658
+ {
2659
+ question: 'Ship it?',
2660
+ source: {
2661
+ session_id: 'sess-abc',
2662
+ session_label: 'Migration Worktree',
2663
+ harness: 'claude-code',
2664
+ },
2665
+ },
2666
+ NOW,
2667
+ )
2565
2668
 
2566
2669
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2567
2670
 
2568
- expect(h.recorder.submitted[0]?.draft.session).toBe('migration-worktree')
2671
+ const draft = h.recorder.submitted[0]?.draft
2672
+ expect(draft?.source?.session_label).toBe('Migration Worktree')
2673
+ expect(draft?.presentation.title).not.toContain('sess-abc')
2674
+ expect(draft?.presentation.body).not.toContain('sess-abc')
2569
2675
  })
2570
2676
  })
2571
2677
 
@@ -2713,7 +2819,6 @@ describe('durable state writes', () => {
2713
2819
  state: 'expired',
2714
2820
  },
2715
2821
  ],
2716
- 'linked-pointer',
2717
2822
  NOW,
2718
2823
  ),
2719
2824
  ).toThrow(/symlink/)