@mutmutco/cli 2.6.0 → 2.8.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 +419 -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,173 @@ 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 sameNames(a, b) {
5880
+ const left = new Set(a ?? []);
5881
+ const right = new Set(b ?? []);
5882
+ if (left.size !== right.size) return false;
5883
+ return [...left].every((name) => right.has(name));
5884
+ }
5885
+ function sameStageContract(a, b) {
5886
+ return STAGES.every((stage2) => sameNames(a[stage2], b[stage2]));
5887
+ }
5888
+ function buildV2HealPatch(repoOrSlug, meta) {
5889
+ const slug = slugOfRepo(repoOrSlug);
5890
+ const repo = repoFrom(repoOrSlug, slug);
5891
+ const sub = defaultSubdomain(slug);
5892
+ const patch = {};
5893
+ const model = defaultDeployModel(meta, repo);
5894
+ if (!meta?.class) patch.class = "deployable";
5895
+ if (!meta?.deployModel) patch.deployModel = model;
5896
+ if (!meta?.vaultPath) patch.vaultPath = `/mmi-future/${slug}`;
5897
+ if (!meta?.kbPointer) patch.kbPointer = `kb/projects/${slug}.md`;
5898
+ if (!meta?.oauth) patch.oauth = { subdomains: [sub], callbackPath: "/auth/callback" };
5899
+ if (!meta?.edgeDomains && model === "tenant-container") {
5900
+ patch.edgeDomains = {
5901
+ dev: `dev.${sub}.mutatismutandis.co`,
5902
+ rc: `rc.${sub}.mutatismutandis.co`,
5903
+ main: `${sub}.mutatismutandis.co`
5904
+ };
5905
+ }
5906
+ if (slug === "mmi-katip") {
5907
+ const required = ["GOOGLE_IMPERSONATE_USER", "GOOGLE_ADMIN_USER"];
5908
+ const current = contractByStage(meta?.requiredRuntimeSecrets);
5909
+ const next = {
5910
+ dev: ensureStageNames(current.dev, required),
5911
+ rc: ensureStageNames(current.rc, required),
5912
+ main: ensureStageNames(current.main, required)
5913
+ };
5914
+ if (!sameStageContract(current, next)) {
5915
+ patch.requiredRuntimeSecrets = next;
5916
+ }
5917
+ }
5918
+ return { slug, patch, appOwnedGaps: appGapsFor(meta, model, slug) };
5919
+ }
5920
+ async function buildV2Doctor(repoOrSlug, deps) {
5921
+ const slug = slugOfRepo(repoOrSlug);
5922
+ const repo = repoFrom(repoOrSlug, slug);
5923
+ const meta = await deps.getProject(slug);
5924
+ const model = defaultDeployModel(meta, repo);
5925
+ const autoHeal = buildV2HealPatch(repo, meta);
5926
+ if (!meta) {
5927
+ const emptySecrets = {
5928
+ dev: { required: [], present: [], missing: [] },
5929
+ rc: { required: [], present: [], missing: [] },
5930
+ main: { required: [], present: [], missing: [] }
5931
+ };
5932
+ const emptyCoords = {
5933
+ dev: { ok: false },
5934
+ rc: { ok: false },
5935
+ main: { ok: false }
5936
+ };
5937
+ return {
5938
+ ok: false,
5939
+ repo,
5940
+ slug,
5941
+ hubOwned: { meta: { ok: false, missing: [`PROJECT#${slug}/META`] }, deployCoords: emptyCoords, secrets: emptySecrets },
5942
+ autoHealAvailable: Object.keys(autoHeal.patch),
5943
+ appOwnedGaps: autoHeal.appOwnedGaps
5944
+ };
5945
+ }
5946
+ const presentSecrets = new Set(await deps.listSecrets(repo));
5947
+ const deployCoords = Object.fromEntries(await Promise.all(STAGES.map(async (stage2) => [stage2, { ok: await deps.hasDeployCoords(slug, stage2) }])));
5948
+ const secrets2 = Object.fromEntries(STAGES.map((stage2) => {
5949
+ const required = stageRequiredSecrets(stage2, meta).map((key) => stageKey(stage2, key));
5950
+ const present = required.filter((key) => presentSecrets.has(key));
5951
+ const missing = required.filter((key) => !presentSecrets.has(key));
5952
+ return [stage2, { required, present, missing }];
5953
+ }));
5954
+ const metaMissing = ["class", "deployModel", "vaultPath", "kbPointer", "oauth"].filter((key) => meta[key] === void 0);
5955
+ const ok = metaMissing.length === 0 && Object.values(deployCoords).every((v) => v.ok) && Object.values(secrets2).every((v) => v.missing.length === 0);
5956
+ return {
5957
+ ok,
5958
+ repo,
5959
+ slug,
5960
+ class: meta.class,
5961
+ deployModel: model,
5962
+ hubOwned: { meta: { ok: metaMissing.length === 0, missing: metaMissing }, deployCoords, secrets: secrets2 },
5963
+ autoHealAvailable: Object.keys(autoHeal.patch),
5964
+ appOwnedGaps: autoHeal.appOwnedGaps
5965
+ };
5966
+ }
5967
+ function renderReadinessIssueBody(existingBody, report, opts = {}) {
5968
+ const start = "<!-- mmi-v2-readiness:start -->";
5969
+ const end = "<!-- mmi-v2-readiness:end -->";
5970
+ const stageLines = STAGES.map((stage2) => {
5971
+ const coords = report.hubOwned.deployCoords[stage2].ok ? "coords ok" : "coords missing/unverified";
5972
+ const missing = report.hubOwned.secrets[stage2].missing;
5973
+ return `- ${stage2}: ${coords}; ${missing.length ? `missing ${missing.join(", ")}` : "required secret names present"}`;
5974
+ });
5975
+ const section = [
5976
+ start,
5977
+ "## Hub v2 readiness",
5978
+ "",
5979
+ `Repo: ${report.repo}`,
5980
+ `Deploy model: ${report.deployModel ?? "(unresolved)"}`,
5981
+ `Overall: ${report.ok ? "ready" : "not ready"}`,
5982
+ "",
5983
+ "### Hub-owned diagnosis",
5984
+ `- META: ${report.hubOwned.meta.ok ? "ok" : `missing ${report.hubOwned.meta.missing.join(", ")}`}`,
5985
+ ...stageLines,
5986
+ "",
5987
+ "### Auto-heal applied / available",
5988
+ ...opts.healed?.length ? opts.healed.map((x) => `- ${x}`) : report.autoHealAvailable.map((x) => `- ${x}`),
5989
+ "",
5990
+ "### App-owned implementation plan",
5991
+ ...report.appOwnedGaps.map((x) => `- ${x}`),
5992
+ end
5993
+ ].join("\n");
5994
+ const re = new RegExp(`${start}[\\s\\S]*?${end}`);
5995
+ return re.test(existingBody) ? existingBody.replace(re, section) : `${existingBody.trim()}
5996
+
5997
+ ${section}`.trim();
5998
+ }
5999
+
5744
6000
  // src/kb.ts
5745
6001
  var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
5746
6002
  function resolveKbSource(rawBase) {
@@ -5887,7 +6143,8 @@ async function planPull(deps, slug, opts = {}) {
5887
6143
  deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
5888
6144
  }
5889
6145
  async function planList(deps, opts = {}) {
5890
- const qs = opts.project ? `?${new URLSearchParams({ project: opts.project }).toString()}` : "";
6146
+ const project2 = opts.project ?? (opts.quiet ? await deps.project() : void 0);
6147
+ const qs = project2 ? `?${new URLSearchParams({ project: project2 }).toString()}` : "";
5891
6148
  let res;
5892
6149
  try {
5893
6150
  res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
@@ -6063,15 +6320,15 @@ async function secretsList(deps, opts) {
6063
6320
  const { secrets: secrets2 } = await res.json();
6064
6321
  deps.log(formatSecretList(secrets2 ?? []));
6065
6322
  }
6066
- var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6323
+ var DEFAULT_RUNTIME_SECRET_NAMES2 = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6067
6324
  function stringList(v) {
6068
6325
  return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
6069
6326
  }
6070
6327
  function requiredRuntimeSecretNames(stage2, contract) {
6071
6328
  const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
6072
- return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
6329
+ return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES2, ...extra])];
6073
6330
  }
6074
- function stageKey(stage2, key) {
6331
+ function stageKey2(stage2, key) {
6075
6332
  return key.includes("/") ? key : `${stage2}/${key}`;
6076
6333
  }
6077
6334
  async function secretsPreflight(deps, opts) {
@@ -6094,7 +6351,7 @@ async function secretsPreflight(deps, opts) {
6094
6351
  }
6095
6352
  const { secrets: secrets2 } = await res.json();
6096
6353
  const present = new Set((secrets2 ?? []).map((s) => s.key));
6097
- const required = opts.required.map((key) => stageKey(opts.stage, key));
6354
+ const required = opts.required.map((key) => stageKey2(opts.stage, key));
6098
6355
  const missing = required.filter((key) => !present.has(key));
6099
6356
  if (missing.length) {
6100
6357
  deps.log(`missing ${missing.join(", ")}`);
@@ -6218,7 +6475,7 @@ var LOOPBACK = ["http://localhost", "http://127.0.0.1"];
6218
6475
  var SSM_ENVS = ["dev", "rc", "main"];
6219
6476
  var SSM_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
6220
6477
  var uniq = (xs) => [...new Set(xs)];
6221
- function defaultSubdomain(slug) {
6478
+ function defaultSubdomain2(slug) {
6222
6479
  const i = slug.indexOf("-");
6223
6480
  return i === -1 ? slug : slug.slice(i + 1);
6224
6481
  }
@@ -6247,7 +6504,7 @@ function oauthSsmKeys() {
6247
6504
  }
6248
6505
  function parseOauthConfig(mmiConfig, slug) {
6249
6506
  const raw = mmiConfig?.oauth ?? {};
6250
- const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain(slug)];
6507
+ const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain2(slug)];
6251
6508
  const domains = Array.isArray(raw.domains) && raw.domains.length > 0 ? raw.domains.map(String) : [...DEFAULT_DOMAINS];
6252
6509
  const callbackPath = typeof raw.callbackPath === "string" && raw.callbackPath ? raw.callbackPath : DEFAULT_CALLBACK_PATH;
6253
6510
  if (!callbackPath.startsWith("/")) {
@@ -6407,7 +6664,15 @@ async function postCapture(capture, quiet = false) {
6407
6664
  // duplicate the note. Backend capture-latency root cause tracked in #255.
6408
6665
  signal: AbortSignal.timeout(2e4)
6409
6666
  });
6410
- if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
6667
+ let message = "";
6668
+ if (!res.ok) {
6669
+ try {
6670
+ const body = await res.clone().json();
6671
+ message = typeof body.message === "string" ? body.message : "";
6672
+ } catch {
6673
+ }
6674
+ }
6675
+ if (!quiet) console.log(res.ok ? "noted" : formatCaptureFailure(res.status, message));
6411
6676
  } catch (e) {
6412
6677
  if (!quiet) console.error(`mmi-cli saga: ${e.message}`);
6413
6678
  }
@@ -6469,34 +6734,6 @@ async function applyGcPlan(plan2, remote) {
6469
6734
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
6470
6735
  }
6471
6736
  }
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
6737
  function resolveVersion() {
6501
6738
  try {
6502
6739
  const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
@@ -6542,39 +6779,41 @@ async function applyVersionAutoUpdate(report, log) {
6542
6779
  }
6543
6780
  }
6544
6781
  try {
6545
- const npm = process.platform === "win32" ? "npm.cmd" : "npm";
6546
6782
  log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
6547
- await execFileP3(npm, ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
6783
+ await runHostBin("npm", ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
6548
6784
  return { ...report, ok: true };
6549
6785
  } catch {
6550
6786
  return report;
6551
6787
  }
6552
6788
  }
6553
- var CLAUDE_PLUGIN_TIMEOUT_MS = 6e4;
6789
+ async function requireFreshTrainCli(commandName) {
6790
+ const report = buildVersionLagReport({
6791
+ currentVersion: resolveVersion(),
6792
+ repoVersion: readRepoVersion(),
6793
+ releasedVersion: await fetchReleasedVersion()
6794
+ });
6795
+ if (report.ok) return;
6796
+ const target = report.staleAgainst === "released" ? `released ${report.releasedVersion}` : `repo ${report.repoVersion}`;
6797
+ throw new Error(`running mmi-cli ${report.currentVersion} is stale against ${target}; run doctor/update first so ${commandName} uses the current train path`);
6798
+ }
6799
+ var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
6800
+ function runHostBin(bin, args, opts) {
6801
+ return isWin ? execFileP3("cmd.exe", ["/c", bin, ...args], opts) : execFileP3(bin, args, opts);
6802
+ }
6554
6803
  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
- }
6804
+ try {
6805
+ await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
6806
+ return true;
6807
+ } catch {
6808
+ return false;
6564
6809
  }
6565
- return false;
6566
6810
  }
6567
6811
  async function applyClaudePluginHeal(surface, log) {
6568
6812
  if (surface !== "claude-cli" && surface !== "claude-vscode") return false;
6569
6813
  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
- }
6814
+ if (!await runClaudePlugin(["plugin", "marketplace", "update", "mmi"])) return false;
6815
+ if (!await runClaudePlugin(["plugin", "update", "mmi@mmi"])) return false;
6816
+ await runClaudePlugin(["plugin", "enable", "mmi@mmi"]);
6578
6817
  return true;
6579
6818
  }
6580
6819
  var program2 = new Command();
@@ -7030,6 +7269,47 @@ function reportWrite(label, res) {
7030
7269
  const detail = res.body?.error ?? "";
7031
7270
  fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
7032
7271
  }
7272
+ async function v2ReadinessDeps(cfg) {
7273
+ const reg = registryClientDeps(cfg);
7274
+ return {
7275
+ getProject: (slug) => fetchProjectBySlug(slug, reg),
7276
+ hasDeployCoords: async (slug, stage2) => {
7277
+ const status = await fetchDeployStatusBySlug(slug, reg);
7278
+ return Boolean(status?.[stage2]);
7279
+ },
7280
+ listSecrets: async (targetRepo2) => {
7281
+ const apiUrl = cfg.sagaApiUrl;
7282
+ if (!apiUrl) return [];
7283
+ const qs = new URLSearchParams({ repo: targetRepo2 }).toString();
7284
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
7285
+ method: "GET",
7286
+ headers: await sagaHeaders(),
7287
+ signal: AbortSignal.timeout(8e3)
7288
+ });
7289
+ if (!res.ok) return [];
7290
+ const body = await res.json();
7291
+ return (body.secrets ?? []).map((s) => s.key).filter(Boolean);
7292
+ }
7293
+ };
7294
+ }
7295
+ async function updateV2ReadinessIssue(repo, report, healed) {
7296
+ const title = "v2 readiness: central deploy + secrets alignment";
7297
+ const freshBody = renderReadinessIssueBody("", report, { healed });
7298
+ const list = await execFileP3("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
7299
+ const issues = JSON.parse(list.stdout || "[]");
7300
+ const existing = issues.find((i) => i.title.toLowerCase().includes("v2 readiness"));
7301
+ if (!existing) {
7302
+ const created = await execFileP3("gh", ["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"], { timeout: 2e4 });
7303
+ const url = created.stdout.trim();
7304
+ const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
7305
+ return { number, url, action: "created" };
7306
+ }
7307
+ const view = await execFileP3("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
7308
+ const current = JSON.parse(view.stdout || "{}");
7309
+ const nextBody = renderReadinessIssueBody(current.body ?? "", report, { healed });
7310
+ await execFileP3("gh", ["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody], { timeout: 2e4 });
7311
+ return { number: existing.number, url: current.url ?? `https://github.com/${repo}/issues/${existing.number}`, action: "updated" };
7312
+ }
7033
7313
  var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
7034
7314
  project.command("list").description("list all projects (identity + board, never deploy coords)").option("--json", "machine-readable output").action(async (o) => {
7035
7315
  const cfg = await loadConfig();
@@ -7058,6 +7338,38 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
7058
7338
  }
7059
7339
  fail(msg);
7060
7340
  });
7341
+ 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) => {
7342
+ if (!o.v2) return fail("project doctor: pass --v2");
7343
+ const cfg = await loadConfig();
7344
+ const report = await buildV2Doctor(repo, await v2ReadinessDeps(cfg));
7345
+ console.log(JSON.stringify(report));
7346
+ if (!report.ok) process.exitCode = 1;
7347
+ });
7348
+ 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) => {
7349
+ if (!o.v2) return fail("project heal: pass --v2");
7350
+ const cfg = await loadConfig();
7351
+ const slug = slugOf(repo);
7352
+ const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
7353
+ const plan2 = buildV2HealPatch(repo, meta);
7354
+ if (!o.apply) {
7355
+ console.log(JSON.stringify({ ok: true, slug, dryRun: true, patch: plan2.patch, appOwnedGaps: plan2.appOwnedGaps }));
7356
+ return;
7357
+ }
7358
+ if (!Object.keys(plan2.patch).length) {
7359
+ console.log(JSON.stringify({ ok: true, slug, applied: [], appOwnedGaps: plan2.appOwnedGaps }));
7360
+ return;
7361
+ }
7362
+ const res = await upsertProject(slug, plan2.patch, registryClientDeps(cfg));
7363
+ if (!res.ok) return reportWrite("project heal", res);
7364
+ console.log(JSON.stringify({ ok: true, slug, applied: Object.keys(plan2.patch), appOwnedGaps: plan2.appOwnedGaps, result: res.body }));
7365
+ });
7366
+ 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) => {
7367
+ if (!o.updateIssue) return fail("project readiness: pass --update-issue");
7368
+ const cfg = await loadConfig();
7369
+ const report = await buildV2Doctor(repo, await v2ReadinessDeps(cfg));
7370
+ const issue2 = await updateV2ReadinessIssue(repo, report, []);
7371
+ console.log(JSON.stringify({ ok: true, repo, issue: issue2, ready: report.ok }));
7372
+ });
7061
7373
  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
7374
  const cfg = await loadConfig();
7063
7375
  const slug = slugOf(repoOrSlug);
@@ -7217,6 +7529,15 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
7217
7529
  const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
7218
7530
  console.log(JSON.stringify(created));
7219
7531
  });
7532
+ async function remoteBranchExists(branch) {
7533
+ if (!branch) return void 0;
7534
+ try {
7535
+ const { stdout } = await execFileP3("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
7536
+ return stdout.trim().length > 0;
7537
+ } catch {
7538
+ return void 0;
7539
+ }
7540
+ }
7220
7541
  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
7542
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
7222
7543
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
@@ -7225,11 +7546,42 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
7225
7546
  const beforeWorktrees = parseWorktreePorcelain(
7226
7547
  (await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
7227
7548
  );
7549
+ const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
7550
+ let remoteDeleteAttempted = false;
7551
+ let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
7228
7552
  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;
7553
+ const message = String(e.message || "");
7554
+ if (/already been merged/i.test(message)) {
7555
+ remoteNotAttemptedReason = "pr-already-merged";
7556
+ return;
7557
+ }
7558
+ if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
7559
+ });
7560
+ if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
7561
+ const remoteAfter = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
7562
+ const remoteBranch = buildRemoteBranchCleanupReport(headRef, {
7563
+ attempted: remoteDeleteAttempted,
7564
+ existedBefore: remoteBefore,
7565
+ existsAfter: remoteAfter,
7566
+ reason: remoteNotAttemptedReason
7567
+ });
7568
+ const localCleanup = repoArgs.length ? {
7569
+ branch: headRef,
7570
+ localBranch: { name: headRef, status: "not-attempted", reason: "repo-option" },
7571
+ worktree: void 0
7572
+ } : await cleanupPrMergeLocalBranch(headRef, {
7573
+ beforeWorktrees,
7574
+ startingPath,
7575
+ execGit: async (args) => (await execFileP3("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
7230
7576
  });
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 }));
7577
+ console.log(JSON.stringify({
7578
+ merged: number,
7579
+ branch: headRef,
7580
+ method: method.slice(2),
7581
+ remoteBranch,
7582
+ localBranch: localCleanup.localBranch,
7583
+ worktree: localCleanup.worktree
7584
+ }));
7233
7585
  });
7234
7586
  async function runBoardRead(o) {
7235
7587
  try {
@@ -7427,6 +7779,11 @@ program2.command("stage-live").description("explain that remote rc/live environm
7427
7779
  });
7428
7780
  for (const commandName of ["rcand", "release", "hotfix"]) {
7429
7781
  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) => {
7782
+ try {
7783
+ await requireFreshTrainCli(commandName);
7784
+ } catch (e) {
7785
+ return fail(`${commandName}: ${e.message}`);
7786
+ }
7430
7787
  if (o.apply) {
7431
7788
  if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
7432
7789
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.6.0",
3
+ "version": "2.8.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",