@mutmutco/cli 3.0.1 → 3.2.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 +1167 -200
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -4789,7 +4789,7 @@ mutation($projectId: ID!, $itemId: ID!) {
4789
4789
  async function updateItemSingleSelect(client, projectId, itemId, fieldId, optionId) {
4790
4790
  await client.graphql(UPDATE_ITEM_FIELD_MUTATION, { projectId, itemId, fieldId, optionId });
4791
4791
  }
4792
- function resolveBoardConfig(cfg) {
4792
+ function missingBoardConfigFields(cfg) {
4793
4793
  const missing = [];
4794
4794
  if (!cfg.projectOwner) missing.push("projectOwner");
4795
4795
  if (!cfg.projectNumber) missing.push("projectNumber");
@@ -4799,9 +4799,20 @@ function resolveBoardConfig(cfg) {
4799
4799
  for (const status of BOARD_STATUSES) {
4800
4800
  if (!cfg.statusOptions?.[status]) missing.push(`statusOptions.${status}`);
4801
4801
  }
4802
- if (missing.length) {
4803
- throw new Error(
4804
- `Hub registry board META missing ${missing.join(", ")}; run \`gh auth login\`, then \`mmi-cli project get <owner/repo>\`, or ask a master-admin to register/backfill board coords`
4802
+ return missing;
4803
+ }
4804
+ function diagnoseBoardConfigGap(cfg) {
4805
+ const missing = missingBoardConfigFields(cfg);
4806
+ if (!missing.length) return null;
4807
+ return cfg.projectId ? { kind: "registry-meta-missing", missing } : { kind: "no-project-detected" };
4808
+ }
4809
+ function resolveBoardConfig(cfg) {
4810
+ const gap = diagnoseBoardConfigGap(cfg);
4811
+ if (gap) {
4812
+ throw gap.kind === "no-project-detected" ? new Error(
4813
+ "no MMI project detected for this workspace (no Hub registry match for this repo, or no git origin in this folder); run `mmi-cli board read --repo <owner/repo>` to target a specific registered repo, or `mmi-cli project get <owner/repo>` to check registration"
4814
+ ) : new Error(
4815
+ `Hub registry board META missing ${gap.missing.join(", ")}; run \`gh auth login\`, then \`mmi-cli project get <owner/repo>\`, or ask a master-admin to register/backfill board coords`
4805
4816
  );
4806
4817
  }
4807
4818
  return {
@@ -4864,6 +4875,11 @@ function detailCandidates(report) {
4864
4875
  ...report.secondary.claimable
4865
4876
  ].filter((item) => item.contentType === "Issue");
4866
4877
  }
4878
+ function boardNotFoundError(ref, board2, opts = {}) {
4879
+ const verb = opts.verb ?? "is not on";
4880
+ const remedy = opts.remedy ?? "if it lives on a different board, pass --repo <owner/repo> for the repo that owns it";
4881
+ return new Error(`${ref} ${verb} the ${board2.owner} #${board2.number} board; ${remedy}`);
4882
+ }
4867
4883
  function findClaimableItem(report, selector) {
4868
4884
  const candidates = [...report.primary.claimable, ...report.secondary.claimable];
4869
4885
  const found = candidates.find((item) => item.repository.toLowerCase() === selector.repo.toLowerCase() && item.number === selector.number);
@@ -4882,7 +4898,10 @@ function findClaimableItem(report, selector) {
4882
4898
  if (existing.assignees.length) throw new Error(`${existing.ref} is already assigned to @${existing.assignees.join(", @")}`);
4883
4899
  throw new Error(`${existing.ref} is not claimable`);
4884
4900
  }
4885
- throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4901
+ throw boardNotFoundError(`${selector.repo}#${selector.number}`, report.project, {
4902
+ verb: "is not a claimable item on",
4903
+ remedy: "it may already be Done (excluded from claim tracking), or it lives on a different board \u2014 pass --repo <owner/repo> for the repo that owns it"
4904
+ });
4886
4905
  }
4887
4906
  function renderBoardItem(item) {
4888
4907
  const assignees = item.assignees.length ? `@${item.assignees.join(", @")}` : "unassigned";
@@ -5022,11 +5041,11 @@ async function readBoard(options, deps = {}) {
5022
5041
  }
5023
5042
  return report;
5024
5043
  }
5025
- function findBoardItem(items, selector) {
5044
+ function findBoardItem(items, selector, board2) {
5026
5045
  const found = items.find(
5027
5046
  (candidate) => candidate.repository.toLowerCase() === selector.repo.toLowerCase() && candidate.number === selector.number
5028
5047
  );
5029
- if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
5048
+ if (!found) throw boardNotFoundError(`${selector.repo}#${selector.number}`, board2);
5030
5049
  return found;
5031
5050
  }
5032
5051
  async function resolveCurrentRepo(options, deps) {
@@ -5045,7 +5064,9 @@ async function moveBoardItem(options, deps = {}) {
5045
5064
  const selector = parseIssueSelector(options.selector, currentRepo);
5046
5065
  const lookup = await fetchIssueProjectItem(client, cfg, selector);
5047
5066
  const item = lookup.item;
5048
- if (!item) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
5067
+ if (!item) {
5068
+ throw boardNotFoundError(`${selector.repo}#${selector.number}`, { owner: cfg.projectOwner, number: cfg.projectNumber });
5069
+ }
5049
5070
  const optionId = cfg.statusOptions[options.status];
5050
5071
  try {
5051
5072
  await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.statusFieldId, optionId);
@@ -5091,7 +5112,7 @@ async function showBoardItem(options, deps = {}) {
5091
5112
  const currentRepo = await resolveCurrentRepo(options, deps);
5092
5113
  const selector = parseIssueSelector(options.selector, currentRepo);
5093
5114
  const { item } = await fetchIssueProjectItem(client, cfg, selector);
5094
- if (!item) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
5115
+ if (!item) throw boardNotFoundError(`${selector.repo}#${selector.number}`, { owner: cfg.projectOwner, number: cfg.projectNumber });
5095
5116
  if (item.contentType === "Issue") {
5096
5117
  try {
5097
5118
  item.details = await fetchIssueDetails(client, item.repository, item.number);
@@ -5104,9 +5125,10 @@ async function showBoardItem(options, deps = {}) {
5104
5125
  async function prepareClaimContext(options, selectors, deps, collected) {
5105
5126
  const cfg = resolveBoardConfig(options.config);
5106
5127
  const client = deps.client ?? defaultGitHubClient();
5128
+ const board2 = { owner: cfg.projectOwner, number: cfg.projectNumber };
5107
5129
  for (const selector of selectors) {
5108
5130
  try {
5109
- findBoardItem(collected.items, selector);
5131
+ findBoardItem(collected.items, selector, board2);
5110
5132
  } catch {
5111
5133
  const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
5112
5134
  if (fallback) collected.items.push(fallback);
@@ -5132,7 +5154,7 @@ async function prepareClaimContext(options, selectors, deps, collected) {
5132
5154
  }
5133
5155
  async function claimOneBoardItem(ctx, selector, options) {
5134
5156
  const { cfg, client, report } = ctx;
5135
- const flatItem = findBoardItem(ctx.items, selector);
5157
+ const flatItem = findBoardItem(ctx.items, selector, { owner: cfg.projectOwner, number: cfg.projectNumber });
5136
5158
  if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !ctx.writable.has(flatItem.repository.toLowerCase())) {
5137
5159
  throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
5138
5160
  }
@@ -6994,6 +7016,18 @@ var PR_LAND_POLL_MS = 3e4;
6994
7016
  var PR_LAND_ENQUEUE_TIMEOUT_MS = 10 * 6e4;
6995
7017
  var PR_LAND_STATE_READ_RETRIES = 3;
6996
7018
  var PR_LAND_STATE_READ_DELAY_MS = 2e3;
7019
+ var PR_LAND_MERGE_RETRY_DELAY_MS = 3e3;
7020
+ async function mergeAutoWithTransientRetry(prNumber, repo, deps) {
7021
+ const first = await deps.mergeAuto(prNumber, repo);
7022
+ if (first.mergeStatus !== "failed") return first;
7023
+ const ready = await deps.probeMergeReady(prNumber, repo).catch(() => ({ open: false, mergeable: false, checksPassing: false }));
7024
+ if (!ready.open || !ready.mergeable || !ready.checksPassing) return first;
7025
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve6) => setTimeout(resolve6, ms)));
7026
+ await sleep(PR_LAND_MERGE_RETRY_DELAY_MS);
7027
+ const retried = await deps.mergeAuto(prNumber, repo);
7028
+ if (retried.mergeStatus !== "failed") return retried;
7029
+ return { mergeStatus: "failed", error: `merge retry after transient failure also failed: ${retried.error ?? first.error ?? "unknown error"}` };
7030
+ }
6997
7031
  async function readGhPrStateWithRetry(fetchState, options) {
6998
7032
  const retries = options?.retries ?? PR_LAND_STATE_READ_RETRIES;
6999
7033
  const delayMs = options?.delayMs ?? PR_LAND_STATE_READ_DELAY_MS;
@@ -7037,7 +7071,7 @@ async function runPrLand(prNumber, options, deps) {
7037
7071
  error: `checks-wait ${checksWait.status}${checksWait.detail ? `: ${checksWait.detail}` : ""}`
7038
7072
  };
7039
7073
  }
7040
- const merge = await deps.mergeAuto(prNumber, repo);
7074
+ const merge = await mergeAutoWithTransientRetry(prNumber, repo, deps);
7041
7075
  base.mergeStatus = merge.mergeStatus;
7042
7076
  if (merge.mergeStatus === "failed") {
7043
7077
  return { ...base, error: merge.error ?? "merge failed" };
@@ -9411,7 +9445,7 @@ function trainPlan(command, options = {}) {
9411
9445
  { label: "merge development to main", gated: true },
9412
9446
  { 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
9447
  { 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 },
9448
+ { 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
9449
  { label: "roll development forward", gated: true }
9416
9450
  ];
9417
9451
  }
@@ -9439,7 +9473,7 @@ function trainPlan(command, options = {}) {
9439
9473
  { label: "merge rc to main", gated: true },
9440
9474
  { 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
9475
  { 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 },
9476
+ { 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
9477
  { label: "roll development forward", gated: true }
9444
9478
  ];
9445
9479
  }
@@ -9943,9 +9977,12 @@ var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
9943
9977
  var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
9944
9978
  var TRAIN_CHECK_ATTEMPTS = 40;
9945
9979
  var TRAIN_CHECK_DELAY_MS = 15e3;
9980
+ var TRAIN_PR_ONLY_AUTOMATION_CONTEXTS = /* @__PURE__ */ new Set(["add-to-project", "mark-merged-pr-done"]);
9981
+ var TRAIN_PR_AUTOMATION_GRACE_ATTEMPTS = 3;
9946
9982
  async function correlateRun(deps, args) {
9947
9983
  const sleep = resolveSleep(deps);
9948
9984
  const threshold = args.since - CORRELATE_SKEW_SLACK_MS;
9985
+ const repo = args.mode === "workflow" ? args.repo ?? HUB_REPO3 : HUB_REPO3;
9949
9986
  let lastError;
9950
9987
  let parsedAnyResponse = false;
9951
9988
  for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
@@ -9954,7 +9991,7 @@ async function correlateRun(deps, args) {
9954
9991
  "run",
9955
9992
  "list",
9956
9993
  "--repo",
9957
- HUB_REPO3,
9994
+ repo,
9958
9995
  "--workflow",
9959
9996
  args.workflow,
9960
9997
  ...args.mode === "workflow" ? ["--event", args.event] : [],
@@ -9997,10 +10034,10 @@ function correlateControlRun(deps, since, titleIncludes) {
9997
10034
  async function correlateWorkflowRun(deps, args) {
9998
10035
  return correlateRun(deps, { ...args, mode: "workflow" });
9999
10036
  }
10000
- async function watchTenantRun(deps, runId) {
10037
+ async function watchTenantRun(deps, runId, repo = HUB_REPO3) {
10001
10038
  if (runId == null) return "pending";
10002
10039
  try {
10003
- await deps.run("gh", ["run", "watch", String(runId), "--repo", HUB_REPO3, "--exit-status"]);
10040
+ await deps.run("gh", ["run", "watch", String(runId), "--repo", repo, "--exit-status"]);
10004
10041
  return "success";
10005
10042
  } catch {
10006
10043
  return "failure";
@@ -10013,9 +10050,9 @@ async function fetchControlRunLog(deps, runId) {
10013
10050
  return "";
10014
10051
  }
10015
10052
  }
10016
- async function watchWorkflowRun(deps, workflow, run) {
10053
+ async function watchWorkflowRun(deps, workflow, run, repo = HUB_REPO3) {
10017
10054
  if (run.runId == null) return { workflow, conclusion: "pending" };
10018
- const conclusion = await watchTenantRun(deps, run.runId);
10055
+ const conclusion = await watchTenantRun(deps, run.runId, repo);
10019
10056
  return { workflow, runId: run.runId, runUrl: run.runUrl, conclusion };
10020
10057
  }
10021
10058
  function aggregateWorkflowRuns(runs) {
@@ -10124,6 +10161,7 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
10124
10161
  let lastStatus = "not checked";
10125
10162
  let lastError;
10126
10163
  const everObserved = /* @__PURE__ */ new Set();
10164
+ const autoSatisfied = /* @__PURE__ */ new Set();
10127
10165
  for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
10128
10166
  if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
10129
10167
  let checkRuns;
@@ -10145,18 +10183,25 @@ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
10145
10183
  for (const c of required) {
10146
10184
  if (checkRuns.some((r) => r.name === c) || statuses.some((s) => s.context === c)) everObserved.add(c);
10147
10185
  }
10148
- const states = required.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
10149
- lastStatus = states.map(([c, s]) => `${c}=${s}`).join(", ");
10186
+ if (attempt >= TRAIN_PR_AUTOMATION_GRACE_ATTEMPTS - 1) {
10187
+ for (const c of required) {
10188
+ if (TRAIN_PR_ONLY_AUTOMATION_CONTEXTS.has(c) && !everObserved.has(c)) autoSatisfied.add(c);
10189
+ }
10190
+ }
10191
+ const pending = required.filter((c) => !autoSatisfied.has(c));
10192
+ const states = pending.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
10193
+ const satisfiedNote = autoSatisfied.size ? `, auto-satisfied (never runs on a tag, see #2404): ${[...autoSatisfied].join(", ")}` : "";
10194
+ lastStatus = `${states.map(([c, s]) => `${c}=${s}`).join(", ")}${satisfiedNote}`;
10150
10195
  const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
10151
10196
  if (failed.length > 0) {
10152
10197
  throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
10153
10198
  }
10154
10199
  if (states.every(([, s]) => s === "success")) {
10155
- return `required checks passed: ${required.join(", ")}`;
10200
+ return `required checks passed: ${pending.join(", ") || "(none pending)"}${satisfiedNote}`;
10156
10201
  }
10157
10202
  }
10158
10203
  const waitedMin = Math.round((TRAIN_CHECK_ATTEMPTS - 1) * TRAIN_CHECK_DELAY_MS / 6e4);
10159
- const neverMaterialized = required.filter((c) => !everObserved.has(c));
10204
+ const neverMaterialized = required.filter((c) => !everObserved.has(c) && !autoSatisfied.has(c));
10160
10205
  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
10206
  throw new Error(
10162
10207
  `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 +10291,19 @@ function tenantPublishRecoveryCommand(slug, repo, ref, stage2, publishDir) {
10246
10291
  if (publishDir && publishDir !== ".") parts.push(`-f publishDir=${publishDir}`);
10247
10292
  return parts.join(" ");
10248
10293
  }
10294
+ var PUBLISH_IDEMPOTENT_MARKER = /already on npm/i;
10295
+ var PUBLISH_LOG_RETRY_ATTEMPTS = 4;
10296
+ var PUBLISH_LOG_RETRY_DELAY_MS = 6e3;
10297
+ async function reconcilePublishFailure(deps, runId) {
10298
+ if (runId == null) return "failure";
10299
+ const sleep = resolveSleep(deps);
10300
+ for (let attempt = 0; attempt < PUBLISH_LOG_RETRY_ATTEMPTS; attempt++) {
10301
+ if (attempt > 0) await sleep(PUBLISH_LOG_RETRY_DELAY_MS);
10302
+ const log = await fetchControlRunLog(deps, runId);
10303
+ if (PUBLISH_IDEMPOTENT_MARKER.test(log)) return "success";
10304
+ }
10305
+ return "failure";
10306
+ }
10249
10307
  async function dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFailure = "throw", publishDir) {
10250
10308
  const since = (deps.now ?? Date.now)();
10251
10309
  const dispatchArgs = [
@@ -10276,8 +10334,16 @@ async function dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFail
10276
10334
  };
10277
10335
  }
10278
10336
  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 };
10337
+ let deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
10338
+ let note = `dispatched tenant-publish.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`;
10339
+ if (deployStatus === "failure") {
10340
+ const reconciled = await reconcilePublishFailure(deps, runId);
10341
+ if (reconciled === "success") {
10342
+ deployStatus = "success";
10343
+ note = `${note}; run reported failure but its log confirms the version is already on npm (idempotent E409 \u2014 #2428)`;
10344
+ }
10345
+ }
10346
+ return { note, runId, runUrl, deployStatus };
10281
10347
  }
10282
10348
  async function dispatchPublishIfRequired(deps, ctx, meta, model, stage2, publishRef, watch, dispatchFailure) {
10283
10349
  if (!meta.publishRequired || stage2 !== "main") return null;
@@ -10294,6 +10360,18 @@ function appendPublishDispatch(deploy, publish) {
10294
10360
  deployStatus: deploy.deployStatus === "failure" || publish.deployStatus === "failure" ? "failure" : deploy.deployStatus === "pending" || publish.deployStatus === "pending" ? "pending" : "success"
10295
10361
  };
10296
10362
  }
10363
+ async function watchOwnWorkflowRuns(deps, repo, targets, since, headSha) {
10364
+ const workflowRuns = [];
10365
+ for (const target of targets) {
10366
+ try {
10367
+ const run = await correlateWorkflowRun(deps, { ...target, since, headSha, repo });
10368
+ workflowRuns.push(await watchWorkflowRun(deps, target.workflow, run, repo));
10369
+ } catch {
10370
+ workflowRuns.push({ workflow: target.workflow, conclusion: "failure" });
10371
+ }
10372
+ }
10373
+ return workflowRuns;
10374
+ }
10297
10375
  async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince, autoRunHeadSha, dispatchFailure = "throw", publishDir) {
10298
10376
  if (model === "tenant-container" || model === "solo-container") {
10299
10377
  const since = (deps.now ?? Date.now)();
@@ -10312,7 +10390,18 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
10312
10390
  return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
10313
10391
  }
10314
10392
  if (model === "registry-publish") {
10315
- return dispatchTenantPublish(deps, ctx, stage2, ref, watch, dispatchFailure, publishDir);
10393
+ 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)";
10394
+ if (ref === "rc" || !watch || !autoRunHeadSha) return { note, deployStatus: "pending" };
10395
+ const since = autoRunSince ?? (deps.now ?? Date.now)();
10396
+ const workflowRuns = await watchOwnWorkflowRuns(
10397
+ deps,
10398
+ ctx.repo,
10399
+ [{ workflow: "publish.yml", event: "release" }],
10400
+ since,
10401
+ autoRunHeadSha
10402
+ );
10403
+ const primary = workflowRuns[0];
10404
+ return { note, runId: primary?.runId, runUrl: primary?.runUrl, workflowRuns, deployStatus: aggregateWorkflowRuns(workflowRuns) };
10316
10405
  }
10317
10406
  if (model === "hub-serverless") {
10318
10407
  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 +10412,7 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
10323
10412
  { workflow: "deploy.yml", event: "release" },
10324
10413
  { workflow: "publish.yml", event: "release" }
10325
10414
  ];
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
- }
10415
+ const workflowRuns = await watchOwnWorkflowRuns(deps, HUB_REPO3, targets, since, autoRunHeadSha);
10335
10416
  const primary = workflowRuns[0];
10336
10417
  return {
10337
10418
  note,
@@ -10364,25 +10445,113 @@ async function preflightMergeToMain(deps, deployModel, remoteRef, blockingPrefix
10364
10445
  }
10365
10446
  return { foldPaths, tolerated, predicted };
10366
10447
  }
10367
- async function executeMergeToMain(deps, sourceRef, mergeLabel, tolerated, predicted) {
10448
+ async function executeMergeToMain(deps, sourceRef, mergeLabel, tolerated, predicted, preFold) {
10368
10449
  await deps.run("git", ["checkout", "main"]);
10369
10450
  await ffOnlyPull(deps, "main");
10451
+ preFold.mainSha = clean(await deps.run("git", ["rev-parse", "main"]));
10370
10452
  if (predicted.length === 0) {
10371
10453
  await deps.run("git", ["merge", sourceRef, "--no-edit"]);
10372
10454
  } else {
10373
10455
  await mergeWithToleratedResolution(deps, sourceRef, mergeLabel, "theirs", tolerated);
10374
10456
  }
10375
10457
  }
10376
- async function mergeSourceToMain(deps, deployModel, args) {
10377
- const { foldPaths, tolerated, predicted } = await preflightMergeToMain(
10378
- deps,
10379
- deployModel,
10380
- args.remoteRef,
10381
- args.blockingPrefix,
10382
- args.realignMessage
10458
+ async function probeFoldFailureState(deps) {
10459
+ const branch = await currentBranch(deps);
10460
+ const mergeInProgress = await deps.run("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).then(() => true).catch(() => false);
10461
+ const dirty = porcelainHasBlockingChanges(await deps.run("git", ["status", "--porcelain"]).catch(() => ""));
10462
+ const mainSha = await deps.run("git", ["rev-parse", "main"]).then(clean).catch(() => "");
10463
+ const originMainSha = await deps.run("git", ["rev-parse", "origin/main"]).then(clean).catch(() => "");
10464
+ const originIsAncestorOfMain = Boolean(mainSha) && Boolean(originMainSha) ? await deps.run("git", ["merge-base", "--is-ancestor", "origin/main", "main"]).then(() => true).catch(() => false) : false;
10465
+ return { branch, mergeInProgress, dirty, mainSha, originMainSha, originIsAncestorOfMain };
10466
+ }
10467
+ function shaLabel(sha) {
10468
+ return sha ? sha.slice(0, 7) : "(unknown)";
10469
+ }
10470
+ async function finishFoldAutoRestore(deps, causeMessage, startBranch, what) {
10471
+ try {
10472
+ await deps.run("git", ["checkout", startBranch]);
10473
+ } catch (e) {
10474
+ return new Error(
10475
+ `${causeMessage}
10476
+
10477
+ fold failed; ${what}, but returning to ${startBranch} afterwards failed: ${e instanceof Error ? e.message : String(e)}. Finish manually: git checkout ${startBranch}`
10478
+ );
10479
+ }
10480
+ return new Error(
10481
+ `${causeMessage}
10482
+
10483
+ fold failed; ${what} and the checkout was returned to ${startBranch}. Nothing was pushed to origin and no tag was created \u2014 rerun the release once the fold issue above is fixed.`
10383
10484
  );
10384
- await executeMergeToMain(deps, args.sourceRef, args.mergeLabel, tolerated, predicted);
10385
- return { foldPaths };
10485
+ }
10486
+ function foldFailureGuidance(causeMessage, probe, startBranch, preFoldMainSha) {
10487
+ const steps = preFoldMainSha ? [
10488
+ ` 1. git status # inspect what is actually there`,
10489
+ ` 2. git log --oneline ${preFoldMainSha}..main # commits beyond the pre-fold main \u2014 confirm they are only this run's merge/fold attempt`,
10490
+ ` 3. git checkout main && git reset --hard ${preFoldMainSha} # back to the exact commit the fold built on`,
10491
+ ` 4. git checkout ${startBranch}`
10492
+ ] : [
10493
+ ` 1. git status # inspect what is actually there`,
10494
+ ` 2. git log --oneline origin/main..main # this run never committed to main (it failed before the merge) \u2014 any commits here predate this run; preserve them`,
10495
+ ` 3. git checkout ${startBranch}`
10496
+ ];
10497
+ return new Error(
10498
+ `${causeMessage}
10499
+
10500
+ fold failed leaving the checkout in a state train --apply will not auto-restore (branch=${probe.branch || "(unknown)"}, dirty-working-tree=${probe.dirty}, main=${shaLabel(probe.mainSha)}, origin/main=${shaLabel(probe.originMainSha)}, pre-fold main=${preFoldMainSha ? shaLabel(preFoldMainSha) : "not captured"}, origin/main is${probe.originIsAncestorOfMain ? "" : " NOT"} an ancestor of main). Nothing was pushed to origin and no tag was created.
10501
+ Recovery sequence:
10502
+ ${steps.join("\n")}`
10503
+ );
10504
+ }
10505
+ async function recoverFailedFold(deps, cause, startBranch, preFoldMainSha) {
10506
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
10507
+ const probe = await probeFoldFailureState(deps);
10508
+ if (probe.mergeInProgress) {
10509
+ try {
10510
+ await deps.run("git", ["merge", "--abort"]);
10511
+ } catch (e) {
10512
+ return new Error(
10513
+ `${causeMessage}
10514
+
10515
+ fold failed with an in-progress merge left on ${probe.branch || "(unknown branch)"}; automatic \`git merge --abort\` failed: ${e instanceof Error ? e.message : String(e)}. Nothing was pushed to origin and no tag was created.
10516
+ Recovery sequence:
10517
+ 1. git merge --abort
10518
+ 2. git checkout ${startBranch}`
10519
+ );
10520
+ }
10521
+ return finishFoldAutoRestore(deps, causeMessage, startBranch, "the in-progress merge was aborted");
10522
+ }
10523
+ if (probe.branch === "main" && preFoldMainSha) {
10524
+ const mainDescendsFromPreFold = await deps.run("git", ["merge-base", "--is-ancestor", preFoldMainSha, "main"]).then(() => true).catch(() => false);
10525
+ if (mainDescendsFromPreFold) {
10526
+ try {
10527
+ await deps.run("git", ["reset", "--hard", preFoldMainSha]);
10528
+ } catch (e) {
10529
+ return new Error(
10530
+ `${causeMessage}
10531
+
10532
+ fold failed after this run's merge/fold committed locally on main (now ${shaLabel(probe.mainSha)}, unpushed, built on the pre-fold main ${shaLabel(preFoldMainSha)}); automatic \`git reset --hard ${shaLabel(preFoldMainSha)}\` failed: ${e instanceof Error ? e.message : String(e)}. Nothing was pushed to origin and no tag was created.
10533
+ Recovery sequence:
10534
+ 1. git reset --hard ${preFoldMainSha}
10535
+ 2. git checkout ${startBranch}`
10536
+ );
10537
+ }
10538
+ const aheadNote = probe.originMainSha && preFoldMainSha !== probe.originMainSha ? `; local main carried commit(s) origin/main (${shaLabel(probe.originMainSha)}) does not have \u2014 they were preserved` : "";
10539
+ return finishFoldAutoRestore(
10540
+ deps,
10541
+ causeMessage,
10542
+ startBranch,
10543
+ `local main was reset to its pre-fold state ${shaLabel(preFoldMainSha)} (discarding only this run's merge/fold commit(s) at ${shaLabel(probe.mainSha)})${aheadNote}`
10544
+ );
10545
+ }
10546
+ }
10547
+ return foldFailureGuidance(causeMessage, probe, startBranch, preFoldMainSha);
10548
+ }
10549
+ async function runFoldStage(deps, startBranch, preFold, fn) {
10550
+ try {
10551
+ return await fn();
10552
+ } catch (e) {
10553
+ throw await recoverFailedFold(deps, e, startBranch, preFold.mainSha);
10554
+ }
10386
10555
  }
10387
10556
  async function completeMainRelease(deps, ctx, meta, deployModel, watch, options, tag, releaseSha) {
10388
10557
  await ensureTagPushed(deps, tag, releaseSha);
@@ -10475,15 +10644,20 @@ async function runTrainApplyPipeline(mode, input) {
10475
10644
  const deployModel2 = await preflight(deps, ctx, "main", meta);
10476
10645
  const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
10477
10646
  const rcShaAtRelease = !directTrack && hasRcBranch ? clean(await deps.run("git", ["rev-parse", "origin/rc"])) : "";
10478
- const { foldPaths: foldPaths2 } = await mergeSourceToMain(deps, deployModel2, {
10479
- sourceRef: "development",
10480
- remoteRef: "origin/development",
10481
- mergeLabel: "development -> main",
10482
- blockingPrefix: "development -> main merge would conflict on untolerated path(s)",
10483
- realignMessage: "The train is misaligned: reconcile main and development via an approved alignment PR, then rerun release."
10647
+ const { foldPaths: foldPaths2, tolerated: tolerated2, predicted: predicted2 } = await preflightMergeToMain(
10648
+ deps,
10649
+ deployModel2,
10650
+ "origin/development",
10651
+ "development -> main merge would conflict on untolerated path(s)",
10652
+ "The train is misaligned: reconcile main and development via an approved alignment PR, then rerun release."
10653
+ );
10654
+ const preFold2 = {};
10655
+ const { versionFold: versionFold2, releaseSha: releaseSha2 } = await runFoldStage(deps, "development", preFold2, async () => {
10656
+ await executeMergeToMain(deps, "development", "development -> main", tolerated2, predicted2, preFold2);
10657
+ const versionFold3 = await foldReleaseVersion(deps, deployModel2, tag2, foldPaths2);
10658
+ const releaseSha3 = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
10659
+ return { versionFold: versionFold3, releaseSha: releaseSha3 };
10484
10660
  });
10485
- const versionFold2 = await foldReleaseVersion(deps, deployModel2, tag2, foldPaths2);
10486
- const releaseSha2 = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
10487
10661
  const { checks: checks2, releaseUrl: releaseUrl2, announceNote: announceNote2, dispatch: d2 } = await completeMainRelease(deps, ctx, meta, deployModel2, watch, options, tag2, releaseSha2);
10488
10662
  const devRollForward2 = await rollDevelopmentForward(deps, ctx, tag2);
10489
10663
  if (directTrack) {
@@ -10560,10 +10734,14 @@ async function runTrainApplyPipeline(mode, input) {
10560
10734
  );
10561
10735
  }
10562
10736
  const releasedRcSha = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
10563
- await executeMergeToMain(deps, "rc", "rc -> main", tolerated, predicted);
10564
- const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
10565
- const versionFold = await foldReleaseVersion(deps, deployModel, tag, foldPaths);
10566
- const releaseSha = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
10737
+ const preFold = {};
10738
+ const { tag, versionFold, releaseSha } = await runFoldStage(deps, "rc", preFold, async () => {
10739
+ await executeMergeToMain(deps, "rc", "rc -> main", tolerated, predicted, preFold);
10740
+ const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
10741
+ const versionFold2 = await foldReleaseVersion(deps, deployModel, tag2, foldPaths);
10742
+ const releaseSha2 = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
10743
+ return { tag: tag2, versionFold: versionFold2, releaseSha: releaseSha2 };
10744
+ });
10567
10745
  const { checks, releaseUrl, announceNote, dispatch: d } = await completeMainRelease(deps, ctx, meta, deployModel, watch, options, tag, releaseSha);
10568
10746
  const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
10569
10747
  const devRollForward = await rollDevelopmentForward(deps, ctx, tag);
@@ -11165,7 +11343,11 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11165
11343
  runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
11166
11344
  }
11167
11345
  deployNote = "watched release-triggered deploy.yml + publish.yml";
11168
- } else if (deployModel === "tenant-container" || deployModel === "solo-container" || deployModel === "registry-publish") {
11346
+ } else if (deployModel === "registry-publish") {
11347
+ const run = await watchReleaseRun(deps, ctx, "publish.yml", mergedSha);
11348
+ runs.push(run);
11349
+ deployNote = "watched this repo's own release-triggered publish.yml (#2428 \u2014 no central dispatch)";
11350
+ } else if (deployModel === "tenant-container" || deployModel === "solo-container") {
11169
11351
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
11170
11352
  const deploy = await dispatchDeploy(
11171
11353
  deps,
@@ -11181,7 +11363,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11181
11363
  );
11182
11364
  const publish = deploy.deployStatus === "success" ? await dispatchPublishIfRequired(deps, ctx, meta, deployModel, "main", tag, true, "report") : null;
11183
11365
  let dispatch = appendPublishDispatch(deploy, publish);
11184
- if (!publish && deploy.deployStatus !== "success" && meta.publishRequired && (deployModel === "tenant-container" || deployModel === "solo-container")) {
11366
+ if (!publish && deploy.deployStatus !== "success" && meta.publishRequired) {
11185
11367
  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
11368
  dispatch = {
11187
11369
  ...dispatch,
@@ -11189,25 +11371,17 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
11189
11371
  };
11190
11372
  }
11191
11373
  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
- }
11374
+ runs.push({
11375
+ workflow: "tenant-deploy.yml",
11376
+ url: deploy.runUrl,
11377
+ conclusion: deploy.deployStatus === "success" ? "success" : deploy.deployStatus === "failure" ? "failure" : deploy.deployStatus ?? "pending"
11378
+ });
11199
11379
  if (publish?.runUrl) {
11200
11380
  runs.push({
11201
11381
  workflow: "tenant-publish.yml",
11202
11382
  url: publish.runUrl,
11203
11383
  conclusion: publish.deployStatus === "success" ? "success" : publish.deployStatus === "failure" ? "failure" : publish.deployStatus ?? "pending"
11204
11384
  });
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
11385
  }
11212
11386
  } else {
11213
11387
  deployNote = `no hotfix deploy dispatch for deployModel=${deployModel} \u2014 prod deploy is repo-specific`;
@@ -14751,16 +14925,24 @@ function cursorPluginPinsNeedingSeed(pins, releasedVersion) {
14751
14925
  return false;
14752
14926
  });
14753
14927
  }
14928
+ function cursorPluginCacheSeedTargets(pins, releasedVersion, cacheRoot, cacheRootExists = false) {
14929
+ const needing = cursorPluginPinsNeedingSeed(pins, releasedVersion);
14930
+ if (needing.length > 0) return needing.map((pin) => pin.path);
14931
+ if (pins.length === 0 && cacheRoot && cacheRootExists && isSemverVersion(releasedVersion)) {
14932
+ return [(0, import_node_path15.join)(cacheRoot, `v${releasedVersion.replace(/^v/, "")}`)];
14933
+ }
14934
+ return [];
14935
+ }
14754
14936
  async function applyCursorPluginCacheSeed(input) {
14755
14937
  if (!isSemverVersion(input.releasedVersion)) return false;
14756
- const pinsToSeed = cursorPluginPinsNeedingSeed(input.pins, input.releasedVersion);
14757
- if (pinsToSeed.length === 0) return false;
14938
+ const targets = cursorPluginCacheSeedTargets(input.pins, input.releasedVersion, input.cacheRoot, input.cacheRootExists);
14939
+ if (targets.length === 0) return false;
14758
14940
  const tmpRoot = await input.mkdtemp("mmi-cursor-seed-");
14759
14941
  const source = await resolvePluginMmiSource(input.releasedVersion, input.hubCheckout, tmpRoot, input.execFileP);
14760
14942
  if (!source) return false;
14761
14943
  input.log(` \u21BB seeding Cursor MMI plugin cache \u2192 ${input.releasedVersion}\u2026`);
14762
- for (const pin of pinsToSeed) {
14763
- syncDirContents(source, pin.path);
14944
+ for (const dest of targets) {
14945
+ syncDirContents(source, dest);
14764
14946
  }
14765
14947
  (0, import_node_fs14.rmSync)(tmpRoot, { recursive: true, force: true });
14766
14948
  return true;
@@ -15040,6 +15222,33 @@ function buildMmiPluginCacheCleanupCheck(input) {
15040
15222
  }))
15041
15223
  };
15042
15224
  }
15225
+ var CURSOR_PLUGIN_CACHE_CLEANUP_LABEL = "leftover older MMI plugin cache pins (Cursor)";
15226
+ var CURSOR_PLUGIN_CACHE_CLEANUP_FIX = "older Cursor MMI plugin cache pins can be removed manually after confirming in Cursor Settings \u2192 Plugins \u2192 Marketplace which pin is registered";
15227
+ function buildCursorPluginCacheCleanupCheck(input) {
15228
+ const base = {
15229
+ ok: true,
15230
+ label: CURSOR_PLUGIN_CACHE_CLEANUP_LABEL,
15231
+ fix: CURSOR_PLUGIN_CACHE_CLEANUP_FIX
15232
+ };
15233
+ if (!input.isOrgRepo || !isSemverVersion2(input.releasedVersion)) return base;
15234
+ const semverPins = input.pins.filter((pin) => isSemverVersion2(pin.version));
15235
+ if (semverPins.length === 0) return base;
15236
+ const activeVersion = semverPins.map((pin) => pin.version).reduce((a, b) => compareVersions(a, b) >= 0 ? a : b);
15237
+ const protectedVersions = new Set(
15238
+ [normalizeVersion(input.releasedVersion), normalizeVersion(activeVersion)].filter((v) => Boolean(v))
15239
+ );
15240
+ const leftoverPins = semverPins.filter((pin) => !protectedVersions.has(normalizeVersion(pin.version)));
15241
+ if (leftoverPins.length === 0) return base;
15242
+ const leftovers = leftoverPins.map((pin) => ({ surface: "cursor", root: input.cacheRoot, name: pin.name, path: pin.path }));
15243
+ const listed = leftoverPins.map((pin) => `${pin.name} (${pin.version})`).join(", ");
15244
+ return {
15245
+ ...base,
15246
+ ok: false,
15247
+ severityOverride: "advisory",
15248
+ leftovers,
15249
+ fix: `${leftoverPins.length} older Cursor MMI plugin cache pin(s) left behind by marketplace re-clones: ${listed}. Not auto-removed: which pin Cursor's Team Marketplace registration is bound to cannot be read programmatically (no per-user CLI, #2409), and the registration may still point at an older pin \u2014 after confirming in Cursor Settings \u2192 Plugins \u2192 Marketplace that the newer pin is the registered one, the older dir(s) can be removed manually`
15250
+ };
15251
+ }
15043
15252
  var NESTED_PLUGIN_TREE_LABEL = "self-nested MMI plugin cache tree (#1126)";
15044
15253
  var NESTED_PLUGIN_TREE_FIX = "SessionStart and mmi-cli doctor auto-clear when possible; if this persists, run the MAX_PATH-safe robocopy cleanup for a self-nested MMI plugin cache tree (#1126)";
15045
15254
  function nestedPluginTreeCleanupCommand(paths, isWindows) {
@@ -15062,7 +15271,21 @@ function buildNestedPluginTreeCheck(input) {
15062
15271
  fix: `${nested.length} self-nested MMI plugin cache tree(s) (#1126) exceed MAX_PATH and can't self-clean \u2014 run: ${nestedPluginTreeCleanupCommand(nested.map((n) => n.path), input.isWindows)}`
15063
15272
  };
15064
15273
  }
15274
+ function nestedTreeReinstallGapFix(surfaces) {
15275
+ const recoveries = surfaces.map((s) => PLUGIN_SURFACE_HEAL[s].recovery).join(" and ");
15276
+ return `self-nested MMI plugin cache tree(s) cleared, but the plugin was NOT reinstalled for ${surfaces.join(" + ")} (host CLI unavailable or reinstall failed) \u2014 run: ${recoveries}`;
15277
+ }
15065
15278
  var CODEX_ACTIVE_CACHE_LABEL = "Codex active plugin cache (vs latest release)";
15279
+ function codexEnabledPluginVersionFromList(jsonText, pluginId = MMI_PLUGIN_ID) {
15280
+ try {
15281
+ const parsed = JSON.parse(jsonText);
15282
+ const rows = Array.isArray(parsed?.installed) ? parsed.installed : [];
15283
+ const row = rows.find((r) => r?.pluginId === pluginId && r.installed === true && r.enabled === true);
15284
+ return isSemverVersion2(row?.version) ? row.version.trim().replace(/^v/, "") : void 0;
15285
+ } catch {
15286
+ return void 0;
15287
+ }
15288
+ }
15066
15289
  function buildCodexActiveCacheCheck(input) {
15067
15290
  const base = {
15068
15291
  ok: true,
@@ -15070,6 +15293,18 @@ function buildCodexActiveCacheCheck(input) {
15070
15293
  fix: CODEX_PLUGIN_RECOVERY
15071
15294
  };
15072
15295
  if (!input.isOrgRepo || !isSemverVersion2(input.releasedVersion)) return base;
15296
+ if (isSemverVersion2(input.codexActiveVersion)) {
15297
+ if (compareVersions(input.codexActiveVersion, input.releasedVersion) < 0) {
15298
+ return {
15299
+ ...base,
15300
+ ok: false,
15301
+ activeCacheVersion: input.codexActiveVersion,
15302
+ releasedVersion: input.releasedVersion,
15303
+ fix: `\`codex plugin list\` reports mmi@mutmutco enabled at ${input.codexActiveVersion} < ${input.releasedVersion} (a newer cache dir or install record may also exist and mask it) \u2014 run \`mmi-cli doctor --apply\` (forces the Codex remove/re-add when \`codex\` is on PATH; restart Codex after), or manually: ${CODEX_PLUGIN_RECOVERY}`
15304
+ };
15305
+ }
15306
+ return { ...base, activeCacheVersion: input.codexActiveVersion, releasedVersion: input.releasedVersion };
15307
+ }
15073
15308
  if (isSemverVersion2(input.codexRecordVersion) && compareVersions(input.codexRecordVersion, input.releasedVersion) < 0) {
15074
15309
  return base;
15075
15310
  }
@@ -15117,6 +15352,55 @@ function reloadAction(surface) {
15117
15352
  return "restart Claude Code (or run /reload-plugins)";
15118
15353
  }
15119
15354
  }
15355
+ function restartAction(surface) {
15356
+ switch (surface) {
15357
+ case "claude-vscode":
15358
+ return "fully restart VS Code";
15359
+ case "codex":
15360
+ return "fully restart Codex";
15361
+ case "opencode":
15362
+ return "fully restart OpenCode";
15363
+ case "cursor":
15364
+ return "fully restart Cursor";
15365
+ case "claude-cli":
15366
+ case "shell":
15367
+ default:
15368
+ return "fully restart Claude Code";
15369
+ }
15370
+ }
15371
+ function parseSessionSkillRootVersion(pluginRoot) {
15372
+ const trimmed = pluginRoot?.trim();
15373
+ if (!trimmed) return void 0;
15374
+ const segments = trimmed.split(/[\\/]+/).filter(Boolean);
15375
+ const mmiIdx = segments.findIndex((seg, i) => seg === "mmi" && segments[i - 1] === "mutmutco");
15376
+ if (mmiIdx < 0) return void 0;
15377
+ const candidate = segments[mmiIdx + 1];
15378
+ return candidate && /^v?\d+\.\d+\.\d+/.test(candidate) ? candidate.replace(/^v/, "") : void 0;
15379
+ }
15380
+ var SESSION_SKILL_ROOT_LABEL = "Session-loaded MMI skill roots (vs on-disk plugin cache)";
15381
+ function buildSessionSkillRootCheck(input) {
15382
+ const base = {
15383
+ ok: true,
15384
+ label: SESSION_SKILL_ROOT_LABEL,
15385
+ fix: `fully restart the host to load the current MMI skill roots \u2014 a running session does not hot-refresh them, and a plugin reload does not re-register them`
15386
+ };
15387
+ if (!input.isOrgRepo) return base;
15388
+ const sessionVersion = parseSessionSkillRootVersion(input.pluginRoot);
15389
+ if (!sessionVersion) return base;
15390
+ const activeCacheVersion = highestSemver(input.cacheVersions ?? []);
15391
+ if (!isSemverVersion2(activeCacheVersion)) return { ...base, sessionVersion };
15392
+ if (compareVersions(sessionVersion, activeCacheVersion) >= 0) {
15393
+ return { ...base, sessionVersion, activeCacheVersion };
15394
+ }
15395
+ return {
15396
+ ...base,
15397
+ ok: false,
15398
+ severityOverride: "advisory",
15399
+ sessionVersion,
15400
+ activeCacheVersion,
15401
+ fix: `This ${input.surface} session loaded MMI skill roots from ${sessionVersion} but the on-disk plugin cache is ${activeCacheVersion} \u2014 a running session does NOT hot-refresh skill roots and a plugin reload does not re-register them, so ${restartAction(input.surface)} to load ${activeCacheVersion}. Until then this session keeps executing the ${sessionVersion} skill bundle; halt autonomous /grind and /build first.`
15402
+ };
15403
+ }
15120
15404
  var CLAUDE_RECOVERY = `claude plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && claude plugin marketplace remove mutmutco && claude plugin marketplace add mutmutco/MMI-Hub --ref main && claude plugin install mmi@mutmutco`;
15121
15405
  var CODEX_RECOVERY = `codex plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && codex plugin marketplace remove mutmutco && codex plugin marketplace add mutmutco/MMI-Hub --ref main && codex plugin add mmi@mutmutco`;
15122
15406
  var CURSOR_RECOVERY = "in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace";
@@ -15329,10 +15613,12 @@ function isSemverVersion2(v) {
15329
15613
  function staleRecordCommand(surface) {
15330
15614
  return surface === "codex" ? CODEX_PLUGIN_RECOVERY : CLAUDE_PLUGIN_RECOVERY;
15331
15615
  }
15616
+ 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
15617
  function staleSurfacesFix(stale, releasedVersion) {
15333
15618
  const parts = stale.map((s) => {
15334
15619
  const at = s.recordPath ? ` (${s.recordPath})` : "";
15335
- return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}`;
15620
+ const warning = s.surface === "claude" ? ` \u2014 ${CLAUDE_UPDATE_BUTTON_WARNING}` : "";
15621
+ return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}${warning}`;
15336
15622
  });
15337
15623
  return `stale installed-plugin record on ${stale.map((s) => s.surface).join(" + ")}: ${parts.join("; ")}`;
15338
15624
  }
@@ -15374,18 +15660,71 @@ function buildInstalledPluginVersionCheck(input) {
15374
15660
  staleSurfaces: stale
15375
15661
  };
15376
15662
  }
15663
+ var MANAGED_PLUGINS = [
15664
+ {
15665
+ // Marketplace-qualified key exactly as installed_plugins.json stores it (same convention as
15666
+ // MMI_PLUGIN_ID = 'mmi@mutmutco') — a bare 'jervaise-powertools' would never match a real record
15667
+ // and the check would be a permanent no-op. The same `<marketplace>/<name>` segments also address its
15668
+ // Cursor Team Marketplace cache dir (~/.cursor/plugins/cache/jervaise/jervaise-powertools/<version>/).
15669
+ id: "jervaise-powertools@jervaise",
15670
+ label: "jervaise-powertools",
15671
+ healCommand: "jerv-cli doctor --apply",
15672
+ opencodePackage: "@jervaise/opencode-jerv"
15673
+ }
15674
+ ];
15675
+ function parseManagedPluginId(id) {
15676
+ const at = id.lastIndexOf("@");
15677
+ if (at <= 0 || at === id.length - 1) return null;
15678
+ return { name: id.slice(0, at), marketplace: id.slice(at + 1) };
15679
+ }
15680
+ var MANAGED_PLUGIN_DRIFT_LABEL = "managed org plugin version drift (cross-surface)";
15681
+ function managedPluginDriftFix(drifted) {
15682
+ return drifted.map((d) => {
15683
+ const versions = d.versions.map((v) => `${v.version} (${v.surface})`).join(" vs ");
15684
+ return `${d.label} drift: ${versions} \u2192 run \`${d.healCommand}\``;
15685
+ }).join(" ; ");
15686
+ }
15687
+ function buildManagedPluginDriftCheck(input) {
15688
+ const base = { ok: true, label: MANAGED_PLUGIN_DRIFT_LABEL, fix: "" };
15689
+ if (!input.isOrgRepo) return base;
15690
+ const registry2 = input.plugins ?? MANAGED_PLUGINS;
15691
+ const drifted = [];
15692
+ const normalize = (v) => v.replace(/^v/, "");
15693
+ for (const descriptor of registry2) {
15694
+ const versions = [];
15695
+ for (const source of input.sources) {
15696
+ if (source.directVersions && descriptor.id in source.directVersions) {
15697
+ const version2 = source.directVersions[descriptor.id];
15698
+ if (isSemverVersion2(version2)) versions.push({ surface: source.surface, version: normalize(version2) });
15699
+ continue;
15700
+ }
15701
+ const records = source.installed?.plugins?.[descriptor.id];
15702
+ if (!Array.isArray(records) || records.length === 0) continue;
15703
+ const recordVersion = bestRecord(records).version;
15704
+ const version = isSemverVersion2(recordVersion) ? recordVersion : highestSemver(source.cacheVersions?.[descriptor.id] ?? []);
15705
+ if (!isSemverVersion2(version)) continue;
15706
+ versions.push({ surface: source.surface, version: normalize(version) });
15707
+ }
15708
+ if (versions.length < 2) continue;
15709
+ const distinctVersions = new Set(versions.map((v) => v.version));
15710
+ if (distinctVersions.size < 2) continue;
15711
+ drifted.push({ pluginId: descriptor.id, label: descriptor.label, healCommand: descriptor.healCommand, versions });
15712
+ }
15713
+ if (drifted.length === 0) return base;
15714
+ return { ...base, ok: false, fix: managedPluginDriftFix(drifted), drifted };
15715
+ }
15377
15716
  var OPENCODE_VERSION_LABEL = "installed OpenCode MMI adapter version (vs latest release)";
15378
15717
  function buildOpencodeVersionCheck(input) {
15379
15718
  const fix = pluginRecoveryFix("opencode");
15380
15719
  const base = { ok: true, label: OPENCODE_VERSION_LABEL, fix };
15381
15720
  if (!input.isOrgRepo || !isSemverVersion2(input.releasedVersion)) return base;
15382
15721
  if (!isSemverVersion2(input.installedVersion)) {
15383
- return { ...base, ok: false, releasedVersion: input.releasedVersion };
15722
+ return { ...base, ok: false, severityOverride: "hard", releasedVersion: input.releasedVersion };
15384
15723
  }
15385
15724
  if (compareVersions(input.installedVersion, input.releasedVersion) >= 0) {
15386
15725
  return { ...base, installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15387
15726
  }
15388
- return { ...base, ok: false, installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15727
+ return { ...base, ok: false, severityOverride: "hard", installedVersion: input.installedVersion, releasedVersion: input.releasedVersion };
15389
15728
  }
15390
15729
  function pickOpencodeActiveVersion(input) {
15391
15730
  for (const value of [input.envStamp, input.cacheVersion, input.diskVersion]) {
@@ -15593,6 +15932,9 @@ function cursorPluginInstallFix(input) {
15593
15932
  const localSeed = input.hubCheckout ? `temporary fallback: copy ${joinCachePath(input.hubCheckout, "plugins", "mmi")} to ${cacheDir}, then restart Cursor` : `temporary fallback: copy plugins/mmi from a local MMI-Hub checkout to ${cacheDir}, then restart Cursor`;
15594
15933
  return `Cursor plugin cache at ${cacheDir} is empty or missing ${CURSOR_PLUGIN_JSON_REL}, ${CURSOR_HOOKS_JSON_REL}, or the cursor-hook dispatcher script \u2014 ${autoSeed}; ${marketplaceRefresh}; ${authSteps}; ${localSeed}; ${logHint}; ${guide}`;
15595
15934
  }
15935
+ function cursorSeededFromNothingAdvisoryFix(releasedVersion) {
15936
+ return `Cursor MMI plugin cache seeded from nothing${releasedVersion ? ` at ${releasedVersion}` : ""} \u2014 the cache is healthy, but the Team Marketplace pin REGISTRATION could not be verified (no per-user CLI, #2409): if MMI commands do not load after restarting Cursor, a master-admin must register/refresh the MMI Team Marketplace in Cursor Settings \u2192 Plugins \u2192 Marketplace (\xA76.8); ${CURSOR_MARKETPLACE_INSTALL_GUIDE}`;
15937
+ }
15596
15938
  function buildCursorPluginInstallCheck(input) {
15597
15939
  const base = {
15598
15940
  ok: true,
@@ -15634,9 +15976,10 @@ function buildCursorPluginInstallCheck(input) {
15634
15976
  return {
15635
15977
  ...base,
15636
15978
  ok: false,
15979
+ severityOverride: "advisory",
15637
15980
  cacheRoot: input.cacheRoot,
15638
15981
  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}`
15982
+ 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
15983
  };
15641
15984
  }
15642
15985
  return { ...base, cacheRoot: input.cacheRoot, pins: input.pins };
@@ -15709,9 +16052,7 @@ function buildHubDeployFreshnessCheck(input) {
15709
16052
  };
15710
16053
  }
15711
16054
  var PLAYWRIGHT_MCP_VISION_CAP_LABEL = "Playwright MCP vision caps (--caps=vision prohibited)";
15712
- var PLAYWRIGHT_MCP_VISION_CAP_FIX = "remove --caps=vision (and vision-first defaults) from Playwright MCP args \u2014 use DOM-first tools; see skills/browser-automation/SKILL.md and bootstrap seed mcp-playwright.template.json";
15713
16055
  var PLAYWRIGHT_MCP_OUTPUT_DIR_LABEL = "Playwright MCP output dir (use tmp/playwright-mcp)";
15714
- var PLAYWRIGHT_MCP_OUTPUT_DIR_FIX = "configure Playwright MCP args with --output-dir tmp/playwright-mcp; see skills/browser-automation/SKILL.md and bootstrap seed mcp-playwright.template.json";
15715
16056
  function textHasPlaywrightMcp(content) {
15716
16057
  const normalized = content.replace(/\r\n/g, "\n");
15717
16058
  return /@playwright\/mcp/.test(normalized) || /mcp_servers\.playwright/.test(normalized) || /"playwright"\s*:\s*\{/.test(normalized) || /\bmcpServers\b/.test(normalized);
@@ -15726,45 +16067,10 @@ function textHasPlaywrightVisionCap(content) {
15726
16067
  if (/\bvision[-_]?(?:first|only|mode)\b/i.test(normalized)) return true;
15727
16068
  return false;
15728
16069
  }
15729
- function buildPlaywrightMcpVisionCapCheck(input) {
15730
- const base = {
15731
- ok: true,
15732
- label: PLAYWRIGHT_MCP_VISION_CAP_LABEL,
15733
- fix: PLAYWRIGHT_MCP_VISION_CAP_FIX
15734
- };
15735
- if (!input.isOrgRepo) return base;
15736
- const offending = input.configs.filter((c) => textHasPlaywrightVisionCap(c.content)).map((c) => c.path);
15737
- if (offending.length === 0) return base;
15738
- return {
15739
- ...base,
15740
- ok: false,
15741
- offendingPaths: offending,
15742
- fix: `${PLAYWRIGHT_MCP_VISION_CAP_FIX} \u2014 found in: ${offending.join(", ")}`
15743
- };
15744
- }
15745
16070
  function textHasCanonicalPlaywrightOutputDir(content) {
15746
16071
  const normalized = content.replace(/\r\n/g, "\n");
15747
16072
  return /--output-dir(?:\s*=\s*|["'\s,]+)tmp\/playwright-mcp\b/.test(normalized);
15748
16073
  }
15749
- function textNeedsPlaywrightOutputDir(content) {
15750
- return textHasPlaywrightMcp(content) && !textHasCanonicalPlaywrightOutputDir(content);
15751
- }
15752
- function buildPlaywrightMcpOutputDirCheck(input) {
15753
- const base = {
15754
- ok: true,
15755
- label: PLAYWRIGHT_MCP_OUTPUT_DIR_LABEL,
15756
- fix: PLAYWRIGHT_MCP_OUTPUT_DIR_FIX
15757
- };
15758
- if (!input.isOrgRepo) return base;
15759
- const offending = input.configs.filter((c) => textNeedsPlaywrightOutputDir(c.content)).map((c) => c.path);
15760
- if (offending.length === 0) return base;
15761
- return {
15762
- ...base,
15763
- ok: false,
15764
- offendingPaths: offending,
15765
- fix: `${PLAYWRIGHT_MCP_OUTPUT_DIR_FIX} \u2014 missing in: ${offending.join(", ")}`
15766
- };
15767
- }
15768
16074
  var STRAY_BROWSER_ARTIFACT_DIRS = [".playwright-mcp", "playwright-report", "test-results"];
15769
16075
  var BROWSER_ARTIFACTS_LABEL = "browser MCP artifacts outside tmp/ (use tmp/playwright-mcp)";
15770
16076
  var BROWSER_ARTIFACTS_FIX = "move or delete stray Playwright output at repo root; re-run MCP with --output-dir tmp/playwright-mcp \u2014 see skills/browser-automation/SKILL.md";
@@ -15804,6 +16110,12 @@ function buildSelfUpdateHaltPayload(input) {
15804
16110
  checks: input.checks
15805
16111
  };
15806
16112
  }
16113
+ var DOCTOR_SELF_UPDATE_HALT_EXIT_CODE = 3;
16114
+ var DOCTOR_SELF_UPDATE_HALT_SENTINEL = "MMI_DOCTOR_SELF_UPDATE_HALT";
16115
+ function selfUpdateHaltSentinelLine(input) {
16116
+ const to = input.report.releasedVersion ?? "latest";
16117
+ return `${DOCTOR_SELF_UPDATE_HALT_SENTINEL} rerunRequired=true updatedTo=${to} rerun="mmi-cli ${input.rerunArgs.join(" ")}"`;
16118
+ }
15807
16119
  var DOCTOR_POST_SELF_UPDATE_ENV = "MMI_DOCTOR_POST_SELF_UPDATE";
15808
16120
  function buildSelfUpdateReexecArgs(opts) {
15809
16121
  const args = ["doctor"];
@@ -15830,6 +16142,30 @@ function preflightOutcome(input) {
15830
16142
  function pluginAutonomousHaltLine(reloadHint) {
15831
16143
  return `\u26A0 PLUGIN RELOAD REQUIRED \u2014 mmi:* skills and agent types are unavailable until you ${reloadHint}. Halt autonomous /grind and /build until then.`;
15832
16144
  }
16145
+ var DOCTOR_EXTENDED_CHECK_LABELS = /* @__PURE__ */ new Set([
16146
+ AWS_CROSS_ACCOUNT_LABEL,
16147
+ HUB_DEPLOY_FRESHNESS_LABEL,
16148
+ PLAYWRIGHT_MCP_VISION_CAP_LABEL,
16149
+ PLAYWRIGHT_MCP_OUTPUT_DIR_LABEL,
16150
+ BROWSER_ARTIFACTS_LABEL,
16151
+ SCRATCH_GC_LABEL,
16152
+ GIT_GC_LABEL,
16153
+ OPENCODE_VERSION_LABEL,
16154
+ OPENCODE_DESKTOP_BOOTSTRAP_LABEL
16155
+ ]);
16156
+ function isDoctorExtendedCheck(label) {
16157
+ if (DOCTOR_EXTENDED_CHECK_LABELS.has(label)) return true;
16158
+ if (label.startsWith(HUB_DEPLOY_FRESHNESS_LABEL)) return true;
16159
+ if (label.startsWith("@mutmutco design-system") || label.startsWith("@mutmutco registry components")) return true;
16160
+ return false;
16161
+ }
16162
+ function isAdvisoryDoctorCheck(check) {
16163
+ if (check.severityOverride) return check.severityOverride === "advisory";
16164
+ return isDoctorExtendedCheck(check.label);
16165
+ }
16166
+ function doctorExitCode(checks) {
16167
+ return checks.some((c) => !c.ok && !isAdvisoryDoctorCheck(c)) ? 1 : 0;
16168
+ }
15833
16169
  function renderPluginUpdateReportStaleOnly(report) {
15834
16170
  const v = report.versions;
15835
16171
  const released = v.released;
@@ -15844,24 +16180,97 @@ function renderPluginUpdateReportStaleOnly(report) {
15844
16180
  return ["Update commands (stale surfaces):", ...blocks];
15845
16181
  }
15846
16182
  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];
16183
+ function doctorCheckGlyph(check) {
16184
+ if (check.ok) return "\u2713";
16185
+ return isAdvisoryDoctorCheck(check) ? "\u26A0" : "\u2717";
16186
+ }
16187
+ function doctorVerdictLine(input) {
16188
+ const gaps = input.checks.filter((c) => !c.ok);
16189
+ const hardGaps = gaps.filter((c) => !isAdvisoryDoctorCheck(c));
16190
+ const softGaps = gaps.filter((c) => isAdvisoryDoctorCheck(c));
16191
+ if (input.shouldApply) {
16192
+ if (hardGaps.length > 0) {
16193
+ return `\u2717 ${hardGaps.length} repair${hardGaps.length === 1 ? "" : "s"} failed \u2014 see the items above.`;
16194
+ }
16195
+ if (softGaps.length > 0) {
16196
+ return `\u26A0 Healed what I could; ${softGaps.length} item${softGaps.length === 1 ? "" : "s"} still need${softGaps.length === 1 ? "s" : ""} attention (above).`;
16197
+ }
16198
+ const healed = input.healedCount ?? 0;
16199
+ if (healed > 0) return `\u2713 Healed ${healed} item${healed === 1 ? "" : "s"} \u2014 everything checks out now.`;
16200
+ return "\u2713 Everything healthy \u2014 nothing needed fixing.";
15852
16201
  }
15853
- const lines = [];
15854
- for (const c of input.gaps) {
15855
- lines.push(`\u2717 ${c.label}`);
15856
- lines.push(` \u2192 ${c.fix}`);
16202
+ const attention = gaps.length;
16203
+ if (attention > 0) {
16204
+ return `\u26A0 ${attention} item${attention === 1 ? " needs" : "s need"} attention \u2014 run \`mmi-cli doctor --apply\` to heal.`;
16205
+ }
16206
+ return "\u2713 Everything healthy.";
16207
+ }
16208
+ function doctorHumanLines(input) {
16209
+ const gaps = input.checks.filter((c) => !c.ok);
16210
+ const lines = [
16211
+ doctorVerdictLine({ checks: input.checks, shouldApply: input.shouldApply, healedCount: input.healedCount }),
16212
+ "",
16213
+ "Checks",
16214
+ ...input.checks.map((c) => ` ${doctorCheckGlyph(c)} ${c.label}`)
16215
+ ];
16216
+ if (gaps.length > 0) {
16217
+ lines.push("", input.shouldApply ? "Repairs" : "Will fix on --apply");
16218
+ for (const c of gaps) {
16219
+ const mark = input.shouldApply ? isAdvisoryDoctorCheck(c) ? "\u26A0" : "\u2717" : "\u2192";
16220
+ lines.push(` ${mark} ${c.label} \u2014 ${c.fix}`);
16221
+ }
16222
+ }
16223
+ if (input.shouldApply && input.pluginReloadRequired) {
16224
+ lines.push("", `\u21BB ${input.reloadHint ?? "reload"} so the healed plugin/MCP installs load.`);
15857
16225
  }
15858
16226
  const stale = renderPluginUpdateReportStaleOnly(input.updateReport);
15859
16227
  if (stale.length) {
15860
- lines.push("");
15861
- lines.push(...stale);
16228
+ lines.push("", ...stale);
16229
+ } else if (!input.verbose) {
16230
+ lines.push("", DOCTOR_VERBOSE_HINT);
16231
+ }
16232
+ if (!input.verbose) return lines;
16233
+ lines.push("", ...renderPluginUpdateReport(input.updateReport));
16234
+ lines.push(
16235
+ "",
16236
+ doctorSummaryLine({ checks: input.checks, updateReport: input.updateReport, shouldApply: input.shouldApply, healedCount: input.healedCount }),
16237
+ ...doctorAuditLines({
16238
+ checks: input.checks,
16239
+ shouldApply: input.shouldApply,
16240
+ healedCount: input.healedCount,
16241
+ cliVersion: input.updateReport.versions.cli,
16242
+ releasedVersion: input.updateReport.versions.released
16243
+ })
16244
+ );
16245
+ return lines;
16246
+ }
16247
+ function doctorSummaryLine(input) {
16248
+ const gaps = input.checks.filter((c) => !c.ok);
16249
+ const mode = input.shouldApply ? "apply" : "plan";
16250
+ const released = input.updateReport.versions.released ?? "unknown";
16251
+ const failed = gaps.filter((c) => !isAdvisoryDoctorCheck(c)).length;
16252
+ const countSummary = input.shouldApply ? `${input.healedCount ?? 0} healed` : `${gaps.length} planned`;
16253
+ return `summary: mode ${mode} \xB7 released ${released} \xB7 ${countSummary} \xB7 ${failed} failed`;
16254
+ }
16255
+ function doctorAuditLines(input) {
16256
+ const gaps = input.checks.filter((c) => !c.ok);
16257
+ const failed = gaps.filter((c) => !isAdvisoryDoctorCheck(c)).length;
16258
+ const lines = [
16259
+ "MMI doctor audit",
16260
+ `mode: ${input.shouldApply ? "apply" : "plan"}`,
16261
+ `cli: ${input.cliVersion ?? "unknown"}`,
16262
+ `released: ${input.releasedVersion ?? "unknown"}`,
16263
+ `checks: ${input.checks.length} (${input.checks.length - gaps.length} ok, ${gaps.length} gap)`,
16264
+ `planned actions: ${gaps.length}`,
16265
+ `healed actions: ${input.healedCount ?? 0}`,
16266
+ `failed actions: ${failed}`
16267
+ ];
16268
+ if (gaps.length) {
16269
+ lines.push("actions:");
16270
+ for (const c of gaps) {
16271
+ lines.push(`[${isAdvisoryDoctorCheck(c) ? "advisory" : "auto"}] ${c.label}: ${c.fix}`);
16272
+ }
15862
16273
  }
15863
- lines.push("");
15864
- lines.push(`\u26A0 ${input.gaps.length} item(s) need attention \u2014 ${DOCTOR_VERBOSE_HINT}`);
15865
16274
  return lines;
15866
16275
  }
15867
16276
  var PLUGIN_RESOLVABILITY_LABEL = "MMI plugin resolvability (marketplace + cache present)";
@@ -15894,6 +16303,262 @@ function buildPluginResolvabilityCheck(input) {
15894
16303
  }
15895
16304
  }
15896
16305
 
16306
+ // src/mcp-reconcile.ts
16307
+ var PLAYWRIGHT_MCP_SPEC = {
16308
+ name: "playwright",
16309
+ command: "npx",
16310
+ args: ["-y", "@playwright/mcp@latest", "--output-dir", "tmp/playwright-mcp"],
16311
+ legacyNames: []
16312
+ };
16313
+ var MCP_RECONCILE_LABEL = "MCP server registrations (org-managed Playwright)";
16314
+ var MCP_RECONCILE_FIX = "register/reconcile the org Playwright MCP server (npx -y @playwright/mcp@latest --output-dir tmp/playwright-mcp) \u2014 run `mmi-cli doctor --apply`; see skills/browser-automation/SKILL.md and bootstrap seed mcp-playwright.template.json";
16315
+ function extractServerBlock(content, format, spec) {
16316
+ return format === "json" ? extractJsonServer(content, spec) : extractTomlServer(content, spec);
16317
+ }
16318
+ function extractJsonServer(content, spec) {
16319
+ let parsed;
16320
+ try {
16321
+ parsed = JSON.parse(content);
16322
+ } catch {
16323
+ return { kind: "unparseable" };
16324
+ }
16325
+ if (parsed == null || typeof parsed !== "object") return { kind: "unparseable" };
16326
+ const servers = parsed.mcpServers;
16327
+ if (servers == null || typeof servers !== "object") return { kind: "absent" };
16328
+ const bag = servers;
16329
+ for (const name of [spec.name, ...spec.legacyNames]) {
16330
+ if (Object.prototype.hasOwnProperty.call(bag, name)) {
16331
+ return { kind: "found", matchedName: name, isLegacy: name !== spec.name, blockText: JSON.stringify(bag[name]) };
16332
+ }
16333
+ }
16334
+ return { kind: "absent" };
16335
+ }
16336
+ function extractTomlServer(content, spec) {
16337
+ const blocks = parseTomlBlocks(content.replace(/\r\n/g, "\n").split("\n"));
16338
+ for (const name of [spec.name, ...spec.legacyNames]) {
16339
+ const full = `mcp_servers.${name}`;
16340
+ const block = blocks.find((b) => b.name === full && !b.isArrayTable);
16341
+ if (block) {
16342
+ const blockText = [block.headerRaw, ...block.body].filter((l) => l != null).join("\n");
16343
+ return { kind: "found", matchedName: name, isLegacy: name !== spec.name, blockText };
16344
+ }
16345
+ }
16346
+ return { kind: "absent" };
16347
+ }
16348
+ function detectMcpState(target, spec) {
16349
+ if (!target.present) return { target, state: "skip", action: "skip", reasons: [] };
16350
+ if (target.content == null) {
16351
+ if (target.createWhenFileAbsent) return { target, state: "absent", action: "install", reasons: [] };
16352
+ return { target, state: "skip", action: "skip", reasons: [] };
16353
+ }
16354
+ const extract = extractServerBlock(target.content, target.format, spec);
16355
+ if (extract.kind === "unparseable") {
16356
+ return { target, state: "drifted", action: "reconcile", reasons: ["unparseable"] };
16357
+ }
16358
+ if (extract.kind === "absent") {
16359
+ return { target, state: "absent", action: "install", reasons: [] };
16360
+ }
16361
+ const reasons = [];
16362
+ if (extract.isLegacy) reasons.push("legacy-name");
16363
+ if (textHasPlaywrightVisionCap(extract.blockText)) reasons.push("vision-cap");
16364
+ if (!textHasCanonicalPlaywrightOutputDir(extract.blockText)) reasons.push("output-dir");
16365
+ if (reasons.length === 0) {
16366
+ return { target, state: "healthy", action: "none", reasons: [], matchedName: extract.matchedName };
16367
+ }
16368
+ return { target, state: "drifted", action: "reconcile", reasons, matchedName: extract.matchedName };
16369
+ }
16370
+ function planMcpReconcile(targets, spec) {
16371
+ return targets.map((t) => detectMcpState(t, spec));
16372
+ }
16373
+ function buildMcpReconcileCheck(plan) {
16374
+ const base = {
16375
+ ok: true,
16376
+ label: MCP_RECONCILE_LABEL,
16377
+ fix: MCP_RECONCILE_FIX,
16378
+ severityOverride: "advisory"
16379
+ };
16380
+ const gaps = plan.filter((p) => p.action === "install" || p.action === "reconcile");
16381
+ if (gaps.length === 0) return base;
16382
+ const detail = gaps.map((g) => `${g.target.path} (${g.action === "install" ? "missing" : g.reasons.join("+") || "drifted"})`).join(", ");
16383
+ return {
16384
+ ...base,
16385
+ ok: false,
16386
+ fix: `${MCP_RECONCILE_FIX} \u2014 ${detail}`,
16387
+ mcpGaps: gaps.map((g) => ({ path: g.target.path, action: g.action, reasons: g.reasons }))
16388
+ };
16389
+ }
16390
+ function applyMcpReconcile(targets, spec, opts) {
16391
+ const results = [];
16392
+ for (const item of planMcpReconcile(targets, spec)) {
16393
+ if (item.action !== "install" && item.action !== "reconcile") continue;
16394
+ const t = item.target;
16395
+ if (t.isRepoFile && !opts.repoWritesAllowed) {
16396
+ results.push({ target: t, action: item.action, outcome: "skipped-repo-write" });
16397
+ continue;
16398
+ }
16399
+ const next = t.format === "json" ? renderJsonMcpConfig(t.content, spec) : renderTomlMcpConfig(t.content, spec);
16400
+ if (next == null) {
16401
+ results.push({ target: t, action: item.action, outcome: "unparseable" });
16402
+ continue;
16403
+ }
16404
+ results.push({ target: t, action: item.action, outcome: opts.write(t.path, next) ? "wrote" : "write-failed" });
16405
+ }
16406
+ return results;
16407
+ }
16408
+ function renderJsonMcpConfig(existing, spec) {
16409
+ const canonicalEntry = { command: spec.command, args: [...spec.args] };
16410
+ if (existing == null || existing.trim() === "") {
16411
+ return `${JSON.stringify({ mcpServers: { [spec.name]: canonicalEntry } }, null, 2)}
16412
+ `;
16413
+ }
16414
+ let parsed;
16415
+ try {
16416
+ parsed = JSON.parse(existing);
16417
+ } catch {
16418
+ return null;
16419
+ }
16420
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
16421
+ const obj = parsed;
16422
+ const rawServers = obj.mcpServers;
16423
+ if (rawServers != null && (typeof rawServers !== "object" || Array.isArray(rawServers))) return null;
16424
+ const servers = rawServers ?? {};
16425
+ const prior = servers[spec.name] ?? spec.legacyNames.map((n) => servers[n]).find((v) => v != null);
16426
+ for (const legacy of spec.legacyNames) delete servers[legacy];
16427
+ const priorObj = prior != null && typeof prior === "object" && !Array.isArray(prior) ? prior : {};
16428
+ servers[spec.name] = { ...priorObj, ...canonicalEntry };
16429
+ obj.mcpServers = servers;
16430
+ const text = `${JSON.stringify(obj, null, 2)}
16431
+ `;
16432
+ return existing.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
16433
+ }
16434
+ function isTomlEscaped(line, i) {
16435
+ let n = 0;
16436
+ for (let j = i - 1; j >= 0 && line[j] === "\\"; j--) n += 1;
16437
+ return n % 2 === 1;
16438
+ }
16439
+ function advanceTomlStringState(line, state) {
16440
+ let multi = state;
16441
+ let single = null;
16442
+ let i = 0;
16443
+ while (i < line.length) {
16444
+ if (multi != null) {
16445
+ if (line.startsWith(multi, i) && !(multi === '"""' && isTomlEscaped(line, i))) {
16446
+ multi = null;
16447
+ i += 3;
16448
+ } else {
16449
+ i += 1;
16450
+ }
16451
+ continue;
16452
+ }
16453
+ if (single != null) {
16454
+ if (line[i] === single && !(single === '"' && isTomlEscaped(line, i))) single = null;
16455
+ i += 1;
16456
+ continue;
16457
+ }
16458
+ if (line[i] === "#") break;
16459
+ if (line.startsWith('"""', i)) {
16460
+ multi = '"""';
16461
+ i += 3;
16462
+ continue;
16463
+ }
16464
+ if (line.startsWith("'''", i)) {
16465
+ multi = "'''";
16466
+ i += 3;
16467
+ continue;
16468
+ }
16469
+ if (line[i] === '"' || line[i] === "'") {
16470
+ single = line[i];
16471
+ i += 1;
16472
+ continue;
16473
+ }
16474
+ i += 1;
16475
+ }
16476
+ return multi;
16477
+ }
16478
+ function parseTomlBlocks(lines) {
16479
+ const blocks = [];
16480
+ let current = { headerRaw: null, name: null, isArrayTable: false, body: [] };
16481
+ let multi = null;
16482
+ const tableRe = /^\s*\[\s*([^[\]]+?)\s*\]\s*$/;
16483
+ const arrayTableRe = /^\s*\[\[\s*([^[\]]+?)\s*\]\]\s*$/;
16484
+ for (const line of lines) {
16485
+ if (multi == null) {
16486
+ const am = arrayTableRe.exec(line);
16487
+ const tm = am == null ? tableRe.exec(line) : null;
16488
+ if (am != null || tm != null) {
16489
+ blocks.push(current);
16490
+ current = { headerRaw: line, name: (am ?? tm)[1].trim(), isArrayTable: am != null, body: [] };
16491
+ continue;
16492
+ }
16493
+ }
16494
+ multi = advanceTomlStringState(line, multi);
16495
+ current.body.push(line);
16496
+ }
16497
+ blocks.push(current);
16498
+ return blocks;
16499
+ }
16500
+ function mergeCanonicalTomlBody(body, commandLine, argsLine) {
16501
+ const preserved = [];
16502
+ for (let i = 0; i < body.length; i++) {
16503
+ const line = body[i];
16504
+ const trimmed = line.trim();
16505
+ if (/^command\s*=/.test(trimmed)) continue;
16506
+ if (/^args\s*=/.test(trimmed)) {
16507
+ let open = (line.match(/\[/g) ?? []).length;
16508
+ let close = (line.match(/\]/g) ?? []).length;
16509
+ while (open > close && i + 1 < body.length) {
16510
+ i += 1;
16511
+ open += (body[i].match(/\[/g) ?? []).length;
16512
+ close += (body[i].match(/\]/g) ?? []).length;
16513
+ }
16514
+ continue;
16515
+ }
16516
+ preserved.push(line);
16517
+ }
16518
+ return [commandLine, argsLine, ...preserved];
16519
+ }
16520
+ function renderTomlMcpConfig(existing, spec) {
16521
+ const commandLine = `command = ${JSON.stringify(spec.command)}`;
16522
+ const argsLine = `args = [${spec.args.map((a) => JSON.stringify(a)).join(", ")}]`;
16523
+ const canonicalHeader = `[mcp_servers.${spec.name}]`;
16524
+ const canonicalName = `mcp_servers.${spec.name}`;
16525
+ if (existing == null || existing.trim() === "") {
16526
+ return `${canonicalHeader}
16527
+ ${commandLine}
16528
+ ${argsLine}
16529
+ `;
16530
+ }
16531
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
16532
+ const lines = existing.replace(/\r\n/g, "\n").split("\n");
16533
+ const legacyFull = new Set(spec.legacyNames.map((n) => `mcp_servers.${n}`));
16534
+ const all = parseTomlBlocks(lines);
16535
+ if (all.some((b) => b.isArrayTable && b.name != null && (b.name === canonicalName || legacyFull.has(b.name)))) return null;
16536
+ const targetBlock = all.find((b) => !b.isArrayTable && b.name === canonicalName) ?? all.find((b) => !b.isArrayTable && b.name != null && legacyFull.has(b.name));
16537
+ const out = [];
16538
+ for (const b of all) {
16539
+ if (b === targetBlock) {
16540
+ out.push({ headerRaw: canonicalHeader, name: canonicalName, isArrayTable: false, body: mergeCanonicalTomlBody(b.body, commandLine, argsLine) });
16541
+ continue;
16542
+ }
16543
+ if (targetBlock != null && !b.isArrayTable && b.name != null && legacyFull.has(b.name)) continue;
16544
+ out.push(b);
16545
+ }
16546
+ if (targetBlock != null) {
16547
+ const joined = out.flatMap((b) => b.headerRaw != null ? [b.headerRaw, ...b.body] : b.body).join("\n");
16548
+ return eol === "\r\n" ? joined.replace(/\n/g, "\r\n") : joined;
16549
+ }
16550
+ const base = out.flatMap((b) => b.headerRaw != null ? [b.headerRaw, ...b.body] : b.body).join("\n").replace(/\n+$/, "");
16551
+ const block = `${canonicalHeader}
16552
+ ${commandLine}
16553
+ ${argsLine}`;
16554
+ const combined = base === "" ? `${block}
16555
+ ` : `${base}
16556
+
16557
+ ${block}
16558
+ `;
16559
+ return eol === "\r\n" ? combined.replace(/\n/g, "\r\n") : combined;
16560
+ }
16561
+
15897
16562
  // src/cli-doctor-shared.ts
15898
16563
  var import_node_fs15 = require("node:fs");
15899
16564
  var import_node_path16 = require("node:path");
@@ -16150,7 +16815,20 @@ var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
16150
16815
  function runHostBin(bin, args, opts) {
16151
16816
  return isWin ? execFileP2("cmd.exe", ["/c", bin, ...args], opts) : execFileP2(bin, args, opts);
16152
16817
  }
16153
- function reexecMmiCli(args) {
16818
+ function reexecStrategies(input) {
16819
+ const strategies = [
16820
+ input.platform === "win32" ? { command: "cmd.exe", args: ["/c", "mmi-cli"], kind: "cmd-shim" } : { command: "mmi-cli", args: [], kind: "direct" }
16821
+ ];
16822
+ if (input.argv1) strategies.push({ command: input.execPath, args: [input.argv1], kind: "direct" });
16823
+ return strategies;
16824
+ }
16825
+ var CMD_NOT_RECOGNIZED_EXIT_CODE = 9009;
16826
+ function reexecAttemptOutcome(strategy, code) {
16827
+ if (code < 0) return "strategy-failed";
16828
+ if (strategy.kind === "cmd-shim" && code === CMD_NOT_RECOGNIZED_EXIT_CODE) return "strategy-failed";
16829
+ return "result";
16830
+ }
16831
+ function spawnReexecAttempt(command, args, env) {
16154
16832
  return new Promise((resolve6) => {
16155
16833
  let settled = false;
16156
16834
  const done = (code) => {
@@ -16159,12 +16837,20 @@ function reexecMmiCli(args) {
16159
16837
  resolve6(code);
16160
16838
  }
16161
16839
  };
16162
- const env = { ...process.env, [DOCTOR_POST_SELF_UPDATE_ENV]: "1" };
16163
- const child = isWin ? (0, import_node_child_process10.spawn)("cmd.exe", ["/c", "mmi-cli", ...args], { stdio: "inherit", env }) : (0, import_node_child_process10.spawn)("mmi-cli", args, { stdio: "inherit", env });
16840
+ const child = (0, import_node_child_process10.spawn)(command, args, { stdio: "inherit", env });
16164
16841
  child.on("error", () => done(-1));
16165
16842
  child.on("exit", (code) => done(code ?? 0));
16166
16843
  });
16167
16844
  }
16845
+ async function reexecMmiCli(args) {
16846
+ const strategies = reexecStrategies({ platform: process.platform, argv1: process.argv[1], execPath: process.execPath });
16847
+ const env = { ...process.env, [DOCTOR_POST_SELF_UPDATE_ENV]: "1" };
16848
+ for (const strategy of strategies) {
16849
+ const code = await spawnReexecAttempt(strategy.command, [...strategy.args, ...args], env);
16850
+ if (reexecAttemptOutcome(strategy, code) === "result") return code;
16851
+ }
16852
+ return -1;
16853
+ }
16168
16854
  function hostBinAvailable(bin) {
16169
16855
  return execFileP2(isWin ? "where" : "which", [bin]).then(() => true).catch(() => false);
16170
16856
  }
@@ -16184,6 +16870,15 @@ async function runCodexPlugin(args) {
16184
16870
  return false;
16185
16871
  }
16186
16872
  }
16873
+ async function codexEnabledMmiVersion() {
16874
+ if (!await hostBinAvailable("codex")) return void 0;
16875
+ try {
16876
+ const { stdout } = await runHostBin("codex", ["plugin", "list", "--json"], { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
16877
+ return codexEnabledPluginVersionFromList(stdout, MMI_PLUGIN_ID);
16878
+ } catch {
16879
+ return void 0;
16880
+ }
16881
+ }
16187
16882
  async function marketplaceAddRefSupported(bin) {
16188
16883
  try {
16189
16884
  const { stdout, stderr } = await runHostBin(bin, ["plugin", "marketplace", "add", "--help"], {
@@ -16246,6 +16941,77 @@ function installedPluginSources() {
16246
16941
  }
16247
16942
  });
16248
16943
  }
16944
+ function managedPluginSurfaceSources() {
16945
+ const claudeCodex = installedPluginSources().map(({ surface, installed }) => {
16946
+ const cacheVersions = {};
16947
+ for (const descriptor of MANAGED_PLUGINS) {
16948
+ const segments = parseManagedPluginId(descriptor.id);
16949
+ if (!segments) continue;
16950
+ try {
16951
+ cacheVersions[descriptor.id] = (0, import_node_fs17.readdirSync)(
16952
+ (0, import_node_path17.join)((0, import_node_os6.homedir)(), `.${surface}`, "plugins", "cache", segments.marketplace, segments.name),
16953
+ { withFileTypes: true }
16954
+ ).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
16955
+ } catch {
16956
+ }
16957
+ }
16958
+ return { surface, installed, cacheVersions };
16959
+ });
16960
+ return [
16961
+ ...claudeCodex,
16962
+ { surface: "cursor", directVersions: managedPluginCursorDirectVersions() },
16963
+ { surface: "opencode", directVersions: managedPluginOpencodeDirectVersions() }
16964
+ ];
16965
+ }
16966
+ function managedPluginCursorDirectVersions() {
16967
+ const out = {};
16968
+ for (const descriptor of MANAGED_PLUGINS) {
16969
+ const segments = parseManagedPluginId(descriptor.id);
16970
+ if (!segments) continue;
16971
+ try {
16972
+ const versions = (0, import_node_fs17.readdirSync)(
16973
+ (0, import_node_path17.join)((0, import_node_os6.homedir)(), ".cursor", "plugins", "cache", segments.marketplace, segments.name),
16974
+ { withFileTypes: true }
16975
+ ).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
16976
+ out[descriptor.id] = highestSemver(versions);
16977
+ } catch {
16978
+ }
16979
+ }
16980
+ return out;
16981
+ }
16982
+ function managedPluginOpencodeDirectVersions() {
16983
+ const out = {};
16984
+ for (const descriptor of MANAGED_PLUGINS) {
16985
+ if (!descriptor.opencodePackage) continue;
16986
+ out[descriptor.id] = readOpencodePackageVersion(descriptor.opencodePackage);
16987
+ }
16988
+ return out;
16989
+ }
16990
+ function readOpencodePackageVersionFrom(packageJsonPath) {
16991
+ try {
16992
+ const parsed = JSON.parse((0, import_node_fs17.readFileSync)(packageJsonPath, "utf8"));
16993
+ return typeof parsed.version === "string" && parsed.version.trim() ? parsed.version.trim() : void 0;
16994
+ } catch {
16995
+ return void 0;
16996
+ }
16997
+ }
16998
+ function readOpencodePackageVersion(packageName) {
16999
+ const [scope, name] = packageName.startsWith("@") ? packageName.slice(1).split("/", 2) : [void 0, packageName];
17000
+ if (!name) return void 0;
17001
+ const diskCandidates = [
17002
+ (0, import_node_path17.join)(opencodeConfigDir(), "node_modules", ...scope ? [`@${scope}`] : [], name, "package.json"),
17003
+ (0, import_node_path17.join)((0, import_node_os6.homedir)(), ".cache", "opencode", "node_modules", ...scope ? [`@${scope}`] : [], name, "package.json")
17004
+ ];
17005
+ const diskVersion = diskCandidates.map(readOpencodePackageVersionFrom).find((v) => v !== void 0);
17006
+ const packagesRoot = (0, import_node_path17.join)((0, import_node_os6.homedir)(), ".cache", "opencode", "packages", ...scope ? [`@${scope}`] : []);
17007
+ let cacheVersion;
17008
+ try {
17009
+ 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));
17010
+ if (cacheVersions.length) cacheVersion = cacheVersions.reduce((lowest, v) => compareVersions(v, lowest) < 0 ? v : lowest);
17011
+ } catch {
17012
+ }
17013
+ return pickOpencodeActiveVersion({ cacheVersion, diskVersion });
17014
+ }
16249
17015
  function readClaudeSettings() {
16250
17016
  try {
16251
17017
  return JSON.parse((0, import_node_fs17.readFileSync)((0, import_node_path17.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
@@ -16460,6 +17226,12 @@ function opencodeInstalledVersionForDoctor() {
16460
17226
  diskVersion: readOpencodeAdapterDiskVersion()
16461
17227
  });
16462
17228
  }
17229
+ function opencodePersistedVersionForDoctor() {
17230
+ return pickOpencodeActiveVersion({
17231
+ cacheVersion: readOpencodeLoadedCacheVersion(),
17232
+ diskVersion: readOpencodeAdapterDiskVersion()
17233
+ });
17234
+ }
16463
17235
  function opencodePluginVersionsForReport() {
16464
17236
  return [process.env.MMI_OPENCODE_PLUGIN_VERSION, readOpencodeAdapterDiskVersion()].filter((v) => Boolean(v));
16465
17237
  }
@@ -16577,8 +17349,8 @@ function hasNestedMmiChild(versionDir) {
16577
17349
  }
16578
17350
  }
16579
17351
  function nestedPluginTreeSnapshot() {
16580
- return mmiPluginCacheRootSnapshots().filter((root) => root.surface === "claude").flatMap(
16581
- (root) => root.entries.filter((e) => e.isDirectory && /^v?\d+\.\d+\.\d+/.test(e.name)).map((e) => ({ surface: "claude", path: e.path, nested: hasNestedMmiChild(e.path) }))
17352
+ return mmiPluginCacheRootSnapshots().flatMap(
17353
+ (root) => root.entries.filter((e) => e.isDirectory && /^v?\d+\.\d+\.\d+/.test(e.name)).map((e) => ({ surface: root.surface, path: e.path, nested: hasNestedMmiChild(e.path) }))
16582
17354
  );
16583
17355
  }
16584
17356
  function uniqueQuarantineTarget(path2) {
@@ -16654,21 +17426,95 @@ function readTextFile(path2) {
16654
17426
  return null;
16655
17427
  }
16656
17428
  }
16657
- function playwrightMcpConfigSnapshots() {
17429
+ function mcpDirExists(path2) {
17430
+ try {
17431
+ return (0, import_node_fs17.existsSync)(path2);
17432
+ } catch {
17433
+ return false;
17434
+ }
17435
+ }
17436
+ function mcpConfigTargets() {
16658
17437
  const cwd = process.cwd();
16659
17438
  const home = (0, import_node_os6.homedir)();
16660
- const candidates = [
16661
- (0, import_node_path17.join)(cwd, ".mcp.json"),
16662
- (0, import_node_path17.join)(cwd, ".cursor", "mcp.json"),
16663
- (0, import_node_path17.join)(home, ".cursor", "mcp.json"),
16664
- (0, import_node_path17.join)(home, ".codex", "config.toml")
17439
+ const cursorProjectDir = (0, import_node_path17.join)(cwd, ".cursor");
17440
+ const cursorUserDir = (0, import_node_path17.join)(home, ".cursor");
17441
+ const codexDir = (0, import_node_path17.join)(home, ".codex");
17442
+ return [
17443
+ // Claude Code project MCP — reconciled if present, never conjured (org seeds .cursor/mcp.json, not this).
17444
+ {
17445
+ host: "claude-code",
17446
+ label: "Claude Code project MCP",
17447
+ path: (0, import_node_path17.join)(cwd, ".mcp.json"),
17448
+ format: "json",
17449
+ present: true,
17450
+ // parent is the repo root (cwd); the caller gates the whole reconcile on isOrgRepo
17451
+ isRepoFile: true,
17452
+ createWhenFileAbsent: false,
17453
+ content: readTextFile((0, import_node_path17.join)(cwd, ".mcp.json"))
17454
+ },
17455
+ // Cursor project MCP — the bootstrap-seeded surface; restored if its .cursor dir exists but the file is gone.
17456
+ {
17457
+ host: "cursor-project",
17458
+ label: "Cursor project MCP",
17459
+ path: (0, import_node_path17.join)(cursorProjectDir, "mcp.json"),
17460
+ format: "json",
17461
+ present: mcpDirExists(cursorProjectDir),
17462
+ isRepoFile: true,
17463
+ createWhenFileAbsent: true,
17464
+ content: readTextFile((0, import_node_path17.join)(cursorProjectDir, "mcp.json"))
17465
+ },
17466
+ // Cursor user MCP — global; written only when Cursor is installed (~/.cursor exists).
17467
+ {
17468
+ host: "cursor-user",
17469
+ label: "Cursor user MCP",
17470
+ path: (0, import_node_path17.join)(cursorUserDir, "mcp.json"),
17471
+ format: "json",
17472
+ present: mcpDirExists(cursorUserDir),
17473
+ isRepoFile: false,
17474
+ createWhenFileAbsent: true,
17475
+ content: readTextFile((0, import_node_path17.join)(cursorUserDir, "mcp.json"))
17476
+ },
17477
+ // Codex user config (TOML) — global; written only when Codex is installed (~/.codex exists).
17478
+ {
17479
+ host: "codex",
17480
+ label: "Codex user config",
17481
+ path: (0, import_node_path17.join)(codexDir, "config.toml"),
17482
+ format: "toml",
17483
+ present: mcpDirExists(codexDir),
17484
+ isRepoFile: false,
17485
+ createWhenFileAbsent: true,
17486
+ content: readTextFile((0, import_node_path17.join)(codexDir, "config.toml"))
17487
+ }
16665
17488
  ];
16666
- const out = [];
16667
- for (const path2 of candidates) {
16668
- const content = readTextFile(path2);
16669
- if (content != null) out.push({ path: path2, content });
17489
+ }
17490
+ function writeMcpConfigFile(path2, content) {
17491
+ try {
17492
+ (0, import_node_fs17.writeFileSync)(path2, content, "utf8");
17493
+ return true;
17494
+ } catch {
17495
+ return false;
16670
17496
  }
16671
- return out;
17497
+ }
17498
+ async function reconcileMcpRegistrations(input) {
17499
+ if (!input.isOrgRepo) return buildMcpReconcileCheck([]);
17500
+ const targets = mcpConfigTargets();
17501
+ if (!input.apply) return buildMcpReconcileCheck(planMcpReconcile(targets, PLAYWRIGHT_MCP_SPEC));
17502
+ const results = applyMcpReconcile(targets, PLAYWRIGHT_MCP_SPEC, {
17503
+ repoWritesAllowed: input.repoWritesAllowed,
17504
+ write: writeMcpConfigFile
17505
+ });
17506
+ for (const r of results) {
17507
+ if (r.outcome === "wrote") {
17508
+ input.onHealed();
17509
+ const verb = r.action === "install" ? "registered" : "reconciled";
17510
+ input.log(` \u21BB ${verb} Playwright MCP in ${r.target.path} \u2014 restart the host to load it`);
17511
+ } else if (r.outcome === "unparseable") {
17512
+ input.log(` \u26A0 ${r.target.label}: could not safely patch ${r.target.path} (unparseable) \u2014 fix it by hand`);
17513
+ } else if (r.outcome === "write-failed") {
17514
+ input.log(` \u26A0 ${r.target.label}: failed to write ${r.target.path}`);
17515
+ }
17516
+ }
17517
+ return buildMcpReconcileCheck(planMcpReconcile(mcpConfigTargets(), PLAYWRIGHT_MCP_SPEC));
16672
17518
  }
16673
17519
  function strayBrowserArtifactPaths() {
16674
17520
  const cwd = process.cwd();
@@ -16704,7 +17550,6 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16704
17550
  const repairLocal = !opts.json || Boolean(opts.apply) || Boolean(opts.preflight);
16705
17551
  const repoWritesAllowed = !opts.noRepoWrites;
16706
17552
  const runExtended = Boolean(opts.verbose) || Boolean(opts.json);
16707
- const terseOutput = !opts.verbose && !opts.json && !opts.banner && !opts.preflight;
16708
17553
  const checks = [];
16709
17554
  const REWRITE_KEY = "url.https://github.com/.insteadOf";
16710
17555
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
@@ -16774,6 +17619,10 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16774
17619
  const markPluginReloadRequired = () => {
16775
17620
  pluginReloadRequired = true;
16776
17621
  };
17622
+ let healedCount = 0;
17623
+ const markHealed = () => {
17624
+ healedCount += 1;
17625
+ };
16777
17626
  let versionReport = buildVersionLagReport({
16778
17627
  currentVersion: resolveClientVersion(),
16779
17628
  repoVersion: readRepoVersion(),
@@ -16789,13 +17638,19 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16789
17638
  checks.push(versionReport);
16790
17639
  if (selfUpdatedCli) {
16791
17640
  if (!opts.json) io.err(selfUpdateReexecLine(versionReport));
16792
- const code = await reexecMmiCli(buildSelfUpdateReexecArgs(opts));
17641
+ const reexecArgs = buildSelfUpdateReexecArgs(opts);
17642
+ const code = await reexecMmiCli(reexecArgs);
16793
17643
  if (code >= 0) {
16794
17644
  if (code > 0) process.exitCode = code;
16795
17645
  return;
16796
17646
  }
16797
- if (opts.json) io.log(JSON.stringify(buildSelfUpdateHaltPayload({ checks, updatedTo: versionReport.releasedVersion }), null, 2));
16798
- else io.err(selfUpdateHaltLine(versionReport));
17647
+ process.exitCode = DOCTOR_SELF_UPDATE_HALT_EXIT_CODE;
17648
+ if (opts.json) {
17649
+ io.log(JSON.stringify(buildSelfUpdateHaltPayload({ checks, updatedTo: versionReport.releasedVersion }), null, 2));
17650
+ } else {
17651
+ io.err(selfUpdateHaltLine(versionReport));
17652
+ io.err(selfUpdateHaltSentinelLine({ report: versionReport, rerunArgs: reexecArgs }));
17653
+ }
16799
17654
  return;
16800
17655
  }
16801
17656
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "Hub API URL configured", fix: "set MMI_HUB_URL or use a current MMI CLI/plugin build" });
@@ -16826,6 +17681,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16826
17681
  try {
16827
17682
  await execFileP2("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
16828
17683
  cloneOk = true;
17684
+ markHealed();
16829
17685
  io.err(" \u21BB repaired: git insteadOf git@github.com \u2192 https (plugin clone over HTTPS)");
16830
17686
  } catch {
16831
17687
  }
@@ -16844,6 +17700,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16844
17700
  if (!pluginCheck.ok && pluginCheck.recordToInsert && repairLocal) {
16845
17701
  if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
16846
17702
  pluginCheck = { ...pluginCheck, ok: true };
17703
+ markHealed();
16847
17704
  io.err(` \u21BB repaired: registered mmi@mutmutco project install record \u2014 ${reloadHint} to load MMI commands`);
16848
17705
  }
16849
17706
  }
@@ -16863,6 +17720,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16863
17720
  surface
16864
17721
  });
16865
17722
  if (legacyPluginCheck.ok) {
17723
+ markHealed();
16866
17724
  io.err(` \u21BB migrated legacy mmi@mmi \u2192 mmi@mutmutco via claude plugin \u2014 ${reloadHint} to load MMI commands`);
16867
17725
  }
16868
17726
  }
@@ -16873,6 +17731,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16873
17731
  surface
16874
17732
  });
16875
17733
  if (legacyPluginCheck.ok) {
17734
+ markHealed();
16876
17735
  io.err(` \u21BB migrated legacy mmi@mmi \u2192 mmi@mutmutco via codex plugin \u2014 ${reloadHint} to load MMI commands`);
16877
17736
  }
16878
17737
  }
@@ -16891,6 +17750,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16891
17750
  gitignoreCheck.removed?.length ? `removed ${gitignoreCheck.removed.join(", ")}` : ""
16892
17751
  ].filter(Boolean).join("; ") || "normalized the block";
16893
17752
  gitignoreCheck = { ...gitignoreCheck, ok: true };
17753
+ markHealed();
16894
17754
  io.err(` \u21BB repaired: org-managed .gitignore block \u2014 ${drift}`);
16895
17755
  io.err(" this is an org-managed update (not unrelated churn) \u2014 stage & commit .gitignore so it stops recurring");
16896
17756
  }
@@ -16904,6 +17764,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16904
17764
  if (!driftCheck.ok && driftCheck.recordsToWrite && repairLocal) {
16905
17765
  if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
16906
17766
  driftCheck = { ...driftCheck, ok: true };
17767
+ markHealed();
16907
17768
  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
17769
  }
16909
17770
  }
@@ -16936,6 +17797,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16936
17797
  installedVersionCheck = healed;
16937
17798
  if (healed.ok) {
16938
17799
  markPluginReloadRequired();
17800
+ markHealed();
16939
17801
  io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reloadAction(surface)} to load the new commands`);
16940
17802
  }
16941
17803
  }
@@ -16945,11 +17807,13 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16945
17807
  installedVersionCheck = healed;
16946
17808
  if (healed.ok) {
16947
17809
  markPluginReloadRequired();
17810
+ markHealed();
16948
17811
  io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via codex plugin \u2014 ${reloadAction(surface)} to load the new commands`);
16949
17812
  }
16950
17813
  }
16951
17814
  }
16952
17815
  checks.push(installedVersionCheck);
17816
+ checks.push(buildManagedPluginDriftCheck({ isOrgRepo, sources: managedPluginSurfaceSources() }));
16953
17817
  let openCodeConfigSnapshot = opencodeConfigSnapshot();
16954
17818
  const inspectOpenCode = surface === "opencode" || openCodeConfigSnapshot.hasConfig || runExtended;
16955
17819
  if (inspectOpenCode) {
@@ -16974,6 +17838,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16974
17838
  });
16975
17839
  if (opencodeConfigCheck.ok) {
16976
17840
  markPluginReloadRequired();
17841
+ markHealed();
16977
17842
  io.err(` \u21BB repaired: wired ${OPENCODE_PLUGIN_PACKAGE} in OpenCode config \u2014 ${reloadAction("opencode")} to load MMI commands`);
16978
17843
  }
16979
17844
  }
@@ -16986,10 +17851,20 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16986
17851
  releasedVersion
16987
17852
  });
16988
17853
  if (!opencodeVersionCheck.ok && repairFull) {
16989
- const refreshed = await forceInstallOpencodeMmiPlugins(openCodeConfigSnapshot, (m) => io.err(m));
16990
- const quarantined = quarantineStaleOpencodePluginCaches(releasedVersion, (m) => io.err(m));
16991
- if (refreshed || quarantined) {
16992
- opencodeInstalledVersion = opencodeInstalledVersionForDoctor() ?? opencodeInstalledVersion;
17854
+ const persistedCurrent = () => buildOpencodeVersionCheck({
17855
+ isOrgRepo,
17856
+ installedVersion: opencodePersistedVersionForDoctor(),
17857
+ releasedVersion
17858
+ }).ok;
17859
+ let refreshed = false;
17860
+ let quarantined = false;
17861
+ if (!persistedCurrent()) {
17862
+ refreshed = await forceInstallOpencodeMmiPlugins(openCodeConfigSnapshot, (m) => io.err(m));
17863
+ quarantined = quarantineStaleOpencodePluginCaches(releasedVersion, (m) => io.err(m));
17864
+ }
17865
+ if (persistedCurrent()) {
17866
+ const wasReinstalled = refreshed || quarantined;
17867
+ opencodeInstalledVersion = opencodePersistedVersionForDoctor() ?? opencodeInstalledVersion;
16993
17868
  opencodeVersionCheck = buildOpencodeVersionCheck({
16994
17869
  isOrgRepo,
16995
17870
  installedVersion: opencodeInstalledVersion,
@@ -16997,7 +17872,9 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
16997
17872
  });
16998
17873
  if (opencodeVersionCheck.ok) {
16999
17874
  markPluginReloadRequired();
17000
- io.err(` \u21BB force-refreshed OpenCode MMI plugin \u2192 ${opencodeInstalledVersion ?? releasedVersion ?? "latest"} \u2014 ${reloadAction("opencode")} to load it`);
17875
+ markHealed();
17876
+ const version = opencodeInstalledVersion ?? releasedVersion ?? "latest";
17877
+ io.err(wasReinstalled ? ` \u21BB force-refreshed OpenCode MMI plugin \u2192 ${version} \u2014 ${reloadAction("opencode")} to load it` : ` \u21BB OpenCode MMI plugin is current on disk (${version}); the running session is stale \u2014 ${reloadAction("opencode")} to load it`);
17001
17878
  }
17002
17879
  }
17003
17880
  }
@@ -17035,6 +17912,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17035
17912
  });
17036
17913
  if (surfaceAssetsCheck.ok) {
17037
17914
  markPluginReloadRequired();
17915
+ markHealed();
17038
17916
  io.err(` \u21BB materialized OpenCode MMI commands + skills path \u2014 ${reloadAction("opencode")} to load them`);
17039
17917
  }
17040
17918
  }
@@ -17056,6 +17934,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17056
17934
  if (!legacyOpenCodeCheck.ok && repairLocal && legacyOpenCodeConfig.legacyPath) {
17057
17935
  if (quarantineOpencodeLegacyConfig(legacyOpenCodeConfig.legacyPath)) {
17058
17936
  legacyOpenCodeCheck = buildOpencodeLegacyConfigCheck({ isOrgRepo: true });
17937
+ markHealed();
17059
17938
  io.err(` \u21BB quarantined legacy OpenCode config \u2192 ${legacyOpenCodeConfig.legacyPath}.bak \u2014 restart OpenCode`);
17060
17939
  }
17061
17940
  }
@@ -17066,6 +17945,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17066
17945
  return versions.sort((a, b) => compareVersions(b, a))[0];
17067
17946
  };
17068
17947
  const codexCacheVersions = () => mmiPluginCacheRootSnapshots().filter((r) => r.surface === "codex").flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name));
17948
+ const codexActiveVersion = () => isOrgRepo && releasedVersion ? codexEnabledMmiVersion() : Promise.resolve(void 0);
17069
17949
  let cacheCleanupCheck = buildMmiPluginCacheCleanupCheck({
17070
17950
  isOrgRepo,
17071
17951
  roots: mmiPluginCacheRootSnapshots(),
@@ -17080,6 +17960,7 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17080
17960
  const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
17081
17961
  const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
17082
17962
  markPluginReloadRequired();
17963
+ markHealed();
17083
17964
  io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
17084
17965
  }
17085
17966
  cacheCleanupCheck = {
@@ -17101,18 +17982,21 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17101
17982
  isOrgRepo,
17102
17983
  releasedVersion,
17103
17984
  codexCacheVersions: codexCacheVersions(),
17104
- codexRecordVersion: codexRecordVersion()
17985
+ codexRecordVersion: codexRecordVersion(),
17986
+ codexActiveVersion: await codexActiveVersion()
17105
17987
  });
17106
17988
  if (!codexActiveCacheCheck.ok && repairFull) {
17107
17989
  const canDriveCodex = surfaceToken(surface) === "codex" || await hostBinAvailable("codex");
17108
17990
  if (canDriveCodex && await applyPluginHeal("codex", surface, (m) => io.err(m), { force: true })) {
17109
17991
  markPluginReloadRequired();
17992
+ markHealed();
17110
17993
  io.err(` \u21BB restored Codex MMI plugin cache \u2192 ${releasedVersion ?? "latest"} via codex plugin \u2014 ${reloadAction("codex")} to load the new commands`);
17111
17994
  codexActiveCacheCheck = buildCodexActiveCacheCheck({
17112
17995
  isOrgRepo,
17113
17996
  releasedVersion,
17114
17997
  codexCacheVersions: codexCacheVersions(),
17115
- codexRecordVersion: codexRecordVersion()
17998
+ codexRecordVersion: codexRecordVersion(),
17999
+ codexActiveVersion: await codexActiveVersion()
17116
18000
  });
17117
18001
  }
17118
18002
  }
@@ -17123,7 +18007,8 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17123
18007
  entries: nestedPluginTreeSnapshot()
17124
18008
  });
17125
18009
  if (!nestedPluginTreeCheck.ok && nestedPluginTreeCheck.nested?.length && repairLocal) {
17126
- const nestedPaths = nestedPluginTreeCheck.nested.map((n) => n.path);
18010
+ const nestedEntries = nestedPluginTreeCheck.nested;
18011
+ const nestedPaths = nestedEntries.map((n) => n.path);
17127
18012
  if (await applyNestedPluginTreeCleanup(nestedPaths, (m) => io.err(m))) {
17128
18013
  nestedPluginTreeCheck = buildNestedPluginTreeCheck({
17129
18014
  isOrgRepo,
@@ -17131,35 +18016,62 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17131
18016
  entries: nestedPluginTreeSnapshot()
17132
18017
  });
17133
18018
  if (nestedPluginTreeCheck.ok) {
17134
- io.err(` \u21BB cleared self-nested MMI plugin cache tree(s) \u2014 reinstalling plugin\u2026`);
18019
+ io.err(` \u21BB cleared self-nested MMI plugin cache tree(s)`);
18020
+ }
18021
+ let reinstalledAny = false;
18022
+ const unreinstalled = [];
18023
+ for (const token of [...new Set(nestedEntries.map((n) => n.surface))]) {
18024
+ const bin = token === "codex" ? "codex" : "claude";
18025
+ const canDrive = surfaceToken(surface) === token || await hostBinAvailable(bin);
18026
+ if (canDrive && await applyPluginHeal(token, surface, (m) => io.err(m), { force: true })) {
18027
+ reinstalledAny = true;
18028
+ io.err(` \u21BB reinstalled MMI plugin (${token}) after nested-cache cleanup \u2014 ${reloadAction(surface)} to load MMI commands`);
18029
+ } else {
18030
+ unreinstalled.push(token);
18031
+ }
17135
18032
  }
17136
- if (await applyPluginHeal("claude", surface, (m) => io.err(m))) {
18033
+ if (reinstalledAny) {
17137
18034
  markPluginReloadRequired();
17138
- io.err(` \u21BB reinstalled MMI plugin after nested-cache cleanup \u2014 ${reloadAction(surface)} to load MMI commands`);
18035
+ markHealed();
17139
18036
  nestedPluginTreeCheck = buildNestedPluginTreeCheck({
17140
18037
  isOrgRepo,
17141
18038
  isWindows: isWin,
17142
18039
  entries: nestedPluginTreeSnapshot()
17143
18040
  });
17144
18041
  }
18042
+ if (unreinstalled.length > 0 && nestedPluginTreeCheck.ok) {
18043
+ nestedPluginTreeCheck = { ...nestedPluginTreeCheck, ok: false, fix: nestedTreeReinstallGapFix(unreinstalled) };
18044
+ }
17145
18045
  }
17146
18046
  }
17147
18047
  checks.push(nestedPluginTreeCheck);
17148
18048
  const cursorCacheRoot = cursorPluginCacheRoot();
18049
+ const cursorCacheRootExists = (0, import_node_fs17.existsSync)(cursorCacheRoot);
17149
18050
  let cursorPins = cursorPluginCachePinSnapshots() ?? [];
18051
+ checks.push(
18052
+ buildCursorPluginCacheCleanupCheck({
18053
+ isOrgRepo,
18054
+ cacheRoot: cursorCacheRoot,
18055
+ pins: cursorPins,
18056
+ releasedVersion
18057
+ })
18058
+ );
17150
18059
  let cursorPluginCheck = buildCursorPluginInstallCheck({
17151
18060
  isOrgRepo,
17152
18061
  surface,
17153
18062
  cacheRoot: cursorCacheRoot,
17154
- cacheRootExists: (0, import_node_fs17.existsSync)(cursorCacheRoot),
18063
+ cacheRootExists: cursorCacheRootExists,
17155
18064
  pins: cursorPins,
17156
18065
  hubCheckout: hubCheckoutForCursorSeed(),
17157
18066
  releasedVersion
17158
18067
  });
17159
18068
  if (!cursorPluginCheck.ok && repairLocal) {
18069
+ const seedingFromNothing = cursorPins.length === 0;
17160
18070
  const seeded = await applyCursorPluginCacheSeed({
17161
18071
  pins: cursorPins,
17162
18072
  releasedVersion,
18073
+ cacheRoot: cursorCacheRoot,
18074
+ cacheRootExists: cursorCacheRootExists,
17163
18075
  hubCheckout: hubCheckoutForCursorSeed(),
17164
18076
  execFileP: execFileP2,
17165
18077
  mkdtemp: (prefix) => (0, import_promises2.mkdtemp)((0, import_node_path17.join)((0, import_node_os6.tmpdir)(), prefix)),
@@ -17171,13 +18083,26 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17171
18083
  isOrgRepo,
17172
18084
  surface,
17173
18085
  cacheRoot: cursorCacheRoot,
17174
- cacheRootExists: (0, import_node_fs17.existsSync)(cursorCacheRoot),
18086
+ cacheRootExists: cursorCacheRootExists,
17175
18087
  pins: cursorPins,
17176
18088
  hubCheckout: hubCheckoutForCursorSeed(),
17177
18089
  releasedVersion
17178
18090
  });
17179
18091
  if (cursorPluginCheck.ok) {
17180
- io.err(` \u21BB seeded Cursor MMI plugin cache \u2192 ${releasedVersion ?? "latest"} \u2014 ${reloadAction(surface)}`);
18092
+ markHealed();
18093
+ if (seedingFromNothing) {
18094
+ io.err(
18095
+ ` \u21BB seeded Cursor MMI plugin cache from nothing \u2192 ${releasedVersion ?? "latest"} (cache only \u2014 Team Marketplace registration not verified, #2409) \u2014 ${reloadAction(surface)}`
18096
+ );
18097
+ cursorPluginCheck = {
18098
+ ...cursorPluginCheck,
18099
+ ok: false,
18100
+ severityOverride: "advisory",
18101
+ fix: cursorSeededFromNothingAdvisoryFix(releasedVersion)
18102
+ };
18103
+ } else {
18104
+ io.err(` \u21BB seeded Cursor MMI plugin cache \u2192 ${releasedVersion ?? "latest"} \u2014 ${reloadAction(surface)}`);
18105
+ }
17181
18106
  }
17182
18107
  }
17183
18108
  }
@@ -17198,20 +18123,29 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17198
18123
  mmiCliOnPath: onPath
17199
18124
  })
17200
18125
  );
17201
- if (runExtended) {
17202
- const playwrightMcpConfigs = playwrightMcpConfigSnapshots();
17203
- checks.push(
17204
- buildPlaywrightMcpVisionCapCheck({
17205
- isOrgRepo,
17206
- configs: playwrightMcpConfigs
17207
- })
17208
- );
18126
+ if (!opts.banner) {
17209
18127
  checks.push(
17210
- buildPlaywrightMcpOutputDirCheck({
18128
+ await reconcileMcpRegistrations({
17211
18129
  isOrgRepo,
17212
- configs: playwrightMcpConfigs
18130
+ apply: Boolean(opts.apply),
18131
+ repoWritesAllowed,
18132
+ log: (m) => io.err(m),
18133
+ onHealed: markHealed
17213
18134
  })
17214
18135
  );
18136
+ }
18137
+ const sessionPluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
18138
+ const sessionCacheSurface = sessionPluginRoot?.includes(".codex") ? "codex" : sessionPluginRoot?.includes(".claude") ? "claude" : void 0;
18139
+ const sessionCacheVersions = sessionCacheSurface ? mmiPluginCacheRootSnapshots().filter((r) => r.surface === sessionCacheSurface).flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name)) : [];
18140
+ checks.push(
18141
+ buildSessionSkillRootCheck({
18142
+ isOrgRepo,
18143
+ surface,
18144
+ pluginRoot: sessionPluginRoot,
18145
+ cacheVersions: sessionCacheVersions
18146
+ })
18147
+ );
18148
+ if (runExtended) {
17215
18149
  checks.push(
17216
18150
  buildBrowserArtifactsCheck({
17217
18151
  isOrgRepo,
@@ -17248,48 +18182,66 @@ async function runDoctor(opts, io = consoleIo, readOrigin) {
17248
18182
  } catch {
17249
18183
  }
17250
18184
  }
18185
+ emitDoctorReport(opts, io, {
18186
+ checks,
18187
+ surface,
18188
+ needsEagerHeal: healPlan.needsEagerHeal,
18189
+ healedCount,
18190
+ pluginReloadRequired,
18191
+ reloadHint,
18192
+ // Surface-aware update report (#865): the per-surface version snapshot + copy-paste update recipes, so
18193
+ // an agent told "make sure the CLI and plugin are up to date" can run the right command per surface and
18194
+ // echo back an unambiguous version line (CLI / Claude plugin / Codex marketplace / Codex active cache).
18195
+ // Passed lazily: emitDoctorReport calls it only past the --preflight/--banner early returns, keeping
18196
+ // the SessionStart hot path free of its synchronous fs I/O.
18197
+ buildUpdateReport: () => {
18198
+ const cacheRoots = mmiPluginCacheRootSnapshots();
18199
+ const cacheVersionsFor = (s) => cacheRoots.filter((r) => r.surface === s).flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name));
18200
+ const sourceVersions = (s) => installedPluginVersions(installedPluginSources().find((src) => src.surface === s)?.installed ?? null);
18201
+ return buildPluginUpdateReport({
18202
+ cliVersion: resolveClientVersion(),
18203
+ claudePluginVersions: sourceVersions("claude"),
18204
+ codexPluginVersions: sourceVersions("codex"),
18205
+ codexCacheVersions: cacheVersionsFor("codex"),
18206
+ opencodePluginVersions: opencodePluginVersionsForReport(),
18207
+ releasedVersion
18208
+ });
18209
+ }
18210
+ });
18211
+ }
18212
+ function emitDoctorReport(opts, io, ctx) {
18213
+ const { checks, surface, healedCount, pluginReloadRequired, reloadHint } = ctx;
17251
18214
  const gaps = checks.filter((c) => !c.ok);
17252
18215
  if (opts.preflight) {
17253
- const outcome = preflightOutcome({ gaps, needsEagerHeal: healPlan.needsEagerHeal, surface });
18216
+ const outcome = preflightOutcome({ gaps, needsEagerHeal: ctx.needsEagerHeal, surface });
17254
18217
  if (outcome.line) io.err(outcome.line);
17255
18218
  if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17256
18219
  return;
17257
18220
  }
17258
18221
  if (opts.banner) {
17259
- if (healPlan.needsEagerHeal) io.err(doctorPreflightDoneLine(surface));
18222
+ if (ctx.needsEagerHeal) io.err(doctorPreflightDoneLine(surface));
17260
18223
  if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
17261
18224
  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
18225
  return;
17263
18226
  }
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
- });
18227
+ const updateReport = ctx.buildUpdateReport();
17275
18228
  const resources = doctorResourcesForGaps(gaps);
18229
+ process.exitCode = doctorExitCode(checks);
17276
18230
  if (opts.json) {
17277
18231
  io.log(JSON.stringify(buildDoctorJsonPayload({ checks, updateReport, resources }), null, 2));
17278
18232
  return;
17279
18233
  }
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}`);
18234
+ if (pluginReloadRequired) io.err(pluginAutonomousHaltLine(reloadHint));
18235
+ for (const line of doctorHumanLines({
18236
+ checks,
18237
+ updateReport,
18238
+ shouldApply: Boolean(opts.apply),
18239
+ healedCount,
18240
+ pluginReloadRequired,
18241
+ reloadHint,
18242
+ verbose: Boolean(opts.verbose)
18243
+ })) io.log(line);
17288
18244
  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
18245
  }
17294
18246
  var USER_SCOPE_GUARD_MARKER = "mmi-guard:v1";
17295
18247
  var USER_SCOPE_GUARD_COMMAND = `mmi-cli guard --session-start || true # ${USER_SCOPE_GUARD_MARKER}`;
@@ -18874,6 +19826,21 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
18874
19826
  pollChecks: () => pollGhPrChecks(prNumber, repo ? ["--repo", repo] : []),
18875
19827
  sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
18876
19828
  }),
19829
+ // #2425: re-probe used ONLY to decide whether a failed `gh pr merge --auto` is worth retrying — a
19830
+ // fresh read of state/mergeable/checks, independent of the merge call's own (possibly stale) error.
19831
+ probeMergeReady: async (prNumber, repo) => {
19832
+ const args = repo ? ["--repo", repo] : [];
19833
+ const [state, checks] = await Promise.all([
19834
+ 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(() => ["", ""]),
19835
+ pollGhPrChecks(prNumber, args).catch(() => "error")
19836
+ ]);
19837
+ const [prState, mergeable] = state;
19838
+ return {
19839
+ open: prState === "OPEN",
19840
+ mergeable: mergeable === "MERGEABLE",
19841
+ checksPassing: checks === "success" || checks === "no-checks-reported"
19842
+ };
19843
+ },
18877
19844
  mergeAuto: async (prNumber, repo) => {
18878
19845
  const args = repo ? ["--repo", repo] : [];
18879
19846
  const readMergeState = () => readGhPrStateWithRetry(async () => (await execFileP2("gh", ["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 })).stdout);
@@ -19274,7 +20241,7 @@ board.command("prune-priority-labels").description("remove retired priority:* la
19274
20241
  });
19275
20242
  board.command("done <issue>").description("set a board item's Status to Done (does not close the GitHub issue; use `gh issue close`)").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return success JSON if the item resolves but the status move fails").action(async (issueRef, o) => {
19276
20243
  try {
19277
- const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
20244
+ const result = await moveBoardItem({ config: await loadConfigForBoardSelector(issueRef, o.repo), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
19278
20245
  if (o.json) return console.log(JSON.stringify(result));
19279
20246
  console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
19280
20247
  } catch (e) {