diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md
index 60b1ebf114a7..4835bfc6a7a2 100644
--- a/docs/gateway/doctor.md
+++ b/docs/gateway/doctor.md
@@ -149,7 +149,7 @@ Flags:
- Optional pre-flight update for git installs (interactive only).
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
- Health check + restart prompt.
- - Skills status summary (eligible/missing/blocked) and plugin status.
+ - Problem-only skill and plugin notes; healthy inventory stays in `openclaw skills check` and `openclaw plugins list`.
@@ -469,19 +469,20 @@ That stages grounded durable candidates into the short-term dreaming store while
- Doctor emits warnings when a provider is open to DMs without an allowlist, or when a policy is configured in a dangerous way.
+ Doctor emits a Security note only when it finds a warning, such as a provider open to DMs without an allowlist or a dangerously configured policy. Use `openclaw security audit` for the full security inventory.
If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout.
- Doctor prints a summary of the workspace state for the default agent:
+ Doctor prints problems and actions for the default agent, not healthy-state inventory:
- - **Skills status**: counts eligible, missing-requirements, and allowlist-blocked skills.
- - **Plugin status**: counts enabled/disabled/errored plugins; lists plugin IDs for any errors; reports bundle plugin capabilities.
+ - **Skills**: lists allowed but unusable skill names; use `openclaw skills check` for requirement details and full counts.
+ - **Plugins**: reports only errored plugin IDs; use `openclaw plugins list` for loaded, imported, disabled, and bundle-plugin inventory.
- **Plugin compatibility warnings**: flags plugins that have compatibility issues with the current runtime.
- **Plugin diagnostics**: surfaces any load-time warnings or errors emitted by the plugin registry.
- **TaskFlow recovery**: surfaces suspicious managed TaskFlows that need manual inspection or cancellation.
+ - **Claude CLI**: reports only binary, authentication, profile, workspace, or project-directory problems; healthy probe details are omitted.
diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts
index 2cd7b53d5156..1a01113efbb4 100644
--- a/src/cli/plugins-cli.list.test.ts
+++ b/src/cli/plugins-cli.list.test.ts
@@ -133,9 +133,8 @@ describe("plugins cli list", () => {
const output = runtimeLogs.join("\n");
expect(output).toContain("Plugin configuration:");
- expect(output).toContain('plugins.allow: stale plugin reference "lossless-claw" was found.');
expect(output).toContain(
- 'plugins.entries.lossless-claw: stale plugin reference "lossless-claw" was found.',
+ "Stale plugin references (plugins.allow/deny/entries): lossless-claw.",
);
expect(output).toContain(
'plugins.slots.contextEngine: slot references missing plugin "lossless-claw".',
diff --git a/src/commands/doctor-auth.profile-health.test.ts b/src/commands/doctor-auth.profile-health.test.ts
index dbe0921b7da0..271138a22194 100644
--- a/src/commands/doctor-auth.profile-health.test.ts
+++ b/src/commands/doctor-auth.profile-health.test.ts
@@ -251,7 +251,7 @@ describe("noteAuthProfileHealth", () => {
});
});
- it("labels model auth diagnostics by agent when multiple agent auth stores are checked", async () => {
+ it("aggregates model auth diagnostics and labels strict agent subsets", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const mainDir = path.join(tempDir, "main-agent");
@@ -285,14 +285,48 @@ describe("noteAuthProfileHealth", () => {
allowKeychainPrompt: false,
});
- expect(noteMock).toHaveBeenCalledWith(
- expect.stringContaining("openai-codex:main"),
- "Model auth (agent: main)",
- );
- expect(noteMock).toHaveBeenCalledWith(
- expect.stringContaining("openai-codex:coder"),
- "Model auth (agent: coder)",
+ const modelAuthCalls = noteMock.mock.calls.filter(([, title]) => title === "Model auth");
+ expect(modelAuthCalls).toHaveLength(1);
+ const body = String(modelAuthCalls[0]?.[0]);
+ expect(body).toContain("openai-codex:coder");
+ expect(body).toContain("(agents: coder)");
+ expect(body).toContain("openai-codex:main");
+ expect(body).toContain("(agents: main)");
+ });
+
+ it("deduplicates model auth diagnostics shared by every agent", async () => {
+ const now = 1_700_000_000_000;
+ vi.spyOn(Date, "now").mockReturnValue(now);
+ const mainDir = path.join(tempDir, "main-agent");
+ const coderDir = path.join(tempDir, "coder-agent");
+ writeAuthStore(mainDir);
+ writeAuthStore(coderDir);
+ authProfileMocks.hasAnyAuthProfileStoreSource.mockReturnValue(true);
+ authProfileMocks.hasLocalAuthProfileStoreSource.mockReturnValue(true);
+ authProfileMocks.ensureAuthProfileStore.mockReturnValue(
+ expiredStore("openai-codex:shared", now - 60_000),
);
+
+ await noteAuthProfileHealth({
+ cfg: {
+ agents: {
+ list: [
+ { id: "main", default: true, agentDir: mainDir },
+ { id: "coder", agentDir: coderDir },
+ ],
+ },
+ } as OpenClawConfig,
+ prompter: {
+ confirmAutoFix: vi.fn(async () => false),
+ } as unknown as DoctorPrompter,
+ allowKeychainPrompt: false,
+ });
+
+ const modelAuthCalls = noteMock.mock.calls.filter(([, title]) => title === "Model auth");
+ expect(modelAuthCalls).toHaveLength(1);
+ const body = String(modelAuthCalls[0]?.[0]);
+ expect(body.match(/openai-codex:shared/g)).toHaveLength(1);
+ expect(body).not.toContain("(agents:");
});
it("does not treat inherited main auth as a local secondary-agent source", async () => {
diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts
index 8d96a6b184cb..9b77c3d5d45f 100644
--- a/src/commands/doctor-auth.ts
+++ b/src/commands/doctor-auth.ts
@@ -456,7 +456,7 @@ async function noteAuthProfileHealthForTarget(params: {
allowKeychainPrompt: boolean;
target: AuthProfileHealthTarget;
labelAgents: boolean;
-}): Promise {
+}): Promise {
const store = ensureAuthProfileStore(params.target.agentDir, {
allowKeychainPrompt: params.allowKeychainPrompt,
});
@@ -500,7 +500,7 @@ async function noteAuthProfileHealthForTarget(params: {
let issues = findIssues();
if (issues.length === 0) {
- return;
+ return [];
}
const refreshTargets = issues.filter(
@@ -548,24 +548,7 @@ async function noteAuthProfileHealthForTarget(params: {
issues = findIssues();
}
- if (issues.length > 0) {
- const issueLines = await Promise.all(
- issues.map((issue) =>
- formatAuthIssueLine(
- {
- profileId: issue.profileId,
- provider: issue.provider,
- status: issue.status,
- reasonCode: issue.reasonCode,
- remainingMs: issue.remainingMs,
- },
- params.cfg,
- store,
- ),
- ),
- );
- note(issueLines.join("\n"), noteTitle("Model auth"));
- }
+ return Promise.all(issues.map((issue) => formatAuthIssueLine(issue, params.cfg, store)));
}
/** Checks configured agent auth stores and emits doctor notes for stale or unusable profiles. */
@@ -586,11 +569,23 @@ export async function noteAuthProfileHealth(params: {
}
const labelAgents = activeTargets.length > 1;
+ const agentsByIssueLine = new Map>();
for (const target of activeTargets) {
- await noteAuthProfileHealthForTarget({
- ...params,
- target,
- labelAgents,
- });
+ for (const line of await noteAuthProfileHealthForTarget({ ...params, target, labelAgents })) {
+ const agentIds = agentsByIssueLine.get(line) ?? new Set();
+ agentsByIssueLine.set(line, agentIds.add(target.agentId));
+ }
}
+ if (agentsByIssueLine.size === 0) {
+ return;
+ }
+ // One aggregated note; a line shared by every checked agent needs no attribution.
+ const lines = [...agentsByIssueLine.entries()]
+ .toSorted(([left], [right]) => left.localeCompare(right))
+ .map(([line, agentIds]) =>
+ agentIds.size === activeTargets.length
+ ? line
+ : `${line} (agents: ${[...agentIds].toSorted().join(", ")})`,
+ );
+ note(lines.join("\n"), "Model auth");
}
diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts
index 0415eb076edb..f72380c04f83 100644
--- a/src/commands/doctor-claude-cli.test.ts
+++ b/src/commands/doctor-claude-cli.test.ts
@@ -85,7 +85,7 @@ describe("noteClaudeCliHealth", () => {
expect(noteFn).not.toHaveBeenCalled();
});
- it("reports a healthy claude-cli setup with the resolved Claude project dir", async () => {
+ it("stays quiet for a healthy claude-cli setup", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const projectDir = resolveClaudeCliProjectDirForWorkspace({ workspaceDir, homeDir });
fs.mkdirSync(projectDir, { recursive: true });
@@ -107,8 +107,8 @@ describe("noteClaudeCliHealth", () => {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
- access: "token-a",
- refresh: "token-r",
+ access: "test-auth-token",
+ refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
@@ -120,22 +120,11 @@ describe("noteClaudeCliHealth", () => {
},
);
- expect(noteFn).toHaveBeenCalledTimes(1);
- expect(noteTitle(noteFn)).toBe("Claude CLI");
- const body = noteBody(noteFn);
- expect(body).toContain("Binary: /opt/homebrew/bin/claude.");
- expect(body).toContain("Headless Claude auth: OK (oauth).");
- expect(body).toContain(
- `OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} (provider claude-cli).`,
- );
- expect(body).toContain("Workspace:");
- expect(body).toContain("(writable).");
- expect(body).toContain("Claude project dir:");
- expect(body).toContain("(present).");
+ expect(noteFn).not.toHaveBeenCalled();
});
});
- it("reports the Claude CLI workspace for a non-default runtime agent", async () => {
+ it("stays quiet for a healthy non-default Claude CLI runtime agent", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const root = path.dirname(workspaceDir);
const defaultWorkspace = path.join(root, "workspace-coder");
@@ -179,8 +168,8 @@ describe("noteClaudeCliHealth", () => {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
- access: "token-a",
- refresh: "token-r",
+ access: "test-auth-token",
+ refresh: "test-token-placeholder",
expires: Date.now() + 60_000,
},
}),
@@ -192,11 +181,7 @@ describe("noteClaudeCliHealth", () => {
},
);
- expect(noteFn).toHaveBeenCalledTimes(1);
- const body = noteBody(noteFn);
- expect(body).toContain(`Agent xiaoao workspace: ${claudeWorkspace} (writable).`);
- expect(body).toContain(`Agent xiaoao Claude project dir: ${projectDir} (present).`);
- expect(body).not.toContain(defaultWorkspace);
+ expect(noteFn).not.toHaveBeenCalled();
});
});
@@ -225,14 +210,12 @@ describe("noteClaudeCliHealth", () => {
);
const body = noteBody(noteFn);
- expect(body).toContain("Headless Claude auth: OK (oauth).");
expect(body).toContain(`OpenClaw auth profile: missing (${CLAUDE_CLI_PROFILE_ID})`);
expect(body).toContain(
"openclaw models auth login --provider anthropic --method cli --set-default",
);
- expect(body).toContain(
- "not created yet; it appears after the first Claude CLI turn in this workspace",
- );
+ expect(body).not.toContain("Headless Claude auth: OK");
+ expect(body).not.toContain("not created yet");
});
});
@@ -263,4 +246,65 @@ describe("noteClaudeCliHealth", () => {
expect(body).toContain("claude auth login");
});
});
+
+ it("lists Claude CLI agents only when a problem is reported", async () => {
+ await withTempHome(({ homeDir, workspaceDir }) => {
+ const root = path.dirname(workspaceDir);
+ const alphaWorkspace = path.join(root, "workspace-alpha");
+ const zetaWorkspace = path.join(root, "workspace-zeta");
+ fs.writeFileSync(alphaWorkspace, "not a directory");
+ fs.mkdirSync(zetaWorkspace, { recursive: true });
+ const runtimeModel = "anthropic/claude-opus-4-7";
+ const noteFn = vi.fn();
+
+ noteClaudeCliHealth(
+ {
+ agents: {
+ defaults: { model: { primary: runtimeModel } },
+ list: [
+ {
+ id: "zeta",
+ default: true,
+ workspace: zetaWorkspace,
+ model: runtimeModel,
+ models: { [runtimeModel]: { agentRuntime: { id: "claude-cli" } } },
+ },
+ {
+ id: "alpha",
+ workspace: alphaWorkspace,
+ model: runtimeModel,
+ models: { [runtimeModel]: { agentRuntime: { id: "claude-cli" } } },
+ },
+ ],
+ },
+ },
+ {
+ homeDir,
+ noteFn,
+ store: createStore({
+ [CLAUDE_CLI_PROFILE_ID]: {
+ type: "oauth",
+ provider: "claude-cli",
+ access: "test-auth-token",
+ refresh: "test-token-placeholder",
+ expires: Date.now() + 60_000,
+ },
+ }),
+ readClaudeCliCredentials: () => ({
+ type: "oauth",
+ expires: Date.now() + 60_000,
+ }),
+ resolveCommandPath: () => "/opt/homebrew/bin/claude",
+ },
+ );
+
+ expect(noteTitle(noteFn)).toBe("Claude CLI");
+ const body = noteBody(noteFn);
+ expect(body).toContain(
+ `Agent alpha workspace: ${alphaWorkspace} exists but is not a directory.`,
+ );
+ expect(body).toContain("Agents using Claude CLI: alpha, zeta.");
+ expect(body).not.toContain(`Agent zeta workspace: ${zetaWorkspace}`);
+ });
+ });
});
diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts
index 8db0c2c6f07d..503c99fa6fa1 100644
--- a/src/commands/doctor-claude-cli.ts
+++ b/src/commands/doctor-claude-cli.ts
@@ -83,25 +83,15 @@ function probeDirectoryHealth(dirPath: string): ClaudeCliDirHealth {
return "present";
}
-function formatCredentialLabel(credential: ClaudeCliReadableCredential): string {
- if (credential.type === "oauth" || credential.type === "token") {
- return credential.type;
- }
- return "unknown";
-}
-
-function formatWorkspaceHealthLine(
+function formatWorkspaceProblemLine(
workspaceDir: string,
health: ClaudeCliDirHealth,
agentId?: string,
-): string {
+): string | null {
const label = agentId ? `Agent ${agentId} workspace` : "Workspace";
const display = shortenHomePath(workspaceDir);
- if (health === "present") {
- return `- ${label}: ${display} (writable).`;
- }
- if (health === "missing") {
- return `- ${label}: ${display} (missing; OpenClaw will create it on first run).`;
+ if (health === "present" || health === "missing") {
+ return null;
}
if (health === "not_directory") {
return `- ${label}: ${display} exists but is not a directory.`;
@@ -112,18 +102,15 @@ function formatWorkspaceHealthLine(
return `- ${label}: ${display} is not writable by this user.`;
}
-function formatProjectDirHealthLine(
+function formatProjectDirProblemLine(
projectDir: string,
health: ClaudeCliDirHealth,
agentId?: string,
-): string {
+): string | null {
const label = agentId ? `Agent ${agentId} Claude project dir` : "Claude project dir";
const display = shortenHomePath(projectDir);
- if (health === "present") {
- return `- ${label}: ${display} (present).`;
- }
- if (health === "missing") {
- return `- ${label}: ${display} (not created yet; it appears after the first Claude CLI turn in this workspace).`;
+ if (health === "present" || health === "missing") {
+ return null;
}
if (health === "not_directory") {
return `- ${label}: ${display} exists but is not a directory.`;
@@ -242,18 +229,14 @@ export function noteClaudeCliHealth(
const lines: string[] = [];
const fixHints: string[] = [];
- if (commandPath) {
- lines.push(`- Binary: ${shortenHomePath(commandPath)}.`);
- } else {
+ if (!commandPath) {
lines.push(`- Binary: command "${command}" was not found on PATH.`);
fixHints.push(
"- Fix: install Claude CLI or set agents.defaults.cliBackends.claude-cli.command to the real binary path.",
);
}
- if (credential) {
- lines.push(`- Headless Claude auth: OK (${formatCredentialLabel(credential)}).`);
- } else {
+ if (!credential) {
lines.push("- Headless Claude auth: unavailable without interactive prompting.");
fixHints.push(
`- Fix: run ${formatCliCommand("claude auth login")}, then ${formatCliCommand(
@@ -278,15 +261,18 @@ export function noteClaudeCliHealth(
"openclaw models auth login --provider anthropic --method cli --set-default",
)} to rewrite the profile cleanly.`,
);
- } else {
- lines.push(
- `- OpenClaw auth profile: ${CLAUDE_CLI_PROFILE_ID} (provider ${CLAUDE_CLI_PROVIDER}).`,
- );
}
for (const target of workspaceTargets) {
const agentLabel = showAgentLabels ? target.agentId : undefined;
- lines.push(formatWorkspaceHealthLine(target.workspaceDir, target.workspaceHealth, agentLabel));
+ const workspaceProblem = formatWorkspaceProblemLine(
+ target.workspaceDir,
+ target.workspaceHealth,
+ agentLabel,
+ );
+ if (workspaceProblem) {
+ lines.push(workspaceProblem);
+ }
if (
target.workspaceHealth === "readonly" ||
target.workspaceHealth === "unreadable" ||
@@ -299,7 +285,14 @@ export function noteClaudeCliHealth(
);
}
- lines.push(formatProjectDirHealthLine(target.projectDir, target.projectDirHealth, agentLabel));
+ const projectDirProblem = formatProjectDirProblemLine(
+ target.projectDir,
+ target.projectDirHealth,
+ agentLabel,
+ );
+ if (projectDirProblem) {
+ lines.push(projectDirProblem);
+ }
if (target.projectDirHealth === "unreadable" || target.projectDirHealth === "not_directory") {
fixHints.push(
`- Fix: make ${
@@ -309,12 +302,18 @@ export function noteClaudeCliHealth(
}
}
- if (workspaceTargets.length > 1) {
+ if (lines.length > 0 && workspaceTargets.length > 1) {
lines.push(
- `- Agents using Claude CLI: ${workspaceTargets.map((target) => target.agentId).join(", ")}.`,
+ `- Agents using Claude CLI: ${workspaceTargets
+ .map((target) => target.agentId)
+ .toSorted((a, b) => a.localeCompare(b))
+ .join(", ")}.`,
);
}
+ if (lines.length === 0 && fixHints.length === 0) {
+ return;
+ }
if (fixHints.length > 0) {
lines.push(...fixHints);
}
diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts
index 253483b17e4c..e1480a532f10 100644
--- a/src/commands/doctor-security.test.ts
+++ b/src/commands/doctor-security.test.ts
@@ -144,6 +144,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).toContain("without authentication");
expect(message).toContain("Safer remote access");
expect(message).toContain("ssh -N -L 18789:127.0.0.1:18789");
+ expect(message).toContain("openclaw security audit --deep");
});
it("uses env token to avoid critical warning", async () => {
@@ -247,17 +248,13 @@ describe("noteSecurityWarnings gateway exposure", () => {
it("skips warning for loopback bind", async () => {
const cfg = { gateway: { bind: "loopback" } } as OpenClawConfig;
await noteSecurityWarnings(cfg);
- const message = lastMessage();
- expect(message).toContain("No channel security warnings detected");
- expect(message).not.toContain("Gateway bound");
+ expect(note).not.toHaveBeenCalled();
});
it("treats unset bind as loopback for host-side doctor checks", async () => {
const cfg = { gateway: {} } as OpenClawConfig;
await noteSecurityWarnings(cfg);
- const message = lastMessage();
- expect(message).toContain("No channel security warnings detected");
- expect(message).not.toContain("Gateway bound");
+ expect(note).not.toHaveBeenCalled();
});
it("shows explicit dmScope config command for multi-user DMs", async () => {
@@ -514,9 +511,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
},
);
- const message = lastMessage();
- expect(message).toContain("No channel security warnings detected");
- expect(message).not.toContain('security="deny"');
+ expect(note).not.toHaveBeenCalled();
});
it("does not invent an on-miss host ask policy when exec-approvals defaults.ask is unset", async () => {
@@ -536,9 +531,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
},
);
- const message = lastMessage();
- expect(message).toContain("No channel security warnings detected");
- expect(message).not.toContain('ask="on-miss"');
+ expect(note).not.toHaveBeenCalled();
});
it("warns when a per-agent exec policy is broader than the matching host agent policy", async () => {
diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts
index d1cf5117ebab..740dd584e6bc 100644
--- a/src/commands/doctor-security.ts
+++ b/src/commands/doctor-security.ts
@@ -439,9 +439,8 @@ export async function collectSecurityWarnings(
/** Emits security warnings plus the deep audit follow-up command. */
export async function noteSecurityWarnings(cfg: OpenClawConfig) {
const warnings = await collectSecurityWarnings(cfg);
- const auditHint = `- Run: ${formatCliCommand("openclaw security audit --deep")}`;
-
- const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."];
- lines.push(auditHint);
- note(lines.join("\n"), "Security");
+ if (warnings.length > 0) {
+ warnings.push(`- Run: ${formatCliCommand("openclaw security audit --deep")}`);
+ note(warnings.join("\n"), "Security");
+ }
}
diff --git a/src/commands/doctor-skills.test.ts b/src/commands/doctor-skills.test.ts
index 5d2c5f362597..e1a192392a83 100644
--- a/src/commands/doctor-skills.test.ts
+++ b/src/commands/doctor-skills.test.ts
@@ -66,7 +66,7 @@ describe("doctor skills", () => {
expect(collectUnavailableAgentSkills(report)).toEqual([unavailable]);
});
- it("formats actionable missing requirement lines without secret values", () => {
+ it("formats unavailable skill names compactly and alphabetically", () => {
const lines = formatUnavailableSkillDoctorLines([
createSkill({
name: "places",
@@ -88,11 +88,25 @@ describe("doctor skills", () => {
},
],
}),
+ createSkill({
+ name: "calendar",
+ eligible: false,
+ platformIncompatible: false,
+ }),
]);
- expect(lines.join("\n")).toContain("places: bins: goplaces; env: GOOGLE_MAPS_API_KEY");
- expect(lines.join("\n")).toContain("install option: Install goplaces (brew)");
- expect(lines.join("\n")).toContain("openclaw doctor --fix");
+ expect(lines).toEqual([
+ "2 allowed skills are not usable in this environment (missing binaries, env vars, or config).",
+ "- calendar, places",
+ "Disable unused skills: openclaw doctor --fix",
+ "Inspect details: openclaw skills check --agent or openclaw skills info --agent ",
+ ]);
+ });
+
+ it("uses singular grammar for one unavailable skill", () => {
+ expect(formatUnavailableSkillDoctorLines([createSkill({ name: "places" })])[0]).toBe(
+ "1 allowed skill is not usable in this environment (missing binaries, env vars, or config).",
+ );
});
it("surfaces a GH_CONFIG_DIR hint when the github skill is eligible but auth lives at a different HOME", () => {
diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts
index 6ecf2d8a5ee2..c6d4d5ac4843 100644
--- a/src/commands/doctor-skills.ts
+++ b/src/commands/doctor-skills.ts
@@ -16,16 +16,8 @@ import type { DoctorPrompter } from "./doctor-prompter.js";
import {
collectUnavailableAgentSkills,
disableUnavailableSkillsInConfig,
- formatMissingSkillSummary,
} from "./doctor-skills-core.js";
-function formatInstallHints(skill: SkillStatusEntry): string[] {
- if (skill.install.length === 0) {
- return [];
- }
- return skill.install.slice(0, 2).map((entry) => ` install option: ${entry.label}`);
-}
-
function defaultGhConfigDiscoveryInput(): GhConfigDiscoveryInput {
return {
platform: process.platform,
@@ -65,13 +57,14 @@ export function describeGhConfigDirHintFromDiscovery(
/** Formats doctor note lines for skills that are allowed but unavailable. */
export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] {
- const lines: string[] = [
- "Some skills are allowed for this agent but are not usable in the current runtime environment.",
+ const count = skills.length;
+ const lines = [
+ `${count} allowed skill${count === 1 ? " is" : "s are"} not usable in this environment (missing binaries, env vars, or config).`,
+ `- ${skills
+ .map((skill) => skill.name)
+ .toSorted((a, b) => a.localeCompare(b))
+ .join(", ")}`,
];
- for (const skill of skills) {
- lines.push(`- ${skill.name}: ${formatMissingSkillSummary(skill)}`);
- lines.push(...formatInstallHints(skill));
- }
lines.push(`Disable unused skills: ${formatCliCommand("openclaw doctor --fix")}`);
lines.push(
`Inspect details: ${formatCliCommand("openclaw skills check --agent ")} or ${formatCliCommand("openclaw skills info --agent ")}`,
diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts
index 2dd29b3d8149..2e9d14b33e56 100644
--- a/src/commands/doctor-state-integrity.ts
+++ b/src/commands/doctor-state-integrity.ts
@@ -1483,11 +1483,7 @@ export function collectWorkspaceBackupTip(workspaceDir: string): string | null {
if (fs.existsSync(gitMarker)) {
return null;
}
- return [
- "- Tip: back up the workspace in a private git repo (GitHub or GitLab).",
- "- Keep ~/.openclaw out of git; it contains credentials and session history.",
- "- Details: /concepts/agent-workspace#git-backup-recommended",
- ].join("\n");
+ return "- Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended";
}
/** Emits the workspace backup tip when applicable. */
diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts
index 8b1228fcd849..f662be9a813d 100644
--- a/src/commands/doctor-workspace-status.test.ts
+++ b/src/commands/doctor-workspace-status.test.ts
@@ -17,7 +17,6 @@ import {
const mocks = vi.hoisted(() => ({
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
- buildWorkspaceSkillStatus: vi.fn(),
buildPluginRegistrySnapshotReport: vi.fn(),
buildPluginCompatibilityWarnings: vi.fn(),
listTaskFlowRecords: vi.fn<() => unknown[]>(() => []),
@@ -29,10 +28,6 @@ vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: (...args: unknown[]) => mocks.resolveDefaultAgentId(...args),
}));
-vi.mock("../skills/discovery/status.js", () => ({
- buildWorkspaceSkillStatus: (...args: unknown[]) => mocks.buildWorkspaceSkillStatus(...args),
-}));
-
vi.mock("../plugins/status.js", () => ({
buildPluginRegistrySnapshotReport: (...args: unknown[]) =>
mocks.buildPluginRegistrySnapshotReport(...args),
@@ -61,9 +56,6 @@ async function runNoteWorkspaceStatusForTest(
const cfg: OpenClawConfig = opts?.cfg ?? {};
mocks.resolveDefaultAgentId.mockReturnValue("default");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
- mocks.buildWorkspaceSkillStatus.mockReturnValue({
- skills: [],
- });
mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
workspaceDir: "/workspace",
...loadResult,
@@ -111,7 +103,7 @@ describe("noteWorkspaceStatus", () => {
}
});
- it("surfaces bundle plugin capabilities in the plugins note", async () => {
+ it("omits healthy plugin inventory", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
plugins: [
@@ -127,36 +119,52 @@ describe("noteWorkspaceStatus", () => {
}),
);
try {
- const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins");
- expect(pluginCalls).toHaveLength(1);
- const [body] = expectDefined(pluginCalls[0], "(pluginCalls)[0] test invariant");
- expect(body).toContain("Bundle plugins: 1");
- expect(body).toContain("agents, commands, skills");
+ expect(noteSpy.mock.calls.filter(([, title]) => title === "Plugins")).toHaveLength(0);
} finally {
noteSpy.mockRestore();
}
});
- it("includes imported plugin counts in the plugins note", async () => {
+ it("lists only errored plugin ids in deterministic order with truncation", async () => {
+ const pluginIds = [
+ "zulu",
+ "bravo",
+ "alpha",
+ "lima",
+ "charlie",
+ "kilo",
+ "delta",
+ "juliet",
+ "echo",
+ "india",
+ "foxtrot",
+ "hotel",
+ ];
const noteSpy = await runNoteWorkspaceStatusForTest(
createPluginLoadResult({
- plugins: [
- createPluginRecord({
- id: "imported-plugin",
- imported: true,
- }),
- createPluginRecord({
- id: "cold-plugin",
- imported: false,
- }),
- ],
+ plugins: pluginIds.map((id) => createPluginRecord({ id, status: "error" })),
}),
);
try {
const pluginCalls = noteSpy.mock.calls.filter(([, title]) => title === "Plugins");
expect(pluginCalls).toHaveLength(1);
const [body] = expectDefined(pluginCalls[0], "(pluginCalls)[0] test invariant");
- expect(body).toContain("Imported: 1");
+ expect(body).toBe(
+ [
+ "Errors: 12",
+ "- alpha",
+ "- bravo",
+ "- charlie",
+ "- delta",
+ "- echo",
+ "- foxtrot",
+ "- hotel",
+ "- india",
+ "- juliet",
+ "- kilo",
+ "- ...",
+ ].join("\n"),
+ );
} finally {
noteSpy.mockRestore();
}
@@ -479,62 +487,4 @@ describe("noteWorkspaceStatus", () => {
noteSpy.mockRestore();
}
});
-
- const makeSkill = (
- skillKey: string,
- fields: { eligible: boolean; platformIncompatible: boolean },
- ) =>
- ({
- skillKey,
- disabled: false,
- blockedByAllowlist: false,
- eligible: fields.eligible,
- platformIncompatible: fields.platformIncompatible,
- }) as never;
-
- async function runWithSkills(skills: unknown[]) {
- mocks.resolveDefaultAgentId.mockReturnValue("default");
- mocks.resolveAgentWorkspaceDir.mockReturnValue("/workspace");
- mocks.buildWorkspaceSkillStatus.mockReturnValue({ skills });
- mocks.buildPluginRegistrySnapshotReport.mockReturnValue({
- workspaceDir: "/workspace",
- ...createPluginLoadResult(),
- });
- mocks.buildPluginCompatibilityWarnings.mockReturnValue([]);
- mocks.listTaskFlowRecords.mockReturnValue([]);
- const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
- noteWorkspaceStatus({});
- return noteSpy;
- }
-
- it("surfaces a platform-incompatible rollup and keeps those skills out of Missing requirements", async () => {
- const noteSpy = await runWithSkills([
- makeSkill("mac-only", { eligible: false, platformIncompatible: true }),
- makeSkill("broken", { eligible: false, platformIncompatible: false }),
- ]);
- try {
- const skillsCall = noteSpy.mock.calls.find(([, title]) => title === "Skills status");
- expect(skillsCall).toBeDefined();
- const [body] = skillsCall as [string, string];
- expect(body).toContain("Incompatible (platform mismatch, auto-skipped): 1");
- expect(body).toContain("Missing requirements: 1");
- } finally {
- noteSpy.mockRestore();
- }
- });
-
- it("omits the platform-incompatible rollup when the count is zero", async () => {
- const noteSpy = await runWithSkills([
- makeSkill("broken", { eligible: false, platformIncompatible: false }),
- ]);
- try {
- const skillsCall = noteSpy.mock.calls.find(([, title]) => title === "Skills status");
- expect(skillsCall).toBeDefined();
- const [body] = skillsCall as [string, string];
- expect(body).not.toContain("Incompatible (platform mismatch");
- expect(body).toContain("Missing requirements: 1");
- } finally {
- noteSpy.mockRestore();
- }
- });
});
diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts
index 62e44fbc856a..c27948630e23 100644
--- a/src/commands/doctor-workspace-status.ts
+++ b/src/commands/doctor-workspace-status.ts
@@ -12,7 +12,6 @@ import {
buildPluginCompatibilityWarnings,
buildPluginRegistrySnapshotReport,
} from "../plugins/status.js";
-import { buildWorkspaceSkillStatus } from "../skills/discovery/status.js";
import { listTasksForFlowId } from "../tasks/runtime-internal.js";
import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
@@ -184,62 +183,24 @@ function notePluginVersionDrift(drift: PluginVersionDriftReport | undefined) {
note(lines.join("\n"), "Plugin version drift");
}
-/** Emits workspace, skills, plugin, and TaskFlow recovery status notes for doctor. */
+/** Emits plugin and TaskFlow recovery problem notes for doctor. */
export function noteWorkspaceStatus(cfg: OpenClawConfig, options: NoteWorkspaceStatusOptions = {}) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
- const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
- const platformIncompatibleCount = skillsReport.skills.filter(
- (s) => s.platformIncompatible && !s.disabled && !s.blockedByAllowlist,
- ).length;
- note(
- [
- `Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
- `Missing requirements: ${
- skillsReport.skills.filter(
- (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist && !s.platformIncompatible,
- ).length
- }`,
- platformIncompatibleCount > 0
- ? `Incompatible (platform mismatch, auto-skipped): ${platformIncompatibleCount}`
- : null,
- `Blocked by allowlist: ${skillsReport.skills.filter((s) => s.blockedByAllowlist).length}`,
- ]
- .filter((line): line is string => Boolean(line))
- .join("\n"),
- "Skills status",
- );
-
const pluginRegistry = buildPluginRegistrySnapshotReport({
config: cfg,
workspaceDir,
});
- if (pluginRegistry.plugins.length > 0) {
- const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
- const disabled = pluginRegistry.plugins.filter((p) => p.status === "disabled");
- const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
- const imported = pluginRegistry.plugins.filter((p) => p.imported);
-
+ const errored = pluginRegistry.plugins
+ .filter((plugin) => plugin.status === "error")
+ .toSorted((a, b) => a.id.localeCompare(b.id));
+ if (errored.length > 0) {
const lines = [
- `Loaded: ${loaded.length}`,
- `Imported: ${imported.length}`,
- `Disabled: ${disabled.length}`,
`Errors: ${errored.length}`,
- errored.length > 0
- ? `- ${errored
- .slice(0, 10)
- .map((p) => p.id)
- .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
- : null,
- ].filter((line): line is string => Boolean(line));
-
- const bundlePlugins = loaded.filter(
- (p) => p.format === "bundle" && (p.bundleCapabilities?.length ?? 0) > 0,
- );
- if (bundlePlugins.length > 0) {
- const allCaps = new Set(bundlePlugins.flatMap((p) => p.bundleCapabilities ?? []));
- lines.push(`Bundle plugins: ${bundlePlugins.length} (${[...allCaps].toSorted().join(", ")})`);
- }
-
+ `- ${errored
+ .slice(0, 10)
+ .map((plugin) => plugin.id)
+ .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`,
+ ];
note(lines.join("\n"), "Plugins");
}
notePluginVersionDrift(options.pluginVersionDrift);
diff --git a/src/commands/doctor/shared/codex-native-assets.test.ts b/src/commands/doctor/shared/codex-native-assets.test.ts
index a0c0f11248f4..59b86a4ffc76 100644
--- a/src/commands/doctor/shared/codex-native-assets.test.ts
+++ b/src/commands/doctor/shared/codex-native-assets.test.ts
@@ -121,10 +121,8 @@ describe("collectCodexNativeAssetInfoNotes", () => {
expect(notes).toStrictEqual([
[
- "- Personal Codex CLI assets were found, but native Codex-mode OpenClaw agents use isolated per-agent Codex homes.",
- `- Sources: ${codexHome} and ${path.join(root, ".agents", "skills")} (1 skill, 0 plugins, 0 config files, 0 hook files).`,
- "- These assets will not be loaded by the Codex app-server child unless you intentionally promote them.",
- "- If the Codex plugin is not installed, run `openclaw plugins install npm:@openclaw/codex` first. Then run `openclaw migrate plan codex` to inventory them. Applying that migration copies skills into the current OpenClaw agent workspace; Codex plugins, hooks, and config stay manual-review only.",
+ `- Personal Codex CLI assets found (1 skill, 0 plugins, 0 config files, 0 hook files) in ${codexHome} and ${path.join(root, ".agents", "skills")}; native Codex-mode agents use isolated per-agent homes and will not load them.`,
+ "- To review or promote them: install the Codex plugin (openclaw plugins install npm:@openclaw/codex), then run openclaw migrate plan codex.",
].join("\n"),
]);
});
diff --git a/src/commands/doctor/shared/codex-native-assets.ts b/src/commands/doctor/shared/codex-native-assets.ts
index 29de6279d724..83f3d94c2048 100644
--- a/src/commands/doctor/shared/codex-native-assets.ts
+++ b/src/commands/doctor/shared/codex-native-assets.ts
@@ -198,10 +198,8 @@ export async function collectCodexNativeAssetInfoNotes(params: {
];
return [
[
- "- Personal Codex CLI assets were found, but native Codex-mode OpenClaw agents use isolated per-agent Codex homes.",
- `- Sources: ${resolveCodexHome(env)} and ${resolvePersonalAgentSkillsDir(env)} (${counts.join(", ")}).`,
- "- These assets will not be loaded by the Codex app-server child unless you intentionally promote them.",
- "- If the Codex plugin is not installed, run `openclaw plugins install npm:@openclaw/codex` first. Then run `openclaw migrate plan codex` to inventory them. Applying that migration copies skills into the current OpenClaw agent workspace; Codex plugins, hooks, and config stay manual-review only.",
+ `- Personal Codex CLI assets found (${counts.join(", ")}) in ${resolveCodexHome(env)} and ${resolvePersonalAgentSkillsDir(env)}; native Codex-mode agents use isolated per-agent homes and will not load them.`,
+ "- To review or promote them: install the Codex plugin (openclaw plugins install npm:@openclaw/codex), then run openclaw migrate plan codex.",
].join("\n"),
];
}
diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts
index ba50de866b49..5468d274891c 100644
--- a/src/commands/doctor/shared/preview-warnings.test.ts
+++ b/src/commands/doctor/shared/preview-warnings.test.ts
@@ -230,18 +230,24 @@ vi.mock("./stale-plugin-config.js", () => ({
autoRepairBlocked: boolean;
doctorFixCommand: string;
hits: Array<{ id: string; surface: string }>;
- }) =>
- hits.map((hit) => {
- const prefix =
- hit.surface === "channel"
- ? `channels.${hit.id}: dangling channel config.`
- : `plugins.allow: stale plugin reference "${hit.id}". plugins.entries.${hit.id} is unused.`;
- return `${prefix} ${
- autoRepairBlocked
- ? `Auto-removal is paused; rerun "${doctorFixCommand}".`
- : `Run "${doctorFixCommand}".`
- }`;
- }),
+ }) => {
+ const pluginIds = hits
+ .filter((hit) => hit.surface !== "channel")
+ .map((hit) => hit.id)
+ .toSorted();
+ const lines = [
+ pluginIds.length > 0
+ ? `Stale plugin references (plugins.allow/deny/entries): ${pluginIds.join(", ")}.`
+ : null,
+ ...hits
+ .filter((hit) => hit.surface === "channel")
+ .map((hit) => `channels.${hit.id}: dangling channel config.`),
+ autoRepairBlocked
+ ? `Auto-removal is paused; rerun "${doctorFixCommand}".`
+ : `Run "${doctorFixCommand}".`,
+ ];
+ return lines.filter((line): line is string => line !== null);
+ },
}));
vi.mock("./bundled-plugin-load-paths.js", () => ({
@@ -370,8 +376,8 @@ describe("doctor preview warnings", () => {
env: { CODEX_HOME: codexHome, HOME: root },
});
- expect(notes.infoNotes.join("\n")).toContain("Personal Codex CLI assets were found");
- expect(notes.warningNotes.join("\n")).not.toContain("Personal Codex CLI assets were found");
+ expect(notes.infoNotes.join("\n")).toContain("Personal Codex CLI assets found");
+ expect(notes.warningNotes.join("\n")).not.toContain("Personal Codex CLI assets found");
});
it("collects provider and shared preview warnings", async () => {
@@ -535,9 +541,8 @@ describe("doctor preview warnings", () => {
const warning = expectSingleWarningContaining(
warnings,
- 'plugins.allow: stale plugin reference "acpx"',
+ "Stale plugin references (plugins.allow/deny/entries): acpx",
);
- expect(warning).toContain("plugins.entries.acpx");
expect(warning).toContain('Run "openclaw doctor --fix"');
expect(warning).not.toContain("Auto-removal is paused");
});
@@ -637,7 +642,7 @@ describe("doctor preview warnings", () => {
const warning = expectSingleWarningContaining(
warnings,
- 'plugins.allow: stale plugin reference "acpx"',
+ "Stale plugin references (plugins.allow/deny/entries): acpx",
);
expect(warning).toContain("Auto-removal is paused");
expect(warning).toContain('rerun "openclaw doctor --fix"');
diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts
index 8c0f625b3b7b..7704ee94b0f8 100644
--- a/src/commands/doctor/shared/stale-plugin-config.test.ts
+++ b/src/commands/doctor/shared/stale-plugin-config.test.ts
@@ -159,17 +159,33 @@ describe("doctor stale plugin config helpers", () => {
it("formats stale plugin warnings with a doctor hint", () => {
const warnings = collectStalePluginConfigWarnings({
hits: [
+ {
+ pluginId: "zeta",
+ pathLabel: "plugins.deny",
+ surface: "deny",
+ },
{
pluginId: "acpx",
pathLabel: "plugins.allow",
surface: "allow",
},
+ {
+ pluginId: "acpx",
+ pathLabel: "plugins.entries.acpx",
+ surface: "entries",
+ },
+ {
+ pluginId: "missing-memory",
+ pathLabel: "plugins.slots.memory",
+ surface: "slot",
+ },
],
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings).toEqual([
- '- plugins.allow: stale plugin reference "acpx" was found.',
+ "- Stale plugin references (plugins.allow/deny/entries): acpx, zeta.",
+ '- plugins.slots.memory: slot references missing plugin "missing-memory".',
'- Run "openclaw doctor --fix" to remove stale plugin ids and dangling channel references.',
]);
});
@@ -378,7 +394,7 @@ describe("doctor stale plugin config helpers", () => {
doctorFixCommand: "openclaw doctor --fix",
autoRepairBlocked: true,
});
- expect(warnings[2]).toContain("Auto-removal is paused");
+ expect(warnings.at(-1)).toContain("Auto-removal is paused");
});
it("keeps an intentionally unavailable Codex plugin entry out of stale diagnostics", () => {
diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts
index 1d228ea7b4f7..4978041a8110 100644
--- a/src/commands/doctor/shared/stale-plugin-config.ts
+++ b/src/commands/doctor/shared/stale-plugin-config.ts
@@ -120,38 +120,19 @@ function scanStalePluginConfigWithState(
const hits: StalePluginConfigHit[] = [];
const staleEvidenceIds = new Set(registryState.missingInstalledIds);
- const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
- for (const rawPluginId of allow) {
- if (typeof rawPluginId !== "string") {
- continue;
+ for (const surface of ["allow", "deny"] as const) {
+ const list = Array.isArray(plugins?.[surface]) ? plugins[surface] : [];
+ for (const rawPluginId of list) {
+ if (typeof rawPluginId !== "string") {
+ continue;
+ }
+ const pluginId = normalizePluginId(rawPluginId);
+ if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) {
+ continue;
+ }
+ hits.push({ pluginId: rawPluginId, pathLabel: `plugins.${surface}`, surface });
+ staleEvidenceIds.add(pluginId);
}
- const pluginId = normalizePluginId(rawPluginId);
- if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) {
- continue;
- }
- hits.push({
- pluginId: rawPluginId,
- pathLabel: "plugins.allow",
- surface: "allow",
- });
- staleEvidenceIds.add(pluginId);
- }
-
- const deny = Array.isArray(plugins?.deny) ? plugins.deny : [];
- for (const rawPluginId of deny) {
- if (typeof rawPluginId !== "string") {
- continue;
- }
- const pluginId = normalizePluginId(rawPluginId);
- if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) {
- continue;
- }
- hits.push({
- pluginId: rawPluginId,
- pathLabel: "plugins.deny",
- surface: "deny",
- });
- staleEvidenceIds.add(pluginId);
}
const entries = asObjectRecord(plugins?.entries);
@@ -301,9 +282,13 @@ function collectDependentChannelConfigHits(
return hits;
}
-function formatStalePluginHitWarning(hit: StalePluginConfigHit): string {
- if (hit.surface === "allow" || hit.surface === "deny" || hit.surface === "entries") {
- return `- ${hit.pathLabel}: stale plugin reference "${hit.pluginId}" was found.`;
+// Policy-list hits collapse into one grouped warning line instead of one line per path.
+const isPolicySurfaceHit = (hit: StalePluginConfigHit) =>
+ hit.surface === "allow" || hit.surface === "deny" || hit.surface === "entries";
+
+function formatStalePluginHitWarning(hit: StalePluginConfigHit): string | null {
+ if (isPolicySurfaceHit(hit)) {
+ return null;
}
if (hit.surface === "slot") {
return `- ${hit.pathLabel}: slot references missing plugin "${hit.pluginId}".`;
@@ -326,7 +311,17 @@ export function collectStalePluginConfigWarnings(params: {
if (params.hits.length === 0) {
return [];
}
- const lines = params.hits.map((hit) => formatStalePluginHitWarning(hit));
+ const policyPluginIds = [
+ ...new Set(params.hits.filter(isPolicySurfaceHit).map((hit) => hit.pluginId)),
+ ].toSorted((a, b) => a.localeCompare(b));
+ const lines = params.hits
+ .map((hit) => formatStalePluginHitWarning(hit))
+ .filter((line): line is string => line !== null);
+ if (policyPluginIds.length > 0) {
+ lines.unshift(
+ `- Stale plugin references (plugins.allow/deny/entries): ${policyPluginIds.join(", ")}.`,
+ );
+ }
if (params.autoRepairBlocked) {
lines.push(
`- Auto-removal is paused because plugin discovery currently has errors. Fix plugin discovery first, then rerun "${params.doctorFixCommand}".`,
diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts
index 2a2e940c619c..10bfc766e34e 100644
--- a/src/flows/doctor-core-checks.test.ts
+++ b/src/flows/doctor-core-checks.test.ts
@@ -764,10 +764,7 @@ describe("CORE_HEALTH_CHECKS", () => {
createDeps({
async collectWorkspaceSuggestionNotes(): Promise {
return [
- [
- "- Tip: back up the workspace in a private git repo (GitHub or GitLab).",
- "- Keep ~/.openclaw out of git; it contains credentials and session history.",
- ].join("\n"),
+ "- Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended",
"Memory system not found in workspace.",
];
},
@@ -793,7 +790,8 @@ describe("CORE_HEALTH_CHECKS", () => {
expect.objectContaining({
checkId: "core/doctor/workspace-suggestions",
severity: "info",
- message: "Tip: back up the workspace in a private git repo (GitHub or GitLab).",
+ message:
+ "Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended",
}),
);
expect(findings).toContainEqual(
diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts
index b3e17771d905..7c6fccdebaba 100644
--- a/src/flows/doctor-health.ts
+++ b/src/flows/doctor-health.ts
@@ -21,9 +21,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
}
const { createDoctorPrompter } = await import("../commands/doctor-prompter.js");
- const { printWizardHeader } = await import("../commands/onboard-helpers.js");
const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options });
- await printWizardHeader(effectiveRuntime);
intro("OpenClaw doctor");
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");