@mutmutco/cli 3.27.1 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.cjs +540 -77
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -9252,6 +9252,22 @@ function parseGhPrChecksResult(stdout, stderr) {
9252
9252
  function pollStateFromParsed(parsed) {
9253
9253
  return parsed === "error" ? "failure" : parsed;
9254
9254
  }
9255
+ var PR_MERGEABLE_UNKNOWN_POLL_RETRIES = 4;
9256
+ var PR_MERGEABLE_UNKNOWN_POLL_MS = 3e3;
9257
+ async function resolveSettledMergeableState(pollMergeable, sleep2) {
9258
+ for (let attempt = 1; attempt <= PR_MERGEABLE_UNKNOWN_POLL_RETRIES; attempt++) {
9259
+ const state = await pollMergeable();
9260
+ if (state !== "UNKNOWN") return state;
9261
+ if (attempt < PR_MERGEABLE_UNKNOWN_POLL_RETRIES) await sleep2(PR_MERGEABLE_UNKNOWN_POLL_MS);
9262
+ }
9263
+ return "UNKNOWN";
9264
+ }
9265
+ function conflictingPrMessage(baseBranch) {
9266
+ return `PR is CONFLICTING with ${baseBranch} \u2014 GitHub will not queue checks until the conflict is resolved. Resolve with: git fetch origin ${baseBranch} && git rebase origin/${baseBranch}`;
9267
+ }
9268
+ function conflictingResult(policy, baseBranch, waitedMs) {
9269
+ return { policy, status: "conflicting", reason: conflictingPrMessage(baseBranch), detail: "conflicting", waitedMs };
9270
+ }
9255
9271
  var PR_CHECKS_TIMEOUT_EXIT_CODE = 2;
9256
9272
  var PR_CHECKS_POLL_MS = 3e4;
9257
9273
  var PR_CHECKS_TIMEOUT_MS = 30 * 6e4;
@@ -9276,6 +9292,7 @@ function decidePrMergeNoCiGuard(checks, policyReason = "registry META ci:none")
9276
9292
  }
9277
9293
  async function waitForPrChecks(deps) {
9278
9294
  let { policy, reason } = await deps.resolvePolicy();
9295
+ const baseBranch = deps.baseBranch ?? "development";
9279
9296
  const queuedStates = [];
9280
9297
  if (policy === "no-ci") {
9281
9298
  const firstState = await deps.pollChecks();
@@ -9287,6 +9304,10 @@ async function waitForPrChecks(deps) {
9287
9304
  policy = "wait-for-checks";
9288
9305
  queuedStates.push(firstState);
9289
9306
  }
9307
+ if (deps.pollMergeable) {
9308
+ const mergeable = await resolveSettledMergeableState(deps.pollMergeable, deps.sleep);
9309
+ if (mergeable === "CONFLICTING") return conflictingResult(policy, baseBranch, 0);
9310
+ }
9290
9311
  const now = deps.now ?? (() => Date.now());
9291
9312
  const timeoutMs = deps.timeoutMs ?? PR_CHECKS_TIMEOUT_MS;
9292
9313
  const started = now();
@@ -9303,6 +9324,10 @@ async function waitForPrChecks(deps) {
9303
9324
  let failureStreak = 0;
9304
9325
  report("starting");
9305
9326
  while (now() < deadline) {
9327
+ if (deps.pollMergeable) {
9328
+ const mergeable = await deps.pollMergeable();
9329
+ if (mergeable === "CONFLICTING") return conflictingResult(policy, baseBranch, now() - started);
9330
+ }
9306
9331
  const state = queuedStates.shift() ?? await deps.pollChecks();
9307
9332
  report(state);
9308
9333
  if (state !== "success") successStreak = 0;
@@ -10454,6 +10479,12 @@ async function runPrLand(prNumber, options, deps) {
10454
10479
  base.ciPolicy = ciPolicy;
10455
10480
  const checksWait = await deps.waitForChecks(prNumber, repo);
10456
10481
  base.checksWait = checksWait;
10482
+ if (checksWait.status === "conflicting") {
10483
+ return {
10484
+ ...base,
10485
+ error: `checks-wait conflicting: ${checksWait.reason ?? "PR is conflicting with the base branch"}`
10486
+ };
10487
+ }
10457
10488
  if (checksWait.status === "failure" || checksWait.status === "timeout") {
10458
10489
  return {
10459
10490
  ...base,
@@ -16632,23 +16663,20 @@ var DEPLOY_SUBSTRATES = ["hetzner-ssh"];
16632
16663
  var DEPLOY_STAGES = ["dev", "rc", "main"];
16633
16664
  var DEPLOY_DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
16634
16665
  var DEPLOY_SHELL_SAFE_RE = /^[A-Za-z0-9./:_?&=%-]*$/;
16635
- function buildSetDeployPatch(slug, input) {
16666
+ function buildSetDeployPatch(_slug, input) {
16636
16667
  const clean4 = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : void 0;
16637
16668
  const stage2 = clean4(input.stage);
16638
16669
  if (!stage2 || !DEPLOY_STAGES.includes(stage2)) {
16639
16670
  throw new Error(`project set-deploy: --stage must be one of: ${DEPLOY_STAGES.join(", ")}`);
16640
16671
  }
16641
- const substrate = clean4(input.substrate) ?? "hetzner-ssh";
16642
- if (!DEPLOY_SUBSTRATES.includes(substrate)) {
16672
+ const substrate = clean4(input.substrate);
16673
+ if (substrate !== void 0 && !DEPLOY_SUBSTRATES.includes(substrate)) {
16643
16674
  throw new Error(`project set-deploy: --substrate must be one of: ${DEPLOY_SUBSTRATES.join(", ")}`);
16644
16675
  }
16645
16676
  const sshHost = clean4(input.sshHost);
16646
- if (substrate === "hetzner-ssh" && !sshHost) {
16647
- throw new Error("project set-deploy: hetzner-ssh requires --ssh-host");
16648
- }
16649
- const sshUser = clean4(input.sshUser) ?? "root";
16650
- const deployPath = clean4(input.deployPath) ?? `/opt/mmi/${slug}/${stage2}`;
16651
- const serviceName = clean4(input.service) ?? slug;
16677
+ const sshUser = clean4(input.sshUser);
16678
+ const deployPath = clean4(input.deployPath);
16679
+ const serviceName = clean4(input.service);
16652
16680
  for (const [label, v] of [["--ssh-host", sshHost], ["--ssh-user", sshUser], ["--deploy-path", deployPath], ["--service", serviceName]]) {
16653
16681
  if (v !== void 0 && !DEPLOY_SHELL_SAFE_RE.test(v)) throw new Error(`project set-deploy: ${label} contains unsafe characters`);
16654
16682
  }
@@ -16664,19 +16692,24 @@ function buildSetDeployPatch(slug, input) {
16664
16692
  if (!DEPLOY_DOMAIN_RE.test(a)) throw new Error(`project set-deploy: --alias must be a DNS hostname, got ${JSON.stringify(a)}`);
16665
16693
  }
16666
16694
  const uniqueAliases = [...new Set(aliases)];
16695
+ if (input.clearAliases && uniqueAliases.length) {
16696
+ throw new Error("project set-deploy: --clear-aliases cannot be combined with --alias (they contradict)");
16697
+ }
16667
16698
  if (input.noEnvFile !== void 0 && typeof input.noEnvFile !== "boolean") {
16668
16699
  throw new Error("project set-deploy: --no-env-file must be true or false");
16669
16700
  }
16670
16701
  return {
16671
16702
  stage: stage2,
16672
- substrate,
16673
- sshHost,
16674
- sshUser,
16675
- deployPath,
16676
- serviceName,
16703
+ ...substrate !== void 0 ? { substrate } : {},
16704
+ ...sshHost !== void 0 ? { sshHost } : {},
16705
+ ...sshUser !== void 0 ? { sshUser } : {},
16706
+ ...deployPath !== void 0 ? { deployPath } : {},
16707
+ ...serviceName !== void 0 ? { serviceName } : {},
16677
16708
  ...domain !== void 0 ? { domain } : {},
16678
16709
  ...port !== void 0 ? { port } : {},
16679
- ...uniqueAliases.length ? { aliases: uniqueAliases } : {},
16710
+ // #2986: an explicit `[]` is what the route reads as "clear them"; omitting the key is what it reads as
16711
+ // "keep them". Those are different instructions and they now have different flags.
16712
+ ...input.clearAliases ? { aliases: [] } : uniqueAliases.length ? { aliases: uniqueAliases } : {},
16680
16713
  ...input.noEnvFile !== void 0 ? { noEnvFile: input.noEnvFile } : {}
16681
16714
  };
16682
16715
  }
@@ -17188,6 +17221,206 @@ function registerSecretsCommands(program3) {
17188
17221
  secrets.command("revoke <repo> <login> <key>").description("MASTER-ONLY: withdraw a previously granted org-tier secret access").action((repo, login, key) => withSecrets((d) => secretsRevoke(d, repo, login, key, {})));
17189
17222
  }
17190
17223
 
17224
+ // src/box.ts
17225
+ var BOX_KEYS = {
17226
+ /** Opens every MM box INCLUDING the CI runner (`mmi-runner`). The general-purpose MM key. */
17227
+ mmMaster: "HETZNER_MASTER_SSH_PRIVATE_KEY",
17228
+ /** Opens the OGUZ project's boxes. */
17229
+ oguzMaster: "HETZNER_OGUZ_MASTER_SSH_PRIVATE_KEY",
17230
+ /** The deploy key. Opens MM prod/tenant boxes but NOT `mmi-runner` — a real trap: reaching for this key
17231
+ * to get into the CI runner fails, and the failure reads like a broken box rather than a wrong key. */
17232
+ mmDeploy: "HETZNER_SSH_PRIVATE_KEY"
17233
+ };
17234
+ var HETZNER_VAULT_SLUG = "_org";
17235
+ var HETZNER_NS = "hetzner";
17236
+ var nsKey = (key) => `${HETZNER_NS}/${key}`;
17237
+ var PROJECT_API_TOKEN = {
17238
+ MM: nsKey("HETZNER_MM_API_TOKEN"),
17239
+ OGUZ: nsKey("HETZNER_OGUZ_API_TOKEN")
17240
+ };
17241
+ function sshKeyForProject(project2) {
17242
+ return project2 === "OGUZ" ? BOX_KEYS.oguzMaster : BOX_KEYS.mmMaster;
17243
+ }
17244
+ function vaultPath(key) {
17245
+ return `/mmi-future/${HETZNER_VAULT_SLUG}/${HETZNER_NS}/${key}`;
17246
+ }
17247
+ function nextPage(payload) {
17248
+ if (!payload || typeof payload !== "object") return null;
17249
+ const { meta } = payload;
17250
+ if (!meta || typeof meta !== "object") return null;
17251
+ const { pagination } = meta;
17252
+ if (!pagination || typeof pagination !== "object") return null;
17253
+ const { next_page: next } = pagination;
17254
+ return typeof next === "number" && Number.isInteger(next) && next > 0 ? next : null;
17255
+ }
17256
+ function parseServer(node, project2) {
17257
+ if (!node || typeof node !== "object") return null;
17258
+ const { name, status, public_net: publicNet } = node;
17259
+ if (typeof name !== "string" || !name) return null;
17260
+ let address = "";
17261
+ if (publicNet && typeof publicNet === "object") {
17262
+ const { ipv4 } = publicNet;
17263
+ if (ipv4 && typeof ipv4 === "object") {
17264
+ const { ip } = ipv4;
17265
+ if (typeof ip === "string") address = ip;
17266
+ }
17267
+ }
17268
+ const sshKey = sshKeyForProject(project2);
17269
+ return {
17270
+ name,
17271
+ address,
17272
+ project: project2,
17273
+ status: typeof status === "string" ? status : "unknown",
17274
+ sshKey,
17275
+ sshKeyPath: vaultPath(sshKey)
17276
+ };
17277
+ }
17278
+ function parseServers(payload, project2) {
17279
+ if (!payload || typeof payload !== "object") {
17280
+ throw new Error(`Hetzner ${project2}: expected an object from /v1/servers, got ${payload === null ? "null" : typeof payload}`);
17281
+ }
17282
+ const { servers } = payload;
17283
+ if (!Array.isArray(servers)) {
17284
+ throw new Error(`Hetzner ${project2}: /v1/servers returned no servers array`);
17285
+ }
17286
+ return servers.map((node) => parseServer(node, project2)).filter((b) => b !== null).sort((a, b) => a.name.localeCompare(b.name));
17287
+ }
17288
+ function lookupBox(name, boxes, incomplete) {
17289
+ const found = boxes.find((b) => b.name.toLowerCase() === name.toLowerCase());
17290
+ if (found) return { kind: "found", box: found };
17291
+ if (incomplete.length) return { kind: "unknown", reasons: [...incomplete] };
17292
+ return { kind: "absent", known: boxes.map((b) => b.name) };
17293
+ }
17294
+ function formatBoxTable(boxes) {
17295
+ if (!boxes.length) return "no boxes";
17296
+ const w = (pick, head) => Math.max(head.length, ...boxes.map((b) => pick(b).length));
17297
+ const nameW = w((b) => b.name, "BOX");
17298
+ const addrW = w((b) => b.address || "(no public ipv4)", "ADDRESS");
17299
+ const projW = w((b) => b.project, "PROJECT");
17300
+ const statW = w((b) => b.status, "STATUS");
17301
+ const row = (name, addr, proj, stat2, key) => `${name.padEnd(nameW)} ${addr.padEnd(addrW)} ${proj.padEnd(projW)} ${stat2.padEnd(statW)} ${key}`;
17302
+ const lines = [row("BOX", "ADDRESS", "PROJECT", "STATUS", "SSH KEY (vault path)")];
17303
+ for (const b of boxes) {
17304
+ lines.push(row(b.name, b.address || "(no public ipv4)", b.project, b.status, b.sshKeyPath));
17305
+ }
17306
+ return lines.join("\n");
17307
+ }
17308
+ function formatSshRecipe(box) {
17309
+ if (!box.address) {
17310
+ return `box ${box.name} has no public IPv4 \u2014 it is not directly reachable over ssh.`;
17311
+ }
17312
+ return [
17313
+ `# ${box.name} (${box.project}, ${box.status}) \u2014 the key value is never printed or kept in shell history`,
17314
+ 'set -eo pipefail # pipefail matters: without it a failed SSM read still "succeeds" through tr',
17315
+ "kf=$(mktemp)",
17316
+ `trap 'rm -f "$kf"' EXIT INT TERM HUP # the key must not outlive this shell, however it dies`,
17317
+ 'chmod 600 "$kf"',
17318
+ `MSYS_NO_PATHCONV=1 aws ssm get-parameter --name ${box.sshKeyPath} \\`,
17319
+ " --with-decryption --query Parameter.Value --output text --region eu-central-1 \\",
17320
+ ` | tr -d '\\r' > "$kf"`,
17321
+ 'test -s "$kf" # never hand ssh an empty key file',
17322
+ `ssh -i "$kf" -o StrictHostKeyChecking=accept-new root@${box.address} 'hostname; uptime'`
17323
+ ].join("\n");
17324
+ }
17325
+
17326
+ // src/box-commands.ts
17327
+ var HETZNER_API = "https://api.hetzner.cloud/v1/servers";
17328
+ var HETZNER_TIMEOUT_MS = 15e3;
17329
+ var PER_PAGE = 50;
17330
+ var MAX_PAGES = 20;
17331
+ var PROJECTS = ["MM", "OGUZ"];
17332
+ async function fetchProjectBoxes(project2, token) {
17333
+ const boxes = [];
17334
+ let page = 1;
17335
+ for (let i = 0; page !== null && i < MAX_PAGES; i += 1) {
17336
+ const url = `${HETZNER_API}?page=${page}&per_page=${PER_PAGE}`;
17337
+ const res = await fetch(url, {
17338
+ headers: { Authorization: `Bearer ${token}` },
17339
+ signal: AbortSignal.timeout(HETZNER_TIMEOUT_MS)
17340
+ });
17341
+ if (!res.ok) {
17342
+ throw new Error(`Hetzner ${project2}: /v1/servers page ${page} returned ${res.status} ${res.statusText}`);
17343
+ }
17344
+ const payload = await res.json();
17345
+ boxes.push(...parseServers(payload, project2));
17346
+ page = nextPage(payload);
17347
+ }
17348
+ if (page !== null) {
17349
+ throw new Error(`Hetzner ${project2}: still paginating after ${MAX_PAGES} pages \u2014 refusing to report a possibly-truncated inventory`);
17350
+ }
17351
+ return boxes;
17352
+ }
17353
+ async function fetchInventory() {
17354
+ const boxes = [];
17355
+ const incomplete = [];
17356
+ await withSecrets(async (deps) => {
17357
+ for (const project2 of PROJECTS) {
17358
+ const key = PROJECT_API_TOKEN[project2];
17359
+ const token = await fetchSecretValue(deps, key, { slug: HETZNER_VAULT_SLUG });
17360
+ if (!token) {
17361
+ incomplete.push(`${project2}: could not read ${key} from the vault (slug ${HETZNER_VAULT_SLUG})`);
17362
+ continue;
17363
+ }
17364
+ try {
17365
+ boxes.push(...await fetchProjectBoxes(project2, token));
17366
+ } catch (e) {
17367
+ incomplete.push(e.message);
17368
+ }
17369
+ }
17370
+ });
17371
+ if (incomplete.length === PROJECTS.length) {
17372
+ throw new Error(`box: could not read any Hetzner project \u2014
17373
+ ${incomplete.join("\n ")}`);
17374
+ }
17375
+ return { boxes: boxes.sort((a, b) => a.name.localeCompare(b.name)), incomplete };
17376
+ }
17377
+ function warnIncomplete(incomplete) {
17378
+ for (const f of incomplete) console.error(`box: WARNING \u2014 ${f}`);
17379
+ if (incomplete.length) console.error("box: this inventory is INCOMPLETE \u2014 boxes may be missing from it.");
17380
+ }
17381
+ function registerBoxCommands(program3) {
17382
+ const box = program3.command("box").description("the box inventory \u2014 which machines exist, their addresses, and the vault key that opens each (never key values)");
17383
+ box.command("list").description("list every Hetzner box (both projects, live) with its address and the vault path of its ssh key").option("--json", "machine-readable output").action(async (o) => {
17384
+ try {
17385
+ const { boxes, incomplete } = await fetchInventory();
17386
+ if (o.json) {
17387
+ console.log(JSON.stringify({ boxes, incomplete }, null, 2));
17388
+ } else {
17389
+ console.log(formatBoxTable(boxes));
17390
+ warnIncomplete(incomplete);
17391
+ }
17392
+ if (incomplete.length) process.exitCode = 1;
17393
+ } catch (e) {
17394
+ await failGraceful(e.message);
17395
+ }
17396
+ });
17397
+ box.command("get <name>").description("one box by name \u2014 its address and ssh key path; --ssh prints a ready-to-run connect recipe").option("--json", "machine-readable output").option("--ssh", "print a copy-paste ssh recipe (materializes the key to a temp file; never echoes it)").action(async (name, o) => {
17398
+ try {
17399
+ const { boxes, incomplete } = await fetchInventory();
17400
+ const result = lookupBox(name, boxes, incomplete);
17401
+ if (result.kind === "unknown") {
17402
+ warnIncomplete(result.reasons);
17403
+ await failGraceful(
17404
+ `box: cannot confirm whether '${name}' exists \u2014 part of the inventory could not be read (see above). Refusing to answer "no such box" from a list I know is incomplete.`
17405
+ );
17406
+ return;
17407
+ }
17408
+ if (result.kind === "absent") {
17409
+ await failGraceful(`box: no box named '${name}'. Known boxes: ${result.known.join(", ") || "(none)"}`);
17410
+ return;
17411
+ }
17412
+ const found = result.box;
17413
+ if (o.json) console.log(JSON.stringify({ box: found, incomplete }, null, 2));
17414
+ else if (o.ssh) console.log(formatSshRecipe(found));
17415
+ else console.log(formatBoxTable([found]));
17416
+ if (!o.json) warnIncomplete(incomplete);
17417
+ if (incomplete.length) process.exitCode = 1;
17418
+ } catch (e) {
17419
+ await failGraceful(e.message);
17420
+ }
17421
+ });
17422
+ }
17423
+
17191
17424
  // src/edge-tunnel.ts
17192
17425
  var HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
17193
17426
  var UPSTREAM_RE = /^https?:\/\/[^/\s]+(?::\d+)?(?:\/.*)?$/;
@@ -19380,15 +19613,6 @@ function parseLinkedIssues(body) {
19380
19613
  }
19381
19614
  return [...refs].sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)));
19382
19615
  }
19383
- function parseClosingIssues(body) {
19384
- const refs = /* @__PURE__ */ new Set();
19385
- const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(#\d+\b(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+|\s*&\s*)#\d+\b)*)/gi;
19386
- let m;
19387
- while ((m = re.exec(body)) !== null) {
19388
- for (const ref of m[1].match(/#\d+/g) ?? []) refs.add(ref);
19389
- }
19390
- return [...refs].sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)));
19391
- }
19392
19616
  function decidePrCloseBranchCleanup(input) {
19393
19617
  if (input.isProtected) return { action: "skip", reason: "protected-branch" };
19394
19618
  if (input.dirty) return { action: "skip", reason: "dirty" };
@@ -19784,6 +20008,88 @@ function parseWorktreePorcelain2(stdout) {
19784
20008
  return out;
19785
20009
  }
19786
20010
 
20011
+ // src/board-advance.ts
20012
+ function repoOf2(ref) {
20013
+ if (!ref || typeof ref !== "object") return void 0;
20014
+ const { number, repository } = ref;
20015
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) return void 0;
20016
+ if (!repository || typeof repository !== "object") return void 0;
20017
+ const { name, owner } = repository;
20018
+ if (typeof name !== "string" || !name) return void 0;
20019
+ if (!owner || typeof owner !== "object") return void 0;
20020
+ const { login } = owner;
20021
+ if (typeof login !== "string" || !login) return void 0;
20022
+ return `${login}/${name}`;
20023
+ }
20024
+ async function advanceClosedIssuesToDone(deps) {
20025
+ let closing;
20026
+ try {
20027
+ closing = await deps.fetchClosingIssues();
20028
+ } catch (e) {
20029
+ return { status: "fetch-failed", entries: [], error: e.message };
20030
+ }
20031
+ if (!Array.isArray(closing)) {
20032
+ return {
20033
+ status: "fetch-failed",
20034
+ entries: [],
20035
+ error: `expected an array of closing issue references, got ${closing === null ? "null" : typeof closing}`
20036
+ };
20037
+ }
20038
+ if (closing.length === 0) return { status: "none", entries: [] };
20039
+ const refs = [];
20040
+ for (const [i, candidate] of closing.entries()) {
20041
+ const repo = repoOf2(candidate);
20042
+ if (!repo) {
20043
+ return {
20044
+ status: "fetch-failed",
20045
+ entries: [],
20046
+ error: `closing reference ${i} is missing a usable issue number or repository \u2014 refusing to guess which board it belongs to`
20047
+ };
20048
+ }
20049
+ refs.push({ ref: candidate, repo });
20050
+ }
20051
+ const entries = [];
20052
+ for (const { ref, repo } of refs) {
20053
+ if (repo.toLowerCase() !== deps.repo.toLowerCase()) {
20054
+ entries.push({
20055
+ issue: `${repo}#${ref.number}`,
20056
+ moved: false,
20057
+ status: "skipped-cross-repo",
20058
+ error: `closed by this PR but lives in another repo \u2014 the ${deps.repo} board does not own it; move it on that repo's board`
20059
+ });
20060
+ continue;
20061
+ }
20062
+ const issue2 = `#${ref.number}`;
20063
+ try {
20064
+ const outcome = await deps.moveIssueToDone(issue2);
20065
+ entries.push(
20066
+ outcome.moved ? { issue: issue2, moved: true, status: "moved" } : { issue: issue2, moved: false, status: "failed", error: outcome.error }
20067
+ );
20068
+ } catch (e) {
20069
+ entries.push({ issue: issue2, moved: false, status: "failed", error: e.message });
20070
+ }
20071
+ }
20072
+ return { status: entries.every((e) => e.moved) ? "ok" : "partial", entries };
20073
+ }
20074
+ function boardAdvanceFailures(result) {
20075
+ return (result?.entries ?? []).filter((e) => !e.moved);
20076
+ }
20077
+ function boardAdvanceExitCode(result) {
20078
+ if (!result) return void 0;
20079
+ if (result.status === "fetch-failed") return 1;
20080
+ return boardAdvanceFailures(result).length > 0 ? 1 : void 0;
20081
+ }
20082
+ function boardAdvanceFailureMessage(result) {
20083
+ if (!result) return void 0;
20084
+ if (result.status === "fetch-failed") {
20085
+ return `pr merge: could not read the PR's closing issues (${result.error ?? "unknown error"}) \u2014 the PR MERGED, but no board item was advanced. Check the board by hand.`;
20086
+ }
20087
+ const failures = boardAdvanceFailures(result);
20088
+ if (!failures.length) return void 0;
20089
+ const named = failures.map((f) => f.error ? `${f.issue} (${f.error})` : f.issue).join(", ");
20090
+ return `pr merge: board advance incomplete for ${named} \u2014 the PR MERGED, but ${failures.length === 1 ? "this issue" : "these issues"} did not reach Done on the board.`;
20091
+ }
20092
+
19787
20093
  // src/session-report.ts
19788
20094
  var GC_GH_TIMEOUT_MS4 = 2e4;
19789
20095
  function shapeSessionIssues(report) {
@@ -19902,6 +20208,7 @@ function registerSessionReport(program3) {
19902
20208
 
19903
20209
  // src/doctor-render.ts
19904
20210
  var RESTART_LINE = "\u21BB Restart Claude to finish.";
20211
+ var VERBOSE_INDENT = " ";
19905
20212
  function renderCheckLine(check) {
19906
20213
  if (check.ok) {
19907
20214
  return check.detail ? `\u2713 ${check.label} \u2014 ${check.detail}` : `\u2713 ${check.label}`;
@@ -19913,17 +20220,39 @@ function renderReport(checks, opts) {
19913
20220
  if (opts.restartPending) lines.push(RESTART_LINE);
19914
20221
  return lines.join("\n");
19915
20222
  }
20223
+ function renderTally(checks) {
20224
+ const total = checks.length;
20225
+ const healthy = total - checks.filter((c) => !c.ok).length;
20226
+ return healthy === total ? `\u2713 all ${total} checks healthy` : `\u2713 ${healthy} of ${total} checks healthy`;
20227
+ }
20228
+ function renderDoctorText(checks, opts) {
20229
+ const lines = [];
20230
+ for (const check of checks) {
20231
+ lines.push(renderCheckLine(check));
20232
+ if (opts.verbose) {
20233
+ for (const evidence of check.verbose ?? []) lines.push(`${VERBOSE_INDENT}${evidence}`);
20234
+ }
20235
+ }
20236
+ lines.push(renderTally(checks));
20237
+ if (opts.restartPending) lines.push(RESTART_LINE);
20238
+ return lines.join("\n");
20239
+ }
19916
20240
  function doctorReportExitCode(checks) {
19917
20241
  return checks.some((c) => !c.ok) ? 1 : 0;
19918
20242
  }
19919
20243
 
19920
20244
  // src/doctor-clean.ts
19921
20245
  function checkGithubAuth(probe) {
19922
- const ok = Boolean(probe.login?.trim());
20246
+ const login = probe.login?.trim();
20247
+ const ok = Boolean(login);
19923
20248
  return {
19924
20249
  ok,
19925
20250
  label: "github auth",
19926
- fix: probe.ghInstalled ? 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"' : "install GitHub CLI (https://cli.github.com), then: gh auth login"
20251
+ fix: probe.ghInstalled ? 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"' : "install GitHub CLI (https://cli.github.com), then: gh auth login",
20252
+ verbose: [
20253
+ `gh installed: ${probe.ghInstalled ? "yes" : "no"}`,
20254
+ `authenticated as: ${login || "(none)"}`
20255
+ ]
19927
20256
  };
19928
20257
  }
19929
20258
  function checkAwsIdentity(probe) {
@@ -19932,23 +20261,36 @@ function checkAwsIdentity(probe) {
19932
20261
  return {
19933
20262
  ok: !arn || !arn.endsWith(":root"),
19934
20263
  label: "aws identity",
19935
- fix: "use a non-root IAM user/session profile (set AWS_PROFILE or run: aws sso login), then verify `aws sts get-caller-identity` is not :root"
20264
+ fix: "use a non-root IAM user/session profile (set AWS_PROFILE or run: aws sso login), then verify `aws sts get-caller-identity` is not :root",
20265
+ // Name the caller. A green "aws identity" says only "not root" — it does not say WHO, and "no AWS
20266
+ // configured at all" is also green here. Under --verbose those two must not look the same.
20267
+ verbose: [`caller: ${arn || "(no AWS identity configured \u2014 allowed)"}`]
19936
20268
  };
19937
20269
  }
19938
20270
  function checkRepoWorktrees(probe) {
19939
20271
  return {
19940
20272
  ok: !(probe.isOrgRepo && probe.hasRepoLocalWorktrees),
19941
20273
  label: "repo worktrees",
19942
- fix: "repo-local `.worktrees/` is not the canonical path \u2014 use `mmi-cli worktree create <branch>` (sibling `../worktrees/`), then remove the in-repo `.worktrees/`"
20274
+ fix: "repo-local `.worktrees/` is not the canonical path \u2014 use `mmi-cli worktree create <branch>` (sibling `../worktrees/`), then remove the in-repo `.worktrees/`",
20275
+ verbose: [
20276
+ `org repo: ${probe.isOrgRepo ? "yes" : "no"}`,
20277
+ `repo-local .worktrees/: ${probe.hasRepoLocalWorktrees ? "present" : "absent"}`
20278
+ ]
19943
20279
  };
19944
20280
  }
19945
20281
  function checkClaudePlugin(probe) {
19946
20282
  const { installed, released, guardState } = probe;
20283
+ const evidence = [
20284
+ `installed: ${installed ?? "(none)"}`,
20285
+ `released: ${released ?? "(not checked \u2014 offline or --fast)"}`,
20286
+ `resolvable: ${guardState}`
20287
+ ];
19947
20288
  if (guardState === "no-install" || guardState === "unresolved") {
19948
20289
  return {
19949
20290
  ok: false,
19950
20291
  label: guardState === "no-install" ? "Claude plugin \u2014 not installed" : "Claude plugin \u2014 unresolved (marketplace/cache missing)",
19951
- fix: "run `mmi-cli plugin-heal` to reinstall the MMI marketplace + plugin, then restart Claude"
20292
+ fix: "run `mmi-cli plugin-heal` to reinstall the MMI marketplace + plugin, then restart Claude",
20293
+ verbose: evidence
19952
20294
  };
19953
20295
  }
19954
20296
  const behind = Boolean(installed && released) && compareVersions(installed, released) < 0;
@@ -19956,43 +20298,61 @@ function checkClaudePlugin(probe) {
19956
20298
  return {
19957
20299
  ok: false,
19958
20300
  label: `Claude plugin ${installed} \u2192 ${released}`,
19959
- fix: "run /plugin, update MMI (reinstall if it shows a legacy row), then restart Claude"
20301
+ fix: "run /plugin, update MMI (reinstall if it shows a legacy row), then restart Claude",
20302
+ verbose: evidence
19960
20303
  };
19961
20304
  }
19962
- return { ok: true, label: installed ? `Claude plugin ${installed}` : "Claude plugin" };
20305
+ return { ok: true, label: installed ? `Claude plugin ${installed}` : "Claude plugin", verbose: evidence };
19963
20306
  }
19964
20307
  function checkCliVersion(input) {
19965
20308
  const report = buildVersionLagReport(input);
19966
- if (!report.releasedVersion) return { ok: true, label: `mmi-cli ${report.currentVersion}` };
19967
- if (report.ok) return { ok: true, label: `mmi-cli ${report.currentVersion}` };
20309
+ const evidence = [
20310
+ `running: ${report.currentVersion}`,
20311
+ `published: ${report.releasedVersion ?? "(not checked \u2014 offline or --fast)"}`
20312
+ ];
20313
+ if (!report.releasedVersion) return { ok: true, label: `mmi-cli ${report.currentVersion}`, verbose: evidence };
20314
+ if (report.ok) return { ok: true, label: `mmi-cli ${report.currentVersion}`, verbose: evidence };
19968
20315
  return {
19969
20316
  ok: false,
19970
20317
  label: `mmi-cli ${report.currentVersion} \u2192 ${report.releasedVersion}`,
19971
- fix: report.fix
20318
+ fix: report.fix,
20319
+ verbose: evidence
19972
20320
  };
19973
20321
  }
19974
20322
  function checkPluginCache(input) {
20323
+ const evidence = [
20324
+ `cached versions: ${input.cached}`,
20325
+ `stale: ${input.stale.length ? input.stale.join(", ") : "(none)"}`
20326
+ ];
19975
20327
  if (input.stale.length === 0) {
19976
- return { ok: true, label: "plugin cache", detail: input.cached ? `${input.cached} version(s), nothing stale` : "no cache" };
20328
+ return {
20329
+ ok: true,
20330
+ label: "plugin cache",
20331
+ detail: input.cached ? `${input.cached} version(s), nothing stale` : "no cache",
20332
+ verbose: evidence
20333
+ };
19977
20334
  }
19978
20335
  const size = input.bytes > 0 ? `, ${(input.bytes / 1e6).toFixed(1)} MB` : "";
19979
20336
  return {
19980
20337
  ok: false,
19981
20338
  label: "plugin cache",
19982
20339
  detail: `${input.cached} versions cached (${input.stale.length} stale${size})`,
19983
- fix: "run `mmi-cli plugin-prune` to review, then `mmi-cli plugin-prune --apply` to delete"
20340
+ fix: "run `mmi-cli plugin-prune` to review, then `mmi-cli plugin-prune --apply` to delete",
20341
+ verbose: evidence
19984
20342
  };
19985
20343
  }
19986
20344
  function checkTrainSync(result) {
20345
+ const evidence = result.branches.length ? result.branches.map((b) => `${b.branch}: ${b.status}${b.reason ? ` (${b.reason})` : ""}`) : ["no local train branches to sync"];
19987
20346
  const problems = result.branches.filter((b) => b.status === "skipped" && (b.reason === "diverged" || b.reason === "ff-failed"));
19988
20347
  if (problems.length) {
19989
- return { ok: false, label: "train branches", fix: problems.map((b) => b.note).join("; ") };
20348
+ return { ok: false, label: "train branches", fix: problems.map((b) => b.note).join("; "), verbose: evidence };
19990
20349
  }
19991
20350
  const moved = result.branches.filter((b) => b.status === "synced");
19992
20351
  return {
19993
20352
  ok: true,
19994
20353
  label: "train branches",
19995
- detail: moved.length ? `fast-forwarded ${moved.map((b) => b.branch).join(", ")}` : void 0
20354
+ detail: moved.length ? `fast-forwarded ${moved.map((b) => b.branch).join(", ")}` : void 0,
20355
+ verbose: evidence
19996
20356
  };
19997
20357
  }
19998
20358
  function planGitignore(current) {
@@ -20019,14 +20379,19 @@ async function runDoctorClean(opts, io, deps) {
20019
20379
  if (aws) checks.push(aws);
20020
20380
  checks.push(checkRepoWorktrees({ isOrgRepo, hasRepoLocalWorktrees: deps.hasRepoLocalWorktrees() }));
20021
20381
  if (isOrgRepo) {
20022
- const gi = planGitignore(deps.readGitignore());
20382
+ const current = deps.readGitignore();
20383
+ const gi = planGitignore(current);
20384
+ const giEvidence = [
20385
+ `.gitignore: ${current === null ? "absent" : `${current.split("\n").length} lines`}`,
20386
+ `managed block: ${gi.ok ? "present and current" : "missing or out of date"}`
20387
+ ];
20023
20388
  if (gi.ok) {
20024
- checks.push({ ok: true, label: "gitignore block" });
20389
+ checks.push({ ok: true, label: "gitignore block", verbose: giEvidence });
20025
20390
  } else if (apply && gi.content && deps.writeGitignore(gi.content)) {
20026
- checks.push({ ok: true, label: "gitignore block", detail: "rewrote managed block" });
20391
+ checks.push({ ok: true, label: "gitignore block", detail: "rewrote managed block", verbose: giEvidence });
20027
20392
  restartPending = true;
20028
20393
  } else {
20029
- checks.push({ ok: false, label: "gitignore block", fix: "run `mmi-cli doctor --apply` to write the org-managed .gitignore block" });
20394
+ checks.push({ ok: false, label: "gitignore block", fix: "run `mmi-cli doctor --apply` to write the org-managed .gitignore block", verbose: giEvidence });
20030
20395
  }
20031
20396
  }
20032
20397
  const plugin = checkClaudePlugin({ installed, released, guardState: deps.pluginGuardState(isOrgRepo) });
@@ -20038,7 +20403,13 @@ async function runDoctorClean(opts, io, deps) {
20038
20403
  try {
20039
20404
  checks.push(checkTrainSync(await deps.syncTrain()));
20040
20405
  } catch (e) {
20041
- checks.push({ ok: false, label: "train branches", fix: `sync failed \u2014 ${e instanceof Error ? e.message : String(e)}` });
20406
+ const message = e instanceof Error ? e.message : String(e);
20407
+ checks.push({
20408
+ ok: false,
20409
+ label: "train branches",
20410
+ fix: `sync failed \u2014 ${message}`,
20411
+ verbose: [`sync threw: ${message}`, "no train branch was fast-forwarded this run"]
20412
+ });
20042
20413
  }
20043
20414
  const repoRoot2 = await deps.repoRoot();
20044
20415
  try {
@@ -20050,36 +20421,64 @@ async function runDoctorClean(opts, io, deps) {
20050
20421
  if (r.removedBranches.length || r.removedRemoteBranches.length) detailParts.push(`${r.removedBranches.length + r.removedRemoteBranches.length} merged branches`);
20051
20422
  if (r.removedWorktreeDirs.length) detailParts.push(`${r.removedWorktreeDirs.length} dead worktrees`);
20052
20423
  if (r.removedTrackingRefs.length) detailParts.push(`${r.removedTrackingRefs.length} stale refs`);
20053
- checks.push({ ok: true, label: "branches / worktrees", detail: reaped ? `reaped ${detailParts.join(", ")}` : "nothing stale" });
20424
+ checks.push({
20425
+ ok: true,
20426
+ label: "branches / worktrees",
20427
+ detail: reaped ? `reaped ${detailParts.join(", ")}` : "nothing stale",
20428
+ // Under --apply the evidence is what was actually DELETED. This is the run where naming it matters
20429
+ // most: the one-liner says "reaped 3 merged branches", and only these lines say which three.
20430
+ verbose: reaped ? [
20431
+ ...r.removedBranches.map((b) => `deleted local branch: ${b}`),
20432
+ ...r.removedRemoteBranches.map((b) => `deleted remote branch: ${b}`),
20433
+ ...r.removedTrackingRefs.map((t) => `deleted tracking ref: ${t}`),
20434
+ ...r.removedWorktreeDirs.map((w) => `removed worktree dir: ${w}`),
20435
+ ...r.failed.map((f) => `FAILED: ${f}`)
20436
+ ] : ["nothing reapable"]
20437
+ });
20054
20438
  if (reaped) restartPending = true;
20055
20439
  } else {
20056
20440
  const n = gcReapable(plan);
20057
- checks.push(n === 0 ? { ok: true, label: "branches / worktrees" } : { ok: false, label: "branches / worktrees", fix: `${n} stale \u2014 run \`mmi-cli doctor --apply\` to reap merged branches, stale refs, and dead worktrees` });
20441
+ const gcEvidence = [
20442
+ ...plan.branches.map((b) => `merged branch: ${b.branch} (PR ${b.prNumbers.map((n2) => `#${n2}`).join(", ") || "?"} ${b.prState})`),
20443
+ ...plan.trackingRefs.map((r) => `stale tracking ref: ${r.ref}`),
20444
+ ...plan.worktreeDirs.map((w) => `dead worktree: ${w.path} (${w.reason})`)
20445
+ ];
20446
+ checks.push(n === 0 ? { ok: true, label: "branches / worktrees", verbose: ["nothing reapable"] } : {
20447
+ ok: false,
20448
+ label: "branches / worktrees",
20449
+ fix: `${n} stale \u2014 run \`mmi-cli doctor --apply\` to reap merged branches, stale refs, and dead worktrees`,
20450
+ verbose: gcEvidence
20451
+ });
20058
20452
  }
20059
20453
  } catch {
20060
20454
  }
20061
20455
  const scratch = deps.executeScratchGc(repoRoot2, { apply: false });
20456
+ const scratchEvidence = scratch.plan.safeAuto.length ? scratch.plan.safeAuto.map((c) => `aged: ${c.path} (${c.reason})`) : ["nothing aged"];
20062
20457
  if (apply && scratch.plan.safeAuto.length > 0) {
20063
20458
  const applied = deps.executeScratchGc(repoRoot2, { apply: true });
20064
20459
  const pruned = applied.applied?.pruned.length ?? 0;
20065
- checks.push({ ok: true, label: "scratch", detail: pruned ? `removed ${pruned} aged item(s)` : "nothing stale" });
20460
+ checks.push({ ok: true, label: "scratch", detail: pruned ? `removed ${pruned} aged item(s)` : "nothing stale", verbose: scratchEvidence });
20066
20461
  if (pruned) restartPending = true;
20067
20462
  } else {
20068
- checks.push(scratch.plan.safeAuto.length === 0 ? { ok: true, label: "scratch" } : { ok: false, label: "scratch", fix: `${scratch.plan.safeAuto.length} aged scratch item(s) \u2014 run \`mmi-cli doctor --apply\`` });
20463
+ checks.push(scratch.plan.safeAuto.length === 0 ? { ok: true, label: "scratch", verbose: scratchEvidence } : { ok: false, label: "scratch", fix: `${scratch.plan.safeAuto.length} aged scratch item(s) \u2014 run \`mmi-cli doctor --apply\``, verbose: scratchEvidence });
20069
20464
  }
20070
20465
  }
20071
20466
  const exitCode = doctorReportExitCode(checks);
20072
20467
  if (opts.json) {
20073
- io.log(JSON.stringify({ checks, restartPending, exitCode }, null, 2));
20468
+ const payload = opts.verbose ? checks : checks.map(({ verbose: _evidence, ...check }) => check);
20469
+ io.log(JSON.stringify({ checks: payload, restartPending, exitCode }, null, 2));
20074
20470
  return exitCode;
20075
20471
  }
20076
20472
  if (opts.banner) {
20077
20473
  const actionable = checks.filter((c) => !c.ok);
20078
- for (const c of actionable) io.log(renderReport([c], { restartPending: false }));
20474
+ for (const c of actionable) {
20475
+ io.log(renderReport([c], { restartPending: false }));
20476
+ if (opts.verbose) for (const evidence of c.verbose ?? []) io.log(`${VERBOSE_INDENT}${evidence}`);
20477
+ }
20079
20478
  if (restartPending) io.log(RESTART_LINE);
20080
20479
  return 0;
20081
20480
  }
20082
- io.log(renderReport(checks, { restartPending }));
20481
+ io.log(renderDoctorText(checks, { verbose: Boolean(opts.verbose), restartPending }));
20083
20482
  return exitCode;
20084
20483
  }
20085
20484
 
@@ -20103,17 +20502,34 @@ function installedClaudePluginVersion() {
20103
20502
  return void 0;
20104
20503
  }
20105
20504
  }
20106
- var gitignorePath = () => (0, import_node_path22.join)(process.cwd(), ".gitignore");
20505
+ function worktreeRootSync() {
20506
+ try {
20507
+ const out = (0, import_node_child_process10.execFileSync)("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
20508
+ let root = out.endsWith("\n") ? out.slice(0, -1) : out;
20509
+ if (process.platform === "win32" && root.endsWith("\r")) root = root.slice(0, -1);
20510
+ return root || null;
20511
+ } catch {
20512
+ return null;
20513
+ }
20514
+ }
20515
+ var gitignorePath = () => {
20516
+ const root = worktreeRootSync();
20517
+ return root === null ? null : (0, import_node_path22.join)(root, ".gitignore");
20518
+ };
20107
20519
  function readGitignore() {
20520
+ const path2 = gitignorePath();
20521
+ if (path2 === null) return null;
20108
20522
  try {
20109
- return (0, import_node_fs23.readFileSync)(gitignorePath(), "utf8");
20523
+ return (0, import_node_fs23.readFileSync)(path2, "utf8");
20110
20524
  } catch {
20111
20525
  return null;
20112
20526
  }
20113
20527
  }
20114
20528
  function writeGitignore(content) {
20529
+ const path2 = gitignorePath();
20530
+ if (path2 === null) return false;
20115
20531
  try {
20116
- (0, import_node_fs23.writeFileSync)(gitignorePath(), content, "utf8");
20532
+ (0, import_node_fs23.writeFileSync)(path2, content, "utf8");
20117
20533
  return true;
20118
20534
  } catch {
20119
20535
  return false;
@@ -20136,7 +20552,8 @@ async function repoRoot() {
20136
20552
  }
20137
20553
  }
20138
20554
  function hasRepoLocalWorktrees() {
20139
- return (0, import_node_fs23.existsSync)((0, import_node_path22.join)(process.cwd(), ".worktrees"));
20555
+ const root = worktreeRootSync();
20556
+ return root !== null && (0, import_node_fs23.existsSync)((0, import_node_path22.join)(root, ".worktrees"));
20140
20557
  }
20141
20558
 
20142
20559
  // src/index.ts
@@ -20167,26 +20584,38 @@ async function loadConfigForRepo(targetRepo2) {
20167
20584
  async function loadConfigForBoardSelector2(selector, repoOption) {
20168
20585
  return loadConfigForRepo(repoFromSelector(selector) ?? repoOption);
20169
20586
  }
20170
- async function advanceClosedIssuesToDone(prNumber, repoOption) {
20171
- const repoArgs = repoOption ? ["--repo", repoOption] : [];
20172
- let body;
20587
+ async function advanceClosedIssuesToDone2(prNumber, repoOption) {
20173
20588
  try {
20174
- body = (await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "body", "--jq", ".body"], { timeout: GC_GH_TIMEOUT_MS5 })).stdout;
20175
- } catch {
20176
- return void 0;
20589
+ return await resolveBoardAdvanceForPr(prNumber, repoOption);
20590
+ } catch (e) {
20591
+ return { status: "fetch-failed", entries: [], error: e.message };
20177
20592
  }
20178
- const closing = parseClosingIssues(body ?? "");
20179
- if (closing.length === 0) return void 0;
20180
- const results = [];
20181
- for (const ref of closing) {
20182
- try {
20593
+ }
20594
+ async function resolveBoardAdvanceForPr(prNumber, repoOption) {
20595
+ const repoArgs = repoOption ? ["--repo", repoOption] : [];
20596
+ const repo = await resolveRepo(repoOption);
20597
+ if (!repo) {
20598
+ return {
20599
+ status: "fetch-failed",
20600
+ entries: [],
20601
+ error: "could not resolve the PR's repo, so a closing reference cannot be proven to belong to this board"
20602
+ };
20603
+ }
20604
+ return advanceClosedIssuesToDone({
20605
+ repo,
20606
+ fetchClosingIssues: async () => {
20607
+ const { stdout } = await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "closingIssuesReferences"], { timeout: GC_GH_TIMEOUT_MS5 });
20608
+ const parsed = JSON.parse(stdout || "null");
20609
+ if (!parsed || typeof parsed !== "object" || !("closingIssuesReferences" in parsed)) {
20610
+ throw new Error("gh returned no closingIssuesReferences field for this PR");
20611
+ }
20612
+ return parsed.closingIssuesReferences;
20613
+ },
20614
+ moveIssueToDone: async (ref) => {
20183
20615
  const moved = await moveBoardItem({ config: await loadConfigForBoardSelector2(ref, repoOption), selector: ref, status: "Done", repo: repoOption, allowPartial: true });
20184
- results.push(moved.partial ? { issue: ref, moved: false, error: moved.warning } : { issue: ref, moved: true });
20185
- } catch (e) {
20186
- results.push({ issue: ref, moved: false, error: e.message });
20616
+ return moved.partial ? { moved: false, error: moved.warning } : { moved: true };
20187
20617
  }
20188
- }
20189
- return results;
20618
+ });
20190
20619
  }
20191
20620
  function renderGcApplyResult(result) {
20192
20621
  const lines = ["gc apply result:"];
@@ -20762,6 +21191,7 @@ function scheduleRelatedDiscovery(o) {
20762
21191
  }
20763
21192
  registerSecretsCommands(program2);
20764
21193
  registerEdgeCommands(program2);
21194
+ registerBoxCommands(program2);
20765
21195
  async function reportWrite(label, res) {
20766
21196
  if (res.ok) {
20767
21197
  console.log(JSON.stringify(res.body));
@@ -21197,7 +21627,7 @@ fullTrack.command("readiness <owner/repo>").description("aggregate branch topolo
21197
21627
  console.log(JSON.stringify(report, null, 2));
21198
21628
  if (!report.rcand.canApply) process.exitCode = 1;
21199
21629
  });
21200
- project.command("set-deploy [owner/repo]").description("write the DEPLOY#<stage> Hetzner deploy coords for a tenant (master-only) \u2014 the explicit-coords path that seeds a freshly-bootstrapped tenant; defaults to the current repo").requiredOption("--stage <stage>", "dev | rc | main").option("--ssh-host <host>", "the box address the deploy ssh-es into (required for hetzner-ssh)").option("--ssh-user <user>", "ssh user (default root)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535)").option("--substrate <substrate>", "hetzner-ssh (default)").option("--deploy-path <path>", "on-box per-stage release root (default /opt/mmi/<slug>/<stage>)").option("--service <name>", "systemd/compose service name (default the slug)").option("--domain <domain>", "canonical serving host (default the project edgeDomains[stage])").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable)").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 true = #2662 env passthrough, no .env symlink; omit to leave the row unchanged").option("--force", "skip the #2748 fileless-compose guard (flip noEnvFile=true even if the stage branch compose still declares env_file:)").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
21630
+ project.command("set-deploy [owner/repo]").description("patch the DEPLOY#<stage> Hetzner deploy coords for a tenant (master-only) \u2014 omitted flags keep their stored value; seeds a freshly-bootstrapped tenant; defaults to the current repo").requiredOption("--stage <stage>", "dev | rc | main").option("--ssh-host <host>", "the box address the deploy ssh-es into; omit to keep the stored value (required only for a NEW hetzner-ssh row \u2014 `mmi-cli box list` finds it)").option("--ssh-user <user>", "ssh user; omit to leave the row unchanged (default root on a new row)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535); omit to leave the row unchanged").option("--substrate <substrate>", "hetzner-ssh; omit to leave the row unchanged").option("--deploy-path <path>", "on-box per-stage release root; omit to leave the row unchanged (default /opt/mmi/<slug>/<stage> on a new row)").option("--service <name>", "systemd/compose service name; omit to leave the row unchanged (default the slug on a new row)").option("--domain <domain>", "canonical serving host; omit to leave the row unchanged").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable); omit to leave the stored aliases unchanged").option("--clear-aliases", "remove EVERY serving alias from the row (#2986) \u2014 omitting --alias preserves them, so clearing needs saying out loud").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 true = #2662 env passthrough, no .env symlink; omit to leave the row unchanged").option("--force", "skip the #2748 fileless-compose guard (flip noEnvFile=true even if the stage branch compose still declares env_file:)").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
21201
21631
  const cfg = await loadConfig();
21202
21632
  let target;
21203
21633
  try {
@@ -21238,6 +21668,7 @@ project.command("set-deploy [owner/repo]").description("write the DEPLOY#<stage>
21238
21668
  service: o.service,
21239
21669
  domain: o.domain,
21240
21670
  aliases: o.alias,
21671
+ clearAliases: o.clearAliases,
21241
21672
  noEnvFile
21242
21673
  });
21243
21674
  } catch (e) {
@@ -21794,7 +22225,24 @@ async function pollGhPrChecks(prNumber, repoArgs) {
21794
22225
  }
21795
22226
  return state;
21796
22227
  }
21797
- pr.command("checks-wait <number>").description(`bounded wait for PR checks; skips immediately on no-ci repos (#1432). Default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m; --timeout raises it. Exit 1 = a check FAILED, exit ${PR_CHECKS_TIMEOUT_EXIT_CODE} = the wait window expired (re-arm)`).option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--timeout <minutes>", `wait budget in minutes (default ${PR_CHECKS_TIMEOUT_MS / 6e4}) \u2014 raise it for serial self-hosted e2e queues`).action(async (number, o) => {
22228
+ async function pollGhPrMergeable(prNumber, repoArgs) {
22229
+ try {
22230
+ const { stdout } = await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "mergeable", "--jq", ".mergeable"], { timeout: GC_GH_TIMEOUT_MS5 });
22231
+ const state = stdout.trim().toUpperCase();
22232
+ return state === "MERGEABLE" || state === "CONFLICTING" ? state : "UNKNOWN";
22233
+ } catch {
22234
+ return "UNKNOWN";
22235
+ }
22236
+ }
22237
+ async function resolvePrBaseBranch(prNumber, repoArgs) {
22238
+ try {
22239
+ const { stdout } = await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "baseRefName", "--jq", ".baseRefName"], { timeout: GC_GH_TIMEOUT_MS5 });
22240
+ return stdout.trim() || "development";
22241
+ } catch {
22242
+ return "development";
22243
+ }
22244
+ }
22245
+ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skips immediately on no-ci repos (#1432), fails immediately on a CONFLICTING PR \u2014 GitHub never queues checks for one (#2970). Default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m; --timeout raises it. Exit 1 = a check FAILED or the PR is CONFLICTING, exit ${PR_CHECKS_TIMEOUT_EXIT_CODE} = the wait window expired (re-arm)`).option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--timeout <minutes>", `wait budget in minutes (default ${PR_CHECKS_TIMEOUT_MS / 6e4}) \u2014 raise it for serial self-hosted e2e queues`).action(async (number, o) => {
21798
22246
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
21799
22247
  let timeoutMs;
21800
22248
  if (o.timeout !== void 0) {
@@ -21802,9 +22250,12 @@ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skip
21802
22250
  if (!Number.isFinite(minutes) || minutes <= 0) return fail("pr checks-wait: --timeout must be a positive number of minutes");
21803
22251
  timeoutMs = Math.round(minutes * 6e4);
21804
22252
  }
22253
+ const baseBranch = await resolvePrBaseBranch(number, repoArgs);
21805
22254
  const result = await waitForPrChecks({
21806
22255
  resolvePolicy: () => resolveMergeCiPolicyForCheckout(o.repo),
21807
22256
  pollChecks: () => pollGhPrChecks(number, repoArgs),
22257
+ pollMergeable: () => pollGhPrMergeable(number, repoArgs),
22258
+ baseBranch,
21808
22259
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
21809
22260
  log: (message) => console.warn(message),
21810
22261
  timeoutMs,
@@ -21813,10 +22264,12 @@ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skip
21813
22264
  progress: ({ state, elapsedMs, remainingMs }) => console.warn(`pr checks-wait: ${state} \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
21814
22265
  });
21815
22266
  if (o.json) printLine(JSON.stringify(result));
21816
- else if (result.status === "timeout") {
22267
+ else if (result.status === "conflicting") {
22268
+ printLine(`pr checks-wait: conflicting \u2014 ${result.reason}`);
22269
+ } else if (result.status === "timeout") {
21817
22270
  printLine(`pr checks-wait: timeout \u2014 waited ${Math.round((result.waitedMs ?? 0) / 6e4)}m, last state: ${result.detail ?? "pending"}. No check failed; re-run to keep waiting, or pass --timeout <minutes>.`);
21818
22271
  } else printLine(`pr checks-wait: ${result.status}${result.reason ? ` \u2014 ${result.reason}` : ""}${result.detail ? ` (${result.detail})` : ""}`);
21819
- if (result.status === "failure") process.exitCode = 1;
22272
+ if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
21820
22273
  if (result.status === "timeout") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
21821
22274
  });
21822
22275
  async function ghMergeAutoEnqueue(prNumber, repo, method) {
@@ -21899,6 +22352,10 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
21899
22352
  waitForChecks: (prNumber, repo) => waitForPrChecks({
21900
22353
  resolvePolicy: () => resolveRepoMergeCiPolicy(repo, ciAuditDeps()),
21901
22354
  pollChecks: () => pollGhPrChecks(prNumber, repo ? ["--repo", repo] : []),
22355
+ // #2970: `pr land` only ever lands PRs based on development (resolveRepo above already rejects any
22356
+ // other base), so the fast-fail message's base branch is always 'development' here.
22357
+ pollMergeable: () => pollGhPrMergeable(prNumber, repo ? ["--repo", repo] : []),
22358
+ baseBranch: "development",
21902
22359
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
21903
22360
  log: (message) => console.warn(message),
21904
22361
  // `pr land` inherits the same (raised) checks budget, so it needs the same liveness — otherwise the
@@ -22172,7 +22629,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
22172
22629
  }
22173
22630
  };
22174
22631
  }
22175
- const boardAdvance = await advanceClosedIssuesToDone(number, o.repo);
22632
+ const boardAdvance = await advanceClosedIssuesToDone2(number, o.repo);
22176
22633
  console.log(JSON.stringify({
22177
22634
  ...buildPrMergeResultPayload({
22178
22635
  number,
@@ -22182,8 +22639,14 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
22182
22639
  localCleanup,
22183
22640
  housekeeping
22184
22641
  }),
22185
- ...boardAdvance ? { boardAdvance } : {}
22642
+ // `boardAdvance` keeps its published array shape; `boardAdvanceStatus` is the field a caller reads to
22643
+ // tell "merged clean" from "merged, but the board did not follow" without guessing from the exit code.
22644
+ ...boardAdvance.entries.length ? { boardAdvance: boardAdvance.entries } : {},
22645
+ boardAdvanceStatus: boardAdvance.status
22186
22646
  }));
22647
+ const boardAdvanceMessage = boardAdvanceFailureMessage(boardAdvance);
22648
+ if (boardAdvanceMessage) console.error(boardAdvanceMessage);
22649
+ process.exitCode = boardAdvanceExitCode(boardAdvance) ?? process.exitCode;
22187
22650
  });
22188
22651
  registerQueryCommands(program2);
22189
22652
  registerWorktreeCommands(program2);
@@ -23380,7 +23843,7 @@ access.command("audit").description("audit collaborator roles + train-branch pus
23380
23843
  });
23381
23844
  access.command("capabilities").description("enumerate your effective vault reach \u2014 every credential NAME + tier + scope you can read/use across project + org/master tiers (names only, no values) (#1615)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((o) => withSecrets((d) => secretsCapabilities(d, o)));
23382
23845
  var isWin2 = process.platform === "win32";
23383
- program2.command("doctor").description("check onboarding gates and auto-heal CLI/plugin wiring; use --verbose for the full audit checklist").option("--banner", "one-line resume summary; silent when all gates pass").option("--fast", "offline-safe fast lane; skips slow checks and network probes").option("--preflight", "eager version/plugin heal with upfront notice when stale; silent when healthy (#1871)").option("--verbose", "run extended audit checks and print the full checklist + version report (#1989)").option("--ci", "stable CI output contract; no color, exit 0 for clear/advisory-only, 1 for hard gaps, 2 for doctor/tool failure").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for train preflights)").option("--self", "verify CLI/plugin version parity, PATH, gh auth scopes, and hook wiring (offline-safe; suggests plugin-heal on a hard gap) (#2689)").addHelpText("after", "\nExit codes:\n 0 no hard gaps remain; advisory gaps may exist\n 1 one or more included hard checks failed\n 2 doctor/tool execution failed before a normal verdict\n\n--banner keeps the legacy SessionStart contract and returns 0 unless the process crashes.\n").action(async (opts) => {
23846
+ program2.command("doctor").description("check onboarding gates and auto-heal CLI/plugin wiring; use --verbose for the full audit checklist").option("--banner", "one-line resume summary; silent when all gates pass").option("--fast", "offline-safe fast lane; skips slow checks and network probes").option("--preflight", "eager version/plugin heal with upfront notice when stale; silent when healthy (#1871)").option("--verbose", "print the evidence behind every check \u2014 probes, resolved paths, versions compared, and the names behind each count (#2977)").option("--ci", "stable CI output contract; no color, exit 0 for clear/advisory-only, 1 for hard gaps, 2 for doctor/tool failure").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for train preflights)").option("--self", "verify CLI/plugin version parity, PATH, gh auth scopes, and hook wiring (offline-safe; suggests plugin-heal on a hard gap) (#2689)").addHelpText("after", "\nExit codes:\n 0 no hard gaps remain; advisory gaps may exist\n 1 one or more included hard checks failed\n 2 doctor/tool execution failed before a normal verdict\n\n--banner keeps the legacy SessionStart contract and returns 0 unless the process crashes.\n").action(async (opts) => {
23384
23847
  if (opts.guide) {
23385
23848
  consoleIo.log("MMI Agentic Onboarding: https://github.com/mutmutco/MMI-Hub/wiki/Agentic-Dev-Environment-Guide");
23386
23849
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.27.1",
3
+ "version": "3.28.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",