magus-cli 7.5.1 → 7.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.5.1",
3
+ "version": "7.5.2",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.5.1",
69
- "magus-cli-darwin-x64": "7.5.1",
70
- "magus-cli-linux-x64": "7.5.1"
68
+ "magus-cli-darwin-arm64": "7.5.2",
69
+ "magus-cli-darwin-x64": "7.5.2",
70
+ "magus-cli-linux-x64": "7.5.2"
71
71
  }
72
72
  }
@@ -1180,3 +1180,163 @@ describe("B4: an update brings the loader forward in the same run", () => {
1180
1180
  expect(await backups()).toEqual([]);
1181
1181
  });
1182
1182
  });
1183
+
1184
+ describe("7.5.2: Claude Code 2.1.281 updates another project's row of the repository", () => {
1185
+ /**
1186
+ * `claude plugin update --scope project` as 2.1.281 does it: the FIRST row
1187
+ * relevant to the repository moves to `version` — here, the main tree's —
1188
+ * and this worktree's own row stays where it was.
1189
+ */
1190
+ async function cli281(version: string): Promise<void> {
1191
+ const registry = await readRegistry();
1192
+ const rows = registry.plugins[PLUGIN];
1193
+ const first = pickLoadedEntry(rows, here) as InstalledPluginEntry;
1194
+ first.version = version;
1195
+ first.installPath = cacheFor(version);
1196
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
1197
+ }
1198
+
1199
+ /** The real registry, rewrite and loader question, against this root. */
1200
+ function deps(version: string): PluginApplyDeps {
1201
+ return {
1202
+ install: async () => {},
1203
+ update: async () => cli281(version),
1204
+ repair: async () => {},
1205
+ readInstalled: async () =>
1206
+ (await readRegistry()).plugins[PLUGIN].find(
1207
+ (e) => e.projectPath === here,
1208
+ )?.version ?? null,
1209
+ saveInstalled: async () => {},
1210
+ registryRows: async () => (await readRegistry()).plugins[PLUGIN],
1211
+ repairStaleLoad: (id, target, projectPath) =>
1212
+ repairStaleLoad(id, target, projectPath, root),
1213
+ loadedVersion: (id, projectPath) =>
1214
+ loadedVersionHere(id, projectPath, root),
1215
+ };
1216
+ }
1217
+
1218
+ const item = (target: string) => ({
1219
+ pluginId: PLUGIN,
1220
+ pinned: "latest",
1221
+ installed: "0.4.0",
1222
+ available: target,
1223
+ target,
1224
+ action: "update" as const,
1225
+ reason: "behind" as const,
1226
+ scopes: ["project" as const],
1227
+ beforeVersions: { project: "0.4.0" },
1228
+ });
1229
+
1230
+ function collect() {
1231
+ const details: string[] = [];
1232
+ return {
1233
+ details,
1234
+ reporter: {
1235
+ begin: () => {},
1236
+ finish: (_ok: boolean, _name: string, detail: string) =>
1237
+ details.push(detail),
1238
+ detach: <T>(work: () => T) => work(),
1239
+ },
1240
+ };
1241
+ }
1242
+
1243
+ test("7.5.2: this worktree's row follows to the version the CLI fetched, and the update says where it wrote", async () => {
1244
+ await writeRegistry([
1245
+ row({ version: "0.4.0", projectPath: mainTree }),
1246
+ row({ version: "0.4.0", projectPath: here }),
1247
+ ]);
1248
+ const { details, reporter } = collect();
1249
+
1250
+ const result = await applyPlugins(
1251
+ [item("0.5.0")],
1252
+ here,
1253
+ deps("0.5.0"),
1254
+ reporter,
1255
+ );
1256
+
1257
+ expect(result.ok).toBe(1);
1258
+ expect(result.unchanged).toEqual([]);
1259
+ expect(
1260
+ (await readRegistry()).plugins[PLUGIN].map((e) => [
1261
+ e.projectPath,
1262
+ e.version,
1263
+ ]),
1264
+ ).toEqual([
1265
+ [mainTree, "0.5.0"],
1266
+ [here, "0.5.0"],
1267
+ ]);
1268
+ expect(details).toEqual([
1269
+ `project → 0.5.0 · Claude Code updated ${mainTree} instead of this project — moved this project's record to v0.5.0 too`,
1270
+ ]);
1271
+ expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
1272
+ });
1273
+
1274
+ test("7.5.2 review: a row of an UNRELATED repository moving during the update is not followed", async () => {
1275
+ const other = path.join(base, "unrelated-752");
1276
+ await fs.ensureDir(other);
1277
+ git(other, "init", "-q");
1278
+ await writeRegistry([
1279
+ row({ version: "0.4.0", projectPath: other }),
1280
+ row({ version: "0.4.0", projectPath: here }),
1281
+ ]);
1282
+ const { details, reporter } = collect();
1283
+ const unrelated: PluginApplyDeps = {
1284
+ ...deps("0.5.0"),
1285
+ // Another repository's session updates its own row meanwhile.
1286
+ update: async () => {
1287
+ const registry = await readRegistry();
1288
+ const moved = registry.plugins[PLUGIN].find(
1289
+ (e) => e.projectPath === other,
1290
+ ) as InstalledPluginEntry;
1291
+ moved.version = "0.5.0";
1292
+ moved.installPath = cacheFor("0.5.0");
1293
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
1294
+ },
1295
+ };
1296
+
1297
+ const result = await applyPlugins(
1298
+ [item("0.5.0")],
1299
+ here,
1300
+ unrelated,
1301
+ reporter,
1302
+ );
1303
+
1304
+ expect(result.unchanged).toEqual([PLUGIN]);
1305
+ expect(
1306
+ (await readRegistry()).plugins[PLUGIN].map((e) => [
1307
+ e.projectPath,
1308
+ e.version,
1309
+ ]),
1310
+ ).toEqual([
1311
+ [other, "0.5.0"],
1312
+ [here, "0.4.0"],
1313
+ ]);
1314
+ expect(details).toEqual([
1315
+ `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0`,
1316
+ ]);
1317
+ });
1318
+
1319
+ test("7.5.2: a version with no cache directory is not followed — no change, and why", async () => {
1320
+ await writeRegistry([
1321
+ row({ version: "0.4.0", projectPath: mainTree }),
1322
+ row({ version: "0.4.0", projectPath: here }),
1323
+ ]);
1324
+ const { details, reporter } = collect();
1325
+
1326
+ const result = await applyPlugins(
1327
+ [item("0.6.0")],
1328
+ here,
1329
+ deps("0.6.0"),
1330
+ reporter,
1331
+ );
1332
+
1333
+ expect(result.unchanged).toEqual([PLUGIN]);
1334
+ expect(
1335
+ (await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
1336
+ ?.version,
1337
+ ).toBe("0.4.0");
1338
+ expect(details[0]).toContain(
1339
+ `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated ${mainTree} instead of this project, and this project's record could not follow: no cache directory for v0.6.0`,
1340
+ );
1341
+ });
1342
+ });
@@ -25,7 +25,7 @@ import type {
25
25
  PluginUpdateItem,
26
26
  UpdatePlan,
27
27
  } from "../services/update-plan.js";
28
- import type { ResolvedBin } from "../types/index.js";
28
+ import type { InstalledPluginEntry, ResolvedBin } from "../types/index.js";
29
29
  import { noMoveReasons } from "../ui/adapters/pluginsAdapter.js";
30
30
 
31
31
  const PROJECT = "/tmp/project";
@@ -925,3 +925,199 @@ describe("B4: an install or update ends with the loader on the new version", ()
925
925
  ).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
926
926
  });
927
927
  });
928
+
929
+ // ── 7.5.2: Claude Code 2.1.281 updates ANOTHER project's row ─────────────────
930
+ //
931
+ // `claude plugin update P --scope project` under 2.1.281 moves the repository's
932
+ // FIRST relevant registry row — the one its loader would pick — not this
933
+ // project's. Measured 2026-09-24, same registry, cwd = the project:
934
+ // 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
935
+ // 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
936
+ // This project's row stayed at 8.0.0, and `magus update` said "no change".
937
+
938
+ describe("7.5.2: an update Claude Code applied to another project's row", () => {
939
+ const HERE = "/tmp/project";
940
+ const OTHER = "/tmp/project/tools/claudeup";
941
+ const UNRELATED = "/tmp/another-repository";
942
+
943
+ /**
944
+ * A registry for dev@magus — another project's row of the same repository
945
+ * first, this project's second — and a fake Claude CLI that updates either
946
+ * the first relevant row (`"2.1.281"`) or this project's (`"2.1.280"`).
947
+ */
948
+ function registry(
949
+ cli: "2.1.280" | "2.1.281" | "unrelated" | "other-version",
950
+ cached: string[] = ["8.1.3", "8.5.0"],
951
+ ) {
952
+ const rows: InstalledPluginEntry[] = [
953
+ {
954
+ scope: "project",
955
+ projectPath: OTHER,
956
+ version: "8.0.0",
957
+ } as InstalledPluginEntry,
958
+ {
959
+ scope: "project",
960
+ projectPath: HERE,
961
+ version: "8.0.0",
962
+ } as InstalledPluginEntry,
963
+ {
964
+ scope: "project",
965
+ projectPath: UNRELATED,
966
+ version: "8.0.0",
967
+ } as InstalledPluginEntry,
968
+ ];
969
+ const repaired: string[] = [];
970
+ const saved: string[] = [];
971
+ const row = (projectPath: string) =>
972
+ rows.find((r) => r.projectPath === projectPath) as InstalledPluginEntry;
973
+ const deps: PluginApplyDeps = {
974
+ install: async () => {},
975
+ update: async () => {
976
+ if (cli === "2.1.280") row(HERE).version = "8.1.3";
977
+ if (cli === "2.1.281") row(OTHER).version = "8.1.3";
978
+ // Another repository's row moving while the CLI runs.
979
+ if (cli === "unrelated") row(UNRELATED).version = "8.1.3";
980
+ // This repository's row, but not to the version this update targets.
981
+ if (cli === "other-version") row(OTHER).version = "8.5.0";
982
+ },
983
+ repair: async () => {},
984
+ readInstalled: async (_id, scope, projectPath) =>
985
+ scope === "project" ? (row(projectPath)?.version ?? null) : null,
986
+ saveInstalled: async (_id, version) => {
987
+ saved.push(version);
988
+ },
989
+ registryRows: async () => rows.map((r) => ({ ...r })),
990
+ // Same repository = this project's tree, as git would answer.
991
+ inThisRepository: (r, projectPath) =>
992
+ (r.projectPath ?? "").startsWith(projectPath),
993
+ // What repairStaleLoad does: refuse an uncached target, else move every
994
+ // row of this repository that is behind it.
995
+ repairStaleLoad: async (pluginId, target) => {
996
+ repaired.push(target);
997
+ if (!cached.includes(target)) {
998
+ return {
999
+ pluginId,
1000
+ target,
1001
+ rewritten: 0,
1002
+ backup: null,
1003
+ loaded: null,
1004
+ ok: false,
1005
+ error: `no cache directory for v${target}`,
1006
+ };
1007
+ }
1008
+ let rewritten = 0;
1009
+ for (const r of rows) {
1010
+ if (r.projectPath?.startsWith(HERE) && r.version !== target) {
1011
+ r.version = target;
1012
+ rewritten++;
1013
+ }
1014
+ }
1015
+ return {
1016
+ pluginId,
1017
+ target,
1018
+ rewritten,
1019
+ backup: null,
1020
+ loaded: target,
1021
+ ok: true,
1022
+ };
1023
+ },
1024
+ // The loader: the first relevant row.
1025
+ loadedVersion: async () => rows[0]?.version ?? null,
1026
+ };
1027
+ return { rows, deps, repaired, saved, row };
1028
+ }
1029
+
1030
+ const update = () =>
1031
+ item({
1032
+ installed: "8.0.0",
1033
+ available: "8.1.3",
1034
+ target: "8.1.3",
1035
+ beforeVersions: { project: "8.0.0" },
1036
+ });
1037
+
1038
+ test("7.5.2: under 2.1.281 this project's row follows to the fetched version, reported as updated", async () => {
1039
+ const { deps, row, repaired, saved } = registry("2.1.281");
1040
+ const { reporter, rows } = recordingReporter();
1041
+
1042
+ const result = await applyPlugins([update()], HERE, deps, reporter);
1043
+
1044
+ expect(row(HERE).version).toBe("8.1.3");
1045
+ expect(row(OTHER).version).toBe("8.1.3"); // the CLI's change stands
1046
+ expect(repaired).toEqual(["8.1.3"]);
1047
+ expect(saved).toEqual(["8.1.3"]);
1048
+ expect(result.ok).toBe(1);
1049
+ expect(result.unchanged).toEqual([]);
1050
+ expect(rows).toEqual([
1051
+ {
1052
+ ok: true,
1053
+ name: "dev@magus",
1054
+ detail: `project → 8.1.3 · Claude Code updated ${OTHER} instead of this project — moved this project's record to v8.1.3 too`,
1055
+ },
1056
+ ]);
1057
+ });
1058
+
1059
+ test("7.5.2: under 2.1.280 the CLI updates this project's row and nothing extra happens", async () => {
1060
+ const { deps, row, repaired } = registry("2.1.280");
1061
+ const { reporter, rows } = recordingReporter();
1062
+
1063
+ const result = await applyPlugins([update()], HERE, deps, reporter);
1064
+
1065
+ expect(row(HERE).version).toBe("8.1.3");
1066
+ expect(result.ok).toBe(1);
1067
+ // Exactly 7.5.1's outcome: the older row ahead of this one is brought
1068
+ // forward by the loader check (B4), and no misdirection is reported.
1069
+ expect(repaired).toEqual(["8.1.3"]);
1070
+ expect(rows[0]?.detail).toBe(
1071
+ "project → 8.1.3 · Claude Code now loads v8.1.3 here (1 older record moved forward)",
1072
+ );
1073
+ });
1074
+
1075
+ test("7.5.2: a version the CLI did not cache is not followed — still an honest no change", async () => {
1076
+ const { deps, row } = registry("2.1.281", []);
1077
+ const { reporter, rows } = recordingReporter();
1078
+
1079
+ const result = await applyPlugins([update()], HERE, deps, reporter);
1080
+
1081
+ expect(row(HERE).version).toBe("8.0.0");
1082
+ expect(result.unchanged).toEqual(["dev@magus"]);
1083
+ expect(rows[0]?.detail).toBe(
1084
+ `no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: no cache directory for v8.1.3)`,
1085
+ );
1086
+ });
1087
+
1088
+ test("7.5.2 review: a row of ANOTHER repository moving to the target is not followed", async () => {
1089
+ const { deps, row, repaired } = registry("unrelated");
1090
+ const { reporter, rows } = recordingReporter();
1091
+
1092
+ const result = await applyPlugins([update()], HERE, deps, reporter);
1093
+
1094
+ expect(row(HERE).version).toBe("8.0.0");
1095
+ expect(repaired).toEqual([]);
1096
+ expect(result.unchanged).toEqual(["dev@magus"]);
1097
+ expect(rows[0]?.detail).toBe(
1098
+ "no change — the installer exited 0 but dev@magus is still 8.0.0",
1099
+ );
1100
+ });
1101
+
1102
+ test("7.5.2 review: this repository's row moving to a version other than the target is not followed", async () => {
1103
+ const { deps, row, repaired } = registry("other-version");
1104
+ const { reporter, rows } = recordingReporter();
1105
+
1106
+ const result = await applyPlugins([update()], HERE, deps, reporter);
1107
+
1108
+ expect(row(HERE).version).toBe("8.0.0");
1109
+ expect(repaired).toEqual([]);
1110
+ expect(result.unchanged).toEqual(["dev@magus"]);
1111
+ expect(rows[0]?.detail).toBe(
1112
+ `no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: it went to v8.5.0, not the v8.1.3 this update asked for)`,
1113
+ );
1114
+ });
1115
+
1116
+ test("7.5.2: the TUI's per-scope update key follows it too", async () => {
1117
+ const { deps, row } = registry("2.1.281");
1118
+ expect(await updatePluginInScope("dev@magus", "project", HERE, deps)).toBe(
1119
+ "8.1.3",
1120
+ );
1121
+ expect(row(HERE).version).toBe("8.1.3");
1122
+ });
1123
+ });
@@ -1991,6 +1991,24 @@ function belongsToThisRepository(
1991
1991
  return repoRootForPath(entry.projectPath, cache) === cwdRoot;
1992
1992
  }
1993
1993
 
1994
+ /**
1995
+ * Is `entry` a row of the repository `projectPath` is in — one
1996
+ * {@link repairStaleLoad} may rewrite? The same predicate, for callers that
1997
+ * judge one row at a time.
1998
+ */
1999
+ export function rowBelongsToRepository(
2000
+ entry: InstalledPluginEntry,
2001
+ projectPath: string,
2002
+ ): boolean {
2003
+ const cache: RepoRootCache = new Map();
2004
+ return belongsToThisRepository(
2005
+ entry,
2006
+ projectPath,
2007
+ repoRootForPath(projectPath, cache),
2008
+ cache,
2009
+ );
2010
+ }
2011
+
1994
2012
  /** A plugin whose loader-resolved version is behind a row this repair can rewrite. */
1995
2013
  export interface StaleLoad {
1996
2014
  pluginId: string;
@@ -31,6 +31,7 @@
31
31
 
32
32
  import path from "node:path";
33
33
  import type {
34
+ InstalledPluginEntry,
34
35
  ProfileSkillRef,
35
36
  ResolvedBin,
36
37
  SkillInfo,
@@ -43,9 +44,12 @@ import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
43
44
  import {
44
45
  type StaleLoadRepairResult,
45
46
  loadedVersionHere,
47
+ readInstalledPluginsRegistry,
46
48
  repairStaleLoad,
49
+ rowBelongsToRepository,
47
50
  } from "./claude-settings.js";
48
51
  import {
52
+ compareVersions,
49
53
  isRepairablyBehind,
50
54
  readInstalledVersionForScope,
51
55
  saveInstalledPluginVersionForScope,
@@ -192,6 +196,21 @@ export interface PluginApplyDeps {
192
196
  pluginId: string,
193
197
  projectPath: string,
194
198
  ) => Promise<string | null>;
199
+ /**
200
+ * Every registry row for a plugin, read fresh — compared before and after
201
+ * `update` to see WHICH row Claude Code moved (see
202
+ * {@link followMisdirectedUpdate}). Optional so a test double that does not
203
+ * model the registry skips the comparison.
204
+ */
205
+ registryRows?: (pluginId: string) => Promise<readonly InstalledPluginEntry[]>;
206
+ /**
207
+ * Is a row this project's repository's — the rows the stale-load repair may
208
+ * rewrite. Defaults to that repair's own predicate (`rowBelongsToRepository`).
209
+ */
210
+ inThisRepository?: (
211
+ row: InstalledPluginEntry,
212
+ projectPath: string,
213
+ ) => boolean;
195
214
  }
196
215
 
197
216
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -202,8 +221,129 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
202
221
  saveInstalled: saveInstalledPluginVersionForScope,
203
222
  repairStaleLoad,
204
223
  loadedVersion: loadedVersionHere,
224
+ registryRows: async (pluginId) => {
225
+ const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
226
+ return Array.isArray(rows) ? rows : [];
227
+ },
205
228
  };
206
229
 
230
+ /**
231
+ * An update Claude Code applied to another project's row instead of this one.
232
+ *
233
+ * Claude Code 2.1.281 changed `claude plugin update P --scope project`: it
234
+ * updates the FIRST registry row relevant to the repository — the row its
235
+ * loader would pick — not the row of the project it runs in. Measured
236
+ * 2026-09-24 on two APFS clones of one sandbox, same registry, cwd = the
237
+ * project:
238
+ *
239
+ * 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
240
+ * 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
241
+ *
242
+ * Both exit 0; under 2.1.281 this project's row stays at 8.0.0.
243
+ */
244
+ export interface MisdirectedUpdate {
245
+ /** Where Claude Code wrote instead: each moved row's project path, or scope. */
246
+ elsewhere: string[];
247
+ /** The version it moved them to. */
248
+ version: string;
249
+ /** Why this project's row did not follow to `version`; null when it did. */
250
+ notFollowed: string | null;
251
+ }
252
+
253
+ /** One registry row's identity: scope, and the project it belongs to. */
254
+ function rowKey(
255
+ row: Pick<InstalledPluginEntry, "scope" | "projectPath">,
256
+ ): string {
257
+ const where = row.projectPath ? path.resolve(row.projectPath) : "";
258
+ return `${row.scope}\u0000${where}`;
259
+ }
260
+
261
+ async function readRows(
262
+ deps: PluginApplyDeps,
263
+ pluginId: string,
264
+ ): Promise<readonly InstalledPluginEntry[] | null> {
265
+ if (!deps.registryRows) return null;
266
+ try {
267
+ return await deps.registryRows(pluginId);
268
+ } catch {
269
+ return null; // No readable registry: nothing to compare.
270
+ }
271
+ }
272
+
273
+ /**
274
+ * After `claude plugin update P --scope <scope>`: if this project's row did not
275
+ * move but another row OF THIS REPOSITORY did — Claude Code 2.1.281 updating
276
+ * the repository's first relevant row instead (see {@link MisdirectedUpdate})
277
+ * — move this project's row forward to that version, with the registry
278
+ * rewrite the stale-load repair performs. That repair refuses a version whose
279
+ * cache directory does not exist.
280
+ *
281
+ * Only rows the repair itself would rewrite count (`inThisRepository`): a row
282
+ * of another repository that happens to move while the CLI runs says nothing
283
+ * about this update. And when the update's `target` is known, the row must
284
+ * have reached exactly that version, or it is not followed.
285
+ *
286
+ * The row Claude Code moved is left as it is: it belongs to this repository,
287
+ * and bringing this repository's rows forward is what the repair does anyway.
288
+ * Null when there was nothing to follow — this project's row moved (2.1.280),
289
+ * or no row of this repository moved at all.
290
+ */
291
+ async function followMisdirectedUpdate(
292
+ deps: PluginApplyDeps,
293
+ pluginId: string,
294
+ scope: PluginScope,
295
+ projectPath: string,
296
+ before: readonly InstalledPluginEntry[],
297
+ target: string | null,
298
+ ): Promise<MisdirectedUpdate | null> {
299
+ const now = await deps.readInstalled(pluginId, scope, projectPath);
300
+ const after = await readRows(deps, pluginId);
301
+ if (!now || !after) return null;
302
+ const inThisRepository = deps.inThisRepository ?? rowBelongsToRepository;
303
+ const was = new Map(before.map((row) => [rowKey(row), row.version]));
304
+ const moved = after.filter(
305
+ (row) =>
306
+ row.version &&
307
+ was.get(rowKey(row)) !== row.version &&
308
+ inThisRepository(row, projectPath),
309
+ );
310
+ const mine = rowKey({
311
+ scope,
312
+ projectPath: scope === "user" ? undefined : projectPath,
313
+ });
314
+ if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
315
+ return null;
316
+ }
317
+ const version = moved
318
+ .map((row) => row.version)
319
+ .reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
320
+ if (!isRepairablyBehind(version, now)) return null;
321
+ const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
322
+ if (target && version !== target) {
323
+ return {
324
+ elsewhere,
325
+ version,
326
+ notFollowed: `it went to v${version}, not the v${target} this update asked for`,
327
+ };
328
+ }
329
+ const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
330
+ return {
331
+ elsewhere,
332
+ version,
333
+ notFollowed: repair.ok
334
+ ? null
335
+ : (repair.error ?? "the registry was not rewritten"),
336
+ };
337
+ }
338
+
339
+ /** The words for a misdirected update, for an item's detail. */
340
+ function describeMisdirected(m: MisdirectedUpdate): string {
341
+ const where = m.elsewhere.join(", ");
342
+ return m.notFollowed === null
343
+ ? `Claude Code updated ${where} instead of this project — moved this project's record to v${m.version} too`
344
+ : `Claude Code updated ${where} instead of this project, and this project's record could not follow: ${m.notFollowed}`;
345
+ }
346
+
207
347
  /**
208
348
  * After a plugin landed at `target` for `projectPath`: make sure that is the
209
349
  * version Claude Code actually loads there, and bring the loader forward when
@@ -319,10 +459,26 @@ async function advanceInScope(
319
459
  pluginId: string,
320
460
  scope: PluginScope,
321
461
  projectPath: string,
322
- ): Promise<void> {
462
+ /** The version this update targets, when known — see followMisdirectedUpdate. */
463
+ target: string | null = null,
464
+ ): Promise<MisdirectedUpdate | null> {
323
465
  const here = await deps.readInstalled(pluginId, scope, projectPath);
324
- if (here) await deps.update(pluginId, scope);
325
- else await deps.install(pluginId, scope);
466
+ if (!here) {
467
+ await deps.install(pluginId, scope);
468
+ return null;
469
+ }
470
+ const before = await readRows(deps, pluginId);
471
+ await deps.update(pluginId, scope);
472
+ return before
473
+ ? followMisdirectedUpdate(
474
+ deps,
475
+ pluginId,
476
+ scope,
477
+ projectPath,
478
+ before,
479
+ target,
480
+ )
481
+ : null;
326
482
  }
327
483
 
328
484
  /**
@@ -449,6 +605,7 @@ export async function applyPlugins(
449
605
  report.begin(item.pluginId);
450
606
  const startedAt = Date.now();
451
607
  const landed: Array<[PluginScope, string | null]> = [];
608
+ const misdirected: MisdirectedUpdate[] = [];
452
609
 
453
610
  try {
454
611
  for (const scope of scopes) {
@@ -467,7 +624,14 @@ export async function applyPlugins(
467
624
  } else if (item.action === "install") {
468
625
  await deps.install(item.pluginId, scope);
469
626
  } else {
470
- await advanceInScope(deps, item.pluginId, scope, projectPath);
627
+ const m = await advanceInScope(
628
+ deps,
629
+ item.pluginId,
630
+ scope,
631
+ projectPath,
632
+ item.target,
633
+ );
634
+ if (m) misdirected.push(m);
471
635
  }
472
636
 
473
637
  // Record what LANDED, not what we asked for.
@@ -520,19 +684,24 @@ export async function applyPlugins(
520
684
  );
521
685
  result.failed.push(item.pluginId);
522
686
  } else if (noMove && !forward) {
687
+ const why = misdirected.map(describeMisdirected);
523
688
  report.finish(
524
689
  true,
525
690
  item.pluginId,
526
- `no change — ${noMove}`,
691
+ `no change — ${noMove}${why.length > 0 ? ` (${why.join("; ")})` : ""}`,
527
692
  Date.now() - startedAt,
528
693
  );
529
694
  result.unchanged.push(item.pluginId);
530
695
  } else {
531
696
  const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
697
+ const notes = [
698
+ ...misdirected.map(describeMisdirected),
699
+ ...(forward ? [describeLoadForward(forward)] : []),
700
+ ];
532
701
  report.finish(
533
702
  true,
534
703
  item.pluginId,
535
- forward ? `${moved} · ${describeLoadForward(forward)}` : moved,
704
+ [moved, ...notes].join(" · "),
536
705
  Date.now() - startedAt,
537
706
  );
538
707
  result.ok++;
@@ -621,8 +790,12 @@ export async function updatePluginInScope(
621
790
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
622
791
  ): Promise<string | null> {
623
792
  // Same guard as the apply loop, for the same reason — see advanceInScope.
793
+ // A misdirected update is followed there; the version read back afterwards
794
+ // is what this returns.
624
795
  return applyOneInScope(
625
- (id, sc) => advanceInScope(deps, id, sc, projectPath),
796
+ async (id, sc) => {
797
+ await advanceInScope(deps, id, sc, projectPath);
798
+ },
626
799
  pluginId,
627
800
  scope,
628
801
  projectPath,