@mutmutco/cli 3.58.0 → 3.59.1

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