@mutmutco/codex-plugin 4.5.148 → 4.5.150

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mmi",
3
- "version": "4.5.148",
3
+ "version": "4.5.150",
4
4
  "mmiCompat": "4.x",
5
5
  "description": "MMI workflow skills and org gates delivery.",
6
6
  "author": {
package/cli/dist/main.cjs CHANGED
@@ -16296,6 +16296,9 @@ function checkRunbookClaims(root, docs, deps) {
16296
16296
  }
16297
16297
  return findings;
16298
16298
  }
16299
+ function localRunbookBase(refExists) {
16300
+ return ["origin/development", "origin/main"].find(refExists);
16301
+ }
16299
16302
  function checkRunbookCoverage(docs, base, git3) {
16300
16303
  const changed = git3(["diff", "--name-only", `${base}...HEAD`]).split(/\r?\n/).filter(Boolean);
16301
16304
  const findings = [];
@@ -18666,10 +18669,10 @@ var rollout_plan_default = {
18666
18669
  note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
18667
18670
  },
18668
18671
  baseline: {
18669
- version: "4.5.148",
18670
- tag: "v4.5.148",
18671
- commit: "4fd7a2ad34c1",
18672
- npm: "@mutmutco/cli@4.5.148"
18672
+ version: "4.5.150",
18673
+ tag: "v4.5.150",
18674
+ commit: "4dc71e3761ec",
18675
+ npm: "@mutmutco/cli@4.5.150"
18673
18676
  },
18674
18677
  exitCriterion: "fleet-n-of-n",
18675
18678
  hubOnlyShortcut: "forbidden",
@@ -18686,14 +18689,14 @@ var rollout_plan_default = {
18686
18689
  repo: "mutmutco/mmi-hub",
18687
18690
  role: "canary",
18688
18691
  schedule: "train",
18689
- v3Target: "v4.5.148"
18692
+ v3Target: "v4.5.150"
18690
18693
  }
18691
18694
  ],
18692
18695
  rollbackTrigger: "Any red inside the post-contract soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a pre-v4 client admitted instead of receiving actionable HTTP 426, or npm consumer install/doctor failure on the v4-only dist.",
18693
18696
  rollback: {
18694
18697
  independent: true,
18695
- mechanism: "npm dist-tag latest -> 4.5.148 and redeploy the Hub Lambda from tag v4.5.148 (4fd7a2ad34c1); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
18696
- v3Target: "v4.5.148 (@mutmutco/cli@4.5.148, tag commit 4fd7a2ad34c1 \u2014 last known-good release carrying the repo-index v4-only contract)"
18698
+ mechanism: "npm dist-tag latest -> 4.5.150 and redeploy the Hub Lambda from tag v4.5.150 (4dc71e3761ec); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
18699
+ v3Target: "v4.5.150 (@mutmutco/cli@4.5.150, tag commit 4dc71e3761ec \u2014 last known-good release carrying the repo-index v4-only contract)"
18697
18700
  }
18698
18701
  },
18699
18702
  {
@@ -26425,6 +26428,7 @@ async function runTrainDoctor(input) {
26425
26428
  const emitters = (workflows ?? []).filter((f) => parseWorkflowJobIds(f.body).some((id) => required.some((context) => contextMatchesJobId(id, context))));
26426
26429
  const gateFiles = emitters.length ? emitters : (workflows ?? []).filter((f) => isGateWorkflowPath(f.path));
26427
26430
  const gates = gateFiles.length ? gateFiles.map((f) => f.path.split("/").pop()) : ["gate"];
26431
+ const tagGateAwaitsPush = new Set(gateFiles.filter((f) => f.body.includes("scripts/gate-tag-reuse.mjs")).map((f) => f.path.split("/").pop()));
26428
26432
  try {
26429
26433
  const tip = await git3(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${startBranch}`]);
26430
26434
  for (const workflow of gates) {
@@ -26432,7 +26436,8 @@ async function runTrainDoctor(input) {
26432
26436
  let lastUrl = run.runUrl;
26433
26437
  let elapsed = 0;
26434
26438
  const startedAt = Date.now();
26435
- while (input.refuse && run.read === "found" && run.conclusion === "pending" && elapsed < CANDIDATE_PUSH_GATE_WAIT_MS) {
26439
+ const awaitsPush = tagGateAwaitsPush.has(workflow);
26440
+ while (input.refuse && !awaitsPush && run.read === "found" && run.conclusion === "pending" && elapsed < CANDIDATE_PUSH_GATE_WAIT_MS) {
26436
26441
  train.warn?.(`candidate push gate ${run.runUrl} still running \u2014 ${elapsed / 1e3}s elapsed (20m bound)`);
26437
26442
  await deps.sleep(CANDIDATE_PUSH_GATE_POLL_MS);
26438
26443
  run = await readTargetRun(train, repo, { workflow, event: "push" }, tip);
@@ -26454,6 +26459,8 @@ async function runTrainDoctor(input) {
26454
26459
  } catch {
26455
26460
  }
26456
26461
  add({ code: "candidate-push-gate-red", severity: "blocker", source: "origin", title: `${workflow} push gate failed on ${startBranch}@${tip.slice(0, 12)} \u2014 ${url}`, remedy: `${url} \u2014 failed job(s): ${jobs}. Fix on development through a normal CI-gated PR and rerun; --heal never clears a red gate` });
26462
+ } else if (run.conclusion === "pending" && awaitsPush) {
26463
+ add({ code: "candidate-push-gate-running", severity: "info", source: "origin", title: `${workflow} push gate still running \u2014 ${url}; the tag gate waits on it (scripts/gate-tag-reuse.mjs), so the train does not`, remedy: "nothing \u2014 a red push gate still blocks; the tag gate runs its full test union if this run ends red or times out (#8318)" });
26457
26464
  } else if (run.conclusion === "pending") {
26458
26465
  add({ code: "candidate-push-gate-running", severity: "warning", source: "origin", title: `${workflow} push gate still running \u2014 ${url}`, remedy: "wait for the run to conclude and rerun; apply waits up to 20 minutes before refusing" });
26459
26466
  if (input.refuse) throw new Error(`candidate push gate still running after 20 minutes \u2014 ${url}; nothing was tagged`);
@@ -40295,7 +40302,8 @@ function boardAdvanceFailureMessage(result) {
40295
40302
  }
40296
40303
 
40297
40304
  // src/pr-land.ts
40298
- var PR_LAND_QUEUE_TIMEOUT_MS = 45 * 6e4;
40305
+ var PR_LAND_QUEUE_TIMEOUT_MS = 75 * 6e4;
40306
+ var PR_LAND_QUEUE_BATCH_SIZE = 5;
40299
40307
  function prHeadBehindBase(input) {
40300
40308
  if ((input.mergeStateStatus ?? "").toUpperCase() === "BEHIND") return true;
40301
40309
  return /out[- ]of[- ]date|not up to date|up to date with the base|update (?:the|your) branch/i.test(input.message ?? "");
@@ -40349,6 +40357,12 @@ async function updatePrHeadFromBase(input) {
40349
40357
  throw new Error(`GitHub accepted the update of ${input.head} from ${input.base} but the head did not move \u2014 check the PR, then rerun`);
40350
40358
  }
40351
40359
  var PR_LAND_POLL_MS = 6e4;
40360
+ var PR_LAND_POLL_MAX_MS = 5 * 6e4;
40361
+ function prLandPollDelayMs(attempt, waiting) {
40362
+ const load = Math.max(1, Math.ceil(waiting / PR_LAND_QUEUE_BATCH_SIZE));
40363
+ const age = Math.min(2, 1 + Math.floor(attempt / 15) * 0.5);
40364
+ return Math.min(PR_LAND_POLL_MAX_MS, Math.round(PR_LAND_POLL_MS * load * age));
40365
+ }
40352
40366
  var PR_LAND_EJECT_CONFIRM_MS = 2e4;
40353
40367
  var PR_LAND_ENQUEUE_TIMEOUT_MS = 10 * 6e4;
40354
40368
  var PR_LAND_STATE_READ_RETRIES = 3;
@@ -40470,8 +40484,8 @@ async function runPrLand(prNumber, options, deps) {
40470
40484
  if (merge.mergeStatus === "merged") {
40471
40485
  return queueMerged(base, deps);
40472
40486
  }
40473
- const deadline = (deps.now ?? (() => Date.now()))() + PR_LAND_QUEUE_TIMEOUT_MS;
40474
- await (deps.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms))))(PR_LAND_POLL_MS);
40487
+ const deadline = (deps.now ?? (() => Date.now()))() + (options.queueTimeoutMs ?? PR_LAND_QUEUE_TIMEOUT_MS);
40488
+ await (deps.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms))))(deps.pollDelayMs?.(0) ?? PR_LAND_POLL_MS);
40475
40489
  return pollPrMergeQueue(prNumber, options, deps, base, void 0, deadline);
40476
40490
  }
40477
40491
  if (merge.mergeStatus === "merged") {
@@ -40510,7 +40524,9 @@ function queueMerged(base, deps) {
40510
40524
  async function pollPrMergeQueue(pr, options, deps, base, snapshot, deadlineMs) {
40511
40525
  const now = deps.now ?? (() => Date.now());
40512
40526
  const sleep2 = deps.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
40513
- const deadline = deadlineMs ?? now() + PR_LAND_QUEUE_TIMEOUT_MS;
40527
+ const timeoutMs = options.queueTimeoutMs ?? PR_LAND_QUEUE_TIMEOUT_MS;
40528
+ const deadline = deadlineMs ?? now() + timeoutMs;
40529
+ let attempt = 0;
40514
40530
  while (now() < deadline) {
40515
40531
  const read = snapshot ?? await deps.readMergeQueue(pr, base.repo);
40516
40532
  snapshot = void 0;
@@ -40540,16 +40556,45 @@ async function pollPrMergeQueue(pr, options, deps, base, snapshot, deadlineMs) {
40540
40556
  deps.queueProgress?.(result2);
40541
40557
  return result2;
40542
40558
  }
40543
- await sleep2(PR_LAND_POLL_MS);
40559
+ await sleep2(deps.pollDelayMs?.(++attempt) ?? PR_LAND_POLL_MS);
40544
40560
  }
40545
40561
  const result = {
40546
40562
  ...base,
40547
40563
  mergeQueue: { ...base.mergeQueue, state: "timeout" },
40548
- error: "merge queue did not reach MERGED within 45 minutes \u2014 rerun pr land to resume polling"
40564
+ error: `merge queue did not reach MERGED within ${Math.round(timeoutMs / 6e4)} minutes \u2014 rerun pr land to resume polling`
40549
40565
  };
40550
40566
  deps.queueProgress?.(result);
40551
40567
  return result;
40552
40568
  }
40569
+ function prLandOutcome(result) {
40570
+ if (result.status !== "failed" && result.status !== "queued") return "merged";
40571
+ if (result.status === "queued") return "queued";
40572
+ const state = result.mergeQueue?.state;
40573
+ return state === "ejected" || state === "timeout" ? state : "failed";
40574
+ }
40575
+ async function landMany(prs, landOne, cleanup, hooks) {
40576
+ const safe = async (pr) => landOne(pr).catch((e) => ({ status: "failed", repo: "", pr, base: "", error: e.message }));
40577
+ return Promise.all(prs.map(async (pr) => {
40578
+ let result = await safe(pr);
40579
+ if (prLandOutcome(result) === "ejected") {
40580
+ hooks?.onRetry?.(pr, result);
40581
+ result = { ...await safe(pr), retriedAfterEjection: true };
40582
+ }
40583
+ const outcome = prLandOutcome(result);
40584
+ const cleaned = result.status === "merged-via-queue" ? await cleanup(result).catch((e) => `cleanup failed: ${e.message}`) : void 0;
40585
+ hooks?.onDone?.();
40586
+ return { pr, outcome, result, ...cleaned ? { cleanup: cleaned } : {} };
40587
+ }));
40588
+ }
40589
+ function renderPrLandTable(entries) {
40590
+ const rows = entries.map((e) => [
40591
+ `#${e.pr}`,
40592
+ e.outcome + (e.result.retriedAfterEjection ? " (after retry)" : ""),
40593
+ [e.result.error, e.cleanup].filter(Boolean).join(" | ") || "-"
40594
+ ]);
40595
+ const width = (i) => Math.max(...rows.map((r) => r[i].length));
40596
+ return rows.map((r) => `${r[0].padEnd(width(0))} ${r[1].padEnd(width(1))} ${r[2]}`).join("\n");
40597
+ }
40553
40598
 
40554
40599
  // src/test-policy-core.ts
40555
40600
  var import_node_child_process13 = require("node:child_process");
@@ -47253,205 +47298,246 @@ ${list}`);
47253
47298
  }
47254
47299
  class PrHeadBehindBaseError extends Error {
47255
47300
  }
47256
- pr.command("land <number>").description("agent merge path (#1440): train probe \u2014 checks-wait \u2014 merge --auto \u2014 poll enqueued \u2014 development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--enqueue-only", "return once queue membership is confirmed; without a merge queue, land normally").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").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").option("--without-review <reason>", "compatibility option; shared merge helpers do not require a review verdict").action(async (number, o) => {
47257
- if (/^(?:[^/]+\/[^/]+)?#\d+$/.test(number.trim())) {
47258
- try {
47259
- const parsed = parseIssueRef(number, o.repo);
47260
- number = String(parsed.number);
47261
- if (parsed.repo) o.repo = parsed.repo;
47262
- } catch (e) {
47263
- return fail(`pr land: ${e.message}`);
47264
- }
47265
- }
47266
- const repoArgs = o.repo ? ["--repo", o.repo] : [];
47267
- const startingPath = (await execFileP("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
47268
- assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr land", { force: o.force });
47269
- const landRepoForGuard = await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
47270
- const landClosingGuardInput = await withAlreadyClosedCommitTargets(
47271
- await readClosingGuardInput(number, repoArgs, landRepoForGuard, "pr land"),
47272
- async (n) => {
47273
- if (!landRepoForGuard) return void 0;
47274
- const viewed = await ghJson(["issue", "view", String(n), "--repo", landRepoForGuard, "--json", "state"]);
47275
- return typeof viewed.state === "string" ? viewed.state : void 0;
47276
- }
47277
- );
47278
- const landSquashBody = squashBodyTextForMerge(landClosingGuardInput, true);
47279
- warnSquashBodyClosingStrip("pr land", landClosingGuardInput, landSquashBody);
47280
- const landClosingGuardVerdict = evaluateClosingGuard(landClosingGuardInput, {
47281
- force: o.force,
47282
- context: "pr land",
47283
- squashBodyText: landSquashBody
47284
- });
47285
- if (landClosingGuardVerdict.blocked) {
47286
- console.error(landClosingGuardVerdict.message);
47287
- process.exitCode = 1;
47288
- return;
47289
- }
47290
- if (landClosingGuardVerdict.message) console.warn(landClosingGuardVerdict.message);
47291
- let mergeQueueEnabled = false;
47292
- const result = await runPrLand(number, { repo: o.repo, requireTrain: o.requireTrain !== false, enqueueOnly: o.enqueueOnly }, {
47293
- hasMergeQueue: async (repo, base) => mergeQueueEnabled = await hasPrMergeQueue(repo, base),
47294
- readMergeQueue: readPrMergeQueue,
47295
- findMergeGroupRun: findPrMergeGroupRun,
47296
- queueProgress: (receipt) => {
47297
- if (!o.json) printLine(prMergeQueueLine(receipt));
47298
- },
47299
- resolveRepo: async (prNumber, repoOpt) => {
47300
- const knownRepo = repoOpt ?? await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
47301
- let repoFromGh = "";
47302
- let base = "";
47303
- if (knownRepo) {
47304
- const rested = await fetchRestPrSnapshot(prNumber, knownRepo).catch(() => void 0);
47305
- if (rested) {
47306
- console.warn("pr land: PR repo/base read via REST (App-capable poll identity) instead of gh GraphQL (#6834).");
47307
- repoFromGh = knownRepo;
47308
- base = rested.baseRef;
47309
- }
47310
- }
47311
- if (!repoFromGh || !base) {
47312
- const args = repoOpt ? ["--repo", repoOpt] : repoArgs;
47313
- const readEnv = await ghReadEnv();
47314
- const viewed = (await execFileP("gh", ["pr", "view", prNumber, ...args, "--json", "headRepository,baseRefName", "--jq", '.headRepository.nameWithOwner + " " + .baseRefName'], { timeout: GC_GH_TIMEOUT_MS, ...readEnv ? { env: readEnv } : {} })).stdout.trim();
47315
- [repoFromGh, base] = viewed.split(/\s+/);
47316
- }
47317
- const repo = repoOpt ?? repoFromGh;
47318
- if (!repo) throw new Error("pr land: could not resolve PR repo");
47319
- let track;
47301
+ pr.command("land <numbers...>").description("agent merge path (#1440): train probe \u2014 checks-wait \u2014 merge --auto \u2014 poll enqueued \u2014 development PRs only. Several PR numbers land together (#8322): all are enqueued, waited on together, a queue-ejected PR is updated and re-enqueued once, a queue merge is followed by worktree/branch cleanup and board Done, and one table (or --json) reports each outcome").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--enqueue-only", "return once queue membership is confirmed; without a merge queue, land normally").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").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").option("--without-review <reason>", "compatibility option; shared merge helpers do not require a review verdict").option("--timeout <minutes>", `merge-queue wait per PR (default ${PR_LAND_QUEUE_TIMEOUT_MS / 6e4} minutes, one window per ${PR_LAND_QUEUE_BATCH_SIZE} PRs \u2014 the queue's own check timeout is 60)`).option("--preserve-worktree", "after a queue merge keep the local worktree and branch (skips the post-merge cleanup)").action(async (numbers, opts) => {
47302
+ const timeoutMinutes = opts.timeout === void 0 ? void 0 : Number(opts.timeout);
47303
+ if (timeoutMinutes !== void 0 && !(timeoutMinutes > 0)) return fail("pr land: --timeout must be a positive number of minutes");
47304
+ const queueTimeoutMs = timeoutMinutes !== void 0 ? Math.round(timeoutMinutes * 6e4) : PR_LAND_QUEUE_TIMEOUT_MS * Math.ceil(numbers.length / PR_LAND_QUEUE_BATCH_SIZE);
47305
+ let inFlight = numbers.length;
47306
+ const landOne = async (numberArg) => {
47307
+ const o = { ...opts };
47308
+ let number = numberArg;
47309
+ const refused = (error) => ({ status: "failed", repo: o.repo ?? "", pr: number, base: "", error });
47310
+ if (/^(?:[^/]+\/[^/]+)?#\d+$/.test(number.trim())) {
47320
47311
  try {
47321
- const meta = await fetchProjectBySlug(slugOf(repo), registryClientDeps(await loadConfig()));
47322
- if (meta) track = resolveReleaseTrack(meta, void 0, repo);
47323
- } catch {
47312
+ const parsed = parseIssueRef(number, o.repo);
47313
+ number = String(parsed.number);
47314
+ if (parsed.repo) o.repo = parsed.repo;
47315
+ } catch (e) {
47316
+ return refused(e.message);
47324
47317
  }
47325
- if (isPromotionBase(base, track)) {
47326
- const shape = track ? `${track} track` : "unresolved track \u2014 strict reading";
47327
- throw new Error(`pr land: base branch ${base} is a promotion target (${shape}) \u2014 promotion merges stay human-only`);
47318
+ }
47319
+ const repoArgs = o.repo ? ["--repo", o.repo] : [];
47320
+ const startingPath = (await execFileP("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
47321
+ assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr land", { force: o.force });
47322
+ const landRepoForGuard = await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
47323
+ const landClosingGuardInput = await withAlreadyClosedCommitTargets(
47324
+ await readClosingGuardInput(number, repoArgs, landRepoForGuard, "pr land"),
47325
+ async (n) => {
47326
+ if (!landRepoForGuard) return void 0;
47327
+ const viewed = await ghJson(["issue", "view", String(n), "--repo", landRepoForGuard, "--json", "state"]);
47328
+ return typeof viewed.state === "string" ? viewed.state : void 0;
47328
47329
  }
47329
- return { repo, base };
47330
- },
47331
- fetchTrainAuthority: async (repo) => fetchTrainAuthority(repo, registryClientDeps(await loadConfig())),
47332
- resolveCiPolicy: (repo) => resolveRepoMergeCiPolicy(repo, ciAuditDeps()),
47333
- waitForChecks: async (prNumber, repo) => {
47334
- const requiredContexts = await fetchRequiredCheckContexts(repo, "development").catch(() => null);
47335
- return waitForPrChecks({
47336
- resolvePolicy: () => resolveRepoMergeCiPolicy(repo, ciAuditDeps()),
47337
- // #3024: REST-only wait loop — runPrLand's resolveRepo already guarantees `repo` is set.
47338
- pollChecks: () => pollRestPrChecks(prNumber, repo, void 0, requiredContexts),
47339
- // #2970: `pr land` only ever lands PRs based on development (resolveRepo above already rejects any
47340
- // other base), so the fast-fail message's base branch is always 'development' here.
47341
- pollMergeable: () => pollRestPrMergeable(prNumber, repo),
47342
- pollRateLimit: () => waitLoopCorePool("pr land"),
47343
- // #3388: `pr land` is the batch path — the one most likely to self-DOS the shared runner and
47344
- // then read its own wall-clock kill as a broken diff.
47345
- diagnoseFailure: () => waitLoopDiagnosis("pr land", prNumber, repo),
47346
- checksUrl: `https://github.com/${repo}/pull/${prNumber}/checks`,
47347
- checksDiagnosticCommand: `gh pr checks ${prNumber} --repo ${repo}`,
47348
- // #5400: same zero-runs delivery probe as checks-wait — do not burn the land budget on silence.
47349
- pollHeadWorkflowRunCount: () => waitLoopHeadWorkflowRunCount("pr land", prNumber, repo),
47350
- baseBranch: "development",
47351
- sleep: (ms) => new Promise((resolve8) => setTimeout(resolve8, ms)),
47352
- log: (message2) => console.warn(message2),
47353
- // `pr land` inherits the same (raised) checks budget, so it needs the same liveness — otherwise the
47354
- // 30m wait is SILENT and reads exactly like the hang #2940 was filed about, only three times longer.
47355
- progress: ({ state, elapsedMs, remainingMs }) => console.warn(`pr land: waiting on checks \u2014 ${state}, ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
47356
- });
47357
- },
47358
- // #2425: re-probe used ONLY to decide whether a failed `gh pr merge --auto` is worth retrying — a
47359
- // fresh read of state/mergeable/checks, independent of the merge call's own (possibly stale) error.
47360
- // #4396: same required-status scoping as the wait loop above — a non-required red must not read
47361
- // as "not ready to retry" any more than it should have blocked the wait itself.
47362
- probeMergeReady: async (prNumber, repo) => {
47363
- const [snapshot, requiredContexts] = await Promise.all([
47364
- fetchRestPrSnapshot(prNumber, repo).catch(() => null),
47365
- // io-census-allow: a failed re-probe reads as open:false/mergeable:false below, the conservative 'not ready to retry' direction per the comment above
47366
- fetchRequiredCheckContexts(repo, "development").catch(() => null)
47367
- // io-census-allow: null is the established failed-state for this read (see pr-checks-rest.ts's partitionByRequired contract) — it makes every check required, the conservative direction
47368
- ]);
47369
- const checks = await pollRestPrChecks(prNumber, repo, void 0, requiredContexts).catch(() => "error");
47370
- return {
47371
- open: snapshot?.state === "open",
47372
- mergeable: snapshot?.mergeable === "MERGEABLE",
47373
- checksPassing: checks === "success" || checks === "no-checks-reported"
47374
- };
47375
- },
47376
- readPrState: async (prNumber, repo) => {
47377
- const args = repo ? ["--repo", repo] : [];
47378
- const readEnv = await ghReadEnv();
47379
- const result2 = await readGhPrStateWithRetry(async () => (await execFileP("gh", ["pr", "view", prNumber, ...args, "--json", "mergeStateStatus,mergeable,state"], { timeout: GC_GH_TIMEOUT_MS, ...readEnv ? { env: readEnv } : {} })).stdout);
47380
- if (!result2.ok) {
47381
- if (isGitHubRateLimitError(result2.error)) return fetchRestPrLandState(prNumber, repo);
47382
- throw new Error(`could not read PR state: ${result2.error}`);
47383
- }
47384
- return JSON.parse(result2.state);
47385
- },
47386
- mergeAuto: (prNumber, repo) => ghMergeAutoEnqueue(prNumber, repo, "--squash", {
47387
- allowedClosing: landClosingGuardInput?.closing,
47388
- bodyText: landSquashBody,
47389
- mergeQueue: mergeQueueEnabled
47390
- }),
47391
- updateBranch: (prNumber, repo) => prLandUpdateBranch(prNumber, repo, Boolean(o.repo)),
47392
- pollMerged: async (prNumber, repo, deadlineMs) => {
47393
- let lastFailure;
47394
- while (Date.now() < deadlineMs) {
47395
- const read = await pollRestPrMerged(prNumber, repo);
47396
- if (read.state === "merged") return true;
47397
- if (read.state === "failed") {
47398
- lastFailure = read.error;
47399
- console.warn(`pr land: merged-state read FAILED (${read.error}) \u2014 retrying; this is not evidence the PR is unmerged`);
47400
- } else {
47401
- lastFailure = void 0;
47330
+ );
47331
+ const landSquashBody = squashBodyTextForMerge(landClosingGuardInput, true);
47332
+ warnSquashBodyClosingStrip("pr land", landClosingGuardInput, landSquashBody);
47333
+ const landClosingGuardVerdict = evaluateClosingGuard(landClosingGuardInput, {
47334
+ force: o.force,
47335
+ context: "pr land",
47336
+ squashBodyText: landSquashBody
47337
+ });
47338
+ if (landClosingGuardVerdict.blocked) {
47339
+ console.error(landClosingGuardVerdict.message);
47340
+ return refused("refused by the closing-keyword guard (see the message above)");
47341
+ }
47342
+ if (landClosingGuardVerdict.message) console.warn(landClosingGuardVerdict.message);
47343
+ let mergeQueueEnabled = false;
47344
+ const result = await runPrLand(number, { repo: o.repo, requireTrain: o.requireTrain !== false, enqueueOnly: o.enqueueOnly, queueTimeoutMs }, {
47345
+ pollDelayMs: (attempt) => prLandPollDelayMs(attempt, inFlight),
47346
+ hasMergeQueue: async (repo, base) => mergeQueueEnabled = await hasPrMergeQueue(repo, base),
47347
+ readMergeQueue: readPrMergeQueue,
47348
+ findMergeGroupRun: findPrMergeGroupRun,
47349
+ queueProgress: (receipt) => {
47350
+ if (!o.json) printLine(prMergeQueueLine(receipt));
47351
+ },
47352
+ resolveRepo: async (prNumber, repoOpt) => {
47353
+ const knownRepo = repoOpt ?? await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
47354
+ let repoFromGh = "";
47355
+ let base = "";
47356
+ if (knownRepo) {
47357
+ const rested = await fetchRestPrSnapshot(prNumber, knownRepo).catch(() => void 0);
47358
+ if (rested) {
47359
+ console.warn("pr land: PR repo/base read via REST (App-capable poll identity) instead of gh GraphQL (#6834).");
47360
+ repoFromGh = knownRepo;
47361
+ base = rested.baseRef;
47362
+ }
47363
+ }
47364
+ if (!repoFromGh || !base) {
47365
+ const args = repoOpt ? ["--repo", repoOpt] : repoArgs;
47366
+ const readEnv = await ghReadEnv();
47367
+ const viewed = (await execFileP("gh", ["pr", "view", prNumber, ...args, "--json", "headRepository,baseRefName", "--jq", '.headRepository.nameWithOwner + " " + .baseRefName'], { timeout: GC_GH_TIMEOUT_MS, ...readEnv ? { env: readEnv } : {} })).stdout.trim();
47368
+ [repoFromGh, base] = viewed.split(/\s+/);
47402
47369
  }
47403
- await new Promise((resolve8) => setTimeout(resolve8, PR_LAND_POLL_MS));
47370
+ const repo = repoOpt ?? repoFromGh;
47371
+ if (!repo) throw new Error("pr land: could not resolve PR repo");
47372
+ let track;
47373
+ try {
47374
+ const meta = await fetchProjectBySlug(slugOf(repo), registryClientDeps(await loadConfig()));
47375
+ if (meta) track = resolveReleaseTrack(meta, void 0, repo);
47376
+ } catch {
47377
+ }
47378
+ if (isPromotionBase(base, track)) {
47379
+ const shape = track ? `${track} track` : "unresolved track \u2014 strict reading";
47380
+ throw new Error(`pr land: base branch ${base} is a promotion target (${shape}) \u2014 promotion merges stay human-only`);
47381
+ }
47382
+ return { repo, base };
47383
+ },
47384
+ fetchTrainAuthority: async (repo) => fetchTrainAuthority(repo, registryClientDeps(await loadConfig())),
47385
+ resolveCiPolicy: (repo) => resolveRepoMergeCiPolicy(repo, ciAuditDeps()),
47386
+ waitForChecks: async (prNumber, repo) => {
47387
+ const requiredContexts = await fetchRequiredCheckContexts(repo, "development").catch(() => null);
47388
+ return waitForPrChecks({
47389
+ resolvePolicy: () => resolveRepoMergeCiPolicy(repo, ciAuditDeps()),
47390
+ // #3024: REST-only wait loop — runPrLand's resolveRepo already guarantees `repo` is set.
47391
+ pollChecks: () => pollRestPrChecks(prNumber, repo, void 0, requiredContexts),
47392
+ // #2970: `pr land` only ever lands PRs based on development (resolveRepo above already rejects any
47393
+ // other base), so the fast-fail message's base branch is always 'development' here.
47394
+ pollMergeable: () => pollRestPrMergeable(prNumber, repo),
47395
+ pollRateLimit: () => waitLoopCorePool("pr land"),
47396
+ // #3388: `pr land` is the batch path — the one most likely to self-DOS the shared runner and
47397
+ // then read its own wall-clock kill as a broken diff.
47398
+ diagnoseFailure: () => waitLoopDiagnosis("pr land", prNumber, repo),
47399
+ checksUrl: `https://github.com/${repo}/pull/${prNumber}/checks`,
47400
+ checksDiagnosticCommand: `gh pr checks ${prNumber} --repo ${repo}`,
47401
+ // #5400: same zero-runs delivery probe as checks-wait — do not burn the land budget on silence.
47402
+ pollHeadWorkflowRunCount: () => waitLoopHeadWorkflowRunCount("pr land", prNumber, repo),
47403
+ baseBranch: "development",
47404
+ sleep: (ms) => new Promise((resolve8) => setTimeout(resolve8, ms)),
47405
+ log: (message2) => console.warn(message2),
47406
+ // `pr land` inherits the same (raised) checks budget, so it needs the same liveness — otherwise the
47407
+ // 30m wait is SILENT and reads exactly like the hang #2940 was filed about, only three times longer.
47408
+ progress: ({ state, elapsedMs, remainingMs }) => console.warn(`pr land: waiting on checks \u2014 ${state}, ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
47409
+ });
47410
+ },
47411
+ // #2425: re-probe used ONLY to decide whether a failed `gh pr merge --auto` is worth retrying — a
47412
+ // fresh read of state/mergeable/checks, independent of the merge call's own (possibly stale) error.
47413
+ // #4396: same required-status scoping as the wait loop above — a non-required red must not read
47414
+ // as "not ready to retry" any more than it should have blocked the wait itself.
47415
+ probeMergeReady: async (prNumber, repo) => {
47416
+ const [snapshot, requiredContexts] = await Promise.all([
47417
+ fetchRestPrSnapshot(prNumber, repo).catch(() => null),
47418
+ // io-census-allow: a failed re-probe reads as open:false/mergeable:false below, the conservative 'not ready to retry' direction per the comment above
47419
+ fetchRequiredCheckContexts(repo, "development").catch(() => null)
47420
+ // io-census-allow: null is the established failed-state for this read (see pr-checks-rest.ts's partitionByRequired contract) — it makes every check required, the conservative direction
47421
+ ]);
47422
+ const checks = await pollRestPrChecks(prNumber, repo, void 0, requiredContexts).catch(() => "error");
47423
+ return {
47424
+ open: snapshot?.state === "open",
47425
+ mergeable: snapshot?.mergeable === "MERGEABLE",
47426
+ checksPassing: checks === "success" || checks === "no-checks-reported"
47427
+ };
47428
+ },
47429
+ readPrState: async (prNumber, repo) => {
47430
+ const args = repo ? ["--repo", repo] : [];
47431
+ const readEnv = await ghReadEnv();
47432
+ const result2 = await readGhPrStateWithRetry(async () => (await execFileP("gh", ["pr", "view", prNumber, ...args, "--json", "mergeStateStatus,mergeable,state"], { timeout: GC_GH_TIMEOUT_MS, ...readEnv ? { env: readEnv } : {} })).stdout);
47433
+ if (!result2.ok) {
47434
+ if (isGitHubRateLimitError(result2.error)) return fetchRestPrLandState(prNumber, repo);
47435
+ throw new Error(`could not read PR state: ${result2.error}`);
47436
+ }
47437
+ return JSON.parse(result2.state);
47438
+ },
47439
+ mergeAuto: (prNumber, repo) => ghMergeAutoEnqueue(prNumber, repo, "--squash", {
47440
+ allowedClosing: landClosingGuardInput?.closing,
47441
+ bodyText: landSquashBody,
47442
+ mergeQueue: mergeQueueEnabled
47443
+ }),
47444
+ updateBranch: (prNumber, repo) => prLandUpdateBranch(prNumber, repo, Boolean(o.repo)),
47445
+ pollMerged: async (prNumber, repo, deadlineMs) => {
47446
+ let lastFailure;
47447
+ while (Date.now() < deadlineMs) {
47448
+ const read = await pollRestPrMerged(prNumber, repo);
47449
+ if (read.state === "merged") return true;
47450
+ if (read.state === "failed") {
47451
+ lastFailure = read.error;
47452
+ console.warn(`pr land: merged-state read FAILED (${read.error}) \u2014 retrying; this is not evidence the PR is unmerged`);
47453
+ } else {
47454
+ lastFailure = void 0;
47455
+ }
47456
+ await new Promise((resolve8) => setTimeout(resolve8, PR_LAND_POLL_MS));
47457
+ }
47458
+ if (lastFailure) {
47459
+ throw new Error(
47460
+ `pr land: could not verify whether PR #${prNumber} merged \u2014 the last merged-state read FAILED (${lastFailure}). The auto-merge enqueue stands; read the PR directly (gh pr view ${prNumber} --repo ${repo} --json merged,state) and rerun cleanup if it merged.`
47461
+ );
47462
+ }
47463
+ return false;
47404
47464
  }
47405
- if (lastFailure) {
47406
- throw new Error(
47407
- `pr land: could not verify whether PR #${prNumber} merged \u2014 the last merged-state read FAILED (${lastFailure}). The auto-merge enqueue stands; read the PR directly (gh pr view ${prNumber} --repo ${repo} --json merged,state) and rerun cleanup if it merged.`
47408
- );
47465
+ });
47466
+ if (result.status !== "failed" && result.status !== "queued") {
47467
+ const boardAdvance = await advanceClosedIssuesToDone2(number, result.repo);
47468
+ const crossRepoFilingIssue = result.repo ? await reconcileCrossRepoFilingIssue(defaultGitHubClient(), result.repo, Number(number)) : { status: "failed", error: "could not resolve the PR repo for cross-repo filing-issue reconciliation" };
47469
+ const recovery = result.repo ? buildPostMergeReconRecovery({
47470
+ repo: result.repo,
47471
+ pr: number,
47472
+ boardAdvance,
47473
+ crossRepoFilingIssue
47474
+ }) : void 0;
47475
+ let recoveryPath;
47476
+ if (recovery) {
47477
+ try {
47478
+ recoveryPath = writePostMergeReconRecovery(process.cwd(), recovery);
47479
+ } catch {
47480
+ }
47481
+ } else if (result.repo) {
47482
+ clearPostMergeReconRecovery(process.cwd(), result.repo, number);
47483
+ }
47484
+ Object.assign(result, {
47485
+ ...boardAdvance.entries.length ? { boardAdvance: boardAdvance.entries } : {},
47486
+ boardAdvanceStatus: boardAdvance.status,
47487
+ ...crossRepoFilingIssue.status !== "not-applicable" ? { crossRepoFilingIssue } : {},
47488
+ ...recovery ? { postMergeReconRecovery: recovery } : {}
47489
+ });
47490
+ for (const line of postMergeReconWarnings({
47491
+ context: "pr land",
47492
+ boardAdvance,
47493
+ crossRepoFilingIssue,
47494
+ recovery,
47495
+ recoveryPath
47496
+ })) {
47497
+ console.error(line);
47409
47498
  }
47410
- return false;
47411
47499
  }
47412
- });
47413
- if (result.status !== "failed" && result.status !== "queued") {
47414
- const boardAdvance = await advanceClosedIssuesToDone2(number, result.repo);
47415
- const crossRepoFilingIssue = result.repo ? await reconcileCrossRepoFilingIssue(defaultGitHubClient(), result.repo, Number(number)) : { status: "failed", error: "could not resolve the PR repo for cross-repo filing-issue reconciliation" };
47416
- const recovery = result.repo ? buildPostMergeReconRecovery({
47417
- repo: result.repo,
47418
- pr: number,
47419
- boardAdvance,
47420
- crossRepoFilingIssue
47421
- }) : void 0;
47422
- let recoveryPath;
47423
- if (recovery) {
47424
- try {
47425
- recoveryPath = writePostMergeReconRecovery(process.cwd(), recovery);
47426
- } catch {
47427
- }
47428
- } else if (result.repo) {
47429
- clearPostMergeReconRecovery(process.cwd(), result.repo, number);
47500
+ return result;
47501
+ };
47502
+ const cwd0 = process.cwd();
47503
+ const primaryRoot0 = (await execFileP("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.match(/^worktree (.+)$/m)?.[1];
47504
+ const cleanupMerged = async (r) => {
47505
+ if (opts.preserveWorktree) return "cleanup skipped (--preserve-worktree)";
47506
+ const ran = await execFileP(process.execPath, [process.argv[1], ...canonicalArgvFor(["pr", "merge", r.pr, "--repo", r.repo])], {
47507
+ cwd: (0, import_node_fs45.existsSync)(cwd0) ? cwd0 : primaryRoot0,
47508
+ timeout: GH_MUTATION_TIMEOUT_MS * 3
47509
+ }).then((x) => ({ stdout: x.stdout, stderr: x.stderr }), (e) => ({ stdout: e.stdout ?? "", stderr: e.stderr ?? "" }));
47510
+ const json = ran.stdout.split("\n").reverse().find((l) => l.startsWith("{"));
47511
+ if (!json) return `cleanup failed: ${ran.stderr.trim().split("\n").pop() || "no receipt"}`;
47512
+ const c = JSON.parse(json);
47513
+ const part = (x) => x ? `${x.status}${x.reason ? ` (${x.reason})` : ""}` : "n/a";
47514
+ return `cleanup ${c.mergeStatus ?? "unknown"}: worktree ${part(c.worktree)}, branch ${part(c.localBranch)}`;
47515
+ };
47516
+ const entries = await landMany(numbers, landOne, cleanupMerged, {
47517
+ onRetry: (pr2, r) => console.warn(`pr land: queue ejected PR #${pr2} (${r.error}) \u2014 landing it once more (head updated from base if behind)`),
47518
+ onDone: () => {
47519
+ inFlight = Math.max(1, inFlight - 1);
47430
47520
  }
47431
- Object.assign(result, {
47432
- ...boardAdvance.entries.length ? { boardAdvance: boardAdvance.entries } : {},
47433
- boardAdvanceStatus: boardAdvance.status,
47434
- ...crossRepoFilingIssue.status !== "not-applicable" ? { crossRepoFilingIssue } : {},
47435
- ...recovery ? { postMergeReconRecovery: recovery } : {}
47436
- });
47437
- for (const line of postMergeReconWarnings({
47438
- context: "pr land",
47439
- boardAdvance,
47440
- crossRepoFilingIssue,
47441
- recovery,
47442
- recoveryPath
47443
- })) {
47444
- console.error(line);
47521
+ });
47522
+ const credential = lastRestPollSource() === "app" ? "app_installation" : "user";
47523
+ if (numbers.length === 1) {
47524
+ const { result, cleanup } = entries[0];
47525
+ if (opts.json) printLine(JSON.stringify({ ...result, ...cleanup ? { cleanup } : {}, credential }));
47526
+ else {
47527
+ printLine(
47528
+ `pr land: ${result.status}${result.error ? ` \u2014 ${result.error}` : ""} (credential: ${credential})` + (result.branchFinality ? ` \u2014 ${result.branchFinality}` : "")
47529
+ );
47530
+ if (cleanup) printLine(`pr land: ${cleanup}`);
47445
47531
  }
47532
+ } else if (opts.json) {
47533
+ printLine(JSON.stringify({ results: entries.map((e) => ({ ...e.result, outcome: e.outcome, ...e.cleanup ? { cleanup: e.cleanup } : {} })), summary: Object.fromEntries(["merged", "ejected", "timeout", "queued", "failed"].map((k) => [k, entries.filter((e) => e.outcome === k).length])), credential }));
47534
+ } else {
47535
+ printLine(renderPrLandTable(entries));
47536
+ printLine(`pr land: ${entries.filter((e) => e.outcome === "merged").length}/${entries.length} merged (credential: ${credential})`);
47446
47537
  }
47447
- const credential = lastRestPollSource() === "app" ? "app_installation" : "user";
47448
- if (o.json) printLine(JSON.stringify({ ...result, credential }));
47449
- else printLine(
47450
- `pr land: ${result.status}${result.error ? ` \u2014 ${result.error}` : ""} (credential: ${credential})` + (result.branchFinality ? ` \u2014 ${result.branchFinality}` : "")
47451
- );
47452
- if (result.status === "failed") process.exitCode = 1;
47538
+ if (entries.some((e) => e.outcome !== "merged" && e.outcome !== "queued")) process.exitCode = 1;
47453
47539
  });
47454
- jsonParity(pr.command("merge <number>").description("merge a PR (squash by default); archives gitignored tmp/** before worktree teardown; on no-ci repos run pr ci-policy / checks-wait first (#1432, #5679)").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); from a foreign checkout the remote probe/delete address this repo and local cleanup runs only in the verified sibling checkout ../<repo>, else localBranch reports skipped-foreign-cwd and the receipt carries foreignCwd: true (#6148)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").addOption(new Option("--disable-auto", "disable a queued auto-merge without merging").conflicts(["auto", "wait", "squash", "merge", "rebase", "preserveWorktree", "gc", "squashBodyFile", "force"])).option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m) \u2014 run as a background/monitor task or under a shell timeout above that budget; a short foreground timeout (e.g. 120s) kills it after checks pass and leaves the PR open (#6027)`).option("--preserve-worktree", "after merge, keep the local PR worktree/branch for an active batch (#1888); also the merge settings-proof bypass when delete_branch_on_merge cannot be proven (#6210)").option("--gc", "acknowledge deleting unarchived gitignored tmp/** evidence newer than the branch base (#5679)").option("--squash-body-file <path>", "squash commit body (overrides GitHub COMMIT_MESSAGES); use when a pushed commit mentions close/fix/resolve + #N that must not close (#5723)").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").option("--without-review <reason>", "compatibility option; shared merge helpers do not require a review verdict")).action(async (number, o) => {
47540
+ jsonParity(pr.command("merge <number>").description("merge a PR (squash by default); archives gitignored tmp/** before worktree teardown; on no-ci repos run pr ci-policy / checks-wait first (#1432, #5679); on a merge-queue base use `pr land <n> [<n>...]` instead (#8322) \u2014 this verb is refused there except for train PRs").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); from a foreign checkout the remote probe/delete address this repo and local cleanup runs only in the verified sibling checkout ../<repo>, else localBranch reports skipped-foreign-cwd and the receipt carries foreignCwd: true (#6148)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").addOption(new Option("--disable-auto", "disable a queued auto-merge without merging").conflicts(["auto", "wait", "squash", "merge", "rebase", "preserveWorktree", "gc", "squashBodyFile", "force"])).option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m) \u2014 run as a background/monitor task or under a shell timeout above that budget; a short foreground timeout (e.g. 120s) kills it after checks pass and leaves the PR open (#6027)`).option("--preserve-worktree", "after merge, keep the local PR worktree/branch for an active batch (#1888); also the merge settings-proof bypass when delete_branch_on_merge cannot be proven (#6210)").option("--gc", "acknowledge deleting unarchived gitignored tmp/** evidence newer than the branch base (#5679)").option("--squash-body-file <path>", "squash commit body (overrides GitHub COMMIT_MESSAGES); use when a pushed commit mentions close/fix/resolve + #N that must not close (#5723)").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").option("--without-review <reason>", "compatibility option; shared merge helpers do not require a review verdict")).action(async (number, o) => {
47455
47541
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
47456
47542
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
47457
47543
  if (o.disableAuto) {
@@ -47693,7 +47779,8 @@ ${list}`);
47693
47779
  });
47694
47780
  try {
47695
47781
  try {
47696
- await mergeOnce();
47782
+ if (prMeta.state === "MERGED") remoteNotAttemptedReason = "pr-already-merged";
47783
+ else await mergeOnce();
47697
47784
  } catch (e) {
47698
47785
  if (!(e instanceof PrHeadBehindBaseError)) throw e;
47699
47786
  const localCheckedOut = !foreignCwd && await prHeadCheckedOutHere(headRef, targetRepo2, Boolean(o.repo));
@@ -55799,8 +55886,19 @@ function registerDeveloperCommands(program3) {
55799
55886
  await failGraceful(e.message);
55800
55887
  }
55801
55888
  });
55802
- docs.command("refs").description("deterministic doc reference gate: repo paths, markdown links, mmi-cli command refs, and pins across docs, org-guide, mmi-help, README and architecture must resolve, or exit 1").option("--json", "machine-readable findings list: { ok, docCount, findings[], warnings[] }").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 docs refs reads the current checkout").hideHelp()).option("--base <ref>", "require covered runbook sections to be touched against this merge base (#8126)").option("--runbooks-only", "check only operator runbooks (#8126)").option("--live", "check operator runbooks against registry tasks, vault names and HTTPS endpoints (implies --runbooks-only)").action(async (o) => {
55889
+ docs.command("refs").description("deterministic doc reference gate: repo paths, markdown links, mmi-cli command refs, and pins across docs, org-guide, mmi-help, README and architecture must resolve, or exit 1").option("--json", "machine-readable findings list: { ok, docCount, findings[], warnings[] }").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 docs refs reads the current checkout").hideHelp()).option("--base <ref>", "require covered runbook sections to be touched against this merge base (#8126)").option("--runbooks-only", "check only operator runbooks (#8126)").option("--live", "check operator runbooks against registry tasks, vault names and HTTPS endpoints (implies --runbooks-only)").option("--local", "pre-push mode (#8323): the CI runbook guard against origin/development (else origin/main) \u2014 implies --runbooks-only, offline, same verdict as the gate step").action(async (o) => {
55803
55890
  try {
55891
+ if (o.local) {
55892
+ o.runbooksOnly = true;
55893
+ if (!o.base) {
55894
+ const exists = /* @__PURE__ */ new Set();
55895
+ for (const ref of ["origin/development", "origin/main"]) {
55896
+ if (await execFileP("git", ["rev-parse", "--verify", "--quiet", ref], { timeout: GIT_TIMEOUT_MS }).then(() => true, () => false)) exists.add(ref);
55897
+ }
55898
+ o.base = localRunbookBase((ref) => exists.has(ref));
55899
+ if (!o.base) return failGraceful("docs refs --local: neither origin/development nor origin/main exists \u2014 run `git fetch origin` or pass --base <ref>");
55900
+ }
55901
+ }
55804
55902
  if (o.repo) {
55805
55903
  const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), `mmi-cli oracle docs refs${o.json ? " --json" : ""}`, "docs refs");
55806
55904
  if (!guard.ok) return failGraceful(guard.message);
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.148",
3
+ "version": "4.5.150",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/codex-plugin",
3
- "version": "4.5.148",
3
+ "version": "4.5.150",
4
4
  "description": "MMI workflow skills and org gates delivery.",
5
5
  "author": {
6
6
  "name": "MMI Future",