@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.
@@ -1,7 +1,8 @@
1
- import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView } from '@raidiant/notifai-protocol';
1
+ import { type ListRepliesResponse, type Platform, type QuestionT, type ReplyView, type RoutableDevice } 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';
5
+ import type { Tone } from './ui/theme.js';
5
6
  import { type HookHarness } from './hooks.js';
6
7
  import { type SendFlags } from './send.js';
7
8
  import type { NativeSkills, SkillScope } from './native-skills.js';
@@ -28,7 +29,13 @@ export interface CommandIo {
28
29
  outro?(message: string): Promise<void>;
29
30
  note?(message: string, title?: string): Promise<void>;
30
31
  spinner?(message: string): Promise<CommandSpinner | null>;
31
- check?(ok: boolean, message: string): Promise<void>;
32
+ /**
33
+ * A report line. `tone` distinguishes the two states that are neither pass
34
+ * nor fail — something optional the user declined, and something that was
35
+ * never evaluated — which a boolean has to round to one or the other.
36
+ * Optional so existing implementations keep type-checking.
37
+ */
38
+ check?(ok: boolean, message: string, tone?: Tone): Promise<void>;
32
39
  }
33
40
  export interface CommandSpinner {
34
41
  message(message: string): void;
@@ -75,6 +82,18 @@ export declare function accessStatusCommand(deps: CommandDeps, flags: {
75
82
  export declare function devicesCommand(deps: CommandDeps, flags: {
76
83
  json?: boolean;
77
84
  }): Promise<number>;
85
+ /**
86
+ * Devices as data, for surfaces that render them themselves.
87
+ *
88
+ * `devicesCommand` prints and returns an exit code, which is the right shape
89
+ * for a command and the wrong one for a menu that wants to show names beside
90
+ * checkboxes. Silent on every failure by design: the interactive app already
91
+ * shows the credential and connectivity state on its status card, so a second
92
+ * error line here would be reporting the same fault twice.
93
+ */
94
+ export declare function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null>;
95
+ /** Whether a device is in a state where a notification would actually arrive. */
96
+ export declare function canDeviceReceive(device: RoutableDevice): boolean;
78
97
  export declare function capabilitiesCommand(deps: CommandDeps, flags: {
79
98
  json?: boolean;
80
99
  platform?: Platform;
@@ -197,6 +216,17 @@ export declare function hooksUninstallCommand(deps: CommandDeps, flags: HooksIns
197
216
  export declare function configShowCommand(deps: CommandDeps, flags: {
198
217
  json?: boolean;
199
218
  explain?: boolean;
219
+ plain?: boolean;
220
+ }): number;
221
+ /**
222
+ * One setting, explained in full.
223
+ *
224
+ * The gap this closes: `config show` prints `require_idle = true` and there was
225
+ * nowhere to go from there. Every key already had a careful explanation — in a
226
+ * TypeScript comment, read by everyone except the person who needed it.
227
+ */
228
+ export declare function configExplainCommand(deps: CommandDeps, key: string, flags?: {
229
+ json?: boolean;
200
230
  }): number;
201
231
  export declare function configSetCommand(deps: CommandDeps, key: string, rawValue: string, flags: {
202
232
  project?: boolean;
@@ -209,7 +239,7 @@ export declare function configSetCommand(deps: CommandDeps, key: string, rawValu
209
239
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
210
240
  * Keep this immutable and public because the command is printed to users.
211
241
  */
212
- export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.2.0";
242
+ export declare const SKILLS_SOURCE = "Raidiant-io/notifai#v0.3.0";
213
243
  /** Derive a contract-valid project slug from a directory name. */
214
244
  export declare function projectSlugFrom(name: string): string;
215
245
  export interface InitFlags {
package/dist/commands.js CHANGED
@@ -8,7 +8,9 @@ import { CAPABILITIES_V1, QUESTION_TEXT_MAX_LENGTH, REPLY_MAX_QUESTIONS, REPLY_M
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';
11
- import { firstBlocker, openItems } from './readiness.js';
11
+ import { acceptedValues, configInfo } from './config-schema.js';
12
+ import { renderConfigExplain, renderConfigList, renderConfigPlain, } from './ui/config-view.js';
13
+ import { firstBlocker, openItems, } from './readiness.js';
12
14
  import { handleSessionEnd, handleStop, handleUserPromptSubmit, parseHookInput, pruneAbandonedSessions, readProjectSession, readProjectSessionPointer, readSessionState, registerQuestion, } from './hooks.js';
13
15
  import { readIdleSeconds } from './idle.js';
14
16
  import { HARNESSES, applyPlan, blockingHookTimeoutSeconds, buildCursorHookConfig, buildHookConfig, codexLayerDir, codexProjectRoot, detectHarness, findInstallations, handlerEvent, loadCursorSettings, loadSettings, mergeCursorHooks, mergeHooks, removeCursorHooks, removeHooks, settingsFile, } from './install-hooks.js';
@@ -237,6 +239,33 @@ export async function devicesCommand(deps, flags) {
237
239
  return reportError(deps, err);
238
240
  }
239
241
  }
242
+ /**
243
+ * Devices as data, for surfaces that render them themselves.
244
+ *
245
+ * `devicesCommand` prints and returns an exit code, which is the right shape
246
+ * for a command and the wrong one for a menu that wants to show names beside
247
+ * checkboxes. Silent on every failure by design: the interactive app already
248
+ * shows the credential and connectivity state on its status card, so a second
249
+ * error line here would be reporting the same fault twice.
250
+ */
251
+ export async function deviceInventory(deps) {
252
+ if (!deps.store.load())
253
+ return null;
254
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
255
+ const authed = authedClient(deps, config);
256
+ if (!authed)
257
+ return null;
258
+ try {
259
+ return (await authed.client.listDevices()).devices;
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ /** Whether a device is in a state where a notification would actually arrive. */
266
+ export function canDeviceReceive(device) {
267
+ return deviceCanReceive(device);
268
+ }
240
269
  export async function capabilitiesCommand(deps, flags) {
241
270
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
242
271
  const credential = deps.store.load();
@@ -433,20 +462,25 @@ export async function repliesCommand(deps, requestedId, flags) {
433
462
  deps.io.err('Pass a request id or --pending, not both.');
434
463
  return EXIT.usage;
435
464
  }
436
- let requestId = requestedId;
465
+ let requestIds = requestedId === undefined ? [] : [requestedId];
437
466
  if (flags.pending === true) {
438
467
  const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)());
439
468
  if (sessionId === null) {
440
469
  deps.io.err('No active session pointer is available in this directory.');
441
470
  return EXIT.noReply;
442
471
  }
443
- requestId = readSessionState(sessionId, deps.env).pending?.request_id;
444
- if (requestId === undefined) {
472
+ // Every delivered question in the session's queue, in registration order —
473
+ // an agent may have several outstanding at once.
474
+ const state = readSessionState(sessionId, deps.env);
475
+ requestIds = (Array.isArray(state.pending) ? state.pending : [])
476
+ .map((entry) => entry.request_id)
477
+ .filter((id) => id !== undefined);
478
+ if (requestIds.length === 0) {
445
479
  deps.io.err(`Session ${sessionId} has no pushed question pending.`);
446
480
  return EXIT.noReply;
447
481
  }
448
482
  }
449
- if (requestId === undefined) {
483
+ if (requestIds.length === 0) {
450
484
  deps.io.err('Pass a request id or --pending.');
451
485
  return EXIT.usage;
452
486
  }
@@ -455,27 +489,44 @@ export async function repliesCommand(deps, requestedId, flags) {
455
489
  if (!authed)
456
490
  return EXIT.auth;
457
491
  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));
492
+ let anyReplies = false;
493
+ let anyDegraded = false;
494
+ let allTimedOut = true;
495
+ const jsonBodies = [];
496
+ for (const requestId of requestIds) {
497
+ const result = await waitForReply(authed.client, requestId, {
498
+ timeoutSeconds: waitSeconds,
499
+ afterSeq,
500
+ now: deps.now,
501
+ sleep: deps.sleep,
502
+ });
503
+ if (flags.json) {
504
+ jsonBodies.push({ ...result.response, degraded: result.degraded });
505
+ }
506
+ else if (result.response.replies.length > 0) {
507
+ if (flags.pending === true)
508
+ deps.io.out(`pending request ${requestId}`);
509
+ printReplies(deps, result.response.replies);
510
+ }
511
+ else {
512
+ printNoReply(deps, requestId, result.response.reply_expires_at);
513
+ }
514
+ anyReplies ||= result.response.replies.length > 0;
515
+ anyDegraded ||= result.degraded;
516
+ allTimedOut &&= result.timedOut;
466
517
  }
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);
518
+ if (flags.json) {
519
+ // One request keeps the response shape agents already parse; several
520
+ // (only possible via --pending) arrive as an array in queue order.
521
+ deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2));
471
522
  }
472
- else
473
- printNoReply(deps, requestId, result.response.reply_expires_at);
474
- if (result.degraded) {
523
+ if (anyDegraded) {
475
524
  deps.io.err(DEGRADED_WAIT_WARNING);
476
525
  return EXIT.network;
477
526
  }
478
- return result.timedOut ? EXIT.noReply : EXIT.ok;
527
+ if (anyReplies)
528
+ return EXIT.ok;
529
+ return allTimedOut ? EXIT.noReply : EXIT.ok;
479
530
  }
480
531
  catch (err) {
481
532
  return reportError(deps, err);
@@ -1378,39 +1429,133 @@ export function configShowCommand(deps, flags) {
1378
1429
  deps.io.out(JSON.stringify(output, null, 2));
1379
1430
  return EXIT.ok;
1380
1431
  }
1381
- for (const key of CONFIG_KEYS) {
1382
- const entry = config[key];
1383
- const provenance = flags.explain ? ` [${entry.source}]` : '';
1384
- deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`);
1432
+ // Anything that is not a person at a terminal keeps the flat `key = value`
1433
+ // form it has always had. Scripts parse this, and a prettier layout for an
1434
+ // audience that cannot see it would only be a breaking change.
1435
+ if (deps.io.interactive !== true || flags.plain === true) {
1436
+ for (const line of renderConfigPlain(config, flags.explain === true))
1437
+ deps.io.out(line);
1438
+ return EXIT.ok;
1439
+ }
1440
+ for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
1441
+ deps.io.out(line);
1385
1442
  }
1386
1443
  return EXIT.ok;
1387
1444
  }
1445
+ /**
1446
+ * One setting, explained in full.
1447
+ *
1448
+ * The gap this closes: `config show` prints `require_idle = true` and there was
1449
+ * nowhere to go from there. Every key already had a careful explanation — in a
1450
+ * TypeScript comment, read by everyone except the person who needed it.
1451
+ */
1452
+ export function configExplainCommand(deps, key, flags = {}) {
1453
+ if (!CONFIG_KEYS.includes(key)) {
1454
+ deps.io.err(`Unknown setting "${key}".`);
1455
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
1456
+ return EXIT.usage;
1457
+ }
1458
+ const configKey = key;
1459
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1460
+ const info = configInfo(configKey);
1461
+ const entry = config[configKey];
1462
+ if (flags.json) {
1463
+ deps.io.out(JSON.stringify({
1464
+ key: configKey,
1465
+ label: info.label,
1466
+ group: info.group,
1467
+ kind: info.kind,
1468
+ summary: info.summary,
1469
+ detail: info.detail,
1470
+ accepts: acceptedValues(configKey),
1471
+ ...(info.choices !== undefined ? { choices: info.choices } : {}),
1472
+ value: entry.value,
1473
+ source: entry.source,
1474
+ }, null, 2));
1475
+ return EXIT.ok;
1476
+ }
1477
+ if (deps.io.interactive !== true) {
1478
+ deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`);
1479
+ deps.io.out(info.detail.replace(/\n\n/g, '\n'));
1480
+ deps.io.out(`accepts: ${acceptedValues(configKey)}`);
1481
+ return EXIT.ok;
1482
+ }
1483
+ for (const line of renderConfigExplain(configKey, config))
1484
+ deps.io.out(line);
1485
+ return EXIT.ok;
1486
+ }
1487
+ /**
1488
+ * Closest config key by edit distance, or null when nothing is close.
1489
+ *
1490
+ * The threshold matters more than the algorithm: suggesting a key that shares
1491
+ * three letters with the typo sends the reader to the wrong setting with
1492
+ * confidence, which is worse than listing all fourteen and letting them look.
1493
+ */
1494
+ function nearestKey(input) {
1495
+ let best = null;
1496
+ let bestDistance = Number.POSITIVE_INFINITY;
1497
+ for (const candidate of CONFIG_KEYS) {
1498
+ const distance = editDistance(input.toLowerCase(), candidate);
1499
+ if (distance < bestDistance) {
1500
+ bestDistance = distance;
1501
+ best = candidate;
1502
+ }
1503
+ }
1504
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
1505
+ }
1506
+ function editDistance(a, b) {
1507
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
1508
+ for (let i = 1; i <= a.length; i += 1) {
1509
+ let diagonal = previous[0];
1510
+ previous[0] = i;
1511
+ for (let j = 1; j <= b.length; j += 1) {
1512
+ const above = previous[j];
1513
+ previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
1514
+ diagonal = above;
1515
+ }
1516
+ }
1517
+ return previous[b.length];
1518
+ }
1388
1519
  export async function configSetCommand(deps, key, rawValue, flags) {
1389
1520
  if (!CONFIG_KEYS.includes(key)) {
1390
- deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`);
1521
+ deps.io.err(`Unknown setting "${key}".`);
1522
+ const near = nearestKey(key);
1523
+ if (near !== null)
1524
+ deps.io.err(`Did you mean "${near}"?`);
1525
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`);
1526
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.');
1391
1527
  return EXIT.usage;
1392
1528
  }
1529
+ const configKey = key;
1530
+ const info = configInfo(configKey);
1393
1531
  let value = rawValue;
1394
- if (NUMERIC_CONFIG_KEYS.includes(key)) {
1532
+ if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
1395
1533
  const numeric = Number(rawValue);
1396
1534
  if (!Number.isInteger(numeric)) {
1397
- deps.io.err(`"${rawValue}" is not an integer.`);
1535
+ deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`);
1398
1536
  return EXIT.usage;
1399
1537
  }
1400
- const bounds = configBounds(key);
1538
+ const bounds = configBounds(configKey);
1401
1539
  if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1402
1540
  deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`);
1403
1541
  return EXIT.usage;
1404
1542
  }
1405
1543
  value = numeric;
1406
1544
  }
1407
- if (BOOLEAN_CONFIG_KEYS.includes(key)) {
1545
+ if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
1408
1546
  if (rawValue !== 'true' && rawValue !== 'false') {
1409
1547
  deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`);
1410
1548
  return EXIT.usage;
1411
1549
  }
1412
1550
  value = rawValue === 'true';
1413
1551
  }
1552
+ // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
1553
+ // value the sender would later reject — a typo that only surfaces at the
1554
+ // moment a notification fails to carry the sound you asked for.
1555
+ if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
1556
+ deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`);
1557
+ return EXIT.usage;
1558
+ }
1414
1559
  if (key === 'devices')
1415
1560
  value = rawValue.split(',').map((s) => s.trim()).filter(Boolean);
1416
1561
  let layer = flags.local ? 'local' : flags.project ? 'project' : 'global';
@@ -1462,7 +1607,7 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1462
1607
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1463
1608
  * Keep this immutable and public because the command is printed to users.
1464
1609
  */
1465
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.0';
1610
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0';
1466
1611
  function skillSourceParts() {
1467
1612
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE);
1468
1613
  return match === null ? null : { source: match[1], ref: match[2] };
@@ -2427,8 +2572,13 @@ export async function doctorCommand(deps, flags) {
2427
2572
  // costs nothing.
2428
2573
  if (deps.io.interactive === true && deps.io.check) {
2429
2574
  await deps.io.intro?.('Notifai doctor');
2430
- for (const s of readiness.states)
2431
- await deps.io.check(s.status !== 'gap', line(s));
2575
+ for (const s of readiness.states) {
2576
+ // Four states, not two. Rendering `optional-gap` and `unknown` as
2577
+ // successes made a green report that had never checked half of what it
2578
+ // listed, and put a tick beside things the reader had deliberately
2579
+ // declined.
2580
+ await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status));
2581
+ }
2432
2582
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`);
2433
2583
  }
2434
2584
  else {
@@ -2447,6 +2597,19 @@ export async function doctorCommand(deps, flags) {
2447
2597
  }
2448
2598
  return ok ? EXIT.ok : EXIT.failed;
2449
2599
  }
2600
+ /** Readiness status as a report tone. */
2601
+ function doctorTone(status) {
2602
+ switch (status) {
2603
+ case 'ready':
2604
+ return 'ok';
2605
+ case 'gap':
2606
+ return 'bad';
2607
+ case 'optional-gap':
2608
+ return 'warn';
2609
+ case 'unknown':
2610
+ return 'pending';
2611
+ }
2612
+ }
2450
2613
  /** One line telling the reader what to actually do about a state. */
2451
2614
  function remedyLine(state) {
2452
2615
  const remedy = state.remedy;
@@ -2474,12 +2637,16 @@ function remedyLine(state) {
2474
2637
  * way and is not worth re-deriving. The judgment added here is which failures
2475
2638
  * actually stand in the way.
2476
2639
  *
2477
- * Two do not. A hook that has never fired is the normal condition of an
2478
- * install thirty seconds old, and OpenCode's inability to resume an idle turn
2479
- * is a property of that harness rather than a fault in this setup. Treating
2480
- * either as blocking would mean `init` could never finish for an OpenCode
2481
- * user, or could only finish after a session had already run — so both report
2482
- * as things worth knowing rather than things to fix.
2640
+ * Not everything failed is in the way. A pointer that has never been
2641
+ * published is the normal condition of an install thirty seconds old — the
2642
+ * next prompt fixes it and no command can — and OpenCode's inability to
2643
+ * resume an idle turn is a property of that harness rather than a fault in
2644
+ * this setup. Treating those as blocking would mean `init` could never
2645
+ * finish for an OpenCode user, or could only finish after a session had
2646
+ * already run — so they report as things worth knowing rather than things to
2647
+ * fix, and `init` walks on to the states it can actually close, delivery
2648
+ * proof included. Which failures are informational, and what the true remedy
2649
+ * is, is each check's own call (`HookCheck`).
2483
2650
  */
2484
2651
  function hookStates(deps) {
2485
2652
  const installations = findInstallations(deps.cwd, deps.env);
@@ -2519,23 +2686,30 @@ function hookStates(deps) {
2519
2686
  }
2520
2687
  /** Real but not in the way; see the note above. */
2521
2688
  const informational = new Set(['hooks (opencode continuation)']);
2522
- if (active === null)
2523
- informational.add('hooks (fired)');
2524
2689
  return [
2525
2690
  ...hookChecks(deps).map((check) => ({
2526
2691
  id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
2527
2692
  title: check.name === 'hooks' || check.name.startsWith('hooks (active')
2528
2693
  ? 'Question routing'
2529
2694
  : check.name,
2530
- status: check.ok ? 'ready' : informational.has(check.name) ? 'optional-gap' : 'gap',
2695
+ status: check.ok
2696
+ ? 'ready'
2697
+ : check.informational === true || informational.has(check.name)
2698
+ ? 'optional-gap'
2699
+ : 'gap',
2531
2700
  detail: check.detail,
2532
2701
  ...(check.ok
2533
2702
  ? {}
2534
2703
  : {
2535
2704
  remedy: {
2536
2705
  by: 'user-here',
2537
- summary: 'the detail above names what to change',
2538
- command: 'notifai hooks install',
2706
+ // The check's own remedy when it has one; the generic reinstall
2707
+ // line was wrong exactly where it mattered (an unfired pointer
2708
+ // needs a prompt, not `hooks install`).
2709
+ ...(check.remedy ?? {
2710
+ summary: 'the detail above names what to change',
2711
+ command: 'notifai hooks install',
2712
+ }),
2539
2713
  },
2540
2714
  }),
2541
2715
  })),
@@ -2573,21 +2747,52 @@ function hookChecks(deps) {
2573
2747
  detail: activeInstallations.length > 0
2574
2748
  ? `active ${active.label} session has a matching hook installation`
2575
2749
  : `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
2750
+ ...(activeInstallations.length > 0
2751
+ ? {}
2752
+ : {
2753
+ remedy: {
2754
+ summary: `install hooks for the active ${active.label} session`,
2755
+ command: `notifai hooks install --harness ${active.harness}`,
2756
+ },
2757
+ }),
2576
2758
  });
2577
2759
  if (activeInstallations.length > 0) {
2578
2760
  const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)());
2579
- const matches = pointer !== null &&
2580
- pointer.harness === active.harness &&
2581
- (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2582
- checks.push({
2583
- name: 'hooks (active session)',
2584
- ok: matches,
2585
- detail: pointer === null
2586
- ? `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`
2587
- : matches
2761
+ if (pointer === null) {
2762
+ // The normal condition of hooks installed moments ago: the pointer
2763
+ // appears when the harness next fires a hook, and no command can
2764
+ // force that. Informational, so `init` walks on to the states it can
2765
+ // actually prove instead of exiting over evidence only time produces.
2766
+ checks.push({
2767
+ name: 'hooks (active session)',
2768
+ ok: false,
2769
+ informational: true,
2770
+ detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
2771
+ remedy: {
2772
+ summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
2773
+ command: 'notifai doctor',
2774
+ },
2775
+ });
2776
+ }
2777
+ else {
2778
+ const matches = pointer.harness === active.harness &&
2779
+ (active.sessionId === undefined || pointer.sessionId === active.sessionId);
2780
+ checks.push({
2781
+ name: 'hooks (active session)',
2782
+ ok: matches,
2783
+ detail: matches
2588
2784
  ? `the project pointer belongs to the active ${active.label} session`
2589
2785
  : `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
2590
- });
2786
+ ...(matches
2787
+ ? {}
2788
+ : {
2789
+ remedy: {
2790
+ summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
2791
+ command: 'notifai doctor',
2792
+ },
2793
+ }),
2794
+ });
2795
+ }
2591
2796
  }
2592
2797
  }
2593
2798
  // A handler naming an event this build dropped exits 2 every time the harness
@@ -2655,9 +2860,20 @@ function hookChecks(deps) {
2655
2860
  checks.push({
2656
2861
  name: 'hooks (fired)',
2657
2862
  ok: fired,
2863
+ // Never-fired is evidence about the past, not a fault in the setup, and
2864
+ // the cure is a prompt no command can send.
2865
+ informational: true,
2658
2866
  detail: fired
2659
2867
  ? 'a session in this directory has run them'
2660
2868
  : `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
2869
+ ...(fired
2870
+ ? {}
2871
+ : {
2872
+ remedy: {
2873
+ summary: 'send one prompt in a session here, then re-check',
2874
+ command: 'notifai doctor',
2875
+ },
2876
+ }),
2661
2877
  });
2662
2878
  if (installations.some((installation) => installation.harness === 'opencode')) {
2663
2879
  checks.push({
@@ -2778,14 +2994,22 @@ export function realIo(env = process.env) {
2778
2994
  error: (next) => progress.error(next),
2779
2995
  };
2780
2996
  },
2781
- check: async (ok, message) => {
2997
+ check: async (ok, message, tone) => {
2782
2998
  if (!interactive())
2783
2999
  return;
2784
3000
  const { log } = await clack();
2785
- if (ok)
2786
- log.success(message);
2787
- else
2788
- log.error(message);
3001
+ switch (tone ?? (ok ? 'ok' : 'bad')) {
3002
+ case 'ok':
3003
+ return log.success(message);
3004
+ case 'warn':
3005
+ return log.warn(message);
3006
+ case 'pending':
3007
+ // Never checked. `log.info` reads as neutral, which is the honest
3008
+ // rendering for a state no evidence was gathered about.
3009
+ return log.info(message);
3010
+ case 'bad':
3011
+ return log.error(message);
3012
+ }
2789
3013
  },
2790
3014
  openUrl: (url) => {
2791
3015
  try {