@raidiant/notifai 0.1.0 → 0.1.2

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/src/hooks.test.ts CHANGED
@@ -523,6 +523,37 @@ describe('superseding a live question', () => {
523
523
  expect(readSessionState('sup2', h.env).retiring).toEqual([])
524
524
  })
525
525
 
526
+ it('keeps the original delivery targets when routing changes before retirement', async () => {
527
+ const h = harness([])
528
+ writeSessionState('sup-targets', h.env, { last_prompt_at: AWAY })
529
+ registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
530
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
531
+
532
+ const live = readSessionState('sup-targets', h.env).pending
533
+ expect(live).toMatchObject({
534
+ request_id: 'req_hook_1',
535
+ device_ids: ['dev_iphone', 'dev_mac'],
536
+ })
537
+
538
+ // The user changes their default routing before the agent supersedes the
539
+ // question. Retirement belongs to the Deliveries that actually carried the
540
+ // first question, not to whichever Device Installation is selected now.
541
+ const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
542
+ mkdirSync(configDir, { recursive: true })
543
+ writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
544
+ registerQuestion('sup-targets', h.env, { question: 'Deploy it?' })
545
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
546
+
547
+ const retirement = h.recorder.submitted.find(
548
+ (submission) => submission.draft.event === 'question_retired',
549
+ )
550
+ expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_hook_1')
551
+ expect(retirement?.draft.targets).toEqual({
552
+ mode: 'selected',
553
+ device_ids: ['dev_iphone', 'dev_mac'],
554
+ })
555
+ })
556
+
526
557
  it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
527
558
  // stop_hook_active short-circuits the escalation path, and a superseding
528
559
  // turn is very often exactly this turn.
@@ -641,7 +672,13 @@ describe('a question that outlives its session', () => {
641
672
  const h = harness([])
642
673
  orphanRetirements(
643
674
  h.env,
644
- [{ request_id: 'req_old', collapse_key: 'ck_old', question: 'Old?', state: 'expired' }],
675
+ [{
676
+ request_id: 'req_old',
677
+ collapse_key: 'ck_old',
678
+ device_ids: ['dev_iphone'],
679
+ question: 'Old?',
680
+ state: 'expired',
681
+ }],
645
682
  undefined,
646
683
  NOW - 25 * 3600 * 1000,
647
684
  )
@@ -735,6 +772,23 @@ describe('ask registration', () => {
735
772
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
736
773
  expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
737
774
  })
775
+
776
+ it('fails explicitly instead of rerouting incomplete live state', () => {
777
+ const h = harness()
778
+ writeSessionState('incomplete', h.env, {
779
+ pending: {
780
+ question: 'Original?',
781
+ request_id: 'req_original',
782
+ collapse_key: 'collapse-original',
783
+ },
784
+ })
785
+
786
+ expect(askCommand(h.deps, 'Replacement?', { session: 'incomplete' })).toBe(EXIT.failed)
787
+ expect(h.io.errLines.join('\n')).toContain(
788
+ 'refusing to retire it without request, collapse, and device identifiers',
789
+ )
790
+ expect(readSessionState('incomplete', h.env).pending?.question).toBe('Original?')
791
+ })
738
792
  })
739
793
 
740
794
  describe('user-prompt-submit hook', () => {
@@ -744,6 +798,59 @@ describe('user-prompt-submit hook', () => {
744
798
  expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
745
799
  })
746
800
 
801
+ it('preserves a delivered question through ask replacement and prompt transition', async () => {
802
+ const h = harness([], 900)
803
+ await hookRunCommand(
804
+ h.deps,
805
+ 'user-prompt-submit',
806
+ stdin({ session_id: 'transition', cwd: h.deps.cwd }),
807
+ )
808
+ expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
809
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
810
+
811
+ const live = readSessionState('transition', h.env).pending
812
+ expect(live).toMatchObject({
813
+ question: 'Ship it?',
814
+ request_id: 'req_hook_1',
815
+ device_ids: ['dev_iphone', 'dev_mac'],
816
+ })
817
+ expect(live?.collapse_key).toMatch(/^notifai-hook-/)
818
+
819
+ // A second plain `ask` resolves through the project pointer and supersedes
820
+ // the live question. The next prompt then resets presence before another
821
+ // Stop can run — the transition that used to erase the retirement debt.
822
+ expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
823
+ expect(readSessionState('transition', h.env).retiring).toEqual([
824
+ {
825
+ request_id: live?.request_id,
826
+ collapse_key: live?.collapse_key,
827
+ device_ids: ['dev_iphone', 'dev_mac'],
828
+ question: 'Ship it?',
829
+ state: 'superseded',
830
+ },
831
+ ])
832
+
833
+ await hookRunCommand(
834
+ h.deps,
835
+ 'user-prompt-submit',
836
+ stdin({ session_id: 'transition', cwd: h.deps.cwd }),
837
+ )
838
+
839
+ const retirement = h.recorder.submitted.find(
840
+ (submission) => submission.draft.event === 'question_retired',
841
+ )?.draft
842
+ expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
843
+ expect(retirement?.lifecycle).toEqual({
844
+ tier: 'done',
845
+ state: 'superseded',
846
+ retires_request_id: live?.request_id,
847
+ })
848
+ expect(retirement?.targets).toEqual({
849
+ mode: 'selected',
850
+ device_ids: ['dev_iphone', 'dev_mac'],
851
+ })
852
+ })
853
+
747
854
  it('retires a question a real timed-out Stop left live on the devices', async () => {
748
855
  // Drives the actual flow rather than hand-writing state: the previous
749
856
  // version of this test fabricated a shape production never wrote, so it
@@ -832,6 +939,24 @@ describe('session-end hook', () => {
832
939
  expect(readSessionState('s12', h.env)).toEqual({})
833
940
  expect(h.recorder.closed).toEqual([])
834
941
  })
942
+
943
+ it('preserves incomplete live state and reports why it cannot retire it', async () => {
944
+ const h = harness()
945
+ writeSessionState('s-incomplete', h.env, {
946
+ pending: {
947
+ question: 'Still live?',
948
+ request_id: 'req_incomplete',
949
+ collapse_key: 'collapse-incomplete',
950
+ },
951
+ })
952
+
953
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
954
+
955
+ expect(readSessionState('s-incomplete', h.env).pending?.request_id).toBe('req_incomplete')
956
+ expect(h.io.errLines.join('\n')).toContain(
957
+ 'refusing to retire it without request, collapse, and device identifiers',
958
+ )
959
+ })
835
960
  })
836
961
 
837
962
  describe('malformed input', () => {
package/src/hooks.ts CHANGED
@@ -67,6 +67,8 @@ export interface SessionState {
67
67
  export interface RetiringQuestion {
68
68
  request_id: string
69
69
  collapse_key: string
70
+ /** The Device Installations that actually received the question. */
71
+ device_ids: string[]
70
72
  /** Shown if the companion has no history entry to correlate against. */
71
73
  question: string
72
74
  state: LifecycleEndState
@@ -118,6 +120,8 @@ export interface PendingQuestion {
118
120
  /** Set once the question has actually been pushed, so it can be retired. */
119
121
  request_id?: string
120
122
  collapse_key?: string
123
+ /** Exact fanout of the live question; routing config may change afterwards. */
124
+ device_ids?: string[]
121
125
  }
122
126
 
123
127
  /** How often the grace window rechecks whether the user has come back. */
@@ -649,24 +653,56 @@ export function parkForRetirement(
649
653
  pending: PendingQuestion,
650
654
  state: LifecycleEndState,
651
655
  ): void {
652
- if (pending.request_id === undefined || pending.collapse_key === undefined) return
656
+ const retirement = retiringQuestion(pending, state)
657
+ if (retirement === null) return
653
658
  const current = readSessionState(sessionId, env)
654
659
  const already = current.retiring ?? []
655
- if (already.some((entry) => entry.request_id === pending.request_id)) return
660
+ if (already.some((entry) => entry.request_id === retirement.request_id)) return
656
661
  writeSessionState(sessionId, env, {
657
662
  ...current,
658
- retiring: [
659
- ...already,
660
- {
661
- request_id: pending.request_id,
662
- collapse_key: pending.collapse_key,
663
- question: pending.question,
664
- state,
665
- },
666
- ],
663
+ retiring: [...already, retirement],
667
664
  })
668
665
  }
669
666
 
667
+ /**
668
+ * Convert live question state into a complete retirement instruction.
669
+ *
670
+ * A request/collapse pair without the original Delivery targets is not safe to
671
+ * send: re-resolving today's routing can miss a device that still shows the
672
+ * question or retire a device that never received it. Fail explicitly and keep
673
+ * the pending record instead of silently broadening or narrowing the fanout.
674
+ */
675
+ function retiringQuestion(
676
+ pending: PendingQuestion,
677
+ state: LifecycleEndState,
678
+ ): RetiringQuestion | null {
679
+ const hasRequest = pending.request_id !== undefined
680
+ const hasCollapse = pending.collapse_key !== undefined
681
+ const hasDevices = pending.device_ids !== undefined && pending.device_ids.length > 0
682
+ if (!hasRequest && !hasCollapse && !hasDevices) return null
683
+ if (!hasRequest || !hasCollapse || !hasDevices) {
684
+ throw new Error(
685
+ 'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
686
+ )
687
+ }
688
+ return {
689
+ request_id: pending.request_id!,
690
+ collapse_key: pending.collapse_key!,
691
+ device_ids: [...pending.device_ids!],
692
+ question: pending.question,
693
+ state,
694
+ }
695
+ }
696
+
697
+ function retirementDeviceIds(entry: RetiringQuestion): string[] {
698
+ if (!Array.isArray(entry.device_ids) || entry.device_ids.length === 0) {
699
+ throw new Error(
700
+ `retirement for ${entry.request_id} is missing its device identifiers; refusing to re-resolve routing`,
701
+ )
702
+ }
703
+ return entry.device_ids
704
+ }
705
+
670
706
  /**
671
707
  * Send every parked retirement, and forget the ones that landed. A failure
672
708
  * leaves the entry in place for the next hook rather than losing it, which is
@@ -692,7 +728,7 @@ export async function drainRetirements(
692
728
  entry.question,
693
729
  entry.state,
694
730
  entry.request_id,
695
- undefined,
731
+ retirementDeviceIds(entry),
696
732
  session,
697
733
  )
698
734
  if (sent) retired.push(entry.request_id)
@@ -777,7 +813,7 @@ export async function drainOrphanRetirements(
777
813
  entry.question,
778
814
  entry.state,
779
815
  entry.request_id,
780
- undefined,
816
+ retirementDeviceIds(entry),
781
817
  entry.session,
782
818
  )
783
819
  if (sent) done.push(entry.request_id)
@@ -811,18 +847,19 @@ export async function handleUserPromptSubmit(
811
847
 
812
848
  const state = readSessionState(sessionId, ctx.env)
813
849
  const pending = state.pending
814
- // Deliberately drops `pending` — the user is here, so the question is the
815
- // terminal's now. `retiring` is carried across: it is the only record of
816
- // notifications still live on the devices, and this reset used to erase it.
850
+ // Park before dropping `pending`. If the process dies between these writes,
851
+ // the next hook sees both copies and dedupes them; the old order could die in
852
+ // the gap after erasing the only request/collapse/device identifiers.
853
+ if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
854
+ const parked = readSessionState(sessionId, ctx.env)
817
855
  writeSessionState(sessionId, ctx.env, {
818
856
  last_prompt_at: ctx.now(),
819
- ...(state.retiring !== undefined ? { retiring: state.retiring } : {}),
857
+ ...(parked.retiring !== undefined ? { retiring: parked.retiring } : {}),
820
858
  })
821
859
  // The bridge that lets a plain `notifai ask` find its own session: an agent
822
860
  // shell command gets no hook payload and no harness exports the id.
823
861
  if (envelope.cwd !== undefined) writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now())
824
862
 
825
- if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
826
863
  const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
827
864
  const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
828
865
  const swept = [...retired, ...orphaned]
@@ -938,7 +975,12 @@ async function escalate(
938
975
  // snapshot in `state` predates it.
939
976
  writeSessionState(sessionId, ctx.env, {
940
977
  ...readSessionState(sessionId, ctx.env),
941
- pending: { ...pending, request_id: live.requestId, collapse_key: live.collapseKey },
978
+ pending: {
979
+ ...pending,
980
+ request_id: live.requestId,
981
+ collapse_key: live.collapseKey,
982
+ device_ids: live.devices,
983
+ },
942
984
  })
943
985
  },
944
986
  })
@@ -962,7 +1004,12 @@ async function escalate(
962
1004
  // set is also what stops the next Stop pushing the same question again.
963
1005
  writeSessionState(sessionId, ctx.env, {
964
1006
  ...carried,
965
- pending: { ...pending, request_id: asked.requestId, collapse_key: asked.collapseKey },
1007
+ pending: {
1008
+ ...pending,
1009
+ request_id: asked.requestId,
1010
+ collapse_key: asked.collapseKey,
1011
+ device_ids: asked.devices,
1012
+ },
966
1013
  })
967
1014
  notes.push(
968
1015
  asked.degraded
@@ -1023,13 +1070,16 @@ export function handleSessionEnd(
1023
1070
  const state = readSessionState(sessionId, env)
1024
1071
  const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
1025
1072
  const pending = state.pending
1026
- if (pending?.request_id !== undefined && pending.collapse_key !== undefined) {
1027
- orphans.push({
1028
- request_id: pending.request_id,
1029
- collapse_key: pending.collapse_key,
1030
- question: pending.question,
1031
- state: 'expired',
1032
- })
1073
+ if (pending !== undefined) {
1074
+ try {
1075
+ const orphan = retiringQuestion(pending, 'expired')
1076
+ if (orphan !== null) orphans.push(orphan)
1077
+ } catch (err) {
1078
+ notes.push(err instanceof Error ? err.message : String(err))
1079
+ // Preserve the only identifiers instead of turning an explicit corrupt
1080
+ // state into an unretirable question during SessionEnd cleanup.
1081
+ return { notes }
1082
+ }
1033
1083
  }
1034
1084
  if (orphans.length > 0) {
1035
1085
  const label = env['NOTIFAI_SESSION']
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  HARNESSES,
24
24
  applyPlan,
25
+ detectHarness,
25
26
  buildHookConfig,
26
27
  codexLayerDir,
27
28
  codexProjectRoot,
@@ -498,3 +499,79 @@ describe('the OpenCode adapter', () => {
498
499
  expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst EXEC = whatever\n`)).toBeNull()
499
500
  })
500
501
  })
502
+
503
+ describe('detectHarness', () => {
504
+ function project(...entries: string[]): string {
505
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-'))
506
+ for (const entry of entries) {
507
+ if (entry.endsWith('.md')) writeFileSync(path.join(dir, entry), '# project\n')
508
+ else mkdirSync(path.join(dir, entry), { recursive: true })
509
+ }
510
+ return dir
511
+ }
512
+
513
+ it('detects Claude Code from a project .claude directory', () => {
514
+ expect(detectHarness(project('.claude'))).toBe('claude-code')
515
+ })
516
+
517
+ it('detects Claude Code from CLAUDE.md alone', () => {
518
+ // A repository worked in daily through Claude Code may never accumulate a
519
+ // .claude/ directory; its project file is the only marker there is.
520
+ expect(detectHarness(project('CLAUDE.md'))).toBe('claude-code')
521
+ })
522
+
523
+ it('detects Claude Code from .claude and CLAUDE.md together without double-counting', () => {
524
+ expect(detectHarness(project('.claude', 'CLAUDE.md'))).toBe('claude-code')
525
+ })
526
+
527
+ it('does not infer a harness from AGENTS.md', () => {
528
+ // AGENTS.md began as a Codex convention and is now read by most agent
529
+ // tooling, Claude Code included. Naming a harness from it would be a guess
530
+ // wearing detection's clothes. With no other project evidence this falls
531
+ // through to machine evidence, which on a multi-tool machine is ambiguous.
532
+ const dir = project('AGENTS.md')
533
+ const answer = detectHarness(dir)
534
+ expect(answer).not.toBe('codex')
535
+ expect(HARNESSES.includes(answer as never) || answer === null).toBe(true)
536
+ })
537
+
538
+ it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
539
+ // The shape this very repository has, and four of the five projects the
540
+ // release was verified in.
541
+ expect(detectHarness(project('CLAUDE.md', 'AGENTS.md'))).toBe('claude-code')
542
+ })
543
+
544
+ it('detects each harness from its own project directory', () => {
545
+ expect(detectHarness(project('.codex'))).toBe('codex')
546
+ expect(detectHarness(project('.cursor'))).toBe('cursor')
547
+ expect(detectHarness(project('.opencode'))).toBe('opencode')
548
+ })
549
+
550
+ it('returns null when the project itself names two harnesses', () => {
551
+ // Genuine ambiguity, and the caller must ask rather than pick. Not the
552
+ // same as having no evidence.
553
+ expect(detectHarness(project('.claude', '.codex'))).toBeNull()
554
+ })
555
+
556
+ it('prefers project evidence over anything installed on the machine', () => {
557
+ // The regression. Machine-global markers used to be OR-ed in per harness,
558
+ // so a developer with several agent tools installed got a candidate for
559
+ // every one of them, the "exactly one" test never passed, and detection
560
+ // returned null in every repository on that machine — including ones whose
561
+ // own contents named a single harness unambiguously.
562
+ //
563
+ // This asserts the ordering rather than the machine state, so it holds on
564
+ // a CI box with a bare home directory and on a developer laptop carrying
565
+ // all four.
566
+ expect(detectHarness(project('.cursor'))).toBe('cursor')
567
+ expect(detectHarness(project('CLAUDE.md'))).toBe('claude-code')
568
+ })
569
+
570
+ it('falls back to machine evidence only when the project offers none', () => {
571
+ const empty = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-empty-'))
572
+ // Whatever this machine has, an empty project must never crash and must
573
+ // return either a single harness or null — never an arbitrary pick.
574
+ const answer = detectHarness(empty)
575
+ expect(answer === null || HARNESSES.includes(answer)).toBe(true)
576
+ })
577
+ })
@@ -342,24 +342,63 @@ export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: s
342
342
  }
343
343
 
344
344
  /** Best-effort detection so `hooks install` usually needs no flags. */
345
- export function detectHarness(cwd: string): Harness | null {
345
+ /**
346
+ * Harnesses this *project* shows evidence of, in the working directory only.
347
+ *
348
+ * `CLAUDE.md` counts because it is Claude Code's own project file and is
349
+ * frequently the only marker: a repository can be worked in daily through
350
+ * Claude Code and never accumulate a `.claude/` directory.
351
+ *
352
+ * `AGENTS.md` deliberately counts for nothing. It began as a Codex
353
+ * convention and is now read by most agent tooling — including Claude Code,
354
+ * and including this repository, where the two filenames are the same
355
+ * document. Treating it as evidence of any one harness would be a guess
356
+ * dressed up as detection.
357
+ */
358
+ function localHarnessEvidence(cwd: string): Harness[] {
346
359
  const found: Harness[] = []
347
- if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(os.homedir(), '.claude'))) {
360
+ if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
348
361
  found.push('claude-code')
349
362
  }
350
- if (existsSync(path.join(cwd, '.codex')) || existsSync(path.join(os.homedir(), '.codex'))) {
351
- found.push('codex')
352
- }
353
- if (existsSync(path.join(cwd, '.cursor')) || existsSync(path.join(os.homedir(), '.cursor'))) {
354
- found.push('cursor')
355
- }
356
- if (
357
- existsSync(path.join(cwd, '.opencode')) ||
358
- existsSync(path.join(os.homedir(), '.config', 'opencode'))
359
- ) {
360
- found.push('opencode')
361
- }
362
- return found.length === 1 ? found[0]! : null
363
+ if (existsSync(path.join(cwd, '.codex'))) found.push('codex')
364
+ if (existsSync(path.join(cwd, '.cursor'))) found.push('cursor')
365
+ if (existsSync(path.join(cwd, '.opencode'))) found.push('opencode')
366
+ return found
367
+ }
368
+
369
+ /** Harnesses installed anywhere on this machine — a much weaker signal. */
370
+ function globalHarnessEvidence(): Harness[] {
371
+ const home = os.homedir()
372
+ const found: Harness[] = []
373
+ if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
374
+ if (existsSync(path.join(home, '.codex'))) found.push('codex')
375
+ if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
376
+ if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
377
+ return found
378
+ }
379
+
380
+ /**
381
+ * Which harness to wire in this directory, or null when it is genuinely
382
+ * unclear.
383
+ *
384
+ * Project evidence decides, and machine evidence is consulted only when the
385
+ * project offers none. The two were previously OR-ed together per harness,
386
+ * which meant a developer with several agent tools installed could never get
387
+ * a detection at all: every `~/.<tool>` directory contributed a candidate, the
388
+ * "exactly one" test never passed, and the answer was null in every repository
389
+ * on the machine. Verified on a machine carrying all four — `notifai init
390
+ * --hooks` failed in five separate projects, each of which plainly used Claude
391
+ * Code.
392
+ *
393
+ * Having a tool installed says nothing about which one *this* project is
394
+ * worked in. Ambiguity within the project is still ambiguity, and returns
395
+ * null so the caller can ask rather than guess.
396
+ */
397
+ export function detectHarness(cwd: string): Harness | null {
398
+ const local = localHarnessEvidence(cwd)
399
+ if (local.length > 0) return local.length === 1 ? local[0]! : null
400
+ const global = globalHarnessEvidence()
401
+ return global.length === 1 ? global[0]! : null
363
402
  }
364
403
 
365
404
  interface SettingsDocument {
package/src/main.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
3
5
  import { Command } from 'commander'
4
6
  import {
5
7
  askCommand,
@@ -62,9 +64,30 @@ async function readStdin(timeoutMs = 2000, maxBytes = 1_000_000): Promise<string
62
64
  })
63
65
  }
64
66
 
67
+ /**
68
+ * One source of truth for the version: the manifest npm actually published.
69
+ *
70
+ * It was hardcoded here as well, and the first patch release proved why that
71
+ * does not hold — `package.json` moved and `notifai --version` kept reporting
72
+ * the old number, which is exactly the string someone pastes into a bug report
73
+ * to say what they are running.
74
+ */
75
+ function version(): string {
76
+ try {
77
+ const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
78
+ const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
79
+ if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { version?: unknown }).version === 'string') {
80
+ return (parsed as { version: string }).version
81
+ }
82
+ } catch {
83
+ // Fall through: an unreadable manifest must not stop the CLI running.
84
+ }
85
+ return 'unknown'
86
+ }
87
+
65
88
  const program = new Command('notifai')
66
89
  .description('Send native device notifications from agents and local programs')
67
- .version('0.1.0')
90
+ .version(version())
68
91
 
69
92
  program
70
93
  .command('login')
@@ -292,11 +315,11 @@ config
292
315
  program
293
316
  .command('init')
294
317
  .description(
295
- 'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness. ' +
318
+ 'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
296
319
  'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
297
320
  )
298
321
  .option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
299
- .option('--skills', 'reserved: this build has no published agent-skill source and exits with an error')
322
+ .option('--skills', 'install/update the agent skill from its pinned public release')
300
323
  .option('--no-skills', 'suppress the optional agent-skill status line')
301
324
  .option('--hooks', 'install harness hooks for registered-question routing')
302
325
  .option('--no-hooks', 'skip the hooks without being asked')
@@ -306,7 +329,7 @@ program
306
329
 
307
330
  program
308
331
  .command('doctor')
309
- .description('Audit config, credential, server, contract, device, and hook readiness (no live send)')
332
+ .description('Audit config, credential, server, contract, device, hook, and saved receipt proof (no live send)')
310
333
  .option('--json', 'machine-readable output')
311
334
  .action(async (opts: { json?: boolean }) => {
312
335
  process.exit(await doctorCommand(deps, opts))
package/src/send.ts CHANGED
@@ -196,6 +196,9 @@ export function formatReceipt(receipt: SubmissionReceipt): string {
196
196
  const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
197
197
  lines.push(` ${d.device_name}: ${d.state}${detail}`)
198
198
  }
199
+ lines.push(
200
+ ` device receipt is not assessed here; run notifai status ${receipt.request_id} for Companion Receipt evidence`,
201
+ )
199
202
  for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
200
203
  return lines.join('\n')
201
204
  }