@mutmutco/cli 4.3.7 → 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 +503 -200
  2. package/package.json +1 -1
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
  }
@@ -9435,7 +9449,7 @@ function invalidReference(ref) {
9435
9449
  return new Error(`invalid reference "${ref}" \u2014 expected ${ISSUE_REF_SHAPES}`);
9436
9450
  }
9437
9451
  function parseIssueRef(ref, expectedRepo) {
9438
- const trimmed = ref.trim();
9452
+ const trimmed = String(ref).trim();
9439
9453
  const url = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)$/i);
9440
9454
  const qualified = trimmed.match(/^([^/\s#]+\/[^/\s#]+)#(\d+)$/);
9441
9455
  const bare = trimmed.match(/^#?(\d+)$/);
@@ -11061,14 +11075,7 @@ function writableOrUnknown(writable) {
11061
11075
  function renderBoardSource() {
11062
11076
  return "source: live";
11063
11077
  }
11064
- async function readBoard(options, deps = {}) {
11065
- const cfg = resolveBoardConfig(options.config);
11066
- const client = deps.client ?? defaultGitHubClient();
11067
- let collected;
11068
- let writable;
11069
- let pullRequests;
11070
- let github;
11071
- let snapshotFallback;
11078
+ async function collectBoardPreferringSnapshot(cfg, options, deps) {
11072
11079
  const attempt = deps.snapshot ? await fetchHubBoardSnapshot(
11073
11080
  {
11074
11081
  owner: cfg.projectOwner,
@@ -11094,19 +11101,37 @@ async function readBoard(options, deps = {}) {
11094
11101
  warnings.push(message2);
11095
11102
  partial = true;
11096
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) {
11097
11131
  for (const unreadable of snapshot.unreadableRepos) {
11098
- warnings.push(`partial claimable access read: ${unreadable.repo}: viewer write access UNREAD (${unreadable.error})`);
11099
- partial = true;
11132
+ collected.warnings.push(`partial claimable access read: ${unreadable.repo}: viewer write access UNREAD (${unreadable.error})`);
11133
+ collected.partial = true;
11100
11134
  }
11101
- collected = {
11102
- items: read.items,
11103
- viewer: snapshot.viewer,
11104
- repo: currentRepo,
11105
- projectId: snapshot.project.id,
11106
- projectTitle: snapshot.project.title,
11107
- warnings,
11108
- partial
11109
- };
11110
11135
  writable = {
11111
11136
  repos: new Set(snapshot.writableRepos.map((repo) => repo.toLowerCase())),
11112
11137
  unknown: new Set(snapshot.unreadableRepos.map((entry) => entry.repo.toLowerCase()))
@@ -11114,11 +11139,6 @@ async function readBoard(options, deps = {}) {
11114
11139
  pullRequests = snapshot.pullRequests;
11115
11140
  github = snapshot.github;
11116
11141
  } else {
11117
- if (attempt?.state === "unavailable") snapshotFallback = attempt.reason;
11118
- collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
11119
- if (snapshotFallback) {
11120
- collected.warnings.push(`Hub board snapshot unavailable (${snapshotFallback}) \u2014 served by the direct user-auth read (emergency fallback)`);
11121
- }
11122
11142
  const probed = await resolveWritableReposForClaimables(collected.items, client);
11123
11143
  collected.warnings.push(...probed.warnings);
11124
11144
  collected.partial = collected.partial || probed.partial;
@@ -12057,21 +12077,45 @@ async function moveBoardItem(options, deps = {}) {
12057
12077
  partial: false
12058
12078
  };
12059
12079
  }
12060
- 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) {
12061
12103
  const cfg = resolveBoardConfig(options.config);
12062
12104
  const client = deps.client ?? defaultGitHubClient();
12063
12105
  const board = { owner: cfg.projectOwner, number: cfg.projectNumber };
12106
+ const unscanned = [];
12064
12107
  for (const selector of selectors) {
12065
12108
  try {
12066
12109
  findBoardItem(collected.items, selector, board);
12067
12110
  } catch {
12068
12111
  const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
12069
- if (fallback) collected.items.push(fallback);
12112
+ if (fallback) {
12113
+ collected.items.push(fallback);
12114
+ unscanned.push(fallback);
12115
+ }
12070
12116
  }
12071
12117
  }
12072
- const writable = await resolveWritableReposForClaimables(collected.items, client);
12073
- collected.warnings.push(...writable.warnings);
12074
- collected.partial = collected.partial || writable.partial;
12118
+ const writable = await resolveClaimWritable(collected, client, snapshot, unscanned);
12075
12119
  const report = {
12076
12120
  project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
12077
12121
  viewer: collected.viewer,
@@ -12209,14 +12253,15 @@ async function claimOneBoardItem(ctx, selector, options) {
12209
12253
  }
12210
12254
  async function claimBoardIssue(options, deps = {}) {
12211
12255
  const cfg = resolveBoardConfig(options.config);
12212
- const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
12256
+ const { collected, snapshot } = await collectBoardPreferringSnapshot(cfg, options, deps);
12213
12257
  const selector = parseIssueSelector(options.selector, collected.repo, options.repo);
12214
- const ctx = await prepareClaimContext(options, [selector], deps, collected);
12215
- 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;
12216
12261
  }
12217
12262
  async function claimBoardIssues(options, deps = {}) {
12218
12263
  const cfg = resolveBoardConfig(options.config);
12219
- const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial, activeOnly: true }, deps);
12264
+ const { collected, snapshot } = await collectBoardPreferringSnapshot(cfg, options, deps);
12220
12265
  const selectors = [];
12221
12266
  const seen = /* @__PURE__ */ new Set();
12222
12267
  for (const raw of options.selectors) {
@@ -12226,7 +12271,7 @@ async function claimBoardIssues(options, deps = {}) {
12226
12271
  seen.add(key);
12227
12272
  selectors.push(selector);
12228
12273
  }
12229
- const ctx = await prepareClaimContext(options, selectors, deps, collected);
12274
+ const ctx = await prepareClaimContext(options, selectors, deps, collected, snapshot);
12230
12275
  const results = new Array(selectors.length);
12231
12276
  let next = 0;
12232
12277
  const worker = async () => {
@@ -12247,7 +12292,9 @@ async function claimBoardIssues(options, deps = {}) {
12247
12292
  viewer: ctx.report.viewer,
12248
12293
  repo: ctx.report.repo,
12249
12294
  results,
12250
- 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] } : {}
12251
12298
  };
12252
12299
  }
12253
12300
  async function moveBoardIssues(options, deps = {}) {
@@ -15403,10 +15450,10 @@ var rollout_plan_default = {
15403
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)."
15404
15451
  },
15405
15452
  baseline: {
15406
- version: "4.3.7",
15407
- tag: "v4.3.7",
15408
- commit: "d7f9efaf3e66",
15409
- npm: "@mutmutco/cli@4.3.7"
15453
+ version: "4.3.8",
15454
+ tag: "v4.3.8",
15455
+ commit: "b109b86b97d1",
15456
+ npm: "@mutmutco/cli@4.3.8"
15410
15457
  },
15411
15458
  exitCriterion: "fleet-n-of-n",
15412
15459
  hubOnlyShortcut: "forbidden",
@@ -15423,14 +15470,14 @@ var rollout_plan_default = {
15423
15470
  repo: "mutmutco/mmi-hub",
15424
15471
  role: "canary",
15425
15472
  schedule: "train",
15426
- v3Target: "v4.3.7"
15473
+ v3Target: "v4.3.8"
15427
15474
  }
15428
15475
  ],
15429
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.",
15430
15477
  rollback: {
15431
15478
  independent: true,
15432
- mechanism: "npm dist-tag latest -> 4.3.7 and redeploy the Hub Lambda from tag v4.3.7 (d7f9efaf3e66); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15433
- v3Target: "v4.3.7 (@mutmutco/cli@4.3.7, tag commit d7f9efaf3e66 \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)"
15434
15481
  }
15435
15482
  },
15436
15483
  {
@@ -18286,7 +18333,8 @@ async function appendJervGatewayReleaseDeploy(deps, repo, tag, tagSha, dispatch)
18286
18333
  if (dispatch.deployStatus !== "success") {
18287
18334
  return {
18288
18335
  ...dispatch,
18289
- 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" }]
18290
18338
  };
18291
18339
  }
18292
18340
  try {
@@ -20599,8 +20647,11 @@ function deployPhaseInput(model, dispatch, opts = {}) {
20599
20647
  note: deployRun ? `${deployRun.workflow} release run` : dispatch.note
20600
20648
  };
20601
20649
  }
20602
- 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 };
20603
20653
  return { state: "skipped", ...sha ? { sha } : {}, note: "registry-publish deploys by publishing \u2014 the release-event publish.yml run is the deploy plane" };
20654
+ }
20604
20655
  case "tenant-container":
20605
20656
  case "solo-container":
20606
20657
  case "static-cdn":
@@ -20661,6 +20712,7 @@ function releasePhaseInputsFromDispatch(model, dispatch, publishDispatch, publis
20661
20712
  function phaseInputsFromRunRows(model, rows, tagSha, opts = {}) {
20662
20713
  const deployRun = rows.find((r) => r.workflow === "deploy.yml");
20663
20714
  const publishRun = rows.find((r) => r.workflow === "publish.yml");
20715
+ const hostRun = rows.find((r) => r.workflow === "jerv-gateway");
20664
20716
  switch (model) {
20665
20717
  case "hub-serverless":
20666
20718
  return {
@@ -20679,7 +20731,9 @@ function phaseInputsFromRunRows(model, rows, tagSha, opts = {}) {
20679
20731
  };
20680
20732
  case "registry-publish":
20681
20733
  return {
20682
- 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" },
20683
20737
  publish: publishRun ? {
20684
20738
  state: runRowState(publishRun, "pending"),
20685
20739
  sha: tagSha,
@@ -21046,12 +21100,14 @@ async function selfConvergeTrainCli(input) {
21046
21100
  var TRAIN_LANES = ["release", "rcand", "hotfix"];
21047
21101
  var TROUBLESHOOTING_GUIDE = "docs/Guides/train-troubleshooting.md";
21048
21102
  var SCRATCH_BRANCH_GLOBS = ["train/check/*", "hotfix-fold/*--port-*"];
21103
+ var COMPOSE_GUARD_HARD_FAIL = /^secrets preflight: .*noEnvFile is (not )?true.*$/m;
21049
21104
  function readLocalWorkflowsDefault(root) {
21050
21105
  const dir = (0, import_node_path22.join)(root, ".github", "workflows");
21051
21106
  let names;
21052
21107
  try {
21053
21108
  names = (0, import_node_fs23.readdirSync)(dir);
21054
- } catch {
21109
+ } catch (e) {
21110
+ if (e.code === "ENOENT") return [];
21055
21111
  return null;
21056
21112
  }
21057
21113
  const files = [];
@@ -21281,12 +21337,12 @@ async function runTrainDoctor(input) {
21281
21337
  ...!hints.hasMainBranch ? ["main"] : [],
21282
21338
  ...!hints.hasRcBranch && track === "full" ? ["rc"] : []
21283
21339
  ];
21284
- 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` });
21285
21341
  }
21286
21342
  try {
21287
21343
  const required = await discoverRequiredCheckContexts(train, ctx, stage);
21288
21344
  if (required.length === 0) {
21289
- 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}\`` });
21290
21346
  } else {
21291
21347
  try {
21292
21348
  assertTagAddressableRequiredContexts({ readWorkflows: () => workflows }, required, repo);
@@ -21358,12 +21414,14 @@ async function runTrainDoctor(input) {
21358
21414
  }
21359
21415
  }
21360
21416
  try {
21361
- await train.runSelf(["secrets", "preflight", "--stage", stage, "--repo", repo]);
21417
+ await train.runSelf(["secrets", "preflight", "--stage", stage, "--repo", repo, ...lane === "hotfix" ? ["--lane", "hotfix"] : []]);
21362
21418
  } catch (e) {
21363
21419
  const err = e;
21364
21420
  const text = [typeof err.stdout === "string" ? err.stdout : "", typeof err.stderr === "string" ? err.stderr : "", message(e)].join("\n");
21365
21421
  if (/^missing /m.test(text)) {
21366
- 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)` });
21367
21425
  } else {
21368
21426
  unverified(`the ${stage} secrets preflight`, clean2(text) || e);
21369
21427
  }
@@ -21553,7 +21611,7 @@ function enforceGateBudget(deps, repo) {
21553
21611
  );
21554
21612
  }
21555
21613
  }
21556
- async function preflight(deps, ctx, stage, meta) {
21614
+ async function preflight(deps, ctx, stage, meta, lane) {
21557
21615
  const model = requireDeployModel(meta, ctx.repo);
21558
21616
  if (model === "content") {
21559
21617
  throw new Error(`${ctx.repo} is a content repo (deployModel=content) \u2014 the release train does not apply (trunk-based; PR to main)`);
@@ -21561,7 +21619,7 @@ async function preflight(deps, ctx, stage, meta) {
21561
21619
  if (model === "none") {
21562
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`);
21563
21621
  }
21564
- 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"] : []]);
21565
21623
  await assertNpmMajorPreflightFromWorkflows(deps, ctx.repo);
21566
21624
  await assertActionsJobsCanStart(deps, ctx.repo);
21567
21625
  enforceGateBudget(deps, ctx.repo);
@@ -22207,7 +22265,7 @@ ${recovery.note}`), recoveryInput);
22207
22265
  const deployRunRepo = releaseRunRepoFor(deployModel, ctx.repo);
22208
22266
  const failedReleaseRun = deployDispatch0.workflowRuns?.find((run) => run.conclusion === "failure");
22209
22267
  const dispatchFailurePhase = failedReleaseRun?.workflow === "publish.yml" || deployModel === "registry-publish" ? "publish" : "deploy";
22210
- const deployDispatch = await recoverFailedDispatchPhase(
22268
+ const recoveredDeployDispatch = await recoverFailedDispatchPhase(
22211
22269
  deps,
22212
22270
  ledger,
22213
22271
  ledgerAnchors,
@@ -22220,6 +22278,7 @@ ${recovery.note}`), recoveryInput);
22220
22278
  workflow: failedReleaseRun?.workflow ?? (isCentralDispatchModel(deployModel) ? "tenant-deploy.yml" : "deploy workflow")
22221
22279
  }
22222
22280
  );
22281
+ const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, releaseSha, recoveredDeployDispatch);
22223
22282
  await recordPhase(ledger, deps, ledgerAnchors, "deploy", phaseEntry(deployPhaseInput(deployModel, deployDispatch, { releaseSha })), {
22224
22283
  landed: "the immutable tag, the green required-check wall, the origin/main fast-forward, and the verified GitHub Release"
22225
22284
  });
@@ -22244,7 +22303,6 @@ ${recovery.note}`), recoveryInput);
22244
22303
  landed: "the immutable tag, the green required-check wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
22245
22304
  });
22246
22305
  let dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
22247
- dispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, releaseSha, dispatch);
22248
22306
  if (publishSkipNote) dispatch = { ...dispatch, note: `${dispatch.note}; tenant-publish.yml skipped (${publishSkipNote})` };
22249
22307
  return { checks, releaseUrl, announceNote, dispatch, ledgerAnchors };
22250
22308
  }
@@ -22520,17 +22578,18 @@ Nothing was written. Inspect ${ledger.path} (or clear it only after proving the
22520
22578
  historicalDeploy = historicalTargets.length ? aggregateWorkflowRuns(historicalRows) : recovered.deployStatus;
22521
22579
  historicalNote = recovered.note;
22522
22580
  }
22581
+ const dispatch2 = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, {
22582
+ note: historicalNote,
22583
+ deployStatus: historicalDeploy,
22584
+ workflowRuns: historicalRows
22585
+ });
22523
22586
  if (persisted) {
22524
- const phaseInputs = phaseInputsFromRunRows(deployModel, historicalRows, tagSha, { repo: ctx.repo });
22587
+ const phaseInputs = phaseInputsFromRunRows(deployModel, dispatch2.workflowRuns ?? historicalRows, tagSha, { repo: ctx.repo });
22525
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" });
22526
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" });
22527
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)" });
22528
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" });
22529
22592
  }
22530
- const dispatch2 = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, {
22531
- note: historicalNote,
22532
- deployStatus: historicalDeploy
22533
- });
22534
22593
  if (isJervHubRepo(ctx.repo)) steps2.push(dispatch2.note);
22535
22594
  if (persisted) {
22536
22595
  await recordPhase(ledger, deps, anchors, "alignment", phaseEntry(alignmentPhaseEntry(devRollForward2, rcAlignment2)), { strict: true, landed: "the released, verified, deploy-resolved release (alignment PRs reused/landed)" });
@@ -22612,7 +22671,7 @@ ${recovery.note}`), recoveryInput);
22612
22671
  const announceNote = deps.announce ? (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note : void 0;
22613
22672
  const autoRunSince = (deps.now ?? Date.now)();
22614
22673
  const deployDispatch0 = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, tagSha, "report", meta.publishDir);
22615
- const deployDispatch = await recoverFailedDispatchPhase(
22674
+ const recoveredDeployDispatch = await recoverFailedDispatchPhase(
22616
22675
  deps,
22617
22676
  ledger,
22618
22677
  anchors,
@@ -22625,6 +22684,7 @@ ${recovery.note}`), recoveryInput);
22625
22684
  workflow: isCentralDispatchModel(deployModel) ? "tenant-deploy.yml" : "deploy workflow"
22626
22685
  }
22627
22686
  );
22687
+ const deployDispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, recoveredDeployDispatch);
22628
22688
  steps.push(`dispatched the ${deployModel} deploy path`);
22629
22689
  await recordPhase(ledger, deps, anchors, "deploy", phaseEntry(deployPhaseInput(deployModel, deployDispatch, { releaseSha: tagSha })), {
22630
22690
  strict: ledgerMode === "strict",
@@ -22643,8 +22703,7 @@ ${recovery.note}`), recoveryInput);
22643
22703
  strict: ledgerMode === "strict",
22644
22704
  landed: "the immutable tag, the re-proven wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
22645
22705
  });
22646
- let dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
22647
- dispatch = await appendJervGatewayReleaseDeploy(deps, ctx.repo, tag, tagSha, dispatch);
22706
+ const dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
22648
22707
  const devRollForward = await rollDevelopmentForward(deps, ctx, tag);
22649
22708
  steps.push(`development roll-forward: ${devRollForward.status}`);
22650
22709
  const rcAlignment = !directTrack && branchHints.hasRcBranch ? await alignRcForward(deps, ctx, tag) : void 0;
@@ -25269,6 +25328,13 @@ function registerBoardCommands(program3) {
25269
25328
  const lane = holder.surface && holder.session && holder.host ? ` (${holder.surface}/${holder.session}@${holder.host})` : "";
25270
25329
  return `@${holder.login}${lane}`;
25271
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
+ }
25272
25338
  function claimVerdict(ref, result) {
25273
25339
  const holder = formatClaimHolder(result.holder);
25274
25340
  const previousHolder = result.previousHolder ? formatClaimHolder(result.previousHolder) : "another lane";
@@ -25291,24 +25357,26 @@ function registerBoardCommands(program3) {
25291
25357
  const board = program3.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
25292
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));
25293
25359
  withExamples(mutating(
25294
- 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"),
25295
25361
  (_opts, args) => ({ command: "board claim", issues: args[0] ?? [] })
25296
25362
  ).action(async (issueRefs, o) => {
25297
25363
  if (issueRefs.length === 1) {
25298
25364
  const issueRef = issueRefs[0];
25299
25365
  try {
25366
+ const config = await loadConfigForBoardSelector2(issueRef, o.repo);
25300
25367
  const result = await claimBoardIssue({
25301
- config: await loadConfigForBoardSelector2(issueRef, o.repo),
25368
+ config,
25302
25369
  selector: issueRef,
25303
25370
  repo: o.repo,
25304
25371
  assignee: o.for,
25305
25372
  force: o.force,
25306
25373
  check: o.check,
25307
25374
  allowPartial: o.allowPartial
25308
- });
25375
+ }, { snapshot: registryClientDeps(config) });
25309
25376
  if (!result.checked) invalidateStatuslineBoardCache();
25310
25377
  if (o.json) return console.log(JSON.stringify(result));
25311
25378
  console.log(claimVerdict(result.item.ref, result));
25379
+ printClaimWarnings(result.warnings);
25312
25380
  } catch (e) {
25313
25381
  if (refuseRateLimited(e, o.json)) return;
25314
25382
  return failGraceful(`board claim failed: ${e.message}`);
@@ -25316,22 +25384,25 @@ function registerBoardCommands(program3) {
25316
25384
  return;
25317
25385
  }
25318
25386
  try {
25387
+ const config = await loadConfigForBoardSelector2(issueRefs[0], o.repo);
25319
25388
  const bulk = await claimBoardIssues({
25320
- config: await loadConfigForBoardSelector2(issueRefs[0], o.repo),
25389
+ config,
25321
25390
  selectors: issueRefs,
25322
25391
  repo: o.repo,
25323
25392
  assignee: o.for,
25324
25393
  force: o.force,
25325
25394
  check: o.check,
25326
25395
  allowPartial: o.allowPartial
25327
- });
25396
+ }, { snapshot: registryClientDeps(config) });
25328
25397
  if (bulk.results.some((r) => r.claimed && !r.checked)) invalidateStatuslineBoardCache();
25329
25398
  if (o.json) {
25330
25399
  console.log(JSON.stringify(bulk.results));
25400
+ printClaimWarnings(bulk.warnings, true);
25331
25401
  } else {
25332
25402
  for (const result of bulk.results) {
25333
25403
  console.log(result.claimed ? claimVerdict(result.ref, result) : `Skipped ${result.ref}: ${result.reason}`);
25334
25404
  }
25405
+ printClaimWarnings(bulk.warnings);
25335
25406
  }
25336
25407
  if (bulk.failed > 0) process.exitCode = 1;
25337
25408
  } catch (e) {
@@ -25644,7 +25715,7 @@ function trainPlan(command, options = {}) {
25644
25715
  { label: "verify the fix is merged on development (the only hotfix origin)", gated: true },
25645
25716
  // #6068: hotfix start/release run the SAME shared preflight as release/rcand, before any git mutation.
25646
25717
  { label: "verify registry META for this project", command: "mmi-cli oracle org project get <owner/repo>", gated: true },
25647
- { 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 },
25648
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 },
25649
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 },
25650
25721
  { label: "branch hotfix from main and cherry-pick the dev commits", command: "git cherry-pick -x <dev-sha>", gated: true },
@@ -28252,7 +28323,7 @@ function formatDeployStatus(r) {
28252
28323
  `running version: ${r.runningVersion ?? "none stamped"}`,
28253
28324
  `last deploy run: ${formatLastRun(r)}`,
28254
28325
  `public URL: ${r.publicUrl ?? "none"}`,
28255
- `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"}`,
28256
28327
  `deploy state: ${r.deployOk === void 0 ? "not stamped" : r.deployOk ? "ok" : "failed"}`
28257
28328
  ];
28258
28329
  if (r.hints.length) {
@@ -28263,22 +28334,9 @@ function formatDeployStatus(r) {
28263
28334
 
28264
28335
  // src/deploy-commands.ts
28265
28336
  var STAGES2 = ["dev", "rc", "main"];
28266
- async function probeHttpBounded(url, timeoutMs = 5e3) {
28267
- const controller = new AbortController();
28268
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
28269
- timeout.unref?.();
28270
- try {
28271
- const res = await fetch(url, { method: "GET", signal: controller.signal });
28272
- return { ok: res.ok, status: res.status };
28273
- } catch (e) {
28274
- return { ok: false, error: e.message };
28275
- } finally {
28276
- clearTimeout(timeout);
28277
- }
28278
- }
28279
28337
  function registerDeployCommands(program3) {
28280
28338
  const deploy = program3.command("deploy").description("per-stage deploy observability \u2014 last run, health, and running version (#2688)");
28281
- 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) => {
28282
28340
  if (!STAGES2.includes(stage)) {
28283
28341
  return fail(`runtime deploy status: <stage> must be dev, rc, or main`);
28284
28342
  }
@@ -28290,7 +28348,7 @@ function registerDeployCommands(program3) {
28290
28348
  const deployFacts = await fetchDeployFactsBySlug(slug, reg);
28291
28349
  const publicUrl = publicUrlFromDeployFact(deployFacts?.stages[stage] ?? null);
28292
28350
  const [health, lastRun] = await Promise.all([
28293
- publicUrl ? probeHttpBounded(publicUrl) : Promise.resolve(void 0),
28351
+ publicUrl ? probePublicHealth(publicUrl) : Promise.resolve(void 0),
28294
28352
  fetchLastTenantDeployRun(slug, stage)
28295
28353
  ]);
28296
28354
  const report = buildDeployStatusReport({
@@ -29021,7 +29079,8 @@ function composeMarksRuntimeEnvTrue(composeText) {
29021
29079
  return false;
29022
29080
  });
29023
29081
  }
29024
- function promotionSourceBranch(stage, releaseTrack) {
29082
+ function promotionSourceBranch(stage, releaseTrack, lane) {
29083
+ if (lane === "hotfix") return "main";
29025
29084
  if (stage === "main") return releaseTrack === "direct" ? "development" : "rc";
29026
29085
  return "development";
29027
29086
  }
@@ -29922,7 +29981,7 @@ async function collectStatus() {
29922
29981
  try {
29923
29982
  const cfg = await loadConfigOrDiscover();
29924
29983
  if (cfg.sagaApiUrl) {
29925
- const report = await readBoard({ config: cfg });
29984
+ const report = await readBoard({ config: cfg }, { snapshot: registryClientDeps(cfg) });
29926
29985
  claimedItems = report.primary.userOwned.map((item) => ({
29927
29986
  number: item.number,
29928
29987
  title: item.title,
@@ -29969,8 +30028,8 @@ var PRIORITY_RANK = {
29969
30028
  };
29970
30029
  async function recommendNext(repo, deps) {
29971
30030
  const load = deps?.loadConfig ?? loadConfigForRepo;
29972
- const reader = deps?.readBoard ?? readBoard;
29973
30031
  const cfg = await load(repo);
30032
+ const reader = deps?.readBoard ?? ((opts) => readBoard(opts, { snapshot: registryClientDeps(cfg) }));
29974
30033
  if (!cfg.sagaApiUrl) throw new Error("Hub API URL not configured \u2014 the board was NOT read (run `mmi-cli doctor`)");
29975
30034
  let report;
29976
30035
  try {
@@ -30016,7 +30075,7 @@ async function collectOnboardStatus(opts = {}) {
30016
30075
  let board = { ok: false, detail: "no config" };
30017
30076
  try {
30018
30077
  if (cfg.sagaApiUrl) {
30019
- const report = await readBoard({ config: cfg });
30078
+ const report = await readBoard({ config: cfg }, { snapshot: registryClientDeps(cfg) });
30020
30079
  const total = report.primary.claimable.length + report.primary.userOwned.length + report.primary.taken.length;
30021
30080
  board = { ok: true, detail: `board has ${total} active items (${report.primary.claimable.length} claimable, ${report.primary.userOwned.length} yours)` };
30022
30081
  } else {
@@ -32577,6 +32636,10 @@ function validateBatchSpecs(specs) {
32577
32636
  spec.labels = [...spec.labels ?? [], ...alias];
32578
32637
  delete spec.label;
32579
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
+ }
32580
32643
  if (spec.repo !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(spec.repo)) {
32581
32644
  errors.push({ row, error: `bad repo "${spec.repo}" \u2014 expected owner/repo` });
32582
32645
  continue;
@@ -32585,13 +32648,17 @@ function validateBatchSpecs(specs) {
32585
32648
  errors.push({ row, error: `unknown type "${spec.type}" \u2014 expected one of: ${validTypes.join(", ")}` });
32586
32649
  continue;
32587
32650
  }
32588
- if (!spec.title || !spec.title.trim()) {
32651
+ if (typeof spec.title !== "string" || !spec.title.trim()) {
32589
32652
  errors.push({ row, error: "missing or empty title" });
32590
32653
  continue;
32591
32654
  }
32655
+ if (spec.body !== void 0 && typeof spec.body !== "string") {
32656
+ errors.push({ row, error: "body must be a string" });
32657
+ continue;
32658
+ }
32592
32659
  let priority;
32593
32660
  try {
32594
- priority = spec.priority ? normalizePriority(spec.priority) : "medium";
32661
+ priority = spec.priority ? normalizePriority(String(spec.priority)) : "medium";
32595
32662
  } catch (e) {
32596
32663
  errors.push({ row, error: e.message });
32597
32664
  continue;
@@ -32604,6 +32671,14 @@ function validateBatchSpecs(specs) {
32604
32671
  if (!labelsCarrySurface(spec.labels)) spec.labels = [...spec.labels ?? [], surfaceLabel(spec.surface)];
32605
32672
  delete spec.surface;
32606
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
+ }
32607
32682
  validated.push({ row, spec, priority, type: spec.type });
32608
32683
  }
32609
32684
  return { ok: errors.length === 0, errors, validated };
@@ -34030,6 +34105,8 @@ var UPDATE_PROJECT_INFO = `mutation($projectId: ID!, $shortDescription: String!,
34030
34105
  projectV2 { id }
34031
34106
  }
34032
34107
  }`;
34108
+ var ProjectInfoReadUnavailableError = class extends Error {
34109
+ };
34033
34110
  function shortDescriptionFromReadme(markdown) {
34034
34111
  const lines2 = markdown.replace(/\r/g, "").split("\n");
34035
34112
  const h1 = lines2.findIndex((line) => /^#\s+\S/.test(line.trim()));
@@ -34751,10 +34828,13 @@ function registerSecretsCommands(program3) {
34751
34828
  const ok = body !== void 0 ? await secretsOrgCatalogSet(d, body, { replace: o.replace, remove: o.remove }) : await secretsOrgCatalogRemove(d, o.remove ?? []);
34752
34829
  if (!ok) process.exitCode = 1;
34753
34830
  }));
34754
- 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) => {
34755
34832
  if (!["dev", "rc", "main"].includes(o.stage)) {
34756
34833
  return fail("secrets preflight: --stage must be dev, rc, or main");
34757
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
+ }
34758
34838
  const cfg = await loadConfig();
34759
34839
  if (!cfg.sagaApiUrl) {
34760
34840
  fail("secrets: Hub API URL not configured");
@@ -34775,7 +34855,7 @@ function registerSecretsCommands(program3) {
34775
34855
  let filelessOk = true;
34776
34856
  if (meta && centralContainer && !o.skipComposeGuard) {
34777
34857
  const stage = o.stage;
34778
- const branch = promotionSourceBranch(stage, resolveReleaseTrack(meta, void 0, repo));
34858
+ const branch = promotionSourceBranch(stage, resolveReleaseTrack(meta, void 0, repo), o.lane);
34779
34859
  const cwdRepo = repoFromRemoteUrl((await execFileP("git", ["remote", "get-url", "origin"]).catch(() => ({ stdout: "" }))).stdout);
34780
34860
  const { sameRepo: sameRepo2 } = resolvePreflightRepoScope(o.repo, cwdRepo);
34781
34861
  const facts = await fetchDeployFactsBySlug(slug, regDeps);
@@ -34981,10 +35061,11 @@ async function resolveBoardConfig2(repoOption) {
34981
35061
  const floor = await loadConfig();
34982
35062
  if (!floor.sagaApiUrl) return null;
34983
35063
  const slug = repoOption ? (repoOption.replace(/\.git$/, "").split("/").pop() ?? repoOption).toLowerCase() : await repoSlug();
34984
- const read = await fetchProjectBySlugChecked(slug, registryClientDeps(floor));
35064
+ const registry2 = registryClientDeps(floor);
35065
+ const read = await fetchProjectBySlugChecked(slug, registry2);
34985
35066
  if (!read.ok || !read.project) return null;
34986
35067
  const cfg = boardConfigFromProject(read.project, floor);
34987
- return readBoard({ config: cfg, repo: repoOption, allowPartial: true });
35068
+ return readBoard({ config: cfg, repo: repoOption, allowPartial: true }, { snapshot: registry2 });
34988
35069
  }
34989
35070
  function registerSessionReport(program3) {
34990
35071
  const report = program3.commands.find((c) => c.name() === "report");
@@ -36753,24 +36834,31 @@ function runTestPolicy(root, deps = {}) {
36753
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.`)
36754
36835
  }] : [];
36755
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";
36756
36840
  const removedByThisDiff = removedPaths(changed);
36757
36841
  const staleFindings = [];
36758
- const unresolved = unresolvedProtectedEntries(policy, root, exists).filter((p) => !removedByThisDiff.has(p));
36842
+ const unresolved = unresolvedProtectedEntries(policy, root, policyTreeExists).filter((p) => !removedByThisDiff.has(p));
36759
36843
  if (unresolved.length > 0) {
36760
36844
  staleFindings.push({
36761
36845
  kind: "stale-protected-entry",
36762
36846
  paths: unresolved,
36763
- detail: `STALE PROTECTED ENTRY \u2014 test-policy.json protects ${unresolved.length} path(s) that do not exist:
36764
- ` + 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.`
36765
36851
  });
36766
36852
  }
36767
- const staleSatisfiers = unresolvedSatisfiers(policy, root, exists).filter((p) => !removedByThisDiff.has(p));
36853
+ const staleSatisfiers = unresolvedSatisfiers(policy, root, policyTreeExists).filter((p) => !removedByThisDiff.has(p));
36768
36854
  if (staleSatisfiers.length > 0) {
36769
36855
  staleFindings.push({
36770
36856
  kind: "stale-satisfied-by",
36771
36857
  paths: staleSatisfiers,
36772
- detail: `STALE STANDING COVERAGE \u2014 a mandatory glob claims ${staleSatisfiers.length} path(s) as satisfiedBy that do not exist:
36773
- ` + 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.`
36774
36862
  });
36775
36863
  }
36776
36864
  const override = lookup.override;
@@ -37085,15 +37173,17 @@ function cleanupGitArgs(cwd, args) {
37085
37173
  }
37086
37174
  async function remoteBranchExists2(branch, options = {}) {
37087
37175
  if (!branch) return void 0;
37176
+ const remote = options.remote ?? "origin";
37088
37177
  try {
37089
- if (options.prune) await execFileP("git", cleanupGitArgs(options.cwd, ["fetch", "origin", "--prune"]), { timeout: GIT_TIMEOUT_MS });
37090
- 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;
37091
37180
  } catch {
37092
37181
  return void 0;
37093
37182
  }
37094
37183
  }
37095
37184
  async function deleteMergedRemoteBranch(options) {
37096
- const remediation = `git push origin --delete ${options.branch}`;
37185
+ const remote = options.remote ?? "origin";
37186
+ const remediation = `git push ${remote} --delete ${options.branch}`;
37097
37187
  if (!options.branch) {
37098
37188
  return {
37099
37189
  branch: options.branch,
@@ -37122,13 +37212,13 @@ async function deleteMergedRemoteBranch(options) {
37122
37212
  existedBefore: false,
37123
37213
  attempted: false,
37124
37214
  status: "failed",
37125
- error: `could not verify absence of origin/${options.branch}`,
37215
+ error: `could not verify absence of ${remote}/${options.branch}`,
37126
37216
  remediation
37127
37217
  };
37128
37218
  }
37129
37219
  }
37130
37220
  try {
37131
- await options.execGit(["push", "origin", "--delete", options.branch]);
37221
+ await options.execGit(["push", remote, "--delete", options.branch]);
37132
37222
  } catch (e) {
37133
37223
  const exists2 = await options.branchExists(options.branch);
37134
37224
  if (exists2 === false) {
@@ -37162,7 +37252,7 @@ async function deleteMergedRemoteBranch(options) {
37162
37252
  existedBefore: options.existedBefore,
37163
37253
  attempted: true,
37164
37254
  status: "failed",
37165
- 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}`,
37166
37256
  remediation
37167
37257
  };
37168
37258
  }
@@ -37504,11 +37594,13 @@ async function prCreateClaimRefusal(body, repoOption, deps = {}) {
37504
37594
  const actor = deps.actor ?? describeSessionIdentity();
37505
37595
  const checkContest = deps.checkContest ?? checkLaneContest;
37506
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;
37507
37599
  const contest = await checkContest(client, { repository: repo, number }, actor);
37508
37600
  if (!contest.contested) continue;
37509
37601
  const ref = `${repo}#${number}`;
37510
37602
  const holder = contest.marker ? `lane ${describeClaimMarker(contest.marker)}` : "another lane";
37511
- 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`;
37512
37604
  }
37513
37605
  return void 0;
37514
37606
  }
@@ -37908,6 +38000,69 @@ function safeRemoveTree(path2) {
37908
38000
  }
37909
38001
  (0, import_node_fs41.unlinkSync)(path2);
37910
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
+ }
37911
38066
  async function describePreCleanFailure(wtPath, execGit, error) {
37912
38067
  const dryRun = await execGit(["-C", wtPath, "clean", "-ndX"]).catch(() => "");
37913
38068
  const remaining = dryRun.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("Would remove ")).map((line) => line.slice("Would remove ".length));
@@ -37942,6 +38097,25 @@ async function verifyBranchHead(git3, branch, expectedHeadOid) {
37942
38097
  }
37943
38098
  return { ok: true };
37944
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
+ }
37945
38119
  async function teardownWorktreeStage(worktreePath) {
37946
38120
  try {
37947
38121
  const result = await stopStage({ cwd: worktreePath, requiredIdentityCwd: worktreePath, globalStatePath: false });
@@ -38035,7 +38209,7 @@ async function removeWorktreeWithReconcile(wtPath, git3, listWorktrees, pathExis
38035
38209
  }
38036
38210
  function isPrMergeWorktreePartial(cleanup) {
38037
38211
  const worktree = cleanup?.worktree;
38038
- 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");
38039
38213
  }
38040
38214
  function prMergeLocalCleanupExitCode(cleanup) {
38041
38215
  return cleanup?.worktree?.status === "failed" || cleanup?.localBranch?.status === "failed" ? 1 : void 0;
@@ -38075,10 +38249,29 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38075
38249
  const mainWorktreeTarget = Boolean(wtPath && mainWorktreePath && samePath(wtPath, mainWorktreePath));
38076
38250
  if (!wtPath || mainWorktreeTarget) {
38077
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
+ }
38078
38270
  report.worktree = {
38079
38271
  path: wtPath,
38080
38272
  status: "not-attempted",
38081
38273
  reason: "main-worktree",
38274
+ error: switched.error,
38082
38275
  remediation: primaryCheckoutBranchRemediation(options.primaryRoot, options.baseRef, branch)
38083
38276
  };
38084
38277
  }
@@ -38098,7 +38291,12 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38098
38291
  return report;
38099
38292
  }
38100
38293
  const porcelain = await execGit(["-C", wtPath, "status", "--porcelain"]).catch(() => void 0);
38101
- 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()) {
38102
38300
  report.worktree = { path: wtPath, status: "refused", reason: "dirty-worktree" };
38103
38301
  report.localBranch = { name: branch, status: "not-attempted", reason: "dirty-worktree" };
38104
38302
  return report;
@@ -38148,6 +38346,25 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38148
38346
  report.localBranch = { name: branch, status: "not-attempted", reason: "archive-failed" };
38149
38347
  return report;
38150
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);
38151
38368
  if (pathExists((0, import_node_path38.join)(wtPath, "node_modules"))) {
38152
38369
  const nmRemoved = await (options.removeRealNodeModules ?? ((p) => removeWorktreeNodeModulesViaHelper(p, { cwd: mainWorktreePath })))(wtPath);
38153
38370
  if (!nmRemoved.ok) {
@@ -38159,6 +38376,7 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38159
38376
  artifactsArchive,
38160
38377
  stageTeardown,
38161
38378
  tmpEvidenceCount: tmpEvidence.length,
38379
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {},
38162
38380
  remediation: `jervcode worktree-node-modules-cleanup --worktree '${wtPath.replace(/'/g, "''")}'`
38163
38381
  };
38164
38382
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-pre-clean-failed" };
@@ -38175,12 +38393,16 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38175
38393
  artifactsArchive,
38176
38394
  stageTeardown,
38177
38395
  tmpEvidenceCount: tmpEvidence.length,
38396
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {},
38178
38397
  // #6076: name the surviving node_modules remover when the dry run found one, else the clean.
38179
38398
  remediation: preClean.remediation ?? `git -C '${wtPath.replace(/'/g, "''")}' clean -ffdX`
38180
38399
  };
38181
38400
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-pre-clean-failed" };
38182
38401
  return report;
38183
38402
  }
38403
+ const preRemoveGuard = unlinkEscapingReparsePoints(wtPath, options.primaryRoot);
38404
+ if (!preRemoveGuard.ok) return refuseReparseEscape(preRemoveGuard.error);
38405
+ unlinkedReparsePoints.push(...preRemoveGuard.unlinked);
38184
38406
  moveCwdToSafeWorktree(wtPath, safeCwd);
38185
38407
  const removal = await removeWorktreeWithReconcile(
38186
38408
  wtPath,
@@ -38195,7 +38417,8 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38195
38417
  error: removal.error,
38196
38418
  artifactsArchive,
38197
38419
  stageTeardown,
38198
- tmpEvidenceCount: tmpEvidence.length
38420
+ tmpEvidenceCount: tmpEvidence.length,
38421
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {}
38199
38422
  };
38200
38423
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
38201
38424
  return report;
@@ -38206,7 +38429,8 @@ async function cleanupPrMergeLocalBranch(branch, options) {
38206
38429
  ...removal.reason ? { reason: removal.reason } : {},
38207
38430
  artifactsArchive,
38208
38431
  stageTeardown,
38209
- tmpEvidenceCount: tmpEvidence.length
38432
+ tmpEvidenceCount: tmpEvidence.length,
38433
+ ...unlinkedReparsePoints.length ? { unlinkedReparsePoints } : {}
38210
38434
  };
38211
38435
  if (pathExists(wtPath)) {
38212
38436
  const residue = await (options.removeResidueDir?.(wtPath) ?? removeResidueDirectory(wtPath));
@@ -38254,12 +38478,18 @@ function renderPrMergeCleanupLines(cleanup) {
38254
38478
  lines2.push(`pr merge: preserved worktree ${wt.path} (--preserve-worktree)`);
38255
38479
  } else if (wt.status === "retained-locked") {
38256
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`);
38257
38484
  } else if (wt.status === "not-attempted" && wt.reason === "main-worktree") {
38258
- 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"}`);
38259
38486
  }
38260
38487
  if (wt.residue === "left" && wt.status !== "retained-locked") {
38261
38488
  lines2.push(`pr merge: worktree ${wt.path} registration is gone but residue remains \u2014 ${wt.residueError ?? wt.path}; remediate: ${wt.remediation ?? wt.path}`);
38262
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
+ }
38263
38493
  if (wt.artifactsArchive?.status === "archived" && wt.artifactsArchive.path) {
38264
38494
  lines2.push(`pr merge: archived worktree evidence to ${wt.artifactsArchive.path}`);
38265
38495
  } else if (wt.artifactsArchive?.status === "blocked" && wt.artifactsArchive.error) {
@@ -39321,7 +39551,7 @@ ${list}`);
39321
39551
  else printLine(`pr land: ${result.status}${result.error ? ` \u2014 ${result.error}` : ""}`);
39322
39552
  if (result.status === "failed") process.exitCode = 1;
39323
39553
  });
39324
- 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) => {
39325
39555
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
39326
39556
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
39327
39557
  if (o.disableAuto) {
@@ -39381,7 +39611,23 @@ ${list}`);
39381
39611
  const startingPath = (await execFileP("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
39382
39612
  const housekeeping = assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr merge", { force: o.force });
39383
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" }));
39384
- 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
+ }
39385
39631
  const ciHeadRef = repoForPostCleanup ? await prHeadRefForCiProbe(number, repoForPostCleanup) : void 0;
39386
39632
  const ciPolicy = await resolveMergeCiPolicyForCheckout(o.repo, ciHeadRef);
39387
39633
  if (o.wait) {
@@ -39439,7 +39685,7 @@ ${list}`);
39439
39685
  if (guard.action === "refuse") throw new Error(`gh pr merge ${number}: ${guard.message}`);
39440
39686
  if (guard.note) console.warn(`pr merge: ${guard.note}`);
39441
39687
  }
39442
- const remoteBefore = await remoteBranchExists2(headRef);
39688
+ const remoteBefore = await remoteBranchExists2(headRef, { remote });
39443
39689
  let upgradedToAuto = false;
39444
39690
  let remoteNotAttemptedReason = "preserved-delayed-cleanup";
39445
39691
  const overrideBody = mergeSquashBody ? { ...writeSquashBodyFile(mergeSquashBody), text: mergeSquashBody } : await composeOverrideBodyFile(
@@ -39558,30 +39804,34 @@ ${list}`);
39558
39804
  }
39559
39805
  const primaryRoot = beforeWorktrees[0]?.path ?? (startingPath || process.cwd());
39560
39806
  let localCleanup;
39561
- try {
39562
- localCleanup = await cleanupPrMergeLocalBranch(headRef, {
39563
- beforeWorktrees,
39564
- startingPath,
39565
- baseRef,
39566
- primaryRoot,
39567
- preserveWorktree: o.preserveWorktree,
39568
- gcAcknowledged: o.gc,
39569
- expectedHeadOid: headRefOid,
39570
- pathExists: (p) => (0, import_node_fs42.existsSync)(p),
39571
- // #5899: pin cleanup git calls to the main checkout — the task worktree this process may be
39572
- // standing in is removed mid-cleanup, so a cwd-relative invocation fails with
39573
- // 'fatal: not a git repository' and leaves a spurious partial-cleanup exit.
39574
- execGit: async (args) => (await execFileP("git", cleanupGitArgs(primaryRoot, args), { timeout: GIT_TIMEOUT_MS })).stdout
39575
- });
39576
- } catch (e) {
39577
- localCleanup = {
39578
- branch: headRef,
39579
- localBranch: {
39580
- name: headRef,
39581
- status: "failed",
39582
- error: e instanceof Error ? e.message : String(e)
39583
- }
39584
- };
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
+ }
39585
39835
  }
39586
39836
  const remoteBranch = await deleteMergedRemoteBranch({
39587
39837
  branch: headRef,
@@ -39592,7 +39842,9 @@ ${list}`);
39592
39842
  // #5899: this leg runs after worktree teardown — anchor it to the main checkout so a cwd inside
39593
39843
  // the removed worktree cannot turn the delete into 'fatal: not a git repository' + manual remediation.
39594
39844
  execGit: async (args) => (await execFileP("git", cleanupGitArgs(primaryRoot, args), { timeout: GIT_TIMEOUT_MS })).stdout,
39595
- 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
39596
39848
  });
39597
39849
  const worktree = localCleanup?.worktree;
39598
39850
  const worktreePartial = isPrMergeWorktreePartial(localCleanup) && worktree?.path ? {
@@ -39655,6 +39907,7 @@ ${list}`);
39655
39907
  ...methodField ? { method: methodField } : {},
39656
39908
  remoteBranch,
39657
39909
  housekeeping,
39910
+ ...foreignCwd ? { foreignCwd: true, ...foreignCheckout ? { foreignCheckout } : {} } : {},
39658
39911
  ...partialCleanup.length ? { cleanupStatus: "partial", partialCleanup } : {},
39659
39912
  ...localCleanup?.worktree ? { worktree: localCleanup.worktree } : {},
39660
39913
  ...localCleanup?.localBranch ? { localBranch: localCleanup.localBranch } : {},
@@ -40564,8 +40817,12 @@ function registerDeveloperCommands(program3) {
40564
40817
  registerSchedulesCommands(program3);
40565
40818
  registerSchedulesLiftCommand(program3);
40566
40819
  const docs = program3.command("docs").description("generated docs surfaces \u2014 the routing index (org knowledge layer)");
40567
- 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) => {
40568
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
+ }
40569
40826
  const root = await repoRoot();
40570
40827
  const result = docsIndex(createDocsIndexDeps(root), { check: Boolean(o.check) });
40571
40828
  if (o.check) {
@@ -40580,8 +40837,12 @@ function registerDeveloperCommands(program3) {
40580
40837
  await failGraceful(e.message);
40581
40838
  }
40582
40839
  });
40583
- 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[] }").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) => {
40584
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
+ }
40585
40846
  const root = await repoRoot();
40586
40847
  const commandPaths = new Set(
40587
40848
  buildCommandManifest(program3).index.map((entry) => entry.path)
@@ -40607,8 +40868,12 @@ function registerDeveloperCommands(program3) {
40607
40868
  }
40608
40869
  });
40609
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");
40610
- 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) => {
40611
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
+ }
40612
40877
  const root = await repoRoot();
40613
40878
  const result = runSpawnPolicy(root);
40614
40879
  if (o.json) {
@@ -40630,8 +40895,13 @@ function registerDeveloperCommands(program3) {
40630
40895
  }
40631
40896
  });
40632
40897
  const tests = program3.command("tests").description("a repo's test-policy.json \u2014 the opt-in test contract and its enforcement");
40633
- 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) => {
40634
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
+ }
40635
40905
  const root = await repoRoot();
40636
40906
  const result = runTestPolicy(root, { base: o.base, policyRef: o.policyRef });
40637
40907
  if (o.json) {
@@ -40661,8 +40931,12 @@ function registerDeveloperCommands(program3) {
40661
40931
  }
40662
40932
  });
40663
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");
40664
- 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) => {
40665
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
+ }
40666
40940
  const root = await repoRoot();
40667
40941
  const receipt = runDistStatus(root);
40668
40942
  if (o.json) {
@@ -40957,7 +41231,7 @@ async function resolveHotfixDeployModel(deps, ctx) {
40957
41231
  }
40958
41232
  async function hotfixPreflight(deps, ctx, verb, targetTag) {
40959
41233
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
40960
- const deployModel = await preflight(deps, ctx, "main", meta);
41234
+ const deployModel = await preflight(deps, ctx, "main", meta, "hotfix");
40961
41235
  const root = await hotfixCheckoutRoot(deps);
40962
41236
  const begin = await beginReleaseLedger(deps, root, targetTag);
40963
41237
  if (!begin.ok) throw new Error(`hotfix ${verb} refused before any mutation: ${begin.error}`);
@@ -41316,6 +41590,16 @@ function hotfixDispatchFromRuns(runs, note) {
41316
41590
  const tenantDeploy2 = runs.find((r) => r.workflow === "tenant-deploy.yml");
41317
41591
  return { note, deployStatus, workflowRuns, ...tenantDeploy2?.runId != null ? { runId: tenantDeploy2.runId } : {}, ...tenantDeploy2?.url ? { runUrl: tenantDeploy2.url } : {} };
41318
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
+ }
41319
41603
  function hotfixPhaseInputsFromRuns(deployModel, runs, note, publishDispatch, publishRequired, opts) {
41320
41604
  const dispatch = hotfixDispatchFromRuns(runs, note);
41321
41605
  return {
@@ -41559,6 +41843,7 @@ ${decision.note}`);
41559
41843
  } else {
41560
41844
  deployNote = `no hotfix deploy dispatch for deployModel=${deployModel} \u2014 prod deploy is repo-specific`;
41561
41845
  }
41846
+ deployNote = await appendHotfixGatewayRun(deps, ctx.repo, tag, mergedSha, runs, deployNote);
41562
41847
  const ledgerInputs = hotfixPhaseInputsFromRuns(
41563
41848
  deployModel,
41564
41849
  runs,
@@ -41751,6 +42036,9 @@ function hotfixStatusRunsFromLedger(ledger) {
41751
42036
  workflow: record.workflow ?? phase,
41752
42037
  ...record.runId != null ? { runId: record.runId } : {},
41753
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 } : {},
41754
42042
  conclusion: record.state === "complete" ? "success" : record.state === "failed" ? "failure" : "pending"
41755
42043
  }];
41756
42044
  });
@@ -42231,6 +42519,7 @@ function renderReleaseResume(r) {
42231
42519
  if (r.devRollForward) lines2.push(` development: ${r.devRollForward.note}`);
42232
42520
  if (r.rcAlignment) lines2.push(` rc: ${r.rcAlignment.note}`);
42233
42521
  if (r.checkout) lines2.push(` checkout: ${r.checkout.note}`);
42522
+ if (r.projectInfoSync) lines2.push(` project info: ${r.projectInfoSync.note}`);
42234
42523
  if (r.ledger) lines2.push(...formatReleaseLedgerReport(r.ledger).map((l, i) => i === 0 ? ` ${l}` : ` ${l}`));
42235
42524
  return lines2.join("\n");
42236
42525
  }
@@ -42258,7 +42547,8 @@ function releaseFollowUpLegs(result, projectInfoSync) {
42258
42547
  const legs = [
42259
42548
  {
42260
42549
  leg: "project-info",
42261
- 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",
42262
42552
  ...projectInfoSync && "error" in projectInfoSync ? { error: projectInfoSync.error } : {}
42263
42553
  }
42264
42554
  ];
@@ -42297,6 +42587,24 @@ function releaseFollowUpLegs(result, projectInfoSync) {
42297
42587
  }
42298
42588
  return legs;
42299
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
+ }
42300
42608
  var JERV_POWERTOOLS_REPO = "mutmutco/Jerv-PowerTools";
42301
42609
  async function runPostReleaseJervDoctor(repo) {
42302
42610
  if (repo.toLowerCase() !== JERV_POWERTOOLS_REPO.toLowerCase()) return void 0;
@@ -42312,10 +42620,35 @@ async function runPostReleaseJervDoctor(repo) {
42312
42620
  };
42313
42621
  }
42314
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
+ }
42315
42648
  function buildReleaseVerdict(commandName, result, projectInfoSync) {
42316
42649
  const alignmentPending = result.devRollForward?.status === "pr-pending" || result.rcAlignment?.status === "pr-pending";
42317
42650
  const followUpStatus = deriveTrainFollowUpStatus({
42318
- projectInfo: projectInfoSync && "error" in projectInfoSync ? "failure" : "ok",
42651
+ projectInfo: projectInfoOutcome(projectInfoSync),
42319
42652
  deploy: deployFollowUpOutcome(result.deployStatus),
42320
42653
  rcRetirement: result.rcRetirement === "failed" ? result.rcRetirementCategory === "wait-timeout" ? "unresolved" : "failure" : "ok",
42321
42654
  alignment: alignmentPending ? "unresolved" : "ok",
@@ -42496,8 +42829,17 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42496
42829
  }
42497
42830
  const raw = await runReleaseResume(trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile });
42498
42831
  const result = raw.dispatch?.workflowRuns ? { ...raw, dispatch: { ...raw.dispatch, workflowRuns: raw.dispatch.workflowRuns.map(workflowRunWithEvidence) } } : raw;
42499
- emitTrainResult("release --resume", o.json ? JSON.stringify(result, null, 2) : renderReleaseResume(result), o.out);
42500
- applyTrainFollowUpExit(resumeFollowUpOf(result.state));
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
+ }));
42501
42843
  return;
42502
42844
  } catch (e) {
42503
42845
  applyTrainFollowUpExit("failed");
@@ -42535,12 +42877,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42535
42877
  let projectInfoSync;
42536
42878
  const postReleaseJervDoctor = commandName === "release" ? await runPostReleaseJervDoctor(result.repo) : void 0;
42537
42879
  if (commandName === "release") {
42538
- try {
42539
- projectInfoSync = await runProjectInfoSyncCallback(result.repo, true);
42540
- } catch (e) {
42541
- const error = e.message;
42542
- projectInfoSync = { applied: false, note: `FAILED \u2014 ${error}`, error };
42543
- }
42880
+ projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
42544
42881
  }
42545
42882
  const { followUpStatus, releaseVerdict } = buildReleaseVerdict(commandName, result, projectInfoSync);
42546
42883
  const reported = {
@@ -42637,22 +42974,6 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42637
42974
  if (conclusion === "failure") return "failure";
42638
42975
  return "unresolved";
42639
42976
  }
42640
- function hotfixFollowUpLegs(runs, foldPort, alignment, foldNote) {
42641
- const legs = runs.map((run) => ({
42642
- leg: run.workflow,
42643
- status: followUpLegStatus(run.conclusion === "failure" ? "failure" : run.conclusion === "success" ? "success" : "pending"),
42644
- ...run.conclusion === "failure" ? { error: "workflow reported failure" } : {}
42645
- }));
42646
- if (foldPort === "failure") {
42647
- legs.push({ leg: "development-fold-port", status: "failed", error: foldNote ?? "development fold port failed" });
42648
- }
42649
- if (alignment === "unresolved") {
42650
- legs.push({ leg: "development-fold-alignment", status: "pending" });
42651
- } else if (alignment === "failure" && foldPort !== "failure") {
42652
- legs.push({ leg: "development-fold-alignment", status: "failed", error: foldNote ?? "development fold alignment failed" });
42653
- }
42654
- return legs;
42655
- }
42656
42977
  async function runHotfixSub(sub, body, o, render) {
42657
42978
  try {
42658
42979
  await requireFreshTrainCli("hotfix");
@@ -42664,11 +42985,12 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42664
42985
  const ledger = result.ledger;
42665
42986
  const alignment = result.alignmentStatus ?? ledgerAlignmentFollowUpOutcome(ledger);
42666
42987
  const projectInfoSync = result.projectInfoSync;
42667
- 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;
42668
42990
  const json = o.json || Boolean(hotfixCmd.opts().json);
42669
42991
  emitTrainResult(`hotfix ${sub}`, json ? JSON.stringify(legs ? Object.assign({}, result, { legs }) : result, null, 2) : render(result), o.out);
42670
42992
  if (runs) applyTrainFollowUpExit(deriveTrainFollowUpStatus({
42671
- projectInfo: projectInfoSync && "error" in projectInfoSync ? "failure" : "ok",
42993
+ projectInfo: projectInfoOutcome(projectInfoSync),
42672
42994
  deploy: reduceFollowUpOutcomes(runs.map((r) => hotfixRunOutcome(r.conclusion))),
42673
42995
  rcRetirement: "ok",
42674
42996
  alignment,
@@ -42692,13 +43014,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
42692
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 () => {
42693
43015
  const result = await runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile, carries: o.carries ? [o.carries] : [] });
42694
43016
  const postReleaseJervDoctor = await runPostReleaseJervDoctor(result.repo);
42695
- let projectInfoSync;
42696
- try {
42697
- projectInfoSync = await runProjectInfoSyncCallback(result.repo, true);
42698
- } catch (e) {
42699
- const error = e.message;
42700
- projectInfoSync = { applied: false, note: `FAILED \u2014 ${error}`, error };
42701
- }
43017
+ const projectInfoSync = await runProjectInfoSyncLeg(runProjectInfoSyncCallback, result.repo);
42702
43018
  return { ...result, projectInfoSync, ...postReleaseJervDoctor ? { postReleaseJervDoctor } : {} };
42703
43019
  }, o, renderHotfixRelease));
42704
43020
  function hotfixStatusDeps() {
@@ -43279,7 +43595,7 @@ tenant.command("reconcile <owner/repo> <stage>").description("re-render this ten
43279
43595
  return failGraceful(`runtime tenant reconcile: ${e.message}`);
43280
43596
  }
43281
43597
  });
43282
- 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) => {
43283
43599
  if (!["dev", "rc", "main"].includes(stage)) return fail("runtime tenant status: <stage> must be dev, rc, or main");
43284
43600
  const cfg = await loadConfig();
43285
43601
  const result = await buildTenantRuntimeStatusFor(repo, stage, cfg);
@@ -43321,26 +43637,13 @@ tenant.command("sweep-rc").description("discover (and optionally retire) running
43321
43637
  return failGraceful(`runtime tenant sweep-rc: ${e.message}`);
43322
43638
  }
43323
43639
  });
43324
- async function probeHttpBounded2(url, timeoutMs = 5e3) {
43325
- const controller = new AbortController();
43326
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
43327
- timeout.unref?.();
43328
- try {
43329
- const res = await fetch(url, { method: "GET", signal: controller.signal });
43330
- return { ok: res.ok, status: res.status };
43331
- } catch (e) {
43332
- return { ok: false, error: e.message };
43333
- } finally {
43334
- clearTimeout(timeout);
43335
- }
43336
- }
43337
43640
  async function buildTenantRuntimeStatusFor(target, stage, cfg) {
43338
43641
  const slug = slugOf(target);
43339
43642
  const reg = registryClientDeps(cfg);
43340
43643
  const facts = await fetchDeployFactsBySlug(slug, reg);
43341
43644
  const deploy = facts?.stages[stage] ?? null;
43342
43645
  const publicUrl = publicUrlFromDeployFact(deploy);
43343
- const publicProbe = publicUrl ? await probeHttpBounded2(publicUrl) : void 0;
43646
+ const publicProbe = publicUrl ? await probePublicHealth(publicUrl) : void 0;
43344
43647
  return buildTenantRuntimeStatus({
43345
43648
  repo: target,
43346
43649
  slug,
@@ -43368,9 +43671,9 @@ async function runProjectInfoSync(target, apply) {
43368
43671
  fetchProjectBySlugChecked(slugOf(targetRepo2), registry2),
43369
43672
  fetchProjectsList(registry2)
43370
43673
  ]);
43371
- 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})`);
43372
43675
  if (!read.project) throw new Error(`org project sync-info: no registry META for ${targetRepo2}`);
43373
- 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");
43374
43677
  if (apply) {
43375
43678
  const authority = await fetchTrainAuthority(targetRepo2, registry2);
43376
43679
  if (!authority.ok) throw new Error(`org project sync-info: train authority unverified (${authority.error})`);