@mutmutco/cli 3.101.0 → 3.102.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 +119 -27
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -7536,6 +7536,39 @@ function missingPlaceholders(rendered) {
7536
7536
  for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
7537
7537
  return [...out];
7538
7538
  }
7539
+ async function resolveHubSeedSource(execGit, defaultBranch = "development") {
7540
+ let status;
7541
+ try {
7542
+ status = (await execGit(["status", "--porcelain"])).trim();
7543
+ } catch (e) {
7544
+ return { ok: false, reason: `could not read git status of this checkout (${e.message}) \u2014 refusing to treat an unknown working tree as the fleet's desired state` };
7545
+ }
7546
+ if (status) {
7547
+ return {
7548
+ ok: false,
7549
+ reason: `this checkout has uncommitted changes \u2014 refusing to read seeds from a working tree that has not been reviewed or merged:
7550
+ ${status}`
7551
+ };
7552
+ }
7553
+ let head;
7554
+ let remoteSha;
7555
+ try {
7556
+ head = (await execGit(["rev-parse", "HEAD"])).trim();
7557
+ remoteSha = (await execGit(["ls-remote", "origin", `refs/heads/${defaultBranch}`])).trim().split(/\s+/)[0] ?? "";
7558
+ } catch (e) {
7559
+ return { ok: false, reason: `could not resolve this checkout's HEAD against origin/${defaultBranch} (${e.message}) \u2014 refusing to read seeds without a known upstream revision` };
7560
+ }
7561
+ if (!remoteSha) {
7562
+ return { ok: false, reason: `\`git ls-remote origin refs/heads/${defaultBranch}\` returned nothing \u2014 refusing to read seeds without a known upstream revision` };
7563
+ }
7564
+ if (head !== remoteSha) {
7565
+ return {
7566
+ ok: false,
7567
+ reason: `this checkout (${head}) is not origin/${defaultBranch} (${remoteSha}) \u2014 fast-forward to origin/${defaultBranch} first; a stale or ahead checkout must not seed the fleet`
7568
+ };
7569
+ }
7570
+ return { ok: true, sha: head };
7571
+ }
7539
7572
  var GITIGNORE_MANAGED_BEGIN = "# >>> mmi-managed >>>";
7540
7573
  var GITIGNORE_MANAGED_END = "# <<< mmi-managed <<<";
7541
7574
  var MANAGED_GITIGNORE_LINES = [
@@ -23943,6 +23976,16 @@ var import_node_crypto6 = require("node:crypto");
23943
23976
  function byteComparableSeeds(manifest, cls) {
23944
23977
  return manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self" && s.classes.includes(cls));
23945
23978
  }
23979
+ function declaredRenderWaivers(manifest, repo) {
23980
+ const slug = repo.includes("/") ? repo.slice(repo.indexOf("/") + 1).toLowerCase() : repo.toLowerCase();
23981
+ const findings = [];
23982
+ for (const seed of manifest.seeds) {
23983
+ if (seed.ownership !== "org" || seed.source === "self") continue;
23984
+ const why = seed.waivers?.[slug];
23985
+ if (why) findings.push({ repo, target: seed.target, state: "waived", detail: `hand-authored, not rendered \u2014 waived: ${why}` });
23986
+ }
23987
+ return findings;
23988
+ }
23946
23989
  function compareSeedBytes(hubContent, repoContent) {
23947
23990
  if (repoContent === null) return "absent";
23948
23991
  const normalize = (s) => s.replace(/\r\n/g, "\n");
@@ -24020,18 +24063,21 @@ function assignWaves(repos, canarySlug) {
24020
24063
  rest.forEach((r, i) => waves.set(r.repo, i < wave1Count ? 1 : 2));
24021
24064
  return waves;
24022
24065
  }
24023
- function statusFor(read) {
24066
+ function statusFor(read, currentSha) {
24024
24067
  if (!read) return { status: "pending", record: {} };
24025
24068
  if (read.drift === "match") return { status: "match", record: { mergeSha: read.pr?.mergeSha } };
24026
24069
  const pr2 = read.pr;
24027
24070
  if (!pr2) return { status: "pending", record: {} };
24028
24071
  if (pr2.state === "closed") return { status: "closed-unmerged", record: { prNumber: pr2.number, prUrl: pr2.url } };
24072
+ if (pr2.state === "merged" && pr2.sourceSha && pr2.sourceSha !== currentSha) {
24073
+ return { status: "pending", record: {} };
24074
+ }
24029
24075
  if (pr2.checks === "red") return { status: "red", record: { prNumber: pr2.number, prUrl: pr2.url } };
24030
24076
  return { status: "open-pending", record: { prNumber: pr2.number, prUrl: pr2.url, mergeSha: pr2.mergeSha } };
24031
24077
  }
24032
24078
  var HALTING_STATUSES = /* @__PURE__ */ new Set(["red", "closed-unmerged"]);
24033
24079
  function planPropagationTick(input) {
24034
- const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied } = input;
24080
+ const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha } = input;
24035
24081
  const readByRepo = new Map(reads.map((r) => [r.repo, r]));
24036
24082
  const records = [];
24037
24083
  const opened = [];
@@ -24065,7 +24111,7 @@ function planPropagationTick(input) {
24065
24111
  let waveAllMatch = true;
24066
24112
  let waveHasRed = false;
24067
24113
  for (const r of waveRepos) {
24068
- const { status, record } = statusFor(readByRepo.get(r.repo));
24114
+ const { status, record } = statusFor(readByRepo.get(r.repo), currentSha);
24069
24115
  const shouldOpen = status === "pending";
24070
24116
  if (shouldOpen) opened.push(r.repo);
24071
24117
  records.push({
@@ -24807,6 +24853,7 @@ async function reconcileOrgNoAgentFilesRuleset(plan, client, org = ORG_LOGIN) {
24807
24853
  }
24808
24854
 
24809
24855
  // src/bootstrap-commands.ts
24856
+ var execGitForSeedSource = async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout;
24810
24857
  function registerBootstrapCommands(program3) {
24811
24858
  const bootstrap = program3.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--json", "machine-readable output").action((o) => {
24812
24859
  if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
@@ -24886,6 +24933,8 @@ function registerBootstrapCommands(program3) {
24886
24933
  const o = { repo: rawValue("--repo", ""), json: rawFlag("--json") };
24887
24934
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
24888
24935
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap drift: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the reference this compares against`);
24936
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
24937
+ if (!seedSource.ok) return fail(`bootstrap drift: ${seedSource.reason}`);
24889
24938
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
24890
24939
  const hubContents = /* @__PURE__ */ new Map();
24891
24940
  for (const s of manifest.seeds) {
@@ -24928,12 +24977,14 @@ function registerBootstrapCommands(program3) {
24928
24977
  reads.push({ target: seed.target, content });
24929
24978
  }
24930
24979
  findings.push(...auditRepoSeedDrift(repo, seeds, hubContents, reads));
24980
+ findings.push(...declaredRenderWaivers(manifest, repo));
24931
24981
  }
24932
24982
  if (o.json) {
24933
24983
  console.log(JSON.stringify({
24934
24984
  // #3842: `ok` reflects real findings; waivers ride the payload so a consumer can see every
24935
24985
  // standing exception without them counting as drift.
24936
24986
  ok: findings.every((f) => f.state === "waived"),
24987
+ sourceSha: seedSource.sha,
24937
24988
  scope: o.repo ? "single-repo" : "fleet",
24938
24989
  reposAudited: targets.length,
24939
24990
  seedsPerRepo,
@@ -24968,6 +25019,8 @@ function registerBootstrapCommands(program3) {
24968
25019
  }
24969
25020
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
24970
25021
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; bootstrap runs from the MMI-Hub repo root by design \u2014 it stamps org-level resources (Project, Ruleset, secrets, access) through the GitHub App, which is only authorized from the Hub checkout`);
25022
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25023
+ if (!seedSource.ok) return fail(`bootstrap apply: ${seedSource.reason}`);
24971
25024
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
24972
25025
  const baseBranch = o.class === "content" ? "main" : "development";
24973
25026
  const slug = parsedRepo.slug;
@@ -25144,9 +25197,9 @@ function registerBootstrapCommands(program3) {
25144
25197
  "--body",
25145
25198
  onlyTarget ? `Auto-opened by \`mmi-cli bootstrap apply ${repo} --only ${onlyTarget} --execute\` (#3818).
25146
25199
 
25147
- \`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\`; this PR brings this repo's copy to the Hub's. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
25200
+ \`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\` at MMI-Hub@${seedSource.sha}; this PR brings this repo's copy to the Hub's. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
25148
25201
 
25149
- \`${baseBranch}\` is protected (${seedPlan.reason}), so delivery goes via this branch + PR \u2014 a direct contents PUT 409s on a protected base.` : `Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#2286): \`${baseBranch}\` is protected (${seedPlan.reason}), so the org-managed seed files are delivered via this branch + PR \u2014 a direct contents PUT 409s on a protected base ("N of N required status checks are expected").`
25202
+ \`${baseBranch}\` is protected (${seedPlan.reason}), so delivery goes via this branch + PR \u2014 a direct contents PUT 409s on a protected base.` : `Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#2286): \`${baseBranch}\` is protected (${seedPlan.reason}), so the org-managed seed files are delivered via this branch + PR \u2014 a direct contents PUT 409s on a protected base ("N of N required status checks are expected"). Seeds are from MMI-Hub@${seedSource.sha}.`
25150
25203
  ]);
25151
25204
  seedPrUrl = created.url;
25152
25205
  }
@@ -25242,7 +25295,7 @@ function registerBootstrapCommands(program3) {
25242
25295
  applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
25243
25296
  }
25244
25297
  }
25245
- if (o.json) console.log(JSON.stringify({ repo, class: o.class, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
25298
+ if (o.json) console.log(JSON.stringify({ repo, class: o.class, sourceSha: seedSource.sha, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
25246
25299
  else {
25247
25300
  console.log(renderSeedPlan(actions));
25248
25301
  if (o.execute) console.log(`
@@ -25254,6 +25307,8 @@ LIVE apply to ${repo}:
25254
25307
  const o = { target: rawValue("--target", ""), execute: rawFlag("--execute"), json: rawFlag("--json") };
25255
25308
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
25256
25309
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap propagate: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the desired state this tick propagates`);
25310
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25311
+ if (!seedSource.ok) return fail(`bootstrap propagate: ${seedSource.reason}`);
25257
25312
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25258
25313
  const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25259
25314
  if (!o.target) {
@@ -25337,13 +25392,14 @@ LIVE apply to ${repo}:
25337
25392
  let pr2;
25338
25393
  try {
25339
25394
  const branch = `${branchPrefix}-${r.slug}`;
25340
- const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit", "--limit", "1"]);
25395
+ const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit,body", "--limit", "1"]);
25341
25396
  const arr = JSON.parse(listed.stdout || "[]");
25342
25397
  const p = arr[0];
25343
25398
  if (p) {
25344
25399
  const rollup = p.statusCheckRollup ?? [];
25345
25400
  const checks = rollup.length === 0 ? "none" : rollup.some((c) => c.conclusion === "FAILURE" || c.state === "FAILURE") ? "red" : rollup.every((c) => c.conclusion === "SUCCESS" || c.state === "SUCCESS") ? "success" : "pending";
25346
- pr2 = { number: p.number, url: p.url, state: p.state === "MERGED" ? "merged" : p.state === "CLOSED" ? "closed" : "open", checks, mergeSha: p.mergeCommit?.oid };
25401
+ const sourceSha = p.body?.match(/Propagates MMI-Hub@([0-9a-fA-F]{7,40})/)?.[1];
25402
+ pr2 = { number: p.number, url: p.url, state: p.state === "MERGED" ? "merged" : p.state === "CLOSED" ? "closed" : "open", checks, mergeSha: p.mergeCommit?.oid, sourceSha };
25347
25403
  }
25348
25404
  } catch {
25349
25405
  pr2 = void 0;
@@ -25365,10 +25421,10 @@ LIVE apply to ${repo}:
25365
25421
  }
25366
25422
  }
25367
25423
  }
25368
- const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied });
25424
+ const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha: seedSource.sha });
25369
25425
  if (o.execute) {
25370
25426
  if (plan.refusedNoCanary) return fail("bootstrap propagate --execute: no canary declared for this target \u2014 refusing to write (set seedCanary:true on exactly one registry repo)");
25371
- const headSha = (await gh(["api", "repos/mutmutco/MMI-Hub/commits/development", "--jq", ".sha"])).stdout.trim();
25427
+ const headSha = seedSource.sha;
25372
25428
  for (const rec of plan.records) {
25373
25429
  if (rec.action !== "open-pr") continue;
25374
25430
  const repoEntry = repos.find((r) => r.repo === rec.repo);
@@ -25433,8 +25489,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25433
25489
  rec.prUrl = prUrl;
25434
25490
  }
25435
25491
  }
25436
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25437
- else console.log(renderPropagationReport(plan));
25492
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25493
+ else console.log(`${renderPropagationReport(plan)}
25494
+ source: MMI-Hub@${seedSource.sha}`);
25438
25495
  if (plan.halted) process.exitCode = 1;
25439
25496
  });
25440
25497
  bootstrap.command("rollback <repo>").description("#4240: open a per-repo revert PR of the recorded seed-propagate merge \u2014 never a fleet-wide overwrite; dry-run unless --execute").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--target <path>", "the manifest target to roll back (an ownership:org + source:self seed, e.g. .github/workflows/agent-pr.yml)").option("--record <path>", "read propagation candidates from a persisted `bootstrap propagate --json` report instead of the live seed-propagate PR history").option("--execute", "LIVE revert via gh (master-gated) \u2014 opens/reuses the seed-rollback PR; dry-run prints the plan only").option("--json", "machine-readable output").action(async (repo) => {
@@ -25454,6 +25511,8 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25454
25511
  }
25455
25512
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
25456
25513
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap rollback: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the manifest names which targets are org-owned and therefore propagated (and rollback-able)`);
25514
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25515
+ if (!seedSource.ok) return fail(`bootstrap rollback: ${seedSource.reason}`);
25457
25516
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25458
25517
  const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25459
25518
  if (!o.target) {
@@ -25509,7 +25568,7 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25509
25568
  }
25510
25569
  const plan = planRollback(repo, seed.target, slug, candidates);
25511
25570
  if (!plan.resolution.found) {
25512
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25571
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25513
25572
  else console.log(renderRollbackReport(plan));
25514
25573
  return fail(`bootstrap rollback: ${plan.resolution.reason}`);
25515
25574
  }
@@ -25564,8 +25623,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25564
25623
  }
25565
25624
  plan.prUrl = prUrl;
25566
25625
  }
25567
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25568
- else console.log(renderRollbackReport(plan));
25626
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25627
+ else console.log(`${renderRollbackReport(plan)}
25628
+ manifest verified against: MMI-Hub@${seedSource.sha}`);
25569
25629
  });
25570
25630
  }
25571
25631
 
@@ -29582,6 +29642,25 @@ function findNegatedClosings(text, closingNumbers) {
29582
29642
  return forIssue.length > 0 && forIssue.every((m) => m.negated);
29583
29643
  });
29584
29644
  }
29645
+ var CROSS_REPO_TOKEN_RE = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g;
29646
+ function findCrossRepoAmbiguousClosings(text, closingNumbers, repo) {
29647
+ if (!closingNumbers.length) return [];
29648
+ const closing = new Set(closingNumbers);
29649
+ const flagged = /* @__PURE__ */ new Set();
29650
+ const repoLower = repo.toLowerCase();
29651
+ for (const match of text.matchAll(CROSS_REPO_TOKEN_RE)) {
29652
+ const n = Number(match[2]);
29653
+ if (!closing.has(n)) continue;
29654
+ if ((match[1] ?? "").toLowerCase() === repoLower) continue;
29655
+ flagged.add(n);
29656
+ }
29657
+ return [...flagged];
29658
+ }
29659
+ function crossRepoAmbiguousRefusalMessage(ambiguous, repo, context = "pr merge") {
29660
+ const named = ambiguous.map((n) => `${repo}#${n}`).join(", ");
29661
+ const first = `#${ambiguous[0] ?? "N"}`;
29662
+ return `${context}: REFUSED \u2014 GitHub will close ${named} on merge (a bare \`#N\` after a closing keyword always means the repo the PR lives in), but this body also mentions ${first} qualified against a DIFFERENT repo elsewhere \u2014 check whether that closing keyword was meant to carry an explicit owner/repo#N qualifier instead. Reword the closing reference with the full owner/repo#N form, or re-run with --force to merge anyway and let ${named} close.`;
29663
+ }
29585
29664
  function commitMessagesText(commits) {
29586
29665
  if (!Array.isArray(commits)) return "";
29587
29666
  return commits.map((commit) => {
@@ -29590,7 +29669,7 @@ function commitMessagesText(commits) {
29590
29669
  ${typeof messageBody === "string" ? messageBody : ""}`;
29591
29670
  }).join("\n");
29592
29671
  }
29593
- function parseClosingGuardInput(raw) {
29672
+ function parseClosingGuardInput(raw, repo) {
29594
29673
  if (!raw || typeof raw !== "object") return void 0;
29595
29674
  const { title, body, state, closingIssuesReferences, commits } = raw;
29596
29675
  if (typeof state !== "string" || !Array.isArray(closingIssuesReferences)) return void 0;
@@ -29603,7 +29682,7 @@ function parseClosingGuardInput(raw) {
29603
29682
  const commitClosing = [...new Set(findClosingMentions(commitMessagesText(commits)).map((m) => m.issue))];
29604
29683
  const text = `${typeof title === "string" ? title : ""}
29605
29684
  ${typeof body === "string" ? body : ""}`;
29606
- return { state, text, closing, commitClosing };
29685
+ return { state, text, closing, commitClosing, repo };
29607
29686
  }
29608
29687
  function negatedClosingRefusalMessage(negated, context = "pr merge") {
29609
29688
  const named = negated.map((n) => `#${n}`).join(", ");
@@ -29632,12 +29711,24 @@ function evaluateClosingGuard(input, opts) {
29632
29711
  }
29633
29712
  if (input.closing.length === 0) return { blocked: false };
29634
29713
  const negated = findNegatedClosings(input.text, input.closing);
29635
- if (!negated.length) return { blocked: false };
29636
- if (!opts.force) return { blocked: true, message: negatedClosingRefusalMessage(negated, opts.context) };
29637
- return {
29638
- blocked: false,
29639
- message: `${opts.context}: --force past the negated-closing guard \u2014 GitHub will still close ${negated.map((n) => `#${n}`).join(", ")} on merge although the PR body says it does not.`
29640
- };
29714
+ if (negated.length) {
29715
+ if (!opts.force) return { blocked: true, message: negatedClosingRefusalMessage(negated, opts.context) };
29716
+ return {
29717
+ blocked: false,
29718
+ message: `${opts.context}: --force past the negated-closing guard \u2014 GitHub will still close ${negated.map((n) => `#${n}`).join(", ")} on merge although the PR body says it does not.`
29719
+ };
29720
+ }
29721
+ if (input.repo) {
29722
+ const ambiguous = findCrossRepoAmbiguousClosings(input.text, input.closing, input.repo);
29723
+ if (ambiguous.length) {
29724
+ if (!opts.force) return { blocked: true, message: crossRepoAmbiguousRefusalMessage(ambiguous, input.repo, opts.context) };
29725
+ return {
29726
+ blocked: false,
29727
+ message: `${opts.context}: --force past the cross-repo-ambiguous closing guard \u2014 GitHub will still close ${ambiguous.map((n) => `${input.repo}#${n}`).join(", ")} on merge.`
29728
+ };
29729
+ }
29730
+ }
29731
+ return { blocked: false };
29641
29732
  }
29642
29733
 
29643
29734
  // src/session-report.ts
@@ -34556,12 +34647,13 @@ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skip
34556
34647
  if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
34557
34648
  if (result.status === "timeout" || result.status === "rate-limited") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
34558
34649
  });
34559
- pr.command("land <number>").description("agent merge path (#1440): train probe ? checks-wait ? merge --auto ? poll enqueued ? development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--preserve-worktree", "after merge, keep the local PR worktree/stage/branch for an active batch (#1888)").option("--force", "acknowledge and land past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword refusal (#3718)").action(async (number, o) => {
34650
+ pr.command("land <number>").description("agent merge path (#1440): train probe ? checks-wait ? merge --auto ? poll enqueued ? development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--preserve-worktree", "after merge, keep the local PR worktree/stage/branch for an active batch (#1888)").option("--force", "acknowledge and land past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal").action(async (number, o) => {
34560
34651
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
34561
34652
  const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
34562
34653
  assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr land", { force: o.force });
34563
34654
  const landClosingGuardRaw = await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "title,body,state,closingIssuesReferences,commits"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => JSON.parse(r.stdout)).catch(() => void 0);
34564
- const landClosingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(landClosingGuardRaw), { force: o.force, context: "pr land" });
34655
+ const landRepoForGuard = await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
34656
+ const landClosingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(landClosingGuardRaw, landRepoForGuard), { force: o.force, context: "pr land" });
34565
34657
  if (landClosingGuardVerdict.blocked) {
34566
34658
  console.error(landClosingGuardVerdict.message);
34567
34659
  process.exitCode = 1;
@@ -34672,14 +34764,14 @@ pr.command("land <number>").description("agent merge path (#1440): train probe ?
34672
34764
  }
34673
34765
  if (result.status === "failed" || result.cleanupError) process.exitCode = 1;
34674
34766
  });
34675
- jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree ? no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").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)").option("--auto", "enable auto-merge ? merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword refusal (#3718)")).action(async (number, o) => {
34767
+ jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree ? no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").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)").option("--auto", "enable auto-merge ? merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal")).action(async (number, o) => {
34676
34768
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
34677
34769
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
34678
34770
  const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
34679
34771
  const [headRef, baseRef, headRefOid] = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName,baseRefName,headRefOid", "--jq", '.headRefName + " " + .baseRefName + " " + (.headRefOid // "")'], { timeout: GC_GH_TIMEOUT_MS2 })).stdout.trim().split(/\s+/);
34680
34772
  const headIsProtected = isProtectedBranch(headRef);
34681
34773
  const closingGuardRaw = await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "title,body,state,closingIssuesReferences,commits"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => JSON.parse(r.stdout)).catch(() => void 0);
34682
- const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw), { force: o.force, context: "pr merge" });
34774
+ const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw, repoForPostCleanup), { force: o.force, context: "pr merge" });
34683
34775
  if (closingGuardVerdict.blocked) {
34684
34776
  console.error(closingGuardVerdict.message);
34685
34777
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.101.0",
3
+ "version": "3.102.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",