magus-cli 7.10.4 → 7.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/plugin-deps/doctor.test.ts +2 -2
  3. package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
  4. package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
  5. package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
  6. package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
  7. package/src/__tests__/blind/requires-min-version/manifests.test.ts +68 -0
  8. package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
  9. package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
  10. package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
  11. package/src/__tests__/blind/requires-min-version/support.ts +83 -0
  12. package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
  13. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
  14. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
  15. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
  16. package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
  17. package/src/__tests__/deps-check-hook.test.ts +9 -9
  18. package/src/__tests__/deps-plan.test.ts +1 -1
  19. package/src/__tests__/deps-report.test.ts +5 -5
  20. package/src/__tests__/doctor-json.test.ts +2 -2
  21. package/src/__tests__/marketplace-refresh.test.ts +1 -1
  22. package/src/__tests__/plugin-requires.test.ts +2 -2
  23. package/src/__tests__/profile-worktree-include.test.ts +212 -0
  24. package/src/__tests__/requires-probe.test.ts +2 -2
  25. package/src/cli/deps-view.ts +5 -3
  26. package/src/services/deps-plan.ts +38 -14
  27. package/src/services/deps-report.ts +24 -7
  28. package/src/services/doctor-bins.ts +40 -12
  29. package/src/services/profile-activate.ts +9 -0
  30. package/src/services/profile-live.ts +26 -0
  31. package/src/services/profile-skills.ts +14 -4
  32. package/src/services/profile-state.ts +18 -0
  33. package/src/services/resolver.ts +1 -0
  34. package/src/shared/plugin/hooks/deps-check.ts +7 -3
  35. package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
  36. package/src/ui/adapters/cliToolsAdapter.ts +6 -1
  37. package/src/ui/renderers/cliToolRenderers.tsx +7 -1
  38. package/src/ui/screens/CliToolsScreen.tsx +8 -1
@@ -18,7 +18,7 @@ import {
18
18
  requiredByOf,
19
19
  } from "../services/deps-plan.js";
20
20
  import type { DepsState } from "../services/deps-report.js";
21
- import { installText } from "../services/doctor-bins.js";
21
+ import { installReason, installText } from "../services/doctor-bins.js";
22
22
  import {
23
23
  type Os,
24
24
  type Presence,
@@ -33,6 +33,8 @@ function presenceLine(name: string, p: Presence, by: string): string | null {
33
33
  return null;
34
34
  case "missing":
35
35
  return ` ✗ ${name} — missing${p.blockedBy ? ` (needs ${p.blockedBy} first)` : ""} (${by})`;
36
+ case "outdated":
37
+ return ` ✗ ${name} — outdated: ${p.found}, needs ${p.minVersion} or later (${by})`;
36
38
  case "off-path":
37
39
  return ` ⚠ ${name} — installed in ${p.prefix}, which is not on the PATH Claude Code starts with (${by})`;
38
40
  case "unknown":
@@ -57,8 +59,8 @@ export function depsSectionLines(
57
59
  const line = presenceLine(bin.name, p, requiredByOf(bin).join(", "));
58
60
  if (!line) return;
59
61
  lines.push(line);
60
- if (p.status === "missing")
61
- lines.push(` fix: ${installText(bin, env)}`);
62
+ if (p.status === "missing" || p.status === "outdated")
63
+ lines.push(` fix: ${installText(bin, env, installReason(p))}`);
62
64
  if (p.status === "off-path") unfixable++;
63
65
  });
64
66
  for (const problem of collected.problems) {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The one installer executor: plan what is missing, then install it.
3
3
  *
4
- * `planDeps` turns the requirements a probe found missing into ordered steps,
4
+ * `planDeps` turns the requirements a probe found missing or outdated into
5
+ * ordered steps,
5
6
  * each rendered by requires-probe's `renderInstall` (the only renderer).
6
7
  * `applyDepsPlan` runs them through `runArgv` and returns one `ApplyOutcome`
7
8
  * per step. Nothing here prints: `magus doctor` (text and `--json`),
@@ -42,6 +43,7 @@ import {
42
43
  BUN_PREFIX,
43
44
  type Bootstrap,
44
45
  type BootstrapTool,
46
+ type InstallReason,
45
47
  type InstallStep,
46
48
  LOCAL_PREFIX,
47
49
  type Os,
@@ -240,10 +242,11 @@ function fromMagusMarketplace(bin: ResolvedBin): boolean {
240
242
  }
241
243
 
242
244
  /**
243
- * The steps that install every `missing` requirement, in the order they must
244
- * run. `presence[i]` is the probe's answer for `bins[i]`. An `off-path`
245
- * requirement is not reinstalled: it needs a PATH line, which `pathAdvice`
246
- * carries. An `unknown` one (a check too slow to answer) is left alone.
245
+ * The steps that install every `missing` requirement and replace every
246
+ * `outdated` one, in the order they must run. `presence[i]` is the probe's
247
+ * answer for `bins[i]`. An `off-path` requirement is not reinstalled: it needs
248
+ * a PATH line, which `pathAdvice` carries. An `unknown` one (a check too slow
249
+ * to answer, or one that printed no version) is left alone.
247
250
  */
248
251
  export function planDeps(
249
252
  bins: ResolvedBin[],
@@ -253,10 +256,11 @@ export function planDeps(
253
256
  const unfixable: DepsPlan["unfixable"] = [];
254
257
  /** The prefixes off the PATH, in the order they came up. */
255
258
  const offPathPrefixes = new Set<UserPrefix>();
256
- const missing: ResolvedBin[] = [];
259
+ const missing: { bin: ResolvedBin; reason: InstallReason }[] = [];
257
260
  bins.forEach((bin, i) => {
258
261
  const p = presence[i];
259
- if (p?.status === "missing") missing.push(bin);
262
+ if (p?.status === "missing") missing.push({ bin, reason: "missing" });
263
+ if (p?.status === "outdated") missing.push({ bin, reason: "outdated" });
260
264
  if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
261
265
  });
262
266
 
@@ -285,7 +289,7 @@ export function planDeps(
285
289
  pluginRoot: bin.root ?? "",
286
290
  });
287
291
 
288
- for (const bin of missing) {
292
+ for (const { bin, reason } of missing) {
289
293
  const option = bin.chosen;
290
294
  if (!option) {
291
295
  unfixable.push({
@@ -294,11 +298,12 @@ export function planDeps(
294
298
  });
295
299
  continue;
296
300
  }
297
- const step = renderInstall(bin, option, {
298
- os: env.os,
299
- arch: env.arch,
300
- pluginRoot: bin.root ?? "",
301
- });
301
+ const step = renderInstall(
302
+ bin,
303
+ option,
304
+ { os: env.os, arch: env.arch, pluginRoot: bin.root ?? "" },
305
+ reason,
306
+ );
302
307
  if (
303
308
  step.kind === "uv-module" &&
304
309
  step.escalation === "sudo" &&
@@ -895,7 +900,9 @@ async function landingDir(
895
900
  * After a step's installer exited 0, re-probe everything it installs with the
896
901
  * same probe doctor runs. Installed only when the probe now finds each one.
897
902
  * Found in a directory off the PATH Claude Code starts with: off-path, naming
898
- * the directory and the rc line. Not found at all: failed.
903
+ * the directory and the rc line. Not found at all: failed. Still below its
904
+ * `minVersion`: failed, naming the version found, the floor and the file the
905
+ * PATH resolves to — usually an older copy ahead of the one just installed.
899
906
  */
900
907
  async function verifyInstalled(
901
908
  p: PlannedStep,
@@ -904,6 +911,7 @@ async function verifyInstalled(
904
911
  const { env } = ctx;
905
912
  const offPath: Verified[] = [];
906
913
  const missing: string[] = [];
914
+ const outdated: string[] = [];
907
915
  for (const target of p.installs) {
908
916
  const presence = await ctx.io.verify(target, env);
909
917
  if (presence.status === "present") continue;
@@ -911,6 +919,18 @@ async function verifyInstalled(
911
919
  offPath.push({ status: "off-path", dir: presence.prefix });
912
920
  continue;
913
921
  }
922
+ if (presence.status === "outdated") {
923
+ const tool = target.requirement.check[0] ?? target.requirement.name;
924
+ const resolved = onPath(tool, env.path);
925
+ outdated.push(
926
+ `${target.requirement.name} is still ${presence.found}, below the ${presence.minVersion} it needs: ${
927
+ resolved
928
+ ? `the PATH Claude Code starts with runs ${resolved}`
929
+ : `${tool} is not on the PATH Claude Code starts with`
930
+ } — remove that older copy or put the new one ahead of it, then restart Claude Code from a new terminal`,
931
+ );
932
+ continue;
933
+ }
914
934
  const tool = target.requirement.check[0] ?? target.requirement.name;
915
935
  const landed = await landingDir(p.step, ctx);
916
936
  if (landed && onPath(tool, landed)) {
@@ -924,6 +944,10 @@ async function verifyInstalled(
924
944
  if (missing.length > 0) {
925
945
  return { status: "failed", why: missing.join("\n") };
926
946
  }
947
+ if (outdated.length > 0) {
948
+ const why = outdated.join("\n");
949
+ return { status: "failed", why, remedy: why };
950
+ }
927
951
  const first = offPath[0];
928
952
  if (first?.dir) {
929
953
  return {
@@ -33,13 +33,20 @@ import {
33
33
  prefixOnPath,
34
34
  requiredByOf,
35
35
  } from "./deps-plan.js";
36
- import { DOCTOR_CHECK_CAP_MS, probeBins } from "./doctor-bins.js";
36
+ import {
37
+ DOCTOR_CHECK_CAP_MS,
38
+ installReason,
39
+ probeBins,
40
+ } from "./doctor-bins.js";
37
41
  import { readManifest } from "./manifest.js";
38
42
  import { resolvePluginRequires, selectBins } from "./plugin-requires.js";
39
43
  import { binKey, cliToolBins, mergeBins } from "./resolver.js";
40
44
 
41
- /** The `--json` document's shape version. */
42
- export const DEPS_REPORT_SCHEMA = 1;
45
+ /**
46
+ * The `--json` document's shape version. 2 added the `outdated` status and a
47
+ * dep's `found` and `minVersion`.
48
+ */
49
+ export const DEPS_REPORT_SCHEMA = 2;
43
50
 
44
51
  export interface PluginParseProblem {
45
52
  plugin: string;
@@ -187,6 +194,10 @@ export interface DepJson {
187
194
  blockedBy?: string;
188
195
  prefix?: string;
189
196
  reason?: string;
197
+ /** Only when `status` is "outdated": the version the check reported. */
198
+ found?: string;
199
+ /** Only when `status` is "outdated": the lowest version that will do. */
200
+ minVersion?: string;
190
201
  fix?: {
191
202
  via: string;
192
203
  escalation: "none" | "sudo";
@@ -227,11 +238,17 @@ export function depsReportJson(
227
238
  if (p.status === "missing" && p.blockedBy) entry.blockedBy = p.blockedBy;
228
239
  if (p.status === "off-path") entry.prefix = p.prefix;
229
240
  if (p.status === "unknown") entry.reason = p.reason;
241
+ if (p.status === "outdated") {
242
+ entry.found = p.found;
243
+ entry.minVersion = p.minVersion;
244
+ }
230
245
  if (p.status !== "present" && bin.chosen) {
231
- const step = renderInstall(bin, bin.chosen, {
232
- ...env,
233
- pluginRoot: bin.root ?? "",
234
- });
246
+ const step = renderInstall(
247
+ bin,
248
+ bin.chosen,
249
+ { ...env, pluginRoot: bin.root ?? "" },
250
+ installReason(p),
251
+ );
235
252
  const prefix = prefixOf(step);
236
253
  entry.fix = {
237
254
  via: bin.chosen.via,
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import {
12
+ type InstallReason,
12
13
  type Presence,
13
14
  type ProbeDepth,
14
15
  type Requirement,
@@ -68,6 +69,7 @@ export async function probeBins(
68
69
  const reqs: Requirement[] = batch.map(({ bin }) => ({
69
70
  name: bin.name,
70
71
  check: expandCheck(bin, bin.root ?? ""),
72
+ ...(bin.minVersion !== undefined ? { minVersion: bin.minVersion } : {}),
71
73
  install: bin.install,
72
74
  }));
73
75
  const answers = await probe(reqs, {
@@ -93,13 +95,25 @@ export async function probeBins(
93
95
  return out;
94
96
  }
95
97
 
96
- /** The line a person runs to install `bin` here, or why nothing can. */
97
- export function installText(bin: ResolvedBin, env: PlanEnv): string {
98
+ /**
99
+ * The line a person runs to install `bin` here — or, `outdated`, to replace
100
+ * the copy that is too old — or why nothing can.
101
+ */
102
+ export function installText(
103
+ bin: ResolvedBin,
104
+ env: PlanEnv,
105
+ reason: InstallReason = "missing",
106
+ ): string {
98
107
  if (!bin.chosen) {
99
108
  return whyUnselectable(bin, env, (t) => onPath(t, env.path) !== null);
100
109
  }
101
110
  return describeStep(
102
- renderInstall(bin, bin.chosen, { ...env, pluginRoot: bin.root ?? "" }),
111
+ renderInstall(
112
+ bin,
113
+ bin.chosen,
114
+ { ...env, pluginRoot: bin.root ?? "" },
115
+ reason,
116
+ ),
103
117
  env.os,
104
118
  );
105
119
  }
@@ -107,7 +121,10 @@ export function installText(bin: ResolvedBin, env: PlanEnv): string {
107
121
  export interface BinCheckResult {
108
122
  name: string;
109
123
  presence: Presence;
110
- /** Advisory text after "run:" for a human to copy. Never executed. */
124
+ /**
125
+ * Advisory text after "run:" for a human to copy, an upgrade when the
126
+ * presence is `outdated`. Never executed.
127
+ */
111
128
  installCommand: string;
112
129
  /** Where the requirement came from (profile cliTools / plugin ids). */
113
130
  sources: string[];
@@ -119,17 +136,28 @@ export async function checkBinaries(
119
136
  opts: ProbeBinsOptions,
120
137
  ): Promise<BinCheckResult[]> {
121
138
  const presences = await probeBins(bins, opts);
122
- return bins.map((bin, i) => ({
123
- name: bin.name,
124
- presence: presences[i] as Presence,
125
- installCommand: installText(bin, opts.env),
126
- sources: bin.sources,
127
- }));
139
+ return bins.map((bin, i) => {
140
+ const presence = presences[i] as Presence;
141
+ return {
142
+ name: bin.name,
143
+ presence,
144
+ installCommand: installText(bin, opts.env, installReason(presence)),
145
+ sources: bin.sources,
146
+ };
147
+ });
148
+ }
149
+
150
+ /** Why a requirement with this presence would be installed. */
151
+ export function installReason(presence: Presence): InstallReason {
152
+ return presence.status === "outdated" ? "outdated" : "missing";
128
153
  }
129
154
 
130
- /** The actionable subset: missing, or installed but not on PATH. */
155
+ /** The actionable subset: missing, outdated, or installed but not on PATH. */
131
156
  export function missingBinaries(results: BinCheckResult[]): BinCheckResult[] {
132
157
  return results.filter(
133
- (r) => r.presence.status === "missing" || r.presence.status === "off-path",
158
+ (r) =>
159
+ r.presence.status === "missing" ||
160
+ r.presence.status === "outdated" ||
161
+ r.presence.status === "off-path",
134
162
  );
135
163
  }
@@ -42,6 +42,8 @@ import {
42
42
  OBSOLETE_GITIGNORE_ENTRIES,
43
43
  PROFILE_GITIGNORE_COMMENT,
44
44
  PROFILE_GITIGNORE_ENTRIES,
45
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
46
+ PROFILE_WORKTREE_INCLUDE_ENTRIES,
45
47
  assertLiveFilesParse,
46
48
  backupRoot,
47
49
  backupStamp,
@@ -324,6 +326,13 @@ export async function activateProfile(
324
326
  remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
325
327
  },
326
328
  );
329
+ // Claude Code copies git-ignored files into a worktree it creates only when
330
+ // `.worktreeinclude` lists them — see PROFILE_WORKTREE_INCLUDE_ENTRIES.
331
+ await updateManagedGitignoreBlock(
332
+ path.join(projectPath, ".worktreeinclude"),
333
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
334
+ { add: PROFILE_WORKTREE_INCLUDE_ENTRIES },
335
+ );
327
336
 
328
337
  const backupDir = path.join(backupRoot(projectPath), backupStamp());
329
338
  const displaced: DisplacedArtifact[] = [];
@@ -72,6 +72,32 @@ export const PROFILE_GITIGNORE_ENTRIES = [
72
72
  export const PROFILE_GITIGNORE_COMMENT =
73
73
  "# magus: generated profile config (source of truth: .claude/profiles.json)";
74
74
 
75
+ /**
76
+ * Paths magus lists in a project's `.worktreeinclude`. Claude Code copies a
77
+ * file into a worktree it creates (`claude -w`, EnterWorktree, agent
78
+ * isolation) only when that file is both git-ignored and listed there, at
79
+ * creation time. magus git-ignores the set-up state above, so without this
80
+ * list every new worktree gets `.claude/profiles.json` from the commit and none
81
+ * of the project's plugins.
82
+ *
83
+ * The marker FILE is listed, not `.claude/profiles/`: the directory would also
84
+ * copy a crash-orphaned temp file. It holds only hashes and ids, so a
85
+ * byte-for-byte copy reconciles as unchanged in the worktree. Backups are never
86
+ * listed — they are this checkout's hand-written files, possibly credentials,
87
+ * and nothing in a worktree uses them. Each magus-installed skill folder gets
88
+ * its own line (see `profile-skills.ts`).
89
+ */
90
+ export const PROFILE_WORKTREE_INCLUDE_ENTRIES = [
91
+ ".claude/settings.json",
92
+ ".mcp.json",
93
+ ".claude/models.json",
94
+ ".claude/profiles/active.json",
95
+ ];
96
+
97
+ /** The comment heading magus's lines in a project's `.worktreeinclude`. */
98
+ export const PROFILE_WORKTREE_INCLUDE_COMMENT =
99
+ "# magus: copied into new worktrees so they start with this project's plugins (source of truth: .claude/profiles.json)";
100
+
75
101
  /**
76
102
  * Lines older releases wrote into magus's managed `.gitignore` block that must
77
103
  * go when an older layout is converted: the blanket skills ignore hid committed
@@ -28,6 +28,7 @@ import type {
28
28
  import { updateManagedGitignoreBlock } from "./conventions-manager.js";
29
29
  import {
30
30
  PROFILE_GITIGNORE_COMMENT,
31
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
31
32
  backupRoot,
32
33
  backupStamp,
33
34
  folderHash,
@@ -251,6 +252,9 @@ export async function inspectManagedSkills(
251
252
  * `.gitignore` block: `before` are the folders the marker listed, `after` the
252
253
  * ones it lists now. Lines are taken out of magus's block only — the same
253
254
  * pattern a person wrote elsewhere in the file stays.
255
+ *
256
+ * The same lines go into magus's `.worktreeinclude` block, so a worktree
257
+ * Claude Code creates gets the folders the copied marker records.
254
258
  */
255
259
  export async function updateSkillIgnoreLines(
256
260
  projectPath: string,
@@ -258,13 +262,19 @@ export async function updateSkillIgnoreLines(
258
262
  after: readonly string[],
259
263
  ): Promise<void> {
260
264
  const keep = new Set(after);
265
+ const edits = {
266
+ add: [...keep].map(skillIgnoreLine),
267
+ remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
268
+ };
261
269
  await updateManagedGitignoreBlock(
262
270
  path.join(projectPath, ".gitignore"),
263
271
  PROFILE_GITIGNORE_COMMENT,
264
- {
265
- add: [...keep].map(skillIgnoreLine),
266
- remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
267
- },
272
+ edits,
273
+ );
274
+ await updateManagedGitignoreBlock(
275
+ path.join(projectPath, ".worktreeinclude"),
276
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
277
+ edits,
268
278
  );
269
279
  }
270
280
 
@@ -28,6 +28,7 @@
28
28
  import path from "node:path";
29
29
  import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
30
30
  import { selectProfileId } from "./active-closure.js";
31
+ import { updateManagedGitignoreBlock } from "./conventions-manager.js";
31
32
  import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
32
33
  import {
33
34
  type ReconcileResult,
@@ -48,6 +49,8 @@ import {
48
49
  type ActiveMarker,
49
50
  LIVE_FILE_KEYS,
50
51
  MACHINE_LOCAL_SETTINGS_KEYS,
52
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
53
+ PROFILE_WORKTREE_INCLUDE_ENTRIES,
51
54
  activeProfile,
52
55
  folderHash,
53
56
  hasCurrentMarker,
@@ -69,6 +72,7 @@ import {
69
72
  SkillClashError,
70
73
  inspectManagedSkills,
71
74
  skillFolder,
75
+ skillIgnoreLine,
72
76
  } from "./profile-skills.js";
73
77
  import { resolveExtends } from "./resolver.js";
74
78
  import type { SettingsParseError } from "./settings-file.js";
@@ -371,6 +375,20 @@ export async function prepareProfileState(
371
375
  out.lines.push(...activationNotes(result, { tracked: false }));
372
376
  out.activations.push(result);
373
377
  }
378
+ // A project set up by a release that never wrote `.worktreeinclude`
379
+ // re-activates rarely, so the gate lists the set-up state here. A no-op
380
+ // when every line is already there.
381
+ const marker = await readActiveMarker(projectPath);
382
+ await updateManagedGitignoreBlock(
383
+ path.join(projectPath, ".worktreeinclude"),
384
+ PROFILE_WORKTREE_INCLUDE_COMMENT,
385
+ {
386
+ add: [
387
+ ...PROFILE_WORKTREE_INCLUDE_ENTRIES,
388
+ ...Object.keys(marker?.skills ?? {}).map(skillIgnoreLine),
389
+ ],
390
+ },
391
+ );
374
392
  out.check = { state: "active", profile, restored: false };
375
393
  }
376
394
  return out;
@@ -314,6 +314,7 @@ export function requirementOf(bin: Requirement): Requirement {
314
314
  name: bin.name,
315
315
  check: bin.check,
316
316
  ...(bin.os ? { os: bin.os } : {}),
317
+ ...(bin.minVersion !== undefined ? { minVersion: bin.minVersion } : {}),
317
318
  install: bin.install,
318
319
  };
319
320
  }
@@ -6,8 +6,9 @@
6
6
  * Reads the plugin's own `.claude-plugin/plugin.json` through
7
7
  * lib/requires-probe.ts, probes each requirement at presence depth (a PATH
8
8
  * lookup for a check that runs the requirement's own binary, the declared check
9
- * for every other), and prints a `systemMessage` carrying the `magus-deps` marker when something is
10
- * missing or off PATH. Prints nothing when healthy. Its "Fix all" line runs
9
+ * for every other, and for any requirement with a `minVersion`), and prints a
10
+ * `systemMessage` carrying the `magus-deps` marker when something is missing,
11
+ * outdated or off PATH. Prints nothing when healthy. Its "Fix all" line runs
11
12
  * doctor with `--project <cwd>`, the session's directory from the hook input:
12
13
  * doctor reads the plugins enabled in the project it checks, and the banner is
13
14
  * read in a terminal that may be anywhere.
@@ -200,8 +201,11 @@ async function main(): Promise<void> {
200
201
  userShell: process.env.SHELL ?? "",
201
202
  };
202
203
  const presences = await probe(reqs, env);
204
+ // Outdated is a finding like missing: claimed once, reported once.
203
205
  const reportable = (p: Presence) =>
204
- p.status === "missing" || p.status === "off-path";
206
+ p.status === "missing" ||
207
+ p.status === "outdated" ||
208
+ p.status === "off-path";
205
209
  if (!presences.some(reportable)) return;
206
210
 
207
211
  const byName = new Map(reqs.map((r) => [r.name, r]));