@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/README.md CHANGED
@@ -35,7 +35,7 @@ banner and is shown in the companion app for when you sit down.
35
35
  ## Asking you a question
36
36
 
37
37
  ```sh
38
- notifai ask "Deploy the migration to production?" --choice "Yes,No"
38
+ notifai ask "Deploy the migration to production?" --choice Yes --choice No
39
39
  ```
40
40
 
41
41
  The agent registers the question, ends its turn, and your answer comes
@@ -1,4 +1,4 @@
1
- import { type ListRepliesResponse, type Platform, type ReplyView } from '@raidiant/notifai-protocol';
1
+ import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView } from '@raidiant/notifai-protocol';
2
2
  import { type ApiClient, type ClientOptions } from './client.js';
3
3
  import type { CredentialStore } from './credentials.js';
4
4
  import { type Readiness } from './readiness.js';
@@ -112,16 +112,15 @@ interface ReplyWaitResult {
112
112
  /** Loop over server-capped long polls until a reply arrives or the caller's deadline passes. */
113
113
  export declare function waitForReply(client: ApiClient, requestId: string, options: ReplyWaitOptions): Promise<ReplyWaitResult>;
114
114
  /**
115
- * The other half of first-reply-wins.
115
+ * The correction note for a wait that saw more than one answer.
116
116
  *
117
- * A blocking send returns the first non-empty batch and acts on it, so a later
118
- * answer that disagrees is silently ignored. That is the right default — an
119
- * agent wants one answer, not a quorum — but silently is the wrong way to do
120
- * it. The server now retires the question on the other devices the moment one
121
- * answers, which makes this rare; it is still reachable, because a device can
122
- * answer between the first reply landing and its retirement arriving.
117
+ * A later reply that conflicts with an earlier one is a correction, so the
118
+ * latest answer is the one that counts. The server retires the question on
119
+ * the other devices the moment one answers, which makes this rare; it is
120
+ * still reachable, because a device can answer between the first reply
121
+ * landing and its retirement arriving.
123
122
  *
124
- * Ordered by seq, so `replies[0]` is the one that won.
123
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
125
124
  */
126
125
  export declare function contradictingAnswer(replies: ReplyView[]): string | null;
127
126
  export declare function statusCommand(deps: CommandDeps, requestId: string, flags: {
@@ -155,16 +154,35 @@ export declare function hookRunCommand(deps: CommandDeps, event: string, readStd
155
154
  */
156
155
  export declare function describeHookFailure(err: unknown): string[];
157
156
  export interface AskFlags {
158
- choice?: string | string[];
157
+ choice?: string[];
158
+ /** The single question is multi-select: several answers may be chosen. */
159
+ multi?: boolean;
160
+ /** Long-form markdown context; shown in the app, never on the banner. */
161
+ detail?: string;
162
+ /** Raw JSON for a multi-question form; replaces the positional question. */
163
+ form?: string;
159
164
  session?: string;
160
165
  }
166
+ /**
167
+ * Turn ask input into the question set that will ride the push. Everything is
168
+ * validated here, at registration, because the push happens inside a hook
169
+ * where a rejection becomes a stderr note the agent never reads.
170
+ */
171
+ export declare function buildQuestions(flags: AskFlags, question: string | undefined): {
172
+ ok: true;
173
+ questions: QuestionT[];
174
+ detail?: string;
175
+ } | {
176
+ ok: false;
177
+ error: string;
178
+ };
161
179
  /**
162
180
  * Registers a question for turn-end routing under the user's presence config.
163
181
  * Returns immediately so the agent can ask in prose and end its turn. With the
164
182
  * default presence gate, recent keyboard or mouse activity keeps it local;
165
183
  * `require_idle = false` intentionally permits a push while the user is active.
166
184
  */
167
- export declare function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number;
185
+ export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number;
168
186
  /** Retire a question so a late answer is rejected rather than silently lost. */
169
187
  export declare function closeCommand(deps: CommandDeps, requestId: string): Promise<number>;
170
188
  export interface HooksInstallFlags {
@@ -191,7 +209,7 @@ export declare function configSetCommand(deps: CommandDeps, key: string, rawValu
191
209
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
192
210
  * Keep this immutable and public because the command is printed to users.
193
211
  */
194
- export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.1.8";
212
+ export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.2.1";
195
213
  /** Derive a contract-valid project slug from a directory name. */
196
214
  export declare function projectSlugFrom(name: string): string;
197
215
  export interface InitFlags {
package/dist/commands.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
7
- import { CAPABILITIES_V1, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
7
+ import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_MAX_WINDOW_SECONDS, validateDraft, } from '@raidiant/notifai-protocol';
8
8
  import { sha256Hex } from '@raidiant/notifai-protocol/node';
9
9
  import { ApiCallError, NetworkError, createClient, } from './client.js';
10
10
  import { BOOLEAN_CONFIG_KEYS, CONFIG_KEYS, NUMERIC_CONFIG_KEYS, configBounds, findProjectConfigPath, findProjectLocalConfigPath, globalConfigPath, loadConfig, sessionConfigPath, } from './config.js';
@@ -13,7 +13,7 @@ import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, p
13
13
  import { readIdleSeconds } from './idle.js';
14
14
  import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
15
15
  import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
16
- import { CHOICE_USAGE, ambiguousChoiceSplit, buildDraft, formatReceipt, parseChoices, receiptExitCode, } from './send.js';
16
+ import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
17
17
  export const EXIT = {
18
18
  ok: 0,
19
19
  failed: 1,
@@ -433,20 +433,25 @@ export async function repliesCommand(deps, requestedId, flags) {
433
433
  deps.io.err('Pass a request id or --pending, not both.');
434
434
  return EXIT.usage;
435
435
  }
436
- let requestId = requestedId;
436
+ let requestIds = requestedId === undefined ? [] : [requestedId];
437
437
  if (flags.pending === true) {
438
438
  const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
439
439
  if (sessionId === null) {
440
440
  deps.io.err('No active session pointer is available in this directory.');
441
441
  return EXIT.noReply;
442
442
  }
443
- requestId = readSessionState(sessionId, deps.env).pending?.request_id;
444
- if (requestId === undefined) {
443
+ // Every delivered question in the session's queue, in registration order —
444
+ // an agent may have several outstanding at once.
445
+ const state = readSessionState(sessionId, deps.env);
446
+ requestIds = (Array.isArray(state.pending) ? state.pending : [])
447
+ .map((entry) => entry.request_id)
448
+ .filter((id) => id !== undefined);
449
+ if (requestIds.length === 0) {
445
450
  deps.io.err(`Session ${sessionId} has no pushed question pending.`);
446
451
  return EXIT.noReply;
447
452
  }
448
453
  }
449
- if (requestId === undefined) {
454
+ if (requestIds.length === 0) {
450
455
  deps.io.err('Pass a request id or --pending.');
451
456
  return EXIT.usage;
452
457
  }
@@ -455,27 +460,44 @@ export async function repliesCommand(deps, requestedId, flags) {
455
460
  if (!authed)
456
461
  return EXIT.auth;
457
462
  try {
458
- const result = await waitForReply(authed.client, requestId, {
459
- timeoutSeconds: waitSeconds,
460
- afterSeq,
461
- now: deps.now,
462
- sleep: deps.sleep,
463
- });
464
- if (flags.json) {
465
- deps.io.out(JSON.stringify({ ...result.response, degraded: result.degraded }, null, 2));
463
+ let anyReplies = false;
464
+ let anyDegraded = false;
465
+ let allTimedOut = true;
466
+ const jsonBodies = [];
467
+ for (const requestId of requestIds) {
468
+ const result = await waitForReply(authed.client, requestId, {
469
+ timeoutSeconds: waitSeconds,
470
+ afterSeq,
471
+ now: deps.now,
472
+ sleep: deps.sleep,
473
+ });
474
+ if (flags.json) {
475
+ jsonBodies.push({ ...result.response, degraded: result.degraded });
476
+ }
477
+ else if (result.response.replies.length > 0) {
478
+ if (flags.pending === true)
479
+ deps.io.out(`pending request ${requestId}`);
480
+ printReplies(deps, result.response.replies);
481
+ }
482
+ else {
483
+ printNoReply(deps, requestId, result.response.reply_expires_at);
484
+ }
485
+ anyReplies ||= result.response.replies.length > 0;
486
+ anyDegraded ||= result.degraded;
487
+ allTimedOut &&= result.timedOut;
466
488
  }
467
- else if (result.response.replies.length > 0) {
468
- if (flags.pending === true)
469
- deps.io.out(`pending request ${requestId}`);
470
- printReplies(deps, result.response.replies);
489
+ if (flags.json) {
490
+ // One request keeps the response shape agents already parse; several
491
+ // (only possible via --pending) arrive as an array in queue order.
492
+ deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
471
493
  }
472
- else
473
- printNoReply(deps, requestId, result.response.reply_expires_at);
474
- if (result.degraded) {
494
+ if (anyDegraded) {
475
495
  deps.io.err(DEGRADED_WAIT_WARNING);
476
496
  return EXIT.network;
477
497
  }
478
- return result.timedOut ? EXIT.noReply : EXIT.ok;
498
+ if (anyReplies)
499
+ return EXIT.ok;
500
+ return allTimedOut ? EXIT.noReply : EXIT.ok;
479
501
  }
480
502
  catch (err) {
481
503
  return reportError(deps, err);
@@ -550,29 +572,27 @@ function printReplies(deps, replies) {
550
572
  deps.io.err(contradiction);
551
573
  }
552
574
  /**
553
- * The other half of first-reply-wins.
575
+ * The correction note for a wait that saw more than one answer.
554
576
  *
555
- * A blocking send returns the first non-empty batch and acts on it, so a later
556
- * answer that disagrees is silently ignored. That is the right default — an
557
- * agent wants one answer, not a quorum — but silently is the wrong way to do
558
- * it. The server now retires the question on the other devices the moment one
559
- * answers, which makes this rare; it is still reachable, because a device can
560
- * answer between the first reply landing and its retirement arriving.
577
+ * A later reply that conflicts with an earlier one is a correction, so the
578
+ * latest answer is the one that counts. The server retires the question on
579
+ * the other devices the moment one answers, which makes this rare; it is
580
+ * still reachable, because a device can answer between the first reply
581
+ * landing and its retirement arriving.
561
582
  *
562
- * Ordered by seq, so `replies[0]` is the one that won.
583
+ * Ordered by seq, so `replies.at(-1)` is the answer to act on.
563
584
  */
564
585
  export function contradictingAnswer(replies) {
565
- const winner = replies[0];
586
+ const winner = replies.at(-1);
566
587
  if (winner === undefined || replies.length < 2)
567
588
  return null;
568
- const disagreeing = replies
569
- .slice(1)
570
- .filter((reply) => (reply.choice_id ?? reply.text) !== (winner.choice_id ?? winner.text));
571
- if (disagreeing.length === 0)
589
+ const key = (reply) => reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text;
590
+ const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner));
591
+ if (corrected.length === 0)
572
592
  return null;
573
- const names = [...new Set(disagreeing.map((reply) => reply.device_name))].join(', ');
574
- return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — it arrived first. ` +
575
- `${names} answered differently afterwards and that answer was not used.`);
593
+ const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ');
594
+ return (`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
595
+ `answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`);
576
596
  }
577
597
  function printNoReply(deps, requestId, expiresAt) {
578
598
  // A harness hook may have retired this question, in which case promising an
@@ -841,6 +861,88 @@ function rejectedPaths(details) {
841
861
  .filter((path) => path !== null && path !== '')
842
862
  .slice(0, 5);
843
863
  }
864
+ /**
865
+ * Turn ask input into the question set that will ride the push. Everything is
866
+ * validated here, at registration, because the push happens inside a hook
867
+ * where a rejection becomes a stderr note the agent never reads.
868
+ */
869
+ export function buildQuestions(flags, question) {
870
+ if (flags.form !== undefined) {
871
+ if (question !== undefined || flags.choice?.length || flags.multi) {
872
+ return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' };
873
+ }
874
+ let parsed;
875
+ try {
876
+ parsed = JSON.parse(flags.form);
877
+ }
878
+ catch {
879
+ return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' };
880
+ }
881
+ if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.questions)) {
882
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
883
+ }
884
+ const form = parsed;
885
+ if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
886
+ return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` };
887
+ }
888
+ if (form.detail !== undefined && typeof form.detail !== 'string') {
889
+ return { ok: false, error: '"detail" must be a markdown string.' };
890
+ }
891
+ const questions = [];
892
+ const usedIds = new Set();
893
+ for (const [index, entry] of form.questions.entries()) {
894
+ if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
895
+ return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
896
+ }
897
+ const spec = entry;
898
+ const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds);
899
+ if ('error' in built)
900
+ return { ok: false, error: `Question ${index + 1}: ${built.error}` };
901
+ questions.push(built.question);
902
+ }
903
+ return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) };
904
+ }
905
+ if (question === undefined || question.trim() === '') {
906
+ return { ok: false, error: 'The question cannot be empty.' };
907
+ }
908
+ const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
909
+ if ('error' in built)
910
+ return { ok: false, error: built.error };
911
+ return {
912
+ ok: true,
913
+ questions: [built.question],
914
+ ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
915
+ };
916
+ }
917
+ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
918
+ const trimmed = text.trim();
919
+ if (trimmed === '')
920
+ return { error: 'the question text cannot be empty.' };
921
+ if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
922
+ return {
923
+ error: `a question must be readable where it is answered: keep it within ` +
924
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
925
+ };
926
+ }
927
+ const choices = parseChoices(choiceLabels);
928
+ if (choices === 'invalid')
929
+ return { error: CHOICE_USAGE };
930
+ if (multi && choices === null) {
931
+ return { error: '--multi needs answers to select between; add --choice.' };
932
+ }
933
+ let id = slugify(trimmed);
934
+ if (id === '' || usedIds.has(id))
935
+ id = `q${index + 1}`;
936
+ usedIds.add(id);
937
+ return {
938
+ question: {
939
+ id,
940
+ text: trimmed,
941
+ ...(choices !== null ? { choices } : {}),
942
+ ...(multi ? { multi: true } : {}),
943
+ },
944
+ };
945
+ }
844
946
  /**
845
947
  * Registers a question for turn-end routing under the user's presence config.
846
948
  * Returns immediately so the agent can ask in prose and end its turn. With the
@@ -895,37 +997,38 @@ export function askCommand(deps, question, flags) {
895
997
  deps.io.err(line);
896
998
  return EXIT.usage;
897
999
  }
898
- if (question.trim() === '') {
899
- deps.io.err('The question cannot be empty.');
900
- return EXIT.usage;
901
- }
902
1000
  // Validate here, not at push time. The push happens inside a hook, where a
903
1001
  // rejection becomes a stderr note the agent never reads — so a malformed
904
- // choice set would look like it registered fine and then silently never ask.
905
- const choices = parseChoices(flags.choice);
906
- if (choices === 'invalid') {
907
- deps.io.err(CHOICE_USAGE);
1002
+ // question set would look like it registered fine and then silently never ask.
1003
+ const built = buildQuestions(flags, question);
1004
+ if (!built.ok) {
1005
+ deps.io.err(built.error);
908
1006
  return EXIT.usage;
909
1007
  }
910
1008
  try {
911
1009
  registerQuestion(sessionId, deps.env, {
912
- question: question.trim(),
913
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
1010
+ question: built.questions[0].text,
1011
+ questions: built.questions,
1012
+ ...(built.detail !== undefined ? { detail: built.detail } : {}),
914
1013
  }, (deps.now ?? Date.now)());
915
1014
  }
916
1015
  catch (err) {
917
1016
  deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
918
1017
  return EXIT.failed;
919
1018
  }
920
- if (choices !== null) {
921
- deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`);
1019
+ for (const [index, entry] of built.questions.entries()) {
1020
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
1021
+ if (entry.choices !== undefined) {
1022
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
1023
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
1024
+ }
1025
+ else if (built.questions.length > 1) {
1026
+ deps.io.out(`${prefix}${entry.text} — free text`);
1027
+ }
922
1028
  }
923
- // On stdout, not stderr: the agent reads the former and this is only useful
924
- // if it is read while there is still time to fix the question.
925
- const ambiguous = ambiguousChoiceSplit(flags.choice);
926
- if (ambiguous !== null)
927
- deps.io.out(ambiguous);
928
- deps.io.out('Question registered. Ask it in the conversation as usual and end your turn.');
1029
+ deps.io.out(built.questions.length > 1
1030
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
1031
+ : 'Question registered. Ask it in the conversation as usual and end your turn.');
929
1032
  return EXIT.ok;
930
1033
  }
931
1034
  function activeHarnessSession(env) {
@@ -1381,7 +1484,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1381
1484
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1382
1485
  * Keep this immutable and public because the command is printed to users.
1383
1486
  */
1384
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1.8';
1487
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1';
1385
1488
  function skillSourceParts() {
1386
1489
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
1387
1490
  return match === null ? null : { source: match[1], ref: match[2] };
@@ -2393,12 +2496,16 @@ function remedyLine(state) {
2393
2496
  * way and is not worth re-deriving. The judgment added here is which failures
2394
2497
  * actually stand in the way.
2395
2498
  *
2396
- * Two do not. A hook that has never fired is the normal condition of an
2397
- * install thirty seconds old, and OpenCode's inability to resume an idle turn
2398
- * is a property of that harness rather than a fault in this setup. Treating
2399
- * either as blocking would mean `init` could never finish for an OpenCode
2400
- * user, or could only finish after a session had already run — so both report
2401
- * as things worth knowing rather than things to fix.
2499
+ * Not everything failed is in the way. A pointer that has never been
2500
+ * published is the normal condition of an install thirty seconds old — the
2501
+ * next prompt fixes it and no command can — and OpenCode's inability to
2502
+ * resume an idle turn is a property of that harness rather than a fault in
2503
+ * this setup. Treating those as blocking would mean `init` could never
2504
+ * finish for an OpenCode user, or could only finish after a session had
2505
+ * already run — so they report as things worth knowing rather than things to
2506
+ * fix, and `init` walks on to the states it can actually close, delivery
2507
+ * proof included. Which failures are informational, and what the true remedy
2508
+ * is, is each check's own call (`HookCheck`).
2402
2509
  */
2403
2510
  function hookStates(deps) {
2404
2511
  const installations = findInstallations(deps.cwd, deps.env);
@@ -2438,23 +2545,30 @@ function hookStates(deps) {
2438
2545
  }
2439
2546
  /** Real but not in the way; see the note above. */
2440
2547
  const informational = new Set(['hooks (opencode continuation)']);
2441
- if (active === null)
2442
- informational.add('hooks (fired)');
2443
2548
  return [
2444
2549
  ...hookChecks(deps).map((check) => ({
2445
2550
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2446
2551
  title: check.name === 'hooks' || check.name.startsWith('hooks (active')
2447
2552
  ? 'Question routing'
2448
2553
  : check.name,
2449
- status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
2554
+ status: check.ok
2555
+ ? 'ready'
2556
+ : check.informational === true || informational.has(check.name)
2557
+ ? 'optional-gap'
2558
+ : 'gap',
2450
2559
  detail: check.detail,
2451
2560
  ...(check.ok
2452
2561
  ? {}
2453
2562
  : {
2454
2563
  remedy: {
2455
2564
  by: 'user-here',
2456
- summary: 'the detail above names what to change',
2457
- command: 'notifai hooks install',
2565
+ // The check's own remedy when it has one; the generic reinstall
2566
+ // line was wrong exactly where it mattered (an unfired pointer
2567
+ // needs a prompt, not `hooks install`).
2568
+ ...(check.remedy ?? {
2569
+ summary: 'the detail above names what to change',
2570
+ command: 'notifai hooks install',
2571
+ }),
2458
2572
  },
2459
2573
  }),
2460
2574
  })),
@@ -2492,21 +2606,52 @@ function hookChecks(deps) {
2492
2606
  detail: activeInstallations.length > 0
2493
2607
  ? `active ${active.label} session has a matching hook installation`
2494
2608
  : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
2609
+ ...(activeInstallations.length > 0
2610
+ ? {}
2611
+ : {
2612
+ remedy: {
2613
+ summary: `install hooks for the active ${active.label} session`,
2614
+ command: `notifai hooks install --harness ${active.harness}`,
2615
+ },
2616
+ }),
2495
2617
  });
2496
2618
  if (activeInstallations.length > 0) {
2497
2619
  const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2498
- const matches = pointer !== null &&
2499
- pointer.harness === active.harness &&
2500
- (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2501
- checks.push({
2502
- name: 'hooks (active session)',
2503
- ok: matches,
2504
- detail: pointer === null
2505
- ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
2506
- : matches
2620
+ if (pointer === null) {
2621
+ // The normal condition of hooks installed moments ago: the pointer
2622
+ // appears when the harness next fires a hook, and no command can
2623
+ // force that. Informational, so `init` walks on to the states it can
2624
+ // actually prove instead of exiting over evidence only time produces.
2625
+ checks.push({
2626
+ name: 'hooks (active session)',
2627
+ ok: false,
2628
+ informational: true,
2629
+ detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
2630
+ remedy: {
2631
+ summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
2632
+ command: 'notifai doctor',
2633
+ },
2634
+ });
2635
+ }
2636
+ else {
2637
+ const matches = pointer.harness === active.harness &&
2638
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2639
+ checks.push({
2640
+ name: 'hooks (active session)',
2641
+ ok: matches,
2642
+ detail: matches
2507
2643
  ? `the project pointer belongs to the active ${active.label} session`
2508
2644
  : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
2509
- });
2645
+ ...(matches
2646
+ ? {}
2647
+ : {
2648
+ remedy: {
2649
+ summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
2650
+ command: 'notifai doctor',
2651
+ },
2652
+ }),
2653
+ });
2654
+ }
2510
2655
  }
2511
2656
  }
2512
2657
  // A handler naming an event this build dropped exits 2 every time the harness
@@ -2574,9 +2719,20 @@ function hookChecks(deps) {
2574
2719
  checks.push({
2575
2720
  name: 'hooks (fired)',
2576
2721
  ok: fired,
2722
+ // Never-fired is evidence about the past, not a fault in the setup, and
2723
+ // the cure is a prompt no command can send.
2724
+ informational: true,
2577
2725
  detail: fired
2578
2726
  ? 'a session in this directory has run them'
2579
2727
  : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
2728
+ ...(fired
2729
+ ? {}
2730
+ : {
2731
+ remedy: {
2732
+ summary: 'send one prompt in a session here, then re-check',
2733
+ command: 'notifai doctor',
2734
+ },
2735
+ }),
2580
2736
  });
2581
2737
  if (installations.some((installation) => installation.harness === 'opencode')) {
2582
2738
  checks.push({