@mutmutco/cli 3.101.0 → 3.103.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 +141 -30
  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 = [
@@ -17913,6 +17946,9 @@ function selectPrunablePluginVersions(names, currentVersion, installedVersion) {
17913
17946
  function pluginCacheRootForConfig(configRoot) {
17914
17947
  return `${configRoot}/plugins/cache/mutmutco/mmi`;
17915
17948
  }
17949
+ function pluginCacheRoot(home) {
17950
+ return pluginCacheRootForConfig(`${home}/.claude`);
17951
+ }
17916
17952
  function runningPluginVersion(env, cliVersion) {
17917
17953
  const root = env.CLAUDE_PLUGIN_ROOT?.trim();
17918
17954
  const leaf = root ? root.replace(/[\\/]+$/, "").split(/[\\/]/).pop() : void 0;
@@ -18134,12 +18170,28 @@ function renderPluginCachePlan(plan, applied) {
18134
18170
  function isMmiPiPackage(entry) {
18135
18171
  return /[\\/]mutmutco[\\/]mmi[\\/][^\\/]+[\\/]\.pi-plugin\/?$/.test(entry);
18136
18172
  }
18173
+ function newestExistingPiPlugin(home) {
18174
+ const cacheRoot = pluginCacheRoot(home);
18175
+ let names;
18176
+ try {
18177
+ names = (0, import_node_fs22.readdirSync)(cacheRoot);
18178
+ } catch {
18179
+ return void 0;
18180
+ }
18181
+ const newest = names.filter(isVersionDirName).sort((a, b) => compareVersions(b, a))[0];
18182
+ if (!newest) return void 0;
18183
+ const candidate = (0, import_node_path20.join)(cacheRoot, newest, ".pi-plugin");
18184
+ return (0, import_node_fs22.existsSync)(candidate) ? candidate : void 0;
18185
+ }
18137
18186
  function expectedPiPluginPath(home, env, installedVersion) {
18138
18187
  const root = env.CLAUDE_PLUGIN_ROOT?.trim();
18139
18188
  if (root && /[\\/]mutmutco[\\/]mmi[\\/]/.test(root)) return (0, import_node_path20.join)(root, ".pi-plugin");
18140
18189
  const version = installedVersion ?? runningPluginVersion(env);
18141
- if (!version) return void 0;
18142
- return (0, import_node_path20.join)(home, ".claude", "plugins", "cache", "mutmutco", "mmi", version, ".pi-plugin");
18190
+ if (version) {
18191
+ const pinned = (0, import_node_path20.join)(home, ".claude", "plugins", "cache", "mutmutco", "mmi", version, ".pi-plugin");
18192
+ if ((0, import_node_fs22.existsSync)(pinned)) return pinned;
18193
+ }
18194
+ return newestExistingPiPlugin(home);
18143
18195
  }
18144
18196
  function settingsPath(home) {
18145
18197
  return (0, import_node_path20.join)(home, ".pi", "agent", "settings.json");
@@ -18147,7 +18199,7 @@ function settingsPath(home) {
18147
18199
  function readPiPluginState(home, env, installedVersion) {
18148
18200
  if (!(0, import_node_fs22.existsSync)((0, import_node_path20.join)(home, ".pi", "agent"))) return void 0;
18149
18201
  const expectedPath = expectedPiPluginPath(home, env, installedVersion);
18150
- if (!expectedPath || !(0, import_node_fs22.existsSync)(expectedPath)) return void 0;
18202
+ if (!expectedPath) return void 0;
18151
18203
  const file = settingsPath(home);
18152
18204
  if (!(0, import_node_fs22.existsSync)(file)) return { expectedPath, settingsReadable: true };
18153
18205
  try {
@@ -23943,6 +23995,16 @@ var import_node_crypto6 = require("node:crypto");
23943
23995
  function byteComparableSeeds(manifest, cls) {
23944
23996
  return manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self" && s.classes.includes(cls));
23945
23997
  }
23998
+ function declaredRenderWaivers(manifest, repo) {
23999
+ const slug = repo.includes("/") ? repo.slice(repo.indexOf("/") + 1).toLowerCase() : repo.toLowerCase();
24000
+ const findings = [];
24001
+ for (const seed of manifest.seeds) {
24002
+ if (seed.ownership !== "org" || seed.source === "self") continue;
24003
+ const why = seed.waivers?.[slug];
24004
+ if (why) findings.push({ repo, target: seed.target, state: "waived", detail: `hand-authored, not rendered \u2014 waived: ${why}` });
24005
+ }
24006
+ return findings;
24007
+ }
23946
24008
  function compareSeedBytes(hubContent, repoContent) {
23947
24009
  if (repoContent === null) return "absent";
23948
24010
  const normalize = (s) => s.replace(/\r\n/g, "\n");
@@ -24020,18 +24082,21 @@ function assignWaves(repos, canarySlug) {
24020
24082
  rest.forEach((r, i) => waves.set(r.repo, i < wave1Count ? 1 : 2));
24021
24083
  return waves;
24022
24084
  }
24023
- function statusFor(read) {
24085
+ function statusFor(read, currentSha) {
24024
24086
  if (!read) return { status: "pending", record: {} };
24025
24087
  if (read.drift === "match") return { status: "match", record: { mergeSha: read.pr?.mergeSha } };
24026
24088
  const pr2 = read.pr;
24027
24089
  if (!pr2) return { status: "pending", record: {} };
24028
24090
  if (pr2.state === "closed") return { status: "closed-unmerged", record: { prNumber: pr2.number, prUrl: pr2.url } };
24091
+ if (pr2.state === "merged" && pr2.sourceSha && pr2.sourceSha !== currentSha) {
24092
+ return { status: "pending", record: {} };
24093
+ }
24029
24094
  if (pr2.checks === "red") return { status: "red", record: { prNumber: pr2.number, prUrl: pr2.url } };
24030
24095
  return { status: "open-pending", record: { prNumber: pr2.number, prUrl: pr2.url, mergeSha: pr2.mergeSha } };
24031
24096
  }
24032
24097
  var HALTING_STATUSES = /* @__PURE__ */ new Set(["red", "closed-unmerged"]);
24033
24098
  function planPropagationTick(input) {
24034
- const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied } = input;
24099
+ const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha } = input;
24035
24100
  const readByRepo = new Map(reads.map((r) => [r.repo, r]));
24036
24101
  const records = [];
24037
24102
  const opened = [];
@@ -24065,7 +24130,7 @@ function planPropagationTick(input) {
24065
24130
  let waveAllMatch = true;
24066
24131
  let waveHasRed = false;
24067
24132
  for (const r of waveRepos) {
24068
- const { status, record } = statusFor(readByRepo.get(r.repo));
24133
+ const { status, record } = statusFor(readByRepo.get(r.repo), currentSha);
24069
24134
  const shouldOpen = status === "pending";
24070
24135
  if (shouldOpen) opened.push(r.repo);
24071
24136
  records.push({
@@ -24807,6 +24872,7 @@ async function reconcileOrgNoAgentFilesRuleset(plan, client, org = ORG_LOGIN) {
24807
24872
  }
24808
24873
 
24809
24874
  // src/bootstrap-commands.ts
24875
+ var execGitForSeedSource = async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout;
24810
24876
  function registerBootstrapCommands(program3) {
24811
24877
  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
24878
  if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
@@ -24886,6 +24952,8 @@ function registerBootstrapCommands(program3) {
24886
24952
  const o = { repo: rawValue("--repo", ""), json: rawFlag("--json") };
24887
24953
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
24888
24954
  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`);
24955
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
24956
+ if (!seedSource.ok) return fail(`bootstrap drift: ${seedSource.reason}`);
24889
24957
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
24890
24958
  const hubContents = /* @__PURE__ */ new Map();
24891
24959
  for (const s of manifest.seeds) {
@@ -24928,12 +24996,14 @@ function registerBootstrapCommands(program3) {
24928
24996
  reads.push({ target: seed.target, content });
24929
24997
  }
24930
24998
  findings.push(...auditRepoSeedDrift(repo, seeds, hubContents, reads));
24999
+ findings.push(...declaredRenderWaivers(manifest, repo));
24931
25000
  }
24932
25001
  if (o.json) {
24933
25002
  console.log(JSON.stringify({
24934
25003
  // #3842: `ok` reflects real findings; waivers ride the payload so a consumer can see every
24935
25004
  // standing exception without them counting as drift.
24936
25005
  ok: findings.every((f) => f.state === "waived"),
25006
+ sourceSha: seedSource.sha,
24937
25007
  scope: o.repo ? "single-repo" : "fleet",
24938
25008
  reposAudited: targets.length,
24939
25009
  seedsPerRepo,
@@ -24968,6 +25038,8 @@ function registerBootstrapCommands(program3) {
24968
25038
  }
24969
25039
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
24970
25040
  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`);
25041
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25042
+ if (!seedSource.ok) return fail(`bootstrap apply: ${seedSource.reason}`);
24971
25043
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
24972
25044
  const baseBranch = o.class === "content" ? "main" : "development";
24973
25045
  const slug = parsedRepo.slug;
@@ -25144,9 +25216,9 @@ function registerBootstrapCommands(program3) {
25144
25216
  "--body",
25145
25217
  onlyTarget ? `Auto-opened by \`mmi-cli bootstrap apply ${repo} --only ${onlyTarget} --execute\` (#3818).
25146
25218
 
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.
25219
+ \`${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
25220
 
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").`
25221
+ \`${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
25222
  ]);
25151
25223
  seedPrUrl = created.url;
25152
25224
  }
@@ -25242,7 +25314,7 @@ function registerBootstrapCommands(program3) {
25242
25314
  applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
25243
25315
  }
25244
25316
  }
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));
25317
+ 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
25318
  else {
25247
25319
  console.log(renderSeedPlan(actions));
25248
25320
  if (o.execute) console.log(`
@@ -25254,6 +25326,8 @@ LIVE apply to ${repo}:
25254
25326
  const o = { target: rawValue("--target", ""), execute: rawFlag("--execute"), json: rawFlag("--json") };
25255
25327
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
25256
25328
  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`);
25329
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25330
+ if (!seedSource.ok) return fail(`bootstrap propagate: ${seedSource.reason}`);
25257
25331
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25258
25332
  const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25259
25333
  if (!o.target) {
@@ -25337,13 +25411,14 @@ LIVE apply to ${repo}:
25337
25411
  let pr2;
25338
25412
  try {
25339
25413
  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"]);
25414
+ 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
25415
  const arr = JSON.parse(listed.stdout || "[]");
25342
25416
  const p = arr[0];
25343
25417
  if (p) {
25344
25418
  const rollup = p.statusCheckRollup ?? [];
25345
25419
  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 };
25420
+ const sourceSha = p.body?.match(/Propagates MMI-Hub@([0-9a-fA-F]{7,40})/)?.[1];
25421
+ pr2 = { number: p.number, url: p.url, state: p.state === "MERGED" ? "merged" : p.state === "CLOSED" ? "closed" : "open", checks, mergeSha: p.mergeCommit?.oid, sourceSha };
25347
25422
  }
25348
25423
  } catch {
25349
25424
  pr2 = void 0;
@@ -25365,10 +25440,10 @@ LIVE apply to ${repo}:
25365
25440
  }
25366
25441
  }
25367
25442
  }
25368
- const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied });
25443
+ const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha: seedSource.sha });
25369
25444
  if (o.execute) {
25370
25445
  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();
25446
+ const headSha = seedSource.sha;
25372
25447
  for (const rec of plan.records) {
25373
25448
  if (rec.action !== "open-pr") continue;
25374
25449
  const repoEntry = repos.find((r) => r.repo === rec.repo);
@@ -25433,8 +25508,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25433
25508
  rec.prUrl = prUrl;
25434
25509
  }
25435
25510
  }
25436
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25437
- else console.log(renderPropagationReport(plan));
25511
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25512
+ else console.log(`${renderPropagationReport(plan)}
25513
+ source: MMI-Hub@${seedSource.sha}`);
25438
25514
  if (plan.halted) process.exitCode = 1;
25439
25515
  });
25440
25516
  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 +25530,8 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25454
25530
  }
25455
25531
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
25456
25532
  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)`);
25533
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25534
+ if (!seedSource.ok) return fail(`bootstrap rollback: ${seedSource.reason}`);
25457
25535
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25458
25536
  const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25459
25537
  if (!o.target) {
@@ -25509,7 +25587,7 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25509
25587
  }
25510
25588
  const plan = planRollback(repo, seed.target, slug, candidates);
25511
25589
  if (!plan.resolution.found) {
25512
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25590
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25513
25591
  else console.log(renderRollbackReport(plan));
25514
25592
  return fail(`bootstrap rollback: ${plan.resolution.reason}`);
25515
25593
  }
@@ -25564,8 +25642,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25564
25642
  }
25565
25643
  plan.prUrl = prUrl;
25566
25644
  }
25567
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25568
- else console.log(renderRollbackReport(plan));
25645
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25646
+ else console.log(`${renderRollbackReport(plan)}
25647
+ manifest verified against: MMI-Hub@${seedSource.sha}`);
25569
25648
  });
25570
25649
  }
25571
25650
 
@@ -29582,6 +29661,25 @@ function findNegatedClosings(text, closingNumbers) {
29582
29661
  return forIssue.length > 0 && forIssue.every((m) => m.negated);
29583
29662
  });
29584
29663
  }
29664
+ var CROSS_REPO_TOKEN_RE = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g;
29665
+ function findCrossRepoAmbiguousClosings(text, closingNumbers, repo) {
29666
+ if (!closingNumbers.length) return [];
29667
+ const closing = new Set(closingNumbers);
29668
+ const flagged = /* @__PURE__ */ new Set();
29669
+ const repoLower = repo.toLowerCase();
29670
+ for (const match of text.matchAll(CROSS_REPO_TOKEN_RE)) {
29671
+ const n = Number(match[2]);
29672
+ if (!closing.has(n)) continue;
29673
+ if ((match[1] ?? "").toLowerCase() === repoLower) continue;
29674
+ flagged.add(n);
29675
+ }
29676
+ return [...flagged];
29677
+ }
29678
+ function crossRepoAmbiguousRefusalMessage(ambiguous, repo, context = "pr merge") {
29679
+ const named = ambiguous.map((n) => `${repo}#${n}`).join(", ");
29680
+ const first = `#${ambiguous[0] ?? "N"}`;
29681
+ 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.`;
29682
+ }
29585
29683
  function commitMessagesText(commits) {
29586
29684
  if (!Array.isArray(commits)) return "";
29587
29685
  return commits.map((commit) => {
@@ -29590,7 +29688,7 @@ function commitMessagesText(commits) {
29590
29688
  ${typeof messageBody === "string" ? messageBody : ""}`;
29591
29689
  }).join("\n");
29592
29690
  }
29593
- function parseClosingGuardInput(raw) {
29691
+ function parseClosingGuardInput(raw, repo) {
29594
29692
  if (!raw || typeof raw !== "object") return void 0;
29595
29693
  const { title, body, state, closingIssuesReferences, commits } = raw;
29596
29694
  if (typeof state !== "string" || !Array.isArray(closingIssuesReferences)) return void 0;
@@ -29603,7 +29701,7 @@ function parseClosingGuardInput(raw) {
29603
29701
  const commitClosing = [...new Set(findClosingMentions(commitMessagesText(commits)).map((m) => m.issue))];
29604
29702
  const text = `${typeof title === "string" ? title : ""}
29605
29703
  ${typeof body === "string" ? body : ""}`;
29606
- return { state, text, closing, commitClosing };
29704
+ return { state, text, closing, commitClosing, repo };
29607
29705
  }
29608
29706
  function negatedClosingRefusalMessage(negated, context = "pr merge") {
29609
29707
  const named = negated.map((n) => `#${n}`).join(", ");
@@ -29632,12 +29730,24 @@ function evaluateClosingGuard(input, opts) {
29632
29730
  }
29633
29731
  if (input.closing.length === 0) return { blocked: false };
29634
29732
  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
- };
29733
+ if (negated.length) {
29734
+ if (!opts.force) return { blocked: true, message: negatedClosingRefusalMessage(negated, opts.context) };
29735
+ return {
29736
+ blocked: false,
29737
+ 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.`
29738
+ };
29739
+ }
29740
+ if (input.repo) {
29741
+ const ambiguous = findCrossRepoAmbiguousClosings(input.text, input.closing, input.repo);
29742
+ if (ambiguous.length) {
29743
+ if (!opts.force) return { blocked: true, message: crossRepoAmbiguousRefusalMessage(ambiguous, input.repo, opts.context) };
29744
+ return {
29745
+ blocked: false,
29746
+ 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.`
29747
+ };
29748
+ }
29749
+ }
29750
+ return { blocked: false };
29641
29751
  }
29642
29752
 
29643
29753
  // src/session-report.ts
@@ -34556,12 +34666,13 @@ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skip
34556
34666
  if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
34557
34667
  if (result.status === "timeout" || result.status === "rate-limited") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
34558
34668
  });
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) => {
34669
+ 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
34670
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
34561
34671
  const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
34562
34672
  assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr land", { force: o.force });
34563
34673
  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" });
34674
+ const landRepoForGuard = await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
34675
+ const landClosingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(landClosingGuardRaw, landRepoForGuard), { force: o.force, context: "pr land" });
34565
34676
  if (landClosingGuardVerdict.blocked) {
34566
34677
  console.error(landClosingGuardVerdict.message);
34567
34678
  process.exitCode = 1;
@@ -34672,14 +34783,14 @@ pr.command("land <number>").description("agent merge path (#1440): train probe ?
34672
34783
  }
34673
34784
  if (result.status === "failed" || result.cleanupError) process.exitCode = 1;
34674
34785
  });
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) => {
34786
+ 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
34787
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
34677
34788
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
34678
34789
  const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
34679
34790
  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
34791
  const headIsProtected = isProtectedBranch(headRef);
34681
34792
  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" });
34793
+ const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw, repoForPostCleanup), { force: o.force, context: "pr merge" });
34683
34794
  if (closingGuardVerdict.blocked) {
34684
34795
  console.error(closingGuardVerdict.message);
34685
34796
  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.103.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",