magus-cli 7.2.0 → 7.3.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.
@@ -45,6 +45,7 @@ import {
45
45
  isAuthoritative,
46
46
  resolveMarketplacePlugins,
47
47
  } from "./marketplace-fetcher.js";
48
+ import { resolveLoadedVersions } from "./plugin-resolution.js";
48
49
  export {
49
50
  fetchMarketplacePlugins,
50
51
  getMarketplaceFetchFailures,
@@ -72,6 +73,17 @@ export interface PluginInfo {
72
73
  marketplaceDisplay: string;
73
74
  enabled: boolean;
74
75
  installedVersion?: string;
76
+ /**
77
+ * The version Claude Code's loader will ACTUALLY load in this project, or
78
+ * null when it will load nothing here.
79
+ *
80
+ * An INDEPENDENT column from `installedVersion`, not a correction of it. The
81
+ * two answer different questions — what magus records as installed here,
82
+ * versus what the loader resolves (services/plugin-resolution.ts) — and when
83
+ * they disagree the session runs code that no version magus records names.
84
+ * That disagreement is the finding; averaging the two would hide it.
85
+ */
86
+ loadedVersion?: string | null;
75
87
  /**
76
88
  * Provably behind the catalog, on ONE version pair.
77
89
  *
@@ -277,6 +289,18 @@ export async function getAvailablePlugins(
277
289
  const enabledPlugins = await getEnabledPlugins(projectPath);
278
290
  const installedVersions = await getInstalledPluginVersions(projectPath);
279
291
 
292
+ // What Claude Code will LOAD here, which is a different question from what is
293
+ // installed here and is answered by a different algorithm — the loader's own,
294
+ // ported in services/plugin-resolution.ts. Resolved once for the whole
295
+ // registry and carried alongside `installedVersion` on every row below; where
296
+ // the two disagree, the classifier calls it `stale-load`.
297
+ const loadedVersions = resolveLoadedVersions(
298
+ await readInstalledPluginsRegistry(),
299
+ projectPath ?? process.cwd(),
300
+ );
301
+ const loadedVersionOf = (pluginId: string): string | null =>
302
+ loadedVersions[pluginId] ?? null;
303
+
280
304
  // Fetch all scopes for per-scope status
281
305
  const userEnabledPlugins = await getGlobalEnabledPlugins();
282
306
  const userInstalledVersions = await getGlobalInstalledPluginVersions();
@@ -384,6 +408,7 @@ export async function getAvailablePlugins(
384
408
  marketplaceDisplay: marketplace.displayName,
385
409
  enabled: isEnabled,
386
410
  installedVersion: installedVersion,
411
+ loadedVersion: loadedVersionOf(pluginId),
387
412
  hasUpdate: isBehind(plugin.version, installedVersion) === true,
388
413
  catalogSource: resolution.source,
389
414
  updateCheckFailed: unverified,
@@ -423,6 +448,7 @@ export async function getAvailablePlugins(
423
448
  marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
424
449
  enabled: isEnabled,
425
450
  installedVersion: installedVersion,
451
+ loadedVersion: loadedVersionOf(pluginId),
426
452
  hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
427
453
  ...scopeStatus,
428
454
  category: localPlugin.category,
@@ -472,6 +498,7 @@ export async function getAvailablePlugins(
472
498
  marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
473
499
  enabled: isEnabled,
474
500
  installedVersion: installedVersion,
501
+ loadedVersion: loadedVersionOf(pluginId),
475
502
  hasUpdate,
476
503
  isOrphaned: true,
477
504
  ...scopeStatus,
@@ -652,6 +679,40 @@ export function isBehind(
652
679
  return bothOpaque ? true : null;
653
680
  }
654
681
 
682
+ /**
683
+ * Is `loaded` behind `target` in a way that MOVING FORWARD would actually fix?
684
+ *
685
+ * THE predicate behind the `stale-load` state. Both the classifier (which counts
686
+ * broken plugins in the Plugins header) and `findStaleLoads` (which counts them
687
+ * for the startup popup) ask this and nothing else, so the two surfaces cannot
688
+ * report different numbers for the same directory. They did: the popup said 8
689
+ * where the header said 6, because it asked {@link isBehind} instead.
690
+ *
691
+ * Stricter than `isBehind` in exactly one place, and deliberately: `isBehind`
692
+ * answers TRUE for two differing opaque ids, because for its question — "is this
693
+ * what the marketplace publishes now" — a different commit sha means no. That
694
+ * reasoning does not carry here. "Move the rows forward from sha A to sha B" is
695
+ * not a repair, it is a coin toss: the two carry no ordering, so nothing
696
+ * establishes which is later. Measured on the reporting machine, that alone put
697
+ * `skill-creator@claude-plugins-official` (two differing shas) and
698
+ * `plugin-dev@claude-plugins-official` (version literally `"unknown"`) into the
699
+ * popup's count, and neither could be repaired — `repairStaleLoad` rejects them
700
+ * at its cache-directory check.
701
+ *
702
+ * So this requires an ORDERED pair: `compareVersions` must return a number, and
703
+ * that number must be positive. A version against `null`, a semver against an
704
+ * opaque id, `"unknown"` against anything, and two differing shas all answer
705
+ * false — "cannot rank these" is never grounds for rewriting a registry row.
706
+ */
707
+ export function isRepairablyBehind(
708
+ target: string | null | undefined,
709
+ loaded: string | null | undefined,
710
+ ): boolean {
711
+ if (!target || !loaded) return false;
712
+ const cmp = compareVersions(target, loaded);
713
+ return cmp !== null && cmp > 0;
714
+ }
715
+
655
716
  // Get installed plugin versions from settings.json (shared, not secret)
656
717
  async function getInstalledPluginVersions(
657
718
  projectPath?: string,
@@ -0,0 +1,280 @@
1
+ /**
2
+ * What Claude Code's loader will ACTUALLY load in this directory.
3
+ *
4
+ * Ported from Claude Code 2.1.274, decompiled: the relevance filter `SF`
5
+ * (byte 178070986) and the selection function `Bls` (byte 178140984). The port
6
+ * was validated against the real loader over 123/123 plugin resolutions across
7
+ * 12 repositories and worktrees. RE-VALIDATE IT WHEN CLAUDE CODE UPDATES —
8
+ * this file describes someone else's algorithm, so a new release can move it
9
+ * without anything here failing.
10
+ *
11
+ * Why it exists: magus reported versions from `settings.installedPluginVersions`
12
+ * overlaid with this project's `installed_plugins.json` rows (see
13
+ * `pickRegistryEntry` in claude-settings.ts). The loader consults neither of
14
+ * those orderings, so magus could report "up to date" while the session in that
15
+ * same directory loaded a two-versions-old plugin. The two answers are
16
+ * genuinely different questions and both are kept:
17
+ *
18
+ * pickRegistryEntry — "what does magus consider installed for this scope"
19
+ * pickLoadedEntry — "what will Claude Code load here"
20
+ *
21
+ * This module holds NO magus policy. It answers the second question only, so
22
+ * that a disagreement between the two is visible rather than averaged away.
23
+ *
24
+ * KNOWN DIVERGENCES from the decompiled original, both of which only ever REMOVE
25
+ * candidates — so each can make this module name an entry the loader does not
26
+ * use, and neither can make it miss one the loader does use:
27
+ *
28
+ * 1. The binary additionally rejects an entry whose `installPath` fails a
29
+ * path-safety check (`suspect(installPath)`). Not ported — every
30
+ * `installPath` is treated as non-suspect here.
31
+ * 2. The binary's relevance filter reads `entry.projectPath === originalCwd ||
32
+ * repoRoot(entry.projectPath) === repoRoot(originalCwd)` with no guard on
33
+ * `projectPath` being absent at all. A project-scope row carrying no
34
+ * `projectPath` is rejected outright here (`isRelevantInstallation`), because
35
+ * what `repoRoot(undefined)` answers inside the binary was not measured and
36
+ * guessing it would put a row into the selection on a guess. Claude Code
37
+ * writes `projectPath` for every project-scope install, so the case is
38
+ * theoretical rather than observed.
39
+ *
40
+ * A THIRD divergence was removed rather than documented: two rootless paths used
41
+ * to compare unequal here, so in a directory belonging to no repository every
42
+ * rootless row was irrelevant to magus while the loader still counted it. See
43
+ * {@link isRelevantInstallation}.
44
+ */
45
+
46
+ import fs from "node:fs";
47
+ import path from "node:path";
48
+ import type {
49
+ InstalledPluginEntry,
50
+ InstalledPluginsRegistry,
51
+ } from "../types/index.js";
52
+
53
+ /**
54
+ * Memo for {@link repoRootForPath}. A registry holds one row per project path
55
+ * and a well-used machine has hundreds of them, most repeated across plugins;
56
+ * each miss walks the tree to the filesystem root with a `stat` per level.
57
+ *
58
+ * Deliberately passed in rather than held at module scope: a git root can come
59
+ * into existence while the TUI is open, and a process-lifetime cache would
60
+ * answer from before it did.
61
+ */
62
+ export type RepoRootCache = Map<string, string | null>;
63
+
64
+ /**
65
+ * Claude Code writes a `managed` scope for policy-installed plugins. The
66
+ * registry type in `types/index.ts` names only the three scopes magus itself
67
+ * writes, so the extra one is widened here instead of loosening a type that
68
+ * dozens of call sites narrow on.
69
+ */
70
+ type LoaderScope = InstalledPluginEntry["scope"] | "managed";
71
+
72
+ /**
73
+ * Scopes the loader treats as relevant to EVERY project, whatever its path:
74
+ * `user` (the machine-wide install) and `managed` (administrator policy).
75
+ *
76
+ * Exported because a writer has to ask the same question the filter does. A row
77
+ * in one of these scopes is not this repository's record — moving it forward
78
+ * changes what every project on the machine loads — so `repairStaleLoad` refuses
79
+ * to rewrite one instead of quietly reaching that far.
80
+ */
81
+ export function isMachineWideScope(
82
+ scope: LoaderScope | string | undefined,
83
+ ): boolean {
84
+ return scope === "user" || scope === "managed";
85
+ }
86
+
87
+ /** `.git` as an entry of `dir`: a directory (real checkout), a file (pointer), or absent. */
88
+ function gitEntryKind(dir: string): "dir" | "file" | null {
89
+ try {
90
+ const stat = fs.statSync(path.join(dir, ".git"));
91
+ if (stat.isDirectory()) return "dir";
92
+ if (stat.isFile()) return "file";
93
+ return null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The main repository root behind a linked worktree's `.git` FILE, which holds
101
+ * `gitdir: <main>/.git/worktrees/<name>`.
102
+ *
103
+ * The `.git` component is searched for first and the `worktrees` component only
104
+ * as a fallback, because the former sits ABOVE the latter in the usual layout
105
+ * and matching `worktrees` first would answer `<main>/.git`. The fallback is
106
+ * for a common directory kept outside a `.git` directory (a bare
107
+ * `/srv/repo.git/worktrees/<name>`), where the repository itself is the closest
108
+ * thing to a root. The same walk maps a submodule's
109
+ * `<super>/.git/modules/<name>` onto the superproject root.
110
+ */
111
+ function mainRootFromGitFile(gitFile: string, dir: string): string | null {
112
+ let gitdir: string;
113
+ try {
114
+ const match = /^\s*gitdir:\s*(.+?)\s*$/m.exec(
115
+ fs.readFileSync(gitFile, "utf8"),
116
+ );
117
+ // A `.git` file we cannot parse still marks a working tree, and `dir` is
118
+ // the only root it could name.
119
+ if (!match?.[1]) return dir;
120
+ gitdir = path.resolve(dir, match[1]);
121
+ } catch {
122
+ return dir;
123
+ }
124
+
125
+ for (const component of [".git", "worktrees"]) {
126
+ let cur = gitdir;
127
+ for (;;) {
128
+ const parent = path.dirname(cur);
129
+ if (path.basename(cur) === component) return parent;
130
+ if (parent === cur) break;
131
+ cur = parent;
132
+ }
133
+ }
134
+ return gitdir;
135
+ }
136
+
137
+ /**
138
+ * The repository root containing `p`, or null when it is in no repository.
139
+ *
140
+ * PATH-BASED, never a `git` subprocess, and that is the whole point: registry
141
+ * rows for DELETED worktrees still take part in the selection below, and every
142
+ * git command fails on a directory that is not there — so a subprocess
143
+ * implementation returns "no root" for exactly the entries that decide the
144
+ * answer. Walking up works on a path that does not exist: the nearest surviving
145
+ * ancestor still carries the `.git` entry.
146
+ */
147
+ export function repoRootForPath(
148
+ p: string,
149
+ cache?: RepoRootCache,
150
+ ): string | null {
151
+ if (!p) return null;
152
+ const start = path.resolve(p);
153
+ const memo = cache?.get(start);
154
+ if (memo !== undefined) return memo;
155
+
156
+ let dir = start;
157
+ let root: string | null = null;
158
+ for (;;) {
159
+ const kind = gitEntryKind(dir);
160
+ if (kind === "dir") {
161
+ root = dir;
162
+ break;
163
+ }
164
+ if (kind === "file") {
165
+ root = mainRootFromGitFile(path.join(dir, ".git"), dir);
166
+ break;
167
+ }
168
+ const parent = path.dirname(dir);
169
+ if (parent === dir) break;
170
+ dir = parent;
171
+ }
172
+
173
+ cache?.set(start, root);
174
+ return root;
175
+ }
176
+
177
+ /**
178
+ * `SF` — is this installation relevant to the current project?
179
+ *
180
+ * Three ways to be relevant, and the third is the one that surprises people: a
181
+ * row installed ANYWHERE in the same repository counts, including a worktree
182
+ * that has since been deleted. Nothing prunes those rows, so they keep voting.
183
+ *
184
+ * @param cwdRoot {@link repoRootForPath} of `cwd`, resolved once by the caller
185
+ * because it is the same answer for every entry.
186
+ */
187
+ export function isRelevantInstallation(
188
+ entry: InstalledPluginEntry,
189
+ cwd: string,
190
+ cwdRoot: string | null,
191
+ cache?: RepoRootCache,
192
+ ): boolean {
193
+ if (isMachineWideScope(entry.scope)) return true;
194
+ if (!entry.projectPath) return false;
195
+
196
+ // The binary compares the two paths as raw strings. They are normalised here
197
+ // first, which can only turn a spelling difference of the same directory
198
+ // ("/p/a/" vs "/p/a") into a match — and that same pair already matches on
199
+ // the repository-root comparison below, which normalises on the way through.
200
+ if (path.resolve(entry.projectPath) === path.resolve(cwd)) return true;
201
+
202
+ // NULL MATCHES NULL, because the original's `repoRoot(entry.projectPath) ===
203
+ // repoRoot(originalCwd)` does: two directories in no repository at all compare
204
+ // equal there, so every rootless row competes when Claude Code runs from a
205
+ // scratch directory, a home directory, or any plain non-git folder.
206
+ //
207
+ // This used to `return false` on a null `cwdRoot`, on the reasoning that two
208
+ // unrelated rootless directories are not the same project. That reasoning is
209
+ // magus policy, and this module holds none: with it, magus could name a
210
+ // `loadedVersion` the loader would not pick, and a repair would skip the very
211
+ // rows the loader treats as relevant — the same class of wrong answer the port
212
+ // exists to eliminate.
213
+ //
214
+ // The policy did not disappear, it moved to where it belongs: `repairStaleLoad`
215
+ // rewrites a row only when it belongs to THIS repository, which requires either
216
+ // the exact same directory or a non-null shared root. So a rootless row from an
217
+ // unrelated directory is reported as relevant (true) and never written (correct).
218
+ return repoRootForPath(entry.projectPath, cache) === cwdRoot;
219
+ }
220
+
221
+ /**
222
+ * `Bls` — the entry the loader will use, or undefined when it loads nothing.
223
+ *
224
+ * Faithful to the decompiled original, including the two parts that look like
225
+ * bugs and are load-bearing:
226
+ *
227
+ * - The single-candidate branch returns WITHOUT checking that `installPath`
228
+ * exists. One relevant row means that row loads (or fails to), whatever is
229
+ * on disk.
230
+ * - `firstValid` is captured BEFORE the existence test, so the fallback is the
231
+ * first entry that merely HAS an `installPath` — not the first existing one.
232
+ *
233
+ * ARRAY ORDER decides ties, and array order is install order. It is not sorted
234
+ * by version, by scope, or by recency, so an older install made earlier beats a
235
+ * newer one made later. That is the madbench 0.3.0 defect in one sentence.
236
+ */
237
+ export function pickLoadedEntry(
238
+ entries: readonly InstalledPluginEntry[] | undefined,
239
+ cwd: string,
240
+ cache?: RepoRootCache,
241
+ ): InstalledPluginEntry | undefined {
242
+ if (!Array.isArray(entries) || entries.length === 0) return undefined;
243
+
244
+ const cwdRoot = repoRootForPath(cwd, cache);
245
+ const relevant = entries.filter((e) =>
246
+ isRelevantInstallation(e, cwd, cwdRoot, cache),
247
+ );
248
+ if (relevant.length === 0) return undefined;
249
+ if (relevant.length === 1) return relevant[0];
250
+
251
+ let firstValid: InstalledPluginEntry | undefined;
252
+ for (const entry of relevant) {
253
+ if (!entry.installPath) continue;
254
+ if (firstValid === undefined) firstValid = entry;
255
+ if (fs.existsSync(entry.installPath)) return entry;
256
+ }
257
+ return firstValid ?? relevant[0];
258
+ }
259
+
260
+ /**
261
+ * pluginId → the version that will actually load in `cwd`.
262
+ *
263
+ * Plugins whose winning entry carries no version are absent from the map rather
264
+ * than present with a placeholder: "no version recorded" is not "loads nothing",
265
+ * and a caller comparing against it must be able to tell.
266
+ */
267
+ export function resolveLoadedVersions(
268
+ registry: InstalledPluginsRegistry,
269
+ cwd: string,
270
+ ): Record<string, string> {
271
+ // One cache for the whole pass: the same few hundred project paths repeat
272
+ // across every plugin in the registry.
273
+ const cache: RepoRootCache = new Map();
274
+ const out: Record<string, string> = {};
275
+ for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
276
+ const pick = pickLoadedEntry(entries, cwd, cache);
277
+ if (pick?.version) out[pluginId] = pick.version;
278
+ }
279
+ return out;
280
+ }
@@ -40,6 +40,10 @@ import { formatCommand } from "../utils/command-utils.js";
40
40
  import { runCommand } from "../utils/run.js";
41
41
  import type { PluginScope } from "./claude-cli.js";
42
42
  import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
43
+ import {
44
+ type StaleLoadRepairResult,
45
+ repairStaleLoad,
46
+ } from "./claude-settings.js";
43
47
  import {
44
48
  readInstalledVersionForScope,
45
49
  saveInstalledPluginVersionForScope,
@@ -165,6 +169,18 @@ export interface PluginApplyDeps {
165
169
  scope: PluginScope,
166
170
  projectPath: string,
167
171
  ) => Promise<void>;
172
+ /**
173
+ * The `stale-load` repair — NOT a Claude Code command, and the only dep here
174
+ * that is not. No `claude plugin` invocation can reach the registry rows that
175
+ * cause a stale load, so this one edits `installed_plugins.json` directly;
176
+ * `repairStaleLoad`'s own doc comment carries the full reasoning and the
177
+ * user's approval of the exception.
178
+ */
179
+ repairStaleLoad: (
180
+ pluginId: string,
181
+ target: string,
182
+ projectPath: string,
183
+ ) => Promise<StaleLoadRepairResult>;
168
184
  }
169
185
 
170
186
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -173,6 +189,7 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
173
189
  repair: repairPlugin,
174
190
  readInstalled: readInstalledVersionForScope,
175
191
  saveInstalled: saveInstalledPluginVersionForScope,
192
+ repairStaleLoad,
176
193
  };
177
194
 
178
195
  // -- selection ---------------------------------------------------------------
@@ -189,7 +206,18 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
189
206
  * project row stays untouched, so the same drift reappears on the next run.
190
207
  */
191
208
  function scopesToApply(item: PluginUpdateItem): PluginScope[] {
192
- if (item.action === "current" || item.action === "unknown") return [];
209
+ // `stale-load` has no scopes, and that is not "skipped": it is applied by
210
+ // `repairStaleLoad`, which rewrites registry rows rather than installing
211
+ // anything, so there is no per-scope work to enumerate. A reinstall is
212
+ // measured NOT to clear it — it writes another row for this project while the
213
+ // row the loader picks belongs to a different one — which is exactly why it
214
+ // must not come through here. {@link pluginNeedsWork} counts it separately.
215
+ if (
216
+ item.action === "current" ||
217
+ item.action === "unknown" ||
218
+ item.action === "stale-load"
219
+ )
220
+ return [];
193
221
  return item.action === "install" ? ["project"] : item.scopes;
194
222
  }
195
223
 
@@ -200,9 +228,12 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
200
228
  * predicate the loop applies. Re-deriving it at the call site is how the two
201
229
  * drift, and a bar that stops short of full at the end of a successful run
202
230
  * looks exactly like a hang.
231
+ *
232
+ * `stale-load` is work without scopes — see {@link scopesToApply} — so it is
233
+ * named here rather than counted through the scope list.
203
234
  */
204
235
  export function pluginNeedsWork(item: PluginUpdateItem): boolean {
205
- return scopesToApply(item).length > 0;
236
+ return item.action === "stale-load" || scopesToApply(item).length > 0;
206
237
  }
207
238
 
208
239
  // -- plugins -----------------------------------------------------------------
@@ -277,6 +308,67 @@ function pluginNoMove(
277
308
  return null;
278
309
  }
279
310
 
311
+ /**
312
+ * Repair ONE broken plugin — the `stale-load` half of {@link applyPlugins}.
313
+ *
314
+ * Success is the repair's own POST-WRITE assertion (`StaleLoadRepairResult.ok`),
315
+ * never "the call returned". The whole class of bug this state reports is a
316
+ * version that looks right and does not load, so a repair that rewrote rows
317
+ * without changing what the loader resolves is a FAILURE and says so.
318
+ */
319
+ async function applyStaleLoad(
320
+ item: PluginUpdateItem,
321
+ projectPath: string,
322
+ deps: PluginApplyDeps,
323
+ report: ApplyReporter,
324
+ result: PluginApplyResult,
325
+ ): Promise<void> {
326
+ report.begin(item.pluginId);
327
+ const startedAt = Date.now();
328
+ // The classifier sets this to the version installed here; a null one means
329
+ // there is nothing on disk to point the rows at, so there is no repair to run.
330
+ if (!item.target) {
331
+ report.finish(
332
+ false,
333
+ item.pluginId,
334
+ `no installed version to point ${item.pluginId}'s registry rows at`,
335
+ Date.now() - startedAt,
336
+ );
337
+ result.failed.push(item.pluginId);
338
+ return;
339
+ }
340
+ try {
341
+ const outcome = await deps.repairStaleLoad(
342
+ item.pluginId,
343
+ item.target,
344
+ projectPath,
345
+ );
346
+ if (outcome.ok) {
347
+ report.finish(
348
+ true,
349
+ item.pluginId,
350
+ `registry → v${outcome.target} (${outcome.rewritten} row${
351
+ outcome.rewritten === 1 ? "" : "s"
352
+ } moved forward)`,
353
+ Date.now() - startedAt,
354
+ );
355
+ result.ok++;
356
+ } else {
357
+ report.finish(
358
+ false,
359
+ item.pluginId,
360
+ outcome.error ?? `${item.pluginId} still does not load v${item.target}`,
361
+ Date.now() - startedAt,
362
+ );
363
+ result.failed.push(item.pluginId);
364
+ }
365
+ } catch (error) {
366
+ const msg = error instanceof Error ? error.message : String(error);
367
+ report.finish(false, item.pluginId, msg, Date.now() - startedAt);
368
+ result.failed.push(item.pluginId);
369
+ }
370
+ }
371
+
280
372
  export async function applyPlugins(
281
373
  items: PluginUpdateItem[],
282
374
  projectPath: string,
@@ -291,6 +383,16 @@ export async function applyPlugins(
291
383
  };
292
384
 
293
385
  for (const item of items) {
386
+ // A stale load is repaired in the REGISTRY, not by any installer, so it
387
+ // leaves the scope loop entirely. Its own branch rather than a scope with a
388
+ // special case inside: everything below reads back a per-scope version and
389
+ // compares it against the catalog, and none of those questions applies to a
390
+ // row rewrite.
391
+ if (item.action === "stale-load") {
392
+ await applyStaleLoad(item, projectPath, deps, report, result);
393
+ continue;
394
+ }
395
+
294
396
  const scopes = scopesToApply(item);
295
397
  if (scopes.length === 0) continue;
296
398