@mutmutco/cli 3.100.0 → 3.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.cjs +239 -46
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -7536,6 +7536,39 @@ function missingPlaceholders(rendered) {
7536
7536
  for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
7537
7537
  return [...out];
7538
7538
  }
7539
+ async function resolveHubSeedSource(execGit, defaultBranch = "development") {
7540
+ let status;
7541
+ try {
7542
+ status = (await execGit(["status", "--porcelain"])).trim();
7543
+ } catch (e) {
7544
+ return { ok: false, reason: `could not read git status of this checkout (${e.message}) \u2014 refusing to treat an unknown working tree as the fleet's desired state` };
7545
+ }
7546
+ if (status) {
7547
+ return {
7548
+ ok: false,
7549
+ reason: `this checkout has uncommitted changes \u2014 refusing to read seeds from a working tree that has not been reviewed or merged:
7550
+ ${status}`
7551
+ };
7552
+ }
7553
+ let head;
7554
+ let remoteSha;
7555
+ try {
7556
+ head = (await execGit(["rev-parse", "HEAD"])).trim();
7557
+ remoteSha = (await execGit(["ls-remote", "origin", `refs/heads/${defaultBranch}`])).trim().split(/\s+/)[0] ?? "";
7558
+ } catch (e) {
7559
+ return { ok: false, reason: `could not resolve this checkout's HEAD against origin/${defaultBranch} (${e.message}) \u2014 refusing to read seeds without a known upstream revision` };
7560
+ }
7561
+ if (!remoteSha) {
7562
+ return { ok: false, reason: `\`git ls-remote origin refs/heads/${defaultBranch}\` returned nothing \u2014 refusing to read seeds without a known upstream revision` };
7563
+ }
7564
+ if (head !== remoteSha) {
7565
+ return {
7566
+ ok: false,
7567
+ reason: `this checkout (${head}) is not origin/${defaultBranch} (${remoteSha}) \u2014 fast-forward to origin/${defaultBranch} first; a stale or ahead checkout must not seed the fleet`
7568
+ };
7569
+ }
7570
+ return { ok: true, sha: head };
7571
+ }
7539
7572
  var GITIGNORE_MANAGED_BEGIN = "# >>> mmi-managed >>>";
7540
7573
  var GITIGNORE_MANAGED_END = "# <<< mmi-managed <<<";
7541
7574
  var MANAGED_GITIGNORE_LINES = [
@@ -12168,6 +12201,16 @@ async function auditRepoCi(repo, deps) {
12168
12201
  });
12169
12202
  }
12170
12203
  }
12204
+ if (repoClass === "deployable") {
12205
+ const exemptReason = meta?.ciExemptReason?.trim();
12206
+ const declaredExempt = statusChecks.size === 0 && !!exemptReason;
12207
+ checks.push({
12208
+ ok: statusChecks.size > 0 || declaredExempt,
12209
+ label: declaredExempt ? "deployable repo declared CI-exempt, not required to gate (#4265)" : "deployable repo requires at least one status check (#4260)",
12210
+ detail: declaredExempt ? `declared-exempt: ${exemptReason}` : statusChecks.size === 0 ? "zero required contexts on an active branch ruleset \u2014 any PR, including an automation seed PR, merges on an empty gate" : void 0,
12211
+ remediation: declaredExempt || statusChecks.size > 0 ? void 0 : `Determine this repo's correct gate context from its own PR-triggered workflow, set it via mmi-cli org project set ${repo} --var requiredChecks=[...], then mmi-cli ci reconcile --repo ${repo} --apply, or, if this repo genuinely has no CI surface, declare it via mmi-cli org project set ${repo} --var ciExemptReason="<why>" (#4265)`
12212
+ });
12213
+ }
12171
12214
  if (deployableGated || repoClass === "hub") {
12172
12215
  const gate = await readDefaultBranchGate(repo, deps.client, baseBranch, Date.now(), gateWorkflowFiles(prWorkflowPaths));
12173
12216
  checks.push({
@@ -13865,6 +13908,12 @@ async function postSchedulesMode(slug, mode, deps) {
13865
13908
  async function postSchedulesRun(id, deps) {
13866
13909
  return postJson("/schedules/run", { id }, deps, "POST", { noRetry: true });
13867
13910
  }
13911
+ async function postSchedulesPark(id, reason, deps) {
13912
+ return postJson("/schedules/park", { id, reason }, deps);
13913
+ }
13914
+ async function postSchedulesUnpark(id, deps) {
13915
+ return postJson("/schedules/unpark", { id }, deps);
13916
+ }
13868
13917
  async function tenantControl(payload, deps) {
13869
13918
  return postJson("/tenant-control", payload, deps, "POST", { noRetry: true });
13870
13919
  }
@@ -20923,7 +20972,7 @@ function authorizeBodyHasMismatch(body) {
20923
20972
  }
20924
20973
 
20925
20974
  // src/project-set.ts
20926
- var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "gate", "seedCanary"];
20975
+ var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "ciExemptReason", "gate", "seedCanary"];
20927
20976
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
20928
20977
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
20929
20978
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -21218,6 +21267,11 @@ function parseRequiredChecksVar(raw) {
21218
21267
  }
21219
21268
  return parsed.map((c) => c.trim());
21220
21269
  }
21270
+ function parseCiExemptReasonVar(raw) {
21271
+ const trimmed = raw.trim();
21272
+ if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
21273
+ return trimmed;
21274
+ }
21221
21275
  function parseGateVar(raw) {
21222
21276
  let parsed;
21223
21277
  try {
@@ -21270,6 +21324,7 @@ var SETTABLE_VAR_KEYS = [
21270
21324
  "portRange",
21271
21325
  "ci",
21272
21326
  "requiredChecks",
21327
+ "ciExemptReason",
21273
21328
  "gate",
21274
21329
  "secrets",
21275
21330
  "seedCanary"
@@ -21296,6 +21351,7 @@ var SETTABLE_VAR_HINTS = {
21296
21351
  portRange: "JSON {start,end} or [start,end]",
21297
21352
  ci: "none \u2014 declare intentional no-ci",
21298
21353
  requiredChecks: 'JSON array, e.g. ["gate"] or [] for no-ci',
21354
+ ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
21299
21355
  gate: "JSON {runtime,cmd,workdir,cacheDepPath,pyVersion}"
21300
21356
  };
21301
21357
  function settableVarHelp() {
@@ -21393,6 +21449,8 @@ function buildProjectSetPatch(input) {
21393
21449
  patch[key] = raw;
21394
21450
  } else if (key === "requiredChecks") {
21395
21451
  patch[key] = parseRequiredChecksVar(raw);
21452
+ } else if (key === "ciExemptReason") {
21453
+ patch[key] = parseCiExemptReasonVar(raw);
21396
21454
  } else if (key === "gate") {
21397
21455
  patch[key] = parseGateVar(raw);
21398
21456
  } else {
@@ -22730,6 +22788,7 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22730
22788
  const liveByName = new Map(liveGithub.map((e) => [e.name, e]));
22731
22789
  const registryById = new Map(registryGithub.map((r) => [r.id, r]));
22732
22790
  const drifts = [];
22791
+ const parked = [];
22733
22792
  for (const e of liveGithub) {
22734
22793
  if (!registryById.has(e.name)) {
22735
22794
  drifts.push({
@@ -22742,6 +22801,10 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22742
22801
  }
22743
22802
  }
22744
22803
  for (const r of registryGithub) {
22804
+ if (r.parkedReason) {
22805
+ parked.push({ id: r.id, executor: r.executor, repo: r.repo, reason: r.parkedReason });
22806
+ continue;
22807
+ }
22745
22808
  const live = liveByName.get(r.id);
22746
22809
  if (live) {
22747
22810
  if (cadenceStale(r.cadence, live.cadence)) {
@@ -22763,7 +22826,7 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22763
22826
  name: r.id,
22764
22827
  executor: r.executor || "github-actions",
22765
22828
  detail: "workflow file present but DISABLED in GitHub Actions \u2014 the dispatcher cannot run it",
22766
- remedy: `\`gh workflow enable\` it in ${r.repo} to re-arm (or, if it was retired on purpose, re-run schedules register so the replace prunes the SCHEDULE# row) \u2014 never prune a lane parked on purpose`
22829
+ remedy: `\`gh workflow enable\` it in ${r.repo} to re-arm, or if this is a deliberate hold run \`mmi-cli org schedules park ${r.id} --reason <ref>\` (#4257) so it stops reading as drift \u2014 never prune a lane parked on purpose`
22767
22830
  });
22768
22831
  }
22769
22832
  continue;
@@ -22778,7 +22841,10 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22778
22841
  });
22779
22842
  }
22780
22843
  }
22781
- return drifts.sort((a, b) => a.class.localeCompare(b.class) || a.name.localeCompare(b.name));
22844
+ return {
22845
+ drifts: drifts.sort((a, b) => a.class.localeCompare(b.class) || a.name.localeCompare(b.name)),
22846
+ parked: parked.sort((a, b) => a.id.localeCompare(b.id))
22847
+ };
22782
22848
  }
22783
22849
  function suppressSelfManagedDrifts(drifts, selfManagedRepos) {
22784
22850
  return drifts.filter((d) => {
@@ -22789,6 +22855,9 @@ function suppressSelfManagedDrifts(drifts, selfManagedRepos) {
22789
22855
  function renderDrift(d) {
22790
22856
  return `${d.class}: ${d.name} (${d.executor}) \u2014 ${d.detail}; remedy: ${d.remedy}`;
22791
22857
  }
22858
+ function renderParked(p) {
22859
+ return `parked (${p.reason}): ${p.id}`;
22860
+ }
22792
22861
  function strayCronDrift(name) {
22793
22862
  return {
22794
22863
  class: "stray-cron",
@@ -22824,12 +22893,17 @@ function unlauncheredLlmDrifts(entries) {
22824
22893
  }
22825
22894
  var HARBOUR_ARM_GRACE_MS = 30 * 60 * 1e3;
22826
22895
  function registeredButUnarmedDrifts(registry2, liveEntries, opts) {
22827
- if (!opts.schedulerRead) return [];
22896
+ if (!opts.schedulerRead) return { drifts: [], parked: [] };
22828
22897
  const now = opts.now ?? Date.now();
22829
22898
  const armed = new Set(liveEntries.map((e) => e.scheduleId).filter((id) => Boolean(id)));
22830
22899
  const drifts = [];
22900
+ const parked = [];
22831
22901
  for (const row of registry2) {
22832
22902
  if (row.executor === "github-actions") continue;
22903
+ if (row.parkedReason) {
22904
+ parked.push({ id: row.id, executor: row.executor, repo: row.repo, reason: row.parkedReason });
22905
+ continue;
22906
+ }
22833
22907
  if (armed.has(row.id)) continue;
22834
22908
  const stamped = row.updatedAt ? Date.parse(row.updatedAt) : NaN;
22835
22909
  if (Number.isFinite(stamped) && now - stamped < HARBOUR_ARM_GRACE_MS) continue;
@@ -22841,25 +22915,36 @@ function registeredButUnarmedDrifts(registry2, liveEntries, opts) {
22841
22915
  remedy: "check the fleet-clock reconciler tick; re-run `mmi-cli org schedules register` for the repo and verify with `mmi-cli org schedules`"
22842
22916
  });
22843
22917
  }
22844
- return drifts.sort((a, b) => a.name.localeCompare(b.name));
22918
+ return {
22919
+ drifts: drifts.sort((a, b) => a.name.localeCompare(b.name)),
22920
+ parked: parked.sort((a, b) => a.id.localeCompare(b.id))
22921
+ };
22845
22922
  }
22846
22923
  function assembleReconciliation(githubEntries2, readRepos, registry2, activeWorkflowNames = /* @__PURE__ */ new Set(), harbour = {}, disabledWorkflowNames = /* @__PURE__ */ new Set()) {
22847
22924
  if (registry2 === null) {
22848
22925
  return {
22849
22926
  reconciliation: [],
22850
22927
  driftLines: [],
22928
+ parked: [],
22929
+ parkedLines: [],
22851
22930
  incomplete: ["registry: could not read GET /schedules/list \u2014 reconciliation skipped (Hub API unreachable or not authenticated)"]
22852
22931
  };
22853
22932
  }
22854
22933
  const live = githubEntries2.filter((e) => e.executor === "github-actions");
22855
- const drifts = [
22856
- ...reconcileGithubActions(live, registry2, readRepos, activeWorkflowNames, disabledWorkflowNames),
22857
- ...registeredButUnarmedDrifts(registry2, harbour.awsEntries ?? [], {
22858
- schedulerRead: Boolean(harbour.schedulerRead),
22859
- now: harbour.now
22860
- })
22861
- ];
22862
- return { reconciliation: drifts, driftLines: drifts.map(renderDrift), incomplete: [] };
22934
+ const githubResult = reconcileGithubActions(live, registry2, readRepos, activeWorkflowNames, disabledWorkflowNames);
22935
+ const harbourResult = registeredButUnarmedDrifts(registry2, harbour.awsEntries ?? [], {
22936
+ schedulerRead: Boolean(harbour.schedulerRead),
22937
+ now: harbour.now
22938
+ });
22939
+ const drifts = [...githubResult.drifts, ...harbourResult.drifts];
22940
+ const parked = [...githubResult.parked, ...harbourResult.parked].sort((a, b) => a.id.localeCompare(b.id));
22941
+ return {
22942
+ reconciliation: drifts,
22943
+ driftLines: drifts.map(renderDrift),
22944
+ parked,
22945
+ parkedLines: parked.map(renderParked),
22946
+ incomplete: []
22947
+ };
22863
22948
  }
22864
22949
 
22865
22950
  // src/schedules-commands.ts
@@ -23023,7 +23108,9 @@ async function fetchNotebook(client = defaultGitHubClient(), readRegistry = defa
23023
23108
  entries: sortEntries([...gh.entries, ...aws.entries, ...DECLARED_ENTRIES]),
23024
23109
  incomplete: [...gh.incomplete, ...aws.incomplete, ...recon.incomplete],
23025
23110
  drift: driftLines,
23026
- reconciliation
23111
+ reconciliation,
23112
+ parked: recon.parked,
23113
+ parkedLines: recon.parkedLines
23027
23114
  };
23028
23115
  }
23029
23116
  async function defaultProjectsRead() {
@@ -23036,10 +23123,13 @@ function warnIncomplete2(incomplete) {
23036
23123
  function reportDrift(drift) {
23037
23124
  for (const d of drift) console.error(`org schedules: DRIFT \u2014 ${d}`);
23038
23125
  }
23126
+ function reportParked(parked) {
23127
+ for (const p of parked) console.log(`org schedules: ${p}`);
23128
+ }
23039
23129
  function registerSchedulesCommands(program3) {
23040
23130
  const schedules = program3.command("schedules").description("the org schedules notebook \u2014 every armed cron, timer, and scheduled LLM lane, resolved live (jerv side lives in jerv-cli schedules)").option("--json", "machine-readable output (consumed by jerv-cli schedules --all)").option("--doc <path>", "optional snapshot only \u2014 splice inventory between schedules:inventory markers; live `org schedules` / --json remains SSOT (Hub#4120)").action(async (o) => {
23041
23131
  try {
23042
- const { entries, incomplete, drift, reconciliation } = await fetchNotebook();
23132
+ const { entries, incomplete, drift, reconciliation, parked, parkedLines } = await fetchNotebook();
23043
23133
  if (o.doc) {
23044
23134
  if (incomplete.length) {
23045
23135
  warnIncomplete2(incomplete);
@@ -23055,9 +23145,10 @@ function registerSchedulesCommands(program3) {
23055
23145
  }
23056
23146
  if (o.json) {
23057
23147
  const now = /* @__PURE__ */ new Date();
23058
- console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation }, null, 2));
23148
+ console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation, parked }, null, 2));
23059
23149
  } else {
23060
23150
  console.log(formatSchedulesTable(entries));
23151
+ reportParked(parkedLines);
23061
23152
  reportDrift(drift);
23062
23153
  warnIncomplete2(incomplete);
23063
23154
  }
@@ -23078,6 +23169,30 @@ function registerSchedulesCommands(program3) {
23078
23169
  await failGraceful(e.message);
23079
23170
  }
23080
23171
  });
23172
+ schedules.command("park <schedule-id>").description("record a deliberate hold on a SCHEDULE# row \u2014 the reconciler skips it and `org schedules` renders `parked (<reason>)` instead of DRIFT (#4257)").requiredOption("--reason <ref>", "why the lane is held, e.g. an issue ref (#1774)").action(async (scheduleId, o) => {
23173
+ try {
23174
+ const res = await postSchedulesPark(scheduleId, o.reason, registryClientDeps(await loadConfig()));
23175
+ if (!res.ok) {
23176
+ await failGraceful(`schedules park: HTTP ${res.status} \u2014 ${JSON.stringify(res.body ?? res.error ?? "failed")}`);
23177
+ return;
23178
+ }
23179
+ console.log(JSON.stringify(res.body, null, 2));
23180
+ } catch (e) {
23181
+ await failGraceful(e.message);
23182
+ }
23183
+ });
23184
+ schedules.command("unpark <schedule-id>").description("clear a deliberate hold on a SCHEDULE# row \u2014 reconciliation resumes normally (#4257)").action(async (scheduleId) => {
23185
+ try {
23186
+ const res = await postSchedulesUnpark(scheduleId, registryClientDeps(await loadConfig()));
23187
+ if (!res.ok) {
23188
+ await failGraceful(`schedules unpark: HTTP ${res.status} \u2014 ${JSON.stringify(res.body ?? res.error ?? "failed")}`);
23189
+ return;
23190
+ }
23191
+ console.log(JSON.stringify(res.body, null, 2));
23192
+ } catch (e) {
23193
+ await failGraceful(e.message);
23194
+ }
23195
+ });
23081
23196
  schedules.command("mode <mode>").description("fleet schedules doctrine for a repo: 'managed' (default \u2014 Hub registry + drift enforcement) or 'self-managed' (opt out; the repo owns its crons). Project-admin self-serve (#3228).").option("--repo <name>", "bare repo name (defaults to the origin remote basename)").action(async (mode, o) => {
23082
23197
  try {
23083
23198
  if (mode !== "managed" && mode !== "self-managed") {
@@ -23861,6 +23976,16 @@ var import_node_crypto6 = require("node:crypto");
23861
23976
  function byteComparableSeeds(manifest, cls) {
23862
23977
  return manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self" && s.classes.includes(cls));
23863
23978
  }
23979
+ function declaredRenderWaivers(manifest, repo) {
23980
+ const slug = repo.includes("/") ? repo.slice(repo.indexOf("/") + 1).toLowerCase() : repo.toLowerCase();
23981
+ const findings = [];
23982
+ for (const seed of manifest.seeds) {
23983
+ if (seed.ownership !== "org" || seed.source === "self") continue;
23984
+ const why = seed.waivers?.[slug];
23985
+ if (why) findings.push({ repo, target: seed.target, state: "waived", detail: `hand-authored, not rendered \u2014 waived: ${why}` });
23986
+ }
23987
+ return findings;
23988
+ }
23864
23989
  function compareSeedBytes(hubContent, repoContent) {
23865
23990
  if (repoContent === null) return "absent";
23866
23991
  const normalize = (s) => s.replace(/\r\n/g, "\n");
@@ -23938,18 +24063,21 @@ function assignWaves(repos, canarySlug) {
23938
24063
  rest.forEach((r, i) => waves.set(r.repo, i < wave1Count ? 1 : 2));
23939
24064
  return waves;
23940
24065
  }
23941
- function statusFor(read) {
24066
+ function statusFor(read, currentSha) {
23942
24067
  if (!read) return { status: "pending", record: {} };
23943
24068
  if (read.drift === "match") return { status: "match", record: { mergeSha: read.pr?.mergeSha } };
23944
24069
  const pr2 = read.pr;
23945
24070
  if (!pr2) return { status: "pending", record: {} };
23946
24071
  if (pr2.state === "closed") return { status: "closed-unmerged", record: { prNumber: pr2.number, prUrl: pr2.url } };
24072
+ if (pr2.state === "merged" && pr2.sourceSha && pr2.sourceSha !== currentSha) {
24073
+ return { status: "pending", record: {} };
24074
+ }
23947
24075
  if (pr2.checks === "red") return { status: "red", record: { prNumber: pr2.number, prUrl: pr2.url } };
23948
24076
  return { status: "open-pending", record: { prNumber: pr2.number, prUrl: pr2.url, mergeSha: pr2.mergeSha } };
23949
24077
  }
23950
24078
  var HALTING_STATUSES = /* @__PURE__ */ new Set(["red", "closed-unmerged"]);
23951
24079
  function planPropagationTick(input) {
23952
- const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied } = input;
24080
+ const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha } = input;
23953
24081
  const readByRepo = new Map(reads.map((r) => [r.repo, r]));
23954
24082
  const records = [];
23955
24083
  const opened = [];
@@ -23983,7 +24111,7 @@ function planPropagationTick(input) {
23983
24111
  let waveAllMatch = true;
23984
24112
  let waveHasRed = false;
23985
24113
  for (const r of waveRepos) {
23986
- const { status, record } = statusFor(readByRepo.get(r.repo));
24114
+ const { status, record } = statusFor(readByRepo.get(r.repo), currentSha);
23987
24115
  const shouldOpen = status === "pending";
23988
24116
  if (shouldOpen) opened.push(r.repo);
23989
24117
  records.push({
@@ -24725,6 +24853,7 @@ async function reconcileOrgNoAgentFilesRuleset(plan, client, org = ORG_LOGIN) {
24725
24853
  }
24726
24854
 
24727
24855
  // src/bootstrap-commands.ts
24856
+ var execGitForSeedSource = async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout;
24728
24857
  function registerBootstrapCommands(program3) {
24729
24858
  const bootstrap = program3.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--json", "machine-readable output").action((o) => {
24730
24859
  if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
@@ -24804,6 +24933,8 @@ function registerBootstrapCommands(program3) {
24804
24933
  const o = { repo: rawValue("--repo", ""), json: rawFlag("--json") };
24805
24934
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
24806
24935
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap drift: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the reference this compares against`);
24936
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
24937
+ if (!seedSource.ok) return fail(`bootstrap drift: ${seedSource.reason}`);
24807
24938
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
24808
24939
  const hubContents = /* @__PURE__ */ new Map();
24809
24940
  for (const s of manifest.seeds) {
@@ -24846,12 +24977,14 @@ function registerBootstrapCommands(program3) {
24846
24977
  reads.push({ target: seed.target, content });
24847
24978
  }
24848
24979
  findings.push(...auditRepoSeedDrift(repo, seeds, hubContents, reads));
24980
+ findings.push(...declaredRenderWaivers(manifest, repo));
24849
24981
  }
24850
24982
  if (o.json) {
24851
24983
  console.log(JSON.stringify({
24852
24984
  // #3842: `ok` reflects real findings; waivers ride the payload so a consumer can see every
24853
24985
  // standing exception without them counting as drift.
24854
24986
  ok: findings.every((f) => f.state === "waived"),
24987
+ sourceSha: seedSource.sha,
24855
24988
  scope: o.repo ? "single-repo" : "fleet",
24856
24989
  reposAudited: targets.length,
24857
24990
  seedsPerRepo,
@@ -24886,6 +25019,8 @@ function registerBootstrapCommands(program3) {
24886
25019
  }
24887
25020
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
24888
25021
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; bootstrap runs from the MMI-Hub repo root by design \u2014 it stamps org-level resources (Project, Ruleset, secrets, access) through the GitHub App, which is only authorized from the Hub checkout`);
25022
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25023
+ if (!seedSource.ok) return fail(`bootstrap apply: ${seedSource.reason}`);
24889
25024
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
24890
25025
  const baseBranch = o.class === "content" ? "main" : "development";
24891
25026
  const slug = parsedRepo.slug;
@@ -25062,9 +25197,9 @@ function registerBootstrapCommands(program3) {
25062
25197
  "--body",
25063
25198
  onlyTarget ? `Auto-opened by \`mmi-cli bootstrap apply ${repo} --only ${onlyTarget} --execute\` (#3818).
25064
25199
 
25065
- \`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\`; this PR brings this repo's copy to the Hub's. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
25200
+ \`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\` at MMI-Hub@${seedSource.sha}; this PR brings this repo's copy to the Hub's. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
25066
25201
 
25067
- \`${baseBranch}\` is protected (${seedPlan.reason}), so delivery goes via this branch + PR \u2014 a direct contents PUT 409s on a protected base.` : `Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#2286): \`${baseBranch}\` is protected (${seedPlan.reason}), so the org-managed seed files are delivered via this branch + PR \u2014 a direct contents PUT 409s on a protected base ("N of N required status checks are expected").`
25202
+ \`${baseBranch}\` is protected (${seedPlan.reason}), so delivery goes via this branch + PR \u2014 a direct contents PUT 409s on a protected base.` : `Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#2286): \`${baseBranch}\` is protected (${seedPlan.reason}), so the org-managed seed files are delivered via this branch + PR \u2014 a direct contents PUT 409s on a protected base ("N of N required status checks are expected"). Seeds are from MMI-Hub@${seedSource.sha}.`
25068
25203
  ]);
25069
25204
  seedPrUrl = created.url;
25070
25205
  }
@@ -25160,7 +25295,7 @@ function registerBootstrapCommands(program3) {
25160
25295
  applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
25161
25296
  }
25162
25297
  }
25163
- if (o.json) console.log(JSON.stringify({ repo, class: o.class, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
25298
+ if (o.json) console.log(JSON.stringify({ repo, class: o.class, sourceSha: seedSource.sha, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
25164
25299
  else {
25165
25300
  console.log(renderSeedPlan(actions));
25166
25301
  if (o.execute) console.log(`
@@ -25172,6 +25307,8 @@ LIVE apply to ${repo}:
25172
25307
  const o = { target: rawValue("--target", ""), execute: rawFlag("--execute"), json: rawFlag("--json") };
25173
25308
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
25174
25309
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap propagate: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the desired state this tick propagates`);
25310
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25311
+ if (!seedSource.ok) return fail(`bootstrap propagate: ${seedSource.reason}`);
25175
25312
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25176
25313
  const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25177
25314
  if (!o.target) {
@@ -25210,9 +25347,28 @@ LIVE apply to ${repo}:
25210
25347
  }
25211
25348
  const bySlugMeta = new Map(projects.flatMap((p) => (p.repos ?? []).map((r) => [(r.includes("/") ? r : `mutmutco/${r}`).toLowerCase(), p])));
25212
25349
  const classOf = (repo) => bySlugMeta.get(repo.toLowerCase())?.class ?? "deployable";
25213
- const canaryProject = projects.find((p) => p.seedCanary === true);
25214
- const canarySlug = canaryProject ? (canaryProject.repos ?? [])[0]?.split("/").pop()?.toLowerCase() ?? null : null;
25215
- const repos = rosterRepos2.map((repo) => {
25350
+ const inClassRepos = rosterRepos2.filter((repo) => seed.classes.includes(classOf(repo)));
25351
+ const classPriority = [.../* @__PURE__ */ new Set(["deployable", ...seed.classes])].filter((c) => seed.classes.includes(c));
25352
+ let canaryProject = null;
25353
+ for (const cls of classPriority) {
25354
+ const inClass = projects.filter((p) => p.seedCanary === true && (p.class ?? "deployable") === cls);
25355
+ if (inClass.length === 0) continue;
25356
+ if (inClass.length > 1) {
25357
+ const slugs = inClass.map((p) => (p.repos ?? [])[0]?.split("/").pop() ?? "?").join(", ");
25358
+ return fail(
25359
+ `bootstrap propagate: target '${seed.target}' (classes [${seed.classes.join(", ")}]) matches ${inClass.length} seedCanary:true repos in class '${cls}' (${slugs}) \u2014 exactly one seedCanary:true repo per class is required`
25360
+ );
25361
+ }
25362
+ canaryProject = inClass[0];
25363
+ break;
25364
+ }
25365
+ if (!canaryProject) {
25366
+ return fail(
25367
+ `bootstrap propagate: target '${seed.target}' needs classes [${seed.classes.join(", ")}] but no registry repo with seedCanary:true carries a matching class \u2014 set seedCanary:true on an in-class repo (mmi-cli org project set <slug> --var seedCanary=true)`
25368
+ );
25369
+ }
25370
+ const canarySlug = (canaryProject.repos ?? [])[0]?.split("/").pop()?.toLowerCase() ?? null;
25371
+ const repos = inClassRepos.map((repo) => {
25216
25372
  const slug = repo.split("/").pop().toLowerCase();
25217
25373
  const waiver = seed.waivers?.[slug];
25218
25374
  return { repo, slug, waiver };
@@ -25236,13 +25392,14 @@ LIVE apply to ${repo}:
25236
25392
  let pr2;
25237
25393
  try {
25238
25394
  const branch = `${branchPrefix}-${r.slug}`;
25239
- const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit", "--limit", "1"]);
25395
+ const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit,body", "--limit", "1"]);
25240
25396
  const arr = JSON.parse(listed.stdout || "[]");
25241
25397
  const p = arr[0];
25242
25398
  if (p) {
25243
25399
  const rollup = p.statusCheckRollup ?? [];
25244
25400
  const checks = rollup.length === 0 ? "none" : rollup.some((c) => c.conclusion === "FAILURE" || c.state === "FAILURE") ? "red" : rollup.every((c) => c.conclusion === "SUCCESS" || c.state === "SUCCESS") ? "success" : "pending";
25245
- pr2 = { number: p.number, url: p.url, state: p.state === "MERGED" ? "merged" : p.state === "CLOSED" ? "closed" : "open", checks, mergeSha: p.mergeCommit?.oid };
25401
+ const sourceSha = p.body?.match(/Propagates MMI-Hub@([0-9a-fA-F]{7,40})/)?.[1];
25402
+ pr2 = { number: p.number, url: p.url, state: p.state === "MERGED" ? "merged" : p.state === "CLOSED" ? "closed" : "open", checks, mergeSha: p.mergeCommit?.oid, sourceSha };
25246
25403
  }
25247
25404
  } catch {
25248
25405
  pr2 = void 0;
@@ -25264,10 +25421,10 @@ LIVE apply to ${repo}:
25264
25421
  }
25265
25422
  }
25266
25423
  }
25267
- const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied });
25424
+ const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha: seedSource.sha });
25268
25425
  if (o.execute) {
25269
25426
  if (plan.refusedNoCanary) return fail("bootstrap propagate --execute: no canary declared for this target \u2014 refusing to write (set seedCanary:true on exactly one registry repo)");
25270
- const headSha = (await gh(["api", "repos/mutmutco/MMI-Hub/commits/development", "--jq", ".sha"])).stdout.trim();
25427
+ const headSha = seedSource.sha;
25271
25428
  for (const rec of plan.records) {
25272
25429
  if (rec.action !== "open-pr") continue;
25273
25430
  const repoEntry = repos.find((r) => r.repo === rec.repo);
@@ -25332,8 +25489,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25332
25489
  rec.prUrl = prUrl;
25333
25490
  }
25334
25491
  }
25335
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25336
- else console.log(renderPropagationReport(plan));
25492
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25493
+ else console.log(`${renderPropagationReport(plan)}
25494
+ source: MMI-Hub@${seedSource.sha}`);
25337
25495
  if (plan.halted) process.exitCode = 1;
25338
25496
  });
25339
25497
  bootstrap.command("rollback <repo>").description("#4240: open a per-repo revert PR of the recorded seed-propagate merge \u2014 never a fleet-wide overwrite; dry-run unless --execute").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--target <path>", "the manifest target to roll back (an ownership:org + source:self seed, e.g. .github/workflows/agent-pr.yml)").option("--record <path>", "read propagation candidates from a persisted `bootstrap propagate --json` report instead of the live seed-propagate PR history").option("--execute", "LIVE revert via gh (master-gated) \u2014 opens/reuses the seed-rollback PR; dry-run prints the plan only").option("--json", "machine-readable output").action(async (repo) => {
@@ -25353,6 +25511,8 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25353
25511
  }
25354
25512
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
25355
25513
  if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap rollback: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the manifest names which targets are org-owned and therefore propagated (and rollback-able)`);
25514
+ const seedSource = await resolveHubSeedSource(execGitForSeedSource);
25515
+ if (!seedSource.ok) return fail(`bootstrap rollback: ${seedSource.reason}`);
25356
25516
  const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25357
25517
  const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25358
25518
  if (!o.target) {
@@ -25408,7 +25568,7 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25408
25568
  }
25409
25569
  const plan = planRollback(repo, seed.target, slug, candidates);
25410
25570
  if (!plan.resolution.found) {
25411
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25571
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25412
25572
  else console.log(renderRollbackReport(plan));
25413
25573
  return fail(`bootstrap rollback: ${plan.resolution.reason}`);
25414
25574
  }
@@ -25463,8 +25623,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25463
25623
  }
25464
25624
  plan.prUrl = prUrl;
25465
25625
  }
25466
- if (o.json) console.log(JSON.stringify(plan, null, 2));
25467
- else console.log(renderRollbackReport(plan));
25626
+ if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
25627
+ else console.log(`${renderRollbackReport(plan)}
25628
+ manifest verified against: MMI-Hub@${seedSource.sha}`);
25468
25629
  });
25469
25630
  }
25470
25631
 
@@ -29481,6 +29642,25 @@ function findNegatedClosings(text, closingNumbers) {
29481
29642
  return forIssue.length > 0 && forIssue.every((m) => m.negated);
29482
29643
  });
29483
29644
  }
29645
+ var CROSS_REPO_TOKEN_RE = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g;
29646
+ function findCrossRepoAmbiguousClosings(text, closingNumbers, repo) {
29647
+ if (!closingNumbers.length) return [];
29648
+ const closing = new Set(closingNumbers);
29649
+ const flagged = /* @__PURE__ */ new Set();
29650
+ const repoLower = repo.toLowerCase();
29651
+ for (const match of text.matchAll(CROSS_REPO_TOKEN_RE)) {
29652
+ const n = Number(match[2]);
29653
+ if (!closing.has(n)) continue;
29654
+ if ((match[1] ?? "").toLowerCase() === repoLower) continue;
29655
+ flagged.add(n);
29656
+ }
29657
+ return [...flagged];
29658
+ }
29659
+ function crossRepoAmbiguousRefusalMessage(ambiguous, repo, context = "pr merge") {
29660
+ const named = ambiguous.map((n) => `${repo}#${n}`).join(", ");
29661
+ const first = `#${ambiguous[0] ?? "N"}`;
29662
+ return `${context}: REFUSED \u2014 GitHub will close ${named} on merge (a bare \`#N\` after a closing keyword always means the repo the PR lives in), but this body also mentions ${first} qualified against a DIFFERENT repo elsewhere \u2014 check whether that closing keyword was meant to carry an explicit owner/repo#N qualifier instead. Reword the closing reference with the full owner/repo#N form, or re-run with --force to merge anyway and let ${named} close.`;
29663
+ }
29484
29664
  function commitMessagesText(commits) {
29485
29665
  if (!Array.isArray(commits)) return "";
29486
29666
  return commits.map((commit) => {
@@ -29489,7 +29669,7 @@ function commitMessagesText(commits) {
29489
29669
  ${typeof messageBody === "string" ? messageBody : ""}`;
29490
29670
  }).join("\n");
29491
29671
  }
29492
- function parseClosingGuardInput(raw) {
29672
+ function parseClosingGuardInput(raw, repo) {
29493
29673
  if (!raw || typeof raw !== "object") return void 0;
29494
29674
  const { title, body, state, closingIssuesReferences, commits } = raw;
29495
29675
  if (typeof state !== "string" || !Array.isArray(closingIssuesReferences)) return void 0;
@@ -29502,7 +29682,7 @@ function parseClosingGuardInput(raw) {
29502
29682
  const commitClosing = [...new Set(findClosingMentions(commitMessagesText(commits)).map((m) => m.issue))];
29503
29683
  const text = `${typeof title === "string" ? title : ""}
29504
29684
  ${typeof body === "string" ? body : ""}`;
29505
- return { state, text, closing, commitClosing };
29685
+ return { state, text, closing, commitClosing, repo };
29506
29686
  }
29507
29687
  function negatedClosingRefusalMessage(negated, context = "pr merge") {
29508
29688
  const named = negated.map((n) => `#${n}`).join(", ");
@@ -29531,12 +29711,24 @@ function evaluateClosingGuard(input, opts) {
29531
29711
  }
29532
29712
  if (input.closing.length === 0) return { blocked: false };
29533
29713
  const negated = findNegatedClosings(input.text, input.closing);
29534
- if (!negated.length) return { blocked: false };
29535
- if (!opts.force) return { blocked: true, message: negatedClosingRefusalMessage(negated, opts.context) };
29536
- return {
29537
- blocked: false,
29538
- message: `${opts.context}: --force past the negated-closing guard \u2014 GitHub will still close ${negated.map((n) => `#${n}`).join(", ")} on merge although the PR body says it does not.`
29539
- };
29714
+ if (negated.length) {
29715
+ if (!opts.force) return { blocked: true, message: negatedClosingRefusalMessage(negated, opts.context) };
29716
+ return {
29717
+ blocked: false,
29718
+ message: `${opts.context}: --force past the negated-closing guard \u2014 GitHub will still close ${negated.map((n) => `#${n}`).join(", ")} on merge although the PR body says it does not.`
29719
+ };
29720
+ }
29721
+ if (input.repo) {
29722
+ const ambiguous = findCrossRepoAmbiguousClosings(input.text, input.closing, input.repo);
29723
+ if (ambiguous.length) {
29724
+ if (!opts.force) return { blocked: true, message: crossRepoAmbiguousRefusalMessage(ambiguous, input.repo, opts.context) };
29725
+ return {
29726
+ blocked: false,
29727
+ message: `${opts.context}: --force past the cross-repo-ambiguous closing guard \u2014 GitHub will still close ${ambiguous.map((n) => `${input.repo}#${n}`).join(", ")} on merge.`
29728
+ };
29729
+ }
29730
+ }
29731
+ return { blocked: false };
29540
29732
  }
29541
29733
 
29542
29734
  // src/session-report.ts
@@ -34455,12 +34647,13 @@ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skip
34455
34647
  if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
34456
34648
  if (result.status === "timeout" || result.status === "rate-limited") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
34457
34649
  });
34458
- pr.command("land <number>").description("agent merge path (#1440): train probe ? checks-wait ? merge --auto ? poll enqueued ? development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--preserve-worktree", "after merge, keep the local PR worktree/stage/branch for an active batch (#1888)").option("--force", "acknowledge and land past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword refusal (#3718)").action(async (number, o) => {
34650
+ pr.command("land <number>").description("agent merge path (#1440): train probe ? checks-wait ? merge --auto ? poll enqueued ? development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--preserve-worktree", "after merge, keep the local PR worktree/stage/branch for an active batch (#1888)").option("--force", "acknowledge and land past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal").action(async (number, o) => {
34459
34651
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
34460
34652
  const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
34461
34653
  assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr land", { force: o.force });
34462
34654
  const landClosingGuardRaw = await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "title,body,state,closingIssuesReferences,commits"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => JSON.parse(r.stdout)).catch(() => void 0);
34463
- const landClosingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(landClosingGuardRaw), { force: o.force, context: "pr land" });
34655
+ const landRepoForGuard = await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
34656
+ const landClosingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(landClosingGuardRaw, landRepoForGuard), { force: o.force, context: "pr land" });
34464
34657
  if (landClosingGuardVerdict.blocked) {
34465
34658
  console.error(landClosingGuardVerdict.message);
34466
34659
  process.exitCode = 1;
@@ -34571,14 +34764,14 @@ pr.command("land <number>").description("agent merge path (#1440): train probe ?
34571
34764
  }
34572
34765
  if (result.status === "failed" || result.cleanupError) process.exitCode = 1;
34573
34766
  });
34574
- jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree ? no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge ? merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword refusal (#3718)")).action(async (number, o) => {
34767
+ jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree ? no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge ? merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal")).action(async (number, o) => {
34575
34768
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
34576
34769
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
34577
34770
  const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
34578
34771
  const [headRef, baseRef, headRefOid] = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName,baseRefName,headRefOid", "--jq", '.headRefName + " " + .baseRefName + " " + (.headRefOid // "")'], { timeout: GC_GH_TIMEOUT_MS2 })).stdout.trim().split(/\s+/);
34579
34772
  const headIsProtected = isProtectedBranch(headRef);
34580
34773
  const closingGuardRaw = await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "title,body,state,closingIssuesReferences,commits"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => JSON.parse(r.stdout)).catch(() => void 0);
34581
- const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw), { force: o.force, context: "pr merge" });
34774
+ const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw, repoForPostCleanup), { force: o.force, context: "pr merge" });
34582
34775
  if (closingGuardVerdict.blocked) {
34583
34776
  console.error(closingGuardVerdict.message);
34584
34777
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.100.0",
3
+ "version": "3.102.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",