@mutmutco/cli 4.3.26 → 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 +99 -29
  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
  }
@@ -15526,10 +15535,10 @@ var rollout_plan_default = {
15526
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)."
15527
15536
  },
15528
15537
  baseline: {
15529
- version: "4.3.26",
15530
- tag: "v4.3.26",
15531
- commit: "32a20f524697",
15532
- npm: "@mutmutco/cli@4.3.26"
15538
+ version: "4.3.27",
15539
+ tag: "v4.3.27",
15540
+ commit: "924b5a7761c4",
15541
+ npm: "@mutmutco/cli@4.3.27"
15533
15542
  },
15534
15543
  exitCriterion: "fleet-n-of-n",
15535
15544
  hubOnlyShortcut: "forbidden",
@@ -15546,14 +15555,14 @@ var rollout_plan_default = {
15546
15555
  repo: "mutmutco/mmi-hub",
15547
15556
  role: "canary",
15548
15557
  schedule: "train",
15549
- v3Target: "v4.3.26"
15558
+ v3Target: "v4.3.27"
15550
15559
  }
15551
15560
  ],
15552
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.",
15553
15562
  rollback: {
15554
15563
  independent: true,
15555
- mechanism: "npm dist-tag latest -> 4.3.26 and redeploy the Hub Lambda from tag v4.3.26 (32a20f524697); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15556
- v3Target: "v4.3.26 (@mutmutco/cli@4.3.26, tag commit 32a20f524697 \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)"
15557
15566
  }
15558
15567
  },
15559
15568
  {
@@ -25575,11 +25584,12 @@ function registerBoardCommands(program3) {
25575
25584
  function claimVerdict(ref, result) {
25576
25585
  const holder = formatClaimHolder(result.holder);
25577
25586
  const previousHolder = result.previousHolder ? formatClaimHolder(result.previousHolder) : "another lane";
25587
+ const reclaimed = result.reclaimedFrom ? ` (reclaimed from ${result.reclaimedFrom})` : "";
25578
25588
  if (result.checked) {
25579
25589
  if (result.outcome === "held") return `Check ${ref}: held by ${holder} - claim would renew the lease (nothing written)`;
25580
25590
  if (result.outcome === "took-over") return `Check ${ref}: held by ${previousHolder} - --force claim would take it over for ${holder} (nothing written)`;
25581
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})`;
25582
- return `Check ${ref}: free - claim would proceed for ${holder} (nothing written)`;
25592
+ return `Check ${ref}: free - claim would proceed for ${holder}${reclaimed} (nothing written)`;
25583
25593
  }
25584
25594
  if (result.partial) {
25585
25595
  if (result.outcome === "took-over") return `Partially took over ${ref} from ${previousHolder}: ${result.warning}`;
@@ -25589,12 +25599,12 @@ function registerBoardCommands(program3) {
25589
25599
  if (result.outcome === "took-over") return `Took over ${ref} from ${previousHolder} for ${holder} - In Progress`;
25590
25600
  if (result.outcome === "resumed") return `Resumed ${ref} from ${previousHolder} for ${holder} - In Progress (${result.resumeEvidence})`;
25591
25601
  if (result.outcome === "held") return `${ref} is held by ${holder} - In Progress`;
25592
- return `Claimed ${ref} for ${holder} - In Progress`;
25602
+ return `Claimed ${ref} for ${holder} - In Progress${reclaimed}`;
25593
25603
  }
25594
25604
  const board = program3.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
25595
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));
25596
25606
  withExamples(mutating(
25597
- 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"),
25598
25608
  (_opts, args) => ({ command: "board claim", issues: args[0] ?? [] })
25599
25609
  ).action(async (issueRefs, o) => {
25600
25610
  if (issueRefs.length === 1) {
@@ -29994,6 +30004,19 @@ function stageExtraEnv(config, stagePort) {
29994
30004
  function stageProcessEnv(stagePort, extraEnv) {
29995
30005
  return { ...stagePort != null ? { STAGE_PORT: String(stagePort) } : {}, ...extraEnv };
29996
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
+ }
29997
30020
  async function ensureStageRuntimeEnv(config, opts, cwd) {
29998
30021
  if (!config.ensureEnv) return;
29999
30022
  const target = (0, import_node_path27.join)(cwd, config.ensureEnv.target);
@@ -30069,10 +30092,19 @@ function writeStagePortReservation(port, cwd, statePath, globalStatePath, now) {
30069
30092
  writeState(statePath, reservation);
30070
30093
  if (globalStatePath && globalStatePath !== statePath) writeState(globalStatePath, reservation);
30071
30094
  }
30072
- 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) {
30073
30100
  await killTree(state.pid);
30074
30101
  if (state.teardown?.command.trim()) {
30075
- 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
+ );
30076
30108
  }
30077
30109
  for (const path2 of [...new Set(paths.filter((p) => Boolean(p)))]) {
30078
30110
  (0, import_node_fs28.rmSync)(path2, { force: true });
@@ -30130,13 +30162,20 @@ async function stopStage(opts = {}) {
30130
30162
  }
30131
30163
  const usingGlobalState = state === globalState;
30132
30164
  const recordedStatePath = state.statePath ?? statePath;
30133
- 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
+ );
30134
30173
  return {
30135
30174
  ok: true,
30136
30175
  action: "stop",
30137
30176
  statePath: recordedStatePath,
30138
30177
  pid: state.pid,
30139
- 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)"}` : "")
30140
30179
  };
30141
30180
  }
30142
30181
  async function startStage(config = {}, opts = {}) {
@@ -30156,8 +30195,7 @@ async function startStage(config = {}, opts = {}) {
30156
30195
  const sub = (s) => substituteStagePort(s, stagePort);
30157
30196
  if (!opts.envPrepared) await ensureStageRuntimeEnv(config, opts, cwd);
30158
30197
  if (stagePort != null && portGuard) await ensureStagePortAvailable(stagePort, cwd, portGuard);
30159
- const extraEnv = stageExtraEnv(config, stagePort);
30160
- const vaultProcessEnv = opts.vaultEnvMerge ?? {};
30198
+ const composeEnv = stageComposeEnv(config, stagePort, opts.vaultEnvMerge, cwd);
30161
30199
  let up = sub(config.up.trim());
30162
30200
  if (opts.forceRecreate) up = appendForceRecreate(up);
30163
30201
  const identity = await resolveStageIdentity(cwd);
@@ -30170,8 +30208,7 @@ async function startStage(config = {}, opts = {}) {
30170
30208
  detached: process.platform !== "win32",
30171
30209
  windowsHide: true,
30172
30210
  stdio: "ignore",
30173
- // Vault secrets first so the stage-selection contract (MMI_STAGE/MMI_PORT/…) always wins on any collision.
30174
- env: { ...process.env, ...vaultProcessEnv, ...stageProcessEnv(stagePort, extraEnv) }
30211
+ env: { ...process.env, ...composeEnv }
30175
30212
  });
30176
30213
  const state = {
30177
30214
  pid: child2.pid ?? 0,
@@ -30182,7 +30219,10 @@ async function startStage(config = {}, opts = {}) {
30182
30219
  healthUrl: sub(config.healthUrl?.trim()) || void 0,
30183
30220
  port: stagePort,
30184
30221
  identity,
30185
- 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
30186
30226
  };
30187
30227
  writeState(statePath, state);
30188
30228
  if (globalStatePath && globalStatePath !== statePath) writeState(globalStatePath, state);
@@ -30190,7 +30230,7 @@ async function startStage(config = {}, opts = {}) {
30190
30230
  if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4, config.healthAnyStatus);
30191
30231
  else await waitForProcessStability(child2);
30192
30232
  } catch (e) {
30193
- await cleanupStageState(state, [statePath, globalStatePath], opts.timeoutMs ?? 6e4, cwd);
30233
+ await cleanupStageState(state, [statePath, globalStatePath], opts.timeoutMs ?? 6e4, cwd, composeEnv);
30194
30234
  throw e;
30195
30235
  }
30196
30236
  const result = {
@@ -30221,7 +30261,12 @@ async function runStage(config = {}, opts = {}) {
30221
30261
  const statePath = opts.statePath ?? stageStatePath(cwd);
30222
30262
  const globalStatePath = await resolveGlobalStatePath(cwd, opts.globalStatePath);
30223
30263
  const portGuard = resolveStagePortGuard(opts);
30224
- 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
+ });
30225
30270
  const reserved = await reservedPortsForWorktree(cwd);
30226
30271
  let stagePort = opts.stagePort;
30227
30272
  if (stagePort != null) {
@@ -30233,14 +30278,13 @@ async function runStage(config = {}, opts = {}) {
30233
30278
  if (stagePort != null) {
30234
30279
  writeStagePortReservation(stagePort, cwd, statePath, globalStatePath, opts.now ?? (() => /* @__PURE__ */ new Date()));
30235
30280
  }
30236
- const extraEnv = stageExtraEnv(config, stagePort);
30237
30281
  const build = config.build?.trim();
30238
30282
  const ranBuild = Boolean(build);
30239
30283
  try {
30240
30284
  await ensureStageRuntimeEnv(config, opts, cwd);
30241
30285
  if (build) {
30242
30286
  await shell(sub(build), cwd, timeoutMs, {
30243
- ...stageProcessEnv(stagePort, extraEnv),
30287
+ ...stageComposeEnv(config, stagePort, opts.vaultEnvMerge, cwd),
30244
30288
  ...opts.buildEnvMerge ?? {}
30245
30289
  });
30246
30290
  }
@@ -42461,6 +42505,30 @@ ${recovery.note}; the full required-check wall did not revalidate`);
42461
42505
  }, `the immutable ${anchors.tag} pushed at ${mergedSha.slice(0, 12)}`);
42462
42506
  return checks;
42463
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
+ }
42464
42532
  async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTrainDoctor) {
42465
42533
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
42466
42534
  const ctx = await buildTrainApplyContext(deps);
@@ -42494,6 +42562,8 @@ async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTr
42494
42562
  assertTagAddressableRequiredContexts(deps, required, ctx.repo);
42495
42563
  if (deployModel === "hub-serverless") {
42496
42564
  await deps.run("node", ["scripts/release-distribution.mjs", "assert-release-lineage", version, "--release-commit", mergedSha]);
42565
+ } else {
42566
+ await assertConsumerBomLineage(deps, mergedSha, tag);
42497
42567
  }
42498
42568
  const releaseExists = await hotfixReleaseExists(deps, ctx, tag);
42499
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.26",
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",