@mutmutco/cli 2.6.0 → 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 +410 -62
  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
@@ -3341,6 +3341,7 @@ function resumeCue() {
3341
3341
 
3342
3342
  // src/saga-note.ts
3343
3343
  var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
3344
+ var ROUTE_LEVEL_403 = "saga API route-level 403 from GitHubAuthorizer cache/policy";
3344
3345
  function agentSurface() {
3345
3346
  const surface = process.env.MMI_AGENT_SURFACE || "claude";
3346
3347
  if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
@@ -3372,6 +3373,10 @@ function buildNoteCapture(summary, o, id, evidence) {
3372
3373
  anchorForce: o.anchorForce || void 0
3373
3374
  };
3374
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
+ }
3375
3380
 
3376
3381
  // src/version-lag.ts
3377
3382
  var VERSION_LABEL = "installed plugin/adapter cache freshness";
@@ -4246,6 +4251,13 @@ function ghError(e) {
4246
4251
  }
4247
4252
 
4248
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
+ }
4249
4261
  var DEFAULT_PROTECTED = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
4250
4262
  function groupedPrs(prs) {
4251
4263
  const out = /* @__PURE__ */ new Map();
@@ -4353,6 +4365,63 @@ function branchMissingFromList(branch, stdout) {
4353
4365
  const names = stdout.split(/\r?\n/).map((line) => line.replace(/^\*\s*/, "").trim()).filter(Boolean);
4354
4366
  return !names.includes(branch);
4355
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
+ }
4356
4425
  function formatGcPlan(plan2, apply) {
4357
4426
  const lines = [`mmi-cli gc: ${apply ? "apply" : "dry-run"}`];
4358
4427
  if (!plan2.branches.length && !plan2.trackingRefs.length) lines.push("nothing to clean");
@@ -4403,8 +4472,9 @@ function trainPlan(command) {
4403
4472
  { label: "verify current branch is development", gated: true },
4404
4473
  { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4405
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 },
4406
4476
  { label: "merge development to rc", gated: true },
4407
- { 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 }
4408
4478
  ];
4409
4479
  }
4410
4480
  if (command === "release") {
@@ -4414,8 +4484,9 @@ function trainPlan(command) {
4414
4484
  { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4415
4485
  { label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
4416
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 },
4417
4488
  { label: "tag release and publish GitHub Release", gated: true },
4418
- { 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 },
4419
4490
  { label: "roll development forward", gated: true }
4420
4491
  ];
4421
4492
  }
@@ -5695,6 +5766,24 @@ async function fetchProjectBySlug(slug, deps) {
5695
5766
  return null;
5696
5767
  }
5697
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
+ }
5698
5787
  async function fetchOrgConfig(deps) {
5699
5788
  if (!deps.baseUrl) return null;
5700
5789
  const token = await deps.token();
@@ -5741,6 +5830,164 @@ async function upsertProject(slug, patch, deps) {
5741
5830
  return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
5742
5831
  }
5743
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
+
5744
5991
  // src/kb.ts
5745
5992
  var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
5746
5993
  function resolveKbSource(rawBase) {
@@ -5887,7 +6134,8 @@ async function planPull(deps, slug, opts = {}) {
5887
6134
  deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
5888
6135
  }
5889
6136
  async function planList(deps, opts = {}) {
5890
- 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()}` : "";
5891
6139
  let res;
5892
6140
  try {
5893
6141
  res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
@@ -6063,15 +6311,15 @@ async function secretsList(deps, opts) {
6063
6311
  const { secrets: secrets2 } = await res.json();
6064
6312
  deps.log(formatSecretList(secrets2 ?? []));
6065
6313
  }
6066
- var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6314
+ var DEFAULT_RUNTIME_SECRET_NAMES2 = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6067
6315
  function stringList(v) {
6068
6316
  return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
6069
6317
  }
6070
6318
  function requiredRuntimeSecretNames(stage2, contract) {
6071
6319
  const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
6072
- return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
6320
+ return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES2, ...extra])];
6073
6321
  }
6074
- function stageKey(stage2, key) {
6322
+ function stageKey2(stage2, key) {
6075
6323
  return key.includes("/") ? key : `${stage2}/${key}`;
6076
6324
  }
6077
6325
  async function secretsPreflight(deps, opts) {
@@ -6094,7 +6342,7 @@ async function secretsPreflight(deps, opts) {
6094
6342
  }
6095
6343
  const { secrets: secrets2 } = await res.json();
6096
6344
  const present = new Set((secrets2 ?? []).map((s) => s.key));
6097
- const required = opts.required.map((key) => stageKey(opts.stage, key));
6345
+ const required = opts.required.map((key) => stageKey2(opts.stage, key));
6098
6346
  const missing = required.filter((key) => !present.has(key));
6099
6347
  if (missing.length) {
6100
6348
  deps.log(`missing ${missing.join(", ")}`);
@@ -6218,7 +6466,7 @@ var LOOPBACK = ["http://localhost", "http://127.0.0.1"];
6218
6466
  var SSM_ENVS = ["dev", "rc", "main"];
6219
6467
  var SSM_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6220
6468
  var uniq = (xs) => [...new Set(xs)];
6221
- function defaultSubdomain(slug) {
6469
+ function defaultSubdomain2(slug) {
6222
6470
  const i = slug.indexOf("-");
6223
6471
  return i === -1 ? slug : slug.slice(i + 1);
6224
6472
  }
@@ -6247,7 +6495,7 @@ function oauthSsmKeys() {
6247
6495
  }
6248
6496
  function parseOauthConfig(mmiConfig, slug) {
6249
6497
  const raw = mmiConfig?.oauth ?? {};
6250
- 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)];
6251
6499
  const domains = Array.isArray(raw.domains) && raw.domains.length > 0 ? raw.domains.map(String) : [...DEFAULT_DOMAINS];
6252
6500
  const callbackPath = typeof raw.callbackPath === "string" && raw.callbackPath ? raw.callbackPath : DEFAULT_CALLBACK_PATH;
6253
6501
  if (!callbackPath.startsWith("/")) {
@@ -6407,7 +6655,15 @@ async function postCapture(capture, quiet = false) {
6407
6655
  // duplicate the note. Backend capture-latency root cause tracked in #255.
6408
6656
  signal: AbortSignal.timeout(2e4)
6409
6657
  });
6410
- 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));
6411
6667
  } catch (e) {
6412
6668
  if (!quiet) console.error(`mmi-cli saga: ${e.message}`);
6413
6669
  }
@@ -6469,34 +6725,6 @@ async function applyGcPlan(plan2, remote) {
6469
6725
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
6470
6726
  }
6471
6727
  }
6472
- async function cleanupLocalBranch(branch, before = {}) {
6473
- const result = { branchDeleted: false };
6474
- if (!branch) return result;
6475
- const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
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(() => {
6482
- });
6483
- result.worktreeRemoved = wtPath;
6484
- }
6485
- const current = safeCwd ? ((await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => ({ stdout: "" }))).stdout || "").trim() : await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
6486
- if (branch !== current) {
6487
- await git(["branch", "-D", branch]).then(() => {
6488
- result.branchDeleted = true;
6489
- }).catch(() => {
6490
- });
6491
- if (!result.branchDeleted) {
6492
- const remaining = await git(["branch", "--list", branch]).catch(() => ({ stdout: "" }));
6493
- result.branchDeleted = branchMissingFromList(branch, remaining.stdout || "");
6494
- }
6495
- }
6496
- if (wtPath) await git(["worktree", "prune"]).catch(() => {
6497
- });
6498
- return result;
6499
- }
6500
6728
  function resolveVersion() {
6501
6729
  try {
6502
6730
  const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
@@ -6542,39 +6770,41 @@ async function applyVersionAutoUpdate(report, log) {
6542
6770
  }
6543
6771
  }
6544
6772
  try {
6545
- const npm = process.platform === "win32" ? "npm.cmd" : "npm";
6546
6773
  log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
6547
- 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 });
6548
6775
  return { ...report, ok: true };
6549
6776
  } catch {
6550
6777
  return report;
6551
6778
  }
6552
6779
  }
6553
- var CLAUDE_PLUGIN_TIMEOUT_MS = 6e4;
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
+ }
6554
6794
  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
- }
6795
+ try {
6796
+ await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
6797
+ return true;
6798
+ } catch {
6799
+ return false;
6564
6800
  }
6565
- return false;
6566
6801
  }
6567
6802
  async function applyClaudePluginHeal(surface, log) {
6568
6803
  if (surface !== "claude-cli" && surface !== "claude-vscode") return false;
6569
6804
  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
- }
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"]);
6578
6808
  return true;
6579
6809
  }
6580
6810
  var program2 = new Command();
@@ -7030,6 +7260,47 @@ function reportWrite(label, res) {
7030
7260
  const detail = res.body?.error ?? "";
7031
7261
  fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
7032
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
+ }
7033
7304
  var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
7034
7305
  project.command("list").description("list all projects (identity + board, never deploy coords)").option("--json", "machine-readable output").action(async (o) => {
7035
7306
  const cfg = await loadConfig();
@@ -7058,6 +7329,38 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
7058
7329
  }
7059
7330
  fail(msg);
7060
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
+ });
7061
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) => {
7062
7365
  const cfg = await loadConfig();
7063
7366
  const slug = slugOf(repoOrSlug);
@@ -7217,6 +7520,15 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
7217
7520
  const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
7218
7521
  console.log(JSON.stringify(created));
7219
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
+ }
7220
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) => {
7221
7533
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
7222
7534
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
@@ -7225,11 +7537,42 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
7225
7537
  const beforeWorktrees = parseWorktreePorcelain(
7226
7538
  (await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
7227
7539
  );
7540
+ const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
7541
+ let remoteDeleteAttempted = false;
7542
+ let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
7228
7543
  await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
7229
- 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
7558
+ });
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
7230
7567
  });
7231
- const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef, { worktrees: beforeWorktrees, startingPath });
7232
- console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
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
+ }));
7233
7576
  });
7234
7577
  async function runBoardRead(o) {
7235
7578
  try {
@@ -7427,6 +7770,11 @@ program2.command("stage-live").description("explain that remote rc/live environm
7427
7770
  });
7428
7771
  for (const commandName of ["rcand", "release", "hotfix"]) {
7429
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
+ }
7430
7778
  if (o.apply) {
7431
7779
  if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
7432
7780
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.6.0",
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",