aiden-runtime 4.1.1 → 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.
Files changed (68) hide show
  1. package/README.md +78 -26
  2. package/dist/cli/v4/aidenCLI.js +169 -9
  3. package/dist/cli/v4/callbacks.js +20 -2
  4. package/dist/cli/v4/chatSession.js +644 -16
  5. package/dist/cli/v4/commands/auth.js +6 -3
  6. package/dist/cli/v4/commands/doctor.js +23 -27
  7. package/dist/cli/v4/commands/help.js +4 -0
  8. package/dist/cli/v4/commands/index.js +10 -1
  9. package/dist/cli/v4/commands/model.js +30 -1
  10. package/dist/cli/v4/commands/reloadSoul.js +37 -0
  11. package/dist/cli/v4/commands/update.js +102 -0
  12. package/dist/cli/v4/defaultSoul.js +68 -2
  13. package/dist/cli/v4/display/capabilityCard.js +135 -0
  14. package/dist/cli/v4/display/sessionEndCard.js +127 -0
  15. package/dist/cli/v4/display/toolTrail.js +172 -0
  16. package/dist/cli/v4/display.js +492 -142
  17. package/dist/cli/v4/doctor.js +472 -58
  18. package/dist/cli/v4/doctorLiveness.js +65 -10
  19. package/dist/cli/v4/promotionPrompt.js +332 -0
  20. package/dist/cli/v4/providerBootSelector.js +144 -0
  21. package/dist/cli/v4/replyRenderer.js +311 -20
  22. package/dist/cli/v4/sessionSummaryGate.js +66 -0
  23. package/dist/cli/v4/skinEngine.js +14 -3
  24. package/dist/cli/v4/toolPreview.js +153 -0
  25. package/dist/core/tools/nowPlaying.js +7 -15
  26. package/dist/core/v4/aidenAgent.js +91 -29
  27. package/dist/core/v4/capabilities.js +89 -0
  28. package/dist/core/v4/contextCompressor.js +25 -8
  29. package/dist/core/v4/distillationIndex.js +167 -0
  30. package/dist/core/v4/distillationStore.js +98 -0
  31. package/dist/core/v4/logger/logger.js +40 -9
  32. package/dist/core/v4/promotionCandidates.js +234 -0
  33. package/dist/core/v4/promptBuilder.js +145 -1
  34. package/dist/core/v4/sessionDistiller.js +452 -0
  35. package/dist/core/v4/skillMining/skillMiner.js +43 -6
  36. package/dist/core/v4/skillOutcomeTracker.js +323 -0
  37. package/dist/core/v4/subsystemHealth.js +143 -0
  38. package/dist/core/v4/toolRegistry.js +16 -1
  39. package/dist/core/v4/update/executeInstall.js +233 -0
  40. package/dist/core/version.js +1 -1
  41. package/dist/moat/memoryGuard.js +111 -0
  42. package/dist/moat/plannerGuard.js +19 -0
  43. package/dist/moat/skillTeacher.js +14 -5
  44. package/dist/providers/v4/chatCompletionsAdapter.js +9 -0
  45. package/dist/providers/v4/errors.js +112 -4
  46. package/dist/providers/v4/modelDefaults.js +65 -0
  47. package/dist/providers/v4/registry.js +9 -2
  48. package/dist/providers/v4/runtimeResolver.js +6 -0
  49. package/dist/tools/v4/index.js +80 -1
  50. package/dist/tools/v4/memory/memoryRemove.js +57 -2
  51. package/dist/tools/v4/memory/sessionSummary.js +151 -0
  52. package/dist/tools/v4/sessions/recallSession.js +177 -0
  53. package/dist/tools/v4/sessions/sessionSearch.js +5 -1
  54. package/dist/tools/v4/system/_psHelpers.js +123 -0
  55. package/dist/tools/v4/system/aidenSelfUpdate.js +162 -0
  56. package/dist/tools/v4/system/appClose.js +79 -0
  57. package/dist/tools/v4/system/appInput.js +154 -0
  58. package/dist/tools/v4/system/appLaunch.js +218 -0
  59. package/dist/tools/v4/system/clipboardRead.js +54 -0
  60. package/dist/tools/v4/system/clipboardWrite.js +84 -0
  61. package/dist/tools/v4/system/mediaKey.js +109 -0
  62. package/dist/tools/v4/system/mediaSessions.js +163 -0
  63. package/dist/tools/v4/system/mediaTransport.js +211 -0
  64. package/dist/tools/v4/system/osProcessList.js +99 -0
  65. package/dist/tools/v4/system/screenshot.js +106 -0
  66. package/dist/tools/v4/system/volumeSet.js +157 -0
  67. package/package.json +4 -1
  68. package/skills/system_control.md +185 -69
@@ -55,6 +55,12 @@ 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;
60
+ exports.renderSubsystemHealthSection = renderSubsystemHealthSection;
61
+ exports.skillOutcomeResults = skillOutcomeResults;
62
+ exports.sessionCounterResults = sessionCounterResults;
63
+ exports.renderSkillOutcomesSection = renderSkillOutcomesSection;
58
64
  exports.resolveBinaryPath = resolveBinaryPath;
59
65
  exports._resetBinaryResolutionCacheForTests = _resetBinaryResolutionCacheForTests;
60
66
  exports.buildProbeInvocation = buildProbeInvocation;
@@ -83,6 +89,273 @@ const license_1 = require("../../core/v4/license");
83
89
  const checkUpdate_1 = require("../../core/v4/update/checkUpdate");
84
90
  const box_1 = require("./box");
85
91
  const audioBackend_1 = require("../../core/voice/audioBackend");
92
+ /**
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):
171
+ * - registry undefined → render nothing (no live state to report)
172
+ * - all subsystems healthy → one-line green summary
173
+ * - any degradation → expand block with last-error per failed sub
174
+ *
175
+ * The Honesty layer is intentionally listed as "(not instrumented yet)"
176
+ * when the expanded block fires, because the audit determined the
177
+ * pure-pattern path has no I/O failure surface today.
178
+ */
179
+ function renderSubsystemHealthSection(registry) {
180
+ if (!registry)
181
+ return '';
182
+ const snaps = registry.snapshot();
183
+ if (snaps.length === 0)
184
+ return '';
185
+ const degraded = snaps.filter((s) => s.totalErrors > 0);
186
+ if (degraded.length === 0) {
187
+ return `\nSubsystem health: all green (${snaps.length} subsystems instrumented)\n`;
188
+ }
189
+ // Expanded form. Per-subsystem rows:
190
+ // ✓ name N calls, 0 errors
191
+ // ✗ name N calls, E errors (last <duration> ago: "message")
192
+ // - honesty (not instrumented yet)
193
+ const lines = ['\nSubsystem health'];
194
+ for (const s of snaps) {
195
+ const mark = s.totalErrors > 0 ? 'x' : 'ok';
196
+ const stats = `${s.totalCalls} call${s.totalCalls === 1 ? '' : 's'}, ${s.totalErrors} error${s.totalErrors === 1 ? '' : 's'}`;
197
+ if (s.lastError) {
198
+ const ago = humanAge(Date.now() - s.lastError.at.getTime());
199
+ const streak = s.lastError.consecutive > 1
200
+ ? ` (${s.lastError.consecutive} consecutive)`
201
+ : '';
202
+ lines.push(` [${mark}] ${s.subsystem.padEnd(16)} ${stats}${streak} (last ${ago} ago: "${s.lastError.message}")`);
203
+ }
204
+ else {
205
+ lines.push(` [${mark}] ${s.subsystem.padEnd(16)} ${stats}`);
206
+ }
207
+ }
208
+ // Slice3 audit decision: HonestyEnforcement was deliberately not
209
+ // instrumented (pure-pattern path has no failure surface). Surface
210
+ // that explicitly so users know the gap is known, not forgotten.
211
+ lines.push(` [-] honesty (not instrumented yet)`);
212
+ lines.push('');
213
+ return lines.join('\n');
214
+ }
215
+ function humanAge(ms) {
216
+ if (ms < 1000)
217
+ return `${ms}ms`;
218
+ if (ms < 60000)
219
+ return `${(ms / 1000).toFixed(0)}s`;
220
+ if (ms < 3600000)
221
+ return `${Math.floor(ms / 60000)}m`;
222
+ if (ms < 86400000)
223
+ return `${Math.floor(ms / 3600000)}h`;
224
+ return `${Math.floor(ms / 86400000)}d`;
225
+ }
226
+ /**
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.
323
+ *
324
+ * Output (when not empty): top N skills sorted by load count, with
325
+ * total observations and success percentage. Last-error message
326
+ * shown for the one most-recently failing skill (cap one row of
327
+ * detail so the block stays compact).
328
+ */
329
+ function renderSkillOutcomesSection(tracker, topN = 5) {
330
+ if (!tracker)
331
+ return '';
332
+ const snaps = tracker.snapshot();
333
+ if (snaps.length === 0)
334
+ return '';
335
+ const lines = ['\nSkill outcomes (top ' + Math.min(topN, snaps.length) + ' by load count)'];
336
+ for (const s of snaps.slice(0, topN)) {
337
+ const attributed = s.toolSuccesses + s.toolFailures;
338
+ const rate = attributed === 0
339
+ ? '—'
340
+ : `${Math.round((s.toolSuccesses / attributed) * 100)}% success`;
341
+ const stats = `loaded ${s.loaded}, ${s.toolSuccesses} ok, ${s.toolFailures} err (${rate})`;
342
+ const last = s.lastUsed
343
+ ? ` last ${humanAge(Date.now() - new Date(s.lastUsed).getTime())} ago`
344
+ : '';
345
+ lines.push(` ${s.skillName.padEnd(32)} ${stats}${last}`);
346
+ }
347
+ // Spotlight the most-recent failure across all tracked skills so a
348
+ // single broken skill is visible without scanning every row.
349
+ const recentFailures = snaps
350
+ .filter((s) => s.lastError)
351
+ .sort((a, b) => new Date(b.lastError.at).getTime() - new Date(a.lastError.at).getTime());
352
+ if (recentFailures.length > 0) {
353
+ const f = recentFailures[0];
354
+ lines.push(` ↳ last failure: ${f.skillName} — "${f.lastError.message}"`);
355
+ }
356
+ lines.push('');
357
+ return lines.join('\n');
358
+ }
86
359
  const DEFAULT_TIMEOUT_MS = 3000;
87
360
  /** Wrap a promise with a timeout. The timed-out path resolves to the fallback result. */
88
361
  async function withTimeout(p, ms, fallback) {
@@ -228,6 +501,7 @@ async function checkConfigFile(paths) {
228
501
  await node_fs_1.promises.access(paths.configYaml);
229
502
  return {
230
503
  name: 'config file',
504
+ group: 'Storage',
231
505
  passed: true,
232
506
  message: `found at ${paths.configYaml}`,
233
507
  durationMs: Date.now() - t0,
@@ -236,6 +510,7 @@ async function checkConfigFile(paths) {
236
510
  catch {
237
511
  return {
238
512
  name: 'config file',
513
+ group: 'Storage',
239
514
  passed: false,
240
515
  message: `missing at ${paths.configYaml}`,
241
516
  suggestion: 'run `aiden setup` to create one',
@@ -259,6 +534,7 @@ function checkProviderAuth(env) {
259
534
  if (present.length === 0) {
260
535
  return {
261
536
  name: 'provider auth',
537
+ group: 'Providers',
262
538
  passed: false,
263
539
  message: 'no provider API key found in environment',
264
540
  suggestion: 'run `aiden setup` and pick a provider, or set ANTHROPIC_API_KEY',
@@ -267,6 +543,7 @@ function checkProviderAuth(env) {
267
543
  }
268
544
  return {
269
545
  name: 'provider auth',
546
+ group: 'Providers',
270
547
  passed: true,
271
548
  message: `${present.length} provider key(s) present (${present.join(', ')})`,
272
549
  durationMs: Date.now() - t0,
@@ -276,6 +553,7 @@ async function checkOllamaReachable(opts) {
276
553
  const t0 = Date.now();
277
554
  const fallback = {
278
555
  name: 'ollama reachable',
556
+ group: 'Inference',
279
557
  passed: false,
280
558
  message: 'no response from http://localhost:11434',
281
559
  suggestion: 'install Ollama from https://ollama.com or skip if you only use cloud providers',
@@ -293,6 +571,7 @@ async function checkOllamaReachable(opts) {
293
571
  }
294
572
  return {
295
573
  name: 'ollama reachable',
574
+ group: 'Inference',
296
575
  passed: true,
297
576
  message: 'Ollama responding on :11434',
298
577
  durationMs: Date.now() - t0,
@@ -311,6 +590,7 @@ async function checkPythonAvailable(opts) {
311
590
  const t0 = Date.now();
312
591
  const fallback = {
313
592
  name: 'python available',
593
+ group: 'System tools',
314
594
  passed: false,
315
595
  message: 'python not found on PATH',
316
596
  suggestion: 'install python 3.10+ — required for graphify and a few skills',
@@ -323,6 +603,7 @@ async function checkPythonAvailable(opts) {
323
603
  if (res.ok) {
324
604
  return {
325
605
  name: 'python available',
606
+ group: 'System tools',
326
607
  passed: true,
327
608
  message: res.stdout || `${bin} present`,
328
609
  durationMs: Date.now() - t0,
@@ -339,6 +620,7 @@ async function checkDockerAvailable(opts) {
339
620
  if (res.ok) {
340
621
  return {
341
622
  name: 'docker available',
623
+ group: 'System tools',
342
624
  passed: true,
343
625
  message: res.stdout || 'docker present',
344
626
  durationMs: Date.now() - t0,
@@ -346,6 +628,7 @@ async function checkDockerAvailable(opts) {
346
628
  }
347
629
  return {
348
630
  name: 'docker available',
631
+ group: 'System tools',
349
632
  passed: false,
350
633
  message: 'docker not found on PATH',
351
634
  suggestion: 'optional — install Docker Desktop if you want sandboxed tool execution',
@@ -353,6 +636,7 @@ async function checkDockerAvailable(opts) {
353
636
  };
354
637
  })(), opts.timeoutMs, {
355
638
  name: 'docker available',
639
+ group: 'System tools',
356
640
  passed: false,
357
641
  message: 'docker probe timed out',
358
642
  durationMs: Date.now() - t0,
@@ -365,6 +649,7 @@ async function checkNpxAvailable(opts) {
365
649
  if (res.ok) {
366
650
  return {
367
651
  name: 'npx available',
652
+ group: 'System tools',
368
653
  passed: true,
369
654
  message: `npx ${res.stdout}`,
370
655
  durationMs: Date.now() - t0,
@@ -372,6 +657,7 @@ async function checkNpxAvailable(opts) {
372
657
  }
373
658
  return {
374
659
  name: 'npx available',
660
+ group: 'System tools',
375
661
  passed: false,
376
662
  message: 'npx not found on PATH',
377
663
  suggestion: 'install Node.js 20+ — required for npm-published MCP servers',
@@ -379,6 +665,7 @@ async function checkNpxAvailable(opts) {
379
665
  };
380
666
  })(), opts.timeoutMs, {
381
667
  name: 'npx available',
668
+ group: 'System tools',
382
669
  passed: false,
383
670
  message: 'npx probe timed out',
384
671
  durationMs: Date.now() - t0,
@@ -391,6 +678,7 @@ async function checkSkillsDir(paths) {
391
678
  if (!stat.isDirectory()) {
392
679
  return {
393
680
  name: 'skills dir',
681
+ group: 'Storage',
394
682
  passed: false,
395
683
  message: `${paths.skillsDir} is not a directory`,
396
684
  durationMs: Date.now() - t0,
@@ -399,6 +687,7 @@ async function checkSkillsDir(paths) {
399
687
  const entries = await node_fs_1.promises.readdir(paths.skillsDir);
400
688
  return {
401
689
  name: 'skills dir',
690
+ group: 'Storage',
402
691
  passed: true,
403
692
  message: `${paths.skillsDir} (${entries.length} entries)`,
404
693
  durationMs: Date.now() - t0,
@@ -407,6 +696,7 @@ async function checkSkillsDir(paths) {
407
696
  catch {
408
697
  return {
409
698
  name: 'skills dir',
699
+ group: 'Storage',
410
700
  passed: false,
411
701
  message: `missing ${paths.skillsDir}`,
412
702
  suggestion: 'run `aiden setup` — it creates the skills directory',
@@ -420,6 +710,7 @@ async function checkBundledManifest(paths) {
420
710
  await node_fs_1.promises.access(paths.bundledManifest);
421
711
  return {
422
712
  name: 'bundled manifest',
713
+ group: 'Storage',
423
714
  passed: true,
424
715
  message: `present at ${paths.bundledManifest}`,
425
716
  durationMs: Date.now() - t0,
@@ -428,6 +719,7 @@ async function checkBundledManifest(paths) {
428
719
  catch {
429
720
  return {
430
721
  name: 'bundled manifest',
722
+ group: 'Storage',
431
723
  passed: false,
432
724
  message: 'bundled skill manifest missing',
433
725
  suggestion: 'reinstall `aiden` — the package was not unpacked correctly',
@@ -441,6 +733,7 @@ async function checkPlatformPaths(paths) {
441
733
  await node_fs_1.promises.access(paths.root);
442
734
  return {
443
735
  name: 'platform paths',
736
+ group: 'Storage',
444
737
  passed: true,
445
738
  message: `aiden home: ${paths.root}`,
446
739
  durationMs: Date.now() - t0,
@@ -449,6 +742,7 @@ async function checkPlatformPaths(paths) {
449
742
  catch {
450
743
  return {
451
744
  name: 'platform paths',
745
+ group: 'Storage',
452
746
  passed: false,
453
747
  message: `aiden home missing: ${paths.root}`,
454
748
  suggestion: 'run `aiden setup` to initialise the home directory',
@@ -475,6 +769,7 @@ async function checkAudioBackend() {
475
769
  if (playback && record) {
476
770
  return {
477
771
  name: 'audio backend',
772
+ group: 'Voice',
478
773
  passed: true,
479
774
  message: `${process.platform}: playback=${playback.label} · record=${record.label}`,
480
775
  durationMs: Date.now() - t0,
@@ -488,6 +783,7 @@ async function checkAudioBackend() {
488
783
  missing.push((0, audioBackend_1.missingBackendMessage)('record'));
489
784
  return {
490
785
  name: 'audio backend',
786
+ group: 'Voice',
491
787
  passed: true,
492
788
  message: `${process.platform}: voice features will not work — backends missing (known: ${[...new Set([...known.playback, ...known.record])].join(', ') || 'none'})`,
493
789
  suggestion: missing.join(' || '),
@@ -508,6 +804,7 @@ async function checkLicense(opts) {
508
804
  if (!present) {
509
805
  return {
510
806
  name: 'license',
807
+ group: 'Updates',
511
808
  passed: true,
512
809
  message: 'free tier (no license cache)',
513
810
  durationMs: Date.now() - t0,
@@ -521,6 +818,7 @@ async function checkLicense(opts) {
521
818
  if (status.tier !== 'pro') {
522
819
  return {
523
820
  name: 'license',
821
+ group: 'Updates',
524
822
  passed: true,
525
823
  message: 'license cache present but not currently valid (free tier)',
526
824
  suggestion: 'run /license refresh to re-verify against server',
@@ -530,12 +828,14 @@ async function checkLicense(opts) {
530
828
  const expiry = status.cache.expiresAt || 'lifetime';
531
829
  return {
532
830
  name: 'license',
831
+ group: 'Updates',
533
832
  passed: true,
534
833
  message: `Pro (${status.cache.plan}, expires ${expiry})`,
535
834
  durationMs: Date.now() - t0,
536
835
  };
537
836
  })(), opts.timeoutMs, {
538
837
  name: 'license',
838
+ group: 'Updates',
539
839
  passed: false,
540
840
  message: 'license check timed out reading cache',
541
841
  durationMs: Date.now() - t0,
@@ -544,6 +844,7 @@ async function checkLicense(opts) {
544
844
  catch (err) {
545
845
  return {
546
846
  name: 'license',
847
+ group: 'Updates',
547
848
  passed: false,
548
849
  message: `license check failed: ${err instanceof Error ? err.message : String(err)}`,
549
850
  suggestion: 'run /license refresh; if persistent, re-activate with /license activate <key>',
@@ -568,6 +869,7 @@ async function checkUpdate(opts) {
568
869
  const where = status.fromCache ? 'cached' : 'live';
569
870
  return {
570
871
  name: 'npm update',
872
+ group: 'Updates',
571
873
  passed: true,
572
874
  message: `installed v${status.installed} is up to date (${where})`,
573
875
  durationMs: Date.now() - t0,
@@ -575,6 +877,7 @@ async function checkUpdate(opts) {
575
877
  }
576
878
  return {
577
879
  name: 'npm update',
880
+ group: 'Updates',
578
881
  passed: true,
579
882
  message: `v${status.latest} available (installed: v${status.installed})`,
580
883
  suggestion: 'run `npm install -g aiden-runtime@latest`',
@@ -584,6 +887,7 @@ async function checkUpdate(opts) {
584
887
  catch (err) {
585
888
  return {
586
889
  name: 'npm update',
890
+ group: 'Updates',
587
891
  passed: false,
588
892
  message: `update check error: ${err instanceof Error ? err.message : String(err)}`,
589
893
  durationMs: Date.now() - t0,
@@ -591,6 +895,7 @@ async function checkUpdate(opts) {
591
895
  }
592
896
  })(), opts.timeoutMs, {
593
897
  name: 'npm update',
898
+ group: 'Updates',
594
899
  passed: true,
595
900
  message: 'update check timed out (network slow — non-fatal)',
596
901
  durationMs: Date.now() - t0,
@@ -605,6 +910,7 @@ async function checkLogsWritable(paths) {
605
910
  await node_fs_1.promises.unlink(probe);
606
911
  return {
607
912
  name: 'logs writable',
913
+ group: 'Storage',
608
914
  passed: true,
609
915
  message: paths.logsDir,
610
916
  durationMs: Date.now() - t0,
@@ -613,6 +919,7 @@ async function checkLogsWritable(paths) {
613
919
  catch (err) {
614
920
  return {
615
921
  name: 'logs writable',
922
+ group: 'Storage',
616
923
  passed: false,
617
924
  message: `cannot write to ${paths.logsDir}: ${err instanceof Error ? err.message : String(err)}`,
618
925
  suggestion: `check permissions on ${node_os_1.default.homedir()}`,
@@ -702,21 +1009,56 @@ function checkIconKind(r) {
702
1009
  return { icon: '⚠', colour: 'warn' };
703
1010
  return { icon: '✓', colour: 'success' };
704
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
+ }
705
1029
  function maxNameWidth(report) {
706
1030
  return report.results.reduce((m, r) => Math.max(m, r.name.length), 0);
707
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
+ }
708
1054
  /**
709
1055
  * Compute the inner-cell width for the health box: widest visible
710
- * content row across all check rows + any hint continuations + the
711
- * footer summary, plus a 1-char trailing gutter. Floored / capped per
712
- * HEALTH_BOX_MIN/MAX_WIDTH. Title length also factored so the
713
- * `╭── 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.
714
1060
  */
715
1061
  function computeHealthBoxWidth(report, nameWidth) {
716
- // The minimum width the title needs (`╭── <title> ──╮` shape):
717
- // 2 corners + 2 leading dashes + 1 space + title + 1 space + at
718
- // least 2 trailing dashes, minus the 2 corners since the cell is
719
- // measured between them.
720
1062
  const titleMin = 2 + 1 + HEALTH_BOX_TITLE.length + 1 + 2;
721
1063
  let widest = titleMin;
722
1064
  const measureRow = (row) => {
@@ -724,25 +1066,41 @@ function computeHealthBoxWidth(report, nameWidth) {
724
1066
  if (v + 1 > widest)
725
1067
  widest = v + 1; // +1 for trailing gutter
726
1068
  };
727
- for (const r of report.results) {
728
- measureRow(` ✓ ${r.name.padEnd(nameWidth)} ${r.message}`);
729
- if (r.suggestion && !r.passed) {
730
- 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
+ }
731
1084
  }
732
1085
  }
733
- const passedCount = report.results.filter((x) => x.passed).length;
734
- measureRow(` ${passedCount} of ${report.results.length} checks passed in ${report.totalMs} ms`);
735
1086
  return Math.max(HEALTH_BOX_MIN_WIDTH, Math.min(HEALTH_BOX_MAX_WIDTH, widest));
736
1087
  }
737
1088
  /**
738
- * Render the report as an orange-bordered rounded box. Pure — returns
739
- * the multi-line string; caller writes it. `display` is needed for
740
- * 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.
741
1100
  *
742
- * Phase 22 Group C smoke-fix #3 (Bug 1 round 2): box width now
743
- * auto-fits to the widest content row instead of clamping at a fixed
744
- * 70 chars. The previous fix correctly aligned the right border but
745
- * 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.
746
1104
  */
747
1105
  function renderHealthBox(report, display) {
748
1106
  const nameWidth = maxNameWidth(report);
@@ -758,23 +1116,50 @@ function renderHealthBox(report, display) {
758
1116
  return `${display.brand(left)}${inner}${display.brand(right)}`;
759
1117
  };
760
1118
  const lines = [top, side('')];
761
- for (const r of report.results) {
762
- const { icon, colour } = checkIconKind(r);
763
- const colouredIcon = display.paint(icon, colour);
764
- const namePart = ` ${colouredIcon} ${r.name.padEnd(nameWidth)} ${r.message}`;
765
- lines.push(side(namePart));
766
- if (r.suggestion && !r.passed) {
767
- // Failed checks get the suggestion on a continuation line, indented
768
- // past the icon column. Prefix in soft cyan to read as a hint.
769
- const hint = ` ${display.muted('hint:')} ${r.suggestion}`;
770
- 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
+ }
771
1157
  }
1158
+ // Blank line between groups (not after the last one).
1159
+ if (gi < groups.length - 1)
1160
+ lines.push(side(''));
772
1161
  }
773
1162
  lines.push(side(''));
774
- const passedCount = report.results.filter((r) => r.passed).length;
775
- const summary = `${passedCount} of ${report.results.length} checks passed in ${report.totalMs} ms`;
776
- const summaryColour = report.passed ? 'success' : 'warn';
777
- lines.push(side(' ' + display.paint(summary, summaryColour)));
778
1163
  lines.push(bot);
779
1164
  return lines.join('\n');
780
1165
  }
@@ -785,39 +1170,68 @@ function renderHealthBox(report, display) {
785
1170
  */
786
1171
  async function runDoctorCli(opts) {
787
1172
  const report = await runDoctor(opts);
788
- for (const r of report.results) {
789
- const marker = r.passed ? '[ok] ' : '[fail]';
790
- process.stdout.write(`${marker} ${r.name}: ${r.message}\n`);
791
- if (!r.passed && r.suggestion) {
792
- process.stdout.write(` hint: ${r.suggestion}\n`);
793
- }
794
- }
795
- process.stdout.write(`\n${report.passed ? 'all checks passed' : 'some checks failed'} in ${report.totalMs} ms\n`);
796
- // Phase v4.1.1-oauth-fix Phase 5: discoverability hint for the deep
797
- // mode. Only emitted in standard mode — if the user already passed
798
- // `--providers`, the section right below this is the answer to the hint.
799
- if (!opts?.liveness) {
800
- process.stdout.write(' hint: Run `aiden doctor --providers` for live provider checks\n');
801
- }
802
- // Phase v4.1.1-oauth-fix Phase 4: opt-in provider liveness.
803
- // Runs after the standard report so a failed config check is visible
804
- // before the network probes start. Section header + summary line are
805
- // rendered by doctorLiveness.renderProviderLivenessSection so the
806
- // 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.
807
1201
  let livenessFailed = false;
808
1202
  if (opts?.liveness) {
809
- process.stdout.write('\n Running provider liveness checks...\n');
810
- const { runProviderLiveness, renderProviderLivenessSection } = await Promise.resolve().then(() => __importStar(require('./doctorLiveness')));
1203
+ const { runProviderLiveness } = await Promise.resolve().then(() => __importStar(require('./doctorLiveness')));
811
1204
  const paths = opts.paths ?? (0, paths_1.resolveAidenPaths)();
812
- const { results, summary } = await runProviderLiveness({
1205
+ const { results: liveResults, summary } = await runProviderLiveness({
813
1206
  paths,
814
1207
  env: opts.env,
815
1208
  fetchImpl: opts.fetchImpl,
816
1209
  timeoutMs: opts.livenessTimeoutMs,
817
1210
  });
818
- 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
+ }
819
1227
  livenessFailed = summary.red > 0;
820
1228
  }
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
+ }
821
1235
  // Liveness reds count toward the overall exit code so CI / scripts
822
1236
  // can `aiden doctor --providers && deploy`.
823
1237
  process.exitCode = (report.passed && !livenessFailed) ? 0 : 1;