@mutmutco/cli 3.56.0 → 3.58.0

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 +513 -70
  2. package/package.json +2 -2
package/dist/main.cjs CHANGED
@@ -7331,9 +7331,116 @@ function defaultSpawn(command, args, env) {
7331
7331
  if (r.error) throw r.error;
7332
7332
  return r.status ?? 1;
7333
7333
  }
7334
- async function secretsUse(deps, key, opts) {
7334
+ function classifyUseToken(token) {
7335
+ if (!isValidSecretKey(token)) return "command";
7336
+ const leaf = token.slice(token.lastIndexOf("/") + 1);
7337
+ if (/^[A-Z][A-Z0-9_]*$/.test(leaf)) return "key";
7338
+ return /^[A-Z]/.test(leaf) ? "ambiguous" : "command";
7339
+ }
7340
+ var USE_OWN_FLAGS = /* @__PURE__ */ new Set(["--repo", "--slug", "--name", "--key"]);
7341
+ function secretsUseTail(argv) {
7342
+ for (let i = 0; i < argv.length - 1; i += 1) {
7343
+ if (argv[i] === "secrets" && argv[i + 1] === "use") return argv.slice(i + 2);
7344
+ }
7345
+ return [];
7346
+ }
7347
+ function excessBareTokens(head) {
7348
+ let excess = 0;
7349
+ let positionalTaken = false;
7350
+ for (let i = 0; i < head.length; ) {
7351
+ const tok = head[i];
7352
+ const eq = tok.indexOf("=");
7353
+ const flag = eq === -1 ? tok : tok.slice(0, eq);
7354
+ if (USE_OWN_FLAGS.has(flag)) {
7355
+ if (flag === "--key") positionalTaken = true;
7356
+ i += eq === -1 ? 2 : 1;
7357
+ continue;
7358
+ }
7359
+ if (tok.startsWith("-")) {
7360
+ i += 1;
7361
+ continue;
7362
+ }
7363
+ if (positionalTaken) excess += 1;
7364
+ else positionalTaken = true;
7365
+ i += 1;
7366
+ }
7367
+ return excess;
7368
+ }
7369
+ function separatorIsOurs(head) {
7370
+ return excessBareTokens(head) === 0;
7371
+ }
7372
+ function parseSecretsUseArgv(tail) {
7373
+ const flags = {};
7374
+ const keys = [];
7375
+ const firstSep = tail.indexOf("--");
7376
+ const sep2 = firstSep !== -1 && separatorIsOurs(tail.slice(0, firstSep)) ? firstSep : -1;
7377
+ const head = sep2 === -1 ? tail : tail.slice(0, sep2);
7378
+ let command = sep2 === -1 ? [] : tail.slice(sep2 + 1).slice();
7379
+ for (let i = 0; i < head.length; ) {
7380
+ const tok = head[i];
7381
+ const eq = tok.indexOf("=");
7382
+ const flag = eq === -1 ? tok : tok.slice(0, eq);
7383
+ if (USE_OWN_FLAGS.has(flag)) {
7384
+ const value = eq === -1 ? head[i + 1] : tok.slice(eq + 1);
7385
+ if (value === void 0) return { kind: "error", error: `secrets use: ${flag} needs a value` };
7386
+ if (flag === "--repo") flags.repo = value;
7387
+ else if (flag === "--slug") flags.slug = value;
7388
+ else if (flag === "--name") flags.name = value;
7389
+ else keys.push(value);
7390
+ i += eq === -1 ? 2 : 1;
7391
+ continue;
7392
+ }
7393
+ if (tok === "--help" || tok === "-h") return { kind: "help" };
7394
+ if (tok.startsWith("-")) {
7395
+ return {
7396
+ kind: "error",
7397
+ error: `secrets use: unknown option ${tok}. \`use\` owns only --key, --repo, --slug and --name; the wrapped command's own flags go after \`--\`, following the command itself.`
7398
+ };
7399
+ }
7400
+ if (keys.length === 0) {
7401
+ keys.push(tok);
7402
+ i += 1;
7403
+ continue;
7404
+ }
7405
+ if (classifyUseToken(tok) !== "command") {
7406
+ const fix = [...keys, tok].map((k) => `--key ${k}`).join(" ");
7407
+ return {
7408
+ kind: "error",
7409
+ error: `secrets use: '${tok}' looks like a second secret key, but \`use\` takes one positional key, so it would be RUN as the command. Name each key explicitly: mmi-cli secrets use ${fix} -- <command> (if '${tok}' really is the command, put it after \`--\`)`
7410
+ };
7411
+ }
7412
+ command = head.slice(i).slice();
7413
+ break;
7414
+ }
7415
+ if (keys.length === 0) return { kind: "error", error: "secrets use: missing <key> (or --key <key>)" };
7416
+ return { kind: "parsed", value: { keys, ...flags, command } };
7417
+ }
7418
+ function resolveUseEnvNames(keys, name) {
7419
+ if (name && keys.length > 1) {
7420
+ return { ok: false, error: `secrets use: --name renames ONE env var, but ${keys.length} keys were given \u2014 drop --name, or run one key per call.` };
7421
+ }
7422
+ const entries = keys.map((key) => ({
7423
+ key,
7424
+ envName: (name && keys.length === 1 ? name : secretLeafName(key)).toUpperCase().replace(/[^A-Z0-9_]/g, "_")
7425
+ }));
7426
+ const claimedBy = /* @__PURE__ */ new Map();
7427
+ for (const e of entries) {
7428
+ const prior = claimedBy.get(e.envName);
7429
+ if (prior) {
7430
+ return { ok: false, error: `secrets use: ${prior} and ${e.key} both inject as ${e.envName} \u2014 one would silently overwrite the other. Run one key per call.` };
7431
+ }
7432
+ claimedBy.set(e.envName, e.key);
7433
+ }
7434
+ return { ok: true, entries };
7435
+ }
7436
+ async function secretsUse(deps, keys, opts) {
7335
7437
  const command = opts.command ?? [];
7438
+ const key = keys[0];
7336
7439
  if (command.length === 0) {
7440
+ if (keys.length > 1) {
7441
+ deps.err(`secrets use: ${keys.length} keys given but no command \u2014 multiple keys only make sense with \`-- <command>\`. For guidance on one key: mmi-cli secrets use ${key}`);
7442
+ return false;
7443
+ }
7337
7444
  const slug2 = await vaultSlug(deps, opts);
7338
7445
  const tier = classifyTier(slug2, key);
7339
7446
  const path2 = secretParamName(slug2, key);
@@ -7356,12 +7463,31 @@ async function secretsUse(deps, key, opts) {
7356
7463
  );
7357
7464
  return;
7358
7465
  }
7359
- if (!isValidSecretKey(key)) {
7360
- deps.err(`invalid secret key ${JSON.stringify(key)}`);
7466
+ for (const k of keys) {
7467
+ if (!isValidSecretKey(k)) {
7468
+ deps.err(`invalid secret key ${JSON.stringify(k)}`);
7469
+ return false;
7470
+ }
7471
+ }
7472
+ const named = resolveUseEnvNames(keys, opts.name);
7473
+ if (!named.ok) {
7474
+ deps.err(named.error);
7361
7475
  return false;
7362
7476
  }
7363
7477
  const repo = await targetRepo(deps, opts);
7364
7478
  const slug = opts.slug?.toLowerCase();
7479
+ const env = { ...process.env };
7480
+ for (const { key: k, envName } of named.entries) {
7481
+ const value = await fetchSecretForUse(deps, { repo, key: k, slug });
7482
+ if (value == null) return false;
7483
+ env[envName] = value;
7484
+ }
7485
+ const [cmd, ...args] = command;
7486
+ const run = deps.spawn ?? defaultSpawn;
7487
+ const code = run(cmd, args, env);
7488
+ return code === 0;
7489
+ }
7490
+ async function fetchSecretForUse(deps, { repo, key, slug }) {
7365
7491
  const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
7366
7492
  method: "POST",
7367
7493
  headers: await deps.headers({ "content-type": "application/json" }),
@@ -7373,23 +7499,19 @@ async function secretsUse(deps, key, opts) {
7373
7499
  if (res.status === 404 && body.code === "secret_not_found") {
7374
7500
  const guidance = resolveNotFoundGuidance({ key, repo, slug, report: await probeCapabilities(deps, repo) });
7375
7501
  for (const line of guidance.lines) deps.err(line);
7376
- return false;
7502
+ return null;
7377
7503
  }
7378
7504
  deps.err(
7379
7505
  await upgradeMessage(res, body) ?? (res.status === 403 ? `secrets use: not authorized for ${key} (HTTP 403) \u2014 ask the master for a \`secrets grant\`${errorDetail(body)}` : `secrets use failed: HTTP ${res.status}${errorDetail(body)}`)
7380
7506
  );
7381
- return false;
7507
+ return null;
7382
7508
  }
7383
7509
  const { value } = await res.json();
7384
7510
  if (!value) {
7385
7511
  deps.err(`secrets use: no value returned for ${key}`);
7386
- return false;
7512
+ return null;
7387
7513
  }
7388
- const envName = (opts.name ?? secretLeafName(key)).toUpperCase().replace(/[^A-Z0-9_]/g, "_");
7389
- const [cmd, ...args] = command;
7390
- const run = deps.spawn ?? defaultSpawn;
7391
- const code = run(cmd, args, { ...process.env, [envName]: value });
7392
- return code === 0;
7514
+ return value;
7393
7515
  }
7394
7516
 
7395
7517
  // src/report.ts
@@ -9251,9 +9373,10 @@ function checkMarketplaceAutoUpdate(probe) {
9251
9373
  if (!probe) return null;
9252
9374
  const evidence = [`${probe.name}: auto-update ${probe.effective ? "on" : "off"} \u2014 ${probe.reason}`];
9253
9375
  if (probe.effective) {
9254
- return { ok: true, label: `marketplace auto-update (${probe.name})`, detail: "on", verbose: evidence };
9376
+ return { id: "marketplace-autoupdate", ok: true, label: `marketplace auto-update (${probe.name})`, detail: "on", verbose: evidence };
9255
9377
  }
9256
9378
  return {
9379
+ id: "marketplace-autoupdate",
9257
9380
  ok: true,
9258
9381
  warn: true,
9259
9382
  label: `marketplace auto-update (${probe.name})`,
@@ -9266,18 +9389,24 @@ function checkMarketplaceCatalogRef(probe) {
9266
9389
  const label = `marketplace catalog ref (${probe.name})`;
9267
9390
  const evidence = [`${probe.name}: ${probe.reason}`, `auto-update: ${probe.autoUpdate ? "on" : "off"}`];
9268
9391
  if (probe.agrees) {
9269
- return { ok: true, label, detail: `${probe.ref}`, verbose: evidence };
9392
+ return { id: "marketplace-catalog-ref", ok: true, label, detail: `${probe.ref}`, verbose: evidence };
9270
9393
  }
9271
9394
  const drift = probe.unpinned ? "unpinned" : `pinned to ${probe.ref}, not ${CATALOG_CONTENT_REF}`;
9272
9395
  if (probe.autoUpdate) {
9273
9396
  return {
9397
+ id: "marketplace-catalog-ref",
9274
9398
  ok: false,
9275
9399
  label,
9276
- fix: `${drift} while auto-update is ON \u2014 this machine installs plugin releases advertised by a branch nobody released; ${CATALOG_REF_PIN_STEPS}`,
9400
+ // #3574: `drift` moves to `detail`, matching the warn branch below, which always had it there. The
9401
+ // ✗ branch carried it in `fix` only because `renderCheckLine` discarded `detail` on failure — one
9402
+ // row, two conventions, decided by which field would actually print.
9403
+ detail: `${drift} while auto-update is ON`,
9404
+ fix: `this machine installs plugin releases advertised by a branch nobody released; ${CATALOG_REF_PIN_STEPS}`,
9277
9405
  verbose: evidence
9278
9406
  };
9279
9407
  }
9280
9408
  return {
9409
+ id: "marketplace-catalog-ref",
9281
9410
  ok: true,
9282
9411
  warn: true,
9283
9412
  label,
@@ -9605,6 +9734,23 @@ function describePrMergeEnqueuedReason(checks) {
9605
9734
  };
9606
9735
  }
9607
9736
  }
9737
+ function decidePrMergeCleanupGate(input) {
9738
+ const state = (input.state ?? "").trim().toUpperCase();
9739
+ if (state === "MERGED") return { action: "cleanup" };
9740
+ if (!state) {
9741
+ return {
9742
+ action: "refuse",
9743
+ reason: "state-unreadable",
9744
+ message: `could not read the PR's state after the merge attempt${input.stateError ? `: ${input.stateError}` : ""} \u2014 cleanup skipped because the merge is unproven`
9745
+ };
9746
+ }
9747
+ if (input.autoRequested) return { action: "enqueued" };
9748
+ return {
9749
+ action: "refuse",
9750
+ reason: "not-merged",
9751
+ message: `the PR is ${state}, not MERGED \u2014 the merge did not happen, so the branch and worktree were left untouched`
9752
+ };
9753
+ }
9608
9754
  function parseGhPrChecksOutput(stdout) {
9609
9755
  const text = stdout.trim();
9610
9756
  if (!text) return "no-checks-reported";
@@ -17716,10 +17862,30 @@ function registerSecretsCommands(program3) {
17716
17862
  if (!ok) process.exitCode = 1;
17717
17863
  }));
17718
17864
  secrets.command("rm <key>").description("remove a secret from your own full project vault; org-infra requires master or an exact grant. --slug _org removes an org-infra <provider>/<KEY> value (#3315)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--slug <slug>", "org-infra namespace (e.g. _org) for the value removal \u2014 master or an exact grant (#3315)").action((key, o) => withSecrets((d) => secretsRemove(d, key, o)));
17719
- secrets.command("use <key> [command...]").description("consume a secret KEYLESS: own-project full tree or an exactly granted org-infra key; injects into command env and never prints it. The wrapped command keeps its OWN flags (`-- node -e \u2026`, `-- curl -H \u2026`) with or without the `--`, because npm's PowerShell shim swallows `--` before the CLI sees it (#3436)").allowUnknownOption().option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--slug <slug>", "org-infra namespace (e.g. _org) for a granted org secret").option("--name <ENVVAR>", "env var name to inject under (default: the key leaf, UPPER_SNAKE)").action((key, command, o) => withSecrets(async (d) => {
17720
- const ok = await secretsUse(d, key, { ...o, command });
17721
- if (ok === false) process.exitCode = 1;
17722
- }));
17865
+ secrets.command("use").usage("<key> | --key <key> [--key <key>\u2026] [-- <command>] [--repo <owner/repo>] [--slug <slug>] [--name <ENVVAR>]").argument("[args...]", "one secret key (or use --key), then `--` and the command to run").description("consume secrets KEYLESS: own-project full tree or exactly granted org-infra keys; injects each into the command env and never prints one. ONE positional key, or several via repeated `--key` (#3561). The wrapped command keeps its OWN flags (`-- node -e \u2026`, `-- curl -H \u2026`) with or without the `--`, because npm's PowerShell shim swallows `--` before the CLI sees it (#3436)").allowUnknownOption().helpOption(false).addHelpText("after", [
17866
+ "",
17867
+ "Options:",
17868
+ " --key <key> a secret key; repeat for several. The only form no shell can misread \u2014",
17869
+ " `npm` and `slack/token` are BOTH valid key names and plausible commands.",
17870
+ " --repo <owner/repo> target repo (defaults to the current repo)",
17871
+ " --slug <slug> org-infra namespace (e.g. _org) for a granted org secret",
17872
+ " --name <ENVVAR> env var to inject under, single key only (default: the key leaf, UPPER_SNAKE)",
17873
+ "",
17874
+ "These belong BEFORE the `--`. Everything after it is the wrapped command, untouched."
17875
+ ].join("\n")).action(function() {
17876
+ const parsed = parseSecretsUseArgv(secretsUseTail(process.argv));
17877
+ if (parsed.kind === "help") return this.outputHelp();
17878
+ return withSecrets(async (d) => {
17879
+ if (parsed.kind === "error") {
17880
+ d.err(parsed.error);
17881
+ process.exitCode = 1;
17882
+ return;
17883
+ }
17884
+ const { keys, command, ...o } = parsed.value;
17885
+ const ok = await secretsUse(d, keys, { ...o, command });
17886
+ if (ok === false) process.exitCode = 1;
17887
+ });
17888
+ });
17723
17889
  secrets.command("grant <repo> <login> <key>").description("MASTER-ONLY: grant a project-admin standing access to one specific org-infra secret").action((repo, login, key) => withSecrets((d) => secretsGrant(d, repo, login, key, {})));
17724
17890
  secrets.command("revoke <repo> <login> <key>").description("MASTER-ONLY: withdraw a previously granted org-infra secret access").action((repo, login, key) => withSecrets((d) => secretsRevoke(d, repo, login, key, {})));
17725
17891
  }
@@ -17864,11 +18030,11 @@ function renderPoolDetail(probe) {
17864
18030
  function checkGithubPools(probe) {
17865
18031
  if (!probe) return [];
17866
18032
  const lines = [];
17867
- lines.push(probe.personal ? { ok: true, label: "github pools (personal)", detail: renderPoolDetail(probe.personal) } : { ok: false, label: "github pools (personal)", fix: "rate_limit unreadable \u2014 check `gh auth status`" });
18033
+ lines.push(probe.personal ? { id: "github-pools-personal", ok: true, label: "github pools (personal)", detail: renderPoolDetail(probe.personal) } : { id: "github-pools-personal", ok: false, label: "github pools (personal)", detail: "rate_limit unreadable", fix: "check `gh auth status`" });
17868
18034
  if (typeof probe.app === "string") {
17869
- lines.push({ ok: false, label: "github pools (app actor)", fix: probe.app });
18035
+ lines.push({ id: "github-pools-app", ok: false, label: "github pools (app actor)", fix: probe.app });
17870
18036
  } else if (probe.app) {
17871
- lines.push({ ok: true, label: "github pools (app actor)", detail: renderPoolDetail(probe.app) });
18037
+ lines.push({ id: "github-pools-app", ok: true, label: "github pools (app actor)", detail: renderPoolDetail(probe.app) });
17872
18038
  }
17873
18039
  return lines;
17874
18040
  }
@@ -18096,6 +18262,92 @@ var import_node_child_process10 = require("node:child_process");
18096
18262
  var import_node_util7 = require("node:util");
18097
18263
 
18098
18264
  // src/schedules.ts
18265
+ function cronFieldValues(field, min, max) {
18266
+ const out = /* @__PURE__ */ new Set();
18267
+ for (const part of field.split(",")) {
18268
+ const [rangePart, stepPart] = part.split("/");
18269
+ const step = stepPart === void 0 ? 1 : Number(stepPart);
18270
+ if (!Number.isInteger(step) || step < 1) return null;
18271
+ let lo;
18272
+ let hi;
18273
+ if (rangePart === "*" || rangePart === "?") {
18274
+ lo = min;
18275
+ hi = max;
18276
+ } else if (rangePart.includes("-")) {
18277
+ const [a, b] = rangePart.split("-");
18278
+ lo = Number(a);
18279
+ hi = Number(b);
18280
+ } else {
18281
+ lo = Number(rangePart);
18282
+ hi = Number(rangePart);
18283
+ }
18284
+ if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) return null;
18285
+ for (let v = lo; v <= hi; v += step) out.add(v);
18286
+ }
18287
+ return out.size ? out : null;
18288
+ }
18289
+ function parseCronSpec(raw) {
18290
+ const inner = raw.trim().replace(/^cron\(/i, "").replace(/\)$/, "").trim();
18291
+ const f = inner.split(/\s+/);
18292
+ if (f.length !== 5 && f.length !== 6) return null;
18293
+ const aws = f.length === 6;
18294
+ const [minute, hour, dom, month, dowRaw] = f;
18295
+ const minutes = cronFieldValues(minute, 0, 59);
18296
+ const hours = cronFieldValues(hour, 0, 23);
18297
+ const doms = cronFieldValues(dom, 1, 31);
18298
+ const months = cronFieldValues(month, 1, 12);
18299
+ const dows = cronFieldValues(dowRaw, aws ? 1 : 0, aws ? 7 : 7);
18300
+ if (!minutes || !hours || !doms || !months || !dows) return null;
18301
+ const normalizedDow = new Set([...dows].map((d) => aws ? d - 1 : d % 7));
18302
+ return {
18303
+ minute: minutes,
18304
+ hour: hours,
18305
+ dom: doms,
18306
+ month: months,
18307
+ dow: normalizedDow,
18308
+ domAny: dom === "*" || dom === "?",
18309
+ dowAny: dowRaw === "*" || dowRaw === "?"
18310
+ };
18311
+ }
18312
+ function cronMatches(spec, d) {
18313
+ if (!spec.minute.has(d.getUTCMinutes())) return false;
18314
+ if (!spec.hour.has(d.getUTCHours())) return false;
18315
+ if (!spec.month.has(d.getUTCMonth() + 1)) return false;
18316
+ const domHit = spec.dom.has(d.getUTCDate());
18317
+ const dowHit = spec.dow.has(d.getUTCDay());
18318
+ if (spec.domAny && spec.dowAny) return true;
18319
+ if (spec.domAny) return dowHit;
18320
+ if (spec.dowAny) return domHit;
18321
+ return domHit || dowHit;
18322
+ }
18323
+ var FIRST_TICK_SCAN_DAYS = 400;
18324
+ function scheduleTickState(cadence, armedAt, now) {
18325
+ if (!cadence || !armedAt) return {};
18326
+ const armed = /* @__PURE__ */ new Date(`${armedAt}T00:00:00Z`);
18327
+ if (Number.isNaN(armed.getTime())) return {};
18328
+ const specs = splitCadence(cadence).flatMap((c) => [...c.match(/cron\([^)]*\)/gi) ?? [], ...extractCronTokens(c)]).map(parseCronSpec).filter((s) => s !== null);
18329
+ if (!specs.length) return {};
18330
+ const cursor = new Date(armed.getTime());
18331
+ cursor.setUTCSeconds(0, 0);
18332
+ const limit = armed.getTime() + FIRST_TICK_SCAN_DAYS * 864e5;
18333
+ while (cursor.getTime() <= limit) {
18334
+ if (specs.some((s) => cronMatches(s, cursor))) {
18335
+ return { firstTickDue: cursor.toISOString(), ticked: cursor.getTime() <= now.getTime() };
18336
+ }
18337
+ cursor.setTime(cursor.getTime() + 6e4);
18338
+ }
18339
+ return {};
18340
+ }
18341
+ var WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
18342
+ function resolvedLabel(entry, now) {
18343
+ if (entry.resolved !== "live") return entry.resolved;
18344
+ const { firstTickDue, ticked } = scheduleTickState(entry.cadence, entry.armedAt, now);
18345
+ if (!firstTickDue) return entry.resolved;
18346
+ const due = new Date(firstTickDue);
18347
+ const armedDay = WEEKDAY[(/* @__PURE__ */ new Date(`${entry.armedAt}T00:00:00Z`)).getUTCDay()];
18348
+ const dueStamp = `${firstTickDue.slice(0, 16).replace("T", " ")}Z`;
18349
+ return ticked ? `live \u2014 first tick was due ${dueStamp}` : `live \u2014 awaiting first tick ${dueStamp} (armed ${armedDay}, fires ${WEEKDAY[due.getUTCDay()]})`;
18350
+ }
18099
18351
  var ORG = "mutmutco";
18100
18352
  var DOC_START_MARKER = "<!-- schedules:inventory:start -->";
18101
18353
  var DOC_END_MARKER = "<!-- schedules:inventory:end -->";
@@ -18253,19 +18505,23 @@ function sortEntries(entries) {
18253
18505
  (a, b) => a.executor.localeCompare(b.executor) || a.name.localeCompare(b.name)
18254
18506
  );
18255
18507
  }
18256
- function formatSchedulesTable(entries) {
18508
+ function formatSchedulesTable(entries, now = /* @__PURE__ */ new Date()) {
18257
18509
  if (!entries.length) return "no armed org schedules found";
18510
+ const resolvedOf = new Map(entries.map((e) => [e, resolvedLabel(e, now)]));
18258
18511
  const w = (pick, head) => Math.max(head.length, ...entries.map((e) => pick(e).length));
18259
18512
  const nameW = w((e) => e.name, "JOB");
18260
18513
  const cadW = w((e) => e.cadence, "CADENCE");
18261
18514
  const exeW = w((e) => e.executor, "EXECUTOR");
18262
18515
  const llmW = w((e) => e.llm, "LLM");
18263
- const resW = w((e) => e.resolved, "RESOLVED");
18516
+ const resW = w((e) => resolvedOf.get(e) ?? e.resolved, "RESOLVED");
18264
18517
  const row = (n, c, x, l, r, s) => `${n.padEnd(nameW)} ${c.padEnd(cadW)} ${x.padEnd(exeW)} ${l.padEnd(llmW)} ${r.padEnd(resW)} ${s}`;
18265
18518
  const lines = [row("JOB", "CADENCE", "EXECUTOR", "LLM", "RESOLVED", "SOURCE")];
18266
- for (const e of entries) lines.push(row(e.name, e.cadence, e.executor, e.llm, e.resolved, e.source));
18519
+ for (const e of entries) lines.push(row(e.name, e.cadence, e.executor, e.llm, resolvedOf.get(e) ?? e.resolved, e.source));
18267
18520
  return lines.join("\n");
18268
18521
  }
18522
+ function withTickState(entry, now = /* @__PURE__ */ new Date()) {
18523
+ return { ...entry, ...scheduleTickState(entry.cadence, entry.armedAt, now) };
18524
+ }
18269
18525
  function renderDocSection(entries, generatedAtIso) {
18270
18526
  const lines = [
18271
18527
  "| Job | Cadence | Executor | LLM | Resolved | Source |",
@@ -18625,7 +18881,8 @@ function registerSchedulesCommands(program3) {
18625
18881
  return;
18626
18882
  }
18627
18883
  if (o.json) {
18628
- console.log(JSON.stringify({ org: entries, incomplete, drift, reconciliation }, null, 2));
18884
+ const now = /* @__PURE__ */ new Date();
18885
+ console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation }, null, 2));
18629
18886
  } else {
18630
18887
  console.log(formatSchedulesTable(entries));
18631
18888
  reportDrift(drift);
@@ -21808,6 +22065,97 @@ function formatStaleLeaks(leaks) {
21808
22065
  async function repoRootOf() {
21809
22066
  return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
21810
22067
  }
22068
+ function classifyLandBranchMergeState(prs) {
22069
+ if (!prs) return { state: "unknown", numbers: [] };
22070
+ if (!prs.length) return { state: "no-pr", numbers: [] };
22071
+ const pick = (s) => prs.filter((pr2) => pr2.state.toUpperCase() === s).map((pr2) => pr2.number);
22072
+ const open2 = pick("OPEN");
22073
+ if (open2.length) return { state: "open", numbers: open2 };
22074
+ const known = /* @__PURE__ */ new Set(["OPEN", "CLOSED", "MERGED"]);
22075
+ const unrecognised = prs.filter((pr2) => !known.has(pr2.state.toUpperCase()));
22076
+ if (unrecognised.length) return { state: "unknown", numbers: prs.map((pr2) => pr2.number) };
22077
+ const merged = pick("MERGED");
22078
+ if (!merged.length) return { state: "closed-unmerged", numbers: prs.map((pr2) => pr2.number) };
22079
+ if (merged.length < prs.length) return { state: "mixed", numbers: prs.map((pr2) => pr2.number) };
22080
+ return { state: "merged", numbers: merged };
22081
+ }
22082
+ function describeLandMergeState(v) {
22083
+ const refs = v.numbers.map((n) => `#${n}`).join(", ");
22084
+ switch (v.state) {
22085
+ case "merged":
22086
+ return `PR ${refs} merged`;
22087
+ case "closed-unmerged":
22088
+ return `PR ${refs} CLOSED \u2014 NOT merged`;
22089
+ case "mixed":
22090
+ return `PR ${refs} \u2014 some merged, some CLOSED unmerged; which one owns the branch head is unproven`;
22091
+ case "open":
22092
+ return `PR ${refs} still OPEN \u2014 NOT merged`;
22093
+ case "no-pr":
22094
+ return "no pull request for this branch";
22095
+ case "unknown":
22096
+ return refs ? `PR ${refs} reported a pull-request state this CLI does not recognise \u2014 merge NOT confirmed` : "pull-request state could not be read \u2014 merge NOT confirmed";
22097
+ }
22098
+ }
22099
+ function decideWorktreeLandRefCleanup(input) {
22100
+ if (input.keepRemote) return { action: "proceed" };
22101
+ const verdict = classifyLandBranchMergeState(input.prs);
22102
+ const refs = verdict.numbers.map((n) => `#${n}`).join(", ");
22103
+ if (verdict.state === "unknown") {
22104
+ return verdict.numbers.length ? {
22105
+ action: "keep-remote",
22106
+ reason: "pr-state-unrecognised",
22107
+ message: `pull request ${refs} for '${input.branch}' reported a state this CLI does not recognise \u2014 keeping the remote branch rather than deleting a ref whose merge is unproven`
22108
+ } : {
22109
+ action: "keep-remote",
22110
+ reason: "pr-state-unreadable",
22111
+ message: `could not read the pull-request state for '${input.branch}' \u2014 keeping the remote branch so an unmerged head ref cannot be deleted on a guess`
22112
+ };
22113
+ }
22114
+ if (verdict.state === "open") {
22115
+ return {
22116
+ action: "refuse",
22117
+ reason: "open-pr",
22118
+ message: `'${input.branch}' still has an OPEN pull request (${refs}). Deleting its head ref would auto-close the PR and leave the work on no branch. Merge it first, or re-run with --keep-remote for a local-only clean`
22119
+ };
22120
+ }
22121
+ if (verdict.state === "mixed") {
22122
+ return {
22123
+ action: "keep-remote",
22124
+ reason: "mixed-pr-state",
22125
+ message: `'${input.branch}' has both a merged and a closed-unmerged pull request (${refs}) and nothing here proves which owns the current head \u2014 keeping the remote branch rather than deleting a ref that may hold unmerged work`
22126
+ };
22127
+ }
22128
+ if (verdict.state === "closed-unmerged") {
22129
+ return {
22130
+ action: "refuse",
22131
+ reason: "unmerged-pr",
22132
+ message: `'${input.branch}' has a pull request that never merged (${refs}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
22133
+ };
22134
+ }
22135
+ return { action: "proceed" };
22136
+ }
22137
+ async function fetchLandBranchPrs(branch, repo) {
22138
+ try {
22139
+ const { stdout } = await execFileP2("gh", [
22140
+ "pr",
22141
+ "list",
22142
+ "--head",
22143
+ branch,
22144
+ "--state",
22145
+ "all",
22146
+ "--limit",
22147
+ "20",
22148
+ ...repo ? ["--repo", repo] : [],
22149
+ "--json",
22150
+ "number,state"
22151
+ ], { timeout: GH_TIMEOUT_MS });
22152
+ const parsed = JSON.parse(stdout.trim() || "null");
22153
+ if (!Array.isArray(parsed)) return void 0;
22154
+ return parsed.filter((pr2) => Boolean(pr2) && typeof pr2 === "object" && typeof pr2.number === "number" && typeof pr2.state === "string").map((pr2) => ({ number: pr2.number, state: pr2.state }));
22155
+ } catch {
22156
+ return void 0;
22157
+ }
22158
+ }
21811
22159
  async function fetchIssueTitle(repo, number) {
21812
22160
  try {
21813
22161
  const { stdout } = await execFileP2("gh", ["issue", "view", String(number), "--repo", repo, "--json", "title", "--jq", ".title"], { timeout: GH_TIMEOUT_MS });
@@ -21869,6 +22217,18 @@ function registerWorktreeCommands(program3) {
21869
22217
  if (landDirty) {
21870
22218
  return fail(`worktree land: refusing to land '${branch}' \u2014 uncommitted changes in ${wtPath}; commit, stash, or remove the worktree manually`, { code: ERROR_CODES.ERR_BAD_ENUM });
21871
22219
  }
22220
+ const landPrs = await fetchLandBranchPrs(branch);
22221
+ const mergeVerdict = classifyLandBranchMergeState(landPrs);
22222
+ const landRefs = decideWorktreeLandRefCleanup({
22223
+ branch,
22224
+ prs: landPrs,
22225
+ keepRemote: Boolean(o.keepRemote)
22226
+ });
22227
+ if (landRefs.action === "refuse") {
22228
+ return fail(`worktree land: ${landRefs.message}`, { code: ERROR_CODES.ERR_BAD_ENUM });
22229
+ }
22230
+ const keepRemoteBranch = Boolean(o.keepRemote) || landRefs.action === "keep-remote";
22231
+ if (landRefs.action === "keep-remote") console.warn(`worktree land: ${landRefs.message}.`);
21872
22232
  const report = [];
21873
22233
  if (hasStage) {
21874
22234
  try {
@@ -21888,14 +22248,28 @@ function registerWorktreeCommands(program3) {
21888
22248
  status: removeOutcome.status === "removed" ? removeOutcome.recovery ? `done (${removeOutcome.recovery})` : "done" : `failed: ${removeOutcome.error ?? "lock held"} \u2014 run: git -C "${primaryCheckout}" worktree remove --force "${wtPath}"`
21889
22249
  });
21890
22250
  report.push(await bestEffortGit(["branch", "-D", branch], primaryCheckout, `delete local branch ${branch}`));
21891
- if (!o.keepRemote) {
22251
+ if (!keepRemoteBranch) {
21892
22252
  report.push(await bestEffortGit(["push", o.remote, "--delete", branch], void 0, `delete remote branch ${branch}`, GH_MUTATION_TIMEOUT_MS));
22253
+ } else if (!o.keepRemote) {
22254
+ report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
21893
22255
  }
21894
22256
  report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
21895
- const result = { dryRun: false, ...plan, ...o.keepRemote ? { keepRemote: true } : {}, report };
22257
+ const result = {
22258
+ dryRun: false,
22259
+ ...plan,
22260
+ ...o.keepRemote ? { keepRemote: true } : {},
22261
+ // #3566: the merge outcome, machine-readable, so an orchestrator never has to infer it from the
22262
+ // absence of an error. `merged` is the ONLY value that means the work reached the base branch.
22263
+ mergeState: mergeVerdict.state,
22264
+ prNumbers: mergeVerdict.numbers,
22265
+ report
22266
+ };
21896
22267
  if (o.json) return console.log(JSON.stringify(result, null, 2));
21897
- console.log(`worktree land: cleaned up branch ${branch}`);
22268
+ console.log(`worktree land: cleaned up branch ${branch} (${describeLandMergeState(mergeVerdict)})`);
21898
22269
  for (const r of report) console.log(` ${r.step}: ${r.status}`);
22270
+ if (mergeVerdict.state !== "merged") {
22271
+ console.warn(`worktree land: '${branch}' was cleaned up but NOT merged \u2014 ${describeLandMergeState(mergeVerdict)}. Do not report this work as shipped.`);
22272
+ }
21899
22273
  if (report.some((r) => r.status.startsWith("failed"))) process.exitCode = 1;
21900
22274
  } catch (e) {
21901
22275
  return failGraceful(`worktree land: ${e.message}`);
@@ -22642,8 +23016,11 @@ var PLUGIN_SURFACE_HEAL = {
22642
23016
  updateRecipe: [CLAUDE_RECOVERY]
22643
23017
  }
22644
23018
  };
22645
- function nonClaudeSurfaceHealMessage() {
22646
- return "No Hub-shipped MMI plugin on this host \u2014 the Hub is Claude-only since #2741.\n Update the CLI instead: npm i -g @mutmutco/cli (org rules ride an AGENTS.md authored outside the Hub)";
23019
+ function nonClaudeSurfaceHealMessage(surface) {
23020
+ if (surface === "codex") {
23021
+ return "Codex ships an MMI plugin (#3563). Install or repair it with:\n codex plugin marketplace add mutmutco/MMI-Hub && codex plugin add mmi@mutmutco\n Then run /hooks and TRUST the MMI hooks \u2014 bundled hooks are skipped until reviewed.\n Update the CLI too: npm i -g @mutmutco/cli";
23022
+ }
23023
+ return "No Hub-shipped MMI plugin on this host \u2014 the Hub ships plugins for Claude and Codex only.\n Update the CLI instead: npm i -g @mutmutco/cli (org rules ride an AGENTS.md authored outside the Hub)";
22647
23024
  }
22648
23025
  function healStepAborts(step, ok) {
22649
23026
  return !ok && step.gated;
@@ -22840,7 +23217,7 @@ async function runGuard(readOrigin) {
22840
23217
  }
22841
23218
  async function runPluginHeal(surface = detectSurface(process.env)) {
22842
23219
  if (surfaceToken(surface) !== "claude") {
22843
- console.log(nonClaudeSurfaceHealMessage());
23220
+ console.log(nonClaudeSurfaceHealMessage(surfaceToken(surface) ?? void 0));
22844
23221
  return;
22845
23222
  }
22846
23223
  const descriptor = PLUGIN_SURFACE_HEAL.claude;
@@ -23982,7 +24359,9 @@ function renderCheckLine(check) {
23982
24359
  if (check.ok) {
23983
24360
  return check.detail ? `\u2713 ${check.label} \u2014 ${check.detail}` : `\u2713 ${check.label}`;
23984
24361
  }
23985
- return check.fix ? `\u2717 ${check.label} \u2014 ${check.fix}` : `\u2717 ${check.label}`;
24362
+ const head = check.detail ? `\u2717 ${check.label} \u2014 ${check.detail}` : `\u2717 ${check.label}`;
24363
+ return check.fix ? `${head}
24364
+ ${VERBOSE_INDENT}\u2192 ${check.fix}` : head;
23986
24365
  }
23987
24366
  function renderReport(checks, opts) {
23988
24367
  const lines = checks.map(renderCheckLine);
@@ -23996,7 +24375,8 @@ function renderTally(checks) {
23996
24375
  }
23997
24376
  function renderDoctorText(checks, opts) {
23998
24377
  const lines = [];
23999
- for (const check of checks) {
24378
+ const shown = opts.verbose ? checks : checks.filter((c) => !c.ok || c.warn);
24379
+ for (const check of shown) {
24000
24380
  lines.push(renderCheckLine(check));
24001
24381
  if (opts.verbose) {
24002
24382
  for (const evidence of check.verbose ?? []) lines.push(`${VERBOSE_INDENT}${evidence}`);
@@ -24019,6 +24399,7 @@ function checkGithubAuth(probe) {
24019
24399
  const rescuers = reach.otherAccountsThatCanSee ?? [];
24020
24400
  return {
24021
24401
  ok: false,
24402
+ id: "github-auth",
24022
24403
  label: "github auth",
24023
24404
  fix: rescuers.length ? `the active gh account (${login}) cannot see ${reach.repo}; another account is logged in \u2014 try: gh auth switch --user ${rescuers[0]}` : `the active gh account (${login}) cannot see ${reach.repo} \u2014 check org membership, or: gh auth login`,
24024
24405
  verbose: [
@@ -24031,6 +24412,7 @@ function checkGithubAuth(probe) {
24031
24412
  }
24032
24413
  return {
24033
24414
  ok: authed,
24415
+ id: "github-auth",
24034
24416
  label: "github auth",
24035
24417
  fix: probe.ghInstalled ? 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"' : "install GitHub CLI (https://cli.github.com), then: gh auth login",
24036
24418
  verbose: [
@@ -24046,6 +24428,7 @@ function checkAwsIdentity(probe) {
24046
24428
  const arn = probe.callerArn?.trim();
24047
24429
  return {
24048
24430
  ok: !arn || !arn.endsWith(":root"),
24431
+ id: "aws-identity",
24049
24432
  label: "aws identity",
24050
24433
  fix: "use a non-root IAM user/session profile (set AWS_PROFILE or run: aws sso login), then verify `aws sts get-caller-identity` is not :root",
24051
24434
  // Name the caller. A green "aws identity" says only "not root" — it does not say WHO, and "no AWS
@@ -24060,6 +24443,7 @@ function checkAwsIdentity(probe) {
24060
24443
  function checkRepoWorktrees(probe) {
24061
24444
  return {
24062
24445
  ok: !(probe.isOrgRepo && probe.hasRepoLocalWorktrees),
24446
+ id: "repo-worktrees",
24063
24447
  label: "repo worktrees",
24064
24448
  fix: "repo-local `.worktrees/` is not the canonical path \u2014 use `mmi-cli worktree create <branch>` (sibling `../worktrees/`), then remove the in-repo `.worktrees/`",
24065
24449
  verbose: [
@@ -24078,11 +24462,12 @@ function checkWorktreeRoots(probe) {
24078
24462
  `scanned by worktree gc/list: ${probe.authoritative}`,
24079
24463
  ...probe.stray.length ? probe.stray.map((s) => `unreachable by any gc: ${s.path} \u2014 ${s.dirs} dir(s), ${formatBytes(s.bytes, s.partial)}`) : ["no other *worktrees* directory beside this repo"]
24080
24464
  ];
24081
- if (!probe.stray.length) return { ok: true, label: "worktree roots", verbose: evidence };
24465
+ if (!probe.stray.length) return { ok: true, id: "worktree-roots", label: "worktree roots", verbose: evidence };
24082
24466
  const named = probe.stray.map((s) => `${s.path} (${s.dirs} dir(s), ${formatBytes(s.bytes, s.partial)})`).join("; ");
24083
24467
  const cutShort = probe.stray.some((s) => s.partial) ? " (a \u2265 figure is a floor \u2014 the size walk hit its time budget, so the real total is larger)" : "";
24084
24468
  return {
24085
24469
  ok: false,
24470
+ id: "worktree-roots",
24086
24471
  label: "worktree roots",
24087
24472
  // #3485: report-only in EVERY mode means report-only in the exit code too. `worktree gc --root` does
24088
24473
  // exist and is the eventual fix, but this row is deliberately not a gate: the remedy is a human
@@ -24111,16 +24496,20 @@ function checkClaudePlugin(probe) {
24111
24496
  const trigger = pluginHealTrigger(probe);
24112
24497
  if (trigger === "unresolved") {
24113
24498
  return {
24499
+ id: "claude-plugin",
24114
24500
  ok: false,
24115
- label: guardState === "no-install" ? "Claude plugin \u2014 not installed" : "Claude plugin \u2014 unresolved (marketplace/cache missing)",
24501
+ label: "Claude plugin",
24502
+ detail: guardState === "no-install" ? "not installed" : "unresolved (marketplace/cache missing)",
24116
24503
  fix: "run `mmi-cli doctor --apply` (or `mmi-cli plugin heal`) to reinstall the MMI marketplace + plugin, then restart Claude",
24117
24504
  verbose: evidence
24118
24505
  };
24119
24506
  }
24120
24507
  if (trigger === "behind") {
24121
24508
  return {
24509
+ id: "claude-plugin",
24122
24510
  ok: false,
24123
- label: `Claude plugin ${installed} \u2192 ${released}`,
24511
+ label: "Claude plugin",
24512
+ detail: `${installed} \u2192 ${released}`,
24124
24513
  // Name the CLI verb that actually fixes it, like every other red row — `/plugin` is the manual
24125
24514
  // fallback, not the first resort (#3282).
24126
24515
  fix: "run `mmi-cli doctor --apply` (or `mmi-cli plugin heal`) to reinstall it, then restart Claude",
@@ -24128,7 +24517,7 @@ function checkClaudePlugin(probe) {
24128
24517
  };
24129
24518
  }
24130
24519
  if (!released) return null;
24131
- return { ok: true, label: installed ? `Claude plugin ${installed}` : "Claude plugin", verbose: evidence };
24520
+ return { id: "claude-plugin", ok: true, label: "Claude plugin", ...installed ? { detail: installed } : {}, verbose: evidence };
24132
24521
  }
24133
24522
  function checkCliVersion(input, releasedNote) {
24134
24523
  const report = buildVersionLagReport(input);
@@ -24137,10 +24526,12 @@ function checkCliVersion(input, releasedNote) {
24137
24526
  `published: ${report.releasedVersion ?? "(not checked \u2014 offline or --fast)"}${report.releasedVersion && releasedNote ? ` ${releasedNote}` : ""}`
24138
24527
  ];
24139
24528
  if (!report.releasedVersion) return null;
24140
- if (report.ok) return { ok: true, label: `mmi-cli ${report.currentVersion}`, verbose: evidence };
24529
+ if (report.ok) return { id: "cli-version", ok: true, label: "mmi-cli", detail: report.currentVersion, verbose: evidence };
24141
24530
  return {
24531
+ id: "cli-version",
24142
24532
  ok: false,
24143
- label: `mmi-cli ${report.currentVersion} \u2192 ${report.releasedVersion}`,
24533
+ label: "mmi-cli",
24534
+ detail: `${report.currentVersion} \u2192 ${report.releasedVersion}`,
24144
24535
  fix: report.fix,
24145
24536
  verbose: evidence
24146
24537
  };
@@ -24154,6 +24545,7 @@ function checkPluginCache(input) {
24154
24545
  if (input.stale.length === 0 && input.staging.length === 0) {
24155
24546
  return {
24156
24547
  ok: true,
24548
+ id: "plugin-cache",
24157
24549
  label: "plugin cache",
24158
24550
  detail: input.cached ? `${input.cached} version(s), nothing stale` : "no cache",
24159
24551
  verbose: evidence
@@ -24170,6 +24562,7 @@ function checkPluginCache(input) {
24170
24562
  }
24171
24563
  return {
24172
24564
  ok: false,
24565
+ id: "plugin-cache",
24173
24566
  label: "plugin cache",
24174
24567
  // #3485: deliberately NOT `reportOnly`, and not in the closed set docs/doctor-contract.md enumerates.
24175
24568
  // A checker argued it qualifies because no `doctor --apply` clears it; I tagged it, then reverted.
@@ -24188,11 +24581,19 @@ function checkTrainSync(result) {
24188
24581
  const evidence = result.branches.length ? result.branches.map((b) => `${b.branch}: ${b.status}${b.reason ? ` (${b.reason})` : ""}`) : ["no local train branches to sync"];
24189
24582
  const problems = result.branches.filter((b) => b.status === "skipped" && (b.reason === "diverged" || b.reason === "ff-failed"));
24190
24583
  if (problems.length) {
24191
- return { ok: false, label: "train branches", fix: problems.map((b) => b.note).join("; "), verbose: evidence };
24584
+ return {
24585
+ ok: false,
24586
+ id: "train-branches",
24587
+ label: "train branches",
24588
+ detail: problems.map((b) => `${b.branch} ${b.reason}`).join(", "),
24589
+ fix: problems.map((b) => b.note).join("; "),
24590
+ verbose: evidence
24591
+ };
24192
24592
  }
24193
24593
  const moved = result.branches.filter((b) => b.status === "synced");
24194
24594
  return {
24195
24595
  ok: true,
24596
+ id: "train-branches",
24196
24597
  label: "train branches",
24197
24598
  detail: moved.length ? `fast-forwarded ${moved.map((b) => b.branch).join(", ")}` : void 0,
24198
24599
  verbose: evidence
@@ -24209,6 +24610,7 @@ function checkSchedules(probe) {
24209
24610
  if (probe.incomplete.length) {
24210
24611
  return {
24211
24612
  ok: false,
24613
+ id: "schedules",
24212
24614
  label: "schedules",
24213
24615
  // #3485: ruled report-only — an unreadable source is a read failure somewhere in the org, and
24214
24616
  // nothing in this checkout clears it.
@@ -24221,6 +24623,7 @@ function checkSchedules(probe) {
24221
24623
  if (probe.drift.length) {
24222
24624
  return {
24223
24625
  ok: false,
24626
+ id: "schedules",
24224
24627
  label: "schedules",
24225
24628
  // #3485 ruled this row report-only because the drift classes are owned by other repos. #3492 makes
24226
24629
  // that conditional rather than blanket: when a finding IS clearable from this checkout — a
@@ -24233,16 +24636,17 @@ function checkSchedules(probe) {
24233
24636
  verbose: evidence
24234
24637
  };
24235
24638
  }
24236
- return { ok: true, label: "schedules", detail: `${probe.armed} armed`, verbose: evidence };
24639
+ return { ok: true, id: "schedules", label: "schedules", detail: `${probe.armed} armed`, verbose: evidence };
24237
24640
  }
24238
24641
  function checkDocsAudit(probe) {
24239
24642
  if (!probe) return null;
24240
24643
  if (!probe.armed) {
24241
- return { ok: true, label: "docs-audit", detail: "janitor not armed", verbose: [probe.detail] };
24644
+ return { ok: true, id: "docs-audit", label: "docs-audit", detail: "janitor not armed", verbose: [probe.detail] };
24242
24645
  }
24243
24646
  if (!probe.ok) {
24244
24647
  return {
24245
24648
  ok: false,
24649
+ id: "docs-audit",
24246
24650
  label: "docs-audit",
24247
24651
  // #3485: ruled report-only — the remedy is re-running or re-arming the janitor in the repo that
24248
24652
  // owns it. `mmi-cli docs audit record` can write a verdict from here, but hand-writing one to clear
@@ -24252,7 +24656,7 @@ function checkDocsAudit(probe) {
24252
24656
  verbose: [probe.detail]
24253
24657
  };
24254
24658
  }
24255
- return { ok: true, label: "docs-audit", detail: probe.detail, verbose: [probe.detail] };
24659
+ return { ok: true, id: "docs-audit", label: "docs-audit", detail: probe.detail, verbose: [probe.detail] };
24256
24660
  }
24257
24661
  function checkSessionPayload(probe) {
24258
24662
  if (!probe) return null;
@@ -24269,8 +24673,13 @@ function checkSessionPayload(probe) {
24269
24673
  if (status === "red") {
24270
24674
  return {
24271
24675
  ok: false,
24676
+ id: "sessionstart-payload",
24272
24677
  label: "SessionStart payload",
24273
- fix: `${chars} characters exceeds the ${SESSION_PAYLOAD_CAP_CHARS}-character additionalContext cap \u2014 Claude Code truncates it to a ~2,000-character preview with no error, so agents are starting on partial doctrine; cut a block from the session-start banner`,
24678
+ // #3574: the count belongs in `detail`, like the amber row below already had it. The red row put
24679
+ // it in `fix` because the ✗ branch used to discard `detail`, which also left the head rendering as
24680
+ // a bare `✗ SessionStart payload` with the one number that matters buried in the advice.
24681
+ detail: `${chars} characters, over the ${SESSION_PAYLOAD_CAP_CHARS}-character cap`,
24682
+ fix: `Claude Code truncates it to a ~2,000-character preview with no error, so agents are starting on partial doctrine; cut a block from the session-start banner`,
24274
24683
  verbose: evidence
24275
24684
  };
24276
24685
  }
@@ -24281,6 +24690,7 @@ function checkSessionPayload(probe) {
24281
24690
  // amber tier dead code on the one lane that runs every session — drift past the budget was
24282
24691
  // invisible until it became a truncation. Still ✓, still never moves the exit code.
24283
24692
  warn: true,
24693
+ id: "sessionstart-payload",
24284
24694
  label: "SessionStart payload",
24285
24695
  detail: `amber \u2014 ${chars} characters, over the ${SESSION_PAYLOAD_BUDGET_CHARS}-character budget and ${SESSION_PAYLOAD_CAP_CHARS - chars} from the hard cap`,
24286
24696
  verbose: evidence
@@ -24288,6 +24698,7 @@ function checkSessionPayload(probe) {
24288
24698
  }
24289
24699
  return {
24290
24700
  ok: true,
24701
+ id: "sessionstart-payload",
24291
24702
  label: "SessionStart payload",
24292
24703
  detail: `${chars} of ${SESSION_PAYLOAD_BUDGET_CHARS} budgeted characters`,
24293
24704
  verbose: evidence
@@ -24335,12 +24746,12 @@ async function runDoctorClean(opts, io, deps) {
24335
24746
  `managed block: ${gi.ok ? "present and current" : "missing or out of date"}`
24336
24747
  ];
24337
24748
  if (gi.ok) {
24338
- checks.push({ ok: true, label: "gitignore block", verbose: giEvidence });
24749
+ checks.push({ ok: true, id: "gitignore-block", label: "gitignore block", verbose: giEvidence });
24339
24750
  } else if (applyRepo && gi.content && deps.writeGitignore(gi.content)) {
24340
- checks.push({ ok: true, label: "gitignore block", detail: "rewrote managed block", verbose: giEvidence });
24751
+ checks.push({ ok: true, id: "gitignore-block", label: "gitignore block", detail: "rewrote managed block", verbose: giEvidence });
24341
24752
  restartPending = true;
24342
24753
  } else {
24343
- checks.push({ ok: false, label: "gitignore block", fix: "run `mmi-cli doctor --apply` to write the org-managed .gitignore block", verbose: giEvidence });
24754
+ checks.push({ ok: false, id: "gitignore-block", label: "gitignore block", fix: "run `mmi-cli doctor --apply` to write the org-managed .gitignore block", verbose: giEvidence });
24344
24755
  }
24345
24756
  }
24346
24757
  const releasedNote = deps.releasedVersionNote?.();
@@ -24348,7 +24759,7 @@ async function runDoctorClean(opts, io, deps) {
24348
24759
  const healTrigger = pluginHealTrigger(pluginProbe);
24349
24760
  if (applyEnv && deps.healPlugin && healTrigger) {
24350
24761
  const heal = await deps.healPlugin();
24351
- const label = healTrigger === "behind" ? `Claude plugin ${installed} \u2192 ${released}` : "Claude plugin \u2014 reinstalled";
24762
+ const measured = healTrigger === "behind" ? `${installed} \u2192 ${released}` : "unresolved install";
24352
24763
  const healEvidence = [
24353
24764
  `installed: ${installed ?? "(none)"}`,
24354
24765
  `released: ${released ?? "(not checked \u2014 offline or --fast)"}`,
@@ -24356,13 +24767,16 @@ async function runDoctorClean(opts, io, deps) {
24356
24767
  `heal: ${heal.detail}`
24357
24768
  ];
24358
24769
  checks.push(heal.ok ? {
24770
+ id: "claude-plugin",
24359
24771
  ok: true,
24360
- label,
24361
- detail: "reinstalled via the MMI marketplace (remove \u2192 add \u2192 install)",
24772
+ label: "Claude plugin",
24773
+ detail: `${measured} \u2014 reinstalled via the MMI marketplace (remove \u2192 add \u2192 install)`,
24362
24774
  verbose: healEvidence
24363
24775
  } : {
24776
+ id: "claude-plugin",
24364
24777
  ok: false,
24365
- label,
24778
+ label: "Claude plugin",
24779
+ detail: measured,
24366
24780
  // #3489: a heal skipped because another doctor holds the env-heal lock is a real ✗ — this run did
24367
24781
  // not fix what it found — but it is not this invocation's to clear. The other process is doing it;
24368
24782
  // waiting is the correct response and a re-run finds it healed. A heal that RAN and failed is a
@@ -24385,13 +24799,16 @@ async function runDoctorClean(opts, io, deps) {
24385
24799
  const heal = await deps.updateCli(cliReport.releasedVersion);
24386
24800
  const healEvidence = [`running: ${cliReport.currentVersion}`, `published: ${cliReport.releasedVersion}`, `heal: ${heal.detail}`];
24387
24801
  checks.push(heal.ok ? {
24802
+ id: "cli-version",
24388
24803
  ok: true,
24389
- label: `mmi-cli ${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
24390
- detail: "self-updated via npm install -g; the next mmi-cli invocation runs the new version",
24804
+ label: "mmi-cli",
24805
+ detail: `${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion} \u2014 self-updated via npm install -g; the next mmi-cli invocation runs the new version`,
24391
24806
  verbose: healEvidence
24392
24807
  } : {
24808
+ id: "cli-version",
24393
24809
  ok: false,
24394
- label: `mmi-cli ${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
24810
+ label: "mmi-cli",
24811
+ detail: `${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
24395
24812
  // #3489: same split as the plugin heal above — a lock-contention skip is not this run's gap.
24396
24813
  ...heal.skipped ? { reportOnly: true } : {},
24397
24814
  fix: heal.skipped ? `${heal.detail} \u2014 another doctor on this machine is updating it now; re-run once it finishes` : `self-update failed (${heal.detail}) \u2014 run \`${cliUpdateCommand(cliReport.releasedVersion)}\``,
@@ -24439,6 +24856,7 @@ async function runDoctorClean(opts, io, deps) {
24439
24856
  const message = e instanceof Error ? e.message : String(e);
24440
24857
  checks.push({
24441
24858
  ok: false,
24859
+ id: "train-branches",
24442
24860
  label: "train branches",
24443
24861
  fix: `sync failed \u2014 ${message}`,
24444
24862
  verbose: [`sync threw: ${message}`, "no train branch was fast-forwarded this run"]
@@ -24455,6 +24873,7 @@ async function runDoctorClean(opts, io, deps) {
24455
24873
  if (r.removedWorktreeDirs.length) detailParts.push(`${r.removedWorktreeDirs.length} dead worktrees`);
24456
24874
  if (r.removedTrackingRefs.length) detailParts.push(`${r.removedTrackingRefs.length} stale refs`);
24457
24875
  checks.push({
24876
+ id: "branches-worktrees",
24458
24877
  ok: true,
24459
24878
  label: "branches / worktrees",
24460
24879
  detail: reaped ? `reaped ${detailParts.join(", ")}` : "nothing stale",
@@ -24476,10 +24895,14 @@ async function runDoctorClean(opts, io, deps) {
24476
24895
  ...plan.trackingRefs.map((r) => `stale tracking ref: ${r.ref}`),
24477
24896
  ...plan.worktreeDirs.map((w) => `dead worktree: ${w.path} (${w.reason})`)
24478
24897
  ];
24479
- checks.push(n === 0 ? { ok: true, label: "branches / worktrees", verbose: ["nothing reapable"] } : {
24898
+ checks.push(n === 0 ? { id: "branches-worktrees", ok: true, label: "branches / worktrees", verbose: ["nothing reapable"] } : {
24899
+ id: "branches-worktrees",
24480
24900
  ok: false,
24481
24901
  label: "branches / worktrees",
24482
- fix: `${n} stale \u2014 run \`mmi-cli doctor --apply\` to reap merged branches, stale refs, and dead worktrees`,
24902
+ // #3574: `${n} stale` was prepended to `fix` because the branch would not render `detail`
24903
+ // the same fact this row already puts in `detail` on its ✓ path four lines up. One convention now.
24904
+ detail: `${n} stale`,
24905
+ fix: "run `mmi-cli doctor --apply` to reap merged branches, stale refs, and dead worktrees",
24483
24906
  verbose: gcEvidence
24484
24907
  });
24485
24908
  }
@@ -24490,10 +24913,10 @@ async function runDoctorClean(opts, io, deps) {
24490
24913
  if (applyRepo && scratch.plan.safeAuto.length > 0) {
24491
24914
  const applied = deps.executeScratchGc(repoRoot2, { apply: true });
24492
24915
  const pruned = applied.applied?.pruned.length ?? 0;
24493
- checks.push({ ok: true, label: "scratch", detail: pruned ? `removed ${pruned} aged item(s)` : "nothing stale", verbose: scratchEvidence });
24916
+ checks.push({ id: "scratch", ok: true, label: "scratch", detail: pruned ? `removed ${pruned} aged item(s)` : "nothing stale", verbose: scratchEvidence });
24494
24917
  if (pruned) restartPending = true;
24495
24918
  } else {
24496
- checks.push(scratch.plan.safeAuto.length === 0 ? { ok: true, label: "scratch", verbose: scratchEvidence } : { ok: false, label: "scratch", fix: `${scratch.plan.safeAuto.length} aged scratch item(s) \u2014 run \`mmi-cli doctor --apply\``, verbose: scratchEvidence });
24919
+ checks.push(scratch.plan.safeAuto.length === 0 ? { id: "scratch", ok: true, label: "scratch", verbose: scratchEvidence } : { id: "scratch", ok: false, label: "scratch", detail: `${scratch.plan.safeAuto.length} aged item(s)`, fix: "run `mmi-cli doctor --apply`", verbose: scratchEvidence });
24497
24920
  }
24498
24921
  }
24499
24922
  const exitCode = doctorReportExitCode(checks);
@@ -26514,6 +26937,7 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
26514
26937
  jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").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)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012)")).action(async (number, o) => {
26515
26938
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
26516
26939
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
26940
+ const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
26517
26941
  const [headRef, baseRef, headRefOid] = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName,baseRefName,headRefOid", "--jq", '.headRefName + " " + .baseRefName + " " + (.headRefOid // "")'], { timeout: GC_GH_TIMEOUT_MS2 })).stdout.trim().split(/\s+/);
26518
26942
  const headIsProtected = isProtectedBranch(headRef);
26519
26943
  const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
@@ -26589,18 +27013,37 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
26589
27013
  }
26590
27014
  if (!ghPrMergeLocalBranchDeleteWarning(message)) throw e;
26591
27015
  });
26592
- if (o.auto || upgradedToAuto) {
26593
- const state = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 }).catch(() => ({ stdout: "" }))).stdout.trim();
26594
- if (state !== "MERGED") {
26595
- const enqueued = describePrMergeEnqueuedReason(await pollGhPrChecks(number, repoArgs).catch(() => void 0));
26596
- console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", enqueuedReason: enqueued.reason, pr: number, branch: headRef, state: state || "unknown", upgradedToAuto: upgradedToAuto || void 0 }));
26597
- console.warn(`pr merge: PR #${number} is ENQUEUED, not merged \u2014 ${enqueued.message}.`);
26598
- if (upgradedToAuto && !o.auto) {
26599
- console.warn(`pr merge: exiting ${PR_MERGE_ENQUEUED_EXIT_CODE} so a chained command does not treat this as a completed merge.`);
26600
- process.exitCode = PR_MERGE_ENQUEUED_EXIT_CODE;
26601
- }
26602
- return;
27016
+ const stateRead = await readGhPrStateWithRetry(
27017
+ () => execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => r.stdout)
27018
+ );
27019
+ const gate = decidePrMergeCleanupGate({
27020
+ state: stateRead.ok ? stateRead.state : void 0,
27021
+ stateError: stateRead.ok ? void 0 : stateRead.error,
27022
+ autoRequested: Boolean(o.auto || upgradedToAuto)
27023
+ });
27024
+ if (gate.action === "enqueued") {
27025
+ const state = stateRead.ok ? stateRead.state : "";
27026
+ const enqueued = describePrMergeEnqueuedReason(await pollGhPrChecks(number, repoArgs).catch(() => void 0));
27027
+ console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", enqueuedReason: enqueued.reason, pr: number, branch: headRef, state: state || "unknown", upgradedToAuto: upgradedToAuto || void 0 }));
27028
+ console.warn(`pr merge: PR #${number} is ENQUEUED, not merged \u2014 ${enqueued.message}.`);
27029
+ if (upgradedToAuto && !o.auto) {
27030
+ console.warn(`pr merge: exiting ${PR_MERGE_ENQUEUED_EXIT_CODE} so a chained command does not treat this as a completed merge.`);
27031
+ process.exitCode = PR_MERGE_ENQUEUED_EXIT_CODE;
26603
27032
  }
27033
+ return;
27034
+ }
27035
+ if (gate.action === "refuse") {
27036
+ console.log(JSON.stringify({
27037
+ mergeStatus: "not-merged",
27038
+ reason: gate.reason,
27039
+ pr: number,
27040
+ branch: headRef,
27041
+ state: stateRead.ok ? stateRead.state : "unknown",
27042
+ cleanupStatus: "skipped"
27043
+ }));
27044
+ console.error(`pr merge: ${gate.message}. Nothing was deleted \u2014 re-check the PR and retry.`);
27045
+ process.exitCode = 1;
27046
+ return;
26604
27047
  }
26605
27048
  if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
26606
27049
  const remoteBranch = await buildPrMergeRemoteBranchCleanupReport(headRef, {
@@ -26641,7 +27084,7 @@ jsonParity(pr.command("merge <number>").description("merge a PR (squash by defau
26641
27084
  }
26642
27085
  };
26643
27086
  }
26644
- const boardAdvance = await advanceClosedIssuesToDone2(number, o.repo);
27087
+ const boardAdvance = await advanceClosedIssuesToDone2(number, repoForPostCleanup);
26645
27088
  console.log(JSON.stringify({
26646
27089
  ...buildPrMergeResultPayload({
26647
27090
  number,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.56.0",
3
+ "version": "3.58.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "node build.mjs",
34
- "test": "vitest run --reporter=default --reporter=../scripts/test-budget-reporter.mjs",
34
+ "test": "vitest run",
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {