@mutmutco/cli 3.0.1 → 3.1.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 (2) hide show
  1. package/dist/main.cjs +414 -82
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -6994,6 +6994,18 @@ var PR_LAND_POLL_MS = 3e4;
6994
6994
  var PR_LAND_ENQUEUE_TIMEOUT_MS = 10 * 6e4;
6995
6995
  var PR_LAND_STATE_READ_RETRIES = 3;
6996
6996
  var PR_LAND_STATE_READ_DELAY_MS = 2e3;
6997
+ var PR_LAND_MERGE_RETRY_DELAY_MS = 3e3;
6998
+ async function mergeAutoWithTransientRetry(prNumber, repo, deps) {
6999
+ const first = await deps.mergeAuto(prNumber, repo);
7000
+ if (first.mergeStatus !== "failed") return first;
7001
+ const ready = await deps.probeMergeReady(prNumber, repo).catch(() => ({ open: false, mergeable: false, checksPassing: false }));
7002
+ if (!ready.open || !ready.mergeable || !ready.checksPassing) return first;
7003
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve6) => setTimeout(resolve6, ms)));
7004
+ await sleep(PR_LAND_MERGE_RETRY_DELAY_MS);
7005
+ const retried = await deps.mergeAuto(prNumber, repo);
7006
+ if (retried.mergeStatus !== "failed") return retried;
7007
+ return { mergeStatus: "failed", error: `merge retry after transient failure also failed: ${retried.error ?? first.error ?? "unknown error"}` };
7008
+ }
6997
7009
  async function readGhPrStateWithRetry(fetchState, options) {
6998
7010
  const retries = options?.retries ?? PR_LAND_STATE_READ_RETRIES;
6999
7011
  const delayMs = options?.delayMs ?? PR_LAND_STATE_READ_DELAY_MS;
@@ -7037,7 +7049,7 @@ async function runPrLand(prNumber, options, deps) {
7037
7049
  error: `checks-wait ${checksWait.status}${checksWait.detail ? `: ${checksWait.detail}` : ""}`
7038
7050
  };
7039
7051
  }
7040
- const merge = await deps.mergeAuto(prNumber, repo);
7052
+ const merge = await mergeAutoWithTransientRetry(prNumber, repo, deps);
7041
7053
  base.mergeStatus = merge.mergeStatus;
7042
7054
  if (merge.mergeStatus === "failed") {
7043
7055
  return { ...base, error: merge.error ?? "merge failed" };
@@ -9411,7 +9423,7 @@ function trainPlan(command, options = {}) {
9411
9423
  { label: "merge development to main", gated: true },
9412
9424
  { label: "fold the version bump into the release commit (Hub: full distribution set; app repos: root package manifest) \u2014 runs inside the apply step, no separate bump PR", gated: true },
9413
9425
  { label: "tag release and publish GitHub Release", gated: true },
9414
- { label: "trigger the repo deploy path from the release event", command: "hub-serverless: deploy.yml + publish.yml auto-fire on the release; other models deploy via their own workflow", gated: true },
9426
+ { label: "trigger the repo deploy path from the release event", command: "hub-serverless: deploy.yml + publish.yml auto-fire on the release; registry-publish: own publish.yml auto-fires, watched on that repo, never a central dispatch (#2428); other models deploy via their own workflow", gated: true },
9415
9427
  { label: "roll development forward", gated: true }
9416
9428
  ];
9417
9429
  }
@@ -9439,7 +9451,7 @@ function trainPlan(command, options = {}) {
9439
9451
  { label: "merge rc to main", gated: true },
9440
9452
  { label: "fold the version bump into the release commit (app repos: root package manifest) \u2014 runs inside the apply step, no separate bump PR", gated: true },
9441
9453
  { label: "tag release and publish GitHub Release", gated: true },
9442
- { label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release, correlate/watch those runs", gated: true },
9454
+ { label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release, correlate/watch those runs; registry-publish: no manual dispatch, own publish.yml auto-fires on the release, correlate/watch that run on the product repo (#2428)", gated: true },
9443
9455
  { label: "roll development forward", gated: true }
9444
9456
  ];
9445
9457
  }
@@ -9943,9 +9955,12 @@ var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
9943
9955
  var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
9944
9956
  var TRAIN_CHECK_ATTEMPTS = 40;
9945
9957
  var TRAIN_CHECK_DELAY_MS = 15e3;
9958
+ var TRAIN_PR_ONLY_AUTOMATION_CONTEXTS = /* @__PURE__ */ new Set(["add-to-project", "mark-merged-pr-done"]);
9959
+ var TRAIN_PR_AUTOMATION_GRACE_ATTEMPTS = 3;
9946
9960
  async function correlateRun(deps, args) {
9947
9961
  const sleep = resolveSleep(deps);
9948
9962
  const threshold = args.since - CORRELATE_SKEW_SLACK_MS;
9963
+ const repo = args.mode === "workflow" ? args.repo ?? HUB_REPO3 : HUB_REPO3;
9949
9964
  let lastError;
9950
9965
  let parsedAnyResponse = false;
9951
9966
  for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
@@ -9954,7 +9969,7 @@ async function correlateRun(deps, args) {
9954
9969
  "run",
9955
9970
  "list",
9956
9971
  "--repo",
9957
- HUB_REPO3,
9972
+ repo,
9958
9973
  "--workflow",
9959
9974
  args.workflow,
9960
9975
  ...args.mode === "workflow" ? ["--event", args.event] : [],
@@ -9997,10 +10012,10 @@ function correlateControlRun(deps, since, titleIncludes) {
9997
10012
  async function correlateWorkflowRun(deps, args) {
9998
10013
  return correlateRun(deps, { ...args, mode: "workflow" });
9999
10014
  }
10000
- async function watchTenantRun(deps, runId) {
10015
+ async function watchTenantRun(deps, runId, repo = HUB_REPO3) {
10001
10016
  if (runId == null) return "pending";
10002
10017
  try {
10003
- await deps.run("gh", ["run", "watch", String(runId), "--repo", HUB_REPO3, "--exit-status"]);
10018
+ await deps.run("gh", ["run", "watch", String(runId), "--repo", repo, "--exit-status"]);
10004
10019
  return "success";
10005
10020
  } catch {
10006
10021
  return "failure";
@@ -10013,9 +10028,9 @@ async function fetchControlRunLog(deps, runId) {
10013
10028
  return "";
10014
10029
  }
10015
10030
  }
10016
- async function watchWorkflowRun(deps, workflow, run) {
10031
+ async function watchWorkflowRun(deps, workflow, run, repo = HUB_REPO3) {
10017
10032
  if (run.runId == null) return { workflow, conclusion: "pending" };
10018
- const conclusion = await watchTenantRun(deps, run.runId);
10033
+ const conclusion = await watchTenantRun(deps, run.runId, repo);
10019
10034
  return { workflow, runId: run.runId, runUrl: run.runUrl, conclusion };
10020
10035
  }
10021
10036
  function aggregateWorkflowRuns(runs) {
@@ -10124,6 +10139,7 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
10124
10139
  let lastStatus = "not checked";
10125
10140
  let lastError;
10126
10141
  const everObserved = /* @__PURE__ */ new Set();
10142
+ const autoSatisfied = /* @__PURE__ */ new Set();
10127
10143
  for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
10128
10144
  if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
10129
10145
  let checkRuns;
@@ -10145,18 +10161,25 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
10145
10161
  for (const c of required) {
10146
10162
  if (checkRuns.some((r) => r.name === c) || statuses.some((s) => s.context === c)) everObserved.add(c);
10147
10163
  }
10148
- const states = required.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
10149
- lastStatus = states.map(([c, s]) => `${c}=${s}`).join(", ");
10164
+ if (attempt >= TRAIN_PR_AUTOMATION_GRACE_ATTEMPTS - 1) {
10165
+ for (const c of required) {
10166
+ if (TRAIN_PR_ONLY_AUTOMATION_CONTEXTS.has(c) && !everObserved.has(c)) autoSatisfied.add(c);
10167
+ }
10168
+ }
10169
+ const pending = required.filter((c) => !autoSatisfied.has(c));
10170
+ const states = pending.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
10171
+ const satisfiedNote = autoSatisfied.size ? `, auto-satisfied (never runs on a tag, see #2404): ${[...autoSatisfied].join(", ")}` : "";
10172
+ lastStatus = `${states.map(([c, s]) => `${c}=${s}`).join(", ")}${satisfiedNote}`;
10150
10173
  const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
10151
10174
  if (failed.length > 0) {
10152
10175
  throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
10153
10176
  }
10154
10177
  if (states.every(([, s]) => s === "success")) {
10155
- return `required checks passed: ${required.join(", ")}`;
10178
+ return `required checks passed: ${pending.join(", ") || "(none pending)"}${satisfiedNote}`;
10156
10179
  }
10157
10180
  }
10158
10181
  const waitedMin = Math.round((TRAIN_CHECK_ATTEMPTS - 1) * TRAIN_CHECK_DELAY_MS / 6e4);
10159
- const neverMaterialized = required.filter((c) => !everObserved.has(c));
10182
+ const neverMaterialized = required.filter((c) => !everObserved.has(c) && !autoSatisfied.has(c));
10160
10183
  const neverNote = neverMaterialized.length ? ` Never materialized on ${sha} (no check-run or commit status ever appeared \u2014 the workflow that produces them was likely not triggered by the tag event): ${neverMaterialized.join(", ")}.` : "";
10161
10184
  throw new Error(
10162
10185
  `timed out after ~${waitedMin}m (${TRAIN_CHECK_ATTEMPTS} attempts) waiting for required train checks on ${sha}.${neverNote} Last observed: ${lastError ? `error: ${lastError}` : lastStatus}`
@@ -10246,6 +10269,19 @@ function tenantPublishRecoveryCommand(slug, repo, ref, stage2, publishDir) {
10246
10269
  if (publishDir && publishDir !== ".") parts.push(`-f publishDir=${publishDir}`);
10247
10270
  return parts.join(" ");
10248
10271
  }
10272
+ var PUBLISH_IDEMPOTENT_MARKER = /already on npm/i;
10273
+ var PUBLISH_LOG_RETRY_ATTEMPTS = 4;
10274
+ var PUBLISH_LOG_RETRY_DELAY_MS = 6e3;
10275
+ async function reconcilePublishFailure(deps, runId) {
10276
+ if (runId == null) return "failure";
10277
+ const sleep = resolveSleep(deps);
10278
+ for (let attempt = 0; attempt < PUBLISH_LOG_RETRY_ATTEMPTS; attempt++) {
10279
+ if (attempt > 0) await sleep(PUBLISH_LOG_RETRY_DELAY_MS);
10280
+ const log = await fetchControlRunLog(deps, runId);
10281
+ if (PUBLISH_IDEMPOTENT_MARKER.test(log)) return "success";
10282
+ }
10283
+ return "failure";
10284
+ }
10249
10285
  async function dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFailure = "throw", publishDir) {
10250
10286
  const since = (deps.now ?? Date.now)();
10251
10287
  const dispatchArgs = [
@@ -10276,8 +10312,16 @@ async function dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFail
10276
10312
  };
10277
10313
  }
10278
10314
  const { runId, runUrl } = await correlatePublishRun(deps, since, [ctx.slug, stage2]);
10279
- const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
10280
- return { note: `dispatched tenant-publish.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
10315
+ let deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
10316
+ let note = `dispatched tenant-publish.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`;
10317
+ if (deployStatus === "failure") {
10318
+ const reconciled = await reconcilePublishFailure(deps, runId);
10319
+ if (reconciled === "success") {
10320
+ deployStatus = "success";
10321
+ note = `${note}; run reported failure but its log confirms the version is already on npm (idempotent E409 \u2014 #2428)`;
10322
+ }
10323
+ }
10324
+ return { note, runId, runUrl, deployStatus };
10281
10325
  }
10282
10326
  async function dispatchPublishIfRequired(deps, ctx, meta, model, stage2, publishRef, watch, dispatchFailure) {
10283
10327
  if (!meta.publishRequired || stage2 !== "main") return null;
@@ -10294,6 +10338,18 @@ function appendPublishDispatch(deploy, publish) {
10294
10338
  deployStatus: deploy.deployStatus === "failure" || publish.deployStatus === "failure" ? "failure" : deploy.deployStatus === "pending" || publish.deployStatus === "pending" ? "pending" : "success"
10295
10339
  };
10296
10340
  }
10341
+ async function watchOwnWorkflowRuns(deps, repo, targets, since, headSha) {
10342
+ const workflowRuns = [];
10343
+ for (const target of targets) {
10344
+ try {
10345
+ const run = await correlateWorkflowRun(deps, { ...target, since, headSha, repo });
10346
+ workflowRuns.push(await watchWorkflowRun(deps, target.workflow, run, repo));
10347
+ } catch {
10348
+ workflowRuns.push({ workflow: target.workflow, conclusion: "failure" });
10349
+ }
10350
+ }
10351
+ return workflowRuns;
10352
+ }
10297
10353
  async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince, autoRunHeadSha, dispatchFailure = "throw", publishDir) {
10298
10354
  if (model === "tenant-container" || model === "solo-container") {
10299
10355
  const since = (deps.now ?? Date.now)();
@@ -10312,7 +10368,18 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
10312
10368
  return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
10313
10369
  }
10314
10370
  if (model === "registry-publish") {
10315
- return dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFailure, publishDir);
10371
+ const note = ref === "rc" ? "no dispatch on rc: registry-publish repos have no rc-stage Release to publish from" : "no central dispatch: this repo's own publish.yml auto-fires on the published Release (#2428)";
10372
+ if (ref === "rc" || !watch || !autoRunHeadSha) return { note, deployStatus: "pending" };
10373
+ const since = autoRunSince ?? (deps.now ?? Date.now)();
10374
+ const workflowRuns = await watchOwnWorkflowRuns(
10375
+ deps,
10376
+ ctx.repo,
10377
+ [{ workflow: "publish.yml", event: "release" }],
10378
+ since,
10379
+ autoRunHeadSha
10380
+ );
10381
+ const primary = workflowRuns[0];
10382
+ return { note, runId: primary?.runId, runUrl: primary?.runUrl, workflowRuns, deployStatus: aggregateWorkflowRuns(workflowRuns) };
10316
10383
  }
10317
10384
  if (model === "hub-serverless") {
10318
10385
  const note = ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)";
@@ -10323,15 +10390,7 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
10323
10390
  { workflow: "deploy.yml", event: "release" },
10324
10391
  { workflow: "publish.yml", event: "release" }
10325
10392
  ];
10326
- const workflowRuns = [];
10327
- for (const target of targets) {
10328
- try {
10329
- const run = await correlateWorkflowRun(deps, { ...target, since, headSha: autoRunHeadSha });
10330
- workflowRuns.push(await watchWorkflowRun(deps, target.workflow, run));
10331
- } catch {
10332
- workflowRuns.push({ workflow: target.workflow, conclusion: "failure" });
10333
- }
10334
- }
10393
+ const workflowRuns = await watchOwnWorkflowRuns(deps, HUB_REPO3, targets, since, autoRunHeadSha);
10335
10394
  const primary = workflowRuns[0];
10336
10395
  return {
10337
10396
  note,
@@ -11165,7 +11224,11 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11165
11224
  runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
11166
11225
  }
11167
11226
  deployNote = "watched release-triggered deploy.yml + publish.yml";
11168
- } else if (deployModel === "tenant-container" || deployModel === "solo-container" || deployModel === "registry-publish") {
11227
+ } else if (deployModel === "registry-publish") {
11228
+ const run = await watchReleaseRun(deps, ctx, "publish.yml", mergedSha);
11229
+ runs.push(run);
11230
+ deployNote = "watched this repo's own release-triggered publish.yml (#2428 \u2014 no central dispatch)";
11231
+ } else if (deployModel === "tenant-container" || deployModel === "solo-container") {
11169
11232
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
11170
11233
  const deploy = await dispatchDeploy(
11171
11234
  deps,
@@ -11181,7 +11244,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11181
11244
  );
11182
11245
  const publish = deploy.deployStatus === "success" ? await dispatchPublishIfRequired(deps, ctx, meta, deployModel, "main", tag, true, "report") : null;
11183
11246
  let dispatch = appendPublishDispatch(deploy, publish);
11184
- if (!publish && deploy.deployStatus !== "success" && meta.publishRequired && (deployModel === "tenant-container" || deployModel === "solo-container")) {
11247
+ if (!publish && deploy.deployStatus !== "success" && meta.publishRequired) {
11185
11248
  const reason = deploy.deployStatus === "failure" ? "box deploy failed \u2014 redeploy the box before publishing" : "box deploy not confirmed (run with --watch) \u2014 publish after the box deploy lands";
11186
11249
  dispatch = {
11187
11250
  ...dispatch,
@@ -11189,25 +11252,17 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11189
11252
  };
11190
11253
  }
11191
11254
  deployNote = dispatch.note;
11192
- if (deployModel !== "registry-publish") {
11193
- runs.push({
11194
- workflow: "tenant-deploy.yml",
11195
- url: deploy.runUrl,
11196
- conclusion: deploy.deployStatus === "success" ? "success" : deploy.deployStatus === "failure" ? "failure" : deploy.deployStatus ?? "pending"
11197
- });
11198
- }
11255
+ runs.push({
11256
+ workflow: "tenant-deploy.yml",
11257
+ url: deploy.runUrl,
11258
+ conclusion: deploy.deployStatus === "success" ? "success" : deploy.deployStatus === "failure" ? "failure" : deploy.deployStatus ?? "pending"
11259
+ });
11199
11260
  if (publish?.runUrl) {
11200
11261
  runs.push({
11201
11262
  workflow: "tenant-publish.yml",
11202
11263
  url: publish.runUrl,
11203
11264
  conclusion: publish.deployStatus === "success" ? "success" : publish.deployStatus === "failure" ? "failure" : publish.deployStatus ?? "pending"
11204
11265
  });
11205
- } else if (deployModel === "registry-publish") {
11206
- runs.push({
11207
- workflow: "tenant-publish.yml",
11208
- url: deploy.runUrl,
11209
- conclusion: deploy.deployStatus === "success" ? "success" : deploy.deployStatus === "failure" ? "failure" : deploy.deployStatus ?? "pending"
11210
- });
11211
11266
  }
11212
11267
  } else {
11213
11268
  deployNote = `no hotfix deploy dispatch for deployModel=${deployModel} \u2014 prod deploy is repo-specific`;
@@ -15329,10 +15384,12 @@ function isSemverVersion2(v) {
15329
15384
  function staleRecordCommand(surface) {
15330
15385
  return surface === "codex" ? CODEX_PLUGIN_RECOVERY : CLAUDE_PLUGIN_RECOVERY;
15331
15386
  }
15387
+ var CLAUDE_UPDATE_BUTTON_WARNING = "Windows: do NOT use the in-app Update button for this plugin (#2411, hits the #1126 MAX_PATH nesting bug) \u2014 run `mmi-cli doctor --apply` instead";
15332
15388
  function staleSurfacesFix(stale, releasedVersion) {
15333
15389
  const parts = stale.map((s) => {
15334
15390
  const at = s.recordPath ? ` (${s.recordPath})` : "";
15335
- return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}`;
15391
+ const warning = s.surface === "claude" ? ` \u2014 ${CLAUDE_UPDATE_BUTTON_WARNING}` : "";
15392
+ return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}${warning}`;
15336
15393
  });
15337
15394
  return `stale installed-plugin record on ${stale.map((s) => s.surface).join(" + ")}: ${parts.join("; ")}`;
15338
15395
  }
@@ -15374,18 +15431,71 @@ function buildInstalledPluginVersionCheck(input) {
15374
15431
  staleSurfaces: stale
15375
15432
  };
15376
15433
  }
15434
+ var MANAGED_PLUGINS = [
15435
+ {
15436
+ // Marketplace-qualified key exactly as installed_plugins.json stores it (same convention as
15437
+ // MMI_PLUGIN_ID = 'mmi@mutmutco') — a bare 'jervaise-powertools' would never match a real record
15438
+ // and the check would be a permanent no-op. The same `<marketplace>/<name>` segments also address its
15439
+ // Cursor Team Marketplace cache dir (~/.cursor/plugins/cache/jervaise/jervaise-powertools/<version>/).
15440
+ id: "jervaise-powertools@jervaise",
15441
+ label: "jervaise-powertools",
15442
+ healCommand: "jerv-cli doctor --apply",
15443
+ opencodePackage: "@jervaise/opencode-jerv"
15444
+ }
15445
+ ];
15446
+ function parseManagedPluginId(id) {
15447
+ const at = id.lastIndexOf("@");
15448
+ if (at <= 0 || at === id.length - 1) return null;
15449
+ return { name: id.slice(0, at), marketplace: id.slice(at + 1) };
15450
+ }
15451
+ var MANAGED_PLUGIN_DRIFT_LABEL = "managed org plugin version drift (cross-surface)";
15452
+ function managedPluginDriftFix(drifted) {
15453
+ return drifted.map((d) => {
15454
+ const versions = d.versions.map((v) => `${v.version} (${v.surface})`).join(" vs ");
15455
+ return `${d.label} drift: ${versions} \u2192 run \`${d.healCommand}\``;
15456
+ }).join(" ; ");
15457
+ }
15458
+ function buildManagedPluginDriftCheck(input) {
15459
+ const base = { ok: true, label: MANAGED_PLUGIN_DRIFT_LABEL, fix: "" };
15460
+ if (!input.isOrgRepo) return base;
15461
+ const registry2 = input.plugins ?? MANAGED_PLUGINS;
15462
+ const drifted = [];
15463
+ const normalize = (v) => v.replace(/^v/, "");
15464
+ for (const descriptor of registry2) {
15465
+ const versions = [];
15466
+ for (const source of input.sources) {
15467
+ if (source.directVersions && descriptor.id in source.directVersions) {
15468
+ const version2 = source.directVersions[descriptor.id];
15469
+ if (isSemverVersion2(version2)) versions.push({ surface: source.surface, version: normalize(version2) });
15470
+ continue;
15471
+ }
15472
+ const records = source.installed?.plugins?.[descriptor.id];
15473
+ if (!Array.isArray(records) || records.length === 0) continue;
15474
+ const recordVersion = bestRecord(records).version;
15475
+ const version = isSemverVersion2(recordVersion) ? recordVersion : highestSemver(source.cacheVersions?.[descriptor.id] ?? []);
15476
+ if (!isSemverVersion2(version)) continue;
15477
+ versions.push({ surface: source.surface, version: normalize(version) });
15478
+ }
15479
+ if (versions.length < 2) continue;
15480
+ const distinctVersions = new Set(versions.map((v) => v.version));
15481
+ if (distinctVersions.size < 2) continue;
15482
+ drifted.push({ pluginId: descriptor.id, label: descriptor.label, healCommand: descriptor.healCommand, versions });
15483
+ }
15484
+ if (drifted.length === 0) return base;
15485
+ return { ...base, ok: false, fix: managedPluginDriftFix(drifted), drifted };
15486
+ }
15377
15487
  var OPENCODE_VERSION_LABEL = "installed OpenCode MMI adapter version (vs latest release)";
15378
15488
  function buildOpencodeVersionCheck(input) {
15379
15489
  const fix = pluginRecoveryFix("opencode");
15380
15490
  const base = { ok: true, label: OPENCODE_VERSION_LABEL, fix };
15381
15491
  if (!input.isOrgRepo || !isSemverVersion2(input.releasedVersion)) return base;
15382
15492
  if (!isSemverVersion2(input.installedVersion)) {
15383
- return { ...base, ok: false, releasedVersion: input.releasedVersion };
15493
+ return { ...base, ok: false, severityOverride: "hard", releasedVersion: input.releasedVersion };
15384
15494
  }
15385
15495
  if (compareVersions(input.installedVersion, input.releasedVersion) >= 0) {
15386
15496
  return { ...base, installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15387
15497
  }
15388
- return { ...base, ok: false, installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15498
+ return { ...base, ok: false, severityOverride: "hard", installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15389
15499
  }
15390
15500
  function pickOpencodeActiveVersion(input) {
15391
15501
  for (const value of [input.envStamp, input.cacheVersion, input.diskVersion]) {
@@ -15634,9 +15744,10 @@ function buildCursorPluginInstallCheck(input) {
15634
15744
  return {
15635
15745
  ...base,
15636
15746
  ok: false,
15747
+ severityOverride: "advisory",
15637
15748
  cacheRoot: input.cacheRoot,
15638
15749
  pins: input.pins,
15639
- fix: `Cursor MMI plugin cache is behind ${input.releasedVersion} (${stale}) \u2014 run \`mmi-cli doctor --apply\` to seed the cache from the latest release (effective after restart Cursor); when Dashboard \u2192 Settings \u2192 Plugins shows Update next to the MMI Team Marketplace, a master-admin can refresh the pin there instead; ${CURSOR_MARKETPLACE_INSTALL_GUIDE}`
15750
+ fix: `Cursor MMI plugin cache is behind ${input.releasedVersion} (${stale}) \u2014 run \`mmi-cli doctor --apply\` to seed the cache from the latest release (effective after restart Cursor); cache healthy after that, but the Team Marketplace REGISTRATION may still show stale \u2014 a master-admin must manually refresh it: Cursor Settings \u2192 Plugins \u2192 Marketplace, click Update next to the MMI Team Marketplace (no per-user CLI exists to do this automatically, #2409); ${CURSOR_MARKETPLACE_INSTALL_GUIDE}`
15640
15751
  };
15641
15752
  }
15642
15753
  return { ...base, cacheRoot: input.cacheRoot, pins: input.pins };
@@ -15830,6 +15941,30 @@ function preflightOutcome(input) {
15830
15941
  function pluginAutonomousHaltLine(reloadHint) {
15831
15942
  return `\u26A0 PLUGIN RELOAD REQUIRED \u2014 mmi:* skills and agent types are unavailable until you ${reloadHint}. Halt autonomous /grind and /build until then.`;
15832
15943
  }
15944
+ var DOCTOR_EXTENDED_CHECK_LABELS = /* @__PURE__ */ new Set([
15945
+ AWS_CROSS_ACCOUNT_LABEL,
15946
+ HUB_DEPLOY_FRESHNESS_LABEL,
15947
+ PLAYWRIGHT_MCP_VISION_CAP_LABEL,
15948
+ PLAYWRIGHT_MCP_OUTPUT_DIR_LABEL,
15949
+ BROWSER_ARTIFACTS_LABEL,
15950
+ SCRATCH_GC_LABEL,
15951
+ GIT_GC_LABEL,
15952
+ OPENCODE_VERSION_LABEL,
15953
+ OPENCODE_DESKTOP_BOOTSTRAP_LABEL
15954
+ ]);
15955
+ function isDoctorExtendedCheck(label) {
15956
+ if (DOCTOR_EXTENDED_CHECK_LABELS.has(label)) return true;
15957
+ if (label.startsWith(HUB_DEPLOY_FRESHNESS_LABEL)) return true;
15958
+ if (label.startsWith("@mutmutco design-system") || label.startsWith("@mutmutco registry components")) return true;
15959
+ return false;
15960
+ }
15961
+ function isAdvisoryDoctorCheck(check) {
15962
+ if (check.severityOverride) return check.severityOverride === "advisory";
15963
+ return isDoctorExtendedCheck(check.label);
15964
+ }
15965
+ function doctorExitCode(checks) {
15966
+ return checks.some((c) => !c.ok && !isAdvisoryDoctorCheck(c)) ? 1 : 0;
15967
+ }
15833
15968
  function renderPluginUpdateReportStaleOnly(report) {
15834
15969
  const v = report.versions;
15835
15970
  const released = v.released;
@@ -15844,24 +15979,97 @@ function renderPluginUpdateReportStaleOnly(report) {
15844
15979
  return ["Update commands (stale surfaces):", ...blocks];
15845
15980
  }
15846
15981
  var DOCTOR_VERBOSE_HINT = "Run mmi-cli doctor --verbose for the full audit checklist + version report.";
15847
- function renderTerseDoctorReport(input) {
15848
- const cliVersion = input.updateReport.versions.cli;
15849
- const versionSuffix = cliVersion ? ` (mmi-cli ${cliVersion})` : "";
15850
- if (!input.gaps.length) {
15851
- return [`\u2713 MMI doctor: all checks passed${versionSuffix}.`, DOCTOR_VERBOSE_HINT];
15982
+ function doctorCheckGlyph(check) {
15983
+ if (check.ok) return "\u2713";
15984
+ return isAdvisoryDoctorCheck(check) ? "\u26A0" : "\u2717";
15985
+ }
15986
+ function doctorVerdictLine(input) {
15987
+ const gaps = input.checks.filter((c) => !c.ok);
15988
+ const hardGaps = gaps.filter((c) => !isAdvisoryDoctorCheck(c));
15989
+ const softGaps = gaps.filter((c) => isAdvisoryDoctorCheck(c));
15990
+ if (input.shouldApply) {
15991
+ if (hardGaps.length > 0) {
15992
+ return `\u2717 ${hardGaps.length} repair${hardGaps.length === 1 ? "" : "s"} failed \u2014 see the items above.`;
15993
+ }
15994
+ if (softGaps.length > 0) {
15995
+ return `\u26A0 Healed what I could; ${softGaps.length} item${softGaps.length === 1 ? "" : "s"} still need${softGaps.length === 1 ? "s" : ""} attention (above).`;
15996
+ }
15997
+ const healed = input.healedCount ?? 0;
15998
+ if (healed > 0) return `\u2713 Healed ${healed} item${healed === 1 ? "" : "s"} \u2014 everything checks out now.`;
15999
+ return "\u2713 Everything healthy \u2014 nothing needed fixing.";
15852
16000
  }
15853
- const lines = [];
15854
- for (const c of input.gaps) {
15855
- lines.push(`\u2717 ${c.label}`);
15856
- lines.push(` \u2192 ${c.fix}`);
16001
+ const attention = gaps.length;
16002
+ if (attention > 0) {
16003
+ return `\u26A0 ${attention} item${attention === 1 ? " needs" : "s need"} attention \u2014 run \`mmi-cli doctor --apply\` to heal.`;
16004
+ }
16005
+ return "\u2713 Everything healthy.";
16006
+ }
16007
+ function doctorHumanLines(input) {
16008
+ const gaps = input.checks.filter((c) => !c.ok);
16009
+ const lines = [
16010
+ doctorVerdictLine({ checks: input.checks, shouldApply: input.shouldApply, healedCount: input.healedCount }),
16011
+ "",
16012
+ "Checks",
16013
+ ...input.checks.map((c) => ` ${doctorCheckGlyph(c)} ${c.label}`)
16014
+ ];
16015
+ if (gaps.length > 0) {
16016
+ lines.push("", input.shouldApply ? "Repairs" : "Will fix on --apply");
16017
+ for (const c of gaps) {
16018
+ const mark = input.shouldApply ? isAdvisoryDoctorCheck(c) ? "\u26A0" : "\u2717" : "\u2192";
16019
+ lines.push(` ${mark} ${c.label} \u2014 ${c.fix}`);
16020
+ }
16021
+ }
16022
+ if (input.shouldApply && input.pluginReloadRequired) {
16023
+ lines.push("", `\u21BB ${input.reloadHint ?? "reload"} so the healed plugin/MCP installs load.`);
15857
16024
  }
15858
16025
  const stale = renderPluginUpdateReportStaleOnly(input.updateReport);
15859
16026
  if (stale.length) {
15860
- lines.push("");
15861
- lines.push(...stale);
16027
+ lines.push("", ...stale);
16028
+ } else if (!input.verbose) {
16029
+ lines.push("", DOCTOR_VERBOSE_HINT);
16030
+ }
16031
+ if (!input.verbose) return lines;
16032
+ lines.push("", ...renderPluginUpdateReport(input.updateReport));
16033
+ lines.push(
16034
+ "",
16035
+ doctorSummaryLine({ checks: input.checks, updateReport: input.updateReport, shouldApply: input.shouldApply, healedCount: input.healedCount }),
16036
+ ...doctorAuditLines({
16037
+ checks: input.checks,
16038
+ shouldApply: input.shouldApply,
16039
+ healedCount: input.healedCount,
16040
+ cliVersion: input.updateReport.versions.cli,
16041
+ releasedVersion: input.updateReport.versions.released
16042
+ })
16043
+ );
16044
+ return lines;
16045
+ }
16046
+ function doctorSummaryLine(input) {
16047
+ const gaps = input.checks.filter((c) => !c.ok);
16048
+ const mode = input.shouldApply ? "apply" : "plan";
16049
+ const released = input.updateReport.versions.released ?? "unknown";
16050
+ const failed = gaps.filter((c) => !isAdvisoryDoctorCheck(c)).length;
16051
+ const countSummary = input.shouldApply ? `${input.healedCount ?? 0} healed` : `${gaps.length} planned`;
16052
+ return `summary: mode ${mode} \xB7 released ${released} \xB7 ${countSummary} \xB7 ${failed} failed`;
16053
+ }
16054
+ function doctorAuditLines(input) {
16055
+ const gaps = input.checks.filter((c) => !c.ok);
16056
+ const failed = gaps.filter((c) => !isAdvisoryDoctorCheck(c)).length;
16057
+ const lines = [
16058
+ "MMI doctor audit",
16059
+ `mode: ${input.shouldApply ? "apply" : "plan"}`,
16060
+ `cli: ${input.cliVersion ?? "unknown"}`,
16061
+ `released: ${input.releasedVersion ?? "unknown"}`,
16062
+ `checks: ${input.checks.length} (${input.checks.length - gaps.length} ok, ${gaps.length} gap)`,
16063
+ `planned actions: ${gaps.length}`,
16064
+ `healed actions: ${input.healedCount ?? 0}`,
16065
+ `failed actions: ${failed}`
16066
+ ];
16067
+ if (gaps.length) {
16068
+ lines.push("actions:");
16069
+ for (const c of gaps) {
16070
+ lines.push(`[${isAdvisoryDoctorCheck(c) ? "advisory" : "auto"}] ${c.label}: ${c.fix}`);
16071
+ }
15862
16072
  }
15863
- lines.push("");
15864
- lines.push(`\u26A0 ${input.gaps.length} item(s) need attention \u2014 ${DOCTOR_VERBOSE_HINT}`);
15865
16073
  return lines;
15866
16074
  }
15867
16075
  var PLUGIN_RESOLVABILITY_LABEL = "MMI plugin resolvability (marketplace + cache present)";
@@ -16246,6 +16454,77 @@ function installedPluginSources() {
16246
16454
  }
16247
16455
  });
16248
16456
  }
16457
+ function managedPluginSurfaceSources() {
16458
+ const claudeCodex = installedPluginSources().map(({ surface, installed }) => {
16459
+ const cacheVersions = {};
16460
+ for (const descriptor of MANAGED_PLUGINS) {
16461
+ const segments = parseManagedPluginId(descriptor.id);
16462
+ if (!segments) continue;
16463
+ try {
16464
+ cacheVersions[descriptor.id] = (0, import_node_fs17.readdirSync)(
16465
+ (0, import_node_path17.join)((0, import_node_os6.homedir)(), `.${surface}`, "plugins", "cache", segments.marketplace, segments.name),
16466
+ { withFileTypes: true }
16467
+ ).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
16468
+ } catch {
16469
+ }
16470
+ }
16471
+ return { surface, installed, cacheVersions };
16472
+ });
16473
+ return [
16474
+ ...claudeCodex,
16475
+ { surface: "cursor", directVersions: managedPluginCursorDirectVersions() },
16476
+ { surface: "opencode", directVersions: managedPluginOpencodeDirectVersions() }
16477
+ ];
16478
+ }
16479
+ function managedPluginCursorDirectVersions() {
16480
+ const out = {};
16481
+ for (const descriptor of MANAGED_PLUGINS) {
16482
+ const segments = parseManagedPluginId(descriptor.id);
16483
+ if (!segments) continue;
16484
+ try {
16485
+ const versions = (0, import_node_fs17.readdirSync)(
16486
+ (0, import_node_path17.join)((0, import_node_os6.homedir)(), ".cursor", "plugins", "cache", segments.marketplace, segments.name),
16487
+ { withFileTypes: true }
16488
+ ).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
16489
+ out[descriptor.id] = highestSemver(versions);
16490
+ } catch {
16491
+ }
16492
+ }
16493
+ return out;
16494
+ }
16495
+ function managedPluginOpencodeDirectVersions() {
16496
+ const out = {};
16497
+ for (const descriptor of MANAGED_PLUGINS) {
16498
+ if (!descriptor.opencodePackage) continue;
16499
+ out[descriptor.id] = readOpencodePackageVersion(descriptor.opencodePackage);
16500
+ }
16501
+ return out;
16502
+ }
16503
+ function readOpencodePackageVersionFrom(packageJsonPath) {
16504
+ try {
16505
+ const parsed = JSON.parse((0, import_node_fs17.readFileSync)(packageJsonPath, "utf8"));
16506
+ return typeof parsed.version === "string" && parsed.version.trim() ? parsed.version.trim() : void 0;
16507
+ } catch {
16508
+ return void 0;
16509
+ }
16510
+ }
16511
+ function readOpencodePackageVersion(packageName) {
16512
+ const [scope, name] = packageName.startsWith("@") ? packageName.slice(1).split("/", 2) : [void 0, packageName];
16513
+ if (!name) return void 0;
16514
+ const diskCandidates = [
16515
+ (0, import_node_path17.join)(opencodeConfigDir(), "node_modules", ...scope ? [`@${scope}`] : [], name, "package.json"),
16516
+ (0, import_node_path17.join)((0, import_node_os6.homedir)(), ".cache", "opencode", "node_modules", ...scope ? [`@${scope}`] : [], name, "package.json")
16517
+ ];
16518
+ const diskVersion = diskCandidates.map(readOpencodePackageVersionFrom).find((v) => v !== void 0);
16519
+ const packagesRoot = (0, import_node_path17.join)((0, import_node_os6.homedir)(), ".cache", "opencode", "packages", ...scope ? [`@${scope}`] : []);
16520
+ let cacheVersion;
16521
+ try {
16522
+ const cacheVersions = (0, import_node_fs17.readdirSync)(packagesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith(`${name}@`) && !e.name.startsWith(".")).map((e) => readOpencodePackageVersionFrom((0, import_node_path17.join)(packagesRoot, e.name, "node_modules", ...scope ? [`@${scope}`] : [], name, "package.json"))).filter((v) => Boolean(v));
16523
+ if (cacheVersions.length) cacheVersion = cacheVersions.reduce((lowest, v) => compareVersions(v, lowest) < 0 ? v : lowest);
16524
+ } catch {
16525
+ }
16526
+ return pickOpencodeActiveVersion({ cacheVersion, diskVersion });
16527
+ }
16249
16528
  function readClaudeSettings() {
16250
16529
  try {
16251
16530
  return JSON.parse((0, import_node_fs17.readFileSync)((0, import_node_path17.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
@@ -16704,7 +16983,6 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16704
16983
  const repairLocal = !opts.json || Boolean(opts.apply) || Boolean(opts.preflight);
16705
16984
  const repoWritesAllowed = !opts.noRepoWrites;
16706
16985
  const runExtended = Boolean(opts.verbose) || Boolean(opts.json);
16707
- const terseOutput = !opts.verbose && !opts.json && !opts.banner && !opts.preflight;
16708
16986
  const checks = [];
16709
16987
  const REWRITE_KEY = "url.https://github.com/.insteadOf";
16710
16988
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
@@ -16774,6 +17052,10 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16774
17052
  const markPluginReloadRequired = () => {
16775
17053
  pluginReloadRequired = true;
16776
17054
  };
17055
+ let healedCount = 0;
17056
+ const markHealed = () => {
17057
+ healedCount += 1;
17058
+ };
16777
17059
  let versionReport = buildVersionLagReport({
16778
17060
  currentVersion: resolveClientVersion(),
16779
17061
  repoVersion: readRepoVersion(),
@@ -16826,6 +17108,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16826
17108
  try {
16827
17109
  await execFileP2("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
16828
17110
  cloneOk = true;
17111
+ markHealed();
16829
17112
  io.err(" \u21BB repaired: git insteadOf git@github.com \u2192 https (plugin clone over HTTPS)");
16830
17113
  } catch {
16831
17114
  }
@@ -16844,6 +17127,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16844
17127
  if (!pluginCheck.ok && pluginCheck.recordToInsert && repairLocal) {
16845
17128
  if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
16846
17129
  pluginCheck = { ...pluginCheck, ok: true };
17130
+ markHealed();
16847
17131
  io.err(` \u21BB repaired: registered mmi@mutmutco project install record \u2014 ${reloadHint} to load MMI commands`);
16848
17132
  }
16849
17133
  }
@@ -16863,6 +17147,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16863
17147
  surface
16864
17148
  });
16865
17149
  if (legacyPluginCheck.ok) {
17150
+ markHealed();
16866
17151
  io.err(` \u21BB migrated legacy mmi@mmi \u2192 mmi@mutmutco via claude plugin \u2014 ${reloadHint} to load MMI commands`);
16867
17152
  }
16868
17153
  }
@@ -16873,6 +17158,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16873
17158
  surface
16874
17159
  });
16875
17160
  if (legacyPluginCheck.ok) {
17161
+ markHealed();
16876
17162
  io.err(` \u21BB migrated legacy mmi@mmi \u2192 mmi@mutmutco via codex plugin \u2014 ${reloadHint} to load MMI commands`);
16877
17163
  }
16878
17164
  }
@@ -16891,6 +17177,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16891
17177
  gitignoreCheck.removed?.length ? `removed ${gitignoreCheck.removed.join(", ")}` : ""
16892
17178
  ].filter(Boolean).join("; ") || "normalized the block";
16893
17179
  gitignoreCheck = { ...gitignoreCheck, ok: true };
17180
+ markHealed();
16894
17181
  io.err(` \u21BB repaired: org-managed .gitignore block \u2014 ${drift}`);
16895
17182
  io.err(" this is an org-managed update (not unrelated churn) \u2014 stage & commit .gitignore so it stops recurring");
16896
17183
  }
@@ -16904,6 +17191,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16904
17191
  if (!driftCheck.ok && driftCheck.recordsToWrite && repairLocal) {
16905
17192
  if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
16906
17193
  driftCheck = { ...driftCheck, ok: true };
17194
+ markHealed();
16907
17195
  io.err(` \u21BB repaired: collapsed mmi@mutmutco to one user-scope entry (backup at installed_plugins.json.bak) \u2014 ${reloadHint} to load MMI commands`);
16908
17196
  }
16909
17197
  }
@@ -16936,6 +17224,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16936
17224
  installedVersionCheck = healed;
16937
17225
  if (healed.ok) {
16938
17226
  markPluginReloadRequired();
17227
+ markHealed();
16939
17228
  io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reloadAction(surface)} to load the new commands`);
16940
17229
  }
16941
17230
  }
@@ -16945,11 +17234,13 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16945
17234
  installedVersionCheck = healed;
16946
17235
  if (healed.ok) {
16947
17236
  markPluginReloadRequired();
17237
+ markHealed();
16948
17238
  io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via codex plugin \u2014 ${reloadAction(surface)} to load the new commands`);
16949
17239
  }
16950
17240
  }
16951
17241
  }
16952
17242
  checks.push(installedVersionCheck);
17243
+ checks.push(buildManagedPluginDriftCheck({ isOrgRepo, sources: managedPluginSurfaceSources() }));
16953
17244
  let openCodeConfigSnapshot = opencodeConfigSnapshot();
16954
17245
  const inspectOpenCode = surface === "opencode" || openCodeConfigSnapshot.hasConfig || runExtended;
16955
17246
  if (inspectOpenCode) {
@@ -16974,6 +17265,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16974
17265
  });
16975
17266
  if (opencodeConfigCheck.ok) {
16976
17267
  markPluginReloadRequired();
17268
+ markHealed();
16977
17269
  io.err(` \u21BB repaired: wired ${OPENCODE_PLUGIN_PACKAGE} in OpenCode config \u2014 ${reloadAction("opencode")} to load MMI commands`);
16978
17270
  }
16979
17271
  }
@@ -16997,6 +17289,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16997
17289
  });
16998
17290
  if (opencodeVersionCheck.ok) {
16999
17291
  markPluginReloadRequired();
17292
+ markHealed();
17000
17293
  io.err(` \u21BB force-refreshed OpenCode MMI plugin \u2192 ${opencodeInstalledVersion ?? releasedVersion ?? "latest"} \u2014 ${reloadAction("opencode")} to load it`);
17001
17294
  }
17002
17295
  }
@@ -17035,6 +17328,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17035
17328
  });
17036
17329
  if (surfaceAssetsCheck.ok) {
17037
17330
  markPluginReloadRequired();
17331
+ markHealed();
17038
17332
  io.err(` \u21BB materialized OpenCode MMI commands + skills path \u2014 ${reloadAction("opencode")} to load them`);
17039
17333
  }
17040
17334
  }
@@ -17056,6 +17350,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17056
17350
  if (!legacyOpenCodeCheck.ok && repairLocal && legacyOpenCodeConfig.legacyPath) {
17057
17351
  if (quarantineOpencodeLegacyConfig(legacyOpenCodeConfig.legacyPath)) {
17058
17352
  legacyOpenCodeCheck = buildOpencodeLegacyConfigCheck({ isOrgRepo: true });
17353
+ markHealed();
17059
17354
  io.err(` \u21BB quarantined legacy OpenCode config \u2192 ${legacyOpenCodeConfig.legacyPath}.bak \u2014 restart OpenCode`);
17060
17355
  }
17061
17356
  }
@@ -17080,6 +17375,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17080
17375
  const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
17081
17376
  const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
17082
17377
  markPluginReloadRequired();
17378
+ markHealed();
17083
17379
  io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
17084
17380
  }
17085
17381
  cacheCleanupCheck = {
@@ -17107,6 +17403,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17107
17403
  const canDriveCodex = surfaceToken(surface) === "codex" || await hostBinAvailable("codex");
17108
17404
  if (canDriveCodex && await applyPluginHeal("codex", surface, (m) => io.err(m), { force: true })) {
17109
17405
  markPluginReloadRequired();
17406
+ markHealed();
17110
17407
  io.err(` \u21BB restored Codex MMI plugin cache \u2192 ${releasedVersion ?? "latest"} via codex plugin \u2014 ${reloadAction("codex")} to load the new commands`);
17111
17408
  codexActiveCacheCheck = buildCodexActiveCacheCheck({
17112
17409
  isOrgRepo,
@@ -17135,6 +17432,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17135
17432
  }
17136
17433
  if (await applyPluginHeal("claude", surface, (m) => io.err(m))) {
17137
17434
  markPluginReloadRequired();
17435
+ markHealed();
17138
17436
  io.err(` \u21BB reinstalled MMI plugin after nested-cache cleanup \u2014 ${reloadAction(surface)} to load MMI commands`);
17139
17437
  nestedPluginTreeCheck = buildNestedPluginTreeCheck({
17140
17438
  isOrgRepo,
@@ -17177,6 +17475,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17177
17475
  releasedVersion
17178
17476
  });
17179
17477
  if (cursorPluginCheck.ok) {
17478
+ markHealed();
17180
17479
  io.err(` \u21BB seeded Cursor MMI plugin cache \u2192 ${releasedVersion ?? "latest"} \u2014 ${reloadAction(surface)}`);
17181
17480
  }
17182
17481
  }
@@ -17248,48 +17547,66 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17248
17547
  } catch {
17249
17548
  }
17250
17549
  }
17550
+ emitDoctorReport(opts, io, {
17551
+ checks,
17552
+ surface,
17553
+ needsEagerHeal: healPlan.needsEagerHeal,
17554
+ healedCount,
17555
+ pluginReloadRequired,
17556
+ reloadHint,
17557
+ // Surface-aware update report (#865): the per-surface version snapshot + copy-paste update recipes, so
17558
+ // an agent told "make sure the CLI and plugin are up to date" can run the right command per surface and
17559
+ // echo back an unambiguous version line (CLI / Claude plugin / Codex marketplace / Codex active cache).
17560
+ // Passed lazily: emitDoctorReport calls it only past the --preflight/--banner early returns, keeping
17561
+ // the SessionStart hot path free of its synchronous fs I/O.
17562
+ buildUpdateReport: () => {
17563
+ const cacheRoots = mmiPluginCacheRootSnapshots();
17564
+ const cacheVersionsFor = (s) => cacheRoots.filter((r) => r.surface === s).flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name));
17565
+ const sourceVersions = (s) => installedPluginVersions(installedPluginSources().find((src) => src.surface === s)?.installed ?? null);
17566
+ return buildPluginUpdateReport({
17567
+ cliVersion: resolveClientVersion(),
17568
+ claudePluginVersions: sourceVersions("claude"),
17569
+ codexPluginVersions: sourceVersions("codex"),
17570
+ codexCacheVersions: cacheVersionsFor("codex"),
17571
+ opencodePluginVersions: opencodePluginVersionsForReport(),
17572
+ releasedVersion
17573
+ });
17574
+ }
17575
+ });
17576
+ }
17577
+ function emitDoctorReport(opts, io, ctx) {
17578
+ const { checks, surface, healedCount, pluginReloadRequired, reloadHint } = ctx;
17251
17579
  const gaps = checks.filter((c) => !c.ok);
17252
17580
  if (opts.preflight) {
17253
- const outcome = preflightOutcome({ gaps, needsEagerHeal: healPlan.needsEagerHeal, surface });
17581
+ const outcome = preflightOutcome({ gaps, needsEagerHeal: ctx.needsEagerHeal, surface });
17254
17582
  if (outcome.line) io.err(outcome.line);
17255
17583
  if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17256
17584
  return;
17257
17585
  }
17258
17586
  if (opts.banner) {
17259
- if (healPlan.needsEagerHeal) io.err(doctorPreflightDoneLine(surface));
17587
+ if (ctx.needsEagerHeal) io.err(doctorPreflightDoneLine(surface));
17260
17588
  if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17261
17589
  if (gaps.length) io.log(`\u26A0 MMI setup needed \u2014 ${gaps.map((g) => g.fix).join(" \xB7 ")} \xB7 guide: ${MMI_AGENTIC_ONBOARDING_GUIDE.url}`);
17262
17590
  return;
17263
17591
  }
17264
- const cacheRoots = mmiPluginCacheRootSnapshots();
17265
- const cacheVersionsFor = (s) => cacheRoots.filter((r) => r.surface === s).flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name));
17266
- const sourceVersions = (s) => installedPluginVersions(installedPluginSources().find((src) => src.surface === s)?.installed ?? null);
17267
- const updateReport = buildPluginUpdateReport({
17268
- cliVersion: resolveClientVersion(),
17269
- claudePluginVersions: sourceVersions("claude"),
17270
- codexPluginVersions: sourceVersions("codex"),
17271
- codexCacheVersions: cacheVersionsFor("codex"),
17272
- opencodePluginVersions: opencodePluginVersionsForReport(),
17273
- releasedVersion
17274
- });
17592
+ const updateReport = ctx.buildUpdateReport();
17275
17593
  const resources = doctorResourcesForGaps(gaps);
17594
+ process.exitCode = doctorExitCode(checks);
17276
17595
  if (opts.json) {
17277
17596
  io.log(JSON.stringify(buildDoctorJsonPayload({ checks, updateReport, resources }), null, 2));
17278
17597
  return;
17279
17598
  }
17280
- if (terseOutput) {
17281
- if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17282
- for (const line of renderTerseDoctorReport({ gaps, updateReport })) io.log(line);
17283
- for (const r of resources) io.log(`Resource: ${r.label} \u2014 ${r.url}`);
17284
- return;
17285
- }
17286
- for (const c of checks) io.log(c.ok ? `\u2713 ${c.label}` : `\u2717 ${c.label}
17287
- \u2192 ${c.fix}`);
17599
+ if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17600
+ for (const line of doctorHumanLines({
17601
+ checks,
17602
+ updateReport,
17603
+ shouldApply: Boolean(opts.apply),
17604
+ healedCount,
17605
+ pluginReloadRequired,
17606
+ reloadHint,
17607
+ verbose: Boolean(opts.verbose)
17608
+ })) io.log(line);
17288
17609
  for (const r of resources) io.log(`Resource: ${r.label} \u2014 ${r.url}`);
17289
- io.log("");
17290
- for (const line of renderPluginUpdateReport(updateReport)) io.log(line);
17291
- io.log(gaps.length ? `
17292
- ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
17293
17610
  }
17294
17611
  var USER_SCOPE_GUARD_MARKER = "mmi-guard:v1";
17295
17612
  var USER_SCOPE_GUARD_COMMAND = `mmi-cli guard --session-start || true # ${USER_SCOPE_GUARD_MARKER}`;
@@ -18874,6 +19191,21 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
18874
19191
  pollChecks: () => pollGhPrChecks(prNumber, repo ? ["--repo", repo] : []),
18875
19192
  sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
18876
19193
  }),
19194
+ // #2425: re-probe used ONLY to decide whether a failed `gh pr merge --auto` is worth retrying — a
19195
+ // fresh read of state/mergeable/checks, independent of the merge call's own (possibly stale) error.
19196
+ probeMergeReady: async (prNumber, repo) => {
19197
+ const args = repo ? ["--repo", repo] : [];
19198
+ const [state, checks] = await Promise.all([
19199
+ execFileP2("gh", ["pr", "view", prNumber, ...args, "--json", "state,mergeable", "--jq", '.state + " " + .mergeable'], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => r.stdout.trim().split(/\s+/)).catch(() => ["", ""]),
19200
+ pollGhPrChecks(prNumber, args).catch(() => "error")
19201
+ ]);
19202
+ const [prState, mergeable] = state;
19203
+ return {
19204
+ open: prState === "OPEN",
19205
+ mergeable: mergeable === "MERGEABLE",
19206
+ checksPassing: checks === "success" || checks === "no-checks-reported"
19207
+ };
19208
+ },
18877
19209
  mergeAuto: async (prNumber, repo) => {
18878
19210
  const args = repo ? ["--repo", repo] : [];
18879
19211
  const readMergeState = () => readGhPrStateWithRetry(async () => (await execFileP2("gh", ["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 })).stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",