aiden-runtime 4.1.2 → 4.1.3

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.
@@ -55,7 +55,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
55
55
  return (mod && mod.__esModule) ? mod : { "default": mod };
56
56
  };
57
57
  Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.DOCTOR_GROUP_ORDER = void 0;
59
+ exports.subsystemHealthResults = subsystemHealthResults;
58
60
  exports.renderSubsystemHealthSection = renderSubsystemHealthSection;
61
+ exports.skillOutcomeResults = skillOutcomeResults;
62
+ exports.sessionCounterResults = sessionCounterResults;
59
63
  exports.renderSkillOutcomesSection = renderSkillOutcomesSection;
60
64
  exports.resolveBinaryPath = resolveBinaryPath;
61
65
  exports._resetBinaryResolutionCacheForTests = _resetBinaryResolutionCacheForTests;
@@ -86,8 +90,84 @@ const checkUpdate_1 = require("../../core/v4/update/checkUpdate");
86
90
  const box_1 = require("./box");
87
91
  const audioBackend_1 = require("../../core/voice/audioBackend");
88
92
  /**
89
- * Phase v4.1.2-slice3: render the Subsystem health section. Decision
90
- * tree (per slice3 Phase 3 Q4):
93
+ * Display order for groups. Renderer iterates this list rather than
94
+ * the order checks appear in the results array — keeps the visual
95
+ * hierarchy stable even if `runDoctor` reorders its check sequence.
96
+ */
97
+ exports.DOCTOR_GROUP_ORDER = [
98
+ 'Providers',
99
+ 'Inference',
100
+ 'System tools',
101
+ 'Storage',
102
+ 'Voice',
103
+ 'Updates',
104
+ 'Subsystem health',
105
+ 'Skill outcomes',
106
+ // v4.1.3-essentials doctor-polish: session counters land just
107
+ // above provider liveness — they're agent-loop telemetry, distinct
108
+ // from skill outcomes (per-skill stats) and subsystem health
109
+ // (per-subsystem error tracking).
110
+ 'Session counters',
111
+ 'Provider liveness',
112
+ ];
113
+ /**
114
+ * v4.1.3-essentials doctor-polish: convert the subsystem-health
115
+ * registry snapshot into CheckResult rows so they render inside the
116
+ * grouped health box alongside the other checks. Returns an empty
117
+ * array when the registry is undefined / empty so the renderer skips
118
+ * the group cleanly (DOCTOR_GROUP_ORDER drops empty groups).
119
+ *
120
+ * One row per subsystem, plus a fixed "(not instrumented yet)" row
121
+ * for HonestyEnforcement — same convention as the legacy section
122
+ * renderer below.
123
+ */
124
+ function subsystemHealthResults(registry) {
125
+ if (!registry)
126
+ return [];
127
+ const snaps = registry.snapshot();
128
+ if (snaps.length === 0)
129
+ return [];
130
+ const out = [];
131
+ for (const s of snaps) {
132
+ const passed = s.totalErrors === 0;
133
+ const stats = `${s.totalCalls} call${s.totalCalls === 1 ? '' : 's'}, ${s.totalErrors} error${s.totalErrors === 1 ? '' : 's'}`;
134
+ let message = stats;
135
+ let suggestion;
136
+ if (s.lastError) {
137
+ const ago = humanAge(Date.now() - s.lastError.at.getTime());
138
+ const streak = s.lastError.consecutive > 1
139
+ ? ` (${s.lastError.consecutive} consecutive)`
140
+ : '';
141
+ message = `${stats}${streak}`;
142
+ suggestion = `last ${ago} ago: "${s.lastError.message}"`;
143
+ }
144
+ out.push({
145
+ name: s.subsystem,
146
+ group: 'Subsystem health',
147
+ passed,
148
+ message,
149
+ ...(suggestion ? { suggestion } : {}),
150
+ });
151
+ }
152
+ // Slice3 audit decision: HonestyEnforcement was deliberately not
153
+ // instrumented (pure-pattern path has no failure surface). Surface
154
+ // that explicitly so users know the gap is known, not forgotten.
155
+ out.push({
156
+ name: 'honesty',
157
+ group: 'Subsystem health',
158
+ passed: true,
159
+ message: '(not instrumented yet)',
160
+ });
161
+ return out;
162
+ }
163
+ /**
164
+ * Phase v4.1.2-slice3: render the Subsystem health section.
165
+ *
166
+ * v4.1.3-essentials doctor-polish: kept for back-compat with any
167
+ * direct callers but the in-REPL `/doctor` path now uses
168
+ * `subsystemHealthResults()` and renders inline via the grouped box.
169
+ *
170
+ * Decision tree (per slice3 Phase 3 Q4):
91
171
  * - registry undefined → render nothing (no live state to report)
92
172
  * - all subsystems healthy → one-line green summary
93
173
  * - any degradation → expand block with last-error per failed sub
@@ -144,9 +224,102 @@ function humanAge(ms) {
144
224
  return `${Math.floor(ms / 86400000)}d`;
145
225
  }
146
226
  /**
147
- * Phase v4.1.2-slice4: render the Skill outcomes section. Per Q3
148
- * decision: silent on empty state (no tracker, or no skills tracked
149
- * yet) doctor output for healthy systems stays short.
227
+ * v4.1.3-essentials doctor-polish: convert the skill-outcome tracker
228
+ * snapshot into CheckResult rows so they render inside the grouped
229
+ * health box. Empty tracker empty array group dropped by renderer.
230
+ *
231
+ * One row per top-N skill (sorted by load count). Skills with at
232
+ * least one failure render as `passed:false` so they get the red
233
+ * `✗` icon and a hint line with the last-error message.
234
+ */
235
+ function skillOutcomeResults(tracker, topN = 5) {
236
+ if (!tracker)
237
+ return [];
238
+ const snaps = tracker.snapshot();
239
+ if (snaps.length === 0)
240
+ return [];
241
+ const out = [];
242
+ for (const s of snaps.slice(0, topN)) {
243
+ const attributed = s.toolSuccesses + s.toolFailures;
244
+ const rate = attributed === 0
245
+ ? '—'
246
+ : `${Math.round((s.toolSuccesses / attributed) * 100)}% success`;
247
+ const last = s.lastUsed
248
+ ? `, last ${humanAge(Date.now() - new Date(s.lastUsed).getTime())} ago`
249
+ : '';
250
+ const message = `loaded ${s.loaded}, ${s.toolSuccesses} ok, ${s.toolFailures} err (${rate})${last}`;
251
+ const passed = s.toolFailures === 0;
252
+ out.push({
253
+ name: s.skillName,
254
+ group: 'Skill outcomes',
255
+ passed,
256
+ message,
257
+ ...(s.lastError && !passed
258
+ ? { suggestion: `last failure: "${s.lastError.message}"` }
259
+ : {}),
260
+ });
261
+ }
262
+ return out;
263
+ }
264
+ function sessionCounterResults(agent) {
265
+ if (!agent)
266
+ return [];
267
+ const s = agent.getSkillEnforcementMetrics();
268
+ const u = agent.getUrlProvenanceMetrics();
269
+ const e = agent.getEmptyResponseMetrics();
270
+ // Same all-zero classifier per row — keeps the rule symmetric
271
+ // across the three counter sources even though their internal
272
+ // semantics differ.
273
+ const allZero = (vals) => vals.every((v) => v === 0);
274
+ const rows = [];
275
+ // ── skill enforcement ─────────────────────────────────────────────
276
+ {
277
+ const passed = allZero([s.armed, s.preArmed, s.recovered, s.failed]);
278
+ rows.push({
279
+ name: 'skill enforcement',
280
+ group: 'Session counters',
281
+ passed: true,
282
+ message: `armed=${s.armed}, pre-armed=${s.preArmed}, ` +
283
+ `recovered=${s.recovered}, failed=${s.failed}`,
284
+ // Suggestion attached only when non-zero so outcomeBucket()
285
+ // routes the row to warning (yellow) rather than passing (green).
286
+ // Text is concise — full counters already in `message`.
287
+ ...(passed ? {} : { suggestion: 'guard fired this session — review if any failed > 0' }),
288
+ });
289
+ }
290
+ // ── URL provenance ────────────────────────────────────────────────
291
+ {
292
+ const passed = allZero([u.blocked, u.recovered, u.failed]);
293
+ rows.push({
294
+ name: 'url provenance',
295
+ group: 'Session counters',
296
+ passed: true,
297
+ message: `blocked=${u.blocked}, recovered=${u.recovered}, failed=${u.failed}`,
298
+ ...(passed ? {} : { suggestion: 'open_url provenance gate fired this session' }),
299
+ });
300
+ }
301
+ // ── empty response ────────────────────────────────────────────────
302
+ {
303
+ const passed = allZero([e.detected, e.retried, e.recovered]);
304
+ rows.push({
305
+ name: 'empty response',
306
+ group: 'Session counters',
307
+ passed: true,
308
+ message: `detected=${e.detected}, retried=${e.retried}, recovered=${e.recovered}`,
309
+ ...(passed ? {} : { suggestion: 'provider emitted empty turn(s) this session' }),
310
+ });
311
+ }
312
+ return rows;
313
+ }
314
+ /**
315
+ * Phase v4.1.2-slice4: render the Skill outcomes section.
316
+ *
317
+ * v4.1.3-essentials doctor-polish: kept for back-compat. The in-REPL
318
+ * `/doctor` path now uses `skillOutcomeResults()` and renders inline
319
+ * via the grouped box.
320
+ *
321
+ * Per Q3 decision: silent on empty state (no tracker, or no skills
322
+ * tracked yet) — doctor output for healthy systems stays short.
150
323
  *
151
324
  * Output (when not empty): top N skills sorted by load count, with
152
325
  * total observations and success percentage. Last-error message
@@ -328,6 +501,7 @@ async function checkConfigFile(paths) {
328
501
  await node_fs_1.promises.access(paths.configYaml);
329
502
  return {
330
503
  name: 'config file',
504
+ group: 'Storage',
331
505
  passed: true,
332
506
  message: `found at ${paths.configYaml}`,
333
507
  durationMs: Date.now() - t0,
@@ -336,6 +510,7 @@ async function checkConfigFile(paths) {
336
510
  catch {
337
511
  return {
338
512
  name: 'config file',
513
+ group: 'Storage',
339
514
  passed: false,
340
515
  message: `missing at ${paths.configYaml}`,
341
516
  suggestion: 'run `aiden setup` to create one',
@@ -359,6 +534,7 @@ function checkProviderAuth(env) {
359
534
  if (present.length === 0) {
360
535
  return {
361
536
  name: 'provider auth',
537
+ group: 'Providers',
362
538
  passed: false,
363
539
  message: 'no provider API key found in environment',
364
540
  suggestion: 'run `aiden setup` and pick a provider, or set ANTHROPIC_API_KEY',
@@ -367,6 +543,7 @@ function checkProviderAuth(env) {
367
543
  }
368
544
  return {
369
545
  name: 'provider auth',
546
+ group: 'Providers',
370
547
  passed: true,
371
548
  message: `${present.length} provider key(s) present (${present.join(', ')})`,
372
549
  durationMs: Date.now() - t0,
@@ -376,6 +553,7 @@ async function checkOllamaReachable(opts) {
376
553
  const t0 = Date.now();
377
554
  const fallback = {
378
555
  name: 'ollama reachable',
556
+ group: 'Inference',
379
557
  passed: false,
380
558
  message: 'no response from http://localhost:11434',
381
559
  suggestion: 'install Ollama from https://ollama.com or skip if you only use cloud providers',
@@ -393,6 +571,7 @@ async function checkOllamaReachable(opts) {
393
571
  }
394
572
  return {
395
573
  name: 'ollama reachable',
574
+ group: 'Inference',
396
575
  passed: true,
397
576
  message: 'Ollama responding on :11434',
398
577
  durationMs: Date.now() - t0,
@@ -411,6 +590,7 @@ async function checkPythonAvailable(opts) {
411
590
  const t0 = Date.now();
412
591
  const fallback = {
413
592
  name: 'python available',
593
+ group: 'System tools',
414
594
  passed: false,
415
595
  message: 'python not found on PATH',
416
596
  suggestion: 'install python 3.10+ — required for graphify and a few skills',
@@ -423,6 +603,7 @@ async function checkPythonAvailable(opts) {
423
603
  if (res.ok) {
424
604
  return {
425
605
  name: 'python available',
606
+ group: 'System tools',
426
607
  passed: true,
427
608
  message: res.stdout || `${bin} present`,
428
609
  durationMs: Date.now() - t0,
@@ -439,6 +620,7 @@ async function checkDockerAvailable(opts) {
439
620
  if (res.ok) {
440
621
  return {
441
622
  name: 'docker available',
623
+ group: 'System tools',
442
624
  passed: true,
443
625
  message: res.stdout || 'docker present',
444
626
  durationMs: Date.now() - t0,
@@ -446,6 +628,7 @@ async function checkDockerAvailable(opts) {
446
628
  }
447
629
  return {
448
630
  name: 'docker available',
631
+ group: 'System tools',
449
632
  passed: false,
450
633
  message: 'docker not found on PATH',
451
634
  suggestion: 'optional — install Docker Desktop if you want sandboxed tool execution',
@@ -453,6 +636,7 @@ async function checkDockerAvailable(opts) {
453
636
  };
454
637
  })(), opts.timeoutMs, {
455
638
  name: 'docker available',
639
+ group: 'System tools',
456
640
  passed: false,
457
641
  message: 'docker probe timed out',
458
642
  durationMs: Date.now() - t0,
@@ -465,6 +649,7 @@ async function checkNpxAvailable(opts) {
465
649
  if (res.ok) {
466
650
  return {
467
651
  name: 'npx available',
652
+ group: 'System tools',
468
653
  passed: true,
469
654
  message: `npx ${res.stdout}`,
470
655
  durationMs: Date.now() - t0,
@@ -472,6 +657,7 @@ async function checkNpxAvailable(opts) {
472
657
  }
473
658
  return {
474
659
  name: 'npx available',
660
+ group: 'System tools',
475
661
  passed: false,
476
662
  message: 'npx not found on PATH',
477
663
  suggestion: 'install Node.js 20+ — required for npm-published MCP servers',
@@ -479,6 +665,7 @@ async function checkNpxAvailable(opts) {
479
665
  };
480
666
  })(), opts.timeoutMs, {
481
667
  name: 'npx available',
668
+ group: 'System tools',
482
669
  passed: false,
483
670
  message: 'npx probe timed out',
484
671
  durationMs: Date.now() - t0,
@@ -491,6 +678,7 @@ async function checkSkillsDir(paths) {
491
678
  if (!stat.isDirectory()) {
492
679
  return {
493
680
  name: 'skills dir',
681
+ group: 'Storage',
494
682
  passed: false,
495
683
  message: `${paths.skillsDir} is not a directory`,
496
684
  durationMs: Date.now() - t0,
@@ -499,6 +687,7 @@ async function checkSkillsDir(paths) {
499
687
  const entries = await node_fs_1.promises.readdir(paths.skillsDir);
500
688
  return {
501
689
  name: 'skills dir',
690
+ group: 'Storage',
502
691
  passed: true,
503
692
  message: `${paths.skillsDir} (${entries.length} entries)`,
504
693
  durationMs: Date.now() - t0,
@@ -507,6 +696,7 @@ async function checkSkillsDir(paths) {
507
696
  catch {
508
697
  return {
509
698
  name: 'skills dir',
699
+ group: 'Storage',
510
700
  passed: false,
511
701
  message: `missing ${paths.skillsDir}`,
512
702
  suggestion: 'run `aiden setup` — it creates the skills directory',
@@ -520,6 +710,7 @@ async function checkBundledManifest(paths) {
520
710
  await node_fs_1.promises.access(paths.bundledManifest);
521
711
  return {
522
712
  name: 'bundled manifest',
713
+ group: 'Storage',
523
714
  passed: true,
524
715
  message: `present at ${paths.bundledManifest}`,
525
716
  durationMs: Date.now() - t0,
@@ -528,6 +719,7 @@ async function checkBundledManifest(paths) {
528
719
  catch {
529
720
  return {
530
721
  name: 'bundled manifest',
722
+ group: 'Storage',
531
723
  passed: false,
532
724
  message: 'bundled skill manifest missing',
533
725
  suggestion: 'reinstall `aiden` — the package was not unpacked correctly',
@@ -541,6 +733,7 @@ async function checkPlatformPaths(paths) {
541
733
  await node_fs_1.promises.access(paths.root);
542
734
  return {
543
735
  name: 'platform paths',
736
+ group: 'Storage',
544
737
  passed: true,
545
738
  message: `aiden home: ${paths.root}`,
546
739
  durationMs: Date.now() - t0,
@@ -549,6 +742,7 @@ async function checkPlatformPaths(paths) {
549
742
  catch {
550
743
  return {
551
744
  name: 'platform paths',
745
+ group: 'Storage',
552
746
  passed: false,
553
747
  message: `aiden home missing: ${paths.root}`,
554
748
  suggestion: 'run `aiden setup` to initialise the home directory',
@@ -575,6 +769,7 @@ async function checkAudioBackend() {
575
769
  if (playback && record) {
576
770
  return {
577
771
  name: 'audio backend',
772
+ group: 'Voice',
578
773
  passed: true,
579
774
  message: `${process.platform}: playback=${playback.label} · record=${record.label}`,
580
775
  durationMs: Date.now() - t0,
@@ -588,6 +783,7 @@ async function checkAudioBackend() {
588
783
  missing.push((0, audioBackend_1.missingBackendMessage)('record'));
589
784
  return {
590
785
  name: 'audio backend',
786
+ group: 'Voice',
591
787
  passed: true,
592
788
  message: `${process.platform}: voice features will not work — backends missing (known: ${[...new Set([...known.playback, ...known.record])].join(', ') || 'none'})`,
593
789
  suggestion: missing.join(' || '),
@@ -608,6 +804,7 @@ async function checkLicense(opts) {
608
804
  if (!present) {
609
805
  return {
610
806
  name: 'license',
807
+ group: 'Updates',
611
808
  passed: true,
612
809
  message: 'free tier (no license cache)',
613
810
  durationMs: Date.now() - t0,
@@ -621,6 +818,7 @@ async function checkLicense(opts) {
621
818
  if (status.tier !== 'pro') {
622
819
  return {
623
820
  name: 'license',
821
+ group: 'Updates',
624
822
  passed: true,
625
823
  message: 'license cache present but not currently valid (free tier)',
626
824
  suggestion: 'run /license refresh to re-verify against server',
@@ -630,12 +828,14 @@ async function checkLicense(opts) {
630
828
  const expiry = status.cache.expiresAt || 'lifetime';
631
829
  return {
632
830
  name: 'license',
831
+ group: 'Updates',
633
832
  passed: true,
634
833
  message: `Pro (${status.cache.plan}, expires ${expiry})`,
635
834
  durationMs: Date.now() - t0,
636
835
  };
637
836
  })(), opts.timeoutMs, {
638
837
  name: 'license',
838
+ group: 'Updates',
639
839
  passed: false,
640
840
  message: 'license check timed out reading cache',
641
841
  durationMs: Date.now() - t0,
@@ -644,6 +844,7 @@ async function checkLicense(opts) {
644
844
  catch (err) {
645
845
  return {
646
846
  name: 'license',
847
+ group: 'Updates',
647
848
  passed: false,
648
849
  message: `license check failed: ${err instanceof Error ? err.message : String(err)}`,
649
850
  suggestion: 'run /license refresh; if persistent, re-activate with /license activate <key>',
@@ -668,6 +869,7 @@ async function checkUpdate(opts) {
668
869
  const where = status.fromCache ? 'cached' : 'live';
669
870
  return {
670
871
  name: 'npm update',
872
+ group: 'Updates',
671
873
  passed: true,
672
874
  message: `installed v${status.installed} is up to date (${where})`,
673
875
  durationMs: Date.now() - t0,
@@ -675,6 +877,7 @@ async function checkUpdate(opts) {
675
877
  }
676
878
  return {
677
879
  name: 'npm update',
880
+ group: 'Updates',
678
881
  passed: true,
679
882
  message: `v${status.latest} available (installed: v${status.installed})`,
680
883
  suggestion: 'run `npm install -g aiden-runtime@latest`',
@@ -684,6 +887,7 @@ async function checkUpdate(opts) {
684
887
  catch (err) {
685
888
  return {
686
889
  name: 'npm update',
890
+ group: 'Updates',
687
891
  passed: false,
688
892
  message: `update check error: ${err instanceof Error ? err.message : String(err)}`,
689
893
  durationMs: Date.now() - t0,
@@ -691,6 +895,7 @@ async function checkUpdate(opts) {
691
895
  }
692
896
  })(), opts.timeoutMs, {
693
897
  name: 'npm update',
898
+ group: 'Updates',
694
899
  passed: true,
695
900
  message: 'update check timed out (network slow — non-fatal)',
696
901
  durationMs: Date.now() - t0,
@@ -705,6 +910,7 @@ async function checkLogsWritable(paths) {
705
910
  await node_fs_1.promises.unlink(probe);
706
911
  return {
707
912
  name: 'logs writable',
913
+ group: 'Storage',
708
914
  passed: true,
709
915
  message: paths.logsDir,
710
916
  durationMs: Date.now() - t0,
@@ -713,6 +919,7 @@ async function checkLogsWritable(paths) {
713
919
  catch (err) {
714
920
  return {
715
921
  name: 'logs writable',
922
+ group: 'Storage',
716
923
  passed: false,
717
924
  message: `cannot write to ${paths.logsDir}: ${err instanceof Error ? err.message : String(err)}`,
718
925
  suggestion: `check permissions on ${node_os_1.default.homedir()}`,
@@ -802,21 +1009,56 @@ function checkIconKind(r) {
802
1009
  return { icon: '⚠', colour: 'warn' };
803
1010
  return { icon: '✓', colour: 'success' };
804
1011
  }
1012
+ /**
1013
+ * Classify outcomes into the three buckets the top summary surfaces:
1014
+ * passing — passed && no suggestion
1015
+ * warning — passed && suggestion present (soft warning — works but
1016
+ * has a remediation hint, e.g. audio backend on Linux)
1017
+ * failing — !passed
1018
+ *
1019
+ * Order matters: failing dominates warning, warning dominates passing.
1020
+ * Same convention as the row-icon picker above.
1021
+ */
1022
+ function outcomeBucket(r) {
1023
+ if (!r.passed)
1024
+ return 'failing';
1025
+ if (r.suggestion)
1026
+ return 'warning';
1027
+ return 'passing';
1028
+ }
805
1029
  function maxNameWidth(report) {
806
1030
  return report.results.reduce((m, r) => Math.max(m, r.name.length), 0);
807
1031
  }
1032
+ /**
1033
+ * v4.1.3-essentials doctor-polish: group results by their `group`
1034
+ * field, preserving the configured display order (DOCTOR_GROUP_ORDER)
1035
+ * and dropping empty groups. Within each group, results stay in their
1036
+ * original insertion order — keeps related checks visually adjacent
1037
+ * even when `runDoctor` reorders for parallelism in a future revision.
1038
+ */
1039
+ function groupResults(results) {
1040
+ const byGroup = new Map();
1041
+ for (const r of results) {
1042
+ const list = byGroup.get(r.group) ?? [];
1043
+ list.push(r);
1044
+ byGroup.set(r.group, list);
1045
+ }
1046
+ const out = [];
1047
+ for (const g of exports.DOCTOR_GROUP_ORDER) {
1048
+ const rows = byGroup.get(g);
1049
+ if (rows && rows.length > 0)
1050
+ out.push({ group: g, rows });
1051
+ }
1052
+ return out;
1053
+ }
808
1054
  /**
809
1055
  * Compute the inner-cell width for the health box: widest visible
810
- * content row across all check rows + any hint continuations + the
811
- * footer summary, plus a 1-char trailing gutter. Floored / capped per
812
- * HEALTH_BOX_MIN/MAX_WIDTH. Title length also factored so the
813
- * `╭── Health Check ─...─╮` row doesn't underflow.
1056
+ * content row across all check rows, section headers, hint
1057
+ * continuations, and the top summary, plus a 1-char trailing gutter.
1058
+ * Floored / capped per HEALTH_BOX_MIN/MAX_WIDTH. Title length also
1059
+ * factored so the `╭── Health Check ─...─╮` row doesn't underflow.
814
1060
  */
815
1061
  function computeHealthBoxWidth(report, nameWidth) {
816
- // The minimum width the title needs (`╭── <title> ──╮` shape):
817
- // 2 corners + 2 leading dashes + 1 space + title + 1 space + at
818
- // least 2 trailing dashes, minus the 2 corners since the cell is
819
- // measured between them.
820
1062
  const titleMin = 2 + 1 + HEALTH_BOX_TITLE.length + 1 + 2;
821
1063
  let widest = titleMin;
822
1064
  const measureRow = (row) => {
@@ -824,25 +1066,41 @@ function computeHealthBoxWidth(report, nameWidth) {
824
1066
  if (v + 1 > widest)
825
1067
  widest = v + 1; // +1 for trailing gutter
826
1068
  };
827
- for (const r of report.results) {
828
- measureRow(` ✓ ${r.name.padEnd(nameWidth)} ${r.message}`);
829
- if (r.suggestion && !r.passed) {
830
- measureRow(` hint: ${r.suggestion}`);
1069
+ // Top summary line.
1070
+ const buckets = { passing: 0, warning: 0, failing: 0 };
1071
+ for (const r of report.results)
1072
+ buckets[outcomeBucket(r)] += 1;
1073
+ measureRow(` Overall: ${buckets.passing} passing · ${buckets.warning} warning · ${buckets.failing} failing (${report.results.length} checks, ${report.totalMs} ms)`);
1074
+ // Section header rows + group's checks + any hint continuations.
1075
+ const groups = groupResults(report.results);
1076
+ for (const g of groups) {
1077
+ const passed = g.rows.filter((r) => r.passed).length;
1078
+ measureRow(` ${g.group} (${passed}/${g.rows.length})`);
1079
+ for (const r of g.rows) {
1080
+ measureRow(` ✓ ${r.name.padEnd(nameWidth)} ${r.message}`);
1081
+ if (r.suggestion && !r.passed) {
1082
+ measureRow(` hint: ${r.suggestion}`);
1083
+ }
831
1084
  }
832
1085
  }
833
- const passedCount = report.results.filter((x) => x.passed).length;
834
- measureRow(` ${passedCount} of ${report.results.length} checks passed in ${report.totalMs} ms`);
835
1086
  return Math.max(HEALTH_BOX_MIN_WIDTH, Math.min(HEALTH_BOX_MAX_WIDTH, widest));
836
1087
  }
837
1088
  /**
838
- * Render the report as an orange-bordered rounded box. Pure — returns
839
- * the multi-line string; caller writes it. `display` is needed for
840
- * skin-aware colouring of the border, icons, and footer summary.
1089
+ * Render the report as an orange-bordered rounded box with grouped
1090
+ * sections + top summary.
1091
+ *
1092
+ * v4.1.3-essentials doctor-polish: previously rendered as a flat list
1093
+ * of 13 rows with the summary at the bottom — hard to scan, easy to
1094
+ * miss issues. Now:
1095
+ * - Top: `Overall: X passing · Y warning · Z failing` with per-
1096
+ * bucket colors (green / yellow / red).
1097
+ * - Section headers per group (`Providers (1/1)`) in brand+bold.
1098
+ * - Rows packed tight within group, blank line between groups.
1099
+ * - Hints stay on a continuation line under failed rows only.
841
1100
  *
842
- * Phase 22 Group C smoke-fix #3 (Bug 1 round 2): box width now
843
- * auto-fits to the widest content row instead of clamping at a fixed
844
- * 70 chars. The previous fix correctly aligned the right border but
845
- * truncated content mid-word for any Windows path > 65-ish chars.
1101
+ * Pure returns the multi-line string; caller writes it. `display`
1102
+ * is needed for skin-aware colouring of the border, icons, headers,
1103
+ * and summary buckets.
846
1104
  */
847
1105
  function renderHealthBox(report, display) {
848
1106
  const nameWidth = maxNameWidth(report);
@@ -858,23 +1116,50 @@ function renderHealthBox(report, display) {
858
1116
  return `${display.brand(left)}${inner}${display.brand(right)}`;
859
1117
  };
860
1118
  const lines = [top, side('')];
861
- for (const r of report.results) {
862
- const { icon, colour } = checkIconKind(r);
863
- const colouredIcon = display.paint(icon, colour);
864
- const namePart = ` ${colouredIcon} ${r.name.padEnd(nameWidth)} ${r.message}`;
865
- lines.push(side(namePart));
866
- if (r.suggestion && !r.passed) {
867
- // Failed checks get the suggestion on a continuation line, indented
868
- // past the icon column. Prefix in soft cyan to read as a hint.
869
- const hint = ` ${display.muted('hint:')} ${r.suggestion}`;
870
- lines.push(side(hint));
1119
+ // ── Top summary three colored buckets ───────────────────────────
1120
+ const buckets = { passing: 0, warning: 0, failing: 0 };
1121
+ for (const r of report.results)
1122
+ buckets[outcomeBucket(r)] += 1;
1123
+ // Each bucket colored only when non-zero; zero counters stay muted
1124
+ // so the eye lands on the actual state.
1125
+ const paintBucket = (n, kind, label) => {
1126
+ const text = `${n} ${label}`;
1127
+ return n > 0 ? display.paint(text, kind) : display.muted(text);
1128
+ };
1129
+ const summary = ` Overall: ${paintBucket(buckets.passing, 'success', 'passing')} · ` +
1130
+ `${paintBucket(buckets.warning, 'warn', 'warning')} · ` +
1131
+ `${paintBucket(buckets.failing, 'error', 'failing')} ` +
1132
+ `${display.muted(`(${report.results.length} checks, ${report.totalMs} ms)`)}`;
1133
+ lines.push(side(summary));
1134
+ lines.push(side(''));
1135
+ // ── Grouped section rendering ─────────────────────────────────────
1136
+ const groups = groupResults(report.results);
1137
+ for (let gi = 0; gi < groups.length; gi += 1) {
1138
+ const g = groups[gi];
1139
+ const passed = g.rows.filter((r) => r.passed).length;
1140
+ // Group-level count tinted by aggregate state: all pass → success,
1141
+ // any fail → error, only warnings → warn.
1142
+ const anyFail = g.rows.some((r) => !r.passed);
1143
+ const anyWarn = g.rows.some((r) => r.passed && r.suggestion);
1144
+ const countColour = anyFail ? 'error' : anyWarn ? 'warn' : 'success';
1145
+ const header = ` ${display.brand(g.group)} ` +
1146
+ display.paint(`(${passed}/${g.rows.length})`, countColour);
1147
+ lines.push(side(header));
1148
+ for (const r of g.rows) {
1149
+ const { icon, colour } = checkIconKind(r);
1150
+ const colouredIcon = display.paint(icon, colour);
1151
+ const namePart = ` ${colouredIcon} ${r.name.padEnd(nameWidth)} ${r.message}`;
1152
+ lines.push(side(namePart));
1153
+ if (r.suggestion && !r.passed) {
1154
+ const hint = ` ${display.muted('hint:')} ${r.suggestion}`;
1155
+ lines.push(side(hint));
1156
+ }
871
1157
  }
1158
+ // Blank line between groups (not after the last one).
1159
+ if (gi < groups.length - 1)
1160
+ lines.push(side(''));
872
1161
  }
873
1162
  lines.push(side(''));
874
- const passedCount = report.results.filter((r) => r.passed).length;
875
- const summary = `${passedCount} of ${report.results.length} checks passed in ${report.totalMs} ms`;
876
- const summaryColour = report.passed ? 'success' : 'warn';
877
- lines.push(side(' ' + display.paint(summary, summaryColour)));
878
1163
  lines.push(bot);
879
1164
  return lines.join('\n');
880
1165
  }
@@ -885,51 +1170,68 @@ function renderHealthBox(report, display) {
885
1170
  */
886
1171
  async function runDoctorCli(opts) {
887
1172
  const report = await runDoctor(opts);
888
- for (const r of report.results) {
889
- const marker = r.passed ? '[ok] ' : '[fail]';
890
- process.stdout.write(`${marker} ${r.name}: ${r.message}\n`);
891
- if (!r.passed && r.suggestion) {
892
- process.stdout.write(` hint: ${r.suggestion}\n`);
893
- }
894
- }
895
- process.stdout.write(`\n${report.passed ? 'all checks passed' : 'some checks failed'} in ${report.totalMs} ms\n`);
896
- // Phase v4.1.1-oauth-fix Phase 5: discoverability hint for the deep
897
- // mode. Only emitted in standard mode — if the user already passed
898
- // `--providers`, the section right below this is the answer to the hint.
899
- if (!opts?.liveness) {
900
- process.stdout.write(' hint: Run `aiden doctor --providers` for live provider checks\n');
901
- }
902
- // Phase v4.1.1-oauth-fix Phase 4: opt-in provider liveness.
903
- // Runs after the standard report so a failed config check is visible
904
- // before the network probes start. Section header + summary line are
905
- // rendered by doctorLiveness.renderProviderLivenessSection so the
906
- // formatting stays alongside its consumer.
1173
+ // v4.1.3-essentials doctor-polish: Path-A unification — the CLI
1174
+ // path now uses the SAME `renderHealthBox` renderer as `/doctor`
1175
+ // (in-REPL). Previously this emitted a plain `[ok]` / `[fail]`
1176
+ // list with no box, no grouping, and the optional sections
1177
+ // (subsystem health, skill outcomes, liveness) dangled below the
1178
+ // summary. Now everything renders inside one cohesive box.
1179
+ //
1180
+ // NO_COLOR / forceMono handling: the Display instance honors both,
1181
+ // so `aiden doctor | tee report.txt` produces clean plain text
1182
+ // when piped (no ANSI in the captured file).
1183
+ //
1184
+ // Lazy-import Display + SkinEngine here because runDoctorCli is
1185
+ // also called from non-REPL contexts (tests) where instantiating
1186
+ // a Display might not be appropriate. CLI invocations get a fresh
1187
+ // skin engine; tests can mock or inspect the report directly.
1188
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1189
+ const { Display: DisplayCtor } = require('./display');
1190
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1191
+ const { SkinEngine: SkinEngineCtor } = require('./skinEngine');
1192
+ const display = new DisplayCtor({ skin: new SkinEngineCtor() });
1193
+ // Pull in-process surfaces if the caller supplied them. CLI
1194
+ // invocations from a fresh process don't have a live agent so the
1195
+ // helpers return empty arrays → groups dropped by renderer.
1196
+ report.results.push(...subsystemHealthResults(opts?.subsystemHealthRegistry));
1197
+ report.results.push(...skillOutcomeResults(opts?.skillOutcomeTracker));
1198
+ // Provider-liveness path: runs an opt-in network probe per
1199
+ // configured provider when `--providers` is set. Results coerce
1200
+ // to CheckResult shape and fold into the same grouped box.
907
1201
  let livenessFailed = false;
908
1202
  if (opts?.liveness) {
909
- process.stdout.write('\n Running provider liveness checks...\n');
910
- const { runProviderLiveness, renderProviderLivenessSection } = await Promise.resolve().then(() => __importStar(require('./doctorLiveness')));
1203
+ const { runProviderLiveness } = await Promise.resolve().then(() => __importStar(require('./doctorLiveness')));
911
1204
  const paths = opts.paths ?? (0, paths_1.resolveAidenPaths)();
912
- const { results, summary } = await runProviderLiveness({
1205
+ const { results: liveResults, summary } = await runProviderLiveness({
913
1206
  paths,
914
1207
  env: opts.env,
915
1208
  fetchImpl: opts.fetchImpl,
916
1209
  timeoutMs: opts.livenessTimeoutMs,
917
1210
  });
918
- process.stdout.write(renderProviderLivenessSection(results, summary));
1211
+ // Coerce per-provider liveness results into CheckResult rows.
1212
+ // `liveResults` shape varies by provider but always has a
1213
+ // `passed`/`ok` flag and a message. Treat unknown shapes as
1214
+ // pass-through so future probe-result additions don't break.
1215
+ for (const lr of liveResults) {
1216
+ const passed = lr.passed === true || lr.ok === true;
1217
+ const latency = typeof lr.latencyMs === 'number' ? ` (${lr.latencyMs}ms)` : '';
1218
+ const msg = (lr.message ?? lr.status ?? (passed ? 'live' : (lr.error ?? 'failed'))) + latency;
1219
+ report.results.push({
1220
+ name: String(lr.name ?? lr.provider ?? 'provider'),
1221
+ group: 'Provider liveness',
1222
+ passed,
1223
+ message: msg,
1224
+ ...(passed ? {} : { suggestion: lr.error ?? 'check API key / network' }),
1225
+ });
1226
+ }
919
1227
  livenessFailed = summary.red > 0;
920
1228
  }
921
- // Phase v4.1.2-slice3: subsystem-health surface. Renders only when
922
- // a registry was passed (in-REPL doctor); standalone CLI doctor has
923
- // no live agent so the section is omitted.
924
- const subsystemBlock = renderSubsystemHealthSection(opts?.subsystemHealthRegistry);
925
- if (subsystemBlock)
926
- process.stdout.write(subsystemBlock);
927
- // Phase v4.1.2-slice4: skill-outcome surface. Same gating — only
928
- // renders when a tracker was passed and has at least one observed
929
- // skill. Standalone CLI invocations skip it.
930
- const outcomesBlock = renderSkillOutcomesSection(opts?.skillOutcomeTracker);
931
- if (outcomesBlock)
932
- process.stdout.write(outcomesBlock);
1229
+ process.stdout.write(renderHealthBox(report, display) + '\n');
1230
+ // Phase v4.1.1-oauth-fix Phase 5: discoverability hint for the deep
1231
+ // mode. Outside the box so it reads as meta-guidance, not a check.
1232
+ if (!opts?.liveness) {
1233
+ process.stdout.write('\n hint: Run `aiden doctor --providers` for live provider checks\n');
1234
+ }
933
1235
  // Liveness reds count toward the overall exit code so CI / scripts
934
1236
  // can `aiden doctor --providers && deploy`.
935
1237
  process.exitCode = (report.passed && !livenessFailed) ? 0 : 1;