@mutmutco/cli 2.14.1 → 2.15.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/index.cjs +736 -95
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -4301,6 +4301,7 @@ var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
4301
4301
  var rawExecFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
4302
4302
  var BOARD_GIT_TIMEOUT_MS = 1e4;
4303
4303
  var WRITE_PROBE_CONCURRENCY = 8;
4304
+ var CLAIM_CONCURRENCY = 5;
4304
4305
  var execFileP2 = (file, args, options = {}) => (
4305
4306
  // encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
4306
4307
  // overloads widen to string|Buffer when options is spread in).
@@ -4675,17 +4676,16 @@ async function showBoardItem(options, deps = {}) {
4675
4676
  }
4676
4677
  return item;
4677
4678
  }
4678
- async function claimBoardIssue(options, deps = {}) {
4679
+ async function prepareClaimContext(options, selectors, deps, collected) {
4679
4680
  const cfg = resolveBoardConfig(options.config);
4680
4681
  const client = deps.client ?? defaultGitHubClient();
4681
- const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
4682
- const selector = parseIssueSelector(options.selector, collected.repo);
4683
- try {
4684
- findBoardItem(collected.items, selector);
4685
- } catch (e) {
4686
- const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
4687
- if (!fallback) throw e;
4688
- collected.items.push(fallback);
4682
+ for (const selector of selectors) {
4683
+ try {
4684
+ findBoardItem(collected.items, selector);
4685
+ } catch {
4686
+ const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
4687
+ if (fallback) collected.items.push(fallback);
4688
+ }
4689
4689
  }
4690
4690
  const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
4691
4691
  collected.warnings.push(...writable.warnings);
@@ -4698,8 +4698,12 @@ async function claimBoardIssue(options, deps = {}) {
4698
4698
  warnings: collected.warnings,
4699
4699
  partial: collected.partial
4700
4700
  };
4701
- const flatItem = findBoardItem(collected.items, selector);
4702
- if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
4701
+ return { cfg, client, items: collected.items, writable: writable.repos, report };
4702
+ }
4703
+ async function claimOneBoardItem(ctx, selector, options) {
4704
+ const { cfg, client, report } = ctx;
4705
+ const flatItem = findBoardItem(ctx.items, selector);
4706
+ if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !ctx.writable.has(flatItem.repository.toLowerCase())) {
4703
4707
  throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
4704
4708
  }
4705
4709
  let item = findClaimableItem(report, selector);
@@ -4736,6 +4740,49 @@ async function claimBoardIssue(options, deps = {}) {
4736
4740
  partial: false
4737
4741
  };
4738
4742
  }
4743
+ async function claimBoardIssue(options, deps = {}) {
4744
+ const cfg = resolveBoardConfig(options.config);
4745
+ const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
4746
+ const selector = parseIssueSelector(options.selector, collected.repo);
4747
+ const ctx = await prepareClaimContext(options, [selector], deps, collected);
4748
+ return claimOneBoardItem(ctx, selector, options);
4749
+ }
4750
+ async function claimBoardIssues(options, deps = {}) {
4751
+ const cfg = resolveBoardConfig(options.config);
4752
+ const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
4753
+ const selectors = [];
4754
+ const seen = /* @__PURE__ */ new Set();
4755
+ for (const raw of options.selectors) {
4756
+ const selector = parseIssueSelector(raw, collected.repo);
4757
+ const key = `${selector.repo.toLowerCase()}#${selector.number}`;
4758
+ if (seen.has(key)) continue;
4759
+ seen.add(key);
4760
+ selectors.push(selector);
4761
+ }
4762
+ const ctx = await prepareClaimContext(options, selectors, deps, collected);
4763
+ const results = new Array(selectors.length);
4764
+ let next = 0;
4765
+ const worker = async () => {
4766
+ while (next < selectors.length) {
4767
+ const index = next++;
4768
+ const selector = selectors[index];
4769
+ const ref = `${selector.repo}#${selector.number}`;
4770
+ try {
4771
+ const result = await claimOneBoardItem(ctx, selector, options);
4772
+ results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning };
4773
+ } catch (e) {
4774
+ results[index] = { ref, claimed: false, reason: e.message };
4775
+ }
4776
+ }
4777
+ };
4778
+ await Promise.all(Array.from({ length: Math.min(CLAIM_CONCURRENCY, selectors.length) }, () => worker()));
4779
+ return {
4780
+ viewer: ctx.report.viewer,
4781
+ repo: ctx.report.repo,
4782
+ results,
4783
+ failed: results.filter((result) => !result.claimed).length
4784
+ };
4785
+ }
4739
4786
  async function setBoardItemPriority(client, cfg, itemId, priority) {
4740
4787
  if (!isPriorityFieldConfigured(cfg)) return void 0;
4741
4788
  const optionId = resolvePriorityOptionId(cfg, priority);
@@ -5315,6 +5362,7 @@ function trainPlan(command) {
5315
5362
  { label: "verify current branch is rc", gated: true },
5316
5363
  { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
5317
5364
  { label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
5365
+ { label: "verify every main-only hotfix commit is covered by the rc candidate", command: "node scripts/hotfix-coverage.mjs", gated: true },
5318
5366
  { label: "merge rc to main", gated: true },
5319
5367
  { label: "for Hub distribution changes, verify the promoted SHA carries the release bump", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
5320
5368
  { label: "tag release and publish GitHub Release", gated: true },
@@ -5323,10 +5371,10 @@ function trainPlan(command) {
5323
5371
  ];
5324
5372
  }
5325
5373
  return [
5326
- { label: "branch hotfix from main", gated: true },
5327
- { label: "apply approved fix", gated: true },
5328
- { label: "deploy prod", gated: true },
5329
- { label: "back-merge to rc and development", gated: true }
5374
+ { label: "verify the fix is merged on development (the only hotfix origin)", gated: true },
5375
+ { label: "branch hotfix from main and cherry-pick the dev commits", command: "git cherry-pick -x <dev-sha>", gated: true },
5376
+ { label: "land on main via PR, tag, deploy prod", gated: true },
5377
+ { label: "forward-bump development distribution manifests (Hub only; no back-merge)", gated: true }
5330
5378
  ];
5331
5379
  }
5332
5380
  function bootstrapPlan(repo, repoClass) {
@@ -5405,9 +5453,9 @@ function stalePosixFields(config, shell2) {
5405
5453
  }
5406
5454
  function sanitizeLocalStage(local, stale) {
5407
5455
  if (!stale.length) return local;
5408
- const clean2 = { ...local };
5409
- for (const field of stale) delete clean2[field];
5410
- return clean2;
5456
+ const clean3 = { ...local };
5457
+ for (const field of stale) delete clean3[field];
5458
+ return clean3;
5411
5459
  }
5412
5460
  function staleNote(staleFields, outcome) {
5413
5461
  const list = staleFields.join(", ");
@@ -5679,7 +5727,7 @@ function buildGitignoreManagedBlockCheck(input) {
5679
5727
  return { ...base, ok: false, contentToWrite: content, added, removed, seeded };
5680
5728
  }
5681
5729
  var MMI_PLUGIN_CACHE_CLEANUP_LABEL = "stale MMI plugin cache dirs (Claude/Codex)";
5682
- var MMI_PLUGIN_CACHE_CLEANUP_FIX = "run `mmi-cli doctor` to quarantine stale MMI-only plugin cache dirs, then reload the affected agent surface";
5730
+ var MMI_PLUGIN_CACHE_CLEANUP_FIX = "run `mmi-cli doctor` (or `mmi-cli doctor --apply --json` for machine callers) to quarantine stale MMI-only plugin cache dirs, then reload the affected agent surface";
5683
5731
  function normalizeVersion(v) {
5684
5732
  return v?.trim().replace(/^v/, "");
5685
5733
  }
@@ -5714,7 +5762,7 @@ function buildMmiPluginCacheCleanupCheck(input) {
5714
5762
  ...base,
5715
5763
  ok: false,
5716
5764
  leftovers,
5717
- cleanedCount: leftovers.length,
5765
+ plannedCount: leftovers.length,
5718
5766
  quarantinePlan: leftovers.map((entry) => ({
5719
5767
  from: entry.path,
5720
5768
  to: cachePathJoin(entry.root, ".mmi-quarantine", stamp, entry.name)
@@ -5768,6 +5816,16 @@ var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest re
5768
5816
  function isSemverVersion(v) {
5769
5817
  return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
5770
5818
  }
5819
+ function staleRecordCommand(surface) {
5820
+ return surface === "codex" ? "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi" : "claude plugin marketplace update mmi && claude plugin update mmi@mmi";
5821
+ }
5822
+ function staleSurfacesFix(stale, releasedVersion) {
5823
+ const parts = stale.map((s) => {
5824
+ const at = s.recordPath ? ` (${s.recordPath})` : "";
5825
+ return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}`;
5826
+ });
5827
+ return `stale installed-plugin record on ${stale.map((s) => s.surface).join(" + ")}: ${parts.join("; ")}`;
5828
+ }
5771
5829
  function buildInstalledPluginVersionCheck(input) {
5772
5830
  const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
5773
5831
  const base = {
@@ -5776,21 +5834,94 @@ function buildInstalledPluginVersionCheck(input) {
5776
5834
  fix: pluginRecoveryFix(input.surface),
5777
5835
  pluginId
5778
5836
  };
5779
- if (!input.isOrgRepo) return base;
5780
- const records = input.installed?.plugins?.[pluginId];
5781
- if (!Array.isArray(records) || records.length === 0) return base;
5782
- const installedVersion = bestRecord(records).version;
5783
- if (!isSemverVersion(installedVersion) || !isSemverVersion(input.releasedVersion)) return base;
5784
- if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
5785
- return { ...base, installedVersion, releasedVersion: input.releasedVersion };
5837
+ if (!input.isOrgRepo || !isSemverVersion(input.releasedVersion)) return base;
5838
+ const stale = [];
5839
+ let sawRecord = false;
5840
+ let currentVersion;
5841
+ for (const source of input.sources) {
5842
+ const records = source.installed?.plugins?.[pluginId];
5843
+ if (!Array.isArray(records) || records.length === 0) continue;
5844
+ sawRecord = true;
5845
+ const installedVersion = bestRecord(records).version;
5846
+ if (!isSemverVersion(installedVersion)) continue;
5847
+ if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
5848
+ currentVersion = currentVersion ?? installedVersion;
5849
+ } else {
5850
+ stale.push({ surface: source.surface, installedVersion, ...source.recordPath ? { recordPath: source.recordPath } : {} });
5851
+ }
5852
+ }
5853
+ if (!sawRecord) return base;
5854
+ if (stale.length === 0) {
5855
+ return { ...base, ...currentVersion ? { installedVersion: currentVersion } : {}, releasedVersion: input.releasedVersion };
5786
5856
  }
5787
5857
  return {
5788
5858
  ...base,
5789
5859
  ok: false,
5790
- installedVersion,
5791
- releasedVersion: input.releasedVersion
5860
+ fix: staleSurfacesFix(stale, input.releasedVersion),
5861
+ installedVersion: stale[0].installedVersion,
5862
+ releasedVersion: input.releasedVersion,
5863
+ staleSurfaces: stale
5792
5864
  };
5793
5865
  }
5866
+ var CURSOR_PLUGIN_INSTALL_LABEL = "Cursor Team Marketplace plugin install";
5867
+ var CURSOR_MARKETPLACE_INSTALL_GUIDE = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/cursor-marketplace-install.md";
5868
+ var CURSOR_PLUGIN_JSON_REL = ".cursor-plugin/plugin.json";
5869
+ var CURSOR_HOOKS_JSON_REL = "hooks/hooks.json";
5870
+ function joinCachePath(root, ...parts) {
5871
+ const sep = root.includes("\\") ? "\\" : "/";
5872
+ return [root.replace(/[\\/]+$/, ""), ...parts].join(sep);
5873
+ }
5874
+ function cursorPluginInstallFix(input) {
5875
+ const logHint = "check %APPDATA%\\Cursor\\logs\\<session>\\window*\\exthost\\anysphere.cursor-agent-exec\\Cursor Plugins.*.log for `unable to get password from user`";
5876
+ const authSteps = "ensure GitHub SSH auth works for Cursor's background git (start ssh-agent, add your GitHub key, verify `ssh -T git@github.com`) \u2014 Cursor disables credential.helper during marketplace clone";
5877
+ const marketplaceRefresh = "in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace, enable MMI, then restart Cursor";
5878
+ const guide = `full recovery: ${CURSOR_MARKETPLACE_INSTALL_GUIDE}`;
5879
+ if (input.reason === "missing-cache") {
5880
+ return `${marketplaceRefresh}; ${authSteps}; ${guide}`;
5881
+ }
5882
+ const pin = input.pinName ?? "<commit-pin>";
5883
+ const cacheDir = joinCachePath(input.cacheRoot, pin);
5884
+ 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`;
5885
+ return `Cursor plugin cache at ${cacheDir} is empty or missing ${CURSOR_PLUGIN_JSON_REL} or ${CURSOR_HOOKS_JSON_REL} \u2014 ${marketplaceRefresh}; ${authSteps}; ${localSeed}; ${logHint}; ${guide}`;
5886
+ }
5887
+ function buildCursorPluginInstallCheck(input) {
5888
+ const base = {
5889
+ ok: true,
5890
+ label: CURSOR_PLUGIN_INSTALL_LABEL,
5891
+ fix: cursorPluginInstallFix({ reason: "missing-cache", cacheRoot: input.cacheRoot })
5892
+ };
5893
+ if (!input.isOrgRepo) return base;
5894
+ const shouldCheck = input.surface === "cursor" || input.cacheRootExists;
5895
+ if (!shouldCheck) return base;
5896
+ if (input.surface === "cursor" && input.pins.length === 0) {
5897
+ return {
5898
+ ...base,
5899
+ ok: false,
5900
+ cacheRoot: input.cacheRoot,
5901
+ pins: [],
5902
+ reason: "missing-cache",
5903
+ fix: cursorPluginInstallFix({ reason: "missing-cache", cacheRoot: input.cacheRoot, hubCheckout: input.hubCheckout })
5904
+ };
5905
+ }
5906
+ for (const pin of input.pins) {
5907
+ if (!pin.hasPluginJson || !pin.hasHooksJson || pin.isEmpty) {
5908
+ return {
5909
+ ...base,
5910
+ ok: false,
5911
+ cacheRoot: input.cacheRoot,
5912
+ pins: input.pins,
5913
+ reason: "incomplete-cache",
5914
+ fix: cursorPluginInstallFix({
5915
+ reason: "incomplete-cache",
5916
+ cacheRoot: input.cacheRoot,
5917
+ pinName: pin.name,
5918
+ hubCheckout: input.hubCheckout
5919
+ })
5920
+ };
5921
+ }
5922
+ }
5923
+ return { ...base, cacheRoot: input.cacheRoot, pins: input.pins };
5924
+ }
5794
5925
 
5795
5926
  // src/stage-runner.ts
5796
5927
  var import_node_child_process5 = require("node:child_process");
@@ -6060,9 +6191,9 @@ async function predictMergeConflicts(deps, ours, theirs) {
6060
6191
  return files;
6061
6192
  }
6062
6193
  }
6063
- async function mergeRcWithSpineResolution(deps) {
6194
+ async function mergeWithSpineResolution(deps, sourceRef, label, resolve) {
6064
6195
  try {
6065
- await deps.run("git", ["merge", "rc", "--no-edit"]);
6196
+ await deps.run("git", ["merge", sourceRef, "--no-edit"]);
6066
6197
  return;
6067
6198
  } catch {
6068
6199
  }
@@ -6071,10 +6202,10 @@ async function mergeRcWithSpineResolution(deps) {
6071
6202
  if (unmerged.length === 0 || nonSpine.length > 0) {
6072
6203
  await deps.run("git", ["merge", "--abort"]);
6073
6204
  throw new Error(
6074
- unmerged.length === 0 ? "rc -> main merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun" : `rc -> main merge conflicts on non-spine path(s): ${nonSpine.join(", ")} \u2014 merge aborted (the train is misaligned; reconcile main and rc via an approved alignment PR, then rerun release)`
6205
+ unmerged.length === 0 ? `${label} merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun` : `${label} merge conflicts on non-spine path(s): ${nonSpine.join(", ")} \u2014 merge aborted (the train is misaligned; reconcile the branches via an approved alignment PR, then rerun)`
6075
6206
  );
6076
6207
  }
6077
- await deps.run("git", ["checkout", "--theirs", "--", ...unmerged]);
6208
+ await deps.run("git", ["checkout", `--${resolve}`, "--", ...unmerged]);
6078
6209
  await deps.run("git", ["add", "--", ...unmerged]);
6079
6210
  await deps.run("git", ["commit", "--no-edit"]);
6080
6211
  }
@@ -6115,9 +6246,10 @@ var HUB_REPO2 = "mutmutco/MMI-Hub";
6115
6246
  var CORRELATE_ATTEMPTS = 5;
6116
6247
  var CORRELATE_DELAY_MS = 1500;
6117
6248
  var CORRELATE_SKEW_SLACK_MS = 1e4;
6118
- var TRAIN_REQUIRED_CHECKS = ["cli", "infra", "docs"];
6119
- var TRAIN_REQUIRED_CHECK_SET = new Set(TRAIN_REQUIRED_CHECKS);
6120
- var TRAIN_CHECKS_JQ = '[.check_runs[]|select(.name|test("^(cli|infra|docs)$"))|{(.name):.conclusion}]';
6249
+ var TRAIN_CHECK_RUNS_JQ = "[.check_runs[]|{name:.name,status:.status,conclusion:.conclusion}]";
6250
+ var TRAIN_COMMIT_STATUS_JQ = "[.statuses[]|{context:.context,state:.state}]";
6251
+ var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
6252
+ var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
6121
6253
  var TRAIN_CHECK_ATTEMPTS = 40;
6122
6254
  var TRAIN_CHECK_DELAY_MS = 15e3;
6123
6255
  async function correlateTenantRun(deps, since) {
@@ -6157,51 +6289,112 @@ async function watchTenantRun(deps, runId) {
6157
6289
  return "failure";
6158
6290
  }
6159
6291
  }
6160
- function parseTrainCheckConclusions(out) {
6292
+ function isNotFoundError(e) {
6293
+ const msg = `${e.message ?? e} ${String(e.stderr ?? "")}`;
6294
+ return /HTTP 404|Not Found|\(404\)/i.test(msg);
6295
+ }
6296
+ function parseStringArray(out, label) {
6161
6297
  const parsed = JSON.parse(out);
6162
- if (!Array.isArray(parsed)) throw new Error("check-runs response was not an array");
6163
- const conclusions = /* @__PURE__ */ new Map();
6164
- for (const row of parsed) {
6165
- if (row == null || typeof row !== "object") continue;
6166
- for (const [name, raw] of Object.entries(row)) {
6167
- if (!TRAIN_REQUIRED_CHECK_SET.has(name)) continue;
6168
- conclusions.set(name, raw == null ? null : String(raw));
6169
- }
6298
+ if (!Array.isArray(parsed)) throw new Error(`${label} response was not an array`);
6299
+ return parsed.filter((v) => typeof v === "string");
6300
+ }
6301
+ async function discoverRequiredCheckContexts(deps, ctx, branch) {
6302
+ const contexts = /* @__PURE__ */ new Set();
6303
+ try {
6304
+ const out = await deps.run("gh", ["api", `repos/${ctx.repo}/branches/${branch}/protection/required_status_checks`, "--jq", TRAIN_PROTECTION_CONTEXTS_JQ]);
6305
+ for (const c of parseStringArray(out, "branch protection required_status_checks")) contexts.add(c);
6306
+ } catch (e) {
6307
+ if (!isNotFoundError(e)) throw new Error(`could not read branch protection for ${ctx.repo}@${branch}: ${e.message ?? e}`);
6170
6308
  }
6171
- return conclusions;
6309
+ try {
6310
+ const out = await deps.run("gh", ["api", `repos/${ctx.repo}/rules/branches/${branch}`, "--jq", TRAIN_RULES_CONTEXTS_JQ]);
6311
+ for (const c of parseStringArray(out, "branch rules required_status_checks")) contexts.add(c);
6312
+ } catch (e) {
6313
+ if (!isNotFoundError(e)) throw new Error(`could not read branch rules for ${ctx.repo}@${branch}: ${e.message ?? e}`);
6314
+ }
6315
+ return [...contexts];
6172
6316
  }
6173
- function describeTrainChecks(conclusions) {
6174
- return TRAIN_REQUIRED_CHECKS.map((name) => `${name}=${conclusions.get(name) ?? "missing"}`).join(", ");
6317
+ function resolveContextState(context, checkRuns, statuses) {
6318
+ let sawFailure = false;
6319
+ for (const r of checkRuns) {
6320
+ if (r.name !== context) continue;
6321
+ if (r.status === "completed") {
6322
+ if (r.conclusion === "success") return "success";
6323
+ sawFailure = true;
6324
+ }
6325
+ }
6326
+ for (const s of statuses) {
6327
+ if (s.context !== context) continue;
6328
+ if (s.state === "success") return "success";
6329
+ if (s.state === "failure" || s.state === "error") sawFailure = true;
6330
+ }
6331
+ return sawFailure ? "failed" : "pending";
6175
6332
  }
6176
- async function waitForRequiredTrainChecks(deps, ctx, sha) {
6333
+ async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
6334
+ if (required.length === 0) {
6335
+ return "no required status checks configured on the target branch \u2014 check wait skipped (GitHub push gate is the backstop)";
6336
+ }
6177
6337
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6178
6338
  let lastStatus = "not checked";
6179
6339
  let lastError;
6180
6340
  for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
6181
6341
  if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
6182
- let conclusions;
6342
+ let checkRuns;
6343
+ let statuses;
6183
6344
  try {
6184
- const out = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/check-runs`, "--jq", TRAIN_CHECKS_JQ]);
6185
- conclusions = parseTrainCheckConclusions(out);
6345
+ const runsOut = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/check-runs`, "--jq", TRAIN_CHECK_RUNS_JQ]);
6346
+ const parsedRuns = JSON.parse(runsOut);
6347
+ if (!Array.isArray(parsedRuns)) throw new Error("check-runs response was not an array");
6348
+ checkRuns = parsedRuns;
6349
+ const statusOut = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/status`, "--jq", TRAIN_COMMIT_STATUS_JQ]);
6350
+ const parsedStatuses = JSON.parse(statusOut);
6351
+ if (!Array.isArray(parsedStatuses)) throw new Error("commit status response was not an array");
6352
+ statuses = parsedStatuses;
6186
6353
  lastError = void 0;
6187
6354
  } catch (e) {
6188
6355
  lastError = e.message || String(e);
6189
6356
  continue;
6190
6357
  }
6191
- lastStatus = describeTrainChecks(conclusions);
6192
- const failed = TRAIN_REQUIRED_CHECKS.filter((name) => {
6193
- const conclusion = conclusions.get(name);
6194
- return conclusion != null && conclusion !== "success";
6195
- });
6358
+ const states = required.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
6359
+ lastStatus = states.map(([c, s]) => `${c}=${s}`).join(", ");
6360
+ const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
6196
6361
  if (failed.length > 0) {
6197
6362
  throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
6198
6363
  }
6199
- if (TRAIN_REQUIRED_CHECKS.every((name) => conclusions.get(name) === "success")) return;
6364
+ if (states.every(([, s]) => s === "success")) {
6365
+ return `required checks passed: ${required.join(", ")}`;
6366
+ }
6200
6367
  }
6201
6368
  throw new Error(
6202
6369
  `timed out waiting for required train checks on ${sha}: ${lastError ? `last error: ${lastError}` : lastStatus}`
6203
6370
  );
6204
6371
  }
6372
+ async function ensureTagPushed(deps, tag, sha) {
6373
+ const remoteOut = await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]);
6374
+ const remoteSha = clean(remoteOut).split(/\s+/)[0] || "";
6375
+ let localSha = "";
6376
+ try {
6377
+ localSha = clean(await deps.run("git", ["rev-parse", "--verify", `refs/tags/${tag}^{commit}`]));
6378
+ } catch {
6379
+ }
6380
+ if (remoteSha) {
6381
+ if (remoteSha !== sha) {
6382
+ throw new Error(
6383
+ `tag ${tag} already exists on origin at ${remoteSha}, but this run intends ${sha} \u2014 refusing to touch a pushed tag. Repair manually: either release the existing tagged commit (reset the local branch to ${remoteSha} and rerun), or mint the next candidate version so a fresh tag is used. Never force-move or delete a pushed tag.`
6384
+ );
6385
+ }
6386
+ if (localSha && localSha !== sha) {
6387
+ throw new Error(`local tag ${tag} points at ${localSha} but origin has it at ${sha} \u2014 delete the stale local tag (git tag -d ${tag}) and rerun`);
6388
+ }
6389
+ return `tag ${tag} already on origin at ${sha.slice(0, 7)} \u2014 resumed without re-pushing`;
6390
+ }
6391
+ if (localSha && localSha !== sha) {
6392
+ throw new Error(`local tag ${tag} already points at ${localSha}, not the intended ${sha} \u2014 delete the stale local tag (git tag -d ${tag}) and rerun`);
6393
+ }
6394
+ if (!localSha) await deps.run("git", ["tag", tag, sha]);
6395
+ await deps.run("git", ["push", "origin", tag]);
6396
+ return `tag ${tag} pushed at ${sha.slice(0, 7)}`;
6397
+ }
6205
6398
  async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
6206
6399
  if (model === "tenant-container") {
6207
6400
  const since = (deps.now ?? Date.now)();
@@ -6267,13 +6460,13 @@ async function runTrainApply(command, deps, options = {}) {
6267
6460
  await deps.run("git", ["checkout", "rc"]);
6268
6461
  await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
6269
6462
  await deps.run("git", ["merge", "development", "--no-edit"]);
6270
- await deps.run("git", ["tag", tag2]);
6271
- await deps.run("git", ["push", "origin", tag2]);
6272
6463
  const rcSha = requireValue(clean(await deps.run("git", ["rev-parse", "rc"])), "rc sha");
6273
- await waitForRequiredTrainChecks(deps, ctx, rcSha);
6464
+ await ensureTagPushed(deps, tag2, rcSha);
6465
+ const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "rc");
6466
+ const checks2 = await waitForRequiredTrainChecks(deps, ctx, rcSha, requiredChecks2);
6274
6467
  await deps.run("git", ["push", "origin", "rc"]);
6275
6468
  const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
6276
- return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
6469
+ return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, checks: checks2, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
6277
6470
  }
6278
6471
  await requireBranch(deps, "rc");
6279
6472
  ensurePositiveCount(
@@ -6294,14 +6487,14 @@ async function runTrainApply(command, deps, options = {}) {
6294
6487
  if (predicted.length === 0) {
6295
6488
  await deps.run("git", ["merge", "rc", "--no-edit"]);
6296
6489
  } else {
6297
- await mergeRcWithSpineResolution(deps);
6490
+ await mergeWithSpineResolution(deps, "rc", "rc -> main", "theirs");
6298
6491
  }
6299
6492
  const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
6300
6493
  await verifyHubDistributionVersion(deps, deployModel, tag);
6301
- await deps.run("git", ["tag", tag]);
6302
- await deps.run("git", ["push", "origin", tag]);
6303
6494
  const releaseSha = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
6304
- await waitForRequiredTrainChecks(deps, ctx, releaseSha);
6495
+ await ensureTagPushed(deps, tag, releaseSha);
6496
+ const requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
6497
+ const checks = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks);
6305
6498
  await deps.run("git", ["push", "origin", "main"]);
6306
6499
  await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
6307
6500
  const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
@@ -6318,6 +6511,7 @@ async function runTrainApply(command, deps, options = {}) {
6318
6511
  tag,
6319
6512
  deployModel,
6320
6513
  promoted: true,
6514
+ checks,
6321
6515
  dispatch: d.note,
6322
6516
  runId: d.runId,
6323
6517
  runUrl: d.runUrl,
@@ -6388,6 +6582,326 @@ async function runTenantRedeploy(deps, options) {
6388
6582
  return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
6389
6583
  }
6390
6584
 
6585
+ // src/hotfix-apply.ts
6586
+ var HOTFIX_RELEASE_WORKFLOWS = ["deploy.yml", "publish.yml"];
6587
+ var HOTFIX_RUN_FIND_ATTEMPTS = 10;
6588
+ var HOTFIX_RUN_FIND_DELAY_MS = 15e3;
6589
+ function clean2(out) {
6590
+ return out.trim();
6591
+ }
6592
+ function sleeper(deps) {
6593
+ return deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6594
+ }
6595
+ function normalizeHotfixVersion(input) {
6596
+ const m = /^v?(\d+\.\d+\.\d+)$/.exec(input.trim());
6597
+ if (!m) throw new Error(`hotfix version must be vX.Y.Z (PATCH release), got "${input}"`);
6598
+ return { tag: `v${m[1]}`, version: m[1] };
6599
+ }
6600
+ async function deriveHotfixVersion(deps) {
6601
+ const out = await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--merged", "origin/main", "--sort=-v:refname"]);
6602
+ const baseTag = out.split("\n").map((s) => s.trim()).find((t) => /^v\d+\.\d+\.\d+$/.test(t));
6603
+ if (!baseTag) throw new Error("no vX.Y.Z release tag found on origin/main \u2014 cannot derive the hotfix PATCH version");
6604
+ const [major, minor, patch] = baseTag.slice(1).split(".").map(Number);
6605
+ const version = `${major}.${minor}.${patch + 1}`;
6606
+ return { tag: `v${version}`, version, baseTag };
6607
+ }
6608
+ function hotfixBranch(tag) {
6609
+ return `hotfix/${tag}`;
6610
+ }
6611
+ async function findHotfixPr(deps, ctx, tag) {
6612
+ const out = await deps.run("gh", [
6613
+ "pr",
6614
+ "list",
6615
+ "--repo",
6616
+ ctx.repo,
6617
+ "--head",
6618
+ hotfixBranch(tag),
6619
+ "--base",
6620
+ "main",
6621
+ "--state",
6622
+ "all",
6623
+ "--limit",
6624
+ "1",
6625
+ "--json",
6626
+ "number,state,url,mergeCommit"
6627
+ ]);
6628
+ const rows = JSON.parse(out || "[]");
6629
+ return rows[0] ?? null;
6630
+ }
6631
+ async function resolveHotfixSource(deps, ctx, from) {
6632
+ const prMatch = /^#?(\d+)$/.exec(from.trim());
6633
+ if (prMatch) {
6634
+ const num = prMatch[1];
6635
+ const out = await deps.run("gh", ["pr", "view", num, "--repo", ctx.repo, "--json", "state,baseRefName,mergeCommit"]);
6636
+ const pr2 = JSON.parse(out);
6637
+ if (pr2.state !== "MERGED") throw new Error(`PR #${num} is ${pr2.state ?? "unknown"}, not MERGED \u2014 hotfix start cherry-picks an already-merged development fix`);
6638
+ if (pr2.baseRefName !== "development") throw new Error(`PR #${num} merged into ${pr2.baseRefName ?? "unknown"}, not development \u2014 name the commit SHA explicitly if this is intentional`);
6639
+ const sha2 = pr2.mergeCommit?.oid;
6640
+ if (!sha2) throw new Error(`PR #${num} has no merge commit recorded \u2014 name the commit SHA explicitly`);
6641
+ return { sha: sha2, label: `PR #${num} (${sha2.slice(0, 7)})` };
6642
+ }
6643
+ const sha = clean2(await deps.run("git", ["rev-parse", "--verify", `${from}^{commit}`]));
6644
+ if (!sha) throw new Error(`could not resolve commit ${from}`);
6645
+ return { sha, label: sha.slice(0, 7) };
6646
+ }
6647
+ async function runHotfixStart(deps, options) {
6648
+ const ctx = await buildTrainApplyContext(deps);
6649
+ const status = await deps.run("git", ["status", "--porcelain"]);
6650
+ if (status.trim()) throw new Error("working tree must be clean before hotfix start");
6651
+ await deps.run("git", ["fetch", "origin", "--tags"]);
6652
+ const { tag, version } = await deriveHotfixVersion(deps);
6653
+ const branch = hotfixBranch(tag);
6654
+ const notes = [];
6655
+ const existingPr = await findHotfixPr(deps, ctx, tag);
6656
+ if (existingPr) {
6657
+ return {
6658
+ ...ctx,
6659
+ command: "hotfix-start",
6660
+ tag,
6661
+ version,
6662
+ branch,
6663
+ source: options.from,
6664
+ prUrl: existingPr.url,
6665
+ reused: true,
6666
+ notes: [`hotfix PR for ${tag} already exists (#${existingPr.number}, ${existingPr.state}) \u2014 reused; next: merge it, then mmi-cli hotfix release ${tag}`]
6667
+ };
6668
+ }
6669
+ const { sha, label } = await resolveHotfixSource(deps, ctx, options.from);
6670
+ const remoteBranch = clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
6671
+ if (remoteBranch) {
6672
+ await deps.run("git", ["checkout", branch]);
6673
+ await deps.run("git", ["pull", "--ff-only", "origin", branch]);
6674
+ notes.push(`branch ${branch} already on origin \u2014 reused (cherry-pick/bump assumed present; PR step resumes)`);
6675
+ } else {
6676
+ await deps.run("git", ["checkout", "-B", branch, "origin/main"]);
6677
+ try {
6678
+ await deps.run("git", ["cherry-pick", "-x", sha]);
6679
+ } catch (e) {
6680
+ await deps.run("git", ["cherry-pick", "--abort"]);
6681
+ throw new Error(`cherry-pick of ${label} onto ${branch} conflicted \u2014 aborted; resolve by hand on a manual hotfix branch, keeping the -x trailer (${e.message ?? e})`);
6682
+ }
6683
+ notes.push(`cherry-picked ${label} onto ${branch} (from origin/main, -x trailer recorded)`);
6684
+ await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version]);
6685
+ const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
6686
+ await deps.run("git", ["add", "--", ...changedFiles]);
6687
+ const staged = await deps.run("git", ["diff", "--cached", "--name-only"]);
6688
+ if (staged.trim()) {
6689
+ await deps.run("git", ["commit", "-m", `hotfix ${tag}: lock plugin set + @mutmutco/cli distribution to ${version}`]);
6690
+ notes.push(`distribution prepared + committed for ${version} (${changedFiles.length} locked paths)`);
6691
+ } else {
6692
+ notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
6693
+ }
6694
+ await deps.run("git", ["push", "-u", "origin", branch]);
6695
+ }
6696
+ const prUrl = clean2(await deps.run("gh", [
6697
+ "pr",
6698
+ "create",
6699
+ "--repo",
6700
+ ctx.repo,
6701
+ "--base",
6702
+ "main",
6703
+ "--head",
6704
+ branch,
6705
+ "--title",
6706
+ `[hotfix] ${tag}`,
6707
+ "--body",
6708
+ `Hotfix ${tag}: cherry-pick of ${label} onto origin/main with the locked distribution bump.
6709
+
6710
+ Merge this PR (human-initiated), then run \`mmi-cli hotfix release ${tag}\`.`
6711
+ ]));
6712
+ notes.push(`opened hotfix PR ${prUrl} \u2014 merge it (human-initiated), then: mmi-cli hotfix release ${tag}`);
6713
+ return { ...ctx, command: "hotfix-start", tag, version, branch, source: label, prUrl, reused: Boolean(remoteBranch), notes };
6714
+ }
6715
+ async function watchReleaseRun(deps, ctx, workflow, sha) {
6716
+ const sleep = sleeper(deps);
6717
+ for (let attempt = 0; attempt < HOTFIX_RUN_FIND_ATTEMPTS; attempt++) {
6718
+ if (attempt > 0) await sleep(HOTFIX_RUN_FIND_DELAY_MS);
6719
+ let rows;
6720
+ try {
6721
+ const out = await deps.run("gh", [
6722
+ "run",
6723
+ "list",
6724
+ "--repo",
6725
+ ctx.repo,
6726
+ "--workflow",
6727
+ workflow,
6728
+ "--event",
6729
+ "release",
6730
+ "--limit",
6731
+ "10",
6732
+ "--json",
6733
+ "databaseId,url,headSha,status,conclusion"
6734
+ ]);
6735
+ rows = JSON.parse(out);
6736
+ } catch {
6737
+ continue;
6738
+ }
6739
+ const run = rows.find((r) => r.headSha === sha && typeof r.databaseId === "number");
6740
+ if (!run) continue;
6741
+ if (run.status === "completed") return { workflow, url: run.url, conclusion: run.conclusion ?? "unknown" };
6742
+ try {
6743
+ await deps.run("gh", ["run", "watch", String(run.databaseId), "--repo", ctx.repo, "--exit-status"]);
6744
+ return { workflow, url: run.url, conclusion: "success" };
6745
+ } catch {
6746
+ return { workflow, url: run.url, conclusion: "failure" };
6747
+ }
6748
+ }
6749
+ return { workflow, conclusion: "not-found" };
6750
+ }
6751
+ async function runHotfixRelease(deps, versionInput) {
6752
+ const ctx = await buildTrainApplyContext(deps);
6753
+ const { tag, version } = normalizeHotfixVersion(versionInput);
6754
+ const status = await deps.run("git", ["status", "--porcelain"]);
6755
+ if (status.trim()) throw new Error("working tree must be clean before hotfix release");
6756
+ await deps.run("git", ["fetch", "origin", "--tags"]);
6757
+ const pr2 = await findHotfixPr(deps, ctx, tag);
6758
+ if (!pr2) throw new Error(`no hotfix PR found for ${tag} (head ${hotfixBranch(tag)}, base main) \u2014 run mmi-cli hotfix start first`);
6759
+ if (pr2.state !== "MERGED") throw new Error(`hotfix PR #${pr2.number} for ${tag} is ${pr2.state}, not MERGED \u2014 merge it (human-initiated), then rerun`);
6760
+ const mergedSha = pr2.mergeCommit?.oid ?? "";
6761
+ if (!mergedSha) throw new Error(`hotfix PR #${pr2.number} reports no merge commit \u2014 cannot pin the release SHA`);
6762
+ await deps.run("git", ["merge-base", "--is-ancestor", mergedSha, "origin/main"]).catch(() => {
6763
+ throw new Error(`merged hotfix SHA ${mergedSha.slice(0, 7)} is not on origin/main \u2014 refusing to tag`);
6764
+ });
6765
+ const required = await discoverRequiredCheckContexts(deps, ctx, "main");
6766
+ const checks = await waitForRequiredTrainChecks(deps, ctx, mergedSha, required);
6767
+ const tagNote = await ensureTagPushed(deps, tag, mergedSha);
6768
+ let releaseNote;
6769
+ let releaseExists = false;
6770
+ try {
6771
+ await deps.run("gh", ["release", "view", tag, "--repo", ctx.repo, "--json", "tagName"]);
6772
+ releaseExists = true;
6773
+ } catch {
6774
+ }
6775
+ if (releaseExists) {
6776
+ releaseNote = `Release ${tag} already exists \u2014 resumed without recreating`;
6777
+ } else {
6778
+ const tagCommit = clean2(await deps.run("git", ["rev-parse", `${tag}^{commit}`]));
6779
+ await deps.run("gh", ["release", "create", tag, "--repo", ctx.repo, "--target", tagCommit, "--generate-notes", "--latest"]);
6780
+ releaseNote = `Release ${tag} created (target ${tagCommit.slice(0, 7)})`;
6781
+ }
6782
+ const runs = [];
6783
+ for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
6784
+ runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
6785
+ }
6786
+ const previousRef = clean2(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
6787
+ let verifyNote;
6788
+ const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
6789
+ try {
6790
+ await deps.run("git", ["-c", "advice.detachedHead=false", "checkout", tag]);
6791
+ const verifyArgs = ["scripts/release-distribution.mjs", "verify", version, ...publishSucceeded ? [] : ["--skip-npm-view"]];
6792
+ await deps.run("node", verifyArgs);
6793
+ verifyNote = `distribution verified at ${tag}${publishSucceeded ? " (npm included)" : " (npm view skipped \u2014 publish run not confirmed)"}`;
6794
+ } finally {
6795
+ if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]);
6796
+ }
6797
+ return { ...ctx, command: "hotfix-release", tag, mergedSha, checks, tagNote, releaseNote, runs, verifyNote };
6798
+ }
6799
+ function versionAtLeast(actual, wanted) {
6800
+ const pa = actual.split(".").map(Number);
6801
+ const pw = wanted.split(".").map(Number);
6802
+ if (pa.length < 3 || pa.some(Number.isNaN) || pw.length < 3 || pw.some(Number.isNaN)) return false;
6803
+ for (let i = 0; i < 3; i += 1) {
6804
+ if (pa[i] !== pw[i]) return pa[i] > pw[i];
6805
+ }
6806
+ return true;
6807
+ }
6808
+ function deriveHotfixState(f) {
6809
+ if (!f.branchExists && !f.pr && !f.tagPushed && !f.releaseExists) {
6810
+ return { state: "not-started", next: `mmi-cli hotfix start --from <pr#|sha> (would mint ${f.tag})` };
6811
+ }
6812
+ if (!f.pr) {
6813
+ return { state: "branch-pushed (no PR)", next: `mmi-cli hotfix start --from <pr#|sha> (resumes at the PR step for ${f.tag})` };
6814
+ }
6815
+ if (f.pr.state === "OPEN") {
6816
+ return { state: "pr-open", next: `merge hotfix PR #${f.pr.number} (human-initiated), then mmi-cli hotfix release ${f.tag}` };
6817
+ }
6818
+ if (f.pr.state !== "MERGED") {
6819
+ return { state: `pr-${(f.pr.state ?? "unknown").toLowerCase()}`, next: `hotfix PR #${f.pr.number} is ${f.pr.state} \u2014 restart with mmi-cli hotfix start if the fix is still needed` };
6820
+ }
6821
+ if (!f.tagPushed || !f.releaseExists) {
6822
+ return { state: "pr-merged (not released)", next: `mmi-cli hotfix release ${f.tag}` };
6823
+ }
6824
+ if (!f.devDistribution.aligned) {
6825
+ return {
6826
+ state: `UNFINISHED \u2014 released but development distribution manifests behind (dev ${f.devDistribution.version} < ${f.version})`,
6827
+ next: `forward-bump development (never a back-merge): branch from origin/development, node scripts/release-distribution.mjs prepare ${f.version}, land by PR (/hotfix Step 5)`
6828
+ };
6829
+ }
6830
+ return { state: "complete", next: "nothing \u2014 pipeline complete (rc absorbs the fix at the next /rcand; /release guards coverage)" };
6831
+ }
6832
+ async function runHotfixStatus(deps, versionInput) {
6833
+ const ctx = await buildTrainApplyContext(deps);
6834
+ await deps.run("git", ["fetch", "origin", "--tags"]);
6835
+ let tag;
6836
+ let version;
6837
+ if (versionInput) {
6838
+ ({ tag, version } = normalizeHotfixVersion(versionInput));
6839
+ } else {
6840
+ const latest = await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--merged", "origin/main", "--sort=-v:refname"]).then((out) => out.split("\n").map((s) => s.trim()).find((t) => /^v\d+\.\d+\.\d+$/.test(t)));
6841
+ if (latest) {
6842
+ const latestFacts = await gatherHotfixFacts(deps, ctx, latest, latest.slice(1));
6843
+ const latestDerived = deriveHotfixState(latestFacts);
6844
+ if (latestDerived.state !== "complete") {
6845
+ return { ...ctx, command: "hotfix-status", ...latestFacts, ...latestDerived };
6846
+ }
6847
+ }
6848
+ ({ tag, version } = await deriveHotfixVersion(deps));
6849
+ }
6850
+ const facts = await gatherHotfixFacts(deps, ctx, tag, version);
6851
+ return { ...ctx, command: "hotfix-status", ...facts, ...deriveHotfixState(facts) };
6852
+ }
6853
+ async function gatherHotfixFacts(deps, ctx, tag, version) {
6854
+ const branchExists = Boolean(clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${hotfixBranch(tag)}`])));
6855
+ const pr2 = await findHotfixPr(deps, ctx, tag);
6856
+ const remoteTag = clean2(await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]));
6857
+ const tagPushed = Boolean(remoteTag);
6858
+ const tagSha = remoteTag.split(/\s+/)[0] || "";
6859
+ let releaseExists = false;
6860
+ try {
6861
+ await deps.run("gh", ["release", "view", tag, "--repo", ctx.repo, "--json", "tagName"]);
6862
+ releaseExists = true;
6863
+ } catch {
6864
+ }
6865
+ const runs = [];
6866
+ if (releaseExists && tagSha) {
6867
+ for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
6868
+ try {
6869
+ const out = await deps.run("gh", [
6870
+ "run",
6871
+ "list",
6872
+ "--repo",
6873
+ ctx.repo,
6874
+ "--workflow",
6875
+ workflow,
6876
+ "--event",
6877
+ "release",
6878
+ "--limit",
6879
+ "10",
6880
+ "--json",
6881
+ "databaseId,url,headSha,status,conclusion"
6882
+ ]);
6883
+ const run = JSON.parse(out).find((r) => r.headSha === tagSha);
6884
+ runs.push(run ? { workflow, url: run.url, conclusion: run.status === "completed" ? run.conclusion ?? "unknown" : run.status ?? "unknown" } : { workflow, conclusion: "not-found" });
6885
+ } catch {
6886
+ runs.push({ workflow, conclusion: "unknown" });
6887
+ }
6888
+ }
6889
+ }
6890
+ const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(clean2, () => "unknown");
6891
+ const devVersion = await deps.run("git", ["show", "origin/development:cli/package.json"]).then(
6892
+ (out) => {
6893
+ try {
6894
+ return JSON.parse(out).version ?? "unknown";
6895
+ } catch {
6896
+ return "unknown";
6897
+ }
6898
+ },
6899
+ () => "unknown"
6900
+ );
6901
+ const devDistribution = { version: devVersion, aligned: versionAtLeast(devVersion, version) };
6902
+ return { tag, version, branchExists, pr: pr2 ? { number: pr2.number, state: pr2.state, url: pr2.url } : null, tagPushed, releaseExists, runs, npmVersion, devDistribution };
6903
+ }
6904
+
6391
6905
  // src/port-registry.ts
6392
6906
  var import_node_fs4 = require("node:fs");
6393
6907
 
@@ -7738,8 +8252,8 @@ function resolveKbSource(rawBase) {
7738
8252
  return { owner: m[1], repo: m[2], ref: m[3] };
7739
8253
  }
7740
8254
  function buildKbGetArgs(src, path2) {
7741
- const clean2 = path2.replace(/^\/+/, "");
7742
- return ["api", `repos/${src.owner}/${src.repo}/contents/${clean2}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
8255
+ const clean3 = path2.replace(/^\/+/, "");
8256
+ return ["api", `repos/${src.owner}/${src.repo}/contents/${clean3}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
7743
8257
  }
7744
8258
  function buildKbTreeArgs(src) {
7745
8259
  return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
@@ -9827,17 +10341,40 @@ async function runBoardRead(o) {
9827
10341
  }
9828
10342
  var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
9829
10343
  board.command("read", { isDefault: true }).description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--allow-partial", "return partial board results when later page/detail reads fail").action((o) => runBoardRead(o));
9830
- board.command("claim <issue>").description("assign a Todo issue and move its Project v2 Status to In Progress").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails").action(async (issueRef, o) => {
10344
+ board.command("claim <issues...>").description("assign Todo issues and move their Project v2 Status to In Progress (one or more refs)").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails").action(async (issueRefs, o) => {
10345
+ if (issueRefs.length === 1) {
10346
+ const issueRef = issueRefs[0];
10347
+ try {
10348
+ const result = await claimBoardIssue({
10349
+ config: await loadConfigForBoardSelector(issueRef, o.repo),
10350
+ selector: issueRef,
10351
+ repo: o.repo,
10352
+ assignee: o.for,
10353
+ allowPartial: o.allowPartial
10354
+ });
10355
+ if (o.json) return console.log(JSON.stringify(result));
10356
+ console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
10357
+ } catch (e) {
10358
+ fail(`board claim failed: ${e.message}`);
10359
+ }
10360
+ return;
10361
+ }
9831
10362
  try {
9832
- const result = await claimBoardIssue({
9833
- config: await loadConfigForBoardSelector(issueRef, o.repo),
9834
- selector: issueRef,
10363
+ const bulk = await claimBoardIssues({
10364
+ config: await loadConfigForBoardSelector(issueRefs[0], o.repo),
10365
+ selectors: issueRefs,
9835
10366
  repo: o.repo,
9836
10367
  assignee: o.for,
9837
10368
  allowPartial: o.allowPartial
9838
10369
  });
9839
- if (o.json) return console.log(JSON.stringify(result));
9840
- console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
10370
+ if (o.json) {
10371
+ console.log(JSON.stringify(bulk.results));
10372
+ } else {
10373
+ for (const result of bulk.results) {
10374
+ console.log(result.claimed ? result.partial ? `Partially claimed ${result.ref}: ${result.warning}` : `Claimed ${result.ref} - In Progress` : `Skipped ${result.ref}: ${result.reason}`);
10375
+ }
10376
+ }
10377
+ if (bulk.failed > 0) process.exitCode = 1;
9841
10378
  } catch (e) {
9842
10379
  fail(`board claim failed: ${e.message}`);
9843
10380
  }
@@ -10078,10 +10615,11 @@ program2.command("stage-live").description("explain that remote rc/live environm
10078
10615
  });
10079
10616
  var GH_TRAIN_TIMEOUT_MS = 3e4;
10080
10617
  var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
10618
+ var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
10081
10619
  function trainApplyDeps() {
10082
10620
  return {
10083
10621
  run: async (file, args) => {
10084
- const timeout = file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
10622
+ const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
10085
10623
  return (await execFileP4(file, args, { timeout })).stdout;
10086
10624
  },
10087
10625
  runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
@@ -10106,15 +10644,14 @@ function renderTrainApply(commandName, r) {
10106
10644
  function renderTenantRedeploy(r) {
10107
10645
  return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
10108
10646
  }
10109
- for (const commandName of ["rcand", "release", "hotfix"]) {
10110
- program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the dispatched tenant-deploy.yml run and report its outcome (tenant-container)").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
10647
+ for (const commandName of ["rcand", "release"]) {
10648
+ program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the dispatched tenant-deploy.yml run and report its outcome (tenant-container)").option("--apply", "execute the guarded master-only train after explicit approval").action(async (o) => {
10111
10649
  try {
10112
10650
  await requireFreshTrainCli(commandName);
10113
10651
  } catch (e) {
10114
10652
  return fail(`${commandName}: ${e.message}`);
10115
10653
  }
10116
10654
  if (o.apply) {
10117
- if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
10118
10655
  try {
10119
10656
  const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch });
10120
10657
  return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
@@ -10126,6 +10663,51 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
10126
10663
  console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
10127
10664
  });
10128
10665
  }
10666
+ function renderHotfixStart(r) {
10667
+ return [`mmi-cli hotfix start: ${r.tag} (${r.branch}, from ${r.source})${r.reused ? " [reused]" : ""}`, ...r.notes.map((n) => ` - ${n}`)].join("\n");
10668
+ }
10669
+ function renderHotfixRelease(r) {
10670
+ return [
10671
+ `mmi-cli hotfix release: ${r.tag} at ${r.mergedSha.slice(0, 7)} on ${r.repo}`,
10672
+ ` - checks: ${r.checks}`,
10673
+ ` - ${r.tagNote}`,
10674
+ ` - ${r.releaseNote}`,
10675
+ ...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
10676
+ ` - ${r.verifyNote}`,
10677
+ ` - next: mmi-cli hotfix status ${r.tag} (no back-merge \u2014 development already has the fix; align its distribution manifests by PR if status says behind)`
10678
+ ].join("\n");
10679
+ }
10680
+ function renderHotfixStatus(r) {
10681
+ return [
10682
+ `mmi-cli hotfix status: ${r.tag} on ${r.repo} \u2014 ${r.state}`,
10683
+ ` - branch: ${r.branchExists ? "pushed" : "absent"} \xB7 PR: ${r.pr ? `#${r.pr.number} ${r.pr.state}` : "none"} \xB7 tag: ${r.tagPushed ? "pushed" : "absent"} \xB7 Release: ${r.releaseExists ? "exists" : "absent"}`,
10684
+ ...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
10685
+ ` - npm @mutmutco/cli: ${r.npmVersion} \xB7 development manifests: ${r.devDistribution.version}${r.devDistribution.aligned ? " (aligned)" : " (behind)"}`,
10686
+ ` - next: ${r.next}`
10687
+ ].join("\n");
10688
+ }
10689
+ async function runHotfixSub(sub, body, json, render) {
10690
+ try {
10691
+ await requireFreshTrainCli("hotfix");
10692
+ const result = await body();
10693
+ printLine(json ? JSON.stringify(result, null, 2) : render(result));
10694
+ } catch (e) {
10695
+ fail(`hotfix ${sub}: ${e.message}`);
10696
+ }
10697
+ }
10698
+ var hotfixCmd = program2.command("hotfix").description("stepwise hotfix orchestrator: start \u2192 release, with status (bare command prints the dry-run plan; no back-merge \u2014 #839)").option("--json", "machine-readable output").option("--apply", "not a verb; use the hotfix subcommands (start/release/status)").action(async (o) => {
10699
+ try {
10700
+ await requireFreshTrainCli("hotfix");
10701
+ } catch (e) {
10702
+ return fail(`hotfix: ${e.message}`);
10703
+ }
10704
+ if (o.apply) return fail("hotfix: use the stepwise subcommands \u2014 mmi-cli hotfix start --from <pr#|sha> \xB7 release <vX.Y.Z> \xB7 status [vX.Y.Z]");
10705
+ const steps = trainPlan("hotfix");
10706
+ console.log(o.json ? JSON.stringify({ command: "hotfix", steps }, null, 2) : renderSteps("mmi-cli hotfix: dry-run plan", steps));
10707
+ });
10708
+ hotfixCmd.command("start").description("cherry-pick a merged development PR (or SHA) onto hotfix/vX.Y.Z from origin/main, bump the distribution, open the main-base PR").requiredOption("--from <pr#|sha>", "merged development PR number or commit SHA to cherry-pick").option("--json", "machine-readable output").action(async (o) => runHotfixSub("start", () => runHotfixStart(trainApplyDeps(), { from: o.from }), o.json, renderHotfixStart));
10709
+ hotfixCmd.command("release <version>").description("after the hotfix PR is merged + checks green: tag, GitHub Release, watch deploy/publish, verify distribution (idempotent)").option("--json", "machine-readable output").action(async (version, o) => runHotfixSub("release", () => runHotfixRelease(trainApplyDeps(), version), o.json, renderHotfixRelease));
10710
+ hotfixCmd.command("status [version]").description("derive the full hotfix pipeline state from live git/gh reads and name the exact next subcommand").option("--json", "machine-readable output").action(async (version, o) => runHotfixSub("status", () => runHotfixStatus(trainApplyDeps(), version), o.json, renderHotfixStatus));
10129
10711
  var bootstrap = program2.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").option("--class <class>", "deployable | content", "deployable").option("--json", "machine-readable output").option("--apply", "reserved for future bootstrap execution after explicit master-admin approval").action((o) => {
10130
10712
  if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
10131
10713
  if (o.apply) return fail("bootstrap: execution is not implemented yet; use the dry-run plan and the existing /bootstrap skill");
@@ -10341,6 +10923,16 @@ function readInstalledPlugins() {
10341
10923
  return null;
10342
10924
  }
10343
10925
  }
10926
+ function installedPluginSources() {
10927
+ return ["claude", "codex"].map((surface) => {
10928
+ const recordPath = (0, import_node_path7.join)((0, import_node_os3.homedir)(), `.${surface}`, "plugins", "installed_plugins.json");
10929
+ try {
10930
+ return { surface, installed: JSON.parse((0, import_node_fs6.readFileSync)(recordPath, "utf8")), recordPath };
10931
+ } catch {
10932
+ return { surface, installed: null, recordPath };
10933
+ }
10934
+ });
10935
+ }
10344
10936
  function readClaudeSettings() {
10345
10937
  try {
10346
10938
  return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
@@ -10387,6 +10979,38 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
10387
10979
  return false;
10388
10980
  }
10389
10981
  }
10982
+ function cursorPluginCacheRoot() {
10983
+ return (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".cursor", "plugins", "cache", "mmi", "mmi");
10984
+ }
10985
+ function cursorPluginCachePinSnapshots() {
10986
+ const root = cursorPluginCacheRoot();
10987
+ try {
10988
+ return (0, import_node_fs6.readdirSync)(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => {
10989
+ const path2 = (0, import_node_path7.join)(root, entry.name);
10990
+ const pluginJson = (0, import_node_path7.join)(path2, ".cursor-plugin", "plugin.json");
10991
+ const hooksJson = (0, import_node_path7.join)(path2, "hooks", "hooks.json");
10992
+ let isEmpty = true;
10993
+ try {
10994
+ isEmpty = (0, import_node_fs6.readdirSync)(path2).length === 0;
10995
+ } catch {
10996
+ isEmpty = true;
10997
+ }
10998
+ return {
10999
+ name: entry.name,
11000
+ path: path2,
11001
+ hasPluginJson: (0, import_node_fs6.existsSync)(pluginJson),
11002
+ hasHooksJson: (0, import_node_fs6.existsSync)(hooksJson),
11003
+ isEmpty
11004
+ };
11005
+ });
11006
+ } catch {
11007
+ return [];
11008
+ }
11009
+ }
11010
+ function hubCheckoutForCursorSeed() {
11011
+ const manifest = (0, import_node_path7.join)(process.cwd(), "plugins", "mmi", ".cursor-plugin", "plugin.json");
11012
+ return (0, import_node_fs6.existsSync)(manifest) ? process.cwd() : void 0;
11013
+ }
10390
11014
  function mmiPluginCacheRootSnapshots() {
10391
11015
  const roots = [
10392
11016
  { surface: "claude", root: (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
@@ -10449,6 +11073,8 @@ async function runDoctor(opts, io = consoleIo) {
10449
11073
  else io.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
10450
11074
  return;
10451
11075
  }
11076
+ const repairLocal = !opts.json || Boolean(opts.apply);
11077
+ const repairFull = !opts.json && !opts.banner || Boolean(opts.apply);
10452
11078
  const checks = [];
10453
11079
  const REWRITE_KEY = "url.https://github.com/.insteadOf";
10454
11080
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
@@ -10483,13 +11109,13 @@ async function runDoctor(opts, io = consoleIo) {
10483
11109
  repoVersion: readRepoVersion(),
10484
11110
  releasedVersion
10485
11111
  });
10486
- if (!opts.json && !opts.banner) versionReport = await applyVersionAutoUpdate(versionReport, (m) => io.err(m));
11112
+ if (repairFull) versionReport = await applyVersionAutoUpdate(versionReport, (m) => io.err(m));
10487
11113
  if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
10488
11114
  checks.push(versionReport);
10489
11115
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "Hub API URL configured", fix: "set MMI_HUB_URL or use a current MMI CLI/plugin build" });
10490
11116
  checks.push(buildAwsCrossAccountCheck({ callerArn }));
10491
11117
  let cloneOk = cloneProbe;
10492
- if (!cloneOk && !opts.banner && !opts.json) {
11118
+ if (!cloneOk && repairFull) {
10493
11119
  try {
10494
11120
  await execFileP4("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
10495
11121
  cloneOk = true;
@@ -10507,7 +11133,7 @@ async function runDoctor(opts, io = consoleIo) {
10507
11133
  mirrorFrom: existingMirrorRecord(installed),
10508
11134
  surface
10509
11135
  });
10510
- if (!pluginCheck.ok && pluginCheck.recordToInsert && !opts.json) {
11136
+ if (!pluginCheck.ok && pluginCheck.recordToInsert && repairLocal) {
10511
11137
  if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
10512
11138
  pluginCheck = { ...pluginCheck, ok: true };
10513
11139
  io.err(` \u21BB repaired: registered mmi@mmi project install record \u2014 ${reloadHint} to load MMI commands`);
@@ -10515,7 +11141,7 @@ async function runDoctor(opts, io = consoleIo) {
10515
11141
  }
10516
11142
  checks.push(pluginCheck);
10517
11143
  let gitignoreCheck = buildGitignoreManagedBlockCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), content: readGitignore() });
10518
- if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && !opts.json && !opts.banner) {
11144
+ if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && repairFull) {
10519
11145
  if (writeGitignore(gitignoreCheck.contentToWrite)) {
10520
11146
  gitignoreCheck = { ...gitignoreCheck, ok: true };
10521
11147
  const drift = gitignoreCheck.seeded ? "inserted the org-managed block" : [
@@ -10528,7 +11154,7 @@ async function runDoctor(opts, io = consoleIo) {
10528
11154
  }
10529
11155
  checks.push(gitignoreCheck);
10530
11156
  let driftCheck = buildPluginConfigDriftCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), installed, surface });
10531
- if (!driftCheck.ok && driftCheck.recordsToWrite && !opts.json) {
11157
+ if (!driftCheck.ok && driftCheck.recordsToWrite && repairLocal) {
10532
11158
  if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
10533
11159
  driftCheck = { ...driftCheck, ok: true };
10534
11160
  io.err(` \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 ${reloadHint} to load MMI commands`);
@@ -10537,15 +11163,16 @@ async function runDoctor(opts, io = consoleIo) {
10537
11163
  checks.push(driftCheck);
10538
11164
  let installedVersionCheck = buildInstalledPluginVersionCheck({
10539
11165
  isOrgRepo: Boolean(cfg.sagaApiUrl),
10540
- installed,
11166
+ sources: installedPluginSources(),
10541
11167
  releasedVersion,
10542
11168
  surface
10543
11169
  });
10544
- if (!installedVersionCheck.ok && !opts.json && !opts.banner) {
10545
- if (await applyClaudePluginHeal(surface, (m) => io.err(m))) {
11170
+ if (!installedVersionCheck.ok && repairFull) {
11171
+ const claudeStale = installedVersionCheck.staleSurfaces?.some((s) => s.surface === "claude") ?? false;
11172
+ if (claudeStale && await applyClaudePluginHeal(surface, (m) => io.err(m))) {
10546
11173
  const healed = buildInstalledPluginVersionCheck({
10547
11174
  isOrgRepo: Boolean(cfg.sagaApiUrl),
10548
- installed: readInstalledPlugins(),
11175
+ sources: installedPluginSources(),
10549
11176
  releasedVersion,
10550
11177
  surface
10551
11178
  });
@@ -10563,21 +11190,35 @@ async function runDoctor(opts, io = consoleIo) {
10563
11190
  releasedVersion,
10564
11191
  installedVersions: installedPluginVersions(installed)
10565
11192
  });
10566
- if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan && !opts.json) {
11193
+ if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan && repairLocal) {
10567
11194
  const moved = quarantinePluginCacheDirs(cacheCleanupCheck.quarantinePlan);
10568
11195
  if (moved > 0) {
10569
11196
  const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
10570
11197
  const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
10571
11198
  io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
10572
11199
  }
10573
- cacheCleanupCheck = buildMmiPluginCacheCleanupCheck({
10574
- isOrgRepo: Boolean(cfg.sagaApiUrl),
10575
- roots: mmiPluginCacheRootSnapshots(),
10576
- activeVersion: resolveVersion(),
10577
- releasedVersion
10578
- });
11200
+ cacheCleanupCheck = {
11201
+ ...buildMmiPluginCacheCleanupCheck({
11202
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
11203
+ roots: mmiPluginCacheRootSnapshots(),
11204
+ activeVersion: resolveVersion(),
11205
+ releasedVersion
11206
+ }),
11207
+ ...moved > 0 ? { cleanedCount: moved } : {}
11208
+ };
10579
11209
  }
10580
11210
  checks.push(cacheCleanupCheck);
11211
+ const cursorCacheRoot = cursorPluginCacheRoot();
11212
+ checks.push(
11213
+ buildCursorPluginInstallCheck({
11214
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
11215
+ surface,
11216
+ cacheRoot: cursorCacheRoot,
11217
+ cacheRootExists: (0, import_node_fs6.existsSync)(cursorCacheRoot),
11218
+ pins: cursorPluginCachePinSnapshots() ?? [],
11219
+ hubCheckout: hubCheckoutForCursorSeed()
11220
+ })
11221
+ );
10581
11222
  const gaps = checks.filter((c) => !c.ok);
10582
11223
  const resources = doctorResourcesForGaps(gaps);
10583
11224
  if (opts.json) {
@@ -10594,7 +11235,7 @@ async function runDoctor(opts, io = consoleIo) {
10594
11235
  io.log(gaps.length ? `
10595
11236
  ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
10596
11237
  }
10597
- program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action((opts) => runDoctor(opts));
11238
+ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version, Cursor Team Marketplace plugin install) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").action((opts) => runDoctor(opts));
10598
11239
  program2.command("session-start").description("run the SessionStart verbs (rules sync, saga session+show, saga health, doctor) in one process; docs sync runs detached").action(async () => {
10599
11240
  try {
10600
11241
  const hook = parseHookInput(await readStdin());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.14.1",
3
+ "version": "2.15.0",
4
4
  "description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",