@mutmutco/cli 4.3.6 → 4.3.8

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 +537 -256
  2. package/package.json +2 -4
package/dist/main.cjs CHANGED
@@ -6998,6 +6998,19 @@ function isPromotionBase(base, track) {
6998
6998
  }
6999
6999
 
7000
7000
  // src/readiness-audit.ts
7001
+ async function probeHttpBounded(url, timeoutMs = 5e3) {
7002
+ try {
7003
+ const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
7004
+ return { ok: res.ok, status: res.status, url };
7005
+ } catch (e) {
7006
+ return { ok: false, error: e.message, url };
7007
+ }
7008
+ }
7009
+ async function probePublicHealth(publicUrl) {
7010
+ const root = await probeHttpBounded(publicUrl);
7011
+ if (root.status !== 401 && root.status !== 403) return root;
7012
+ return probeHttpBounded(`${publicUrl.replace(/\/$/, "")}/health`);
7013
+ }
7001
7014
  var TENANT_DEPLOY_RUN_SCAN_LIMIT = 100;
7002
7015
  function pickTenantDeployRun(rows, slug, stage) {
7003
7016
  const escaped = slug.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
@@ -7016,7 +7029,8 @@ function tenantRuntimeHints(stage, fact, probe) {
7016
7029
  if (!fact.sshHostPresent && fact.substrate === "hetzner-ssh") hints.push(`DEPLOY#${stage} has hetzner-ssh substrate but no sshHost presence; tenant-deploy cannot reach the box.`);
7017
7030
  if (!fact.domain) hints.push(`DEPLOY#${stage} has no edgeVhost.domain; Cloudflare/Caddy public URL cannot be derived.`);
7018
7031
  if (typeof fact.port !== "number") hints.push(`DEPLOY#${stage} has no edgeVhost.port; Caddy upstream/healthUrl hints cannot be checked.`);
7019
- if (probe?.ok === false || probe?.status != null && probe.status >= 500) hints.push("Public URL probe failed; for Cloudflare 525 check Caddy TLS/origin certificate and Cloudflare SSL mode before changing app code.");
7032
+ if (probe?.url?.endsWith("/health") && probe.ok === false) hints.push(`Root is auth-walled (401/403) and ${probe.url} answered ${probe.status ?? probe.error}; expose a 2xx /health route or fix the app \u2014 the edge is fine.`);
7033
+ else if (probe?.ok === false || probe?.status != null && probe.status >= 500) hints.push("Public URL probe failed; for Cloudflare 525 check Caddy TLS/origin certificate and Cloudflare SSL mode before changing app code.");
7020
7034
  if (stage === "rc") hints.push("rc runtime is expected to be ephemeral: present between /rcand and /release, then retired after release.");
7021
7035
  return hints;
7022
7036
  }
@@ -9347,8 +9361,6 @@ function normalizeTitle(title) {
9347
9361
  return title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
9348
9362
  }
9349
9363
  var REPORT_TIMEOUT_MS = 8e3;
9350
- var RED_TEAM_SLA_DAYS = 14;
9351
- var RED_TEAM_SLA_MS = RED_TEAM_SLA_DAYS * 24 * 60 * 60 * 1e3;
9352
9364
  async function fileReport(deps, req) {
9353
9365
  const res = await deps.fetch(`${deps.apiUrl}/reports`, {
9354
9366
  method: "POST",
@@ -9437,7 +9449,7 @@ function invalidReference(ref) {
9437
9449
  return new Error(`invalid reference "${ref}" \u2014 expected ${ISSUE_REF_SHAPES}`);
9438
9450
  }
9439
9451
  function parseIssueRef(ref, expectedRepo) {
9440
- const trimmed = ref.trim();
9452
+ const trimmed = String(ref).trim();
9441
9453
  const url = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)$/i);
9442
9454
  const qualified = trimmed.match(/^([^/\s#]+\/[^/\s#]+)#(\d+)$/);
9443
9455
  const bare = trimmed.match(/^#?(\d+)$/);
@@ -11063,14 +11075,7 @@ function writableOrUnknown(writable) {
11063
11075
  function renderBoardSource() {
11064
11076
  return "source: live";
11065
11077
  }
11066
- async function readBoard(options, deps = {}) {
11067
- const cfg = resolveBoardConfig(options.config);
11068
- const client = deps.client ?? defaultGitHubClient();
11069
- let collected;
11070
- let writable;
11071
- let pullRequests;
11072
- let github;
11073
- let snapshotFallback;
11078
+ async function collectBoardPreferringSnapshot(cfg, options, deps) {
11074
11079
  const attempt = deps.snapshot ? await fetchHubBoardSnapshot(
11075
11080
  {
11076
11081
  owner: cfg.projectOwner,
@@ -11096,19 +11101,37 @@ async function readBoard(options, deps = {}) {
11096
11101
  warnings.push(message2);
11097
11102
  partial = true;
11098
11103
  }
11104
+ return {
11105
+ collected: {
11106
+ items: read.items,
11107
+ viewer: snapshot.viewer,
11108
+ repo: currentRepo,
11109
+ projectId: snapshot.project.id,
11110
+ projectTitle: snapshot.project.title,
11111
+ warnings,
11112
+ partial
11113
+ },
11114
+ snapshot
11115
+ };
11116
+ }
11117
+ const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
11118
+ if (attempt?.state === "unavailable") {
11119
+ collected.warnings.push(`Hub board snapshot unavailable (${attempt.reason}) \u2014 served by the direct user-auth read (emergency fallback)`);
11120
+ }
11121
+ return { collected };
11122
+ }
11123
+ async function readBoard(options, deps = {}) {
11124
+ const cfg = resolveBoardConfig(options.config);
11125
+ const client = deps.client ?? defaultGitHubClient();
11126
+ const { collected, snapshot } = await collectBoardPreferringSnapshot(cfg, options, deps);
11127
+ let writable;
11128
+ let pullRequests;
11129
+ let github;
11130
+ if (snapshot) {
11099
11131
  for (const unreadable of snapshot.unreadableRepos) {
11100
- warnings.push(`partial claimable access read: ${unreadable.repo}: viewer write access UNREAD (${unreadable.error})`);
11101
- partial = true;
11132
+ collected.warnings.push(`partial claimable access read: ${unreadable.repo}: viewer write access UNREAD (${unreadable.error})`);
11133
+ collected.partial = true;
11102
11134
  }
11103
- collected = {
11104
- items: read.items,
11105
- viewer: snapshot.viewer,
11106
- repo: currentRepo,
11107
- projectId: snapshot.project.id,
11108
- projectTitle: snapshot.project.title,
11109
- warnings,
11110
- partial
11111
- };
11112
11135
  writable = {
11113
11136
  repos: new Set(snapshot.writableRepos.map((repo) => repo.toLowerCase())),
11114
11137
  unknown: new Set(snapshot.unreadableRepos.map((entry) => entry.repo.toLowerCase()))
@@ -11116,11 +11139,6 @@ async function readBoard(options, deps = {}) {
11116
11139
  pullRequests = snapshot.pullRequests;
11117
11140
  github = snapshot.github;
11118
11141
  } else {
11119
- if (attempt?.state === "unavailable") snapshotFallback = attempt.reason;
11120
- collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
11121
- if (snapshotFallback) {
11122
- collected.warnings.push(`Hub board snapshot unavailable (${snapshotFallback}) \u2014 served by the direct user-auth read (emergency fallback)`);
11123
- }
11124
11142
  const probed = await resolveWritableReposForClaimables(collected.items, client);
11125
11143
  collected.warnings.push(...probed.warnings);
11126
11144
  collected.partial = collected.partial || probed.partial;
@@ -12059,21 +12077,45 @@ async function moveBoardItem(options, deps = {}) {
12059
12077
  partial: false
12060
12078
  };
12061
12079
  }
12062
- async function prepareClaimContext(options, selectors, deps, collected) {
12080
+ async function resolveClaimWritable(collected, client, snapshot, unscanned) {
12081
+ const probe = async (items) => {
12082
+ const probed2 = await resolveWritableReposForClaimables(items, client);
12083
+ collected.warnings.push(...probed2.warnings);
12084
+ collected.partial = collected.partial || probed2.partial;
12085
+ return probed2;
12086
+ };
12087
+ if (!snapshot) {
12088
+ const probed2 = await probe(collected.items);
12089
+ return { repos: probed2.repos, unknown: probed2.unknown };
12090
+ }
12091
+ for (const unreadable of snapshot.unreadableRepos) {
12092
+ collected.warnings.push(`partial claimable access read: ${unreadable.repo}: viewer write access UNREAD (${unreadable.error})`);
12093
+ collected.partial = true;
12094
+ }
12095
+ const repos = new Set(snapshot.writableRepos.map((repo) => repo.toLowerCase()));
12096
+ const unknown = new Set(snapshot.unreadableRepos.map((entry) => entry.repo.toLowerCase()));
12097
+ const missed = unscanned.filter((item) => !repos.has(item.repository.toLowerCase()) && !unknown.has(item.repository.toLowerCase()));
12098
+ if (!missed.length) return { repos, unknown };
12099
+ const probed = await probe(missed);
12100
+ return { repos: /* @__PURE__ */ new Set([...repos, ...probed.repos]), unknown: /* @__PURE__ */ new Set([...unknown, ...probed.unknown]) };
12101
+ }
12102
+ async function prepareClaimContext(options, selectors, deps, collected, snapshot) {
12063
12103
  const cfg = resolveBoardConfig(options.config);
12064
12104
  const client = deps.client ?? defaultGitHubClient();
12065
12105
  const board = { owner: cfg.projectOwner, number: cfg.projectNumber };
12106
+ const unscanned = [];
12066
12107
  for (const selector of selectors) {
12067
12108
  try {
12068
12109
  findBoardItem(collected.items, selector, board);
12069
12110
  } catch {
12070
12111
  const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
12071
- if (fallback) collected.items.push(fallback);
12112
+ if (fallback) {
12113
+ collected.items.push(fallback);
12114
+ unscanned.push(fallback);
12115
+ }
12072
12116
  }
12073
12117
  }
12074
- const writable = await resolveWritableReposForClaimables(collected.items, client);
12075
- collected.warnings.push(...writable.warnings);
12076
- collected.partial = collected.partial || writable.partial;
12118
+ const writable = await resolveClaimWritable(collected, client, snapshot, unscanned);
12077
12119
  const report = {
12078
12120
  project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
12079
12121
  viewer: collected.viewer,
@@ -12211,14 +12253,15 @@ async function claimOneBoardItem(ctx, selector, options) {
12211
12253
  }
12212
12254
  async function claimBoardIssue(options, deps = {}) {
12213
12255
  const cfg = resolveBoardConfig(options.config);
12214
- const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
12256
+ const { collected, snapshot } = await collectBoardPreferringSnapshot(cfg, options, deps);
12215
12257
  const selector = parseIssueSelector(options.selector, collected.repo, options.repo);
12216
- const ctx = await prepareClaimContext(options, [selector], deps, collected);
12217
- return claimOneBoardItem(ctx, selector, options);
12258
+ const ctx = await prepareClaimContext(options, [selector], deps, collected, snapshot);
12259
+ const result = await claimOneBoardItem(ctx, selector, options);
12260
+ return ctx.report.warnings.length ? { ...result, warnings: [...ctx.report.warnings] } : result;
12218
12261
  }
12219
12262
  async function claimBoardIssues(options, deps = {}) {
12220
12263
  const cfg = resolveBoardConfig(options.config);
12221
- const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
12264
+ const { collected, snapshot } = await collectBoardPreferringSnapshot(cfg, options, deps);
12222
12265
  const selectors = [];
12223
12266
  const seen = /* @__PURE__ */ new Set();
12224
12267
  for (const raw of options.selectors) {
@@ -12228,7 +12271,7 @@ async function claimBoardIssues(options, deps = {}) {
12228
12271
  seen.add(key);
12229
12272
  selectors.push(selector);
12230
12273
  }
12231
- const ctx = await prepareClaimContext(options, selectors, deps, collected);
12274
+ const ctx = await prepareClaimContext(options, selectors, deps, collected, snapshot);
12232
12275
  const results = new Array(selectors.length);
12233
12276
  let next = 0;
12234
12277
  const worker = async () => {
@@ -12249,7 +12292,9 @@ async function claimBoardIssues(options, deps = {}) {
12249
12292
  viewer: ctx.report.viewer,
12250
12293
  repo: ctx.report.repo,
12251
12294
  results,
12252
- failed: results.filter((result) => !result.claimed).length
12295
+ failed: results.filter((result) => !result.claimed).length,
12296
+ // #6162: as in claimBoardIssue — the emergency user-auth fallback is named with the verdict.
12297
+ ...ctx.report.warnings.length ? { warnings: [...ctx.report.warnings] } : {}
12253
12298
  };
12254
12299
  }
12255
12300
  async function moveBoardIssues(options, deps = {}) {
@@ -14321,6 +14366,12 @@ function trainFollowUpNote(status) {
14321
14366
  return `follow-up ${status}: the exit code is never the verdict, read workflowRuns \u2014 see docs/Guides/train-troubleshooting.md#follow-up-pending`;
14322
14367
  }
14323
14368
  var TRAIN_DEPLOY_FAILED_NOTE = "promotion stands; retry the deploy, do not re-tag \u2014 see docs/Guides/train-troubleshooting.md#deploy-failed";
14369
+ function missingRunUrlNote(runId) {
14370
+ return runId != null ? `no run URL emitted \u2014 GitHub Actions returned run ${runId} without its URL` : "no run URL emitted \u2014 run absent or unreadable";
14371
+ }
14372
+ function workflowRunWithEvidence(run) {
14373
+ return run.runUrl || run.runUrlNote ? run : { ...run, runUrlNote: missingRunUrlNote(run.runId) };
14374
+ }
14324
14375
  function isCentralDispatchModel(model) {
14325
14376
  return model === "tenant-container" || model === "solo-container" || model === "static-cdn";
14326
14377
  }
@@ -15399,10 +15450,10 @@ var rollout_plan_default = {
15399
15450
  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)."
15400
15451
  },
15401
15452
  baseline: {
15402
- version: "4.3.6",
15403
- tag: "v4.3.6",
15404
- commit: "b8e51db4f933",
15405
- npm: "@mutmutco/cli@4.3.6"
15453
+ version: "4.3.8",
15454
+ tag: "v4.3.8",
15455
+ commit: "b109b86b97d1",
15456
+ npm: "@mutmutco/cli@4.3.8"
15406
15457
  },
15407
15458
  exitCriterion: "fleet-n-of-n",
15408
15459
  hubOnlyShortcut: "forbidden",
@@ -15419,14 +15470,14 @@ var rollout_plan_default = {
15419
15470
  repo: "mutmutco/mmi-hub",
15420
15471
  role: "canary",
15421
15472
  schedule: "train",
15422
- v3Target: "v4.3.6"
15473
+ v3Target: "v4.3.8"
15423
15474
  }
15424
15475
  ],
15425
15476
  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.",
15426
15477
  rollback: {
15427
15478
  independent: true,
15428
- mechanism: "npm dist-tag latest -> 4.3.6 and redeploy the Hub Lambda from tag v4.3.6 (b8e51db4f933); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15429
- v3Target: "v4.3.6 (@mutmutco/cli@4.3.6, tag commit b8e51db4f933 \u2014 last known-good release carrying the repo-index v4-only contract)"
15479
+ mechanism: "npm dist-tag latest -> 4.3.8 and redeploy the Hub Lambda from tag v4.3.8 (b109b86b97d1); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15480
+ v3Target: "v4.3.8 (@mutmutco/cli@4.3.8, tag commit b109b86b97d1 \u2014 last known-good release carrying the repo-index v4-only contract)"
15430
15481
  }
15431
15482
  },
15432
15483
  {
@@ -18276,12 +18327,14 @@ function appendPublishDispatch(deploy, publish) {
18276
18327
  deployStatus: deploy.deployStatus === "failure" || publish.deployStatus === "failure" ? "failure" : deploy.deployStatus === "pending" || publish.deployStatus === "pending" ? "pending" : "success"
18277
18328
  };
18278
18329
  }
18330
+ var JERV_GATEWAY_RUN_URL_NOTE = "no run URL emitted \u2014 host health leg runs outside GitHub Actions";
18279
18331
  async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch) {
18280
18332
  if (!isJervHubRepo(repo)) return dispatch;
18281
18333
  if (dispatch.deployStatus !== "success") {
18282
18334
  return {
18283
18335
  ...dispatch,
18284
- note: `${dispatch.note}; Jerv Gateway deploy deferred until package publication is proven green`
18336
+ note: `${dispatch.note}; Jerv Gateway deploy deferred until package publication is proven green`,
18337
+ workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", runUrlNote: JERV_GATEWAY_RUN_URL_NOTE, conclusion: "pending" }]
18285
18338
  };
18286
18339
  }
18287
18340
  try {
@@ -18299,7 +18352,7 @@ async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch)
18299
18352
  return {
18300
18353
  ...dispatch,
18301
18354
  note: `${dispatch.note}; Jerv Gateway deployed ${tagSha.slice(0, 12)} and passed health checks`,
18302
- workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", conclusion: "success" }],
18355
+ workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", runUrlNote: JERV_GATEWAY_RUN_URL_NOTE, conclusion: "success" }],
18303
18356
  deployStatus: "success"
18304
18357
  };
18305
18358
  } catch (error) {
@@ -18308,7 +18361,7 @@ async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch)
18308
18361
  ...dispatch,
18309
18362
  // #6095: the helper's receipt, health or rollback failed after a proven publish — the release resumes, never re-tags.
18310
18363
  note: `${dispatch.note}; Jerv Gateway deploy FAILED (${detail}) \u2014 see docs/Guides/train-troubleshooting.md#jerv-gateway-deploy`,
18311
- workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", conclusion: "failure" }],
18364
+ workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "jerv-gateway", runUrlNote: JERV_GATEWAY_RUN_URL_NOTE, conclusion: "failure" }],
18312
18365
  deployStatus: "failure"
18313
18366
  };
18314
18367
  }
@@ -20594,8 +20647,11 @@ function deployPhaseInput(model, dispatch, opts = {}) {
20594
20647
  note: deployRun ? `${deployRun.workflow} release run` : dispatch.note
20595
20648
  };
20596
20649
  }
20597
- case "registry-publish":
20650
+ case "registry-publish": {
20651
+ const hostRun = (dispatch.workflowRuns ?? []).find((r) => r.workflow === "jerv-gateway");
20652
+ if (hostRun) return { state: runRowState(hostRun, dispatch.deployStatus), ...sha ? { sha } : {}, workflow: hostRun.workflow, note: dispatch.note };
20598
20653
  return { state: "skipped", ...sha ? { sha } : {}, note: "registry-publish deploys by publishing \u2014 the release-event publish.yml run is the deploy plane" };
20654
+ }
20599
20655
  case "tenant-container":
20600
20656
  case "solo-container":
20601
20657
  case "static-cdn":
@@ -20656,6 +20712,7 @@ function releasePhaseInputsFromDispatch(model, dispatch, publishDispatch, publis
20656
20712
  function phaseInputsFromRunRows(model, rows, tagSha, opts = {}) {
20657
20713
  const deployRun = rows.find((r) => r.workflow === "deploy.yml");
20658
20714
  const publishRun = rows.find((r) => r.workflow === "publish.yml");
20715
+ const hostRun = rows.find((r) => r.workflow === "jerv-gateway");
20659
20716
  switch (model) {
20660
20717
  case "hub-serverless":
20661
20718
  return {
@@ -20674,7 +20731,9 @@ function phaseInputsFromRunRows(model, rows, tagSha, opts = {}) {
20674
20731
  };
20675
20732
  case "registry-publish":
20676
20733
  return {
20677
- deploy: { state: "skipped", sha: tagSha, note: "registry-publish deploys by publishing \u2014 the release-event publish.yml run is the deploy plane" },
20734
+ // #884/#6145: an operator-host deploy leg (Jerv-Hub's Gateway) outranks 'skipped' here exactly as
20735
+ // in deployPhaseInput — it carries no runId, so no run metadata and no resume re-verification.
20736
+ deploy: hostRun ? { state: runRowState(hostRun, "pending"), sha: tagSha, workflow: hostRun.workflow, note: "jerv-gateway operator-host deploy on the release SHA" } : { state: "skipped", sha: tagSha, note: "registry-publish deploys by publishing \u2014 the release-event publish.yml run is the deploy plane" },
20678
20737
  publish: publishRun ? {
20679
20738
  state: runRowState(publishRun, "pending"),
20680
20739
  sha: tagSha,
@@ -21041,12 +21100,14 @@ async function selfConvergeTrainCli(input) {
21041
21100
  var TRAIN_LANES = ["release", "rcand", "hotfix"];
21042
21101
  var TROUBLESHOOTING_GUIDE = "docs/Guides/train-troubleshooting.md";
21043
21102
  var SCRATCH_BRANCH_GLOBS = ["train/check/*", "hotfix-fold/*--port-*"];
21103
+ var COMPOSE_GUARD_HARD_FAIL = /^secrets preflight: .*noEnvFile is (not )?true.*$/m;
21044
21104
  function readLocalWorkflowsDefault(root) {
21045
21105
  const dir = (0, import_node_path22.join)(root, ".github", "workflows");
21046
21106
  let names;
21047
21107
  try {
21048
21108
  names = (0, import_node_fs23.readdirSync)(dir);
21049
- } catch {
21109
+ } catch (e) {
21110
+ if (e.code === "ENOENT") return [];
21050
21111
  return null;
21051
21112
  }
21052
21113
  const files = [];
@@ -21276,12 +21337,12 @@ async function runTrainDoctor(input) {
21276
21337
  ...!hints.hasMainBranch ? ["main"] : [],
21277
21338
  ...!hints.hasRcBranch && track === "full" ? ["rc"] : []
21278
21339
  ];
21279
- if (missing.length) add({ code: "bootstrap-gap", severity: "blocker", source: "origin", title: `train branch(es) missing on origin: ${missing.join(", ")}`, remedy: `bootstrap the repo train: \`mmi-cli bootstrap apply ${repo} --execute\` (from the MMI-Hub root), then rerun` });
21340
+ if (missing.length) add({ code: "bootstrap-gap", severity: "blocker", source: "origin", title: `train branch(es) missing on origin: ${missing.join(", ")}`, remedy: `bootstrap the repo train: \`mmi-cli devops bootstrap apply ${repo} --execute\` (from the MMI-Hub root), then rerun` });
21280
21341
  }
21281
21342
  try {
21282
21343
  const required = await discoverRequiredCheckContexts(train, ctx, stage);
21283
21344
  if (required.length === 0) {
21284
- add({ code: "bootstrap-gap", severity: "blocker", source: "origin", title: `no ruleset requires a status check on ${stage} \u2014 the train would tag without a gate`, remedy: `activate the product ruleset: \`mmi-cli bootstrap apply ${repo} --execute\` (from the MMI-Hub root), or \`mmi-cli devops ci audit --repo ${repo}\`` });
21345
+ add({ code: "bootstrap-gap", severity: "warning", source: "origin", title: `no ruleset requires a status check on ${stage} \u2014 the train tags without a check wait (the GitHub push gate is the backstop)`, remedy: `activate the product ruleset: \`mmi-cli devops bootstrap apply ${repo} --execute\` (from the MMI-Hub root), or \`mmi-cli devops ci audit --repo ${repo}\`` });
21285
21346
  } else {
21286
21347
  try {
21287
21348
  assertTagAddressableRequiredContexts({ readWorkflows: () => workflows }, required, repo);
@@ -21353,12 +21414,14 @@ async function runTrainDoctor(input) {
21353
21414
  }
21354
21415
  }
21355
21416
  try {
21356
- await train.runSelf(["secrets", "preflight", "--stage", stage, "--repo", repo]);
21417
+ await train.runSelf(["secrets", "preflight", "--stage", stage, "--repo", repo, ...lane === "hotfix" ? ["--lane", "hotfix"] : []]);
21357
21418
  } catch (e) {
21358
21419
  const err = e;
21359
21420
  const text = [typeof err.stdout === "string" ? err.stdout : "", typeof err.stderr === "string" ? err.stderr : "", message(e)].join("\n");
21360
21421
  if (/^missing /m.test(text)) {
21361
- add({ code: "secrets-missing", severity: "blocker", source: "origin", title: `required ${stage} secret name(s) are absent: ${clean2(text.match(/^missing .*$/m)?.[0] ?? "")}`, remedy: `provision them in the vault (\`mmi-cli vault secrets request <KEY> --repo ${repo}\`), then rerun \`mmi-cli vault secrets preflight --stage ${stage} --repo ${repo}\`` });
21422
+ add({ code: "secrets-missing", severity: "blocker", source: "origin", title: `required ${stage} secret name(s) are absent: ${clean2(text.match(/^missing .*$/m)?.[0] ?? "")}`, remedy: `provision them in the vault (\`mmi-cli vault secrets request <KEY> --repo ${repo}\`), then rerun \`mmi-cli vault secrets preflight --stage ${stage} --repo ${repo}${lane === "hotfix" ? " --lane hotfix" : ""}\`` });
21423
+ } else if (COMPOSE_GUARD_HARD_FAIL.test(text)) {
21424
+ add({ code: "compose-guard-mismatch", severity: "blocker", source: "origin", title: clean2(text.match(COMPOSE_GUARD_HARD_FAIL)?.[0] ?? text), remedy: `fix the compose/DEPLOY#${stage}.noEnvFile pair per the fileless-transition guide the preflight printed, then rerun \`mmi-cli vault secrets preflight --stage ${stage} --repo ${repo}${lane === "hotfix" ? " --lane hotfix" : ""}\`; --skip-compose-guard only after independent verification (#2813)` });
21362
21425
  } else {
21363
21426
  unverified(`the ${stage} secrets preflight`, clean2(text) || e);
21364
21427
  }
@@ -21548,7 +21611,7 @@ function enforceGateBudget(deps, repo) {
21548
21611
  );
21549
21612
  }
21550
21613
  }
21551
- async function preflight(deps, ctx, stage, meta) {
21614
+ async function preflight(deps, ctx, stage, meta, lane) {
21552
21615
  const model = requireDeployModel(meta, ctx.repo);
21553
21616
  if (model === "content") {
21554
21617
  throw new Error(`${ctx.repo} is a content repo (deployModel=content) \u2014 the release train does not apply (trunk-based; PR to main)`);
@@ -21556,7 +21619,7 @@ async function preflight(deps, ctx, stage, meta) {
21556
21619
  if (model === "none") {
21557
21620
  throw new Error(`${ctx.repo} is not Hub-deployed (deployModel=none) \u2014 the release train does not apply; use the project's own release path`);
21558
21621
  }
21559
- await deps.runSelf(["secrets", "preflight", "--stage", stage, "--repo", ctx.repo]);
21622
+ await deps.runSelf(["secrets", "preflight", "--stage", stage, "--repo", ctx.repo, ...lane === "hotfix" ? ["--lane", "hotfix"] : []]);
21560
21623
  await assertNpmMajorPreflightFromWorkflows(deps, ctx.repo);
21561
21624
  await assertActionsJobsCanStart(deps, ctx.repo);
21562
21625
  enforceGateBudget(deps, ctx.repo);
@@ -22202,7 +22265,7 @@ ${recovery.note}`), recoveryInput);
22202
22265
  const deployRunRepo = releaseRunRepoFor(deployModel, ctx.repo);
22203
22266
  const failedReleaseRun = deployDispatch0.workflowRuns?.find((run) => run.conclusion === "failure");
22204
22267
  const dispatchFailurePhase = failedReleaseRun?.workflow === "publish.yml" || deployModel === "registry-publish" ? "publish" : "deploy";
22205
- const deployDispatch = await recoverFailedDispatchPhase(
22268
+ const recoveredDeployDispatch = await recoverFailedDispatchPhase(
22206
22269
  deps,
22207
22270
  ledger,
22208
22271
  ledgerAnchors,
@@ -22215,6 +22278,7 @@ ${recovery.note}`), recoveryInput);
22215
22278
  workflow: failedReleaseRun?.workflow ?? (isCentralDispatchModel(deployModel) ? "tenant-deploy.yml" : "deploy workflow")
22216
22279
  }
22217
22280
  );
22281
+ const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, releaseSha, recoveredDeployDispatch);
22218
22282
  await recordPhase(ledger, deps, ledgerAnchors, "deploy", phaseEntry(deployPhaseInput(deployModel, deployDispatch, { releaseSha })), {
22219
22283
  landed: "the immutable tag, the green required-check wall, the origin/main fast-forward, and the verified GitHub Release"
22220
22284
  });
@@ -22239,7 +22303,6 @@ ${recovery.note}`), recoveryInput);
22239
22303
  landed: "the immutable tag, the green required-check wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
22240
22304
  });
22241
22305
  let dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
22242
- dispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, releaseSha, dispatch);
22243
22306
  if (publishSkipNote) dispatch = { ...dispatch, note: `${dispatch.note}; tenant-publish.yml skipped (${publishSkipNote})` };
22244
22307
  return { checks, releaseUrl, announceNote, dispatch, ledgerAnchors };
22245
22308
  }
@@ -22515,17 +22578,18 @@ Nothing was written. Inspect ${ledger.path} (or clear it only after proving the
22515
22578
  historicalDeploy = historicalTargets.length ? aggregateWorkflowRuns(historicalRows) : recovered.deployStatus;
22516
22579
  historicalNote = recovered.note;
22517
22580
  }
22581
+ const dispatch2 = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, {
22582
+ note: historicalNote,
22583
+ deployStatus: historicalDeploy,
22584
+ workflowRuns: historicalRows
22585
+ });
22518
22586
  if (persisted) {
22519
- const phaseInputs = phaseInputsFromRunRows(deployModel, historicalRows, tagSha, { repo: ctx.repo });
22587
+ const phaseInputs = phaseInputsFromRunRows(deployModel, dispatch2.workflowRuns ?? historicalRows, tagSha, { repo: ctx.repo });
22520
22588
  await recordPhase(ledger, deps, anchors, "deploy", phaseEntry(phaseInputs.deploy), { strict: true, landed: "the verified GitHub Release and origin/main at the immutable tag SHA" });
22521
22589
  await recordPhase(ledger, deps, anchors, "publish", phaseEntry(phaseInputs.publish), { strict: true, landed: "the verified GitHub Release and origin/main at the immutable tag SHA" });
22522
22590
  await recordPhase(ledger, deps, anchors, "githubRelease", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified live: the GitHub Release exists at the immutable tag SHA" }), { strict: true, landed: "the promotion (verified live)" });
22523
22591
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified live: origin/main contains the immutable tag SHA" }), { strict: true, landed: "nothing beyond the already-public release" });
22524
22592
  }
22525
- const dispatch2 = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, {
22526
- note: historicalNote,
22527
- deployStatus: historicalDeploy
22528
- });
22529
22593
  if (isJervHubRepo(ctx.repo)) steps2.push(dispatch2.note);
22530
22594
  if (persisted) {
22531
22595
  await recordPhase(ledger, deps, anchors, "alignment", phaseEntry(alignmentPhaseEntry(devRollForward2, rcAlignment2)), { strict: true, landed: "the released, verified, deploy-resolved release (alignment PRs reused/landed)" });
@@ -22607,7 +22671,7 @@ ${recovery.note}`), recoveryInput);
22607
22671
  const announceNote = deps.announce ? (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note : void 0;
22608
22672
  const autoRunSince = (deps.now ?? Date.now)();
22609
22673
  const deployDispatch0 = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, tagSha, "report", meta.publishDir);
22610
- const deployDispatch = await recoverFailedDispatchPhase(
22674
+ const recoveredDeployDispatch = await recoverFailedDispatchPhase(
22611
22675
  deps,
22612
22676
  ledger,
22613
22677
  anchors,
@@ -22620,6 +22684,7 @@ ${recovery.note}`), recoveryInput);
22620
22684
  workflow: isCentralDispatchModel(deployModel) ? "tenant-deploy.yml" : "deploy workflow"
22621
22685
  }
22622
22686
  );
22687
+ const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, recoveredDeployDispatch);
22623
22688
  steps.push(`dispatched the ${deployModel} deploy path`);
22624
22689
  await recordPhase(ledger, deps, anchors, "deploy", phaseEntry(deployPhaseInput(deployModel, deployDispatch, { releaseSha: tagSha })), {
22625
22690
  strict: ledgerMode === "strict",
@@ -22638,8 +22703,7 @@ ${recovery.note}`), recoveryInput);
22638
22703
  strict: ledgerMode === "strict",
22639
22704
  landed: "the immutable tag, the re-proven wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
22640
22705
  });
22641
- let dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
22642
- dispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, dispatch);
22706
+ const dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
22643
22707
  const devRollForward = await rollDevelopmentForward(deps, ctx, tag);
22644
22708
  steps.push(`development roll-forward: ${devRollForward.status}`);
22645
22709
  const rcAlignment = !directTrack && branchHints.hasRcBranch ? await alignRcForward(deps, ctx, tag) : void 0;
@@ -25264,6 +25328,13 @@ function registerBoardCommands(program3) {
25264
25328
  const lane = holder.surface && holder.session && holder.host ? ` (${holder.surface}/${holder.session}@${holder.host})` : "";
25265
25329
  return `@${holder.login}${lane}`;
25266
25330
  }
25331
+ function printClaimWarnings(warnings, toStderr = false) {
25332
+ for (const warning of warnings ?? []) {
25333
+ if (toStderr) process.stderr.write(`Warning: ${warning}
25334
+ `);
25335
+ else console.log(`Warning: ${warning}`);
25336
+ }
25337
+ }
25267
25338
  function claimVerdict(ref, result) {
25268
25339
  const holder = formatClaimHolder(result.holder);
25269
25340
  const previousHolder = result.previousHolder ? formatClaimHolder(result.previousHolder) : "another lane";
@@ -25286,24 +25357,26 @@ function registerBoardCommands(program3) {
25286
25357
  const board = program3.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
25287
25358
  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));
25288
25359
  withExamples(mutating(
25289
- 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)").addHelpText("after", "\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"),
25360
+ 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"),
25290
25361
  (_opts, args) => ({ command: "board claim", issues: args[0] ?? [] })
25291
25362
  ).action(async (issueRefs, o) => {
25292
25363
  if (issueRefs.length === 1) {
25293
25364
  const issueRef = issueRefs[0];
25294
25365
  try {
25366
+ const config = await loadConfigForBoardSelector2(issueRef, o.repo);
25295
25367
  const result = await claimBoardIssue({
25296
- config: await loadConfigForBoardSelector2(issueRef, o.repo),
25368
+ config,
25297
25369
  selector: issueRef,
25298
25370
  repo: o.repo,
25299
25371
  assignee: o.for,
25300
25372
  force: o.force,
25301
25373
  check: o.check,
25302
25374
  allowPartial: o.allowPartial
25303
- });
25375
+ }, { snapshot: registryClientDeps(config) });
25304
25376
  if (!result.checked) invalidateStatuslineBoardCache();
25305
25377
  if (o.json) return console.log(JSON.stringify(result));
25306
25378
  console.log(claimVerdict(result.item.ref, result));
25379
+ printClaimWarnings(result.warnings);
25307
25380
  } catch (e) {
25308
25381
  if (refuseRateLimited(e, o.json)) return;
25309
25382
  return failGraceful(`board claim failed: ${e.message}`);
@@ -25311,22 +25384,25 @@ function registerBoardCommands(program3) {
25311
25384
  return;
25312
25385
  }
25313
25386
  try {
25387
+ const config = await loadConfigForBoardSelector2(issueRefs[0], o.repo);
25314
25388
  const bulk = await claimBoardIssues({
25315
- config: await loadConfigForBoardSelector2(issueRefs[0], o.repo),
25389
+ config,
25316
25390
  selectors: issueRefs,
25317
25391
  repo: o.repo,
25318
25392
  assignee: o.for,
25319
25393
  force: o.force,
25320
25394
  check: o.check,
25321
25395
  allowPartial: o.allowPartial
25322
- });
25396
+ }, { snapshot: registryClientDeps(config) });
25323
25397
  if (bulk.results.some((r) => r.claimed && !r.checked)) invalidateStatuslineBoardCache();
25324
25398
  if (o.json) {
25325
25399
  console.log(JSON.stringify(bulk.results));
25400
+ printClaimWarnings(bulk.warnings, true);
25326
25401
  } else {
25327
25402
  for (const result of bulk.results) {
25328
25403
  console.log(result.claimed ? claimVerdict(result.ref, result) : `Skipped ${result.ref}: ${result.reason}`);
25329
25404
  }
25405
+ printClaimWarnings(bulk.warnings);
25330
25406
  }
25331
25407
  if (bulk.failed > 0) process.exitCode = 1;
25332
25408
  } catch (e) {
@@ -25639,7 +25715,7 @@ function trainPlan(command, options = {}) {
25639
25715
  { label: "verify the fix is merged on development (the only hotfix origin)", gated: true },
25640
25716
  // #6068: hotfix start/release run the SAME shared preflight as release/rcand, before any git mutation.
25641
25717
  { label: "verify registry META for this project", command: "mmi-cli oracle org project get <owner/repo>", gated: true },
25642
- { label: "preflight required main secret names", command: "mmi-cli vault secrets preflight --stage main --repo <owner/repo>", gated: true },
25718
+ { label: "preflight required main secret names", command: "mmi-cli vault secrets preflight --stage main --repo <owner/repo> --lane hotfix", gated: true },
25643
25719
  { label: "preflight local npm major vs the CI-declared npm, GitHub Actions hosted job start (billing/spending), and the gate wall-clock budget", command: "shared train preflight (#5666/#5604/#3178) \u2014 runs inside hotfix start and hotfix release", gated: true },
25644
25720
  { label: "refuse on a prior pending/failed train ledger leg (release, rcand or hotfix) before mutating anything", command: "shared release ledger (#5987/#6068) \u2014 finish that run first", gated: true },
25645
25721
  { label: "branch hotfix from main and cherry-pick the dev commits", command: "git cherry-pick -x <dev-sha>", gated: true },
@@ -28247,7 +28323,7 @@ function formatDeployStatus(r) {
28247
28323
  `running version: ${r.runningVersion ?? "none stamped"}`,
28248
28324
  `last deploy run: ${formatLastRun(r)}`,
28249
28325
  `public URL: ${r.publicUrl ?? "none"}`,
28250
- `health probe: ${r.health ? `${r.health.ok ? "ok" : "failed"}${r.health.status ? ` (HTTP ${r.health.status})` : ""}${r.health.error ? ` \u2014 ${r.health.error}` : ""}` : "not probed"}`,
28326
+ `health probe: ${r.health ? `${r.health.ok ? "ok" : "failed"}${r.health.status ? ` (HTTP ${r.health.status})` : ""}${r.health.error ? ` \u2014 ${r.health.error}` : ""}${r.health.url && r.health.url !== r.publicUrl ? ` at ${r.health.url}` : ""}` : "not probed"}`,
28251
28327
  `deploy state: ${r.deployOk === void 0 ? "not stamped" : r.deployOk ? "ok" : "failed"}`
28252
28328
  ];
28253
28329
  if (r.hints.length) {
@@ -28258,22 +28334,9 @@ function formatDeployStatus(r) {
28258
28334
 
28259
28335
  // src/deploy-commands.ts
28260
28336
  var STAGES2 = ["dev", "rc", "main"];
28261
- async function probeHttpBounded(url, timeoutMs = 5e3) {
28262
- const controller = new AbortController();
28263
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
28264
- timeout.unref?.();
28265
- try {
28266
- const res = await fetch(url, { method: "GET", signal: controller.signal });
28267
- return { ok: res.ok, status: res.status };
28268
- } catch (e) {
28269
- return { ok: false, error: e.message };
28270
- } finally {
28271
- clearTimeout(timeout);
28272
- }
28273
- }
28274
28337
  function registerDeployCommands(program3) {
28275
28338
  const deploy = program3.command("deploy").description("per-stage deploy observability \u2014 last run, health, and running version (#2688)");
28276
- deploy.command("status <stage>").description("last deploy run + runtime health probe + running version for a stage (defaults to the current repo)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action(async (stage, o) => {
28339
+ deploy.command("status <stage>").description("last deploy run + runtime health probe + running version for a stage (defaults to the current repo); an auth-walled root (401/403) is re-probed at /health and the `url` field names the endpoint that answered (#6137)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action(async (stage, o) => {
28277
28340
  if (!STAGES2.includes(stage)) {
28278
28341
  return fail(`runtime deploy status: <stage> must be dev, rc, or main`);
28279
28342
  }
@@ -28285,7 +28348,7 @@ function registerDeployCommands(program3) {
28285
28348
  const deployFacts = await fetchDeployFactsBySlug(slug, reg);
28286
28349
  const publicUrl = publicUrlFromDeployFact(deployFacts?.stages[stage] ?? null);
28287
28350
  const [health, lastRun] = await Promise.all([
28288
- publicUrl ? probeHttpBounded(publicUrl) : Promise.resolve(void 0),
28351
+ publicUrl ? probePublicHealth(publicUrl) : Promise.resolve(void 0),
28289
28352
  fetchLastTenantDeployRun(slug, stage)
28290
28353
  ]);
28291
28354
  const report = buildDeployStatusReport({
@@ -29016,7 +29079,8 @@ function composeMarksRuntimeEnvTrue(composeText) {
29016
29079
  return false;
29017
29080
  });
29018
29081
  }
29019
- function promotionSourceBranch(stage, releaseTrack) {
29082
+ function promotionSourceBranch(stage, releaseTrack, lane) {
29083
+ if (lane === "hotfix") return "main";
29020
29084
  if (stage === "main") return releaseTrack === "direct" ? "development" : "rc";
29021
29085
  return "development";
29022
29086
  }
@@ -29917,7 +29981,7 @@ async function collectStatus() {
29917
29981
  try {
29918
29982
  const cfg = await loadConfigOrDiscover();
29919
29983
  if (cfg.sagaApiUrl) {
29920
- const report = await readBoard({ config: cfg });
29984
+ const report = await readBoard({ config: cfg }, { snapshot: registryClientDeps(cfg) });
29921
29985
  claimedItems = report.primary.userOwned.map((item) => ({
29922
29986
  number: item.number,
29923
29987
  title: item.title,
@@ -29964,8 +30028,8 @@ var PRIORITY_RANK = {
29964
30028
  };
29965
30029
  async function recommendNext(repo, deps) {
29966
30030
  const load = deps?.loadConfig ?? loadConfigForRepo;
29967
- const reader = deps?.readBoard ?? readBoard;
29968
30031
  const cfg = await load(repo);
30032
+ const reader = deps?.readBoard ?? ((opts) => readBoard(opts, { snapshot: registryClientDeps(cfg) }));
29969
30033
  if (!cfg.sagaApiUrl) throw new Error("Hub API URL not configured \u2014 the board was NOT read (run `mmi-cli doctor`)");
29970
30034
  let report;
29971
30035
  try {
@@ -30011,7 +30075,7 @@ async function collectOnboardStatus(opts = {}) {
30011
30075
  let board = { ok: false, detail: "no config" };
30012
30076
  try {
30013
30077
  if (cfg.sagaApiUrl) {
30014
- const report = await readBoard({ config: cfg });
30078
+ const report = await readBoard({ config: cfg }, { snapshot: registryClientDeps(cfg) });
30015
30079
  const total = report.primary.claimable.length + report.primary.userOwned.length + report.primary.taken.length;
30016
30080
  board = { ok: true, detail: `board has ${total} active items (${report.primary.claimable.length} claimable, ${report.primary.userOwned.length} yours)` };
30017
30081
  } else {
@@ -30230,14 +30294,6 @@ var surfaces_default = {
30230
30294
  sourcePath: "skills",
30231
30295
  targetPath: "packages/claude-plugin/skills"
30232
30296
  },
30233
- {
30234
- mode: "files",
30235
- sourcePath: "scripts",
30236
- targetPath: "packages/claude-plugin/scripts",
30237
- include: [
30238
- "edit-tool-paths.mjs"
30239
- ]
30240
- },
30241
30297
  {
30242
30298
  mode: "files",
30243
30299
  sourcePath: "bin",
@@ -30313,14 +30369,6 @@ var surfaces_default = {
30313
30369
  sourcePath: "skills",
30314
30370
  targetPath: "packages/codex-plugin/skills"
30315
30371
  },
30316
- {
30317
- mode: "files",
30318
- sourcePath: "scripts",
30319
- targetPath: "packages/codex-plugin/scripts",
30320
- include: [
30321
- "edit-tool-paths.mjs"
30322
- ]
30323
- },
30324
30372
  {
30325
30373
  mode: "files",
30326
30374
  sourcePath: "bin",
@@ -30396,14 +30444,6 @@ var surfaces_default = {
30396
30444
  sourcePath: "skills",
30397
30445
  targetPath: "packages/cursor-plugin/skills"
30398
30446
  },
30399
- {
30400
- mode: "files",
30401
- sourcePath: "scripts",
30402
- targetPath: "packages/cursor-plugin/scripts",
30403
- include: [
30404
- "edit-tool-paths.mjs"
30405
- ]
30406
- },
30407
30447
  {
30408
30448
  mode: "files",
30409
30449
  sourcePath: "bin",
@@ -30482,14 +30522,6 @@ var surfaces_default = {
30482
30522
  excludeNamePrefixes: [
30483
30523
  "_"
30484
30524
  ]
30485
- },
30486
- {
30487
- mode: "files",
30488
- sourcePath: "scripts",
30489
- targetPath: ".pi-plugin/scripts",
30490
- include: [
30491
- "edit-tool-paths.mjs"
30492
- ]
30493
30525
  }
30494
30526
  ]
30495
30527
  },
@@ -30549,14 +30581,6 @@ var surfaces_default = {
30549
30581
  mode: "directories",
30550
30582
  sourcePath: "skills",
30551
30583
  targetPath: "packages/hermes-plugin/skills"
30552
- },
30553
- {
30554
- mode: "files",
30555
- sourcePath: "scripts",
30556
- targetPath: "packages/hermes-plugin/scripts",
30557
- include: [
30558
- "edit-tool-paths.mjs"
30559
- ]
30560
30584
  }
30561
30585
  ]
30562
30586
  },
@@ -32612,6 +32636,10 @@ function validateBatchSpecs(specs) {
32612
32636
  spec.labels = [...spec.labels ?? [], ...alias];
32613
32637
  delete spec.label;
32614
32638
  }
32639
+ if (spec.labels !== void 0 && (!Array.isArray(spec.labels) || spec.labels.some((l) => typeof l !== "string" || !l.trim()))) {
32640
+ errors.push({ row, error: "labels must be an array of non-empty strings" });
32641
+ continue;
32642
+ }
32615
32643
  if (spec.repo !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(spec.repo)) {
32616
32644
  errors.push({ row, error: `bad repo "${spec.repo}" \u2014 expected owner/repo` });
32617
32645
  continue;
@@ -32620,13 +32648,17 @@ function validateBatchSpecs(specs) {
32620
32648
  errors.push({ row, error: `unknown type "${spec.type}" \u2014 expected one of: ${validTypes.join(", ")}` });
32621
32649
  continue;
32622
32650
  }
32623
- if (!spec.title || !spec.title.trim()) {
32651
+ if (typeof spec.title !== "string" || !spec.title.trim()) {
32624
32652
  errors.push({ row, error: "missing or empty title" });
32625
32653
  continue;
32626
32654
  }
32655
+ if (spec.body !== void 0 && typeof spec.body !== "string") {
32656
+ errors.push({ row, error: "body must be a string" });
32657
+ continue;
32658
+ }
32627
32659
  let priority;
32628
32660
  try {
32629
- priority = spec.priority ? normalizePriority(spec.priority) : "medium";
32661
+ priority = spec.priority ? normalizePriority(String(spec.priority)) : "medium";
32630
32662
  } catch (e) {
32631
32663
  errors.push({ row, error: e.message });
32632
32664
  continue;
@@ -32639,6 +32671,14 @@ function validateBatchSpecs(specs) {
32639
32671
  if (!labelsCarrySurface(spec.labels)) spec.labels = [...spec.labels ?? [], surfaceLabel(spec.surface)];
32640
32672
  delete spec.surface;
32641
32673
  }
32674
+ if (spec.parent !== void 0) {
32675
+ try {
32676
+ parseIssueRef(spec.parent);
32677
+ } catch (e) {
32678
+ errors.push({ row, error: e.message });
32679
+ continue;
32680
+ }
32681
+ }
32642
32682
  validated.push({ row, spec, priority, type: spec.type });
32643
32683
  }
32644
32684
  return { ok: errors.length === 0, errors, validated };
@@ -34065,6 +34105,8 @@ var UPDATE_PROJECT_INFO = `mutation($projectId: ID!, $shortDescription: String!,
34065
34105
  projectV2 { id }
34066
34106
  }
34067
34107
  }`;
34108
+ var ProjectInfoReadUnavailableError = class extends Error {
34109
+ };
34068
34110
  function shortDescriptionFromReadme(markdown) {
34069
34111
  const lines2 = markdown.replace(/\r/g, "").split("\n");
34070
34112
  const h1 = lines2.findIndex((line) => /^#\s+\S/.test(line.trim()));
@@ -34786,10 +34828,13 @@ function registerSecretsCommands(program3) {
34786
34828
  const ok = body !== void 0 ? await secretsOrgCatalogSet(d, body, { replace: o.replace, remove: o.remove }) : await secretsOrgCatalogRemove(d, o.remove ?? []);
34787
34829
  if (!ok) process.exitCode = 1;
34788
34830
  }));
34789
- secrets.command("preflight").description("check required stage secret names for a deploy/train without reading values").requiredOption("--stage <dev|rc|main>", "stage to check").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--required <KEY...>", "required keys; bare keys are scoped under --stage").option("--skip-compose-guard", "skip the #2813 fileless-compose \u2194 DEPLOY#.noEnvFile promotion guard").option("--json", "machine-readable output").action(async (o) => {
34831
+ secrets.command("preflight").description("check required stage secret names for a deploy/train without reading values").requiredOption("--stage <dev|rc|main>", "stage to check").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--required <KEY...>", "required keys; bare keys are scoped under --stage").option("--skip-compose-guard", "skip the #2813 fileless-compose \u2194 DEPLOY#.noEnvFile promotion guard").option("--lane <hotfix>", "only hotfix is accepted: the hotfix lane deploys main, so the #2813 guard reads main, not rc (#6136); release/rcand need no flag").option("--json", "machine-readable output").action(async (o) => {
34790
34832
  if (!["dev", "rc", "main"].includes(o.stage)) {
34791
34833
  return fail("secrets preflight: --stage must be dev, rc, or main");
34792
34834
  }
34835
+ if (o.lane !== void 0 && o.lane !== "hotfix") {
34836
+ return fail("secrets preflight: --lane must be hotfix (the only lane that changes the compose source)");
34837
+ }
34793
34838
  const cfg = await loadConfig();
34794
34839
  if (!cfg.sagaApiUrl) {
34795
34840
  fail("secrets: Hub API URL not configured");
@@ -34810,7 +34855,7 @@ function registerSecretsCommands(program3) {
34810
34855
  let filelessOk = true;
34811
34856
  if (meta && centralContainer && !o.skipComposeGuard) {
34812
34857
  const stage = o.stage;
34813
- const branch = promotionSourceBranch(stage, resolveReleaseTrack(meta, void 0, repo));
34858
+ const branch = promotionSourceBranch(stage, resolveReleaseTrack(meta, void 0, repo), o.lane);
34814
34859
  const cwdRepo = repoFromRemoteUrl((await execFileP("git", ["remote", "get-url", "origin"]).catch(() => ({ stdout: "" }))).stdout);
34815
34860
  const { sameRepo: sameRepo2 } = resolvePreflightRepoScope(o.repo, cwdRepo);
34816
34861
  const facts = await fetchDeployFactsBySlug(slug, regDeps);
@@ -35016,10 +35061,11 @@ async function resolveBoardConfig2(repoOption) {
35016
35061
  const floor = await loadConfig();
35017
35062
  if (!floor.sagaApiUrl) return null;
35018
35063
  const slug = repoOption ? (repoOption.replace(/\.git$/, "").split("/").pop() ?? repoOption).toLowerCase() : await repoSlug();
35019
- const read = await fetchProjectBySlugChecked(slug, registryClientDeps(floor));
35064
+ const registry2 = registryClientDeps(floor);
35065
+ const read = await fetchProjectBySlugChecked(slug, registry2);
35020
35066
  if (!read.ok || !read.project) return null;
35021
35067
  const cfg = boardConfigFromProject(read.project, floor);
35022
- return readBoard({ config: cfg, repo: repoOption, allowPartial: true });
35068
+ return readBoard({ config: cfg, repo: repoOption, allowPartial: true }, { snapshot: registry2 });
35023
35069
  }
35024
35070
  function registerSessionReport(program3) {
35025
35071
  const report = program3.commands.find((c) => c.name() === "report");
@@ -36788,24 +36834,31 @@ function runTestPolicy(root, deps = {}) {
36788
36834
  ` + (deps.base === HOTFIX_DIFF_BASE ? ` Hotfix lane (--base ${HOTFIX_DIFF_BASE}): pass --policy-ref <exact-${HOTFIX_POLICY_REF}-commit> so the current policy is read from ${HOTFIX_POLICY_REF}.` : ` Restore ${POLICY_FILE} on this branch; the policy file belongs on every non-hotfix lane.`)
36789
36835
  }] : [];
36790
36836
  const present = (path2) => exists((0, import_node_path33.join)(root, path2));
36837
+ const policyTree = policySource.sha ? new Set(git2(["ls-tree", "-r", "-z", "--name-only", policySource.sha], root).split("\0").filter(Boolean).map((p) => (0, import_node_path33.join)(root, p))) : null;
36838
+ const policyTreeExists = policyTree ? (abs) => policyTree.has(abs) : exists;
36839
+ const where = policySource.sha ? `${policySource.ref}@${policySource.sha}` : "this worktree";
36791
36840
  const removedByThisDiff = removedPaths(changed);
36792
36841
  const staleFindings = [];
36793
- const unresolved = unresolvedProtectedEntries(policy, root, exists).filter((p) => !removedByThisDiff.has(p));
36842
+ const unresolved = unresolvedProtectedEntries(policy, root, policyTreeExists).filter((p) => !removedByThisDiff.has(p));
36794
36843
  if (unresolved.length > 0) {
36795
36844
  staleFindings.push({
36796
36845
  kind: "stale-protected-entry",
36797
36846
  paths: unresolved,
36798
- detail: `STALE PROTECTED ENTRY \u2014 test-policy.json protects ${unresolved.length} path(s) that do not exist:
36799
- ` + unresolved.map((p) => ` ${p}`).join("\n") + "\n An entry naming a missing file reads as protection while protecting nothing.\n Restore the file, or remove its entry."
36847
+ detail: `STALE PROTECTED ENTRY \u2014 test-policy.json protects ${unresolved.length} path(s) that do not exist on ${where}:
36848
+ ` + unresolved.map((p) => ` ${p}`).join("\n") + `
36849
+ An entry naming a missing file reads as protection while protecting nothing.
36850
+ Restore the file on ${where}, or remove its entry.`
36800
36851
  });
36801
36852
  }
36802
- const staleSatisfiers = unresolvedSatisfiers(policy, root, exists).filter((p) => !removedByThisDiff.has(p));
36853
+ const staleSatisfiers = unresolvedSatisfiers(policy, root, policyTreeExists).filter((p) => !removedByThisDiff.has(p));
36803
36854
  if (staleSatisfiers.length > 0) {
36804
36855
  staleFindings.push({
36805
36856
  kind: "stale-satisfied-by",
36806
36857
  paths: staleSatisfiers,
36807
- detail: `STALE STANDING COVERAGE \u2014 a mandatory glob claims ${staleSatisfiers.length} path(s) as satisfiedBy that do not exist:
36808
- ` + staleSatisfiers.map((p) => ` ${p}`).join("\n") + "\n A glob discharged by coverage that is not there is a glob enforcing nothing, quietly.\n Restore the file, or drop it from satisfiedBy so the glob asks for a test again."
36858
+ detail: `STALE STANDING COVERAGE \u2014 a mandatory glob claims ${staleSatisfiers.length} path(s) as satisfiedBy that do not exist on ${where}:
36859
+ ` + staleSatisfiers.map((p) => ` ${p}`).join("\n") + `
36860
+ A glob discharged by coverage that is not there is a glob enforcing nothing, quietly.
36861
+ Restore the file on ${where}, or drop it from satisfiedBy so the glob asks for a test again.`
36809
36862
  });
36810
36863
  }
36811
36864
  const override = lookup.override;
@@ -37120,15 +37173,17 @@ function cleanupGitArgs(cwd, args) {
37120
37173
  }
37121
37174
  async function remoteBranchExists2(branch, options = {}) {
37122
37175
  if (!branch) return void 0;
37176
+ const remote = options.remote ?? "origin";
37123
37177
  try {
37124
- if (options.prune) await execFileP("git", cleanupGitArgs(options.cwd, ["fetch", "origin", "--prune"]), { timeout: GIT_TIMEOUT_MS });
37125
- return (await execFileP("git", cleanupGitArgs(options.cwd, ["ls-remote", "--heads", "origin", branch]), { timeout: GIT_TIMEOUT_MS })).stdout.trim().length > 0;
37178
+ if (options.prune) await execFileP("git", cleanupGitArgs(options.cwd, ["fetch", remote, "--prune"]), { timeout: GIT_TIMEOUT_MS });
37179
+ return (await execFileP("git", cleanupGitArgs(options.cwd, ["ls-remote", "--heads", remote, branch]), { timeout: GIT_TIMEOUT_MS })).stdout.trim().length > 0;
37126
37180
  } catch {
37127
37181
  return void 0;
37128
37182
  }
37129
37183
  }
37130
37184
  async function deleteMergedRemoteBranch(options) {
37131
- const remediation = `git push origin --delete ${options.branch}`;
37185
+ const remote = options.remote ?? "origin";
37186
+ const remediation = `git push ${remote} --delete ${options.branch}`;
37132
37187
  if (!options.branch) {
37133
37188
  return {
37134
37189
  branch: options.branch,
@@ -37157,13 +37212,13 @@ async function deleteMergedRemoteBranch(options) {
37157
37212
  existedBefore: false,
37158
37213
  attempted: false,
37159
37214
  status: "failed",
37160
- error: `could not verify absence of origin/${options.branch}`,
37215
+ error: `could not verify absence of ${remote}/${options.branch}`,
37161
37216
  remediation
37162
37217
  };
37163
37218
  }
37164
37219
  }
37165
37220
  try {
37166
- await options.execGit(["push", "origin", "--delete", options.branch]);
37221
+ await options.execGit(["push", remote, "--delete", options.branch]);
37167
37222
  } catch (e) {
37168
37223
  const exists2 = await options.branchExists(options.branch);
37169
37224
  if (exists2 === false) {
@@ -37197,7 +37252,7 @@ async function deleteMergedRemoteBranch(options) {
37197
37252
  existedBefore: options.existedBefore,
37198
37253
  attempted: true,
37199
37254
  status: "failed",
37200
- error: exists ? `origin still reports ${options.branch} after deletion` : `could not verify deletion of origin/${options.branch}`,
37255
+ error: exists ? `${remote} still reports ${options.branch} after deletion` : `could not verify deletion of ${remote}/${options.branch}`,
37201
37256
  remediation
37202
37257
  };
37203
37258
  }
@@ -37539,11 +37594,13 @@ async function prCreateClaimRefusal(body, repoOption, deps = {}) {
37539
37594
  const actor = deps.actor ?? describeSessionIdentity();
37540
37595
  const checkContest = deps.checkContest ?? checkLaneContest;
37541
37596
  for (const number of issues) {
37597
+ const state = await client.rest("GET", `repos/${repo}/issues/${number}`).then((issue) => issue?.state, () => void 0);
37598
+ if (state?.toLowerCase() === "closed") continue;
37542
37599
  const contest = await checkContest(client, { repository: repo, number }, actor);
37543
37600
  if (!contest.contested) continue;
37544
37601
  const ref = `${repo}#${number}`;
37545
37602
  const holder = contest.marker ? `lane ${describeClaimMarker(contest.marker)}` : "another lane";
37546
- return `pr create: REFUSED \u2014 ${ref} is held by ${holder} with live or unreadable work evidence; run \`mmi-cli oracle board claim ${ref} --force\` before creating this PR`;
37603
+ return `pr create: REFUSED \u2014 ${ref} is held by ${holder} with live or unreadable work evidence; run \`mmi-cli oracle board claim ${ref} --force\` (move it to In Progress first if it sits In Review) or drop the closing line before creating this PR`;
37547
37604
  }
37548
37605
  return void 0;
37549
37606
  }
@@ -37845,9 +37902,9 @@ function isPathAtOrWithin(path2, worktreePath) {
37845
37902
  function isWindowsCwdLockResidueError(error) {
37846
37903
  return process.platform === "win32" && /\bEPERM\b|access(?: is)? denied/i.test(error);
37847
37904
  }
37848
- function deferredCwdLockRemediation(primaryRoot, wtPath) {
37905
+ function deferredCwdLockRemediation(wtPath) {
37849
37906
  const quote = (path2) => path2.replace(/'/g, "''");
37850
- return `After this command exits: Set-Location -LiteralPath '${quote(primaryRoot)}'; Remove-Item -LiteralPath '${quote(wtPath)}' -Recurse -Force`;
37907
+ return `After this command exits: jervcode worktree-cleanup --worktree '${quote(wtPath)}'`;
37851
37908
  }
37852
37909
  function primaryCheckoutBranchRemediation(primaryRoot, baseRef, branch) {
37853
37910
  const quote = (value) => value.replace(/'/g, "''");
@@ -37943,6 +38000,69 @@ function safeRemoveTree(path2) {
37943
38000
  }
37944
38001
  (0, import_node_fs41.unlinkSync)(path2);
37945
38002
  }
38003
+ function errorMessage(e) {
38004
+ return e instanceof Error ? e.message : String(e);
38005
+ }
38006
+ function resolvedOrRaw(path2) {
38007
+ try {
38008
+ return normPath2((0, import_node_fs41.realpathSync)(path2));
38009
+ } catch {
38010
+ return normPath2(path2);
38011
+ }
38012
+ }
38013
+ function unlinkEscapingReparsePoints(root, primaryRoot) {
38014
+ let realRoot;
38015
+ try {
38016
+ if ((0, import_node_fs41.lstatSync)(root).isSymbolicLink()) {
38017
+ return { ok: false, error: `delete root ${normPath2(root)} is a reparse point resolving to ${resolvedOrRaw(root)}` };
38018
+ }
38019
+ realRoot = normPath2((0, import_node_fs41.realpathSync)(root));
38020
+ } catch (e) {
38021
+ if (e.code === "ENOENT") return { ok: true, unlinked: [] };
38022
+ return { ok: false, error: `cannot resolve delete root ${normPath2(root)}: ${errorMessage(e)}` };
38023
+ }
38024
+ const realPrimary = resolvedOrRaw(primaryRoot);
38025
+ const forbidden = isPathAtOrWithin(realPrimary, realRoot) ? realPrimary : [`${realPrimary}/node_modules`, `${realPrimary}/packages`].find((p) => isPathAtOrWithin(realRoot, p));
38026
+ if (forbidden) {
38027
+ return { ok: false, error: `delete root ${normPath2(root)} (resolved ${realRoot}) is or contains the primary checkout path ${forbidden}` };
38028
+ }
38029
+ const unlinked = [];
38030
+ const stack = [realRoot];
38031
+ while (stack.length) {
38032
+ const dir = stack.pop();
38033
+ let entries;
38034
+ try {
38035
+ entries = (0, import_node_fs41.readdirSync)(dir, { withFileTypes: true });
38036
+ } catch (e) {
38037
+ return { ok: false, error: `cannot scan ${normPath2(dir)} for reparse points: ${errorMessage(e)}` };
38038
+ }
38039
+ for (const entry of entries) {
38040
+ const child2 = (0, import_node_path38.join)(dir, entry.name);
38041
+ if (entry.isSymbolicLink()) {
38042
+ let target = "";
38043
+ try {
38044
+ target = normPath2((0, import_node_fs41.realpathSync)(child2));
38045
+ } catch {
38046
+ target = "";
38047
+ }
38048
+ if (target && isPathAtOrWithin(target, realRoot)) continue;
38049
+ try {
38050
+ safeRemoveTree(child2);
38051
+ } catch (e) {
38052
+ return { ok: false, error: `cannot unlink reparse point ${normPath2(child2)} -> ${target || "unresolvable"}: ${errorMessage(e)}` };
38053
+ }
38054
+ unlinked.push(normPath2(child2));
38055
+ continue;
38056
+ }
38057
+ if (entry.isDirectory()) stack.push(child2);
38058
+ }
38059
+ }
38060
+ return { ok: true, unlinked };
38061
+ }
38062
+ function reparseEscapeRemediation(wtPath) {
38063
+ const quote = (value) => value.replace(/'/g, "''");
38064
+ return `inspect '${quote(wtPath)}' manually \u2014 a reparse point resolves outside the worktree; never remove it recursively`;
38065
+ }
37946
38066
  async function describePreCleanFailure(wtPath, execGit, error) {
37947
38067
  const dryRun = await execGit(["-C", wtPath, "clean", "-ndX"]).catch(() => "");
37948
38068
  const remaining = dryRun.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("Would remove ")).map((line) => line.slice("Would remove ".length));
@@ -37977,6 +38097,25 @@ async function verifyBranchHead(git3, branch, expectedHeadOid) {
37977
38097
  }
37978
38098
  return { ok: true };
37979
38099
  }
38100
+ async function switchPrimaryCheckoutOffMergedBranch(wtPath, branch, baseRef, execGit, expectedHeadOid) {
38101
+ const git3 = (args) => execGit(["-C", wtPath, ...args]);
38102
+ const headCheck = await verifyBranchHead(git3, branch, expectedHeadOid);
38103
+ if (!headCheck.ok) return { ok: false, error: headCheck.error ? `${headCheck.reason}: ${headCheck.error}` : headCheck.reason };
38104
+ const porcelain = await git3(["status", "--porcelain"]).catch(() => void 0);
38105
+ if (porcelain === void 0) return { ok: false, error: "could not read the primary checkout status" };
38106
+ if (porcelainHasBlockingChanges(porcelain)) return { ok: false, error: "dirty-worktree" };
38107
+ try {
38108
+ await git3(["switch", baseRef]);
38109
+ } catch (e) {
38110
+ return { ok: false, error: `switch to ${baseRef} failed: ${formatGitCommandError(e)}` };
38111
+ }
38112
+ try {
38113
+ await git3(["branch", "-D", branch]);
38114
+ } catch (e) {
38115
+ return { ok: false, switchedTo: baseRef, error: `branch -D ${branch} failed after switching to ${baseRef}: ${formatGitCommandError(e)}` };
38116
+ }
38117
+ return { ok: true, switchedTo: baseRef };
38118
+ }
37980
38119
  async function teardownWorktreeStage(worktreePath) {
37981
38120
  try {
37982
38121
  const result = await stopStage({ cwd: worktreePath, requiredIdentityCwd: worktreePath, globalStatePath: false });
@@ -38070,7 +38209,7 @@ async function removeWorktreeWithReconcile(wtPath, git3, listWorktrees, pathExis
38070
38209
  }
38071
38210
  function isPrMergeWorktreePartial(cleanup) {
38072
38211
  const worktree = cleanup?.worktree;
38073
- return Boolean(worktree?.path && worktree.status !== "removed" && worktree.status !== "preserved" && worktree.status !== "retained-locked");
38212
+ return Boolean(worktree?.path && worktree.status !== "removed" && worktree.status !== "preserved" && worktree.status !== "retained-locked" && worktree.status !== "switched-primary");
38074
38213
  }
38075
38214
  function prMergeLocalCleanupExitCode(cleanup) {
38076
38215
  return cleanup?.worktree?.status === "failed" || cleanup?.localBranch?.status === "failed" ? 1 : void 0;
@@ -38110,10 +38249,29 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38110
38249
  const mainWorktreeTarget = Boolean(wtPath && mainWorktreePath && samePath(wtPath, mainWorktreePath));
38111
38250
  if (!wtPath || mainWorktreeTarget) {
38112
38251
  if (wtPath && mainWorktreeTarget) {
38252
+ const switched = await switchPrimaryCheckoutOffMergedBranch(wtPath, branch, options.baseRef, execGit, options.expectedHeadOid);
38253
+ if (switched.ok) {
38254
+ report.worktree = { path: wtPath, status: "switched-primary", reason: "main-worktree", switchedTo: switched.switchedTo };
38255
+ report.localBranch = { name: branch, status: "deleted" };
38256
+ return report;
38257
+ }
38258
+ if (switched.switchedTo) {
38259
+ report.worktree = {
38260
+ path: wtPath,
38261
+ status: "switched-primary",
38262
+ reason: "main-worktree",
38263
+ switchedTo: switched.switchedTo,
38264
+ error: switched.error,
38265
+ remediation: primaryCheckoutBranchRemediation(options.primaryRoot, options.baseRef, branch)
38266
+ };
38267
+ report.localBranch = { name: branch, status: "failed", error: switched.error };
38268
+ return report;
38269
+ }
38113
38270
  report.worktree = {
38114
38271
  path: wtPath,
38115
38272
  status: "not-attempted",
38116
38273
  reason: "main-worktree",
38274
+ error: switched.error,
38117
38275
  remediation: primaryCheckoutBranchRemediation(options.primaryRoot, options.baseRef, branch)
38118
38276
  };
38119
38277
  }
@@ -38133,7 +38291,12 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38133
38291
  return report;
38134
38292
  }
38135
38293
  const porcelain = await execGit(["-C", wtPath, "status", "--porcelain"]).catch(() => void 0);
38136
- if (porcelain?.trim()) {
38294
+ if (porcelain === void 0) {
38295
+ report.worktree = { path: wtPath, status: "refused", reason: "status-unreadable", error: "could not read the worktree status" };
38296
+ report.localBranch = { name: branch, status: "not-attempted", reason: "status-unreadable" };
38297
+ return report;
38298
+ }
38299
+ if (porcelain.trim()) {
38137
38300
  report.worktree = { path: wtPath, status: "refused", reason: "dirty-worktree" };
38138
38301
  report.localBranch = { name: branch, status: "not-attempted", reason: "dirty-worktree" };
38139
38302
  return report;
@@ -38183,6 +38346,25 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38183
38346
  report.localBranch = { name: branch, status: "not-attempted", reason: "archive-failed" };
38184
38347
  return report;
38185
38348
  }
38349
+ const unlinkedReparsePoints = [];
38350
+ const refuseReparseEscape = (error) => {
38351
+ report.worktree = {
38352
+ path: wtPath,
38353
+ status: "refused",
38354
+ reason: "reparse-escape",
38355
+ error,
38356
+ artifactsArchive,
38357
+ stageTeardown,
38358
+ tmpEvidenceCount: tmpEvidence.length,
38359
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {},
38360
+ remediation: reparseEscapeRemediation(wtPath)
38361
+ };
38362
+ report.localBranch = { name: branch, status: "not-attempted", reason: "reparse-escape" };
38363
+ return report;
38364
+ };
38365
+ const preHelperGuard = unlinkEscapingReparsePoints(wtPath, options.primaryRoot);
38366
+ if (!preHelperGuard.ok) return refuseReparseEscape(preHelperGuard.error);
38367
+ unlinkedReparsePoints.push(...preHelperGuard.unlinked);
38186
38368
  if (pathExists((0, import_node_path38.join)(wtPath, "node_modules"))) {
38187
38369
  const nmRemoved = await (options.removeRealNodeModules ?? ((p) => removeWorktreeNodeModulesViaHelper(p, { cwd: mainWorktreePath })))(wtPath);
38188
38370
  if (!nmRemoved.ok) {
@@ -38194,6 +38376,7 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38194
38376
  artifactsArchive,
38195
38377
  stageTeardown,
38196
38378
  tmpEvidenceCount: tmpEvidence.length,
38379
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {},
38197
38380
  remediation: `jervcode worktree-node-modules-cleanup --worktree '${wtPath.replace(/'/g, "''")}'`
38198
38381
  };
38199
38382
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-pre-clean-failed" };
@@ -38210,12 +38393,16 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38210
38393
  artifactsArchive,
38211
38394
  stageTeardown,
38212
38395
  tmpEvidenceCount: tmpEvidence.length,
38396
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {},
38213
38397
  // #6076: name the surviving node_modules remover when the dry run found one, else the clean.
38214
38398
  remediation: preClean.remediation ?? `git -C '${wtPath.replace(/'/g, "''")}' clean -ffdX`
38215
38399
  };
38216
38400
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-pre-clean-failed" };
38217
38401
  return report;
38218
38402
  }
38403
+ const preRemoveGuard = unlinkEscapingReparsePoints(wtPath, options.primaryRoot);
38404
+ if (!preRemoveGuard.ok) return refuseReparseEscape(preRemoveGuard.error);
38405
+ unlinkedReparsePoints.push(...preRemoveGuard.unlinked);
38219
38406
  moveCwdToSafeWorktree(wtPath, safeCwd);
38220
38407
  const removal = await removeWorktreeWithReconcile(
38221
38408
  wtPath,
@@ -38230,7 +38417,8 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38230
38417
  error: removal.error,
38231
38418
  artifactsArchive,
38232
38419
  stageTeardown,
38233
- tmpEvidenceCount: tmpEvidence.length
38420
+ tmpEvidenceCount: tmpEvidence.length,
38421
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {}
38234
38422
  };
38235
38423
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
38236
38424
  return report;
@@ -38241,7 +38429,8 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38241
38429
  ...removal.reason ? { reason: removal.reason } : {},
38242
38430
  artifactsArchive,
38243
38431
  stageTeardown,
38244
- tmpEvidenceCount: tmpEvidence.length
38432
+ tmpEvidenceCount: tmpEvidence.length,
38433
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {}
38245
38434
  };
38246
38435
  if (pathExists(wtPath)) {
38247
38436
  const residue = await (options.removeResidueDir?.(wtPath) ?? removeResidueDirectory(wtPath));
@@ -38253,7 +38442,7 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38253
38442
  if (isWindowsCwdLockResidueError(residue.error) && isPathAtOrWithin(options.startingPath, wtPath)) {
38254
38443
  report.worktree.status = "retained-locked";
38255
38444
  report.worktree.reason = "parent-cwd-lock";
38256
- report.worktree.remediation = deferredCwdLockRemediation(options.primaryRoot, wtPath);
38445
+ report.worktree.remediation = deferredCwdLockRemediation(wtPath);
38257
38446
  } else {
38258
38447
  report.worktree.status = "failed";
38259
38448
  report.worktree.reason = "residue-remains";
@@ -38289,12 +38478,18 @@ function renderPrMergeCleanupLines(cleanup) {
38289
38478
  lines2.push(`pr merge: preserved worktree ${wt.path} (--preserve-worktree)`);
38290
38479
  } else if (wt.status === "retained-locked") {
38291
38480
  lines2.push(`pr merge: worktree ${wt.path} cleanup deferred \u2014 its Windows parent shell still holds the cwd; ${wt.remediation ?? "exit the shell and remove the residue from the primary checkout"}`);
38481
+ } else if (wt.status === "switched-primary") {
38482
+ const now = wt.switchedTo ? `to ${wt.switchedTo}` : "to the base branch";
38483
+ lines2.push(wt.error ? `pr merge: primary checkout ${wt.path} switched ${now} but deleting the merged branch failed \u2014 ${wt.error}; remediate: ${wt.remediation ?? "delete the merged branch manually"}` : `pr merge: primary checkout ${wt.path} held the merged branch \u2014 switched it back ${now} and deleted the branch`);
38292
38484
  } else if (wt.status === "not-attempted" && wt.reason === "main-worktree") {
38293
- lines2.push(`pr merge: merged branch is still checked out in the primary checkout ${wt.path} \u2014 not removed; remediate: ${wt.remediation ?? "switch it back to the base branch and delete the merged branch"}`);
38485
+ lines2.push(`pr merge: merged branch is still checked out in the primary checkout ${wt.path} \u2014 not removed${wt.error ? ` (${wt.error})` : ""}; remediate: ${wt.remediation ?? "switch it back to the base branch and delete the merged branch"}`);
38294
38486
  }
38295
38487
  if (wt.residue === "left" && wt.status !== "retained-locked") {
38296
38488
  lines2.push(`pr merge: worktree ${wt.path} registration is gone but residue remains \u2014 ${wt.residueError ?? wt.path}; remediate: ${wt.remediation ?? wt.path}`);
38297
38489
  }
38490
+ if (wt.unlinkedReparsePoints?.length) {
38491
+ lines2.push(`pr merge: unlinked ${wt.unlinkedReparsePoints.length} reparse point(s) resolving outside ${wt.path}: ${wt.unlinkedReparsePoints.join(", ")}`);
38492
+ }
38298
38493
  if (wt.artifactsArchive?.status === "archived" && wt.artifactsArchive.path) {
38299
38494
  lines2.push(`pr merge: archived worktree evidence to ${wt.artifactsArchive.path}`);
38300
38495
  } else if (wt.artifactsArchive?.status === "blocked" && wt.artifactsArchive.error) {
@@ -39356,7 +39551,7 @@ ${list}`);
39356
39551
  else printLine(`pr land: ${result.status}${result.error ? ` \u2014 ${result.error}` : ""}`);
39357
39552
  if (result.status === "failed") process.exitCode = 1;
39358
39553
  });
39359
- jsonParity(pr.command("merge <number>").description("merge a PR (squash by default); archives gitignored tmp/** before worktree teardown; on no-ci repos run pr ci-policy / checks-wait first (#1432, #5679)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").addOption(new Option("--disable-auto", "disable a queued auto-merge without merging").conflicts(["auto", "wait", "squash", "merge", "rebase", "preserveWorktree", "gc", "squashBodyFile", "force"])).option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m) \u2014 run as a background/monitor task or under a shell timeout above that budget; a short foreground timeout (e.g. 120s) kills it after checks pass and leaves the PR open (#6027)`).option("--preserve-worktree", "after merge, keep the local PR worktree/branch for an active batch (#1888)").option("--gc", "acknowledge deleting unarchived gitignored tmp/** evidence newer than the branch base (#5679)").option("--squash-body-file <path>", "squash commit body (overrides GitHub COMMIT_MESSAGES); use when a pushed commit mentions close/fix/resolve + #N that must not close (#5723)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal")).action(async (number, o) => {
39554
+ jsonParity(pr.command("merge <number>").description("merge a PR (squash by default); archives gitignored tmp/** before worktree teardown; on no-ci repos run pr ci-policy / checks-wait first (#1432, #5679)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo); from a foreign checkout the remote probe/delete address this repo and local cleanup runs only in the verified sibling checkout ../<repo>, else localBranch reports skipped-foreign-cwd and the receipt carries foreignCwd: true (#6148)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").addOption(new Option("--disable-auto", "disable a queued auto-merge without merging").conflicts(["auto", "wait", "squash", "merge", "rebase", "preserveWorktree", "gc", "squashBodyFile", "force"])).option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m) \u2014 run as a background/monitor task or under a shell timeout above that budget; a short foreground timeout (e.g. 120s) kills it after checks pass and leaves the PR open (#6027)`).option("--preserve-worktree", "after merge, keep the local PR worktree/branch for an active batch (#1888)").option("--gc", "acknowledge deleting unarchived gitignored tmp/** evidence newer than the branch base (#5679)").option("--squash-body-file <path>", "squash commit body (overrides GitHub COMMIT_MESSAGES); use when a pushed commit mentions close/fix/resolve + #N that must not close (#5723)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal")).action(async (number, o) => {
39360
39555
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
39361
39556
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
39362
39557
  if (o.disableAuto) {
@@ -39416,7 +39611,23 @@ ${list}`);
39416
39611
  const startingPath = (await execFileP("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
39417
39612
  const housekeeping = assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr merge", { force: o.force });
39418
39613
  const beforeWorktreesRead = await execFileP("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).then((r) => ({ state: "ok", stdout: r.stdout })).catch((e) => ({ state: "failed", error: e.message || "git worktree list failed" }));
39419
- const beforeWorktrees = beforeWorktreesRead.state === "ok" ? parseGitWorktreePorcelain(beforeWorktreesRead.stdout) : [];
39614
+ let beforeWorktrees = beforeWorktreesRead.state === "ok" ? parseGitWorktreePorcelain(beforeWorktreesRead.stdout) : [];
39615
+ const cwdRepo = repoFromRemoteUrl(await gitOut(["remote", "get-url", "origin"]).catch(() => ""));
39616
+ const targetRepo2 = repoForPostCleanup ? repoForPostCleanup.split("/").slice(-2).join("/") : void 0;
39617
+ const foreignCwd = Boolean(o.repo) && Boolean(targetRepo2) && cwdRepo?.toLowerCase() !== targetRepo2.toLowerCase();
39618
+ const remote = foreignCwd ? `https://github.com/${targetRepo2}.git` : "origin";
39619
+ let foreignCheckout;
39620
+ if (foreignCwd) {
39621
+ const sibling = (0, import_node_path39.join)((0, import_node_path39.dirname)(beforeWorktrees[0]?.path || startingPath || process.cwd()), targetRepo2.split("/")[1]);
39622
+ const siblingRepo = repoFromRemoteUrl(await gitOut(["-C", sibling, "remote", "get-url", "origin"]).catch(() => ""));
39623
+ if (siblingRepo?.toLowerCase() === targetRepo2.toLowerCase()) {
39624
+ const siblingWorktrees = await execFileP("git", ["-C", sibling, "worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).then((r) => parseGitWorktreePorcelain(r.stdout)).catch(() => void 0);
39625
+ if (siblingWorktrees?.length) {
39626
+ foreignCheckout = sibling;
39627
+ beforeWorktrees = siblingWorktrees;
39628
+ }
39629
+ }
39630
+ }
39420
39631
  const ciHeadRef = repoForPostCleanup ? await prHeadRefForCiProbe(number, repoForPostCleanup) : void 0;
39421
39632
  const ciPolicy = await resolveMergeCiPolicyForCheckout(o.repo, ciHeadRef);
39422
39633
  if (o.wait) {
@@ -39474,7 +39685,7 @@ ${list}`);
39474
39685
  if (guard.action === "refuse") throw new Error(`gh pr merge ${number}: ${guard.message}`);
39475
39686
  if (guard.note) console.warn(`pr merge: ${guard.note}`);
39476
39687
  }
39477
- const remoteBefore = await remoteBranchExists2(headRef);
39688
+ const remoteBefore = await remoteBranchExists2(headRef, { remote });
39478
39689
  let upgradedToAuto = false;
39479
39690
  let remoteNotAttemptedReason = "preserved-delayed-cleanup";
39480
39691
  const overrideBody = mergeSquashBody ? { ...writeSquashBodyFile(mergeSquashBody), text: mergeSquashBody } : await composeOverrideBodyFile(
@@ -39593,30 +39804,34 @@ ${list}`);
39593
39804
  }
39594
39805
  const primaryRoot = beforeWorktrees[0]?.path ?? (startingPath || process.cwd());
39595
39806
  let localCleanup;
39596
- try {
39597
- localCleanup = await cleanupPrMergeLocalBranch(headRef, {
39598
- beforeWorktrees,
39599
- startingPath,
39600
- baseRef,
39601
- primaryRoot,
39602
- preserveWorktree: o.preserveWorktree,
39603
- gcAcknowledged: o.gc,
39604
- expectedHeadOid: headRefOid,
39605
- pathExists: (p) => (0, import_node_fs42.existsSync)(p),
39606
- // #5899: pin cleanup git calls to the main checkout — the task worktree this process may be
39607
- // standing in is removed mid-cleanup, so a cwd-relative invocation fails with
39608
- // 'fatal: not a git repository' and leaves a spurious partial-cleanup exit.
39609
- execGit: async (args) => (await execFileP("git", cleanupGitArgs(primaryRoot, args), { timeout: GIT_TIMEOUT_MS })).stdout
39610
- });
39611
- } catch (e) {
39612
- localCleanup = {
39613
- branch: headRef,
39614
- localBranch: {
39615
- name: headRef,
39616
- status: "failed",
39617
- error: e instanceof Error ? e.message : String(e)
39618
- }
39619
- };
39807
+ if (foreignCwd && !foreignCheckout) {
39808
+ localCleanup = { branch: headRef, localBranch: { name: headRef, status: "not-attempted", reason: "skipped-foreign-cwd" } };
39809
+ } else {
39810
+ try {
39811
+ localCleanup = await cleanupPrMergeLocalBranch(headRef, {
39812
+ beforeWorktrees,
39813
+ startingPath,
39814
+ baseRef,
39815
+ primaryRoot,
39816
+ preserveWorktree: o.preserveWorktree,
39817
+ gcAcknowledged: o.gc,
39818
+ expectedHeadOid: headRefOid,
39819
+ pathExists: (p) => (0, import_node_fs42.existsSync)(p),
39820
+ // #5899: pin cleanup git calls to the main checkout the task worktree this process may be
39821
+ // standing in is removed mid-cleanup, so a cwd-relative invocation fails with
39822
+ // 'fatal: not a git repository' and leaves a spurious partial-cleanup exit.
39823
+ execGit: async (args) => (await execFileP("git", cleanupGitArgs(primaryRoot, args), { timeout: GIT_TIMEOUT_MS })).stdout
39824
+ });
39825
+ } catch (e) {
39826
+ localCleanup = {
39827
+ branch: headRef,
39828
+ localBranch: {
39829
+ name: headRef,
39830
+ status: "failed",
39831
+ error: e instanceof Error ? e.message : String(e)
39832
+ }
39833
+ };
39834
+ }
39620
39835
  }
39621
39836
  const remoteBranch = await deleteMergedRemoteBranch({
39622
39837
  branch: headRef,
@@ -39627,7 +39842,9 @@ ${list}`);
39627
39842
  // #5899: this leg runs after worktree teardown — anchor it to the main checkout so a cwd inside
39628
39843
  // the removed worktree cannot turn the delete into 'fatal: not a git repository' + manual remediation.
39629
39844
  execGit: async (args) => (await execFileP("git", cleanupGitArgs(primaryRoot, args), { timeout: GIT_TIMEOUT_MS })).stdout,
39630
- branchExists: (b) => remoteBranchExists2(b, { cwd: primaryRoot })
39845
+ branchExists: (b) => remoteBranchExists2(b, { cwd: primaryRoot, remote }),
39846
+ // #6148: aim the delete and its proof at `--repo`, not the cwd checkout's origin.
39847
+ remote
39631
39848
  });
39632
39849
  const worktree = localCleanup?.worktree;
39633
39850
  const worktreePartial = isPrMergeWorktreePartial(localCleanup) && worktree?.path ? {
@@ -39690,6 +39907,7 @@ ${list}`);
39690
39907
  ...methodField ? { method: methodField } : {},
39691
39908
  remoteBranch,
39692
39909
  housekeeping,
39910
+ ...foreignCwd ? { foreignCwd: true, ...foreignCheckout ? { foreignCheckout } : {} } : {},
39693
39911
  ...partialCleanup.length ? { cleanupStatus: "partial", partialCleanup } : {},
39694
39912
  ...localCleanup?.worktree ? { worktree: localCleanup.worktree } : {},
39695
39913
  ...localCleanup?.localBranch ? { localBranch: localCleanup.localBranch } : {},
@@ -40599,8 +40817,12 @@ function registerDeveloperCommands(program3) {
40599
40817
  registerSchedulesCommands(program3);
40600
40818
  registerSchedulesLiftCommand(program3);
40601
40819
  const docs = program3.command("docs").description("generated docs surfaces \u2014 the routing index (org knowledge layer)");
40602
- docs.command("index").description("regenerate docs/index.md from the docs/ tree (--write, the default) or fail on drift (--check) \u2014 the generated routing index, never hand-maintained").option("--check", "compare against the committed docs/index.md and exit 1 on drift; never write").option("--write", "regenerate docs/index.md when it has drifted (the default)").action(async (o) => {
40820
+ docs.command("index").description("regenerate docs/index.md from the docs/ tree (--write, the default) or fail on drift (--check) \u2014 the generated routing index, never hand-maintained").option("--check", "compare against the committed docs/index.md and exit 1 on drift; never write").option("--write", "regenerate docs/index.md when it has drifted (the default)").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 docs index reads the current checkout").hideHelp()).action(async (o) => {
40603
40821
  try {
40822
+ if (o.repo) {
40823
+ const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), `mmi-cli oracle docs index ${o.check ? "--check" : "--write"}`, "docs index");
40824
+ if (!guard.ok) return failGraceful(guard.message);
40825
+ }
40604
40826
  const root = await repoRoot();
40605
40827
  const result = docsIndex(createDocsIndexDeps(root), { check: Boolean(o.check) });
40606
40828
  if (o.check) {
@@ -40615,8 +40837,12 @@ function registerDeveloperCommands(program3) {
40615
40837
  await failGraceful(e.message);
40616
40838
  }
40617
40839
  });
40618
- docs.command("refs").description("deterministic doc reference gate: every backticked repo path, relative .md link, `mmi-cli` command ref, and `<!-- pinned by \u2014 -->` comment across docs/** + README.md + architecture.md must resolve, or exit 1 (fleet-portable port of the Hub gate scripts/check-doc-refs.mjs, #3339)").option("--json", "machine-readable findings list: { ok, docCount, findings[], warnings[] }").action(async (o) => {
40840
+ docs.command("refs").description("deterministic doc reference gate: every backticked repo path, relative .md link, `mmi-cli` command ref, and `<!-- pinned by \u2014 -->` comment across docs/** + README.md + architecture.md must resolve, or exit 1 (#3339)").option("--json", "machine-readable findings list: { ok, docCount, findings[], warnings[] }").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 docs refs reads the current checkout").hideHelp()).action(async (o) => {
40619
40841
  try {
40842
+ if (o.repo) {
40843
+ const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), `mmi-cli oracle docs refs${o.json ? " --json" : ""}`, "docs refs");
40844
+ if (!guard.ok) return failGraceful(guard.message);
40845
+ }
40620
40846
  const root = await repoRoot();
40621
40847
  const commandPaths = new Set(
40622
40848
  buildCommandManifest(program3).index.map((entry) => entry.path)
@@ -40642,8 +40868,12 @@ function registerDeveloperCommands(program3) {
40642
40868
  }
40643
40869
  });
40644
40870
  const spawnCmd = program3.command("spawn").description("this repo's process-spawn contract \u2014 every child process must be unable to pop a console window");
40645
- spawnCmd.command("policy").description("enforce the windowsHide contract across this repo's tracked source: a child_process call must set windowsHide, or take its options from a named constant, a type annotation, or a forwarded caller bag that does. Waive a call the scan cannot classify with a `// windows-hide-exempt: <reason>` comment on the line above it (#3979)").option("--json", "machine-readable result: { ok, scannedCount, findings[] }").action(async (o) => {
40871
+ spawnCmd.command("policy").description("enforce the windowsHide contract across this repo's tracked source: a child_process call must set windowsHide, or take its options from a named constant, a type annotation, or a forwarded caller bag that does. Waive a call the scan cannot classify with a `// windows-hide-exempt: <reason>` comment on the line above it (#3979)").option("--json", "machine-readable result: { ok, scannedCount, findings[] }").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 spawn policy reads the current checkout").hideHelp()).action(async (o) => {
40646
40872
  try {
40873
+ if (o.repo) {
40874
+ const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), `mmi-cli spawn policy${o.json ? " --json" : ""}`, "spawn policy");
40875
+ if (!guard.ok) return failGraceful(guard.message);
40876
+ }
40647
40877
  const root = await repoRoot();
40648
40878
  const result = runSpawnPolicy(root);
40649
40879
  if (o.json) {
@@ -40665,8 +40895,13 @@ function registerDeveloperCommands(program3) {
40665
40895
  }
40666
40896
  });
40667
40897
  const tests = program3.command("tests").description("a repo's test-policy.json \u2014 the opt-in test contract and its enforcement");
40668
- tests.command("policy").description("enforce this repo's test-policy.json against the diff: a mandatory-zone change must carry a test, an unrequested new test file is refused, a `protected` test file may not be deleted or renamed away, and a `protected` entry naming a missing file is refused. Override any of them with a `Test-Policy-Override: <reason>` commit trailer (#3605)").option("--json", "machine-readable result: { ok, base, policySource: { ref, sha, path }, root, changedCount, mandatoryCount, matchedMandatoryCount, matchedMandatoryGlobs, testCommandsAllowed, commandClasses, findings[] }").option("--base <ref>", "comparison base (default: TEST_POLICY_BASE, then origin/development, then origin/main)").option("--policy-ref <commit>", "load only test-policy.json from the exact fetched origin/development commit; valid only with --base origin/main").action(async (o) => {
40898
+ tests.command("policy").description("enforce this repo's test-policy.json against the diff: a mandatory-zone change must carry a test, an unrequested new test file is refused, a `protected` test file may not be deleted or renamed away, and a `protected` entry naming a missing file is refused. Override any of them with a `Test-Policy-Override: <reason>` commit trailer (#3605)").option("--json", "machine-readable result: { ok, base, policySource: { ref, sha, path }, root, changedCount, mandatoryCount, matchedMandatoryCount, matchedMandatoryGlobs, testCommandsAllowed, commandClasses, findings[] }").option("--base <ref>", "comparison base (default: TEST_POLICY_BASE, then origin/development, then origin/main)").option("--policy-ref <commit>", "load test-policy.json from the exact fetched origin/development commit and audit its protected/satisfiedBy paths against that commit's tree, not this checkout; valid only with --base origin/main").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 tests policy reads the current checkout").hideHelp()).action(async (o) => {
40669
40899
  try {
40900
+ if (o.repo) {
40901
+ const rerun = `mmi-cli tests policy${o.base ? ` --base ${o.base}` : ""}${o.policyRef ? ` --policy-ref ${o.policyRef}` : ""}${o.json ? " --json" : ""}`;
40902
+ const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), rerun, "tests policy");
40903
+ if (!guard.ok) return failGraceful(guard.message);
40904
+ }
40670
40905
  const root = await repoRoot();
40671
40906
  const result = runTestPolicy(root, { base: o.base, policyRef: o.policyRef });
40672
40907
  if (o.json) {
@@ -40696,8 +40931,12 @@ function registerDeveloperCommands(program3) {
40696
40931
  }
40697
40932
  });
40698
40933
  const distCmd = program3.command("dist").description("this repo's committed dist/BOM drift receipt \u2014 whether cli/dist, updater/dist and distribution-bom.json still match a fresh build of source");
40699
- distCmd.command("status").description("rebuild every committed dist artifact to a temp dir and report committed vs rebuilt-expected sha256 plus the BOM's recorded dist identities \u2014 a visible, non-blocking receipt. Development checkouts may lag source until the release fold; drift NEVER fails the run, and nothing is refreshed for you (#5576)").option("--json", "machine-readable receipt: { ok, staleCount, artifacts[], bom, summary } (full hashes; drift still exits 0)").action(async (o) => {
40934
+ distCmd.command("status").description("rebuild every committed dist artifact to a temp dir and report committed vs rebuilt-expected sha256 plus the BOM's recorded dist identities \u2014 a visible, non-blocking receipt. Development checkouts may lag source until the release fold; drift NEVER fails the run, and nothing is refreshed for you (#5576)").option("--json", "machine-readable receipt: { ok, staleCount, artifacts[], bom, summary } (full hashes; drift still exits 0)").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 dist status reads the current checkout").hideHelp()).action(async (o) => {
40700
40935
  try {
40936
+ if (o.repo) {
40937
+ const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), `mmi-cli dist status${o.json ? " --json" : ""}`, "dist status");
40938
+ if (!guard.ok) return failGraceful(guard.message);
40939
+ }
40701
40940
  const root = await repoRoot();
40702
40941
  const receipt = runDistStatus(root);
40703
40942
  if (o.json) {
@@ -40992,7 +41231,7 @@ async function resolveHotfixDeployModel(deps, ctx) {
40992
41231
  }
40993
41232
  async function hotfixPreflight(deps, ctx, verb, targetTag) {
40994
41233
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
40995
- const deployModel = await preflight(deps, ctx, "main", meta);
41234
+ const deployModel = await preflight(deps, ctx, "main", meta, "hotfix");
40996
41235
  const root = await hotfixCheckoutRoot(deps);
40997
41236
  const begin = await beginReleaseLedger(deps, root, targetTag);
40998
41237
  if (!begin.ok) throw new Error(`hotfix ${verb} refused before any mutation: ${begin.error}`);
@@ -41351,6 +41590,16 @@ function hotfixDispatchFromRuns(runs, note) {
41351
41590
  const tenantDeploy2 = runs.find((r) => r.workflow === "tenant-deploy.yml");
41352
41591
  return { note, deployStatus, workflowRuns, ...tenantDeploy2?.runId != null ? { runId: tenantDeploy2.runId } : {}, ...tenantDeploy2?.url ? { runUrl: tenantDeploy2.url } : {} };
41353
41592
  }
41593
+ async function appendHotfixGatewayRun(deps, repo, tag, sha, runs, note) {
41594
+ const input = hotfixDispatchFromRuns(runs, note);
41595
+ const gateway = await appendJervGatewayReleaseDeploy(deps, repo, tag, sha, input);
41596
+ if (gateway === input) return note;
41597
+ const row = gateway.workflowRuns?.at(-1);
41598
+ if (row?.workflow === "jerv-gateway") {
41599
+ runs.push({ workflow: row.workflow, ...row.runUrlNote ? { runUrlNote: row.runUrlNote } : {}, conclusion: row.conclusion });
41600
+ }
41601
+ return gateway.note;
41602
+ }
41354
41603
  function hotfixPhaseInputsFromRuns(deployModel, runs, note, publishDispatch, publishRequired, opts) {
41355
41604
  const dispatch = hotfixDispatchFromRuns(runs, note);
41356
41605
  return {
@@ -41594,6 +41843,7 @@ ${decision.note}`);
41594
41843
  } else {
41595
41844
  deployNote = `no hotfix deploy dispatch for deployModel=${deployModel} \u2014 prod deploy is repo-specific`;
41596
41845
  }
41846
+ deployNote = await appendHotfixGatewayRun(deps, ctx.repo, tag, mergedSha, runs, deployNote);
41597
41847
  const ledgerInputs = hotfixPhaseInputsFromRuns(
41598
41848
  deployModel,
41599
41849
  runs,
@@ -41786,6 +42036,9 @@ function hotfixStatusRunsFromLedger(ledger) {
41786
42036
  workflow: record.workflow ?? phase,
41787
42037
  ...record.runId != null ? { runId: record.runId } : {},
41788
42038
  ...record.runUrl ? { url: record.runUrl } : {},
42039
+ // #912: the operator-host Gateway leg carries no run id by design — without its own note the
42040
+ // renderer's missing-URL fallback reads it as 'run absent or unreadable'.
42041
+ ...record.workflow === "jerv-gateway" ? { runUrlNote: JERV_GATEWAY_RUN_URL_NOTE } : {},
41789
42042
  conclusion: record.state === "complete" ? "success" : record.state === "failed" ? "failure" : "pending"
41790
42043
  }];
41791
42044
  });
@@ -42243,8 +42496,8 @@ function trainApplyDeps() {
42243
42496
  };
42244
42497
  }
42245
42498
  function formatWorkflowRun(r) {
42246
- const ref = r.runUrl ?? (r.runId != null ? String(r.runId) : "unresolved");
42247
- return `${r.workflow} ${ref} ${r.conclusion.toUpperCase()}`;
42499
+ const evidenced = workflowRunWithEvidence(r);
42500
+ return `${evidenced.workflow} ${evidenced.runUrl ?? evidenced.runUrlNote} ${evidenced.conclusion.toUpperCase()}`;
42248
42501
  }
42249
42502
  function renderDeployLine(d) {
42250
42503
  const parts = [d.dispatch];
@@ -42266,6 +42519,7 @@ function renderReleaseResume(r) {
42266
42519
  if (r.devRollForward) lines2.push(` development: ${r.devRollForward.note}`);
42267
42520
  if (r.rcAlignment) lines2.push(` rc: ${r.rcAlignment.note}`);
42268
42521
  if (r.checkout) lines2.push(` checkout: ${r.checkout.note}`);
42522
+ if (r.projectInfoSync) lines2.push(` project info: ${r.projectInfoSync.note}`);
42269
42523
  if (r.ledger) lines2.push(...formatReleaseLedgerReport(r.ledger).map((l, i) => i === 0 ? ` ${l}` : ` ${l}`));
42270
42524
  return lines2.join("\n");
42271
42525
  }
@@ -42293,7 +42547,8 @@ function releaseFollowUpLegs(result, projectInfoSync) {
42293
42547
  const legs = [
42294
42548
  {
42295
42549
  leg: "project-info",
42296
- status: projectInfoSync && "error" in projectInfoSync ? "failed" : "success",
42550
+ // #6180: a transient Hub read outage is PENDING (rerun the manual verb), never a failed follow-up.
42551
+ status: projectInfoSync && "error" in projectInfoSync ? projectInfoSync.pending ? "pending" : "failed" : "success",
42297
42552
  ...projectInfoSync && "error" in projectInfoSync ? { error: projectInfoSync.error } : {}
42298
42553
  }
42299
42554
  ];
@@ -42332,6 +42587,24 @@ function releaseFollowUpLegs(result, projectInfoSync) {
42332
42587
  }
42333
42588
  return legs;
42334
42589
  }
42590
+ function hotfixFollowUpLegs(runs, foldPort, alignment, foldNote, deployNote) {
42591
+ const legs = runs.map((run) => ({
42592
+ leg: run.workflow,
42593
+ status: followUpLegStatus(run.conclusion === "failure" ? "failure" : run.conclusion === "success" ? "success" : "pending"),
42594
+ // #912 lane parity with releaseFollowUpLegs: the operator-host Gateway leg has no workflow run to
42595
+ // read, so its deploy note IS the error — a flat "workflow reported failure" names nothing.
42596
+ ...run.conclusion === "failure" ? { error: run.workflow === "jerv-gateway" && deployNote ? deployNote : "workflow reported failure" } : {}
42597
+ }));
42598
+ if (foldPort === "failure") {
42599
+ legs.push({ leg: "development-fold-port", status: "failed", error: foldNote ?? "development fold port failed" });
42600
+ }
42601
+ if (alignment === "unresolved") {
42602
+ legs.push({ leg: "development-fold-alignment", status: "pending" });
42603
+ } else if (alignment === "failure" && foldPort !== "failure") {
42604
+ legs.push({ leg: "development-fold-alignment", status: "failed", error: foldNote ?? "development fold alignment failed" });
42605
+ }
42606
+ return legs;
42607
+ }
42335
42608
  var JERV_POWERTOOLS_REPO = "mutmutco/Jerv-PowerTools";
42336
42609
  async function runPostReleaseJervDoctor(repo) {
42337
42610
  if (repo.toLowerCase() !== JERV_POWERTOOLS_REPO.toLowerCase()) return void 0;
@@ -42347,10 +42620,35 @@ async function runPostReleaseJervDoctor(repo) {
42347
42620
  };
42348
42621
  }
42349
42622
  }
42623
+ async function runProjectInfoSyncLeg(cb, repo, sleep2 = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms))) {
42624
+ for (let attempt = 0; ; attempt++) {
42625
+ try {
42626
+ return await cb(repo, true);
42627
+ } catch (e) {
42628
+ const error = e.message;
42629
+ if (!(e instanceof ProjectInfoReadUnavailableError)) return { applied: false, note: `FAILED \u2014 ${error}`, error };
42630
+ if (attempt === 0) {
42631
+ await sleep2(PROJECT_INFO_RETRY_MS);
42632
+ continue;
42633
+ }
42634
+ return {
42635
+ applied: false,
42636
+ pending: true,
42637
+ note: `PENDING \u2014 ${error}; rerun \`mmi-cli oracle org project sync-info ${repo} --apply\``,
42638
+ error
42639
+ };
42640
+ }
42641
+ }
42642
+ }
42643
+ var PROJECT_INFO_RETRY_MS = 3e3;
42644
+ function projectInfoOutcome(projectInfoSync) {
42645
+ if (!projectInfoSync || !("error" in projectInfoSync)) return "ok";
42646
+ return projectInfoSync.pending ? "unresolved" : "failure";
42647
+ }
42350
42648
  function buildReleaseVerdict(commandName, result, projectInfoSync) {
42351
42649
  const alignmentPending = result.devRollForward?.status === "pr-pending" || result.rcAlignment?.status === "pr-pending";
42352
42650
  const followUpStatus = deriveTrainFollowUpStatus({
42353
- projectInfo: projectInfoSync && "error" in projectInfoSync ? "failure" : "ok",
42651
+ projectInfo: projectInfoOutcome(projectInfoSync),
42354
42652
  deploy: deployFollowUpOutcome(result.deployStatus),
42355
42653
  rcRetirement: result.rcRetirement === "failed" ? result.rcRetirementCategory === "wait-timeout" ? "unresolved" : "failure" : "ok",
42356
42654
  alignment: alignmentPending ? "unresolved" : "ok",
@@ -42517,7 +42815,8 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42517
42815
  if (o.apply) return fail(`${commandName}: --resume and --apply are mutually exclusive \u2014 --apply cuts the NEXT version, --resume finishes the immutable tag already on origin`);
42518
42816
  try {
42519
42817
  if (commandName === "rcand") {
42520
- const result2 = await runRcandResume(trainApplyDeps(), { watch: o.watch });
42818
+ const raw2 = await runRcandResume(trainApplyDeps(), { watch: o.watch });
42819
+ const result2 = raw2.workflowRuns ? { ...raw2, workflowRuns: raw2.workflowRuns.map(workflowRunWithEvidence) } : raw2;
42521
42820
  emitTrainResult("rcand --resume", o.json ? JSON.stringify(result2, null, 2) : renderRcandResume(result2), o.out);
42522
42821
  applyTrainFollowUpExit(deriveTrainFollowUpStatus({
42523
42822
  projectInfo: "ok",
@@ -42528,9 +42827,19 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42528
42827
  }));
42529
42828
  return;
42530
42829
  }
42531
- const result = await runReleaseResume(trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile });
42532
- emitTrainResult("release --resume", o.json ? JSON.stringify(result, null, 2) : renderReleaseResume(result), o.out);
42533
- applyTrainFollowUpExit(resumeFollowUpOf(result.state));
42830
+ const raw = await runReleaseResume(trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile });
42831
+ const result = raw.dispatch?.workflowRuns ? { ...raw, dispatch: { ...raw.dispatch, workflowRuns: raw.dispatch.workflowRuns.map(workflowRunWithEvidence) } } : raw;
42832
+ const projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
42833
+ const resumed = { ...result, projectInfoSync };
42834
+ emitTrainResult("release --resume", o.json ? JSON.stringify(resumed, null, 2) : renderReleaseResume(resumed), o.out);
42835
+ const resumeStatus = resumeFollowUpOf(result.state);
42836
+ applyTrainFollowUpExit(deriveTrainFollowUpStatus({
42837
+ projectInfo: projectInfoOutcome(projectInfoSync),
42838
+ deploy: "ok",
42839
+ rcRetirement: "ok",
42840
+ alignment: resumeStatus === "failed" ? "failure" : resumeStatus === "pending" ? "unresolved" : "ok",
42841
+ foldPort: "ok"
42842
+ }));
42534
42843
  return;
42535
42844
  } catch (e) {
42536
42845
  applyTrainFollowUpExit("failed");
@@ -42563,16 +42872,12 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42563
42872
  if (o.apply) {
42564
42873
  try {
42565
42874
  const ack = (o.ack ?? "").split(",").map((s) => s.trim()).filter(Boolean);
42566
- const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile, ack, dev: o.dev });
42875
+ const raw = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile, ack, dev: o.dev });
42876
+ const result = raw.workflowRuns ? { ...raw, workflowRuns: raw.workflowRuns.map(workflowRunWithEvidence) } : raw;
42567
42877
  let projectInfoSync;
42568
42878
  const postReleaseJervDoctor = commandName === "release" ? await runPostReleaseJervDoctor(result.repo) : void 0;
42569
42879
  if (commandName === "release") {
42570
- try {
42571
- projectInfoSync = await runProjectInfoSyncCallback(result.repo, true);
42572
- } catch (e) {
42573
- const error = e.message;
42574
- projectInfoSync = { applied: false, note: `FAILED \u2014 ${error}`, error };
42575
- }
42880
+ projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
42576
42881
  }
42577
42882
  const { followUpStatus, releaseVerdict } = buildReleaseVerdict(commandName, result, projectInfoSync);
42578
42883
  const reported = {
@@ -42625,6 +42930,13 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42625
42930
  function renderHotfixStart(r) {
42626
42931
  return [`mmi-cli devops hotfix start: ${r.tag} (${r.branch}, from ${r.source})${r.reused ? " [reused]" : ""}`, ...r.notes.map((n) => ` - ${n}`)].join("\n");
42627
42932
  }
42933
+ function hotfixRunWithEvidence(run) {
42934
+ return run.url || run.runUrlNote ? run : { ...run, runUrlNote: missingRunUrlNote(run.runId) };
42935
+ }
42936
+ function formatHotfixRun(run) {
42937
+ const evidenced = hotfixRunWithEvidence(run);
42938
+ return ` - ${evidenced.workflow}: ${evidenced.conclusion}${evidenced.conclusion === "failure" ? " \u2014 error: workflow reported failure" : ""} (${evidenced.url ?? evidenced.runUrlNote})`;
42939
+ }
42628
42940
  function renderHotfixRelease(r) {
42629
42941
  return [
42630
42942
  `mmi-cli devops hotfix release: ${r.tag} at ${r.mergedSha.slice(0, 7)} on ${r.repo} (deployModel=${r.deployModel})`,
@@ -42632,7 +42944,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42632
42944
  ` - ${r.tagNote}`,
42633
42945
  ` - ${r.releaseNote}`,
42634
42946
  ` - deploy: ${r.deployNote}`,
42635
- ...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.conclusion === "failure" ? " \u2014 error: workflow reported failure" : ""}${run.url ? ` (${run.url})` : ""}`),
42947
+ ...r.runs.map(formatHotfixRun),
42636
42948
  ` - ${r.verifyNote}`,
42637
42949
  ...r.announceNote ? [` - announce: ${r.announceNote}`] : [],
42638
42950
  ` - fold: ${r.foldNote}`,
@@ -42649,7 +42961,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42649
42961
  return [
42650
42962
  `mmi-cli devops hotfix status: ${r.tag} on ${r.repo} \u2014 ${r.state}`,
42651
42963
  ` - branch: ${r.branchExists ? "pushed" : "absent"} \u2014 PR: ${r.pr ? `#${r.pr.number} ${r.pr.state}` : "none"} \u2014 tag: ${r.tagPushed ? "pushed" : "absent"} \u2014 Release: ${r.releaseExists ? "exists" : "absent"}`,
42652
- ...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.conclusion === "failure" ? " \u2014 error: workflow reported failure" : ""}${run.url ? ` (${run.url})` : ""}`),
42964
+ ...r.runs.map(formatHotfixRun),
42653
42965
  ` - development fold alignment: ${r.alignmentStatus}${r.alignmentNote ? ` \u2014 ${r.alignmentNote}` : ""}`,
42654
42966
  ` - npm @mutmutco/cli: ${r.npmVersion}`,
42655
42967
  ` - next: ${r.next}`,
@@ -42662,36 +42974,23 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42662
42974
  if (conclusion === "failure") return "failure";
42663
42975
  return "unresolved";
42664
42976
  }
42665
- function hotfixFollowUpLegs(runs, foldPort, alignment, foldNote) {
42666
- const legs = runs.map((run) => ({
42667
- leg: run.workflow,
42668
- status: followUpLegStatus(run.conclusion === "failure" ? "failure" : run.conclusion === "success" ? "success" : "pending"),
42669
- ...run.conclusion === "failure" ? { error: "workflow reported failure" } : {}
42670
- }));
42671
- if (foldPort === "failure") {
42672
- legs.push({ leg: "development-fold-port", status: "failed", error: foldNote ?? "development fold port failed" });
42673
- }
42674
- if (alignment === "unresolved") {
42675
- legs.push({ leg: "development-fold-alignment", status: "pending" });
42676
- } else if (alignment === "failure" && foldPort !== "failure") {
42677
- legs.push({ leg: "development-fold-alignment", status: "failed", error: foldNote ?? "development fold alignment failed" });
42678
- }
42679
- return legs;
42680
- }
42681
42977
  async function runHotfixSub(sub, body, o, render) {
42682
42978
  try {
42683
42979
  await requireFreshTrainCli("hotfix");
42684
- const result = await body();
42980
+ const raw = await body();
42981
+ const rawRuns = raw.runs;
42982
+ const result = rawRuns ? Object.assign({}, raw, { runs: rawRuns.map(hotfixRunWithEvidence) }) : raw;
42685
42983
  const runs = result.runs;
42686
42984
  const foldPort = result.foldStatus ?? "ok";
42687
42985
  const ledger = result.ledger;
42688
42986
  const alignment = result.alignmentStatus ?? ledgerAlignmentFollowUpOutcome(ledger);
42689
42987
  const projectInfoSync = result.projectInfoSync;
42690
- const legs = runs ? hotfixFollowUpLegs(runs, foldPort, alignment, result.foldNote) : void 0;
42988
+ const deployNote = result.deployNote ?? ledger?.phases.deploy.error ?? ledger?.phases.deploy.note;
42989
+ const legs = runs ? hotfixFollowUpLegs(runs, foldPort, alignment, result.foldNote, deployNote) : void 0;
42691
42990
  const json = o.json || Boolean(hotfixCmd.opts().json);
42692
42991
  emitTrainResult(`hotfix ${sub}`, json ? JSON.stringify(legs ? Object.assign({}, result, { legs }) : result, null, 2) : render(result), o.out);
42693
42992
  if (runs) applyTrainFollowUpExit(deriveTrainFollowUpStatus({
42694
- projectInfo: projectInfoSync && "error" in projectInfoSync ? "failure" : "ok",
42993
+ projectInfo: projectInfoOutcome(projectInfoSync),
42695
42994
  deploy: reduceFollowUpOutcomes(runs.map((r) => hotfixRunOutcome(r.conclusion))),
42696
42995
  rcRetirement: "ok",
42697
42996
  alignment,
@@ -42715,13 +43014,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42715
43014
  hotfixCmd.command("release <version>").description("after the hotfix PR is merged + checks green: tag, GitHub Release, watch deploy/publish, verify distribution (idempotent)").option("--json", "machine-readable output").option("--announce-summary-file <path>", "agent-curated 3-6 line Hub Slack summary; required for a NEW MMI-Hub hotfix Release (#883/#3901/#6068)").option("--carries <pr#|sha[,pr#|sha...]>", "declared fix target(s) this hotfix must carry; each must be proven present before tagging (#3056)").option("--out <path>", "write the result to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5983/#6068)").action(async (version, o) => runHotfixSub("release", async () => {
42716
43015
  const result = await runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile, carries: o.carries ? [o.carries] : [] });
42717
43016
  const postReleaseJervDoctor = await runPostReleaseJervDoctor(result.repo);
42718
- let projectInfoSync;
42719
- try {
42720
- projectInfoSync = await runProjectInfoSyncCallback(result.repo, true);
42721
- } catch (e) {
42722
- const error = e.message;
42723
- projectInfoSync = { applied: false, note: `FAILED \u2014 ${error}`, error };
42724
- }
43017
+ const projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
42725
43018
  return { ...result, projectInfoSync, ...postReleaseJervDoctor ? { postReleaseJervDoctor } : {} };
42726
43019
  }, o, renderHotfixRelease));
42727
43020
  function hotfixStatusDeps() {
@@ -43302,7 +43595,7 @@ tenant.command("reconcile <owner/repo> <stage>").description("re-render this ten
43302
43595
  return failGraceful(`runtime tenant reconcile: ${e.message}`);
43303
43596
  }
43304
43597
  });
43305
- tenant.command("status <owner/repo> <stage>").description("read tenant runtime readiness without dispatching tenant-control: DEPLOY row, last deploy run, public URL probe, and TLS/Caddy/Cloudflare hints").action(async (repo, stage) => {
43598
+ tenant.command("status <owner/repo> <stage>").description("read tenant runtime readiness without dispatching tenant-control: DEPLOY row, last deploy run, public URL probe, and TLS/Caddy/Cloudflare hints; an auth-walled root (401/403) is re-probed at /health and the `url` field names the endpoint that answered (#6137)").action(async (repo, stage) => {
43306
43599
  if (!["dev", "rc", "main"].includes(stage)) return fail("runtime tenant status: <stage> must be dev, rc, or main");
43307
43600
  const cfg = await loadConfig();
43308
43601
  const result = await buildTenantRuntimeStatusFor(repo, stage, cfg);
@@ -43312,7 +43605,8 @@ tenant.command("status <owner/repo> <stage>").description("read tenant runtime r
43312
43605
  tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the central tenant-deploy.yml for an already-promoted ref (no re-tag/merge); train-authority gated").option("--ref <ref>", "ref to deploy (defaults to the stage branch rc/main, or `development` for dev \u2014 the promoted/staging ref)").option("--watch", "block on the dispatched run and report its outcome (gh run watch --exit-status)").option("--json", "machine-readable output").action(async (repo, stage, o) => {
43313
43606
  if (stage !== "dev" && stage !== "rc" && stage !== "main") return fail("runtime tenant redeploy: <stage> must be dev, rc, or main");
43314
43607
  try {
43315
- const result = await runTenantRedeploy(trainApplyDeps(), { repo, stage, ref: o.ref, watch: o.watch });
43608
+ const raw = await runTenantRedeploy(trainApplyDeps(), { repo, stage, ref: o.ref, watch: o.watch });
43609
+ const result = raw.workflowRuns ? { ...raw, workflowRuns: raw.workflowRuns.map(workflowRunWithEvidence) } : raw;
43316
43610
  return printLine(o.json ? JSON.stringify(result, null, 2) : renderTenantRedeploy(result));
43317
43611
  } catch (e) {
43318
43612
  return failGraceful(`runtime tenant redeploy: ${e.message}`);
@@ -43343,26 +43637,13 @@ tenant.command("sweep-rc").description("discover (and optionally retire) running
43343
43637
  return failGraceful(`runtime tenant sweep-rc: ${e.message}`);
43344
43638
  }
43345
43639
  });
43346
- async function probeHttpBounded2(url, timeoutMs = 5e3) {
43347
- const controller = new AbortController();
43348
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
43349
- timeout.unref?.();
43350
- try {
43351
- const res = await fetch(url, { method: "GET", signal: controller.signal });
43352
- return { ok: res.ok, status: res.status };
43353
- } catch (e) {
43354
- return { ok: false, error: e.message };
43355
- } finally {
43356
- clearTimeout(timeout);
43357
- }
43358
- }
43359
43640
  async function buildTenantRuntimeStatusFor(target, stage, cfg) {
43360
43641
  const slug = slugOf(target);
43361
43642
  const reg = registryClientDeps(cfg);
43362
43643
  const facts = await fetchDeployFactsBySlug(slug, reg);
43363
43644
  const deploy = facts?.stages[stage] ?? null;
43364
43645
  const publicUrl = publicUrlFromDeployFact(deploy);
43365
- const publicProbe = publicUrl ? await probeHttpBounded2(publicUrl) : void 0;
43646
+ const publicProbe = publicUrl ? await probePublicHealth(publicUrl) : void 0;
43366
43647
  return buildTenantRuntimeStatus({
43367
43648
  repo: target,
43368
43649
  slug,
@@ -43390,9 +43671,9 @@ async function runProjectInfoSync(target, apply) {
43390
43671
  fetchProjectBySlugChecked(slugOf(targetRepo2), registry2),
43391
43672
  fetchProjectsList(registry2)
43392
43673
  ]);
43393
- if (!read.ok) throw new Error(`org project sync-info: Hub registry read failed (${read.error})`);
43674
+ if (!read.ok) throw new ProjectInfoReadUnavailableError(`org project sync-info: Hub registry read failed (${read.error})`);
43394
43675
  if (!read.project) throw new Error(`org project sync-info: no registry META for ${targetRepo2}`);
43395
- if (!projects) throw new Error("org project sync-info: Hub project list unavailable");
43676
+ if (!projects) throw new ProjectInfoReadUnavailableError("org project sync-info: Hub project list unavailable");
43396
43677
  if (apply) {
43397
43678
  const authority = await fetchTrainAuthority(targetRepo2, registry2);
43398
43679
  if (!authority.ok) throw new Error(`org project sync-info: train authority unverified (${authority.error})`);