@raidiant/notifai 8.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. package/src/url-policy.ts +399 -0
@@ -6,17 +6,18 @@ import {
6
6
  type ReplyView,
7
7
  type SubmissionReceipt,
8
8
  } from '@raidiant/notifai-protocol'
9
- import { randomBytes } from 'node:crypto'
10
9
  import { ApiCallError, NetworkError } from './client.js'
11
10
  import type { FlagOverrides, loadConfig } from './config.js'
12
- import { MIN_REPLY_WINDOW_SECONDS, readProjectSession, readSessionState } from './hooks.js'
11
+ import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
13
12
  import {
14
13
  buildDraft,
15
14
  formatReceipt,
16
15
  receiptExitCode,
16
+ rejectAccidentalEscapedNewlines,
17
17
  validateMediaInputs,
18
18
  type SendFlags,
19
19
  } from './send.js'
20
+ import { recordObservedDeliveryProof, setupProofProject } from './commands-setup-proof.js'
20
21
  import {
21
22
  EXIT,
22
23
  authedClient,
@@ -26,6 +27,13 @@ import {
26
27
  type CommandDeps,
27
28
  } from './commands-core.js'
28
29
  import { activeHarnessSession } from './commands-harness-context.js'
30
+ import { resolveCommandSession } from './command-session.js'
31
+ import {
32
+ beginSendAttempt,
33
+ semanticMediaIds,
34
+ sendDraftFingerprint,
35
+ settleSendAttempt,
36
+ } from './send-attempts.js'
29
37
  import {
30
38
  acknowledgementCommand,
31
39
  printAcknowledgementStatus,
@@ -47,9 +55,14 @@ export async function sendCommand(
47
55
  noWait?: boolean
48
56
  replyTimeout?: number
49
57
  idempotencyKey?: string
58
+ retry?: boolean
50
59
  baseUrl?: string
51
60
  },
52
61
  ): Promise<number> {
62
+ if (flags.retry === true && flags.idempotencyKey !== undefined) {
63
+ deps.io.err('Pass --retry or --idempotency-key, not both.')
64
+ return EXIT.usage
65
+ }
53
66
  const hasChoice = Array.isArray(flags.choice)
54
67
  ? flags.choice.length > 0
55
68
  : flags.choice !== undefined
@@ -63,7 +76,7 @@ export async function sendCommand(
63
76
  if (!flags.reply && flags.kind === undefined) {
64
77
  deps.io.err(
65
78
  '--kind is required: say what this notification is. ' +
66
- 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed). ' +
79
+ 'update (news) · done (finished) · failed (terminal failure) · blocked (cannot proceed; no User reply would resume it). ' +
67
80
  '--reply makes it a question without --kind.',
68
81
  )
69
82
  return EXIT.usage
@@ -111,6 +124,11 @@ export async function sendCommand(
111
124
  )
112
125
  return EXIT.usage
113
126
  }
127
+ const escapedBody = rejectAccidentalEscapedNewlines(flags.body, flags.literalBackslashN)
128
+ if (escapedBody !== null) {
129
+ deps.io.err(escapedBody)
130
+ return EXIT.usage
131
+ }
114
132
  const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
115
133
  if (mediaInputError !== null) {
116
134
  deps.io.err(mediaInputError)
@@ -132,14 +150,44 @@ export async function sendCommand(
132
150
  }
133
151
  const authed = authedClient(deps, config)
134
152
  if (!authed) return EXIT.auth
153
+ const credential = deps.store.load()!
154
+ let semanticImages: string[]
155
+ try {
156
+ semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
157
+ } catch (err) {
158
+ deps.io.err(`Could not fingerprint an image for safe retry: ${String(err)}`)
159
+ return EXIT.usage
160
+ }
161
+ const semanticBuild = buildDraft(
162
+ config,
163
+ { ...flags, ...(semanticImages.length > 0 ? { image: semanticImages } : {}) },
164
+ source.invocation,
165
+ )
166
+ if (!semanticBuild.ok) {
167
+ deps.io.err(semanticBuild.error)
168
+ return EXIT.usage
169
+ }
170
+ const attempt = beginSendAttempt({
171
+ env: deps.env,
172
+ credential,
173
+ fingerprint: sendDraftFingerprint(semanticBuild.draft, credential),
174
+ retry: flags.retry === true,
175
+ ...(flags.idempotencyKey === undefined ? {} : { idempotencyKey: flags.idempotencyKey }),
176
+ now: (deps.now ?? Date.now)(),
177
+ })
178
+ if (!attempt.ok) {
179
+ deps.io.err(`${attempt.code}: ${attempt.message}`)
180
+ return EXIT.usage
181
+ }
135
182
  const mediaIds: string[] = []
136
183
  for (const image of flags.image ?? []) {
137
184
  if (image.startsWith('med_')) {
138
185
  mediaIds.push(image)
139
186
  continue
140
187
  }
141
- const uploaded = await uploadImage(deps, authed.client, image)
188
+ const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
142
189
  if (!uploaded.ok) {
190
+ settleSendAttempt(deps.env, attempt.attemptId)
143
191
  if (uploaded.error !== null) deps.io.err(uploaded.error)
144
192
  return uploaded.exit
145
193
  }
@@ -148,16 +196,19 @@ export async function sendCommand(
148
196
  flags = { ...flags, image: mediaIds }
149
197
  const build = buildDraft(config, flags, source.invocation)
150
198
  if (!build.ok) {
199
+ settleSendAttempt(deps.env, attempt.attemptId)
151
200
  deps.io.err(build.error)
152
201
  return EXIT.usage
153
202
  }
154
203
  const capabilities = CAPABILITIES_V1.describe(build.platform)
155
204
  if (!capabilities) {
205
+ settleSendAttempt(deps.env, attempt.attemptId)
156
206
  deps.io.err(`No capability contract is available for ${build.platform}.`)
157
207
  return EXIT.usage
158
208
  }
159
209
  const validation = validateDraft(build.draft, capabilities)
160
210
  if (!validation.ok) {
211
+ settleSendAttempt(deps.env, attempt.attemptId)
161
212
  for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
162
213
  return EXIT.usage
163
214
  }
@@ -174,7 +225,15 @@ export async function sendCommand(
174
225
  )
175
226
  }
176
227
  const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
177
- const idempotencyKey = flags.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
228
+ const idempotencyKey = attempt.idempotencyKey
229
+ // Persist the retry identity before entering the ambiguous network boundary.
230
+ // If the process is killed after the server accepts but before a response,
231
+ // this is the only durable way to reconstruct the same Agent Event key.
232
+ log(deps).info('send.attempt', {
233
+ attempt_id: attempt.attemptId,
234
+ idempotency_key: idempotencyKey,
235
+ replay: attempt.replay,
236
+ })
178
237
  let receipt: SubmissionReceipt
179
238
  try {
180
239
  receipt = await authed.client.submit(
@@ -182,8 +241,27 @@ export async function sendCommand(
182
241
  waitSeconds,
183
242
  )
184
243
  } catch (err) {
185
- return reportError(deps, err)
244
+ const exit = reportError(deps, err)
245
+ if (exit === EXIT.network) {
246
+ deps.io.err(
247
+ 'The server may have accepted this Notification Request before the connection failed. ' +
248
+ 'Re-run the exact semantic send with `--retry`; the CLI will reuse its opaque attempt identity.',
249
+ )
250
+ }
251
+ if (err instanceof ApiCallError && err.code === 'no_active_devices') {
252
+ deps.io.err(
253
+ 'After setup succeeds, repeat this exact original Notification Request with `--retry`; ' +
254
+ 'the setup verification request does not replace this Agent Event.',
255
+ )
256
+ }
257
+ if (!(exit === EXIT.network || (err instanceof ApiCallError && err.code === 'no_active_devices'))) {
258
+ settleSendAttempt(deps.env, attempt.attemptId)
259
+ } else {
260
+ deps.io.err(`Opaque retry attempt: ${attempt.attemptId}. Re-run the exact semantic send with \`--retry\`; do not retry automatically.`)
261
+ }
262
+ return exit
186
263
  }
264
+ settleSendAttempt(deps.env, attempt.attemptId)
187
265
  const receiptExit = receiptExitCode(receipt)
188
266
  // The single most useful line in the log: it ties the local invocation to
189
267
  // the server-side request id, which is what every later question about this
@@ -191,7 +269,7 @@ export async function sendCommand(
191
269
  log(deps).info('send.submitted', {
192
270
  request_id: receipt.request_id,
193
271
  kind: flags.reply ? 'question' : (flags.kind ?? 'update'),
194
- title: flags.title,
272
+ title_chars: flags.title.length,
195
273
  overall: receipt.overall,
196
274
  replayed: receipt.replayed,
197
275
  agent_acknowledgement_required: receipt.agent_acknowledgement_required,
@@ -354,28 +432,64 @@ export async function repliesCommand(
354
432
  return EXIT.usage
355
433
  }
356
434
  let requestIds = requestedId === undefined ? [] : [requestedId]
435
+ let lifecycleSessionId: string | undefined
357
436
  if (flags.pending === true) {
358
- const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
359
- if (sessionId === null) {
437
+ const session = resolveCommandSession(deps)
438
+ if (session === null) {
360
439
  deps.io.err('No active session pointer is available in this directory.')
361
440
  return EXIT.noReply
362
441
  }
442
+ const sessionId = session.sessionId
443
+ lifecycleSessionId = sessionId
363
444
  // Every delivered question in the session's queue, in registration order —
364
445
  // an agent may have several outstanding at once.
365
446
  const state = readSessionState(sessionId, deps.env)
366
- requestIds = (Array.isArray(state.pending) ? state.pending : [])
447
+ const pending = Array.isArray(state.pending) ? state.pending : []
448
+ requestIds = pending
367
449
  .map((entry) => entry.request_id)
368
450
  .filter((id): id is string => id !== undefined)
451
+ const unsent = pending.filter((entry) => entry.request_id === undefined)
369
452
  if (requestIds.length === 0) {
370
453
  // `--json` is a promise about stdout, and an empty result is still a
371
454
  // result: a caller that asked for machine-readable output must not have
372
455
  // to parse an English sentence to learn there was nothing pending.
373
456
  if (flags.json === true) {
374
- deps.io.out(JSON.stringify({ session_id: sessionId, pending: [], replies: [] }, null, 2))
457
+ deps.io.out(
458
+ JSON.stringify(
459
+ {
460
+ session_id: sessionId,
461
+ pending: unsent.map((entry) => ({
462
+ question: entry.question,
463
+ ...(entry.question_id === undefined ? {} : { question_id: entry.question_id }),
464
+ })),
465
+ replies: [],
466
+ },
467
+ null,
468
+ 2,
469
+ ),
470
+ )
471
+ } else if (unsent.length > 0) {
472
+ for (const entry of unsent) {
473
+ deps.io.out(
474
+ entry.question_id === undefined
475
+ ? `unpushed question: ${entry.question}`
476
+ : `unpushed question ${entry.question_id}: ${entry.question}`,
477
+ )
478
+ }
479
+ deps.io.out('Retire one with `notifai close <question_id>` so a later Stop will not send it.')
375
480
  } else {
376
481
  deps.io.err(`Session ${sessionId} has no pushed question pending.`)
377
482
  }
378
- return EXIT.noReply
483
+ return unsent.length > 0 ? EXIT.ok : EXIT.noReply
484
+ }
485
+ if (unsent.length > 0 && flags.json !== true) {
486
+ for (const entry of unsent) {
487
+ deps.io.out(
488
+ entry.question_id === undefined
489
+ ? `unpushed question: ${entry.question}`
490
+ : `unpushed question ${entry.question_id}: ${entry.question}`,
491
+ )
492
+ }
379
493
  }
380
494
  }
381
495
  if (requestIds.length === 0) {
@@ -383,7 +497,11 @@ export async function repliesCommand(
383
497
  return EXIT.usage
384
498
  }
385
499
 
386
- const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
500
+ const config = loadLoggedConfig(deps, {
501
+ cwd: deps.cwd,
502
+ env: deps.env,
503
+ ...(lifecycleSessionId === undefined ? {} : { sessionId: lifecycleSessionId }),
504
+ })
387
505
  const authed = authedClient(deps, config)
388
506
  if (!authed) return EXIT.auth
389
507
  try {
@@ -527,8 +645,8 @@ function recordReplies(deps: CommandDeps, requestId: string, replies: readonly R
527
645
  request_id: requestId,
528
646
  sequence: reply.seq,
529
647
  device: reply.device_name,
530
- text: reply.text,
531
- answers: reply.answers,
648
+ text_chars: reply.text.length,
649
+ answers_count: reply.answers?.length ?? 0,
532
650
  source: reply.source,
533
651
  })
534
652
  }
@@ -587,11 +705,12 @@ export async function statusCommand(
587
705
  if (!authed) return EXIT.auth
588
706
  try {
589
707
  const snapshot = await authed.client.evidence(requestId)
708
+ recordObservedDeliveryProof(deps, snapshot, setupProofProject(deps, config.project.value))
590
709
  if (flags.json) {
591
710
  deps.io.out(JSON.stringify(snapshot, null, 2))
592
711
  return EXIT.ok
593
712
  }
594
- deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
713
+ deps.io.out(`request ${snapshot.request_id} — ${snapshot.overall}`)
595
714
  let anyReplyReceived = false
596
715
  for (const d of snapshot.deliveries) {
597
716
  deps.io.out(` ${d.device_name}:`)
@@ -0,0 +1,72 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import type { CommandDeps } from './commands-core.js'
6
+ import type { MachineCredential } from './credentials.js'
7
+ import { readSetupProof, setupProofApplies, writeSetupProof } from './commands-setup-proof.js'
8
+
9
+ const roots: string[] = []
10
+ afterEach(() => {
11
+ while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ function deps(root: string, cwd: string, credential: MachineCredential): CommandDeps {
15
+ return {
16
+ cwd,
17
+ env: { XDG_STATE_HOME: path.join(root, 'state'), XDG_CONFIG_HOME: path.join(root, 'config') },
18
+ io: { out: () => {}, err: () => {}, confirm: async () => false, openUrl: () => {} },
19
+ store: {
20
+ load: () => credential,
21
+ save: () => {},
22
+ clear: () => {},
23
+ describe: () => 'test',
24
+ },
25
+ }
26
+ }
27
+
28
+ describe('stable setup delivery proof identity', () => {
29
+ it('is shared by linked checkouts of one Project and separated by Project', () => {
30
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-identity-'))
31
+ roots.push(root)
32
+ const credential = {
33
+ machineId: 'mac_one',
34
+ machineName: 'One',
35
+ baseUrl: 'https://app.notifai.test',
36
+ secret: 'approval-one',
37
+ }
38
+ const first = deps(root, path.join(root, 'worktree-a'), credential)
39
+ const second = deps(root, path.join(root, 'worktree-b'), credential)
40
+ const proof = {
41
+ request_id: 'req_proof',
42
+ device_id: 'dev_one',
43
+ project: 'shared-project',
44
+ started_at: '2026-08-26T00:00:00.000Z',
45
+ }
46
+ expect(writeSetupProof(first, proof)).toBe(true)
47
+ expect(readSetupProof(second, 'shared-project')).toEqual(proof)
48
+ expect(readSetupProof(second, 'different-project')).toBeNull()
49
+ })
50
+
51
+ it('invalidates on service, machine approval, machine, or device change', () => {
52
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-invalidation-'))
53
+ roots.push(root)
54
+ const credential = {
55
+ machineId: 'mac_one',
56
+ machineName: 'One',
57
+ baseUrl: 'https://app.notifai.test',
58
+ secret: 'approval-one',
59
+ }
60
+ const original = deps(root, root, credential)
61
+ expect(writeSetupProof(original, {
62
+ request_id: 'req_proof',
63
+ device_id: 'dev_one',
64
+ project: 'project',
65
+ started_at: '2026-08-26T00:00:00.000Z',
66
+ })).toBe(true)
67
+ expect(readSetupProof(deps(root, root, { ...credential, baseUrl: 'https://other.test' }), 'project')).toBeNull()
68
+ expect(readSetupProof(deps(root, root, { ...credential, machineId: 'mac_two' }), 'project')).toBeNull()
69
+ expect(readSetupProof(deps(root, root, { ...credential, secret: 'approval-two' }), 'project')).toBeNull()
70
+ expect(setupProofApplies(readSetupProof(original, 'project'), 'project', ['dev_two'])).toBe(false)
71
+ })
72
+ })
@@ -4,34 +4,41 @@ import {
4
4
  existsSync,
5
5
  mkdirSync,
6
6
  readFileSync,
7
- realpathSync,
8
7
  writeFileSync,
9
8
  } from 'node:fs'
10
9
  import path from 'node:path'
11
10
  import { stateDir } from './config.js'
12
11
  import type { CommandDeps } from './commands-core.js'
12
+ import { inferInvocationContext } from './invocation-context.js'
13
13
 
14
- interface SetupProofRecord {
14
+ export interface SetupProofRecord {
15
15
  request_id: string
16
16
  device_id: string
17
17
  project: string | null
18
18
  started_at: string
19
19
  }
20
20
 
21
- function setupProofPath(deps: CommandDeps): string {
22
- let projectDir = path.resolve(deps.cwd)
23
- try {
24
- projectDir = realpathSync(projectDir)
25
- } catch {
26
- // A deleted or not-yet-created cwd cannot collide with a real directory:
27
- // the resolved absolute path is still a stable local identity for it.
28
- }
29
- const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
21
+ export function setupProofProject(deps: CommandDeps, configured: string | null): string | null {
22
+ return configured ?? inferInvocationContext(deps.cwd).project
23
+ }
24
+
25
+ function setupProofPath(deps: CommandDeps, project: string | null): string | null {
26
+ const credential = deps.store.load()
27
+ if (credential === null) return null
28
+ const approval = createHash('sha256')
29
+ .update('notifai-setup-proof-approval-v1\0')
30
+ .update(credential.secret)
31
+ .digest('base64url')
32
+ const digest = createHash('sha256')
33
+ .update(JSON.stringify({ project, machine_id: credential.machineId, service: credential.baseUrl, approval }))
34
+ .digest('hex')
35
+ .slice(0, 32)
30
36
  return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
31
37
  }
32
38
 
33
- export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
34
- const file = setupProofPath(deps)
39
+ export function readSetupProof(deps: CommandDeps, project: string | null): SetupProofRecord | null {
40
+ const file = setupProofPath(deps, project)
41
+ if (file === null) return null
35
42
  if (!existsSync(file)) return null
36
43
  try {
37
44
  const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
@@ -48,7 +55,11 @@ export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
48
55
  }
49
56
 
50
57
  export function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
51
- const file = setupProofPath(deps)
58
+ const file = setupProofPath(deps, proof.project)
59
+ if (file === null) {
60
+ deps.io.err('Could not save setup proof without an Approved Machine credential.')
61
+ return false
62
+ }
52
63
  try {
53
64
  mkdirSync(path.dirname(file), { recursive: true })
54
65
  writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
@@ -66,6 +77,48 @@ export function observedCompanionReceipt(
66
77
  deviceId: string,
67
78
  ): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
68
79
  const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId)
69
- const receipt = delivery?.events.find((event) => event.stage === 'companion_received')
70
- return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null
80
+ if (!delivery) return null
81
+ if (delivery.companion_receipt.state === 'observed' && delivery.companion_receipt.observed_at) {
82
+ return { delivery, observedAt: delivery.companion_receipt.observed_at }
83
+ }
84
+ const receipt = delivery.events.find((event) => event.stage === 'companion_received')
85
+ return receipt ? { delivery, observedAt: receipt.occurred_at } : null
86
+ }
87
+
88
+ /** Replace an unknown setup proof after this age; recent in-flight proofs stay. */
89
+ export const SETUP_PROOF_STALE_MS = 24 * 60 * 60 * 1000
90
+
91
+ export function setupProofIsStale(proof: SetupProofRecord, now: number): boolean {
92
+ const started = Date.parse(proof.started_at)
93
+ return Number.isFinite(started) && now - started > SETUP_PROOF_STALE_MS
94
+ }
95
+
96
+ export function setupProofApplies(
97
+ proof: SetupProofRecord | null,
98
+ project: string | null,
99
+ deviceIds: readonly string[],
100
+ ): proof is SetupProofRecord {
101
+ return proof !== null && proof.project === project && deviceIds.includes(proof.device_id)
102
+ }
103
+
104
+ /**
105
+ * Persist ordinary send/status Companion Receipts as the project's delivery
106
+ * proof so doctor does not demand a second verification request.
107
+ */
108
+ export function recordObservedDeliveryProof(
109
+ deps: CommandDeps,
110
+ snapshot: EvidenceSnapshot,
111
+ project: string | null,
112
+ ): boolean {
113
+ for (const delivery of snapshot.deliveries) {
114
+ const observed = observedCompanionReceipt(snapshot, delivery.device_id)
115
+ if (observed === null) continue
116
+ return writeSetupProof(deps, {
117
+ request_id: snapshot.request_id,
118
+ device_id: delivery.device_id,
119
+ project,
120
+ started_at: observed.observedAt,
121
+ })
122
+ }
123
+ return false
71
124
  }
@@ -1,4 +1,4 @@
1
- import type { NativeSkill, SkillScope } from './native-skills.js'
1
+ import { SKILLS_INSTALLER_SPEC, type NativeSkill, type SkillScope } from './native-skills.js'
2
2
  import { type ReadinessState } from './readiness.js'
3
3
  import { skillsSource } from './release.js'
4
4
  import type { CommandDeps } from './commands-core.js'
@@ -18,6 +18,8 @@ export const SKILLS_SOURCE: string | null = skillsSource()
18
18
  */
19
19
  const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching this CLI'
20
20
 
21
+ const SKILL_SCOPES: readonly SkillScope[] = ['project', 'global']
22
+
21
23
  function skillSourceParts(): { source: string; ref: string } | null {
22
24
  if (SKILLS_SOURCE === null) return null
23
25
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -35,13 +37,23 @@ function expectedSkill(skill: NativeSkill): boolean {
35
37
  )
36
38
  }
37
39
 
38
- export async function skillReadiness(
39
- deps: CommandDeps,
40
- selectedScope?: SkillScope,
41
- ): Promise<ReadinessState> {
42
- const scopes: SkillScope[] = selectedScope === undefined ? ['project', 'global'] : [selectedScope]
40
+ function skillPin(skill: NativeSkill): string {
41
+ return skill.ref ?? 'unknown pin'
42
+ }
43
+
44
+ export interface ScopedNotifaiSkills {
45
+ installed: NativeSkill[]
46
+ errors: string[]
47
+ }
48
+
49
+ /**
50
+ * Installer-managed notifai skills in both scopes. Duplicate detection has to
51
+ * see the pair; a selected-scope filter would hide the copy the harness still
52
+ * lists.
53
+ */
54
+ export async function listScopedNotifaiSkills(deps: CommandDeps): Promise<ScopedNotifaiSkills> {
43
55
  const results = await Promise.all(
44
- scopes.map(async (scope) => {
56
+ SKILL_SCOPES.map(async (scope) => {
45
57
  if (deps.nativeSkills === undefined) return { scope, skills: [] as NativeSkill[] }
46
58
  try {
47
59
  return { scope, ...(await deps.nativeSkills.list(scope, deps.cwd, deps.env)) }
@@ -50,19 +62,70 @@ export async function skillReadiness(
50
62
  }
51
63
  }),
52
64
  )
53
- const installed = results.flatMap(({ skills }) => skills).find(expectedSkill)
54
- if (installed !== undefined) {
65
+ return {
66
+ installed: results.flatMap(({ skills }) => skills.filter((skill) => skill.name === 'notifai')),
67
+ errors: results
68
+ .filter((result) => result.error !== undefined)
69
+ .map((result) => `${result.scope}: ${result.error}`),
70
+ }
71
+ }
72
+
73
+ function duplicateSkillState(installed: NativeSkill[], selectedScope?: SkillScope): ReadinessState {
74
+ const project = installed.find((skill) => skill.scope === 'project')
75
+ const global = installed.find((skill) => skill.scope === 'global')
76
+ const projectPin = project === undefined ? 'not installed' : skillPin(project)
77
+ const globalPin = global === undefined ? 'not installed' : skillPin(global)
78
+ return {
79
+ id: 'skill',
80
+ title: 'Agent guidance skill',
81
+ status: 'gap',
82
+ detail:
83
+ `project (${projectPin}) and global (${globalPin}) are both installed, so the harness lists both. ` +
84
+ 'Keep either project or global and uninstall the other.',
85
+ technical: {
86
+ project:
87
+ project === undefined
88
+ ? null
89
+ : { ref: project.ref, path: project.path, current: expectedSkill(project) },
90
+ global:
91
+ global === undefined
92
+ ? null
93
+ : { ref: global.ref, path: global.path, current: expectedSkill(global) },
94
+ resolution: 'both-listed',
95
+ },
96
+ remedy:
97
+ selectedScope === undefined
98
+ ? {
99
+ by: 'user-here',
100
+ summary:
101
+ 'keep either the project or the machine-global skill and uninstall the other; add --global to remove the machine-global copy',
102
+ command: `npx -y ${SKILLS_INSTALLER_SPEC} remove notifai`,
103
+ }
104
+ : {
105
+ by: 'cli',
106
+ summary: `keep the ${selectedScope} skill and uninstall the other`,
107
+ command: `notifai init --skills --setup-scope ${selectedScope}`,
108
+ },
109
+ }
110
+ }
111
+
112
+ export async function skillReadiness(
113
+ deps: CommandDeps,
114
+ selectedScope?: SkillScope,
115
+ ): Promise<ReadinessState> {
116
+ const { installed, errors } = await listScopedNotifaiSkills(deps)
117
+ if (installed.length > 1) return duplicateSkillState(installed, selectedScope)
118
+
119
+ const current = installed.find(expectedSkill)
120
+ if (current !== undefined) {
55
121
  return {
56
122
  id: 'skill',
57
123
  title: 'Agent guidance skill',
58
124
  status: 'ready',
59
- detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${installed.scope} scope`,
125
+ detail: `installed from ${SKILLS_SOURCE_LABEL} in the ${current.scope} scope`,
60
126
  }
61
127
  }
62
128
 
63
- const errors = results
64
- .filter((result) => result.error !== undefined)
65
- .map((result) => `${result.scope}: ${result.error}`)
66
129
  const scopeText = selectedScope === undefined ? 'project or machine-global scope' : `${selectedScope} scope`
67
130
  return {
68
131
  id: 'skill',
@@ -77,8 +140,8 @@ export async function skillReadiness(
77
140
  summary: 'install the skill agents follow when deciding to notify',
78
141
  command:
79
142
  selectedScope === undefined
80
- ? 'notifai init --skills'
81
- : `notifai init --skills --skills-scope ${selectedScope}`,
143
+ ? 'notifai init --skills --setup-scope project'
144
+ : `notifai init --skills --setup-scope ${selectedScope}`,
82
145
  },
83
146
  }
84
147
  }