@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -3,7 +3,10 @@ import { explainIsolationBoundary } from '../lib/isolation-boundary-report.js';
3
3
  import { addHostOption } from '../lib/hosts/option.js';
4
4
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
5
5
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
6
- import { fanOutDevices } from '../lib/devices/fleet.js';
6
+ import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
+ import { fleetDialTarget } from '../lib/devices/connect.js';
8
+ import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
9
+ import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
7
10
  import { resolveHost } from '../lib/hosts/registry.js';
8
11
  import { sshExecAsync } from '../lib/ssh-exec.js';
9
12
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -15,7 +18,9 @@ import { formatSignInBadge } from '../lib/signin-badge.js';
15
18
  import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
16
19
  import { loadManifest, isStale } from '../lib/staleness/index.js';
17
20
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
18
- import { checkSyncStatus, countOrphans } from '../lib/drift.js';
21
+ import { checkVersionHookWiring, registerHooksToSettings } from '../lib/hooks.js';
22
+ import { isVersionIsolated } from '../lib/versions.js';
23
+ import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
19
24
  import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
20
25
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
21
26
  import { listCliStatus } from '../lib/cli-resources.js';
@@ -24,6 +29,7 @@ import { heal, healChangedAnything } from '../lib/heal.js';
24
29
  import { blocksLocalScripts, getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
25
30
  import { scanUserRcFiles, rcSecretWarningLines } from '../lib/secrets/rc-hygiene.js';
26
31
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
32
+ import { readRepoBehindMarkers } from '../lib/auto-pull.js';
27
33
  import * as fs from 'fs';
28
34
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
29
35
  // ─── overview mode (no target) ────────────────────────────────────────────────
@@ -61,7 +67,19 @@ function printWrappedLine(prefix, text) {
61
67
  for (const line of wrapLine(prefix, text))
62
68
  console.log(chalk.gray(line));
63
69
  }
64
- function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
70
+ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers) {
71
+ // Triaged health banner FIRST, so a user running bare `agents doctor` sees what
72
+ // is unhealthy, why it matters, and the exact fix before scrolling the detail
73
+ // sections below. Same triage model as target mode, aggregated across versions.
74
+ const overviewHealth = computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers);
75
+ console.log(chalk.bold('Health'));
76
+ renderHealthBlock(overviewHealth, {
77
+ healthySummary: syncRows.length
78
+ ? `${syncRows.length} version${syncRows.length === 1 ? '' : 's'} reconciled · hooks wired · sources current`
79
+ : 'no installed versions to check',
80
+ healFix: verdictIsAutoFixable(overviewHealth) ? 'agents doctor --fix' : undefined,
81
+ });
82
+ console.log();
65
83
  console.log(chalk.bold('Agent CLIs'));
66
84
  // Show the fleet you actually run — agents that are ready in PATH, plus any
67
85
  // you MANAGE (have installed versions) whose binary isn't resolving (a real
@@ -100,20 +118,26 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
100
118
  for (const row of syncRows) {
101
119
  const tag = row.isDefault ? chalk.gray(' (default)') : '';
102
120
  const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
103
- if (row.status === 'fresh') {
121
+ const unwired = (row.unwiredHooks ?? 0) > 0;
122
+ if (row.status === 'fresh' && !unwired) {
104
123
  console.log(` ${chalk.green('fresh')} ${label}`);
124
+ continue;
105
125
  }
106
- else if (row.status === 'stale') {
107
- anyOutOfSync = true;
126
+ anyOutOfSync = true;
127
+ if (row.status === 'stale') {
108
128
  console.log(` ${chalk.yellow('stale')} ${label} ${chalk.gray('— sources changed since last sync')}`);
109
- for (const line of row.divergence ?? []) {
110
- console.log(chalk.gray(` ${line}`));
111
- }
112
129
  }
113
- else {
114
- anyOutOfSync = true;
130
+ else if (row.status === 'never-synced') {
115
131
  console.log(` ${chalk.gray('cold ')} ${label} ${chalk.gray('— never synced')}`);
116
132
  }
133
+ else {
134
+ // Manifest-fresh but a declared hook is present-on-disk yet not wired into
135
+ // settings.json — it never fires (the yosemite-s1 blind spot).
136
+ console.log(` ${chalk.red('unwired')} ${label} ${chalk.gray('— hooks present but not wired into settings.json')}`);
137
+ }
138
+ for (const line of row.divergence ?? []) {
139
+ console.log(chalk.gray(` ${line}`));
140
+ }
117
141
  }
118
142
  // Launching does NOT reconcile a version home — the shim hot path only
119
143
  // resolves a version and compiles project-scoped resources (shims.ts v15/v16).
@@ -182,6 +206,10 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
182
206
  // environment (readable via /proc/<pid>/environ) — the RUSH-1968 class. Flag
183
207
  // them here so the master passphrase never silently lives in `~/.zshenv` again.
184
208
  renderRcHygieneAdvisory();
209
+ // Repos that are behind upstream — surfaced here instead of on stderr during
210
+ // every command. Markers are written by the background fetch worker and persist
211
+ // until the user runs `agents repo pull <alias>`.
212
+ renderRepoBehindAdvisory(repoBehindMarkers);
185
213
  }
186
214
  // ─── windows execution-policy advisory ─────────────────────────────────────────
187
215
  /**
@@ -217,6 +245,25 @@ function renderRcHygieneAdvisory() {
217
245
  console.log(chalk.gray(` ${line}`));
218
246
  }
219
247
  }
248
+ // ─── repo-behind advisory ─────────────────────────────────────────────────────
249
+ /**
250
+ * Render repo-behind notices from background fetch markers as a "Repo updates"
251
+ * section in `agents doctor`. Reads without consuming the markers so repeated
252
+ * invocations keep showing the notice until the user runs `agents repo pull`.
253
+ */
254
+ function renderRepoBehindAdvisory(markers) {
255
+ const behind = markers.filter((m) => m.behind > 0);
256
+ if (behind.length === 0)
257
+ return;
258
+ console.log();
259
+ console.log(chalk.bold('Repo updates'));
260
+ for (const m of behind) {
261
+ const label = m.alias === 'user' ? '~/.agents/' : m.alias;
262
+ const commits = `${m.behind} commit${m.behind === 1 ? '' : 's'}`;
263
+ console.log(` ${chalk.yellow('warn ')} ${label} is ${commits} behind ${m.branch}`);
264
+ console.log(chalk.gray(` run \`agents repo pull ${m.alias}\` to update`));
265
+ }
266
+ }
220
267
  function renderExecPolicyAdvisory() {
221
268
  // Only probe the policy on Windows — getEffectiveExecutionPolicy() spawns
222
269
  // powershell, which is a wasted (doomed) process on POSIX where the advisory
@@ -234,7 +281,7 @@ function renderExecPolicyAdvisory() {
234
281
  console.log(chalk.gray(` ${line}`));
235
282
  }
236
283
  }
237
- const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'gemini', 'droid'];
284
+ const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'droid'];
238
285
  function shortAgentHeader(name) {
239
286
  return name.slice(0, 4).padEnd(4);
240
287
  }
@@ -314,29 +361,74 @@ async function probeFleetTarget(target) {
314
361
  };
315
362
  }
316
363
  }
364
+ /**
365
+ * Fetch a device's harness inventory by running its top-level `agents doctor
366
+ * --json` (which carries the `fleet` field, RUSH-2027). Separate from
367
+ * {@link probeFleetTarget} — that forwards `teams doctor --json` for per-agent
368
+ * readiness, a different payload. Returns the parsed {@link FleetInventory} or
369
+ * null (unreachable, non-zero exit, unparseable, or an older CLI with no
370
+ * `fleet` field); a null is skipped by the comparator, never a false gap.
371
+ */
372
+ async function probeFleetInventory(target) {
373
+ const isWin = /^win/i.test((target.os ?? '').trim());
374
+ const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
375
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
376
+ if (res.code !== 0)
377
+ return null;
378
+ try {
379
+ const parsed = JSON.parse(res.stdout);
380
+ return parsed.fleet ?? null;
381
+ }
382
+ catch {
383
+ return null;
384
+ }
385
+ }
317
386
  async function runDevicesDoctor(opts) {
318
387
  const singleName = opts.host || opts.device;
319
388
  const targets = await resolveFleetTargets(opts);
320
389
  const localName = machineId();
321
390
  const results = [];
322
- // Local machine first, directly.
391
+ // Local machine first, directly. Its inventory is collected in-process — no
392
+ // SSH round-trip to this box.
323
393
  if (!singleName) {
324
- results.push({ name: localName, online: true, agents: await collectTeamsDoctorData() });
394
+ results.push({
395
+ name: localName,
396
+ online: true,
397
+ agents: await collectTeamsDoctorData(),
398
+ inventory: collectLocalFleetInventory(opts.cwd ?? process.cwd()),
399
+ });
325
400
  }
326
- // Remote targets in parallel, through the shared fleet fan-out helper.
327
- const remoteResults = await fanOutDevices(targets, probeFleetTarget);
401
+ // Remote targets in parallel: agent readiness (teams doctor) and the harness
402
+ // inventory (top-level doctor --json) in one fan-out per concern. Both run
403
+ // through the shared fleet helper so an offline box degrades to a skipped row.
404
+ const [remoteResults, inventoryResults] = await Promise.all([
405
+ fanOutDevices(targets, probeFleetTarget),
406
+ fanOutDevices(targets, probeFleetInventory),
407
+ ]);
408
+ const inventoryByName = new Map();
409
+ for (const r of inventoryResults)
410
+ inventoryByName.set(r.name, r.status === 'ok' ? (r.value ?? null) : null);
328
411
  results.push(...remoteResults.map((r) => {
412
+ const inventory = inventoryByName.get(r.name) ?? undefined;
329
413
  if (r.status === 'ok' && r.value)
330
- return r.value;
414
+ return { ...r.value, inventory: inventory ?? undefined };
331
415
  return {
332
416
  name: r.name,
333
417
  online: false,
334
418
  error: r.error ?? String(r.reason ?? 'skipped'),
335
419
  agents: {},
420
+ inventory: inventory ?? undefined,
336
421
  };
337
422
  }));
423
+ // Cross-device divergence: compare every device's inventory against the local
424
+ // baseline and flag resources / agent versions / repo state present on one box
425
+ // but missing on another (RUSH-2027). A single-device filter has no baseline
426
+ // to compare against, so the section is only meaningful for the full fan-out.
427
+ const divergence = singleName
428
+ ? null
429
+ : compareFleetInventories(results.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), localName);
338
430
  if (opts.json) {
339
- console.log(JSON.stringify({ devices: results }, null, 2));
431
+ console.log(JSON.stringify({ devices: results, fleet: divergence }, null, 2));
340
432
  return;
341
433
  }
342
434
  if (results.length === 0) {
@@ -360,6 +452,49 @@ async function runDevicesDoctor(opts) {
360
452
  }
361
453
  console.log();
362
454
  console.log(chalk.gray(' rdy* = installed and signed in · rdy = installed · no = not installed · err = probe failed · - = offline'));
455
+ if (divergence) {
456
+ console.log();
457
+ for (const line of renderFleetDivergence(divergence))
458
+ console.log(line);
459
+ }
460
+ }
461
+ /**
462
+ * Render the cross-device divergence section for `agents doctor --devices`
463
+ * (RUSH-2027): a clean all-clear when the fleet agrees, otherwise the specific
464
+ * gaps — a resource/version present here but missing on a box, or a diverged
465
+ * config repo — grouped by device, each a plain-language line. Devices that
466
+ * couldn't be compared (offline / older CLI) are named so the readout is honest.
467
+ */
468
+ export function renderFleetDivergence(report) {
469
+ const lines = [chalk.bold('Cross-device divergence')];
470
+ const skippedNote = report.skippedDevices.length > 0
471
+ ? chalk.gray(` (not compared: ${report.skippedDevices.join(', ')} — offline or no inventory)`)
472
+ : null;
473
+ if (!report.hasDivergence) {
474
+ lines.push(chalk.green(` Fleet is consistent — every compared device matches ${report.baseline}.`));
475
+ if (skippedNote)
476
+ lines.push(skippedNote);
477
+ return lines;
478
+ }
479
+ // Group findings by the LAGGING device so a box with several gaps reads as one
480
+ // block AND the remediation points at the right box. For a `*-missing-local`
481
+ // finding the resource is absent on the local baseline (present on `d.device`),
482
+ // so the box that's behind is the baseline — not the remote that has it.
483
+ const byDevice = new Map();
484
+ for (const d of report.divergences) {
485
+ const lagging = d.kind.endsWith('-missing-local') ? report.baseline : d.device;
486
+ (byDevice.get(lagging) ?? byDevice.set(lagging, []).get(lagging)).push(d);
487
+ }
488
+ for (const device of Array.from(byDevice.keys()).sort()) {
489
+ lines.push(` ${chalk.yellow('⚠')} ${chalk.bold(device)}`);
490
+ for (const d of byDevice.get(device)) {
491
+ lines.push(chalk.gray(` ${d.message}`));
492
+ }
493
+ }
494
+ if (skippedNote)
495
+ lines.push(skippedNote);
496
+ lines.push(chalk.gray(' Read-only — run `agents apply` or `agents repo pull` on a lagging box to reconcile.'));
497
+ return lines;
363
498
  }
364
499
  function parseTargetArg(arg) {
365
500
  const at = arg.indexOf('@');
@@ -505,6 +640,284 @@ function readExpectedForDiff(kind, row) {
505
640
  return null;
506
641
  return safeRead(row.sourcePath);
507
642
  }
643
+ // Family of agents whose hooks re-wire through registerHooksToSettings into a
644
+ // Claude-style settings.json (matches checkVersionHookWiring's supported set).
645
+ const HOOK_WIRING_FIX_AGENTS = ['claude', 'droid'];
646
+ /** Categories `--fix` reconciles (vs. `agents repo pull` for a behind source, or
647
+ * `agents prune cleanup` for an orphan). Drives the heal footer. */
648
+ const AUTO_FIXABLE_CATEGORIES = new Set([
649
+ 'unwired-hook', 'settings-missing', 'settings-unparseable', 'missing', 'divergent', 'stale', 'never-synced',
650
+ ]);
651
+ export function verdictIsAutoFixable(v) {
652
+ return v.issues.some((i) => AUTO_FIXABLE_CATEGORIES.has(i.category));
653
+ }
654
+ /**
655
+ * Fold a version report's divergences into a triaged verdict — one severity-tagged
656
+ * finding per unwired hook, missing/divergent/extra resource, and behind-origin
657
+ * source layer, each carrying its subject, plain-English impact, and exact fix. An
658
+ * UNWIRED hook or a stale source is as unhealthy as a divergent file, not a
659
+ * footnote. Pure so the health rollup is unit-testable without a version home.
660
+ */
661
+ export function computeVerdict(report) {
662
+ const issues = [];
663
+ const idLabel = `${report.agent}@${report.version}`;
664
+ const fixCmd = `agents doctor ${idLabel} --fix`;
665
+ const syncCmd = `agents sync ${idLabel} --yes`;
666
+ // ── critical: settings.json / unwired hooks (silent breakage) ──
667
+ const w = report.hookWiring;
668
+ if (w?.settingsMissing) {
669
+ const n = w.expected ?? 0;
670
+ issues.push({
671
+ severity: 'critical', category: 'settings-missing', subject: 'settings.json',
672
+ impact: `not found; ${n} declared hook${n === 1 ? '' : 's'} never fire`,
673
+ fix: syncCmd,
674
+ text: `settings.json missing (${n} hook${n === 1 ? '' : 's'} unwired)`, color: 'red',
675
+ });
676
+ }
677
+ else if (w?.settingsUnparseable) {
678
+ issues.push({
679
+ severity: 'critical', category: 'settings-unparseable', subject: 'settings.json',
680
+ impact: `unparseable; hook wiring can't be verified`,
681
+ fix: syncCmd,
682
+ text: 'settings.json unparseable', color: 'red',
683
+ });
684
+ }
685
+ else if (w) {
686
+ for (const u of w.unwired) {
687
+ issues.push({
688
+ severity: 'critical', category: 'unwired-hook', subject: u.name,
689
+ impact: 'on disk but not wired into settings.json; the hook never fires',
690
+ fix: syncCmd,
691
+ text: `${u.name} unwired`, color: 'red',
692
+ });
693
+ }
694
+ }
695
+ // ── critical: missing resources (declared in sources, absent from home) ──
696
+ for (const kind of DOCTOR_ALL_KINDS) {
697
+ for (const r of report.kinds[kind]) {
698
+ if (r.status !== 'missing')
699
+ continue;
700
+ issues.push({
701
+ severity: 'critical', category: 'missing', subject: r.name,
702
+ impact: `declared in sources but absent from the version home (${kind})`,
703
+ fix: fixCmd,
704
+ text: `${r.name} missing`, color: 'red',
705
+ });
706
+ }
707
+ }
708
+ // ── warning: source layer behind origin (home reconciled against stale truth) ──
709
+ for (const b of report.sourceBehind ?? []) {
710
+ if (b.behind <= 0)
711
+ continue;
712
+ issues.push({
713
+ severity: 'warning', category: 'source-behind', subject: b.label,
714
+ impact: `${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}; you're running stale config`,
715
+ fix: `agents repo pull ${b.alias}`,
716
+ text: `source ${b.label} ${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}`, color: 'yellow',
717
+ });
718
+ }
719
+ // ── warning: divergent resources (drifted from source) ──
720
+ for (const kind of DOCTOR_ALL_KINDS) {
721
+ for (const r of report.kinds[kind]) {
722
+ if (r.status !== 'diff')
723
+ continue;
724
+ issues.push({
725
+ severity: 'warning', category: 'divergent', subject: r.name,
726
+ impact: r.detail ? collapseWhitespace(r.detail) : 'differs from source',
727
+ fix: fixCmd,
728
+ text: `${r.name} divergent`, color: 'yellow',
729
+ });
730
+ }
731
+ }
732
+ // ── info: extra / orphan resources (present in home, no source) ──
733
+ for (const kind of DOCTOR_ALL_KINDS) {
734
+ for (const r of report.kinds[kind]) {
735
+ if (r.status !== 'extra')
736
+ continue;
737
+ issues.push({
738
+ severity: 'info', category: 'extra', subject: r.name,
739
+ impact: `orphan in the version home with no source (${kind})`,
740
+ fix: 'agents prune cleanup',
741
+ text: `${r.name} extra`, color: 'magenta',
742
+ });
743
+ }
744
+ }
745
+ return { healthy: issues.length === 0, issues, reconciled: report.summary.ok };
746
+ }
747
+ // ─── triaged health block (shared by target + overview) ────────────────────────
748
+ const SEVERITY_COLOR = {
749
+ critical: chalk.red,
750
+ warning: chalk.yellow,
751
+ info: chalk.magenta,
752
+ };
753
+ // Restrained terminal glyphs — the ✓ ✗ ⚠ set plus a subtle info dot, colored via
754
+ // chalk to match the man-page voice. No colorful emoji.
755
+ const SEVERITY_GLYPH = {
756
+ critical: '✗',
757
+ warning: '⚠',
758
+ info: '·',
759
+ };
760
+ function severityCounts(issues) {
761
+ return {
762
+ critical: issues.filter((i) => i.severity === 'critical').length,
763
+ warning: issues.filter((i) => i.severity === 'warning').length,
764
+ info: issues.filter((i) => i.severity === 'info').length,
765
+ };
766
+ }
767
+ /** The info tier (orphans; one identical `prune cleanup` fix, already enumerated
768
+ * in the detail section) is capped with a rollup so the block stays scannable
769
+ * when a version home carries dozens of orphans. Critical + warning are
770
+ * actionable, each with a distinct subject/fix, so they are never capped. */
771
+ const INFO_CAP = 5;
772
+ /**
773
+ * Build the lines of a triaged health block: a single green ✓ line when healthy,
774
+ * otherwise a severity-counted ✗ header followed by one row per finding (icon ·
775
+ * severity · subject — impact, then the exact fix under it) and an optional heal
776
+ * footer. Pure — returns the lines so the rendered output is unit-testable. Shared
777
+ * by target mode and the bare overview so both read the same way.
778
+ */
779
+ export function healthBlockLines(verdict, opts) {
780
+ if (verdict.healthy) {
781
+ return [` ${chalk.green('✓')} ${chalk.green('healthy')} ${chalk.gray('— ' + opts.healthySummary)}`];
782
+ }
783
+ const lines = [];
784
+ const c = severityCounts(verdict.issues);
785
+ const bits = [];
786
+ if (c.critical)
787
+ bits.push(`${c.critical} critical`);
788
+ if (c.warning)
789
+ bits.push(`${c.warning} warning${c.warning === 1 ? '' : 's'}`);
790
+ if (c.info)
791
+ bits.push(`${c.info} info`);
792
+ const total = verdict.issues.length;
793
+ lines.push(` ${chalk.red('✗')} ${chalk.red('unhealthy')} ${chalk.gray(`— ${total} issue${total === 1 ? '' : 's'} (${bits.join(' · ')})`)}`);
794
+ lines.push('');
795
+ const cont = ' '.repeat(14); // aligns the fix line under the subject column
796
+ const issueLines = (i) => {
797
+ const glyph = SEVERITY_COLOR[i.severity](SEVERITY_GLYPH[i.severity]);
798
+ const word = SEVERITY_COLOR[i.severity](i.severity.padEnd(8));
799
+ lines.push(` ${glyph} ${word} ${chalk.bold(i.subject)} ${chalk.gray('— ' + i.impact)}`);
800
+ lines.push(chalk.gray(`${cont}→ ${i.fix}`));
801
+ };
802
+ const actionable = verdict.issues.filter((i) => i.severity !== 'info');
803
+ const infoIssues = verdict.issues.filter((i) => i.severity === 'info');
804
+ for (const i of actionable)
805
+ issueLines(i);
806
+ for (const i of infoIssues.slice(0, INFO_CAP))
807
+ issueLines(i);
808
+ const hiddenInfo = infoIssues.length - Math.min(infoIssues.length, INFO_CAP);
809
+ if (hiddenInfo > 0) {
810
+ const glyph = SEVERITY_COLOR.info(SEVERITY_GLYPH.info);
811
+ const word = SEVERITY_COLOR.info('info'.padEnd(8));
812
+ lines.push(` ${glyph} ${word} ${chalk.gray(`+${hiddenInfo} more orphan${hiddenInfo === 1 ? '' : 's'}`)} ${chalk.gray('— agents prune cleanup')}`);
813
+ }
814
+ if (opts.healFix) {
815
+ lines.push('');
816
+ lines.push(` ${chalk.gray("heal what's auto-fixable:")} ${opts.healFix}`);
817
+ }
818
+ return lines;
819
+ }
820
+ function renderHealthBlock(verdict, opts) {
821
+ for (const line of healthBlockLines(verdict, opts))
822
+ console.log(line);
823
+ }
824
+ /**
825
+ * Aggregate the bare `agents doctor` overview into the same triaged verdict target
826
+ * mode uses — folding per-version wiring/sync drift, behind-origin source layers,
827
+ * and orphan resources into severity-tagged findings. Agent-agnostic: every
828
+ * installed version is classified the same way. Pure, so it is unit-testable.
829
+ */
830
+ export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers) {
831
+ const issues = [];
832
+ const pretty = (agent, version) => `${AGENT_NAMES[agent] || agent}@${version}`;
833
+ // critical: unwired hooks / broken settings.json per version
834
+ for (const row of syncRows) {
835
+ const n = row.unwiredHooks ?? 0;
836
+ if (n <= 0)
837
+ continue;
838
+ const label = pretty(row.agent, row.version);
839
+ issues.push({
840
+ severity: 'critical', category: 'unwired-hook', subject: label,
841
+ impact: `${n} hook${n === 1 ? '' : 's'} present on disk but not wired into settings.json; never fire`,
842
+ fix: `agents sync ${row.agent}@${row.version} --yes`,
843
+ text: `${label} ${n} unwired`, color: 'red',
844
+ });
845
+ }
846
+ // warning: source layers behind origin
847
+ for (const m of repoBehindMarkers) {
848
+ if (m.behind <= 0)
849
+ continue;
850
+ const label = m.alias === 'user' ? '~/.agents' : m.alias;
851
+ issues.push({
852
+ severity: 'warning', category: 'source-behind', subject: label,
853
+ impact: `${m.behind} commit${m.behind === 1 ? '' : 's'} behind ${m.branch}; you're running stale config`,
854
+ fix: `agents repo pull ${m.alias}`,
855
+ text: `${label} ${m.behind} behind`, color: 'yellow',
856
+ });
857
+ }
858
+ // warning: stale / never-synced versions
859
+ for (const row of syncRows) {
860
+ const label = pretty(row.agent, row.version);
861
+ if (row.status === 'stale') {
862
+ issues.push({
863
+ severity: 'warning', category: 'stale', subject: label,
864
+ impact: 'sources changed since last sync',
865
+ fix: `agents doctor ${row.agent}@${row.version} --fix`,
866
+ text: `${label} stale`, color: 'yellow',
867
+ });
868
+ }
869
+ else if (row.status === 'never-synced') {
870
+ issues.push({
871
+ severity: 'warning', category: 'never-synced', subject: label,
872
+ impact: 'installed but never synced',
873
+ fix: `agents sync ${row.agent}@${row.version} --yes`,
874
+ text: `${label} never-synced`, color: 'yellow',
875
+ });
876
+ }
877
+ }
878
+ // info: orphan resources per version
879
+ for (const row of orphanRows) {
880
+ const parts = [];
881
+ if (row.commands)
882
+ parts.push(`${row.commands} command${row.commands === 1 ? '' : 's'}`);
883
+ if (row.skills)
884
+ parts.push(`${row.skills} skill${row.skills === 1 ? '' : 's'}`);
885
+ if (row.hooks)
886
+ parts.push(`${row.hooks} hook${row.hooks === 1 ? '' : 's'}`);
887
+ const label = pretty(row.agent, row.version);
888
+ issues.push({
889
+ severity: 'info', category: 'orphan', subject: label,
890
+ impact: `${parts.join(', ')} in the version home with no source`,
891
+ fix: 'agents prune cleanup',
892
+ text: `${label} orphan`, color: 'magenta',
893
+ });
894
+ }
895
+ const reconciled = syncRows.filter((r) => r.status === 'fresh' && (r.unwiredHooks ?? 0) === 0).length;
896
+ return { healthy: issues.length === 0, issues, reconciled };
897
+ }
898
+ /**
899
+ * Render the UNWIRED hook rows (and settings.json problems) inside the hooks
900
+ * section, in the same row style as the reconcile rows above them.
901
+ */
902
+ function renderHookWiringRows(w) {
903
+ if (!w.supported)
904
+ return;
905
+ if (w.settingsMissing) {
906
+ const where = w.settingsPath ? ` at ${w.settingsPath}` : '';
907
+ console.log(` ${chalk.red('UNWIRED')} ${chalk.gray(`settings.json not found${where} — ${w.expected ?? 0} declared hook(s) never fire`)}`);
908
+ return;
909
+ }
910
+ if (w.settingsUnparseable) {
911
+ const where = w.settingsPath ? ` at ${w.settingsPath}` : '';
912
+ console.log(` ${chalk.red('UNWIRED')} ${chalk.gray(`settings.json unparseable${where} — wiring can't be verified`)}`);
913
+ return;
914
+ }
915
+ for (const u of w.unwired) {
916
+ const name = padToWidth(truncateToWidth(u.name, 28), 28);
917
+ const scope = u.matcher ? `event=${u.event} matcher=${u.matcher}` : `event=${u.event}`;
918
+ console.log(` ${chalk.red('UNWIRED')} ${name} ${chalk.gray(scope)}`);
919
+ }
920
+ }
508
921
  function renderTargetText(report, options) {
509
922
  const label = `${AGENT_NAMES[report.agent] || report.agent}@${report.version}`;
510
923
  console.log(chalk.bold(label));
@@ -546,23 +959,22 @@ function renderTargetText(report, options) {
546
959
  if (options.requestedKinds && !options.requestedKinds.has(kind))
547
960
  continue;
548
961
  renderKindSection(kind, rows, report.layers, options);
962
+ // A hook file can reconcile "ok" above yet be absent from settings.json — a
963
+ // present-but-dead hook. Surface that right under the hooks section.
964
+ if (kind === 'hooks' && report.hookWiring)
965
+ renderHookWiringRows(report.hookWiring);
549
966
  }
550
967
  console.log();
551
- const { ok, diff, missing, extra } = report.summary;
552
- const verdictParts = [];
553
- if (diff)
554
- verdictParts.push(chalk.yellow(`${diff} divergent`));
555
- if (missing)
556
- verdictParts.push(chalk.red(`${missing} missing`));
557
- if (extra)
558
- verdictParts.push(chalk.magenta(`${extra} extra`));
559
- if (verdictParts.length === 0) {
560
- console.log(chalk.green(` Verdict: ${ok} resource${ok === 1 ? '' : 's'} reconciled. Version home matches resolved sources.`));
561
- }
562
- else {
563
- console.log(` Verdict: ${verdictParts.join(', ')}.`);
564
- printWrappedLine(' ', `Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`);
565
- }
968
+ const verdict = computeVerdict(report);
969
+ // A source layer behind origin is healed by `agents repo pull`, not `--fix` —
970
+ // the per-issue fix already names the right command, so the heal footer only
971
+ // shows when something is genuinely `--fix`-able (never in the source-behind or
972
+ // orphan-only case, where it would mislead).
973
+ const hooksWired = report.hookWiring?.supported ? ' · hooks wired' : '';
974
+ renderHealthBlock(verdict, {
975
+ healthySummary: `${verdict.reconciled} resource${verdict.reconciled === 1 ? '' : 's'} reconciled${hooksWired} · sources current`,
976
+ healFix: verdictIsAutoFixable(verdict) ? `agents doctor ${report.agent}@${report.version} --fix` : undefined,
977
+ });
566
978
  }
567
979
  // ─── fix / heal mode ───────────────────────────────────────────────────────────
568
980
  function renderHealText(result) {
@@ -616,6 +1028,53 @@ function renderHealText(result) {
616
1028
  console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
617
1029
  }
618
1030
  }
1031
+ /**
1032
+ * Re-wire hooks that reconcile as files but are absent from settings.json.
1033
+ *
1034
+ * heal() only re-syncs resources the diff flags missing/diff; a hook whose file
1035
+ * is byte-identical to source but never referenced in settings.json is neither,
1036
+ * so heal walks past it. registerHooksToSettings (the same call `agents sync`
1037
+ * makes at versions.ts) regenerates the wiring, so run it for any Claude-family
1038
+ * version this fix targets that has unwired hooks. Only claude/droid — the set
1039
+ * checkVersionHookWiring can verify.
1040
+ */
1041
+ function rewireUnwiredHooks(parsed) {
1042
+ const out = [];
1043
+ const agents = parsed?.agent
1044
+ ? (HOOK_WIRING_FIX_AGENTS.includes(parsed.agent) ? [parsed.agent] : [])
1045
+ : HOOK_WIRING_FIX_AGENTS;
1046
+ for (const agent of agents) {
1047
+ // A named version is explicit consent; a sweep excludes isolated copies,
1048
+ // mirroring heal().
1049
+ const versions = parsed?.agent && parsed.versionExplicit
1050
+ ? parsed.versions
1051
+ : listInstalledVersions(agent).filter((v) => !isVersionIsolated(agent, v));
1052
+ for (const version of versions) {
1053
+ const before = checkVersionHookWiring(agent, version);
1054
+ if (!before.supported)
1055
+ continue;
1056
+ const need = before.unwired.length + (before.settingsMissing ? (before.expected ?? 0) : 0);
1057
+ if (need === 0)
1058
+ continue;
1059
+ registerHooksToSettings(agent, getVersionHomePath(agent, version));
1060
+ const after = checkVersionHookWiring(agent, version);
1061
+ const remaining = after.unwired.length + (after.settingsMissing ? (after.expected ?? 0) : 0);
1062
+ out.push({ agent, version, rewired: Math.max(0, need - remaining), remaining });
1063
+ }
1064
+ }
1065
+ return out;
1066
+ }
1067
+ function renderHookRewireText(rewired) {
1068
+ for (const r of rewired) {
1069
+ const label = `${AGENT_NAMES[r.agent] || r.agent}@${r.version}`;
1070
+ if (r.remaining === 0) {
1071
+ console.log(` ${chalk.green('rewired')} ${label} ${chalk.gray(`${r.rewired} hook${r.rewired === 1 ? '' : 's'} wired into settings.json`)}`);
1072
+ }
1073
+ else {
1074
+ console.log(` ${chalk.yellow('hold ')} ${label} ${chalk.gray(`${r.remaining} hook${r.remaining === 1 ? '' : 's'} still unwired — run \`agents sync ${r.agent}@${r.version} --yes\``)}`);
1075
+ }
1076
+ }
1077
+ }
619
1078
  async function runFix(parsed, opts) {
620
1079
  // Heal targets the global install — project layer is irrelevant, so cwd is
621
1080
  // left to heal's neutral default rather than process.cwd().
@@ -630,11 +1089,170 @@ async function runFix(parsed, opts) {
630
1089
  agent: parsed?.agent,
631
1090
  versions: parsed?.versionExplicit ? parsed.versions : undefined,
632
1091
  });
1092
+ // Re-wire hooks the diff-driven heal leaves behind (present file, not wired).
1093
+ const rewired = rewireUnwiredHooks(parsed);
633
1094
  if (opts.json) {
634
- console.log(JSON.stringify(result, null, 2));
1095
+ console.log(JSON.stringify({ ...result, hookRewire: rewired }, null, 2));
635
1096
  return;
636
1097
  }
637
1098
  renderHealText(result);
1099
+ renderHookRewireText(rewired);
1100
+ }
1101
+ function checkLabel(row) {
1102
+ return `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
1103
+ }
1104
+ function runCheckGate(opts, cwd) {
1105
+ const drift = computeDrift(cwd);
1106
+ if (opts.json) {
1107
+ console.log(JSON.stringify({
1108
+ hasDrift: drift.hasDrift,
1109
+ stale: drift.staleCount,
1110
+ neverSynced: drift.neverSyncedCount,
1111
+ unwiredHookVersions: drift.unwiredHookVersions,
1112
+ orphanVersions: drift.orphanVersionCount,
1113
+ sourceBehind: drift.sourceBehind,
1114
+ versions: drift.syncRows.map((r) => ({
1115
+ agent: r.agent,
1116
+ version: r.version,
1117
+ status: r.status,
1118
+ isDefault: r.isDefault,
1119
+ unwiredHooks: r.unwiredHooks ?? 0,
1120
+ divergence: r.divergence ?? [],
1121
+ })),
1122
+ }, null, 2));
1123
+ process.exit(drift.hasDrift ? 1 : 0);
1124
+ }
1125
+ if (drift.syncRows.length === 0) {
1126
+ // Nothing installed is a clean state, not a failure — CI on a fresh
1127
+ // checkout with no versions should pass, not error.
1128
+ console.log(chalk.gray('check: no installed versions — nothing to verify'));
1129
+ process.exit(0);
1130
+ }
1131
+ if (!drift.hasDrift) {
1132
+ const orphanNote = drift.orphanVersionCount > 0
1133
+ ? chalk.gray(` (${drift.orphanVersionCount} version(s) carry orphans — run \`agents prune cleanup\`)`)
1134
+ : '';
1135
+ console.log(`${chalk.green('ok')} ${drift.syncRows.length} version(s) in sync${orphanNote}`);
1136
+ process.exit(0);
1137
+ }
1138
+ // Drift: one-line verdict always, per-version detail unless --quiet.
1139
+ const parts = [];
1140
+ if (drift.staleCount > 0)
1141
+ parts.push(`${drift.staleCount} stale`);
1142
+ if (drift.neverSyncedCount > 0)
1143
+ parts.push(`${drift.neverSyncedCount} never-synced`);
1144
+ if (drift.unwiredHookVersions > 0)
1145
+ parts.push(`${drift.unwiredHookVersions} with unwired hooks`);
1146
+ if (drift.sourceBehind.length > 0)
1147
+ parts.push(`${drift.sourceBehind.length} source layer(s) behind origin`);
1148
+ console.error(`${chalk.red('drift')} ${parts.join(', ')}`);
1149
+ if (!opts.quiet) {
1150
+ for (const row of drift.syncRows) {
1151
+ const unwired = (row.unwiredHooks ?? 0) > 0;
1152
+ if (row.status === 'fresh' && !unwired)
1153
+ continue;
1154
+ const tag = row.status === 'stale' ? chalk.yellow('stale')
1155
+ : row.status === 'never-synced' ? chalk.gray('cold ')
1156
+ : chalk.red('unwired'); // fresh but hooks not wired into settings.json
1157
+ console.error(` ${tag} ${checkLabel(row)}`);
1158
+ for (const line of row.divergence ?? []) {
1159
+ console.error(chalk.gray(` ${line}`));
1160
+ }
1161
+ }
1162
+ for (const b of drift.sourceBehind) {
1163
+ console.error(` ${chalk.red('behind')} source ${b.label} ${chalk.gray(`${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}`)}`);
1164
+ }
1165
+ const hints = [];
1166
+ if (drift.staleCount > 0 || drift.neverSyncedCount > 0 || drift.unwiredHookVersions > 0) {
1167
+ hints.push('`agents doctor --fix` (or `agents doctor <agent>@<version> --fix`)');
1168
+ }
1169
+ if (drift.sourceBehind.length > 0)
1170
+ hints.push('`agents repo pull <alias>` for a source layer behind origin');
1171
+ console.error(chalk.gray(`\nReconcile with ${hints.join('; ')}.`));
1172
+ }
1173
+ process.exit(1);
1174
+ }
1175
+ function checkPayload(device, drift) {
1176
+ return {
1177
+ device,
1178
+ hasDrift: drift.hasDrift,
1179
+ stale: drift.staleCount,
1180
+ neverSynced: drift.neverSyncedCount,
1181
+ orphanVersions: drift.orphanVersionCount,
1182
+ };
1183
+ }
1184
+ async function probeDeviceCheck(target) {
1185
+ const isWin = /^win/i.test((target.platform ?? '').trim());
1186
+ const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
1187
+ const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
1188
+ if (res.code !== 0 && !res.stdout.trim()) {
1189
+ throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
1190
+ }
1191
+ try {
1192
+ const parsed = JSON.parse(res.stdout);
1193
+ return {
1194
+ device: target.name,
1195
+ hasDrift: Boolean(parsed.hasDrift),
1196
+ stale: parsed.stale ?? 0,
1197
+ neverSynced: parsed.neverSynced ?? 0,
1198
+ orphanVersions: parsed.orphanVersions ?? 0,
1199
+ };
1200
+ }
1201
+ catch (err) {
1202
+ throw new Error(`invalid JSON (${err?.message ?? 'parse error'})`);
1203
+ }
1204
+ }
1205
+ async function runDevicesCheck(opts, cwd) {
1206
+ const registry = await loadDevices();
1207
+ const self = machineId();
1208
+ const planned = planFleetTargets(registry);
1209
+ const local = checkPayload(self, computeDrift(cwd));
1210
+ const remoteTargets = remoteFleetTargets(planned, self)
1211
+ .map((t) => ({
1212
+ name: t.device.name,
1213
+ platform: t.device.platform,
1214
+ skip: t.skip,
1215
+ dialTarget: fleetDialTarget(t.device),
1216
+ }));
1217
+ const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
1218
+ const devices = [local];
1219
+ for (const result of remote) {
1220
+ if (result.status === 'ok' && result.value) {
1221
+ devices.push(result.value);
1222
+ }
1223
+ else {
1224
+ devices.push({
1225
+ device: result.name,
1226
+ hasDrift: true,
1227
+ stale: 0,
1228
+ neverSynced: 0,
1229
+ orphanVersions: 0,
1230
+ error: result.error ?? String(result.reason ?? 'skipped'),
1231
+ });
1232
+ }
1233
+ }
1234
+ const hasDrift = devices.some((d) => d.hasDrift || d.error);
1235
+ if (opts.json) {
1236
+ console.log(JSON.stringify({ hasDrift, devices }, null, 2));
1237
+ process.exit(hasDrift ? 1 : 0);
1238
+ }
1239
+ if (!hasDrift) {
1240
+ console.log(chalk.green('ok') + chalk.gray(` ${devices.length} device(s) in sync`));
1241
+ process.exit(0);
1242
+ }
1243
+ console.error(chalk.red('drift') + chalk.gray(` ${devices.filter((d) => d.hasDrift || d.error).length} of ${devices.length} device(s)`));
1244
+ if (!opts.quiet) {
1245
+ for (const d of devices) {
1246
+ if (!d.hasDrift && !d.error)
1247
+ continue;
1248
+ const detail = d.error
1249
+ ? d.error
1250
+ : [`${d.stale} stale`, `${d.neverSynced} never-synced`].filter((p) => !p.startsWith('0 ')).join(', ');
1251
+ console.error(` ${chalk.yellow(d.device.padEnd(18))} ${detail || 'drift'}`);
1252
+ }
1253
+ console.error(chalk.gray('\nReconcile each device with `agents doctor --fix` or `agents repo pull user`.'));
1254
+ }
1255
+ process.exit(1);
638
1256
  }
639
1257
  // ─── command registration ────────────────────────────────────────────────────
640
1258
  export function registerDoctorCommand(program) {
@@ -647,8 +1265,10 @@ export function registerDoctorCommand(program) {
647
1265
  .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())')
648
1266
  .option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
649
1267
  .option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted')
650
- .option('--devices', 'Check agent readiness on every registered device (alias --hosts)')
651
- .option('--hosts', 'Alias of --devices');
1268
+ .option('--devices', 'Check agent readiness AND cross-device harness divergence (missing resources/versions, repo drift) on every registered device (alias --hosts)')
1269
+ .option('--hosts', 'Alias of --devices')
1270
+ .option('--check', 'CI drift gate: exit non-zero when any installed version is out of sync (stale or never-synced), zero when clean. Combine with --devices to gate the whole fleet.')
1271
+ .option('-q, --quiet', 'With --check, suppress per-version lines; print only the one-line verdict');
652
1272
  setHelpSections(doctorCmd, {
653
1273
  examples: `
654
1274
  # Overview: CLI availability + sync status + orphans across all defaults
@@ -658,7 +1278,7 @@ export function registerDoctorCommand(program) {
658
1278
  agents doctor claude@default
659
1279
 
660
1280
  # All installed versions of one agent
661
- agents doctor gemini
1281
+ agents doctor antigravity
662
1282
 
663
1283
  # Pin to a specific installed version
664
1284
  agents doctor codex@0.117.0
@@ -671,10 +1291,37 @@ export function registerDoctorCommand(program) {
671
1291
 
672
1292
  # Heal just one agent (all its installed versions)
673
1293
  agents doctor claude --fix
1294
+
1295
+ # Fleet: agent readiness + cross-device divergence (missing plugins/skills,
1296
+ # agent-version gaps, .agents/.system repo drift) vs this machine
1297
+ agents doctor --devices
1298
+
1299
+ # CI drift gate: exit 1 if anything drifted (stale/never-synced), 0 if clean
1300
+ agents doctor --check
1301
+ agents doctor --check --quiet # just the verdict line
1302
+ agents doctor --check --json # machine-readable, for scripting
1303
+ agents doctor --check --devices # gate every registered device
1304
+ agents doctor --check || { echo "resources drifted — run 'agents doctor --fix'"; exit 1; }
674
1305
  `,
675
1306
  });
676
1307
  doctorCmd.action(async (target, opts) => {
677
1308
  const cwd = opts.cwd ? opts.cwd : process.cwd();
1309
+ // CI drift gate. Kept BEFORE the --devices branch so `doctor --check
1310
+ // --devices` routes to the drift gate fan-out (runDevicesCheck), while a
1311
+ // bare `doctor --devices` still routes to the readiness matrix below.
1312
+ if (opts.check) {
1313
+ if (target) {
1314
+ console.error(chalk.red('Cannot combine --check with a target argument.'));
1315
+ process.exit(1);
1316
+ }
1317
+ if (opts.devices || opts.hosts) {
1318
+ await runDevicesCheck(opts, cwd);
1319
+ }
1320
+ else {
1321
+ runCheckGate(opts, cwd);
1322
+ }
1323
+ return;
1324
+ }
678
1325
  if (opts.devices || opts.hosts) {
679
1326
  if (target) {
680
1327
  console.error(chalk.red('Cannot combine --devices with a target argument.'));
@@ -758,6 +1405,7 @@ export function registerDoctorCommand(program) {
758
1405
  const syncRows = checkSyncStatus(cwd);
759
1406
  const orphanRows = countOrphans();
760
1407
  const hostClis = listCliStatus(cwd);
1408
+ const repoBehindMarkers = readRepoBehindMarkers();
761
1409
  // Advisory login state per installed agent (file-based getAccountInfo,
762
1410
  // no home → the account-global/active credential). Best-effort: a probe
763
1411
  // failure just leaves that agent's badge as "logged out".
@@ -782,6 +1430,16 @@ export function registerDoctorCommand(program) {
782
1430
  auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
783
1431
  sync: syncRows,
784
1432
  orphans: orphanRows,
1433
+ // Triaged overview health — severity/category/subject/impact/fix per
1434
+ // finding, aggregated across versions. Additive; existing consumers
1435
+ // reading `sync`/`orphans`/`repos` are unaffected.
1436
+ health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers),
1437
+ // This host's harness inventory — installed resources per kind,
1438
+ // installed version ids per agent, and `.agents`/`.system` repo
1439
+ // state — so `agents doctor --devices` can compare presence across
1440
+ // the fleet and flag a resource/version present here but missing
1441
+ // elsewhere (RUSH-2027). Read-only; no network.
1442
+ fleet: collectLocalFleetInventory(cwd),
785
1443
  hostClis: {
786
1444
  statuses: hostClis.statuses.map((s) => ({
787
1445
  name: s.manifest.name,
@@ -791,14 +1449,24 @@ export function registerDoctorCommand(program) {
791
1449
  })),
792
1450
  errors: hostClis.errors,
793
1451
  },
1452
+ // Repos behind upstream — emitted here so menubar and other consumers
1453
+ // can surface the notices without reading stderr from normal commands.
1454
+ repos: repoBehindMarkers.map((m) => ({
1455
+ alias: m.alias,
1456
+ dir: m.dir,
1457
+ behind: m.behind,
1458
+ branch: m.branch,
1459
+ fetchedAt: m.fetchedAt,
1460
+ })),
794
1461
  }, null, 2));
795
1462
  return;
796
1463
  }
797
- renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn);
1464
+ renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers);
798
1465
  // Point at the interactive reconcile when anything is out of sync — the
799
1466
  // report shouldn't be a dead end. `agents status` runs the unified
800
1467
  // home-reading engine and offers to sync (opt-in, never auto-fires here).
801
- if (syncRows.some((r) => r.status !== 'fresh')) {
1468
+ // Unwired hooks and a behind-origin repo count as out-of-sync too.
1469
+ if (syncRows.some((r) => r.status !== 'fresh' || (r.unwiredHooks ?? 0) > 0) || repoBehindMarkers.some((m) => m.behind > 0)) {
802
1470
  console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
803
1471
  }
804
1472
  return;
@@ -814,8 +1482,18 @@ export function registerDoctorCommand(program) {
814
1482
  process.exit(1);
815
1483
  }
816
1484
  const reports = parsed.versions.map((v) => diffVersionResources(parsed.agent, v, { cwd, kinds }));
1485
+ // Source-layer staleness is global (same across versions) and needs a git
1486
+ // probe kept out of the pure diff — compute once, attach to every report so
1487
+ // both --json and the text verdict carry it.
1488
+ const sourceBehind = computeSourceBehind();
1489
+ for (const r of reports)
1490
+ r.sourceBehind = sourceBehind;
817
1491
  if (opts.json) {
818
- console.log(JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2));
1492
+ // Carry the triaged verdict (severity/category/subject/impact/fix per
1493
+ // issue) alongside the report — additive, so existing consumers reading
1494
+ // `summary`/`kinds`/`hookWiring`/`sourceBehind` are unaffected.
1495
+ const withVerdict = reports.map((r) => ({ ...r, verdict: computeVerdict(r) }));
1496
+ console.log(JSON.stringify(withVerdict.length === 1 ? withVerdict[0] : withVerdict, null, 2));
819
1497
  return;
820
1498
  }
821
1499
  const showDiff = !!opts.diff;