@phnx-labs/agents-cli 1.20.78 → 1.20.83

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 (183) hide show
  1. package/CHANGELOG.md +595 -0
  2. package/README.md +26 -26
  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 +66 -0
  12. package/dist/commands/doctor.js +596 -33
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +24 -5
  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/menubar.js +12 -0
  22. package/dist/commands/models.js +1 -1
  23. package/dist/commands/packages.js +6 -6
  24. package/dist/commands/permissions.js +1 -1
  25. package/dist/commands/profiles.js +1 -1
  26. package/dist/commands/pull.d.ts +1 -1
  27. package/dist/commands/pull.js +4 -4
  28. package/dist/commands/repo.d.ts +78 -0
  29. package/dist/commands/repo.js +187 -11
  30. package/dist/commands/routines.d.ts +8 -1
  31. package/dist/commands/routines.js +241 -77
  32. package/dist/commands/rules.js +13 -12
  33. package/dist/commands/secrets.d.ts +3 -1
  34. package/dist/commands/secrets.js +100 -32
  35. package/dist/commands/sessions-inject.d.ts +7 -4
  36. package/dist/commands/sessions-inject.js +18 -18
  37. package/dist/commands/sessions-picker.js +32 -3
  38. package/dist/commands/sessions-resume.d.ts +16 -0
  39. package/dist/commands/sessions-resume.js +14 -13
  40. package/dist/commands/sessions.d.ts +115 -0
  41. package/dist/commands/sessions.js +189 -35
  42. package/dist/commands/set.d.ts +15 -0
  43. package/dist/commands/set.js +79 -0
  44. package/dist/commands/setup-fleet.d.ts +21 -0
  45. package/dist/commands/setup-fleet.js +201 -0
  46. package/dist/commands/setup-secrets.d.ts +20 -0
  47. package/dist/commands/setup-secrets.js +223 -0
  48. package/dist/commands/setup.js +17 -3
  49. package/dist/commands/skills.js +1 -1
  50. package/dist/commands/sync.js +7 -2
  51. package/dist/commands/teams-picker.js +0 -1
  52. package/dist/commands/teams.d.ts +33 -1
  53. package/dist/commands/teams.js +208 -44
  54. package/dist/commands/versions.js +10 -5
  55. package/dist/commands/view.d.ts +1 -0
  56. package/dist/commands/view.js +11 -0
  57. package/dist/index.js +52 -8
  58. package/dist/lib/acp/harnesses.js +0 -7
  59. package/dist/lib/activity.d.ts +156 -0
  60. package/dist/lib/activity.js +282 -0
  61. package/dist/lib/agents.d.ts +9 -2
  62. package/dist/lib/agents.js +72 -63
  63. package/dist/lib/browser/service.js +3 -0
  64. package/dist/lib/browser/types.d.ts +7 -0
  65. package/dist/lib/capabilities.js +6 -2
  66. package/dist/lib/crabbox/lease.js +2 -2
  67. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  68. package/dist/lib/crabbox/setup-copy.js +4 -4
  69. package/dist/lib/daemon.d.ts +18 -0
  70. package/dist/lib/daemon.js +47 -6
  71. package/dist/lib/devices/fleet.d.ts +1 -1
  72. package/dist/lib/devices/fleet.js +1 -1
  73. package/dist/lib/doctor-diff.d.ts +20 -0
  74. package/dist/lib/doctor-diff.js +6 -1
  75. package/dist/lib/drift.d.ts +31 -11
  76. package/dist/lib/drift.js +58 -7
  77. package/dist/lib/events.d.ts +8 -1
  78. package/dist/lib/events.js +10 -1
  79. package/dist/lib/exec.d.ts +7 -0
  80. package/dist/lib/exec.js +69 -11
  81. package/dist/lib/feed-post.d.ts +28 -1
  82. package/dist/lib/feed-post.js +110 -2
  83. package/dist/lib/fleet/apply.js +8 -1
  84. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  85. package/dist/lib/fleet/auth-sync.js +25 -11
  86. package/dist/lib/fleet/remote-login.js +5 -0
  87. package/dist/lib/git.d.ts +14 -14
  88. package/dist/lib/git.js +41 -44
  89. package/dist/lib/hooks.d.ts +71 -0
  90. package/dist/lib/hooks.js +245 -95
  91. package/dist/lib/hosts/dispatch.d.ts +28 -1
  92. package/dist/lib/hosts/dispatch.js +33 -5
  93. package/dist/lib/hosts/option.js +2 -2
  94. package/dist/lib/hosts/passthrough.d.ts +22 -1
  95. package/dist/lib/hosts/passthrough.js +238 -13
  96. package/dist/lib/hosts/run-target.d.ts +2 -0
  97. package/dist/lib/hosts/run-target.js +1 -0
  98. package/dist/lib/hq/floor.d.ts +1 -1
  99. package/dist/lib/hq/floor.js +6 -1
  100. package/dist/lib/mcp.js +3 -71
  101. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  102. package/dist/lib/menubar/install-menubar.d.ts +24 -0
  103. package/dist/lib/menubar/install-menubar.js +52 -2
  104. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  105. package/dist/lib/menubar/notify-desktop.js +41 -5
  106. package/dist/lib/merged-resources.d.ts +11 -0
  107. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  108. package/dist/lib/permissions.d.ts +0 -32
  109. package/dist/lib/permissions.js +4 -157
  110. package/dist/lib/picker.js +1 -1
  111. package/dist/lib/platform/process.d.ts +16 -5
  112. package/dist/lib/platform/process.js +54 -0
  113. package/dist/lib/plugins.d.ts +0 -8
  114. package/dist/lib/plugins.js +4 -110
  115. package/dist/lib/project-resources.js +4 -1
  116. package/dist/lib/refresh.d.ts +5 -3
  117. package/dist/lib/refresh.js +7 -5
  118. package/dist/lib/resources/commands.js +4 -1
  119. package/dist/lib/resources/mcp.js +0 -4
  120. package/dist/lib/resources/permissions.d.ts +1 -1
  121. package/dist/lib/resources/permissions.js +1 -5
  122. package/dist/lib/resources/rules.js +4 -1
  123. package/dist/lib/resources/skills.js +4 -1
  124. package/dist/lib/resources/subagents.js +4 -0
  125. package/dist/lib/resources/types.d.ts +1 -1
  126. package/dist/lib/routines.d.ts +33 -0
  127. package/dist/lib/routines.js +122 -0
  128. package/dist/lib/run-defaults.d.ts +1 -0
  129. package/dist/lib/run-defaults.js +9 -0
  130. package/dist/lib/runner.d.ts +0 -12
  131. package/dist/lib/runner.js +49 -10
  132. package/dist/lib/scheduler.js +16 -4
  133. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  134. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  135. package/dist/lib/secrets/agent.d.ts +16 -0
  136. package/dist/lib/secrets/agent.js +105 -3
  137. package/dist/lib/secrets/audit.d.ts +46 -0
  138. package/dist/lib/secrets/audit.js +56 -0
  139. package/dist/lib/secrets/bundles.d.ts +11 -8
  140. package/dist/lib/secrets/bundles.js +31 -23
  141. package/dist/lib/secrets/index.d.ts +2 -1
  142. package/dist/lib/secrets/index.js +8 -2
  143. package/dist/lib/secrets/remote.d.ts +75 -2
  144. package/dist/lib/secrets/remote.js +139 -5
  145. package/dist/lib/secrets/sync.js +3 -3
  146. package/dist/lib/session/active.d.ts +128 -26
  147. package/dist/lib/session/active.js +192 -53
  148. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  149. package/dist/lib/session/actor-sidecar.js +101 -0
  150. package/dist/lib/session/db.d.ts +4 -1
  151. package/dist/lib/session/db.js +65 -8
  152. package/dist/lib/session/discover.d.ts +4 -0
  153. package/dist/lib/session/discover.js +13 -0
  154. package/dist/lib/session/hook-sessions.d.ts +9 -0
  155. package/dist/lib/session/hook-sessions.js +57 -8
  156. package/dist/lib/session/pid-registry.d.ts +13 -0
  157. package/dist/lib/session/render.d.ts +2 -0
  158. package/dist/lib/session/render.js +1 -1
  159. package/dist/lib/session/types.d.ts +16 -0
  160. package/dist/lib/staleness/detectors/commands.js +2 -2
  161. package/dist/lib/staleness/detectors/permissions.js +1 -45
  162. package/dist/lib/staleness/writers/commands.js +4 -5
  163. package/dist/lib/staleness/writers/hooks.js +1 -1
  164. package/dist/lib/startup/command-registry.d.ts +1 -2
  165. package/dist/lib/startup/command-registry.js +2 -4
  166. package/dist/lib/state.js +57 -1
  167. package/dist/lib/subagents-registry.js +5 -12
  168. package/dist/lib/subagents.d.ts +0 -10
  169. package/dist/lib/subagents.js +0 -12
  170. package/dist/lib/teams/agents.d.ts +0 -2
  171. package/dist/lib/teams/agents.js +10 -27
  172. package/dist/lib/teams/index.d.ts +1 -1
  173. package/dist/lib/teams/index.js +1 -1
  174. package/dist/lib/types.d.ts +17 -9
  175. package/dist/lib/usage.d.ts +28 -5
  176. package/dist/lib/usage.js +271 -8
  177. package/dist/lib/versions.js +7 -1
  178. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  179. package/dist/lib/watchdog/watchdog.js +25 -13
  180. package/package.json +1 -2
  181. package/dist/commands/check.d.ts +0 -15
  182. package/dist/commands/check.js +0 -180
  183. package/dist/commands/resources.d.ts +0 -5
@@ -3,7 +3,8 @@ 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';
7
8
  import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
8
9
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
9
10
  import { resolveHost } from '../lib/hosts/registry.js';
@@ -17,7 +18,9 @@ import { formatSignInBadge } from '../lib/signin-badge.js';
17
18
  import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
18
19
  import { loadManifest, isStale } from '../lib/staleness/index.js';
19
20
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
20
- import { checkSyncStatus, countOrphans } from '../lib/drift.js';
21
+ import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings } from '../lib/hooks.js';
22
+ import { isVersionIsolated } from '../lib/versions.js';
23
+ import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
21
24
  import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
22
25
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
23
26
  import { listCliStatus } from '../lib/cli-resources.js';
@@ -64,7 +67,19 @@ function printWrappedLine(prefix, text) {
64
67
  for (const line of wrapLine(prefix, text))
65
68
  console.log(chalk.gray(line));
66
69
  }
67
- function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers) {
70
+ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks) {
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, duplicateHooks);
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();
68
83
  console.log(chalk.bold('Agent CLIs'));
69
84
  // Show the fleet you actually run — agents that are ready in PATH, plus any
70
85
  // you MANAGE (have installed versions) whose binary isn't resolving (a real
@@ -103,20 +118,26 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBe
103
118
  for (const row of syncRows) {
104
119
  const tag = row.isDefault ? chalk.gray(' (default)') : '';
105
120
  const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
106
- if (row.status === 'fresh') {
121
+ const unwired = (row.unwiredHooks ?? 0) > 0;
122
+ if (row.status === 'fresh' && !unwired) {
107
123
  console.log(` ${chalk.green('fresh')} ${label}`);
124
+ continue;
108
125
  }
109
- else if (row.status === 'stale') {
110
- anyOutOfSync = true;
126
+ anyOutOfSync = true;
127
+ if (row.status === 'stale') {
111
128
  console.log(` ${chalk.yellow('stale')} ${label} ${chalk.gray('— sources changed since last sync')}`);
112
- for (const line of row.divergence ?? []) {
113
- console.log(chalk.gray(` ${line}`));
114
- }
115
129
  }
116
- else {
117
- anyOutOfSync = true;
130
+ else if (row.status === 'never-synced') {
118
131
  console.log(` ${chalk.gray('cold ')} ${label} ${chalk.gray('— never synced')}`);
119
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
+ }
120
141
  }
121
142
  // Launching does NOT reconcile a version home — the shim hot path only
122
143
  // resolves a version and compiles project-scoped resources (shims.ts v15/v16).
@@ -260,7 +281,7 @@ function renderExecPolicyAdvisory() {
260
281
  console.log(chalk.gray(` ${line}`));
261
282
  }
262
283
  }
263
- const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'gemini', 'droid'];
284
+ const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'droid'];
264
285
  function shortAgentHeader(name) {
265
286
  return name.slice(0, 4).padEnd(4);
266
287
  }
@@ -619,6 +640,301 @@ function readExpectedForDiff(kind, row) {
619
640
  return null;
620
641
  return safeRead(row.sourcePath);
621
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, duplicateHooks = []) {
831
+ const issues = [];
832
+ const pretty = (agent, version) => `${AGENT_NAMES[agent] || agent}@${version}`;
833
+ // critical/warning: same hook resource materialized in several version homes.
834
+ // Different content is more severe because a stale copy can disagree with
835
+ // the active gate; byte-identical copies are noise and duplicate runtime cost.
836
+ for (const finding of duplicateHooks) {
837
+ const versions = finding.copies.map((copy) => copy.version).join(', ');
838
+ const active = finding.authoritative.version;
839
+ const drift = finding.kind === 'drift';
840
+ issues.push({
841
+ severity: drift ? 'critical' : 'warning',
842
+ category: drift ? 'duplicate-hook-drift' : 'duplicate-hook',
843
+ subject: `${finding.agent}/${finding.name}`,
844
+ impact: `${drift ? 'different content' : 'identical content'} across versions ${versions}; ${active} is authoritative`,
845
+ fix: `agents sync ${finding.agent}@${active} --yes`,
846
+ text: `${finding.name} ${drift ? 'drift' : 'duplicated'} across ${versions}`,
847
+ color: drift ? 'red' : 'yellow',
848
+ });
849
+ }
850
+ // critical: unwired hooks / broken settings.json per version
851
+ for (const row of syncRows) {
852
+ const n = row.unwiredHooks ?? 0;
853
+ if (n <= 0)
854
+ continue;
855
+ const label = pretty(row.agent, row.version);
856
+ issues.push({
857
+ severity: 'critical', category: 'unwired-hook', subject: label,
858
+ impact: `${n} hook${n === 1 ? '' : 's'} present on disk but not wired into settings.json; never fire`,
859
+ fix: `agents sync ${row.agent}@${row.version} --yes`,
860
+ text: `${label} ${n} unwired`, color: 'red',
861
+ });
862
+ }
863
+ // warning: source layers behind origin
864
+ for (const m of repoBehindMarkers) {
865
+ if (m.behind <= 0)
866
+ continue;
867
+ const label = m.alias === 'user' ? '~/.agents' : m.alias;
868
+ issues.push({
869
+ severity: 'warning', category: 'source-behind', subject: label,
870
+ impact: `${m.behind} commit${m.behind === 1 ? '' : 's'} behind ${m.branch}; you're running stale config`,
871
+ fix: `agents repo pull ${m.alias}`,
872
+ text: `${label} ${m.behind} behind`, color: 'yellow',
873
+ });
874
+ }
875
+ // warning: stale / never-synced versions
876
+ for (const row of syncRows) {
877
+ const label = pretty(row.agent, row.version);
878
+ if (row.status === 'stale') {
879
+ issues.push({
880
+ severity: 'warning', category: 'stale', subject: label,
881
+ impact: 'sources changed since last sync',
882
+ fix: `agents doctor ${row.agent}@${row.version} --fix`,
883
+ text: `${label} stale`, color: 'yellow',
884
+ });
885
+ }
886
+ else if (row.status === 'never-synced') {
887
+ issues.push({
888
+ severity: 'warning', category: 'never-synced', subject: label,
889
+ impact: 'installed but never synced',
890
+ fix: `agents sync ${row.agent}@${row.version} --yes`,
891
+ text: `${label} never-synced`, color: 'yellow',
892
+ });
893
+ }
894
+ }
895
+ // info: orphan resources per version
896
+ for (const row of orphanRows) {
897
+ const parts = [];
898
+ if (row.commands)
899
+ parts.push(`${row.commands} command${row.commands === 1 ? '' : 's'}`);
900
+ if (row.skills)
901
+ parts.push(`${row.skills} skill${row.skills === 1 ? '' : 's'}`);
902
+ if (row.hooks)
903
+ parts.push(`${row.hooks} hook${row.hooks === 1 ? '' : 's'}`);
904
+ const label = pretty(row.agent, row.version);
905
+ issues.push({
906
+ severity: 'info', category: 'orphan', subject: label,
907
+ impact: `${parts.join(', ')} in the version home with no source`,
908
+ fix: 'agents prune cleanup',
909
+ text: `${label} orphan`, color: 'magenta',
910
+ });
911
+ }
912
+ const reconciled = syncRows.filter((r) => r.status === 'fresh' && (r.unwiredHooks ?? 0) === 0).length;
913
+ return { healthy: issues.length === 0, issues, reconciled };
914
+ }
915
+ /**
916
+ * Render the UNWIRED hook rows (and settings.json problems) inside the hooks
917
+ * section, in the same row style as the reconcile rows above them.
918
+ */
919
+ function renderHookWiringRows(w) {
920
+ if (!w.supported)
921
+ return;
922
+ if (w.settingsMissing) {
923
+ const where = w.settingsPath ? ` at ${w.settingsPath}` : '';
924
+ console.log(` ${chalk.red('UNWIRED')} ${chalk.gray(`settings.json not found${where} — ${w.expected ?? 0} declared hook(s) never fire`)}`);
925
+ return;
926
+ }
927
+ if (w.settingsUnparseable) {
928
+ const where = w.settingsPath ? ` at ${w.settingsPath}` : '';
929
+ console.log(` ${chalk.red('UNWIRED')} ${chalk.gray(`settings.json unparseable${where} — wiring can't be verified`)}`);
930
+ return;
931
+ }
932
+ for (const u of w.unwired) {
933
+ const name = padToWidth(truncateToWidth(u.name, 28), 28);
934
+ const scope = u.matcher ? `event=${u.event} matcher=${u.matcher}` : `event=${u.event}`;
935
+ console.log(` ${chalk.red('UNWIRED')} ${name} ${chalk.gray(scope)}`);
936
+ }
937
+ }
622
938
  function renderTargetText(report, options) {
623
939
  const label = `${AGENT_NAMES[report.agent] || report.agent}@${report.version}`;
624
940
  console.log(chalk.bold(label));
@@ -660,23 +976,22 @@ function renderTargetText(report, options) {
660
976
  if (options.requestedKinds && !options.requestedKinds.has(kind))
661
977
  continue;
662
978
  renderKindSection(kind, rows, report.layers, options);
979
+ // A hook file can reconcile "ok" above yet be absent from settings.json — a
980
+ // present-but-dead hook. Surface that right under the hooks section.
981
+ if (kind === 'hooks' && report.hookWiring)
982
+ renderHookWiringRows(report.hookWiring);
663
983
  }
664
984
  console.log();
665
- const { ok, diff, missing, extra } = report.summary;
666
- const verdictParts = [];
667
- if (diff)
668
- verdictParts.push(chalk.yellow(`${diff} divergent`));
669
- if (missing)
670
- verdictParts.push(chalk.red(`${missing} missing`));
671
- if (extra)
672
- verdictParts.push(chalk.magenta(`${extra} extra`));
673
- if (verdictParts.length === 0) {
674
- console.log(chalk.green(` Verdict: ${ok} resource${ok === 1 ? '' : 's'} reconciled. Version home matches resolved sources.`));
675
- }
676
- else {
677
- console.log(` Verdict: ${verdictParts.join(', ')}.`);
678
- printWrappedLine(' ', `Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`);
679
- }
985
+ const verdict = computeVerdict(report);
986
+ // A source layer behind origin is healed by `agents repo pull`, not `--fix` —
987
+ // the per-issue fix already names the right command, so the heal footer only
988
+ // shows when something is genuinely `--fix`-able (never in the source-behind or
989
+ // orphan-only case, where it would mislead).
990
+ const hooksWired = report.hookWiring?.supported ? ' · hooks wired' : '';
991
+ renderHealthBlock(verdict, {
992
+ healthySummary: `${verdict.reconciled} resource${verdict.reconciled === 1 ? '' : 's'} reconciled${hooksWired} · sources current`,
993
+ healFix: verdictIsAutoFixable(verdict) ? `agents doctor ${report.agent}@${report.version} --fix` : undefined,
994
+ });
680
995
  }
681
996
  // ─── fix / heal mode ───────────────────────────────────────────────────────────
682
997
  function renderHealText(result) {
@@ -730,6 +1045,53 @@ function renderHealText(result) {
730
1045
  console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
731
1046
  }
732
1047
  }
1048
+ /**
1049
+ * Re-wire hooks that reconcile as files but are absent from settings.json.
1050
+ *
1051
+ * heal() only re-syncs resources the diff flags missing/diff; a hook whose file
1052
+ * is byte-identical to source but never referenced in settings.json is neither,
1053
+ * so heal walks past it. registerHooksToSettings (the same call `agents sync`
1054
+ * makes at versions.ts) regenerates the wiring, so run it for any Claude-family
1055
+ * version this fix targets that has unwired hooks. Only claude/droid — the set
1056
+ * checkVersionHookWiring can verify.
1057
+ */
1058
+ function rewireUnwiredHooks(parsed) {
1059
+ const out = [];
1060
+ const agents = parsed?.agent
1061
+ ? (HOOK_WIRING_FIX_AGENTS.includes(parsed.agent) ? [parsed.agent] : [])
1062
+ : HOOK_WIRING_FIX_AGENTS;
1063
+ for (const agent of agents) {
1064
+ // A named version is explicit consent; a sweep excludes isolated copies,
1065
+ // mirroring heal().
1066
+ const versions = parsed?.agent && parsed.versionExplicit
1067
+ ? parsed.versions
1068
+ : listInstalledVersions(agent).filter((v) => !isVersionIsolated(agent, v));
1069
+ for (const version of versions) {
1070
+ const before = checkVersionHookWiring(agent, version);
1071
+ if (!before.supported)
1072
+ continue;
1073
+ const need = before.unwired.length + (before.settingsMissing ? (before.expected ?? 0) : 0);
1074
+ if (need === 0)
1075
+ continue;
1076
+ registerHooksToSettings(agent, getVersionHomePath(agent, version));
1077
+ const after = checkVersionHookWiring(agent, version);
1078
+ const remaining = after.unwired.length + (after.settingsMissing ? (after.expected ?? 0) : 0);
1079
+ out.push({ agent, version, rewired: Math.max(0, need - remaining), remaining });
1080
+ }
1081
+ }
1082
+ return out;
1083
+ }
1084
+ function renderHookRewireText(rewired) {
1085
+ for (const r of rewired) {
1086
+ const label = `${AGENT_NAMES[r.agent] || r.agent}@${r.version}`;
1087
+ if (r.remaining === 0) {
1088
+ console.log(` ${chalk.green('rewired')} ${label} ${chalk.gray(`${r.rewired} hook${r.rewired === 1 ? '' : 's'} wired into settings.json`)}`);
1089
+ }
1090
+ else {
1091
+ 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\``)}`);
1092
+ }
1093
+ }
1094
+ }
733
1095
  async function runFix(parsed, opts) {
734
1096
  // Heal targets the global install — project layer is irrelevant, so cwd is
735
1097
  // left to heal's neutral default rather than process.cwd().
@@ -744,11 +1106,170 @@ async function runFix(parsed, opts) {
744
1106
  agent: parsed?.agent,
745
1107
  versions: parsed?.versionExplicit ? parsed.versions : undefined,
746
1108
  });
1109
+ // Re-wire hooks the diff-driven heal leaves behind (present file, not wired).
1110
+ const rewired = rewireUnwiredHooks(parsed);
747
1111
  if (opts.json) {
748
- console.log(JSON.stringify(result, null, 2));
1112
+ console.log(JSON.stringify({ ...result, hookRewire: rewired }, null, 2));
749
1113
  return;
750
1114
  }
751
1115
  renderHealText(result);
1116
+ renderHookRewireText(rewired);
1117
+ }
1118
+ function checkLabel(row) {
1119
+ return `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
1120
+ }
1121
+ function runCheckGate(opts, cwd) {
1122
+ const drift = computeDrift(cwd);
1123
+ if (opts.json) {
1124
+ console.log(JSON.stringify({
1125
+ hasDrift: drift.hasDrift,
1126
+ stale: drift.staleCount,
1127
+ neverSynced: drift.neverSyncedCount,
1128
+ unwiredHookVersions: drift.unwiredHookVersions,
1129
+ orphanVersions: drift.orphanVersionCount,
1130
+ sourceBehind: drift.sourceBehind,
1131
+ versions: drift.syncRows.map((r) => ({
1132
+ agent: r.agent,
1133
+ version: r.version,
1134
+ status: r.status,
1135
+ isDefault: r.isDefault,
1136
+ unwiredHooks: r.unwiredHooks ?? 0,
1137
+ divergence: r.divergence ?? [],
1138
+ })),
1139
+ }, null, 2));
1140
+ process.exit(drift.hasDrift ? 1 : 0);
1141
+ }
1142
+ if (drift.syncRows.length === 0) {
1143
+ // Nothing installed is a clean state, not a failure — CI on a fresh
1144
+ // checkout with no versions should pass, not error.
1145
+ console.log(chalk.gray('check: no installed versions — nothing to verify'));
1146
+ process.exit(0);
1147
+ }
1148
+ if (!drift.hasDrift) {
1149
+ const orphanNote = drift.orphanVersionCount > 0
1150
+ ? chalk.gray(` (${drift.orphanVersionCount} version(s) carry orphans — run \`agents prune cleanup\`)`)
1151
+ : '';
1152
+ console.log(`${chalk.green('ok')} ${drift.syncRows.length} version(s) in sync${orphanNote}`);
1153
+ process.exit(0);
1154
+ }
1155
+ // Drift: one-line verdict always, per-version detail unless --quiet.
1156
+ const parts = [];
1157
+ if (drift.staleCount > 0)
1158
+ parts.push(`${drift.staleCount} stale`);
1159
+ if (drift.neverSyncedCount > 0)
1160
+ parts.push(`${drift.neverSyncedCount} never-synced`);
1161
+ if (drift.unwiredHookVersions > 0)
1162
+ parts.push(`${drift.unwiredHookVersions} with unwired hooks`);
1163
+ if (drift.sourceBehind.length > 0)
1164
+ parts.push(`${drift.sourceBehind.length} source layer(s) behind origin`);
1165
+ console.error(`${chalk.red('drift')} ${parts.join(', ')}`);
1166
+ if (!opts.quiet) {
1167
+ for (const row of drift.syncRows) {
1168
+ const unwired = (row.unwiredHooks ?? 0) > 0;
1169
+ if (row.status === 'fresh' && !unwired)
1170
+ continue;
1171
+ const tag = row.status === 'stale' ? chalk.yellow('stale')
1172
+ : row.status === 'never-synced' ? chalk.gray('cold ')
1173
+ : chalk.red('unwired'); // fresh but hooks not wired into settings.json
1174
+ console.error(` ${tag} ${checkLabel(row)}`);
1175
+ for (const line of row.divergence ?? []) {
1176
+ console.error(chalk.gray(` ${line}`));
1177
+ }
1178
+ }
1179
+ for (const b of drift.sourceBehind) {
1180
+ console.error(` ${chalk.red('behind')} source ${b.label} ${chalk.gray(`${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}`)}`);
1181
+ }
1182
+ const hints = [];
1183
+ if (drift.staleCount > 0 || drift.neverSyncedCount > 0 || drift.unwiredHookVersions > 0) {
1184
+ hints.push('`agents doctor --fix` (or `agents doctor <agent>@<version> --fix`)');
1185
+ }
1186
+ if (drift.sourceBehind.length > 0)
1187
+ hints.push('`agents repo pull <alias>` for a source layer behind origin');
1188
+ console.error(chalk.gray(`\nReconcile with ${hints.join('; ')}.`));
1189
+ }
1190
+ process.exit(1);
1191
+ }
1192
+ function checkPayload(device, drift) {
1193
+ return {
1194
+ device,
1195
+ hasDrift: drift.hasDrift,
1196
+ stale: drift.staleCount,
1197
+ neverSynced: drift.neverSyncedCount,
1198
+ orphanVersions: drift.orphanVersionCount,
1199
+ };
1200
+ }
1201
+ async function probeDeviceCheck(target) {
1202
+ const isWin = /^win/i.test((target.platform ?? '').trim());
1203
+ const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
1204
+ const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
1205
+ if (res.code !== 0 && !res.stdout.trim()) {
1206
+ throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
1207
+ }
1208
+ try {
1209
+ const parsed = JSON.parse(res.stdout);
1210
+ return {
1211
+ device: target.name,
1212
+ hasDrift: Boolean(parsed.hasDrift),
1213
+ stale: parsed.stale ?? 0,
1214
+ neverSynced: parsed.neverSynced ?? 0,
1215
+ orphanVersions: parsed.orphanVersions ?? 0,
1216
+ };
1217
+ }
1218
+ catch (err) {
1219
+ throw new Error(`invalid JSON (${err?.message ?? 'parse error'})`);
1220
+ }
1221
+ }
1222
+ async function runDevicesCheck(opts, cwd) {
1223
+ const registry = await loadDevices();
1224
+ const self = machineId();
1225
+ const planned = planFleetTargets(registry);
1226
+ const local = checkPayload(self, computeDrift(cwd));
1227
+ const remoteTargets = remoteFleetTargets(planned, self)
1228
+ .map((t) => ({
1229
+ name: t.device.name,
1230
+ platform: t.device.platform,
1231
+ skip: t.skip,
1232
+ dialTarget: fleetDialTarget(t.device),
1233
+ }));
1234
+ const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
1235
+ const devices = [local];
1236
+ for (const result of remote) {
1237
+ if (result.status === 'ok' && result.value) {
1238
+ devices.push(result.value);
1239
+ }
1240
+ else {
1241
+ devices.push({
1242
+ device: result.name,
1243
+ hasDrift: true,
1244
+ stale: 0,
1245
+ neverSynced: 0,
1246
+ orphanVersions: 0,
1247
+ error: result.error ?? String(result.reason ?? 'skipped'),
1248
+ });
1249
+ }
1250
+ }
1251
+ const hasDrift = devices.some((d) => d.hasDrift || d.error);
1252
+ if (opts.json) {
1253
+ console.log(JSON.stringify({ hasDrift, devices }, null, 2));
1254
+ process.exit(hasDrift ? 1 : 0);
1255
+ }
1256
+ if (!hasDrift) {
1257
+ console.log(chalk.green('ok') + chalk.gray(` ${devices.length} device(s) in sync`));
1258
+ process.exit(0);
1259
+ }
1260
+ console.error(chalk.red('drift') + chalk.gray(` ${devices.filter((d) => d.hasDrift || d.error).length} of ${devices.length} device(s)`));
1261
+ if (!opts.quiet) {
1262
+ for (const d of devices) {
1263
+ if (!d.hasDrift && !d.error)
1264
+ continue;
1265
+ const detail = d.error
1266
+ ? d.error
1267
+ : [`${d.stale} stale`, `${d.neverSynced} never-synced`].filter((p) => !p.startsWith('0 ')).join(', ');
1268
+ console.error(` ${chalk.yellow(d.device.padEnd(18))} ${detail || 'drift'}`);
1269
+ }
1270
+ console.error(chalk.gray('\nReconcile each device with `agents doctor --fix` or `agents repo pull user`.'));
1271
+ }
1272
+ process.exit(1);
752
1273
  }
753
1274
  // ─── command registration ────────────────────────────────────────────────────
754
1275
  export function registerDoctorCommand(program) {
@@ -762,7 +1283,9 @@ export function registerDoctorCommand(program) {
762
1283
  .option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
763
1284
  .option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted')
764
1285
  .option('--devices', 'Check agent readiness AND cross-device harness divergence (missing resources/versions, repo drift) on every registered device (alias --hosts)')
765
- .option('--hosts', 'Alias of --devices');
1286
+ .option('--hosts', 'Alias of --devices')
1287
+ .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.')
1288
+ .option('-q, --quiet', 'With --check, suppress per-version lines; print only the one-line verdict');
766
1289
  setHelpSections(doctorCmd, {
767
1290
  examples: `
768
1291
  # Overview: CLI availability + sync status + orphans across all defaults
@@ -772,7 +1295,7 @@ export function registerDoctorCommand(program) {
772
1295
  agents doctor claude@default
773
1296
 
774
1297
  # All installed versions of one agent
775
- agents doctor gemini
1298
+ agents doctor antigravity
776
1299
 
777
1300
  # Pin to a specific installed version
778
1301
  agents doctor codex@0.117.0
@@ -789,10 +1312,33 @@ export function registerDoctorCommand(program) {
789
1312
  # Fleet: agent readiness + cross-device divergence (missing plugins/skills,
790
1313
  # agent-version gaps, .agents/.system repo drift) vs this machine
791
1314
  agents doctor --devices
1315
+
1316
+ # CI drift gate: exit 1 if anything drifted (stale/never-synced), 0 if clean
1317
+ agents doctor --check
1318
+ agents doctor --check --quiet # just the verdict line
1319
+ agents doctor --check --json # machine-readable, for scripting
1320
+ agents doctor --check --devices # gate every registered device
1321
+ agents doctor --check || { echo "resources drifted — run 'agents doctor --fix'"; exit 1; }
792
1322
  `,
793
1323
  });
794
1324
  doctorCmd.action(async (target, opts) => {
795
1325
  const cwd = opts.cwd ? opts.cwd : process.cwd();
1326
+ // CI drift gate. Kept BEFORE the --devices branch so `doctor --check
1327
+ // --devices` routes to the drift gate fan-out (runDevicesCheck), while a
1328
+ // bare `doctor --devices` still routes to the readiness matrix below.
1329
+ if (opts.check) {
1330
+ if (target) {
1331
+ console.error(chalk.red('Cannot combine --check with a target argument.'));
1332
+ process.exit(1);
1333
+ }
1334
+ if (opts.devices || opts.hosts) {
1335
+ await runDevicesCheck(opts, cwd);
1336
+ }
1337
+ else {
1338
+ runCheckGate(opts, cwd);
1339
+ }
1340
+ return;
1341
+ }
796
1342
  if (opts.devices || opts.hosts) {
797
1343
  if (target) {
798
1344
  console.error(chalk.red('Cannot combine --devices with a target argument.'));
@@ -877,6 +1423,7 @@ export function registerDoctorCommand(program) {
877
1423
  const orphanRows = countOrphans();
878
1424
  const hostClis = listCliStatus(cwd);
879
1425
  const repoBehindMarkers = readRepoBehindMarkers();
1426
+ const duplicateHooks = inspectDuplicateVersionHooks(cwd);
880
1427
  // Advisory login state per installed agent (file-based getAccountInfo,
881
1428
  // no home → the account-global/active credential). Best-effort: a probe
882
1429
  // failure just leaves that agent's badge as "logged out".
@@ -901,6 +1448,11 @@ export function registerDoctorCommand(program) {
901
1448
  auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
902
1449
  sync: syncRows,
903
1450
  orphans: orphanRows,
1451
+ // Triaged overview health — severity/category/subject/impact/fix per
1452
+ // finding, aggregated across versions. Additive; existing consumers
1453
+ // reading `sync`/`orphans`/`repos` are unaffected.
1454
+ health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks),
1455
+ duplicateHooks,
904
1456
  // This host's harness inventory — installed resources per kind,
905
1457
  // installed version ids per agent, and `.agents`/`.system` repo
906
1458
  // state — so `agents doctor --devices` can compare presence across
@@ -928,11 +1480,12 @@ export function registerDoctorCommand(program) {
928
1480
  }, null, 2));
929
1481
  return;
930
1482
  }
931
- renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers);
1483
+ renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks);
932
1484
  // Point at the interactive reconcile when anything is out of sync — the
933
1485
  // report shouldn't be a dead end. `agents status` runs the unified
934
1486
  // home-reading engine and offers to sync (opt-in, never auto-fires here).
935
- if (syncRows.some((r) => r.status !== 'fresh')) {
1487
+ // Unwired hooks and a behind-origin repo count as out-of-sync too.
1488
+ if (syncRows.some((r) => r.status !== 'fresh' || (r.unwiredHooks ?? 0) > 0) || repoBehindMarkers.some((m) => m.behind > 0)) {
936
1489
  console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
937
1490
  }
938
1491
  return;
@@ -948,8 +1501,18 @@ export function registerDoctorCommand(program) {
948
1501
  process.exit(1);
949
1502
  }
950
1503
  const reports = parsed.versions.map((v) => diffVersionResources(parsed.agent, v, { cwd, kinds }));
1504
+ // Source-layer staleness is global (same across versions) and needs a git
1505
+ // probe kept out of the pure diff — compute once, attach to every report so
1506
+ // both --json and the text verdict carry it.
1507
+ const sourceBehind = computeSourceBehind();
1508
+ for (const r of reports)
1509
+ r.sourceBehind = sourceBehind;
951
1510
  if (opts.json) {
952
- console.log(JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2));
1511
+ // Carry the triaged verdict (severity/category/subject/impact/fix per
1512
+ // issue) alongside the report — additive, so existing consumers reading
1513
+ // `summary`/`kinds`/`hookWiring`/`sourceBehind` are unaffected.
1514
+ const withVerdict = reports.map((r) => ({ ...r, verdict: computeVerdict(r) }));
1515
+ console.log(JSON.stringify(withVerdict.length === 1 ? withVerdict[0] : withVerdict, null, 2));
953
1516
  return;
954
1517
  }
955
1518
  const showDiff = !!opts.diff;