@phnx-labs/agents-cli 1.20.78 → 1.20.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +595 -0
- package/README.md +26 -26
- 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 +66 -0
- package/dist/commands/doctor.js +596 -33
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +24 -5
- 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/menubar.js +12 -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 +187 -11
- package/dist/commands/routines.d.ts +8 -1
- package/dist/commands/routines.js +241 -77
- package/dist/commands/rules.js +13 -12
- package/dist/commands/secrets.d.ts +3 -1
- package/dist/commands/secrets.js +100 -32
- package/dist/commands/sessions-inject.d.ts +7 -4
- package/dist/commands/sessions-inject.js +18 -18
- package/dist/commands/sessions-picker.js +32 -3
- package/dist/commands/sessions-resume.d.ts +16 -0
- package/dist/commands/sessions-resume.js +14 -13
- package/dist/commands/sessions.d.ts +115 -0
- package/dist/commands/sessions.js +189 -35
- 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/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 +52 -8
- 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 -2
- package/dist/lib/agents.js +72 -63
- 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 +18 -0
- package/dist/lib/daemon.js +47 -6
- package/dist/lib/devices/fleet.d.ts +1 -1
- package/dist/lib/devices/fleet.js +1 -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.d.ts +7 -0
- package/dist/lib/exec.js +69 -11
- 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 +14 -14
- package/dist/lib/git.js +41 -44
- package/dist/lib/hooks.d.ts +71 -0
- package/dist/lib/hooks.js +245 -95
- 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/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/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +24 -0
- package/dist/lib/menubar/install-menubar.js +52 -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 +33 -0
- package/dist/lib/routines.js +122 -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 +49 -10
- package/dist/lib/scheduler.js +16 -4
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +16 -0
- package/dist/lib/secrets/agent.js +105 -3
- package/dist/lib/secrets/audit.d.ts +46 -0
- package/dist/lib/secrets/audit.js +56 -0
- package/dist/lib/secrets/bundles.d.ts +11 -8
- package/dist/lib/secrets/bundles.js +31 -23
- 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/sync.js +3 -3
- package/dist/lib/session/active.d.ts +128 -26
- package/dist/lib/session/active.js +192 -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 +4 -1
- package/dist/lib/session/db.js +65 -8
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +13 -0
- package/dist/lib/session/hook-sessions.d.ts +9 -0
- package/dist/lib/session/hook-sessions.js +57 -8
- 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/types.d.ts +16 -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/state.js +57 -1
- 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/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
21
|
+
import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings } from '../lib/hooks.js';
|
|
22
|
+
import { isVersionIsolated } from '../lib/versions.js';
|
|
23
|
+
import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
|
|
21
24
|
import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
|
|
22
25
|
import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
|
|
23
26
|
import { listCliStatus } from '../lib/cli-resources.js';
|
|
@@ -64,7 +67,19 @@ function printWrappedLine(prefix, text) {
|
|
|
64
67
|
for (const line of wrapLine(prefix, text))
|
|
65
68
|
console.log(chalk.gray(line));
|
|
66
69
|
}
|
|
67
|
-
function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers) {
|
|
70
|
+
function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks) {
|
|
71
|
+
// Triaged health banner FIRST, so a user running bare `agents doctor` sees what
|
|
72
|
+
// is unhealthy, why it matters, and the exact fix before scrolling the detail
|
|
73
|
+
// sections below. Same triage model as target mode, aggregated across versions.
|
|
74
|
+
const overviewHealth = computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks);
|
|
75
|
+
console.log(chalk.bold('Health'));
|
|
76
|
+
renderHealthBlock(overviewHealth, {
|
|
77
|
+
healthySummary: syncRows.length
|
|
78
|
+
? `${syncRows.length} version${syncRows.length === 1 ? '' : 's'} reconciled · hooks wired · sources current`
|
|
79
|
+
: 'no installed versions to check',
|
|
80
|
+
healFix: verdictIsAutoFixable(overviewHealth) ? 'agents doctor --fix' : undefined,
|
|
81
|
+
});
|
|
82
|
+
console.log();
|
|
68
83
|
console.log(chalk.bold('Agent CLIs'));
|
|
69
84
|
// Show the fleet you actually run — agents that are ready in PATH, plus any
|
|
70
85
|
// you MANAGE (have installed versions) whose binary isn't resolving (a real
|
|
@@ -103,20 +118,26 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBe
|
|
|
103
118
|
for (const row of syncRows) {
|
|
104
119
|
const tag = row.isDefault ? chalk.gray(' (default)') : '';
|
|
105
120
|
const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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', '
|
|
284
|
+
const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'droid'];
|
|
264
285
|
function shortAgentHeader(name) {
|
|
265
286
|
return name.slice(0, 4).padEnd(4);
|
|
266
287
|
}
|
|
@@ -619,6 +640,301 @@ function readExpectedForDiff(kind, row) {
|
|
|
619
640
|
return null;
|
|
620
641
|
return safeRead(row.sourcePath);
|
|
621
642
|
}
|
|
643
|
+
// Family of agents whose hooks re-wire through registerHooksToSettings into a
|
|
644
|
+
// Claude-style settings.json (matches checkVersionHookWiring's supported set).
|
|
645
|
+
const HOOK_WIRING_FIX_AGENTS = ['claude', 'droid'];
|
|
646
|
+
/** Categories `--fix` reconciles (vs. `agents repo pull` for a behind source, or
|
|
647
|
+
* `agents prune cleanup` for an orphan). Drives the heal footer. */
|
|
648
|
+
const AUTO_FIXABLE_CATEGORIES = new Set([
|
|
649
|
+
'unwired-hook', 'settings-missing', 'settings-unparseable', 'missing', 'divergent', 'stale', 'never-synced',
|
|
650
|
+
]);
|
|
651
|
+
export function verdictIsAutoFixable(v) {
|
|
652
|
+
return v.issues.some((i) => AUTO_FIXABLE_CATEGORIES.has(i.category));
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Fold a version report's divergences into a triaged verdict — one severity-tagged
|
|
656
|
+
* finding per unwired hook, missing/divergent/extra resource, and behind-origin
|
|
657
|
+
* source layer, each carrying its subject, plain-English impact, and exact fix. An
|
|
658
|
+
* UNWIRED hook or a stale source is as unhealthy as a divergent file, not a
|
|
659
|
+
* footnote. Pure so the health rollup is unit-testable without a version home.
|
|
660
|
+
*/
|
|
661
|
+
export function computeVerdict(report) {
|
|
662
|
+
const issues = [];
|
|
663
|
+
const idLabel = `${report.agent}@${report.version}`;
|
|
664
|
+
const fixCmd = `agents doctor ${idLabel} --fix`;
|
|
665
|
+
const syncCmd = `agents sync ${idLabel} --yes`;
|
|
666
|
+
// ── critical: settings.json / unwired hooks (silent breakage) ──
|
|
667
|
+
const w = report.hookWiring;
|
|
668
|
+
if (w?.settingsMissing) {
|
|
669
|
+
const n = w.expected ?? 0;
|
|
670
|
+
issues.push({
|
|
671
|
+
severity: 'critical', category: 'settings-missing', subject: 'settings.json',
|
|
672
|
+
impact: `not found; ${n} declared hook${n === 1 ? '' : 's'} never fire`,
|
|
673
|
+
fix: syncCmd,
|
|
674
|
+
text: `settings.json missing (${n} hook${n === 1 ? '' : 's'} unwired)`, color: 'red',
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
else if (w?.settingsUnparseable) {
|
|
678
|
+
issues.push({
|
|
679
|
+
severity: 'critical', category: 'settings-unparseable', subject: 'settings.json',
|
|
680
|
+
impact: `unparseable; hook wiring can't be verified`,
|
|
681
|
+
fix: syncCmd,
|
|
682
|
+
text: 'settings.json unparseable', color: 'red',
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
else if (w) {
|
|
686
|
+
for (const u of w.unwired) {
|
|
687
|
+
issues.push({
|
|
688
|
+
severity: 'critical', category: 'unwired-hook', subject: u.name,
|
|
689
|
+
impact: 'on disk but not wired into settings.json; the hook never fires',
|
|
690
|
+
fix: syncCmd,
|
|
691
|
+
text: `${u.name} unwired`, color: 'red',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// ── critical: missing resources (declared in sources, absent from home) ──
|
|
696
|
+
for (const kind of DOCTOR_ALL_KINDS) {
|
|
697
|
+
for (const r of report.kinds[kind]) {
|
|
698
|
+
if (r.status !== 'missing')
|
|
699
|
+
continue;
|
|
700
|
+
issues.push({
|
|
701
|
+
severity: 'critical', category: 'missing', subject: r.name,
|
|
702
|
+
impact: `declared in sources but absent from the version home (${kind})`,
|
|
703
|
+
fix: fixCmd,
|
|
704
|
+
text: `${r.name} missing`, color: 'red',
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// ── warning: source layer behind origin (home reconciled against stale truth) ──
|
|
709
|
+
for (const b of report.sourceBehind ?? []) {
|
|
710
|
+
if (b.behind <= 0)
|
|
711
|
+
continue;
|
|
712
|
+
issues.push({
|
|
713
|
+
severity: 'warning', category: 'source-behind', subject: b.label,
|
|
714
|
+
impact: `${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}; you're running stale config`,
|
|
715
|
+
fix: `agents repo pull ${b.alias}`,
|
|
716
|
+
text: `source ${b.label} ${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}`, color: 'yellow',
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
// ── warning: divergent resources (drifted from source) ──
|
|
720
|
+
for (const kind of DOCTOR_ALL_KINDS) {
|
|
721
|
+
for (const r of report.kinds[kind]) {
|
|
722
|
+
if (r.status !== 'diff')
|
|
723
|
+
continue;
|
|
724
|
+
issues.push({
|
|
725
|
+
severity: 'warning', category: 'divergent', subject: r.name,
|
|
726
|
+
impact: r.detail ? collapseWhitespace(r.detail) : 'differs from source',
|
|
727
|
+
fix: fixCmd,
|
|
728
|
+
text: `${r.name} divergent`, color: 'yellow',
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// ── info: extra / orphan resources (present in home, no source) ──
|
|
733
|
+
for (const kind of DOCTOR_ALL_KINDS) {
|
|
734
|
+
for (const r of report.kinds[kind]) {
|
|
735
|
+
if (r.status !== 'extra')
|
|
736
|
+
continue;
|
|
737
|
+
issues.push({
|
|
738
|
+
severity: 'info', category: 'extra', subject: r.name,
|
|
739
|
+
impact: `orphan in the version home with no source (${kind})`,
|
|
740
|
+
fix: 'agents prune cleanup',
|
|
741
|
+
text: `${r.name} extra`, color: 'magenta',
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return { healthy: issues.length === 0, issues, reconciled: report.summary.ok };
|
|
746
|
+
}
|
|
747
|
+
// ─── triaged health block (shared by target + overview) ────────────────────────
|
|
748
|
+
const SEVERITY_COLOR = {
|
|
749
|
+
critical: chalk.red,
|
|
750
|
+
warning: chalk.yellow,
|
|
751
|
+
info: chalk.magenta,
|
|
752
|
+
};
|
|
753
|
+
// Restrained terminal glyphs — the ✓ ✗ ⚠ set plus a subtle info dot, colored via
|
|
754
|
+
// chalk to match the man-page voice. No colorful emoji.
|
|
755
|
+
const SEVERITY_GLYPH = {
|
|
756
|
+
critical: '✗',
|
|
757
|
+
warning: '⚠',
|
|
758
|
+
info: '·',
|
|
759
|
+
};
|
|
760
|
+
function severityCounts(issues) {
|
|
761
|
+
return {
|
|
762
|
+
critical: issues.filter((i) => i.severity === 'critical').length,
|
|
763
|
+
warning: issues.filter((i) => i.severity === 'warning').length,
|
|
764
|
+
info: issues.filter((i) => i.severity === 'info').length,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/** The info tier (orphans; one identical `prune cleanup` fix, already enumerated
|
|
768
|
+
* in the detail section) is capped with a rollup so the block stays scannable
|
|
769
|
+
* when a version home carries dozens of orphans. Critical + warning are
|
|
770
|
+
* actionable, each with a distinct subject/fix, so they are never capped. */
|
|
771
|
+
const INFO_CAP = 5;
|
|
772
|
+
/**
|
|
773
|
+
* Build the lines of a triaged health block: a single green ✓ line when healthy,
|
|
774
|
+
* otherwise a severity-counted ✗ header followed by one row per finding (icon ·
|
|
775
|
+
* severity · subject — impact, then the exact fix under it) and an optional heal
|
|
776
|
+
* footer. Pure — returns the lines so the rendered output is unit-testable. Shared
|
|
777
|
+
* by target mode and the bare overview so both read the same way.
|
|
778
|
+
*/
|
|
779
|
+
export function healthBlockLines(verdict, opts) {
|
|
780
|
+
if (verdict.healthy) {
|
|
781
|
+
return [` ${chalk.green('✓')} ${chalk.green('healthy')} ${chalk.gray('— ' + opts.healthySummary)}`];
|
|
782
|
+
}
|
|
783
|
+
const lines = [];
|
|
784
|
+
const c = severityCounts(verdict.issues);
|
|
785
|
+
const bits = [];
|
|
786
|
+
if (c.critical)
|
|
787
|
+
bits.push(`${c.critical} critical`);
|
|
788
|
+
if (c.warning)
|
|
789
|
+
bits.push(`${c.warning} warning${c.warning === 1 ? '' : 's'}`);
|
|
790
|
+
if (c.info)
|
|
791
|
+
bits.push(`${c.info} info`);
|
|
792
|
+
const total = verdict.issues.length;
|
|
793
|
+
lines.push(` ${chalk.red('✗')} ${chalk.red('unhealthy')} ${chalk.gray(`— ${total} issue${total === 1 ? '' : 's'} (${bits.join(' · ')})`)}`);
|
|
794
|
+
lines.push('');
|
|
795
|
+
const cont = ' '.repeat(14); // aligns the fix line under the subject column
|
|
796
|
+
const issueLines = (i) => {
|
|
797
|
+
const glyph = SEVERITY_COLOR[i.severity](SEVERITY_GLYPH[i.severity]);
|
|
798
|
+
const word = SEVERITY_COLOR[i.severity](i.severity.padEnd(8));
|
|
799
|
+
lines.push(` ${glyph} ${word} ${chalk.bold(i.subject)} ${chalk.gray('— ' + i.impact)}`);
|
|
800
|
+
lines.push(chalk.gray(`${cont}→ ${i.fix}`));
|
|
801
|
+
};
|
|
802
|
+
const actionable = verdict.issues.filter((i) => i.severity !== 'info');
|
|
803
|
+
const infoIssues = verdict.issues.filter((i) => i.severity === 'info');
|
|
804
|
+
for (const i of actionable)
|
|
805
|
+
issueLines(i);
|
|
806
|
+
for (const i of infoIssues.slice(0, INFO_CAP))
|
|
807
|
+
issueLines(i);
|
|
808
|
+
const hiddenInfo = infoIssues.length - Math.min(infoIssues.length, INFO_CAP);
|
|
809
|
+
if (hiddenInfo > 0) {
|
|
810
|
+
const glyph = SEVERITY_COLOR.info(SEVERITY_GLYPH.info);
|
|
811
|
+
const word = SEVERITY_COLOR.info('info'.padEnd(8));
|
|
812
|
+
lines.push(` ${glyph} ${word} ${chalk.gray(`+${hiddenInfo} more orphan${hiddenInfo === 1 ? '' : 's'}`)} ${chalk.gray('— agents prune cleanup')}`);
|
|
813
|
+
}
|
|
814
|
+
if (opts.healFix) {
|
|
815
|
+
lines.push('');
|
|
816
|
+
lines.push(` ${chalk.gray("heal what's auto-fixable:")} ${opts.healFix}`);
|
|
817
|
+
}
|
|
818
|
+
return lines;
|
|
819
|
+
}
|
|
820
|
+
function renderHealthBlock(verdict, opts) {
|
|
821
|
+
for (const line of healthBlockLines(verdict, opts))
|
|
822
|
+
console.log(line);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Aggregate the bare `agents doctor` overview into the same triaged verdict target
|
|
826
|
+
* mode uses — folding per-version wiring/sync drift, behind-origin source layers,
|
|
827
|
+
* and orphan resources into severity-tagged findings. Agent-agnostic: every
|
|
828
|
+
* installed version is classified the same way. Pure, so it is unit-testable.
|
|
829
|
+
*/
|
|
830
|
+
export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks = []) {
|
|
831
|
+
const issues = [];
|
|
832
|
+
const pretty = (agent, version) => `${AGENT_NAMES[agent] || agent}@${version}`;
|
|
833
|
+
// critical/warning: same hook resource materialized in several version homes.
|
|
834
|
+
// Different content is more severe because a stale copy can disagree with
|
|
835
|
+
// the active gate; byte-identical copies are noise and duplicate runtime cost.
|
|
836
|
+
for (const finding of duplicateHooks) {
|
|
837
|
+
const versions = finding.copies.map((copy) => copy.version).join(', ');
|
|
838
|
+
const active = finding.authoritative.version;
|
|
839
|
+
const drift = finding.kind === 'drift';
|
|
840
|
+
issues.push({
|
|
841
|
+
severity: drift ? 'critical' : 'warning',
|
|
842
|
+
category: drift ? 'duplicate-hook-drift' : 'duplicate-hook',
|
|
843
|
+
subject: `${finding.agent}/${finding.name}`,
|
|
844
|
+
impact: `${drift ? 'different content' : 'identical content'} across versions ${versions}; ${active} is authoritative`,
|
|
845
|
+
fix: `agents sync ${finding.agent}@${active} --yes`,
|
|
846
|
+
text: `${finding.name} ${drift ? 'drift' : 'duplicated'} across ${versions}`,
|
|
847
|
+
color: drift ? 'red' : 'yellow',
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
// critical: unwired hooks / broken settings.json per version
|
|
851
|
+
for (const row of syncRows) {
|
|
852
|
+
const n = row.unwiredHooks ?? 0;
|
|
853
|
+
if (n <= 0)
|
|
854
|
+
continue;
|
|
855
|
+
const label = pretty(row.agent, row.version);
|
|
856
|
+
issues.push({
|
|
857
|
+
severity: 'critical', category: 'unwired-hook', subject: label,
|
|
858
|
+
impact: `${n} hook${n === 1 ? '' : 's'} present on disk but not wired into settings.json; never fire`,
|
|
859
|
+
fix: `agents sync ${row.agent}@${row.version} --yes`,
|
|
860
|
+
text: `${label} ${n} unwired`, color: 'red',
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
// warning: source layers behind origin
|
|
864
|
+
for (const m of repoBehindMarkers) {
|
|
865
|
+
if (m.behind <= 0)
|
|
866
|
+
continue;
|
|
867
|
+
const label = m.alias === 'user' ? '~/.agents' : m.alias;
|
|
868
|
+
issues.push({
|
|
869
|
+
severity: 'warning', category: 'source-behind', subject: label,
|
|
870
|
+
impact: `${m.behind} commit${m.behind === 1 ? '' : 's'} behind ${m.branch}; you're running stale config`,
|
|
871
|
+
fix: `agents repo pull ${m.alias}`,
|
|
872
|
+
text: `${label} ${m.behind} behind`, color: 'yellow',
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
// warning: stale / never-synced versions
|
|
876
|
+
for (const row of syncRows) {
|
|
877
|
+
const label = pretty(row.agent, row.version);
|
|
878
|
+
if (row.status === 'stale') {
|
|
879
|
+
issues.push({
|
|
880
|
+
severity: 'warning', category: 'stale', subject: label,
|
|
881
|
+
impact: 'sources changed since last sync',
|
|
882
|
+
fix: `agents doctor ${row.agent}@${row.version} --fix`,
|
|
883
|
+
text: `${label} stale`, color: 'yellow',
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
else if (row.status === 'never-synced') {
|
|
887
|
+
issues.push({
|
|
888
|
+
severity: 'warning', category: 'never-synced', subject: label,
|
|
889
|
+
impact: 'installed but never synced',
|
|
890
|
+
fix: `agents sync ${row.agent}@${row.version} --yes`,
|
|
891
|
+
text: `${label} never-synced`, color: 'yellow',
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// info: orphan resources per version
|
|
896
|
+
for (const row of orphanRows) {
|
|
897
|
+
const parts = [];
|
|
898
|
+
if (row.commands)
|
|
899
|
+
parts.push(`${row.commands} command${row.commands === 1 ? '' : 's'}`);
|
|
900
|
+
if (row.skills)
|
|
901
|
+
parts.push(`${row.skills} skill${row.skills === 1 ? '' : 's'}`);
|
|
902
|
+
if (row.hooks)
|
|
903
|
+
parts.push(`${row.hooks} hook${row.hooks === 1 ? '' : 's'}`);
|
|
904
|
+
const label = pretty(row.agent, row.version);
|
|
905
|
+
issues.push({
|
|
906
|
+
severity: 'info', category: 'orphan', subject: label,
|
|
907
|
+
impact: `${parts.join(', ')} in the version home with no source`,
|
|
908
|
+
fix: 'agents prune cleanup',
|
|
909
|
+
text: `${label} orphan`, color: 'magenta',
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
const reconciled = syncRows.filter((r) => r.status === 'fresh' && (r.unwiredHooks ?? 0) === 0).length;
|
|
913
|
+
return { healthy: issues.length === 0, issues, reconciled };
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Render the UNWIRED hook rows (and settings.json problems) inside the hooks
|
|
917
|
+
* section, in the same row style as the reconcile rows above them.
|
|
918
|
+
*/
|
|
919
|
+
function renderHookWiringRows(w) {
|
|
920
|
+
if (!w.supported)
|
|
921
|
+
return;
|
|
922
|
+
if (w.settingsMissing) {
|
|
923
|
+
const where = w.settingsPath ? ` at ${w.settingsPath}` : '';
|
|
924
|
+
console.log(` ${chalk.red('UNWIRED')} ${chalk.gray(`settings.json not found${where} — ${w.expected ?? 0} declared hook(s) never fire`)}`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (w.settingsUnparseable) {
|
|
928
|
+
const where = w.settingsPath ? ` at ${w.settingsPath}` : '';
|
|
929
|
+
console.log(` ${chalk.red('UNWIRED')} ${chalk.gray(`settings.json unparseable${where} — wiring can't be verified`)}`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
for (const u of w.unwired) {
|
|
933
|
+
const name = padToWidth(truncateToWidth(u.name, 28), 28);
|
|
934
|
+
const scope = u.matcher ? `event=${u.event} matcher=${u.matcher}` : `event=${u.event}`;
|
|
935
|
+
console.log(` ${chalk.red('UNWIRED')} ${name} ${chalk.gray(scope)}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
622
938
|
function renderTargetText(report, options) {
|
|
623
939
|
const label = `${AGENT_NAMES[report.agent] || report.agent}@${report.version}`;
|
|
624
940
|
console.log(chalk.bold(label));
|
|
@@ -660,23 +976,22 @@ function renderTargetText(report, options) {
|
|
|
660
976
|
if (options.requestedKinds && !options.requestedKinds.has(kind))
|
|
661
977
|
continue;
|
|
662
978
|
renderKindSection(kind, rows, report.layers, options);
|
|
979
|
+
// A hook file can reconcile "ok" above yet be absent from settings.json — a
|
|
980
|
+
// present-but-dead hook. Surface that right under the hooks section.
|
|
981
|
+
if (kind === 'hooks' && report.hookWiring)
|
|
982
|
+
renderHookWiringRows(report.hookWiring);
|
|
663
983
|
}
|
|
664
984
|
console.log();
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
676
|
-
else {
|
|
677
|
-
console.log(` Verdict: ${verdictParts.join(', ')}.`);
|
|
678
|
-
printWrappedLine(' ', `Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`);
|
|
679
|
-
}
|
|
985
|
+
const verdict = computeVerdict(report);
|
|
986
|
+
// A source layer behind origin is healed by `agents repo pull`, not `--fix` —
|
|
987
|
+
// the per-issue fix already names the right command, so the heal footer only
|
|
988
|
+
// shows when something is genuinely `--fix`-able (never in the source-behind or
|
|
989
|
+
// orphan-only case, where it would mislead).
|
|
990
|
+
const hooksWired = report.hookWiring?.supported ? ' · hooks wired' : '';
|
|
991
|
+
renderHealthBlock(verdict, {
|
|
992
|
+
healthySummary: `${verdict.reconciled} resource${verdict.reconciled === 1 ? '' : 's'} reconciled${hooksWired} · sources current`,
|
|
993
|
+
healFix: verdictIsAutoFixable(verdict) ? `agents doctor ${report.agent}@${report.version} --fix` : undefined,
|
|
994
|
+
});
|
|
680
995
|
}
|
|
681
996
|
// ─── fix / heal mode ───────────────────────────────────────────────────────────
|
|
682
997
|
function renderHealText(result) {
|
|
@@ -730,6 +1045,53 @@ function renderHealText(result) {
|
|
|
730
1045
|
console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
|
|
731
1046
|
}
|
|
732
1047
|
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Re-wire hooks that reconcile as files but are absent from settings.json.
|
|
1050
|
+
*
|
|
1051
|
+
* heal() only re-syncs resources the diff flags missing/diff; a hook whose file
|
|
1052
|
+
* is byte-identical to source but never referenced in settings.json is neither,
|
|
1053
|
+
* so heal walks past it. registerHooksToSettings (the same call `agents sync`
|
|
1054
|
+
* makes at versions.ts) regenerates the wiring, so run it for any Claude-family
|
|
1055
|
+
* version this fix targets that has unwired hooks. Only claude/droid — the set
|
|
1056
|
+
* checkVersionHookWiring can verify.
|
|
1057
|
+
*/
|
|
1058
|
+
function rewireUnwiredHooks(parsed) {
|
|
1059
|
+
const out = [];
|
|
1060
|
+
const agents = parsed?.agent
|
|
1061
|
+
? (HOOK_WIRING_FIX_AGENTS.includes(parsed.agent) ? [parsed.agent] : [])
|
|
1062
|
+
: HOOK_WIRING_FIX_AGENTS;
|
|
1063
|
+
for (const agent of agents) {
|
|
1064
|
+
// A named version is explicit consent; a sweep excludes isolated copies,
|
|
1065
|
+
// mirroring heal().
|
|
1066
|
+
const versions = parsed?.agent && parsed.versionExplicit
|
|
1067
|
+
? parsed.versions
|
|
1068
|
+
: listInstalledVersions(agent).filter((v) => !isVersionIsolated(agent, v));
|
|
1069
|
+
for (const version of versions) {
|
|
1070
|
+
const before = checkVersionHookWiring(agent, version);
|
|
1071
|
+
if (!before.supported)
|
|
1072
|
+
continue;
|
|
1073
|
+
const need = before.unwired.length + (before.settingsMissing ? (before.expected ?? 0) : 0);
|
|
1074
|
+
if (need === 0)
|
|
1075
|
+
continue;
|
|
1076
|
+
registerHooksToSettings(agent, getVersionHomePath(agent, version));
|
|
1077
|
+
const after = checkVersionHookWiring(agent, version);
|
|
1078
|
+
const remaining = after.unwired.length + (after.settingsMissing ? (after.expected ?? 0) : 0);
|
|
1079
|
+
out.push({ agent, version, rewired: Math.max(0, need - remaining), remaining });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
function renderHookRewireText(rewired) {
|
|
1085
|
+
for (const r of rewired) {
|
|
1086
|
+
const label = `${AGENT_NAMES[r.agent] || r.agent}@${r.version}`;
|
|
1087
|
+
if (r.remaining === 0) {
|
|
1088
|
+
console.log(` ${chalk.green('rewired')} ${label} ${chalk.gray(`${r.rewired} hook${r.rewired === 1 ? '' : 's'} wired into settings.json`)}`);
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
console.log(` ${chalk.yellow('hold ')} ${label} ${chalk.gray(`${r.remaining} hook${r.remaining === 1 ? '' : 's'} still unwired — run \`agents sync ${r.agent}@${r.version} --yes\``)}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
733
1095
|
async function runFix(parsed, opts) {
|
|
734
1096
|
// Heal targets the global install — project layer is irrelevant, so cwd is
|
|
735
1097
|
// left to heal's neutral default rather than process.cwd().
|
|
@@ -744,11 +1106,170 @@ async function runFix(parsed, opts) {
|
|
|
744
1106
|
agent: parsed?.agent,
|
|
745
1107
|
versions: parsed?.versionExplicit ? parsed.versions : undefined,
|
|
746
1108
|
});
|
|
1109
|
+
// Re-wire hooks the diff-driven heal leaves behind (present file, not wired).
|
|
1110
|
+
const rewired = rewireUnwiredHooks(parsed);
|
|
747
1111
|
if (opts.json) {
|
|
748
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1112
|
+
console.log(JSON.stringify({ ...result, hookRewire: rewired }, null, 2));
|
|
749
1113
|
return;
|
|
750
1114
|
}
|
|
751
1115
|
renderHealText(result);
|
|
1116
|
+
renderHookRewireText(rewired);
|
|
1117
|
+
}
|
|
1118
|
+
function checkLabel(row) {
|
|
1119
|
+
return `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
|
|
1120
|
+
}
|
|
1121
|
+
function runCheckGate(opts, cwd) {
|
|
1122
|
+
const drift = computeDrift(cwd);
|
|
1123
|
+
if (opts.json) {
|
|
1124
|
+
console.log(JSON.stringify({
|
|
1125
|
+
hasDrift: drift.hasDrift,
|
|
1126
|
+
stale: drift.staleCount,
|
|
1127
|
+
neverSynced: drift.neverSyncedCount,
|
|
1128
|
+
unwiredHookVersions: drift.unwiredHookVersions,
|
|
1129
|
+
orphanVersions: drift.orphanVersionCount,
|
|
1130
|
+
sourceBehind: drift.sourceBehind,
|
|
1131
|
+
versions: drift.syncRows.map((r) => ({
|
|
1132
|
+
agent: r.agent,
|
|
1133
|
+
version: r.version,
|
|
1134
|
+
status: r.status,
|
|
1135
|
+
isDefault: r.isDefault,
|
|
1136
|
+
unwiredHooks: r.unwiredHooks ?? 0,
|
|
1137
|
+
divergence: r.divergence ?? [],
|
|
1138
|
+
})),
|
|
1139
|
+
}, null, 2));
|
|
1140
|
+
process.exit(drift.hasDrift ? 1 : 0);
|
|
1141
|
+
}
|
|
1142
|
+
if (drift.syncRows.length === 0) {
|
|
1143
|
+
// Nothing installed is a clean state, not a failure — CI on a fresh
|
|
1144
|
+
// checkout with no versions should pass, not error.
|
|
1145
|
+
console.log(chalk.gray('check: no installed versions — nothing to verify'));
|
|
1146
|
+
process.exit(0);
|
|
1147
|
+
}
|
|
1148
|
+
if (!drift.hasDrift) {
|
|
1149
|
+
const orphanNote = drift.orphanVersionCount > 0
|
|
1150
|
+
? chalk.gray(` (${drift.orphanVersionCount} version(s) carry orphans — run \`agents prune cleanup\`)`)
|
|
1151
|
+
: '';
|
|
1152
|
+
console.log(`${chalk.green('ok')} ${drift.syncRows.length} version(s) in sync${orphanNote}`);
|
|
1153
|
+
process.exit(0);
|
|
1154
|
+
}
|
|
1155
|
+
// Drift: one-line verdict always, per-version detail unless --quiet.
|
|
1156
|
+
const parts = [];
|
|
1157
|
+
if (drift.staleCount > 0)
|
|
1158
|
+
parts.push(`${drift.staleCount} stale`);
|
|
1159
|
+
if (drift.neverSyncedCount > 0)
|
|
1160
|
+
parts.push(`${drift.neverSyncedCount} never-synced`);
|
|
1161
|
+
if (drift.unwiredHookVersions > 0)
|
|
1162
|
+
parts.push(`${drift.unwiredHookVersions} with unwired hooks`);
|
|
1163
|
+
if (drift.sourceBehind.length > 0)
|
|
1164
|
+
parts.push(`${drift.sourceBehind.length} source layer(s) behind origin`);
|
|
1165
|
+
console.error(`${chalk.red('drift')} ${parts.join(', ')}`);
|
|
1166
|
+
if (!opts.quiet) {
|
|
1167
|
+
for (const row of drift.syncRows) {
|
|
1168
|
+
const unwired = (row.unwiredHooks ?? 0) > 0;
|
|
1169
|
+
if (row.status === 'fresh' && !unwired)
|
|
1170
|
+
continue;
|
|
1171
|
+
const tag = row.status === 'stale' ? chalk.yellow('stale')
|
|
1172
|
+
: row.status === 'never-synced' ? chalk.gray('cold ')
|
|
1173
|
+
: chalk.red('unwired'); // fresh but hooks not wired into settings.json
|
|
1174
|
+
console.error(` ${tag} ${checkLabel(row)}`);
|
|
1175
|
+
for (const line of row.divergence ?? []) {
|
|
1176
|
+
console.error(chalk.gray(` ${line}`));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
for (const b of drift.sourceBehind) {
|
|
1180
|
+
console.error(` ${chalk.red('behind')} source ${b.label} ${chalk.gray(`${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}`)}`);
|
|
1181
|
+
}
|
|
1182
|
+
const hints = [];
|
|
1183
|
+
if (drift.staleCount > 0 || drift.neverSyncedCount > 0 || drift.unwiredHookVersions > 0) {
|
|
1184
|
+
hints.push('`agents doctor --fix` (or `agents doctor <agent>@<version> --fix`)');
|
|
1185
|
+
}
|
|
1186
|
+
if (drift.sourceBehind.length > 0)
|
|
1187
|
+
hints.push('`agents repo pull <alias>` for a source layer behind origin');
|
|
1188
|
+
console.error(chalk.gray(`\nReconcile with ${hints.join('; ')}.`));
|
|
1189
|
+
}
|
|
1190
|
+
process.exit(1);
|
|
1191
|
+
}
|
|
1192
|
+
function checkPayload(device, drift) {
|
|
1193
|
+
return {
|
|
1194
|
+
device,
|
|
1195
|
+
hasDrift: drift.hasDrift,
|
|
1196
|
+
stale: drift.staleCount,
|
|
1197
|
+
neverSynced: drift.neverSyncedCount,
|
|
1198
|
+
orphanVersions: drift.orphanVersionCount,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
async function probeDeviceCheck(target) {
|
|
1202
|
+
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
1203
|
+
const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--check', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
|
|
1204
|
+
const res = await sshExecAsync(target.dialTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
|
|
1205
|
+
if (res.code !== 0 && !res.stdout.trim()) {
|
|
1206
|
+
throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
const parsed = JSON.parse(res.stdout);
|
|
1210
|
+
return {
|
|
1211
|
+
device: target.name,
|
|
1212
|
+
hasDrift: Boolean(parsed.hasDrift),
|
|
1213
|
+
stale: parsed.stale ?? 0,
|
|
1214
|
+
neverSynced: parsed.neverSynced ?? 0,
|
|
1215
|
+
orphanVersions: parsed.orphanVersions ?? 0,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
catch (err) {
|
|
1219
|
+
throw new Error(`invalid JSON (${err?.message ?? 'parse error'})`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
async function runDevicesCheck(opts, cwd) {
|
|
1223
|
+
const registry = await loadDevices();
|
|
1224
|
+
const self = machineId();
|
|
1225
|
+
const planned = planFleetTargets(registry);
|
|
1226
|
+
const local = checkPayload(self, computeDrift(cwd));
|
|
1227
|
+
const remoteTargets = remoteFleetTargets(planned, self)
|
|
1228
|
+
.map((t) => ({
|
|
1229
|
+
name: t.device.name,
|
|
1230
|
+
platform: t.device.platform,
|
|
1231
|
+
skip: t.skip,
|
|
1232
|
+
dialTarget: fleetDialTarget(t.device),
|
|
1233
|
+
}));
|
|
1234
|
+
const remote = await fanOutDevices(remoteTargets, probeDeviceCheck);
|
|
1235
|
+
const devices = [local];
|
|
1236
|
+
for (const result of remote) {
|
|
1237
|
+
if (result.status === 'ok' && result.value) {
|
|
1238
|
+
devices.push(result.value);
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
devices.push({
|
|
1242
|
+
device: result.name,
|
|
1243
|
+
hasDrift: true,
|
|
1244
|
+
stale: 0,
|
|
1245
|
+
neverSynced: 0,
|
|
1246
|
+
orphanVersions: 0,
|
|
1247
|
+
error: result.error ?? String(result.reason ?? 'skipped'),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const hasDrift = devices.some((d) => d.hasDrift || d.error);
|
|
1252
|
+
if (opts.json) {
|
|
1253
|
+
console.log(JSON.stringify({ hasDrift, devices }, null, 2));
|
|
1254
|
+
process.exit(hasDrift ? 1 : 0);
|
|
1255
|
+
}
|
|
1256
|
+
if (!hasDrift) {
|
|
1257
|
+
console.log(chalk.green('ok') + chalk.gray(` ${devices.length} device(s) in sync`));
|
|
1258
|
+
process.exit(0);
|
|
1259
|
+
}
|
|
1260
|
+
console.error(chalk.red('drift') + chalk.gray(` ${devices.filter((d) => d.hasDrift || d.error).length} of ${devices.length} device(s)`));
|
|
1261
|
+
if (!opts.quiet) {
|
|
1262
|
+
for (const d of devices) {
|
|
1263
|
+
if (!d.hasDrift && !d.error)
|
|
1264
|
+
continue;
|
|
1265
|
+
const detail = d.error
|
|
1266
|
+
? d.error
|
|
1267
|
+
: [`${d.stale} stale`, `${d.neverSynced} never-synced`].filter((p) => !p.startsWith('0 ')).join(', ');
|
|
1268
|
+
console.error(` ${chalk.yellow(d.device.padEnd(18))} ${detail || 'drift'}`);
|
|
1269
|
+
}
|
|
1270
|
+
console.error(chalk.gray('\nReconcile each device with `agents doctor --fix` or `agents repo pull user`.'));
|
|
1271
|
+
}
|
|
1272
|
+
process.exit(1);
|
|
752
1273
|
}
|
|
753
1274
|
// ─── command registration ────────────────────────────────────────────────────
|
|
754
1275
|
export function registerDoctorCommand(program) {
|
|
@@ -762,7 +1283,9 @@ export function registerDoctorCommand(program) {
|
|
|
762
1283
|
.option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
|
|
763
1284
|
.option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted')
|
|
764
1285
|
.option('--devices', 'Check agent readiness AND cross-device harness divergence (missing resources/versions, repo drift) on every registered device (alias --hosts)')
|
|
765
|
-
.option('--hosts', 'Alias of --devices')
|
|
1286
|
+
.option('--hosts', 'Alias of --devices')
|
|
1287
|
+
.option('--check', 'CI drift gate: exit non-zero when any installed version is out of sync (stale or never-synced), zero when clean. Combine with --devices to gate the whole fleet.')
|
|
1288
|
+
.option('-q, --quiet', 'With --check, suppress per-version lines; print only the one-line verdict');
|
|
766
1289
|
setHelpSections(doctorCmd, {
|
|
767
1290
|
examples: `
|
|
768
1291
|
# Overview: CLI availability + sync status + orphans across all defaults
|
|
@@ -772,7 +1295,7 @@ export function registerDoctorCommand(program) {
|
|
|
772
1295
|
agents doctor claude@default
|
|
773
1296
|
|
|
774
1297
|
# All installed versions of one agent
|
|
775
|
-
agents doctor
|
|
1298
|
+
agents doctor antigravity
|
|
776
1299
|
|
|
777
1300
|
# Pin to a specific installed version
|
|
778
1301
|
agents doctor codex@0.117.0
|
|
@@ -789,10 +1312,33 @@ export function registerDoctorCommand(program) {
|
|
|
789
1312
|
# Fleet: agent readiness + cross-device divergence (missing plugins/skills,
|
|
790
1313
|
# agent-version gaps, .agents/.system repo drift) vs this machine
|
|
791
1314
|
agents doctor --devices
|
|
1315
|
+
|
|
1316
|
+
# CI drift gate: exit 1 if anything drifted (stale/never-synced), 0 if clean
|
|
1317
|
+
agents doctor --check
|
|
1318
|
+
agents doctor --check --quiet # just the verdict line
|
|
1319
|
+
agents doctor --check --json # machine-readable, for scripting
|
|
1320
|
+
agents doctor --check --devices # gate every registered device
|
|
1321
|
+
agents doctor --check || { echo "resources drifted — run 'agents doctor --fix'"; exit 1; }
|
|
792
1322
|
`,
|
|
793
1323
|
});
|
|
794
1324
|
doctorCmd.action(async (target, opts) => {
|
|
795
1325
|
const cwd = opts.cwd ? opts.cwd : process.cwd();
|
|
1326
|
+
// CI drift gate. Kept BEFORE the --devices branch so `doctor --check
|
|
1327
|
+
// --devices` routes to the drift gate fan-out (runDevicesCheck), while a
|
|
1328
|
+
// bare `doctor --devices` still routes to the readiness matrix below.
|
|
1329
|
+
if (opts.check) {
|
|
1330
|
+
if (target) {
|
|
1331
|
+
console.error(chalk.red('Cannot combine --check with a target argument.'));
|
|
1332
|
+
process.exit(1);
|
|
1333
|
+
}
|
|
1334
|
+
if (opts.devices || opts.hosts) {
|
|
1335
|
+
await runDevicesCheck(opts, cwd);
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
runCheckGate(opts, cwd);
|
|
1339
|
+
}
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
796
1342
|
if (opts.devices || opts.hosts) {
|
|
797
1343
|
if (target) {
|
|
798
1344
|
console.error(chalk.red('Cannot combine --devices with a target argument.'));
|
|
@@ -877,6 +1423,7 @@ export function registerDoctorCommand(program) {
|
|
|
877
1423
|
const orphanRows = countOrphans();
|
|
878
1424
|
const hostClis = listCliStatus(cwd);
|
|
879
1425
|
const repoBehindMarkers = readRepoBehindMarkers();
|
|
1426
|
+
const duplicateHooks = inspectDuplicateVersionHooks(cwd);
|
|
880
1427
|
// Advisory login state per installed agent (file-based getAccountInfo,
|
|
881
1428
|
// no home → the account-global/active credential). Best-effort: a probe
|
|
882
1429
|
// failure just leaves that agent's badge as "logged out".
|
|
@@ -901,6 +1448,11 @@ export function registerDoctorCommand(program) {
|
|
|
901
1448
|
auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
|
|
902
1449
|
sync: syncRows,
|
|
903
1450
|
orphans: orphanRows,
|
|
1451
|
+
// Triaged overview health — severity/category/subject/impact/fix per
|
|
1452
|
+
// finding, aggregated across versions. Additive; existing consumers
|
|
1453
|
+
// reading `sync`/`orphans`/`repos` are unaffected.
|
|
1454
|
+
health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks),
|
|
1455
|
+
duplicateHooks,
|
|
904
1456
|
// This host's harness inventory — installed resources per kind,
|
|
905
1457
|
// installed version ids per agent, and `.agents`/`.system` repo
|
|
906
1458
|
// state — so `agents doctor --devices` can compare presence across
|
|
@@ -928,11 +1480,12 @@ export function registerDoctorCommand(program) {
|
|
|
928
1480
|
}, null, 2));
|
|
929
1481
|
return;
|
|
930
1482
|
}
|
|
931
|
-
renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers);
|
|
1483
|
+
renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks);
|
|
932
1484
|
// Point at the interactive reconcile when anything is out of sync — the
|
|
933
1485
|
// report shouldn't be a dead end. `agents status` runs the unified
|
|
934
1486
|
// home-reading engine and offers to sync (opt-in, never auto-fires here).
|
|
935
|
-
|
|
1487
|
+
// Unwired hooks and a behind-origin repo count as out-of-sync too.
|
|
1488
|
+
if (syncRows.some((r) => r.status !== 'fresh' || (r.unwiredHooks ?? 0) > 0) || repoBehindMarkers.some((m) => m.behind > 0)) {
|
|
936
1489
|
console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
|
|
937
1490
|
}
|
|
938
1491
|
return;
|
|
@@ -948,8 +1501,18 @@ export function registerDoctorCommand(program) {
|
|
|
948
1501
|
process.exit(1);
|
|
949
1502
|
}
|
|
950
1503
|
const reports = parsed.versions.map((v) => diffVersionResources(parsed.agent, v, { cwd, kinds }));
|
|
1504
|
+
// Source-layer staleness is global (same across versions) and needs a git
|
|
1505
|
+
// probe kept out of the pure diff — compute once, attach to every report so
|
|
1506
|
+
// both --json and the text verdict carry it.
|
|
1507
|
+
const sourceBehind = computeSourceBehind();
|
|
1508
|
+
for (const r of reports)
|
|
1509
|
+
r.sourceBehind = sourceBehind;
|
|
951
1510
|
if (opts.json) {
|
|
952
|
-
|
|
1511
|
+
// Carry the triaged verdict (severity/category/subject/impact/fix per
|
|
1512
|
+
// issue) alongside the report — additive, so existing consumers reading
|
|
1513
|
+
// `summary`/`kinds`/`hookWiring`/`sourceBehind` are unaffected.
|
|
1514
|
+
const withVerdict = reports.map((r) => ({ ...r, verdict: computeVerdict(r) }));
|
|
1515
|
+
console.log(JSON.stringify(withVerdict.length === 1 ? withVerdict[0] : withVerdict, null, 2));
|
|
953
1516
|
return;
|
|
954
1517
|
}
|
|
955
1518
|
const showDiff = !!opts.diff;
|