@phnx-labs/agents-cli 1.20.78 → 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 (176) hide show
  1. package/CHANGELOG.md +487 -0
  2. package/README.md +25 -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 +65 -0
  12. package/dist/commands/doctor.js +575 -31
  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/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 +187 -11
  29. package/dist/commands/routines.js +12 -2
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +100 -32
  33. package/dist/commands/sessions-inject.d.ts +7 -4
  34. package/dist/commands/sessions-inject.js +18 -18
  35. package/dist/commands/sessions-picker.js +32 -3
  36. package/dist/commands/sessions-resume.d.ts +16 -0
  37. package/dist/commands/sessions-resume.js +14 -13
  38. package/dist/commands/sessions.d.ts +31 -0
  39. package/dist/commands/sessions.js +141 -16
  40. package/dist/commands/set.d.ts +15 -0
  41. package/dist/commands/set.js +79 -0
  42. package/dist/commands/setup-fleet.d.ts +21 -0
  43. package/dist/commands/setup-fleet.js +201 -0
  44. package/dist/commands/setup-secrets.d.ts +20 -0
  45. package/dist/commands/setup-secrets.js +223 -0
  46. package/dist/commands/setup.js +17 -3
  47. package/dist/commands/skills.js +1 -1
  48. package/dist/commands/sync.js +7 -2
  49. package/dist/commands/teams-picker.js +0 -1
  50. package/dist/commands/teams.d.ts +33 -1
  51. package/dist/commands/teams.js +208 -44
  52. package/dist/commands/versions.js +10 -5
  53. package/dist/commands/view.d.ts +1 -0
  54. package/dist/commands/view.js +11 -0
  55. package/dist/index.js +5 -8
  56. package/dist/lib/acp/harnesses.js +0 -7
  57. package/dist/lib/activity.d.ts +156 -0
  58. package/dist/lib/activity.js +282 -0
  59. package/dist/lib/agents.d.ts +9 -2
  60. package/dist/lib/agents.js +72 -63
  61. package/dist/lib/browser/service.js +3 -0
  62. package/dist/lib/browser/types.d.ts +7 -0
  63. package/dist/lib/capabilities.js +6 -2
  64. package/dist/lib/crabbox/lease.js +2 -2
  65. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  66. package/dist/lib/crabbox/setup-copy.js +4 -4
  67. package/dist/lib/daemon.d.ts +18 -0
  68. package/dist/lib/daemon.js +47 -6
  69. package/dist/lib/devices/fleet.d.ts +1 -1
  70. package/dist/lib/devices/fleet.js +1 -1
  71. package/dist/lib/doctor-diff.d.ts +20 -0
  72. package/dist/lib/doctor-diff.js +6 -1
  73. package/dist/lib/drift.d.ts +31 -11
  74. package/dist/lib/drift.js +58 -7
  75. package/dist/lib/events.d.ts +8 -1
  76. package/dist/lib/events.js +10 -1
  77. package/dist/lib/exec.js +35 -8
  78. package/dist/lib/feed-post.d.ts +28 -1
  79. package/dist/lib/feed-post.js +110 -2
  80. package/dist/lib/fleet/apply.js +8 -1
  81. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  82. package/dist/lib/fleet/auth-sync.js +25 -11
  83. package/dist/lib/fleet/remote-login.js +5 -0
  84. package/dist/lib/git.d.ts +14 -14
  85. package/dist/lib/git.js +41 -44
  86. package/dist/lib/hooks.d.ts +42 -0
  87. package/dist/lib/hooks.js +137 -93
  88. package/dist/lib/hosts/dispatch.d.ts +28 -1
  89. package/dist/lib/hosts/dispatch.js +33 -5
  90. package/dist/lib/hosts/option.js +2 -2
  91. package/dist/lib/hosts/passthrough.d.ts +22 -1
  92. package/dist/lib/hosts/passthrough.js +238 -13
  93. package/dist/lib/hosts/run-target.d.ts +2 -0
  94. package/dist/lib/hosts/run-target.js +1 -0
  95. package/dist/lib/hq/floor.d.ts +1 -1
  96. package/dist/lib/hq/floor.js +6 -1
  97. package/dist/lib/mcp.js +3 -71
  98. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  99. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  100. package/dist/lib/menubar/notify-desktop.js +41 -5
  101. package/dist/lib/merged-resources.d.ts +11 -0
  102. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  103. package/dist/lib/permissions.d.ts +0 -32
  104. package/dist/lib/permissions.js +4 -157
  105. package/dist/lib/picker.js +1 -1
  106. package/dist/lib/platform/process.d.ts +16 -5
  107. package/dist/lib/platform/process.js +54 -0
  108. package/dist/lib/plugins.d.ts +0 -8
  109. package/dist/lib/plugins.js +4 -110
  110. package/dist/lib/project-resources.js +4 -1
  111. package/dist/lib/refresh.d.ts +5 -3
  112. package/dist/lib/refresh.js +7 -5
  113. package/dist/lib/resources/commands.js +4 -1
  114. package/dist/lib/resources/mcp.js +0 -4
  115. package/dist/lib/resources/permissions.d.ts +1 -1
  116. package/dist/lib/resources/permissions.js +1 -5
  117. package/dist/lib/resources/rules.js +4 -1
  118. package/dist/lib/resources/skills.js +4 -1
  119. package/dist/lib/resources/subagents.js +4 -0
  120. package/dist/lib/resources/types.d.ts +1 -1
  121. package/dist/lib/routines.d.ts +20 -0
  122. package/dist/lib/routines.js +6 -0
  123. package/dist/lib/run-defaults.d.ts +1 -0
  124. package/dist/lib/run-defaults.js +9 -0
  125. package/dist/lib/runner.d.ts +0 -12
  126. package/dist/lib/runner.js +49 -10
  127. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  129. package/dist/lib/secrets/agent.d.ts +16 -0
  130. package/dist/lib/secrets/agent.js +105 -3
  131. package/dist/lib/secrets/audit.d.ts +46 -0
  132. package/dist/lib/secrets/audit.js +56 -0
  133. package/dist/lib/secrets/bundles.d.ts +11 -8
  134. package/dist/lib/secrets/bundles.js +31 -23
  135. package/dist/lib/secrets/index.d.ts +2 -1
  136. package/dist/lib/secrets/index.js +8 -2
  137. package/dist/lib/secrets/remote.d.ts +75 -2
  138. package/dist/lib/secrets/remote.js +139 -5
  139. package/dist/lib/secrets/sync.js +3 -3
  140. package/dist/lib/session/active.d.ts +110 -26
  141. package/dist/lib/session/active.js +161 -50
  142. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  143. package/dist/lib/session/actor-sidecar.js +101 -0
  144. package/dist/lib/session/db.d.ts +4 -1
  145. package/dist/lib/session/db.js +65 -8
  146. package/dist/lib/session/discover.d.ts +4 -0
  147. package/dist/lib/session/discover.js +13 -0
  148. package/dist/lib/session/hook-sessions.d.ts +9 -0
  149. package/dist/lib/session/hook-sessions.js +57 -8
  150. package/dist/lib/session/pid-registry.d.ts +13 -0
  151. package/dist/lib/session/render.d.ts +2 -0
  152. package/dist/lib/session/render.js +1 -1
  153. package/dist/lib/session/types.d.ts +16 -0
  154. package/dist/lib/staleness/detectors/commands.js +2 -2
  155. package/dist/lib/staleness/detectors/permissions.js +1 -45
  156. package/dist/lib/staleness/writers/commands.js +4 -5
  157. package/dist/lib/staleness/writers/hooks.js +1 -1
  158. package/dist/lib/startup/command-registry.d.ts +1 -2
  159. package/dist/lib/startup/command-registry.js +2 -4
  160. package/dist/lib/subagents-registry.js +5 -12
  161. package/dist/lib/subagents.d.ts +0 -10
  162. package/dist/lib/subagents.js +0 -12
  163. package/dist/lib/teams/agents.d.ts +0 -2
  164. package/dist/lib/teams/agents.js +10 -27
  165. package/dist/lib/teams/index.d.ts +1 -1
  166. package/dist/lib/teams/index.js +1 -1
  167. package/dist/lib/types.d.ts +17 -9
  168. package/dist/lib/usage.d.ts +28 -5
  169. package/dist/lib/usage.js +271 -8
  170. package/dist/lib/versions.js +7 -1
  171. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  172. package/dist/lib/watchdog/watchdog.js +25 -13
  173. package/package.json +1 -2
  174. package/dist/commands/check.d.ts +0 -15
  175. package/dist/commands/check.js +0 -180
  176. 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, 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';
@@ -65,6 +68,18 @@ function printWrappedLine(prefix, text) {
65
68
  console.log(chalk.gray(line));
66
69
  }
67
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();
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,284 @@ 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) {
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
+ }
622
921
  function renderTargetText(report, options) {
623
922
  const label = `${AGENT_NAMES[report.agent] || report.agent}@${report.version}`;
624
923
  console.log(chalk.bold(label));
@@ -660,23 +959,22 @@ function renderTargetText(report, options) {
660
959
  if (options.requestedKinds && !options.requestedKinds.has(kind))
661
960
  continue;
662
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);
663
966
  }
664
967
  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
- }
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
+ });
680
978
  }
681
979
  // ─── fix / heal mode ───────────────────────────────────────────────────────────
682
980
  function renderHealText(result) {
@@ -730,6 +1028,53 @@ function renderHealText(result) {
730
1028
  console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
731
1029
  }
732
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
+ }
733
1078
  async function runFix(parsed, opts) {
734
1079
  // Heal targets the global install — project layer is irrelevant, so cwd is
735
1080
  // left to heal's neutral default rather than process.cwd().
@@ -744,11 +1089,170 @@ async function runFix(parsed, opts) {
744
1089
  agent: parsed?.agent,
745
1090
  versions: parsed?.versionExplicit ? parsed.versions : undefined,
746
1091
  });
1092
+ // Re-wire hooks the diff-driven heal leaves behind (present file, not wired).
1093
+ const rewired = rewireUnwiredHooks(parsed);
747
1094
  if (opts.json) {
748
- console.log(JSON.stringify(result, null, 2));
1095
+ console.log(JSON.stringify({ ...result, hookRewire: rewired }, null, 2));
749
1096
  return;
750
1097
  }
751
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);
752
1256
  }
753
1257
  // ─── command registration ────────────────────────────────────────────────────
754
1258
  export function registerDoctorCommand(program) {
@@ -762,7 +1266,9 @@ export function registerDoctorCommand(program) {
762
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)")
763
1267
  .option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted')
764
1268
  .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');
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');
766
1272
  setHelpSections(doctorCmd, {
767
1273
  examples: `
768
1274
  # Overview: CLI availability + sync status + orphans across all defaults
@@ -772,7 +1278,7 @@ export function registerDoctorCommand(program) {
772
1278
  agents doctor claude@default
773
1279
 
774
1280
  # All installed versions of one agent
775
- agents doctor gemini
1281
+ agents doctor antigravity
776
1282
 
777
1283
  # Pin to a specific installed version
778
1284
  agents doctor codex@0.117.0
@@ -789,10 +1295,33 @@ export function registerDoctorCommand(program) {
789
1295
  # Fleet: agent readiness + cross-device divergence (missing plugins/skills,
790
1296
  # agent-version gaps, .agents/.system repo drift) vs this machine
791
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; }
792
1305
  `,
793
1306
  });
794
1307
  doctorCmd.action(async (target, opts) => {
795
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
+ }
796
1325
  if (opts.devices || opts.hosts) {
797
1326
  if (target) {
798
1327
  console.error(chalk.red('Cannot combine --devices with a target argument.'));
@@ -901,6 +1430,10 @@ export function registerDoctorCommand(program) {
901
1430
  auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
902
1431
  sync: syncRows,
903
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),
904
1437
  // This host's harness inventory — installed resources per kind,
905
1438
  // installed version ids per agent, and `.agents`/`.system` repo
906
1439
  // state — so `agents doctor --devices` can compare presence across
@@ -932,7 +1465,8 @@ export function registerDoctorCommand(program) {
932
1465
  // Point at the interactive reconcile when anything is out of sync — the
933
1466
  // report shouldn't be a dead end. `agents status` runs the unified
934
1467
  // home-reading engine and offers to sync (opt-in, never auto-fires here).
935
- 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)) {
936
1470
  console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
937
1471
  }
938
1472
  return;
@@ -948,8 +1482,18 @@ export function registerDoctorCommand(program) {
948
1482
  process.exit(1);
949
1483
  }
950
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;
951
1491
  if (opts.json) {
952
- 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));
953
1497
  return;
954
1498
  }
955
1499
  const showDiff = !!opts.diff;