@mutmutco/cli 3.57.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 +411 -65
  2. package/package.json +1 -1
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,
@@ -17733,10 +17862,30 @@ function registerSecretsCommands(program3) {
17733
17862
  if (!ok) process.exitCode = 1;
17734
17863
  }));
17735
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)));
17736
- 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) => {
17737
- const ok = await secretsUse(d, key, { ...o, command });
17738
- if (ok === false) process.exitCode = 1;
17739
- }));
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
+ });
17740
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, {})));
17741
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, {})));
17742
17891
  }
@@ -17881,11 +18030,11 @@ function renderPoolDetail(probe) {
17881
18030
  function checkGithubPools(probe) {
17882
18031
  if (!probe) return [];
17883
18032
  const lines = [];
17884
- 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`" });
17885
18034
  if (typeof probe.app === "string") {
17886
- 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 });
17887
18036
  } else if (probe.app) {
17888
- 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) });
17889
18038
  }
17890
18039
  return lines;
17891
18040
  }
@@ -18113,6 +18262,92 @@ var import_node_child_process10 = require("node:child_process");
18113
18262
  var import_node_util7 = require("node:util");
18114
18263
 
18115
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
+ }
18116
18351
  var ORG = "mutmutco";
18117
18352
  var DOC_START_MARKER = "<!-- schedules:inventory:start -->";
18118
18353
  var DOC_END_MARKER = "<!-- schedules:inventory:end -->";
@@ -18270,19 +18505,23 @@ function sortEntries(entries) {
18270
18505
  (a, b) => a.executor.localeCompare(b.executor) || a.name.localeCompare(b.name)
18271
18506
  );
18272
18507
  }
18273
- function formatSchedulesTable(entries) {
18508
+ function formatSchedulesTable(entries, now = /* @__PURE__ */ new Date()) {
18274
18509
  if (!entries.length) return "no armed org schedules found";
18510
+ const resolvedOf = new Map(entries.map((e) => [e, resolvedLabel(e, now)]));
18275
18511
  const w = (pick, head) => Math.max(head.length, ...entries.map((e) => pick(e).length));
18276
18512
  const nameW = w((e) => e.name, "JOB");
18277
18513
  const cadW = w((e) => e.cadence, "CADENCE");
18278
18514
  const exeW = w((e) => e.executor, "EXECUTOR");
18279
18515
  const llmW = w((e) => e.llm, "LLM");
18280
- const resW = w((e) => e.resolved, "RESOLVED");
18516
+ const resW = w((e) => resolvedOf.get(e) ?? e.resolved, "RESOLVED");
18281
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}`;
18282
18518
  const lines = [row("JOB", "CADENCE", "EXECUTOR", "LLM", "RESOLVED", "SOURCE")];
18283
- 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));
18284
18520
  return lines.join("\n");
18285
18521
  }
18522
+ function withTickState(entry, now = /* @__PURE__ */ new Date()) {
18523
+ return { ...entry, ...scheduleTickState(entry.cadence, entry.armedAt, now) };
18524
+ }
18286
18525
  function renderDocSection(entries, generatedAtIso) {
18287
18526
  const lines = [
18288
18527
  "| Job | Cadence | Executor | LLM | Resolved | Source |",
@@ -18642,7 +18881,8 @@ function registerSchedulesCommands(program3) {
18642
18881
  return;
18643
18882
  }
18644
18883
  if (o.json) {
18645
- 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));
18646
18886
  } else {
18647
18887
  console.log(formatSchedulesTable(entries));
18648
18888
  reportDrift(drift);
@@ -21825,28 +22065,71 @@ function formatStaleLeaks(leaks) {
21825
22065
  async function repoRootOf() {
21826
22066
  return (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
21827
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
+ }
21828
22099
  function decideWorktreeLandRefCleanup(input) {
21829
22100
  if (input.keepRemote) return { action: "proceed" };
21830
- if (!input.prs) {
21831
- return {
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
+ } : {
21832
22109
  action: "keep-remote",
21833
22110
  reason: "pr-state-unreadable",
21834
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`
21835
22112
  };
21836
22113
  }
21837
- const open2 = input.prs.filter((pr2) => pr2.state.toUpperCase() === "OPEN");
21838
- if (open2.length) {
22114
+ if (verdict.state === "open") {
21839
22115
  return {
21840
22116
  action: "refuse",
21841
22117
  reason: "open-pr",
21842
- message: `'${input.branch}' still has an OPEN pull request (#${open2.map((pr2) => pr2.number).join(", #")}). 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`
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`
21843
22126
  };
21844
22127
  }
21845
- if (input.prs.length && !input.prs.some((pr2) => pr2.state.toUpperCase() === "MERGED")) {
22128
+ if (verdict.state === "closed-unmerged") {
21846
22129
  return {
21847
22130
  action: "refuse",
21848
22131
  reason: "unmerged-pr",
21849
- message: `'${input.branch}' has a pull request that never merged (#${input.prs.map((pr2) => pr2.number).join(", #")}). The remote branch is the only thing still holding that work. Re-run with --keep-remote for a local-only clean`
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`
21850
22133
  };
21851
22134
  }
21852
22135
  return { action: "proceed" };
@@ -21934,9 +22217,11 @@ function registerWorktreeCommands(program3) {
21934
22217
  if (landDirty) {
21935
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 });
21936
22219
  }
22220
+ const landPrs = await fetchLandBranchPrs(branch);
22221
+ const mergeVerdict = classifyLandBranchMergeState(landPrs);
21937
22222
  const landRefs = decideWorktreeLandRefCleanup({
21938
22223
  branch,
21939
- prs: await fetchLandBranchPrs(branch),
22224
+ prs: landPrs,
21940
22225
  keepRemote: Boolean(o.keepRemote)
21941
22226
  });
21942
22227
  if (landRefs.action === "refuse") {
@@ -21969,10 +22254,22 @@ function registerWorktreeCommands(program3) {
21969
22254
  report.push({ step: `delete remote branch ${branch}`, status: `skipped: ${landRefs.action === "keep-remote" ? landRefs.reason : "kept"}` });
21970
22255
  }
21971
22256
  report.push(await bestEffortGit(["worktree", "prune"], primaryCheckout, "prune worktree metadata"));
21972
- 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
+ };
21973
22267
  if (o.json) return console.log(JSON.stringify(result, null, 2));
21974
- console.log(`worktree land: cleaned up branch ${branch}`);
22268
+ console.log(`worktree land: cleaned up branch ${branch} (${describeLandMergeState(mergeVerdict)})`);
21975
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
+ }
21976
22273
  if (report.some((r) => r.status.startsWith("failed"))) process.exitCode = 1;
21977
22274
  } catch (e) {
21978
22275
  return failGraceful(`worktree land: ${e.message}`);
@@ -22719,8 +23016,11 @@ var PLUGIN_SURFACE_HEAL = {
22719
23016
  updateRecipe: [CLAUDE_RECOVERY]
22720
23017
  }
22721
23018
  };
22722
- function nonClaudeSurfaceHealMessage() {
22723
- 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)";
22724
23024
  }
22725
23025
  function healStepAborts(step, ok) {
22726
23026
  return !ok && step.gated;
@@ -22917,7 +23217,7 @@ async function runGuard(readOrigin) {
22917
23217
  }
22918
23218
  async function runPluginHeal(surface = detectSurface(process.env)) {
22919
23219
  if (surfaceToken(surface) !== "claude") {
22920
- console.log(nonClaudeSurfaceHealMessage());
23220
+ console.log(nonClaudeSurfaceHealMessage(surfaceToken(surface) ?? void 0));
22921
23221
  return;
22922
23222
  }
22923
23223
  const descriptor = PLUGIN_SURFACE_HEAL.claude;
@@ -24059,7 +24359,9 @@ function renderCheckLine(check) {
24059
24359
  if (check.ok) {
24060
24360
  return check.detail ? `\u2713 ${check.label} \u2014 ${check.detail}` : `\u2713 ${check.label}`;
24061
24361
  }
24062
- 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;
24063
24365
  }
24064
24366
  function renderReport(checks, opts) {
24065
24367
  const lines = checks.map(renderCheckLine);
@@ -24073,7 +24375,8 @@ function renderTally(checks) {
24073
24375
  }
24074
24376
  function renderDoctorText(checks, opts) {
24075
24377
  const lines = [];
24076
- for (const check of checks) {
24378
+ const shown = opts.verbose ? checks : checks.filter((c) => !c.ok || c.warn);
24379
+ for (const check of shown) {
24077
24380
  lines.push(renderCheckLine(check));
24078
24381
  if (opts.verbose) {
24079
24382
  for (const evidence of check.verbose ?? []) lines.push(`${VERBOSE_INDENT}${evidence}`);
@@ -24096,6 +24399,7 @@ function checkGithubAuth(probe) {
24096
24399
  const rescuers = reach.otherAccountsThatCanSee ?? [];
24097
24400
  return {
24098
24401
  ok: false,
24402
+ id: "github-auth",
24099
24403
  label: "github auth",
24100
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`,
24101
24405
  verbose: [
@@ -24108,6 +24412,7 @@ function checkGithubAuth(probe) {
24108
24412
  }
24109
24413
  return {
24110
24414
  ok: authed,
24415
+ id: "github-auth",
24111
24416
  label: "github auth",
24112
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",
24113
24418
  verbose: [
@@ -24123,6 +24428,7 @@ function checkAwsIdentity(probe) {
24123
24428
  const arn = probe.callerArn?.trim();
24124
24429
  return {
24125
24430
  ok: !arn || !arn.endsWith(":root"),
24431
+ id: "aws-identity",
24126
24432
  label: "aws identity",
24127
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",
24128
24434
  // Name the caller. A green "aws identity" says only "not root" — it does not say WHO, and "no AWS
@@ -24137,6 +24443,7 @@ function checkAwsIdentity(probe) {
24137
24443
  function checkRepoWorktrees(probe) {
24138
24444
  return {
24139
24445
  ok: !(probe.isOrgRepo && probe.hasRepoLocalWorktrees),
24446
+ id: "repo-worktrees",
24140
24447
  label: "repo worktrees",
24141
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/`",
24142
24449
  verbose: [
@@ -24155,11 +24462,12 @@ function checkWorktreeRoots(probe) {
24155
24462
  `scanned by worktree gc/list: ${probe.authoritative}`,
24156
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"]
24157
24464
  ];
24158
- 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 };
24159
24466
  const named = probe.stray.map((s) => `${s.path} (${s.dirs} dir(s), ${formatBytes(s.bytes, s.partial)})`).join("; ");
24160
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)" : "";
24161
24468
  return {
24162
24469
  ok: false,
24470
+ id: "worktree-roots",
24163
24471
  label: "worktree roots",
24164
24472
  // #3485: report-only in EVERY mode means report-only in the exit code too. `worktree gc --root` does
24165
24473
  // exist and is the eventual fix, but this row is deliberately not a gate: the remedy is a human
@@ -24188,16 +24496,20 @@ function checkClaudePlugin(probe) {
24188
24496
  const trigger = pluginHealTrigger(probe);
24189
24497
  if (trigger === "unresolved") {
24190
24498
  return {
24499
+ id: "claude-plugin",
24191
24500
  ok: false,
24192
- 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)",
24193
24503
  fix: "run `mmi-cli doctor --apply` (or `mmi-cli plugin heal`) to reinstall the MMI marketplace + plugin, then restart Claude",
24194
24504
  verbose: evidence
24195
24505
  };
24196
24506
  }
24197
24507
  if (trigger === "behind") {
24198
24508
  return {
24509
+ id: "claude-plugin",
24199
24510
  ok: false,
24200
- label: `Claude plugin ${installed} \u2192 ${released}`,
24511
+ label: "Claude plugin",
24512
+ detail: `${installed} \u2192 ${released}`,
24201
24513
  // Name the CLI verb that actually fixes it, like every other red row — `/plugin` is the manual
24202
24514
  // fallback, not the first resort (#3282).
24203
24515
  fix: "run `mmi-cli doctor --apply` (or `mmi-cli plugin heal`) to reinstall it, then restart Claude",
@@ -24205,7 +24517,7 @@ function checkClaudePlugin(probe) {
24205
24517
  };
24206
24518
  }
24207
24519
  if (!released) return null;
24208
- 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 };
24209
24521
  }
24210
24522
  function checkCliVersion(input, releasedNote) {
24211
24523
  const report = buildVersionLagReport(input);
@@ -24214,10 +24526,12 @@ function checkCliVersion(input, releasedNote) {
24214
24526
  `published: ${report.releasedVersion ?? "(not checked \u2014 offline or --fast)"}${report.releasedVersion && releasedNote ? ` ${releasedNote}` : ""}`
24215
24527
  ];
24216
24528
  if (!report.releasedVersion) return null;
24217
- 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 };
24218
24530
  return {
24531
+ id: "cli-version",
24219
24532
  ok: false,
24220
- label: `mmi-cli ${report.currentVersion} \u2192 ${report.releasedVersion}`,
24533
+ label: "mmi-cli",
24534
+ detail: `${report.currentVersion} \u2192 ${report.releasedVersion}`,
24221
24535
  fix: report.fix,
24222
24536
  verbose: evidence
24223
24537
  };
@@ -24231,6 +24545,7 @@ function checkPluginCache(input) {
24231
24545
  if (input.stale.length === 0 && input.staging.length === 0) {
24232
24546
  return {
24233
24547
  ok: true,
24548
+ id: "plugin-cache",
24234
24549
  label: "plugin cache",
24235
24550
  detail: input.cached ? `${input.cached} version(s), nothing stale` : "no cache",
24236
24551
  verbose: evidence
@@ -24247,6 +24562,7 @@ function checkPluginCache(input) {
24247
24562
  }
24248
24563
  return {
24249
24564
  ok: false,
24565
+ id: "plugin-cache",
24250
24566
  label: "plugin cache",
24251
24567
  // #3485: deliberately NOT `reportOnly`, and not in the closed set docs/doctor-contract.md enumerates.
24252
24568
  // A checker argued it qualifies because no `doctor --apply` clears it; I tagged it, then reverted.
@@ -24265,11 +24581,19 @@ function checkTrainSync(result) {
24265
24581
  const evidence = result.branches.length ? result.branches.map((b) => `${b.branch}: ${b.status}${b.reason ? ` (${b.reason})` : ""}`) : ["no local train branches to sync"];
24266
24582
  const problems = result.branches.filter((b) => b.status === "skipped" && (b.reason === "diverged" || b.reason === "ff-failed"));
24267
24583
  if (problems.length) {
24268
- 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
+ };
24269
24592
  }
24270
24593
  const moved = result.branches.filter((b) => b.status === "synced");
24271
24594
  return {
24272
24595
  ok: true,
24596
+ id: "train-branches",
24273
24597
  label: "train branches",
24274
24598
  detail: moved.length ? `fast-forwarded ${moved.map((b) => b.branch).join(", ")}` : void 0,
24275
24599
  verbose: evidence
@@ -24286,6 +24610,7 @@ function checkSchedules(probe) {
24286
24610
  if (probe.incomplete.length) {
24287
24611
  return {
24288
24612
  ok: false,
24613
+ id: "schedules",
24289
24614
  label: "schedules",
24290
24615
  // #3485: ruled report-only — an unreadable source is a read failure somewhere in the org, and
24291
24616
  // nothing in this checkout clears it.
@@ -24298,6 +24623,7 @@ function checkSchedules(probe) {
24298
24623
  if (probe.drift.length) {
24299
24624
  return {
24300
24625
  ok: false,
24626
+ id: "schedules",
24301
24627
  label: "schedules",
24302
24628
  // #3485 ruled this row report-only because the drift classes are owned by other repos. #3492 makes
24303
24629
  // that conditional rather than blanket: when a finding IS clearable from this checkout — a
@@ -24310,16 +24636,17 @@ function checkSchedules(probe) {
24310
24636
  verbose: evidence
24311
24637
  };
24312
24638
  }
24313
- 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 };
24314
24640
  }
24315
24641
  function checkDocsAudit(probe) {
24316
24642
  if (!probe) return null;
24317
24643
  if (!probe.armed) {
24318
- 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] };
24319
24645
  }
24320
24646
  if (!probe.ok) {
24321
24647
  return {
24322
24648
  ok: false,
24649
+ id: "docs-audit",
24323
24650
  label: "docs-audit",
24324
24651
  // #3485: ruled report-only — the remedy is re-running or re-arming the janitor in the repo that
24325
24652
  // owns it. `mmi-cli docs audit record` can write a verdict from here, but hand-writing one to clear
@@ -24329,7 +24656,7 @@ function checkDocsAudit(probe) {
24329
24656
  verbose: [probe.detail]
24330
24657
  };
24331
24658
  }
24332
- 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] };
24333
24660
  }
24334
24661
  function checkSessionPayload(probe) {
24335
24662
  if (!probe) return null;
@@ -24346,8 +24673,13 @@ function checkSessionPayload(probe) {
24346
24673
  if (status === "red") {
24347
24674
  return {
24348
24675
  ok: false,
24676
+ id: "sessionstart-payload",
24349
24677
  label: "SessionStart payload",
24350
- 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`,
24351
24683
  verbose: evidence
24352
24684
  };
24353
24685
  }
@@ -24358,6 +24690,7 @@ function checkSessionPayload(probe) {
24358
24690
  // amber tier dead code on the one lane that runs every session — drift past the budget was
24359
24691
  // invisible until it became a truncation. Still ✓, still never moves the exit code.
24360
24692
  warn: true,
24693
+ id: "sessionstart-payload",
24361
24694
  label: "SessionStart payload",
24362
24695
  detail: `amber \u2014 ${chars} characters, over the ${SESSION_PAYLOAD_BUDGET_CHARS}-character budget and ${SESSION_PAYLOAD_CAP_CHARS - chars} from the hard cap`,
24363
24696
  verbose: evidence
@@ -24365,6 +24698,7 @@ function checkSessionPayload(probe) {
24365
24698
  }
24366
24699
  return {
24367
24700
  ok: true,
24701
+ id: "sessionstart-payload",
24368
24702
  label: "SessionStart payload",
24369
24703
  detail: `${chars} of ${SESSION_PAYLOAD_BUDGET_CHARS} budgeted characters`,
24370
24704
  verbose: evidence
@@ -24412,12 +24746,12 @@ async function runDoctorClean(opts, io, deps) {
24412
24746
  `managed block: ${gi.ok ? "present and current" : "missing or out of date"}`
24413
24747
  ];
24414
24748
  if (gi.ok) {
24415
- checks.push({ ok: true, label: "gitignore block", verbose: giEvidence });
24749
+ checks.push({ ok: true, id: "gitignore-block", label: "gitignore block", verbose: giEvidence });
24416
24750
  } else if (applyRepo && gi.content && deps.writeGitignore(gi.content)) {
24417
- 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 });
24418
24752
  restartPending = true;
24419
24753
  } else {
24420
- 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 });
24421
24755
  }
24422
24756
  }
24423
24757
  const releasedNote = deps.releasedVersionNote?.();
@@ -24425,7 +24759,7 @@ async function runDoctorClean(opts, io, deps) {
24425
24759
  const healTrigger = pluginHealTrigger(pluginProbe);
24426
24760
  if (applyEnv && deps.healPlugin && healTrigger) {
24427
24761
  const heal = await deps.healPlugin();
24428
- const label = healTrigger === "behind" ? `Claude plugin ${installed} \u2192 ${released}` : "Claude plugin \u2014 reinstalled";
24762
+ const measured = healTrigger === "behind" ? `${installed} \u2192 ${released}` : "unresolved install";
24429
24763
  const healEvidence = [
24430
24764
  `installed: ${installed ?? "(none)"}`,
24431
24765
  `released: ${released ?? "(not checked \u2014 offline or --fast)"}`,
@@ -24433,13 +24767,16 @@ async function runDoctorClean(opts, io, deps) {
24433
24767
  `heal: ${heal.detail}`
24434
24768
  ];
24435
24769
  checks.push(heal.ok ? {
24770
+ id: "claude-plugin",
24436
24771
  ok: true,
24437
- label,
24438
- 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)`,
24439
24774
  verbose: healEvidence
24440
24775
  } : {
24776
+ id: "claude-plugin",
24441
24777
  ok: false,
24442
- label,
24778
+ label: "Claude plugin",
24779
+ detail: measured,
24443
24780
  // #3489: a heal skipped because another doctor holds the env-heal lock is a real ✗ — this run did
24444
24781
  // not fix what it found — but it is not this invocation's to clear. The other process is doing it;
24445
24782
  // waiting is the correct response and a re-run finds it healed. A heal that RAN and failed is a
@@ -24462,13 +24799,16 @@ async function runDoctorClean(opts, io, deps) {
24462
24799
  const heal = await deps.updateCli(cliReport.releasedVersion);
24463
24800
  const healEvidence = [`running: ${cliReport.currentVersion}`, `published: ${cliReport.releasedVersion}`, `heal: ${heal.detail}`];
24464
24801
  checks.push(heal.ok ? {
24802
+ id: "cli-version",
24465
24803
  ok: true,
24466
- label: `mmi-cli ${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
24467
- 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`,
24468
24806
  verbose: healEvidence
24469
24807
  } : {
24808
+ id: "cli-version",
24470
24809
  ok: false,
24471
- label: `mmi-cli ${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
24810
+ label: "mmi-cli",
24811
+ detail: `${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
24472
24812
  // #3489: same split as the plugin heal above — a lock-contention skip is not this run's gap.
24473
24813
  ...heal.skipped ? { reportOnly: true } : {},
24474
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)}\``,
@@ -24516,6 +24856,7 @@ async function runDoctorClean(opts, io, deps) {
24516
24856
  const message = e instanceof Error ? e.message : String(e);
24517
24857
  checks.push({
24518
24858
  ok: false,
24859
+ id: "train-branches",
24519
24860
  label: "train branches",
24520
24861
  fix: `sync failed \u2014 ${message}`,
24521
24862
  verbose: [`sync threw: ${message}`, "no train branch was fast-forwarded this run"]
@@ -24532,6 +24873,7 @@ async function runDoctorClean(opts, io, deps) {
24532
24873
  if (r.removedWorktreeDirs.length) detailParts.push(`${r.removedWorktreeDirs.length} dead worktrees`);
24533
24874
  if (r.removedTrackingRefs.length) detailParts.push(`${r.removedTrackingRefs.length} stale refs`);
24534
24875
  checks.push({
24876
+ id: "branches-worktrees",
24535
24877
  ok: true,
24536
24878
  label: "branches / worktrees",
24537
24879
  detail: reaped ? `reaped ${detailParts.join(", ")}` : "nothing stale",
@@ -24553,10 +24895,14 @@ async function runDoctorClean(opts, io, deps) {
24553
24895
  ...plan.trackingRefs.map((r) => `stale tracking ref: ${r.ref}`),
24554
24896
  ...plan.worktreeDirs.map((w) => `dead worktree: ${w.path} (${w.reason})`)
24555
24897
  ];
24556
- 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",
24557
24900
  ok: false,
24558
24901
  label: "branches / worktrees",
24559
- 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",
24560
24906
  verbose: gcEvidence
24561
24907
  });
24562
24908
  }
@@ -24567,10 +24913,10 @@ async function runDoctorClean(opts, io, deps) {
24567
24913
  if (applyRepo && scratch.plan.safeAuto.length > 0) {
24568
24914
  const applied = deps.executeScratchGc(repoRoot2, { apply: true });
24569
24915
  const pruned = applied.applied?.pruned.length ?? 0;
24570
- 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 });
24571
24917
  if (pruned) restartPending = true;
24572
24918
  } else {
24573
- 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 });
24574
24920
  }
24575
24921
  }
24576
24922
  const exitCode = doctorReportExitCode(checks);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.57.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",