@mutmutco/claude-plugin 4.6.9 → 4.6.10

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,7 +1,7 @@
1
1
  {
2
2
  "name": "mmi",
3
3
  "displayName": "MMI",
4
- "version": "4.6.9",
4
+ "version": "4.6.10",
5
5
  "mmiCompat": "4.x",
6
6
  "description": "MMI workflow skills and org gates delivery.",
7
7
  "author": {
package/cli/dist/main.cjs CHANGED
@@ -15350,6 +15350,9 @@ var APP_WRITE_COMMANDS = /* @__PURE__ */ new Map([
15350
15350
  // #9587: one named run rerun (all or failed jobs) — same actions:write profile, nothing broader.
15351
15351
  ["run rerun", { metadata: "read", actions: "write" }],
15352
15352
  ["issue comment", { metadata: "read", issues: "write", pull_requests: "write" }],
15353
+ // #9614: closing an issue is an issue-state write — it rides the org App (mmi-github-app), so the close
15354
+ // is attributed to the App instead of the personal account. Nothing broader than issues.
15355
+ ["issue close", { metadata: "read", issues: "write" }],
15353
15356
  // #8401: the detached related-issue lookup (issue list/view reads + one marker comment) — no identity contract.
15354
15357
  ["issue discover-related", { metadata: "read", issues: "write" }]
15355
15358
  ]);
@@ -15738,6 +15741,13 @@ function decidePrMergeNoCiGuard(checks, policyReason = "registry META ci:none")
15738
15741
  ].join(" ")
15739
15742
  };
15740
15743
  }
15744
+ function pendingEvidenceItems(evidence) {
15745
+ const items = evidence.checks.filter((check) => check.state === "pending").map((check) => `${check.name} (${check.status ?? "unknown"}${check.conclusion ? `/${check.conclusion}` : ""}, startedAt=${check.startedAt ?? "null"}, appId=${check.appId ?? "null"})`);
15746
+ for (const run of evidence.workflows) {
15747
+ if (run.status !== "completed") items.push(`${run.name}#${run.id} (${run.status})`);
15748
+ }
15749
+ return items;
15750
+ }
15741
15751
  async function waitForPrChecks(deps) {
15742
15752
  let { policy, reason } = await deps.resolvePolicy();
15743
15753
  const baseBranch = deps.baseBranch ?? "development";
@@ -15768,10 +15778,10 @@ async function waitForPrChecks(deps) {
15768
15778
  const timeoutMs = deps.timeoutMs ?? PR_CHECKS_TIMEOUT_MS;
15769
15779
  const started = now();
15770
15780
  const deadline = started + timeoutMs;
15771
- const report = (state) => {
15781
+ const report = (state, detail) => {
15772
15782
  try {
15773
15783
  const elapsedMs = now() - started;
15774
- deps.progress?.({ state, elapsedMs, remainingMs: Math.max(0, deadline - now()) });
15784
+ deps.progress?.({ state, elapsedMs, remainingMs: Math.max(0, deadline - now()), ...detail === void 0 ? {} : { detail } });
15775
15785
  } catch {
15776
15786
  }
15777
15787
  };
@@ -15781,6 +15791,10 @@ async function waitForPrChecks(deps) {
15781
15791
  let zeroRunsStreak = 0;
15782
15792
  let evidenceHeadSha;
15783
15793
  let lastEvidence;
15794
+ const pendingWaitDetail = (evidence) => {
15795
+ const items = pendingEvidenceItems(evidence);
15796
+ return items.length ? `pending (${items.join(", ")})` : void 0;
15797
+ };
15784
15798
  report("starting");
15785
15799
  while (now() < deadline) {
15786
15800
  if (deps.pollRateLimit) {
@@ -15845,7 +15859,7 @@ async function waitForPrChecks(deps) {
15845
15859
  await deps.sleep(waitMs);
15846
15860
  continue;
15847
15861
  }
15848
- report(state);
15862
+ report(state, state === "pending" && lastEvidence ? pendingWaitDetail(lastEvidence) : void 0);
15849
15863
  if (state !== "success") successStreak = 0;
15850
15864
  if (state !== "failure") failureStreak = 0;
15851
15865
  if (state !== "no-checks-reported") zeroRunsStreak = 0;
@@ -15921,10 +15935,18 @@ async function waitForPrChecks(deps) {
15921
15935
  await deps.sleep(pollIntervalMs(now() - started));
15922
15936
  continue;
15923
15937
  }
15924
- lastDetail = state;
15938
+ const pendingDetail = state === "pending" && lastEvidence ? pendingWaitDetail(lastEvidence) : void 0;
15939
+ lastDetail = pendingDetail ?? state;
15925
15940
  await deps.sleep(pollIntervalMs(now() - started));
15926
15941
  }
15927
- return { policy, status: "timeout", reason, detail: lastDetail, waitedMs: now() - started };
15942
+ return {
15943
+ policy,
15944
+ status: "timeout",
15945
+ reason,
15946
+ detail: lastDetail,
15947
+ waitedMs: now() - started,
15948
+ ...lastEvidence ? { headSha: lastEvidence.headSha, checks: lastEvidence.checks, workflowRuns: lastEvidence.workflows } : {}
15949
+ };
15928
15950
  }
15929
15951
 
15930
15952
  // src/release-auxiliary-workflows.ts
@@ -16394,16 +16416,23 @@ async function fetchHeadCheckRuns(headSha, repo, gh2) {
16394
16416
  const runsOut = await gh2(["--paginate", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app_id: .app.id, details_url, started_at, completed_at}"]);
16395
16417
  return dedupeLatestCheckRuns(parseNdjsonLines(runsOut));
16396
16418
  }
16397
- async function fetchHeadCheckEntries(headSha, repo, gh2) {
16419
+ async function fetchHeadCheckEntriesWithRuns(headSha, repo, gh2) {
16398
16420
  const [runs2, statusesOut] = await Promise.all([
16399
16421
  fetchHeadCheckRuns(headSha, repo, gh2),
16400
16422
  gh2(["--paginate", `repos/${repo}/commits/${headSha}/status?per_page=100`, "--jq", ".statuses[] | {context, state, target_url, description}"])
16401
16423
  ]);
16402
16424
  const statuses = parseNdjsonLines(statusesOut);
16403
- return [
16404
- ...runs2.filter((run) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(run.name ?? "")).map((run) => ({ name: run.name ?? `check-run ${run.id ?? "unknown"}`, bucket: classifyCheckRun(run) })),
16405
- ...statuses.filter((s) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(s.context ?? "")).map((s) => ({ name: s.context ?? "unknown-status", bucket: classifyCommitStatus(s.state) }))
16406
- ];
16425
+ return {
16426
+ runs: runs2,
16427
+ statuses,
16428
+ entries: [
16429
+ ...runs2.filter((run) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(run.name ?? "")).map((run) => ({ name: run.name ?? `check-run ${run.id ?? "unknown"}`, bucket: classifyCheckRun(run) })),
16430
+ ...statuses.filter((s) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(s.context ?? "")).map((s) => ({ name: s.context ?? "unknown-status", bucket: classifyCommitStatus(s.state) }))
16431
+ ]
16432
+ };
16433
+ }
16434
+ async function fetchHeadCheckEntries(headSha, repo, gh2) {
16435
+ return (await fetchHeadCheckEntriesWithRuns(headSha, repo, gh2)).entries;
16407
16436
  }
16408
16437
  async function pollRestPrChecks(prNumber, repo, gh2 = defaultGhApi, requiredContexts) {
16409
16438
  const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh2);
@@ -16411,38 +16440,68 @@ async function pollRestPrChecks(prNumber, repo, gh2 = defaultGhApi, requiredCont
16411
16440
  const { relevant } = partitionByRequired(entries, requiredContexts);
16412
16441
  return derivePollState(relevant);
16413
16442
  }
16414
- async function pollRestPrChecksWithWorkflowEvidence(prNumber, repo, gh2 = defaultGhApi) {
16415
- const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh2);
16416
- const [entries, rawRuns] = await Promise.all([
16417
- fetchHeadCheckEntries(snapshot.headSha, repo, gh2),
16418
- gh2(["--paginate", `repos/${repo}/actions/runs?head_sha=${encodeURIComponent(snapshot.headSha)}&per_page=100`, "--jq", ".workflow_runs[] | {id, name, head_sha, status, conclusion, html_url}"])
16443
+ async function pollRestHeadChecksWithWorkflowEvidence(headSha, repo, gh2) {
16444
+ const [{ runs: runs2, statuses }, rawRuns] = await Promise.all([
16445
+ fetchHeadCheckEntriesWithRuns(headSha, repo, gh2),
16446
+ gh2(["--paginate", `repos/${repo}/actions/runs?head_sha=${encodeURIComponent(headSha)}&per_page=100`, "--jq", ".workflow_runs[] | {id, name, head_sha, status, conclusion, html_url}"])
16419
16447
  ]);
16420
- const checks = entries.filter((entry) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(entry.name)).map((entry) => ({ name: entry.name, state: entry.bucket }));
16421
16448
  const workflows = parseNdjsonLines(rawRuns).flatMap((run) => {
16422
- if (run.head_sha !== snapshot.headSha || typeof run.id !== "number" || typeof run.name !== "string" || typeof run.status !== "string") return [];
16449
+ if (run.head_sha !== headSha || typeof run.id !== "number" || typeof run.name !== "string" || typeof run.status !== "string") return [];
16423
16450
  if (AUXILIARY_RELEASE_CHECK_CONTEXTS.has(run.name)) return [];
16424
16451
  return [{
16425
16452
  id: run.id,
16426
16453
  name: run.name,
16427
- headSha: snapshot.headSha,
16454
+ headSha,
16428
16455
  status: run.status,
16429
16456
  conclusion: typeof run.conclusion === "string" ? run.conclusion : null,
16430
16457
  ...typeof run.html_url === "string" ? { url: run.html_url } : {}
16431
16458
  }];
16432
16459
  });
16433
- const workflowBuckets = workflows.map((run) => {
16460
+ const workflowById = new Map(workflows.map((run) => [run.id, run]));
16461
+ const isStalePendingCheckRun = (run) => {
16462
+ if (classifyCheckRun(run) !== "pending") return false;
16463
+ const match = /\/actions\/runs\/(\d+)(?:\/|$)/.exec(run.details_url ?? "");
16464
+ const owning = match ? workflowById.get(Number(match[1])) : void 0;
16465
+ if (!owning) return false;
16466
+ if (owning.status === "completed") return true;
16467
+ return workflows.some((newer) => newer.id > owning.id && newer.name === owning.name && newer.status === "completed");
16468
+ };
16469
+ const checks = [
16470
+ ...runs2.filter((run) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(run.name ?? "")).filter((run) => !isStalePendingCheckRun(run)).map((run) => ({
16471
+ name: run.name ?? `check-run ${run.id ?? "unknown"}`,
16472
+ state: classifyCheckRun(run),
16473
+ ...run.status !== void 0 ? { status: run.status } : {},
16474
+ conclusion: run.conclusion ?? null,
16475
+ startedAt: run.started_at ?? null,
16476
+ appId: run.app_id ?? null
16477
+ })),
16478
+ ...statuses.filter((s) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(s.context ?? "")).map((s) => ({ name: s.context ?? "unknown-status", state: classifyCommitStatus(s.state) }))
16479
+ ];
16480
+ const newestRunByName = /* @__PURE__ */ new Map();
16481
+ for (const run of workflows) {
16482
+ const current = newestRunByName.get(run.name);
16483
+ if (!current || run.id > current.id) newestRunByName.set(run.name, run);
16484
+ }
16485
+ const workflowBuckets = [...newestRunByName.values()].map((run) => {
16434
16486
  if (run.status !== "completed") return "pending";
16435
16487
  return run.conclusion === "success" || run.conclusion === "neutral" || run.conclusion === "skipped" ? "pass" : "fail";
16436
16488
  });
16437
16489
  return {
16438
16490
  state: derivePollState([...checks.map((check) => check.state), ...workflowBuckets]),
16439
- headSha: snapshot.headSha,
16491
+ headSha,
16440
16492
  checks,
16441
16493
  workflows
16442
16494
  };
16443
16495
  }
16496
+ async function pollRestPrChecksWithWorkflowEvidence(prNumber, repo, gh2 = defaultGhApi) {
16497
+ const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh2);
16498
+ return pollRestHeadChecksWithWorkflowEvidence(snapshot.headSha, repo, gh2);
16499
+ }
16500
+ async function pollRestCommitChecksWithWorkflowEvidence(sha, repo, gh2 = defaultGhApi) {
16501
+ return pollRestHeadChecksWithWorkflowEvidence(sha, repo, gh2);
16502
+ }
16444
16503
  async function pollRestCommitChecks(sha, repo, gh2 = defaultGhApi) {
16445
- return derivePollState(partitionByRequired(await fetchHeadCheckEntries(sha, repo, gh2), null).relevant);
16504
+ return (await pollRestCommitChecksWithWorkflowEvidence(sha, repo, gh2)).state;
16446
16505
  }
16447
16506
  var GH_ZERO_TIME = "0001-01-01T00:00:00Z";
16448
16507
  var GH_PR_CHECKS_JSON_FIELDS = ["bucket", "completedAt", "description", "link", "name", "startedAt", "state"];
@@ -21018,8 +21077,9 @@ function resolveSpawnTarget(command, args, platform2 = process.platform) {
21018
21077
  if (platform2 !== "win32") return { cmd: command, args };
21019
21078
  for (const t of [command, ...args]) {
21020
21079
  if (/[\r\n]/.test(t)) {
21080
+ const preview = JSON.stringify(t.length > 80 ? `${t.slice(0, 77)}...` : t);
21021
21081
  throw new Error(
21022
- `cannot spawn safely on Windows: cmd.exe truncates at a newline, even quoted: ${JSON.stringify(t)}`
21082
+ `cannot spawn safely on Windows: cmd.exe truncates at a newline, even quoted: ${preview} \u2014 write the multiline script to a git-ignored file and run \`mmi-cli vault secrets use <KEY> -- node .jerv/tmp/<script>.mjs\` (#6048)`
21023
21083
  );
21024
21084
  }
21025
21085
  }
@@ -22319,8 +22379,22 @@ function renderBoardItem(item) {
22319
22379
  }
22320
22380
  return lines2.join("\n");
22321
22381
  }
22382
+ function renderCachedBoardSource(report) {
22383
+ if (typeof report.stale?.ageMs === "number") {
22384
+ const ageSeconds = Math.floor(report.stale.ageMs / 1e3);
22385
+ return `source: cache \xB7 built ${report.builtAt ? `${report.builtAt} (${ageSeconds}s old)` : `${ageSeconds}s ago`} \xB7 stale`;
22386
+ }
22387
+ if (report.builtAt) {
22388
+ const builtMs = Date.parse(report.builtAt);
22389
+ if (Number.isFinite(builtMs)) {
22390
+ return `source: cache \xB7 built ${report.builtAt} (${Math.max(0, Math.floor((Date.now() - builtMs) / 1e3))}s old) \xB7 fresh`;
22391
+ }
22392
+ return `source: cache \xB7 built ${report.builtAt} \xB7 fresh`;
22393
+ }
22394
+ return "source: cache";
22395
+ }
22322
22396
  function renderBoardReport(report) {
22323
- const lines2 = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`, report.source === "cache" ? "source: cache" : renderBoardSource()];
22397
+ const lines2 = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`, report.source === "cache" ? renderCachedBoardSource(report) : renderBoardSource()];
22324
22398
  if (report.github?.rateLimit) {
22325
22399
  const { limit, remaining, cost, resetAt } = report.github.rateLimit;
22326
22400
  lines2.push(`github: app installation \xB7 GraphQL ${remaining}/${limit} remaining \xB7 cost ${cost} \xB7 resets ${resetAt}`);
@@ -23576,7 +23650,7 @@ var contestedBoardReadCommand = (repo) => `mmi-cli oracle board read --json${rep
23576
23650
  function contestedClaimRecovery(ref) {
23577
23651
  const repo = /^([^/\s#]+\/[^/\s#]+)#\d+$/.exec(ref)?.[1];
23578
23652
  const boardRead = contestedBoardReadCommand(repo);
23579
- return `${CONTESTED_CLAIM_RECOVERY}; recovery order: refresh the board read (${boardRead}), then choose an item from its claimable list, or coordinate a handoff with the holder before using --force; list free items with: ${boardRead} and pick from its claimable list`;
23653
+ return `${CONTESTED_CLAIM_RECOVERY}; recovery order: refresh the board read (${boardRead}), then choose an item from its claimable list, or coordinate a handoff with the holder before using --force`;
23580
23654
  }
23581
23655
  function laneContestMessage(ref, contest, verb) {
23582
23656
  const { holder, why } = laneContestParts(contest);
@@ -23594,6 +23668,7 @@ function ghError(e) {
23594
23668
  init_github_client();
23595
23669
  init_github_quota();
23596
23670
  var CLAIM_CONCURRENCY = 5;
23671
+ var VERIFIED_FREE_CANDIDATE_LIMIT = 5;
23597
23672
  function withGatedWriteChainHint(message2) {
23598
23673
  return `${message2}
23599
23674
  note: run refused writes separately or join dependent commands with '&&'; a ';' chain continues after failure`;
@@ -23854,9 +23929,16 @@ async function claimOneBoardItem(ctx, selector, options) {
23854
23929
  const { holder: holder2, why } = laneContestParts(contest);
23855
23930
  throw new Error(isUnknownHold(contest) ? `${item.ref} cannot be verified as free: ${holder2} (${why}); the liveness reads failed so the claim is refused fail-closed \u2014 ${CONTESTED_CLAIM_RECOVERY}` : `${item.ref} is already claimed by ${holder2} with live evidence of active work (${why}); ${CONTESTED_CLAIM_RECOVERY}`);
23856
23931
  }
23857
- const candidateRefs = [...report.primary.claimable, ...report.secondary.claimable].filter((candidate) => candidate.contentType === "Issue" && candidate.state === "OPEN" && !candidate.claim?.currentlyClaimed && !ctx.selectedRefs.has(candidate.ref.toLowerCase())).map((candidate) => candidate.ref);
23858
- const alternatives = candidateRefs.length ? `Board snapshot candidates (not guaranteed free): ${candidateRefs.join(", ")}` : "Board snapshot candidates: none in this read";
23859
- const refusal = `${laneContestMessage(item.ref, contest, "claim")}. ${alternatives}; refresh mmi-cli oracle board read before claiming.`;
23932
+ const candidates = [...report.primary.claimable, ...report.secondary.claimable].filter((candidate) => candidate.contentType === "Issue" && candidate.state === "OPEN" && !candidate.claim?.currentlyClaimed && !ctx.selectedRefs.has(candidate.ref.toLowerCase())).slice(0, VERIFIED_FREE_CANDIDATE_LIMIT);
23933
+ const verifiedFreeRefs = [];
23934
+ for (const candidate of candidates) {
23935
+ const candidateContest = await checkLaneContest(client, candidate, ctx.session, report.viewer, ctx.appClient, ctx.landing).catch(() => void 0);
23936
+ if (candidateContest && !candidateContest.contested && candidateContest.unverifiable.length === 0) {
23937
+ verifiedFreeRefs.push(candidate.ref);
23938
+ }
23939
+ }
23940
+ const alternatives = verifiedFreeRefs.length ? `Next verified free item: \`mmi-cli oracle board claim ${verifiedFreeRefs[0]}\`${verifiedFreeRefs.length > 1 ? ` (also free: ${verifiedFreeRefs.slice(1).join(", ")})` : ""}` : `Verified free items in this read: none \u2014 refresh \`mmi-cli oracle board read --json --repo ${report.repo}\` or coordinate a handoff`;
23941
+ const refusal = `${laneContestMessage(item.ref, contest, "claim")}. ${alternatives}`;
23860
23942
  throw new Error(withGatedWriteChainHint(refusal));
23861
23943
  }
23862
23944
  previousHolder = displaced();
@@ -27513,10 +27595,10 @@ var rollout_plan_default = {
27513
27595
  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)."
27514
27596
  },
27515
27597
  baseline: {
27516
- version: "4.6.9",
27517
- tag: "v4.6.9",
27518
- commit: "7cbf84926132",
27519
- npm: "@mutmutco/cli@4.6.9"
27598
+ version: "4.6.10",
27599
+ tag: "v4.6.10",
27600
+ commit: "a4bbe43232e9",
27601
+ npm: "@mutmutco/cli@4.6.10"
27520
27602
  },
27521
27603
  exitCriterion: "fleet-n-of-n",
27522
27604
  hubOnlyShortcut: "forbidden",
@@ -27533,14 +27615,14 @@ var rollout_plan_default = {
27533
27615
  repo: "mutmutco/mmi-hub",
27534
27616
  role: "canary",
27535
27617
  schedule: "train",
27536
- v3Target: "v4.6.9"
27618
+ v3Target: "v4.6.10"
27537
27619
  }
27538
27620
  ],
27539
27621
  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.",
27540
27622
  rollback: {
27541
27623
  independent: true,
27542
- mechanism: "npm dist-tag latest -> 4.6.9 and redeploy the Hub Lambda from tag v4.6.9 (7cbf84926132); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
27543
- v3Target: "v4.6.9 (@mutmutco/cli@4.6.9, tag commit 7cbf84926132 \u2014 last known-good release carrying the repo-index v4-only contract)"
27624
+ mechanism: "npm dist-tag latest -> 4.6.10 and redeploy the Hub Lambda from tag v4.6.10 (a4bbe43232e9); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
27625
+ v3Target: "v4.6.10 (@mutmutco/cli@4.6.10, tag commit a4bbe43232e9 \u2014 last known-good release carrying the repo-index v4-only contract)"
27544
27626
  }
27545
27627
  },
27546
27628
  {
@@ -41262,7 +41344,18 @@ async function readGhPrStateWithRetry(fetchState, options) {
41262
41344
  }
41263
41345
  return { ok: false, error: lastError };
41264
41346
  }
41347
+ function isNoFailedCheckQueueEjection(run) {
41348
+ return !run || run.conclusion === null || run.conclusion === "cancelled" || run.conclusion === "neutral" || run.conclusion === "skipped";
41349
+ }
41265
41350
  async function runPrLand(prNumber, options, deps) {
41351
+ const first = await runPrLandOnce(prNumber, options, deps);
41352
+ if (options.enqueueOnly || options.expectHead || first.status !== "failed") return first;
41353
+ const queue = first.mergeQueue;
41354
+ const nothingFailed = queue?.state === "timeout" || queue?.state === "ejected" && isNoFailedCheckQueueEjection(queue.ejectedRun);
41355
+ if (!nothingFailed) return first;
41356
+ return { ...await runPrLandOnce(prNumber, options, deps), retriedAfterEjection: true };
41357
+ }
41358
+ async function runPrLandOnce(prNumber, options, deps) {
41266
41359
  const resolved = await deps.resolveRepo(prNumber, options.repo);
41267
41360
  const { repo } = resolved;
41268
41361
  const base = { status: "failed", repo, pr: prNumber, base: resolved.base };
@@ -41308,6 +41401,18 @@ async function runPrLand(prNumber, options, deps) {
41308
41401
  base.mergeStatus = "auto-merge-enqueued";
41309
41402
  return pollPrMergeQueue(prNumber, options, deps, base, queueSnapshot);
41310
41403
  }
41404
+ if (deps.preflightQueue) {
41405
+ const preflight2 = await deps.preflightQueue(prNumber, repo, pinned);
41406
+ if (preflight2 && preflight2.mergeStatus === "failed") {
41407
+ const rawError = preflight2.error ?? "queue preparation needed";
41408
+ return {
41409
+ ...base,
41410
+ mergeStatus: "failed",
41411
+ ...preflight2.preparation ? { preparation: preflight2.preparation } : {},
41412
+ error: !rawError.includes("read pr queue") ? `${rawError} \u2014 ${prQueueReadHint(prNumber, repo)}` : rawError
41413
+ };
41414
+ }
41415
+ }
41311
41416
  }
41312
41417
  const checksWaitError = (checksWait) => {
41313
41418
  if (checksWait.status === "conflicting") {
@@ -41468,7 +41573,7 @@ async function pollPrMergeQueue(pr, options, deps, base, snapshot, deadlineMs) {
41468
41573
  ...base,
41469
41574
  mergeQueue: { ...base.mergeQueue, state: "ejected", ...run ? { ejectedRun: run } : {} },
41470
41575
  ...base.pinnedHead ? { pinnedHead: { ...base.pinnedHead, refused: "ejected" } } : {},
41471
- error: `merge queue ejected PR #${pr} \u2014 ${run ? `merge_group run ${run.id} ${run.conclusion} ${run.url}` : "merge_group run not found"}; ${prQueueReadHint(pr, base.repo)}`
41576
+ error: `merge queue ejected PR #${pr} \u2014 ${run ? `merge_group run ${run.id} ${run.conclusion} ${run.url}` : "merge_group run not found"}; ${prQueueReadHint(pr, base.repo)}${isNoFailedCheckQueueEjection(run) ? `; no check failed \u2014 re-run \`mmi-cli devops pr land ${pr} --repo ${base.repo}\` once to re-enqueue, and if a land is refused again report the exact refusal verbatim (never fall back to a direct/admin merge or an \`MMI_ACTOR=personal\` landing workaround)` : ""}`
41472
41577
  };
41473
41578
  deps.queueProgress?.(result2);
41474
41579
  return result2;
@@ -41478,7 +41583,7 @@ async function pollPrMergeQueue(pr, options, deps, base, snapshot, deadlineMs) {
41478
41583
  const result = {
41479
41584
  ...base,
41480
41585
  mergeQueue: { ...base.mergeQueue, state: "timeout" },
41481
- error: `merge queue did not reach MERGED within ${Math.round(timeoutMs / 6e4)} minutes \u2014 ${prQueueReadHint(pr, base.repo)} and rerun pr land to resume polling`
41586
+ error: `merge queue did not reach MERGED within ${Math.round(timeoutMs / 6e4)} minutes \u2014 ${prQueueReadHint(pr, base.repo)} and rerun \`mmi-cli devops pr land ${pr} --repo ${base.repo}\` once to resume/re-enqueue; if it still does not land, report the exact refusal verbatim (never bypass the ruleset or fall back to a direct/admin or \`MMI_ACTOR=personal\` merge)`
41482
41587
  };
41483
41588
  deps.queueProgress?.(result);
41484
41589
  return result;
@@ -41498,7 +41603,7 @@ async function landMany(prs, landOne, cleanup, hooks) {
41498
41603
  const safe = async (pr) => landOne(pr).catch((e) => ({ status: "failed", repo: "", pr, base: "", error: e.message }));
41499
41604
  return Promise.all(prs.map(async (pr) => {
41500
41605
  let result = await safe(pr);
41501
- if (prLandOutcome(result) === "ejected" && !result.pinnedHead) {
41606
+ if (prLandOutcome(result) === "ejected" && !result.pinnedHead && !result.retriedAfterEjection) {
41502
41607
  const baseRed = await hooks?.baseRedCheck?.(pr, result).catch(() => void 0);
41503
41608
  if (baseRed) {
41504
41609
  result = { ...result, baseRed: { base: result.base, baseRunId: baseRed.baseRunId }, error: `base-red: failures already on ${result.base} (${baseRed.baseRunId})` };
@@ -41670,10 +41775,16 @@ function trailerBlockBreak(body, line) {
41670
41775
  const lines2 = String(body ?? "").split(/\r?\n/);
41671
41776
  const at = lines2.findIndex((candidate) => candidate.trim() === line);
41672
41777
  if (at === -1) return { after: "", before: "" };
41778
+ let sawBlank = false;
41673
41779
  for (let i = at + 1; i < lines2.length; i += 1) {
41674
41780
  const next = lines2[i];
41675
- if (!next.trim()) continue;
41676
- if (/^[A-Za-z][A-Za-z0-9-]*:\s/.test(next)) return { after: "", before: "" };
41781
+ if (!next.trim()) {
41782
+ sawBlank = true;
41783
+ continue;
41784
+ }
41785
+ if (/^[A-Za-z][A-Za-z0-9-]*:\s/.test(next)) {
41786
+ return sawBlank ? { after: "", before: "", splitByBlankBefore: next.trim() } : { after: "", before: "" };
41787
+ }
41677
41788
  return { after: next.trim(), before: "" };
41678
41789
  }
41679
41790
  let paraStart = at;
@@ -41693,7 +41804,10 @@ function clipParagraph(line) {
41693
41804
  }
41694
41805
  function invisibleTrailer(sha, line, body = "") {
41695
41806
  const broken = trailerBlockBreak(body, line);
41696
- const brokenDiagnostic = broken.after ? ` Broken by the paragraph after the trailer-shaped line, which ends git's final block:
41807
+ const brokenDiagnostic = broken.splitByBlankBefore ? ` Broken by a blank line before the final trailer paragraph, so git only read the last paragraph as the trailer block:
41808
+ ${clipParagraph(broken.splitByBlankBefore)}
41809
+ Keep every Key: value trailer (e.g. ${TRAILER_KEY} and Runbook-Unaffected) in one contiguous final paragraph with no blank line between them.
41810
+ ` : broken.after ? ` Broken by the paragraph after the trailer-shaped line, which ends git's final block:
41697
41811
  ${clipParagraph(broken.after)}
41698
41812
  ` : broken.before ? ` Broken by the non-trailer line before the trailer-shaped line, which prevents git from recognizing the final block:
41699
41813
  ${clipParagraph(broken.before)}
@@ -41713,9 +41827,11 @@ function invisibleTrailer(sha, line, body = "") {
41713
41827
  The nearest waiver wins, and it supersedes this one \u2014 no force-push, no re-filed branch.
41714
41828
  Diagnosis: git parsed no ${TRAILER_KEY} trailer for ${sha.slice(0, 8)} at all \u2014 read its own view first:
41715
41829
  git log -1 --format='%(trailers)' ${sha.slice(0, 8)}
41716
- ` + brokenDiagnostic + ` Forward fix, no hand-reformatting \u2014 let git build the block from one message:
41830
+ ` + brokenDiagnostic + ` Forward fix, no hand-reformatting \u2014 let git build the block from one message. Run this
41831
+ yourself, right now, in this worktree; it is not a blocker to report and it needs no human:
41717
41832
  git commit --allow-empty --trailer "${TRAILER_KEY}: <reason>"
41718
- That commit's message IS a valid final trailer block, so it is the nearest (winning) waiver.`
41833
+ That commit's message IS a valid final trailer block, so it is the nearest (winning) waiver.
41834
+ Re-run the policy receipt and the focused test command immediately after it.`
41719
41835
  };
41720
41836
  }
41721
41837
  function unknownScope(sha, unknown) {
@@ -42846,9 +42962,7 @@ ${list}
42846
42962
  Co-authored-by: GitHub <noreply@github.com>
42847
42963
  `;
42848
42964
  }
42849
- async function enqueuePrMergeQueue(pr, repo, options = {}) {
42850
- if (!/^\d+$/.test(pr)) throw new Error("merge queue enqueue requires a PR number");
42851
- pr = pr.replace(/^0+(?=\d)/, "");
42965
+ async function inspectQueuePreparation(pr, repo, options) {
42852
42966
  const read = options.read ?? readApi;
42853
42967
  const queueRead = options.read ?? actorReadApi;
42854
42968
  const write = options.write ?? writeApi;
@@ -42860,14 +42974,14 @@ async function enqueuePrMergeQueue(pr, repo, options = {}) {
42860
42974
  const pull = metadata.pullRequest;
42861
42975
  if (!pull || !pull.id || !/^[a-f0-9]{40}$/i.test(pull.headRefOid)) throw new Error(`merge queue PR #${pr} identity unreadable`);
42862
42976
  const head = options.expectedHeadOid ?? pull.headRefOid;
42863
- if (head !== pull.headRefOid) return { mergeStatus: "failed", retryable: false, error: `merge queue head changed: expected ${head}, observed ${pull.headRefOid}` };
42864
- const stamp = (result2, selected = false, intentId) => {
42977
+ if (head !== pull.headRefOid) return { kind: "settled", result: { mergeStatus: "failed", retryable: false, error: `merge queue head changed: expected ${head}, observed ${pull.headRefOid}` } };
42978
+ const stamp = (result, selected = false, intentId) => {
42865
42979
  const credential = admissionCredential(repo, pr, head, options.receiptDirectory, selected, intentId);
42866
- return credential === "unknown" ? result2 : { ...result2, credential };
42980
+ return credential === "unknown" ? result : { ...result, credential };
42867
42981
  };
42868
- if (pull.state === "MERGED") return stamp({ mergeStatus: "merged" });
42869
- if (pull.isInMergeQueue) return stamp({ mergeStatus: "auto-merge-enqueued" });
42870
- if (pull.state !== "OPEN") return { mergeStatus: "failed", error: `PR #${pr} is ${pull.state}; no queue submission` };
42982
+ if (pull.state === "MERGED") return { kind: "settled", result: stamp({ mergeStatus: "merged" }) };
42983
+ if (pull.isInMergeQueue) return { kind: "settled", result: stamp({ mergeStatus: "auto-merge-enqueued" }) };
42984
+ if (pull.state !== "OPEN") return { kind: "settled", result: { mergeStatus: "failed", error: `PR #${pr} is ${pull.state}; no queue submission` } };
42871
42985
  const closing = [...pull.closingIssuesReferences.nodes.map((node) => node.number)];
42872
42986
  let page = pull.closingIssuesReferences.pageInfo;
42873
42987
  while (page.hasNextPage) {
@@ -42900,7 +43014,7 @@ async function enqueuePrMergeQueue(pr, repo, options = {}) {
42900
43014
  status: "preparation-needed",
42901
43015
  reason,
42902
43016
  issueTargets: targets.map((number) => `${repo}#${number}`),
42903
- nextAction: reason === "queue-message" ? "PR author: if the PR is meant to close these issues, add `Closes #N` to the PR body (`mmi-cli devops pr edit <pr> --body-file <path>`) and rerun pr land without pushing a new head; if they must stay open, remove the closing phrases from the source commit or PR text using Part of #N / Refs #N, then re-run checks and pr land with the prepared head. A queue cannot accept --squash-body-file." : "PR author: prepare the source commit messages and required trailers without a body override; re-run checks and pr land. GitHub may append coauthors even to one source commit, so source consolidation alone does not prove the generated message passes test policy. The combined queue candidate must pass with the updated shared trailer reader. Keep repository settings unchanged."
43017
+ nextAction: reason === "queue-message" ? `PR author: if PR #${pr} is meant to close ${targets.length ? targets.map((number) => `#${number}`).join(", ") : "#N"}, add ${targets.length ? targets.map((number) => `\`Closes #${number}\``).join(", ") : "`Closes #N`"} to the PR body (\`mmi-cli devops pr edit ${pr} --repo ${repo} --body-file .jerv/tmp/pr-${pr}-body.md\`) and rerun \`mmi-cli devops pr land ${pr} --repo ${repo}\` without pushing a new head; if ${targets.length ? targets.map((number) => `#${number}`).join(", ") : "#N"} must stay open, remove the closing phrases from the source commit or PR text using ${targets.length ? targets.map((number) => `\`Part of #${number}\``).join(", ") : "`Part of #N`"} / ${targets.length ? targets.map((number) => `\`Refs #${number}\``).join(", ") : "`Refs #N`"}, then re-run checks and \`pr land\` with the prepared head. A queue cannot accept --squash-body-file.` : "PR author: prepare the source commit messages and required trailers without a body override; re-run checks and pr land. GitHub may append coauthors even to one source commit, so source consolidation alone does not prove the generated message passes test policy. The combined queue candidate must pass with the updated shared trailer reader. Keep repository settings unchanged."
42904
43018
  },
42905
43019
  error: `queue preparation needed: ${detail}`
42906
43020
  });
@@ -42918,9 +43032,9 @@ async function enqueuePrMergeQueue(pr, repo, options = {}) {
42918
43032
  );
42919
43033
  method = legacy.mergeQueue?.configuration?.mergeMethod;
42920
43034
  }
42921
- if (!["SQUASH", "MERGE", "REBASE"].includes(method ?? "")) return preparation("queue-message-policy", [], "the effective queue merge method is unknown; no submission occurred");
43035
+ if (!["SQUASH", "MERGE", "REBASE"].includes(method ?? "")) return { kind: "refused", result: preparation("queue-message-policy", [], "the effective queue merge method is unknown; no submission occurred") };
42922
43036
  if (method === "SQUASH" && (!["PR_TITLE", "COMMIT_OR_PR_TITLE"].includes(policy.title ?? "") || !["PR_BODY", "COMMIT_MESSAGES", "BLANK"].includes(policy.body ?? ""))) {
42923
- return preparation("queue-message-policy", [], "the repository squash-message policy is unreadable; no submission occurred");
43037
+ return { kind: "refused", result: preparation("queue-message-policy", [], "the repository squash-message policy is unreadable; no submission occurred") };
42924
43038
  }
42925
43039
  const messages = parsed.commits;
42926
43040
  const headline = policy.title === "COMMIT_OR_PR_TITLE" && messages.length === 1 ? messages[0].headline : pull.title;
@@ -42937,7 +43051,7 @@ ${body}`;
42937
43051
  ${message2.body}`).join("\n\n");
42938
43052
  if (method === "MERGE") {
42939
43053
  if (!["PR_TITLE", "MERGE_MESSAGE"].includes(repository.merge_commit_title ?? "") || !["PR_TITLE", "PR_BODY", "BLANK"].includes(repository.merge_commit_message ?? "")) {
42940
- return preparation("queue-message-policy", [], "the repository merge-message policy is unreadable; no submission occurred");
43054
+ return { kind: "refused", result: preparation("queue-message-policy", [], "the repository merge-message policy is unreadable; no submission occurred") };
42941
43055
  }
42942
43056
  if (repository.merge_commit_title === "PR_TITLE" || repository.merge_commit_message === "PR_TITLE") actualMessage += `
42943
43057
 
@@ -42959,19 +43073,22 @@ ${pull.body}`;
42959
43073
  ...findNegatedClosings(input.text, closing),
42960
43074
  ...findCrossRepoAmbiguousClosings(input.text, closing, repo)
42961
43075
  ])];
42962
- return preparation("queue-message", targets, `GitHub's unchanged ${policy.body} message is unsafe for ${targets.map((number) => `${repo}#${number}`).join(", ")}. ${verdict.message}`);
43076
+ return { kind: "refused", result: preparation("queue-message", targets, `GitHub's unchanged ${policy.body} message is unsafe for ${targets.map((number) => `${repo}#${number}`).join(", ")}. ${closingGuardDiagnostic(verdict.message)} See preparation.nextAction for the exact \`mmi-cli devops pr edit ${pr} --repo ${repo} --body-file .jerv/tmp/pr-${pr}-body.md\` remedy.`) };
42963
43077
  }
42964
43078
  if (method === "SQUASH" && policy.body === "COMMIT_MESSAGES" && messages.length > 1 && options.bodyText == null && messages.some((message2) => /^\s*Test-Policy-Override\s*:/im.test(`${message2.headline}
42965
43079
  ${message2.body}`)) && !readMessageOverride2(githubQueueSquashMessage(pull.title, pr, messages), process.cwd())) {
42966
43080
  return {
42967
- mergeStatus: "failed",
42968
- preparation: {
42969
- status: "preparation-needed",
42970
- reason: "queue-message-override",
42971
- issueTargets: [],
42972
- nextAction: "PR author: this repository squashes with COMMIT_MESSAGES, and GitHub composes a multi-commit squash as a list of every commit message followed by its own co-author block, so this Test-Policy-Override trailer would not be in the final trailer block the queue check reads. Put the change in ONE commit whose message ends with a trailer block carrying the waiver (no force-push: push a new branch whose single commit has the same tree, parented on the PR base; open it as a new PR and close this one), or make the waiver the final trailer block of the LAST commit, then rerun pr land. Keep repository settings unchanged."
42973
- },
42974
- error: "queue preparation needed: the multi-commit COMMIT_MESSAGES squash would bury the Test-Policy-Override waiver mid-message, so the queue check would fail malformed-override-trailer"
43081
+ kind: "refused",
43082
+ result: {
43083
+ mergeStatus: "failed",
43084
+ preparation: {
43085
+ status: "preparation-needed",
43086
+ reason: "queue-message-override",
43087
+ issueTargets: [],
43088
+ nextAction: "PR author: this repository squashes with COMMIT_MESSAGES, and GitHub composes a multi-commit squash as a list of every commit message followed by its own co-author block, so this Test-Policy-Override trailer would not be in the final trailer block the queue check reads. Put the change in ONE commit whose message ends with a trailer block carrying the waiver (no force-push: push a new branch whose single commit has the same tree, parented on the PR base; open it as a new PR and close this one), or make the waiver the final trailer block of the LAST commit, then rerun pr land. Keep repository settings unchanged."
43089
+ },
43090
+ error: "queue preparation needed: the multi-commit COMMIT_MESSAGES squash would bury the Test-Policy-Override waiver mid-message, so the queue check would fail malformed-override-trailer"
43091
+ }
42975
43092
  };
42976
43093
  }
42977
43094
  const requiredBody = options.bodyText ?? composedBody;
@@ -42979,18 +43096,38 @@ ${message2.body}`)) && !readMessageOverride2(githubQueueSquashMessage(pull.title
42979
43096
  if (requiredBody && !prBodyPreservesOverrides && (method !== "SQUASH" || requiredBody.trim() !== body.trim())) {
42980
43097
  if (method === "SQUASH" && policy.body === "PR_BODY" && options.bodyText == null) {
42981
43098
  return {
42982
- mergeStatus: "failed",
42983
- preparation: {
42984
- status: "preparation-needed",
42985
- reason: "queue-message-override",
42986
- issueTargets: [],
42987
- nextAction: "PR author: this repository squashes with PR_BODY, so place the required Test-Policy-Override trailer(s) in the PR body final trailer block (e.g. mmi-cli devops pr edit <n> --body-file <path>) without a body override, then rerun pr land. Keep repository settings unchanged."
42988
- },
42989
- error: "queue preparation needed: repository squash policy is PR_BODY, and the PR body does not carry the required Test-Policy-Override trailer(s) in its final Git-recognized trailer block"
43099
+ kind: "refused",
43100
+ result: {
43101
+ mergeStatus: "failed",
43102
+ preparation: {
43103
+ status: "preparation-needed",
43104
+ reason: "queue-message-override",
43105
+ issueTargets: [],
43106
+ nextAction: `PR author: this repository squashes with PR_BODY, so place the required Test-Policy-Override trailer(s) in the PR body final trailer block (e.g. \`mmi-cli devops pr edit ${pr} --repo ${repo} --body-file .jerv/tmp/pr-${pr}-body.md\`) without a body override, then rerun \`pr land\`. Keep repository settings unchanged.`
43107
+ },
43108
+ error: "queue preparation needed: repository squash policy is PR_BODY, and the PR body does not carry the required Test-Policy-Override trailer(s) in its final Git-recognized trailer block"
43109
+ }
42990
43110
  };
42991
43111
  }
42992
- return preparation("queue-message-override", [], "the requested sanitized body or trailer arrangement cannot be sent to queue admission");
43112
+ return { kind: "refused", result: preparation("queue-message-override", [], "the requested sanitized body or trailer arrangement cannot be sent to queue admission") };
42993
43113
  }
43114
+ return { kind: "ready", head, pull, read, queueRead, write, ...verdict.message ? { warning: verdict.message } : {} };
43115
+ }
43116
+ async function preflightPrMergeQueue(prNumber, repo, options = {}) {
43117
+ if (!/^\d+$/.test(prNumber)) return void 0;
43118
+ const inspected = await inspectQueuePreparation(prNumber.replace(/^0+(?=\d)/, ""), repo, options);
43119
+ return inspected.kind === "refused" ? inspected.result : void 0;
43120
+ }
43121
+ async function enqueuePrMergeQueue(pr, repo, options = {}) {
43122
+ if (!/^\d+$/.test(pr)) throw new Error("merge queue enqueue requires a PR number");
43123
+ pr = pr.replace(/^0+(?=\d)/, "");
43124
+ const inspected = await inspectQueuePreparation(pr, repo, options);
43125
+ if (inspected.kind !== "ready") return inspected.result;
43126
+ const { head, pull, read, queueRead, write } = inspected;
43127
+ const stamp = (result2, selected = false, intentId) => {
43128
+ const credential = admissionCredential(repo, pr, head, options.receiptDirectory, selected, intentId);
43129
+ return credential === "unknown" ? result2 : { ...result2, credential };
43130
+ };
42994
43131
  const current = await queueGraphql(
42995
43132
  repo,
42996
43133
  `pullRequest(number:${pr}) { headRefOid title body }`,
@@ -43001,7 +43138,12 @@ ${message2.body}`)) && !readMessageOverride2(githubQueueSquashMessage(pull.title
43001
43138
  }
43002
43139
  const selection = { selected: false };
43003
43140
  const result = await submitQueueAdmission(pr, repo, head, queueRead, write, { ...options, timelineRead: read, selection });
43004
- return { ...stamp(result, selection.selected, selection.intentId), ...verdict.message ? { warning: verdict.message } : {} };
43141
+ return { ...stamp(result, selection.selected, selection.intentId), ...inspected.warning ? { warning: inspected.warning } : {} };
43142
+ }
43143
+ function closingGuardDiagnostic(message2) {
43144
+ if (!message2) return "";
43145
+ const remedy = message2.search(/GitHub's merge queue composes|If this PR is meant to close/);
43146
+ return (remedy === -1 ? message2 : message2.slice(0, remedy)).trim();
43005
43147
  }
43006
43148
  function decideQueueMergeRoute(hasQueue, method, headRef) {
43007
43149
  if (!hasQueue) return "normal";
@@ -43178,8 +43320,10 @@ function prMergeQueueLine(result) {
43178
43320
  return `pr land: PR #${result.pr} queued on ${queue.base}${queue.position === void 0 ? "" : ` (position ${queue.position})`}`;
43179
43321
  case "merged":
43180
43322
  return `pr land: PR #${result.pr} merged via merge queue`;
43181
- case "ejected":
43182
- return `pr land: PR #${result.pr} ejected from merge queue \u2014 ${queue.ejectedRun ? `merge_group run ${queue.ejectedRun.id} ${queue.ejectedRun.conclusion} ${queue.ejectedRun.url}` : "merge_group run not found"}`;
43323
+ case "ejected": {
43324
+ const line = `pr land: PR #${result.pr} ejected from merge queue \u2014 ${queue.ejectedRun ? `merge_group run ${queue.ejectedRun.id} ${queue.ejectedRun.conclusion} ${queue.ejectedRun.url}` : "merge_group run not found"}`;
43325
+ return isNoFailedCheckQueueEjection(queue.ejectedRun) ? `${line} \u2014 no check failed; re-run \`mmi-cli devops pr land ${result.pr}${result.repo ? ` --repo ${result.repo}` : ""}\` once to re-enqueue, and report any further refusal verbatim (never fall back to a direct/admin merge or \`MMI_ACTOR=personal\` workaround)` : line;
43326
+ }
43183
43327
  case "timeout":
43184
43328
  return `pr land: PR #${result.pr} merge queue timeout on ${queue.base}`;
43185
43329
  }
@@ -43697,9 +43841,27 @@ function refuseNotClaimable(e, json) {
43697
43841
  process.exitCode = 1;
43698
43842
  return true;
43699
43843
  }
43700
- var BOARD_READ_HELP = "\nread is always the authoritative live GitHub Project v2 board (#4926).\n--direct skips the Hub snapshot and uses the existing direct GitHub GraphQL read immediately.\n--allow-partial applies to the paginated path and detail reads.\n\nWhen using Windows PowerShell 5.1, avoid shell redirects: PowerShell 5.1's `> file.json` is Out-File,\nwhich writes UTF-16LE with a BOM, and Node reading it as 'utf8' then fails JSON.parse at position 1\n(#5802). Use --out instead \u2014 the CLI writes the file itself as UTF-8:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n\nThe --json report is an OBJECT \u2014 never an array, and there is no top-level `items` list:\n {project, viewer, repo, primary, secondary, warnings, partial[, source, builtAt, eventCursor, ...]}\n`primary` is this repo and `secondary` the other repos in scope; each is a bucket object\n {userOwned, claimable, taken, unownedInFlight}\nand each bucket is an array of items. Filter the BUCKET, not the report:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n node -e \"const b=require('./.jerv/tmp/board.json');console.log(b.primary.claimable.length)\"\n\nAdd --flat with --json for one array across both scopes: {ref,title,status,assignees,labels,bucket}.\n\nUnder --json, parse stdout ONLY and check the exit code first. A failure still prints a\nhuman-readable reason on stderr, so never pipe 2>&1 into a JSON parser (#9151) \u2014 the merged\ntext is not JSON and hides the real board failure.\n";
43844
+ var BOARD_READ_HELP = "\nread is always the authoritative live GitHub Project v2 board (#4926).\n--direct skips the Hub snapshot and uses the existing direct GitHub GraphQL read immediately.\n--allow-partial applies to the paginated path and detail reads.\n\nWhen using Windows PowerShell 5.1, avoid shell redirects: PowerShell 5.1's `> file.json` is Out-File,\nwhich writes UTF-16LE with a BOM, and Node reading it as 'utf8' then fails JSON.parse at position 1\n(#5802). Use --out instead \u2014 the CLI writes the file itself as UTF-8:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n\nThe --json report is an OBJECT \u2014 never an array, and there is no top-level `items` list:\n {project, viewer, repo, primary, secondary, warnings, partial[, source, builtAt, eventCursor, ...]}\n`primary` is this repo and `secondary` the other repos in scope; each is a bucket object\n {userOwned, claimable, taken, unownedInFlight}\nand each bucket is an array of items. Filter the BUCKET, not the report:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n node -e \"const b=require('./.jerv/tmp/board.json');console.log(b.primary.claimable.length)\"\n\nAdd --flat with --json for one array across both scopes: {ref,number,title,status,assignees,labels,bucket,claim,openPullRequests}\n(#9595: claim is the parsed mmi-claim evidence object, null when unclaimed; openPullRequests lists the OPEN\n PRs whose closing-issue body links name the item. On the FULL JSON object that list lives at\n `report.pullRequests` \u2014 there is no top-level `openPullRequests`, and #9596 hand-written projections\n that assume one crash on `.filter`.)\n\nA cached snapshot read renders `source: cache \xB7 built <timestamp> \xB7 fresh|stale` (#9584) and carries\n`builtAt` under --json, so a same-turn re-read of the same builtAt buys nothing new.\n\nUnder --json, parse stdout ONLY and check the exit code first. A failure still prints a\nhuman-readable reason on stderr, so never pipe 2>&1 into a JSON parser (#9151) \u2014 the merged\ntext is not JSON and hides the real board failure.\n";
43701
43845
  function flattenBoardReport(report) {
43702
- return [report.primary, report.secondary].flatMap((scope) => ["userOwned", "claimable", "taken", "unownedInFlight"].flatMap((bucket) => scope[bucket].map(({ ref, title, status, assignees, labels }) => ({ ref, title, status, assignees, labels, bucket }))));
43846
+ const closingByRef = /* @__PURE__ */ new Map();
43847
+ for (const pull of report.pullRequests ?? []) {
43848
+ for (const closes of pull.closesIssues) {
43849
+ const rows = closingByRef.get(closes) ?? [];
43850
+ rows.push({ number: pull.number, url: pull.url });
43851
+ closingByRef.set(closes, rows);
43852
+ }
43853
+ }
43854
+ return [report.primary, report.secondary].flatMap((scope) => ["userOwned", "claimable", "taken", "unownedInFlight"].flatMap((bucket) => scope[bucket].map(({ ref, number, title, status, assignees, labels, claim }) => ({
43855
+ ref,
43856
+ number,
43857
+ title,
43858
+ status,
43859
+ assignees,
43860
+ labels,
43861
+ bucket,
43862
+ claim: claim ?? null,
43863
+ openPullRequests: closingByRef.get(ref) ?? []
43864
+ }))));
43703
43865
  }
43704
43866
  function registerBoardCommands(program3) {
43705
43867
  function withDiscoverMissDetail(message2) {
@@ -54954,23 +55116,24 @@ async function closeIssue(client, options, deps = {}) {
54954
55116
  if (options.evidence !== void 0) {
54955
55117
  await verifyCloseEvidence(client, options.evidence, repo, parsed.number, reason === "duplicate-of" ? options.duplicateOf : void 0);
54956
55118
  }
55119
+ const appWriter = await selectClaimWriteClient(client, deps.appClient);
54957
55120
  if (reason === "completed" && options.evidence !== void 0) {
54958
55121
  const ensureLabels = deps.ensureLabels ?? ensureLabelsExist;
54959
55122
  await ensureLabels([CLOSED_MERGED_LOOP_STATE_LABEL], repo);
54960
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/labels`, {
55123
+ await appWriter.rest("POST", `repos/${repo}/issues/${parsed.number}/labels`, {
54961
55124
  body: { labels: [CLOSED_MERGED_LOOP_STATE_LABEL] }
54962
55125
  });
54963
55126
  }
54964
- await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, {
55127
+ await appWriter.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, {
54965
55128
  body: { state: "closed", state_reason: stateReason }
54966
55129
  });
54967
55130
  if (reason === "duplicate-of" && options.duplicateOf) {
54968
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
55131
+ await appWriter.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
54969
55132
  body: { body: `Duplicate of #${options.duplicateOf}` }
54970
55133
  });
54971
55134
  }
54972
55135
  if (options.comment) {
54973
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
55136
+ await appWriter.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
54974
55137
  body: { body: options.comment }
54975
55138
  });
54976
55139
  }
@@ -59609,7 +59772,7 @@ ${preparation.findings.map((finding) => `[${finding.check}/${finding.state}] ${f
59609
59772
  timeoutMs: Math.max(1, budgetMs - (Date.now() - waitStarted)),
59610
59773
  // Liveness on stderr, one line per poll. A silent bounded wait is indistinguishable from a hang, and
59611
59774
  // an agent harness kills it on its own (shorter) deadline before the verdict ever prints (#2940).
59612
- progress: ({ state, elapsedMs, remainingMs }) => console.warn(`pr checks-wait: ${state} \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
59775
+ progress: ({ state, elapsedMs, remainingMs, detail }) => console.warn(`pr checks-wait: ${detail && detail.startsWith("pending (") ? detail : state} \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
59613
59776
  });
59614
59777
  printChecksWaitResult("pr checks-wait", result, o.json);
59615
59778
  });
@@ -59716,6 +59879,8 @@ ${preparation.findings.map((finding) => `[${finding.check}/${finding.state}] ${f
59716
59879
  readHeadSha: (prNumber, repo) => readPrLandPinnedHeadSha(prNumber, repo),
59717
59880
  pollDelayMs: (attempt) => prLandPollDelayMs(attempt, inFlight),
59718
59881
  hasMergeQueue: hasPrMergeQueue,
59882
+ // #9611: read-only preparation verdict BEFORE the check wait; the enqueue then re-reads and submits.
59883
+ preflightQueue: (prNumber, repo, expectedHeadOid) => preflightPrMergeQueue(prNumber, repo, { expectedHeadOid, force: o.force, enqueueOnly: o.enqueueOnly }),
59719
59884
  enqueueQueue: async (prNumber, repo, expectedHeadOid) => {
59720
59885
  const receipt = await enqueuePrMergeQueue(prNumber, repo, { expectedHeadOid, force: o.force, enqueueOnly: o.enqueueOnly });
59721
59886
  if (receipt.warning) console.warn(receipt.warning);
@@ -60381,6 +60546,9 @@ ${preparation.findings.map((finding) => `[${finding.check}/${finding.state}] ${f
60381
60546
  const result = await waitForPrChecks({
60382
60547
  resolvePolicy: () => resolveMergeCiPolicyForCheckout(o.repo),
60383
60548
  pollChecks: () => pollRestCommitChecks(sha, repo),
60549
+ // #9589: the evidence-bearing read lets the wait name the rows holding it open and carry the
60550
+ // full check/workflow evidence on the timeout receipt.
60551
+ pollChecksWithEvidence: () => pollRestCommitChecksWithWorkflowEvidence(sha, repo),
60384
60552
  pollRateLimit: () => waitLoopCorePool("ci checks-wait"),
60385
60553
  checksUrl: `https://github.com/${repo}/commit/${sha}/checks`,
60386
60554
  checksDiagnosticCommand: `gh api repos/${repo}/commits/${sha}/check-runs`,
@@ -60393,7 +60561,7 @@ ${preparation.findings.map((finding) => `[${finding.check}/${finding.state}] ${f
60393
60561
  sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms)),
60394
60562
  log: (message2) => console.warn(message2),
60395
60563
  timeoutMs: timeoutMs ?? PR_CHECKS_TIMEOUT_MS,
60396
- progress: ({ state, elapsedMs, remainingMs }) => console.warn(`ci checks-wait: ${state} \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
60564
+ progress: ({ state, elapsedMs, remainingMs, detail }) => console.warn(`ci checks-wait: ${detail && detail.startsWith("pending (") ? detail : state} \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
60397
60565
  });
60398
60566
  printChecksWaitResult("ci checks-wait", result, o.json);
60399
60567
  });
@@ -66245,7 +66413,7 @@ function typedReadHint(path2) {
66245
66413
  if (!match) return "";
66246
66414
  const [, owner, repo, kind, item, sub] = match;
66247
66415
  const slug = `${owner}/${repo}`;
66248
- const say = (command, note = "") => ` Typed read for this route: \`${command}\`${note}.`;
66416
+ const say = (command, note = "") => ` Typed read for this route: \`MMI_ACTOR=app ${command}\`${note}.`;
66249
66417
  if (kind === "issues") {
66250
66418
  if (!item) return say(`mmi-cli devops read issue list --repo ${slug} --json number,title,state,labels`);
66251
66419
  if (item.startsWith("comments/")) {
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.6.9",
3
+ "version": "4.6.10",
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/claude-plugin",
3
- "version": "4.6.9",
3
+ "version": "4.6.10",
4
4
  "description": "MMI workflow skills and org gates delivery.",
5
5
  "author": {
6
6
  "name": "MMI Future",
@@ -20,7 +20,10 @@ One call; the JSON carries `viewer`, `repo` and the project title. Use `primary`
20
20
  `secondary` for other repos on the Project. Without `--flat`, `board read --json` returns an object
21
21
  `{project, viewer, repo, primary, secondary, warnings, partial}` whose `primary` and `secondary` are bucket
22
22
  objects `{userOwned, claimable, taken, unownedInFlight}` of item arrays (no top-level `items` array), so filter
23
- a bucket (e.g. `report.primary.claimable`) or pass `--json --flat`, never `(j.items||j).filter`. Partial reads
23
+ a bucket (e.g. `report.primary.claimable`) or pass `--json --flat`, never `(j.items||j).filter`. `--flat` rows
24
+ are `{ref,number,title,status,assignees,labels,bucket,claim,openPullRequests}` (`claim` is the parsed mmi-claim
25
+ evidence object, null when unclaimed; `openPullRequests` lists the OPEN PRs whose closing-issue body links name
26
+ the item — on the full JSON object that list lives at `report.pullRequests`, never `openPullRequests`). Partial reads
24
27
  exit nonzero; `--allow-partial` only when the dev accepts an incomplete board. The CLI partitions rows into
25
28
  **Yours**, **Free to claim** and **Taken**; stale stamps (4 hours without work) hold nothing. Partition rules,
26
29
  stamp details and `--out` rationale: [reference.md](reference.md).
@@ -62,7 +65,7 @@ Present the plan — batch count, issue count, one line per batch naming its sha
62
65
  mmi-cli oracle board claim <ref> <ref> <ref> --json
63
66
  ```
64
67
 
65
- Announce issue/PR counts; refresh `mmi-cli oracle board read` immediately before each claim (a list read earlier in the session may already be stale) and select ordinary claims from **Free to claim**. Read every result: failures exit nonzero. On conflict, do not blindly retry: stop that batch, never retry the same ref, refresh and reselect from the fresh **Free to claim** list, or coordinate a handoff; `--force` only for an owner-authorized intentional takeover. Run writes separately, never `;`; dependent follow-ups use `&&`.
68
+ Announce issue/PR counts; refresh `mmi-cli oracle board read` immediately before each claim (a list read earlier in the session may already be stale) and select ordinary claims from **Free to claim**. A cached read states its provenance — the render prints `source: cache · built <timestamp> · fresh|stale` and `--json` carries `builtAt` — so if a read in the same turn/window already returned a fresh snapshot and no board write has intervened since, re-reading the same `builtAt` snapshot buys no new information: reuse it instead of re-reading. Read every result: failures exit nonzero. On conflict, do not blindly retry: stop that batch, never retry the same ref, refresh and reselect from the fresh **Free to claim** list, or coordinate a handoff; `--force` only for an owner-authorized intentional takeover. Run writes separately, never `;`; dependent follow-ups use `&&`.
66
69
 
67
70
  Run each `mmi-cli oracle issue create` as its own simple command. Any command whose text must carry quote
68
71
  characters — an issue body, an inline `node -e` / `bash -c` one-liner — is written to a file, never spliced: a
@@ -12,7 +12,7 @@ Every refusal and every doctor blocker names its `code` and `anchor`: open the f
12
12
 
13
13
  ## Merge floor
14
14
 
15
- - **Ordinary development PRs land with `mmi-cli devops pr land <number>`.** The rules below are for the train's own merges.
15
+ - **Ordinary development PRs land with `mmi-cli devops pr land <number>`.** If a merge-queue group leaves the queue with no failed check, `pr land` re-queues once (or rerun `mmi-cli devops pr land <number>` once); a refused land is a stop to report verbatim — never fall back to a hand/admin merge, actor override (`MMI_ACTOR=personal`), or interim workaround. The rules below are for the train's own merges.
16
16
  - **Never squash-merge a tagged commit (#3167).** A squash re-mints the SHA and orphans the tag, so tag-anchored checks mis-read. Any merge that carries an already-tagged commit — the `main → development` roll-forward, an alignment PR, a hotfix fold PR — lands as a true merge: `mmi-cli devops pr merge <number> --auto --merge` (or `--wait --merge`); `--wait` alone still squashes. The hotfix main-base PR itself may squash: its tag lands after, on the merged `main` HEAD, and the `-x` trailer must survive the squash message.
17
17
  - **A refused train is a stop, never a license to finish by hand.** Every fail-closed halt — doctor blocker, authority probe, coverage guard, untolerated conflict, required checks, stray tag, ledger — ends the run. Fix the cause on `development` through a normal PR and rerun the train; never hand-resolve on `main`, hand-cherry-pick, bare-push a train branch, ack to save time, or bypass a check.
18
18
  - **Probe the latest Release tag, not branches or stale local tags (#2734).** Queue: `mmi-cli devops read pr queue <number> --repo <owner/repo> --json`; gh rejects `mergeQueueEntry`.