@mutmutco/cli 2.5.1 → 2.7.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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +612 -59
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,7 @@ mmi-cli doctor --json
36
36
  KB curation without echoing the plan body.
37
37
  `mmi-cli plan` remains a compatibility alias.
38
38
  - `mmi-cli secrets where|list|get|set|edit|rm|use|grant|revoke` manages two-tier project/org secrets without logging values; `where` prints the vault layout + well-known keys, and values move over TLS in the request body — never an argument.
39
- - `mmi-cli project list|get|resolve|set` reads the DDB org registry a project's identity + board coords + deploy coordinates (`resolve` reads deploy coords, which are OIDC-gated; `set` is master-only).
39
+ - `mmi-cli project list|get|resolve|doctor|heal|readiness|set` reads and repairs Hub-owned v2 readiness state. `doctor --v2 --json` diagnoses central deploy/secrets readiness, `heal --v2 --apply` fixes only registry-owned defaults, and `readiness --update-issue` updates the repo's v2 readiness issue; `set` is master-only.
40
40
  - `mmi-cli registry org` reads org-level constants from the registry (`ORG#config`).
41
41
  - `mmi-cli oauth plan|verify` prints a repo's canonical Google OAuth URI set (read from the registry) and verifies the client is port-agnostic.
42
42
  - `mmi-cli issue create` creates typed, prioritized GitHub issues (priority sets the board field, not a label) and queues related-issue discovery.
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,
@@ -3336,6 +3341,7 @@ function resumeCue() {
3336
3341
 
3337
3342
  // src/saga-note.ts
3338
3343
  var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
3344
+ var ROUTE_LEVEL_403 = "saga API route-level 403 from GitHubAuthorizer cache/policy";
3339
3345
  function agentSurface() {
3340
3346
  const surface = process.env.MMI_AGENT_SURFACE || "claude";
3341
3347
  if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
@@ -3367,6 +3373,10 @@ function buildNoteCapture(summary, o, id, evidence) {
3367
3373
  anchorForce: o.anchorForce || void 0
3368
3374
  };
3369
3375
  }
3376
+ function formatCaptureFailure(status, message) {
3377
+ if (status === 403 && message === "Forbidden") return `saga: ${ROUTE_LEVEL_403} (HTTP 403)`;
3378
+ return `saga: HTTP ${status}`;
3379
+ }
3370
3380
 
3371
3381
  // src/version-lag.ts
3372
3382
  var VERSION_LABEL = "installed plugin/adapter cache freshness";
@@ -3598,6 +3608,36 @@ query($owner: String!, $number: Int!, $after: String) {
3598
3608
  }
3599
3609
  }
3600
3610
  }`;
3611
+ var ISSUE_PROJECT_ITEM_QUERY = `
3612
+ query($repoOwner: String!, $repoName: String!, $number: Int!) {
3613
+ repository(owner: $repoOwner, name: $repoName) {
3614
+ issue(number: $number) {
3615
+ id
3616
+ number
3617
+ title
3618
+ url
3619
+ state
3620
+ repository { nameWithOwner }
3621
+ labels(first: 10) { nodes { name } }
3622
+ assignees(first: 10) { nodes { login } }
3623
+ projectItems(first: 20) {
3624
+ nodes {
3625
+ id
3626
+ project { id title }
3627
+ fieldValues(first: 8) {
3628
+ nodes {
3629
+ ... on ProjectV2ItemFieldSingleSelectValue {
3630
+ name
3631
+ optionId
3632
+ field { ... on ProjectV2SingleSelectField { name } }
3633
+ }
3634
+ }
3635
+ }
3636
+ }
3637
+ }
3638
+ }
3639
+ }
3640
+ }`;
3601
3641
  function resolveBoardConfig(cfg) {
3602
3642
  const missing = [];
3603
3643
  if (!cfg.projectOwner) missing.push("projectOwner");
@@ -3871,6 +3911,14 @@ async function claimBoardIssue(options, deps = {}) {
3871
3911
  const cfg = resolveBoardConfig(options.config);
3872
3912
  const gh = deps.gh ?? defaultGh;
3873
3913
  const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
3914
+ const selector = parseIssueSelector(options.selector, collected.repo);
3915
+ try {
3916
+ findBoardItem(collected.items, selector);
3917
+ } catch (e) {
3918
+ const fallback = await fetchIssueProjectItem(gh, cfg, selector);
3919
+ if (!fallback) throw e;
3920
+ collected.items.push(fallback);
3921
+ }
3874
3922
  const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
3875
3923
  collected.warnings.push(...writable.warnings);
3876
3924
  collected.partial = collected.partial || writable.partial;
@@ -3882,7 +3930,6 @@ async function claimBoardIssue(options, deps = {}) {
3882
3930
  warnings: collected.warnings,
3883
3931
  partial: collected.partial
3884
3932
  };
3885
- const selector = parseIssueSelector(options.selector, collected.repo);
3886
3933
  const flatItem = findBoardItem(collected.items, selector);
3887
3934
  if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
3888
3935
  throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
@@ -4039,6 +4086,42 @@ async function fetchProjectPage(gh, cfg, after) {
4039
4086
  if (!parsed.data) throw new Error("gh GraphQL response did not include data");
4040
4087
  return parsed.data;
4041
4088
  }
4089
+ async function fetchIssueProjectItem(gh, cfg, selector) {
4090
+ const [repoOwner, repoName] = selector.repo.split("/");
4091
+ if (!repoOwner || !repoName) return void 0;
4092
+ const { stdout } = await gh([
4093
+ "api",
4094
+ "graphql",
4095
+ "-f",
4096
+ `query=${ISSUE_PROJECT_ITEM_QUERY}`,
4097
+ "-f",
4098
+ `repoOwner=${repoOwner}`,
4099
+ "-f",
4100
+ `repoName=${repoName}`,
4101
+ "-F",
4102
+ `number=${selector.number}`
4103
+ ]);
4104
+ const parsed = JSON.parse(stdout);
4105
+ const issue2 = parsed.data?.repository?.issue;
4106
+ if (!issue2) return void 0;
4107
+ const projectItem = (issue2.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
4108
+ if (!projectItem) return void 0;
4109
+ return nodeToItem({
4110
+ id: projectItem.id,
4111
+ fieldValues: projectItem.fieldValues,
4112
+ content: {
4113
+ __typename: "Issue",
4114
+ id: issue2.id,
4115
+ number: issue2.number,
4116
+ title: issue2.title,
4117
+ url: issue2.url,
4118
+ state: issue2.state,
4119
+ repository: issue2.repository,
4120
+ labels: issue2.labels,
4121
+ assignees: issue2.assignees
4122
+ }
4123
+ });
4124
+ }
4042
4125
  function nodesToItems(nodes, warnings) {
4043
4126
  const items = [];
4044
4127
  for (const node of nodes) {
@@ -4168,6 +4251,13 @@ function ghError(e) {
4168
4251
  }
4169
4252
 
4170
4253
  // src/gc.ts
4254
+ function buildRemoteBranchCleanupReport(branch, input) {
4255
+ if (!input.attempted) return { name: branch, status: "not-attempted", reason: input.reason };
4256
+ if (input.existsAfter === true) return { name: branch, status: "failed", reason: "still-present-after-delete" };
4257
+ if (input.existedBefore === false && input.existsAfter === false) return { name: branch, status: "already-gone" };
4258
+ if (input.existsAfter === false) return { name: branch, status: "deleted" };
4259
+ return { name: branch, status: "not-attempted", reason: input.reason ?? "remote-check-unavailable" };
4260
+ }
4171
4261
  var DEFAULT_PROTECTED = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
4172
4262
  function groupedPrs(prs) {
4173
4263
  const out = /* @__PURE__ */ new Map();
@@ -4275,6 +4365,63 @@ function branchMissingFromList(branch, stdout) {
4275
4365
  const names = stdout.split(/\r?\n/).map((line) => line.replace(/^\*\s*/, "").trim()).filter(Boolean);
4276
4366
  return !names.includes(branch);
4277
4367
  }
4368
+ function shellQuote(value) {
4369
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
4370
+ }
4371
+ function safeWorktreeRemoveCommand(safeCwd, targetPath) {
4372
+ const prefix = safeCwd ? `git -C ${shellQuote(safeCwd)}` : "git";
4373
+ return `${prefix} worktree remove --force ${shellQuote(targetPath)}`;
4374
+ }
4375
+ function errorMessage(error) {
4376
+ return error instanceof Error ? error.message : String(error);
4377
+ }
4378
+ async function cleanupPrMergeLocalBranch(branch, options) {
4379
+ const report = {
4380
+ branch,
4381
+ localBranch: { name: branch, status: "not-attempted", reason: branch ? "pending" : "missing-branch" }
4382
+ };
4383
+ if (!branch) return report;
4384
+ let afterWorktrees = [];
4385
+ try {
4386
+ afterWorktrees = parseWorktreePorcelain(await options.execGit(["worktree", "list", "--porcelain"]));
4387
+ } catch (e) {
4388
+ report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-list-failed", error: errorMessage(e) };
4389
+ return report;
4390
+ }
4391
+ const beforeWorktrees = options.beforeWorktrees ?? [];
4392
+ const wtPath = selectPrMergeCleanupWorktree(branch, beforeWorktrees, afterWorktrees, options.startingPath);
4393
+ const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
4394
+ const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
4395
+ if (wtPath) {
4396
+ try {
4397
+ await git(["worktree", "remove", "--force", wtPath]);
4398
+ report.worktree = { path: wtPath, status: "removed" };
4399
+ } catch (e) {
4400
+ report.worktree = {
4401
+ path: wtPath,
4402
+ status: "failed",
4403
+ error: errorMessage(e),
4404
+ safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath)
4405
+ };
4406
+ report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
4407
+ return report;
4408
+ }
4409
+ }
4410
+ const current = (await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "") || "").trim();
4411
+ if (branch === current) {
4412
+ report.localBranch = { name: branch, status: "not-attempted", reason: "current-branch" };
4413
+ return report;
4414
+ }
4415
+ try {
4416
+ await git(["branch", "-D", branch]);
4417
+ report.localBranch = { name: branch, status: "deleted" };
4418
+ } catch (e) {
4419
+ const remaining = await git(["branch", "--list", branch]).catch(() => "");
4420
+ report.localBranch = branchMissingFromList(branch, remaining) ? { name: branch, status: "already-gone" } : { name: branch, status: "failed", error: errorMessage(e) };
4421
+ }
4422
+ if (wtPath) await git(["worktree", "prune"]).catch(() => "");
4423
+ return report;
4424
+ }
4278
4425
  function formatGcPlan(plan2, apply) {
4279
4426
  const lines = [`mmi-cli gc: ${apply ? "apply" : "dry-run"}`];
4280
4427
  if (!plan2.branches.length && !plan2.trackingRefs.length) lines.push("nothing to clean");
@@ -4325,8 +4472,9 @@ function trainPlan(command) {
4325
4472
  { label: "verify current branch is development", gated: true },
4326
4473
  { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4327
4474
  { label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
4475
+ { label: "for Hub distribution changes, verify the release bump is already landed before touching rc", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
4328
4476
  { label: "merge development to rc", gated: true },
4329
- { label: "dispatch central tenant deploy for rc", command: "gh workflow run tenant-deploy.yml --repo mutmutco/MMI-Hub -f slug=<slug> -f repo=<owner/repo> -f ref=rc -f stage=rc", gated: true }
4477
+ { label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml ...; hub-serverless: no manual dispatch, deploy.yml auto-fires on rc push", gated: true }
4330
4478
  ];
4331
4479
  }
4332
4480
  if (command === "release") {
@@ -4336,8 +4484,9 @@ function trainPlan(command) {
4336
4484
  { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4337
4485
  { label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
4338
4486
  { label: "merge rc to main", gated: true },
4487
+ { 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 },
4339
4488
  { label: "tag release and publish GitHub Release", gated: true },
4340
- { label: "dispatch central tenant deploy for main", command: "gh workflow run tenant-deploy.yml --repo mutmutco/MMI-Hub -f slug=<slug> -f repo=<owner/repo> -f ref=main -f stage=main", gated: true },
4489
+ { label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml ...; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release", gated: true },
4341
4490
  { label: "roll development forward", gated: true }
4342
4491
  ];
4343
4492
  }
@@ -4505,8 +4654,13 @@ var PLUGIN_DRIFT_LABEL = "plugin config drift (mmi@mmi duplicate rows / stale gi
4505
4654
  function recordFreshness(r) {
4506
4655
  return r.lastUpdated ?? r.installedAt ?? "";
4507
4656
  }
4508
- function freshestRecord(records) {
4509
- return records.reduce((best, r) => recordFreshness(r) > recordFreshness(best) ? r : best);
4657
+ function bestRecord(records) {
4658
+ return records.reduce((best, r) => {
4659
+ const byVersion = compareVersions(r.version ?? "0", best.version ?? "0");
4660
+ if (byVersion > 0) return r;
4661
+ if (byVersion < 0) return best;
4662
+ return recordFreshness(r) > recordFreshness(best) ? r : best;
4663
+ });
4510
4664
  }
4511
4665
  function pluginConfigDriftFix(pluginId) {
4512
4666
  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`;
@@ -4523,19 +4677,21 @@ function buildPluginConfigDriftCheck(input) {
4523
4677
  const records = input.installed?.plugins?.[pluginId];
4524
4678
  if (!Array.isArray(records) || records.length === 0) return base;
4525
4679
  const projectRows = records.filter((r) => r.scope === "project");
4526
- const freshest = freshestRecord(records);
4527
- const freshSha = freshest.gitCommitSha;
4528
- const staleShaRows = freshSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== freshSha).length : 0;
4680
+ const best = bestRecord(records);
4681
+ const bestSha = best.gitCommitSha;
4682
+ const staleShaRows = bestSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== bestSha).length : 0;
4683
+ const duplicateRows = records.length > 1 ? records.length : 0;
4529
4684
  const duplicateProjectRows = projectRows.length > 1 ? projectRows.length : 0;
4530
- if (duplicateProjectRows === 0 && staleShaRows === 0) {
4531
- return { ...base, duplicateProjectRows: 0, staleShaRows: 0 };
4685
+ if (duplicateRows === 0 && staleShaRows === 0) {
4686
+ return { ...base, duplicateRows: 0, duplicateProjectRows: 0, staleShaRows: 0 };
4532
4687
  }
4533
- const { projectPath: _drop, ...rest } = freshest;
4688
+ const { projectPath: _drop, ...rest } = best;
4534
4689
  const collapsed = { ...rest, scope: "user" };
4535
4690
  return {
4536
4691
  ...base,
4537
4692
  ok: false,
4538
4693
  recordsToWrite: [collapsed],
4694
+ duplicateRows,
4539
4695
  duplicateProjectRows,
4540
4696
  staleShaRows
4541
4697
  };
@@ -4549,6 +4705,58 @@ function buildGitignoreManagedBlockCheck(input) {
4549
4705
  if (!changed) return base;
4550
4706
  return { ...base, ok: false, contentToWrite: content };
4551
4707
  }
4708
+ function detectSurface(env) {
4709
+ const has = (k) => Boolean(env[k]?.trim());
4710
+ if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
4711
+ return "codex";
4712
+ }
4713
+ const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
4714
+ const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
4715
+ if (isClaude && isVscode) return "claude-vscode";
4716
+ if (isClaude) return "claude-cli";
4717
+ return "shell";
4718
+ }
4719
+ function pluginRecoveryFix(surface) {
4720
+ const claude = "claude plugin marketplace update mmi && claude plugin update mmi@mmi && claude plugin enable mmi@mmi";
4721
+ switch (surface) {
4722
+ case "claude-vscode":
4723
+ return `${claude} # then reopen the VS Code workspace to reload MMI commands`;
4724
+ case "claude-cli":
4725
+ return `${claude} # then restart Claude Code, or run /reload-plugins`;
4726
+ case "codex":
4727
+ return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
4728
+ case "shell":
4729
+ default:
4730
+ return "npm install -g @mutmutco/cli@latest";
4731
+ }
4732
+ }
4733
+ var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest release)";
4734
+ function isSemverVersion(v) {
4735
+ return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
4736
+ }
4737
+ function buildInstalledPluginVersionCheck(input) {
4738
+ const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
4739
+ const base = {
4740
+ ok: true,
4741
+ label: INSTALLED_PLUGIN_VERSION_LABEL,
4742
+ fix: pluginRecoveryFix(input.surface),
4743
+ pluginId
4744
+ };
4745
+ if (!input.isOrgRepo) return base;
4746
+ const records = input.installed?.plugins?.[pluginId];
4747
+ if (!Array.isArray(records) || records.length === 0) return base;
4748
+ const installedVersion = bestRecord(records).version;
4749
+ if (!isSemverVersion(installedVersion) || !isSemverVersion(input.releasedVersion)) return base;
4750
+ if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
4751
+ return { ...base, installedVersion, releasedVersion: input.releasedVersion };
4752
+ }
4753
+ return {
4754
+ ...base,
4755
+ ok: false,
4756
+ installedVersion,
4757
+ releasedVersion: input.releasedVersion
4758
+ };
4759
+ }
4552
4760
 
4553
4761
  // src/stage-runner.ts
4554
4762
  var import_node_child_process3 = require("node:child_process");
@@ -5558,6 +5766,24 @@ async function fetchProjectBySlug(slug, deps) {
5558
5766
  return null;
5559
5767
  }
5560
5768
  }
5769
+ async function fetchDeployStatusBySlug(slug, deps) {
5770
+ if (!deps.baseUrl || !slug) return null;
5771
+ const token = await deps.token();
5772
+ if (!token) return null;
5773
+ const doFetch = deps.fetch ?? fetch;
5774
+ try {
5775
+ const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}/deploy-status`, {
5776
+ method: "GET",
5777
+ headers: { Authorization: `Bearer ${token}` },
5778
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
5779
+ });
5780
+ if (!res.ok) return null;
5781
+ const body = await res.json();
5782
+ return body?.stages ?? null;
5783
+ } catch {
5784
+ return null;
5785
+ }
5786
+ }
5561
5787
  async function fetchOrgConfig(deps) {
5562
5788
  if (!deps.baseUrl) return null;
5563
5789
  const token = await deps.token();
@@ -5604,6 +5830,164 @@ async function upsertProject(slug, patch, deps) {
5604
5830
  return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
5605
5831
  }
5606
5832
 
5833
+ // src/project-readiness.ts
5834
+ var STAGES = ["dev", "rc", "main"];
5835
+ var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
5836
+ function slugOfRepo(repoOrSlug) {
5837
+ return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
5838
+ }
5839
+ function repoFrom(repoOrSlug, slug) {
5840
+ return repoOrSlug.includes("/") ? repoOrSlug : `mutmutco/${slug}`;
5841
+ }
5842
+ function defaultSubdomain(slug) {
5843
+ return slug.replace(/^[a-z]+-/, "");
5844
+ }
5845
+ function defaultDeployModel(meta, repo) {
5846
+ if (meta?.deployModel) return meta.deployModel;
5847
+ if (meta?.class === "content") return "content";
5848
+ if (repo.toLowerCase().endsWith("/mmi-hub")) return "hub-serverless";
5849
+ return "tenant-container";
5850
+ }
5851
+ function stageRequiredSecrets(stage2, meta) {
5852
+ const contract = meta.requiredRuntimeSecrets;
5853
+ const extra = Array.isArray(contract) ? contract : Array.isArray(contract?.[stage2]) ? contract[stage2] ?? [] : [];
5854
+ return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
5855
+ }
5856
+ function stageKey(stage2, key) {
5857
+ return key.includes("/") ? key : `${stage2}/${key}`;
5858
+ }
5859
+ function appGapsFor(meta, model, slug) {
5860
+ if (model === "content") return ["Content repo: keep app-owned work to docs/content changes; release train does not apply."];
5861
+ const gaps = [
5862
+ "Ensure Docker/compose runs from the central release directory and consumes the Hub-generated .env.",
5863
+ "Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
5864
+ "Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
5865
+ ];
5866
+ if (slug === "mmi-katip") {
5867
+ gaps.push("Katip-specific app plan: declare Google Workspace env requirements, remove prod-hidden impersonation defaults, and make non-critical Google Workspace failures degrade instead of crash-looping.");
5868
+ }
5869
+ if (!meta) gaps.unshift("No app-owned repo changes can be planned precisely until Hub registry META exists.");
5870
+ return gaps;
5871
+ }
5872
+ function contractByStage(contract) {
5873
+ if (Array.isArray(contract)) return { dev: contract, rc: contract, main: contract };
5874
+ return contract ?? {};
5875
+ }
5876
+ function ensureStageNames(names, required) {
5877
+ return [.../* @__PURE__ */ new Set([...names ?? [], ...required])];
5878
+ }
5879
+ function buildV2HealPatch(repoOrSlug, meta) {
5880
+ const slug = slugOfRepo(repoOrSlug);
5881
+ const repo = repoFrom(repoOrSlug, slug);
5882
+ const sub = defaultSubdomain(slug);
5883
+ const patch = {};
5884
+ const model = defaultDeployModel(meta, repo);
5885
+ if (!meta?.class) patch.class = "deployable";
5886
+ if (!meta?.deployModel) patch.deployModel = model;
5887
+ if (!meta?.vaultPath) patch.vaultPath = `/mmi-future/${slug}`;
5888
+ if (!meta?.kbPointer) patch.kbPointer = `kb/projects/${slug}.md`;
5889
+ if (!meta?.oauth) patch.oauth = { subdomains: [sub], callbackPath: "/auth/callback" };
5890
+ if (!meta?.edgeDomains && model === "tenant-container") {
5891
+ patch.edgeDomains = {
5892
+ dev: `dev.${sub}.mutatismutandis.co`,
5893
+ rc: `rc.${sub}.mutatismutandis.co`,
5894
+ main: `${sub}.mutatismutandis.co`
5895
+ };
5896
+ }
5897
+ if (slug === "mmi-katip") {
5898
+ const required = ["GOOGLE_IMPERSONATE_USER", "GOOGLE_ADMIN_USER"];
5899
+ const current = contractByStage(meta?.requiredRuntimeSecrets);
5900
+ const next = {
5901
+ dev: ensureStageNames(current.dev, required),
5902
+ rc: ensureStageNames(current.rc, required),
5903
+ main: ensureStageNames(current.main, required)
5904
+ };
5905
+ if (JSON.stringify(current) !== JSON.stringify(next)) {
5906
+ patch.requiredRuntimeSecrets = next;
5907
+ }
5908
+ }
5909
+ return { slug, patch, appOwnedGaps: appGapsFor(meta, model, slug) };
5910
+ }
5911
+ async function buildV2Doctor(repoOrSlug, deps) {
5912
+ const slug = slugOfRepo(repoOrSlug);
5913
+ const repo = repoFrom(repoOrSlug, slug);
5914
+ const meta = await deps.getProject(slug);
5915
+ const model = defaultDeployModel(meta, repo);
5916
+ const autoHeal = buildV2HealPatch(repo, meta);
5917
+ if (!meta) {
5918
+ const emptySecrets = {
5919
+ dev: { required: [], present: [], missing: [] },
5920
+ rc: { required: [], present: [], missing: [] },
5921
+ main: { required: [], present: [], missing: [] }
5922
+ };
5923
+ const emptyCoords = {
5924
+ dev: { ok: false },
5925
+ rc: { ok: false },
5926
+ main: { ok: false }
5927
+ };
5928
+ return {
5929
+ ok: false,
5930
+ repo,
5931
+ slug,
5932
+ hubOwned: { meta: { ok: false, missing: [`PROJECT#${slug}/META`] }, deployCoords: emptyCoords, secrets: emptySecrets },
5933
+ autoHealAvailable: Object.keys(autoHeal.patch),
5934
+ appOwnedGaps: autoHeal.appOwnedGaps
5935
+ };
5936
+ }
5937
+ const presentSecrets = new Set(await deps.listSecrets(repo));
5938
+ const deployCoords = Object.fromEntries(await Promise.all(STAGES.map(async (stage2) => [stage2, { ok: await deps.hasDeployCoords(slug, stage2) }])));
5939
+ const secrets2 = Object.fromEntries(STAGES.map((stage2) => {
5940
+ const required = stageRequiredSecrets(stage2, meta).map((key) => stageKey(stage2, key));
5941
+ const present = required.filter((key) => presentSecrets.has(key));
5942
+ const missing = required.filter((key) => !presentSecrets.has(key));
5943
+ return [stage2, { required, present, missing }];
5944
+ }));
5945
+ const metaMissing = ["class", "deployModel", "vaultPath", "kbPointer", "oauth"].filter((key) => meta[key] === void 0);
5946
+ const ok = metaMissing.length === 0 && Object.values(deployCoords).every((v) => v.ok) && Object.values(secrets2).every((v) => v.missing.length === 0);
5947
+ return {
5948
+ ok,
5949
+ repo,
5950
+ slug,
5951
+ class: meta.class,
5952
+ deployModel: model,
5953
+ hubOwned: { meta: { ok: metaMissing.length === 0, missing: metaMissing }, deployCoords, secrets: secrets2 },
5954
+ autoHealAvailable: Object.keys(autoHeal.patch),
5955
+ appOwnedGaps: autoHeal.appOwnedGaps
5956
+ };
5957
+ }
5958
+ function renderReadinessIssueBody(existingBody, report, opts = {}) {
5959
+ const start = "<!-- mmi-v2-readiness:start -->";
5960
+ const end = "<!-- mmi-v2-readiness:end -->";
5961
+ const stageLines = STAGES.map((stage2) => {
5962
+ const coords = report.hubOwned.deployCoords[stage2].ok ? "coords ok" : "coords missing/unverified";
5963
+ const missing = report.hubOwned.secrets[stage2].missing;
5964
+ return `- ${stage2}: ${coords}; ${missing.length ? `missing ${missing.join(", ")}` : "required secret names present"}`;
5965
+ });
5966
+ const section = [
5967
+ start,
5968
+ "## Hub v2 readiness",
5969
+ "",
5970
+ `Repo: ${report.repo}`,
5971
+ `Deploy model: ${report.deployModel ?? "(unresolved)"}`,
5972
+ `Overall: ${report.ok ? "ready" : "not ready"}`,
5973
+ "",
5974
+ "### Hub-owned diagnosis",
5975
+ `- META: ${report.hubOwned.meta.ok ? "ok" : `missing ${report.hubOwned.meta.missing.join(", ")}`}`,
5976
+ ...stageLines,
5977
+ "",
5978
+ "### Auto-heal applied / available",
5979
+ ...opts.healed?.length ? opts.healed.map((x) => `- ${x}`) : report.autoHealAvailable.map((x) => `- ${x}`),
5980
+ "",
5981
+ "### App-owned implementation plan",
5982
+ ...report.appOwnedGaps.map((x) => `- ${x}`),
5983
+ end
5984
+ ].join("\n");
5985
+ const re = new RegExp(`${start}[\\s\\S]*?${end}`);
5986
+ return re.test(existingBody) ? existingBody.replace(re, section) : `${existingBody.trim()}
5987
+
5988
+ ${section}`.trim();
5989
+ }
5990
+
5607
5991
  // src/kb.ts
5608
5992
  var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
5609
5993
  function resolveKbSource(rawBase) {
@@ -5750,7 +6134,8 @@ async function planPull(deps, slug, opts = {}) {
5750
6134
  deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
5751
6135
  }
5752
6136
  async function planList(deps, opts = {}) {
5753
- const qs = opts.project ? `?${new URLSearchParams({ project: opts.project }).toString()}` : "";
6137
+ const project2 = opts.project ?? (opts.quiet ? await deps.project() : void 0);
6138
+ const qs = project2 ? `?${new URLSearchParams({ project: project2 }).toString()}` : "";
5754
6139
  let res;
5755
6140
  try {
5756
6141
  res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
@@ -5926,15 +6311,15 @@ async function secretsList(deps, opts) {
5926
6311
  const { secrets: secrets2 } = await res.json();
5927
6312
  deps.log(formatSecretList(secrets2 ?? []));
5928
6313
  }
5929
- var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6314
+ var DEFAULT_RUNTIME_SECRET_NAMES2 = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
5930
6315
  function stringList(v) {
5931
6316
  return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
5932
6317
  }
5933
6318
  function requiredRuntimeSecretNames(stage2, contract) {
5934
6319
  const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
5935
- return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
6320
+ return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES2, ...extra])];
5936
6321
  }
5937
- function stageKey(stage2, key) {
6322
+ function stageKey2(stage2, key) {
5938
6323
  return key.includes("/") ? key : `${stage2}/${key}`;
5939
6324
  }
5940
6325
  async function secretsPreflight(deps, opts) {
@@ -5957,7 +6342,7 @@ async function secretsPreflight(deps, opts) {
5957
6342
  }
5958
6343
  const { secrets: secrets2 } = await res.json();
5959
6344
  const present = new Set((secrets2 ?? []).map((s) => s.key));
5960
- const required = opts.required.map((key) => stageKey(opts.stage, key));
6345
+ const required = opts.required.map((key) => stageKey2(opts.stage, key));
5961
6346
  const missing = required.filter((key) => !present.has(key));
5962
6347
  if (missing.length) {
5963
6348
  deps.log(`missing ${missing.join(", ")}`);
@@ -6081,7 +6466,7 @@ var LOOPBACK = ["http://localhost", "http://127.0.0.1"];
6081
6466
  var SSM_ENVS = ["dev", "rc", "main"];
6082
6467
  var SSM_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6083
6468
  var uniq = (xs) => [...new Set(xs)];
6084
- function defaultSubdomain(slug) {
6469
+ function defaultSubdomain2(slug) {
6085
6470
  const i = slug.indexOf("-");
6086
6471
  return i === -1 ? slug : slug.slice(i + 1);
6087
6472
  }
@@ -6110,7 +6495,7 @@ function oauthSsmKeys() {
6110
6495
  }
6111
6496
  function parseOauthConfig(mmiConfig, slug) {
6112
6497
  const raw = mmiConfig?.oauth ?? {};
6113
- const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain(slug)];
6498
+ const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain2(slug)];
6114
6499
  const domains = Array.isArray(raw.domains) && raw.domains.length > 0 ? raw.domains.map(String) : [...DEFAULT_DOMAINS];
6115
6500
  const callbackPath = typeof raw.callbackPath === "string" && raw.callbackPath ? raw.callbackPath : DEFAULT_CALLBACK_PATH;
6116
6501
  if (!callbackPath.startsWith("/")) {
@@ -6270,7 +6655,15 @@ async function postCapture(capture, quiet = false) {
6270
6655
  // duplicate the note. Backend capture-latency root cause tracked in #255.
6271
6656
  signal: AbortSignal.timeout(2e4)
6272
6657
  });
6273
- if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
6658
+ let message = "";
6659
+ if (!res.ok) {
6660
+ try {
6661
+ const body = await res.clone().json();
6662
+ message = typeof body.message === "string" ? body.message : "";
6663
+ } catch {
6664
+ }
6665
+ }
6666
+ if (!quiet) console.log(res.ok ? "noted" : formatCaptureFailure(res.status, message));
6274
6667
  } catch (e) {
6275
6668
  if (!quiet) console.error(`mmi-cli saga: ${e.message}`);
6276
6669
  }
@@ -6332,34 +6725,6 @@ async function applyGcPlan(plan2, remote) {
6332
6725
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
6333
6726
  }
6334
6727
  }
6335
- async function cleanupLocalBranch(branch, before = {}) {
6336
- const result = { branchDeleted: false };
6337
- if (!branch) return result;
6338
- const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
6339
- const afterWorktrees = parseWorktreePorcelain(stdout);
6340
- const wtPath = selectPrMergeCleanupWorktree(branch, before.worktrees ?? [], afterWorktrees, before.startingPath);
6341
- const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...before.worktrees ?? []], wtPath);
6342
- const git = (args) => safeCwd ? execFileP3("git", ["-C", safeCwd, ...args], { timeout: GIT_TIMEOUT_MS }) : execFileP3("git", args, { timeout: GIT_TIMEOUT_MS });
6343
- if (wtPath) {
6344
- await git(["worktree", "remove", "--force", wtPath]).catch(() => {
6345
- });
6346
- result.worktreeRemoved = wtPath;
6347
- }
6348
- const current = safeCwd ? ((await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => ({ stdout: "" }))).stdout || "").trim() : await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
6349
- if (branch !== current) {
6350
- await git(["branch", "-D", branch]).then(() => {
6351
- result.branchDeleted = true;
6352
- }).catch(() => {
6353
- });
6354
- if (!result.branchDeleted) {
6355
- const remaining = await git(["branch", "--list", branch]).catch(() => ({ stdout: "" }));
6356
- result.branchDeleted = branchMissingFromList(branch, remaining.stdout || "");
6357
- }
6358
- }
6359
- if (wtPath) await git(["worktree", "prune"]).catch(() => {
6360
- });
6361
- return result;
6362
- }
6363
6728
  function resolveVersion() {
6364
6729
  try {
6365
6730
  const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
@@ -6405,14 +6770,43 @@ async function applyVersionAutoUpdate(report, log) {
6405
6770
  }
6406
6771
  }
6407
6772
  try {
6408
- const npm = process.platform === "win32" ? "npm.cmd" : "npm";
6409
6773
  log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
6410
- await execFileP3(npm, ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
6774
+ await runHostBin("npm", ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
6411
6775
  return { ...report, ok: true };
6412
6776
  } catch {
6413
6777
  return report;
6414
6778
  }
6415
6779
  }
6780
+ async function requireFreshTrainCli(commandName) {
6781
+ const report = buildVersionLagReport({
6782
+ currentVersion: resolveVersion(),
6783
+ repoVersion: readRepoVersion(),
6784
+ releasedVersion: await fetchReleasedVersion()
6785
+ });
6786
+ if (report.ok) return;
6787
+ const target = report.staleAgainst === "released" ? `released ${report.releasedVersion}` : `repo ${report.repoVersion}`;
6788
+ throw new Error(`running mmi-cli ${report.currentVersion} is stale against ${target}; run doctor/update first so ${commandName} uses the current train path`);
6789
+ }
6790
+ var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
6791
+ function runHostBin(bin, args, opts) {
6792
+ return isWin ? execFileP3("cmd.exe", ["/c", bin, ...args], opts) : execFileP3(bin, args, opts);
6793
+ }
6794
+ async function runClaudePlugin(args) {
6795
+ try {
6796
+ await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
6797
+ return true;
6798
+ } catch {
6799
+ return false;
6800
+ }
6801
+ }
6802
+ async function applyClaudePluginHeal(surface, log) {
6803
+ if (surface !== "claude-cli" && surface !== "claude-vscode") return false;
6804
+ log(" \u21BB updating the MMI plugin via `claude plugin` (marketplace \u2192 update \u2192 enable)\u2026");
6805
+ if (!await runClaudePlugin(["plugin", "marketplace", "update", "mmi"])) return false;
6806
+ if (!await runClaudePlugin(["plugin", "update", "mmi@mmi"])) return false;
6807
+ await runClaudePlugin(["plugin", "enable", "mmi@mmi"]);
6808
+ return true;
6809
+ }
6416
6810
  var program2 = new Command();
6417
6811
  program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
6418
6812
  var rules = program2.command("rules").description("org rules delivery");
@@ -6554,9 +6948,15 @@ saga.command("key").option("--json", "machine-readable output").description("pri
6554
6948
  async function probeBackend(url) {
6555
6949
  try {
6556
6950
  const res = await fetch(`${url}/saga/head`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
6557
- return res.ok;
6951
+ let message = "";
6952
+ try {
6953
+ const body = await res.clone().json();
6954
+ message = typeof body.message === "string" ? body.message : "";
6955
+ } catch {
6956
+ }
6957
+ return { reachable: res.ok || res.status === 403, status: res.status, message };
6558
6958
  } catch {
6559
- return false;
6959
+ return { reachable: false };
6560
6960
  }
6561
6961
  }
6562
6962
  async function probeSagaAccess(url, key) {
@@ -6574,9 +6974,18 @@ saga.command("health").option("--json", "machine-readable output").option("--ban
6574
6974
  const key = await sagaKey(cfg, session);
6575
6975
  const source = session.source;
6576
6976
  const identity = await githubLogin();
6577
- const reachable = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : false;
6578
- const authorized = cfg.sagaApiUrl && reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6579
- const report = buildHealth({ key, source, identity, reachable, authorized, sagaApiUrl: cfg.sagaApiUrl });
6977
+ const liveness = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : { reachable: false };
6978
+ const authorized = cfg.sagaApiUrl && liveness.reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6979
+ const report = buildHealth({
6980
+ key,
6981
+ source,
6982
+ identity,
6983
+ reachable: liveness.reachable,
6984
+ livenessStatus: liveness.status,
6985
+ livenessMessage: liveness.message,
6986
+ authorized,
6987
+ sagaApiUrl: cfg.sagaApiUrl
6988
+ });
6580
6989
  if (o.json) return console.log(JSON.stringify(report));
6581
6990
  if (o.banner) {
6582
6991
  const banner = healthBanner(report);
@@ -6851,6 +7260,47 @@ function reportWrite(label, res) {
6851
7260
  const detail = res.body?.error ?? "";
6852
7261
  fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
6853
7262
  }
7263
+ async function v2ReadinessDeps(cfg) {
7264
+ const reg = registryClientDeps(cfg);
7265
+ return {
7266
+ getProject: (slug) => fetchProjectBySlug(slug, reg),
7267
+ hasDeployCoords: async (slug, stage2) => {
7268
+ const status = await fetchDeployStatusBySlug(slug, reg);
7269
+ return Boolean(status?.[stage2]);
7270
+ },
7271
+ listSecrets: async (targetRepo2) => {
7272
+ const apiUrl = cfg.sagaApiUrl;
7273
+ if (!apiUrl) return [];
7274
+ const qs = new URLSearchParams({ repo: targetRepo2 }).toString();
7275
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
7276
+ method: "GET",
7277
+ headers: await sagaHeaders(),
7278
+ signal: AbortSignal.timeout(8e3)
7279
+ });
7280
+ if (!res.ok) return [];
7281
+ const body = await res.json();
7282
+ return (body.secrets ?? []).map((s) => s.key).filter(Boolean);
7283
+ }
7284
+ };
7285
+ }
7286
+ async function updateV2ReadinessIssue(repo, report, healed) {
7287
+ const title = "v2 readiness: central deploy + secrets alignment";
7288
+ const freshBody = renderReadinessIssueBody("", report, { healed });
7289
+ const list = await execFileP3("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
7290
+ const issues = JSON.parse(list.stdout || "[]");
7291
+ const existing = issues.find((i) => i.title.toLowerCase().includes("v2 readiness"));
7292
+ if (!existing) {
7293
+ const created = await execFileP3("gh", ["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"], { timeout: 2e4 });
7294
+ const url = created.stdout.trim();
7295
+ const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
7296
+ return { number, url, action: "created" };
7297
+ }
7298
+ const view = await execFileP3("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
7299
+ const current = JSON.parse(view.stdout || "{}");
7300
+ const nextBody = renderReadinessIssueBody(current.body ?? "", report, { healed });
7301
+ await execFileP3("gh", ["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody], { timeout: 2e4 });
7302
+ return { number: existing.number, url: current.url ?? `https://github.com/${repo}/issues/${existing.number}`, action: "updated" };
7303
+ }
6854
7304
  var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
6855
7305
  project.command("list").description("list all projects (identity + board, never deploy coords)").option("--json", "machine-readable output").action(async (o) => {
6856
7306
  const cfg = await loadConfig();
@@ -6879,6 +7329,38 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
6879
7329
  }
6880
7330
  fail(msg);
6881
7331
  });
7332
+ project.command("doctor <owner/repo>").description("diagnose Hub v2 readiness for a repo without reading product repo files").option("--v2", "run the v2 central deploy/secrets readiness contract").option("--json", "machine-readable output").action(async (repo, o) => {
7333
+ if (!o.v2) return fail("project doctor: pass --v2");
7334
+ const cfg = await loadConfig();
7335
+ const report = await buildV2Doctor(repo, await v2ReadinessDeps(cfg));
7336
+ console.log(JSON.stringify(report));
7337
+ if (!report.ok) process.exitCode = 1;
7338
+ });
7339
+ project.command("heal <owner/repo>").description("repair Hub-owned v2 readiness state only; never product repo files").option("--v2", "run the v2 central deploy/secrets readiness contract").option("--apply", "apply the Hub-owned registry patch").option("--json", "machine-readable output").action(async (repo, o) => {
7340
+ if (!o.v2) return fail("project heal: pass --v2");
7341
+ const cfg = await loadConfig();
7342
+ const slug = slugOf(repo);
7343
+ const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
7344
+ const plan2 = buildV2HealPatch(repo, meta);
7345
+ if (!o.apply) {
7346
+ console.log(JSON.stringify({ ok: true, slug, dryRun: true, patch: plan2.patch, appOwnedGaps: plan2.appOwnedGaps }));
7347
+ return;
7348
+ }
7349
+ if (!Object.keys(plan2.patch).length) {
7350
+ console.log(JSON.stringify({ ok: true, slug, applied: [], appOwnedGaps: plan2.appOwnedGaps }));
7351
+ return;
7352
+ }
7353
+ const res = await upsertProject(slug, plan2.patch, registryClientDeps(cfg));
7354
+ if (!res.ok) return reportWrite("project heal", res);
7355
+ console.log(JSON.stringify({ ok: true, slug, applied: Object.keys(plan2.patch), appOwnedGaps: plan2.appOwnedGaps, result: res.body }));
7356
+ });
7357
+ project.command("readiness <owner/repo>").description("update the repo v2 readiness issue with Hub diagnosis and app-owned tasks").option("--update-issue", "create/update the bounded v2 readiness issue section").option("--json", "machine-readable output").action(async (repo, o) => {
7358
+ if (!o.updateIssue) return fail("project readiness: pass --update-issue");
7359
+ const cfg = await loadConfig();
7360
+ const report = await buildV2Doctor(repo, await v2ReadinessDeps(cfg));
7361
+ const issue2 = await updateV2ReadinessIssue(repo, report, []);
7362
+ console.log(JSON.stringify({ ok: true, repo, issue: issue2, ready: report.ok }));
7363
+ });
6882
7364
  project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project META (idempotent merge; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--deploy-model <model>", "hub-serverless | serverless | tenant-container | content (release-train deploy path, #514)").option("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
6883
7365
  const cfg = await loadConfig();
6884
7366
  const slug = slugOf(repoOrSlug);
@@ -7038,6 +7520,15 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
7038
7520
  const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
7039
7521
  console.log(JSON.stringify(created));
7040
7522
  });
7523
+ async function remoteBranchExists(branch) {
7524
+ if (!branch) return void 0;
7525
+ try {
7526
+ const { stdout } = await execFileP3("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
7527
+ return stdout.trim().length > 0;
7528
+ } catch {
7529
+ return void 0;
7530
+ }
7531
+ }
7041
7532
  pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
7042
7533
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
7043
7534
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
@@ -7046,11 +7537,42 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
7046
7537
  const beforeWorktrees = parseWorktreePorcelain(
7047
7538
  (await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
7048
7539
  );
7540
+ const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
7541
+ let remoteDeleteAttempted = false;
7542
+ let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
7049
7543
  await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
7050
- if (!/used by worktree|cannot delete branch|already been merged/i.test(String(e.message || ""))) throw e;
7544
+ const message = String(e.message || "");
7545
+ if (/already been merged/i.test(message)) {
7546
+ remoteNotAttemptedReason = "pr-already-merged";
7547
+ return;
7548
+ }
7549
+ if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
7550
+ });
7551
+ if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
7552
+ const remoteAfter = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
7553
+ const remoteBranch = buildRemoteBranchCleanupReport(headRef, {
7554
+ attempted: remoteDeleteAttempted,
7555
+ existedBefore: remoteBefore,
7556
+ existsAfter: remoteAfter,
7557
+ reason: remoteNotAttemptedReason
7051
7558
  });
7052
- const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef, { worktrees: beforeWorktrees, startingPath });
7053
- console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
7559
+ const localCleanup = repoArgs.length ? {
7560
+ branch: headRef,
7561
+ localBranch: { name: headRef, status: "not-attempted", reason: "repo-option" },
7562
+ worktree: void 0
7563
+ } : await cleanupPrMergeLocalBranch(headRef, {
7564
+ beforeWorktrees,
7565
+ startingPath,
7566
+ execGit: async (args) => (await execFileP3("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
7567
+ });
7568
+ console.log(JSON.stringify({
7569
+ merged: number,
7570
+ branch: headRef,
7571
+ method: method.slice(2),
7572
+ remoteBranch,
7573
+ localBranch: localCleanup.localBranch,
7574
+ worktree: localCleanup.worktree
7575
+ }));
7054
7576
  });
7055
7577
  async function runBoardRead(o) {
7056
7578
  try {
@@ -7248,6 +7770,11 @@ program2.command("stage-live").description("explain that remote rc/live environm
7248
7770
  });
7249
7771
  for (const commandName of ["rcand", "release", "hotfix"]) {
7250
7772
  program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
7773
+ try {
7774
+ await requireFreshTrainCli(commandName);
7775
+ } catch (e) {
7776
+ return fail(`${commandName}: ${e.message}`);
7777
+ }
7251
7778
  if (o.apply) {
7252
7779
  if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
7253
7780
  try {
@@ -7489,7 +8016,7 @@ function writeGitignore(content) {
7489
8016
  return false;
7490
8017
  }
7491
8018
  }
7492
- 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) => {
8019
+ 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) => {
7493
8020
  if (opts.guide) {
7494
8021
  if (opts.json) console.log(JSON.stringify({ resources: [MMI_AGENTIC_ONBOARDING_GUIDE] }, null, 2));
7495
8022
  else console.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
@@ -7517,12 +8044,15 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7517
8044
  if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
7518
8045
  }
7519
8046
  checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
8047
+ const surface = detectSurface(process.env);
8048
+ const releasedVersion = await fetchReleasedVersion();
7520
8049
  let versionReport = buildVersionLagReport({
7521
8050
  currentVersion: resolveVersion(),
7522
8051
  repoVersion: readRepoVersion(),
7523
- releasedVersion: await fetchReleasedVersion()
8052
+ releasedVersion
7524
8053
  });
7525
8054
  if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
8055
+ if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
7526
8056
  checks.push(versionReport);
7527
8057
  const cfg = await loadConfig();
7528
8058
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
@@ -7574,7 +8104,30 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7574
8104
  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");
7575
8105
  }
7576
8106
  }
8107
+ if (!driftCheck.ok) driftCheck = { ...driftCheck, fix: pluginRecoveryFix(surface) };
7577
8108
  checks.push(driftCheck);
8109
+ let installedVersionCheck = buildInstalledPluginVersionCheck({
8110
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
8111
+ installed,
8112
+ releasedVersion,
8113
+ surface
8114
+ });
8115
+ if (!installedVersionCheck.ok && !opts.json) {
8116
+ if (await applyClaudePluginHeal(surface, (m) => console.error(m))) {
8117
+ const healed = buildInstalledPluginVersionCheck({
8118
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
8119
+ installed: readInstalledPlugins(),
8120
+ releasedVersion,
8121
+ surface
8122
+ });
8123
+ installedVersionCheck = healed;
8124
+ if (healed.ok && !opts.banner) {
8125
+ const reload = surface === "claude-vscode" ? "reopen the VS Code workspace" : "restart Claude Code (or run /reload-plugins)";
8126
+ console.error(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reload} to load the new commands`);
8127
+ }
8128
+ }
8129
+ }
8130
+ checks.push(installedVersionCheck);
7578
8131
  const gaps = checks.filter((c) => !c.ok);
7579
8132
  const resources = doctorResourcesForGaps(gaps);
7580
8133
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.5.1",
3
+ "version": "2.7.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",