@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.
- package/CHANGELOG.md +589 -0
- package/README.md +27 -25
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +9 -0
- package/dist/commands/activity.js +153 -24
- package/dist/commands/apply.js +11 -4
- package/dist/commands/cli.js +1 -1
- package/dist/commands/commands.d.ts +1 -1
- package/dist/commands/commands.js +7 -6
- package/dist/commands/defaults.js +3 -11
- package/dist/commands/doctor.d.ts +74 -0
- package/dist/commands/doctor.js +718 -40
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -8
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +87 -11
- package/dist/commands/harness.js +2 -2
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/logs.js +6 -0
- package/dist/commands/models.js +1 -1
- package/dist/commands/packages.js +6 -6
- package/dist/commands/permissions.js +1 -1
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/pull.d.ts +1 -1
- package/dist/commands/pull.js +4 -4
- package/dist/commands/repo.d.ts +78 -0
- package/dist/commands/repo.js +195 -11
- package/dist/commands/routines.js +16 -4
- package/dist/commands/rules.js +13 -12
- package/dist/commands/secrets.d.ts +3 -1
- package/dist/commands/secrets.js +108 -34
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-inject.d.ts +7 -4
- package/dist/commands/sessions-inject.js +18 -18
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +90 -7
- package/dist/commands/sessions-resume.d.ts +16 -0
- package/dist/commands/sessions-resume.js +14 -13
- package/dist/commands/sessions.d.ts +126 -1
- package/dist/commands/sessions.js +365 -42
- package/dist/commands/set.d.ts +15 -0
- package/dist/commands/set.js +79 -0
- package/dist/commands/setup-fleet.d.ts +21 -0
- package/dist/commands/setup-fleet.js +201 -0
- package/dist/commands/setup-secrets.d.ts +20 -0
- package/dist/commands/setup-secrets.js +223 -0
- package/dist/commands/setup.js +17 -3
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +9 -1
- package/dist/commands/sync.js +7 -2
- package/dist/commands/teams-picker.js +0 -1
- package/dist/commands/teams.d.ts +33 -1
- package/dist/commands/teams.js +208 -44
- package/dist/commands/versions.js +10 -5
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +11 -0
- package/dist/index.js +8 -13
- package/dist/lib/acp/harnesses.js +0 -7
- package/dist/lib/activity.d.ts +156 -0
- package/dist/lib/activity.js +282 -0
- package/dist/lib/agents.d.ts +9 -23
- package/dist/lib/agents.js +72 -100
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/browser/service.js +3 -0
- package/dist/lib/browser/types.d.ts +7 -0
- package/dist/lib/capabilities.js +6 -2
- package/dist/lib/crabbox/lease.js +2 -2
- package/dist/lib/crabbox/setup-copy.d.ts +4 -4
- package/dist/lib/crabbox/setup-copy.js +4 -4
- package/dist/lib/daemon.d.ts +26 -40
- package/dist/lib/daemon.js +63 -123
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +1 -1
- package/dist/lib/devices/fleet.js +1 -1
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/doctor-diff.d.ts +20 -0
- package/dist/lib/doctor-diff.js +6 -1
- package/dist/lib/drift.d.ts +31 -11
- package/dist/lib/drift.js +58 -7
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +10 -1
- package/dist/lib/exec.js +35 -8
- package/dist/lib/feed-post.d.ts +28 -1
- package/dist/lib/feed-post.js +110 -2
- package/dist/lib/fleet/apply.js +8 -1
- package/dist/lib/fleet/auth-sync.d.ts +18 -1
- package/dist/lib/fleet/auth-sync.js +25 -11
- package/dist/lib/fleet/remote-login.js +5 -0
- package/dist/lib/git.d.ts +27 -14
- package/dist/lib/git.js +138 -43
- package/dist/lib/hooks.d.ts +42 -0
- package/dist/lib/hooks.js +137 -93
- package/dist/lib/hosts/dispatch.d.ts +28 -1
- package/dist/lib/hosts/dispatch.js +33 -5
- package/dist/lib/hosts/option.js +2 -2
- package/dist/lib/hosts/passthrough.d.ts +22 -1
- package/dist/lib/hosts/passthrough.js +238 -13
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/hosts/run-target.d.ts +2 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/hq/floor.d.ts +1 -1
- package/dist/lib/hq/floor.js +6 -1
- package/dist/lib/mcp.js +3 -71
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/notify-desktop.d.ts +15 -0
- package/dist/lib/menubar/notify-desktop.js +41 -5
- package/dist/lib/merged-resources.d.ts +11 -0
- package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
- package/dist/lib/permissions.d.ts +0 -32
- package/dist/lib/permissions.js +4 -157
- package/dist/lib/picker.js +1 -1
- package/dist/lib/platform/process.d.ts +16 -5
- package/dist/lib/platform/process.js +54 -0
- package/dist/lib/plugins.d.ts +0 -8
- package/dist/lib/plugins.js +4 -110
- package/dist/lib/project-resources.js +4 -1
- package/dist/lib/refresh.d.ts +5 -3
- package/dist/lib/refresh.js +7 -5
- package/dist/lib/resources/commands.js +4 -1
- package/dist/lib/resources/mcp.js +0 -4
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +1 -5
- package/dist/lib/resources/rules.js +4 -1
- package/dist/lib/resources/skills.js +4 -1
- package/dist/lib/resources/subagents.js +4 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +20 -0
- package/dist/lib/routines.js +6 -0
- package/dist/lib/run-defaults.d.ts +1 -0
- package/dist/lib/run-defaults.js +9 -0
- package/dist/lib/runner.d.ts +0 -12
- package/dist/lib/runner.js +71 -29
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +18 -0
- package/dist/lib/secrets/agent.js +131 -17
- package/dist/lib/secrets/audit.d.ts +46 -0
- package/dist/lib/secrets/audit.js +56 -0
- package/dist/lib/secrets/bundles.d.ts +12 -9
- package/dist/lib/secrets/bundles.js +47 -31
- package/dist/lib/secrets/index.d.ts +2 -1
- package/dist/lib/secrets/index.js +8 -2
- package/dist/lib/secrets/remote.d.ts +75 -2
- package/dist/lib/secrets/remote.js +139 -5
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/secrets/sync.js +3 -3
- package/dist/lib/session/active.d.ts +117 -26
- package/dist/lib/session/active.js +199 -53
- package/dist/lib/session/actor-sidecar.d.ts +23 -0
- package/dist/lib/session/actor-sidecar.js +101 -0
- package/dist/lib/session/db.d.ts +41 -1
- package/dist/lib/session/db.js +173 -8
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +19 -2
- package/dist/lib/session/discover.js +32 -11
- package/dist/lib/session/hook-sessions.d.ts +9 -0
- package/dist/lib/session/hook-sessions.js +57 -8
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/pid-registry.d.ts +13 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +16 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/staleness/detectors/commands.js +2 -2
- package/dist/lib/staleness/detectors/permissions.js +1 -45
- package/dist/lib/staleness/writers/commands.js +4 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -2
- package/dist/lib/startup/command-registry.js +2 -4
- package/dist/lib/subagents-registry.js +5 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -12
- package/dist/lib/teams/agents.d.ts +0 -2
- package/dist/lib/teams/agents.js +10 -27
- package/dist/lib/teams/index.d.ts +1 -1
- package/dist/lib/teams/index.js +1 -1
- package/dist/lib/types.d.ts +17 -9
- package/dist/lib/usage.d.ts +28 -5
- package/dist/lib/usage.js +271 -8
- package/dist/lib/versions.js +7 -1
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +25 -13
- package/package.json +1 -2
- package/dist/commands/check.d.ts +0 -15
- package/dist/commands/check.js +0 -180
- package/dist/commands/resources.d.ts +0 -5
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
package/dist/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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', '
|
|
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({
|
|
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
|
|
327
|
-
|
|
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
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|