magus-cli 7.5.3 → 7.5.4

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 (59) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/mate-availability.test.ts +11 -13
  13. package/src/__tests__/plugin-contents.test.ts +0 -1
  14. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  15. package/src/__tests__/plugin-row-text.test.ts +18 -21
  16. package/src/__tests__/profile-command.test.ts +2 -2
  17. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  18. package/src/__tests__/profile-skills.test.ts +3 -3
  19. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  20. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  21. package/src/__tests__/profile-state.test.ts +27 -22
  22. package/src/__tests__/scope-action.test.ts +35 -38
  23. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  24. package/src/__tests__/select-restore.test.ts +209 -0
  25. package/src/__tests__/stale-load-repair.test.ts +1 -2
  26. package/src/__tests__/update-apply.test.ts +18 -2
  27. package/src/__tests__/update-null-readback.test.ts +300 -0
  28. package/src/__tests__/update-plan-parity.test.ts +249 -0
  29. package/src/__tests__/update-plan.test.ts +66 -67
  30. package/src/__tests__/version-unknown-state.test.ts +38 -39
  31. package/src/cli/bootstrap.ts +239 -66
  32. package/src/cli/doctor.ts +4 -3
  33. package/src/cli/install.ts +83 -27
  34. package/src/cli/prompt.ts +7 -3
  35. package/src/cli/router.ts +1 -0
  36. package/src/cli/select.ts +168 -0
  37. package/src/cli/update-view.ts +118 -6
  38. package/src/cli/update.ts +6 -4
  39. package/src/services/claude-settings.ts +28 -0
  40. package/src/services/manifest.ts +31 -0
  41. package/src/services/mate-availability.ts +23 -29
  42. package/src/services/plugin-manager.ts +247 -105
  43. package/src/services/profile-absorb.ts +10 -7
  44. package/src/services/profile-activate.ts +39 -18
  45. package/src/services/profile-startup.ts +160 -4
  46. package/src/services/profile-state.ts +7 -6
  47. package/src/services/tracked-live-files.ts +24 -3
  48. package/src/services/update-engine.ts +28 -10
  49. package/src/services/update-plan.ts +78 -94
  50. package/src/ui/App.tsx +7 -2
  51. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  52. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  53. package/src/ui/hooks/useProfileStartupModal.ts +87 -7
  54. package/src/ui/keymap.ts +1 -1
  55. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  56. package/src/ui/renderers/pluginRowText.ts +6 -2
  57. package/src/ui/renderers/pluginStatusText.ts +4 -3
  58. package/src/ui/screens/PluginsScreen.tsx +28 -41
  59. package/src/cli/tui-prompt.tsx +0 -143
@@ -76,9 +76,10 @@ import {
76
76
  } from "./profile-skills.js";
77
77
  import {
78
78
  GitCheckError,
79
+ type TrackedNotice,
80
+ trackedNotice,
79
81
  trackedPaths,
80
82
  trackedSkillFolders,
81
- trackedWarning,
82
83
  } from "./tracked-live-files.js";
83
84
  import type { ApplyReporter } from "./update-engine.js";
84
85
 
@@ -127,8 +128,11 @@ export function reportDisplaced(
127
128
  projectPath: string,
128
129
  ): (artifact: DisplacedArtifact) => void {
129
130
  const rel = (p: string) => path.relative(projectPath, p);
130
- return ({ link, savedTo }) =>
131
- console.log(` ${rel(link)} → kept at ${rel(savedTo)}`);
131
+ return ({ link }) =>
132
+ // The user's file, never where the backup went: that is magus's own
133
+ // storage, and the owner wants only what the user uses on screen.
134
+ // `savedTo` stays in the result for anything that needs to find it.
135
+ console.log(` Backed up your previous ${rel(link)}`);
132
136
  }
133
137
 
134
138
  /**
@@ -157,20 +161,31 @@ export async function trackedGeneratedFiles(
157
161
  }
158
162
 
159
163
  /**
160
- * {@link trackedGeneratedFiles} as the warning lines the CLI prints: one per
161
- * tracked path, or one saying git could not be asked.
164
+ * {@link trackedGeneratedFiles} as the notices the CLI prints: one per tracked
165
+ * path, or one saying git could not be asked.
162
166
  */
163
- export async function trackedGeneratedWarnings(
167
+ export async function trackedGeneratedNotices(
164
168
  projectPath: string,
165
- ): Promise<string[]> {
169
+ ): Promise<TrackedNotice[]> {
166
170
  try {
167
- return (await trackedGeneratedFiles(projectPath)).map(trackedWarning);
171
+ return (await trackedGeneratedFiles(projectPath)).map(trackedNotice);
168
172
  } catch (error) {
169
- if (error instanceof GitCheckError) return [error.message];
173
+ if (error instanceof GitCheckError) {
174
+ return [{ problem: error.message, fix: [] }];
175
+ }
170
176
  throw error;
171
177
  }
172
178
  }
173
179
 
180
+ /** The tracked-path notices an activation result carries. */
181
+ export function activationTrackedNotices(
182
+ result: ActivationResult,
183
+ ): TrackedNotice[] {
184
+ return typeof result.tracked === "string"
185
+ ? [{ problem: result.tracked, fix: [] }]
186
+ : result.tracked.map(trackedNotice);
187
+ }
188
+
174
189
  /**
175
190
  * The folders of `skills` the team committed under `.claude/skills/`. Such a
176
191
  * folder is the team's copy of the skill: magus neither installs over it nor
@@ -195,16 +210,21 @@ export function releasedCommittedSkillNote(folder: string): string {
195
210
  /**
196
211
  * Lines describing what activation did beyond the switch itself, phrased for
197
212
  * both the terminal and the TUI's outcome modal.
213
+ *
214
+ * `tracked: false` leaves the tracked-path notices out, for the CLI, which
215
+ * prints them itself as a warning with the fix beneath it
216
+ * ({@link activationTrackedNotices}).
198
217
  */
199
- export function activationNotes(result: ActivationResult): string[] {
218
+ export function activationNotes(
219
+ result: ActivationResult,
220
+ opts: { tracked?: boolean } = {},
221
+ ): string[] {
200
222
  const lines: string[] = [
201
223
  ...(result.absorbed ?? []),
202
224
  ...describeDependencies(result.dependencies, result.profileId),
203
225
  ];
204
226
  for (const folder of result.conversion.restored) {
205
- lines.push(
206
- `Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
207
- );
227
+ lines.push(`Restored your custom skill "${folder}"`);
208
228
  }
209
229
  for (const c of result.conversion.conflicts) {
210
230
  lines.push(
@@ -222,12 +242,13 @@ export function activationNotes(result: ActivationResult): string[] {
222
242
  `Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
223
243
  );
224
244
  }
225
- if (typeof result.tracked === "string") lines.push(result.tracked);
226
- else lines.push(...result.tracked.map(trackedWarning));
245
+ if (opts.tracked !== false) {
246
+ for (const n of activationTrackedNotices(result)) {
247
+ lines.push(n.problem, ...n.fix);
248
+ }
249
+ }
227
250
  if (result.keptAt) {
228
- lines.push(
229
- `The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
230
- );
251
+ lines.push("A backup of the old profile format was kept");
231
252
  }
232
253
  return lines;
233
254
  }
@@ -26,6 +26,12 @@
26
26
  *
27
27
  * ok a profile is active; the manifest is in effect; ask nothing
28
28
  * unmigrated no manifest — the project is not using profiles at all
29
+ * old-format the project is on an older magus profile format; asked about
30
+ * before it is updated, in the CLI and the TUI alike
31
+ * nested no manifest here, but this folder is inside a project that
32
+ * has one — Claude Code started here would read this folder's
33
+ * settings, not that project's, so nothing is offered: the way
34
+ * on is running magus in that project, or `magus profile init`
29
35
  * inactive a manifest with profiles, none of them activated
30
36
  * unknown the active-profile marker names a profile the manifest does
31
37
  * not define; the only way on is activating one that exists
@@ -50,18 +56,25 @@
50
56
  * detects.
51
57
  */
52
58
 
59
+ import path from "node:path";
53
60
  import type { ProfileManifest } from "../types/index.js";
54
61
  // Type-only, so it is erased: this module stays free of the UI and of React,
55
62
  // which is what lets `magus hook` and the CLI import it. The shape of a
56
63
  // selectable option is presentation-neutral data, and both surfaces render the
57
64
  // same list from these builders rather than each composing its own.
58
65
  import type { SelectOption } from "../ui/state/types.js";
59
- import { MANIFEST_FILE, emptyManifest, readManifest } from "./manifest.js";
66
+ import {
67
+ MANIFEST_FILE,
68
+ emptyManifest,
69
+ findEnclosingManifest,
70
+ readManifest,
71
+ } from "./manifest.js";
60
72
  import type { ProfileChoice } from "./profile-activate.js";
61
73
  import { freeProfileId } from "./profile-adopt.js";
62
74
  import {
63
75
  activeProfile,
64
76
  displaceableArtifacts,
77
+ hasOlderLayout,
65
78
  namedActiveProfile,
66
79
  } from "./profile-live.js";
67
80
  import { prepareProfileState } from "./profile-state.js";
@@ -71,6 +84,8 @@ import { SettingsParseError } from "./settings-file.js";
71
84
  export type ProfileStartupState =
72
85
  | "ok"
73
86
  | "unmigrated"
87
+ | "old-format"
88
+ | "nested"
74
89
  | "inactive"
75
90
  | "unknown"
76
91
  | "unparseable";
@@ -102,6 +117,11 @@ export interface ProfileStartupNeed {
102
117
  activate: ActivatableProfile[];
103
118
  /** For `unknown`: the profile the marker names. */
104
119
  unknownProfile?: string;
120
+ /**
121
+ * For `nested`: the enclosing project — its path relative to this folder
122
+ * and its folder name, which is how the user knows it.
123
+ */
124
+ enclosing?: { rel: string; name: string };
105
125
  /**
106
126
  * The profile this project declares it wants, if the manifest names one that
107
127
  * exists. Used to preselect the row so Enter does the intended thing.
@@ -167,6 +187,24 @@ export async function detectProfileStartupNeed(
167
187
  const ids = Object.keys(manifest.profiles);
168
188
 
169
189
  if (ids.length === 0) {
190
+ // One decision for the CLI and the TUI alike. It lived in the CLI alone,
191
+ // so `magus update` in a subfolder explained and stopped while the TUI in
192
+ // the same folder offered to save a second, separate profile.
193
+ const enclosing = await findEnclosingManifest(projectPath);
194
+ if (enclosing !== null) {
195
+ return {
196
+ state: "nested",
197
+ adopt: null,
198
+ activate: [],
199
+ declaredDefault: null,
200
+ autoActivate: false,
201
+ manifest,
202
+ enclosing: {
203
+ rel: path.relative(projectPath, enclosing),
204
+ name: path.basename(enclosing),
205
+ },
206
+ };
207
+ }
170
208
  return {
171
209
  state: "unmigrated",
172
210
  adopt: freeProfileId(manifest),
@@ -242,11 +280,45 @@ export async function detectProfileStartupNeed(
242
280
  };
243
281
  }
244
282
 
283
+ /**
284
+ * The `old-format` question, or null when the project is not on an older
285
+ * magus profile format. One source for the CLI's gate and the TUI's startup,
286
+ * so both ask the same thing — the TUI used to convert without asking while
287
+ * the CLI asked.
288
+ */
289
+ export async function oldFormatNeed(
290
+ projectPath: string,
291
+ ): Promise<ProfileStartupNeed | null> {
292
+ if (!(await hasOlderLayout(projectPath))) return null;
293
+ let manifest: ProfileManifest;
294
+ try {
295
+ manifest = await readManifest(projectPath);
296
+ } catch (error) {
297
+ if (!(error instanceof SettingsParseError)) throw error;
298
+ return unparseableNeed(error);
299
+ }
300
+ return {
301
+ state: "old-format",
302
+ adopt: null,
303
+ activate: [],
304
+ declaredDefault: null,
305
+ autoActivate: false,
306
+ manifest,
307
+ };
308
+ }
309
+
245
310
  /** What the TUI shows at startup, beyond the question (if any). */
246
311
  export interface ProfileStartup {
247
312
  need: ProfileStartupNeed;
248
313
  /** One line per thing the gate did: absorbed edits, regeneration, repairs. */
249
314
  notices: string[];
315
+ /**
316
+ * One line per thing the gate REFUSED — the lines the CLI stops on. Kept
317
+ * apart from `notices` because a refusal can leave detection reading `ok`:
318
+ * an old layout whose settings file does not parse is not converted, yet
319
+ * still names its profile, so only this says the update did not happen.
320
+ */
321
+ problems: string[];
250
322
  }
251
323
 
252
324
  /**
@@ -257,17 +329,66 @@ export interface ProfileStartup {
257
329
  */
258
330
  export async function prepareProfileStartup(
259
331
  projectPath: string,
332
+ /**
333
+ * Update an older profile format. Without it, a project on one is answered
334
+ * with the `old-format` question and NOTHING is converted — the TUI asks
335
+ * first, as the CLI does; the modal's "Update it now" calls back with this.
336
+ */
337
+ opts: { convert?: boolean } = {},
260
338
  ): Promise<ProfileStartup> {
339
+ if (!opts.convert) {
340
+ const format = await oldFormatNeed(projectPath);
341
+ if (format !== null) return { need: format, notices: [], problems: [] };
342
+ }
261
343
  const notices: string[] = [];
344
+ const problems: string[] = [];
262
345
  try {
263
346
  const prep = await prepareProfileState(projectPath);
264
- notices.push(...prep.lines, ...prep.problems);
347
+ notices.push(...prep.lines);
348
+ problems.push(...prep.problems);
265
349
  } catch (error) {
266
350
  if (!(error instanceof SettingsParseError)) {
267
- notices.push(error instanceof Error ? error.message : String(error));
351
+ problems.push(error instanceof Error ? error.message : String(error));
268
352
  }
269
353
  }
270
- return { need: await detectProfileStartupNeed(projectPath), notices };
354
+ return {
355
+ need: await detectProfileStartupNeed(projectPath),
356
+ notices,
357
+ problems,
358
+ };
359
+ }
360
+
361
+ /** What the TUI shows once "Update it now" has run. */
362
+ export type FormatUpdateOutcome =
363
+ | { kind: "ask"; need: ProfileStartupNeed }
364
+ | { kind: "updated"; title: string; message: string }
365
+ | { kind: "failed"; title: string; message: string };
366
+
367
+ /**
368
+ * The verdict on an old-format update, from {@link prepareProfileStartup}'s
369
+ * result. A refusal is a failure even when detection reads `ok` — the CLI
370
+ * stops on the same lines, and the TUI used to show "Profile format updated"
371
+ * over a message saying magus had not updated it.
372
+ */
373
+ export function formatUpdateOutcome(
374
+ result: ProfileStartup,
375
+ ): FormatUpdateOutcome {
376
+ if (result.problems.length > 0) {
377
+ return {
378
+ kind: "failed",
379
+ title: "Could not update the profile format",
380
+ message: [...result.problems, "", "Your profiles are kept."].join("\n"),
381
+ };
382
+ }
383
+ if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
384
+ return {
385
+ kind: "updated",
386
+ title: "Profile format updated",
387
+ message:
388
+ result.notices.length > 0
389
+ ? result.notices.join("\n")
390
+ : "This project now uses the current profile format.",
391
+ };
271
392
  }
272
393
 
273
394
  // ─── What to offer, as data both surfaces render ─────────────────────────────
@@ -276,6 +397,8 @@ export async function prepareProfileStartup(
276
397
  export const ADOPT_VALUE = "adopt";
277
398
  export const QUIT_VALUE = "quit";
278
399
  export const ACTIVATE_PREFIX = "activate:";
400
+ /** The `old-format` question's "Update it now" row. Not a profile choice. */
401
+ export const UPDATE_FORMAT_VALUE = "update-format";
279
402
 
280
403
  /**
281
404
  * Title and message for the state we are in.
@@ -349,6 +472,35 @@ export function describeNeed(need: ProfileStartupNeed): {
349
472
  };
350
473
  }
351
474
 
475
+ if (need.state === "old-format") {
476
+ return {
477
+ title: "This project uses an old magus profile format",
478
+ message:
479
+ "magus has to update it before it can manage this project.\n\nYour profiles, plugins and custom skills are kept.",
480
+ };
481
+ }
482
+
483
+ if (need.state === "nested") {
484
+ // The one other place a path appears, and for the same reason as
485
+ // `unparseable`: the folder IS the message. It sits on a line of its own,
486
+ // short, so the modal never wraps it at a `.`. The reason is stated as
487
+ // Claude Code's behaviour, which it is — magus follows it.
488
+ const at = need.enclosing;
489
+ return {
490
+ title: "Inside another Claude Code project",
491
+ message: [
492
+ "This folder is inside a Claude Code project that already has profiles:",
493
+ "",
494
+ ` ${at?.rel ?? ".."} (${at?.name ?? "?"})`,
495
+ "",
496
+ "Claude Code treats each folder you start it in as a separate project, with its own plugins and settings, and magus follows it.",
497
+ "",
498
+ "To manage plugins for the whole project, run magus there.",
499
+ "To give this folder a setup of its own, run magus profile init here.",
500
+ ].join("\n"),
501
+ };
502
+ }
503
+
352
504
  if (need.state === "unknown") {
353
505
  return {
354
506
  title: "Active profile no longer exists",
@@ -390,6 +542,10 @@ export function describeNeed(need: ProfileStartupNeed): {
390
542
  export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
391
543
  const options: SelectOption[] = [];
392
544
 
545
+ if (need.state === "old-format") {
546
+ options.push({ label: "Update it now", value: UPDATE_FORMAT_VALUE });
547
+ }
548
+
393
549
  if (need.adopt !== null) {
394
550
  options.push({
395
551
  // Says what it PRESERVES, not what it creates. "Create <id> from this
@@ -261,7 +261,7 @@ export function describeUnknownProfile(
261
261
  export function describeUnmarked(
262
262
  check: Extract<ActiveProfileCheck, { state: "unmarked" }>,
263
263
  ): string {
264
- return `No profile is active in this project, and its live config matches no single profile. Run ${switchHint(check.available, check.suggested)}.`;
264
+ return `No profile is active in this project, and its plugins match none of the profiles. Run ${switchHint(check.available, check.suggested)}.`;
265
265
  }
266
266
 
267
267
  /**
@@ -328,7 +328,7 @@ export async function prepareProfileState(
328
328
 
329
329
  if (check.state === "active" && check.restored) {
330
330
  out.lines.push(
331
- `No active-profile marker, but the live config matches profile "${check.profile}" exactly — restored it`,
331
+ `Using profile "${check.profile}" — this project's plugins match it`,
332
332
  );
333
333
  }
334
334
 
@@ -353,10 +353,11 @@ export async function prepareProfileState(
353
353
  );
354
354
  if (check.state === "old-layout") {
355
355
  out.lines.push(
356
- `Converted profile "${profile}" from the older layout to real files`,
356
+ `Updated profile "${profile}" to the current profile format`,
357
357
  );
358
358
  }
359
- out.lines.push(...activationNotes(result));
359
+ // Tracked files are the command's to report, once (see profile-absorb).
360
+ out.lines.push(...activationNotes(result, { tracked: false }));
360
361
  }
361
362
  out.check = { state: "active", profile, restored: false };
362
363
  }
@@ -386,11 +387,11 @@ export async function inspectProfileState(
386
387
 
387
388
  if (check.state === "old-layout") {
388
389
  pending.push(
389
- `Profile "${check.profile}" is on an older magus layout (links into .claude/_profiles/). The next magus command, or \`magus doctor --fix\`, adds any changes Claude Code made under it to profiles.json, converts it to real files, and keeps .claude/_profiles/ in .claude/.magus-backups/.`,
390
+ `Profile "${check.profile}" uses an old magus profile format. The next magus command, or \`magus doctor --fix\`, updates it — keeping any changes Claude Code made, and a backup of the old format.`,
390
391
  );
391
392
  } else if (check.state === "unmarked" && check.match !== null) {
392
393
  pending.push(
393
- `No active-profile marker, but the live config matches profile "${check.match}" exactly — the next magus command, or \`magus doctor --fix\`, restores it.`,
394
+ `This project's plugins match profile "${check.match}" — the next magus command, or \`magus doctor --fix\`, switches to it.`,
394
395
  );
395
396
  } else if (
396
397
  check.state === "active" &&
@@ -46,9 +46,30 @@ export function untrackCommand(rel: string): string {
46
46
  return `git rm --cached ${rel}`;
47
47
  }
48
48
 
49
- /** The warning the CLI prints for one tracked generated path. */
50
- export function trackedWarning(rel: string): string {
51
- return `${rel} is generated from .claude/profiles.json but tracked by git — run \`${untrackCommand(rel)}\`, then commit`;
49
+ /** A tracked generated path, as reported: the problem, then the fix beneath. */
50
+ export interface TrackedNotice {
51
+ problem: string;
52
+ /** Lines under the problem. Empty when git itself could not be asked. */
53
+ fix: string[];
54
+ }
55
+
56
+ /**
57
+ * The notice for one tracked generated path.
58
+ *
59
+ * Short lines, not one long one. The single line this replaced ran the
60
+ * problem, the cause and the command together and wrapped mid-command at 80
61
+ * columns; the owner read it as confusing. Now: what is wrong, what the user
62
+ * will see because of it, and the command alone on its line to be copied —
63
+ * each under 80 columns with the report's indent.
64
+ */
65
+ export function trackedNotice(rel: string): TrackedNotice {
66
+ return {
67
+ problem: `git tracks ${rel}, but magus rewrites it`,
68
+ fix: [
69
+ "It will keep showing as changed. Untrack it — the file stays on disk:",
70
+ `${untrackCommand(rel)}, then commit`,
71
+ ],
72
+ };
52
73
  }
53
74
 
54
75
  /**
@@ -387,9 +387,16 @@ function describeLoadForward(outcome: StaleLoadRepairResult): string {
387
387
  /**
388
388
  * Scopes the apply loop would touch for `item`. Empty means it is skipped.
389
389
  *
390
- * A plugin the profile declares but nothing has installed goes in at PROJECT
391
- * scope, matching `install`: a profile is a property of one repo, so its
392
- * plugins must not be enabled machine-wide.
390
+ * A plugin the profile declares but nothing has installed (reason `absent`)
391
+ * goes in at PROJECT scope, matching `install`: a profile is a property of one
392
+ * repo, so its plugins must not be enabled machine-wide.
393
+ *
394
+ * A plugin whose files are missing is reinstalled exactly where it is
395
+ * installed — the plan's `scopes`. Every install used to go to project scope,
396
+ * so a user-scope plugin with its files missing was installed with `--scope
397
+ * project`, which turns it on in the project's settings.json; for a plugin the
398
+ * profile does not declare, the reconcile after the run then absorbed that
399
+ * record into the team's committed profiles.json.
393
400
  *
394
401
  * Every scope holding an outdated install is returned, not just one. Fixing a
395
402
  * single hardcoded scope does not converge: it reports success while the
@@ -408,7 +415,9 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
408
415
  item.action === "stale-load"
409
416
  )
410
417
  return [];
411
- return item.action === "install" ? ["project"] : item.scopes;
418
+ return item.action === "install" && item.reason === "absent"
419
+ ? ["project"]
420
+ : item.scopes;
412
421
  }
413
422
 
414
423
  /**
@@ -451,8 +460,11 @@ export function pluginNeedsWork(item: PluginUpdateItem): boolean {
451
460
  * report "0.2.4 installed, 0.3.0 available" while owning no row of its own.
452
461
  * That is exactly the shape that used to reach `update`.
453
462
  *
454
- * `readInstalled` matches the project path EXACTLY (no worktree inheritance),
455
- * which is what makes it the right question to ask here.
463
+ * `readInstalled` reads this project's own row at `scope` by exact path — no
464
+ * worktree inheritance and no user-row fallback — which is what makes it the
465
+ * right question to ask here. It used to fall back to the user row, so a plugin
466
+ * installed only at user scope and turned on in project settings passed this
467
+ * check and reached `update --scope project` from a folder with no project row.
456
468
  */
457
469
  async function advanceInScope(
458
470
  deps: PluginApplyDeps,
@@ -487,9 +499,14 @@ async function advanceInScope(
487
499
  * No installer output is parsed — "already at the latest version" is
488
500
  * locale- and version-fragile. The registry row the apply loop already reads
489
501
  * back is compared with the before-version the plan carries:
490
- * - update: every touched scope reads back exactly what it held before;
502
+ * - update: no touched scope recorded a version it did not hold before;
491
503
  * - install: no touched scope reads back any version at all;
492
504
  * - repair: never — same version is its definition; the files moved.
505
+ *
506
+ * A null read-back is nothing recorded, so it is never a move. It used to
507
+ * count as one — null differs from any before-version — and since the
508
+ * read-back matches this project's own row exactly, an update that recorded
509
+ * nothing reported "project → <target>" on every run while nothing changed.
493
510
  */
494
511
  function pluginNoMove(
495
512
  item: PluginUpdateItem,
@@ -497,10 +514,11 @@ function pluginNoMove(
497
514
  ): string | null {
498
515
  if (landed.length === 0) return null;
499
516
  if (item.action === "update") {
500
- const same = landed.every(
501
- ([scope, actual]) => (actual ?? undefined) === item.beforeVersions[scope],
517
+ const moved = landed.some(
518
+ ([scope, actual]) =>
519
+ actual !== null && actual !== item.beforeVersions[scope],
502
520
  );
503
- if (!same) return null;
521
+ if (moved) return null;
504
522
  const v = landed.find(([, a]) => a)?.[1];
505
523
  return v
506
524
  ? `the installer exited 0 but ${item.pluginId} is still ${v}`