@raidiant/notifai 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.ts CHANGED
@@ -41,8 +41,21 @@ import {
41
41
  type ConfigKey,
42
42
  type FlagOverrides,
43
43
  } from './config.js'
44
+ import { acceptedValues, configInfo } from './config-schema.js'
45
+ import {
46
+ renderConfigExplain,
47
+ renderConfigList,
48
+ renderConfigPlain,
49
+ } from './ui/config-view.js'
44
50
  import type { CredentialStore, MachineCredential } from './credentials.js'
45
- import { firstBlocker, openItems, type Readiness, type ReadinessState } from './readiness.js'
51
+ import {
52
+ firstBlocker,
53
+ openItems,
54
+ type Readiness,
55
+ type ReadinessState,
56
+ type StateStatus,
57
+ } from './readiness.js'
58
+ import type { Tone } from './ui/theme.js'
46
59
  import {
47
60
  handleSessionEnd,
48
61
  handleStop,
@@ -113,7 +126,13 @@ export interface CommandIo {
113
126
  outro?(message: string): Promise<void>
114
127
  note?(message: string, title?: string): Promise<void>
115
128
  spinner?(message: string): Promise<CommandSpinner | null>
116
- check?(ok: boolean, message: string): Promise<void>
129
+ /**
130
+ * A report line. `tone` distinguishes the two states that are neither pass
131
+ * nor fail — something optional the user declined, and something that was
132
+ * never evaluated — which a boolean has to round to one or the other.
133
+ * Optional so existing implementations keep type-checking.
134
+ */
135
+ check?(ok: boolean, message: string, tone?: Tone): Promise<void>
117
136
  }
118
137
 
119
138
  export interface CommandSpinner {
@@ -387,6 +406,32 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
387
406
  }
388
407
  }
389
408
 
409
+ /**
410
+ * Devices as data, for surfaces that render them themselves.
411
+ *
412
+ * `devicesCommand` prints and returns an exit code, which is the right shape
413
+ * for a command and the wrong one for a menu that wants to show names beside
414
+ * checkboxes. Silent on every failure by design: the interactive app already
415
+ * shows the credential and connectivity state on its status card, so a second
416
+ * error line here would be reporting the same fault twice.
417
+ */
418
+ export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
419
+ if (!deps.store.load()) return null
420
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
421
+ const authed = authedClient(deps, config)
422
+ if (!authed) return null
423
+ try {
424
+ return (await authed.client.listDevices()).devices
425
+ } catch {
426
+ return null
427
+ }
428
+ }
429
+
430
+ /** Whether a device is in a state where a notification would actually arrive. */
431
+ export function canDeviceReceive(device: RoutableDevice): boolean {
432
+ return deviceCanReceive(device)
433
+ }
434
+
390
435
  export async function capabilitiesCommand(
391
436
  deps: CommandDeps,
392
437
  flags: { json?: boolean; platform?: Platform },
@@ -637,20 +682,25 @@ export async function repliesCommand(
637
682
  deps.io.err('Pass a request id or --pending, not both.')
638
683
  return EXIT.usage
639
684
  }
640
- let requestId = requestedId
685
+ let requestIds = requestedId === undefined ? [] : [requestedId]
641
686
  if (flags.pending === true) {
642
687
  const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
643
688
  if (sessionId === null) {
644
689
  deps.io.err('No active session pointer is available in this directory.')
645
690
  return EXIT.noReply
646
691
  }
647
- requestId = readSessionState(sessionId, deps.env).pending?.request_id
648
- if (requestId === undefined) {
692
+ // Every delivered question in the session's queue, in registration order —
693
+ // an agent may have several outstanding at once.
694
+ const state = readSessionState(sessionId, deps.env)
695
+ requestIds = (Array.isArray(state.pending) ? state.pending : [])
696
+ .map((entry) => entry.request_id)
697
+ .filter((id): id is string => id !== undefined)
698
+ if (requestIds.length === 0) {
649
699
  deps.io.err(`Session ${sessionId} has no pushed question pending.`)
650
700
  return EXIT.noReply
651
701
  }
652
702
  }
653
- if (requestId === undefined) {
703
+ if (requestIds.length === 0) {
654
704
  deps.io.err('Pass a request id or --pending.')
655
705
  return EXIT.usage
656
706
  }
@@ -659,24 +709,40 @@ export async function repliesCommand(
659
709
  const authed = authedClient(deps, config)
660
710
  if (!authed) return EXIT.auth
661
711
  try {
662
- const result = await waitForReply(authed.client, requestId, {
663
- timeoutSeconds: waitSeconds,
664
- afterSeq,
665
- now: deps.now,
666
- sleep: deps.sleep,
667
- })
712
+ let anyReplies = false
713
+ let anyDegraded = false
714
+ let allTimedOut = true
715
+ const jsonBodies: object[] = []
716
+ for (const requestId of requestIds) {
717
+ const result = await waitForReply(authed.client, requestId, {
718
+ timeoutSeconds: waitSeconds,
719
+ afterSeq,
720
+ now: deps.now,
721
+ sleep: deps.sleep,
722
+ })
723
+ if (flags.json) {
724
+ jsonBodies.push({ ...result.response, degraded: result.degraded })
725
+ } else if (result.response.replies.length > 0) {
726
+ if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
727
+ printReplies(deps, result.response.replies)
728
+ } else {
729
+ printNoReply(deps, requestId, result.response.reply_expires_at)
730
+ }
731
+ anyReplies ||= result.response.replies.length > 0
732
+ anyDegraded ||= result.degraded
733
+ allTimedOut &&= result.timedOut
734
+ }
668
735
  if (flags.json) {
669
- deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2))
670
- } else if (result.response.replies.length > 0) {
671
- if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
672
- printReplies(deps, result.response.replies)
736
+ // One request keeps the response shape agents already parse; several
737
+ // (only possible via --pending) arrive as an array in queue order.
738
+ deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
673
739
  }
674
- else printNoReply(deps, requestId, result.response.reply_expires_at)
675
- if (result.degraded) {
740
+ if (anyDegraded) {
676
741
  deps.io.err(DEGRADED_WAIT_WARNING)
677
742
  return EXIT.network
678
743
  }
679
- return result.timedOut ? EXIT.noReply : EXIT.ok
744
+ if (anyReplies) return EXIT.ok
745
+ return allTimedOut ? EXIT.noReply : EXIT.ok
680
746
  } catch (err) {
681
747
  return reportError(deps, err)
682
748
  }
@@ -1729,7 +1795,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
1729
1795
 
1730
1796
  export function configShowCommand(
1731
1797
  deps: CommandDeps,
1732
- flags: { json?: boolean; explain?: boolean },
1798
+ flags: { json?: boolean; explain?: boolean; plain?: boolean },
1733
1799
  ): number {
1734
1800
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1735
1801
  if (flags.json) {
@@ -1739,14 +1805,111 @@ export function configShowCommand(
1739
1805
  deps.io.out(JSON.stringify(output, null, 2))
1740
1806
  return EXIT.ok
1741
1807
  }
1742
- for (const key of CONFIG_KEYS) {
1743
- const entry = config[key]
1744
- const provenance = flags.explain ? ` [${entry.source}]` : ''
1745
- deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`)
1808
+ // Anything that is not a person at a terminal keeps the flat `key = value`
1809
+ // form it has always had. Scripts parse this, and a prettier layout for an
1810
+ // audience that cannot see it would only be a breaking change.
1811
+ if (deps.io.interactive !== true || flags.plain === true) {
1812
+ for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
1813
+ return EXIT.ok
1814
+ }
1815
+ for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
1816
+ deps.io.out(line)
1817
+ }
1818
+ return EXIT.ok
1819
+ }
1820
+
1821
+ /**
1822
+ * One setting, explained in full.
1823
+ *
1824
+ * The gap this closes: `config show` prints `require_idle = true` and there was
1825
+ * nowhere to go from there. Every key already had a careful explanation — in a
1826
+ * TypeScript comment, read by everyone except the person who needed it.
1827
+ */
1828
+ export function configExplainCommand(
1829
+ deps: CommandDeps,
1830
+ key: string,
1831
+ flags: { json?: boolean } = {},
1832
+ ): number {
1833
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
1834
+ deps.io.err(`Unknown setting "${key}".`)
1835
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
1836
+ return EXIT.usage
1837
+ }
1838
+ const configKey = key as ConfigKey
1839
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1840
+ const info = configInfo(configKey)
1841
+ const entry = config[configKey]
1842
+
1843
+ if (flags.json) {
1844
+ deps.io.out(
1845
+ JSON.stringify(
1846
+ {
1847
+ key: configKey,
1848
+ label: info.label,
1849
+ group: info.group,
1850
+ kind: info.kind,
1851
+ summary: info.summary,
1852
+ detail: info.detail,
1853
+ accepts: acceptedValues(configKey),
1854
+ ...(info.choices !== undefined ? { choices: info.choices } : {}),
1855
+ value: entry.value,
1856
+ source: entry.source,
1857
+ },
1858
+ null,
1859
+ 2,
1860
+ ),
1861
+ )
1862
+ return EXIT.ok
1863
+ }
1864
+
1865
+ if (deps.io.interactive !== true) {
1866
+ deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
1867
+ deps.io.out(info.detail.replace(/\n\n/g, '\n'))
1868
+ deps.io.out(`accepts: ${acceptedValues(configKey)}`)
1869
+ return EXIT.ok
1746
1870
  }
1871
+ for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
1747
1872
  return EXIT.ok
1748
1873
  }
1749
1874
 
1875
+ /**
1876
+ * Closest config key by edit distance, or null when nothing is close.
1877
+ *
1878
+ * The threshold matters more than the algorithm: suggesting a key that shares
1879
+ * three letters with the typo sends the reader to the wrong setting with
1880
+ * confidence, which is worse than listing all fourteen and letting them look.
1881
+ */
1882
+ function nearestKey(input: string): string | null {
1883
+ let best: string | null = null
1884
+ let bestDistance = Number.POSITIVE_INFINITY
1885
+ for (const candidate of CONFIG_KEYS) {
1886
+ const distance = editDistance(input.toLowerCase(), candidate)
1887
+ if (distance < bestDistance) {
1888
+ bestDistance = distance
1889
+ best = candidate
1890
+ }
1891
+ }
1892
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
1893
+ }
1894
+
1895
+ function editDistance(a: string, b: string): number {
1896
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
1897
+ for (let i = 1; i <= a.length; i += 1) {
1898
+ let diagonal = previous[0]!
1899
+ previous[0] = i
1900
+ for (let j = 1; j <= b.length; j += 1) {
1901
+ const above = previous[j]!
1902
+ previous[j] = Math.min(
1903
+ previous[j]! + 1,
1904
+ previous[j - 1]! + 1,
1905
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
1906
+ )
1907
+ diagonal = above
1908
+ }
1909
+ }
1910
+ return previous[b.length]!
1911
+ }
1912
+
1750
1913
  export async function configSetCommand(
1751
1914
  deps: CommandDeps,
1752
1915
  key: string,
@@ -1754,30 +1917,43 @@ export async function configSetCommand(
1754
1917
  flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
1755
1918
  ): Promise<number> {
1756
1919
  if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
1757
- deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`)
1920
+ deps.io.err(`Unknown setting "${key}".`)
1921
+ const near = nearestKey(key)
1922
+ if (near !== null) deps.io.err(`Did you mean "${near}"?`)
1923
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
1924
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
1758
1925
  return EXIT.usage
1759
1926
  }
1927
+ const configKey = key as ConfigKey
1928
+ const info = configInfo(configKey)
1760
1929
  let value: unknown = rawValue
1761
- if (NUMERIC_CONFIG_KEYS.includes(key as ConfigKey)) {
1930
+ if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
1762
1931
  const numeric = Number(rawValue)
1763
1932
  if (!Number.isInteger(numeric)) {
1764
- deps.io.err(`"${rawValue}" is not an integer.`)
1933
+ deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
1765
1934
  return EXIT.usage
1766
1935
  }
1767
- const bounds = configBounds(key as ConfigKey)
1936
+ const bounds = configBounds(configKey)
1768
1937
  if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1769
1938
  deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
1770
1939
  return EXIT.usage
1771
1940
  }
1772
1941
  value = numeric
1773
1942
  }
1774
- if (BOOLEAN_CONFIG_KEYS.includes(key as ConfigKey)) {
1943
+ if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
1775
1944
  if (rawValue !== 'true' && rawValue !== 'false') {
1776
1945
  deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
1777
1946
  return EXIT.usage
1778
1947
  }
1779
1948
  value = rawValue === 'true'
1780
1949
  }
1950
+ // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
1951
+ // value the sender would later reject — a typo that only surfaces at the
1952
+ // moment a notification fails to carry the sound you asked for.
1953
+ if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
1954
+ deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
1955
+ return EXIT.usage
1956
+ }
1781
1957
  if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
1782
1958
 
1783
1959
  let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
@@ -1836,7 +2012,7 @@ export async function configSetCommand(
1836
2012
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1837
2013
  * Keep this immutable and public because the command is printed to users.
1838
2014
  */
1839
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0'
2015
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0'
1840
2016
 
1841
2017
  function skillSourceParts(): { source: string; ref: string } | null {
1842
2018
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -2991,7 +3167,13 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
2991
3167
  // costs nothing.
2992
3168
  if (deps.io.interactive === true && deps.io.check) {
2993
3169
  await deps.io.intro?.('Notifai doctor')
2994
- for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
3170
+ for (const s of readiness.states) {
3171
+ // Four states, not two. Rendering `optional-gap` and `unknown` as
3172
+ // successes made a green report that had never checked half of what it
3173
+ // listed, and put a tick beside things the reader had deliberately
3174
+ // declined.
3175
+ await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
3176
+ }
2995
3177
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
2996
3178
  } else {
2997
3179
  for (const s of readiness.states) {
@@ -3010,6 +3192,20 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
3010
3192
  return ok ? EXIT.ok : EXIT.failed
3011
3193
  }
3012
3194
 
3195
+ /** Readiness status as a report tone. */
3196
+ function doctorTone(status: StateStatus): Tone {
3197
+ switch (status) {
3198
+ case 'ready':
3199
+ return 'ok'
3200
+ case 'gap':
3201
+ return 'bad'
3202
+ case 'optional-gap':
3203
+ return 'warn'
3204
+ case 'unknown':
3205
+ return 'pending'
3206
+ }
3207
+ }
3208
+
3013
3209
  /** One line telling the reader what to actually do about a state. */
3014
3210
  function remedyLine(state: ReadinessState): string {
3015
3211
  const remedy = state.remedy
@@ -3036,12 +3232,16 @@ function remedyLine(state: ReadinessState): string {
3036
3232
  * way and is not worth re-deriving. The judgment added here is which failures
3037
3233
  * actually stand in the way.
3038
3234
  *
3039
- * Two do not. A hook that has never fired is the normal condition of an
3040
- * install thirty seconds old, and OpenCode's inability to resume an idle turn
3041
- * is a property of that harness rather than a fault in this setup. Treating
3042
- * either as blocking would mean `init` could never finish for an OpenCode
3043
- * user, or could only finish after a session had already run — so both report
3044
- * as things worth knowing rather than things to fix.
3235
+ * Not everything failed is in the way. A pointer that has never been
3236
+ * published is the normal condition of an install thirty seconds old — the
3237
+ * next prompt fixes it and no command can — and OpenCode's inability to
3238
+ * resume an idle turn is a property of that harness rather than a fault in
3239
+ * this setup. Treating those as blocking would mean `init` could never
3240
+ * finish for an OpenCode user, or could only finish after a session had
3241
+ * already run — so they report as things worth knowing rather than things to
3242
+ * fix, and `init` walks on to the states it can actually close, delivery
3243
+ * proof included. Which failures are informational, and what the true remedy
3244
+ * is, is each check's own call (`HookCheck`).
3045
3245
  */
3046
3246
  function hookStates(deps: CommandDeps): ReadinessState[] {
3047
3247
  const installations = findInstallations(deps.cwd, deps.env)
@@ -3084,7 +3284,6 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3084
3284
 
3085
3285
  /** Real but not in the way; see the note above. */
3086
3286
  const informational = new Set(['hooks (opencode continuation)'])
3087
- if (active === null) informational.add('hooks (fired)')
3088
3287
  return [
3089
3288
  ...hookChecks(deps).map((check) => ({
3090
3289
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
@@ -3092,15 +3291,24 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3092
3291
  check.name === 'hooks' || check.name.startsWith('hooks (active')
3093
3292
  ? 'Question routing'
3094
3293
  : check.name,
3095
- status: check.ok ? 'ready' as const : informational.has(check.name) ? 'optional-gap' as const : 'gap' as const,
3294
+ status: check.ok
3295
+ ? 'ready' as const
3296
+ : check.informational === true || informational.has(check.name)
3297
+ ? 'optional-gap' as const
3298
+ : 'gap' as const,
3096
3299
  detail: check.detail,
3097
3300
  ...(check.ok
3098
3301
  ? {}
3099
3302
  : {
3100
3303
  remedy: {
3101
3304
  by: 'user-here' as const,
3102
- summary: 'the detail above names what to change',
3103
- command: 'notifai hooks install',
3305
+ // The check's own remedy when it has one; the generic reinstall
3306
+ // line was wrong exactly where it mattered (an unfired pointer
3307
+ // needs a prompt, not `hooks install`).
3308
+ ...(check.remedy ?? {
3309
+ summary: 'the detail above names what to change',
3310
+ command: 'notifai hooks install',
3311
+ }),
3104
3312
  },
3105
3313
  }),
3106
3314
  })),
@@ -3108,8 +3316,18 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3108
3316
  ]
3109
3317
  }
3110
3318
 
3111
- function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: string }[] {
3112
- const checks: { name: string; ok: boolean; detail: string }[] = []
3319
+ interface HookCheck {
3320
+ name: string
3321
+ ok: boolean
3322
+ detail: string
3323
+ /** Real but not in the way: worth a line, never a blocker. */
3324
+ informational?: boolean
3325
+ /** A remedy truer than the generic `notifai hooks install`. */
3326
+ remedy?: { summary: string; command: string }
3327
+ }
3328
+
3329
+ function hookChecks(deps: CommandDeps): HookCheck[] {
3330
+ const checks: HookCheck[] = []
3113
3331
  const installations = findInstallations(deps.cwd, deps.env)
3114
3332
 
3115
3333
  // Not having hooks is a setup someone chose, not a fault: `send` works
@@ -3143,23 +3361,52 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
3143
3361
  activeInstallations.length > 0
3144
3362
  ? `active ${active.label} session has a matching hook installation`
3145
3363
  : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
3364
+ ...(activeInstallations.length > 0
3365
+ ? {}
3366
+ : {
3367
+ remedy: {
3368
+ summary: `install hooks for the active ${active.label} session`,
3369
+ command: `notifai hooks install --harness ${active.harness}`,
3370
+ },
3371
+ }),
3146
3372
  })
3147
3373
  if (activeInstallations.length > 0) {
3148
3374
  const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3149
- const matches =
3150
- pointer !== null &&
3151
- pointer.harness === active.harness &&
3152
- (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3153
- checks.push({
3154
- name: 'hooks (active session)',
3155
- ok: matches,
3156
- detail:
3157
- pointer === null
3158
- ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
3159
- : matches
3160
- ? `the project pointer belongs to the active ${active.label} session`
3161
- : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3162
- })
3375
+ if (pointer === null) {
3376
+ // The normal condition of hooks installed moments ago: the pointer
3377
+ // appears when the harness next fires a hook, and no command can
3378
+ // force that. Informational, so `init` walks on to the states it can
3379
+ // actually prove instead of exiting over evidence only time produces.
3380
+ checks.push({
3381
+ name: 'hooks (active session)',
3382
+ ok: false,
3383
+ informational: true,
3384
+ detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
3385
+ remedy: {
3386
+ summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
3387
+ command: 'notifai doctor',
3388
+ },
3389
+ })
3390
+ } else {
3391
+ const matches =
3392
+ pointer.harness === active.harness &&
3393
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3394
+ checks.push({
3395
+ name: 'hooks (active session)',
3396
+ ok: matches,
3397
+ detail: matches
3398
+ ? `the project pointer belongs to the active ${active.label} session`
3399
+ : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3400
+ ...(matches
3401
+ ? {}
3402
+ : {
3403
+ remedy: {
3404
+ summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
3405
+ command: 'notifai doctor',
3406
+ },
3407
+ }),
3408
+ })
3409
+ }
3163
3410
  }
3164
3411
  }
3165
3412
 
@@ -3252,9 +3499,20 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
3252
3499
  checks.push({
3253
3500
  name: 'hooks (fired)',
3254
3501
  ok: fired,
3502
+ // Never-fired is evidence about the past, not a fault in the setup, and
3503
+ // the cure is a prompt no command can send.
3504
+ informational: true,
3255
3505
  detail: fired
3256
3506
  ? 'a session in this directory has run them'
3257
3507
  : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
3508
+ ...(fired
3509
+ ? {}
3510
+ : {
3511
+ remedy: {
3512
+ summary: 'send one prompt in a session here, then re-check',
3513
+ command: 'notifai doctor',
3514
+ },
3515
+ }),
3258
3516
  })
3259
3517
 
3260
3518
  if (installations.some((installation) => installation.harness === 'opencode')) {
@@ -3380,11 +3638,21 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
3380
3638
  error: (next) => progress.error(next),
3381
3639
  }
3382
3640
  },
3383
- check: async (ok, message) => {
3641
+ check: async (ok, message, tone) => {
3384
3642
  if (!interactive()) return
3385
3643
  const { log } = await clack()
3386
- if (ok) log.success(message)
3387
- else log.error(message)
3644
+ switch (tone ?? (ok ? 'ok' : 'bad')) {
3645
+ case 'ok':
3646
+ return log.success(message)
3647
+ case 'warn':
3648
+ return log.warn(message)
3649
+ case 'pending':
3650
+ // Never checked. `log.info` reads as neutral, which is the honest
3651
+ // rendering for a state no evidence was gathered about.
3652
+ return log.info(message)
3653
+ case 'bad':
3654
+ return log.error(message)
3655
+ }
3388
3656
  },
3389
3657
  openUrl: (url) => {
3390
3658
  try {