@mutmutco/codex-plugin 4.5.96 → 4.5.98

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mmi",
3
- "version": "4.5.96",
3
+ "version": "4.5.98",
4
4
  "mmiCompat": "4.x",
5
5
  "description": "MMI workflow skills and org gates delivery.",
6
6
  "author": {
package/cli/dist/main.cjs CHANGED
@@ -5927,7 +5927,7 @@ async function waitForPrChecks(deps) {
5927
5927
  reason = NO_CI_LIVE_CHECKS_CONTRADICTION_REASON;
5928
5928
  deps.log?.(`merge CI policy contradiction: ${reason}; waiting for live PR checks`);
5929
5929
  policy = "wait-for-checks";
5930
- queuedStates.push(firstState);
5930
+ if (!deps.pollChecksWithEvidence) queuedStates.push(firstState);
5931
5931
  } catch (e) {
5932
5932
  if (!isRetryableGitHubWaitReadError(e)) {
5933
5933
  return { policy, status: "failure", reason: waitReadFailureMessage(e), detail: "github-read-failed" };
@@ -5955,6 +5955,8 @@ async function waitForPrChecks(deps) {
5955
5955
  let successStreak = 0;
5956
5956
  let failureStreak = 0;
5957
5957
  let zeroRunsStreak = 0;
5958
+ let evidenceHeadSha;
5959
+ let lastEvidence;
5958
5960
  report("starting");
5959
5961
  while (now() < deadline) {
5960
5962
  if (deps.pollRateLimit) {
@@ -5986,7 +5988,17 @@ async function waitForPrChecks(deps) {
5986
5988
  }
5987
5989
  let state;
5988
5990
  try {
5989
- state = queuedStates.shift() ?? await deps.pollChecks();
5991
+ if (queuedStates.length) state = queuedStates.shift();
5992
+ else if (deps.pollChecksWithEvidence) {
5993
+ lastEvidence = await deps.pollChecksWithEvidence();
5994
+ if (evidenceHeadSha !== void 0 && evidenceHeadSha !== lastEvidence.headSha) {
5995
+ successStreak = 0;
5996
+ failureStreak = 0;
5997
+ zeroRunsStreak = 0;
5998
+ }
5999
+ evidenceHeadSha = lastEvidence.headSha;
6000
+ state = lastEvidence.state;
6001
+ } else state = await deps.pollChecks();
5990
6002
  } catch (e) {
5991
6003
  if (!isRetryableGitHubWaitReadError(e)) {
5992
6004
  return { policy, status: "failure", reason: waitReadFailureMessage(e), detail: "github-read-failed", waitedMs: now() - started };
@@ -6032,7 +6044,13 @@ async function waitForPrChecks(deps) {
6032
6044
  if (state === "success") {
6033
6045
  successStreak += 1;
6034
6046
  if (successStreak >= PR_CHECKS_SUCCESS_CONFIRMATIONS) {
6035
- return { policy, status: "success", reason, detail: "checks-success" };
6047
+ return {
6048
+ policy,
6049
+ status: "success",
6050
+ reason,
6051
+ detail: "checks-success",
6052
+ ...lastEvidence ? { headSha: lastEvidence.headSha, checks: lastEvidence.checks, workflowRuns: lastEvidence.workflows } : {}
6053
+ };
6036
6054
  }
6037
6055
  lastDetail = "confirming-success";
6038
6056
  await deps.sleep(PR_CHECKS_CONFIRM_MS);
@@ -6347,6 +6365,36 @@ async function pollRestPrChecks(prNumber, repo, gh2 = defaultGhApi, requiredCont
6347
6365
  const { relevant } = partitionByRequired(entries, requiredContexts);
6348
6366
  return derivePollState(relevant);
6349
6367
  }
6368
+ async function pollRestPrChecksWithWorkflowEvidence(prNumber, repo, gh2 = defaultGhApi) {
6369
+ const snapshot = await fetchRestPrSnapshot(prNumber, repo, gh2);
6370
+ const [entries, rawRuns] = await Promise.all([
6371
+ fetchHeadCheckEntries(snapshot.headSha, repo, gh2),
6372
+ gh2(["--paginate", `repos/${repo}/actions/runs?head_sha=${encodeURIComponent(snapshot.headSha)}&per_page=100`, "--jq", ".workflow_runs[] | {id, name, head_sha, status, conclusion, html_url}"])
6373
+ ]);
6374
+ const checks = entries.filter((entry) => !AUXILIARY_RELEASE_CHECK_CONTEXTS.has(entry.name)).map((entry) => ({ name: entry.name, state: entry.bucket }));
6375
+ const workflows = parseNdjsonLines(rawRuns).flatMap((run) => {
6376
+ if (run.head_sha !== snapshot.headSha || typeof run.id !== "number" || typeof run.name !== "string" || typeof run.status !== "string") return [];
6377
+ if (AUXILIARY_RELEASE_CHECK_CONTEXTS.has(run.name)) return [];
6378
+ return [{
6379
+ id: run.id,
6380
+ name: run.name,
6381
+ headSha: snapshot.headSha,
6382
+ status: run.status,
6383
+ conclusion: typeof run.conclusion === "string" ? run.conclusion : null,
6384
+ ...typeof run.html_url === "string" ? { url: run.html_url } : {}
6385
+ }];
6386
+ });
6387
+ const workflowBuckets = workflows.map((run) => {
6388
+ if (run.status !== "completed") return "pending";
6389
+ return run.conclusion === "success" || run.conclusion === "neutral" || run.conclusion === "skipped" ? "pass" : "fail";
6390
+ });
6391
+ return {
6392
+ state: derivePollState([...checks.map((check) => check.state), ...workflowBuckets]),
6393
+ headSha: snapshot.headSha,
6394
+ checks,
6395
+ workflows
6396
+ };
6397
+ }
6350
6398
  async function pollRestCommitChecks(sha, repo, gh2 = defaultGhApi) {
6351
6399
  return derivePollState(partitionByRequired(await fetchHeadCheckEntries(sha, repo, gh2), null).relevant);
6352
6400
  }
@@ -13143,6 +13191,7 @@ function deriveLoopStaleness(item, loopState, now = Date.now()) {
13143
13191
  };
13144
13192
  }
13145
13193
  var CLAIM_MARKER_PATTERN = /<!--\s*mmi-claim:\s*(\{[\s\S]*?\})\s*-->/g;
13194
+ var CLAIM_RELEASE_PATTERN = /<!--\s*mmi-claim-release:\s*(\{[\s\S]*?\})\s*-->/g;
13146
13195
  var CLAIM_MARKER_LIVE_MS = 24 * 60 * 6e4;
13147
13196
  var CLAIM_STAMP_IDLE_MS = 4 * 60 * 6e4;
13148
13197
  function isValidClaimMarker(parsed) {
@@ -13161,10 +13210,36 @@ function parseClaimMarkers(body) {
13161
13210
  }
13162
13211
  return out;
13163
13212
  }
13213
+ function isValidClaimRelease(parsed) {
13214
+ if (!parsed || typeof parsed !== "object") return false;
13215
+ const release = parsed;
13216
+ return typeof release.v === "number" && typeof release.ts === "string" && Number.isFinite(Date.parse(release.ts)) && (release.session === void 0 || typeof release.session === "string") && (release.surface === void 0 || typeof release.surface === "string") && (release.host === void 0 || typeof release.host === "string");
13217
+ }
13218
+ function parseClaimReleases(body) {
13219
+ const out = [];
13220
+ for (const match of body.matchAll(CLAIM_RELEASE_PATTERN)) {
13221
+ try {
13222
+ const parsed = JSON.parse(match[1]);
13223
+ if (isValidClaimRelease(parsed)) out.push(parsed);
13224
+ } catch {
13225
+ }
13226
+ }
13227
+ return out;
13228
+ }
13229
+ function renderClaimReleaseBody(release) {
13230
+ return `Released by \`${formatSessionIdentity(release)}\` at ${release.ts}.
13231
+
13232
+ <!-- mmi-claim-release: ${JSON.stringify(release)} -->`;
13233
+ }
13164
13234
  function latestClaimMarker(comments) {
13165
13235
  for (let i = comments.length - 1; i >= 0; i -= 1) {
13166
- const markers = parseClaimMarkers(comments[i].body);
13167
- if (markers.length) return markers[markers.length - 1];
13236
+ const body = comments[i].body;
13237
+ const markers = parseClaimMarkers(body);
13238
+ const releases = parseClaimReleases(body);
13239
+ if (!markers.length && !releases.length) continue;
13240
+ if (!releases.length) return markers[markers.length - 1];
13241
+ if (!markers.length) return void 0;
13242
+ return body.lastIndexOf("mmi-claim-release:") > body.lastIndexOf("mmi-claim:") ? void 0 : markers[markers.length - 1];
13168
13243
  }
13169
13244
  return void 0;
13170
13245
  }
@@ -13198,7 +13273,7 @@ function claimFromMarker(item, marker, linked = {}, now = Date.now()) {
13198
13273
  holder: describeClaimMarker(marker),
13199
13274
  ageMs: now - Date.parse(marker.ts),
13200
13275
  currentlyClaimed: item.assignees.length > 0 && item.status === "In Progress",
13201
- ...claimStampStaleness(marker, { activityAt: [item.updatedAt, ...linked.activityAt ?? []], linkedWork: linked.linkedWork }, now)
13276
+ ...claimStampStaleness(marker, { activityAt: [...linked.activityAt ?? []], linkedWork: linked.linkedWork }, now)
13202
13277
  };
13203
13278
  }
13204
13279
  function classifyClaimableMarker(comments, actor, now = Date.now(), activityAt = []) {
@@ -13280,7 +13355,7 @@ async function demoteLiveClaimedClaimables(scope, client, actor = describeSessio
13280
13355
  warnings.push(`${item.ref} left claimable: claim-marker evidence could not be read`);
13281
13356
  continue;
13282
13357
  }
13283
- const verdict = classifyClaimableMarker(comments, actor, deps.now, [item.updatedAt]);
13358
+ const verdict = classifyClaimableMarker(comments, actor, deps.now);
13284
13359
  if (verdict.verdict === "claimable") {
13285
13360
  stillClaimable.push(item);
13286
13361
  } else if (verdict.verdict === "owned") {
@@ -14201,6 +14276,20 @@ async function unclaimBoardIssue(options, deps = {}) {
14201
14276
  if (!options.allowPartial) throw new Error(warning);
14202
14277
  return { item, viewer, repo: currentRepo, status: item.status, partial: true, warning };
14203
14278
  }
14279
+ try {
14280
+ const actor = deps.session ?? describeSessionIdentity();
14281
+ const release = {
14282
+ v: 1,
14283
+ session: actor.session,
14284
+ surface: actor.surface,
14285
+ host: actor.host,
14286
+ ts: (/* @__PURE__ */ new Date()).toISOString()
14287
+ };
14288
+ await postIssueComment(client, { ref: item.ref, body: renderClaimReleaseBody(release) });
14289
+ } catch (e) {
14290
+ process.stderr.write(`warning: ${item.ref} was unclaimed, but recording the release marker comment failed: ${ghError(e)}
14291
+ `);
14292
+ }
14204
14293
  const optionId = cfg.statusOptions[toStatus];
14205
14294
  let unclaimCredential = "user";
14206
14295
  try {
@@ -15224,7 +15313,7 @@ function createDocsIndexDeps(repoRoot2) {
15224
15313
 
15225
15314
  // src/gate-budget.ts
15226
15315
  var BLESSED_RUN_WITH_BUDGET_SHA = "5ce541add569e54e7ca96de67a768577d3d604e4";
15227
- var BLESSED_RUNNER_GATE_SHA = "a6ee756d94fd95884451381e94408f0348eb11db";
15316
+ var BLESSED_RUNNER_GATE_SHA = "a19e9392e5ad9b0675f02e4304d68dd933fad60f";
15228
15317
  var REMOTE_USE = /^mutmutco\/MMI-Hub\/\.github\/actions\/run-with-budget@(\S+)$/;
15229
15318
  var LOCAL_USE = "./.github/actions/run-with-budget";
15230
15319
  var FULL_SHA = /^[0-9a-f]{40}$/;
@@ -17256,10 +17345,10 @@ var rollout_plan_default = {
17256
17345
  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)."
17257
17346
  },
17258
17347
  baseline: {
17259
- version: "4.5.96",
17260
- tag: "v4.5.96",
17261
- commit: "191fc3cf3396",
17262
- npm: "@mutmutco/cli@4.5.96"
17348
+ version: "4.5.98",
17349
+ tag: "v4.5.98",
17350
+ commit: "125c59187d10",
17351
+ npm: "@mutmutco/cli@4.5.98"
17263
17352
  },
17264
17353
  exitCriterion: "fleet-n-of-n",
17265
17354
  hubOnlyShortcut: "forbidden",
@@ -17276,14 +17365,14 @@ var rollout_plan_default = {
17276
17365
  repo: "mutmutco/mmi-hub",
17277
17366
  role: "canary",
17278
17367
  schedule: "train",
17279
- v3Target: "v4.5.96"
17368
+ v3Target: "v4.5.98"
17280
17369
  }
17281
17370
  ],
17282
17371
  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.",
17283
17372
  rollback: {
17284
17373
  independent: true,
17285
- mechanism: "npm dist-tag latest -> 4.5.96 and redeploy the Hub Lambda from tag v4.5.96 (191fc3cf3396); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17286
- v3Target: "v4.5.96 (@mutmutco/cli@4.5.96, tag commit 191fc3cf3396 \u2014 last known-good release carrying the repo-index v4-only contract)"
17374
+ mechanism: "npm dist-tag latest -> 4.5.98 and redeploy the Hub Lambda from tag v4.5.98 (125c59187d10); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17375
+ v3Target: "v4.5.98 (@mutmutco/cli@4.5.98, tag commit 125c59187d10 \u2014 last known-good release carrying the repo-index v4-only contract)"
17287
17376
  }
17288
17377
  },
17289
17378
  {
@@ -30465,8 +30554,10 @@ function trainPlanTargetsFromTags(tags, explicitRaw) {
30465
30554
  }
30466
30555
  function derivedStagePlan(derived, shell2, stops = true) {
30467
30556
  const { port } = derived;
30468
- const envOrder = ["MMI_STAGE", "MMI_PORT", "COMPOSE_PROFILES"];
30469
- const resolved = { MMI_STAGE: "development", MMI_PORT: String(port), COMPOSE_PROFILES: "local" };
30557
+ const baseOrder = ["MMI_STAGE", "MMI_PORT", "COMPOSE_PROFILES"];
30558
+ const projectName = derived.config.env?.COMPOSE_PROJECT_NAME?.trim();
30559
+ const envOrder = projectName ? [...baseOrder, "COMPOSE_PROJECT_NAME"] : baseOrder;
30560
+ const resolved = { MMI_STAGE: "dev", MMI_PORT: String(port), COMPOSE_PROFILES: "local", ...projectName ? { COMPOSE_PROJECT_NAME: projectName } : {} };
30470
30561
  const ensureEnv = shell2 === "powershell" ? `if (-not (Test-Path -LiteralPath '.env')) { Copy-Item -LiteralPath '.env.example' -Destination '.env' }` : `[ -f .env ] || cp .env.example .env`;
30471
30562
  const envPrefix = shell2 === "powershell" ? envOrder.map((k) => `$env:${k}='${resolved[k]}'`).join("; ") : envOrder.map((k) => `${k}=${resolved[k]}`).join(" ");
30472
30563
  const build = shell2 === "powershell" ? `${envPrefix}; ${derived.config.build ?? "docker compose build --no-cache"}` : `${envPrefix} ${derived.config.build ?? "docker compose build --no-cache"}`;
@@ -30871,7 +30962,7 @@ init_hub_auth();
30871
30962
  init_github_client();
30872
30963
 
30873
30964
  // src/project-set.ts
30874
- var UNSET_KEYS = ["projectAdmins", "installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary", "notifyOnDeploy", "vercelVersionEnv", "stageAllowlist", "suiteHold"];
30965
+ var UNSET_KEYS = ["projectAdmins", "installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary", "notifyOnDeploy", "vercelVersionEnv", "stageAllowlist", "suiteHold", "internalPeers"];
30875
30966
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
30876
30967
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
30877
30968
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -31406,6 +31497,7 @@ var SETTABLE_VAR_KEYS = [
31406
31497
  "requiredRuntimeSecrets",
31407
31498
  "requiredBuildSecrets",
31408
31499
  "tenantTasks",
31500
+ "internalPeers",
31409
31501
  "edgeDomains",
31410
31502
  "statusFieldId",
31411
31503
  "statusOptions",
@@ -31444,6 +31536,7 @@ var SETTABLE_VAR_HINTS = {
31444
31536
  requiredRuntimeSecrets: 'JSON stage map, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]}',
31445
31537
  requiredBuildSecrets: 'JSON flat array, e.g. ["NODE_AUTH_TOKEN=@github-packages-token"]',
31446
31538
  tenantTasks: 'JSON map {name:{service,command[],stages[],timeoutSeconds?,artifact?:"required",secretOutput?:{path,vault:{repo,key},overwrite?}}} \u2014 merged per entry, at most 20 stored in total; retire ONE task with --unset-task NAME; use {artifact} in argv when required; a secretOutput is master-only',
31539
+ internalPeers: "JSON {owner/repo:[dev|rc|main]} \u2014 same-box suite peers via the box Caddy; clear with --unset internalPeers (#7886)",
31447
31540
  secrets: "JSON catalog map keyed by KEY {key,purpose,group,owner,stages[],consumers[]} \u2014 merged per entry; drop ONE entry with --unset-secret KEY; clear ALL with --unset secrets --confirm-clear-secrets; prefer --secrets-file",
31448
31541
  edgeDomains: "JSON {dev,rc,main} domain map",
31449
31542
  statusOptions: "JSON name\u2192id map",
@@ -31520,6 +31613,26 @@ function clearSecretsConfirmationError(unsets, existing, confirmed) {
31520
31613
  if (confirmed) return null;
31521
31614
  return `org project set: --unset secrets clears the WHOLE catalog for ${existing?.slug ?? "this project"} (${count} declared entr${count === 1 ? "y" : "ies"} would be removed). To drop ONE entry use --unset-secret <KEY>; re-run with --confirm-clear-secrets to wipe all ${count}.`;
31522
31615
  }
31616
+ function parseInternalPeersVar(raw) {
31617
+ let parsed;
31618
+ try {
31619
+ parsed = JSON.parse(raw);
31620
+ } catch {
31621
+ throw new Error("org project set: internalPeers must be valid JSON");
31622
+ }
31623
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("org project set: internalPeers must be a JSON object");
31624
+ const entries = Object.entries(parsed);
31625
+ if (entries.length < 1 || entries.length > 20) throw new Error("org project set: internalPeers holds 1-20 peer declarations");
31626
+ const repoRe = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
31627
+ const validStages = /* @__PURE__ */ new Set(["dev", "rc", "main"]);
31628
+ const out = {};
31629
+ for (const [repo, rawStages] of entries) {
31630
+ if (!repoRe.test(repo)) throw new Error("org project set: internalPeers key must look like owner/name \u2014 got " + JSON.stringify(repo));
31631
+ if (!Array.isArray(rawStages) || rawStages.length < 1 || rawStages.length > 3 || rawStages.some((stg) => typeof stg !== "string" || !validStages.has(stg)) || new Set(rawStages).size !== rawStages.length) throw new Error("org project set: internalPeers stages must be a non-empty array of unique dev/rc/main values");
31632
+ out[repo] = rawStages;
31633
+ }
31634
+ return out;
31635
+ }
31523
31636
  function parseSuiteHoldVar(raw) {
31524
31637
  let parsed;
31525
31638
  try {
@@ -31582,6 +31695,8 @@ function buildProjectSetPatch(input) {
31582
31695
  patch[key] = parseBuildSecretsVar(raw);
31583
31696
  } else if (key === "tenantTasks") {
31584
31697
  patch[key] = parseTenantTasksVar(raw);
31698
+ } else if (key === "internalPeers") {
31699
+ patch[key] = parseInternalPeersVar(raw);
31585
31700
  } else if (key === "secrets") {
31586
31701
  patch[key] = parseSecretsCatalogVar(raw);
31587
31702
  } else if (key === "edgeDomains") {
@@ -36456,7 +36571,7 @@ function readSuiteHotfix(path2) {
36456
36571
  } catch {
36457
36572
  throw new Error(`suite hotfix record unreadable at ${path2}; repair it, never restart`);
36458
36573
  }
36459
- if (r.schema !== "mmi-suite-hotfix/v1" || !r.repo || !r.member || !r.from || r.tag !== void 0 && !RELEASE_TAG.test(r.tag) || r.released && !r.tag || r.pinMerged && !r.released || r.leadTag !== void 0 && (!RELEASE_TAG.test(r.leadTag) || !r.pinMerged) || r.leadReleased && !r.leadTag || r.mapped && !r.leadReleased)
36574
+ if (r.schema !== "mmi-suite-hotfix/v1" || !r.repo || !r.member || !r.from || r.tag !== void 0 && !RELEASE_TAG.test(r.tag) || r.released && !r.tag || r.pinMerged && !r.released || r.mainPublished && !r.released || r.leadTag !== void 0 && (!RELEASE_TAG.test(r.leadTag) || !r.pinMerged) || r.leadReleased && !r.leadTag || r.mapped && !r.leadReleased)
36460
36575
  throw new Error(`suite hotfix record has unknown or invalid state at ${path2}; repair it, never restart`);
36461
36576
  return r;
36462
36577
  }
@@ -36471,8 +36586,10 @@ async function suiteHotfix(deps, opts) {
36471
36586
  if (found.repo === SUITE_REPO) throw new Error(`${SUITE_REPO} is the suite lead; hotfix a member, this command cuts the lead; nothing was written`);
36472
36587
  const held = await deps.hold(found);
36473
36588
  if (held) throw new Error(`${found.repo} is HELD (${held.reason}); clear it with suite hold clear before a suite hotfix; nothing was written`);
36589
+ const mainPath = mainBundlePath(found.bundles);
36474
36590
  const plan = [
36475
36591
  `${found.repo}: hotfix start --from ${opts.from} + release (suite-solo, in-process)`,
36592
+ `${found.repo}: publish main bundle (module-rc-publish channel=main) at ${mainPath}`,
36476
36593
  `${SUITE_REPO}: main pin PR for ${found.id}, wait for merge`,
36477
36594
  `${SUITE_REPO}: hotfix (patch) carrying only the merged pin PR onto main`,
36478
36595
  `${SUITE_REPO}: record suite version -> member versions on its release`
@@ -36491,6 +36608,7 @@ async function suiteHotfix(deps, opts) {
36491
36608
  const save = () => writeSuiteHotfix(path2, rec);
36492
36609
  save();
36493
36610
  const resume = `rerun suite hotfix ${found.id} --from ${rec.from} --owner-go to resume`;
36611
+ if (!rec.released) await deps.preflightMain(found.repo, mainPath);
36494
36612
  if (!rec.tag) {
36495
36613
  const started = await deps.hotfixStart(found.repo, { from: rec.from, ack: rec.ack });
36496
36614
  if (!RELEASE_TAG.test(started.tag)) throw new Error(`${found.repo}: hotfix start returned no vX.Y.Z tag`);
@@ -36508,6 +36626,15 @@ async function suiteHotfix(deps, opts) {
36508
36626
  rec.released = true;
36509
36627
  save();
36510
36628
  }
36629
+ if (!rec.mainPublished) {
36630
+ try {
36631
+ await deps.publishMain(found.repo, rec.tag, mainPath);
36632
+ } catch (e) {
36633
+ throw new Error(`${found.repo}: main bundle publish failed (${e.message}); ${rec.tag} is released: ${resume}`);
36634
+ }
36635
+ rec.mainPublished = true;
36636
+ save();
36637
+ }
36511
36638
  if (!rec.pinMerged) {
36512
36639
  const pin = await deps.soloPin(found.repo, rec.tag);
36513
36640
  if (pin.status === "superseded") throw new Error(`${found.id} pin at ${rec.tag} is superseded by ${pin.pr}; a newer ${found.id} release owns the pin; stop`);
@@ -39572,11 +39699,6 @@ var PROTECTED_BRANCHES = /* @__PURE__ */ new Set(["development", "main", "master
39572
39699
  function isProtectedBranch(branch) {
39573
39700
  return PROTECTED_BRANCHES.has(branch.trim());
39574
39701
  }
39575
- function deriveComposeProjectName(worktreePath) {
39576
- const base = worktreePath.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop()?.toLowerCase() ?? "";
39577
- const name = base.replace(/[^a-z0-9_-]+/g, "_").replace(/^[^a-z0-9]+/, "");
39578
- return name || void 0;
39579
- }
39580
39702
 
39581
39703
  // src/pr-merge-helpers.ts
39582
39704
  function buildPrMergeArgs(input) {
@@ -41789,6 +41911,53 @@ function parsePrChecksTable(stdout) {
41789
41911
  }
41790
41912
  return rows;
41791
41913
  }
41914
+ function includeMissingRequiredChecks(rows, required) {
41915
+ const reported = new Set(rows.map((row) => row.name));
41916
+ const missing = [...new Set(required)].filter((name) => !reported.has(name)).map((name) => ({
41917
+ name,
41918
+ status: "missing",
41919
+ details: "expected required check has not reported"
41920
+ }));
41921
+ return [...rows, ...missing];
41922
+ }
41923
+ async function requiredCheckContexts(repo, prNumber) {
41924
+ const { stdout: prOutput } = await execFileP("gh", ["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefName"], { timeout: GC_GH_TIMEOUT_MS2 });
41925
+ const baseRef = JSON.parse(prOutput).baseRefName;
41926
+ if (typeof baseRef !== "string" || !baseRef) throw new Error("pull request response had no base branch");
41927
+ const read = async (path2) => {
41928
+ try {
41929
+ const { stdout } = await execFileP("gh", ["api", path2], { timeout: GC_GH_TIMEOUT_MS2 });
41930
+ return JSON.parse(stdout);
41931
+ } catch (e) {
41932
+ const err = e;
41933
+ if (/\b404\b|Not Found/i.test(`${err.stderr ?? ""} ${err.message ?? ""}`)) return null;
41934
+ throw e;
41935
+ }
41936
+ };
41937
+ const [classic, rules] = await Promise.all([
41938
+ read(`repos/${repo}/branches/${encodeURIComponent(baseRef)}/protection/required_status_checks`),
41939
+ read(`repos/${repo}/rules/branches/${encodeURIComponent(baseRef)}`)
41940
+ ]);
41941
+ const contexts = /* @__PURE__ */ new Set();
41942
+ const oldContexts = classic?.contexts;
41943
+ if (Array.isArray(oldContexts)) {
41944
+ for (const name of oldContexts) if (typeof name === "string") contexts.add(name);
41945
+ }
41946
+ if (Array.isArray(rules)) {
41947
+ for (const rule of rules) {
41948
+ const required = rule.type === "required_status_checks" ? rule.parameters?.required_status_checks : void 0;
41949
+ if (Array.isArray(required)) for (const check of required) {
41950
+ const name = check?.context;
41951
+ if (typeof name === "string") contexts.add(name);
41952
+ }
41953
+ }
41954
+ }
41955
+ return [...contexts];
41956
+ }
41957
+ function checksStateWithExpected(state, rows, required) {
41958
+ const checks = includeMissingRequiredChecks(rows, required);
41959
+ return checks.some((row) => row.status === "missing") && (state === "success" || state === "no-checks-reported") ? { state: "incomplete", rows: checks } : { state, rows: checks };
41960
+ }
41792
41961
  function parseLinkedIssues(body) {
41793
41962
  const refs = /* @__PURE__ */ new Set();
41794
41963
  const re = /\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved|ref|refs|re|references)\s+#(\d+)\b/gi;
@@ -42049,6 +42218,7 @@ function registerPrLifecycleCommands(program3) {
42049
42218
  return stdout;
42050
42219
  };
42051
42220
  const withLogs = async (rows) => o.logs ? attachFailureLogs(rows, fetchLog) : rows;
42221
+ let expected = [];
42052
42222
  const snap = async () => {
42053
42223
  let stdout = "";
42054
42224
  let stderr = "";
@@ -42063,14 +42233,15 @@ function registerPrLifecycleCommands(program3) {
42063
42233
  const parsed = parseGhPrChecksResult(stdout, stderr);
42064
42234
  const state = pollStateFromParsed(parsed);
42065
42235
  const rows = parsePrChecksTable(stdout);
42066
- return { state, rows };
42236
+ return checksStateWithExpected(state, rows, expected);
42067
42237
  };
42068
42238
  try {
42239
+ expected = await requiredCheckContexts(repo, n);
42069
42240
  if (!o.watch) {
42070
42241
  const { state, rows } = await snap();
42071
42242
  const output = { state, checks: await withLogs(rows) };
42072
42243
  console.log(JSON.stringify(output));
42073
- if (state === "failure" || state === "failing") process.exitCode = 1;
42244
+ if (state === "failure" || state === "failing" || state === "incomplete") process.exitCode = 1;
42074
42245
  return;
42075
42246
  }
42076
42247
  const deadline = Date.now() + CHECKS_WATCH_TIMEOUT_MS;
@@ -42801,11 +42972,18 @@ function parseDockerContainerLs(stdout) {
42801
42972
  function containersPublishingPort(containers, port) {
42802
42973
  return containers.filter((c) => c.publishedHostPorts.includes(port));
42803
42974
  }
42975
+ function stageComposeProjectName(cwd) {
42976
+ const parts = cwd.replace(/\\/g, "/").replace(/\/+$/, "").split("/").filter(Boolean);
42977
+ const base = parts.pop() ?? "";
42978
+ const parent = parts.pop();
42979
+ const raw = parent ? `${parent}-${base}` : base;
42980
+ const name = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^[^a-z0-9]+/, "");
42981
+ return name || "mmi-stage";
42982
+ }
42804
42983
  function isOwnComposeContainer(container, cwd) {
42805
42984
  if (container.composeWorkingDir && normPath(container.composeWorkingDir) === normPath(cwd)) return true;
42806
42985
  if (container.composeWorkingDir && pathUnder(container.composeWorkingDir, cwd)) return true;
42807
- const derived = deriveComposeProjectName(cwd);
42808
- return Boolean(derived && container.composeProject === derived);
42986
+ return container.composeProject === stageComposeProjectName(cwd);
42809
42987
  }
42810
42988
  function planStagePortTeardown(containers, port, cwd) {
42811
42989
  const ownProjects = [];
@@ -43047,11 +43225,13 @@ function composeResolvesPort(cwd) {
43047
43225
  return (0, import_node_fs38.existsSync)(envFile) && envFileKeys((0, import_node_fs38.readFileSync)(envFile, "utf8")).has("PORT");
43048
43226
  }
43049
43227
  function stageComposeEnv(config, stagePort, vaultEnvMerge, cwd) {
43228
+ const composeProject = config.env?.COMPOSE_PROJECT_NAME ?? vaultEnvMerge?.COMPOSE_PROJECT_NAME ?? stageComposeProjectName(cwd);
43050
43229
  return {
43051
43230
  // Vault secrets first so the stage-selection contract (MMI_STAGE/MMI_PORT/…) always wins on any collision.
43052
43231
  ...vaultEnvMerge ?? {},
43053
43232
  ...stageProcessEnv(stagePort, stageExtraEnv(config, stagePort)),
43054
- ...stagePort != null && !composeResolvesPort(cwd) ? { PORT: String(stagePort) } : {}
43233
+ ...stagePort != null && !composeResolvesPort(cwd) ? { PORT: String(stagePort) } : {},
43234
+ COMPOSE_PROJECT_NAME: composeProject
43055
43235
  };
43056
43236
  }
43057
43237
  function stageComposeFiles(cwd) {
@@ -43201,6 +43381,58 @@ async function killTree(pid) {
43201
43381
  }
43202
43382
  }
43203
43383
  }
43384
+ async function describeExitedComposeServices(cwd, env) {
43385
+ const dockerEnv = { ...process.env, ...env };
43386
+ const run = (args) => execFileP3("docker", args, {
43387
+ cwd,
43388
+ windowsHide: true,
43389
+ timeout: DOCKER_TIMEOUT_MS,
43390
+ maxBuffer: 1024 * 1024 * 4,
43391
+ env: dockerEnv
43392
+ }).then(({ stdout }) => stdout);
43393
+ let rows;
43394
+ try {
43395
+ const text = (await run(["compose", "ps", "--all", "--format", "json"])).trim();
43396
+ if (!text) return void 0;
43397
+ try {
43398
+ const parsed = JSON.parse(text);
43399
+ rows = Array.isArray(parsed) ? parsed : [parsed];
43400
+ } catch {
43401
+ rows = text.split(/\r?\n/).flatMap((line) => {
43402
+ try {
43403
+ return [JSON.parse(line)];
43404
+ } catch {
43405
+ return [];
43406
+ }
43407
+ });
43408
+ }
43409
+ } catch {
43410
+ return void 0;
43411
+ }
43412
+ const exited = rows.filter((row) => typeof row.State === "string" && /^(exited|dead)$/i.test(row.State));
43413
+ if (!exited.length) return void 0;
43414
+ const parts = [];
43415
+ for (const row of exited.slice(0, 3)) {
43416
+ const service = String(row.Service ?? row.Name ?? "unknown");
43417
+ let exit = row.ExitCode ?? row.exitCode;
43418
+ if (exit == null && row.ID) {
43419
+ try {
43420
+ exit = (await run(["inspect", "--format", "{{.State.ExitCode}}", String(row.ID)])).trim() || void 0;
43421
+ } catch {
43422
+ }
43423
+ }
43424
+ let tail = "";
43425
+ try {
43426
+ tail = (await run(["compose", "logs", "--no-color", "--tail", "50", service])).trim();
43427
+ } catch {
43428
+ }
43429
+ parts.push(`service '${service}' exited before health (exit ${exit ?? "unknown"})${tail ? ` \u2014 log tail:
43430
+ ${tail}` : ""}`);
43431
+ }
43432
+ return `
43433
+ compose services exited before health:
43434
+ ${parts.join("\n")}`;
43435
+ }
43204
43436
  async function waitForHealth(url, timeoutMs, anyStatus = false) {
43205
43437
  const deadline = Date.now() + timeoutMs;
43206
43438
  let last = "";
@@ -43301,7 +43533,15 @@ async function startStage(config = {}, opts = {}) {
43301
43533
  if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4, config.healthAnyStatus);
43302
43534
  else await waitForProcessStability(child2);
43303
43535
  } catch (e) {
43536
+ let detail;
43537
+ if (state.healthUrl) {
43538
+ try {
43539
+ detail = await describeExitedComposeServices(cwd, upEnv);
43540
+ } catch {
43541
+ }
43542
+ }
43304
43543
  await cleanupStageState(state, [statePath, globalStatePath], opts.timeoutMs ?? 6e4, cwd, upEnv);
43544
+ if (detail) throw new Error(`${e.message}${detail}`, { cause: e });
43305
43545
  throw e;
43306
43546
  }
43307
43547
  const result = {
@@ -45242,6 +45482,11 @@ ${list}`);
45242
45482
  } else {
45243
45483
  printLine(`${label}: ${result.status}${result.reason ? ` \u2014 ${result.reason}` : ""}${result.detail ? ` (${result.detail})` : ""}`);
45244
45484
  for (const line of failedChecksReceiptLines(label, result)) printLine(line);
45485
+ if (result.status === "success" && result.headSha) {
45486
+ const checks = result.checks?.map((check) => `${check.name}=${check.state}`).join(", ") || "none";
45487
+ const workflows = result.workflowRuns?.map((run) => `${run.name}#${run.id}=${run.status}/${run.conclusion ?? "pending"}`).join(", ") || "none";
45488
+ printLine(`${label}: verified head ${result.headSha}; checks: ${checks}; workflows: ${workflows}`);
45489
+ }
45245
45490
  }
45246
45491
  if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
45247
45492
  if (result.status === "timeout" || result.status === "rate-limited") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
@@ -45294,6 +45539,7 @@ ${list}`);
45294
45539
  // pending must hold the wait open, and a non-required red must fail it; #4396's scoping is the
45295
45540
  // merge paths' contract (`pr land`, `pr merge --wait`), which mirror the GitHub merge button.
45296
45541
  pollChecks: () => pollRestPrChecks(number, repo),
45542
+ pollChecksWithEvidence: () => pollRestPrChecksWithWorkflowEvidence(number, repo),
45297
45543
  pollMergeable: () => pollRestPrMergeable(number, repo),
45298
45544
  pollRateLimit: () => waitLoopCorePool("pr checks-wait"),
45299
45545
  // #3388: on a confirmed red, read the failing runs' annotations so a wall-clock budget kill stops
@@ -51812,10 +52058,13 @@ function deriveStage(inputs) {
51812
52058
  // #2655: only bootstrap a `.env` when the repo ships `.env.example` (legacy). Without it, the stage is
51813
52059
  // vault-native — the runner injects the declared dev secrets into the compose process env (no disk file).
51814
52060
  ...inputs.hasEnvExample ? { ensureEnv: { example: ".env.example", target: ".env" } } : {},
51815
- // MMI_PORT is the host-published port (the free STAGE_PORT); PORT is intentionally absent so compose
51816
- // reads the container's bind port from .env rather than this overriding it to the publish port (#1505).
52061
+ // MMI_STAGE is the local dev stage name the estate compose contract selects on (`${MMI_STAGE:-dev}`
52062
+ // in the seeded compose template): `development` never matched it, so a derived stage booted with
52063
+ // the wrong stage identity (#7870). MMI_PORT is the host-published port (the free STAGE_PORT); PORT
52064
+ // is intentionally absent so compose reads the container's bind port from .env rather than this
52065
+ // overriding it to the publish port (#1505).
51817
52066
  env: {
51818
- MMI_STAGE: "development",
52067
+ MMI_STAGE: "dev",
51819
52068
  MMI_PORT: "$STAGE_PORT",
51820
52069
  COMPOSE_PROFILES: "local"
51821
52070
  }
@@ -52014,13 +52263,20 @@ function registerStageCommands(program3) {
52014
52263
  const portRangeMeta = project2?.portRange ?? void 0;
52015
52264
  const portRange = portRangeMeta && typeof portRangeMeta.start === "number" && typeof portRangeMeta.end === "number" ? [portRangeMeta.start, portRangeMeta.end] : void 0;
52016
52265
  const repo = Array.isArray(project2?.repos) && typeof project2.repos[0] === "string" ? project2.repos[0] : slug;
52266
+ const resolution = decideStage({
52267
+ registry: { deployModel: project2?.deployModel, portRange, error: read.ok ? void 0 : read.error },
52268
+ hasCompose: (0, import_node_fs48.existsSync)((0, import_node_path46.join)(process.cwd(), "docker-compose.yml")),
52269
+ hasEnvExample: (0, import_node_fs48.existsSync)((0, import_node_path46.join)(process.cwd(), ".env.example")),
52270
+ repo
52271
+ });
52272
+ if (resolution.source === "derived" && resolution.derived) {
52273
+ resolution.derived.config.env = {
52274
+ ...resolution.derived.config.env,
52275
+ COMPOSE_PROJECT_NAME: stageComposeProjectName(process.cwd())
52276
+ };
52277
+ }
52017
52278
  return {
52018
- resolution: decideStage({
52019
- registry: { deployModel: project2?.deployModel, portRange, error: read.ok ? void 0 : read.error },
52020
- hasCompose: (0, import_node_fs48.existsSync)((0, import_node_path46.join)(process.cwd(), "docker-compose.yml")),
52021
- hasEnvExample: (0, import_node_fs48.existsSync)((0, import_node_path46.join)(process.cwd(), ".env.example")),
52022
- repo
52023
- }),
52279
+ resolution,
52024
52280
  project: project2,
52025
52281
  cfg
52026
52282
  };
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.96",
3
+ "version": "4.5.98",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/codex-plugin",
3
- "version": "4.5.96",
3
+ "version": "4.5.98",
4
4
  "description": "MMI workflow skills and org gates delivery.",
5
5
  "author": {
6
6
  "name": "MMI Future",