@mutmutco/cli 2.5.0 → 2.6.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 +261 -25
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -3309,6 +3309,9 @@ function buildHealth(i) {
3309
3309
  if (!i.sagaApiUrl) problems.push("sagaApiUrl not configured in .mmi/config.json");
3310
3310
  if (!i.identity) problems.push("no GitHub identity (gh auth token / GH_TOKEN)");
3311
3311
  if (!i.reachable) problems.push("saga backend unreachable");
3312
+ if (i.reachable && i.livenessStatus === 403 && i.livenessMessage === "Forbidden") {
3313
+ problems.push("saga API route-level 403 from GitHubAuthorizer cache/policy");
3314
+ }
3312
3315
  if (i.reachable && i.authorized === false) problems.push("saga backend rejected authenticated state access");
3313
3316
  if (!i.key.sessionId || i.key.sessionId === "-") problems.push("unsafe session id");
3314
3317
  const safeToWrite = problems.length === 0;
@@ -3317,6 +3320,8 @@ function buildHealth(i) {
3317
3320
  safeToWrite,
3318
3321
  identity: i.identity,
3319
3322
  reachable: i.reachable,
3323
+ livenessStatus: i.livenessStatus,
3324
+ livenessMessage: i.livenessMessage,
3320
3325
  authorized: i.authorized,
3321
3326
  sagaApiUrl: i.sagaApiUrl,
3322
3327
  key: i.key,
@@ -3598,6 +3603,36 @@ query($owner: String!, $number: Int!, $after: String) {
3598
3603
  }
3599
3604
  }
3600
3605
  }`;
3606
+ var ISSUE_PROJECT_ITEM_QUERY = `
3607
+ query($repoOwner: String!, $repoName: String!, $number: Int!) {
3608
+ repository(owner: $repoOwner, name: $repoName) {
3609
+ issue(number: $number) {
3610
+ id
3611
+ number
3612
+ title
3613
+ url
3614
+ state
3615
+ repository { nameWithOwner }
3616
+ labels(first: 10) { nodes { name } }
3617
+ assignees(first: 10) { nodes { login } }
3618
+ projectItems(first: 20) {
3619
+ nodes {
3620
+ id
3621
+ project { id title }
3622
+ fieldValues(first: 8) {
3623
+ nodes {
3624
+ ... on ProjectV2ItemFieldSingleSelectValue {
3625
+ name
3626
+ optionId
3627
+ field { ... on ProjectV2SingleSelectField { name } }
3628
+ }
3629
+ }
3630
+ }
3631
+ }
3632
+ }
3633
+ }
3634
+ }
3635
+ }`;
3601
3636
  function resolveBoardConfig(cfg) {
3602
3637
  const missing = [];
3603
3638
  if (!cfg.projectOwner) missing.push("projectOwner");
@@ -3871,6 +3906,14 @@ async function claimBoardIssue(options, deps = {}) {
3871
3906
  const cfg = resolveBoardConfig(options.config);
3872
3907
  const gh = deps.gh ?? defaultGh;
3873
3908
  const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
3909
+ const selector = parseIssueSelector(options.selector, collected.repo);
3910
+ try {
3911
+ findBoardItem(collected.items, selector);
3912
+ } catch (e) {
3913
+ const fallback = await fetchIssueProjectItem(gh, cfg, selector);
3914
+ if (!fallback) throw e;
3915
+ collected.items.push(fallback);
3916
+ }
3874
3917
  const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
3875
3918
  collected.warnings.push(...writable.warnings);
3876
3919
  collected.partial = collected.partial || writable.partial;
@@ -3882,7 +3925,6 @@ async function claimBoardIssue(options, deps = {}) {
3882
3925
  warnings: collected.warnings,
3883
3926
  partial: collected.partial
3884
3927
  };
3885
- const selector = parseIssueSelector(options.selector, collected.repo);
3886
3928
  const flatItem = findBoardItem(collected.items, selector);
3887
3929
  if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
3888
3930
  throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
@@ -4039,6 +4081,42 @@ async function fetchProjectPage(gh, cfg, after) {
4039
4081
  if (!parsed.data) throw new Error("gh GraphQL response did not include data");
4040
4082
  return parsed.data;
4041
4083
  }
4084
+ async function fetchIssueProjectItem(gh, cfg, selector) {
4085
+ const [repoOwner, repoName] = selector.repo.split("/");
4086
+ if (!repoOwner || !repoName) return void 0;
4087
+ const { stdout } = await gh([
4088
+ "api",
4089
+ "graphql",
4090
+ "-f",
4091
+ `query=${ISSUE_PROJECT_ITEM_QUERY}`,
4092
+ "-f",
4093
+ `repoOwner=${repoOwner}`,
4094
+ "-f",
4095
+ `repoName=${repoName}`,
4096
+ "-F",
4097
+ `number=${selector.number}`
4098
+ ]);
4099
+ const parsed = JSON.parse(stdout);
4100
+ const issue2 = parsed.data?.repository?.issue;
4101
+ if (!issue2) return void 0;
4102
+ const projectItem = (issue2.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
4103
+ if (!projectItem) return void 0;
4104
+ return nodeToItem({
4105
+ id: projectItem.id,
4106
+ fieldValues: projectItem.fieldValues,
4107
+ content: {
4108
+ __typename: "Issue",
4109
+ id: issue2.id,
4110
+ number: issue2.number,
4111
+ title: issue2.title,
4112
+ url: issue2.url,
4113
+ state: issue2.state,
4114
+ repository: issue2.repository,
4115
+ labels: issue2.labels,
4116
+ assignees: issue2.assignees
4117
+ }
4118
+ });
4119
+ }
4042
4120
  function nodesToItems(nodes, warnings) {
4043
4121
  const items = [];
4044
4122
  for (const node of nodes) {
@@ -4255,6 +4333,26 @@ function parseWorktreePorcelain(stdout) {
4255
4333
  }
4256
4334
  return out;
4257
4335
  }
4336
+ function samePath(a, b) {
4337
+ return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
4338
+ }
4339
+ function selectPrMergeCleanupWorktree(branch, before, after, startingPath) {
4340
+ if (!branch) return void 0;
4341
+ const current = after.find((w) => w.branch === branch)?.path;
4342
+ if (current) return current;
4343
+ const previous = before.find((w) => w.branch === branch)?.path;
4344
+ if (previous) return previous;
4345
+ if (startingPath && before.some((w) => w.branch === branch && samePath(w.path, startingPath))) return startingPath;
4346
+ return void 0;
4347
+ }
4348
+ function selectSafeWorktreeCwd(worktrees, targetPath) {
4349
+ if (!targetPath) return void 0;
4350
+ return worktrees.find((w) => !samePath(w.path, targetPath))?.path;
4351
+ }
4352
+ function branchMissingFromList(branch, stdout) {
4353
+ const names = stdout.split(/\r?\n/).map((line) => line.replace(/^\*\s*/, "").trim()).filter(Boolean);
4354
+ return !names.includes(branch);
4355
+ }
4258
4356
  function formatGcPlan(plan2, apply) {
4259
4357
  const lines = [`mmi-cli gc: ${apply ? "apply" : "dry-run"}`];
4260
4358
  if (!plan2.branches.length && !plan2.trackingRefs.length) lines.push("nothing to clean");
@@ -4485,8 +4583,13 @@ var PLUGIN_DRIFT_LABEL = "plugin config drift (mmi@mmi duplicate rows / stale gi
4485
4583
  function recordFreshness(r) {
4486
4584
  return r.lastUpdated ?? r.installedAt ?? "";
4487
4585
  }
4488
- function freshestRecord(records) {
4489
- return records.reduce((best, r) => recordFreshness(r) > recordFreshness(best) ? r : best);
4586
+ function bestRecord(records) {
4587
+ return records.reduce((best, r) => {
4588
+ const byVersion = compareVersions(r.version ?? "0", best.version ?? "0");
4589
+ if (byVersion > 0) return r;
4590
+ if (byVersion < 0) return best;
4591
+ return recordFreshness(r) > recordFreshness(best) ? r : best;
4592
+ });
4490
4593
  }
4491
4594
  function pluginConfigDriftFix(pluginId) {
4492
4595
  return `\`${pluginId}\` registered as N duplicate project rows / a stale gitCommitSha in ~/.claude/plugins/installed_plugins.json \u2014 run \`mmi-cli doctor\` to collapse them to one \`scope: user\` entry at the highest version (a .bak backup is written first), or in \`/plugin\` uninstall the extra rows and reinstall once at user scope`;
@@ -4503,19 +4606,21 @@ function buildPluginConfigDriftCheck(input) {
4503
4606
  const records = input.installed?.plugins?.[pluginId];
4504
4607
  if (!Array.isArray(records) || records.length === 0) return base;
4505
4608
  const projectRows = records.filter((r) => r.scope === "project");
4506
- const freshest = freshestRecord(records);
4507
- const freshSha = freshest.gitCommitSha;
4508
- const staleShaRows = freshSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== freshSha).length : 0;
4609
+ const best = bestRecord(records);
4610
+ const bestSha = best.gitCommitSha;
4611
+ const staleShaRows = bestSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== bestSha).length : 0;
4612
+ const duplicateRows = records.length > 1 ? records.length : 0;
4509
4613
  const duplicateProjectRows = projectRows.length > 1 ? projectRows.length : 0;
4510
- if (duplicateProjectRows === 0 && staleShaRows === 0) {
4511
- return { ...base, duplicateProjectRows: 0, staleShaRows: 0 };
4614
+ if (duplicateRows === 0 && staleShaRows === 0) {
4615
+ return { ...base, duplicateRows: 0, duplicateProjectRows: 0, staleShaRows: 0 };
4512
4616
  }
4513
- const { projectPath: _drop, ...rest } = freshest;
4617
+ const { projectPath: _drop, ...rest } = best;
4514
4618
  const collapsed = { ...rest, scope: "user" };
4515
4619
  return {
4516
4620
  ...base,
4517
4621
  ok: false,
4518
4622
  recordsToWrite: [collapsed],
4623
+ duplicateRows,
4519
4624
  duplicateProjectRows,
4520
4625
  staleShaRows
4521
4626
  };
@@ -4529,6 +4634,58 @@ function buildGitignoreManagedBlockCheck(input) {
4529
4634
  if (!changed) return base;
4530
4635
  return { ...base, ok: false, contentToWrite: content };
4531
4636
  }
4637
+ function detectSurface(env) {
4638
+ const has = (k) => Boolean(env[k]?.trim());
4639
+ if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
4640
+ return "codex";
4641
+ }
4642
+ const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
4643
+ const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
4644
+ if (isClaude && isVscode) return "claude-vscode";
4645
+ if (isClaude) return "claude-cli";
4646
+ return "shell";
4647
+ }
4648
+ function pluginRecoveryFix(surface) {
4649
+ const claude = "claude plugin marketplace update mmi && claude plugin update mmi@mmi && claude plugin enable mmi@mmi";
4650
+ switch (surface) {
4651
+ case "claude-vscode":
4652
+ return `${claude} # then reopen the VS Code workspace to reload MMI commands`;
4653
+ case "claude-cli":
4654
+ return `${claude} # then restart Claude Code, or run /reload-plugins`;
4655
+ case "codex":
4656
+ return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
4657
+ case "shell":
4658
+ default:
4659
+ return "npm install -g @mutmutco/cli@latest";
4660
+ }
4661
+ }
4662
+ var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest release)";
4663
+ function isSemverVersion(v) {
4664
+ return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
4665
+ }
4666
+ function buildInstalledPluginVersionCheck(input) {
4667
+ const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
4668
+ const base = {
4669
+ ok: true,
4670
+ label: INSTALLED_PLUGIN_VERSION_LABEL,
4671
+ fix: pluginRecoveryFix(input.surface),
4672
+ pluginId
4673
+ };
4674
+ if (!input.isOrgRepo) return base;
4675
+ const records = input.installed?.plugins?.[pluginId];
4676
+ if (!Array.isArray(records) || records.length === 0) return base;
4677
+ const installedVersion = bestRecord(records).version;
4678
+ if (!isSemverVersion(installedVersion) || !isSemverVersion(input.releasedVersion)) return base;
4679
+ if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
4680
+ return { ...base, installedVersion, releasedVersion: input.releasedVersion };
4681
+ }
4682
+ return {
4683
+ ...base,
4684
+ ok: false,
4685
+ installedVersion,
4686
+ releasedVersion: input.releasedVersion
4687
+ };
4688
+ }
4532
4689
 
4533
4690
  // src/stage-runner.ts
4534
4691
  var import_node_child_process3 = require("node:child_process");
@@ -6312,24 +6469,31 @@ async function applyGcPlan(plan2, remote) {
6312
6469
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
6313
6470
  }
6314
6471
  }
6315
- async function cleanupLocalBranch(branch) {
6472
+ async function cleanupLocalBranch(branch, before = {}) {
6316
6473
  const result = { branchDeleted: false };
6317
6474
  if (!branch) return result;
6318
6475
  const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
6319
- const wt = parseWorktreePorcelain(stdout).find((w) => w.branch === branch);
6320
- if (wt) {
6321
- await execFileP3("git", ["worktree", "remove", "--force", wt.path], { timeout: GIT_TIMEOUT_MS }).catch(() => {
6476
+ const afterWorktrees = parseWorktreePorcelain(stdout);
6477
+ const wtPath = selectPrMergeCleanupWorktree(branch, before.worktrees ?? [], afterWorktrees, before.startingPath);
6478
+ const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...before.worktrees ?? []], wtPath);
6479
+ const git = (args) => safeCwd ? execFileP3("git", ["-C", safeCwd, ...args], { timeout: GIT_TIMEOUT_MS }) : execFileP3("git", args, { timeout: GIT_TIMEOUT_MS });
6480
+ if (wtPath) {
6481
+ await git(["worktree", "remove", "--force", wtPath]).catch(() => {
6322
6482
  });
6323
- result.worktreeRemoved = wt.path;
6483
+ result.worktreeRemoved = wtPath;
6324
6484
  }
6325
- const current = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
6485
+ const current = safeCwd ? ((await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => ({ stdout: "" }))).stdout || "").trim() : await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
6326
6486
  if (branch !== current) {
6327
- await execFileP3("git", ["branch", "-D", branch], { timeout: GIT_TIMEOUT_MS }).then(() => {
6487
+ await git(["branch", "-D", branch]).then(() => {
6328
6488
  result.branchDeleted = true;
6329
6489
  }).catch(() => {
6330
6490
  });
6491
+ if (!result.branchDeleted) {
6492
+ const remaining = await git(["branch", "--list", branch]).catch(() => ({ stdout: "" }));
6493
+ result.branchDeleted = branchMissingFromList(branch, remaining.stdout || "");
6494
+ }
6331
6495
  }
6332
- if (wt) await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS }).catch(() => {
6496
+ if (wtPath) await git(["worktree", "prune"]).catch(() => {
6333
6497
  });
6334
6498
  return result;
6335
6499
  }
@@ -6386,6 +6550,33 @@ async function applyVersionAutoUpdate(report, log) {
6386
6550
  return report;
6387
6551
  }
6388
6552
  }
6553
+ var CLAUDE_PLUGIN_TIMEOUT_MS = 6e4;
6554
+ async function runClaudePlugin(args) {
6555
+ const candidates = isWin ? ["claude.cmd", "claude.exe", "claude"] : ["claude"];
6556
+ for (const bin of candidates) {
6557
+ try {
6558
+ await execFileP3(bin, args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
6559
+ return true;
6560
+ } catch (err) {
6561
+ if (err.code === "ENOENT") continue;
6562
+ return false;
6563
+ }
6564
+ }
6565
+ return false;
6566
+ }
6567
+ async function applyClaudePluginHeal(surface, log) {
6568
+ if (surface !== "claude-cli" && surface !== "claude-vscode") return false;
6569
+ log(" \u21BB updating the MMI plugin via `claude plugin` (marketplace \u2192 update \u2192 enable)\u2026");
6570
+ const steps = [
6571
+ ["plugin", "marketplace", "update", "mmi"],
6572
+ ["plugin", "update", "mmi@mmi"],
6573
+ ["plugin", "enable", "mmi@mmi"]
6574
+ ];
6575
+ for (const step of steps) {
6576
+ if (!await runClaudePlugin(step)) return false;
6577
+ }
6578
+ return true;
6579
+ }
6389
6580
  var program2 = new Command();
6390
6581
  program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
6391
6582
  var rules = program2.command("rules").description("org rules delivery");
@@ -6527,9 +6718,15 @@ saga.command("key").option("--json", "machine-readable output").description("pri
6527
6718
  async function probeBackend(url) {
6528
6719
  try {
6529
6720
  const res = await fetch(`${url}/saga/head`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
6530
- return res.ok;
6721
+ let message = "";
6722
+ try {
6723
+ const body = await res.clone().json();
6724
+ message = typeof body.message === "string" ? body.message : "";
6725
+ } catch {
6726
+ }
6727
+ return { reachable: res.ok || res.status === 403, status: res.status, message };
6531
6728
  } catch {
6532
- return false;
6729
+ return { reachable: false };
6533
6730
  }
6534
6731
  }
6535
6732
  async function probeSagaAccess(url, key) {
@@ -6547,9 +6744,18 @@ saga.command("health").option("--json", "machine-readable output").option("--ban
6547
6744
  const key = await sagaKey(cfg, session);
6548
6745
  const source = session.source;
6549
6746
  const identity = await githubLogin();
6550
- const reachable = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : false;
6551
- const authorized = cfg.sagaApiUrl && reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6552
- const report = buildHealth({ key, source, identity, reachable, authorized, sagaApiUrl: cfg.sagaApiUrl });
6747
+ const liveness = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : { reachable: false };
6748
+ const authorized = cfg.sagaApiUrl && liveness.reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6749
+ const report = buildHealth({
6750
+ key,
6751
+ source,
6752
+ identity,
6753
+ reachable: liveness.reachable,
6754
+ livenessStatus: liveness.status,
6755
+ livenessMessage: liveness.message,
6756
+ authorized,
6757
+ sagaApiUrl: cfg.sagaApiUrl
6758
+ });
6553
6759
  if (o.json) return console.log(JSON.stringify(report));
6554
6760
  if (o.banner) {
6555
6761
  const banner = healthBanner(report);
@@ -7015,10 +7221,14 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
7015
7221
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
7016
7222
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
7017
7223
  const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
7224
+ const startingPath = (await execFileP3("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
7225
+ const beforeWorktrees = parseWorktreePorcelain(
7226
+ (await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
7227
+ );
7018
7228
  await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
7019
7229
  if (!/used by worktree|cannot delete branch|already been merged/i.test(String(e.message || ""))) throw e;
7020
7230
  });
7021
- const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef);
7231
+ const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef, { worktrees: beforeWorktrees, startingPath });
7022
7232
  console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
7023
7233
  });
7024
7234
  async function runBoardRead(o) {
@@ -7458,7 +7668,7 @@ function writeGitignore(content) {
7458
7668
  return false;
7459
7669
  }
7460
7670
  }
7461
- program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block, plugin config drift) 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(async (opts) => {
7671
+ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, 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(async (opts) => {
7462
7672
  if (opts.guide) {
7463
7673
  if (opts.json) console.log(JSON.stringify({ resources: [MMI_AGENTIC_ONBOARDING_GUIDE] }, null, 2));
7464
7674
  else console.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
@@ -7486,12 +7696,15 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7486
7696
  if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
7487
7697
  }
7488
7698
  checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
7699
+ const surface = detectSurface(process.env);
7700
+ const releasedVersion = await fetchReleasedVersion();
7489
7701
  let versionReport = buildVersionLagReport({
7490
7702
  currentVersion: resolveVersion(),
7491
7703
  repoVersion: readRepoVersion(),
7492
- releasedVersion: await fetchReleasedVersion()
7704
+ releasedVersion
7493
7705
  });
7494
7706
  if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
7707
+ if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
7495
7708
  checks.push(versionReport);
7496
7709
  const cfg = await loadConfig();
7497
7710
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
@@ -7543,7 +7756,30 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7543
7756
  if (!opts.banner) console.error(" \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 run /reload-plugins");
7544
7757
  }
7545
7758
  }
7759
+ if (!driftCheck.ok) driftCheck = { ...driftCheck, fix: pluginRecoveryFix(surface) };
7546
7760
  checks.push(driftCheck);
7761
+ let installedVersionCheck = buildInstalledPluginVersionCheck({
7762
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
7763
+ installed,
7764
+ releasedVersion,
7765
+ surface
7766
+ });
7767
+ if (!installedVersionCheck.ok && !opts.json) {
7768
+ if (await applyClaudePluginHeal(surface, (m) => console.error(m))) {
7769
+ const healed = buildInstalledPluginVersionCheck({
7770
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
7771
+ installed: readInstalledPlugins(),
7772
+ releasedVersion,
7773
+ surface
7774
+ });
7775
+ installedVersionCheck = healed;
7776
+ if (healed.ok && !opts.banner) {
7777
+ const reload = surface === "claude-vscode" ? "reopen the VS Code workspace" : "restart Claude Code (or run /reload-plugins)";
7778
+ console.error(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reload} to load the new commands`);
7779
+ }
7780
+ }
7781
+ }
7782
+ checks.push(installedVersionCheck);
7547
7783
  const gaps = checks.filter((c) => !c.ok);
7548
7784
  const resources = doctorResourcesForGaps(gaps);
7549
7785
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",