@raidiant/notifai 0.1.8 → 0.2.1

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
@@ -6,12 +6,15 @@ import path from 'node:path'
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
7
7
  import {
8
8
  CAPABILITIES_V1,
9
+ QUESTION_TEXT_MAX_LENGTH,
10
+ REPLY_MAX_QUESTIONS,
9
11
  REPLY_MAX_WINDOW_SECONDS,
10
12
  validateDraft,
11
13
  type EvidenceSnapshot,
12
14
  type AccountAccessResponse,
13
15
  type ListRepliesResponse,
14
16
  type Platform,
17
+ type QuestionT,
15
18
  type ReplyView,
16
19
  type RoutableDevice,
17
20
  type SubmissionReceipt,
@@ -82,11 +85,11 @@ import {
82
85
  } from './opencode-plugin.js'
83
86
  import {
84
87
  CHOICE_USAGE,
85
- ambiguousChoiceSplit,
86
88
  buildDraft,
87
89
  formatReceipt,
88
90
  parseChoices,
89
91
  receiptExitCode,
92
+ slugify,
90
93
  type SendFlags,
91
94
  } from './send.js'
92
95
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
@@ -634,20 +637,25 @@ export async function repliesCommand(
634
637
  deps.io.err('Pass a request id or --pending, not both.')
635
638
  return EXIT.usage
636
639
  }
637
- let requestId = requestedId
640
+ let requestIds = requestedId === undefined ? [] : [requestedId]
638
641
  if (flags.pending === true) {
639
642
  const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
640
643
  if (sessionId === null) {
641
644
  deps.io.err('No active session pointer is available in this directory.')
642
645
  return EXIT.noReply
643
646
  }
644
- requestId = readSessionState(sessionId, deps.env).pending?.request_id
645
- if (requestId === undefined) {
647
+ // Every delivered question in the session's queue, in registration order —
648
+ // an agent may have several outstanding at once.
649
+ const state = readSessionState(sessionId, deps.env)
650
+ requestIds = (Array.isArray(state.pending) ? state.pending : [])
651
+ .map((entry) => entry.request_id)
652
+ .filter((id): id is string => id !== undefined)
653
+ if (requestIds.length === 0) {
646
654
  deps.io.err(`Session ${sessionId} has no pushed question pending.`)
647
655
  return EXIT.noReply
648
656
  }
649
657
  }
650
- if (requestId === undefined) {
658
+ if (requestIds.length === 0) {
651
659
  deps.io.err('Pass a request id or --pending.')
652
660
  return EXIT.usage
653
661
  }
@@ -656,24 +664,40 @@ export async function repliesCommand(
656
664
  const authed = authedClient(deps, config)
657
665
  if (!authed) return EXIT.auth
658
666
  try {
659
- const result = await waitForReply(authed.client, requestId, {
660
- timeoutSeconds: waitSeconds,
661
- afterSeq,
662
- now: deps.now,
663
- sleep: deps.sleep,
664
- })
667
+ let anyReplies = false
668
+ let anyDegraded = false
669
+ let allTimedOut = true
670
+ const jsonBodies: object[] = []
671
+ for (const requestId of requestIds) {
672
+ const result = await waitForReply(authed.client, requestId, {
673
+ timeoutSeconds: waitSeconds,
674
+ afterSeq,
675
+ now: deps.now,
676
+ sleep: deps.sleep,
677
+ })
678
+ if (flags.json) {
679
+ jsonBodies.push({ ...result.response, degraded: result.degraded })
680
+ } else if (result.response.replies.length > 0) {
681
+ if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
682
+ printReplies(deps, result.response.replies)
683
+ } else {
684
+ printNoReply(deps, requestId, result.response.reply_expires_at)
685
+ }
686
+ anyReplies ||= result.response.replies.length > 0
687
+ anyDegraded ||= result.degraded
688
+ allTimedOut &&= result.timedOut
689
+ }
665
690
  if (flags.json) {
666
- deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2))
667
- } else if (result.response.replies.length > 0) {
668
- if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
669
- printReplies(deps, result.response.replies)
691
+ // One request keeps the response shape agents already parse; several
692
+ // (only possible via --pending) arrive as an array in queue order.
693
+ deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
670
694
  }
671
- else printNoReply(deps, requestId, result.response.reply_expires_at)
672
- if (result.degraded) {
695
+ if (anyDegraded) {
673
696
  deps.io.err(DEGRADED_WAIT_WARNING)
674
697
  return EXIT.network
675
698
  }
676
- return result.timedOut ? EXIT.noReply : EXIT.ok
699
+ if (anyReplies) return EXIT.ok
700
+ return allTimedOut ? EXIT.noReply : EXIT.ok
677
701
  } catch (err) {
678
702
  return reportError(deps, err)
679
703
  }
@@ -770,28 +794,27 @@ function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
770
794
  }
771
795
 
772
796
  /**
773
- * The other half of first-reply-wins.
797
+ * The correction note for a wait that saw more than one answer.
774
798
  *
775
- * A blocking send returns the first non-empty batch and acts on it, so a later
776
- * answer that disagrees is silently ignored. That is the right default — an
777
- * agent wants one answer, not a quorum — but silently is the wrong way to do
778
- * it. The server now retires the question on the other devices the moment one
779
- * answers, which makes this rare; it is still reachable, because a device can
780
- * answer between the first reply landing and its retirement arriving.
799
+ * A later reply that conflicts with an earlier one is a correction, so the
800
+ * latest answer is the one that counts. The server retires the question on
801
+ * the other devices the moment one answers, which makes this rare; it is
802
+ * still reachable, because a device can answer between the first reply
803
+ * landing and its retirement arriving.
781
804
  *
782
- * Ordered by seq, so `replies[0]` is the one that won.
805
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
783
806
  */
784
807
  export function contradictingAnswer(replies: ReplyView[]): string | null {
785
- const winner = replies[0]
808
+ const winner = replies.at(-1)
786
809
  if (winner === undefined || replies.length < 2) return null
787
- const disagreeing = replies
788
- .slice(1)
789
- .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text))
790
- if (disagreeing.length === 0) return null
791
- const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ')
810
+ const key = (reply: ReplyView) =>
811
+ reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text
812
+ const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner))
813
+ if (corrected.length === 0) return null
814
+ const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ')
792
815
  return (
793
- `note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
794
- `${names} answered differently afterwards and that answer was not used.`
816
+ `note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
817
+ `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`
795
818
  )
796
819
  }
797
820
 
@@ -1096,17 +1119,119 @@ function rejectedPaths(details: unknown): string[] {
1096
1119
  }
1097
1120
 
1098
1121
  export interface AskFlags {
1099
- choice?: string | string[]
1122
+ choice?: string[]
1123
+ /** The single question is multi-select: several answers may be chosen. */
1124
+ multi?: boolean
1125
+ /** Long-form markdown context; shown in the app, never on the banner. */
1126
+ detail?: string
1127
+ /** Raw JSON for a multi-question form; replaces the positional question. */
1128
+ form?: string
1100
1129
  session?: string
1101
1130
  }
1102
1131
 
1132
+ /** The `--form` document: what an agent writes to ask several things at once. */
1133
+ interface AskFormQuestion {
1134
+ text: string
1135
+ choices?: string[]
1136
+ multi?: boolean
1137
+ }
1138
+
1139
+ /**
1140
+ * Turn ask input into the question set that will ride the push. Everything is
1141
+ * validated here, at registration, because the push happens inside a hook
1142
+ * where a rejection becomes a stderr note the agent never reads.
1143
+ */
1144
+ export function buildQuestions(
1145
+ flags: AskFlags,
1146
+ question: string | undefined,
1147
+ ): { ok: true; questions: QuestionT[]; detail?: string } | { ok: false; error: string } {
1148
+ if (flags.form !== undefined) {
1149
+ if (question !== undefined || flags.choice?.length || flags.multi) {
1150
+ return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
1151
+ }
1152
+ let parsed: unknown
1153
+ try {
1154
+ parsed = JSON.parse(flags.form)
1155
+ } catch {
1156
+ return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' }
1157
+ }
1158
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { questions?: unknown }).questions)) {
1159
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1160
+ }
1161
+ const form = parsed as { questions: unknown[]; detail?: unknown }
1162
+ if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
1163
+ return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
1164
+ }
1165
+ if (form.detail !== undefined && typeof form.detail !== 'string') {
1166
+ return { ok: false, error: '"detail" must be a markdown string.' }
1167
+ }
1168
+ const questions: QuestionT[] = []
1169
+ const usedIds = new Set<string>()
1170
+ for (const [index, entry] of form.questions.entries()) {
1171
+ if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
1172
+ return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
1173
+ }
1174
+ const spec = entry as AskFormQuestion
1175
+ const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds)
1176
+ if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
1177
+ questions.push(built.question)
1178
+ }
1179
+ return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) }
1180
+ }
1181
+
1182
+ if (question === undefined || question.trim() === '') {
1183
+ return { ok: false, error: 'The question cannot be empty.' }
1184
+ }
1185
+ const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
1186
+ if ('error' in built) return { ok: false, error: built.error }
1187
+ return {
1188
+ ok: true,
1189
+ questions: [built.question],
1190
+ ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
1191
+ }
1192
+ }
1193
+
1194
+ function buildOneQuestion(
1195
+ text: string,
1196
+ choiceLabels: string[] | undefined,
1197
+ multi: boolean,
1198
+ index: number,
1199
+ usedIds: Set<string>,
1200
+ ): { question: QuestionT } | { error: string } {
1201
+ const trimmed = text.trim()
1202
+ if (trimmed === '') return { error: 'the question text cannot be empty.' }
1203
+ if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
1204
+ return {
1205
+ error:
1206
+ `a question must be readable where it is answered: keep it within ` +
1207
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
1208
+ }
1209
+ }
1210
+ const choices = parseChoices(choiceLabels)
1211
+ if (choices === 'invalid') return { error: CHOICE_USAGE }
1212
+ if (multi && choices === null) {
1213
+ return { error: '--multi needs answers to select between; add --choice.' }
1214
+ }
1215
+ let id = slugify(trimmed)
1216
+ if (id === '' || usedIds.has(id)) id = `q${index + 1}`
1217
+ usedIds.add(id)
1218
+ return {
1219
+ question: {
1220
+ id,
1221
+ text: trimmed,
1222
+ ...(choices !== null ? { choices } : {}),
1223
+ ...(multi ? { multi: true } : {}),
1224
+ },
1225
+ }
1226
+ }
1227
+
1103
1228
  /**
1104
1229
  * Registers a question for turn-end routing under the user's presence config.
1105
1230
  * Returns immediately so the agent can ask in prose and end its turn. With the
1106
1231
  * default presence gate, recent keyboard or mouse activity keeps it local;
1107
1232
  * `require_idle = false` intentionally permits a push while the user is active.
1108
1233
  */
1109
- export function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number {
1234
+ export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
1110
1235
  // An agent calling this gets no hook payload. Harness-native environment
1111
1236
  // markers identify the active owner, while the UserPromptSubmit hook leaves
1112
1237
  // a pointer keyed on the project directory with the hook's canonical id.
@@ -1155,16 +1280,12 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
1155
1280
  for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1156
1281
  return EXIT.usage
1157
1282
  }
1158
- if (question.trim() === '') {
1159
- deps.io.err('The question cannot be empty.')
1160
- return EXIT.usage
1161
- }
1162
1283
  // Validate here, not at push time. The push happens inside a hook, where a
1163
1284
  // rejection becomes a stderr note the agent never reads — so a malformed
1164
- // choice set would look like it registered fine and then silently never ask.
1165
- const choices = parseChoices(flags.choice)
1166
- if (choices === 'invalid') {
1167
- deps.io.err(CHOICE_USAGE)
1285
+ // question set would look like it registered fine and then silently never ask.
1286
+ const built = buildQuestions(flags, question)
1287
+ if (!built.ok) {
1288
+ deps.io.err(built.error)
1168
1289
  return EXIT.usage
1169
1290
  }
1170
1291
  try {
@@ -1172,8 +1293,9 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
1172
1293
  sessionId,
1173
1294
  deps.env,
1174
1295
  {
1175
- question: question.trim(),
1176
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
1296
+ question: built.questions[0]!.text,
1297
+ questions: built.questions,
1298
+ ...(built.detail !== undefined ? { detail: built.detail } : {}),
1177
1299
  },
1178
1300
  (deps.now ?? Date.now)(),
1179
1301
  )
@@ -1181,14 +1303,20 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
1181
1303
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
1182
1304
  return EXIT.failed
1183
1305
  }
1184
- if (choices !== null) {
1185
- deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`)
1306
+ for (const [index, entry] of built.questions.entries()) {
1307
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1308
+ if (entry.choices !== undefined) {
1309
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
1310
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
1311
+ } else if (built.questions.length > 1) {
1312
+ deps.io.out(`${prefix}${entry.text} — free text`)
1313
+ }
1186
1314
  }
1187
- // On stdout, not stderr: the agent reads the former and this is only useful
1188
- // if it is read while there is still time to fix the question.
1189
- const ambiguous = ambiguousChoiceSplit(flags.choice)
1190
- if (ambiguous !== null) deps.io.out(ambiguous)
1191
- deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.')
1315
+ deps.io.out(
1316
+ built.questions.length > 1
1317
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1318
+ : 'Question registered. Ask it in the conversation as usual and end your turn.',
1319
+ )
1192
1320
  return EXIT.ok
1193
1321
  }
1194
1322
 
@@ -1729,7 +1857,7 @@ export async function configSetCommand(
1729
1857
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1730
1858
  * Keep this immutable and public because the command is printed to users.
1731
1859
  */
1732
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8'
1860
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1'
1733
1861
 
1734
1862
  function skillSourceParts(): { source: string; ref: string } | null {
1735
1863
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -2929,12 +3057,16 @@ function remedyLine(state: ReadinessState): string {
2929
3057
  * way and is not worth re-deriving. The judgment added here is which failures
2930
3058
  * actually stand in the way.
2931
3059
  *
2932
- * Two do not. A hook that has never fired is the normal condition of an
2933
- * install thirty seconds old, and OpenCode's inability to resume an idle turn
2934
- * is a property of that harness rather than a fault in this setup. Treating
2935
- * either as blocking would mean `init` could never finish for an OpenCode
2936
- * user, or could only finish after a session had already run — so both report
2937
- * as things worth knowing rather than things to fix.
3060
+ * Not everything failed is in the way. A pointer that has never been
3061
+ * published is the normal condition of an install thirty seconds old — the
3062
+ * next prompt fixes it and no command can — and OpenCode's inability to
3063
+ * resume an idle turn is a property of that harness rather than a fault in
3064
+ * this setup. Treating those as blocking would mean `init` could never
3065
+ * finish for an OpenCode user, or could only finish after a session had
3066
+ * already run — so they report as things worth knowing rather than things to
3067
+ * fix, and `init` walks on to the states it can actually close, delivery
3068
+ * proof included. Which failures are informational, and what the true remedy
3069
+ * is, is each check's own call (`HookCheck`).
2938
3070
  */
2939
3071
  function hookStates(deps: CommandDeps): ReadinessState[] {
2940
3072
  const installations = findInstallations(deps.cwd, deps.env)
@@ -2977,7 +3109,6 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
2977
3109
 
2978
3110
  /** Real but not in the way; see the note above. */
2979
3111
  const informational = new Set(['hooks (opencode continuation)'])
2980
- if (active === null) informational.add('hooks (fired)')
2981
3112
  return [
2982
3113
  ...hookChecks(deps).map((check) => ({
2983
3114
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
@@ -2985,15 +3116,24 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
2985
3116
  check.name === 'hooks' || check.name.startsWith('hooks (active')
2986
3117
  ? 'Question routing'
2987
3118
  : check.name,
2988
- status: check.ok ? 'ready' as const : informational.has(check.name) ? 'optional-gap' as const : 'gap' as const,
3119
+ status: check.ok
3120
+ ? 'ready' as const
3121
+ : check.informational === true || informational.has(check.name)
3122
+ ? 'optional-gap' as const
3123
+ : 'gap' as const,
2989
3124
  detail: check.detail,
2990
3125
  ...(check.ok
2991
3126
  ? {}
2992
3127
  : {
2993
3128
  remedy: {
2994
3129
  by: 'user-here' as const,
2995
- summary: 'the detail above names what to change',
2996
- command: 'notifai hooks install',
3130
+ // The check's own remedy when it has one; the generic reinstall
3131
+ // line was wrong exactly where it mattered (an unfired pointer
3132
+ // needs a prompt, not `hooks install`).
3133
+ ...(check.remedy ?? {
3134
+ summary: 'the detail above names what to change',
3135
+ command: 'notifai hooks install',
3136
+ }),
2997
3137
  },
2998
3138
  }),
2999
3139
  })),
@@ -3001,8 +3141,18 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
3001
3141
  ]
3002
3142
  }
3003
3143
 
3004
- function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: string }[] {
3005
- const checks: { name: string; ok: boolean; detail: string }[] = []
3144
+ interface HookCheck {
3145
+ name: string
3146
+ ok: boolean
3147
+ detail: string
3148
+ /** Real but not in the way: worth a line, never a blocker. */
3149
+ informational?: boolean
3150
+ /** A remedy truer than the generic `notifai hooks install`. */
3151
+ remedy?: { summary: string; command: string }
3152
+ }
3153
+
3154
+ function hookChecks(deps: CommandDeps): HookCheck[] {
3155
+ const checks: HookCheck[] = []
3006
3156
  const installations = findInstallations(deps.cwd, deps.env)
3007
3157
 
3008
3158
  // Not having hooks is a setup someone chose, not a fault: `send` works
@@ -3036,23 +3186,52 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
3036
3186
  activeInstallations.length > 0
3037
3187
  ? `active ${active.label} session has a matching hook installation`
3038
3188
  : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
3189
+ ...(activeInstallations.length > 0
3190
+ ? {}
3191
+ : {
3192
+ remedy: {
3193
+ summary: `install hooks for the active ${active.label} session`,
3194
+ command: `notifai hooks install --harness ${active.harness}`,
3195
+ },
3196
+ }),
3039
3197
  })
3040
3198
  if (activeInstallations.length > 0) {
3041
3199
  const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
3042
- const matches =
3043
- pointer !== null &&
3044
- pointer.harness === active.harness &&
3045
- (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3046
- checks.push({
3047
- name: 'hooks (active session)',
3048
- ok: matches,
3049
- detail:
3050
- pointer === null
3051
- ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
3052
- : matches
3053
- ? `the project pointer belongs to the active ${active.label} session`
3054
- : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3055
- })
3200
+ if (pointer === null) {
3201
+ // The normal condition of hooks installed moments ago: the pointer
3202
+ // appears when the harness next fires a hook, and no command can
3203
+ // force that. Informational, so `init` walks on to the states it can
3204
+ // actually prove instead of exiting over evidence only time produces.
3205
+ checks.push({
3206
+ name: 'hooks (active session)',
3207
+ ok: false,
3208
+ informational: true,
3209
+ detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
3210
+ remedy: {
3211
+ summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
3212
+ command: 'notifai doctor',
3213
+ },
3214
+ })
3215
+ } else {
3216
+ const matches =
3217
+ pointer.harness === active.harness &&
3218
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId)
3219
+ checks.push({
3220
+ name: 'hooks (active session)',
3221
+ ok: matches,
3222
+ detail: matches
3223
+ ? `the project pointer belongs to the active ${active.label} session`
3224
+ : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
3225
+ ...(matches
3226
+ ? {}
3227
+ : {
3228
+ remedy: {
3229
+ summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
3230
+ command: 'notifai doctor',
3231
+ },
3232
+ }),
3233
+ })
3234
+ }
3056
3235
  }
3057
3236
  }
3058
3237
 
@@ -3145,9 +3324,20 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
3145
3324
  checks.push({
3146
3325
  name: 'hooks (fired)',
3147
3326
  ok: fired,
3327
+ // Never-fired is evidence about the past, not a fault in the setup, and
3328
+ // the cure is a prompt no command can send.
3329
+ informational: true,
3148
3330
  detail: fired
3149
3331
  ? 'a session in this directory has run them'
3150
3332
  : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
3333
+ ...(fired
3334
+ ? {}
3335
+ : {
3336
+ remedy: {
3337
+ summary: 'send one prompt in a session here, then re-check',
3338
+ command: 'notifai doctor',
3339
+ },
3340
+ }),
3151
3341
  })
3152
3342
 
3153
3343
  if (installations.some((installation) => installation.harness === 'opencode')) {