@mutmutco/cli 3.58.0 → 3.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.cjs +1996 -1725
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -3411,7 +3411,7 @@ var program = new Command();
3411
3411
 
3412
3412
  // src/index.ts
3413
3413
  var import_promises8 = require("node:fs/promises");
3414
- var import_node_fs32 = require("node:fs");
3414
+ var import_node_fs33 = require("node:fs");
3415
3415
  var import_node_child_process13 = require("node:child_process");
3416
3416
 
3417
3417
  // src/cli-shared.ts
@@ -9679,7 +9679,7 @@ function commandLadderHint() {
9679
9679
  }
9680
9680
 
9681
9681
  // src/index.ts
9682
- var import_node_path30 = require("node:path");
9682
+ var import_node_path31 = require("node:path");
9683
9683
 
9684
9684
  // src/merge-ci-policy.ts
9685
9685
  function resolveMergeCiPolicy(input) {
@@ -12163,7 +12163,7 @@ async function executeWaveLand(plan, deps, opts = { preserveWorktree: true }) {
12163
12163
  }
12164
12164
 
12165
12165
  // src/index.ts
12166
- var import_node_os9 = require("node:os");
12166
+ var import_node_os10 = require("node:os");
12167
12167
 
12168
12168
  // src/attach-to-project.ts
12169
12169
  async function resolveAutoAddBoardAttach(client, cfg, selector, priority, warn = (m) => process.stderr.write(m), retry = {}) {
@@ -19221,13 +19221,13 @@ function registerSchedulesLiftCommand(program3, deps = {}) {
19221
19221
  // src/edge-tunnel.ts
19222
19222
  var HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
19223
19223
  var UPSTREAM_RE = /^https?:\/\/[^/\s]+(?::\d+)?(?:\/.*)?$/;
19224
- function tunnelNameFromHostname(hostname) {
19225
- return hostname.replace(/\./g, "-").slice(0, 63);
19224
+ function tunnelNameFromHostname(hostname2) {
19225
+ return hostname2.replace(/\./g, "-").slice(0, 63);
19226
19226
  }
19227
- function planInfraTunnel(hostname, upstream) {
19228
- const host = hostname.trim().toLowerCase();
19227
+ function planInfraTunnel(hostname2, upstream) {
19228
+ const host = hostname2.trim().toLowerCase();
19229
19229
  const origin = upstream.trim();
19230
- if (!HOSTNAME_RE.test(host)) throw new Error(`invalid hostname ${JSON.stringify(hostname)}`);
19230
+ if (!HOSTNAME_RE.test(host)) throw new Error(`invalid hostname ${JSON.stringify(hostname2)}`);
19231
19231
  if (!UPSTREAM_RE.test(origin)) throw new Error(`invalid upstream ${JSON.stringify(upstream)} \u2014 expected http(s)://host:port`);
19232
19232
  const tunnelName = tunnelNameFromHostname(host);
19233
19233
  const configYaml = [
@@ -21201,9 +21201,9 @@ function registerBoardCommands(program3) {
21201
21201
  }
21202
21202
 
21203
21203
  // src/merge-cleanup.ts
21204
- var import_node_fs25 = require("node:fs");
21204
+ var import_node_fs27 = require("node:fs");
21205
21205
  var import_promises6 = require("node:fs/promises");
21206
- var import_node_path24 = require("node:path");
21206
+ var import_node_path26 = require("node:path");
21207
21207
  var import_node_child_process11 = require("node:child_process");
21208
21208
 
21209
21209
  // src/board-advance.ts
@@ -21369,1870 +21369,2097 @@ function makeDeferredWorktreeStore(registryPath, lockOpts = {}) {
21369
21369
  };
21370
21370
  }
21371
21371
 
21372
- // src/merge-cleanup.ts
21373
- var GC_GH_TIMEOUT_MS2 = 2e4;
21374
- async function advanceClosedIssuesToDone2(prNumber, repoOption) {
21375
- try {
21376
- return await resolveBoardAdvanceForPr(prNumber, repoOption);
21377
- } catch (e) {
21378
- return { status: "fetch-failed", entries: [], error: e.message };
21372
+ // src/plugin-guard-io.ts
21373
+ var import_node_fs25 = require("node:fs");
21374
+ var import_node_path24 = require("node:path");
21375
+ var import_node_os6 = require("node:os");
21376
+
21377
+ // src/plugin-guard.ts
21378
+ function buildPluginGuardDecision(i) {
21379
+ if (!i.isOrgRepo) return { state: "not-org" };
21380
+ if (!i.installRecordPresent) return { state: "no-install" };
21381
+ if (!i.marketplaceClonePresent || !i.pluginCachePresent) return { state: "unresolved" };
21382
+ return { state: "healthy" };
21383
+ }
21384
+ function buildPluginGuardLine(state, opts = {}) {
21385
+ if (state === "healthy" || state === "not-org") return { exitCode: 0 };
21386
+ const recovery = opts.recovery ?? "mmi-cli plugin heal";
21387
+ const restartHint = opts.restartHint ?? "restart your agent host / reload plugins";
21388
+ const reason = state === "no-install" ? "MMI plugin is not installed for this user/session" : "MMI plugin is installed but its marketplace/cache is unresolved";
21389
+ return {
21390
+ line: `[mmi-guard] ${reason}; run ${recovery} and ${restartHint}.`,
21391
+ exitCode: 1
21392
+ };
21393
+ }
21394
+
21395
+ // src/plugin-guard-io.ts
21396
+ var isWin = process.platform === "win32";
21397
+ var MMI_PLUGIN_ID = "mmi@mutmutco";
21398
+ var LEGACY_MMI_MARKETPLACE = "mmi";
21399
+ function detectSurface(env) {
21400
+ const has = (k) => Boolean(env[k]?.trim());
21401
+ if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
21402
+ return "codex";
21403
+ }
21404
+ if (env.MMI_AGENT_SURFACE === "cursor" || has("CURSOR_TRACE_ID") || has("CURSOR_USER") || has("CURSOR_SESSION_ID") || env.CURSOR_AGENT === "1" || has("CURSOR_EXTENSION_HOST_ROLE")) {
21405
+ return "cursor";
21379
21406
  }
21407
+ if (env.MMI_AGENT_SURFACE === "opencode" || has("OPENCODE_CLIENT") || has("OPENCODE_SERVER_USERNAME")) return "opencode";
21408
+ const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
21409
+ const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
21410
+ if (isClaude && isVscode) return "claude-vscode";
21411
+ if (isClaude) return "claude-cli";
21412
+ return "shell";
21380
21413
  }
21381
- async function resolveBoardAdvanceForPr(prNumber, repoOption) {
21382
- const repoArgs = repoOption ? ["--repo", repoOption] : [];
21383
- const repo = await resolveRepo(repoOption);
21384
- if (!repo) {
21385
- return {
21386
- status: "fetch-failed",
21387
- entries: [],
21388
- error: "could not resolve the PR's repo, so a closing reference cannot be proven to belong to this board"
21389
- };
21414
+ function surfaceToken(surface) {
21415
+ switch (surface) {
21416
+ case "claude-cli":
21417
+ case "claude-vscode":
21418
+ return "claude";
21419
+ case "codex":
21420
+ return "codex";
21421
+ case "cursor":
21422
+ return "cursor";
21423
+ case "opencode":
21424
+ return "opencode";
21425
+ case "shell":
21426
+ default:
21427
+ return null;
21390
21428
  }
21391
- return advanceClosedIssuesToDone({
21392
- repo,
21393
- fetchClosingIssues: async () => {
21394
- const { stdout } = await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "closingIssuesReferences"], { timeout: GC_GH_TIMEOUT_MS2 });
21395
- const parsed = JSON.parse(stdout || "null");
21396
- if (!parsed || typeof parsed !== "object" || !("closingIssuesReferences" in parsed)) {
21397
- throw new Error("gh returned no closingIssuesReferences field for this PR");
21398
- }
21399
- return parsed.closingIssuesReferences;
21400
- },
21401
- moveIssueToDone: async (ref) => {
21402
- const moved = await moveBoardItem({ config: await loadConfigForBoardSelector2(ref, repoOption), selector: ref, status: "Done", repo: repoOption, allowPartial: true });
21403
- return moved.partial ? { moved: false, error: moved.warning } : { moved: true };
21404
- }
21429
+ }
21430
+ function reloadAction(surface) {
21431
+ switch (surface) {
21432
+ case "claude-vscode":
21433
+ return "restart VS Code";
21434
+ case "codex":
21435
+ return "restart Codex";
21436
+ case "opencode":
21437
+ return "restart OpenCode";
21438
+ case "cursor":
21439
+ return "restart Cursor (or refresh the Team Marketplace from Dashboard \u2192 Settings \u2192 Plugins)";
21440
+ case "claude-cli":
21441
+ case "shell":
21442
+ default:
21443
+ return "restart Claude Code (or run /reload-plugins)";
21444
+ }
21445
+ }
21446
+ var CLAUDE_RECOVERY = `claude plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && claude plugin marketplace remove mutmutco && claude plugin marketplace add mutmutco/MMI-Hub --ref main && claude plugin install mmi@mutmutco`;
21447
+ var PLUGIN_SURFACE_HEAL = {
21448
+ claude: {
21449
+ delivery: "plugin-cli",
21450
+ recovery: CLAUDE_RECOVERY,
21451
+ healSteps: [
21452
+ { args: ["plugin", "marketplace", "remove", LEGACY_MMI_MARKETPLACE], gated: false },
21453
+ { args: ["plugin", "marketplace", "remove", "mutmutco"], gated: false },
21454
+ { args: ["plugin", "marketplace", "add", "mutmutco/MMI-Hub", "--ref", "main"], gated: true },
21455
+ { args: ["plugin", "install", "mmi@mutmutco"], gated: true },
21456
+ { args: ["plugin", "enable", "mmi@mutmutco"], gated: false }
21457
+ ],
21458
+ fix: (surface) => `${CLAUDE_RECOVERY} # then ${reloadAction(surface)} to reload MMI commands`,
21459
+ updateRecipe: [CLAUDE_RECOVERY]
21460
+ }
21461
+ };
21462
+ function nonClaudeSurfaceHealMessage(surface) {
21463
+ if (surface === "codex") {
21464
+ return "Codex ships an MMI plugin (#3563). Install or repair it with:\n codex plugin marketplace add mutmutco/MMI-Hub && codex plugin add mmi@mutmutco\n Then run /hooks and TRUST the MMI hooks \u2014 bundled hooks are skipped until reviewed.\n Update the CLI too: npm i -g @mutmutco/cli";
21465
+ }
21466
+ return "No Hub-shipped MMI plugin on this host \u2014 the Hub ships plugins for Claude and Codex only.\n Update the CLI instead: npm i -g @mutmutco/cli (org rules ride an AGENTS.md authored outside the Hub)";
21467
+ }
21468
+ function healStepAborts(step, ok) {
21469
+ return !ok && step.gated;
21470
+ }
21471
+ function marketplaceAddSupportsRef(helpText) {
21472
+ if (!helpText) return false;
21473
+ return /(^|\s)--ref(\b|=)/.test(helpText);
21474
+ }
21475
+ function adaptHealStepsForRefSupport(steps, refSupported) {
21476
+ if (refSupported) return { steps: [...steps], strippedRef: false };
21477
+ let strippedRef = false;
21478
+ const adapted = steps.map((step) => {
21479
+ const refIdx = step.args.indexOf("--ref");
21480
+ const isAdd = step.args.includes("marketplace") && step.args.includes("add");
21481
+ if (!isAdd || refIdx === -1) return step;
21482
+ strippedRef = true;
21483
+ return { ...step, args: [...step.args.slice(0, refIdx), ...step.args.slice(refIdx + 2)] };
21405
21484
  });
21485
+ return { steps: adapted, strippedRef };
21406
21486
  }
21407
- function renderGcApplyResult(result) {
21408
- const lines = ["worktree gc apply result:"];
21409
- lines.push(` branches removed: ${result.removedBranches.length ? result.removedBranches.join(", ") : "none"}`);
21410
- lines.push(` remote branches removed: ${result.removedRemoteBranches.length ? result.removedRemoteBranches.join(", ") : "none"}`);
21411
- lines.push(` tracking refs removed: ${result.removedTrackingRefs.length ? result.removedTrackingRefs.join(", ") : "none"}`);
21412
- lines.push(` dead sibling dirs removed: ${result.removedWorktreeDirs.length ? result.removedWorktreeDirs.join(", ") : "none"}`);
21413
- lines.push(` stale registrations pruned: ${result.pruned ? "yes" : "no"} (clears registrations only \u2014 never deletes a directory)`);
21414
- if (result.failed.length) {
21415
- lines.push(` failed (${result.failed.length}):`);
21416
- for (const f of result.failed) lines.push(` - ${f}`);
21487
+ function recoveryWithoutRef(recovery) {
21488
+ return recovery.replace(/ --ref \S+/g, "");
21489
+ }
21490
+ function hasProjectInstallRecord(file, pluginId, projectPath) {
21491
+ const records = file?.plugins?.[pluginId];
21492
+ if (!Array.isArray(records)) return false;
21493
+ return records.some((r) => r.scope === "project" && r.projectPath === projectPath);
21494
+ }
21495
+ function hasUserInstallRecord(file, pluginId) {
21496
+ const records = file?.plugins?.[pluginId];
21497
+ if (!Array.isArray(records)) return false;
21498
+ return records.some((r) => r.scope === "user");
21499
+ }
21500
+ var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
21501
+ var NPM_VIEW_TIMEOUT_MS = 15e3;
21502
+ function runHostBin(bin, args, opts) {
21503
+ return isWin ? execFileP2("cmd.exe", ["/c", bin, ...args], opts) : execFileP2(bin, args, opts);
21504
+ }
21505
+ var installedPluginsPath2 = (surface = detectSurface(process.env)) => {
21506
+ const homeDir = surface === "codex" ? ".codex" : ".claude";
21507
+ return (0, import_node_path24.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "installed_plugins.json");
21508
+ };
21509
+ function readInstalledPlugins(surface = detectSurface(process.env)) {
21510
+ try {
21511
+ return JSON.parse((0, import_node_fs25.readFileSync)(installedPluginsPath2(surface), "utf8"));
21512
+ } catch {
21513
+ return null;
21417
21514
  }
21418
- const removedNothing = result.removedBranches.length === 0 && result.removedRemoteBranches.length === 0 && result.removedTrackingRefs.length === 0 && result.removedWorktreeDirs.length === 0;
21419
- if (removedNothing) {
21420
- lines.push(" NOTHING WAS REMOVED. A stale worktree whose directory still exists is not reachable by");
21421
- lines.push(" this verb \u2014 remove it from inside it with `mmi-cli worktree land --apply`, or delete the");
21422
- lines.push(" directory and re-run to clear its registration.");
21515
+ }
21516
+ function marketplaceCloneCandidates(surface, home) {
21517
+ if (surface === "codex") {
21518
+ return [
21519
+ (0, import_node_path24.join)(home, ".codex", ".tmp", "marketplaces", "mutmutco"),
21520
+ (0, import_node_path24.join)(home, ".codex", "plugins", "marketplaces", "mutmutco")
21521
+ ];
21423
21522
  }
21424
- return lines.join("\n");
21523
+ return [(0, import_node_path24.join)(home, ".claude", "plugins", "marketplaces", "mutmutco")];
21425
21524
  }
21426
- async function deleteReviewedRemoteBranch(remote, branch, expectedHeadOid) {
21427
- if (!isSafeGitRemoteName(remote)) throw new Error(`unsafe remote name ${remote}`);
21428
- const { stdout } = await execFileP2("git", ["ls-remote", "--heads", remote, branch], { timeout: GIT_TIMEOUT_MS });
21429
- const line = stdout.trim().split(/\r?\n/).find(Boolean);
21430
- if (!line) return "already-gone";
21431
- const remoteHead = line.split(/\s+/)[0]?.trim();
21432
- if (!remoteHead || remoteHead !== expectedHeadOid) {
21433
- throw new Error(`${remoteHead || "unknown"} != ${expectedHeadOid}`);
21525
+ function marketplaceClonePresent(surface, home, exists = import_node_fs25.existsSync) {
21526
+ return marketplaceCloneCandidates(surface, home).some(exists);
21527
+ }
21528
+ async function fetchNpmReleasedVersion() {
21529
+ try {
21530
+ const { stdout } = await runHostBin("npm", npmReleasedVersionArgs(), { timeout: NPM_VIEW_TIMEOUT_MS });
21531
+ return parseNpmVersion(stdout);
21532
+ } catch {
21533
+ return void 0;
21434
21534
  }
21535
+ }
21536
+ var NPM_INSTALL_TIMEOUT_MS = 12e4;
21537
+ async function npmSelfUpdateCli(target) {
21538
+ const command = cliUpdateCommand(target);
21435
21539
  try {
21436
- await execFileP2("git", [
21437
- "push",
21438
- remote,
21439
- `--force-with-lease=refs/heads/${branch}:${expectedHeadOid}`,
21440
- `:refs/heads/${branch}`
21441
- ], { timeout: GH_MUTATION_TIMEOUT_MS });
21540
+ await runHostBin("npm", ["install", "-g", `@mutmutco/cli@${target ?? "latest"}`], { timeout: NPM_INSTALL_TIMEOUT_MS });
21541
+ return { ok: true, detail: `${command} exited 0` };
21442
21542
  } catch (e) {
21443
- const after = await execFileP2("git", ["ls-remote", "--heads", remote, branch], { timeout: GIT_TIMEOUT_MS }).catch(() => void 0);
21444
- if (after && !after.stdout.trim().split(/\r?\n/).find(Boolean)) return "already-gone";
21445
- throw e;
21543
+ return { ok: false, detail: e.message.trim().slice(0, 200).replace(/\s+/g, " ") };
21446
21544
  }
21447
- return "deleted";
21448
21545
  }
21449
- function evaluatePrMergeHousekeeping(report, context, force = false) {
21450
- const scratch = report.scratch;
21451
- const failed = scratch?.status === "failed";
21452
- const prunable = scratch?.safeAuto ?? 0;
21453
- const unprunable = scratch?.skipped ?? 0;
21454
- if (!(failed || prunable > 0 || unprunable > 0)) return { blocked: false };
21455
- if (force) return { blocked: false };
21456
- const detail = failed ? `scratch housekeeping failed${scratch?.error ? `: ${scratch.error}` : ""}` : `${prunable} prunable + ${unprunable} un-prunable scratch item(s) remain after cleanup`;
21546
+ function snapshotPluginGuardInput(surface = detectSurface(process.env), isOrgRepo = false) {
21547
+ const homeDir = surface === "codex" ? ".codex" : ".claude";
21548
+ const installed = readInstalledPlugins(surface);
21457
21549
  return {
21458
- blocked: true,
21459
- reason: `${context} housekeeping blocked merge: ${detail}. Run \`mmi-cli worktree gc --scratch --apply\` to clear it, then retry \u2014 or re-run \`mmi-cli ${context} --force\` to acknowledge and land anyway. (Kept advisory plans/ scratch never blocks: it is gitignored and can never reach origin.)`
21550
+ isOrgRepo,
21551
+ installRecordPresent: hasUserInstallRecord(installed, MMI_PLUGIN_ID) || hasProjectInstallRecord(installed, MMI_PLUGIN_ID, process.cwd()),
21552
+ marketplaceClonePresent: marketplaceClonePresent(surface, (0, import_node_os6.homedir)()),
21553
+ pluginCachePresent: (0, import_node_fs25.existsSync)((0, import_node_path24.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "cache", "mutmutco", "mmi"))
21460
21554
  };
21461
21555
  }
21462
- function assertPrMergeHousekeepingClean(startingPath, context, options = {}) {
21463
- const build = options.buildHousekeeping ?? ((repoRoot2) => buildPrMergeScratchHousekeeping(repoRoot2));
21464
- const housekeeping = build(startingPath || process.cwd());
21465
- const verdict = evaluatePrMergeHousekeeping(housekeeping, context, options.force);
21466
- if (verdict.blocked) throw new Error(verdict.reason);
21467
- return housekeeping;
21556
+ function claudePluginGuardState(isOrgRepo) {
21557
+ return buildPluginGuardDecision(snapshotPluginGuardInput(detectSurface(process.env), isOrgRepo)).state;
21468
21558
  }
21469
- async function applyGcPlan(plan, remote, opts = {}) {
21470
- const result = { removedBranches: [], removedRemoteBranches: [], removedTrackingRefs: [], removedWorktreeDirs: [], failed: [], pruned: false };
21471
- const beforeWorktrees = parseWorktreePorcelain(
21472
- (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
21473
- );
21474
- const deferredStore = await createDeferredWorktreeStore();
21475
- const branchTracking = await applyBranchAndTrackingCleanup(plan, {
21476
- localBranchHeads,
21477
- cleanupBranch: (branch, expectedHeadOid) => {
21478
- const wtDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
21479
- return cleanupPrMergeLocalBranch(branch.branch, {
21480
- beforeWorktrees,
21481
- startingPath: branch.worktreePath,
21482
- pathExists: (p) => (0, import_node_fs25.existsSync)(p),
21483
- execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
21484
- teardownWorktreeStage,
21485
- deferredStore,
21486
- expectedHeadOid,
21487
- detachReparsePoints: wtDeps.detachReparsePoints,
21488
- // #3064: junction-safe teardown
21489
- removeWorktreeDir: wtDeps.removeWorktreeDir
21490
- });
21491
- },
21492
- cleanupRemoteBranch: (branch, expectedHeadOid) => deleteReviewedRemoteBranch(remote, branch.branch, expectedHeadOid),
21493
- cleanupTrackingRefs: (trackingRefs, unsafeBranches) => applyTrackingRefCleanup(trackingRefs, remote, unsafeBranches, {
21494
- execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
21495
- })
21496
- });
21497
- result.removedBranches.push(...branchTracking.removedBranches);
21498
- result.removedRemoteBranches.push(...branchTracking.removedRemoteBranches);
21499
- result.removedTrackingRefs.push(...branchTracking.removedTrackingRefs);
21500
- result.failed.push(...branchTracking.failed);
21501
- const removeDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
21502
- const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
21503
- const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
21504
- const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path24.dirname)((0, import_node_path24.dirname)(worktreeGitRoot)) : repoRoot2;
21505
- const siblingRoot = opts.root ? resolveExplicitScanRoot(opts.root, primaryRepoRoot) : siblingMmiWorktreesRoot(primaryRepoRoot);
21506
- for (const wt of plan.worktreeDirs) {
21507
- try {
21508
- const cleanupTarget = resolveSafeSiblingWorktreeCleanupTarget(wt.path, siblingRoot, {
21509
- realpath: (path2) => (0, import_node_fs25.realpathSync)(path2)
21510
- });
21511
- if (!cleanupTarget.ok) {
21512
- result.failed.push(`${wt.path}: ${cleanupTarget.reason}`);
21513
- continue;
21514
- }
21515
- const stillValid = validateDeadWorktreeDirCleanup(wt, inspectSiblingWorktreeDir(wt.path, worktreeGitRoot));
21516
- if (!stillValid.ok) {
21517
- result.failed.push(`${wt.path}: skipped dead-dir removal (${stillValid.reason})`);
21518
- continue;
21519
- }
21520
- const contentSafe = validateDeadWorktreeDirContent(wt, inspectDeadWorktreeDirContent(wt.path));
21521
- if (!contentSafe.ok) {
21522
- result.failed.push(`${wt.path}: skipped dead-dir removal (${contentSafe.reason})`);
21523
- continue;
21524
- }
21525
- await removeDeps.removeWorktreeDir(cleanupTarget.path);
21526
- result.removedWorktreeDirs.push(wt.path);
21527
- } catch (e) {
21528
- result.failed.push(`${wt.path}: ${e.message.split("\n")[0]}`);
21529
- }
21530
- }
21559
+ async function runClaudePlugin(args) {
21531
21560
  try {
21532
- await execFileP2("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
21533
- result.pruned = true;
21534
- } catch (e) {
21535
- result.failed.push(`worktree prune: ${e.message.split("\n")[0]}`);
21561
+ await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
21562
+ return true;
21563
+ } catch {
21564
+ return false;
21536
21565
  }
21537
- return result;
21538
21566
  }
21539
- async function pollGhPrChecks(prNumber, repoArgs) {
21540
- let stdout = "";
21541
- let stderr = "";
21567
+ async function marketplaceAddRefSupported(bin) {
21542
21568
  try {
21543
- ({ stdout, stderr } = await execFileP2("gh", ["pr", "checks", prNumber, ...repoArgs], { timeout: GC_GH_TIMEOUT_MS2 }));
21544
- } catch (e) {
21545
- const err = e;
21546
- if (err.killed || err.stdout == null && err.stderr == null) throw e;
21547
- stdout = err.stdout ?? "";
21548
- stderr = err.stderr ?? "";
21549
- }
21550
- const state = parseGhPrChecksResult(stdout, stderr);
21551
- if (state === "error") {
21552
- throw new Error(`gh pr checks ${prNumber}: ${(stderr || stdout).trim() || "unknown error"}`);
21569
+ const { stdout, stderr } = await runHostBin(bin, ["plugin", "marketplace", "add", "--help"], {
21570
+ timeout: CLAUDE_PLUGIN_TIMEOUT_MS
21571
+ });
21572
+ return marketplaceAddSupportsRef(`${stdout}
21573
+ ${stderr}`);
21574
+ } catch {
21575
+ return false;
21553
21576
  }
21554
- return state;
21555
21577
  }
21556
- function ghFailureReason(e) {
21557
- const err = e;
21558
- const head = String(err?.message ?? "").split("\n")[0]?.trim() ?? "";
21559
- const detail = `${err?.stderr ?? ""}
21560
- ${err?.stdout ?? ""}`.split("\n").map((line) => line.trim()).find((line) => line.length > 0);
21561
- if (detail && head) return `${head} \u2014 ${detail}`;
21562
- return detail || head || "unknown error";
21578
+ function healBannerLine(bin, token, refSupported) {
21579
+ const installVerb = token === "codex" ? "add" : "install";
21580
+ return refSupported ? ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add --ref main \u2192 ${installVerb})\u2026` : ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add \u2192 ${installVerb}; release ref pinned by the marketplace manifest)\u2026`;
21563
21581
  }
21564
- var defaultGhMergeAutoIo = {
21565
- gh: async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout
21566
- };
21567
- async function ghMergeAutoEnqueue(prNumber, repo, method, io = defaultGhMergeAutoIo) {
21568
- const args = repo ? ["--repo", repo] : [];
21569
- const headRef = (await io.gh(["pr", "view", prNumber, ...args, "--json", "headRefName", "--jq", ".headRefName"], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
21570
- const deleteBranch = !isProtectedBranch(headRef);
21571
- const readMergeState = () => readGhPrStateWithRetry(
21572
- () => io.gh(["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], GC_GH_TIMEOUT_MS2)
21573
- );
21574
- const confirmEnqueueOutcome = async () => {
21575
- const stateRead = await readMergeState();
21576
- if (!stateRead.ok) {
21577
- return { mergeStatus: "failed", error: `could not read PR state after merge: ${stateRead.error}` };
21578
- }
21579
- if (stateRead.state === "MERGED") return { mergeStatus: "merged" };
21580
- const confirmation = await confirmAutoMergeEnqueued({
21581
- readAutoMergeRequest: async () => io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2),
21582
- readMerged: async () => {
21583
- const s = await readMergeState();
21584
- return s.ok && s.state === "MERGED";
21585
- },
21586
- reEnqueue: async () => {
21587
- await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
21588
- }
21589
- });
21590
- if (confirmation === "merged") return { mergeStatus: "merged" };
21591
- if (confirmation === "enqueued") return { mergeStatus: "auto-merge-enqueued" };
21592
- return { mergeStatus: "failed", error: "auto-merge did not stick \u2014 GitHub reported no autoMergeRequest after enqueue; retry once the PR has a pending check" };
21593
- };
21594
- try {
21595
- await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
21596
- } catch (e) {
21597
- const message = String(e.message || "");
21598
- if (/already been merged/i.test(message)) return { mergeStatus: "merged" };
21599
- const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
21600
- if (note) return { mergeStatus: "failed", error: note };
21601
- if (mergeAutoRejectedPrAlreadyClean(message)) {
21602
- try {
21603
- await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: false, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
21604
- } catch (e2) {
21605
- const m2 = String(e2.message || "");
21606
- if (/already been merged/i.test(m2)) return { mergeStatus: "merged" };
21607
- if (!ghPrMergeLocalBranchDeleteWarning(m2)) {
21608
- const afterDirect = await readMergeState();
21609
- if (afterDirect.ok && afterDirect.state === "MERGED") return { mergeStatus: "merged" };
21610
- if (!afterDirect.ok) {
21611
- return { mergeStatus: "failed", error: `could not confirm PR state after clean-status fallback: ${afterDirect.error}` };
21612
- }
21613
- try {
21614
- await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
21615
- } catch (e3) {
21616
- const m3 = String(e3.message || "");
21617
- if (/already been merged/i.test(m3)) return { mergeStatus: "merged" };
21618
- const afterRetry = await readMergeState();
21619
- if (afterRetry.ok && afterRetry.state === "MERGED") return { mergeStatus: "merged" };
21620
- const stuck = (await io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
21621
- if (stuck) return { mergeStatus: "auto-merge-enqueued" };
21622
- return { mergeStatus: "failed", error: `${ghFailureReason(e2)} (re-enqueue also failed: ${ghFailureReason(e3)})` };
21623
- }
21624
- return confirmEnqueueOutcome();
21625
- }
21626
- }
21627
- const stateRead = await readMergeState();
21628
- if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
21629
- return {
21630
- mergeStatus: "failed",
21631
- error: stateRead.ok ? "direct merge did not land after clean-status fallback" : `could not confirm PR state after clean-status fallback: ${stateRead.error}`
21632
- };
21633
- }
21634
- if (ghPrMergeLocalBranchDeleteWarning(message)) {
21635
- const stateRead = await readMergeState();
21636
- if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
21637
- return {
21638
- mergeStatus: "failed",
21639
- error: stateRead.ok ? message.split("\n")[0] : `could not confirm PR state after local branch cleanup warning: ${stateRead.error}`
21640
- };
21641
- }
21642
- if (!basePolicyBlocksImmediateMerge(message)) {
21643
- return { mergeStatus: "failed", error: ghFailureReason(e) };
21644
- }
21645
- return { mergeStatus: "failed", error: `merge blocked: ${ghFailureReason(e)} \u2014 ensure checks are green` };
21646
- }
21647
- return confirmEnqueueOutcome();
21582
+ function refAbsenceNote(bin, refSupported) {
21583
+ return refSupported ? "" : `
21584
+ Note: \`${bin}\` has no \`--ref\` option; the marketplace manifest pins the released branch, so none is needed (#2516).`;
21648
21585
  }
21649
- async function remoteBranchExists2(branch, options = {}) {
21650
- return checkRemoteBranchExists(branch, {
21651
- execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
21652
- }, options);
21586
+ var PLUGIN_READ_REPO2 = "mutmutco/MMI-Hub";
21587
+ function pluginReadGrantNote(login = "<your-github-login>") {
21588
+ return `
21589
+ If the reinstall 404s on ${PLUGIN_READ_REPO2}, you lack read on it. An org owner must grant it (idempotent):
21590
+ gh api -X PUT repos/${PLUGIN_READ_REPO2}/collaborators/${login} -f permission=pull`;
21653
21591
  }
21654
- var COMPOSE_TIMEOUT_MS = 12e4;
21655
- function spawnDeferredGcSweep() {
21656
- spawnDetachedSelf(["worktree", "gc", "sweep-deferred", "--quiet"], { spawn: import_node_child_process11.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
21592
+ async function applyPluginHeal(surface, log, opts) {
21593
+ if (!opts?.force && surfaceToken(surface) !== "claude") return false;
21594
+ const tableSteps = PLUGIN_SURFACE_HEAL.claude.healSteps;
21595
+ if (!tableSteps) return false;
21596
+ const refSupported = await marketplaceAddRefSupported("claude");
21597
+ const { steps } = adaptHealStepsForRefSupport(tableSteps, refSupported);
21598
+ log(healBannerLine("claude", "claude", refSupported));
21599
+ const pinsPath = (0, import_node_path24.join)((0, import_node_os6.homedir)(), ...KNOWN_MARKETPLACES_RELATIVE);
21600
+ const pins = captureMarketplacePins(readKnownMarketplacesFile(pinsPath), [MMI_MARKETPLACE_NAME, JERV_MARKETPLACE_NAME]);
21601
+ for (const step of steps) {
21602
+ if (healStepAborts(step, await runClaudePlugin([...step.args]))) return false;
21603
+ }
21604
+ const restored = restoreMarketplacePinsOnDisk(pinsPath, pins);
21605
+ if (restored) log(` ${restored}`);
21606
+ return true;
21657
21607
  }
21658
- async function createDeferredWorktreeStore() {
21608
+ async function healClaudePluginForDoctor(surface = detectSurface(process.env)) {
21609
+ if (surfaceToken(surface) !== "claude") {
21610
+ return { ok: false, detail: `not a Claude surface (${surface}) \u2014 no Hub-shipped plugin to reinstall` };
21611
+ }
21612
+ const steps = [];
21613
+ const ok = await applyPluginHeal(surface, (msg) => steps.push(msg.trim()));
21614
+ const pinNote = steps.find((s) => s.startsWith("re-pinned ") || s.includes("re-pin by hand"));
21615
+ return {
21616
+ ok,
21617
+ detail: ok ? `marketplace remove \u2192 add \u2192 install succeeded${pinNote ? `; ${pinNote}` : ""}` : `\`claude plugin\` reinstall failed or was skipped${steps.length ? ` (${steps[steps.length - 1]})` : ""}`
21618
+ };
21619
+ }
21620
+ function readKnownMarketplacesFile(path2) {
21659
21621
  try {
21660
- const { stdout } = await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS });
21661
- return makeDeferredWorktreeStore(deferredWorktreesRegistryPath(stdout.trim()));
21622
+ return (0, import_node_fs25.existsSync)(path2) ? (0, import_node_fs25.readFileSync)(path2, "utf8") : void 0;
21662
21623
  } catch {
21663
21624
  return void 0;
21664
21625
  }
21665
21626
  }
21666
- var realWorktreeDirRemover = {
21667
- probe: (p) => {
21668
- let st;
21669
- try {
21670
- st = (0, import_node_fs25.lstatSync)(p);
21671
- } catch {
21672
- return null;
21673
- }
21674
- if (st.isSymbolicLink()) return "link";
21675
- try {
21676
- (0, import_node_fs25.readlinkSync)(p);
21677
- return "link";
21678
- } catch {
21679
- }
21680
- return st.isDirectory() ? "dir" : "file";
21681
- },
21682
- readdir: (p) => {
21683
- try {
21684
- return (0, import_node_fs25.readdirSync)(p);
21685
- } catch {
21686
- return [];
21687
- }
21688
- },
21689
- // A directory reparse point (junction / dir-symlink) is detached with rmdir (unlinks the mount point,
21690
- // leaving the target); a file symlink with unlink. rmdir first, fall back to unlink.
21691
- detachLink: (p) => {
21692
- try {
21693
- (0, import_node_fs25.rmdirSync)(p);
21694
- } catch {
21695
- (0, import_node_fs25.unlinkSync)(p);
21696
- }
21697
- },
21698
- removeTree: (p) => (0, import_promises6.rm)(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
21699
- };
21700
- async function resolvePrimaryCheckout(execGit) {
21627
+ function restoreMarketplacePinsOnDisk(path2, pins) {
21628
+ if (pins.size === 0) return void 0;
21629
+ const after = readKnownMarketplacesFile(path2);
21630
+ const next = restoreMarketplacePins(after, pins);
21631
+ if (next === null) return void 0;
21701
21632
  try {
21702
- return parseWorktreePorcelain(await execGit(["worktree", "list", "--porcelain"]))[0]?.path;
21633
+ (0, import_node_fs25.writeFileSync)(path2, next, "utf8");
21703
21634
  } catch {
21704
- return void 0;
21635
+ return `could NOT restore ${[...pins.keys()].join(", ")} \u2014 re-pin by hand`;
21705
21636
  }
21637
+ const verify = readKnownMarketplacesFile(path2);
21638
+ const failed = [...pins].filter(([name, want]) => {
21639
+ const got = readKnownMarketplace(verify, name);
21640
+ return typeof want.autoUpdate === "boolean" && got.declared !== want.autoUpdate || want.ref !== void 0 && got.ref !== want.ref;
21641
+ });
21642
+ return failed.length ? `restore did NOT take for ${failed.map(([n]) => n).join(", ")} \u2014 re-pin by hand` : `re-pinned ${[...pins.keys()].join(", ")} (auto-update + catalog ref survive the reinstall)`;
21706
21643
  }
21707
- function worktreeRemoveDeps(execGit) {
21708
- return {
21709
- git: execGit,
21710
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
21711
- // #3064: unlink any reparse point (esp. a `node_modules` junction to base) before the git-native
21712
- // `worktree remove --force`, which would otherwise recurse through it and empty the base checkout.
21713
- detachReparsePoints: (worktreePath) => detachReparsePoints(worktreePath, realWorktreeDirRemover),
21714
- removeWorktreeDir: async (worktreePath) => removeWorktreeTree(worktreePath, await resolvePrimaryCheckout(execGit), realWorktreeDirRemover)
21715
- };
21716
- }
21717
- async function worktreeHasStageState(worktreePath) {
21718
- if (stageStateFileBelongsToWorktree(stageStatePath(worktreePath), worktreePath)) return true;
21644
+ async function runGuard(readOrigin) {
21719
21645
  try {
21720
- const { stdout } = await execFileP2("git", ["-C", worktreePath, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS });
21721
- const globalPath = stageGlobalStatePath(worktreePath, stdout.trim());
21722
- return stageStateFileBelongsToWorktree(globalPath, worktreePath);
21646
+ const surface = detectSurface(process.env);
21647
+ if (surfaceToken(surface) !== "claude") {
21648
+ process.exitCode = 0;
21649
+ return;
21650
+ }
21651
+ const isOrgRepo = readOrigin ? await isOrgRepoRoot(readOrigin) : await isOrgRepoRoot();
21652
+ const input = snapshotPluginGuardInput(surface, isOrgRepo);
21653
+ const { state } = buildPluginGuardDecision(input);
21654
+ const { line, exitCode } = buildPluginGuardLine(state);
21655
+ if (line) console.error(line);
21656
+ process.exitCode = exitCode;
21723
21657
  } catch {
21724
- return false;
21658
+ process.exitCode = 0;
21725
21659
  }
21726
21660
  }
21727
- function stageStateFileBelongsToWorktree(statePath, worktreePath) {
21728
- if (!(0, import_node_fs25.existsSync)(statePath)) return false;
21729
- try {
21730
- const state = JSON.parse((0, import_node_fs25.readFileSync)(statePath, "utf8"));
21731
- const recordedCwd = typeof state.identity?.cwd === "string" ? state.identity.cwd : typeof state.cwd === "string" ? state.cwd : "";
21732
- return Boolean(recordedCwd && isPathUnderDirectory(recordedCwd, worktreePath));
21733
- } catch {
21734
- return false;
21661
+ async function runPluginHeal(surface = detectSurface(process.env)) {
21662
+ if (surfaceToken(surface) !== "claude") {
21663
+ console.log(nonClaudeSurfaceHealMessage(surfaceToken(surface) ?? void 0));
21664
+ return;
21665
+ }
21666
+ const descriptor = PLUGIN_SURFACE_HEAL.claude;
21667
+ const healed = await applyPluginHeal(surface, console.log, { force: true });
21668
+ if (healed) {
21669
+ console.log(` \u2713 MMI plugin reinstalled \u2014 ${reloadAction(surface)} to load MMI commands`);
21670
+ } else {
21671
+ const refSupported = await marketplaceAddRefSupported("claude");
21672
+ const recovery = refSupported ? descriptor.recovery : recoveryWithoutRef(descriptor.recovery);
21673
+ const note = refAbsenceNote("claude", refSupported);
21674
+ console.log(` \u2717 Auto-heal failed or was skipped. Run manually:
21675
+ ${recovery}${note}${pluginReadGrantNote()}`);
21735
21676
  }
21736
- }
21737
- function teardownWorktreeStage(worktreePath) {
21738
- return runWorktreeStageTeardown(worktreePath, {
21739
- hasStageState: worktreeHasStageState,
21740
- stopRecordedStage: async (wt) => (await stopStage({ cwd: wt, requiredIdentityCwd: wt })).pid,
21741
- listComposeProjects: async () => {
21742
- const { stdout } = await execFileP2("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS2 });
21743
- return parseComposeLs(stdout);
21744
- },
21745
- composeDown: async (project2) => {
21746
- await execFileP2("docker", ["compose", "-p", project2, "down", "-v"], { timeout: COMPOSE_TIMEOUT_MS });
21747
- }
21748
- });
21749
21677
  }
21750
21678
 
21751
- // src/pr-checks-rest.ts
21752
- var REST_GH_TIMEOUT_MS = 2e4;
21753
- async function defaultGhApi(args) {
21754
- const { stdout } = await execFileP2("gh", ["api", ...args], { timeout: REST_GH_TIMEOUT_MS });
21755
- return stdout;
21756
- }
21757
- function classifyCheckRun(run) {
21758
- if (run.status !== "completed") return "pending";
21759
- switch (run.conclusion) {
21760
- case "success":
21761
- case "neutral":
21762
- case "skipped":
21763
- return "pass";
21764
- case "failure":
21765
- case "timed_out":
21766
- case "cancelled":
21767
- case "action_required":
21768
- case "startup_failure":
21769
- case "stale":
21770
- return "fail";
21771
- default:
21772
- return "pending";
21679
+ // src/worktree-ownership.ts
21680
+ var import_node_fs26 = require("node:fs");
21681
+ var import_node_os7 = require("node:os");
21682
+ var import_node_path25 = require("node:path");
21683
+ var OWNERS_FILE = "worktree-owners.json";
21684
+ var EVENTS_FILE = "worktree-events.jsonl";
21685
+ var WORKTREE_ACTIVITY_WINDOW_MS = 30 * 6e4;
21686
+ var SESSION_ID_ENV_VARS = ["MMI_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID", "CURSOR_SESSION_ID"];
21687
+ function readSessionId(env) {
21688
+ for (const key of SESSION_ID_ENV_VARS) {
21689
+ const value = env[key]?.trim();
21690
+ if (value) return value;
21773
21691
  }
21692
+ return void 0;
21774
21693
  }
21775
- function classifyCommitStatus(state) {
21776
- if (state === "success") return "pass";
21777
- if (state === "failure" || state === "error") return "fail";
21778
- return "pending";
21694
+ function describeActor(input) {
21695
+ const session = readSessionId(input.env);
21696
+ return {
21697
+ surface: input.surface,
21698
+ host: input.host ?? (0, import_node_os7.hostname)(),
21699
+ pid: input.pid ?? process.pid,
21700
+ cwd: input.cwd,
21701
+ ...session ? { session } : {}
21702
+ };
21779
21703
  }
21780
- function dedupeLatestCheckRuns(runs) {
21781
- const byIdentity = /* @__PURE__ */ new Map();
21782
- const unkeyed = [];
21783
- for (const run of runs) {
21784
- if (!run.name || typeof run.id !== "number") {
21785
- unkeyed.push(run);
21786
- continue;
21787
- }
21788
- const key = `${run.app_id ?? "unknown-app"}:${run.name}`;
21789
- const prior = byIdentity.get(key);
21790
- if (!prior || run.id >= (prior.id ?? 0)) byIdentity.set(key, run);
21791
- }
21792
- return [...byIdentity.values(), ...unkeyed];
21704
+ function sameSession(a, b) {
21705
+ return Boolean(a?.session && b?.session && a.session === b.session);
21793
21706
  }
21794
- function derivePollState(buckets) {
21795
- if (!buckets.length) return "no-checks-reported";
21796
- const anyFailure = buckets.includes("fail");
21797
- const anyPending = buckets.includes("pending");
21798
- if (anyFailure) return anyPending ? "failing" : "failure";
21799
- return anyPending ? "pending" : "success";
21707
+ function worktreeOwnersPath(primaryRoot) {
21708
+ return repoRuntimeStatePath(primaryRoot, OWNERS_FILE);
21800
21709
  }
21801
- function parseNdjsonLines(stdout) {
21802
- return stdout.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line));
21710
+ function worktreeEventsPath(primaryRoot) {
21711
+ return repoRuntimeStatePath(primaryRoot, EVENTS_FILE);
21803
21712
  }
21804
- function parseRestPrSnapshot(json) {
21805
- const pr2 = json ?? {};
21806
- const headSha = pr2.head?.sha ?? "";
21807
- if (!headSha) throw new Error("pulls endpoint returned no head SHA");
21808
- const headRepo = pr2.head?.repo?.full_name;
21809
- const baseRepo = pr2.base?.repo?.full_name;
21810
- return {
21811
- headSha,
21812
- headRef: pr2.head?.ref ?? "",
21813
- // Only call it a fork when BOTH names are known and differ — an absent name (private/deleted fork)
21814
- // must not read as same-repo and send a git-ref probe at a branch the base repo does not have.
21815
- headIsFork: Boolean(headRepo && baseRepo && headRepo !== baseRepo),
21816
- mergeable: pr2.mergeable === true ? "MERGEABLE" : pr2.mergeable === false ? "CONFLICTING" : "UNKNOWN",
21817
- baseRef: pr2.base?.ref ?? "development",
21818
- merged: pr2.merged === true,
21819
- state: pr2.state ?? ""
21713
+ function sameWorktreePath(a, b, platform2 = process.platform) {
21714
+ const norm = (p) => {
21715
+ const unified = p.replace(/\\/g, "/").replace(/\/+$/, "");
21716
+ return platform2 === "win32" || platform2 === "darwin" ? unified.toLowerCase() : unified;
21820
21717
  };
21718
+ return norm(a) === norm(b);
21821
21719
  }
21822
- async function fetchBranchTipSha(repo, ref, gh = defaultGhApi) {
21823
- if (!ref) return null;
21720
+ function isActor(value) {
21721
+ if (!value || typeof value !== "object") return false;
21722
+ const a = value;
21723
+ return typeof a.surface === "string" && typeof a.host === "string" && typeof a.pid === "number" && typeof a.cwd === "string";
21724
+ }
21725
+ function parseWorktreeOwners(text) {
21726
+ let parsed;
21824
21727
  try {
21825
- const parsed = JSON.parse(await gh([`repos/${repo}/git/ref/heads/${ref}`]));
21826
- const sha = parsed.object?.sha;
21827
- return typeof sha === "string" && sha ? sha : null;
21728
+ parsed = JSON.parse(text);
21828
21729
  } catch {
21829
- return null;
21730
+ return [];
21830
21731
  }
21732
+ if (!parsed || !Array.isArray(parsed.entries)) return [];
21733
+ return parsed.entries.filter((e) => {
21734
+ if (!e || typeof e !== "object") return false;
21735
+ const entry = e;
21736
+ return typeof entry.path === "string" && entry.path.length > 0 && typeof entry.branch === "string" && typeof entry.createdAt === "string" && typeof entry.lastSeenAt === "string" && isActor(entry.actor);
21737
+ });
21831
21738
  }
21832
- async function fetchRestPrSnapshot(prNumber, repo, gh = defaultGhApi) {
21833
- return parseRestPrSnapshot(JSON.parse(await gh([`repos/${repo}/pulls/${prNumber}`])));
21834
- }
21835
- async function fetchHeadCheckRuns(headSha, repo, gh) {
21836
- const runsOut = await gh(["--paginate", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app_id: .app.id}"]);
21837
- return dedupeLatestCheckRuns(parseNdjsonLines(runsOut));
21739
+ function serializeWorktreeOwners(entries) {
21740
+ return `${JSON.stringify({ entries }, null, 2)}
21741
+ `;
21838
21742
  }
21839
- async function fetchHeadCheckBuckets(headSha, repo, gh) {
21840
- const [runs, statusesOut] = await Promise.all([
21841
- fetchHeadCheckRuns(headSha, repo, gh),
21842
- gh(["--paginate", `repos/${repo}/commits/${headSha}/status?per_page=100`, "--jq", ".statuses[] | {context, state}"])
21843
- ]);
21844
- const statuses = parseNdjsonLines(statusesOut);
21845
- return [...runs.map(classifyCheckRun), ...statuses.map((s) => classifyCommitStatus(s.state))];
21743
+ function upsertWorktreeOwner(entries, entry) {
21744
+ return [...entries.filter((e) => !sameWorktreePath(e.path, entry.path)), entry];
21745
+ }
21746
+ function withoutWorktreeOwner(entries, path2) {
21747
+ return entries.filter((e) => !sameWorktreePath(e.path, path2));
21748
+ }
21749
+ function findWorktreeOwner(entries, path2) {
21750
+ return entries.find((e) => sameWorktreePath(e.path, path2));
21751
+ }
21752
+ function decideWorktreeRemoval(input) {
21753
+ if (input.force) return { action: "proceed", reason: "forced" };
21754
+ const owner = input.owner;
21755
+ if (!owner) return { action: "proceed", reason: "no-owner" };
21756
+ if (sameSession(owner.actor, input.actor)) return { action: "proceed", reason: "self-directed" };
21757
+ const windowMs = input.windowMs ?? WORKTREE_ACTIVITY_WINDOW_MS;
21758
+ const lastSeen = Date.parse(owner.lastSeenAt) || Date.parse(owner.createdAt);
21759
+ const idleMs = Number.isFinite(lastSeen) ? input.now - lastSeen : 0;
21760
+ if (idleMs >= windowMs) return { action: "proceed", reason: "inactive" };
21761
+ const minutes = Math.max(1, Math.round(idleMs / 6e4));
21762
+ return {
21763
+ action: "refuse",
21764
+ reason: "recently-active",
21765
+ message: `${input.path} was created by another session (${describeActorShort(owner.actor)}) and last active ${minutes} minute(s) ago. A worktree this fresh is not abandoned \u2014 it is a session's working tree before its first commit, which is exactly the window work has been lost in (MMI-Hub#3580). Leave it, or pass --force to remove it anyway.`
21766
+ };
21846
21767
  }
21847
- async function pollRestPrChecks(prNumber, repo, gh = defaultGhApi) {
21848
- const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh);
21849
- return derivePollState(await fetchHeadCheckBuckets(snapshot.headSha, repo, gh));
21768
+ function describeActorShort(actor) {
21769
+ return `${actor.surface} pid ${actor.pid} on ${actor.host}${actor.session ? ` session ${actor.session}` : ""}`;
21850
21770
  }
21851
- async function pollRestPrMergeable(prNumber, repo, gh = defaultGhApi) {
21771
+ function readOwners(primaryRoot) {
21852
21772
  try {
21853
- return (await fetchRestPrSnapshot(prNumber, repo, gh)).mergeable;
21773
+ return parseWorktreeOwners((0, import_node_fs26.readFileSync)(worktreeOwnersPath(primaryRoot), "utf8"));
21854
21774
  } catch {
21855
- return "UNKNOWN";
21775
+ return [];
21856
21776
  }
21857
21777
  }
21858
- async function pollRestPrMerged(prNumber, repo, gh = defaultGhApi) {
21778
+ function writeOwners(primaryRoot, entries) {
21859
21779
  try {
21860
- return (await fetchRestPrSnapshot(prNumber, repo, gh)).merged;
21780
+ const path2 = worktreeOwnersPath(primaryRoot);
21781
+ (0, import_node_fs26.mkdirSync)((0, import_node_path25.dirname)(path2), { recursive: true });
21782
+ (0, import_node_fs26.writeFileSync)(path2, serializeWorktreeOwners(entries), "utf8");
21861
21783
  } catch {
21862
- return false;
21863
21784
  }
21864
21785
  }
21865
- var CI_BUDGET_ANNOTATION_TITLE = "CI budget exceeded";
21866
- var CI_DISK_ANNOTATION_TITLE = "CI runner disk";
21867
- var CI_TOOLCHAIN_ANNOTATION_TITLE = "CI runner toolchain";
21868
- var RUNNER_INFRA_ANNOTATION_TITLES = /* @__PURE__ */ new Set([
21869
- CI_BUDGET_ANNOTATION_TITLE,
21870
- CI_DISK_ANNOTATION_TITLE,
21871
- CI_TOOLCHAIN_ANNOTATION_TITLE
21872
- ]);
21873
- function isErrorAnnotation(a) {
21874
- const level = a.annotation_level?.toLowerCase();
21875
- return level === "failure" || level === "error";
21786
+ function readWorktreeOwners(primaryRoot) {
21787
+ return readOwners(primaryRoot);
21876
21788
  }
21877
- function classifyFailedChecks(failing) {
21878
- const infraFailures = [];
21879
- const otherFailures = [];
21880
- for (const check of failing) {
21881
- const errors = check.annotations.filter(isErrorAnnotation);
21882
- const allInfra = errors.length > 0 && errors.every((a) => RUNNER_INFRA_ANNOTATION_TITLES.has(a.title?.trim() ?? ""));
21883
- (allInfra ? infraFailures : otherFailures).push(check.name);
21884
- }
21885
- if (infraFailures.length && !otherFailures.length) {
21886
- return {
21887
- cause: "runner-infra",
21888
- infraFailures,
21889
- otherFailures,
21890
- reason: `${infraFailures.length} check(s) failed on RUNNER INFRASTRUCTURE, not your diff: ${infraFailures.join(", ")}. No test failed \u2014 the shared runner hit a wall-clock budget kill, ran out of disk, or was missing a toolchain (the check annotation names which). Re-run once the runner drains/heals (gh run rerun --failed); do not debug the diff.`
21891
- };
21892
- }
21893
- return {
21894
- cause: "checks-failure",
21895
- infraFailures,
21896
- otherFailures,
21897
- reason: infraFailures.length ? `checks failed: ${otherFailures.join(", ")}; separately, ${infraFailures.join(", ")} failed on runner infrastructure, not a test.` : `checks failed: ${otherFailures.join(", ") || "unknown"}.`
21898
- };
21789
+ function lookupWorktreeOwner(primaryRoot, path2) {
21790
+ return findWorktreeOwner(readOwners(primaryRoot), path2);
21899
21791
  }
21900
- async function diagnoseFailedRestChecks(prNumber, repo, gh = defaultGhApi) {
21792
+ function recordWorktreeOwner(primaryRoot, entry) {
21793
+ writeOwners(primaryRoot, upsertWorktreeOwner(readOwners(primaryRoot), entry));
21794
+ }
21795
+ function touchWorktreeOwner(primaryRoot, path2, at = /* @__PURE__ */ new Date()) {
21796
+ const entries = readOwners(primaryRoot);
21797
+ const owner = findWorktreeOwner(entries, path2);
21798
+ if (!owner) return;
21799
+ writeOwners(primaryRoot, upsertWorktreeOwner(entries, { ...owner, lastSeenAt: at.toISOString() }));
21800
+ }
21801
+ function dropWorktreeOwner(primaryRoot, path2) {
21802
+ const entries = readOwners(primaryRoot);
21803
+ if (!findWorktreeOwner(entries, path2)) return;
21804
+ writeOwners(primaryRoot, withoutWorktreeOwner(entries, path2));
21805
+ }
21806
+ function appendWorktreeEvent(primaryRoot, event) {
21901
21807
  try {
21902
- const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh);
21903
- if (!snapshot.headIsFork) {
21904
- const branchTip = await fetchBranchTipSha(repo, snapshot.headRef, gh);
21905
- if (branchTip && branchTip !== snapshot.headSha) {
21906
- return {
21907
- cause: "stale-head",
21908
- infraFailures: [],
21909
- otherFailures: [],
21910
- reason: `the PR head (${snapshot.headSha.slice(0, 7)}) is BEHIND the branch tip (${branchTip.slice(0, 7)}) \u2014 GitHub likely dropped a \`synchronize\` event, so the reported red is a superseded commit and NO run exists for your current push. This is not your diff failing. Re-push (an empty commit is enough) or close and reopen the PR to re-sync the head and trigger a fresh run.`
21911
- };
21912
- }
21913
- }
21914
- const failing = (await fetchHeadCheckRuns(snapshot.headSha, repo, gh)).filter((run) => classifyCheckRun(run) === "fail" && typeof run.id === "number");
21915
- if (!failing.length) return null;
21916
- const annotated = await Promise.all(failing.map(async (run) => ({
21917
- name: run.name ?? `check-run ${run.id}`,
21918
- annotations: JSON.parse(await gh([`repos/${repo}/check-runs/${run.id}/annotations`]))
21919
- })));
21920
- return classifyFailedChecks(annotated);
21808
+ const path2 = worktreeEventsPath(primaryRoot);
21809
+ (0, import_node_fs26.mkdirSync)((0, import_node_path25.dirname)(path2), { recursive: true });
21810
+ (0, import_node_fs26.appendFileSync)(path2, `${JSON.stringify({ at: event.at ?? (/* @__PURE__ */ new Date()).toISOString(), ...event })}
21811
+ `, "utf8");
21921
21812
  } catch {
21922
- return null;
21923
21813
  }
21924
21814
  }
21925
- async function fetchRestCorePool(gh = defaultGhApi) {
21815
+ function readWorktreeEvents(primaryRoot, limit = 50) {
21816
+ let text;
21926
21817
  try {
21927
- const parsed = JSON.parse(await gh(["rate_limit"]));
21928
- const core = parsed.resources?.core;
21929
- if (typeof core?.remaining !== "number" || typeof core?.reset !== "number") return null;
21930
- return { remaining: core.remaining, reset: core.reset };
21818
+ text = (0, import_node_fs26.readFileSync)(worktreeEventsPath(primaryRoot), "utf8");
21931
21819
  } catch {
21932
- return null;
21820
+ return [];
21821
+ }
21822
+ const events = [];
21823
+ for (const line of text.split(/\r?\n/)) {
21824
+ if (!line.trim()) continue;
21825
+ try {
21826
+ const parsed = JSON.parse(line);
21827
+ if (parsed && typeof parsed.target === "string" && typeof parsed.action === "string") events.push(parsed);
21828
+ } catch {
21829
+ }
21933
21830
  }
21831
+ return limit > 0 ? events.slice(-limit) : events;
21832
+ }
21833
+ function formatWorktreeEvents(events) {
21834
+ if (!events.length) {
21835
+ return "worktree events: none recorded yet (this log starts at the first `worktree create` after #3580 landed)";
21836
+ }
21837
+ const lines = [`worktree events: ${events.length} (newest first)`];
21838
+ for (const e of [...events].reverse()) {
21839
+ lines.push(` ${e.at} ${e.action.toUpperCase()} ${e.target}${e.branch ? ` (branch ${e.branch})` : ""}`);
21840
+ lines.push(` by: ${e.command} \u2014 ${describeActorShort(e.actor)}`);
21841
+ if (e.owner) lines.push(` owner: ${describeActorShort(e.owner.actor)}, created ${e.owner.createdAt}, last active ${e.owner.lastSeenAt}`);
21842
+ if (e.reason) lines.push(` reason: ${e.reason}`);
21843
+ }
21844
+ return lines.join("\n");
21934
21845
  }
21935
21846
 
21936
- // src/worktree-lifecycle-commands.ts
21937
- var import_node_fs26 = require("node:fs");
21938
- var import_node_path25 = require("node:path");
21939
- var GH_TIMEOUT_MS = 2e4;
21940
- var DEFAULT_BASE = "origin/development";
21941
- var DEFAULT_REMOTE = "origin";
21942
- var PROTECTED_BRANCHES2 = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
21943
- function slugifyIssueTitle(title) {
21944
- const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21945
- return slug.slice(0, 50);
21847
+ // src/merge-cleanup.ts
21848
+ var GC_GH_TIMEOUT_MS2 = 2e4;
21849
+ async function advanceClosedIssuesToDone2(prNumber, repoOption) {
21850
+ try {
21851
+ return await resolveBoardAdvanceForPr(prNumber, repoOption);
21852
+ } catch (e) {
21853
+ return { status: "fetch-failed", entries: [], error: e.message };
21854
+ }
21946
21855
  }
21947
- function buildNewBranchName(issueNumber, slug) {
21948
- const clean4 = slug.replace(/^[-]+|[-]+$/g, "");
21949
- return clean4 ? `${issueNumber}-${clean4}` : String(issueNumber);
21856
+ async function resolveBoardAdvanceForPr(prNumber, repoOption) {
21857
+ const repoArgs = repoOption ? ["--repo", repoOption] : [];
21858
+ const repo = await resolveRepo(repoOption);
21859
+ if (!repo) {
21860
+ return {
21861
+ status: "fetch-failed",
21862
+ entries: [],
21863
+ error: "could not resolve the PR's repo, so a closing reference cannot be proven to belong to this board"
21864
+ };
21865
+ }
21866
+ return advanceClosedIssuesToDone({
21867
+ repo,
21868
+ fetchClosingIssues: async () => {
21869
+ const { stdout } = await execFileP2("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "closingIssuesReferences"], { timeout: GC_GH_TIMEOUT_MS2 });
21870
+ const parsed = JSON.parse(stdout || "null");
21871
+ if (!parsed || typeof parsed !== "object" || !("closingIssuesReferences" in parsed)) {
21872
+ throw new Error("gh returned no closingIssuesReferences field for this PR");
21873
+ }
21874
+ return parsed.closingIssuesReferences;
21875
+ },
21876
+ moveIssueToDone: async (ref) => {
21877
+ const moved = await moveBoardItem({ config: await loadConfigForBoardSelector2(ref, repoOption), selector: ref, status: "Done", repo: repoOption, allowPartial: true });
21878
+ return moved.partial ? { moved: false, error: moved.warning } : { moved: true };
21879
+ }
21880
+ });
21950
21881
  }
21951
- function isIssueRef(target) {
21952
- return /^#?\d+$/.test(target) || /^[\w.-]+\/[\w.-]+#\d+$/.test(target);
21882
+ function recordGcWorktreeRemoval(primaryRoot, actor, owners, path2, branch) {
21883
+ const owner = findWorktreeOwner(owners, path2);
21884
+ appendWorktreeEvent(primaryRoot, {
21885
+ action: "removed",
21886
+ command: "worktree gc",
21887
+ target: path2,
21888
+ branch: branch ?? owner?.branch,
21889
+ actor,
21890
+ owner: owner ? { createdAt: owner.createdAt, lastSeenAt: owner.lastSeenAt, actor: owner.actor } : void 0,
21891
+ reason: owner ? "branch merged/closed or directory dead; owner registration was stale" : "branch merged/closed or directory dead; no owner registered"
21892
+ });
21893
+ dropWorktreeOwner(primaryRoot, path2);
21953
21894
  }
21954
- function worktreeCreatePlan(opts, args) {
21955
- const target = String(args[0] ?? "");
21895
+ function renderGcApplyResult(result) {
21896
+ const lines = ["worktree gc apply result:"];
21897
+ lines.push(` branches removed: ${result.removedBranches.length ? result.removedBranches.join(", ") : "none"}`);
21898
+ lines.push(` remote branches removed: ${result.removedRemoteBranches.length ? result.removedRemoteBranches.join(", ") : "none"}`);
21899
+ lines.push(` tracking refs removed: ${result.removedTrackingRefs.length ? result.removedTrackingRefs.join(", ") : "none"}`);
21900
+ lines.push(` dead sibling dirs removed: ${result.removedWorktreeDirs.length ? result.removedWorktreeDirs.join(", ") : "none"}`);
21901
+ lines.push(` stale registrations pruned: ${result.pruned ? "yes" : "no"} (clears registrations only \u2014 never deletes a directory)`);
21902
+ if (result.refused.length) {
21903
+ lines.push(` refused \u2014 owned by another session (${result.refused.length}):`);
21904
+ for (const r of result.refused) lines.push(` - ${r}`);
21905
+ }
21906
+ if (result.failed.length) {
21907
+ lines.push(` failed (${result.failed.length}):`);
21908
+ for (const f of result.failed) lines.push(` - ${f}`);
21909
+ }
21910
+ const removedNothing = result.removedBranches.length === 0 && result.removedRemoteBranches.length === 0 && result.removedTrackingRefs.length === 0 && result.removedWorktreeDirs.length === 0;
21911
+ if (removedNothing && !result.refused.length) {
21912
+ lines.push(" NOTHING WAS REMOVED. A stale worktree whose directory still exists is not reachable by");
21913
+ lines.push(" this verb \u2014 remove it from inside it with `mmi-cli worktree land --apply`, or delete the");
21914
+ lines.push(" directory and re-run to clear its registration.");
21915
+ }
21916
+ return lines.join("\n");
21917
+ }
21918
+ async function deleteReviewedRemoteBranch(remote, branch, expectedHeadOid) {
21919
+ if (!isSafeGitRemoteName(remote)) throw new Error(`unsafe remote name ${remote}`);
21920
+ const { stdout } = await execFileP2("git", ["ls-remote", "--heads", remote, branch], { timeout: GIT_TIMEOUT_MS });
21921
+ const line = stdout.trim().split(/\r?\n/).find(Boolean);
21922
+ if (!line) return "already-gone";
21923
+ const remoteHead = line.split(/\s+/)[0]?.trim();
21924
+ if (!remoteHead || remoteHead !== expectedHeadOid) {
21925
+ throw new Error(`${remoteHead || "unknown"} != ${expectedHeadOid}`);
21926
+ }
21927
+ try {
21928
+ await execFileP2("git", [
21929
+ "push",
21930
+ remote,
21931
+ `--force-with-lease=refs/heads/${branch}:${expectedHeadOid}`,
21932
+ `:refs/heads/${branch}`
21933
+ ], { timeout: GH_MUTATION_TIMEOUT_MS });
21934
+ } catch (e) {
21935
+ const after = await execFileP2("git", ["ls-remote", "--heads", remote, branch], { timeout: GIT_TIMEOUT_MS }).catch(() => void 0);
21936
+ if (after && !after.stdout.trim().split(/\r?\n/).find(Boolean)) return "already-gone";
21937
+ throw e;
21938
+ }
21939
+ return "deleted";
21940
+ }
21941
+ function evaluatePrMergeHousekeeping(report, context, force = false) {
21942
+ const scratch = report.scratch;
21943
+ const failed = scratch?.status === "failed";
21944
+ const prunable = scratch?.safeAuto ?? 0;
21945
+ const unprunable = scratch?.skipped ?? 0;
21946
+ if (!(failed || prunable > 0 || unprunable > 0)) return { blocked: false };
21947
+ if (force) return { blocked: false };
21948
+ const detail = failed ? `scratch housekeeping failed${scratch?.error ? `: ${scratch.error}` : ""}` : `${prunable} prunable + ${unprunable} un-prunable scratch item(s) remain after cleanup`;
21956
21949
  return {
21957
- command: "worktree create",
21958
- target,
21959
- issueForm: isIssueRef(target),
21960
- claim: Boolean(opts.claim),
21961
- slug: opts.slug || void 0,
21962
- from: opts.base || opts.from || DEFAULT_BASE,
21963
- remote: opts.remote || DEFAULT_REMOTE,
21964
- repo: opts.repo || void 0,
21965
- assignee: opts.for || void 0
21950
+ blocked: true,
21951
+ reason: `${context} housekeeping blocked merge: ${detail}. Run \`mmi-cli worktree gc --scratch --apply\` to clear it, then retry \u2014 or re-run \`mmi-cli ${context} --force\` to acknowledge and land anyway. (Kept advisory plans/ scratch never blocks: it is gitignored and can never reach origin.)`
21966
21952
  };
21967
21953
  }
21968
- function landSteps(branch, hasWorktree, hasStage) {
21969
- const steps = [];
21970
- if (hasStage) steps.push("stop spawned dev stage");
21971
- if (hasWorktree) steps.push(`remove worktree (branch ${branch})`);
21972
- steps.push(`delete local branch ${branch}`);
21973
- steps.push(`delete remote branch ${branch}`);
21974
- steps.push("prune worktree metadata");
21975
- return steps;
21954
+ function assertPrMergeHousekeepingClean(startingPath, context, options = {}) {
21955
+ const build = options.buildHousekeeping ?? ((repoRoot2) => buildPrMergeScratchHousekeeping(repoRoot2));
21956
+ const housekeeping = build(startingPath || process.cwd());
21957
+ const verdict = evaluatePrMergeHousekeeping(housekeeping, context, options.force);
21958
+ if (verdict.blocked) throw new Error(verdict.reason);
21959
+ return housekeeping;
21976
21960
  }
21977
- function classifyStaleLeaks(input) {
21978
- const protectedBranches = input.protectedBranches ?? PROTECTED_BRANCHES2;
21979
- const leaks = [];
21980
- const worktreeBranches2 = new Set(input.worktrees.filter((w) => !w.primary).map((w) => w.branch));
21981
- const stageByPath = new Map(input.stages.map((s) => [s.path, s.port]));
21982
- for (const wt of input.worktrees) {
21983
- if (wt.primary) continue;
21984
- if (protectedBranches.has(wt.branch)) continue;
21985
- if (input.openPrBranches.has(wt.branch)) continue;
21986
- if (!input.closedBranches.has(wt.branch)) continue;
21987
- leaks.push({
21988
- kind: "stale-worktree",
21989
- ref: wt.branch,
21990
- detail: `merged/closed branch ${wt.branch} still checked out at ${wt.path}`,
21991
- remediation: `cd "${wt.path}" && mmi-cli worktree land --apply (or: mmi-cli worktree gc --apply)`
21961
+ async function applyGcPlan(plan, remote, opts = {}) {
21962
+ const result = { removedBranches: [], removedRemoteBranches: [], removedTrackingRefs: [], removedWorktreeDirs: [], refused: [], failed: [], pruned: false };
21963
+ const beforeWorktrees = parseWorktreePorcelain(
21964
+ (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
21965
+ );
21966
+ const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
21967
+ const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
21968
+ const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path26.dirname)((0, import_node_path26.dirname)(worktreeGitRoot)) : repoRoot2;
21969
+ const gcActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: process.cwd() });
21970
+ const owners = readWorktreeOwners(primaryRepoRoot);
21971
+ const removalNow = Date.now();
21972
+ const refusesRemoval = (path2) => {
21973
+ if (!path2) return false;
21974
+ const owner = findWorktreeOwner(owners, path2);
21975
+ const verdict = decideWorktreeRemoval({ path: path2, owner, actor: gcActor, now: removalNow, force: opts.force });
21976
+ if (verdict.action !== "refuse") return false;
21977
+ result.refused.push(verdict.message);
21978
+ appendWorktreeEvent(primaryRepoRoot, {
21979
+ action: "refused",
21980
+ command: "worktree gc",
21981
+ target: path2,
21982
+ branch: owner?.branch,
21983
+ actor: gcActor,
21984
+ owner: owner ? { createdAt: owner.createdAt, lastSeenAt: owner.lastSeenAt, actor: owner.actor } : void 0,
21985
+ reason: verdict.message
21992
21986
  });
21987
+ return true;
21988
+ };
21989
+ const branchesToClean = plan.branches.filter((b) => !refusesRemoval(b.worktreePath));
21990
+ const worktreeDirsToRemove = plan.worktreeDirs.filter((d) => !refusesRemoval(d.path));
21991
+ const deferredStore = await createDeferredWorktreeStore();
21992
+ const branchTracking = await applyBranchAndTrackingCleanup({ ...plan, branches: branchesToClean }, {
21993
+ localBranchHeads,
21994
+ cleanupBranch: async (branch, expectedHeadOid) => {
21995
+ const wtDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
21996
+ const cleanup = await cleanupPrMergeLocalBranch(branch.branch, {
21997
+ beforeWorktrees,
21998
+ startingPath: branch.worktreePath,
21999
+ pathExists: (p) => (0, import_node_fs27.existsSync)(p),
22000
+ execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
22001
+ teardownWorktreeStage,
22002
+ deferredStore,
22003
+ expectedHeadOid,
22004
+ detachReparsePoints: wtDeps.detachReparsePoints,
22005
+ // #3064: junction-safe teardown
22006
+ removeWorktreeDir: wtDeps.removeWorktreeDir
22007
+ });
22008
+ if (cleanup.worktree?.status === "removed") {
22009
+ recordGcWorktreeRemoval(primaryRepoRoot, gcActor, owners, cleanup.worktree.path, branch.branch);
22010
+ }
22011
+ return cleanup;
22012
+ },
22013
+ cleanupRemoteBranch: (branch, expectedHeadOid) => deleteReviewedRemoteBranch(remote, branch.branch, expectedHeadOid),
22014
+ cleanupTrackingRefs: (trackingRefs, unsafeBranches) => applyTrackingRefCleanup(trackingRefs, remote, unsafeBranches, {
22015
+ execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
22016
+ })
22017
+ });
22018
+ result.removedBranches.push(...branchTracking.removedBranches);
22019
+ result.removedRemoteBranches.push(...branchTracking.removedRemoteBranches);
22020
+ result.removedTrackingRefs.push(...branchTracking.removedTrackingRefs);
22021
+ result.failed.push(...branchTracking.failed);
22022
+ const removeDeps = worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout);
22023
+ const siblingRoot = opts.root ? resolveExplicitScanRoot(opts.root, primaryRepoRoot) : siblingMmiWorktreesRoot(primaryRepoRoot);
22024
+ for (const wt of worktreeDirsToRemove) {
22025
+ try {
22026
+ const cleanupTarget = resolveSafeSiblingWorktreeCleanupTarget(wt.path, siblingRoot, {
22027
+ realpath: (path2) => (0, import_node_fs27.realpathSync)(path2)
22028
+ });
22029
+ if (!cleanupTarget.ok) {
22030
+ result.failed.push(`${wt.path}: ${cleanupTarget.reason}`);
22031
+ continue;
22032
+ }
22033
+ const stillValid = validateDeadWorktreeDirCleanup(wt, inspectSiblingWorktreeDir(wt.path, worktreeGitRoot));
22034
+ if (!stillValid.ok) {
22035
+ result.failed.push(`${wt.path}: skipped dead-dir removal (${stillValid.reason})`);
22036
+ continue;
22037
+ }
22038
+ const contentSafe = validateDeadWorktreeDirContent(wt, inspectDeadWorktreeDirContent(wt.path));
22039
+ if (!contentSafe.ok) {
22040
+ result.failed.push(`${wt.path}: skipped dead-dir removal (${contentSafe.reason})`);
22041
+ continue;
22042
+ }
22043
+ await removeDeps.removeWorktreeDir(cleanupTarget.path);
22044
+ result.removedWorktreeDirs.push(wt.path);
22045
+ recordGcWorktreeRemoval(primaryRepoRoot, gcActor, owners, wt.path, findWorktreeOwner(owners, wt.path)?.branch);
22046
+ } catch (e) {
22047
+ result.failed.push(`${wt.path}: ${e.message.split("\n")[0]}`);
22048
+ }
21993
22049
  }
21994
- for (const branch of input.localBranches) {
21995
- if (protectedBranches.has(branch)) continue;
21996
- if (branch === input.currentBranch) continue;
21997
- if (input.openPrBranches.has(branch)) continue;
21998
- if (!input.closedBranches.has(branch)) continue;
21999
- if (worktreeBranches2.has(branch)) continue;
22000
- leaks.push({
22001
- kind: "stale-branch",
22002
- ref: branch,
22003
- detail: `merged/closed branch ${branch} has no worktree`,
22004
- remediation: "mmi-cli worktree gc --apply"
22005
- });
22050
+ try {
22051
+ await execFileP2("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
22052
+ result.pruned = true;
22053
+ } catch (e) {
22054
+ result.failed.push(`worktree prune: ${e.message.split("\n")[0]}`);
22006
22055
  }
22007
- for (const wt of input.worktrees) {
22008
- if (wt.primary) continue;
22009
- const port = stageByPath.get(wt.path);
22010
- if (port == null) continue;
22011
- if (input.openPrBranches.has(wt.branch)) continue;
22012
- if (protectedBranches.has(wt.branch) && !input.closedBranches.has(wt.branch)) continue;
22013
- if (!input.closedBranches.has(wt.branch)) continue;
22014
- leaks.push({
22015
- kind: "stale-stage",
22016
- ref: wt.branch,
22017
- detail: `dev stage on port ${port} still running in ${wt.path} (branch ${wt.branch} merged/closed)`,
22018
- remediation: `cd "${wt.path}" && mmi-cli stage stop --apply`
22019
- });
22056
+ return result;
22057
+ }
22058
+ async function pollGhPrChecks(prNumber, repoArgs) {
22059
+ let stdout = "";
22060
+ let stderr = "";
22061
+ try {
22062
+ ({ stdout, stderr } = await execFileP2("gh", ["pr", "checks", prNumber, ...repoArgs], { timeout: GC_GH_TIMEOUT_MS2 }));
22063
+ } catch (e) {
22064
+ const err = e;
22065
+ if (err.killed || err.stdout == null && err.stderr == null) throw e;
22066
+ stdout = err.stdout ?? "";
22067
+ stderr = err.stderr ?? "";
22020
22068
  }
22021
- const knownPaths = new Set(input.worktrees.map((w) => w.path));
22022
- for (const dir of input.orphanDirs) {
22023
- if (knownPaths.has(dir.path)) continue;
22024
- leaks.push({
22025
- kind: "orphan-dir",
22026
- ref: dir.path,
22027
- // Say only what the inspection proved. `orphaned-folder` proves the `.git` entry is gone while this
22028
- // repo's worktree metadata still points at the directory — NOT that the directory is empty; gc
22029
- // checks contents separately, at apply time.
22030
- detail: dir.reason === "dangling-gitdir" ? "this repo's worktree directory whose git metadata is gone" : "this repo's worktree metadata points here, but the .git entry is gone",
22031
- // gc --apply re-proves ownership before deleting anything; a raw `rm -rf` does not, so it is never
22032
- // offered here — following it on a mis-classified dir would destroy another repo's live worktree.
22033
- remediation: "mmi-cli worktree gc --apply"
22034
- });
22069
+ const state = parseGhPrChecksResult(stdout, stderr);
22070
+ if (state === "error") {
22071
+ throw new Error(`gh pr checks ${prNumber}: ${(stderr || stdout).trim() || "unknown error"}`);
22035
22072
  }
22036
- return leaks;
22073
+ return state;
22037
22074
  }
22038
- var defaultOrphanDirScanDeps = {
22039
- listDirs: (root) => {
22040
- try {
22041
- return (0, import_node_fs26.readdirSync)(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => (0, import_node_path25.join)(root, e.name));
22042
- } catch {
22043
- return [];
22044
- }
22045
- },
22046
- inspect: inspectSiblingWorktreeDir
22047
- };
22048
- function scanOrphanDirs(worktreesRoot, worktreeGitRoot, deps = defaultOrphanDirScanDeps) {
22049
- const candidates = [];
22050
- for (const dir of deps.listDirs(worktreesRoot)) {
22051
- const { cleanup } = classifySiblingWorktreeDir(deps.inspect(dir, worktreeGitRoot));
22052
- if (cleanup) candidates.push({ path: dir, reason: cleanup.reason });
22053
- }
22054
- return candidates;
22075
+ function ghFailureReason(e) {
22076
+ const err = e;
22077
+ const head = String(err?.message ?? "").split("\n")[0]?.trim() ?? "";
22078
+ const detail = `${err?.stderr ?? ""}
22079
+ ${err?.stdout ?? ""}`.split("\n").map((line) => line.trim()).find((line) => line.length > 0);
22080
+ if (detail && head) return `${head} \u2014 ${detail}`;
22081
+ return detail || head || "unknown error";
22055
22082
  }
22056
- function formatStaleLeaks(leaks) {
22057
- if (!leaks.length) return "worktree list --stale: no leaks found";
22058
- const lines = [`worktree list --stale: ${leaks.length} leak(s)`];
22059
- for (const leak of leaks) {
22060
- lines.push(` [${leak.kind}] ${leak.ref} \u2014 ${leak.detail}`);
22061
- lines.push(` fix: ${leak.remediation}`);
22083
+ var defaultGhMergeAutoIo = {
22084
+ gh: async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout
22085
+ };
22086
+ async function ghMergeAutoEnqueue(prNumber, repo, method, io = defaultGhMergeAutoIo) {
22087
+ const args = repo ? ["--repo", repo] : [];
22088
+ const headRef = (await io.gh(["pr", "view", prNumber, ...args, "--json", "headRefName", "--jq", ".headRefName"], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
22089
+ const deleteBranch = !isProtectedBranch(headRef);
22090
+ const readMergeState = () => readGhPrStateWithRetry(
22091
+ () => io.gh(["pr", "view", prNumber, ...args, "--json", "state", "--jq", ".state"], GC_GH_TIMEOUT_MS2)
22092
+ );
22093
+ const confirmEnqueueOutcome = async () => {
22094
+ const stateRead = await readMergeState();
22095
+ if (!stateRead.ok) {
22096
+ return { mergeStatus: "failed", error: `could not read PR state after merge: ${stateRead.error}` };
22097
+ }
22098
+ if (stateRead.state === "MERGED") return { mergeStatus: "merged" };
22099
+ const confirmation = await confirmAutoMergeEnqueued({
22100
+ readAutoMergeRequest: async () => io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2),
22101
+ readMerged: async () => {
22102
+ const s = await readMergeState();
22103
+ return s.ok && s.state === "MERGED";
22104
+ },
22105
+ reEnqueue: async () => {
22106
+ await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
22107
+ }
22108
+ });
22109
+ if (confirmation === "merged") return { mergeStatus: "merged" };
22110
+ if (confirmation === "enqueued") return { mergeStatus: "auto-merge-enqueued" };
22111
+ return { mergeStatus: "failed", error: "auto-merge did not stick \u2014 GitHub reported no autoMergeRequest after enqueue; retry once the PR has a pending check" };
22112
+ };
22113
+ try {
22114
+ await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
22115
+ } catch (e) {
22116
+ const message = String(e.message || "");
22117
+ if (/already been merged/i.test(message)) return { mergeStatus: "merged" };
22118
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
22119
+ if (note) return { mergeStatus: "failed", error: note };
22120
+ if (mergeAutoRejectedPrAlreadyClean(message)) {
22121
+ try {
22122
+ await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: false, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
22123
+ } catch (e2) {
22124
+ const m2 = String(e2.message || "");
22125
+ if (/already been merged/i.test(m2)) return { mergeStatus: "merged" };
22126
+ if (!ghPrMergeLocalBranchDeleteWarning(m2)) {
22127
+ const afterDirect = await readMergeState();
22128
+ if (afterDirect.ok && afterDirect.state === "MERGED") return { mergeStatus: "merged" };
22129
+ if (!afterDirect.ok) {
22130
+ return { mergeStatus: "failed", error: `could not confirm PR state after clean-status fallback: ${afterDirect.error}` };
22131
+ }
22132
+ try {
22133
+ await io.gh(buildPrMergeArgs({ number: prNumber, repoArgs: args, method, auto: true, deleteBranch }), GH_MUTATION_TIMEOUT_MS);
22134
+ } catch (e3) {
22135
+ const m3 = String(e3.message || "");
22136
+ if (/already been merged/i.test(m3)) return { mergeStatus: "merged" };
22137
+ const afterRetry = await readMergeState();
22138
+ if (afterRetry.ok && afterRetry.state === "MERGED") return { mergeStatus: "merged" };
22139
+ const stuck = (await io.gh(["pr", "view", prNumber, ...args, "--json", "autoMergeRequest", "--jq", '.autoMergeRequest // ""'], GC_GH_TIMEOUT_MS2).catch(() => "")).trim();
22140
+ if (stuck) return { mergeStatus: "auto-merge-enqueued" };
22141
+ return { mergeStatus: "failed", error: `${ghFailureReason(e2)} (re-enqueue also failed: ${ghFailureReason(e3)})` };
22142
+ }
22143
+ return confirmEnqueueOutcome();
22144
+ }
22145
+ }
22146
+ const stateRead = await readMergeState();
22147
+ if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
22148
+ return {
22149
+ mergeStatus: "failed",
22150
+ error: stateRead.ok ? "direct merge did not land after clean-status fallback" : `could not confirm PR state after clean-status fallback: ${stateRead.error}`
22151
+ };
22152
+ }
22153
+ if (ghPrMergeLocalBranchDeleteWarning(message)) {
22154
+ const stateRead = await readMergeState();
22155
+ if (stateRead.ok && stateRead.state === "MERGED") return { mergeStatus: "merged" };
22156
+ return {
22157
+ mergeStatus: "failed",
22158
+ error: stateRead.ok ? message.split("\n")[0] : `could not confirm PR state after local branch cleanup warning: ${stateRead.error}`
22159
+ };
22160
+ }
22161
+ if (!basePolicyBlocksImmediateMerge(message)) {
22162
+ return { mergeStatus: "failed", error: ghFailureReason(e) };
22163
+ }
22164
+ return { mergeStatus: "failed", error: `merge blocked: ${ghFailureReason(e)} \u2014 ensure checks are green` };
22062
22165
  }
22063
- return lines.join("\n");
22064
- }
22065
- async function repoRootOf() {
22066
- return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
22067
- }
22068
- function classifyLandBranchMergeState(prs) {
22069
- if (!prs) return { state: "unknown", numbers: [] };
22070
- if (!prs.length) return { state: "no-pr", numbers: [] };
22071
- const pick = (s) => prs.filter((pr2) => pr2.state.toUpperCase() === s).map((pr2) => pr2.number);
22072
- const open2 = pick("OPEN");
22073
- if (open2.length) return { state: "open", numbers: open2 };
22074
- const known = /* @__PURE__ */ new Set(["OPEN", "CLOSED", "MERGED"]);
22075
- const unrecognised = prs.filter((pr2) => !known.has(pr2.state.toUpperCase()));
22076
- if (unrecognised.length) return { state: "unknown", numbers: prs.map((pr2) => pr2.number) };
22077
- const merged = pick("MERGED");
22078
- if (!merged.length) return { state: "closed-unmerged", numbers: prs.map((pr2) => pr2.number) };
22079
- if (merged.length < prs.length) return { state: "mixed", numbers: prs.map((pr2) => pr2.number) };
22080
- return { state: "merged", numbers: merged };
22166
+ return confirmEnqueueOutcome();
22081
22167
  }
22082
- function describeLandMergeState(v) {
22083
- const refs = v.numbers.map((n) => `#${n}`).join(", ");
22084
- switch (v.state) {
22085
- case "merged":
22086
- return `PR ${refs} merged`;
22087
- case "closed-unmerged":
22088
- return `PR ${refs} CLOSED \u2014 NOT merged`;
22089
- case "mixed":
22090
- return `PR ${refs} \u2014 some merged, some CLOSED unmerged; which one owns the branch head is unproven`;
22091
- case "open":
22092
- return `PR ${refs} still OPEN \u2014 NOT merged`;
22093
- case "no-pr":
22094
- return "no pull request for this branch";
22095
- case "unknown":
22096
- return refs ? `PR ${refs} reported a pull-request state this CLI does not recognise \u2014 merge NOT confirmed` : "pull-request state could not be read \u2014 merge NOT confirmed";
22097
- }
22168
+ async function remoteBranchExists2(branch, options = {}) {
22169
+ return checkRemoteBranchExists(branch, {
22170
+ execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
22171
+ }, options);
22098
22172
  }
22099
- function decideWorktreeLandRefCleanup(input) {
22100
- if (input.keepRemote) return { action: "proceed" };
22101
- const verdict = classifyLandBranchMergeState(input.prs);
22102
- const refs = verdict.numbers.map((n) => `#${n}`).join(", ");
22103
- if (verdict.state === "unknown") {
22104
- return verdict.numbers.length ? {
22105
- action: "keep-remote",
22106
- reason: "pr-state-unrecognised",
22107
- message: `pull request ${refs} for '${input.branch}' reported a state this CLI does not recognise \u2014 keeping the remote branch rather than deleting a ref whose merge is unproven`
22108
- } : {
22109
- action: "keep-remote",
22110
- reason: "pr-state-unreadable",
22111
- message: `could not read the pull-request state for '${input.branch}' \u2014 keeping the remote branch so an unmerged head ref cannot be deleted on a guess`
22112
- };
22113
- }
22114
- if (verdict.state === "open") {
22115
- return {
22116
- action: "refuse",
22117
- reason: "open-pr",
22118
- message: `'${input.branch}' still has an OPEN pull request (${refs}). Deleting its head ref would auto-close the PR and leave the work on no branch. Merge it first, or re-run with --keep-remote for a local-only clean`
22119
- };
22120
- }
22121
- if (verdict.state === "mixed") {
22122
- return {
22123
- action: "keep-remote",
22124
- reason: "mixed-pr-state",
22125
- message: `'${input.branch}' has both a merged and a closed-unmerged pull request (${refs}) and nothing here proves which owns the current head \u2014 keeping the remote branch rather than deleting a ref that may hold unmerged work`
22126
- };
22127
- }
22128
- if (verdict.state === "closed-unmerged") {
22129
- return {
22130
- action: "refuse",
22131
- reason: "unmerged-pr",
22132
- message: `'${input.branch}' has a pull request that never merged (${refs}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
22133
- };
22134
- }
22135
- return { action: "proceed" };
22173
+ var COMPOSE_TIMEOUT_MS = 12e4;
22174
+ function spawnDeferredGcSweep() {
22175
+ spawnDetachedSelf(["worktree", "gc", "sweep-deferred", "--quiet"], { spawn: import_node_child_process11.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
22136
22176
  }
22137
- async function fetchLandBranchPrs(branch, repo) {
22177
+ async function createDeferredWorktreeStore() {
22138
22178
  try {
22139
- const { stdout } = await execFileP2("gh", [
22140
- "pr",
22141
- "list",
22142
- "--head",
22143
- branch,
22144
- "--state",
22145
- "all",
22146
- "--limit",
22147
- "20",
22148
- ...repo ? ["--repo", repo] : [],
22149
- "--json",
22150
- "number,state"
22151
- ], { timeout: GH_TIMEOUT_MS });
22152
- const parsed = JSON.parse(stdout.trim() || "null");
22153
- if (!Array.isArray(parsed)) return void 0;
22154
- return parsed.filter((pr2) => Boolean(pr2) && typeof pr2 === "object" && typeof pr2.number === "number" && typeof pr2.state === "string").map((pr2) => ({ number: pr2.number, state: pr2.state }));
22179
+ const { stdout } = await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS });
22180
+ return makeDeferredWorktreeStore(deferredWorktreesRegistryPath(stdout.trim()));
22155
22181
  } catch {
22156
22182
  return void 0;
22157
22183
  }
22158
22184
  }
22159
- async function fetchIssueTitle(repo, number) {
22185
+ var realWorktreeDirRemover = {
22186
+ probe: (p) => {
22187
+ let st;
22188
+ try {
22189
+ st = (0, import_node_fs27.lstatSync)(p);
22190
+ } catch {
22191
+ return null;
22192
+ }
22193
+ if (st.isSymbolicLink()) return "link";
22194
+ try {
22195
+ (0, import_node_fs27.readlinkSync)(p);
22196
+ return "link";
22197
+ } catch {
22198
+ }
22199
+ return st.isDirectory() ? "dir" : "file";
22200
+ },
22201
+ readdir: (p) => {
22202
+ try {
22203
+ return (0, import_node_fs27.readdirSync)(p);
22204
+ } catch {
22205
+ return [];
22206
+ }
22207
+ },
22208
+ // A directory reparse point (junction / dir-symlink) is detached with rmdir (unlinks the mount point,
22209
+ // leaving the target); a file symlink with unlink. rmdir first, fall back to unlink.
22210
+ detachLink: (p) => {
22211
+ try {
22212
+ (0, import_node_fs27.rmdirSync)(p);
22213
+ } catch {
22214
+ (0, import_node_fs27.unlinkSync)(p);
22215
+ }
22216
+ },
22217
+ removeTree: (p) => (0, import_promises6.rm)(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
22218
+ };
22219
+ async function resolvePrimaryCheckout(execGit) {
22160
22220
  try {
22161
- const { stdout } = await execFileP2("gh", ["issue", "view", String(number), "--repo", repo, "--json", "title", "--jq", ".title"], { timeout: GH_TIMEOUT_MS });
22162
- const title = stdout.trim();
22163
- return title || void 0;
22221
+ return parseWorktreePorcelain(await execGit(["worktree", "list", "--porcelain"]))[0]?.path;
22164
22222
  } catch {
22165
22223
  return void 0;
22166
22224
  }
22167
22225
  }
22168
- async function selfShell(args, timeoutMs) {
22169
- const scriptPath = process.argv[1];
22170
- const { stdout } = await execFileP2(process.execPath, [scriptPath, ...args], { timeout: timeoutMs });
22171
- return stdout;
22226
+ function worktreeRemoveDeps(execGit) {
22227
+ return {
22228
+ git: execGit,
22229
+ sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
22230
+ // #3064: unlink any reparse point (esp. a `node_modules` junction to base) before the git-native
22231
+ // `worktree remove --force`, which would otherwise recurse through it and empty the base checkout.
22232
+ detachReparsePoints: (worktreePath) => detachReparsePoints(worktreePath, realWorktreeDirRemover),
22233
+ removeWorktreeDir: async (worktreePath) => removeWorktreeTree(worktreePath, await resolvePrimaryCheckout(execGit), realWorktreeDirRemover)
22234
+ };
22172
22235
  }
22173
- function registerWorktreeCommands(program3) {
22174
- const worktree2 = program3.commands.find((c) => c.name() === "worktree");
22175
- if (!worktree2) return;
22176
- const has = (name) => worktree2.commands.some((c) => c.name() === name);
22177
- if (has("land") && has("list")) return;
22178
- if (!has("land")) worktree2.command("land").description("post-merge clean: remove the current worktree, delete its local + origin branch, and stop its dev stage (dry-run by default)").option("--apply", "execute the cleanup (default: dry-run preview)").option("--dry-run", "show what would be removed without doing it (default)").option("--remote <name>", "remote whose branch is deleted", DEFAULT_REMOTE).option("--keep-remote", "do not delete the remote (origin) branch").option("--json", "machine-readable output").action(async (o) => {
22179
- if (o.apply && o.dryRun) return fail("worktree land: choose either --dry-run or --apply");
22180
- const apply = Boolean(o.apply);
22181
- try {
22182
- const wtPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS })).stdout.trim();
22183
- const branch = (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS })).stdout.trim();
22184
- if (!wtPath || !branch) return fail("worktree land: not inside a git worktree");
22185
- if (PROTECTED_BRANCHES2.has(branch)) {
22186
- return fail(`worktree land: refusing to land protected branch '${branch}'`, { code: ERROR_CODES.ERR_BAD_ENUM });
22187
- }
22188
- const gitFile = (0, import_node_path25.join)(wtPath, ".git");
22189
- const isLinked = (0, import_node_fs26.existsSync)(gitFile) && (0, import_node_fs26.statSync)(gitFile).isFile();
22190
- if (apply && !isLinked) {
22191
- return fail("worktree land: run from inside the linked worktree you want to land (this is the primary checkout)");
22192
- }
22193
- const commonDir = (await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
22194
- const primaryCheckout = commonDir ? (0, import_node_path25.dirname)(commonDir) : wtPath;
22195
- const stageSummary = readStageSummary(wtPath);
22196
- const hasStage = stageSummary != null;
22197
- const steps = landSteps(branch, true, hasStage);
22198
- const plan = {
22199
- command: "worktree land",
22200
- branch,
22201
- worktreePath: wtPath,
22202
- primaryCheckout,
22203
- remote: o.remote,
22204
- hasStage,
22205
- steps
22206
- };
22207
- if (!apply) {
22208
- const preview = { dryRun: true, ...plan, ...o.keepRemote ? { keepRemote: true } : {} };
22209
- if (o.json) return console.log(JSON.stringify(preview, null, 2));
22210
- const lines = [`worktree land: dry-run (pass --apply to execute)`, ` branch: ${branch}`, ` worktree: ${wtPath}`];
22211
- if (hasStage) lines.push(` stage: port ${stageSummary.port} (will be stopped)`);
22212
- if (o.keepRemote) lines.push(` remote branch: kept (--keep-remote)`);
22213
- for (const s of steps) lines.push(` - ${s}`);
22214
- return console.log(lines.join("\n"));
22215
- }
22216
- const landDirty = ((await execFileP2("git", ["-C", wtPath, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "unreadable" }))).stdout || "").trim().length > 0;
22217
- if (landDirty) {
22218
- return fail(`worktree land: refusing to land '${branch}' \u2014 uncommitted changes in ${wtPath}; commit, stash, or remove the worktree manually`, { code: ERROR_CODES.ERR_BAD_ENUM });
22219
- }
22220
- const landPrs = await fetchLandBranchPrs(branch);
22221
- const mergeVerdict = classifyLandBranchMergeState(landPrs);
22222
- const landRefs = decideWorktreeLandRefCleanup({
22223
- branch,
22224
- prs: landPrs,
22225
- keepRemote: Boolean(o.keepRemote)
22226
- });
22227
- if (landRefs.action === "refuse") {
22228
- return fail(`worktree land: ${landRefs.message}`, { code: ERROR_CODES.ERR_BAD_ENUM });
22229
- }
22230
- const keepRemoteBranch = Boolean(o.keepRemote) || landRefs.action === "keep-remote";
22231
- if (landRefs.action === "keep-remote") console.warn(`worktree land: ${landRefs.message}.`);
22232
- const report = [];
22233
- if (hasStage) {
22234
- try {
22235
- await stopStage({ cwd: wtPath, requiredIdentityCwd: wtPath });
22236
- report.push({ step: "stop stage", status: "stopped" });
22237
- } catch (e) {
22238
- report.push({ step: "stop stage", status: `failed: ${e.message.split("\n")[0]}` });
22239
- }
22240
- }
22241
- releaseCwdIfUnderWorktree(wtPath, primaryCheckout);
22242
- const removeOutcome = await removeWorktreeWithRecovery(
22243
- wtPath,
22244
- worktreeRemoveDeps(async (args) => (await execFileP2("git", ["-C", primaryCheckout, ...args], { timeout: GIT_TIMEOUT_MS })).stdout)
22245
- );
22246
- report.push({
22247
- step: "remove worktree",
22248
- status: removeOutcome.status === "removed" ? removeOutcome.recovery ? `done (${removeOutcome.recovery})` : "done" : `failed: ${removeOutcome.error ?? "lock held"} \u2014 run: git -C "${primaryCheckout}" worktree remove --force "${wtPath}"`
22249
- });
22250
- report.push(await bestEffortGit(["branch", "-D", branch], primaryCheckout, `delete local branch ${branch}`));
22251
- if (!keepRemoteBranch) {
22252
- report.push(await bestEffortGit(["push", o.remote, "--delete", branch], void 0, `delete remote branch ${branch}`, GH_MUTATION_TIMEOUT_MS));
22253
- } else if (!o.keepRemote) {
22254
- report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
22255
- }
22256
- report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
22257
- const result = {
22258
- dryRun: false,
22259
- ...plan,
22260
- ...o.keepRemote ? { keepRemote: true } : {},
22261
- // #3566: the merge outcome, machine-readable, so an orchestrator never has to infer it from the
22262
- // absence of an error. `merged` is the ONLY value that means the work reached the base branch.
22263
- mergeState: mergeVerdict.state,
22264
- prNumbers: mergeVerdict.numbers,
22265
- report
22266
- };
22267
- if (o.json) return console.log(JSON.stringify(result, null, 2));
22268
- console.log(`worktree land: cleaned up branch ${branch} (${describeLandMergeState(mergeVerdict)})`);
22269
- for (const r of report) console.log(` ${r.step}: ${r.status}`);
22270
- if (mergeVerdict.state !== "merged") {
22271
- console.warn(`worktree land: '${branch}' was cleaned up but NOT merged \u2014 ${describeLandMergeState(mergeVerdict)}. Do not report this work as shipped.`);
22272
- }
22273
- if (report.some((r) => r.status.startsWith("failed"))) process.exitCode = 1;
22274
- } catch (e) {
22275
- return failGraceful(`worktree land: ${e.message}`);
22236
+ async function worktreeHasStageState(worktreePath) {
22237
+ if (stageStateFileBelongsToWorktree(stageStatePath(worktreePath), worktreePath)) return true;
22238
+ try {
22239
+ const { stdout } = await execFileP2("git", ["-C", worktreePath, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS });
22240
+ const globalPath = stageGlobalStatePath(worktreePath, stdout.trim());
22241
+ return stageStateFileBelongsToWorktree(globalPath, worktreePath);
22242
+ } catch {
22243
+ return false;
22244
+ }
22245
+ }
22246
+ function stageStateFileBelongsToWorktree(statePath, worktreePath) {
22247
+ if (!(0, import_node_fs27.existsSync)(statePath)) return false;
22248
+ try {
22249
+ const state = JSON.parse((0, import_node_fs27.readFileSync)(statePath, "utf8"));
22250
+ const recordedCwd = typeof state.identity?.cwd === "string" ? state.identity.cwd : typeof state.cwd === "string" ? state.cwd : "";
22251
+ return Boolean(recordedCwd && isPathUnderDirectory(recordedCwd, worktreePath));
22252
+ } catch {
22253
+ return false;
22254
+ }
22255
+ }
22256
+ function teardownWorktreeStage(worktreePath) {
22257
+ return runWorktreeStageTeardown(worktreePath, {
22258
+ hasStageState: worktreeHasStageState,
22259
+ stopRecordedStage: async (wt) => (await stopStage({ cwd: wt, requiredIdentityCwd: wt })).pid,
22260
+ listComposeProjects: async () => {
22261
+ const { stdout } = await execFileP2("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS2 });
22262
+ return parseComposeLs(stdout);
22263
+ },
22264
+ composeDown: async (project2) => {
22265
+ await execFileP2("docker", ["compose", "-p", project2, "down", "-v"], { timeout: COMPOSE_TIMEOUT_MS });
22276
22266
  }
22277
22267
  });
22278
- if (!has("list")) worktree2.command("list").description("list worktrees; --stale surfaces orphaned worktrees/branches/running dev servers with remediation").option("--stale", "show only leaks (merged/closed branches, orphan dirs, stale dev servers)").option("--json", "machine-readable output").action(async (o) => {
22279
- try {
22280
- const ctx = await gatherWorktreeContext();
22281
- if (o.stale) {
22282
- const leaks = classifyStaleLeaks(ctx);
22283
- if (o.json) return console.log(JSON.stringify({ stale: leaks, count: leaks.length }, null, 2));
22284
- return console.log(formatStaleLeaks(leaks));
22285
- }
22286
- if (o.json) return console.log(JSON.stringify({ worktrees: ctx.worktrees }, null, 2));
22287
- if (!ctx.worktrees.length) return console.log("worktree list: no worktrees");
22288
- const lines = ["worktree list:"];
22289
- for (const wt of ctx.worktrees) {
22290
- const tag = wt.primary ? "primary" : "worktree";
22291
- lines.push(` ${wt.branch} @ ${wt.path} (${tag})`);
22292
- }
22293
- console.log(lines.join("\n"));
22294
- } catch (e) {
22295
- return failGraceful(`worktree list: ${e.message}`);
22268
+ }
22269
+
22270
+ // src/pr-checks-rest.ts
22271
+ var REST_GH_TIMEOUT_MS = 2e4;
22272
+ async function defaultGhApi(args) {
22273
+ const { stdout } = await execFileP2("gh", ["api", ...args], { timeout: REST_GH_TIMEOUT_MS });
22274
+ return stdout;
22275
+ }
22276
+ function classifyCheckRun(run) {
22277
+ if (run.status !== "completed") return "pending";
22278
+ switch (run.conclusion) {
22279
+ case "success":
22280
+ case "neutral":
22281
+ case "skipped":
22282
+ return "pass";
22283
+ case "failure":
22284
+ case "timed_out":
22285
+ case "cancelled":
22286
+ case "action_required":
22287
+ case "startup_failure":
22288
+ case "stale":
22289
+ return "fail";
22290
+ default:
22291
+ return "pending";
22292
+ }
22293
+ }
22294
+ function classifyCommitStatus(state) {
22295
+ if (state === "success") return "pass";
22296
+ if (state === "failure" || state === "error") return "fail";
22297
+ return "pending";
22298
+ }
22299
+ function dedupeLatestCheckRuns(runs) {
22300
+ const byIdentity = /* @__PURE__ */ new Map();
22301
+ const unkeyed = [];
22302
+ for (const run of runs) {
22303
+ if (!run.name || typeof run.id !== "number") {
22304
+ unkeyed.push(run);
22305
+ continue;
22296
22306
  }
22297
- });
22307
+ const key = `${run.app_id ?? "unknown-app"}:${run.name}`;
22308
+ const prior = byIdentity.get(key);
22309
+ if (!prior || run.id >= (prior.id ?? 0)) byIdentity.set(key, run);
22310
+ }
22311
+ return [...byIdentity.values(), ...unkeyed];
22298
22312
  }
22299
- async function gatherWorktreeContext() {
22300
- const repoRoot2 = await repoRootOf();
22301
- const porcelain = (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
22302
- const parsed = parseWorktreePorcelain(porcelain);
22303
- const primaryPath = parsed[0]?.path ?? repoRoot2;
22304
- const worktrees = parsed.map((w) => ({ path: toNativePath(w.path), branch: w.branch, primary: w.path === primaryPath }));
22305
- const branchOut = (await execFileP2("git", ["branch", "--format=%(refname:short)"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
22306
- const localBranches = branchOut.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
22307
- const currentBranch2 = (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || void 0;
22308
- const openPrBranches = /* @__PURE__ */ new Set();
22309
- const closedBranches = /* @__PURE__ */ new Set();
22313
+ function derivePollState(buckets) {
22314
+ if (!buckets.length) return "no-checks-reported";
22315
+ const anyFailure = buckets.includes("fail");
22316
+ const anyPending = buckets.includes("pending");
22317
+ if (anyFailure) return anyPending ? "failing" : "failure";
22318
+ return anyPending ? "pending" : "success";
22319
+ }
22320
+ function parseNdjsonLines(stdout) {
22321
+ return stdout.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line));
22322
+ }
22323
+ function parseRestPrSnapshot(json) {
22324
+ const pr2 = json ?? {};
22325
+ const headSha = pr2.head?.sha ?? "";
22326
+ if (!headSha) throw new Error("pulls endpoint returned no head SHA");
22327
+ const headRepo = pr2.head?.repo?.full_name;
22328
+ const baseRepo = pr2.base?.repo?.full_name;
22329
+ return {
22330
+ headSha,
22331
+ headRef: pr2.head?.ref ?? "",
22332
+ // Only call it a fork when BOTH names are known and differ — an absent name (private/deleted fork)
22333
+ // must not read as same-repo and send a git-ref probe at a branch the base repo does not have.
22334
+ headIsFork: Boolean(headRepo && baseRepo && headRepo !== baseRepo),
22335
+ mergeable: pr2.mergeable === true ? "MERGEABLE" : pr2.mergeable === false ? "CONFLICTING" : "UNKNOWN",
22336
+ baseRef: pr2.base?.ref ?? "development",
22337
+ merged: pr2.merged === true,
22338
+ state: pr2.state ?? ""
22339
+ };
22340
+ }
22341
+ async function fetchBranchTipSha(repo, ref, gh = defaultGhApi) {
22342
+ if (!ref) return null;
22310
22343
  try {
22311
- const { stdout } = await execFileP2("gh", ["pr", "list", "--state", "all", "--limit", "200", "--json", "headRefName,state"], { timeout: GH_TIMEOUT_MS });
22312
- const prs = JSON.parse(stdout || "[]");
22313
- const byBranch = /* @__PURE__ */ new Map();
22314
- for (const pr2 of prs) {
22315
- const arr = byBranch.get(pr2.headRefName) ?? [];
22316
- arr.push(pr2.state);
22317
- byBranch.set(pr2.headRefName, arr);
22318
- }
22319
- for (const [br, states] of byBranch) {
22320
- if (states.some((s) => s === "OPEN")) openPrBranches.add(br);
22321
- else if (states.some((s) => s === "MERGED" || s === "CLOSED")) closedBranches.add(br);
22322
- }
22344
+ const parsed = JSON.parse(await gh([`repos/${repo}/git/ref/heads/${ref}`]));
22345
+ const sha = parsed.object?.sha;
22346
+ return typeof sha === "string" && sha ? sha : null;
22323
22347
  } catch {
22348
+ return null;
22324
22349
  }
22325
- const stages = [];
22326
- for (const wt of worktrees) {
22327
- const s = readStageSummary(wt.path);
22328
- if (s) stages.push({ path: wt.path, port: s.port });
22329
- }
22330
- const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
22331
- const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path25.dirname)((0, import_node_path25.dirname)(worktreeGitRoot)) : repoRoot2;
22332
- const wtRoot = siblingMmiWorktreesRoot(primaryRepoRoot);
22333
- let orphanDirs = [];
22334
- if ((0, import_node_fs26.existsSync)(wtRoot)) {
22335
- orphanDirs = scanOrphanDirs(wtRoot, worktreeGitRoot, {
22336
- ...defaultOrphanDirScanDeps,
22337
- listDirs: (root) => worktreeScanDirs(root, primaryRepoRoot, defaultOrphanDirScanDeps.listDirs, isRepoCheckoutDir)
22338
- });
22339
- }
22340
- return { worktrees, localBranches, currentBranch: currentBranch2, openPrBranches, closedBranches, stages, orphanDirs };
22341
22350
  }
22342
- async function bestEffortGit(args, cwd, step, timeoutMs = GIT_TIMEOUT_MS) {
22351
+ async function fetchRestPrSnapshot(prNumber, repo, gh = defaultGhApi) {
22352
+ return parseRestPrSnapshot(JSON.parse(await gh([`repos/${repo}/pulls/${prNumber}`])));
22353
+ }
22354
+ async function fetchHeadCheckRuns(headSha, repo, gh) {
22355
+ const runsOut = await gh(["--paginate", `repos/${repo}/commits/${headSha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app_id: .app.id}"]);
22356
+ return dedupeLatestCheckRuns(parseNdjsonLines(runsOut));
22357
+ }
22358
+ async function fetchHeadCheckBuckets(headSha, repo, gh) {
22359
+ const [runs, statusesOut] = await Promise.all([
22360
+ fetchHeadCheckRuns(headSha, repo, gh),
22361
+ gh(["--paginate", `repos/${repo}/commits/${headSha}/status?per_page=100`, "--jq", ".statuses[] | {context, state}"])
22362
+ ]);
22363
+ const statuses = parseNdjsonLines(statusesOut);
22364
+ return [...runs.map(classifyCheckRun), ...statuses.map((s) => classifyCommitStatus(s.state))];
22365
+ }
22366
+ async function pollRestPrChecks(prNumber, repo, gh = defaultGhApi) {
22367
+ const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh);
22368
+ return derivePollState(await fetchHeadCheckBuckets(snapshot.headSha, repo, gh));
22369
+ }
22370
+ async function pollRestPrMergeable(prNumber, repo, gh = defaultGhApi) {
22343
22371
  try {
22344
- const fullArgs = cwd ? ["-C", cwd, ...args] : args;
22345
- await execFileP2("git", fullArgs, { timeout: timeoutMs });
22346
- return { step, status: "done" };
22347
- } catch (e) {
22348
- const msg = e.message.split("\n")[0];
22349
- if (/not found|already|no such|does not exist|not a valid|couldn't|unable to resolve/i.test(msg)) {
22350
- return { step, status: "already gone" };
22351
- }
22352
- return { step, status: `failed: ${msg}` };
22372
+ return (await fetchRestPrSnapshot(prNumber, repo, gh)).mergeable;
22373
+ } catch {
22374
+ return "UNKNOWN";
22353
22375
  }
22354
22376
  }
22355
-
22356
- // src/issue-commands.ts
22357
- var import_node_fs27 = require("node:fs");
22358
- var import_node_crypto5 = require("node:crypto");
22359
- var ghRunner = async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout;
22360
- async function editIssue(client, options, deps = {}) {
22361
- const parsed = parseIssueRef(options.ref);
22362
- const repo = parsed.repo ?? options.defaultRepo;
22363
- if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22364
- const url = `https://github.com/${repo}/issues/${parsed.number}`;
22365
- const patch = {};
22366
- let bodyChanged = false;
22367
- if (options.title !== void 0) patch.title = options.title;
22368
- if (options.body !== void 0 || options.bodyFile !== void 0) {
22369
- let body;
22370
- if (options.bodyFile) {
22371
- const td = deps.textDeps ?? { readFile: (p, e) => Promise.resolve((0, import_node_fs27.readFileSync)(p, e)), readStdin: () => Promise.resolve("") };
22372
- body = await resolveIssueBody({ body: options.body, bodyFile: options.bodyFile }, td);
22373
- } else {
22374
- body = options.body ?? "";
22375
- }
22376
- patch.body = body;
22377
- bodyChanged = true;
22378
- }
22379
- if (patch.title !== void 0 || patch.body !== void 0) {
22380
- await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: patch });
22381
- }
22382
- const labelsAdded = [];
22383
- const labelsRemoved = [];
22384
- if (options.addLabel?.length) {
22385
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/labels`, { body: { labels: options.addLabel } });
22386
- labelsAdded.push(...options.addLabel);
22377
+ async function pollRestPrMerged(prNumber, repo, gh = defaultGhApi) {
22378
+ try {
22379
+ return (await fetchRestPrSnapshot(prNumber, repo, gh)).merged;
22380
+ } catch {
22381
+ return false;
22387
22382
  }
22388
- for (const label of options.removeLabel ?? []) {
22389
- try {
22390
- await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/labels/${encodeURIComponent(label)}`);
22391
- labelsRemoved.push(label);
22392
- } catch (e) {
22393
- if (e instanceof GitHubApiError && e.status === 404) {
22394
- labelsRemoved.push(label);
22395
- } else {
22396
- throw e;
22397
- }
22398
- }
22383
+ }
22384
+ var CI_BUDGET_ANNOTATION_TITLE = "CI budget exceeded";
22385
+ var CI_DISK_ANNOTATION_TITLE = "CI runner disk";
22386
+ var CI_TOOLCHAIN_ANNOTATION_TITLE = "CI runner toolchain";
22387
+ var RUNNER_INFRA_ANNOTATION_TITLES = /* @__PURE__ */ new Set([
22388
+ CI_BUDGET_ANNOTATION_TITLE,
22389
+ CI_DISK_ANNOTATION_TITLE,
22390
+ CI_TOOLCHAIN_ANNOTATION_TITLE
22391
+ ]);
22392
+ function isErrorAnnotation(a) {
22393
+ const level = a.annotation_level?.toLowerCase();
22394
+ return level === "failure" || level === "error";
22395
+ }
22396
+ function classifyFailedChecks(failing) {
22397
+ const infraFailures = [];
22398
+ const otherFailures = [];
22399
+ for (const check of failing) {
22400
+ const errors = check.annotations.filter(isErrorAnnotation);
22401
+ const allInfra = errors.length > 0 && errors.every((a) => RUNNER_INFRA_ANNOTATION_TITLES.has(a.title?.trim() ?? ""));
22402
+ (allInfra ? infraFailures : otherFailures).push(check.name);
22399
22403
  }
22400
- let parentResult;
22401
- if (options.parent) {
22402
- const run = deps.runGh ?? ghRunner;
22403
- parentResult = await linkSubIssue(run, options.parent, options.ref, repo);
22404
+ if (infraFailures.length && !otherFailures.length) {
22405
+ return {
22406
+ cause: "runner-infra",
22407
+ infraFailures,
22408
+ otherFailures,
22409
+ reason: `${infraFailures.length} check(s) failed on RUNNER INFRASTRUCTURE, not your diff: ${infraFailures.join(", ")}. No test failed \u2014 the shared runner hit a wall-clock budget kill, ran out of disk, or was missing a toolchain (the check annotation names which). Re-run once the runner drains/heals (gh run rerun --failed); do not debug the diff.`
22410
+ };
22404
22411
  }
22405
22412
  return {
22406
- number: parsed.number,
22407
- repo,
22408
- url,
22409
- ...patch.title !== void 0 ? { title: patch.title } : {},
22410
- bodyChanged,
22411
- labelsAdded,
22412
- labelsRemoved,
22413
- ...parentResult ? { parent: parentResult } : {}
22413
+ cause: "checks-failure",
22414
+ infraFailures,
22415
+ otherFailures,
22416
+ reason: infraFailures.length ? `checks failed: ${otherFailures.join(", ")}; separately, ${infraFailures.join(", ")} failed on runner infrastructure, not a test.` : `checks failed: ${otherFailures.join(", ") || "unknown"}.`
22414
22417
  };
22415
22418
  }
22416
- async function closeIssue(client, options, deps = {}) {
22417
- const parsed = parseIssueRef(options.ref);
22418
- const repo = parsed.repo ?? options.defaultRepo;
22419
- if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22420
- const url = `https://github.com/${repo}/issues/${parsed.number}`;
22421
- const reason = options.reason ?? "completed";
22422
- const stateReason = reason === "duplicate-of" ? "not_planned" : reason === "not-planned" ? "not_planned" : "completed";
22423
- await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, {
22424
- body: { state: "closed", state_reason: stateReason }
22425
- });
22426
- if (reason === "duplicate-of" && options.duplicateOf) {
22427
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
22428
- body: { body: `Duplicate of #${options.duplicateOf}` }
22429
- });
22430
- }
22431
- if (options.comment) {
22432
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
22433
- body: { body: options.comment }
22434
- });
22435
- }
22436
- let board;
22419
+ async function diagnoseFailedRestChecks(prNumber, repo, gh = defaultGhApi) {
22437
22420
  try {
22438
- const cfg = await loadConfigForBoardSelector(options.ref, options.defaultRepo);
22439
- if (cfg.projectId) {
22440
- const result = await moveBoardItem({ config: cfg, selector: options.ref, status: "Done", repo, allowPartial: true }, deps);
22441
- board = { status: result.status, partial: result.partial };
22421
+ const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh);
22422
+ if (!snapshot.headIsFork) {
22423
+ const branchTip = await fetchBranchTipSha(repo, snapshot.headRef, gh);
22424
+ if (branchTip && branchTip !== snapshot.headSha) {
22425
+ return {
22426
+ cause: "stale-head",
22427
+ infraFailures: [],
22428
+ otherFailures: [],
22429
+ reason: `the PR head (${snapshot.headSha.slice(0, 7)}) is BEHIND the branch tip (${branchTip.slice(0, 7)}) \u2014 GitHub likely dropped a \`synchronize\` event, so the reported red is a superseded commit and NO run exists for your current push. This is not your diff failing. Re-push (an empty commit is enough) or close and reopen the PR to re-sync the head and trigger a fresh run.`
22430
+ };
22431
+ }
22442
22432
  }
22433
+ const failing = (await fetchHeadCheckRuns(snapshot.headSha, repo, gh)).filter((run) => classifyCheckRun(run) === "fail" && typeof run.id === "number");
22434
+ if (!failing.length) return null;
22435
+ const annotated = await Promise.all(failing.map(async (run) => ({
22436
+ name: run.name ?? `check-run ${run.id}`,
22437
+ annotations: JSON.parse(await gh([`repos/${repo}/check-runs/${run.id}/annotations`]))
22438
+ })));
22439
+ return classifyFailedChecks(annotated);
22443
22440
  } catch {
22441
+ return null;
22444
22442
  }
22445
- return { number: parsed.number, repo, url, state: "closed", stateReason, ...board ? { board } : {} };
22446
22443
  }
22447
- async function reopenIssue(client, ref, defaultRepo, deps = {}) {
22448
- const parsed = parseIssueRef(ref);
22449
- const repo = parsed.repo ?? defaultRepo;
22450
- if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22451
- const url = `https://github.com/${repo}/issues/${parsed.number}`;
22452
- await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: { state: "open" } });
22453
- let board;
22444
+ async function fetchRestCorePool(gh = defaultGhApi) {
22454
22445
  try {
22455
- const cfg = await loadConfigForBoardSelector(ref, defaultRepo);
22456
- if (cfg.projectId) {
22457
- const result = await moveBoardItem({ config: cfg, selector: ref, status: "Todo", repo, allowPartial: true }, deps);
22458
- board = { status: result.status, partial: result.partial };
22459
- }
22446
+ const parsed = JSON.parse(await gh(["rate_limit"]));
22447
+ const core = parsed.resources?.core;
22448
+ if (typeof core?.remaining !== "number" || typeof core?.reset !== "number") return null;
22449
+ return { remaining: core.remaining, reset: core.reset };
22460
22450
  } catch {
22451
+ return null;
22461
22452
  }
22462
- return { number: parsed.number, repo, url, state: "open", ...board ? { board } : {} };
22463
22453
  }
22464
- async function assignIssue(client, ref, login, defaultRepo) {
22465
- const parsed = parseIssueRef(ref);
22466
- const repo = parsed.repo ?? defaultRepo;
22467
- if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22468
- await client.rest("POST", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
22469
- return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "assigned" };
22454
+
22455
+ // src/worktree-lifecycle-commands.ts
22456
+ var import_node_fs28 = require("node:fs");
22457
+ var import_node_path27 = require("node:path");
22458
+ var GH_TIMEOUT_MS = 2e4;
22459
+ var DEFAULT_BASE = "origin/development";
22460
+ var DEFAULT_REMOTE = "origin";
22461
+ var PROTECTED_BRANCHES2 = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
22462
+ function slugifyIssueTitle(title) {
22463
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
22464
+ return slug.slice(0, 50);
22470
22465
  }
22471
- async function unassignIssue(client, ref, login, defaultRepo) {
22472
- const parsed = parseIssueRef(ref);
22473
- const repo = parsed.repo ?? defaultRepo;
22474
- if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22475
- await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
22476
- return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "unassigned" };
22466
+ function buildNewBranchName(issueNumber, slug) {
22467
+ const clean4 = slug.replace(/^[-]+|[-]+$/g, "");
22468
+ return clean4 ? `${issueNumber}-${clean4}` : String(issueNumber);
22477
22469
  }
22478
- var TRANSFER_ISSUE_MUTATION = `
22479
- mutation($issueId: ID!, $repositoryId: ID!) {
22480
- transferIssue(input: {issueId: $issueId, repositoryId: $repositoryId}) {
22481
- issue {
22482
- id
22483
- number
22484
- url
22485
- repository { nameWithOwner }
22486
- }
22470
+ function isIssueRef(target) {
22471
+ return /^#?\d+$/.test(target) || /^[\w.-]+\/[\w.-]+#\d+$/.test(target);
22472
+ }
22473
+ function worktreeCreatePlan(opts, args) {
22474
+ const target = String(args[0] ?? "");
22475
+ return {
22476
+ command: "worktree create",
22477
+ target,
22478
+ issueForm: isIssueRef(target),
22479
+ claim: Boolean(opts.claim),
22480
+ slug: opts.slug || void 0,
22481
+ from: opts.base || opts.from || DEFAULT_BASE,
22482
+ remote: opts.remote || DEFAULT_REMOTE,
22483
+ repo: opts.repo || void 0,
22484
+ assignee: opts.for || void 0
22485
+ };
22486
+ }
22487
+ function landSteps(branch, hasWorktree, hasStage) {
22488
+ const steps = [];
22489
+ if (hasStage) steps.push("stop spawned dev stage");
22490
+ if (hasWorktree) steps.push(`remove worktree (branch ${branch})`);
22491
+ steps.push(`delete local branch ${branch}`);
22492
+ steps.push(`delete remote branch ${branch}`);
22493
+ steps.push("prune worktree metadata");
22494
+ return steps;
22495
+ }
22496
+ function classifyStaleLeaks(input) {
22497
+ const protectedBranches = input.protectedBranches ?? PROTECTED_BRANCHES2;
22498
+ const leaks = [];
22499
+ const worktreeBranches2 = new Set(input.worktrees.filter((w) => !w.primary).map((w) => w.branch));
22500
+ const stageByPath = new Map(input.stages.map((s) => [s.path, s.port]));
22501
+ for (const wt of input.worktrees) {
22502
+ if (wt.primary) continue;
22503
+ if (protectedBranches.has(wt.branch)) continue;
22504
+ if (input.openPrBranches.has(wt.branch)) continue;
22505
+ if (!input.closedBranches.has(wt.branch)) continue;
22506
+ leaks.push({
22507
+ kind: "stale-worktree",
22508
+ ref: wt.branch,
22509
+ detail: `merged/closed branch ${wt.branch} still checked out at ${wt.path}`,
22510
+ remediation: `cd "${wt.path}" && mmi-cli worktree land --apply (or: mmi-cli worktree gc --apply)`
22511
+ });
22487
22512
  }
22488
- }`;
22489
- var ADD_PROJECT_ITEM_MUTATION = `
22490
- mutation($projectId: ID!, $contentId: ID!) {
22491
- addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
22492
- item { id }
22513
+ for (const branch of input.localBranches) {
22514
+ if (protectedBranches.has(branch)) continue;
22515
+ if (branch === input.currentBranch) continue;
22516
+ if (input.openPrBranches.has(branch)) continue;
22517
+ if (!input.closedBranches.has(branch)) continue;
22518
+ if (worktreeBranches2.has(branch)) continue;
22519
+ leaks.push({
22520
+ kind: "stale-branch",
22521
+ ref: branch,
22522
+ detail: `merged/closed branch ${branch} has no worktree`,
22523
+ remediation: "mmi-cli worktree gc --apply"
22524
+ });
22493
22525
  }
22494
- }`;
22495
- async function relocateIssue(client, ref, toRepo, defaultRepo, deps = {}) {
22496
- const parsed = parseIssueRef(ref);
22497
- const sourceRepo = parsed.repo ?? defaultRepo;
22498
- if (!sourceRepo) throw new Error("could not resolve the source repo \u2014 pass owner/repo#123 or use --repo");
22499
- let sourceItemId;
22500
- let priority;
22501
- try {
22502
- const sourceCfg = await loadConfigForBoardSelector(ref, defaultRepo);
22503
- if (sourceCfg.projectId) {
22504
- const resolved = resolveBoardConfig(sourceCfg);
22505
- const lookup = await fetchIssueProjectItem(client, resolved, { repo: sourceRepo, number: parsed.number });
22506
- sourceItemId = lookup.item?.itemId;
22507
- priority = lookup.item?.priority;
22508
- }
22509
- } catch {
22526
+ for (const wt of input.worktrees) {
22527
+ if (wt.primary) continue;
22528
+ const port = stageByPath.get(wt.path);
22529
+ if (port == null) continue;
22530
+ if (input.openPrBranches.has(wt.branch)) continue;
22531
+ if (protectedBranches.has(wt.branch) && !input.closedBranches.has(wt.branch)) continue;
22532
+ if (!input.closedBranches.has(wt.branch)) continue;
22533
+ leaks.push({
22534
+ kind: "stale-stage",
22535
+ ref: wt.branch,
22536
+ detail: `dev stage on port ${port} still running in ${wt.path} (branch ${wt.branch} merged/closed)`,
22537
+ remediation: `cd "${wt.path}" && mmi-cli stage stop --apply`
22538
+ });
22510
22539
  }
22511
- const sourceIssue = await client.rest("GET", `repos/${sourceRepo}/issues/${parsed.number}`);
22512
- const issueNodeId = sourceIssue?.node_id;
22513
- if (!issueNodeId) throw new Error(`could not resolve node id for ${sourceRepo}#${parsed.number}`);
22514
- const targetRepoData = await client.rest("GET", `repos/${toRepo}`);
22515
- const targetRepoNodeId = targetRepoData?.node_id;
22516
- if (!targetRepoNodeId) throw new Error(`could not resolve node id for repo ${toRepo}`);
22517
- const transferred = await client.graphql(
22518
- TRANSFER_ISSUE_MUTATION,
22519
- { issueId: issueNodeId, repositoryId: targetRepoNodeId }
22520
- );
22521
- const newIssue = transferred?.transferIssue?.issue;
22522
- if (!newIssue?.id || !newIssue.number || !newIssue.url) {
22523
- throw new Error("transferIssue returned an unexpected response (no issue id/number/url)");
22540
+ const knownPaths = new Set(input.worktrees.map((w) => w.path));
22541
+ for (const dir of input.orphanDirs) {
22542
+ if (knownPaths.has(dir.path)) continue;
22543
+ leaks.push({
22544
+ kind: "orphan-dir",
22545
+ ref: dir.path,
22546
+ // Say only what the inspection proved. `orphaned-folder` proves the `.git` entry is gone while this
22547
+ // repo's worktree metadata still points at the directory — NOT that the directory is empty; gc
22548
+ // checks contents separately, at apply time.
22549
+ detail: dir.reason === "dangling-gitdir" ? "this repo's worktree directory whose git metadata is gone" : "this repo's worktree metadata points here, but the .git entry is gone",
22550
+ // gc --apply re-proves ownership before deleting anything; a raw `rm -rf` does not, so it is never
22551
+ // offered here following it on a mis-classified dir would destroy another repo's live worktree.
22552
+ remediation: "mmi-cli worktree gc --apply"
22553
+ });
22524
22554
  }
22525
- const newRepo = newIssue.repository?.nameWithOwner ?? toRepo;
22526
- const newNumber = newIssue.number;
22527
- const newUrl = newIssue.url;
22528
- const newNodeId = newIssue.id;
22529
- let swept = false;
22530
- if (sourceItemId) {
22555
+ return leaks;
22556
+ }
22557
+ var defaultOrphanDirScanDeps = {
22558
+ listDirs: (root) => {
22531
22559
  try {
22532
- const sourceCfg = await loadConfigForBoardSelector(ref, defaultRepo);
22533
- if (sourceCfg.projectId) {
22534
- const resolved = resolveBoardConfig(sourceCfg);
22535
- const DELETE_MUTATION = `mutation($projectId: ID!, $itemId: ID!) { deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) { deletedItemId } }`;
22536
- await client.graphql(DELETE_MUTATION, { projectId: resolved.projectId, itemId: sourceItemId });
22537
- swept = true;
22538
- }
22560
+ return (0, import_node_fs28.readdirSync)(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => (0, import_node_path27.join)(root, e.name));
22539
22561
  } catch {
22562
+ return [];
22540
22563
  }
22564
+ },
22565
+ inspect: inspectSiblingWorktreeDir
22566
+ };
22567
+ function scanOrphanDirs(worktreesRoot, worktreeGitRoot, deps = defaultOrphanDirScanDeps) {
22568
+ const candidates = [];
22569
+ for (const dir of deps.listDirs(worktreesRoot)) {
22570
+ const { cleanup } = classifySiblingWorktreeDir(deps.inspect(dir, worktreeGitRoot));
22571
+ if (cleanup) candidates.push({ path: dir, reason: cleanup.reason });
22541
22572
  }
22542
- let added = false;
22543
- let carriedPriority;
22544
- try {
22545
- const destCfg = await loadConfigForBoardSelector(`${newRepo}#${newNumber}`, newRepo);
22546
- if (destCfg.projectId) {
22547
- const addResult = await client.graphql(
22548
- ADD_PROJECT_ITEM_MUTATION,
22549
- { projectId: destCfg.projectId, contentId: newNodeId }
22550
- );
22551
- const newItemId = addResult?.addProjectV2ItemById?.item?.id;
22552
- if (newItemId && priority) {
22553
- try {
22554
- const p = priority.toLowerCase();
22555
- if (CLI_PRIORITIES.includes(p)) {
22556
- await setBoardItemPriority(client, destCfg, newItemId, p);
22557
- carriedPriority = priority;
22558
- }
22559
- } catch {
22560
- }
22561
- }
22562
- added = true;
22563
- }
22564
- } catch {
22573
+ return candidates;
22574
+ }
22575
+ function formatStaleLeaks(leaks) {
22576
+ if (!leaks.length) return "worktree list --stale: no leaks found";
22577
+ const lines = [`worktree list --stale: ${leaks.length} leak(s)`];
22578
+ for (const leak of leaks) {
22579
+ lines.push(` [${leak.kind}] ${leak.ref} \u2014 ${leak.detail}`);
22580
+ lines.push(` fix: ${leak.remediation}`);
22565
22581
  }
22566
- return {
22567
- number: newNumber,
22568
- repo: newRepo,
22569
- url: newUrl,
22570
- previousRepo: sourceRepo,
22571
- previousNumber: parsed.number,
22572
- board: { swept, added, ...carriedPriority ? { priority: carriedPriority } : {} }
22573
- };
22582
+ return lines.join("\n");
22574
22583
  }
22575
- function idempotencyMarker(key) {
22576
- return `mmi-idempotency:${key}`;
22584
+ async function repoRootOf() {
22585
+ return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
22577
22586
  }
22578
- function appendIdempotencyMarker(body, key) {
22579
- const marker = `<!-- ${idempotencyMarker(key)} -->`;
22580
- if (body.includes(marker)) return body;
22581
- return `${body.replace(/\s+$/, "")}
22582
-
22583
- ${marker}
22584
- `;
22587
+ function classifyLandBranchMergeState(prs) {
22588
+ if (!prs) return { state: "unknown", numbers: [] };
22589
+ if (!prs.length) return { state: "no-pr", numbers: [] };
22590
+ const pick = (s) => prs.filter((pr2) => pr2.state.toUpperCase() === s).map((pr2) => pr2.number);
22591
+ const open2 = pick("OPEN");
22592
+ if (open2.length) return { state: "open", numbers: open2 };
22593
+ const known = /* @__PURE__ */ new Set(["OPEN", "CLOSED", "MERGED"]);
22594
+ const unrecognised = prs.filter((pr2) => !known.has(pr2.state.toUpperCase()));
22595
+ if (unrecognised.length) return { state: "unknown", numbers: prs.map((pr2) => pr2.number) };
22596
+ const merged = pick("MERGED");
22597
+ if (!merged.length) return { state: "closed-unmerged", numbers: prs.map((pr2) => pr2.number) };
22598
+ if (merged.length < prs.length) return { state: "mixed", numbers: prs.map((pr2) => pr2.number) };
22599
+ return { state: "merged", numbers: merged };
22585
22600
  }
22586
- function rowIdempotencyKey(batchKey, spec) {
22587
- const identity = `${spec.type}
22588
- ${spec.title.trim()}
22589
- ${spec.body ?? ""}`;
22590
- const hash = (0, import_node_crypto5.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
22591
- return `${batchKey}:${hash}`;
22601
+ function describeLandMergeState(v) {
22602
+ const refs = v.numbers.map((n) => `#${n}`).join(", ");
22603
+ switch (v.state) {
22604
+ case "merged":
22605
+ return `PR ${refs} merged`;
22606
+ case "closed-unmerged":
22607
+ return `PR ${refs} CLOSED \u2014 NOT merged`;
22608
+ case "mixed":
22609
+ return `PR ${refs} \u2014 some merged, some CLOSED unmerged; which one owns the branch head is unproven`;
22610
+ case "open":
22611
+ return `PR ${refs} still OPEN \u2014 NOT merged`;
22612
+ case "no-pr":
22613
+ return "no pull request for this branch";
22614
+ case "unknown":
22615
+ return refs ? `PR ${refs} reported a pull-request state this CLI does not recognise \u2014 merge NOT confirmed` : "pull-request state could not be read \u2014 merge NOT confirmed";
22616
+ }
22592
22617
  }
22593
- var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "parent", "repo"]);
22594
- function validateBatchSpecs(specs) {
22595
- const errors = [];
22596
- const validated = [];
22597
- const validTypes = ["bug", "feature", "task"];
22598
- for (let i = 0; i < specs.length; i += 1) {
22599
- const spec = specs[i];
22600
- const row = i + 1;
22601
- if (!spec || typeof spec !== "object") {
22602
- errors.push({ row, error: "not an object" });
22603
- continue;
22604
- }
22605
- const unknown = Object.keys(spec).filter((k) => !BATCH_SPEC_KEYS.has(k));
22606
- if (unknown.length) {
22607
- errors.push({ row, error: `unknown key(s): ${unknown.join(", ")} \u2014 expected only: ${[...BATCH_SPEC_KEYS].join(", ")}` });
22608
- continue;
22609
- }
22610
- if (spec.repo !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(spec.repo)) {
22611
- errors.push({ row, error: `bad repo "${spec.repo}" \u2014 expected owner/repo` });
22612
- continue;
22613
- }
22614
- if (!validTypes.includes(spec.type)) {
22615
- errors.push({ row, error: `unknown type "${spec.type}" \u2014 expected one of: ${validTypes.join(", ")}` });
22616
- continue;
22617
- }
22618
- if (!spec.title || !spec.title.trim()) {
22619
- errors.push({ row, error: "missing or empty title" });
22620
- continue;
22621
- }
22622
- let priority;
22623
- try {
22624
- priority = spec.priority ? normalizePriority(spec.priority) : "medium";
22625
- } catch (e) {
22626
- errors.push({ row, error: e.message });
22627
- continue;
22628
- }
22629
- validated.push({ row, spec, priority, type: spec.type });
22618
+ function decideWorktreeLandRefCleanup(input) {
22619
+ if (input.keepRemote) return { action: "proceed" };
22620
+ const verdict = classifyLandBranchMergeState(input.prs);
22621
+ const refs = verdict.numbers.map((n) => `#${n}`).join(", ");
22622
+ if (verdict.state === "unknown") {
22623
+ return verdict.numbers.length ? {
22624
+ action: "keep-remote",
22625
+ reason: "pr-state-unrecognised",
22626
+ message: `pull request ${refs} for '${input.branch}' reported a state this CLI does not recognise \u2014 keeping the remote branch rather than deleting a ref whose merge is unproven`
22627
+ } : {
22628
+ action: "keep-remote",
22629
+ reason: "pr-state-unreadable",
22630
+ message: `could not read the pull-request state for '${input.branch}' \u2014 keeping the remote branch so an unmerged head ref cannot be deleted on a guess`
22631
+ };
22632
+ }
22633
+ if (verdict.state === "open") {
22634
+ return {
22635
+ action: "refuse",
22636
+ reason: "open-pr",
22637
+ message: `'${input.branch}' still has an OPEN pull request (${refs}). Deleting its head ref would auto-close the PR and leave the work on no branch. Merge it first, or re-run with --keep-remote for a local-only clean`
22638
+ };
22639
+ }
22640
+ if (verdict.state === "mixed") {
22641
+ return {
22642
+ action: "keep-remote",
22643
+ reason: "mixed-pr-state",
22644
+ message: `'${input.branch}' has both a merged and a closed-unmerged pull request (${refs}) and nothing here proves which owns the current head \u2014 keeping the remote branch rather than deleting a ref that may hold unmerged work`
22645
+ };
22630
22646
  }
22631
- return { ok: errors.length === 0, errors, validated };
22632
- }
22633
- async function createIssuesBatch(specs, options, deps = {}) {
22634
- const ensureLabels = deps.ensureLabels ?? ensureLabelsExist;
22635
- const client = deps.client ?? defaultGitHubClient();
22636
- const runCreate = deps.runCreate ?? (async (args) => (await execFileP2("gh", args, { timeout: GH_MUTATION_TIMEOUT_MS })).stdout);
22637
- const validation = validateBatchSpecs(specs);
22638
- if (!validation.ok) {
22639
- const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
22640
- throw new Error(`batch validation failed (${validation.errors.length} error(s)):
22641
- ${lines}`);
22647
+ if (verdict.state === "closed-unmerged") {
22648
+ return {
22649
+ action: "refuse",
22650
+ reason: "unmerged-pr",
22651
+ message: `'${input.branch}' has a pull request that never merged (${refs}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
22652
+ };
22642
22653
  }
22643
- const defaultRepo = await resolveRepo(options.repo);
22644
- if (!defaultRepo && validation.validated.some((v) => !v.spec.repo)) {
22645
- throw new Error("could not resolve repo \u2014 pass --repo owner/repo or set repo per row");
22654
+ return { action: "proceed" };
22655
+ }
22656
+ async function fetchLandBranchPrs(branch, repo) {
22657
+ try {
22658
+ const { stdout } = await execFileP2("gh", [
22659
+ "pr",
22660
+ "list",
22661
+ "--head",
22662
+ branch,
22663
+ "--state",
22664
+ "all",
22665
+ "--limit",
22666
+ "20",
22667
+ ...repo ? ["--repo", repo] : [],
22668
+ "--json",
22669
+ "number,state"
22670
+ ], { timeout: GH_TIMEOUT_MS });
22671
+ const parsed = JSON.parse(stdout.trim() || "null");
22672
+ if (!Array.isArray(parsed)) return void 0;
22673
+ return parsed.filter((pr2) => Boolean(pr2) && typeof pr2 === "object" && typeof pr2.number === "number" && typeof pr2.state === "string").map((pr2) => ({ number: pr2.number, state: pr2.state }));
22674
+ } catch {
22675
+ return void 0;
22646
22676
  }
22647
- const rowRepo = (spec) => spec.repo ?? defaultRepo;
22648
- const labelsByRepo = /* @__PURE__ */ new Map();
22649
- for (const { spec } of validation.validated) {
22650
- if (!spec.labels?.length) continue;
22651
- const bucket = labelsByRepo.get(rowRepo(spec)) ?? /* @__PURE__ */ new Set();
22652
- for (const label of spec.labels) bucket.add(label);
22653
- labelsByRepo.set(rowRepo(spec), bucket);
22677
+ }
22678
+ async function fetchIssueTitle(repo, number) {
22679
+ try {
22680
+ const { stdout } = await execFileP2("gh", ["issue", "view", String(number), "--repo", repo, "--json", "title", "--jq", ".title"], { timeout: GH_TIMEOUT_MS });
22681
+ const title = stdout.trim();
22682
+ return title || void 0;
22683
+ } catch {
22684
+ return void 0;
22654
22685
  }
22655
- for (const [repo, labels] of labelsByRepo) await ensureLabels([...labels], repo);
22656
- const created = [];
22657
- const failures = [];
22658
- for (const entry of validation.validated) {
22659
- const { spec, priority, type } = entry;
22686
+ }
22687
+ async function selfShell(args, timeoutMs) {
22688
+ const scriptPath = process.argv[1];
22689
+ const { stdout } = await execFileP2(process.execPath, [scriptPath, ...args], { timeout: timeoutMs });
22690
+ return stdout;
22691
+ }
22692
+ function registerWorktreeCommands(program3) {
22693
+ const worktree2 = program3.commands.find((c) => c.name() === "worktree");
22694
+ if (!worktree2) return;
22695
+ const has = (name) => worktree2.commands.some((c) => c.name() === name);
22696
+ if (has("land") && has("list")) return;
22697
+ if (!has("land")) worktree2.command("land").description("post-merge clean: remove the current worktree, delete its local + origin branch, and stop its dev stage (dry-run by default)").option("--apply", "execute the cleanup (default: dry-run preview)").option("--dry-run", "show what would be removed without doing it (default)").option("--remote <name>", "remote whose branch is deleted", DEFAULT_REMOTE).option("--keep-remote", "do not delete the remote (origin) branch").option("--json", "machine-readable output").action(async (o) => {
22698
+ if (o.apply && o.dryRun) return fail("worktree land: choose either --dry-run or --apply");
22699
+ const apply = Boolean(o.apply);
22660
22700
  try {
22661
- const rowKey = options.idempotencyKey ? rowIdempotencyKey(options.idempotencyKey, spec) : void 0;
22662
- if (rowKey) {
22663
- const existing = await findExistingByIdempotencyKey(client, rowKey, rowRepo(spec));
22664
- if (existing) {
22665
- created.push({ row: entry.row, ...existing, idempotent: true });
22666
- continue;
22667
- }
22701
+ const wtPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS })).stdout.trim();
22702
+ const branch = (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS })).stdout.trim();
22703
+ if (!wtPath || !branch) return fail("worktree land: not inside a git worktree");
22704
+ if (PROTECTED_BRANCHES2.has(branch)) {
22705
+ return fail(`worktree land: refusing to land protected branch '${branch}'`, { code: ERROR_CODES.ERR_BAD_ENUM });
22668
22706
  }
22669
- let body = spec.body ?? "";
22670
- if (rowKey) {
22671
- body = appendIdempotencyMarker(body, rowKey);
22707
+ const gitFile = (0, import_node_path27.join)(wtPath, ".git");
22708
+ const isLinked = (0, import_node_fs28.existsSync)(gitFile) && (0, import_node_fs28.statSync)(gitFile).isFile();
22709
+ if (apply && !isLinked) {
22710
+ return fail("worktree land: run from inside the linked worktree you want to land (this is the primary checkout)");
22672
22711
  }
22673
- const rowPriority = spec.priority ? priority : options.priority ?? priority;
22674
- const args = buildIssueArgs({ type, title: spec.title, body, priority: rowPriority, repo: rowRepo(spec), labels: spec.labels });
22675
- const swapped = await bodyArgsViaFile(args);
22676
- let row;
22677
- try {
22678
- const stdout = await runCreate(swapped.args);
22679
- const result = parseCreatedUrl(stdout);
22680
- row = { row: entry.row, number: result.number, url: result.url };
22681
- created.push(row);
22682
- } finally {
22683
- await swapped.cleanup();
22712
+ const commonDir = (await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
22713
+ const primaryCheckout = commonDir ? (0, import_node_path27.dirname)(commonDir) : wtPath;
22714
+ const stageSummary = readStageSummary(wtPath);
22715
+ const hasStage = stageSummary != null;
22716
+ const steps = landSteps(branch, true, hasStage);
22717
+ const plan = {
22718
+ command: "worktree land",
22719
+ branch,
22720
+ worktreePath: wtPath,
22721
+ primaryCheckout,
22722
+ remote: o.remote,
22723
+ hasStage,
22724
+ steps
22725
+ };
22726
+ if (!apply) {
22727
+ const preview = { dryRun: true, ...plan, ...o.keepRemote ? { keepRemote: true } : {} };
22728
+ if (o.json) return console.log(JSON.stringify(preview, null, 2));
22729
+ const lines = [`worktree land: dry-run (pass --apply to execute)`, ` branch: ${branch}`, ` worktree: ${wtPath}`];
22730
+ if (hasStage) lines.push(` stage: port ${stageSummary.port} (will be stopped)`);
22731
+ if (o.keepRemote) lines.push(` remote branch: kept (--keep-remote)`);
22732
+ for (const s of steps) lines.push(` - ${s}`);
22733
+ return console.log(lines.join("\n"));
22684
22734
  }
22685
- if (deps.attach) {
22686
- const attached = await deps.attach(row.number, rowRepo(spec), rowPriority);
22687
- row.onBoard = attached.onBoard;
22688
- if (attached.projectItemId) row.projectItemId = attached.projectItemId;
22735
+ const landDirty = ((await execFileP2("git", ["-C", wtPath, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "unreadable" }))).stdout || "").trim().length > 0;
22736
+ if (landDirty) {
22737
+ return fail(`worktree land: refusing to land '${branch}' \u2014 uncommitted changes in ${wtPath}; commit, stash, or remove the worktree manually`, { code: ERROR_CODES.ERR_BAD_ENUM });
22689
22738
  }
22690
- const parentRef = spec.parent ?? options.parent;
22691
- if (parentRef) {
22739
+ const landPrs = await fetchLandBranchPrs(branch);
22740
+ const mergeVerdict = classifyLandBranchMergeState(landPrs);
22741
+ const landRefs = decideWorktreeLandRefCleanup({
22742
+ branch,
22743
+ prs: landPrs,
22744
+ keepRemote: Boolean(o.keepRemote)
22745
+ });
22746
+ if (landRefs.action === "refuse") {
22747
+ return fail(`worktree land: ${landRefs.message}`, { code: ERROR_CODES.ERR_BAD_ENUM });
22748
+ }
22749
+ const keepRemoteBranch = Boolean(o.keepRemote) || landRefs.action === "keep-remote";
22750
+ if (landRefs.action === "keep-remote") console.warn(`worktree land: ${landRefs.message}.`);
22751
+ const landOwner = lookupWorktreeOwner(primaryCheckout, wtPath);
22752
+ const report = [];
22753
+ if (hasStage) {
22692
22754
  try {
22693
- const run = deps.runGh ?? ghRunner;
22694
- row.parent = await linkSubIssue(run, parentRef, row.url, rowRepo(spec));
22755
+ await stopStage({ cwd: wtPath, requiredIdentityCwd: wtPath });
22756
+ report.push({ step: "stop stage", status: "stopped" });
22695
22757
  } catch (e) {
22696
- const err = e;
22697
- row.parentLinkError = (err.stderr || err.message || String(e)).trim();
22698
- process.stderr.write(`warning: issue #${row.number} created but NOT linked under ${parentRef}: ${row.parentLinkError}
22699
- `);
22758
+ report.push({ step: "stop stage", status: `failed: ${e.message.split("\n")[0]}` });
22700
22759
  }
22701
22760
  }
22702
- } catch (e) {
22703
- const err = e;
22704
- const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
22705
- failures.push({ row: entry.row, error: (err.stderr || err.message || String(e)).trim() + (note ? ` (${note})` : "") });
22706
- }
22707
- }
22708
- return { created, failures };
22709
- }
22710
- async function findExistingByIdempotencyKey(client, key, repo) {
22711
- const marker = idempotencyMarker(key);
22712
- try {
22713
- const result = await client.rest(
22714
- "GET",
22715
- `search/issues?per_page=5&q=${encodeURIComponent(`repo:${repo} "${marker}" in:body`)}`
22716
- );
22717
- const open2 = result?.items?.find((item) => item.state === "open" && item.number);
22718
- if (!open2?.number) return void 0;
22719
- return { number: open2.number, url: open2.html_url ?? `https://github.com/${repo}/issues/${open2.number}` };
22720
- } catch {
22721
- return void 0;
22722
- }
22723
- }
22724
- function parseCloseReason(raw) {
22725
- if (raw === void 0) return {};
22726
- const trimmed = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
22727
- if (trimmed === "completed" || trimmed === "complete") return { reason: "completed" };
22728
- if (trimmed === "notplanned" || trimmed === "notplanned") return { reason: "not-planned" };
22729
- if (trimmed === "duplicateof" || trimmed === "duplicate") return { reason: "duplicate-of" };
22730
- throw new Error(`unknown close reason "${raw}" \u2014 expected completed, not-planned, or duplicate-of`);
22731
- }
22732
- function registerIssueLifecycleCommands(program3, deps = {}) {
22733
- const issue2 = program3.commands.find((c) => c.name() === "issue");
22734
- if (!issue2) return;
22735
- mutating(
22736
- issue2.command("edit <ref>").description("edit an issue title/body/labels or reparent it (--parent) \u2014 reparenting is impossible via raw gh").option("--title <title>", "new title").option("--body <body>", "new body (markdown)").option("--body-file <path|->", "read new body from a UTF-8 file, or stdin with -").option("--add-label <label...>", "label(s) to add (repeatable)").option("--remove-label <label...>", "label(s) to remove (repeatable)").option("--parent <ref>", "reparent: link under this parent (#123, owner/repo#123, or URL)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
22737
- (opts, args) => ({ command: "issue edit", ref: args[0], fields: Object.keys(opts).filter((k) => k !== "repo" && opts[k] !== void 0) })
22738
- ).action(async (ref, o) => {
22739
- try {
22740
- const defaultRepo = await resolveRepo(o.repo);
22741
- const result = await editIssue(defaultGitHubClient(), {
22742
- ref,
22743
- defaultRepo,
22744
- title: o.title,
22745
- body: o.body,
22746
- bodyFile: o.bodyFile,
22747
- addLabel: o.addLabel,
22748
- removeLabel: o.removeLabel,
22749
- parent: o.parent
22761
+ releaseCwdIfUnderWorktree(wtPath, primaryCheckout);
22762
+ const removeOutcome = await removeWorktreeWithRecovery(
22763
+ wtPath,
22764
+ worktreeRemoveDeps(async (args) => (await execFileP2("git", ["-C", primaryCheckout, ...args], { timeout: GIT_TIMEOUT_MS })).stdout)
22765
+ );
22766
+ report.push({
22767
+ step: "remove worktree",
22768
+ status: removeOutcome.status === "removed" ? removeOutcome.recovery ? `done (${removeOutcome.recovery})` : "done" : `failed: ${removeOutcome.error ?? "lock held"} \u2014 run: git -C "${primaryCheckout}" worktree remove --force "${wtPath}"`
22750
22769
  });
22751
- console.log(JSON.stringify(result));
22752
- } catch (e) {
22753
- return failGraceful(`issue edit failed: ${e.message}`);
22754
- }
22755
- });
22756
- mutating(
22757
- issue2.command("close <ref>").description("close an issue and move its board item to Done (--reason completed|not-planned|duplicate-of --duplicate-of <n>)").option("--reason <reason>", "completed | not-planned | duplicate-of (defaults to completed)").option("--duplicate-of <number>", "the issue number this duplicates (use with --reason duplicate-of)").option("--comment <text>", "a closing comment to post").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
22758
- (opts, args) => {
22759
- let reason;
22760
- try {
22761
- reason = parseCloseReason(opts.reason).reason;
22762
- } catch (e) {
22763
- fail(`issue close: ${e.message}`, {
22764
- code: ERROR_CODES.ERR_BAD_ENUM,
22765
- offending_flag: "--reason",
22766
- expected: ["completed", "not-planned", "duplicate-of"]
22767
- });
22770
+ const landActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: wtPath });
22771
+ appendWorktreeEvent(primaryCheckout, {
22772
+ action: removeOutcome.status === "removed" ? "removed" : "failed",
22773
+ command: "worktree land",
22774
+ // NATIVE separators (#3338): `land` reads its path from `git rev-parse`, which emits forward
22775
+ // slashes even on Windows, while `create` and `gc` record the native form. Logging both spellings
22776
+ // puts one worktree under two names in the file an operator greps to find what took theirs.
22777
+ target: toNativePath(wtPath),
22778
+ branch,
22779
+ actor: landActor,
22780
+ owner: landOwner ? { createdAt: landOwner.createdAt, lastSeenAt: landOwner.lastSeenAt, actor: landOwner.actor } : void 0,
22781
+ reason: describeLandMergeState(mergeVerdict)
22782
+ });
22783
+ if (removeOutcome.status === "removed") dropWorktreeOwner(primaryCheckout, wtPath);
22784
+ report.push(await bestEffortGit(["branch", "-D", branch], primaryCheckout, `delete local branch ${branch}`));
22785
+ if (!keepRemoteBranch) {
22786
+ report.push(await bestEffortGit(["push", o.remote, "--delete", branch], void 0, `delete remote branch ${branch}`, GH_MUTATION_TIMEOUT_MS));
22787
+ } else if (!o.keepRemote) {
22788
+ report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
22768
22789
  }
22769
- if (reason === "duplicate-of" && !opts.duplicateOf) {
22770
- fail("issue close: --reason duplicate-of requires --duplicate-of <number>", {
22771
- code: ERROR_CODES.ERR_MISSING_FLAG,
22772
- offending_flag: "--duplicate-of"
22773
- });
22790
+ report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
22791
+ const result = {
22792
+ dryRun: false,
22793
+ ...plan,
22794
+ ...o.keepRemote ? { keepRemote: true } : {},
22795
+ // #3566: the merge outcome, machine-readable, so an orchestrator never has to infer it from the
22796
+ // absence of an error. `merged` is the ONLY value that means the work reached the base branch.
22797
+ mergeState: mergeVerdict.state,
22798
+ prNumbers: mergeVerdict.numbers,
22799
+ report
22800
+ };
22801
+ if (o.json) return console.log(JSON.stringify(result, null, 2));
22802
+ console.log(`worktree land: cleaned up branch ${branch} (${describeLandMergeState(mergeVerdict)})`);
22803
+ for (const r of report) console.log(` ${r.step}: ${r.status}`);
22804
+ if (mergeVerdict.state !== "merged") {
22805
+ console.warn(`worktree land: '${branch}' was cleaned up but NOT merged \u2014 ${describeLandMergeState(mergeVerdict)}. Do not report this work as shipped.`);
22774
22806
  }
22775
- return { command: "issue close", ref: args[0], reason: reason ?? "completed", duplicateOf: opts.duplicateOf };
22776
- }
22777
- ).action(async (ref, o) => {
22778
- const parsed = parseCloseReason(o.reason);
22779
- try {
22780
- const defaultRepo = await resolveRepo(o.repo);
22781
- const result = await closeIssue(defaultGitHubClient(), {
22782
- ref,
22783
- defaultRepo,
22784
- reason: parsed.reason,
22785
- duplicateOf: o.duplicateOf ? Number(o.duplicateOf) : void 0,
22786
- comment: o.comment
22787
- });
22788
- console.log(JSON.stringify(result));
22789
- } catch (e) {
22790
- return failGraceful(`issue close failed: ${e.message}`);
22791
- }
22792
- });
22793
- mutating(
22794
- issue2.command("reopen <ref>").description("reopen a closed issue and move its board item back to Todo").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
22795
- (_opts, args) => ({ command: "issue reopen", ref: args[0] })
22796
- ).action(async (ref, o) => {
22797
- try {
22798
- const defaultRepo = await resolveRepo(o.repo);
22799
- const result = await reopenIssue(defaultGitHubClient(), ref, defaultRepo);
22800
- console.log(JSON.stringify(result));
22801
- } catch (e) {
22802
- return failGraceful(`issue reopen failed: ${e.message}`);
22803
- }
22804
- });
22805
- mutating(
22806
- issue2.command("assign <ref> <login>").description("assign a user to an issue \u2014 pure ownership, no board Status change (unlike board claim)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
22807
- (_opts, args) => ({ command: "issue assign", ref: args[0], login: args[1] })
22808
- ).action(async (ref, login, o) => {
22809
- try {
22810
- const defaultRepo = await resolveRepo(o.repo);
22811
- const result = await assignIssue(defaultGitHubClient(), ref, login, defaultRepo);
22812
- console.log(JSON.stringify(result));
22813
- } catch (e) {
22814
- return failGraceful(`issue assign failed: ${e.message}`);
22815
- }
22816
- });
22817
- mutating(
22818
- issue2.command("unassign <ref> <login>").description("remove a user from an issue \u2014 pure ownership, no board Status change").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
22819
- (_opts, args) => ({ command: "issue unassign", ref: args[0], login: args[1] })
22820
- ).action(async (ref, login, o) => {
22821
- try {
22822
- const defaultRepo = await resolveRepo(o.repo);
22823
- const result = await unassignIssue(defaultGitHubClient(), ref, login, defaultRepo);
22824
- console.log(JSON.stringify(result));
22825
- } catch (e) {
22826
- return failGraceful(`issue unassign failed: ${e.message}`);
22827
- }
22828
- });
22829
- mutating(
22830
- issue2.command("relocate <ref>").description("transfer an issue to another repo, sweep the ghost board item, add to the destination board, and carry priority").requiredOption("--to <owner/repo>", "destination repository").option("--repo <owner/repo>", "source repo for a bare ref (defaults to the current repo)"),
22831
- (opts, args) => ({ command: "issue relocate", ref: args[0], to: opts.to })
22832
- ).action(async (ref, o) => {
22833
- try {
22834
- const defaultRepo = await resolveRepo(o.repo);
22835
- const result = await relocateIssue(defaultGitHubClient(), ref, o.to, defaultRepo);
22836
- console.log(JSON.stringify(result));
22807
+ if (report.some((r) => r.status.startsWith("failed"))) process.exitCode = 1;
22837
22808
  } catch (e) {
22838
- return failGraceful(`issue relocate failed: ${e.message}`);
22809
+ return failGraceful(`worktree land: ${e.message}`);
22839
22810
  }
22840
22811
  });
22841
- mutating(
22842
- issue2.command("unlink-child <parent> <child>").description("unlink a child issue from its parent (inverse of link-child) and print {parentNumber,subIssueNumber,totalCount} JSON").option("--repo <owner/repo>", "repo for bare refs on either side (defaults to the current repo)"),
22843
- (_opts, args) => ({ command: "issue unlink-child", parent: args[0], child: args[1] })
22844
- ).action(async (parentRef, childRef, o) => {
22845
- const defaultRepo = await resolveRepo(o.repo);
22812
+ if (!has("list")) worktree2.command("list").description("list worktrees; --stale surfaces orphaned worktrees/branches/running dev servers with remediation").option("--stale", "show only leaks (merged/closed branches, orphan dirs, stale dev servers)").option("--json", "machine-readable output").action(async (o) => {
22846
22813
  try {
22847
- const result = await unlinkSubIssue(ghRunner, parentRef, childRef, defaultRepo);
22848
- console.log(JSON.stringify(result));
22814
+ const ctx = await gatherWorktreeContext();
22815
+ if (o.stale) {
22816
+ const leaks = classifyStaleLeaks(ctx);
22817
+ if (o.json) return console.log(JSON.stringify({ stale: leaks, count: leaks.length }, null, 2));
22818
+ return console.log(formatStaleLeaks(leaks));
22819
+ }
22820
+ if (o.json) return console.log(JSON.stringify({ worktrees: ctx.worktrees }, null, 2));
22821
+ if (!ctx.worktrees.length) return console.log("worktree list: no worktrees");
22822
+ const lines = ["worktree list:"];
22823
+ for (const wt of ctx.worktrees) {
22824
+ const tag = wt.primary ? "primary" : "worktree";
22825
+ lines.push(` ${wt.branch} @ ${wt.path} (${tag})`);
22826
+ }
22827
+ console.log(lines.join("\n"));
22849
22828
  } catch (e) {
22850
- const err = e;
22851
- const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
22852
- return failGraceful(`issue unlink-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
22829
+ return failGraceful(`worktree list: ${e.message}`);
22853
22830
  }
22854
22831
  });
22855
- extendCreateCommand(issue2, deps.attach);
22856
22832
  }
22857
- function extendCreateCommand(issue2, batchAttach) {
22858
- const createCmd = issue2.commands.find((c) => c.name() === "create");
22859
- if (!createCmd) return;
22860
- createCmd.option("--batch <file.json>", "create multiple issues from a JSON array file (pre-validates all rows before creating any)").option("--idempotency-key <key>", "create-if-not-exists: skip creation when an issue with this key already exists (retried loops must not duplicate)");
22861
- const internal = createCmd;
22862
- const originalListener = internal._actionHandler;
22863
- internal._actionHandler = async (args) => {
22864
- const opts = createCmd.opts();
22865
- if (opts.batch) {
22866
- let specs;
22867
- try {
22868
- const raw = (0, import_node_fs27.readFileSync)(opts.batch, "utf8");
22869
- specs = JSON.parse(raw);
22870
- if (!Array.isArray(specs)) throw new Error("batch file must contain a JSON array");
22871
- } catch (e) {
22872
- return fail(`issue create --batch: cannot read/parse ${opts.batch}: ${e.message}`);
22873
- }
22874
- const validation = validateBatchSpecs(specs);
22875
- if (!validation.ok) {
22876
- const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
22877
- return fail(`issue create --batch: validation failed (${validation.errors.length} error(s)):
22878
- ${lines}`);
22879
- }
22880
- const batchParent = opts.parent;
22881
- const batchPriority = opts.priority ? normalizePriority(opts.priority) : void 0;
22882
- if (opts.dryRun || opts.validateOnly) {
22883
- const planned = validation.validated.map((v) => ({
22884
- row: v.row,
22885
- type: v.type,
22886
- title: v.spec.title,
22887
- priority: v.spec.priority ? v.priority : batchPriority ?? v.priority,
22888
- repo: v.spec.repo,
22889
- ...v.spec.parent ?? batchParent ? { parent: v.spec.parent ?? batchParent } : {}
22890
- }));
22891
- console.log(JSON.stringify(opts.validateOnly ? { ok: true, planned } : { dry_run: true, planned }));
22892
- return;
22893
- }
22894
- try {
22895
- const result = await createIssuesBatch(specs, {
22896
- repo: opts.repo,
22897
- idempotencyKey: opts.idempotencyKey,
22898
- parent: batchParent,
22899
- priority: batchPriority
22900
- }, { attach: batchAttach });
22901
- console.log(JSON.stringify(result));
22902
- if (result.failures.length) process.exitCode = 1;
22903
- } catch (e) {
22904
- return failGraceful(`issue create --batch failed: ${e.message}`);
22905
- }
22906
- return;
22833
+ async function gatherWorktreeContext() {
22834
+ const repoRoot2 = await repoRootOf();
22835
+ const porcelain = (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
22836
+ const parsed = parseWorktreePorcelain(porcelain);
22837
+ const primaryPath = parsed[0]?.path ?? repoRoot2;
22838
+ const worktrees = parsed.map((w) => ({ path: toNativePath(w.path), branch: w.branch, primary: w.path === primaryPath }));
22839
+ const branchOut = (await execFileP2("git", ["branch", "--format=%(refname:short)"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
22840
+ const localBranches = branchOut.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
22841
+ const currentBranch2 = (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || void 0;
22842
+ const openPrBranches = /* @__PURE__ */ new Set();
22843
+ const closedBranches = /* @__PURE__ */ new Set();
22844
+ try {
22845
+ const { stdout } = await execFileP2("gh", ["pr", "list", "--state", "all", "--limit", "200", "--json", "headRefName,state"], { timeout: GH_TIMEOUT_MS });
22846
+ const prs = JSON.parse(stdout || "[]");
22847
+ const byBranch = /* @__PURE__ */ new Map();
22848
+ for (const pr2 of prs) {
22849
+ const arr = byBranch.get(pr2.headRefName) ?? [];
22850
+ arr.push(pr2.state);
22851
+ byBranch.set(pr2.headRefName, arr);
22907
22852
  }
22908
- if (opts.idempotencyKey && !opts.dryRun && !opts.validateOnly) {
22909
- try {
22910
- const repo = await resolveRepo(opts.repo);
22911
- if (repo) {
22912
- const existing = await findExistingByIdempotencyKey(defaultGitHubClient(), opts.idempotencyKey, repo);
22913
- if (existing) {
22914
- console.log(JSON.stringify({ ...existing, idempotent: true }));
22915
- return;
22916
- }
22917
- }
22918
- } catch {
22919
- }
22853
+ for (const [br, states] of byBranch) {
22854
+ if (states.some((s) => s === "OPEN")) openPrBranches.add(br);
22855
+ else if (states.some((s) => s === "MERGED" || s === "CLOSED")) closedBranches.add(br);
22920
22856
  }
22921
- return originalListener?.(args);
22922
- };
22923
- }
22924
-
22925
- // src/train-commands.ts
22926
- var import_node_fs29 = require("node:fs");
22927
- var import_node_path27 = require("node:path");
22928
-
22929
- // src/plugin-guard-io.ts
22930
- var import_node_fs28 = require("node:fs");
22931
- var import_node_path26 = require("node:path");
22932
- var import_node_os6 = require("node:os");
22933
-
22934
- // src/plugin-guard.ts
22935
- function buildPluginGuardDecision(i) {
22936
- if (!i.isOrgRepo) return { state: "not-org" };
22937
- if (!i.installRecordPresent) return { state: "no-install" };
22938
- if (!i.marketplaceClonePresent || !i.pluginCachePresent) return { state: "unresolved" };
22939
- return { state: "healthy" };
22940
- }
22941
- function buildPluginGuardLine(state, opts = {}) {
22942
- if (state === "healthy" || state === "not-org") return { exitCode: 0 };
22943
- const recovery = opts.recovery ?? "mmi-cli plugin heal";
22944
- const restartHint = opts.restartHint ?? "restart your agent host / reload plugins";
22945
- const reason = state === "no-install" ? "MMI plugin is not installed for this user/session" : "MMI plugin is installed but its marketplace/cache is unresolved";
22946
- return {
22947
- line: `[mmi-guard] ${reason}; run ${recovery} and ${restartHint}.`,
22948
- exitCode: 1
22949
- };
22950
- }
22951
-
22952
- // src/plugin-guard-io.ts
22953
- var isWin = process.platform === "win32";
22954
- var MMI_PLUGIN_ID = "mmi@mutmutco";
22955
- var LEGACY_MMI_MARKETPLACE = "mmi";
22956
- function detectSurface(env) {
22957
- const has = (k) => Boolean(env[k]?.trim());
22958
- if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
22959
- return "codex";
22857
+ } catch {
22960
22858
  }
22961
- if (env.MMI_AGENT_SURFACE === "cursor" || has("CURSOR_TRACE_ID") || has("CURSOR_USER") || has("CURSOR_SESSION_ID") || env.CURSOR_AGENT === "1" || has("CURSOR_EXTENSION_HOST_ROLE")) {
22962
- return "cursor";
22859
+ const stages = [];
22860
+ for (const wt of worktrees) {
22861
+ const s = readStageSummary(wt.path);
22862
+ if (s) stages.push({ path: wt.path, port: s.port });
22963
22863
  }
22964
- if (env.MMI_AGENT_SURFACE === "opencode" || has("OPENCODE_CLIENT") || has("OPENCODE_SERVER_USERNAME")) return "opencode";
22965
- const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
22966
- const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
22967
- if (isClaude && isVscode) return "claude-vscode";
22968
- if (isClaude) return "claude-cli";
22969
- return "shell";
22970
- }
22971
- function surfaceToken(surface) {
22972
- switch (surface) {
22973
- case "claude-cli":
22974
- case "claude-vscode":
22975
- return "claude";
22976
- case "codex":
22977
- return "codex";
22978
- case "cursor":
22979
- return "cursor";
22980
- case "opencode":
22981
- return "opencode";
22982
- case "shell":
22983
- default:
22984
- return null;
22864
+ const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
22865
+ const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path27.dirname)((0, import_node_path27.dirname)(worktreeGitRoot)) : repoRoot2;
22866
+ const wtRoot = siblingMmiWorktreesRoot(primaryRepoRoot);
22867
+ let orphanDirs = [];
22868
+ if ((0, import_node_fs28.existsSync)(wtRoot)) {
22869
+ orphanDirs = scanOrphanDirs(wtRoot, worktreeGitRoot, {
22870
+ ...defaultOrphanDirScanDeps,
22871
+ listDirs: (root) => worktreeScanDirs(root, primaryRepoRoot, defaultOrphanDirScanDeps.listDirs, isRepoCheckoutDir)
22872
+ });
22985
22873
  }
22874
+ return { worktrees, localBranches, currentBranch: currentBranch2, openPrBranches, closedBranches, stages, orphanDirs };
22986
22875
  }
22987
- function reloadAction(surface) {
22988
- switch (surface) {
22989
- case "claude-vscode":
22990
- return "restart VS Code";
22991
- case "codex":
22992
- return "restart Codex";
22993
- case "opencode":
22994
- return "restart OpenCode";
22995
- case "cursor":
22996
- return "restart Cursor (or refresh the Team Marketplace from Dashboard \u2192 Settings \u2192 Plugins)";
22997
- case "claude-cli":
22998
- case "shell":
22999
- default:
23000
- return "restart Claude Code (or run /reload-plugins)";
22876
+ async function bestEffortGit(args, cwd, step, timeoutMs = GIT_TIMEOUT_MS) {
22877
+ try {
22878
+ const fullArgs = cwd ? ["-C", cwd, ...args] : args;
22879
+ await execFileP2("git", fullArgs, { timeout: timeoutMs });
22880
+ return { step, status: "done" };
22881
+ } catch (e) {
22882
+ const msg = e.message.split("\n")[0];
22883
+ if (/not found|already|no such|does not exist|not a valid|couldn't|unable to resolve/i.test(msg)) {
22884
+ return { step, status: "already gone" };
22885
+ }
22886
+ return { step, status: `failed: ${msg}` };
23001
22887
  }
23002
22888
  }
23003
- var CLAUDE_RECOVERY = `claude plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && claude plugin marketplace remove mutmutco && claude plugin marketplace add mutmutco/MMI-Hub --ref main && claude plugin install mmi@mutmutco`;
23004
- var PLUGIN_SURFACE_HEAL = {
23005
- claude: {
23006
- delivery: "plugin-cli",
23007
- recovery: CLAUDE_RECOVERY,
23008
- healSteps: [
23009
- { args: ["plugin", "marketplace", "remove", LEGACY_MMI_MARKETPLACE], gated: false },
23010
- { args: ["plugin", "marketplace", "remove", "mutmutco"], gated: false },
23011
- { args: ["plugin", "marketplace", "add", "mutmutco/MMI-Hub", "--ref", "main"], gated: true },
23012
- { args: ["plugin", "install", "mmi@mutmutco"], gated: true },
23013
- { args: ["plugin", "enable", "mmi@mutmutco"], gated: false }
23014
- ],
23015
- fix: (surface) => `${CLAUDE_RECOVERY} # then ${reloadAction(surface)} to reload MMI commands`,
23016
- updateRecipe: [CLAUDE_RECOVERY]
22889
+
22890
+ // src/issue-commands.ts
22891
+ var import_node_fs29 = require("node:fs");
22892
+ var import_node_crypto5 = require("node:crypto");
22893
+ var ghRunner = async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout;
22894
+ async function editIssue(client, options, deps = {}) {
22895
+ const parsed = parseIssueRef(options.ref);
22896
+ const repo = parsed.repo ?? options.defaultRepo;
22897
+ if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22898
+ const url = `https://github.com/${repo}/issues/${parsed.number}`;
22899
+ const patch = {};
22900
+ let bodyChanged = false;
22901
+ if (options.title !== void 0) patch.title = options.title;
22902
+ if (options.body !== void 0 || options.bodyFile !== void 0) {
22903
+ let body;
22904
+ if (options.bodyFile) {
22905
+ const td = deps.textDeps ?? { readFile: (p, e) => Promise.resolve((0, import_node_fs29.readFileSync)(p, e)), readStdin: () => Promise.resolve("") };
22906
+ body = await resolveIssueBody({ body: options.body, bodyFile: options.bodyFile }, td);
22907
+ } else {
22908
+ body = options.body ?? "";
22909
+ }
22910
+ patch.body = body;
22911
+ bodyChanged = true;
23017
22912
  }
23018
- };
23019
- function nonClaudeSurfaceHealMessage(surface) {
23020
- if (surface === "codex") {
23021
- return "Codex ships an MMI plugin (#3563). Install or repair it with:\n codex plugin marketplace add mutmutco/MMI-Hub && codex plugin add mmi@mutmutco\n Then run /hooks and TRUST the MMI hooks \u2014 bundled hooks are skipped until reviewed.\n Update the CLI too: npm i -g @mutmutco/cli";
22913
+ if (patch.title !== void 0 || patch.body !== void 0) {
22914
+ await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: patch });
23022
22915
  }
23023
- return "No Hub-shipped MMI plugin on this host \u2014 the Hub ships plugins for Claude and Codex only.\n Update the CLI instead: npm i -g @mutmutco/cli (org rules ride an AGENTS.md authored outside the Hub)";
23024
- }
23025
- function healStepAborts(step, ok) {
23026
- return !ok && step.gated;
23027
- }
23028
- function marketplaceAddSupportsRef(helpText) {
23029
- if (!helpText) return false;
23030
- return /(^|\s)--ref(\b|=)/.test(helpText);
22916
+ const labelsAdded = [];
22917
+ const labelsRemoved = [];
22918
+ if (options.addLabel?.length) {
22919
+ await client.rest("POST", `repos/${repo}/issues/${parsed.number}/labels`, { body: { labels: options.addLabel } });
22920
+ labelsAdded.push(...options.addLabel);
22921
+ }
22922
+ for (const label of options.removeLabel ?? []) {
22923
+ try {
22924
+ await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/labels/${encodeURIComponent(label)}`);
22925
+ labelsRemoved.push(label);
22926
+ } catch (e) {
22927
+ if (e instanceof GitHubApiError && e.status === 404) {
22928
+ labelsRemoved.push(label);
22929
+ } else {
22930
+ throw e;
22931
+ }
22932
+ }
22933
+ }
22934
+ let parentResult;
22935
+ if (options.parent) {
22936
+ const run = deps.runGh ?? ghRunner;
22937
+ parentResult = await linkSubIssue(run, options.parent, options.ref, repo);
22938
+ }
22939
+ return {
22940
+ number: parsed.number,
22941
+ repo,
22942
+ url,
22943
+ ...patch.title !== void 0 ? { title: patch.title } : {},
22944
+ bodyChanged,
22945
+ labelsAdded,
22946
+ labelsRemoved,
22947
+ ...parentResult ? { parent: parentResult } : {}
22948
+ };
23031
22949
  }
23032
- function adaptHealStepsForRefSupport(steps, refSupported) {
23033
- if (refSupported) return { steps: [...steps], strippedRef: false };
23034
- let strippedRef = false;
23035
- const adapted = steps.map((step) => {
23036
- const refIdx = step.args.indexOf("--ref");
23037
- const isAdd = step.args.includes("marketplace") && step.args.includes("add");
23038
- if (!isAdd || refIdx === -1) return step;
23039
- strippedRef = true;
23040
- return { ...step, args: [...step.args.slice(0, refIdx), ...step.args.slice(refIdx + 2)] };
22950
+ async function closeIssue(client, options, deps = {}) {
22951
+ const parsed = parseIssueRef(options.ref);
22952
+ const repo = parsed.repo ?? options.defaultRepo;
22953
+ if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22954
+ const url = `https://github.com/${repo}/issues/${parsed.number}`;
22955
+ const reason = options.reason ?? "completed";
22956
+ const stateReason = reason === "duplicate-of" ? "not_planned" : reason === "not-planned" ? "not_planned" : "completed";
22957
+ await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, {
22958
+ body: { state: "closed", state_reason: stateReason }
23041
22959
  });
23042
- return { steps: adapted, strippedRef };
23043
- }
23044
- function recoveryWithoutRef(recovery) {
23045
- return recovery.replace(/ --ref \S+/g, "");
23046
- }
23047
- function hasProjectInstallRecord(file, pluginId, projectPath) {
23048
- const records = file?.plugins?.[pluginId];
23049
- if (!Array.isArray(records)) return false;
23050
- return records.some((r) => r.scope === "project" && r.projectPath === projectPath);
23051
- }
23052
- function hasUserInstallRecord(file, pluginId) {
23053
- const records = file?.plugins?.[pluginId];
23054
- if (!Array.isArray(records)) return false;
23055
- return records.some((r) => r.scope === "user");
23056
- }
23057
- var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
23058
- var NPM_VIEW_TIMEOUT_MS = 15e3;
23059
- function runHostBin(bin, args, opts) {
23060
- return isWin ? execFileP2("cmd.exe", ["/c", bin, ...args], opts) : execFileP2(bin, args, opts);
23061
- }
23062
- var installedPluginsPath2 = (surface = detectSurface(process.env)) => {
23063
- const homeDir = surface === "codex" ? ".codex" : ".claude";
23064
- return (0, import_node_path26.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "installed_plugins.json");
23065
- };
23066
- function readInstalledPlugins(surface = detectSurface(process.env)) {
23067
- try {
23068
- return JSON.parse((0, import_node_fs28.readFileSync)(installedPluginsPath2(surface), "utf8"));
23069
- } catch {
23070
- return null;
22960
+ if (reason === "duplicate-of" && options.duplicateOf) {
22961
+ await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
22962
+ body: { body: `Duplicate of #${options.duplicateOf}` }
22963
+ });
23071
22964
  }
23072
- }
23073
- function marketplaceCloneCandidates(surface, home) {
23074
- if (surface === "codex") {
23075
- return [
23076
- (0, import_node_path26.join)(home, ".codex", ".tmp", "marketplaces", "mutmutco"),
23077
- (0, import_node_path26.join)(home, ".codex", "plugins", "marketplaces", "mutmutco")
23078
- ];
22965
+ if (options.comment) {
22966
+ await client.rest("POST", `repos/${repo}/issues/${parsed.number}/comments`, {
22967
+ body: { body: options.comment }
22968
+ });
23079
22969
  }
23080
- return [(0, import_node_path26.join)(home, ".claude", "plugins", "marketplaces", "mutmutco")];
23081
- }
23082
- function marketplaceClonePresent(surface, home, exists = import_node_fs28.existsSync) {
23083
- return marketplaceCloneCandidates(surface, home).some(exists);
23084
- }
23085
- async function fetchNpmReleasedVersion() {
22970
+ let board;
23086
22971
  try {
23087
- const { stdout } = await runHostBin("npm", npmReleasedVersionArgs(), { timeout: NPM_VIEW_TIMEOUT_MS });
23088
- return parseNpmVersion(stdout);
22972
+ const cfg = await loadConfigForBoardSelector(options.ref, options.defaultRepo);
22973
+ if (cfg.projectId) {
22974
+ const result = await moveBoardItem({ config: cfg, selector: options.ref, status: "Done", repo, allowPartial: true }, deps);
22975
+ board = { status: result.status, partial: result.partial };
22976
+ }
23089
22977
  } catch {
23090
- return void 0;
23091
22978
  }
22979
+ return { number: parsed.number, repo, url, state: "closed", stateReason, ...board ? { board } : {} };
23092
22980
  }
23093
- var NPM_INSTALL_TIMEOUT_MS = 12e4;
23094
- async function npmSelfUpdateCli(target) {
23095
- const command = cliUpdateCommand(target);
22981
+ async function reopenIssue(client, ref, defaultRepo, deps = {}) {
22982
+ const parsed = parseIssueRef(ref);
22983
+ const repo = parsed.repo ?? defaultRepo;
22984
+ if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
22985
+ const url = `https://github.com/${repo}/issues/${parsed.number}`;
22986
+ await client.rest("PATCH", `repos/${repo}/issues/${parsed.number}`, { body: { state: "open" } });
22987
+ let board;
23096
22988
  try {
23097
- await runHostBin("npm", ["install", "-g", `@mutmutco/cli@${target ?? "latest"}`], { timeout: NPM_INSTALL_TIMEOUT_MS });
23098
- return { ok: true, detail: `${command} exited 0` };
23099
- } catch (e) {
23100
- return { ok: false, detail: e.message.trim().slice(0, 200).replace(/\s+/g, " ") };
22989
+ const cfg = await loadConfigForBoardSelector(ref, defaultRepo);
22990
+ if (cfg.projectId) {
22991
+ const result = await moveBoardItem({ config: cfg, selector: ref, status: "Todo", repo, allowPartial: true }, deps);
22992
+ board = { status: result.status, partial: result.partial };
22993
+ }
22994
+ } catch {
23101
22995
  }
22996
+ return { number: parsed.number, repo, url, state: "open", ...board ? { board } : {} };
23102
22997
  }
23103
- function snapshotPluginGuardInput(surface = detectSurface(process.env), isOrgRepo = false) {
23104
- const homeDir = surface === "codex" ? ".codex" : ".claude";
23105
- const installed = readInstalledPlugins(surface);
23106
- return {
23107
- isOrgRepo,
23108
- installRecordPresent: hasUserInstallRecord(installed, MMI_PLUGIN_ID) || hasProjectInstallRecord(installed, MMI_PLUGIN_ID, process.cwd()),
23109
- marketplaceClonePresent: marketplaceClonePresent(surface, (0, import_node_os6.homedir)()),
23110
- pluginCachePresent: (0, import_node_fs28.existsSync)((0, import_node_path26.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "cache", "mutmutco", "mmi"))
23111
- };
22998
+ async function assignIssue(client, ref, login, defaultRepo) {
22999
+ const parsed = parseIssueRef(ref);
23000
+ const repo = parsed.repo ?? defaultRepo;
23001
+ if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
23002
+ await client.rest("POST", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
23003
+ return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "assigned" };
23112
23004
  }
23113
- function claudePluginGuardState(isOrgRepo) {
23114
- return buildPluginGuardDecision(snapshotPluginGuardInput(detectSurface(process.env), isOrgRepo)).state;
23005
+ async function unassignIssue(client, ref, login, defaultRepo) {
23006
+ const parsed = parseIssueRef(ref);
23007
+ const repo = parsed.repo ?? defaultRepo;
23008
+ if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
23009
+ await client.rest("DELETE", `repos/${repo}/issues/${parsed.number}/assignees`, { body: { assignees: [login] } });
23010
+ return { number: parsed.number, repo, url: `https://github.com/${repo}/issues/${parsed.number}`, login, action: "unassigned" };
23115
23011
  }
23116
- async function runClaudePlugin(args) {
23012
+ var TRANSFER_ISSUE_MUTATION = `
23013
+ mutation($issueId: ID!, $repositoryId: ID!) {
23014
+ transferIssue(input: {issueId: $issueId, repositoryId: $repositoryId}) {
23015
+ issue {
23016
+ id
23017
+ number
23018
+ url
23019
+ repository { nameWithOwner }
23020
+ }
23021
+ }
23022
+ }`;
23023
+ var ADD_PROJECT_ITEM_MUTATION = `
23024
+ mutation($projectId: ID!, $contentId: ID!) {
23025
+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
23026
+ item { id }
23027
+ }
23028
+ }`;
23029
+ async function relocateIssue(client, ref, toRepo, defaultRepo, deps = {}) {
23030
+ const parsed = parseIssueRef(ref);
23031
+ const sourceRepo = parsed.repo ?? defaultRepo;
23032
+ if (!sourceRepo) throw new Error("could not resolve the source repo \u2014 pass owner/repo#123 or use --repo");
23033
+ let sourceItemId;
23034
+ let priority;
23117
23035
  try {
23118
- await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
23119
- return true;
23036
+ const sourceCfg = await loadConfigForBoardSelector(ref, defaultRepo);
23037
+ if (sourceCfg.projectId) {
23038
+ const resolved = resolveBoardConfig(sourceCfg);
23039
+ const lookup = await fetchIssueProjectItem(client, resolved, { repo: sourceRepo, number: parsed.number });
23040
+ sourceItemId = lookup.item?.itemId;
23041
+ priority = lookup.item?.priority;
23042
+ }
23120
23043
  } catch {
23121
- return false;
23122
23044
  }
23123
- }
23124
- async function marketplaceAddRefSupported(bin) {
23045
+ const sourceIssue = await client.rest("GET", `repos/${sourceRepo}/issues/${parsed.number}`);
23046
+ const issueNodeId = sourceIssue?.node_id;
23047
+ if (!issueNodeId) throw new Error(`could not resolve node id for ${sourceRepo}#${parsed.number}`);
23048
+ const targetRepoData = await client.rest("GET", `repos/${toRepo}`);
23049
+ const targetRepoNodeId = targetRepoData?.node_id;
23050
+ if (!targetRepoNodeId) throw new Error(`could not resolve node id for repo ${toRepo}`);
23051
+ const transferred = await client.graphql(
23052
+ TRANSFER_ISSUE_MUTATION,
23053
+ { issueId: issueNodeId, repositoryId: targetRepoNodeId }
23054
+ );
23055
+ const newIssue = transferred?.transferIssue?.issue;
23056
+ if (!newIssue?.id || !newIssue.number || !newIssue.url) {
23057
+ throw new Error("transferIssue returned an unexpected response (no issue id/number/url)");
23058
+ }
23059
+ const newRepo = newIssue.repository?.nameWithOwner ?? toRepo;
23060
+ const newNumber = newIssue.number;
23061
+ const newUrl = newIssue.url;
23062
+ const newNodeId = newIssue.id;
23063
+ let swept = false;
23064
+ if (sourceItemId) {
23065
+ try {
23066
+ const sourceCfg = await loadConfigForBoardSelector(ref, defaultRepo);
23067
+ if (sourceCfg.projectId) {
23068
+ const resolved = resolveBoardConfig(sourceCfg);
23069
+ const DELETE_MUTATION = `mutation($projectId: ID!, $itemId: ID!) { deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) { deletedItemId } }`;
23070
+ await client.graphql(DELETE_MUTATION, { projectId: resolved.projectId, itemId: sourceItemId });
23071
+ swept = true;
23072
+ }
23073
+ } catch {
23074
+ }
23075
+ }
23076
+ let added = false;
23077
+ let carriedPriority;
23125
23078
  try {
23126
- const { stdout, stderr } = await runHostBin(bin, ["plugin", "marketplace", "add", "--help"], {
23127
- timeout: CLAUDE_PLUGIN_TIMEOUT_MS
23128
- });
23129
- return marketplaceAddSupportsRef(`${stdout}
23130
- ${stderr}`);
23079
+ const destCfg = await loadConfigForBoardSelector(`${newRepo}#${newNumber}`, newRepo);
23080
+ if (destCfg.projectId) {
23081
+ const addResult = await client.graphql(
23082
+ ADD_PROJECT_ITEM_MUTATION,
23083
+ { projectId: destCfg.projectId, contentId: newNodeId }
23084
+ );
23085
+ const newItemId = addResult?.addProjectV2ItemById?.item?.id;
23086
+ if (newItemId && priority) {
23087
+ try {
23088
+ const p = priority.toLowerCase();
23089
+ if (CLI_PRIORITIES.includes(p)) {
23090
+ await setBoardItemPriority(client, destCfg, newItemId, p);
23091
+ carriedPriority = priority;
23092
+ }
23093
+ } catch {
23094
+ }
23095
+ }
23096
+ added = true;
23097
+ }
23131
23098
  } catch {
23132
- return false;
23133
23099
  }
23100
+ return {
23101
+ number: newNumber,
23102
+ repo: newRepo,
23103
+ url: newUrl,
23104
+ previousRepo: sourceRepo,
23105
+ previousNumber: parsed.number,
23106
+ board: { swept, added, ...carriedPriority ? { priority: carriedPriority } : {} }
23107
+ };
23134
23108
  }
23135
- function healBannerLine(bin, token, refSupported) {
23136
- const installVerb = token === "codex" ? "add" : "install";
23137
- return refSupported ? ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add --ref main \u2192 ${installVerb})\u2026` : ` \u21BB reinstalling the MMI plugin via \`${bin} plugin\` (marketplace remove \u2192 add \u2192 ${installVerb}; release ref pinned by the marketplace manifest)\u2026`;
23109
+ function idempotencyMarker(key) {
23110
+ return `mmi-idempotency:${key}`;
23138
23111
  }
23139
- function refAbsenceNote(bin, refSupported) {
23140
- return refSupported ? "" : `
23141
- Note: \`${bin}\` has no \`--ref\` option; the marketplace manifest pins the released branch, so none is needed (#2516).`;
23112
+ function appendIdempotencyMarker(body, key) {
23113
+ const marker = `<!-- ${idempotencyMarker(key)} -->`;
23114
+ if (body.includes(marker)) return body;
23115
+ return `${body.replace(/\s+$/, "")}
23116
+
23117
+ ${marker}
23118
+ `;
23142
23119
  }
23143
- var PLUGIN_READ_REPO2 = "mutmutco/MMI-Hub";
23144
- function pluginReadGrantNote(login = "<your-github-login>") {
23145
- return `
23146
- If the reinstall 404s on ${PLUGIN_READ_REPO2}, you lack read on it. An org owner must grant it (idempotent):
23147
- gh api -X PUT repos/${PLUGIN_READ_REPO2}/collaborators/${login} -f permission=pull`;
23120
+ function rowIdempotencyKey(batchKey, spec) {
23121
+ const identity = `${spec.type}
23122
+ ${spec.title.trim()}
23123
+ ${spec.body ?? ""}`;
23124
+ const hash = (0, import_node_crypto5.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
23125
+ return `${batchKey}:${hash}`;
23148
23126
  }
23149
- async function applyPluginHeal(surface, log, opts) {
23150
- if (!opts?.force && surfaceToken(surface) !== "claude") return false;
23151
- const tableSteps = PLUGIN_SURFACE_HEAL.claude.healSteps;
23152
- if (!tableSteps) return false;
23153
- const refSupported = await marketplaceAddRefSupported("claude");
23154
- const { steps } = adaptHealStepsForRefSupport(tableSteps, refSupported);
23155
- log(healBannerLine("claude", "claude", refSupported));
23156
- const pinsPath = (0, import_node_path26.join)((0, import_node_os6.homedir)(), ...KNOWN_MARKETPLACES_RELATIVE);
23157
- const pins = captureMarketplacePins(readKnownMarketplacesFile(pinsPath), [MMI_MARKETPLACE_NAME, JERV_MARKETPLACE_NAME]);
23158
- for (const step of steps) {
23159
- if (healStepAborts(step, await runClaudePlugin([...step.args]))) return false;
23127
+ var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "parent", "repo"]);
23128
+ function validateBatchSpecs(specs) {
23129
+ const errors = [];
23130
+ const validated = [];
23131
+ const validTypes = ["bug", "feature", "task"];
23132
+ for (let i = 0; i < specs.length; i += 1) {
23133
+ const spec = specs[i];
23134
+ const row = i + 1;
23135
+ if (!spec || typeof spec !== "object") {
23136
+ errors.push({ row, error: "not an object" });
23137
+ continue;
23138
+ }
23139
+ const unknown = Object.keys(spec).filter((k) => !BATCH_SPEC_KEYS.has(k));
23140
+ if (unknown.length) {
23141
+ errors.push({ row, error: `unknown key(s): ${unknown.join(", ")} \u2014 expected only: ${[...BATCH_SPEC_KEYS].join(", ")}` });
23142
+ continue;
23143
+ }
23144
+ if (spec.repo !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(spec.repo)) {
23145
+ errors.push({ row, error: `bad repo "${spec.repo}" \u2014 expected owner/repo` });
23146
+ continue;
23147
+ }
23148
+ if (!validTypes.includes(spec.type)) {
23149
+ errors.push({ row, error: `unknown type "${spec.type}" \u2014 expected one of: ${validTypes.join(", ")}` });
23150
+ continue;
23151
+ }
23152
+ if (!spec.title || !spec.title.trim()) {
23153
+ errors.push({ row, error: "missing or empty title" });
23154
+ continue;
23155
+ }
23156
+ let priority;
23157
+ try {
23158
+ priority = spec.priority ? normalizePriority(spec.priority) : "medium";
23159
+ } catch (e) {
23160
+ errors.push({ row, error: e.message });
23161
+ continue;
23162
+ }
23163
+ validated.push({ row, spec, priority, type: spec.type });
23160
23164
  }
23161
- const restored = restoreMarketplacePinsOnDisk(pinsPath, pins);
23162
- if (restored) log(` ${restored}`);
23163
- return true;
23165
+ return { ok: errors.length === 0, errors, validated };
23164
23166
  }
23165
- async function healClaudePluginForDoctor(surface = detectSurface(process.env)) {
23166
- if (surfaceToken(surface) !== "claude") {
23167
- return { ok: false, detail: `not a Claude surface (${surface}) \u2014 no Hub-shipped plugin to reinstall` };
23167
+ async function createIssuesBatch(specs, options, deps = {}) {
23168
+ const ensureLabels = deps.ensureLabels ?? ensureLabelsExist;
23169
+ const client = deps.client ?? defaultGitHubClient();
23170
+ const runCreate = deps.runCreate ?? (async (args) => (await execFileP2("gh", args, { timeout: GH_MUTATION_TIMEOUT_MS })).stdout);
23171
+ const validation = validateBatchSpecs(specs);
23172
+ if (!validation.ok) {
23173
+ const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
23174
+ throw new Error(`batch validation failed (${validation.errors.length} error(s)):
23175
+ ${lines}`);
23168
23176
  }
23169
- const steps = [];
23170
- const ok = await applyPluginHeal(surface, (msg) => steps.push(msg.trim()));
23171
- const pinNote = steps.find((s) => s.startsWith("re-pinned ") || s.includes("re-pin by hand"));
23172
- return {
23173
- ok,
23174
- detail: ok ? `marketplace remove \u2192 add \u2192 install succeeded${pinNote ? `; ${pinNote}` : ""}` : `\`claude plugin\` reinstall failed or was skipped${steps.length ? ` (${steps[steps.length - 1]})` : ""}`
23175
- };
23176
- }
23177
- function readKnownMarketplacesFile(path2) {
23178
- try {
23179
- return (0, import_node_fs28.existsSync)(path2) ? (0, import_node_fs28.readFileSync)(path2, "utf8") : void 0;
23180
- } catch {
23181
- return void 0;
23177
+ const defaultRepo = await resolveRepo(options.repo);
23178
+ if (!defaultRepo && validation.validated.some((v) => !v.spec.repo)) {
23179
+ throw new Error("could not resolve repo \u2014 pass --repo owner/repo or set repo per row");
23180
+ }
23181
+ const rowRepo = (spec) => spec.repo ?? defaultRepo;
23182
+ const labelsByRepo = /* @__PURE__ */ new Map();
23183
+ for (const { spec } of validation.validated) {
23184
+ if (!spec.labels?.length) continue;
23185
+ const bucket = labelsByRepo.get(rowRepo(spec)) ?? /* @__PURE__ */ new Set();
23186
+ for (const label of spec.labels) bucket.add(label);
23187
+ labelsByRepo.set(rowRepo(spec), bucket);
23188
+ }
23189
+ for (const [repo, labels] of labelsByRepo) await ensureLabels([...labels], repo);
23190
+ const created = [];
23191
+ const failures = [];
23192
+ for (const entry of validation.validated) {
23193
+ const { spec, priority, type } = entry;
23194
+ try {
23195
+ const rowKey = options.idempotencyKey ? rowIdempotencyKey(options.idempotencyKey, spec) : void 0;
23196
+ if (rowKey) {
23197
+ const existing = await findExistingByIdempotencyKey(client, rowKey, rowRepo(spec));
23198
+ if (existing) {
23199
+ created.push({ row: entry.row, ...existing, idempotent: true });
23200
+ continue;
23201
+ }
23202
+ }
23203
+ let body = spec.body ?? "";
23204
+ if (rowKey) {
23205
+ body = appendIdempotencyMarker(body, rowKey);
23206
+ }
23207
+ const rowPriority = spec.priority ? priority : options.priority ?? priority;
23208
+ const args = buildIssueArgs({ type, title: spec.title, body, priority: rowPriority, repo: rowRepo(spec), labels: spec.labels });
23209
+ const swapped = await bodyArgsViaFile(args);
23210
+ let row;
23211
+ try {
23212
+ const stdout = await runCreate(swapped.args);
23213
+ const result = parseCreatedUrl(stdout);
23214
+ row = { row: entry.row, number: result.number, url: result.url };
23215
+ created.push(row);
23216
+ } finally {
23217
+ await swapped.cleanup();
23218
+ }
23219
+ if (deps.attach) {
23220
+ const attached = await deps.attach(row.number, rowRepo(spec), rowPriority);
23221
+ row.onBoard = attached.onBoard;
23222
+ if (attached.projectItemId) row.projectItemId = attached.projectItemId;
23223
+ }
23224
+ const parentRef = spec.parent ?? options.parent;
23225
+ if (parentRef) {
23226
+ try {
23227
+ const run = deps.runGh ?? ghRunner;
23228
+ row.parent = await linkSubIssue(run, parentRef, row.url, rowRepo(spec));
23229
+ } catch (e) {
23230
+ const err = e;
23231
+ row.parentLinkError = (err.stderr || err.message || String(e)).trim();
23232
+ process.stderr.write(`warning: issue #${row.number} created but NOT linked under ${parentRef}: ${row.parentLinkError}
23233
+ `);
23234
+ }
23235
+ }
23236
+ } catch (e) {
23237
+ const err = e;
23238
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
23239
+ failures.push({ row: entry.row, error: (err.stderr || err.message || String(e)).trim() + (note ? ` (${note})` : "") });
23240
+ }
23182
23241
  }
23242
+ return { created, failures };
23183
23243
  }
23184
- function restoreMarketplacePinsOnDisk(path2, pins) {
23185
- if (pins.size === 0) return void 0;
23186
- const after = readKnownMarketplacesFile(path2);
23187
- const next = restoreMarketplacePins(after, pins);
23188
- if (next === null) return void 0;
23244
+ async function findExistingByIdempotencyKey(client, key, repo) {
23245
+ const marker = idempotencyMarker(key);
23189
23246
  try {
23190
- (0, import_node_fs28.writeFileSync)(path2, next, "utf8");
23247
+ const result = await client.rest(
23248
+ "GET",
23249
+ `search/issues?per_page=5&q=${encodeURIComponent(`repo:${repo} "${marker}" in:body`)}`
23250
+ );
23251
+ const open2 = result?.items?.find((item) => item.state === "open" && item.number);
23252
+ if (!open2?.number) return void 0;
23253
+ return { number: open2.number, url: open2.html_url ?? `https://github.com/${repo}/issues/${open2.number}` };
23191
23254
  } catch {
23192
- return `could NOT restore ${[...pins.keys()].join(", ")} \u2014 re-pin by hand`;
23255
+ return void 0;
23193
23256
  }
23194
- const verify = readKnownMarketplacesFile(path2);
23195
- const failed = [...pins].filter(([name, want]) => {
23196
- const got = readKnownMarketplace(verify, name);
23197
- return typeof want.autoUpdate === "boolean" && got.declared !== want.autoUpdate || want.ref !== void 0 && got.ref !== want.ref;
23257
+ }
23258
+ function parseCloseReason(raw) {
23259
+ if (raw === void 0) return {};
23260
+ const trimmed = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
23261
+ if (trimmed === "completed" || trimmed === "complete") return { reason: "completed" };
23262
+ if (trimmed === "notplanned" || trimmed === "notplanned") return { reason: "not-planned" };
23263
+ if (trimmed === "duplicateof" || trimmed === "duplicate") return { reason: "duplicate-of" };
23264
+ throw new Error(`unknown close reason "${raw}" \u2014 expected completed, not-planned, or duplicate-of`);
23265
+ }
23266
+ function registerIssueLifecycleCommands(program3, deps = {}) {
23267
+ const issue2 = program3.commands.find((c) => c.name() === "issue");
23268
+ if (!issue2) return;
23269
+ mutating(
23270
+ issue2.command("edit <ref>").description("edit an issue title/body/labels or reparent it (--parent) \u2014 reparenting is impossible via raw gh").option("--title <title>", "new title").option("--body <body>", "new body (markdown)").option("--body-file <path|->", "read new body from a UTF-8 file, or stdin with -").option("--add-label <label...>", "label(s) to add (repeatable)").option("--remove-label <label...>", "label(s) to remove (repeatable)").option("--parent <ref>", "reparent: link under this parent (#123, owner/repo#123, or URL)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
23271
+ (opts, args) => ({ command: "issue edit", ref: args[0], fields: Object.keys(opts).filter((k) => k !== "repo" && opts[k] !== void 0) })
23272
+ ).action(async (ref, o) => {
23273
+ try {
23274
+ const defaultRepo = await resolveRepo(o.repo);
23275
+ const result = await editIssue(defaultGitHubClient(), {
23276
+ ref,
23277
+ defaultRepo,
23278
+ title: o.title,
23279
+ body: o.body,
23280
+ bodyFile: o.bodyFile,
23281
+ addLabel: o.addLabel,
23282
+ removeLabel: o.removeLabel,
23283
+ parent: o.parent
23284
+ });
23285
+ console.log(JSON.stringify(result));
23286
+ } catch (e) {
23287
+ return failGraceful(`issue edit failed: ${e.message}`);
23288
+ }
23198
23289
  });
23199
- return failed.length ? `restore did NOT take for ${failed.map(([n]) => n).join(", ")} \u2014 re-pin by hand` : `re-pinned ${[...pins.keys()].join(", ")} (auto-update + catalog ref survive the reinstall)`;
23290
+ mutating(
23291
+ issue2.command("close <ref>").description("close an issue and move its board item to Done (--reason completed|not-planned|duplicate-of --duplicate-of <n>)").option("--reason <reason>", "completed | not-planned | duplicate-of (defaults to completed)").option("--duplicate-of <number>", "the issue number this duplicates (use with --reason duplicate-of)").option("--comment <text>", "a closing comment to post").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
23292
+ (opts, args) => {
23293
+ let reason;
23294
+ try {
23295
+ reason = parseCloseReason(opts.reason).reason;
23296
+ } catch (e) {
23297
+ fail(`issue close: ${e.message}`, {
23298
+ code: ERROR_CODES.ERR_BAD_ENUM,
23299
+ offending_flag: "--reason",
23300
+ expected: ["completed", "not-planned", "duplicate-of"]
23301
+ });
23302
+ }
23303
+ if (reason === "duplicate-of" && !opts.duplicateOf) {
23304
+ fail("issue close: --reason duplicate-of requires --duplicate-of <number>", {
23305
+ code: ERROR_CODES.ERR_MISSING_FLAG,
23306
+ offending_flag: "--duplicate-of"
23307
+ });
23308
+ }
23309
+ return { command: "issue close", ref: args[0], reason: reason ?? "completed", duplicateOf: opts.duplicateOf };
23310
+ }
23311
+ ).action(async (ref, o) => {
23312
+ const parsed = parseCloseReason(o.reason);
23313
+ try {
23314
+ const defaultRepo = await resolveRepo(o.repo);
23315
+ const result = await closeIssue(defaultGitHubClient(), {
23316
+ ref,
23317
+ defaultRepo,
23318
+ reason: parsed.reason,
23319
+ duplicateOf: o.duplicateOf ? Number(o.duplicateOf) : void 0,
23320
+ comment: o.comment
23321
+ });
23322
+ console.log(JSON.stringify(result));
23323
+ } catch (e) {
23324
+ return failGraceful(`issue close failed: ${e.message}`);
23325
+ }
23326
+ });
23327
+ mutating(
23328
+ issue2.command("reopen <ref>").description("reopen a closed issue and move its board item back to Todo").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
23329
+ (_opts, args) => ({ command: "issue reopen", ref: args[0] })
23330
+ ).action(async (ref, o) => {
23331
+ try {
23332
+ const defaultRepo = await resolveRepo(o.repo);
23333
+ const result = await reopenIssue(defaultGitHubClient(), ref, defaultRepo);
23334
+ console.log(JSON.stringify(result));
23335
+ } catch (e) {
23336
+ return failGraceful(`issue reopen failed: ${e.message}`);
23337
+ }
23338
+ });
23339
+ mutating(
23340
+ issue2.command("assign <ref> <login>").description("assign a user to an issue \u2014 pure ownership, no board Status change (unlike board claim)").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
23341
+ (_opts, args) => ({ command: "issue assign", ref: args[0], login: args[1] })
23342
+ ).action(async (ref, login, o) => {
23343
+ try {
23344
+ const defaultRepo = await resolveRepo(o.repo);
23345
+ const result = await assignIssue(defaultGitHubClient(), ref, login, defaultRepo);
23346
+ console.log(JSON.stringify(result));
23347
+ } catch (e) {
23348
+ return failGraceful(`issue assign failed: ${e.message}`);
23349
+ }
23350
+ });
23351
+ mutating(
23352
+ issue2.command("unassign <ref> <login>").description("remove a user from an issue \u2014 pure ownership, no board Status change").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)"),
23353
+ (_opts, args) => ({ command: "issue unassign", ref: args[0], login: args[1] })
23354
+ ).action(async (ref, login, o) => {
23355
+ try {
23356
+ const defaultRepo = await resolveRepo(o.repo);
23357
+ const result = await unassignIssue(defaultGitHubClient(), ref, login, defaultRepo);
23358
+ console.log(JSON.stringify(result));
23359
+ } catch (e) {
23360
+ return failGraceful(`issue unassign failed: ${e.message}`);
23361
+ }
23362
+ });
23363
+ mutating(
23364
+ issue2.command("relocate <ref>").description("transfer an issue to another repo, sweep the ghost board item, add to the destination board, and carry priority").requiredOption("--to <owner/repo>", "destination repository").option("--repo <owner/repo>", "source repo for a bare ref (defaults to the current repo)"),
23365
+ (opts, args) => ({ command: "issue relocate", ref: args[0], to: opts.to })
23366
+ ).action(async (ref, o) => {
23367
+ try {
23368
+ const defaultRepo = await resolveRepo(o.repo);
23369
+ const result = await relocateIssue(defaultGitHubClient(), ref, o.to, defaultRepo);
23370
+ console.log(JSON.stringify(result));
23371
+ } catch (e) {
23372
+ return failGraceful(`issue relocate failed: ${e.message}`);
23373
+ }
23374
+ });
23375
+ mutating(
23376
+ issue2.command("unlink-child <parent> <child>").description("unlink a child issue from its parent (inverse of link-child) and print {parentNumber,subIssueNumber,totalCount} JSON").option("--repo <owner/repo>", "repo for bare refs on either side (defaults to the current repo)"),
23377
+ (_opts, args) => ({ command: "issue unlink-child", parent: args[0], child: args[1] })
23378
+ ).action(async (parentRef, childRef, o) => {
23379
+ const defaultRepo = await resolveRepo(o.repo);
23380
+ try {
23381
+ const result = await unlinkSubIssue(ghRunner, parentRef, childRef, defaultRepo);
23382
+ console.log(JSON.stringify(result));
23383
+ } catch (e) {
23384
+ const err = e;
23385
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
23386
+ return failGraceful(`issue unlink-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
23387
+ }
23388
+ });
23389
+ extendCreateCommand(issue2, deps.attach);
23200
23390
  }
23201
- async function runGuard(readOrigin) {
23202
- try {
23203
- const surface = detectSurface(process.env);
23204
- if (surfaceToken(surface) !== "claude") {
23205
- process.exitCode = 0;
23391
+ function extendCreateCommand(issue2, batchAttach) {
23392
+ const createCmd = issue2.commands.find((c) => c.name() === "create");
23393
+ if (!createCmd) return;
23394
+ createCmd.option("--batch <file.json>", "create multiple issues from a JSON array file (pre-validates all rows before creating any)").option("--idempotency-key <key>", "create-if-not-exists: skip creation when an issue with this key already exists (retried loops must not duplicate)");
23395
+ const internal = createCmd;
23396
+ const originalListener = internal._actionHandler;
23397
+ internal._actionHandler = async (args) => {
23398
+ const opts = createCmd.opts();
23399
+ if (opts.batch) {
23400
+ let specs;
23401
+ try {
23402
+ const raw = (0, import_node_fs29.readFileSync)(opts.batch, "utf8");
23403
+ specs = JSON.parse(raw);
23404
+ if (!Array.isArray(specs)) throw new Error("batch file must contain a JSON array");
23405
+ } catch (e) {
23406
+ return fail(`issue create --batch: cannot read/parse ${opts.batch}: ${e.message}`);
23407
+ }
23408
+ const validation = validateBatchSpecs(specs);
23409
+ if (!validation.ok) {
23410
+ const lines = validation.errors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
23411
+ return fail(`issue create --batch: validation failed (${validation.errors.length} error(s)):
23412
+ ${lines}`);
23413
+ }
23414
+ const batchParent = opts.parent;
23415
+ const batchPriority = opts.priority ? normalizePriority(opts.priority) : void 0;
23416
+ if (opts.dryRun || opts.validateOnly) {
23417
+ const planned = validation.validated.map((v) => ({
23418
+ row: v.row,
23419
+ type: v.type,
23420
+ title: v.spec.title,
23421
+ priority: v.spec.priority ? v.priority : batchPriority ?? v.priority,
23422
+ repo: v.spec.repo,
23423
+ ...v.spec.parent ?? batchParent ? { parent: v.spec.parent ?? batchParent } : {}
23424
+ }));
23425
+ console.log(JSON.stringify(opts.validateOnly ? { ok: true, planned } : { dry_run: true, planned }));
23426
+ return;
23427
+ }
23428
+ try {
23429
+ const result = await createIssuesBatch(specs, {
23430
+ repo: opts.repo,
23431
+ idempotencyKey: opts.idempotencyKey,
23432
+ parent: batchParent,
23433
+ priority: batchPriority
23434
+ }, { attach: batchAttach });
23435
+ console.log(JSON.stringify(result));
23436
+ if (result.failures.length) process.exitCode = 1;
23437
+ } catch (e) {
23438
+ return failGraceful(`issue create --batch failed: ${e.message}`);
23439
+ }
23206
23440
  return;
23207
23441
  }
23208
- const isOrgRepo = readOrigin ? await isOrgRepoRoot(readOrigin) : await isOrgRepoRoot();
23209
- const input = snapshotPluginGuardInput(surface, isOrgRepo);
23210
- const { state } = buildPluginGuardDecision(input);
23211
- const { line, exitCode } = buildPluginGuardLine(state);
23212
- if (line) console.error(line);
23213
- process.exitCode = exitCode;
23214
- } catch {
23215
- process.exitCode = 0;
23216
- }
23217
- }
23218
- async function runPluginHeal(surface = detectSurface(process.env)) {
23219
- if (surfaceToken(surface) !== "claude") {
23220
- console.log(nonClaudeSurfaceHealMessage(surfaceToken(surface) ?? void 0));
23221
- return;
23222
- }
23223
- const descriptor = PLUGIN_SURFACE_HEAL.claude;
23224
- const healed = await applyPluginHeal(surface, console.log, { force: true });
23225
- if (healed) {
23226
- console.log(` \u2713 MMI plugin reinstalled \u2014 ${reloadAction(surface)} to load MMI commands`);
23227
- } else {
23228
- const refSupported = await marketplaceAddRefSupported("claude");
23229
- const recovery = refSupported ? descriptor.recovery : recoveryWithoutRef(descriptor.recovery);
23230
- const note = refAbsenceNote("claude", refSupported);
23231
- console.log(` \u2717 Auto-heal failed or was skipped. Run manually:
23232
- ${recovery}${note}${pluginReadGrantNote()}`);
23233
- }
23442
+ if (opts.idempotencyKey && !opts.dryRun && !opts.validateOnly) {
23443
+ try {
23444
+ const repo = await resolveRepo(opts.repo);
23445
+ if (repo) {
23446
+ const existing = await findExistingByIdempotencyKey(defaultGitHubClient(), opts.idempotencyKey, repo);
23447
+ if (existing) {
23448
+ console.log(JSON.stringify({ ...existing, idempotent: true }));
23449
+ return;
23450
+ }
23451
+ }
23452
+ } catch {
23453
+ }
23454
+ }
23455
+ return originalListener?.(args);
23456
+ };
23234
23457
  }
23235
23458
 
23459
+ // src/train-commands.ts
23460
+ var import_node_fs30 = require("node:fs");
23461
+ var import_node_path28 = require("node:path");
23462
+
23236
23463
  // src/train-status.ts
23237
23464
  function buildTrainStatusReport(input) {
23238
23465
  const releaseTrack = resolveReleaseTrack(input.meta, void 0, input.repo);
@@ -23271,7 +23498,7 @@ function formatTrainStatus(r) {
23271
23498
  // src/train-commands.ts
23272
23499
  function readRepoVersion() {
23273
23500
  try {
23274
- return JSON.parse((0, import_node_fs29.readFileSync)((0, import_node_path27.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
23501
+ return JSON.parse((0, import_node_fs30.readFileSync)((0, import_node_path28.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
23275
23502
  } catch {
23276
23503
  return void 0;
23277
23504
  }
@@ -23417,9 +23644,9 @@ function registerDeployCommands(program3) {
23417
23644
  }
23418
23645
 
23419
23646
  // src/discovery-commands.ts
23420
- var import_node_fs30 = require("node:fs");
23421
- var import_node_os7 = require("node:os");
23422
- var import_node_path28 = require("node:path");
23647
+ var import_node_fs31 = require("node:fs");
23648
+ var import_node_os8 = require("node:os");
23649
+ var import_node_path29 = require("node:path");
23423
23650
  var GC_GH_TIMEOUT_MS3 = 2e4;
23424
23651
  async function collectStatus() {
23425
23652
  let branch = "";
@@ -23594,10 +23821,10 @@ async function collectOnboardStatus() {
23594
23821
  else if (top) nextCommand = `mmi-cli board claim ${top.number} # ${top.title}`;
23595
23822
  else nextCommand = "mmi-cli board read \u2014 no claimable items found";
23596
23823
  }
23597
- const home = (0, import_node_os7.homedir)();
23824
+ const home = (0, import_node_os8.homedir)();
23598
23825
  const plugin = onboardPluginGate({
23599
- readKnown: () => readFileSyncSafe((0, import_node_path28.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs30.readFileSync),
23600
- readSettings: () => readFileSyncSafe((0, import_node_path28.join)(home, ".claude", "settings.json"), import_node_fs30.readFileSync)
23826
+ readKnown: () => readFileSyncSafe((0, import_node_path29.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs31.readFileSync),
23827
+ readSettings: () => readFileSyncSafe((0, import_node_path29.join)(home, ".claude", "settings.json"), import_node_fs31.readFileSync)
23601
23828
  });
23602
23829
  return { track, board, registry: registry2, secrets, plugin, nextCommand };
23603
23830
  }
@@ -25017,9 +25244,9 @@ function ghAccountCaveat(announcedLogin, accounts) {
25017
25244
  }
25018
25245
 
25019
25246
  // src/doctor-io.ts
25020
- var import_node_fs31 = require("node:fs");
25021
- var import_node_os8 = require("node:os");
25022
- var import_node_path29 = require("node:path");
25247
+ var import_node_fs32 = require("node:fs");
25248
+ var import_node_os9 = require("node:os");
25249
+ var import_node_path30 = require("node:path");
25023
25250
  var import_node_child_process12 = require("node:child_process");
25024
25251
  var import_node_util8 = require("node:util");
25025
25252
  var execFileP6 = (0, import_node_util8.promisify)(import_node_child_process12.execFile);
@@ -25027,7 +25254,7 @@ var MMI_PLUGIN_ID2 = "mmi@mutmutco";
25027
25254
  function installedClaudePluginVersion() {
25028
25255
  try {
25029
25256
  const file = JSON.parse(
25030
- (0, import_node_fs31.readFileSync)((0, import_node_path29.join)((0, import_node_os8.homedir)(), ".claude", "plugins", "installed_plugins.json"), "utf8")
25257
+ (0, import_node_fs32.readFileSync)((0, import_node_path30.join)((0, import_node_os9.homedir)(), ".claude", "plugins", "installed_plugins.json"), "utf8")
25031
25258
  );
25032
25259
  const versions = (file.plugins?.[MMI_PLUGIN_ID2] ?? []).map((r) => r.version).filter((v) => Boolean(v));
25033
25260
  if (versions.length === 0) return void 0;
@@ -25048,13 +25275,13 @@ function worktreeRootSync() {
25048
25275
  }
25049
25276
  var gitignorePath = () => {
25050
25277
  const root = worktreeRootSync();
25051
- return root === null ? null : (0, import_node_path29.join)(root, ".gitignore");
25278
+ return root === null ? null : (0, import_node_path30.join)(root, ".gitignore");
25052
25279
  };
25053
25280
  function readGitignore() {
25054
25281
  const path2 = gitignorePath();
25055
25282
  if (path2 === null) return null;
25056
25283
  try {
25057
- return (0, import_node_fs31.readFileSync)(path2, "utf8");
25284
+ return (0, import_node_fs32.readFileSync)(path2, "utf8");
25058
25285
  } catch {
25059
25286
  return null;
25060
25287
  }
@@ -25063,7 +25290,7 @@ function writeGitignore(content) {
25063
25290
  const path2 = gitignorePath();
25064
25291
  if (path2 === null) return false;
25065
25292
  try {
25066
- (0, import_node_fs31.writeFileSync)(path2, content, "utf8");
25293
+ (0, import_node_fs32.writeFileSync)(path2, content, "utf8");
25067
25294
  return true;
25068
25295
  } catch {
25069
25296
  return false;
@@ -25087,7 +25314,7 @@ async function repoRoot() {
25087
25314
  }
25088
25315
  function hasRepoLocalWorktrees() {
25089
25316
  const root = worktreeRootSync();
25090
- return root !== null && (0, import_node_fs31.existsSync)((0, import_node_path29.join)(root, ".worktrees"));
25317
+ return root !== null && (0, import_node_fs32.existsSync)((0, import_node_path30.join)(root, ".worktrees"));
25091
25318
  }
25092
25319
 
25093
25320
  // src/index.ts
@@ -25122,8 +25349,8 @@ ${r.stderr ?? ""}`).catch(() => "");
25122
25349
  function ghMultiAccountCaveat(announcedLogin) {
25123
25350
  try {
25124
25351
  const hostsPath = ghHostsConfigPath(process.env, process.platform);
25125
- if (!hostsPath || !(0, import_node_fs32.existsSync)(hostsPath)) return void 0;
25126
- return ghAccountCaveat(announcedLogin, parseGhHostsAccounts((0, import_node_fs32.readFileSync)(hostsPath, "utf8")));
25352
+ if (!hostsPath || !(0, import_node_fs33.existsSync)(hostsPath)) return void 0;
25353
+ return ghAccountCaveat(announcedLogin, parseGhHostsAccounts((0, import_node_fs33.readFileSync)(hostsPath, "utf8")));
25127
25354
  } catch {
25128
25355
  return void 0;
25129
25356
  }
@@ -25131,12 +25358,12 @@ function ghMultiAccountCaveat(announcedLogin) {
25131
25358
  var ENV_HEAL_LOCK_STALE_MS = 10 * 6e4;
25132
25359
  var ENV_HEAL_LOCK_MAX_WAIT_MS = 2 * 6e4;
25133
25360
  function envHealLockPath(home) {
25134
- return (0, import_node_path30.join)(home, ".claude", "plugins", ".mmi-env-heal.lock");
25361
+ return (0, import_node_path31.join)(home, ".claude", "plugins", ".mmi-env-heal.lock");
25135
25362
  }
25136
25363
  async function withEnvHealLock(what, run) {
25137
25364
  try {
25138
25365
  return await withFileLock(
25139
- envHealLockPath((0, import_node_os9.homedir)()),
25366
+ envHealLockPath((0, import_node_os10.homedir)()),
25140
25367
  { staleMs: ENV_HEAL_LOCK_STALE_MS, maxWaitMs: ENV_HEAL_LOCK_MAX_WAIT_MS, label: "mmi env-heal lock" },
25141
25368
  run
25142
25369
  );
@@ -25231,9 +25458,9 @@ function mmiDoctorDeps(opts = {}) {
25231
25458
  },
25232
25459
  pluginCache: () => {
25233
25460
  const plan = buildPluginCachePlan(
25234
- (0, import_node_os9.homedir)(),
25461
+ (0, import_node_os10.homedir)(),
25235
25462
  runningPluginVersion(process.env, resolveClientVersion()),
25236
- pluginCacheFsDeps((0, import_node_os9.homedir)(), () => 0)
25463
+ pluginCacheFsDeps((0, import_node_os10.homedir)(), () => 0)
25237
25464
  );
25238
25465
  return {
25239
25466
  stale: plan.prune,
@@ -25279,11 +25506,11 @@ function mmiDoctorDeps(opts = {}) {
25279
25506
  // which branch it would pick one up from. Two local file reads, no network, fail-soft to no rows.
25280
25507
  marketplaceRows: () => {
25281
25508
  try {
25282
- const home = (0, import_node_os9.homedir)();
25509
+ const home = (0, import_node_os10.homedir)();
25283
25510
  return marketplaceRows(
25284
25511
  MMI_MARKETPLACE_NAME,
25285
- readFileSyncSafe((0, import_node_path30.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs32.readFileSync),
25286
- readFileSyncSafe((0, import_node_path30.join)(home, ".claude", "settings.json"), import_node_fs32.readFileSync)
25512
+ readFileSyncSafe((0, import_node_path31.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs33.readFileSync),
25513
+ readFileSyncSafe((0, import_node_path31.join)(home, ".claude", "settings.json"), import_node_fs33.readFileSync)
25287
25514
  );
25288
25515
  } catch {
25289
25516
  return [];
@@ -25413,19 +25640,19 @@ program2.hook("preAction", async (_thisCommand, actionCommand) => {
25413
25640
  });
25414
25641
  var rules = program2.command("rules").description("org-managed .gitignore delivery");
25415
25642
  rules.command("gitignore").option("--write", "upsert the managed block into .gitignore (default: check only, non-zero exit on drift)").option("--json", "machine-readable output").description("verify (or --write) this repo's org-managed .gitignore block matches the SSOT").action((opts) => {
25416
- const path2 = (0, import_node_path30.join)(process.cwd(), ".gitignore");
25417
- const current = (0, import_node_fs32.existsSync)(path2) ? (0, import_node_fs32.readFileSync)(path2, "utf8") : null;
25643
+ const path2 = (0, import_node_path31.join)(process.cwd(), ".gitignore");
25644
+ const current = (0, import_node_fs33.existsSync)(path2) ? (0, import_node_fs33.readFileSync)(path2, "utf8") : null;
25418
25645
  const plan = planManagedGitignore(current);
25419
25646
  const drift = [...plan.added.map((l) => `+${l}`), ...plan.removed.map((l) => `-${l}`)].join(", ") || "block normalize";
25420
25647
  if (opts.json) {
25421
- if (opts.write && plan.changed) (0, import_node_fs32.writeFileSync)(path2, plan.content, "utf8");
25648
+ if (opts.write && plan.changed) (0, import_node_fs33.writeFileSync)(path2, plan.content, "utf8");
25422
25649
  console.log(JSON.stringify(plan, null, 2));
25423
25650
  if (!opts.write && plan.changed) process.exitCode = 1;
25424
25651
  return;
25425
25652
  }
25426
25653
  if (opts.write) {
25427
25654
  if (plan.changed) {
25428
- (0, import_node_fs32.writeFileSync)(path2, plan.content, "utf8");
25655
+ (0, import_node_fs33.writeFileSync)(path2, plan.content, "utf8");
25429
25656
  console.log(`mmi-cli org rules gitignore: updated .gitignore (${drift})`);
25430
25657
  } else {
25431
25658
  console.log("mmi-cli org rules gitignore: up to date");
@@ -25535,7 +25762,7 @@ gcCmd.command("sweep-deferred").description("retry IDE-locked deferred worktree
25535
25762
  process.exit(process.exitCode ?? 0);
25536
25763
  });
25537
25764
  });
25538
- gcCmd.option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR local+remote branches, linked worktrees, and stale tracking refs").option("--json", "machine-readable output").option("--scratch", "prune safe local scratch (#1864) instead of git branches/refs; local plans are surfaced advisory-only, never auto-pruned").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").option("--root <path>", "sweep an explicitly named worktrees root instead of the authoritative ../mmi-worktrees (#3471) \u2014 descends into this repo container when present; ownership, dead-dir, and content guards still apply").action(async (o) => {
25765
+ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR local+remote branches, linked worktrees, and stale tracking refs").option("--json", "machine-readable output").option("--scratch", "prune safe local scratch (#1864) instead of git branches/refs; local plans are surfaced advisory-only, never auto-pruned").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").option("--force", "remove worktrees even when the ownership registry shows another session created or worked in them recently (#3580)").option("--root <path>", "sweep an explicitly named worktrees root instead of the authoritative ../mmi-worktrees (#3471) \u2014 descends into this repo container when present; ownership, dead-dir, and content guards still apply").action(async (o) => {
25539
25766
  if (o.apply && o.dryRun) return fail("worktree gc: choose either --dry-run or --apply");
25540
25767
  if (o.scratch) {
25541
25768
  try {
@@ -25551,8 +25778,8 @@ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--appl
25551
25778
  if (!Number.isFinite(limit) || limit < 1) return fail("worktree gc: --limit must be a positive integer");
25552
25779
  let root;
25553
25780
  if (o.root !== void 0) {
25554
- root = (0, import_node_path30.resolve)(o.root);
25555
- if (!(0, import_node_fs32.existsSync)(root) || !(0, import_node_fs32.statSync)(root).isDirectory()) return fail(`worktree gc: --root ${o.root} is not a directory`);
25781
+ root = (0, import_node_path31.resolve)(o.root);
25782
+ if (!(0, import_node_fs33.existsSync)(root) || !(0, import_node_fs33.statSync)(root).isDirectory()) return fail(`worktree gc: --root ${o.root} is not a directory`);
25556
25783
  const gcRepoRoot = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
25557
25784
  if (isPathUnderDirectory(gcRepoRoot, root)) {
25558
25785
  return fail(`worktree gc: --root ${root} contains this checkout \u2014 name a worktrees root, not the repo or an ancestor of it`);
@@ -25572,7 +25799,7 @@ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--appl
25572
25799
  // git fsmonitor daemon from inheriting this sweep's stdio pipe and wedging the git call on Windows.
25573
25800
  worktreeRemoveDeps(async (args) => (await execFileP2("git", ["-c", "core.fsmonitor=false", ...args], { timeout: GIT_TIMEOUT_MS })).stdout)
25574
25801
  ).catch(() => void 0);
25575
- applyResult = await applyGcPlan(plan, o.remote, { root });
25802
+ applyResult = await applyGcPlan(plan, o.remote, { root, force: o.force });
25576
25803
  }
25577
25804
  if (o.json) {
25578
25805
  console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, ...root ? { root } : {}, plan, applyResult }, null, 2));
@@ -25616,6 +25843,18 @@ function runWorktreeInstall(command, cwd, quiet) {
25616
25843
  async function primaryCheckoutRoot(from) {
25617
25844
  return primaryCheckoutRootOf(async (args) => (await execFileP2("git", ["-C", from, ...args], { timeout: GIT_TIMEOUT_MS })).stdout);
25618
25845
  }
25846
+ async function unprovenWorktreeReason(wtPath, repoRoot2) {
25847
+ if (!(0, import_node_fs33.existsSync)(wtPath)) return `${wtPath} does not exist on disk`;
25848
+ const porcelain = (await execFileP2("git", ["-C", repoRoot2, "worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout;
25849
+ const registered = parseWorktreePorcelainEntries(porcelain);
25850
+ if (!registered.length) {
25851
+ return `'git worktree list' could not be read from ${repoRoot2}, so ${wtPath} could not be proven registered`;
25852
+ }
25853
+ if (!registered.some((w) => samePath(w.path, wtPath))) {
25854
+ return `${wtPath} exists on disk but 'git worktree list' does not name it \u2014 its git metadata is gone, so it is not a usable worktree`;
25855
+ }
25856
+ return void 0;
25857
+ }
25619
25858
  function makeProvisionDeps(worktreeRoot, quiet, log) {
25620
25859
  return {
25621
25860
  runInstall: (command, cwd) => runWorktreeInstall(command, cwd, quiet),
@@ -25626,26 +25865,26 @@ function makeProvisionDeps(worktreeRoot, quiet, log) {
25626
25865
  function acquireWorktreeSetupLock(worktreeRoot) {
25627
25866
  const lockPath = repoRuntimeStatePath(worktreeRoot, "worktree-setup.lock");
25628
25867
  const take = () => {
25629
- const fd = (0, import_node_fs32.openSync)(lockPath, "wx");
25868
+ const fd = (0, import_node_fs33.openSync)(lockPath, "wx");
25630
25869
  try {
25631
- (0, import_node_fs32.writeSync)(fd, String(Date.now()));
25870
+ (0, import_node_fs33.writeSync)(fd, String(Date.now()));
25632
25871
  } finally {
25633
- (0, import_node_fs32.closeSync)(fd);
25872
+ (0, import_node_fs33.closeSync)(fd);
25634
25873
  }
25635
25874
  return () => {
25636
25875
  try {
25637
- (0, import_node_fs32.rmSync)(lockPath, { force: true });
25876
+ (0, import_node_fs33.rmSync)(lockPath, { force: true });
25638
25877
  } catch {
25639
25878
  }
25640
25879
  };
25641
25880
  };
25642
25881
  try {
25643
- (0, import_node_fs32.mkdirSync)((0, import_node_path30.dirname)(lockPath), { recursive: true });
25882
+ (0, import_node_fs33.mkdirSync)((0, import_node_path31.dirname)(lockPath), { recursive: true });
25644
25883
  return take();
25645
25884
  } catch {
25646
25885
  try {
25647
- if (Date.now() - (0, import_node_fs32.statSync)(lockPath).mtimeMs > WORKTREE_SETUP_LOCK_TTL_MS) {
25648
- (0, import_node_fs32.rmSync)(lockPath, { force: true });
25886
+ if (Date.now() - (0, import_node_fs33.statSync)(lockPath).mtimeMs > WORKTREE_SETUP_LOCK_TTL_MS) {
25887
+ (0, import_node_fs33.rmSync)(lockPath, { force: true });
25649
25888
  return take();
25650
25889
  }
25651
25890
  } catch {
@@ -25658,6 +25897,7 @@ withExamples(mutating(
25658
25897
  worktree.command("create <branch-or-issue>").description("create a worktree from a base ref and provision it (install deps + copy local-only config); an issue ref derives the <issue>-<slug> branch, and --claim claims its board item (#2996, formerly worktree new)").option("--from <ref>", "base ref to branch from", "origin/development").option("--base <ref>", "alias of --from (git's own word for the base ref)").option("--path <path>", "worktree path (default: ../mmi-worktrees/<RepoName>/<branch> \u2014 authoritative; the <RepoName> segment is what makes ownership provable from the path, #3471. gc/list read this root in BOTH the nested and the legacy flat shape)").option("--remote <name>", "remote to fetch when --from is a <remote>/<branch> ref", "origin").option("--slug <slug>", "issue-ref form: override the slug derived from the issue title").option("--claim", "issue-ref form: claim the issue's board item after provisioning").option("--for <login>", "with --claim: assign the board item to this login instead of @me"),
25659
25898
  worktreeCreatePlan
25660
25899
  ).action(async (target, o, cmd) => {
25900
+ let step = "resolve the branch name";
25661
25901
  try {
25662
25902
  if (o.base !== void 0 && cmd.getOptionValueSource("from") === "cli" && o.base !== o.from) {
25663
25903
  return fail(`worktree create: --from and --base are the same option (--base is an alias); got --from ${o.from} and --base ${o.base} \u2014 pass one`);
@@ -25681,10 +25921,12 @@ withExamples(mutating(
25681
25921
  const repoRoot2 = await primaryCheckoutRoot(process.cwd()) ?? process.cwd();
25682
25922
  const wtPath = o.path ?? defaultWorktreePath(repoRoot2, branch);
25683
25923
  const { base, fetchBranch } = resolveWorktreeBase(fromRef, o.remote);
25924
+ step = `fetch the base ref ${fromRef}`;
25684
25925
  if (fetchBranch) {
25685
25926
  const fetchErr = await execFileP2("git", ["fetch", o.remote, fetchBranch], { timeout: GH_MUTATION_TIMEOUT_MS }).then(() => void 0).catch((e) => (e instanceof Error ? e.message : String(e)).split("\n")[0]);
25686
25927
  if (fetchErr) console.error(` warning: could not fetch ${o.remote}/${fetchBranch} (${fetchErr}); base ${base} may be stale`);
25687
25928
  }
25929
+ step = `git worktree add ${wtPath}`;
25688
25930
  await addWorktreeRobust(wtPath, branch, base, {
25689
25931
  git: async (args) => (await execFileP2("git", args, { timeout: GH_MUTATION_TIMEOUT_MS })).stdout,
25690
25932
  revParse: async (ref) => {
@@ -25700,11 +25942,13 @@ withExamples(mutating(
25700
25942
  if (!o.json) console.error(` ${m}`);
25701
25943
  }
25702
25944
  });
25945
+ step = "install deps + copy local-only config";
25703
25946
  const report = await provisionWorktree(wtPath, makeProvisionDeps(wtPath, Boolean(o.json), (m) => {
25704
25947
  if (!o.json) console.error(` ${m}`);
25705
25948
  }));
25706
25949
  let claim;
25707
25950
  let claimError;
25951
+ step = "claim the board item";
25708
25952
  if (issueForm && o.claim && selector) {
25709
25953
  try {
25710
25954
  const claimRaw = await selfShell(
@@ -25716,6 +25960,21 @@ withExamples(mutating(
25716
25960
  claimError = e.message.split("\n")[0];
25717
25961
  }
25718
25962
  }
25963
+ const unproven = await unprovenWorktreeReason(wtPath, repoRoot2);
25964
+ if (unproven) {
25965
+ const detail = `worktree create: reported success but ${unproven}. The create either failed silently or the worktree was removed after it ran (MMI-Hub#3580). Re-run this command; if it recurs, check \`mmi-cli worktree events\` for what removed it.`;
25966
+ if (o.json) {
25967
+ console.log(JSON.stringify({ ok: false, branch, path: wtPath, base, error: detail }, null, 2));
25968
+ } else {
25969
+ console.error(detail);
25970
+ }
25971
+ process.exitCode = 1;
25972
+ return;
25973
+ }
25974
+ const createActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: process.cwd() });
25975
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
25976
+ recordWorktreeOwner(repoRoot2, { path: wtPath, branch, createdAt, lastSeenAt: createdAt, actor: createActor });
25977
+ appendWorktreeEvent(repoRoot2, { action: "created", command: "worktree create", target: wtPath, branch, actor: createActor });
25719
25978
  if (o.json) {
25720
25979
  return console.log(JSON.stringify({
25721
25980
  branch,
@@ -25734,7 +25993,7 @@ withExamples(mutating(
25734
25993
  else console.log(` board item ${selector.repo}#${selector.number} claimed`);
25735
25994
  }
25736
25995
  } catch (e) {
25737
- fail(`worktree create: ${e.message}`);
25996
+ fail(`worktree create: failed at step '${step}' \u2014 ${e.message}`);
25738
25997
  }
25739
25998
  }), [
25740
25999
  "mmi-cli worktree create fix/gc-sweep-2687",
@@ -25765,6 +26024,14 @@ worktree.command("setup [path]").description("provision an existing worktree (in
25765
26024
  release();
25766
26025
  }
25767
26026
  });
26027
+ worktree.command("events").description("who created and who removed this repo's worktrees \u2014 the append-only attribution log (#3580)").option("--limit <n>", "most recent events to show", "50").option("--json", "machine-readable output").action(async (o) => {
26028
+ const limit = Number.parseInt(o.limit, 10);
26029
+ if (!Number.isFinite(limit) || limit < 1) return fail("worktree events: --limit must be a positive integer");
26030
+ const primaryRoot = await primaryCheckoutRoot(process.cwd()) ?? process.cwd();
26031
+ const events = readWorktreeEvents(primaryRoot, limit);
26032
+ if (o.json) return console.log(JSON.stringify({ events, count: events.length }, null, 2));
26033
+ console.log(formatWorktreeEvents(events));
26034
+ });
25768
26035
  async function ghJson(args, timeout = 1e4) {
25769
26036
  const { stdout } = await execFileP2("gh", args, { timeout });
25770
26037
  return JSON.parse(stdout);
@@ -26087,7 +26354,7 @@ project.command("set [owner/repo]").description("upsert project META (idempotent
26087
26354
  if (dupe) return fail(`org project set: KEY "${dupe}" was passed to both --var and --set; --set is an alias of --var, so pass each KEY once`);
26088
26355
  if (o.secretsFile) {
26089
26356
  try {
26090
- vars.push(`secrets=${(0, import_node_fs32.readFileSync)(o.secretsFile, "utf8")}`);
26357
+ vars.push(`secrets=${(0, import_node_fs33.readFileSync)(o.secretsFile, "utf8")}`);
26091
26358
  } catch (e) {
26092
26359
  return fail(`org project set: cannot read --secrets-file ${o.secretsFile}: ${e.message}`);
26093
26360
  }
@@ -26743,11 +27010,11 @@ pr.command("view <number>").description("read a PR as structured JSON (merged st
26743
27010
  }
26744
27011
  });
26745
27012
  async function listCiWorkflowPaths(cwd = process.cwd()) {
26746
- const wfDir = (0, import_node_path30.join)(cwd, ".github", "workflows");
26747
- if (!(0, import_node_fs32.existsSync)(wfDir)) return [];
26748
- return (0, import_node_fs32.readdirSync)(wfDir).filter((name) => /\.(ya?ml)$/i.test(name)).filter((name) => {
27013
+ const wfDir = (0, import_node_path31.join)(cwd, ".github", "workflows");
27014
+ if (!(0, import_node_fs33.existsSync)(wfDir)) return [];
27015
+ return (0, import_node_fs33.readdirSync)(wfDir).filter((name) => /\.(ya?ml)$/i.test(name)).filter((name) => {
26749
27016
  try {
26750
- return workflowReportsPrChecks((0, import_node_fs32.readFileSync)((0, import_node_path30.join)(wfDir, name), "utf8"));
27017
+ return workflowReportsPrChecks((0, import_node_fs33.readFileSync)((0, import_node_path31.join)(wfDir, name), "utf8"));
26751
27018
  } catch {
26752
27019
  return true;
26753
27020
  }
@@ -26774,16 +27041,16 @@ function ciAuditDeps() {
26774
27041
  // gate re-seed step is skipped gracefully rather than failing mid-run.
26775
27042
  readSeedFile: (path2) => {
26776
27043
  if (!root) return null;
26777
- const fullPath = (0, import_node_path30.join)(root, path2);
26778
- return (0, import_node_fs32.existsSync)(fullPath) ? (0, import_node_fs32.readFileSync)(fullPath, "utf8") : null;
27044
+ const fullPath = (0, import_node_path31.join)(root, path2);
27045
+ return (0, import_node_fs33.existsSync)(fullPath) ? (0, import_node_fs33.readFileSync)(fullPath, "utf8") : null;
26779
27046
  }
26780
27047
  };
26781
27048
  }
26782
27049
  function hubRoot() {
26783
- const fromPkg = (0, import_node_path30.join)(__dirname, "..", "..");
27050
+ const fromPkg = (0, import_node_path31.join)(__dirname, "..", "..");
26784
27051
  const marker = "skills/bootstrap/seeds/manifest.json";
26785
- if ((0, import_node_fs32.existsSync)((0, import_node_path30.join)(fromPkg, marker))) return fromPkg;
26786
- if ((0, import_node_fs32.existsSync)((0, import_node_path30.join)(process.cwd(), marker))) return process.cwd();
27052
+ if ((0, import_node_fs33.existsSync)((0, import_node_path31.join)(fromPkg, marker))) return fromPkg;
27053
+ if ((0, import_node_fs33.existsSync)((0, import_node_path31.join)(process.cwd(), marker))) return process.cwd();
26787
27054
  return null;
26788
27055
  }
26789
27056
  pr.command("ci-policy").description("report merge CI policy: wait-for-checks vs no-ci (for grind/build agents)").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current checkout)").action(async (o) => {
@@ -27060,7 +27327,7 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
27060
27327
  localCleanup = await cleanupPrMergeLocalBranch(headRef, {
27061
27328
  beforeWorktrees,
27062
27329
  startingPath,
27063
- pathExists: (p) => (0, import_node_fs32.existsSync)(p),
27330
+ pathExists: (p) => (0, import_node_fs33.existsSync)(p),
27064
27331
  execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
27065
27332
  teardownWorktreeStage,
27066
27333
  deferredStore,
@@ -27401,10 +27668,10 @@ access.command("audit").description("audit collaborator roles + train-branch pus
27401
27668
  targets = resolution.targets;
27402
27669
  }
27403
27670
  const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
27404
- const fileMatrix = (0, import_node_fs32.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs32.readFileSync)("access-matrix.json", "utf8")) : {};
27671
+ const fileMatrix = (0, import_node_fs33.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs33.readFileSync)("access-matrix.json", "utf8")) : {};
27405
27672
  const matrix = mergeAccessMatrix(fileMatrix, derivedMatrix);
27406
27673
  const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
27407
- const fileContracts = (0, import_node_fs32.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs32.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
27674
+ const fileContracts = (0, import_node_fs33.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs33.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
27408
27675
  const dataAccess = mergeDataAccessContracts(fileContracts, derivedContracts);
27409
27676
  const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
27410
27677
  console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
@@ -27438,16 +27705,16 @@ function directoryBytes(path2) {
27438
27705
  let total = 0;
27439
27706
  let entries;
27440
27707
  try {
27441
- entries = (0, import_node_fs32.readdirSync)(path2, { withFileTypes: true });
27708
+ entries = (0, import_node_fs33.readdirSync)(path2, { withFileTypes: true });
27442
27709
  } catch {
27443
27710
  return 0;
27444
27711
  }
27445
27712
  for (const entry of entries) {
27446
- const child2 = (0, import_node_path30.join)(path2, entry.name);
27713
+ const child2 = (0, import_node_path31.join)(path2, entry.name);
27447
27714
  if (entry.isDirectory()) total += directoryBytes(child2);
27448
27715
  else {
27449
27716
  try {
27450
- total += (0, import_node_fs32.statSync)(child2).size;
27717
+ total += (0, import_node_fs33.statSync)(child2).size;
27451
27718
  } catch {
27452
27719
  }
27453
27720
  }
@@ -27455,25 +27722,25 @@ function directoryBytes(path2) {
27455
27722
  return total;
27456
27723
  }
27457
27724
  function listDirEntries(dir) {
27458
- return (0, import_node_fs32.readdirSync)(dir, { withFileTypes: true }).map((d) => ({ name: d.name, isDirectory: d.isDirectory() }));
27725
+ return (0, import_node_fs33.readdirSync)(dir, { withFileTypes: true }).map((d) => ({ name: d.name, isDirectory: d.isDirectory() }));
27459
27726
  }
27460
27727
  function readInstalledPluginRefs(home) {
27461
27728
  const p = installedPluginsPath(home);
27462
- if (!(0, import_node_fs32.existsSync)(p)) return [];
27729
+ if (!(0, import_node_fs33.existsSync)(p)) return [];
27463
27730
  try {
27464
- return installedPluginPaths((0, import_node_fs32.readFileSync)(p, "utf8"));
27731
+ return installedPluginPaths((0, import_node_fs33.readFileSync)(p, "utf8"));
27465
27732
  } catch {
27466
27733
  return null;
27467
27734
  }
27468
27735
  }
27469
27736
  function pluginCacheFsDeps(home, dirBytes) {
27470
27737
  return {
27471
- exists: (p) => (0, import_node_fs32.existsSync)(p),
27472
- listVersionDirs: (root) => (0, import_node_fs32.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name),
27738
+ exists: (p) => (0, import_node_fs33.existsSync)(p),
27739
+ listVersionDirs: (root) => (0, import_node_fs33.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name),
27473
27740
  dirBytes,
27474
- listStagingDirs: (root) => (0, import_node_fs32.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
27741
+ listStagingDirs: (root) => (0, import_node_fs33.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
27475
27742
  try {
27476
- return { name: d.name, mtimeMs: newestMtimeMs((0, import_node_path30.join)(root, d.name), listDirEntries, (p) => (0, import_node_fs32.statSync)(p).mtimeMs) };
27743
+ return { name: d.name, mtimeMs: newestMtimeMs((0, import_node_path31.join)(root, d.name), listDirEntries, (p) => (0, import_node_fs33.statSync)(p).mtimeMs) };
27477
27744
  } catch {
27478
27745
  return { name: d.name, mtimeMs: Date.now() };
27479
27746
  }
@@ -27487,10 +27754,10 @@ function stagingApplyFsGuard(home) {
27487
27754
  return {
27488
27755
  referencedPaths: () => readInstalledPluginRefs(home),
27489
27756
  mtimeMs: (name) => {
27490
- const p = (0, import_node_path30.join)(stagingRoot, name);
27491
- if (!(0, import_node_fs32.existsSync)(p)) return null;
27757
+ const p = (0, import_node_path31.join)(stagingRoot, name);
27758
+ if (!(0, import_node_fs33.existsSync)(p)) return null;
27492
27759
  try {
27493
- return newestMtimeMs(p, listDirEntries, (q) => (0, import_node_fs32.statSync)(q).mtimeMs);
27760
+ return newestMtimeMs(p, listDirEntries, (q) => (0, import_node_fs33.statSync)(q).mtimeMs);
27494
27761
  } catch {
27495
27762
  return null;
27496
27763
  }
@@ -27500,13 +27767,13 @@ function stagingApplyFsGuard(home) {
27500
27767
  }
27501
27768
  program2.command("plugin-prune").description(`prune stale cached MMI plugin versions (keeps running + newest, ${PLUGIN_CACHE_KEEP} total) and orphaned temp_git_* staging dirs; dry-run unless --apply (#2903, #2990)`).option("--apply", "actually delete the stale version dirs + orphaned staging dirs (default: report only)").option("--json", "machine-readable output").action((o) => {
27502
27769
  const plan = buildPluginCachePlan(
27503
- (0, import_node_os9.homedir)(),
27770
+ (0, import_node_os10.homedir)(),
27504
27771
  runningPluginVersion(process.env, resolveClientVersion()),
27505
- pluginCacheFsDeps((0, import_node_os9.homedir)(), directoryBytes),
27772
+ pluginCacheFsDeps((0, import_node_os10.homedir)(), directoryBytes),
27506
27773
  { withBytes: true }
27507
27774
  );
27508
27775
  const anythingToDelete = plan.prune.length > 0 || plan.staging.length > 0;
27509
- const result = o.apply && anythingToDelete ? applyPluginCachePlan(plan, (p) => (0, import_node_fs32.rmSync)(p, { recursive: true, force: true }), stagingApplyFsGuard((0, import_node_os9.homedir)())) : void 0;
27776
+ const result = o.apply && anythingToDelete ? applyPluginCachePlan(plan, (p) => (0, import_node_fs33.rmSync)(p, { recursive: true, force: true }), stagingApplyFsGuard((0, import_node_os10.homedir)())) : void 0;
27510
27777
  const warnings = plan.prune.length > 0 ? [CONCURRENT_SESSION_WARNING] : [];
27511
27778
  if (o.json) console.log(JSON.stringify({ ...plan, warnings, applied: result ?? null }));
27512
27779
  else console.log(renderPluginCachePlan(plan, result));
@@ -27579,6 +27846,10 @@ program2.command("session-start").description("run the SessionStart verbs (whoam
27579
27846
  spawnDetachedSelf(["worktree", "setup", "--quiet"], { spawn: import_node_child_process13.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
27580
27847
  bannerIo.log(worktreeBanner);
27581
27848
  }
27849
+ if (isLinkedWorktree(process.cwd())) {
27850
+ const primaryRoot = await primaryCheckoutRoot(process.cwd());
27851
+ if (primaryRoot) touchWorktreeOwner(primaryRoot, process.cwd());
27852
+ }
27582
27853
  const payloadChars = measured.chars();
27583
27854
  recordSessionPayload(process.cwd(), payloadChars);
27584
27855
  appendHookActivity(process.cwd(), { event: "SessionStart", script: "session-start", outcome: "ran", action: "session-start hook completed", payloadChars });