@mutmutco/cli 4.3.25 → 4.3.27

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 +122 -42
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -10939,9 +10939,15 @@ function boardNotFoundError(ref, board, opts = {}) {
10939
10939
  function evaluateClaim(item, login) {
10940
10940
  const others = item.assignees.filter((a) => a.toLowerCase() !== login.toLowerCase());
10941
10941
  const mine = item.assignees.some((a) => a.toLowerCase() === login.toLowerCase());
10942
+ if (others.length && item.status === "In Review") {
10943
+ return {
10944
+ ok: false,
10945
+ reason: `${item.ref} is not claimable: In Review and held by @${others.join(", @")} \u2014 ask the holder, or wait for the review to land`
10946
+ };
10947
+ }
10942
10948
  if (others.length) return { ok: false, reason: `${item.ref} is already assigned to @${others.join(", @")}` };
10943
10949
  if (item.status === "In Progress" && mine) return { ok: true, alreadyClaimed: true };
10944
- if (item.status !== "Todo" && item.status !== "In Progress") {
10950
+ if (item.status !== "Todo" && item.status !== "In Progress" && item.status !== "In Review") {
10945
10951
  return { ok: false, reason: `${item.ref} is not claimable: Status is ${item.status}` };
10946
10952
  }
10947
10953
  return { ok: true, alreadyClaimed: false };
@@ -11064,7 +11070,7 @@ async function repoCanPush(repo, client) {
11064
11070
  }
11065
11071
  }
11066
11072
  async function resolveWritableReposForClaimables(items, client) {
11067
- const candidateRepos = [...new Set(items.filter((item) => (item.status === "Todo" || item.status === "In Progress") && item.assignees.length === 0).map((item) => item.repository))];
11073
+ const candidateRepos = [...new Set(items.filter((item) => (item.status === "Todo" || item.status === "In Progress" || item.status === "In Review") && item.assignees.length === 0).map((item) => item.repository))];
11068
11074
  const repos = /* @__PURE__ */ new Set();
11069
11075
  const unknown = /* @__PURE__ */ new Set();
11070
11076
  const warnings = [];
@@ -12177,7 +12183,7 @@ async function prepareClaimContext(options, selectors, deps, collected, snapshot
12177
12183
  async function claimOneBoardItem(ctx, selector, options) {
12178
12184
  const { cfg, client, report } = ctx;
12179
12185
  const flatItem = findBoardItem(ctx.items, selector, { owner: cfg.projectOwner, number: cfg.projectNumber });
12180
- const wouldWrite = flatItem.assignees.length === 0 && (flatItem.status === "Todo" || flatItem.status === "In Progress");
12186
+ const wouldWrite = flatItem.assignees.length === 0 && (flatItem.status === "Todo" || flatItem.status === "In Progress" || flatItem.status === "In Review");
12181
12187
  if (wouldWrite && !ctx.writable.has(flatItem.repository.toLowerCase())) {
12182
12188
  throw new Error(
12183
12189
  `${flatItem.ref} is not claimable: the token from ${describeTokenSource()} reports no write access to ${flatItem.repository}`
@@ -12216,7 +12222,10 @@ async function claimOneBoardItem(ctx, selector, options) {
12216
12222
  };
12217
12223
  let previousHolder;
12218
12224
  let resumeEvidence;
12219
- const claimedReceipt = () => previousHolder ? resumeEvidence ? { outcome: "resumed", holder, previousHolder, resumeEvidence } : { outcome: "took-over", holder, previousHolder } : { outcome: "claimed", holder };
12225
+ const claimedReceipt = () => ({
12226
+ ...item.status === "In Review" ? { reclaimedFrom: "In Review" } : {},
12227
+ ...previousHolder ? resumeEvidence ? { outcome: "resumed", holder, previousHolder, resumeEvidence } : { outcome: "took-over", holder, previousHolder } : { outcome: "claimed", holder }
12228
+ });
12220
12229
  const heldReceipt = () => previousHolder ? resumeEvidence ? { outcome: "resumed", holder, previousHolder, resumeEvidence } : { outcome: "took-over", holder, previousHolder } : { outcome: "held", holder };
12221
12230
  if (flatItem.contentType !== "Issue") throw new Error(`${flatItem.ref} is not an issue`);
12222
12231
  const pre = evaluateClaim(flatItem, assignedLogin);
@@ -12274,7 +12283,7 @@ async function claimOneBoardItem(ctx, selector, options) {
12274
12283
  } catch (e) {
12275
12284
  const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
12276
12285
  if (!options.allowPartial) throw new Error(warning);
12277
- return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning, ...claimedReceipt() };
12286
+ return { item, viewer: report.viewer, repo: report.repo, status: item.status, partial: true, warning, ...claimedReceipt() };
12278
12287
  }
12279
12288
  return {
12280
12289
  item: {
@@ -12320,7 +12329,7 @@ async function claimBoardIssues(options, deps = {}) {
12320
12329
  const ref = `${selector.repo}#${selector.number}`;
12321
12330
  try {
12322
12331
  const result = await claimOneBoardItem(ctx, selector, { ...options, bulk: true });
12323
- results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning, outcome: result.outcome, holder: result.holder, previousHolder: result.previousHolder, resumeEvidence: result.resumeEvidence, alreadyClaimed: result.alreadyClaimed, checked: result.checked };
12332
+ results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning, outcome: result.outcome, holder: result.holder, previousHolder: result.previousHolder, resumeEvidence: result.resumeEvidence, reclaimedFrom: result.reclaimedFrom, alreadyClaimed: result.alreadyClaimed, checked: result.checked };
12324
12333
  } catch (e) {
12325
12334
  results[index] = { ref, claimed: false, reason: e.message };
12326
12335
  }
@@ -14074,7 +14083,7 @@ var GATE_RUNTIME_DEFAULTS = {
14074
14083
  node: { cmd: DEFAULT_GATE_CMD, install: "npm ci" },
14075
14084
  python: { cmd: "pytest", install: 'pip install -e ".[dev]"' }
14076
14085
  };
14077
- function gateSeedVars(cls, releaseTrack, runtime = "node", requiredCheckBranches) {
14086
+ function gateSeedVars(cls, releaseTrack, runtime = "node", requiredCheckBranches, requiredChecks) {
14078
14087
  const rt = GATE_RUNTIME_DEFAULTS[runtime] ?? GATE_RUNTIME_DEFAULTS.node;
14079
14088
  const runtimeVars = {
14080
14089
  GATE_RUNTIME: runtime,
@@ -14094,12 +14103,14 @@ function gateSeedVars(cls, releaseTrack, runtime = "node", requiredCheckBranches
14094
14103
  const trackBranches = track === "trunk" ? ["main"] : track === "direct" ? ["development", "main"] : ["development", "rc", "main"];
14095
14104
  const rulesetBranches = requiredCheckBranches?.length ? [...requiredCheckBranches] : trackBranches;
14096
14105
  const rulesetRefs = JSON.stringify(rulesetBranches.map((branch) => `refs/heads/${branch}`));
14106
+ const rulesetContexts = JSON.stringify((requiredChecks?.length ? requiredChecks : ["gate"]).map((context) => ({ context })));
14097
14107
  if (track === "trunk") {
14098
14108
  return {
14099
14109
  ...runtimeVars,
14100
14110
  GATE_PUSH_BRANCHES_YAML: "[main]",
14101
14111
  GATE_FULL_RUN_BRANCH: "main",
14102
- GATE_RULESET_BRANCH_REFS_JSON: rulesetRefs
14112
+ GATE_RULESET_BRANCH_REFS_JSON: rulesetRefs,
14113
+ GATE_RULESET_CONTEXTS_JSON: rulesetContexts
14103
14114
  };
14104
14115
  }
14105
14116
  if (track === "direct") {
@@ -14107,17 +14118,19 @@ function gateSeedVars(cls, releaseTrack, runtime = "node", requiredCheckBranches
14107
14118
  ...runtimeVars,
14108
14119
  GATE_PUSH_BRANCHES_YAML: "[development, main]",
14109
14120
  GATE_FULL_RUN_BRANCH: "development",
14110
- GATE_RULESET_BRANCH_REFS_JSON: rulesetRefs
14121
+ GATE_RULESET_BRANCH_REFS_JSON: rulesetRefs,
14122
+ GATE_RULESET_CONTEXTS_JSON: rulesetContexts
14111
14123
  };
14112
14124
  }
14113
14125
  return {
14114
14126
  ...runtimeVars,
14115
14127
  GATE_PUSH_BRANCHES_YAML: "[development, rc, main]",
14116
14128
  GATE_FULL_RUN_BRANCH: "development",
14117
- GATE_RULESET_BRANCH_REFS_JSON: rulesetRefs
14129
+ GATE_RULESET_BRANCH_REFS_JSON: rulesetRefs,
14130
+ GATE_RULESET_CONTEXTS_JSON: rulesetContexts
14118
14131
  };
14119
14132
  }
14120
- function withDerivedRepoVars(vars, parsed, cls, releaseTrack, requiredCheckBranches) {
14133
+ function withDerivedRepoVars(vars, parsed, cls, releaseTrack, requiredCheckBranches, requiredChecks) {
14121
14134
  const out = { ...vars };
14122
14135
  out.REPO_NAME ??= parsed.name;
14123
14136
  out.REPO_SLUG ??= parsed.slug;
@@ -14125,7 +14138,7 @@ function withDerivedRepoVars(vars, parsed, cls, releaseTrack, requiredCheckBranc
14125
14138
  out.PROJECT_OWNER ??= parsed.owner;
14126
14139
  const track = releaseTrack ?? resolveBootstrapReleaseTrack(cls);
14127
14140
  const runtime = out.GATE_RUNTIME === "python" ? "python" : "node";
14128
- for (const [key, value] of Object.entries(gateSeedVars(cls, track, runtime, requiredCheckBranches))) {
14141
+ for (const [key, value] of Object.entries(gateSeedVars(cls, track, runtime, requiredCheckBranches, requiredChecks))) {
14129
14142
  out[key] ??= value;
14130
14143
  }
14131
14144
  if (!out.GATE_AFFECTED_CMD.trim()) out.GATE_AFFECTED_CMD = out.GATE_CMD;
@@ -15522,10 +15535,10 @@ var rollout_plan_default = {
15522
15535
  note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
15523
15536
  },
15524
15537
  baseline: {
15525
- version: "4.3.25",
15526
- tag: "v4.3.25",
15527
- commit: "fc9a141cca17",
15528
- npm: "@mutmutco/cli@4.3.25"
15538
+ version: "4.3.27",
15539
+ tag: "v4.3.27",
15540
+ commit: "924b5a7761c4",
15541
+ npm: "@mutmutco/cli@4.3.27"
15529
15542
  },
15530
15543
  exitCriterion: "fleet-n-of-n",
15531
15544
  hubOnlyShortcut: "forbidden",
@@ -15542,14 +15555,14 @@ var rollout_plan_default = {
15542
15555
  repo: "mutmutco/mmi-hub",
15543
15556
  role: "canary",
15544
15557
  schedule: "train",
15545
- v3Target: "v4.3.25"
15558
+ v3Target: "v4.3.27"
15546
15559
  }
15547
15560
  ],
15548
15561
  rollbackTrigger: "Any red inside the post-contract soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a pre-v4 client admitted instead of receiving actionable HTTP 426, or npm consumer install/doctor failure on the v4-only dist.",
15549
15562
  rollback: {
15550
15563
  independent: true,
15551
- mechanism: "npm dist-tag latest -> 4.3.25 and redeploy the Hub Lambda from tag v4.3.25 (fc9a141cca17); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15552
- v3Target: "v4.3.25 (@mutmutco/cli@4.3.25, tag commit fc9a141cca17 \u2014 last known-good release carrying the repo-index v4-only contract)"
15564
+ mechanism: "npm dist-tag latest -> 4.3.27 and redeploy the Hub Lambda from tag v4.3.27 (924b5a7761c4); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15565
+ v3Target: "v4.3.27 (@mutmutco/cli@4.3.27, tag commit 924b5a7761c4 \u2014 last known-good release carrying the repo-index v4-only contract)"
15553
15566
  }
15554
15567
  },
15555
15568
  {
@@ -24213,7 +24226,8 @@ async function seedGateYml(repo, deps, meta, result) {
24213
24226
  parsed,
24214
24227
  "deployable",
24215
24228
  releaseTrack,
24216
- meta?.requiredCheckBranches
24229
+ meta?.requiredCheckBranches,
24230
+ registryRequiredContexts(meta) ?? void 0
24217
24231
  );
24218
24232
  if (await contentExists(deps, repo, baseBranch, PRODUCT_GATE_PATH)) {
24219
24233
  return await seedRulesetRefIfMissing(repo, deps, refOnlyVars(), baseBranch, result);
@@ -24240,7 +24254,8 @@ async function seedGateYml(repo, deps, meta, result) {
24240
24254
  parsed,
24241
24255
  "deployable",
24242
24256
  releaseTrack,
24243
- meta?.requiredCheckBranches
24257
+ meta?.requiredCheckBranches,
24258
+ registryRequiredContexts(meta) ?? void 0
24244
24259
  );
24245
24260
  const rendered = renderSeedBody(deps, GATE_TEMPLATE_SEED, PRODUCT_GATE_PATH, derivedVars);
24246
24261
  if (rendered == null) {
@@ -25569,11 +25584,12 @@ function registerBoardCommands(program3) {
25569
25584
  function claimVerdict(ref, result) {
25570
25585
  const holder = formatClaimHolder(result.holder);
25571
25586
  const previousHolder = result.previousHolder ? formatClaimHolder(result.previousHolder) : "another lane";
25587
+ const reclaimed = result.reclaimedFrom ? ` (reclaimed from ${result.reclaimedFrom})` : "";
25572
25588
  if (result.checked) {
25573
25589
  if (result.outcome === "held") return `Check ${ref}: held by ${holder} - claim would renew the lease (nothing written)`;
25574
25590
  if (result.outcome === "took-over") return `Check ${ref}: held by ${previousHolder} - --force claim would take it over for ${holder} (nothing written)`;
25575
25591
  if (result.outcome === "resumed") return `Check ${ref}: prior lane ${previousHolder} is verifiably dead - claim would resume its work for ${holder} (nothing written; ${result.resumeEvidence})`;
25576
- return `Check ${ref}: free - claim would proceed for ${holder} (nothing written)`;
25592
+ return `Check ${ref}: free - claim would proceed for ${holder}${reclaimed} (nothing written)`;
25577
25593
  }
25578
25594
  if (result.partial) {
25579
25595
  if (result.outcome === "took-over") return `Partially took over ${ref} from ${previousHolder}: ${result.warning}`;
@@ -25583,12 +25599,12 @@ function registerBoardCommands(program3) {
25583
25599
  if (result.outcome === "took-over") return `Took over ${ref} from ${previousHolder} for ${holder} - In Progress`;
25584
25600
  if (result.outcome === "resumed") return `Resumed ${ref} from ${previousHolder} for ${holder} - In Progress (${result.resumeEvidence})`;
25585
25601
  if (result.outcome === "held") return `${ref} is held by ${holder} - In Progress`;
25586
- return `Claimed ${ref} for ${holder} - In Progress`;
25602
+ return `Claimed ${ref} for ${holder} - In Progress${reclaimed}`;
25587
25603
  }
25588
25604
  const board = program3.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
25589
25605
  board.command("read", { isDefault: true }).alias("list").description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--direct", "bypass the Hub snapshot and read the live board through direct GitHub GraphQL").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--bodies", "fetch body/comments for EVERY scoped row, including taken and unowned in-flight ones \u2014 for consumers that scope by Status rather than ownership (#4861); implies --bundle-details and costs one extra read per row").option("--allow-partial", "return partial board results when later page/detail reads fail").option("--out <path>", "write the output to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5802)").addHelpText("after", "\nread is always the authoritative live GitHub Project v2 board (#4926).\n--direct skips the Hub snapshot and uses the existing direct GitHub GraphQL read immediately.\n--allow-partial applies to the paginated path and detail reads.\n\nNever capture the JSON with a shell redirect on Windows: PowerShell 5.1's `> file.json` is Out-File,\nwhich writes UTF-16LE with a BOM, and Node reading it as 'utf8' then fails JSON.parse at position 1\n(#5802). Use --out instead \u2014 the CLI writes the file itself as UTF-8:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n").action((o) => runBoardRead(o));
25590
25606
  withExamples(mutating(
25591
- board.command("claim <issues...>").description("claim issues: assign them and move their Project v2 Status to In Progress \u2014 idempotent, so an item already yours and In Progress succeeds unchanged (one or more refs); the board scan rides the Hub snapshot, same as board read").addHelpText("after", "\nclaim reads the board through the same Hub snapshot leg as `board read` (the App-installation\ncredential, never your personal GraphQL pool); the direct user-auth read is an emergency fallback\nand is named in a Warning line after the verdict (#6162).\n\nevery claim stamps a lane-identity marker comment on the issue (`<!-- mmi-claim: \u2026 -->`,\nsurface/session@host) so other agents can attribute the hold (#3727). The session is the\nhost-exported id when the surface provides one, otherwise a per-process `synth-` fallback \u2014\na claim is never anonymous (#5245). `board show`, doctor and unclaim read the latest marker.\n\nsame-owner resume (#6035): when the prior marker was posted by YOUR login on THIS host and its\nlocal session is verifiably dead (transcript probe), the claim proceeds as a resume without\n--force and names the evidence. A live, foreign-host, or unprobeable prior lane still refuses.\n").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--force", "take an item already claimed by another lane that shows live evidence of active work (#3727)").option("--check", "read-only: run every claim gate and report the verdict, writing nothing \u2014 exits 1 with the same refusal a real claim would raise (#4511)").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails"),
25607
+ board.command("claim <issues...>").description("claim issues: assign them and move their Project v2 Status to In Progress \u2014 idempotent, so an item already yours and In Progress succeeds unchanged (one or more refs); the board scan rides the Hub snapshot, same as board read").addHelpText("after", "\nclaim reads the board through the same Hub snapshot leg as `board read` (the App-installation\ncredential, never your personal GraphQL pool); the direct user-auth read is an emergency fallback\nand is named in a Warning line after the verdict (#6162).\n\nevery claim stamps a lane-identity marker comment on the issue (`<!-- mmi-claim: \u2026 -->`,\nsurface/session@host) so other agents can attribute the hold (#3727). The session is the\nhost-exported id when the surface provides one, otherwise a per-process `synth-` fallback \u2014\na claim is never anonymous (#5245). `board show`, doctor and unclaim read the latest marker.\n\nsame-owner resume (#6035): when the prior marker was posted by YOUR login on THIS host and its\nlocal session is verifiably dead (transcript probe), the claim proceeds as a resume without\n--force and names the evidence. A live, foreign-host, or unprobeable prior lane still refuses.\n\nreclaim from In Review (#6339): an In Review item with no holder, or one held by the claiming\nlogin, is claimable \u2014 it moves back to In Progress and the receipt names the status it came from.\nAn In Review item another login holds is refused (ask the holder, or wait for the review to land),\nand Done stays refused. The board status is the authority; no PR state is consulted.\n").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--force", "take an item already claimed by another lane that shows live evidence of active work (#3727)").option("--check", "read-only: run every claim gate and report the verdict, writing nothing \u2014 exits 1 with the same refusal a real claim would raise (#4511)").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails"),
25592
25608
  (_opts, args) => ({ command: "board claim", issues: args[0] ?? [] })
25593
25609
  ).action(async (issueRefs, o) => {
25594
25610
  if (issueRefs.length === 1) {
@@ -26590,8 +26606,9 @@ function deployStagesForBootstrap(deployModel, track) {
26590
26606
  return branchesForTrack(track).map((branch) => branch === "development" ? "dev" : branch);
26591
26607
  }
26592
26608
  var missingBaseDetail = (base) => `${base} is missing; no source ref exists from which bootstrap can create the train lanes`;
26593
- function productRulesetMatches(fact, branches) {
26594
- if (fact?.enforcement !== "active" || !fact.contexts.includes("gate")) return false;
26609
+ function productRulesetMatches(fact, branches, contexts) {
26610
+ const wantedContexts = contexts?.length ? contexts : ["gate"];
26611
+ if (fact?.enforcement !== "active" || !sameNames(fact.contexts, wantedContexts)) return false;
26595
26612
  const wanted = branches.map((branch) => branch.startsWith("refs/") ? branch : `refs/heads/${branch}`);
26596
26613
  return sameNames(fact.branches, wanted);
26597
26614
  }
@@ -26627,7 +26644,7 @@ function planBootstrapControlPlane(input) {
26627
26644
  code: "bootstrap-gap",
26628
26645
  detail: [input.github.gateError, input.github.rulesetError].filter(Boolean).join("; ")
26629
26646
  });
26630
- } else if (productRulesetMatches(input.github.productRuleset, input.requiredCheckBranches)) {
26647
+ } else if (productRulesetMatches(input.github.productRuleset, input.requiredCheckBranches, input.requiredChecks)) {
26631
26648
  rows.push({ kind: "product-ruleset", target: "mmi-product-required-checks", action: "skip", detail: "active gate context and branch scope already match the seeded authority" });
26632
26649
  } else if (!input.github.gateExists) {
26633
26650
  rows.push({ kind: "product-ruleset", target: "mmi-product-required-checks", action: "park", detail: `${PRODUCT_GATE_PATH2} is not present on ${base}; install disabled and activate on the next apply after the seed lands` });
@@ -27639,6 +27656,7 @@ function registerBootstrapCommands(program3) {
27639
27656
  repoClass: cls,
27640
27657
  releaseTrack: track,
27641
27658
  requiredCheckBranches: resolveRequiredCheckBranches(metaWithDeclaredClass(meta, cls), repo),
27659
+ requiredChecks: meta?.requiredChecks ?? void 0,
27642
27660
  deployModel: meta?.deployModel,
27643
27661
  owners: controlOwners,
27644
27662
  github,
@@ -27778,7 +27796,7 @@ function registerBootstrapCommands(program3) {
27778
27796
  for (const [k, v] of Object.entries(gateConfigToVars(meta?.gate))) if (rawVars[k] == null) rawVars[k] = v;
27779
27797
  } catch {
27780
27798
  }
27781
- const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class, bootstrapReleaseTrack, registryRequiredCheckBranches);
27799
+ const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class, bootstrapReleaseTrack, registryRequiredCheckBranches, registryMeta?.requiredChecks);
27782
27800
  if (!vars.PROJECT_ID) {
27783
27801
  try {
27784
27802
  const r = await gh(linkedProjectsQueryArgs(parsedRepo.owner, parsedRepo.name));
@@ -27837,6 +27855,7 @@ function registerBootstrapCommands(program3) {
27837
27855
  repoClass: o.class,
27838
27856
  releaseTrack: effectiveTrack,
27839
27857
  requiredCheckBranches: resolveRequiredCheckBranches(metaWithDeclaredClass(registryMeta, o.class), repo),
27858
+ requiredChecks: registryMeta?.requiredChecks ?? void 0,
27840
27859
  deployModel: applyDeployModel,
27841
27860
  owners: controlOwners,
27842
27861
  github,
@@ -28186,6 +28205,7 @@ ${onlyManagedBlock ? `Only the marker-bounded Hub-managed block inside repo-owne
28186
28205
  repoClass: o.class,
28187
28206
  releaseTrack: effectiveTrack,
28188
28207
  requiredCheckBranches: resolveRequiredCheckBranches(metaWithDeclaredClass(registryMeta, o.class), repo),
28208
+ requiredChecks: registryMeta?.requiredChecks ?? void 0,
28189
28209
  deployModel: applyDeployModel,
28190
28210
  owners: controlOwners,
28191
28211
  github,
@@ -28296,7 +28316,7 @@ LIVE apply to ${repo}:
28296
28316
  if (seed.source.startsWith("seed:")) {
28297
28317
  const project2 = projects.find((p) => (p.repos ?? []).some((repo) => repo.toLowerCase() === r.repo.toLowerCase()));
28298
28318
  const track = resolveReleaseTrack(project2, void 0, r.repo);
28299
- const vars = withDerivedRepoVars({}, parseOwnerRepo(r.repo), repoClass, track, project2?.requiredCheckBranches);
28319
+ const vars = withDerivedRepoVars({}, parseOwnerRepo(r.repo), repoClass, track, project2?.requiredCheckBranches, project2?.requiredChecks);
28300
28320
  const resolved = resolveSeedWriteContent(seed, vars, readSeedFile2, content);
28301
28321
  if (!resolved.ok || resolved.content == null) {
28302
28322
  return fail(`bootstrap propagate: ${r.repo} ${seed.target}: ${resolved.ok ? "rendered no content" : resolved.reason} \u2014 refusing an incomplete per-repo render`);
@@ -29984,6 +30004,19 @@ function stageExtraEnv(config, stagePort) {
29984
30004
  function stageProcessEnv(stagePort, extraEnv) {
29985
30005
  return { ...stagePort != null ? { STAGE_PORT: String(stagePort) } : {}, ...extraEnv };
29986
30006
  }
30007
+ function composeResolvesPort(cwd) {
30008
+ if (process.env.PORT) return true;
30009
+ const envFile = (0, import_node_path27.join)(cwd, ".env");
30010
+ return (0, import_node_fs28.existsSync)(envFile) && envFileKeys((0, import_node_fs28.readFileSync)(envFile, "utf8")).has("PORT");
30011
+ }
30012
+ function stageComposeEnv(config, stagePort, vaultEnvMerge, cwd) {
30013
+ return {
30014
+ // Vault secrets first so the stage-selection contract (MMI_STAGE/MMI_PORT/…) always wins on any collision.
30015
+ ...vaultEnvMerge ?? {},
30016
+ ...stageProcessEnv(stagePort, stageExtraEnv(config, stagePort)),
30017
+ ...stagePort != null && !composeResolvesPort(cwd) ? { PORT: String(stagePort) } : {}
30018
+ };
30019
+ }
29987
30020
  async function ensureStageRuntimeEnv(config, opts, cwd) {
29988
30021
  if (!config.ensureEnv) return;
29989
30022
  const target = (0, import_node_path27.join)(cwd, config.ensureEnv.target);
@@ -30059,10 +30092,19 @@ function writeStagePortReservation(port, cwd, statePath, globalStatePath, now) {
30059
30092
  writeState(statePath, reservation);
30060
30093
  if (globalStatePath && globalStatePath !== statePath) writeState(globalStatePath, reservation);
30061
30094
  }
30062
- async function cleanupStageState(state, paths, timeoutMs, fallbackCwd) {
30095
+ function teardownEnv(state, currentEnv) {
30096
+ const env = { ...state.teardown?.env ?? {}, ...currentEnv ?? {} };
30097
+ return Object.keys(env).length ? env : void 0;
30098
+ }
30099
+ async function cleanupStageState(state, paths, timeoutMs, fallbackCwd, currentEnv) {
30063
30100
  await killTree(state.pid);
30064
30101
  if (state.teardown?.command.trim()) {
30065
- await shell(state.teardown.command.trim(), state.teardown.cwd || state.cwd || fallbackCwd, Math.max(timeoutMs, 1e4));
30102
+ await shell(
30103
+ state.teardown.command.trim(),
30104
+ state.teardown.cwd || state.cwd || fallbackCwd,
30105
+ Math.max(timeoutMs, 1e4),
30106
+ teardownEnv(state, currentEnv)
30107
+ );
30066
30108
  }
30067
30109
  for (const path2 of [...new Set(paths.filter((p) => Boolean(p)))]) {
30068
30110
  (0, import_node_fs28.rmSync)(path2, { force: true });
@@ -30120,13 +30162,20 @@ async function stopStage(opts = {}) {
30120
30162
  }
30121
30163
  const usingGlobalState = state === globalState;
30122
30164
  const recordedStatePath = state.statePath ?? statePath;
30123
- await cleanupStageState(state, [statePath, recordedStatePath, usingGlobalState || !opts.requiredIdentityCwd ? globalStatePath : void 0], opts.timeoutMs ?? 6e4, cwd);
30165
+ const recordedTeardownEnv = Boolean(state.teardown?.env);
30166
+ await cleanupStageState(
30167
+ state,
30168
+ [statePath, recordedStatePath, usingGlobalState || !opts.requiredIdentityCwd ? globalStatePath : void 0],
30169
+ opts.timeoutMs ?? 6e4,
30170
+ cwd,
30171
+ opts.vaultEnvMerge
30172
+ );
30124
30173
  return {
30125
30174
  ok: true,
30126
30175
  action: "stop",
30127
30176
  statePath: recordedStatePath,
30128
30177
  pid: state.pid,
30129
- message: `stopped previous stage pid ${state.pid}${state.teardown?.command.trim() ? " and ran teardown" : ""}`
30178
+ message: `stopped previous stage pid ${state.pid}` + (state.teardown?.command.trim() ? ` and ran teardown${recordedTeardownEnv ? "" : " (no recorded teardown env \u2014 used the current environment)"}` : "")
30130
30179
  };
30131
30180
  }
30132
30181
  async function startStage(config = {}, opts = {}) {
@@ -30146,8 +30195,7 @@ async function startStage(config = {}, opts = {}) {
30146
30195
  const sub = (s) => substituteStagePort(s, stagePort);
30147
30196
  if (!opts.envPrepared) await ensureStageRuntimeEnv(config, opts, cwd);
30148
30197
  if (stagePort != null && portGuard) await ensureStagePortAvailable(stagePort, cwd, portGuard);
30149
- const extraEnv = stageExtraEnv(config, stagePort);
30150
- const vaultProcessEnv = opts.vaultEnvMerge ?? {};
30198
+ const composeEnv = stageComposeEnv(config, stagePort, opts.vaultEnvMerge, cwd);
30151
30199
  let up = sub(config.up.trim());
30152
30200
  if (opts.forceRecreate) up = appendForceRecreate(up);
30153
30201
  const identity = await resolveStageIdentity(cwd);
@@ -30160,8 +30208,7 @@ async function startStage(config = {}, opts = {}) {
30160
30208
  detached: process.platform !== "win32",
30161
30209
  windowsHide: true,
30162
30210
  stdio: "ignore",
30163
- // Vault secrets first so the stage-selection contract (MMI_STAGE/MMI_PORT/…) always wins on any collision.
30164
- env: { ...process.env, ...vaultProcessEnv, ...stageProcessEnv(stagePort, extraEnv) }
30211
+ env: { ...process.env, ...composeEnv }
30165
30212
  });
30166
30213
  const state = {
30167
30214
  pid: child2.pid ?? 0,
@@ -30172,7 +30219,10 @@ async function startStage(config = {}, opts = {}) {
30172
30219
  healthUrl: sub(config.healthUrl?.trim()) || void 0,
30173
30220
  port: stagePort,
30174
30221
  identity,
30175
- teardown: config.teardown?.trim() ? { command: sub(config.teardown.trim()), cwd } : void 0
30222
+ // #6343: record the non-secret half of the interpolation env so a LATER `stage stop` can run
30223
+ // `docker compose down` against the same file. `vaultEnvMerge` is omitted deliberately — no secret
30224
+ // value ever reaches disk (#2655); the stopping invocation re-fetches them.
30225
+ teardown: config.teardown?.trim() ? { command: sub(config.teardown.trim()), cwd, env: stageComposeEnv(config, stagePort, void 0, cwd) } : void 0
30176
30226
  };
30177
30227
  writeState(statePath, state);
30178
30228
  if (globalStatePath && globalStatePath !== statePath) writeState(globalStatePath, state);
@@ -30180,7 +30230,7 @@ async function startStage(config = {}, opts = {}) {
30180
30230
  if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4, config.healthAnyStatus);
30181
30231
  else await waitForProcessStability(child2);
30182
30232
  } catch (e) {
30183
- await cleanupStageState(state, [statePath, globalStatePath], opts.timeoutMs ?? 6e4, cwd);
30233
+ await cleanupStageState(state, [statePath, globalStatePath], opts.timeoutMs ?? 6e4, cwd, composeEnv);
30184
30234
  throw e;
30185
30235
  }
30186
30236
  const result = {
@@ -30211,7 +30261,12 @@ async function runStage(config = {}, opts = {}) {
30211
30261
  const statePath = opts.statePath ?? stageStatePath(cwd);
30212
30262
  const globalStatePath = await resolveGlobalStatePath(cwd, opts.globalStatePath);
30213
30263
  const portGuard = resolveStagePortGuard(opts);
30214
- await stopStage({ ...opts, cwd, requiredIdentityCwd: opts.requiredIdentityCwd ?? cwd });
30264
+ await stopStage({
30265
+ ...opts,
30266
+ cwd,
30267
+ requiredIdentityCwd: opts.requiredIdentityCwd ?? cwd,
30268
+ vaultEnvMerge: stageComposeEnv(config, opts.stagePort, opts.vaultEnvMerge, cwd)
30269
+ });
30215
30270
  const reserved = await reservedPortsForWorktree(cwd);
30216
30271
  let stagePort = opts.stagePort;
30217
30272
  if (stagePort != null) {
@@ -30223,14 +30278,13 @@ async function runStage(config = {}, opts = {}) {
30223
30278
  if (stagePort != null) {
30224
30279
  writeStagePortReservation(stagePort, cwd, statePath, globalStatePath, opts.now ?? (() => /* @__PURE__ */ new Date()));
30225
30280
  }
30226
- const extraEnv = stageExtraEnv(config, stagePort);
30227
30281
  const build = config.build?.trim();
30228
30282
  const ranBuild = Boolean(build);
30229
30283
  try {
30230
30284
  await ensureStageRuntimeEnv(config, opts, cwd);
30231
30285
  if (build) {
30232
30286
  await shell(sub(build), cwd, timeoutMs, {
30233
- ...stageProcessEnv(stagePort, extraEnv),
30287
+ ...stageComposeEnv(config, stagePort, opts.vaultEnvMerge, cwd),
30234
30288
  ...opts.buildEnvMerge ?? {}
30235
30289
  });
30236
30290
  }
@@ -42451,6 +42505,30 @@ ${recovery.note}; the full required-check wall did not revalidate`);
42451
42505
  }, `the immutable ${anchors.tag} pushed at ${mergedSha.slice(0, 12)}`);
42452
42506
  return checks;
42453
42507
  }
42508
+ async function assertConsumerBomLineage(deps, mergedSha, tag) {
42509
+ let bomText;
42510
+ try {
42511
+ bomText = await deps.run("git", ["show", `${mergedSha}:distribution-bom.json`]);
42512
+ } catch {
42513
+ return;
42514
+ }
42515
+ let stamp;
42516
+ try {
42517
+ stamp = JSON.parse(bomText).sourceCommit;
42518
+ } catch (e) {
42519
+ throw new Error(`hotfix ${tag}: distribution-bom.json at ${mergedSha.slice(0, 12)} is unparseable (${e.message}) \u2014 the updater would refuse this release; fix the BOM on development and rerun from a fresh port`);
42520
+ }
42521
+ if (typeof stamp !== "string" || !/^[0-9a-f]{40}$/.test(stamp)) {
42522
+ throw new Error(`hotfix ${tag}: distribution-bom.json at ${mergedSha.slice(0, 12)} carries no 40-hex sourceCommit \u2014 the updater would refuse this release`);
42523
+ }
42524
+ try {
42525
+ await deps.run("git", ["merge-base", "--is-ancestor", stamp, mergedSha]);
42526
+ } catch {
42527
+ throw new Error(
42528
+ `hotfix ${tag}: distribution-bom.json at ${mergedSha.slice(0, 12)} stamps sourceCommit ${stamp.slice(0, 12)}, which the merged main commit does not contain \u2014 every updater would reject ${tag} (gateCandidate ancestry). The fold was stamped on the hotfix branch and squash-merged away: make the consumer stamp a durable merge-base (Jerv-Hub#1084), land that on development, and port it with the fix \u2014 see docs/Guides/train-troubleshooting.md#hotfix-bom-lineage`
42529
+ );
42530
+ }
42531
+ }
42454
42532
  async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTrainDoctor) {
42455
42533
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
42456
42534
  const ctx = await buildTrainApplyContext(deps);
@@ -42484,6 +42562,8 @@ async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTr
42484
42562
  assertTagAddressableRequiredContexts(deps, required, ctx.repo);
42485
42563
  if (deployModel === "hub-serverless") {
42486
42564
  await deps.run("node", ["scripts/release-distribution.mjs", "assert-release-lineage", version, "--release-commit", mergedSha]);
42565
+ } else {
42566
+ await assertConsumerBomLineage(deps, mergedSha, tag);
42487
42567
  }
42488
42568
  const releaseExists = await hotfixReleaseExists(deps, ctx, tag);
42489
42569
  if (!releaseExists && isHubControlRepo(ctx.repo)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.3.25",
3
+ "version": "4.3.27",
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",