@mutmutco/cli 3.0.0 → 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 +424 -86
  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
  }
@@ -9684,6 +9696,12 @@ async function foldReleaseVersion(deps, model, tag, foldPaths) {
9684
9696
  for (const path2 of foldPaths) {
9685
9697
  await deps.run("git", ["add", "--", path2]).catch(() => void 0);
9686
9698
  }
9699
+ const strayVersionEdits = (await deps.run("git", ["diff", "--name-only"])).split("\n").map((s) => s.trim()).filter(Boolean);
9700
+ if (strayVersionEdits.length > 0) {
9701
+ throw new Error(
9702
+ `version fold left tracked changes outside the fold set uncommitted: ${strayVersionEdits.join(", ")}. The repo's \`version\` script modified paths the train does not stage (fold set: ${foldPaths.join(", ") || "(none)"}). Add them to that script's git-add list (or the fold set) so the release commit is complete \u2014 shipping now would tag a main whose version-lockstep check is red.`
9703
+ );
9704
+ }
9687
9705
  const staged = (await deps.run("git", ["diff", "--cached", "--name-only"])).trim();
9688
9706
  if (!staged) return `version fold: manifests already at ${version} \u2014 nothing committed`;
9689
9707
  await deps.run("git", ["commit", "-m", `chore(release): bump distribution to ${tag}`]);
@@ -9937,9 +9955,12 @@ var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
9937
9955
  var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
9938
9956
  var TRAIN_CHECK_ATTEMPTS = 40;
9939
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;
9940
9960
  async function correlateRun(deps, args) {
9941
9961
  const sleep = resolveSleep(deps);
9942
9962
  const threshold = args.since - CORRELATE_SKEW_SLACK_MS;
9963
+ const repo = args.mode === "workflow" ? args.repo ?? HUB_REPO3 : HUB_REPO3;
9943
9964
  let lastError;
9944
9965
  let parsedAnyResponse = false;
9945
9966
  for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
@@ -9948,7 +9969,7 @@ async function correlateRun(deps, args) {
9948
9969
  "run",
9949
9970
  "list",
9950
9971
  "--repo",
9951
- HUB_REPO3,
9972
+ repo,
9952
9973
  "--workflow",
9953
9974
  args.workflow,
9954
9975
  ...args.mode === "workflow" ? ["--event", args.event] : [],
@@ -9991,10 +10012,10 @@ function correlateControlRun(deps, since, titleIncludes) {
9991
10012
  async function correlateWorkflowRun(deps, args) {
9992
10013
  return correlateRun(deps, { ...args, mode: "workflow" });
9993
10014
  }
9994
- async function watchTenantRun(deps, runId) {
10015
+ async function watchTenantRun(deps, runId, repo = HUB_REPO3) {
9995
10016
  if (runId == null) return "pending";
9996
10017
  try {
9997
- 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"]);
9998
10019
  return "success";
9999
10020
  } catch {
10000
10021
  return "failure";
@@ -10007,9 +10028,9 @@ async function fetchControlRunLog(deps, runId) {
10007
10028
  return "";
10008
10029
  }
10009
10030
  }
10010
- async function watchWorkflowRun(deps, workflow, run) {
10031
+ async function watchWorkflowRun(deps, workflow, run, repo = HUB_REPO3) {
10011
10032
  if (run.runId == null) return { workflow, conclusion: "pending" };
10012
- const conclusion = await watchTenantRun(deps, run.runId);
10033
+ const conclusion = await watchTenantRun(deps, run.runId, repo);
10013
10034
  return { workflow, runId: run.runId, runUrl: run.runUrl, conclusion };
10014
10035
  }
10015
10036
  function aggregateWorkflowRuns(runs) {
@@ -10068,7 +10089,7 @@ async function rollDevelopmentForward(deps, ctx, tag) {
10068
10089
  status: "pr-pending",
10069
10090
  prNumber: existing.number,
10070
10091
  prUrl: existing.url,
10071
- note: `alignment PR already open: ${existing.url} \u2014 land it with \`gh pr merge ${existing.number} --merge\``
10092
+ note: `alignment PR already open: ${existing.url} \u2014 land it with \`mmi-cli pr merge ${existing.number} --auto --merge\``
10072
10093
  };
10073
10094
  }
10074
10095
  const ahead = clean(await deps.run("git", ["rev-list", "--count", "origin/development..main"]));
@@ -10077,14 +10098,14 @@ async function rollDevelopmentForward(deps, ctx, tag) {
10077
10098
  }
10078
10099
  const body = `Carries the ${tag} release (including the version fold) from \`main\` back to \`development\`.
10079
10100
 
10080
- \`development\` requires status checks, so the release train opens this alignment PR instead of a direct push of the un-checked merge commit (#1143). Land it with a **true merge** (\`gh pr merge --merge\`, not squash) so the merge parentage survives and the misalignment guard stays satisfied.`;
10101
+ \`development\` requires status checks, so the release train opens this alignment PR instead of a direct push of the un-checked merge commit (#1143). Land it with a **true merge** \u2014 \`mmi-cli pr merge <n> --auto --merge\` (not squash) so the merge parentage survives and the misalignment guard stays satisfied. \`--auto\` waits out the checks this PR triggers, which otherwise block an immediate merge right after the release.`;
10081
10102
  const url = clean(await deps.run("gh", ["pr", "create", "--repo", ctx.repo, "--base", "development", "--head", "main", "--title", `chore(release): align development to ${tag}`, "--body", body]));
10082
10103
  const number = parsePrNumber(url);
10083
10104
  return {
10084
10105
  status: "pr-pending",
10085
10106
  prNumber: number,
10086
10107
  prUrl: url || void 0,
10087
- note: `development requires checks (${required.join(", ")}); opened alignment PR ${url || "(url unavailable)"} \u2014 land it with \`gh pr merge ${number ?? "<number>"} --merge\``
10108
+ note: `development requires checks (${required.join(", ")}); opened alignment PR ${url || "(url unavailable)"} \u2014 land it with \`mmi-cli pr merge ${number ?? "<number>"} --auto --merge\``
10088
10109
  };
10089
10110
  }
10090
10111
  function resolveContextState(context, checkRuns, statuses) {
@@ -10118,6 +10139,7 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
10118
10139
  let lastStatus = "not checked";
10119
10140
  let lastError;
10120
10141
  const everObserved = /* @__PURE__ */ new Set();
10142
+ const autoSatisfied = /* @__PURE__ */ new Set();
10121
10143
  for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
10122
10144
  if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
10123
10145
  let checkRuns;
@@ -10139,18 +10161,25 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
10139
10161
  for (const c of required) {
10140
10162
  if (checkRuns.some((r) => r.name === c) || statuses.some((s) => s.context === c)) everObserved.add(c);
10141
10163
  }
10142
- const states = required.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
10143
- 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}`;
10144
10173
  const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
10145
10174
  if (failed.length > 0) {
10146
10175
  throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
10147
10176
  }
10148
10177
  if (states.every(([, s]) => s === "success")) {
10149
- return `required checks passed: ${required.join(", ")}`;
10178
+ return `required checks passed: ${pending.join(", ") || "(none pending)"}${satisfiedNote}`;
10150
10179
  }
10151
10180
  }
10152
10181
  const waitedMin = Math.round((TRAIN_CHECK_ATTEMPTS - 1) * TRAIN_CHECK_DELAY_MS / 6e4);
10153
- const neverMaterialized = required.filter((c) => !everObserved.has(c));
10182
+ const neverMaterialized = required.filter((c) => !everObserved.has(c) && !autoSatisfied.has(c));
10154
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(", ")}.` : "";
10155
10184
  throw new Error(
10156
10185
  `timed out after ~${waitedMin}m (${TRAIN_CHECK_ATTEMPTS} attempts) waiting for required train checks on ${sha}.${neverNote} Last observed: ${lastError ? `error: ${lastError}` : lastStatus}`
@@ -10240,6 +10269,19 @@ function tenantPublishRecoveryCommand(slug, repo, ref, stage2, publishDir) {
10240
10269
  if (publishDir && publishDir !== ".") parts.push(`-f publishDir=${publishDir}`);
10241
10270
  return parts.join(" ");
10242
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
+ }
10243
10285
  async function dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFailure = "throw", publishDir) {
10244
10286
  const since = (deps.now ?? Date.now)();
10245
10287
  const dispatchArgs = [
@@ -10270,8 +10312,16 @@ async function dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFail
10270
10312
  };
10271
10313
  }
10272
10314
  const { runId, runUrl } = await correlatePublishRun(deps, since, [ctx.slug, stage2]);
10273
- const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
10274
- 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 };
10275
10325
  }
10276
10326
  async function dispatchPublishIfRequired(deps, ctx, meta, model, stage2, publishRef, watch, dispatchFailure) {
10277
10327
  if (!meta.publishRequired || stage2 !== "main") return null;
@@ -10288,6 +10338,18 @@ function appendPublishDispatch(deploy, publish) {
10288
10338
  deployStatus: deploy.deployStatus === "failure" || publish.deployStatus === "failure" ? "failure" : deploy.deployStatus === "pending" || publish.deployStatus === "pending" ? "pending" : "success"
10289
10339
  };
10290
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
+ }
10291
10353
  async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince, autoRunHeadSha, dispatchFailure = "throw", publishDir) {
10292
10354
  if (model === "tenant-container" || model === "solo-container") {
10293
10355
  const since = (deps.now ?? Date.now)();
@@ -10306,7 +10368,18 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
10306
10368
  return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
10307
10369
  }
10308
10370
  if (model === "registry-publish") {
10309
- 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) };
10310
10383
  }
10311
10384
  if (model === "hub-serverless") {
10312
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)";
@@ -10317,15 +10390,7 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
10317
10390
  { workflow: "deploy.yml", event: "release" },
10318
10391
  { workflow: "publish.yml", event: "release" }
10319
10392
  ];
10320
- const workflowRuns = [];
10321
- for (const target of targets) {
10322
- try {
10323
- const run = await correlateWorkflowRun(deps, { ...target, since, headSha: autoRunHeadSha });
10324
- workflowRuns.push(await watchWorkflowRun(deps, target.workflow, run));
10325
- } catch {
10326
- workflowRuns.push({ workflow: target.workflow, conclusion: "failure" });
10327
- }
10328
- }
10393
+ const workflowRuns = await watchOwnWorkflowRuns(deps, HUB_REPO3, targets, since, autoRunHeadSha);
10329
10394
  const primary = workflowRuns[0];
10330
10395
  return {
10331
10396
  note,
@@ -11159,7 +11224,11 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11159
11224
  runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
11160
11225
  }
11161
11226
  deployNote = "watched release-triggered deploy.yml + publish.yml";
11162
- } 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") {
11163
11232
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
11164
11233
  const deploy = await dispatchDeploy(
11165
11234
  deps,
@@ -11175,7 +11244,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11175
11244
  );
11176
11245
  const publish = deploy.deployStatus === "success" ? await dispatchPublishIfRequired(deps, ctx, meta, deployModel, "main", tag, true, "report") : null;
11177
11246
  let dispatch = appendPublishDispatch(deploy, publish);
11178
- if (!publish && deploy.deployStatus !== "success" && meta.publishRequired && (deployModel === "tenant-container" || deployModel === "solo-container")) {
11247
+ if (!publish && deploy.deployStatus !== "success" && meta.publishRequired) {
11179
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";
11180
11249
  dispatch = {
11181
11250
  ...dispatch,
@@ -11183,25 +11252,17 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11183
11252
  };
11184
11253
  }
11185
11254
  deployNote = dispatch.note;
11186
- if (deployModel !== "registry-publish") {
11187
- runs.push({
11188
- workflow: "tenant-deploy.yml",
11189
- url: deploy.runUrl,
11190
- conclusion: deploy.deployStatus === "success" ? "success" : deploy.deployStatus === "failure" ? "failure" : deploy.deployStatus ?? "pending"
11191
- });
11192
- }
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
+ });
11193
11260
  if (publish?.runUrl) {
11194
11261
  runs.push({
11195
11262
  workflow: "tenant-publish.yml",
11196
11263
  url: publish.runUrl,
11197
11264
  conclusion: publish.deployStatus === "success" ? "success" : publish.deployStatus === "failure" ? "failure" : publish.deployStatus ?? "pending"
11198
11265
  });
11199
- } else if (deployModel === "registry-publish") {
11200
- runs.push({
11201
- workflow: "tenant-publish.yml",
11202
- url: deploy.runUrl,
11203
- conclusion: deploy.deployStatus === "success" ? "success" : deploy.deployStatus === "failure" ? "failure" : deploy.deployStatus ?? "pending"
11204
- });
11205
11266
  }
11206
11267
  } else {
11207
11268
  deployNote = `no hotfix deploy dispatch for deployModel=${deployModel} \u2014 prod deploy is repo-specific`;
@@ -15323,10 +15384,12 @@ function isSemverVersion2(v) {
15323
15384
  function staleRecordCommand(surface) {
15324
15385
  return surface === "codex" ? CODEX_PLUGIN_RECOVERY : CLAUDE_PLUGIN_RECOVERY;
15325
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";
15326
15388
  function staleSurfacesFix(stale, releasedVersion) {
15327
15389
  const parts = stale.map((s) => {
15328
15390
  const at = s.recordPath ? ` (${s.recordPath})` : "";
15329
- 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}`;
15330
15393
  });
15331
15394
  return `stale installed-plugin record on ${stale.map((s) => s.surface).join(" + ")}: ${parts.join("; ")}`;
15332
15395
  }
@@ -15368,18 +15431,71 @@ function buildInstalledPluginVersionCheck(input) {
15368
15431
  staleSurfaces: stale
15369
15432
  };
15370
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
+ }
15371
15487
  var OPENCODE_VERSION_LABEL = "installed OpenCode MMI adapter version (vs latest release)";
15372
15488
  function buildOpencodeVersionCheck(input) {
15373
15489
  const fix = pluginRecoveryFix("opencode");
15374
15490
  const base = { ok: true, label: OPENCODE_VERSION_LABEL, fix };
15375
15491
  if (!input.isOrgRepo || !isSemverVersion2(input.releasedVersion)) return base;
15376
15492
  if (!isSemverVersion2(input.installedVersion)) {
15377
- return { ...base, ok: false, releasedVersion: input.releasedVersion };
15493
+ return { ...base, ok: false, severityOverride: "hard", releasedVersion: input.releasedVersion };
15378
15494
  }
15379
15495
  if (compareVersions(input.installedVersion, input.releasedVersion) >= 0) {
15380
15496
  return { ...base, installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15381
15497
  }
15382
- return { ...base, ok: false, installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15498
+ return { ...base, ok: false, severityOverride: "hard", installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15383
15499
  }
15384
15500
  function pickOpencodeActiveVersion(input) {
15385
15501
  for (const value of [input.envStamp, input.cacheVersion, input.diskVersion]) {
@@ -15628,9 +15744,10 @@ function buildCursorPluginInstallCheck(input) {
15628
15744
  return {
15629
15745
  ...base,
15630
15746
  ok: false,
15747
+ severityOverride: "advisory",
15631
15748
  cacheRoot: input.cacheRoot,
15632
15749
  pins: input.pins,
15633
- 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}`
15634
15751
  };
15635
15752
  }
15636
15753
  return { ...base, cacheRoot: input.cacheRoot, pins: input.pins };
@@ -15824,6 +15941,30 @@ function preflightOutcome(input) {
15824
15941
  function pluginAutonomousHaltLine(reloadHint) {
15825
15942
  return `\u26A0 PLUGIN RELOAD REQUIRED \u2014 mmi:* skills and agent types are unavailable until you ${reloadHint}. Halt autonomous /grind and /build until then.`;
15826
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
+ }
15827
15968
  function renderPluginUpdateReportStaleOnly(report) {
15828
15969
  const v = report.versions;
15829
15970
  const released = v.released;
@@ -15838,24 +15979,97 @@ function renderPluginUpdateReportStaleOnly(report) {
15838
15979
  return ["Update commands (stale surfaces):", ...blocks];
15839
15980
  }
15840
15981
  var DOCTOR_VERBOSE_HINT = "Run mmi-cli doctor --verbose for the full audit checklist + version report.";
15841
- function renderTerseDoctorReport(input) {
15842
- const cliVersion = input.updateReport.versions.cli;
15843
- const versionSuffix = cliVersion ? ` (mmi-cli ${cliVersion})` : "";
15844
- if (!input.gaps.length) {
15845
- 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.";
15846
16000
  }
15847
- const lines = [];
15848
- for (const c of input.gaps) {
15849
- lines.push(`\u2717 ${c.label}`);
15850
- 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.`);
15851
16024
  }
15852
16025
  const stale = renderPluginUpdateReportStaleOnly(input.updateReport);
15853
16026
  if (stale.length) {
15854
- lines.push("");
15855
- 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
+ }
15856
16072
  }
15857
- lines.push("");
15858
- lines.push(`\u26A0 ${input.gaps.length} item(s) need attention \u2014 ${DOCTOR_VERBOSE_HINT}`);
15859
16073
  return lines;
15860
16074
  }
15861
16075
  var PLUGIN_RESOLVABILITY_LABEL = "MMI plugin resolvability (marketplace + cache present)";
@@ -16240,6 +16454,77 @@ function installedPluginSources() {
16240
16454
  }
16241
16455
  });
16242
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
+ }
16243
16528
  function readClaudeSettings() {
16244
16529
  try {
16245
16530
  return JSON.parse((0, import_node_fs17.readFileSync)((0, import_node_path17.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
@@ -16698,7 +16983,6 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16698
16983
  const repairLocal = !opts.json || Boolean(opts.apply) || Boolean(opts.preflight);
16699
16984
  const repoWritesAllowed = !opts.noRepoWrites;
16700
16985
  const runExtended = Boolean(opts.verbose) || Boolean(opts.json);
16701
- const terseOutput = !opts.verbose && !opts.json && !opts.banner && !opts.preflight;
16702
16986
  const checks = [];
16703
16987
  const REWRITE_KEY = "url.https://github.com/.insteadOf";
16704
16988
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
@@ -16768,6 +17052,10 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16768
17052
  const markPluginReloadRequired = () => {
16769
17053
  pluginReloadRequired = true;
16770
17054
  };
17055
+ let healedCount = 0;
17056
+ const markHealed = () => {
17057
+ healedCount += 1;
17058
+ };
16771
17059
  let versionReport = buildVersionLagReport({
16772
17060
  currentVersion: resolveClientVersion(),
16773
17061
  repoVersion: readRepoVersion(),
@@ -16820,6 +17108,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16820
17108
  try {
16821
17109
  await execFileP2("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
16822
17110
  cloneOk = true;
17111
+ markHealed();
16823
17112
  io.err(" \u21BB repaired: git insteadOf git@github.com \u2192 https (plugin clone over HTTPS)");
16824
17113
  } catch {
16825
17114
  }
@@ -16838,6 +17127,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16838
17127
  if (!pluginCheck.ok && pluginCheck.recordToInsert && repairLocal) {
16839
17128
  if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
16840
17129
  pluginCheck = { ...pluginCheck, ok: true };
17130
+ markHealed();
16841
17131
  io.err(` \u21BB repaired: registered mmi@mutmutco project install record \u2014 ${reloadHint} to load MMI commands`);
16842
17132
  }
16843
17133
  }
@@ -16857,6 +17147,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16857
17147
  surface
16858
17148
  });
16859
17149
  if (legacyPluginCheck.ok) {
17150
+ markHealed();
16860
17151
  io.err(` \u21BB migrated legacy mmi@mmi \u2192 mmi@mutmutco via claude plugin \u2014 ${reloadHint} to load MMI commands`);
16861
17152
  }
16862
17153
  }
@@ -16867,6 +17158,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16867
17158
  surface
16868
17159
  });
16869
17160
  if (legacyPluginCheck.ok) {
17161
+ markHealed();
16870
17162
  io.err(` \u21BB migrated legacy mmi@mmi \u2192 mmi@mutmutco via codex plugin \u2014 ${reloadHint} to load MMI commands`);
16871
17163
  }
16872
17164
  }
@@ -16885,6 +17177,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16885
17177
  gitignoreCheck.removed?.length ? `removed ${gitignoreCheck.removed.join(", ")}` : ""
16886
17178
  ].filter(Boolean).join("; ") || "normalized the block";
16887
17179
  gitignoreCheck = { ...gitignoreCheck, ok: true };
17180
+ markHealed();
16888
17181
  io.err(` \u21BB repaired: org-managed .gitignore block \u2014 ${drift}`);
16889
17182
  io.err(" this is an org-managed update (not unrelated churn) \u2014 stage & commit .gitignore so it stops recurring");
16890
17183
  }
@@ -16898,6 +17191,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16898
17191
  if (!driftCheck.ok && driftCheck.recordsToWrite && repairLocal) {
16899
17192
  if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
16900
17193
  driftCheck = { ...driftCheck, ok: true };
17194
+ markHealed();
16901
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`);
16902
17196
  }
16903
17197
  }
@@ -16930,6 +17224,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16930
17224
  installedVersionCheck = healed;
16931
17225
  if (healed.ok) {
16932
17226
  markPluginReloadRequired();
17227
+ markHealed();
16933
17228
  io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reloadAction(surface)} to load the new commands`);
16934
17229
  }
16935
17230
  }
@@ -16939,11 +17234,13 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16939
17234
  installedVersionCheck = healed;
16940
17235
  if (healed.ok) {
16941
17236
  markPluginReloadRequired();
17237
+ markHealed();
16942
17238
  io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via codex plugin \u2014 ${reloadAction(surface)} to load the new commands`);
16943
17239
  }
16944
17240
  }
16945
17241
  }
16946
17242
  checks.push(installedVersionCheck);
17243
+ checks.push(buildManagedPluginDriftCheck({ isOrgRepo, sources: managedPluginSurfaceSources() }));
16947
17244
  let openCodeConfigSnapshot = opencodeConfigSnapshot();
16948
17245
  const inspectOpenCode = surface === "opencode" || openCodeConfigSnapshot.hasConfig || runExtended;
16949
17246
  if (inspectOpenCode) {
@@ -16968,6 +17265,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16968
17265
  });
16969
17266
  if (opencodeConfigCheck.ok) {
16970
17267
  markPluginReloadRequired();
17268
+ markHealed();
16971
17269
  io.err(` \u21BB repaired: wired ${OPENCODE_PLUGIN_PACKAGE} in OpenCode config \u2014 ${reloadAction("opencode")} to load MMI commands`);
16972
17270
  }
16973
17271
  }
@@ -16991,6 +17289,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16991
17289
  });
16992
17290
  if (opencodeVersionCheck.ok) {
16993
17291
  markPluginReloadRequired();
17292
+ markHealed();
16994
17293
  io.err(` \u21BB force-refreshed OpenCode MMI plugin \u2192 ${opencodeInstalledVersion ?? releasedVersion ?? "latest"} \u2014 ${reloadAction("opencode")} to load it`);
16995
17294
  }
16996
17295
  }
@@ -17029,6 +17328,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17029
17328
  });
17030
17329
  if (surfaceAssetsCheck.ok) {
17031
17330
  markPluginReloadRequired();
17331
+ markHealed();
17032
17332
  io.err(` \u21BB materialized OpenCode MMI commands + skills path \u2014 ${reloadAction("opencode")} to load them`);
17033
17333
  }
17034
17334
  }
@@ -17050,6 +17350,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17050
17350
  if (!legacyOpenCodeCheck.ok && repairLocal && legacyOpenCodeConfig.legacyPath) {
17051
17351
  if (quarantineOpencodeLegacyConfig(legacyOpenCodeConfig.legacyPath)) {
17052
17352
  legacyOpenCodeCheck = buildOpencodeLegacyConfigCheck({ isOrgRepo: true });
17353
+ markHealed();
17053
17354
  io.err(` \u21BB quarantined legacy OpenCode config \u2192 ${legacyOpenCodeConfig.legacyPath}.bak \u2014 restart OpenCode`);
17054
17355
  }
17055
17356
  }
@@ -17074,6 +17375,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17074
17375
  const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
17075
17376
  const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
17076
17377
  markPluginReloadRequired();
17378
+ markHealed();
17077
17379
  io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
17078
17380
  }
17079
17381
  cacheCleanupCheck = {
@@ -17101,6 +17403,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17101
17403
  const canDriveCodex = surfaceToken(surface) === "codex" || await hostBinAvailable("codex");
17102
17404
  if (canDriveCodex && await applyPluginHeal("codex", surface, (m) => io.err(m), { force: true })) {
17103
17405
  markPluginReloadRequired();
17406
+ markHealed();
17104
17407
  io.err(` \u21BB restored Codex MMI plugin cache \u2192 ${releasedVersion ?? "latest"} via codex plugin \u2014 ${reloadAction("codex")} to load the new commands`);
17105
17408
  codexActiveCacheCheck = buildCodexActiveCacheCheck({
17106
17409
  isOrgRepo,
@@ -17129,6 +17432,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17129
17432
  }
17130
17433
  if (await applyPluginHeal("claude", surface, (m) => io.err(m))) {
17131
17434
  markPluginReloadRequired();
17435
+ markHealed();
17132
17436
  io.err(` \u21BB reinstalled MMI plugin after nested-cache cleanup \u2014 ${reloadAction(surface)} to load MMI commands`);
17133
17437
  nestedPluginTreeCheck = buildNestedPluginTreeCheck({
17134
17438
  isOrgRepo,
@@ -17171,6 +17475,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17171
17475
  releasedVersion
17172
17476
  });
17173
17477
  if (cursorPluginCheck.ok) {
17478
+ markHealed();
17174
17479
  io.err(` \u21BB seeded Cursor MMI plugin cache \u2192 ${releasedVersion ?? "latest"} \u2014 ${reloadAction(surface)}`);
17175
17480
  }
17176
17481
  }
@@ -17242,48 +17547,66 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17242
17547
  } catch {
17243
17548
  }
17244
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;
17245
17579
  const gaps = checks.filter((c) => !c.ok);
17246
17580
  if (opts.preflight) {
17247
- const outcome = preflightOutcome({ gaps, needsEagerHeal: healPlan.needsEagerHeal, surface });
17581
+ const outcome = preflightOutcome({ gaps, needsEagerHeal: ctx.needsEagerHeal, surface });
17248
17582
  if (outcome.line) io.err(outcome.line);
17249
17583
  if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17250
17584
  return;
17251
17585
  }
17252
17586
  if (opts.banner) {
17253
- if (healPlan.needsEagerHeal) io.err(doctorPreflightDoneLine(surface));
17587
+ if (ctx.needsEagerHeal) io.err(doctorPreflightDoneLine(surface));
17254
17588
  if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17255
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}`);
17256
17590
  return;
17257
17591
  }
17258
- const cacheRoots = mmiPluginCacheRootSnapshots();
17259
- const cacheVersionsFor = (s) => cacheRoots.filter((r) => r.surface === s).flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name));
17260
- const sourceVersions = (s) => installedPluginVersions(installedPluginSources().find((src) => src.surface === s)?.installed ?? null);
17261
- const updateReport = buildPluginUpdateReport({
17262
- cliVersion: resolveClientVersion(),
17263
- claudePluginVersions: sourceVersions("claude"),
17264
- codexPluginVersions: sourceVersions("codex"),
17265
- codexCacheVersions: cacheVersionsFor("codex"),
17266
- opencodePluginVersions: opencodePluginVersionsForReport(),
17267
- releasedVersion
17268
- });
17592
+ const updateReport = ctx.buildUpdateReport();
17269
17593
  const resources = doctorResourcesForGaps(gaps);
17594
+ process.exitCode = doctorExitCode(checks);
17270
17595
  if (opts.json) {
17271
17596
  io.log(JSON.stringify(buildDoctorJsonPayload({ checks, updateReport, resources }), null, 2));
17272
17597
  return;
17273
17598
  }
17274
- if (terseOutput) {
17275
- if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17276
- for (const line of renderTerseDoctorReport({ gaps, updateReport })) io.log(line);
17277
- for (const r of resources) io.log(`Resource: ${r.label} \u2014 ${r.url}`);
17278
- return;
17279
- }
17280
- for (const c of checks) io.log(c.ok ? `\u2713 ${c.label}` : `\u2717 ${c.label}
17281
- \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);
17282
17609
  for (const r of resources) io.log(`Resource: ${r.label} \u2014 ${r.url}`);
17283
- io.log("");
17284
- for (const line of renderPluginUpdateReport(updateReport)) io.log(line);
17285
- io.log(gaps.length ? `
17286
- ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
17287
17610
  }
17288
17611
  var USER_SCOPE_GUARD_MARKER = "mmi-guard:v1";
17289
17612
  var USER_SCOPE_GUARD_COMMAND = `mmi-cli guard --session-start || true # ${USER_SCOPE_GUARD_MARKER}`;
@@ -18868,6 +19191,21 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
18868
19191
  pollChecks: () => pollGhPrChecks(prNumber, repo ? ["--repo", repo] : []),
18869
19192
  sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
18870
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
+ },
18871
19209
  mergeAuto: async (prNumber, repo) => {
18872
19210
  const args = repo ? ["--repo", repo] : [];
18873
19211
  const readMergeState = () => readGhPrStateWithRetry(async () => (await execFileP2("gh", ["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 })).stdout);
@@ -19625,7 +19963,7 @@ function renderTrainApply(commandName, r) {
19625
19963
  if (r.rcRetirement) base = `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})`;
19626
19964
  if (r.devRollForward) {
19627
19965
  const f = r.devRollForward;
19628
- base = f.status === "pr-pending" ? `${base}; dev roll-forward: ALIGNMENT PR PENDING \u2014 land it with \`gh pr merge ${f.prNumber ?? "<number>"} --merge\`${f.prUrl ? ` (${f.prUrl})` : ""}` : `${base}; dev roll-forward: ${f.note}`;
19966
+ base = f.status === "pr-pending" ? `${base}; dev roll-forward: ALIGNMENT PR PENDING \u2014 land it with \`mmi-cli pr merge ${f.prNumber ?? "<number>"} --auto --merge\`${f.prUrl ? ` (${f.prUrl})` : ""}` : `${base}; dev roll-forward: ${f.note}`;
19629
19967
  }
19630
19968
  if (r.checkout) {
19631
19969
  base = `${base}; checkout: ${r.checkout.note}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.0.0",
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",