@mutmutco/cli 4.3.2 → 4.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.cjs +190 -47
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -5555,6 +5555,31 @@ function rewriteNegatedClosingPhrases(text) {
5555
5555
  (seg, i) => i % 2 === 1 ? seg : seg.replace(NEGATED_CLOSING_PHRASE_RE, (_m, plain, linked) => `leaves #${plain ?? linked} open`)
5556
5556
  ).join("");
5557
5557
  }
5558
+ var LINE_START_CLOSING_RE = /^\s*(?:[-*+]\s+)?(?:\*\*|__)?\s*(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:#\d+|\[#\d+\]\()/i;
5559
+ var FENCE_LINE_RE = /^\s*(?:```|~~~)/;
5560
+ function rewriteCloseKeywordProse(text) {
5561
+ if (!text.includes("#")) return { text, rewritten: [] };
5562
+ const rewritten = /* @__PURE__ */ new Set();
5563
+ let inFence = false;
5564
+ const lines = text.split("\n").map((line) => {
5565
+ if (FENCE_LINE_RE.test(line)) {
5566
+ inFence = !inFence;
5567
+ return line;
5568
+ }
5569
+ if (inFence || LINE_START_CLOSING_RE.test(line)) return line;
5570
+ CLOSING_MENTION_RE.lastIndex = 0;
5571
+ return line.split(/(`[^`\n]*`)/g).map((seg, i) => {
5572
+ if (i % 2 === 1) return seg;
5573
+ return seg.replace(CLOSING_MENTION_RE, (match, _keyword, plain, linked) => {
5574
+ const n = Number(plain ?? linked);
5575
+ if (!Number.isInteger(n) || n <= 0) return match;
5576
+ rewritten.add(n);
5577
+ return `part of #${n}`;
5578
+ });
5579
+ }).join("");
5580
+ });
5581
+ return rewritten.size ? { text: lines.join("\n"), rewritten: [...rewritten] } : { text, rewritten: [] };
5582
+ }
5558
5583
  function findClosingMentions(text) {
5559
5584
  const mentions = [];
5560
5585
  for (const match of text.matchAll(CLOSING_MENTION_RE)) {
@@ -5588,11 +5613,12 @@ var CROSS_REPO_TOKEN_RE = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g;
5588
5613
  function findCrossRepoAmbiguousClosings(text, closingNumbers, repo) {
5589
5614
  if (!closingNumbers.length) return [];
5590
5615
  const closing = new Set(closingNumbers);
5616
+ const bareClosing = new Set(findClosingMentions(text).map((m) => m.issue));
5591
5617
  const flagged = /* @__PURE__ */ new Set();
5592
5618
  const repoLower = repo.toLowerCase();
5593
5619
  for (const match of text.matchAll(CROSS_REPO_TOKEN_RE)) {
5594
5620
  const n = Number(match[2]);
5595
- if (!closing.has(n)) continue;
5621
+ if (!closing.has(n) || !bareClosing.has(n)) continue;
5596
5622
  if ((match[1] ?? "").toLowerCase() === repoLower) continue;
5597
5623
  flagged.add(n);
5598
5624
  }
@@ -11708,7 +11734,16 @@ async function gatherClaimLiveness(client, repo, number, fetchOpenPulls) {
11708
11734
  const out = { failed: [] };
11709
11735
  try {
11710
11736
  const comments = await client.restPaginate(`repos/${repo}/issues/${number}/comments`);
11711
- out.marker = latestClaimMarker(comments.map((comment) => ({ body: comment.body ?? "" })));
11737
+ const rows = comments.map((comment) => ({ body: comment.body ?? "", login: comment.user?.login ?? void 0 }));
11738
+ out.marker = latestClaimMarker(rows);
11739
+ if (out.marker) {
11740
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
11741
+ if (latestClaimMarker([rows[i]])) {
11742
+ out.markerBy = rows[i].login;
11743
+ break;
11744
+ }
11745
+ }
11746
+ }
11712
11747
  out.markerAgeMs = out.marker ? Date.now() - Date.parse(out.marker.ts) : void 0;
11713
11748
  if (out.marker) {
11714
11749
  const state = probeLocalClaimSession(out.marker);
@@ -11775,17 +11810,26 @@ function laneOwnership(marker, current) {
11775
11810
  }
11776
11811
  return "unknown";
11777
11812
  }
11778
- async function checkLaneContest(client, item, actor = describeSessionIdentity()) {
11813
+ function laneResumeEvidence(evidence) {
11814
+ if (!evidence.marker || evidence.sessionState !== "dead" || evidence.failed.length > 0) return void 0;
11815
+ if (!isHostSessionId(evidence.marker.session)) return void 0;
11816
+ const artifacts = [evidence.openPr, evidence.branch ? `live branch ${evidence.branch}` : void 0].filter(Boolean);
11817
+ return `prior claim by lane ${describeClaimMarker(evidence.marker)} (@${evidence.markerBy}, ${formatClaimAge(evidence.markerAgeMs)} old) is verifiably not running on this host \u2014 its local session transcript is gone or stale${artifacts.length ? `; resuming over ${artifacts.join("; ")}` : ""}`;
11818
+ }
11819
+ async function checkLaneContest(client, item, actor = describeSessionIdentity(), viewerLogin) {
11779
11820
  const evidence = await gatherClaimLiveness(client, item.repository, item.number, openPullsFetcher(client));
11780
11821
  const ownership = laneOwnership(evidence.marker, actor);
11822
+ const sameOwner = Boolean(viewerLogin && evidence.markerBy && evidence.markerBy.toLowerCase() === viewerLogin.toLowerCase());
11823
+ const resume = ownership !== "mine" && sameOwner ? laneResumeEvidence(evidence) : void 0;
11781
11824
  const live = ownership === "mine" ? [] : liveEvidenceLines(evidence, item.repository);
11782
11825
  const unverifiable = ownership === "mine" ? [] : evidence.failed;
11783
11826
  return {
11784
- contested: ownership !== "mine" && (live.length > 0 || unverifiable.length > 0),
11827
+ contested: ownership !== "mine" && !resume && (live.length > 0 || unverifiable.length > 0),
11785
11828
  live,
11786
11829
  unverifiable,
11787
11830
  ownership,
11788
- marker: evidence.marker
11831
+ marker: evidence.marker,
11832
+ resume
11789
11833
  };
11790
11834
  }
11791
11835
  function laneContestMessage(ref, contest, verb) {
@@ -12084,8 +12128,9 @@ async function claimOneBoardItem(ctx, selector, options) {
12084
12128
  host: ctx.session.host
12085
12129
  };
12086
12130
  let previousHolder;
12087
- const claimedReceipt = () => previousHolder ? { outcome: "took-over", holder, previousHolder } : { outcome: "claimed", holder };
12088
- const heldReceipt = () => previousHolder ? { outcome: "took-over", holder, previousHolder } : { outcome: "held", holder };
12131
+ let resumeEvidence;
12132
+ const claimedReceipt = () => previousHolder ? resumeEvidence ? { outcome: "resumed", holder, previousHolder, resumeEvidence } : { outcome: "took-over", holder, previousHolder } : { outcome: "claimed", holder };
12133
+ const heldReceipt = () => previousHolder ? resumeEvidence ? { outcome: "resumed", holder, previousHolder, resumeEvidence } : { outcome: "took-over", holder, previousHolder } : { outcome: "held", holder };
12089
12134
  if (flatItem.contentType !== "Issue") throw new Error(`${flatItem.ref} is not an issue`);
12090
12135
  const pre = evaluateClaim(flatItem, assignedLogin);
12091
12136
  if (!pre.ok) throw new Error(pre.reason);
@@ -12096,17 +12141,25 @@ async function claimOneBoardItem(ctx, selector, options) {
12096
12141
  if (!verdict.ok) throw new Error(verdict.reason);
12097
12142
  item = fresh;
12098
12143
  const refuseIfContested = async () => {
12099
- const contest = await checkLaneContest(client, item, ctx.session);
12100
- if (!contest.contested) return;
12101
- if (!options.force) throw new Error(laneContestMessage(item.ref, contest, "claim"));
12102
- previousHolder = {
12144
+ const contest = await checkLaneContest(client, item, ctx.session, report.viewer);
12145
+ resumeEvidence = void 0;
12146
+ const displaced = () => ({
12103
12147
  login: assignedLogin,
12104
12148
  ...contest.marker ? {
12105
12149
  session: contest.marker.session,
12106
12150
  surface: contest.marker.surface,
12107
12151
  host: contest.marker.host
12108
12152
  } : {}
12109
- };
12153
+ });
12154
+ if (!contest.contested) {
12155
+ if (contest.resume && contest.marker) {
12156
+ resumeEvidence = contest.resume;
12157
+ previousHolder = displaced();
12158
+ }
12159
+ return;
12160
+ }
12161
+ if (!options.force) throw new Error(laneContestMessage(item.ref, contest, "claim"));
12162
+ previousHolder = displaced();
12110
12163
  };
12111
12164
  await refuseIfContested();
12112
12165
  if (verdict.alreadyClaimed) {
@@ -12176,7 +12229,7 @@ async function claimBoardIssues(options, deps = {}) {
12176
12229
  const ref = `${selector.repo}#${selector.number}`;
12177
12230
  try {
12178
12231
  const result = await claimOneBoardItem(ctx, selector, options);
12179
- results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning, outcome: result.outcome, holder: result.holder, previousHolder: result.previousHolder, alreadyClaimed: result.alreadyClaimed, checked: result.checked };
12232
+ results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning, outcome: result.outcome, holder: result.holder, previousHolder: result.previousHolder, resumeEvidence: result.resumeEvidence, alreadyClaimed: result.alreadyClaimed, checked: result.checked };
12180
12233
  } catch (e) {
12181
12234
  results[index] = { ref, claimed: false, reason: e.message };
12182
12235
  }
@@ -15322,10 +15375,10 @@ var rollout_plan_default = {
15322
15375
  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)."
15323
15376
  },
15324
15377
  baseline: {
15325
- version: "4.3.2",
15326
- tag: "v4.3.2",
15327
- commit: "c4aa995c1dfc",
15328
- npm: "@mutmutco/cli@4.3.2"
15378
+ version: "4.3.4",
15379
+ tag: "v4.3.4",
15380
+ commit: "cd7d0dd9cc3a",
15381
+ npm: "@mutmutco/cli@4.3.4"
15329
15382
  },
15330
15383
  exitCriterion: "fleet-n-of-n",
15331
15384
  hubOnlyShortcut: "forbidden",
@@ -15342,14 +15395,14 @@ var rollout_plan_default = {
15342
15395
  repo: "mutmutco/mmi-hub",
15343
15396
  role: "canary",
15344
15397
  schedule: "train",
15345
- v3Target: "v4.3.2"
15398
+ v3Target: "v4.3.4"
15346
15399
  }
15347
15400
  ],
15348
15401
  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.",
15349
15402
  rollback: {
15350
15403
  independent: true,
15351
- mechanism: "npm dist-tag latest -> 4.3.2 and redeploy the Hub Lambda from tag v4.3.2 (c4aa995c1dfc); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15352
- v3Target: "v4.3.2 (@mutmutco/cli@4.3.2, tag commit c4aa995c1dfc \u2014 last known-good release carrying the repo-index v4-only contract)"
15404
+ mechanism: "npm dist-tag latest -> 4.3.4 and redeploy the Hub Lambda from tag v4.3.4 (cd7d0dd9cc3a); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15405
+ v3Target: "v4.3.4 (@mutmutco/cli@4.3.4, tag commit cd7d0dd9cc3a \u2014 last known-good release carrying the repo-index v4-only contract)"
15353
15406
  }
15354
15407
  },
15355
15408
  {
@@ -23765,19 +23818,23 @@ function registerBoardCommands(program3) {
23765
23818
  if (result.checked) {
23766
23819
  if (result.outcome === "held") return `Check ${ref}: held by ${holder} - claim would renew the lease (nothing written)`;
23767
23820
  if (result.outcome === "took-over") return `Check ${ref}: held by ${previousHolder} - --force claim would take it over for ${holder} (nothing written)`;
23821
+ if (result.outcome === "resumed") return `Check ${ref}: prior lane ${previousHolder} is verifiably dead - claim would resume its work for ${holder} (nothing written; ${result.resumeEvidence})`;
23768
23822
  return `Check ${ref}: free - claim would proceed for ${holder} (nothing written)`;
23769
23823
  }
23770
23824
  if (result.partial) {
23771
- return result.outcome === "took-over" ? `Partially took over ${ref} from ${previousHolder}: ${result.warning}` : `Partially claimed ${ref} for ${holder}: ${result.warning}`;
23825
+ if (result.outcome === "took-over") return `Partially took over ${ref} from ${previousHolder}: ${result.warning}`;
23826
+ if (result.outcome === "resumed") return `Partially resumed ${ref} from ${previousHolder}: ${result.warning}`;
23827
+ return `Partially claimed ${ref} for ${holder}: ${result.warning}`;
23772
23828
  }
23773
23829
  if (result.outcome === "took-over") return `Took over ${ref} from ${previousHolder} for ${holder} - In Progress`;
23830
+ if (result.outcome === "resumed") return `Resumed ${ref} from ${previousHolder} for ${holder} - In Progress (${result.resumeEvidence})`;
23774
23831
  if (result.outcome === "held") return `${ref} is held by ${holder} - In Progress`;
23775
23832
  return `Claimed ${ref} for ${holder} - In Progress`;
23776
23833
  }
23777
23834
  const board = program3.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
23778
23835
  board.command("read", { isDefault: true }).alias("list").description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--direct", "bypass the Hub snapshot and read the live board through direct GitHub GraphQL").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--bodies", "fetch body/comments for EVERY scoped row, including taken and unowned in-flight ones \u2014 for consumers that scope by Status rather than ownership (#4861); implies --bundle-details and costs one extra read per row").option("--allow-partial", "return partial board results when later page/detail reads fail").option("--out <path>", "write the output to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5802)").addHelpText("after", "\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\nNever capture the JSON with a shell redirect on Windows: 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").action((o) => runBoardRead(o));
23779
23836
  withExamples(mutating(
23780
- board.command("claim <issues...>").description("claim issues: assign them and move their Project v2 Status to In Progress \u2014 idempotent, so an item already yours and In Progress succeeds unchanged (one or more refs)").addHelpText("after", "\nevery claim stamps a lane-identity marker comment on the issue (`<!-- mmi-claim: \u2026 -->`,\nsurface/session@host) so other agents can attribute the hold (#3727). The session is the\nhost-exported id when the surface provides one, otherwise a per-process `synth-` fallback \u2014\na claim is never anonymous (#5245). `board show`, doctor and unclaim read the latest marker.\n").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--force", "take an item already claimed by another lane that shows live evidence of active work (#3727)").option("--check", "read-only: run every claim gate and report the verdict, writing nothing \u2014 exits 1 with the same refusal a real claim would raise (#4511)").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails"),
23837
+ board.command("claim <issues...>").description("claim issues: assign them and move their Project v2 Status to In Progress \u2014 idempotent, so an item already yours and In Progress succeeds unchanged (one or more refs)").addHelpText("after", "\nevery claim stamps a lane-identity marker comment on the issue (`<!-- mmi-claim: \u2026 -->`,\nsurface/session@host) so other agents can attribute the hold (#3727). The session is the\nhost-exported id when the surface provides one, otherwise a per-process `synth-` fallback \u2014\na claim is never anonymous (#5245). `board show`, doctor and unclaim read the latest marker.\n\nsame-owner resume (#6035): when the prior marker was posted by YOUR login on THIS host and its\nlocal session is verifiably dead (transcript probe), the claim proceeds as a resume without\n--force and names the evidence. A live, foreign-host, or unprobeable prior lane still refuses.\n").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--force", "take an item already claimed by another lane that shows live evidence of active work (#3727)").option("--check", "read-only: run every claim gate and report the verdict, writing nothing \u2014 exits 1 with the same refusal a real claim would raise (#4511)").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails"),
23781
23838
  (_opts, args) => ({ command: "board claim", issues: args[0] ?? [] })
23782
23839
  ).action(async (issueRefs, o) => {
23783
23840
  if (issueRefs.length === 1) {
@@ -35372,6 +35429,10 @@ function ghPrMergeLocalBranchDeleteWarning(message) {
35372
35429
  function mergeAutoRejectedPrAlreadyClean(message) {
35373
35430
  return /clean status \(enablePullRequestAutoMerge\)|is in clean status/i.test(message);
35374
35431
  }
35432
+ function mergeRejectedBaseBranchModified(message) {
35433
+ return /base branch was modified\. review and try the merge again/i.test(message);
35434
+ }
35435
+ var PR_MERGE_BASE_RACE_RETRY_DELAY_MS = 3e3;
35375
35436
  function assertRemoteBranchPreservedOnMerge(repo, deleteBranchOnMerge) {
35376
35437
  if (deleteBranchOnMerge === false) return;
35377
35438
  throw new Error(
@@ -35616,7 +35677,8 @@ async function mergeAutoEnqueueWithBody(prNumber, args, method, io, bodyFile) {
35616
35677
  return confirmEnqueueOutcome();
35617
35678
  }
35618
35679
  function cleanupGitArgs(cwd, args) {
35619
- return cwd ? ["-C", cwd, ...args] : args;
35680
+ if (!cwd || args[0] === "-C") return args;
35681
+ return ["-C", cwd, ...args];
35620
35682
  }
35621
35683
  async function remoteBranchExists2(branch, options = {}) {
35622
35684
  if (!branch) return void 0;
@@ -35651,14 +35713,16 @@ async function deleteMergedRemoteBranch(options) {
35651
35713
  if (options.existedBefore === false) {
35652
35714
  const exists2 = await options.branchExists(options.branch);
35653
35715
  if (exists2 === false) return { branch: options.branch, existedBefore: false, attempted: false, status: "already-gone" };
35654
- return {
35655
- branch: options.branch,
35656
- existedBefore: false,
35657
- attempted: false,
35658
- status: "failed",
35659
- error: exists2 ? `origin reports ${options.branch} after merge` : `could not verify absence of origin/${options.branch}`,
35660
- remediation
35661
- };
35716
+ if (exists2 !== true) {
35717
+ return {
35718
+ branch: options.branch,
35719
+ existedBefore: false,
35720
+ attempted: false,
35721
+ status: "failed",
35722
+ error: `could not verify absence of origin/${options.branch}`,
35723
+ remediation
35724
+ };
35725
+ }
35662
35726
  }
35663
35727
  try {
35664
35728
  await options.execGit(["push", "origin", "--delete", options.branch]);
@@ -36028,6 +36092,24 @@ async function checkDocsIndexAtHead(opts, deps) {
36028
36092
  };
36029
36093
  }
36030
36094
 
36095
+ // src/pr-create-claim-guard.ts
36096
+ async function prCreateClaimRefusal(body, repoOption, deps = {}) {
36097
+ const issues = [...new Set(findClosingMentions(body).map((mention) => mention.issue))];
36098
+ if (!issues.length) return void 0;
36099
+ const repo = await requireRepo(repoOption);
36100
+ const client = deps.client ?? defaultGitHubClient();
36101
+ const actor = deps.actor ?? describeSessionIdentity();
36102
+ const checkContest = deps.checkContest ?? checkLaneContest;
36103
+ for (const number of issues) {
36104
+ const contest = await checkContest(client, { repository: repo, number }, actor);
36105
+ if (!contest.contested) continue;
36106
+ const ref = `${repo}#${number}`;
36107
+ const holder = contest.marker ? `lane ${describeClaimMarker(contest.marker)}` : "another lane";
36108
+ return `pr create: REFUSED \u2014 ${ref} is held by ${holder} with live or unreadable work evidence; run \`mmi-cli oracle board claim ${ref} --force\` before creating this PR`;
36109
+ }
36110
+ return void 0;
36111
+ }
36112
+
36031
36113
  // src/worktree-merge-cleanup.ts
36032
36114
  var import_node_fs40 = require("node:fs");
36033
36115
  var import_node_path37 = require("node:path");
@@ -36313,6 +36395,22 @@ function samePath(a, b) {
36313
36395
  const nb = normPath2(b);
36314
36396
  return process.platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
36315
36397
  }
36398
+ function isPathAtOrWithin(path2, worktreePath) {
36399
+ if (!path2) return false;
36400
+ const candidate = normPath2(path2);
36401
+ const target = normPath2(worktreePath);
36402
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
36403
+ const normalizedCandidate = normalize(candidate);
36404
+ const normalizedTarget = normalize(target);
36405
+ return normalizedCandidate === normalizedTarget || normalizedCandidate.startsWith(`${normalizedTarget}/`);
36406
+ }
36407
+ function isWindowsCwdLockResidueError(error) {
36408
+ return process.platform === "win32" && /\bEPERM\b|access(?: is)? denied/i.test(error);
36409
+ }
36410
+ function deferredCwdLockRemediation(primaryRoot, wtPath) {
36411
+ const quote = (path2) => path2.replace(/'/g, "''");
36412
+ return `After this command exits: Set-Location -LiteralPath '${quote(primaryRoot)}'; Remove-Item -LiteralPath '${quote(wtPath)}' -Recurse -Force`;
36413
+ }
36316
36414
  function selectPrMergeCleanupWorktree(branch, before, after, startingPath) {
36317
36415
  if (!branch) return void 0;
36318
36416
  const current = after.find((w) => w.branch === branch)?.path;
@@ -36481,6 +36579,10 @@ async function removeWorktreeWithReconcile(wtPath, git3, listWorktrees, pathExis
36481
36579
  })
36482
36580
  };
36483
36581
  }
36582
+ function isPrMergeWorktreePartial(cleanup) {
36583
+ const worktree = cleanup?.worktree;
36584
+ return Boolean(worktree?.path && worktree.status !== "removed" && worktree.status !== "preserved" && worktree.status !== "retained-locked");
36585
+ }
36484
36586
  function prMergeLocalCleanupExitCode(cleanup) {
36485
36587
  return cleanup?.worktree?.status === "failed" || cleanup?.localBranch?.status === "failed" ? 1 : void 0;
36486
36588
  }
@@ -36651,11 +36753,17 @@ async function cleanupPrMergeLocalBranch(branch, options) {
36651
36753
  if (residue.ok) {
36652
36754
  report.worktree.residue = "swept";
36653
36755
  } else {
36654
- report.worktree.status = "failed";
36655
- report.worktree.reason = "residue-remains";
36656
36756
  report.worktree.residue = "left";
36657
36757
  report.worktree.residueError = residue.error;
36658
- report.worktree.remediation = `Remove-Item -LiteralPath '${wtPath.replace(/'/g, "''")}' -Force`;
36758
+ if (isWindowsCwdLockResidueError(residue.error) && isPathAtOrWithin(options.startingPath, wtPath)) {
36759
+ report.worktree.status = "retained-locked";
36760
+ report.worktree.reason = "parent-cwd-lock";
36761
+ report.worktree.remediation = deferredCwdLockRemediation(options.primaryRoot, wtPath);
36762
+ } else {
36763
+ report.worktree.status = "failed";
36764
+ report.worktree.reason = "residue-remains";
36765
+ report.worktree.remediation = `Remove-Item -LiteralPath '${wtPath.replace(/'/g, "''")}' -Force`;
36766
+ }
36659
36767
  }
36660
36768
  }
36661
36769
  try {
@@ -36684,8 +36792,10 @@ function renderPrMergeCleanupLines(cleanup) {
36684
36792
  lines.push(`pr merge: worktree ${wt.path} removal failed${wt.error ? ` \u2014 ${wt.error}` : ""}`);
36685
36793
  } else if (wt.status === "preserved") {
36686
36794
  lines.push(`pr merge: preserved worktree ${wt.path} (--preserve-worktree)`);
36795
+ } else if (wt.status === "retained-locked") {
36796
+ lines.push(`pr merge: worktree ${wt.path} cleanup deferred \u2014 its Windows parent shell still holds the cwd; ${wt.remediation ?? "exit the shell and remove the residue from the primary checkout"}`);
36687
36797
  }
36688
- if (wt.residue === "left") {
36798
+ if (wt.residue === "left" && wt.status !== "retained-locked") {
36689
36799
  lines.push(`pr merge: worktree ${wt.path} registration is gone but residue remains \u2014 ${wt.residueError ?? wt.path}; remediate: ${wt.remediation ?? wt.path}`);
36690
36800
  }
36691
36801
  if (wt.artifactsArchive?.status === "archived" && wt.artifactsArchive.path) {
@@ -37331,10 +37441,20 @@ ${list}`);
37331
37441
  }
37332
37442
  body = normalizeClosingDirectives(body);
37333
37443
  body = rewriteNegatedClosingPhrases(body);
37444
+ const prose = rewriteCloseKeywordProse(body);
37445
+ if (prose.rewritten.length) {
37446
+ body = prose.text;
37447
+ process.stderr.write(
37448
+ `pr create: WARNING \u2014 rewrote close-keyword prose to "part of #N" for ${prose.rewritten.map((n) => `#${n}`).join(", ")}: GitHub parses close/fix/resolve + #N even mid-sentence (#6039). Keep closing keywords on a dedicated "Closes #N" line.
37449
+ `
37450
+ );
37451
+ }
37334
37452
  const docsCheck = await checkDocsIndexAtHead({ repo: o.repo, head: o.head }, createPrCreateDocsIndexDeps());
37335
37453
  if (docsCheck && !docsCheck.ok) {
37336
37454
  return fail(`pr create: ${docsCheck.detail} \u2014 ${docsCheck.fix}`);
37337
37455
  }
37456
+ const claimRefusal = await prCreateClaimRefusal(body, o.repo);
37457
+ if (claimRefusal) return fail(claimRefusal);
37338
37458
  const created = await ghCreate(buildPrArgs({ title, body, base: o.base, head: o.head, repo: o.repo, draft: o.draft }));
37339
37459
  if (isGhCreateRateLimited(created)) {
37340
37460
  console.log(JSON.stringify(created));
@@ -37349,7 +37469,8 @@ ${list}`);
37349
37469
  ], [
37350
37470
  "--head and --base default to the current branch and the repo default; only pass them to override.",
37351
37471
  "Use --body-file for multiline PR bodies instead of shell-escaped inline markdown.",
37352
- "Write that file under .jerv/ inside the host workspace (#4405); host policy governs untracked files."
37472
+ "Write that file under .jerv/ inside the host workspace (#4405); host policy governs untracked files.",
37473
+ 'Keep closing keywords on a dedicated line ("Closes #N") \u2014 GitHub parses close/fix/resolve + #N even mid-sentence, so pr create rewrites such prose to "part of #N" (#6039).'
37353
37474
  ]);
37354
37475
  pr.command("view <ref>").description("read a PR as structured JSON (merged state, head/base, URL, merge commit) \u2014 the mmi-cli read path (#2347). --comments folds in every comment; --context also adds linkedIssues (the issues it closes/references, #2894)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json [fields...]", 'gh --json field list (overrides the default field set). Accepts commas, spaces, or repeated --json flags \u2014 in PowerShell an unquoted comma list is an array literal, so QUOTE it: --json "state,baseRefName,mergeCommit"').option("--comments", "include every comment (body + comments in one call) \u2014 read the whole PR before landing it (#2894)").option("--context", "full working context in one call: implies --comments and also adds linkedIssues (the issues the PR closes/references) (#2894)").action(async (ref, o) => {
37355
37476
  let parsed;
@@ -37738,7 +37859,7 @@ ${list}`);
37738
37859
  else printLine(`pr land: ${result.status}${result.error ? ` \u2014 ${result.error}` : ""}`);
37739
37860
  if (result.status === "failed") process.exitCode = 1;
37740
37861
  });
37741
- 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)").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)`).option("--preserve-worktree", "after merge, keep the local PR worktree/branch for an active batch (#1888)").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")).action(async (number, o) => {
37862
+ 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)").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)").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")).action(async (number, o) => {
37742
37863
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
37743
37864
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
37744
37865
  if (o.disableAuto) {
@@ -37802,6 +37923,7 @@ ${list}`);
37802
37923
  const ciHeadRef = repoForPostCleanup ? await prHeadRefForCiProbe(number, repoForPostCleanup) : void 0;
37803
37924
  const ciPolicy = await resolveMergeCiPolicyForCheckout(o.repo, ciHeadRef);
37804
37925
  if (o.wait) {
37926
+ console.warn(`pr merge: --wait can hold the shell up to the full ${PR_CHECKS_TIMEOUT_MS / 6e4}m budget plus the merge \u2014 run under a shell timeout above that budget or as a background/monitor task (#6027)`);
37805
37927
  const repo = await requireRepo(o.repo);
37806
37928
  const budgetMs = PR_CHECKS_TIMEOUT_MS;
37807
37929
  const waitStarted = Date.now();
@@ -37887,6 +38009,21 @@ ${list}`);
37887
38009
  });
37888
38010
  return;
37889
38011
  }
38012
+ if (!o.auto && mergeRejectedBaseBranchModified(message)) {
38013
+ console.warn(`pr merge: the base branch was modified by a concurrent merge \u2014 waiting ${PR_MERGE_BASE_RACE_RETRY_DELAY_MS / 1e3}s and retrying PR #${number}'s merge once (#6052).`);
38014
+ await new Promise((resolve7) => setTimeout(resolve7, PR_MERGE_BASE_RACE_RETRY_DELAY_MS));
38015
+ await execFileP("gh", buildPrMergeArgs({ number, repoArgs, method, auto: false, deleteBranch: false, bodyFile }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e2) => {
38016
+ const m2 = String(e2.message || "");
38017
+ if (/already been merged/i.test(m2)) {
38018
+ remoteNotAttemptedReason = "pr-already-merged";
38019
+ return;
38020
+ }
38021
+ const note2 = timeoutKillNote(e2, GH_MUTATION_TIMEOUT_MS);
38022
+ if (note2) throw new Error(`gh pr merge ${number}: ${note2}`);
38023
+ if (!ghPrMergeLocalBranchDeleteWarning(m2)) throw e2;
38024
+ });
38025
+ return;
38026
+ }
37890
38027
  if (o.auto && mergeAutoRejectedPrAlreadyClean(message)) {
37891
38028
  await execFileP("gh", buildPrMergeArgs({ number, repoArgs, method, auto: false, deleteBranch: false, bodyFile }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e2) => {
37892
38029
  const m2 = String(e2.message || "");
@@ -37995,12 +38132,13 @@ ${list}`);
37995
38132
  execGit: async (args) => (await execFileP("git", cleanupGitArgs(primaryRoot, args), { timeout: GIT_TIMEOUT_MS })).stdout,
37996
38133
  branchExists: (b) => remoteBranchExists2(b, { cwd: primaryRoot })
37997
38134
  });
37998
- const worktreePartial = localCleanup?.worktree?.path && localCleanup.worktree.status !== "removed" ? {
38135
+ const worktree = localCleanup?.worktree;
38136
+ const worktreePartial = isPrMergeWorktreePartial(localCleanup) && worktree?.path ? {
37999
38137
  kind: "worktree-directory",
38000
- path: localCleanup.worktree.path,
38001
- reason: localCleanup.worktree.reason ?? localCleanup.worktree.status,
38002
- error: localCleanup.worktree.error ?? localCleanup.worktree.residueError,
38003
- remediation: localCleanup.worktree.remediation ?? `Remove-Item -LiteralPath '${localCleanup.worktree.path?.replace(/'/g, "''")}' -Recurse -Force`
38138
+ path: worktree.path,
38139
+ reason: worktree.reason ?? worktree.status,
38140
+ error: worktree.error ?? worktree.residueError,
38141
+ remediation: worktree.remediation ?? `Remove-Item -LiteralPath '${worktree.path.replace(/'/g, "''")}' -Recurse -Force`
38004
38142
  } : void 0;
38005
38143
  const partialCleanup = [
38006
38144
  ...worktreePartial ? [worktreePartial] : [],
@@ -38345,11 +38483,16 @@ function registerBoxCommands(program3) {
38345
38483
  await failGraceful("runtime box get: --script requires --ssh (it writes the ssh connect recipe)");
38346
38484
  return;
38347
38485
  }
38348
- if (o.json) console.log(JSON.stringify({ box: found, incomplete }, null, 2));
38349
- else if (o.ssh && o.script) {
38350
- (0, import_node_fs42.writeFileSync)(o.script, sshRecipeScript(found), "utf8");
38351
- console.log(`wrote ${o.script} \u2014 run: bash "${o.script}"`);
38352
- } else if (o.ssh) console.log(`${formatSshRecipe(found)}
38486
+ const wroteScript = o.ssh && o.script ? o.script : null;
38487
+ if (wroteScript) (0, import_node_fs42.writeFileSync)(wroteScript, sshRecipeScript(found), "utf8");
38488
+ if (o.json) {
38489
+ console.log(JSON.stringify({
38490
+ box: found,
38491
+ incomplete,
38492
+ ...wroteScript ? { script: { path: wroteScript, run: `bash "${wroteScript}"` } } : {}
38493
+ }, null, 2));
38494
+ } else if (wroteScript) console.log(`wrote ${wroteScript} \u2014 run: bash "${wroteScript}"`);
38495
+ else if (o.ssh) console.log(`${formatSshRecipe(found)}
38353
38496
  ${SSH_RECIPE_AGENT_NOTE}`);
38354
38497
  else console.log(formatBoxTable([found]));
38355
38498
  if (!o.json) warnIncomplete2(incomplete);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.3.2",
3
+ "version": "4.3.4",
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",