@mutmutco/cli 2.30.0 → 2.32.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.
package/dist/main.cjs CHANGED
@@ -3473,18 +3473,28 @@ function versionAtLeast(v, min) {
3473
3473
  }
3474
3474
 
3475
3475
  // src/client-version.ts
3476
- function resolveClientVersion() {
3476
+ function resolveClientVersionManifestCandidates(distDir = __dirname) {
3477
+ return [
3478
+ (0, import_node_path2.join)(distDir, "..", "..", ".claude-plugin", "plugin.json"),
3479
+ (0, import_node_path2.join)(distDir, "..", "..", ".cursor-plugin", "plugin.json"),
3480
+ (0, import_node_path2.join)(distDir, "..", "..", ".codex-plugin", "plugin.json"),
3481
+ (0, import_node_path2.join)(distDir, "..", "package.json")
3482
+ ];
3483
+ }
3484
+ function readVersionFromManifest(path2) {
3477
3485
  try {
3478
- const manifest = (0, import_node_path2.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
3479
- return JSON.parse((0, import_node_fs2.readFileSync)(manifest, "utf8")).version || "0.0.0";
3486
+ const version = JSON.parse((0, import_node_fs2.readFileSync)(path2, "utf8")).version;
3487
+ return typeof version === "string" && version.trim() ? version.trim() : null;
3480
3488
  } catch {
3481
- try {
3482
- const pkg = (0, import_node_path2.join)(__dirname, "..", "package.json");
3483
- return JSON.parse((0, import_node_fs2.readFileSync)(pkg, "utf8")).version || "0.0.0";
3484
- } catch {
3485
- return "0.0.0";
3486
- }
3489
+ return null;
3490
+ }
3491
+ }
3492
+ function resolveClientVersion() {
3493
+ for (const manifest of resolveClientVersionManifestCandidates()) {
3494
+ const version = readVersionFromManifest(manifest);
3495
+ if (version) return version;
3487
3496
  }
3497
+ return "0.0.0";
3488
3498
  }
3489
3499
  function clientVersionHeaders() {
3490
3500
  return { [CLIENT_VERSION_HEADER]: resolveClientVersion() };
@@ -4102,7 +4112,7 @@ async function hubAuthToken(deps) {
4102
4112
  var injectedStdin;
4103
4113
  async function readStdin() {
4104
4114
  if (injectedStdin !== void 0) return injectedStdin;
4105
- if (process.stdin.isTTY) return "";
4115
+ if (process.stdin.isTTY !== false) return "";
4106
4116
  const chunks = [];
4107
4117
  for await (const chunk of process.stdin) chunks.push(chunk);
4108
4118
  return Buffer.concat(chunks).toString("utf8");
@@ -5247,6 +5257,15 @@ function buildIngestPayload(args) {
5247
5257
 
5248
5258
  // src/honcho-client.ts
5249
5259
  var HONCHO_ASSISTANT_PEER = "assistant";
5260
+ function parseHonchoQueueStatus(json) {
5261
+ const o = json && typeof json === "object" ? json : {};
5262
+ const pendingRaw = o.pending_work_units ?? o.depth ?? o.queue_depth ?? o.pending;
5263
+ const inProgressRaw = o.in_progress_work_units;
5264
+ const pending = typeof pendingRaw === "number" ? pendingRaw : null;
5265
+ const inProgress = typeof inProgressRaw === "number" ? inProgressRaw : null;
5266
+ const stalled = (pending ?? 0) > 0 && (inProgress ?? 0) === 0;
5267
+ return { pending, inProgress, stalled };
5268
+ }
5250
5269
  var enc = encodeURIComponent;
5251
5270
  var base = (apiUrl) => apiUrl.replace(/\/+$/, "");
5252
5271
  var honchoRoutes = {
@@ -5313,20 +5332,28 @@ async function fetchPeerCard(cfg, peer, opts = {}) {
5313
5332
  try {
5314
5333
  const path2 = honchoRoutes.peerCard(cfg.workspace, peer, opts.project);
5315
5334
  const res = await request(cfg, fetchImpl, "GET", path2, void 0, timeoutMs);
5316
- if (!res.ok) return null;
5335
+ if (!res.ok) return { status: "error", httpStatus: res.status };
5317
5336
  const json = await res.json();
5318
- return formatPeerCardLines(json?.peer_card, maxChars);
5319
- } catch {
5320
- return null;
5337
+ const profile = formatPeerCardLines(json?.peer_card, maxChars);
5338
+ return profile ? { status: "ok", data: profile } : { status: "empty" };
5339
+ } catch (e) {
5340
+ return { status: "error", message: e.message };
5321
5341
  }
5322
5342
  }
5323
5343
  async function fetchPeerCardWithFallback(cfg, peer, project2, opts = {}) {
5324
5344
  if (project2) {
5325
5345
  const scoped = await fetchPeerCard(cfg, peer, { ...opts, project: project2 });
5326
- if (scoped) return { profile: scoped, scope: "project" };
5346
+ if (scoped.status === "ok") return { profile: scoped.data, scope: "project", status: "ok" };
5347
+ if (scoped.status === "error") {
5348
+ return { profile: null, scope: null, status: "error", httpStatus: scoped.httpStatus, message: scoped.message };
5349
+ }
5327
5350
  }
5328
5351
  const wide = await fetchPeerCard(cfg, peer, opts);
5329
- return { profile: wide, scope: wide ? "peer" : null };
5352
+ if (wide.status === "ok") return { profile: wide.data, scope: "peer", status: "ok" };
5353
+ if (wide.status === "error") {
5354
+ return { profile: null, scope: null, status: "error", httpStatus: wide.httpStatus, message: wide.message };
5355
+ }
5356
+ return { profile: null, scope: null, status: "empty" };
5330
5357
  }
5331
5358
  async function dialecticChat(cfg, peer, query, opts = {}, fetchImpl = fetch) {
5332
5359
  try {
@@ -5336,25 +5363,44 @@ async function dialecticChat(cfg, peer, query, opts = {}, fetchImpl = fetch) {
5336
5363
  ...opts.target ? { target: opts.target } : {},
5337
5364
  ...opts.sessionId ? { session_id: opts.sessionId } : {}
5338
5365
  }, opts.timeoutMs ?? 15e3);
5339
- if (!res.ok) return null;
5366
+ if (!res.ok) return { status: "error", httpStatus: res.status };
5340
5367
  const json = await res.json();
5341
- return (json?.content ?? "").trim() || null;
5342
- } catch {
5343
- return null;
5368
+ const answer = (json?.content ?? "").trim();
5369
+ return answer ? { status: "ok", data: answer } : { status: "empty" };
5370
+ } catch (e) {
5371
+ return { status: "error", message: e.message };
5344
5372
  }
5345
5373
  }
5346
5374
  async function probeHoncho(cfg, fetchImpl = fetch, timeoutMs = 3e3, opts = {}) {
5375
+ const emptyQueue = { pending: null, inProgress: null, stalled: false };
5347
5376
  try {
5348
5377
  const healthRes = await request(cfg, fetchImpl, "GET", honchoRoutes.health(), void 0, timeoutMs);
5349
5378
  if (!healthRes.ok) {
5350
- return { reachable: false, status: healthRes.status, authOk: false, authStatus: void 0 };
5379
+ return { reachable: false, status: healthRes.status, authOk: false, authStatus: void 0, queue: emptyQueue };
5351
5380
  }
5352
5381
  const authPath = opts.peer ? honchoRoutes.peerCard(cfg.workspace, opts.peer) : honchoRoutes.queueStatus(cfg.workspace);
5353
5382
  const authRes = await request(cfg, fetchImpl, "GET", authPath, void 0, timeoutMs);
5354
5383
  const authOk = authRes.status !== 401 && authRes.status !== 403;
5355
- return { reachable: true, status: healthRes.status, authOk, authStatus: authRes.status };
5384
+ let queue = emptyQueue;
5385
+ if (authPath.includes("/queue/status") && authRes.ok) {
5386
+ try {
5387
+ queue = parseHonchoQueueStatus(await authRes.json());
5388
+ } catch {
5389
+ queue = emptyQueue;
5390
+ }
5391
+ } else {
5392
+ const queueRes = await request(cfg, fetchImpl, "GET", honchoRoutes.queueStatus(cfg.workspace), void 0, timeoutMs);
5393
+ if (queueRes.ok) {
5394
+ try {
5395
+ queue = parseHonchoQueueStatus(await queueRes.json());
5396
+ } catch {
5397
+ queue = emptyQueue;
5398
+ }
5399
+ }
5400
+ }
5401
+ return { reachable: true, status: healthRes.status, authOk, authStatus: authRes.status, queue };
5356
5402
  } catch {
5357
- return { reachable: false, authOk: false };
5403
+ return { reachable: false, authOk: false, queue: emptyQueue };
5358
5404
  }
5359
5405
  }
5360
5406
 
@@ -5542,8 +5588,21 @@ async function runHonchoContext(opts, io = consoleIo) {
5542
5588
  return;
5543
5589
  }
5544
5590
  const key = await sagaKey(cfg);
5545
- const { profile, scope } = await fetchPeerCardWithFallback(hc, peer, key.project);
5546
- if (opts.json) return io.log(JSON.stringify({ peer, project: key.project, cardScope: scope, profile }));
5591
+ const { profile, scope, status, httpStatus, message } = await fetchPeerCardWithFallback(hc, peer, key.project);
5592
+ if (opts.json) {
5593
+ return io.log(JSON.stringify({
5594
+ peer,
5595
+ project: key.project,
5596
+ cardScope: scope,
5597
+ profile,
5598
+ status,
5599
+ ...status === "error" ? { error: { httpStatus, message } } : {}
5600
+ }));
5601
+ }
5602
+ if (status === "error") {
5603
+ io.err(`honcho context: peer card request failed${httpStatus ? ` (${httpStatus})` : ""}${message ? `: ${message}` : ""}`);
5604
+ return;
5605
+ }
5547
5606
  if (!profile) return;
5548
5607
  io.log(opts.banner ? profileBanner(profile, peer, scope === "project" ? key.project : void 0) : profile);
5549
5608
  }
@@ -5567,9 +5626,21 @@ async function runHonchoChat(query, opts, io = consoleIo) {
5567
5626
  io.err("honcho chat: query is empty after redaction");
5568
5627
  return;
5569
5628
  }
5570
- const answer = await dialecticChat(hc, peer, safeQuery, { target: opts.target });
5571
- if (opts.json) return io.log(JSON.stringify({ peer, query, answer }));
5572
- io.log(answer ?? "(no answer \u2014 the profile may be empty or the service is unreachable)");
5629
+ const result = await dialecticChat(hc, peer, safeQuery, { target: opts.target });
5630
+ if (opts.json) {
5631
+ return io.log(JSON.stringify({
5632
+ peer,
5633
+ query,
5634
+ answer: result.status === "ok" ? result.data : null,
5635
+ status: result.status,
5636
+ ...result.status === "error" ? { error: { httpStatus: result.httpStatus, message: result.message } } : {}
5637
+ }));
5638
+ }
5639
+ if (result.status === "error") {
5640
+ io.err(`honcho chat: request failed${result.httpStatus ? ` (${result.httpStatus})` : ""}${result.message ? `: ${result.message}` : ""}`);
5641
+ return;
5642
+ }
5643
+ io.log(result.status === "ok" ? result.data : "(no answer \u2014 the profile may be empty or not yet built)");
5573
5644
  }
5574
5645
  async function runHonchoHealth(o, io = consoleIo) {
5575
5646
  const cfg = await loadConfig();
@@ -5577,12 +5648,19 @@ async function runHonchoHealth(o, io = consoleIo) {
5577
5648
  const apiKey = await honchoApiKey();
5578
5649
  const hc = apiKey ? { apiUrl, apiKey, workspace } : null;
5579
5650
  const peer = honchoPeerId(await honchoLogin(cfg), cfg);
5580
- const liveness = hc ? await probeHoncho(hc, fetch, 3e3, { peer: peer ?? void 0 }) : { reachable: false, status: void 0, authOk: false, authStatus: void 0 };
5651
+ const liveness = hc ? await probeHoncho(hc, fetch, 3e3, { peer: peer ?? void 0 }) : {
5652
+ reachable: false,
5653
+ status: void 0,
5654
+ authOk: false,
5655
+ authStatus: void 0,
5656
+ queue: { pending: null, inProgress: null, stalled: false }
5657
+ };
5581
5658
  const pending = readHonchoPending().length;
5582
5659
  const ingestSkip = readIngestSkip();
5583
5660
  const ingestSkipMessage = formatIngestSkip(ingestSkip);
5661
+ const deriverStalled = liveness.queue.stalled;
5584
5662
  const report = {
5585
- ok: !!hc && liveness.reachable && liveness.authOk,
5663
+ ok: !!hc && liveness.reachable && liveness.authOk && !deriverStalled,
5586
5664
  configured: !!hc,
5587
5665
  apiUrl,
5588
5666
  apiKeyConfigured: !!apiKey,
@@ -5593,6 +5671,11 @@ async function runHonchoHealth(o, io = consoleIo) {
5593
5671
  peer: peer ?? null,
5594
5672
  workspace,
5595
5673
  pending,
5674
+ deriverQueue: {
5675
+ pending: liveness.queue.pending,
5676
+ inProgress: liveness.queue.inProgress,
5677
+ stalled: deriverStalled
5678
+ },
5596
5679
  ingestSkip: ingestSkip ?? null,
5597
5680
  ingestSkipMessage: ingestSkipMessage ?? null
5598
5681
  };
@@ -5600,6 +5683,7 @@ async function runHonchoHealth(o, io = consoleIo) {
5600
5683
  if (o.banner) {
5601
5684
  if (report.configured && !report.reachable) io.log(`honcho: configured but unreachable (${pending} pending)`);
5602
5685
  if (report.configured && report.reachable && !report.authOk) io.log("honcho: configured but API key rejected (401/403)");
5686
+ if (deriverStalled) io.log(`honcho: deriver stalled (${liveness.queue.pending} pending, 0 in progress)`);
5603
5687
  if (ingestSkipMessage) io.log(`honcho: ${ingestSkipMessage}`);
5604
5688
  return;
5605
5689
  }
@@ -5608,8 +5692,12 @@ async function runHonchoHealth(o, io = consoleIo) {
5608
5692
  if (report.configured && !report.reachable) io.log(" - service unreachable");
5609
5693
  if (report.configured && report.reachable && !report.authOk) io.log(" - API key rejected (401/403) \u2014 rotate or fix vault path");
5610
5694
  if (!report.peer) io.log(" - no peer identity resolved (gh login) \u2014 ingest will be skipped");
5695
+ if (deriverStalled) io.log(` - deriver stalled (${liveness.queue.pending} pending, 0 in progress)`);
5611
5696
  if (ingestSkipMessage) io.log(` - ${ingestSkipMessage}`);
5612
5697
  if (pending > 0) io.log(` - ${pending} ingest(s) queued locally`);
5698
+ if (liveness.queue.pending != null && liveness.queue.pending > 0) {
5699
+ io.log(` - deriver queue: ${liveness.queue.pending} pending, ${liveness.queue.inProgress ?? 0} in progress`);
5700
+ }
5613
5701
  }
5614
5702
  async function runHonchoKey(o, io = consoleIo) {
5615
5703
  const cfg = await loadConfig();
@@ -7141,7 +7229,7 @@ ${buildReportBody(body, sourceRepo)}`;
7141
7229
 
7142
7230
  // src/skill-lesson.ts
7143
7231
  var SKILL_LESSON_LABEL = "skill-lesson";
7144
- var SKILL_NAMES = ["bootstrap", "grind", "hotfix", "mmi", "rcand", "release", "secrets", "stage"];
7232
+ var SKILL_NAMES = ["bootstrap", "browser-automation", "grind", "hotfix", "mmi", "rcand", "release", "secrets", "stage"];
7145
7233
  function assertSkillName(name) {
7146
7234
  const match = SKILL_NAMES.find((skill) => skill === name);
7147
7235
  if (!match) throw new Error(`unknown skill "${name}" \u2014 expected one of: ${SKILL_NAMES.join(", ")}`);
@@ -7267,11 +7355,183 @@ function buildPanelPlan(input) {
7267
7355
  };
7268
7356
  }
7269
7357
 
7358
+ // src/grind-policy.ts
7359
+ var DEFAULT_SEARCH_DENY_DOMAINS = [
7360
+ "stackoverflow.com",
7361
+ "stackexchange.com",
7362
+ "github.com/issues",
7363
+ "reddit.com"
7364
+ ];
7365
+
7366
+ // src/verify-fusion.ts
7367
+ var DEFAULT_MODELS = {
7368
+ builder: "builder-slot",
7369
+ verifier: "verifier-slot",
7370
+ third: "third-slot",
7371
+ synthesizer: "verifier-slot"
7372
+ };
7373
+ function resolveFusionProviderUrl(explicit) {
7374
+ if (explicit) return explicit;
7375
+ const env = process.env.MMI_FUSION_PROVIDER_URL?.trim();
7376
+ return env || null;
7377
+ }
7378
+ function buildFusionPlan(input) {
7379
+ const routing = input.routing;
7380
+ const lenses = input.lenses ?? [...GRIND_LENSES];
7381
+ const provider = resolveFusionProviderUrl(input.providerUrl ?? void 0);
7382
+ const models = {
7383
+ ...DEFAULT_MODELS,
7384
+ ...input.models,
7385
+ synthesizer: input.models?.synthesizer ?? input.models?.third ?? input.models?.verifier ?? DEFAULT_MODELS.synthesizer
7386
+ };
7387
+ if (models.verifier === models.builder) {
7388
+ throw new Error("fusion plan: verifier must not equal builder");
7389
+ }
7390
+ if (models.synthesizer === models.builder) {
7391
+ throw new Error("fusion plan: synthesizer must not equal builder");
7392
+ }
7393
+ const toolPolicy = {
7394
+ webSearch: Boolean(input.toolPolicy?.webSearch),
7395
+ maxQueriesPerLens: input.toolPolicy?.maxQueriesPerLens ?? 3,
7396
+ denyDomains: input.toolPolicy?.denyDomains ?? []
7397
+ };
7398
+ return {
7399
+ provider,
7400
+ routing,
7401
+ lenses,
7402
+ models,
7403
+ toolPolicy,
7404
+ criteria: input.criteria,
7405
+ diff: input.diff,
7406
+ fallback: "host-panel",
7407
+ instructions: "Hosted fusion when provider is configured; else spawn host lenses and pipe JSON to `mmi-cli verify synthesize`. Synthesizer slot must differ from builder."
7408
+ };
7409
+ }
7410
+ function adaptFusionResponse(raw) {
7411
+ if (raw.lenses) {
7412
+ const lenses = parseLensResults(raw.lenses);
7413
+ const base2 = synthesizePanelReport(lenses);
7414
+ return {
7415
+ ...base2,
7416
+ consensus: raw.consensus?.length ? raw.consensus : base2.consensus,
7417
+ contradictions: raw.contradictions ?? base2.contradictions,
7418
+ partial_coverage: raw.partial_coverage ?? base2.partial_coverage,
7419
+ unique_insights: raw.unique_insights ?? base2.unique_insights,
7420
+ blind_spots: raw.blind_spots ?? base2.blind_spots,
7421
+ nits: raw.nits ?? base2.nits
7422
+ };
7423
+ }
7424
+ const blockers = (raw.blockers ?? []).map((b, i) => ({
7425
+ id: `fusion-${i}`,
7426
+ title: b.title,
7427
+ file: b.file,
7428
+ line: b.line,
7429
+ why: b.why,
7430
+ sources: b.sources ?? ["hosted-fusion"]
7431
+ }));
7432
+ return {
7433
+ consensus: raw.consensus ?? [],
7434
+ contradictions: raw.contradictions ?? [],
7435
+ partial_coverage: raw.partial_coverage ?? [],
7436
+ unique_insights: raw.unique_insights ?? [],
7437
+ blind_spots: raw.blind_spots ?? [],
7438
+ blockers,
7439
+ nits: raw.nits ?? []
7440
+ };
7441
+ }
7442
+ async function runFusionProvider(plan2, deps = {}) {
7443
+ const url = resolveFusionProviderUrl(deps.providerUrl ?? plan2.provider ?? void 0);
7444
+ if (!url) {
7445
+ return { ok: false, source: "fallback", error: "no fusion provider configured" };
7446
+ }
7447
+ const fetchImpl = deps.fetch ?? fetch;
7448
+ const apiKey = deps.apiKey ?? process.env.MMI_FUSION_API_KEY?.trim();
7449
+ const headers = { "content-type": "application/json" };
7450
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`;
7451
+ try {
7452
+ const res = await fetchImpl(url, {
7453
+ method: "POST",
7454
+ headers,
7455
+ body: JSON.stringify({
7456
+ routing: plan2.routing,
7457
+ lenses: plan2.lenses,
7458
+ models: plan2.models,
7459
+ toolPolicy: plan2.toolPolicy,
7460
+ criteria: plan2.criteria,
7461
+ diff: plan2.diff
7462
+ }),
7463
+ signal: AbortSignal.timeout(3e4)
7464
+ });
7465
+ if (!res.ok) {
7466
+ return { ok: false, source: "fallback", error: `provider HTTP ${res.status}` };
7467
+ }
7468
+ const body = await res.json();
7469
+ return { ok: true, source: "hosted-fusion", report: adaptFusionResponse(body) };
7470
+ } catch (e) {
7471
+ return { ok: false, source: "fallback", error: e.message };
7472
+ }
7473
+ }
7474
+ function parseFusionLenses(raw) {
7475
+ return raw.split(",").map((s) => assertGrindLens(s.trim()));
7476
+ }
7477
+ function parseFusionRouting(raw) {
7478
+ return assertVerifyRouting(raw);
7479
+ }
7480
+
7270
7481
  // src/gc.ts
7482
+ var DEFERRED_SWEEP_COMMAND = "mmi-cli gc --apply";
7483
+ var DEFERRED_NOTE = "Worktree cleanup deferred \u2014 close this folder in your editor (or run cleanup from a shell outside it), then rerun mmi-cli gc --apply.";
7271
7484
  var WORKTREE_LOCK_RE = /EPERM|EBUSY|EACCES|ENOTEMPTY|permission denied|access is denied|used by another process|resource busy|directory not empty/i;
7272
7485
  function isWorktreeLockError(error) {
7273
7486
  return WORKTREE_LOCK_RE.test(error instanceof Error ? error.message : String(error));
7274
7487
  }
7488
+ function deferredWorktreesRegistryPath(gitDir) {
7489
+ const base2 = gitDir.replace(/\\/g, "/").replace(/\/+$/, "");
7490
+ return `${base2}/mmi-deferred-worktrees.json`;
7491
+ }
7492
+ function parseDeferredWorktreesFile(text) {
7493
+ const parsed = JSON.parse(text);
7494
+ if (!parsed || !Array.isArray(parsed.entries)) return [];
7495
+ return parsed.entries.filter((e) => Boolean(e) && typeof e === "object" && typeof e.path === "string" && typeof e.branch === "string" && e.reason === "lock-held").map((e) => ({ ...e, registeredAt: e.registeredAt || (/* @__PURE__ */ new Date(0)).toISOString() }));
7496
+ }
7497
+ function serializeDeferredWorktrees(entries) {
7498
+ return `${JSON.stringify({ entries }, null, 2)}
7499
+ `;
7500
+ }
7501
+ function deferredPathKey(path2) {
7502
+ return normPath(path2);
7503
+ }
7504
+ function isPersistentWorktreeLockFailure(outcome) {
7505
+ return outcome.status === "failed" && isWorktreeLockError(outcome.error);
7506
+ }
7507
+ async function registerDeferredWorktree(store, entry) {
7508
+ const existing = await store.read();
7509
+ const key = deferredPathKey(entry.path);
7510
+ const already = existing.some((e) => deferredPathKey(e.path) === key);
7511
+ if (already) return { entries: existing, newlyRegistered: false };
7512
+ const next = {
7513
+ ...entry,
7514
+ registeredAt: entry.registeredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
7515
+ reason: "lock-held"
7516
+ };
7517
+ const entries = [...existing, next];
7518
+ await store.write(entries);
7519
+ return { entries, newlyRegistered: true };
7520
+ }
7521
+ async function sweepDeferredWorktrees(store, deps) {
7522
+ if (!store) return { removed: [], stillDeferred: [] };
7523
+ const entries = await store.read();
7524
+ if (!entries.length) return { removed: [], stillDeferred: [] };
7525
+ const removed = [];
7526
+ const stillDeferred = [];
7527
+ for (const entry of entries) {
7528
+ const outcome = await removeWorktreeWithRecovery(entry.path, deps);
7529
+ if (outcome.status === "removed") removed.push(entry.path);
7530
+ else stillDeferred.push(entry);
7531
+ }
7532
+ await store.write(stillDeferred);
7533
+ return { removed, stillDeferred };
7534
+ }
7275
7535
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7276
7536
  async function removeWorktreeWithRecovery(wtPath, deps) {
7277
7537
  const maxAttempts = deps.maxAttempts ?? 3;
@@ -7337,7 +7597,9 @@ function summarizePrMergeCleanupStatus(input) {
7337
7597
  if (input.remoteBranch.status === "failed") return "warnings";
7338
7598
  if (input.localBranch.status === "failed") return "warnings";
7339
7599
  if (input.localBranch.reason === "worktree-removal-failed") return "warnings";
7600
+ if (input.localBranch.reason === "worktree-removal-deferred") return "warnings";
7340
7601
  if (input.worktree?.status === "failed") return "warnings";
7602
+ if (input.worktree?.status === "deferred") return "warnings";
7341
7603
  return "clean";
7342
7604
  }
7343
7605
  function buildPrMergeResultPayload(input) {
@@ -7565,9 +7827,10 @@ function selectPrMergeCleanupWorktree(branch, before, after, startingPath) {
7565
7827
  if (startingPath && before.some((w) => w.branch === branch && samePath(w.path, startingPath))) return startingPath;
7566
7828
  return void 0;
7567
7829
  }
7568
- function selectSafeWorktreeCwd(worktrees, targetPath) {
7830
+ function selectSafeWorktreeCwd(worktrees, targetPath, options) {
7569
7831
  if (!targetPath) return void 0;
7570
- return worktrees.find((w) => !samePath(w.path, targetPath))?.path;
7832
+ const exists = options?.pathExists ?? (() => true);
7833
+ return worktrees.find((w) => !samePath(w.path, targetPath) && exists(w.path))?.path;
7571
7834
  }
7572
7835
  function branchMissingFromList(branch, stdout) {
7573
7836
  const names = stdout.split(/\r?\n/).map((line) => line.replace(/^\*\s*/, "").trim()).filter(Boolean);
@@ -7598,8 +7861,16 @@ async function cleanupPrMergeLocalBranch(branch, options) {
7598
7861
  }
7599
7862
  const beforeWorktrees = options.beforeWorktrees ?? [];
7600
7863
  const wtPath = selectPrMergeCleanupWorktree(branch, beforeWorktrees, afterWorktrees, options.startingPath);
7601
- const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
7864
+ const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath, {
7865
+ pathExists: options.pathExists
7866
+ });
7602
7867
  const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
7868
+ const removeDeps = {
7869
+ git,
7870
+ sleep: options.sleep ?? defaultSleep,
7871
+ removeWorktreeDir: options.removeWorktreeDir
7872
+ };
7873
+ await sweepDeferredWorktrees(options.deferredStore, removeDeps).catch(() => void 0);
7603
7874
  const mainWorktreePath = beforeWorktrees[0]?.path ?? afterWorktrees[0]?.path;
7604
7875
  const mainWorktreeTarget = Boolean(wtPath && mainWorktreePath && samePath(wtPath, mainWorktreePath));
7605
7876
  if (wtPath && mainWorktreeTarget) {
@@ -7620,6 +7891,37 @@ async function cleanupPrMergeLocalBranch(branch, options) {
7620
7891
  });
7621
7892
  if (outcome.status === "removed") {
7622
7893
  report.worktree = { path: wtPath, status: "removed", stageTeardown, recovery: outcome.recovery };
7894
+ } else if (isPersistentWorktreeLockFailure(outcome) && options.deferredStore) {
7895
+ try {
7896
+ const { newlyRegistered } = await registerDeferredWorktree(options.deferredStore, {
7897
+ path: wtPath,
7898
+ branch,
7899
+ reason: "lock-held"
7900
+ });
7901
+ report.worktree = {
7902
+ path: wtPath,
7903
+ status: "deferred",
7904
+ reason: "lock-held",
7905
+ error: outcome.error,
7906
+ deferredNote: DEFERRED_NOTE,
7907
+ deferredSweepCommand: DEFERRED_SWEEP_COMMAND,
7908
+ ...newlyRegistered ? { safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath) } : {},
7909
+ stageTeardown
7910
+ };
7911
+ report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-deferred" };
7912
+ return report;
7913
+ } catch (e) {
7914
+ report.worktree = {
7915
+ path: wtPath,
7916
+ status: "failed",
7917
+ reason: "deferred-registry-unavailable",
7918
+ error: errorMessage(e),
7919
+ safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath),
7920
+ stageTeardown
7921
+ };
7922
+ report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
7923
+ return report;
7924
+ }
7623
7925
  } else {
7624
7926
  report.worktree = {
7625
7927
  path: wtPath,
@@ -8419,6 +8721,7 @@ var CURSOR_PLUGIN_INSTALL_LABEL = "Cursor Team Marketplace plugin install";
8419
8721
  var CURSOR_MARKETPLACE_INSTALL_GUIDE = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/cursor-marketplace-install.md";
8420
8722
  var CURSOR_PLUGIN_JSON_REL = ".cursor-plugin/plugin.json";
8421
8723
  var CURSOR_HOOKS_JSON_REL = "hooks/hooks.json";
8724
+ var CURSOR_HOOK_CLI_LABEL = "Cursor hook CLI bundle";
8422
8725
  function joinCachePath(root, ...parts) {
8423
8726
  const sep = root.includes("\\") ? "\\" : "/";
8424
8727
  return [root.replace(/[\\/]+$/, ""), ...parts].join(sep);
@@ -8474,6 +8777,20 @@ function buildCursorPluginInstallCheck(input) {
8474
8777
  }
8475
8778
  return { ...base2, cacheRoot: input.cacheRoot, pins: input.pins };
8476
8779
  }
8780
+ function buildCursorHookCliCheck(input) {
8781
+ const fix = "update the MMI Team Marketplace plugin (releases ship cli/dist under plugins/mmi/cli/dist) or install mmi-cli on PATH \u2014 Cursor hooks fall back to PATH when the bundled CLI is missing";
8782
+ const base2 = { ok: true, label: CURSOR_HOOK_CLI_LABEL, fix };
8783
+ if (!input.isOrgRepo) return base2;
8784
+ const shouldCheck = input.surface === "cursor" || input.pins.length > 0;
8785
+ if (!shouldCheck) return base2;
8786
+ if (input.pins.length === 0) {
8787
+ if (input.surface === "cursor" && !input.mmiCliOnPath) return { ...base2, ok: false };
8788
+ return base2;
8789
+ }
8790
+ const missingBundle = input.pins.some((p) => !p.hasCliBundle);
8791
+ if (missingBundle && !input.mmiCliOnPath) return { ...base2, ok: false };
8792
+ return base2;
8793
+ }
8477
8794
  var HUB_COMPAT_FIX = "update mmi-cli (npm i -g @mutmutco/cli) / refresh the MMI plugin, then rerun doctor";
8478
8795
  function buildHubCompatCheck(input) {
8479
8796
  const label = "Hub compatibility (client version vs Hub minimum)";
@@ -8483,6 +8800,48 @@ function buildHubCompatCheck(input) {
8483
8800
  }
8484
8801
  return { ok: versionAtLeast(input.installedVersion, min), label: `${label}: requires >= ${min}`, fix: HUB_COMPAT_FIX };
8485
8802
  }
8803
+ var PLAYWRIGHT_MCP_VISION_CAP_LABEL = "Playwright MCP vision caps (--caps=vision prohibited)";
8804
+ var PLAYWRIGHT_MCP_VISION_CAP_FIX = "remove --caps=vision (and vision-first defaults) from Playwright MCP args \u2014 use DOM-first tools; see skills/browser-automation/SKILL.md and bootstrap seed mcp-playwright.template.json";
8805
+ function textHasPlaywrightVisionCap(content) {
8806
+ const normalized = content.replace(/\r\n/g, "\n");
8807
+ if (/(?:^|[\s"'`=])--caps=(?:[^"\s\n]*,)?vision\b/m.test(normalized)) return true;
8808
+ if (/\b--caps\s*=\s*["']?vision\b/.test(normalized)) return true;
8809
+ if (/"--caps"\s*,\s*"vision"/.test(normalized)) return true;
8810
+ if (/"caps"\s*:\s*"vision"/.test(normalized)) return true;
8811
+ const playwrightMcp = /@playwright\/mcp/.test(normalized) || /mcp_servers\.playwright/.test(normalized) || /"playwright"\s*:\s*\{/.test(normalized) || /\bmcpServers\b/.test(normalized);
8812
+ if (!playwrightMcp) return false;
8813
+ if (/\bvision[-_]?(?:first|only|mode)\b/i.test(normalized)) return true;
8814
+ return false;
8815
+ }
8816
+ function buildPlaywrightMcpVisionCapCheck(input) {
8817
+ const base2 = {
8818
+ ok: true,
8819
+ label: PLAYWRIGHT_MCP_VISION_CAP_LABEL,
8820
+ fix: PLAYWRIGHT_MCP_VISION_CAP_FIX
8821
+ };
8822
+ if (!input.isOrgRepo) return base2;
8823
+ const offending = input.configs.filter((c) => textHasPlaywrightVisionCap(c.content)).map((c) => c.path);
8824
+ if (offending.length === 0) return base2;
8825
+ return {
8826
+ ...base2,
8827
+ ok: false,
8828
+ offendingPaths: offending,
8829
+ fix: `${PLAYWRIGHT_MCP_VISION_CAP_FIX} \u2014 found in: ${offending.join(", ")}`
8830
+ };
8831
+ }
8832
+ var STRAY_BROWSER_ARTIFACT_DIRS = [".playwright-mcp", "playwright-report", "test-results"];
8833
+ var BROWSER_ARTIFACTS_LABEL = "browser MCP artifacts outside tmp/ (use tmp/playwright-mcp)";
8834
+ var BROWSER_ARTIFACTS_FIX = "move or delete stray Playwright output at repo root; re-run MCP with --output-dir tmp/playwright-mcp \u2014 see skills/browser-automation/SKILL.md";
8835
+ function buildBrowserArtifactsCheck(input) {
8836
+ const base2 = { ok: true, label: BROWSER_ARTIFACTS_LABEL, fix: BROWSER_ARTIFACTS_FIX };
8837
+ if (!input.isOrgRepo || input.strayPaths.length === 0) return base2;
8838
+ return {
8839
+ ...base2,
8840
+ ok: false,
8841
+ strayPaths: [...input.strayPaths],
8842
+ fix: `${BROWSER_ARTIFACTS_FIX} \u2014 found: ${input.strayPaths.join(", ")}`
8843
+ };
8844
+ }
8486
8845
 
8487
8846
  // src/stage-live.ts
8488
8847
  var import_node_net = require("node:net");
@@ -8856,10 +9215,22 @@ function resolveDeployModel(meta, repo) {
8856
9215
  function projectTypeClearsWebProfile(projectType, deployModel) {
8857
9216
  return projectType === "content" || projectType === "desktop-game" || projectType === "non-deployable" || projectType === "cli-tool" || deployModel === "content" || deployModel === "none" || deployModel === "registry-publish";
8858
9217
  }
8859
- function resolveReleaseTrack(meta) {
9218
+ function inferReleaseTrackFromBranches(hints) {
9219
+ if (hints.hasRcBranch) return void 0;
9220
+ if (hints.hasDevelopmentBranch && hints.hasMainBranch) return "direct";
9221
+ return void 0;
9222
+ }
9223
+ function resolveBootstrapReleaseTrack(cls, explicit) {
9224
+ if (isReleaseTrack(explicit)) return explicit;
9225
+ if (cls === "content") return "trunk";
9226
+ return "full";
9227
+ }
9228
+ function resolveReleaseTrack(meta, hints) {
8860
9229
  const raw = typeof meta?.releaseTrack === "string" ? meta.releaseTrack : void 0;
8861
9230
  if (isReleaseTrack(raw)) return raw;
8862
9231
  if (meta?.class === "content" || meta?.deployModel === "content") return "trunk";
9232
+ const inferred = hints ? inferReleaseTrackFromBranches(hints) : void 0;
9233
+ if (inferred) return inferred;
8863
9234
  return "full";
8864
9235
  }
8865
9236
  function branchesForTrack(track) {
@@ -9010,6 +9381,18 @@ function ensurePositiveCount(out, emptyMessage) {
9010
9381
  if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
9011
9382
  if (count <= 0) throw new Error(emptyMessage);
9012
9383
  }
9384
+ async function remoteBranchExists(deps, branch) {
9385
+ const out = clean(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
9386
+ return out.length > 0;
9387
+ }
9388
+ async function loadReleaseTrackBranchHints(deps) {
9389
+ const [hasDevelopmentBranch, hasMainBranch, hasRcBranch] = await Promise.all([
9390
+ remoteBranchExists(deps, "development"),
9391
+ remoteBranchExists(deps, "main"),
9392
+ remoteBranchExists(deps, "rc")
9393
+ ]);
9394
+ return { hasDevelopmentBranch, hasMainBranch, hasRcBranch };
9395
+ }
9013
9396
  async function buildTrainApplyContext(deps) {
9014
9397
  const repo = requireValue(clean(await deps.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])), "repo");
9015
9398
  const [owner, name] = repo.split("/");
@@ -9443,7 +9826,8 @@ async function runTrainApply(command, deps, options = {}) {
9443
9826
  await requireCleanTree(deps);
9444
9827
  await deps.run("git", ["fetch", "origin"]);
9445
9828
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
9446
- const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta) === "direct";
9829
+ const branchHints = await loadReleaseTrackBranchHints(deps);
9830
+ const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta, branchHints) === "direct";
9447
9831
  if (command === "rcand") {
9448
9832
  await requireBranch(deps, "development");
9449
9833
  if (directTrack) {
@@ -9502,7 +9886,8 @@ async function runTrainApply(command, deps, options = {}) {
9502
9886
  const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "main");
9503
9887
  const checks2 = await waitForRequiredTrainChecks(deps, ctx, releaseSha2, requiredChecks2);
9504
9888
  await deps.run("git", ["push", "origin", "main"]);
9505
- const releaseUrl2 = clean(await deps.run("gh", ["release", "create", tag2, "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
9889
+ const releaseUrl2 = clean(await deps.run("gh", ["release", "create", tag2, "--target", "main", "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
9890
+ await verifyPublishedRelease(deps, ctx.repo, tag2, "main", releaseSha2);
9506
9891
  const announceNote2 = deps.announce ? (await deps.announce({ repo: ctx.repo, tag: tag2, summaryFile: options.announceSummaryFile })).note : void 0;
9507
9892
  const autoRunSince2 = (deps.now ?? Date.now)();
9508
9893
  const d2 = await dispatchDeploy(deps, ctx, "main", "main", deployModel2, watch, autoRunSince2, releaseSha2, "report");
@@ -9534,13 +9919,16 @@ async function runTrainApply(command, deps, options = {}) {
9534
9919
  if (command === "release" && options.dev) {
9535
9920
  await requireBranch(deps, "development");
9536
9921
  await ffOnlyPull(deps, "development");
9537
- const rcOnlyOut = (await deps.run("git", ["rev-list", "--count", "--right-only", "--cherry-pick", "--no-merges", "origin/development...origin/rc"])).trim();
9538
- const rcOnly = Number.parseInt(rcOnlyOut, 10);
9539
- if (!Number.isFinite(rcOnly)) throw new Error(`release --dev: could not count rc-only commits for the guard: ${rcOnlyOut || "(empty)"}`);
9540
- if (rcOnly > 0) {
9541
- throw new Error(
9542
- `release --dev refused: origin/rc carries ${rcOnly} commit(s) not in origin/development \u2014 a development -> main release would drop that rc-only content. Land it on development first, or release the candidate via the default rc -> main path, then rerun.`
9543
- );
9922
+ const hasRcBranch = branchHints.hasRcBranch ?? false;
9923
+ if (hasRcBranch) {
9924
+ const rcOnlyOut = (await deps.run("git", ["rev-list", "--count", "--right-only", "--cherry-pick", "--no-merges", "origin/development...origin/rc"])).trim();
9925
+ const rcOnly = Number.parseInt(rcOnlyOut, 10);
9926
+ if (!Number.isFinite(rcOnly)) throw new Error(`release --dev: could not count rc-only commits for the guard: ${rcOnlyOut || "(empty)"}`);
9927
+ if (rcOnly > 0) {
9928
+ throw new Error(
9929
+ `release --dev refused: origin/rc carries ${rcOnly} commit(s) not in origin/development \u2014 a development -> main release would drop that rc-only content. Land it on development first, or release the candidate via the default rc -> main path, then rerun.`
9930
+ );
9931
+ }
9544
9932
  }
9545
9933
  ensurePositiveCount(
9546
9934
  await deps.run("git", ["rev-list", "--count", "origin/main..origin/development"]),
@@ -9548,7 +9936,7 @@ async function runTrainApply(command, deps, options = {}) {
9548
9936
  );
9549
9937
  const deployModel2 = await preflight(deps, ctx, "main", meta);
9550
9938
  const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
9551
- const rcShaAtRelease = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
9939
+ const rcShaAtRelease = hasRcBranch ? clean(await deps.run("git", ["rev-parse", "origin/rc"])) : "";
9552
9940
  const foldPaths2 = await resolveFoldPaths(deps, deployModel2);
9553
9941
  const tolerated2 = [...foldPaths2, ...RELEASE_TOLERATED_PATHS];
9554
9942
  const predicted2 = await predictMergeConflicts(deps, "origin/main", "origin/development");
@@ -9571,18 +9959,23 @@ async function runTrainApply(command, deps, options = {}) {
9571
9959
  const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "main");
9572
9960
  const checks2 = await waitForRequiredTrainChecks(deps, ctx, releaseSha2, requiredChecks2);
9573
9961
  await deps.run("git", ["push", "origin", "main"]);
9574
- const releaseUrl2 = clean(await deps.run("gh", ["release", "create", tag2, "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
9962
+ const releaseUrl2 = clean(await deps.run("gh", ["release", "create", tag2, "--target", "main", "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
9963
+ await verifyPublishedRelease(deps, ctx.repo, tag2, "main", releaseSha2);
9575
9964
  const announceNote2 = deps.announce ? (await deps.announce({ repo: ctx.repo, tag: tag2, summaryFile: options.announceSummaryFile })).note : void 0;
9576
9965
  const autoRunSince2 = (deps.now ?? Date.now)();
9577
9966
  const d2 = await dispatchDeploy(deps, ctx, "main", "main", deployModel2, watch, autoRunSince2, releaseSha2, "report");
9578
- const retirement2 = await retireRcRuntime(deps, ctx, deployModel2, d2.deployStatus, rcShaAtRelease);
9967
+ const retirement2 = hasRcBranch ? await retireRcRuntime(deps, ctx, deployModel2, d2.deployStatus, rcShaAtRelease) : { status: "not-applicable", note: "no origin/rc branch \u2014 rc runtime retirement skipped" };
9579
9968
  const devRollForward2 = await rollDevelopmentForward(deps, ctx, tag2);
9580
9969
  let rcAlignment2;
9581
- try {
9582
- await deps.run("git", ["push", "origin", "main:rc"]);
9583
- rcAlignment2 = "origin/rc aligned to the released main";
9584
- } catch (e) {
9585
- rcAlignment2 = `rc alignment push failed \u2014 align manually with \`git push origin main:rc\`: ${e instanceof Error ? e.message : String(e)}`;
9970
+ if (hasRcBranch) {
9971
+ try {
9972
+ await deps.run("git", ["push", "origin", "main:rc"]);
9973
+ rcAlignment2 = "origin/rc aligned to the released main";
9974
+ } catch (e) {
9975
+ rcAlignment2 = `rc alignment push failed \u2014 align manually with \`git push origin main:rc\`: ${e instanceof Error ? e.message : String(e)}`;
9976
+ }
9977
+ } else {
9978
+ rcAlignment2 = "no origin/rc branch \u2014 rc alignment skipped";
9586
9979
  }
9587
9980
  const environments2 = await buildEnvironments(deps, ctx, deployModel2, d2.deployStatus, retirement2);
9588
9981
  return {
@@ -9987,6 +10380,11 @@ async function deriveHotfixVersion(deps) {
9987
10380
  function hotfixBranch(tag) {
9988
10381
  return `hotfix/${tag}`;
9989
10382
  }
10383
+ async function resolveHotfixDeployModel(deps, ctx) {
10384
+ const load = await loadProjectMeta(deps, ctx);
10385
+ const meta = load.status === "ok" ? load.meta : null;
10386
+ return resolveDeployModel2(meta, ctx.repo);
10387
+ }
9990
10388
  async function findHotfixPr(deps, ctx, tag) {
9991
10389
  const out = await deps.run("gh", [
9992
10390
  "pr",
@@ -10024,6 +10422,7 @@ async function resolveHotfixSource(deps, ctx, from) {
10024
10422
  }
10025
10423
  async function runHotfixStart(deps, options) {
10026
10424
  const ctx = await buildTrainApplyContext(deps);
10425
+ const deployModel = await resolveHotfixDeployModel(deps, ctx);
10027
10426
  const status = await deps.run("git", ["status", "--porcelain"]);
10028
10427
  if (status.trim()) throw new Error("working tree must be clean before hotfix start");
10029
10428
  await deps.run("git", ["fetch", "origin", "--tags"]);
@@ -10061,18 +10460,23 @@ async function runHotfixStart(deps, options) {
10061
10460
  throw new Error(`cherry-pick of ${label} onto ${branch} conflicted \u2014 aborted; resolve by hand on a manual hotfix branch, keeping the -x trailer (${e.message ?? e})`);
10062
10461
  }
10063
10462
  notes.push(`cherry-picked ${label} onto ${branch} (from origin/main, -x trailer recorded)`);
10064
- await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version]);
10065
- const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
10066
- await deps.run("git", ["add", "--", ...changedFiles]);
10067
- const staged = await deps.run("git", ["diff", "--cached", "--name-only"]);
10068
- if (staged.trim()) {
10069
- await deps.run("git", ["commit", "-m", `hotfix ${tag}: lock plugin set + @mutmutco/cli distribution to ${version}`]);
10070
- notes.push(`distribution prepared + committed for ${version} (${changedFiles.length} locked paths)`);
10463
+ if (deployModel === "hub-serverless") {
10464
+ await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version]);
10465
+ const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
10466
+ await deps.run("git", ["add", "--", ...changedFiles]);
10467
+ const staged = await deps.run("git", ["diff", "--cached", "--name-only"]);
10468
+ if (staged.trim()) {
10469
+ await deps.run("git", ["commit", "-m", `hotfix ${tag}: lock plugin set + @mutmutco/cli distribution to ${version}`]);
10470
+ notes.push(`distribution prepared + committed for ${version} (${changedFiles.length} locked paths)`);
10471
+ } else {
10472
+ notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
10473
+ }
10071
10474
  } else {
10072
- notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
10475
+ notes.push(`distribution bump skipped (deployModel=${deployModel}, Hub-only step)`);
10073
10476
  }
10074
10477
  await deps.run("git", ["push", "-u", "origin", branch]);
10075
10478
  }
10479
+ const bumpNote = deployModel === "hub-serverless" ? " with the locked distribution bump" : "";
10076
10480
  const prUrl = clean2(await deps.run("gh", [
10077
10481
  "pr",
10078
10482
  "create",
@@ -10085,7 +10489,7 @@ async function runHotfixStart(deps, options) {
10085
10489
  "--title",
10086
10490
  `[hotfix] ${tag}`,
10087
10491
  "--body",
10088
- `Hotfix ${tag}: cherry-pick of ${label} onto origin/main with the locked distribution bump.
10492
+ `Hotfix ${tag}: cherry-pick of ${label} onto origin/main${bumpNote}.
10089
10493
 
10090
10494
  Merge this PR (human-initiated), then run \`mmi-cli hotfix release ${tag}\`.`
10091
10495
  ]));
@@ -10130,6 +10534,7 @@ async function watchReleaseRun(deps, ctx, workflow, sha) {
10130
10534
  }
10131
10535
  async function runHotfixRelease(deps, versionInput, options = {}) {
10132
10536
  const ctx = await buildTrainApplyContext(deps);
10537
+ const deployModel = await resolveHotfixDeployModel(deps, ctx);
10133
10538
  const { tag, version } = normalizeHotfixVersion(versionInput);
10134
10539
  const status = await deps.run("git", ["status", "--porcelain"]);
10135
10540
  if (status.trim()) throw new Error("working tree must be clean before hotfix release");
@@ -10164,33 +10569,74 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
10164
10569
  }
10165
10570
  }
10166
10571
  const runs = [];
10167
- for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
10168
- runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
10572
+ let deployNote;
10573
+ if (deployModel === "hub-serverless") {
10574
+ for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
10575
+ runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
10576
+ }
10577
+ deployNote = "watched release-triggered deploy.yml + publish.yml";
10578
+ } else if (deployModel === "tenant-container" || deployModel === "solo-container") {
10579
+ const dispatch = await dispatchDeploy(
10580
+ deps,
10581
+ ctx,
10582
+ "main",
10583
+ "main",
10584
+ deployModel,
10585
+ true,
10586
+ (deps.now ?? Date.now)(),
10587
+ mergedSha,
10588
+ "report"
10589
+ );
10590
+ deployNote = dispatch.note;
10591
+ runs.push({
10592
+ workflow: "tenant-deploy.yml",
10593
+ url: dispatch.runUrl,
10594
+ conclusion: dispatch.deployStatus === "success" ? "success" : dispatch.deployStatus === "failure" ? "failure" : dispatch.deployStatus ?? "pending"
10595
+ });
10596
+ } else {
10597
+ deployNote = `no hotfix deploy dispatch for deployModel=${deployModel} \u2014 prod deploy is repo-specific`;
10169
10598
  }
10170
- const previousRef = clean2(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
10171
10599
  let verifyNote;
10172
- const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
10173
- try {
10174
- await deps.run("git", ["-c", "advice.detachedHead=false", "checkout", tag]);
10175
- const verifyArgs = ["scripts/release-distribution.mjs", "verify", version, ...publishSucceeded ? [] : ["--skip-npm-view"]];
10176
- const sleep = sleeper(deps);
10177
- let attempt = 0;
10178
- for (; ; ) {
10179
- attempt++;
10180
- try {
10181
- await deps.run("node", verifyArgs);
10182
- break;
10183
- } catch (err) {
10184
- if (attempt >= HOTFIX_VERIFY_ATTEMPTS) throw err;
10185
- await sleep(HOTFIX_VERIFY_RETRY_MS);
10600
+ if (deployModel === "hub-serverless") {
10601
+ const previousRef = clean2(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
10602
+ const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
10603
+ try {
10604
+ await deps.run("git", ["-c", "advice.detachedHead=false", "checkout", tag]);
10605
+ const verifyArgs = ["scripts/release-distribution.mjs", "verify", version, ...publishSucceeded ? [] : ["--skip-npm-view"]];
10606
+ const sleep = sleeper(deps);
10607
+ let attempt = 0;
10608
+ for (; ; ) {
10609
+ attempt++;
10610
+ try {
10611
+ await deps.run("node", verifyArgs);
10612
+ break;
10613
+ } catch (err) {
10614
+ if (attempt >= HOTFIX_VERIFY_ATTEMPTS) throw err;
10615
+ await sleep(HOTFIX_VERIFY_RETRY_MS);
10616
+ }
10186
10617
  }
10618
+ const retried = attempt > 1 ? `, after ${attempt} attempts (npm propagation lag)` : "";
10619
+ verifyNote = `distribution verified at ${tag}${publishSucceeded ? ` (npm included${retried})` : " (npm view skipped \u2014 publish run not confirmed)"}`;
10620
+ } finally {
10621
+ if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]);
10187
10622
  }
10188
- const retried = attempt > 1 ? `, after ${attempt} attempts (npm propagation lag)` : "";
10189
- verifyNote = `distribution verified at ${tag}${publishSucceeded ? ` (npm included${retried})` : " (npm view skipped \u2014 publish run not confirmed)"}`;
10190
- } finally {
10191
- if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]);
10623
+ } else {
10624
+ verifyNote = `distribution verify skipped (deployModel=${deployModel}, Hub-only step)`;
10192
10625
  }
10193
- return { ...ctx, command: "hotfix-release", tag, mergedSha, checks, tagNote, releaseNote, runs, verifyNote, announceNote };
10626
+ return {
10627
+ ...ctx,
10628
+ command: "hotfix-release",
10629
+ tag,
10630
+ mergedSha,
10631
+ deployModel,
10632
+ checks,
10633
+ tagNote,
10634
+ releaseNote,
10635
+ runs,
10636
+ deployNote,
10637
+ verifyNote,
10638
+ announceNote
10639
+ };
10194
10640
  }
10195
10641
  function deriveHotfixState(f) {
10196
10642
  if (!f.branchExists && !f.pr && !f.tagPushed && !f.releaseExists) {
@@ -10226,6 +10672,11 @@ async function runHotfixStatus(deps, versionInput) {
10226
10672
  return { ...ctx, command: "hotfix-status", ...latestFacts, ...latestDerived };
10227
10673
  }
10228
10674
  }
10675
+ const inFlight = await findInFlightHotfixVersion(deps, ctx);
10676
+ if (inFlight) {
10677
+ const facts2 = await gatherHotfixFacts(deps, ctx, inFlight.tag, inFlight.version);
10678
+ return { ...ctx, command: "hotfix-status", ...facts2, ...deriveHotfixState(facts2) };
10679
+ }
10229
10680
  ({ tag, version } = await deriveHotfixVersion(deps));
10230
10681
  }
10231
10682
  const facts = await gatherHotfixFacts(deps, ctx, tag, version);
@@ -10271,6 +10722,47 @@ async function gatherHotfixFacts(deps, ctx, tag, version) {
10271
10722
  const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(clean2, () => "unknown");
10272
10723
  return { tag, version, branchExists, pr: pr2 ? { number: pr2.number, state: pr2.state, url: pr2.url } : null, tagPushed, releaseExists, runs, npmVersion };
10273
10724
  }
10725
+ async function findInFlightHotfixVersion(deps, ctx) {
10726
+ const tags = /* @__PURE__ */ new Set();
10727
+ const out = await deps.run("gh", [
10728
+ "pr",
10729
+ "list",
10730
+ "--repo",
10731
+ ctx.repo,
10732
+ "--base",
10733
+ "main",
10734
+ "--state",
10735
+ "all",
10736
+ "--limit",
10737
+ "50",
10738
+ "--json",
10739
+ "headRefName"
10740
+ ]);
10741
+ for (const row of JSON.parse(out || "[]")) {
10742
+ const m = typeof row.headRefName === "string" && /^hotfix\/(v\d+\.\d+\.\d+)/.exec(row.headRefName);
10743
+ if (m) tags.add(m[1]);
10744
+ }
10745
+ const branchOut = clean2(await deps.run("git", ["ls-remote", "origin", "refs/heads/hotfix/v*"]));
10746
+ for (const line of branchOut.split("\n").filter(Boolean)) {
10747
+ const ref = line.split(/\s+/)[1] ?? "";
10748
+ const m = /^refs\/heads\/hotfix\/(v\d+\.\d+\.\d+)/.exec(ref);
10749
+ if (m) tags.add(m[1]);
10750
+ }
10751
+ const sorted = [...tags].sort((a, b) => {
10752
+ const pa = a.slice(1).split(".").map(Number);
10753
+ const pb = b.slice(1).split(".").map(Number);
10754
+ for (let i = 0; i < 3; i++) {
10755
+ if (pa[i] !== pb[i]) return pb[i] - pa[i];
10756
+ }
10757
+ return 0;
10758
+ });
10759
+ for (const tag of sorted) {
10760
+ const version = tag.slice(1);
10761
+ const facts = await gatherHotfixFacts(deps, ctx, tag, version);
10762
+ if (deriveHotfixState(facts).state !== "complete") return { tag, version };
10763
+ }
10764
+ return null;
10765
+ }
10274
10766
 
10275
10767
  // src/release-announce.ts
10276
10768
  var ANNOUNCE_REPO = "mutmutco/MMI-Hub";
@@ -10716,6 +11208,9 @@ var requiredIssueTemplates = [
10716
11208
  ".github/ISSUE_TEMPLATE/config.yml"
10717
11209
  ];
10718
11210
  var requiredWorkflows = [];
11211
+ var requiredProductWorkflows = [".github/workflows/gate.yml"];
11212
+ var requiredProductRulesetRef = ".github/rulesets/mmi-product-required-checks.json";
11213
+ var HUB_REPO3 = "mutmutco/MMI-Hub";
10719
11214
  var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
10720
11215
  var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
10721
11216
  var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
@@ -10728,6 +11223,7 @@ var requiredProjectWorkflows = [
10728
11223
  ];
10729
11224
  var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
10730
11225
  var requiredHubStatusChecks = ["cli", "infra", "docs"];
11226
+ var requiredProductStatusChecks = ["gate"];
10731
11227
  function expectedBranches(repoClass, releaseTrack) {
10732
11228
  if (isReleaseTrack(releaseTrack)) return branchesForTrack(releaseTrack);
10733
11229
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
@@ -10880,6 +11376,16 @@ async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
10880
11376
  for (const path2 of requiredWorkflows) {
10881
11377
  checks.push({ ok: await contentExists(deps, repo, baseBranch, path2), label: `automation workflow exists: ${path2}` });
10882
11378
  }
11379
+ if (repo !== HUB_REPO3) {
11380
+ for (const path2 of requiredProductWorkflows) {
11381
+ checks.push({ ok: await contentExists(deps, repo, baseBranch, path2), label: `gate workflow exists: ${path2}` });
11382
+ }
11383
+ checks.push({
11384
+ ok: await contentExists(deps, repo, baseBranch, requiredProductRulesetRef),
11385
+ label: "product required-check ruleset reference exists",
11386
+ detail: `expected: ${requiredProductRulesetRef} (apply as an active repo ruleset after bootstrap)`
11387
+ });
11388
+ }
10883
11389
  if (repoClass === "deployable") {
10884
11390
  const trainScript = "scripts/next-version.mjs";
10885
11391
  checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
@@ -11015,7 +11521,7 @@ async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
11015
11521
  label: "covered by an active org ruleset",
11016
11522
  detail: orgRuleset ? void 0 : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
11017
11523
  });
11018
- if (repo === "mutmutco/MMI-Hub") {
11524
+ if (repo === HUB_REPO3) {
11019
11525
  const statusChecks = rulesetStatusChecks(rulesets.filter((r) => r.target === "branch" && r.enforcement === "active"));
11020
11526
  const missing = requiredHubStatusChecks.filter((check) => !statusChecks.has(check));
11021
11527
  checks.push({
@@ -11023,6 +11529,14 @@ async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
11023
11529
  label: "Hub required status checks configured",
11024
11530
  detail: optionDetail(missing)
11025
11531
  });
11532
+ } else {
11533
+ const statusChecks = rulesetStatusChecks(rulesets.filter((r) => r.target === "branch" && r.enforcement === "active"));
11534
+ const missing = requiredProductStatusChecks.filter((check) => !statusChecks.has(check));
11535
+ checks.push({
11536
+ ok: missing.length === 0,
11537
+ label: "product required status checks configured",
11538
+ detail: missing.length ? `missing contexts: ${missing.join(", ")} \u2014 apply ${requiredProductRulesetRef} as an active repo ruleset` : void 0
11539
+ });
11026
11540
  }
11027
11541
  const declaredApis = (deps.requiredGcpApis ?? []).filter((a) => a && a.trim());
11028
11542
  if (declaredApis.length > 0) {
@@ -11074,12 +11588,42 @@ function parseOwnerRepo(repo) {
11074
11588
  return { owner, name, slug: name.toLowerCase(), fullName: `${owner}/${name}` };
11075
11589
  }
11076
11590
  var DEFAULT_INSTALL_CMD = "npm ci";
11077
- function withDerivedRepoVars(vars, parsed, cls) {
11591
+ var DEFAULT_GATE_CMD = "npm run check";
11592
+ function gateSeedVars(cls, releaseTrack) {
11593
+ const track = releaseTrack ?? (cls === "content" ? "trunk" : "full");
11594
+ if (track === "trunk") {
11595
+ return {
11596
+ GATE_CMD: DEFAULT_GATE_CMD,
11597
+ GATE_PUSH_BRANCHES_YAML: "[main]",
11598
+ GATE_FULL_RUN_BRANCH: "main",
11599
+ GATE_RULESET_BRANCH_REFS_JSON: '["refs/heads/main"]'
11600
+ };
11601
+ }
11602
+ if (track === "direct") {
11603
+ return {
11604
+ GATE_CMD: DEFAULT_GATE_CMD,
11605
+ GATE_PUSH_BRANCHES_YAML: "[development, main]",
11606
+ GATE_FULL_RUN_BRANCH: "development",
11607
+ GATE_RULESET_BRANCH_REFS_JSON: '["refs/heads/development", "refs/heads/main"]'
11608
+ };
11609
+ }
11610
+ return {
11611
+ GATE_CMD: DEFAULT_GATE_CMD,
11612
+ GATE_PUSH_BRANCHES_YAML: "[development, rc, main]",
11613
+ GATE_FULL_RUN_BRANCH: "development",
11614
+ GATE_RULESET_BRANCH_REFS_JSON: '["refs/heads/development", "refs/heads/rc", "refs/heads/main"]'
11615
+ };
11616
+ }
11617
+ function withDerivedRepoVars(vars, parsed, cls, releaseTrack) {
11078
11618
  const out = { ...vars };
11079
11619
  out.REPO_NAME ??= parsed.name;
11080
11620
  out.REPO_SLUG ??= parsed.slug;
11081
11621
  out.CLASS ??= cls;
11082
11622
  out.INSTALL_CMD ??= DEFAULT_INSTALL_CMD;
11623
+ const track = releaseTrack ?? resolveBootstrapReleaseTrack(cls);
11624
+ for (const [key, value] of Object.entries(gateSeedVars(cls, track))) {
11625
+ out[key] ??= value;
11626
+ }
11083
11627
  return out;
11084
11628
  }
11085
11629
  function planSeedAction(seed, exists) {
@@ -11189,8 +11733,8 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
11189
11733
  class: cls,
11190
11734
  projectType,
11191
11735
  deployModel,
11192
- // #917: only emit when explicitly direct/trunk; absent registry resolves to `full` (no clobber).
11193
- releaseTrack: isReleaseTrack(options.releaseTrack) ? options.releaseTrack : void 0,
11736
+ // #1359: always persist an explicit track so release tooling never guesses from absence alone.
11737
+ releaseTrack: resolveBootstrapReleaseTrack(cls, options.releaseTrack),
11194
11738
  // Board coords (from GraphQL at bootstrap, passed as --var by the skill).
11195
11739
  projectOwner: vars.PROJECT_OWNER || void 0,
11196
11740
  projectNumber: num(vars.PROJECT_NUMBER),
@@ -11204,6 +11748,11 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
11204
11748
  kbPointer: `kb/projects/${slug}.md`
11205
11749
  };
11206
11750
  for (const k of Object.keys(payload)) if (payload[k] === void 0) delete payload[k];
11751
+ if (payload.projectId && payload.projectNumber == null) {
11752
+ throw new Error(
11753
+ "bootstrap apply: PROJECT_ID is set but PROJECT_NUMBER is missing \u2014 pass --var PROJECT_NUMBER=<N> or ensure the live board GraphQL query succeeds"
11754
+ );
11755
+ }
11207
11756
  return payload;
11208
11757
  }
11209
11758
  var BOARD_FIELD_VAR_MAP = {
@@ -11216,9 +11765,18 @@ var BOARD_FIELD_VAR_MAP = {
11216
11765
  options: { Urgent: "PRIORITY_URGENT", High: "PRIORITY_HIGH", Medium: "PRIORITY_MEDIUM", Low: "PRIORITY_LOW" }
11217
11766
  }
11218
11767
  };
11768
+ function projectV2BoardNode(fieldsJson) {
11769
+ if (Array.isArray(fieldsJson)) return { fields: { nodes: fieldsJson } };
11770
+ const wrapped = fieldsJson;
11771
+ return wrapped?.data?.node ?? wrapped?.node;
11772
+ }
11219
11773
  function extractBoardFieldVars(fieldsJson) {
11220
11774
  const out = {};
11221
- const nodes = Array.isArray(fieldsJson) ? fieldsJson : fieldsJson?.data?.node?.fields?.nodes ?? fieldsJson?.node?.fields?.nodes;
11775
+ const projectNode = projectV2BoardNode(fieldsJson);
11776
+ if (typeof projectNode?.number === "number" && Number.isFinite(projectNode.number)) {
11777
+ out.PROJECT_NUMBER = String(projectNode.number);
11778
+ }
11779
+ const nodes = projectNode?.fields?.nodes;
11222
11780
  if (!Array.isArray(nodes)) return out;
11223
11781
  for (const node of nodes) {
11224
11782
  const field = node;
@@ -11236,7 +11794,7 @@ function extractBoardFieldVars(fieldsJson) {
11236
11794
  return out;
11237
11795
  }
11238
11796
  function boardFieldsQueryArgs(projectId) {
11239
- const query = "query($id: ID!) { node(id: $id) { ... on ProjectV2 { fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } } }";
11797
+ const query = "query($id: ID!) { node(id: $id) { ... on ProjectV2 { number fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } } }";
11240
11798
  return ["api", "graphql", "-f", `query=${query}`, "-f", `id=${projectId}`];
11241
11799
  }
11242
11800
  function serializeRegistry(obj) {
@@ -11481,6 +12039,14 @@ function dnsErrorToResolution(code) {
11481
12039
  }
11482
12040
  var STAGES = ["dev", "rc", "main"];
11483
12041
  var DEFAULT_RUNTIME_SECRET_NAMES2 = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
12042
+ function boardRegistryGaps(meta) {
12043
+ if (!meta?.projectId) return [];
12044
+ if (meta.projectNumber != null) return [];
12045
+ return ["projectNumber"];
12046
+ }
12047
+ function boardRegistryGapMessage(repo) {
12048
+ return `Board META incomplete for ${repo}: registry has projectId but no projectNumber \u2014 board claim and auto-add will fail until projectNumber is backfilled (re-run \`node infra/migrate/seed-registry.mjs\` or \`mmi-cli bootstrap apply --execute\` with board vars)`;
12049
+ }
11484
12050
  function slugOfRepo(repoOrSlug) {
11485
12051
  return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
11486
12052
  }
@@ -11679,6 +12245,7 @@ function buildV2HealPatch(repoOrSlug, meta) {
11679
12245
  }
11680
12246
  }
11681
12247
  const appOwnedGaps = confidentType ? appGapsFor(meta, model, slug, confidentType) : [`Project type is unset and not derivable \u2014 classify with \`mmi-cli project set ${repo} --project-type <web-app|hub-service|content|desktop-game|non-deployable|cli-tool|worker> --deploy-model <tenant-container|solo-container|hub-serverless|serverless|registry-publish|content|none>\` before heal completes the v2 fields (prevents defaulting a non-web repo to tenant-container).`];
12248
+ if (boardRegistryGaps(meta).length) appOwnedGaps.unshift(boardRegistryGapMessage(repo));
11682
12249
  return { slug, patch, appOwnedGaps };
11683
12250
  }
11684
12251
  async function runV2Heal(repoOrSlug, opts, deps) {
@@ -11768,7 +12335,7 @@ async function buildV2Doctor(repoOrSlug, deps) {
11768
12335
  const missing = required.filter((key) => !presentSecrets.has(key));
11769
12336
  return [stage2, { required, present, missing }];
11770
12337
  }));
11771
- const metaMissing = ["class", "projectType", "deployModel", "vaultPath", "kbPointer"].filter((key) => meta[key] === void 0);
12338
+ const metaMissing = ["class", "projectType", "deployModel", "vaultPath", "kbPointer"].filter((key) => meta[key] === void 0).concat(boardRegistryGaps(meta));
11772
12339
  const ok = !secretsError && metaMissing.length === 0 && Object.values(deployCoords).every((v) => v.ok) && Object.values(secrets2).every((v) => v.missing.length === 0);
11773
12340
  const edgeDomainWarnings = deps.resolveDns ? await probeEdgeDomains(meta, deps.resolveDns) : [];
11774
12341
  return {
@@ -12489,9 +13056,13 @@ async function relevantPlans(deps, signals, opts = {}) {
12489
13056
  deps.err(`northstar relevant: ${e.message}`);
12490
13057
  return;
12491
13058
  }
12492
- if (!plans.length) return deps.log("no North Stars for this repo yet");
13059
+ if (!plans.length) {
13060
+ if (opts.json) return deps.log(JSON.stringify({ ranked: [] }));
13061
+ return deps.log("no North Stars for this repo yet");
13062
+ }
12493
13063
  const ranked = rankPlansByRelevance(plans, signals, { includeAll: opts.includeAll });
12494
13064
  const top = (opts.includeAll ? ranked : ranked.filter((r) => r.score > 0)).slice(0, opts.limit ?? 5);
13065
+ if (opts.json) return deps.log(JSON.stringify({ ranked: top }));
12495
13066
  if (!top.length) {
12496
13067
  return deps.log(`no task-relevant North Stars among ${plans.length} for this repo \u2014 \`mmi-cli northstar relevant --all\` lists recent ones`);
12497
13068
  }
@@ -12573,9 +13144,14 @@ async function planSync(deps, opts = {}) {
12573
13144
  if (!opts.quiet) deps.err(`northstar sync: list refresh failed: ${e.message}`);
12574
13145
  }
12575
13146
  }
12576
- async function planStatus(deps) {
13147
+ async function planStatus(deps, opts = {}) {
12577
13148
  const queue = parseQueue(deps.readQueueRaw());
12578
13149
  const idx = parseIndex(deps.readIndexRaw());
13150
+ if (opts.json) {
13151
+ deps.log(JSON.stringify({ queue, index: idx }));
13152
+ if (queue.some((e) => e.conflict || e.deadLettered)) process.exitCode = 1;
13153
+ return;
13154
+ }
12579
13155
  for (const e of queue) {
12580
13156
  if (e.conflict) deps.err(`${e.slug} \xB7 CONFLICT \u2014 ${e.conflict}`);
12581
13157
  else if (e.deadLettered) deps.err(`${e.slug} \xB7 DEAD-LETTER \u2014 ${e.deadLettered}`);
@@ -13034,7 +13610,7 @@ async function runWhoami(io = consoleIo) {
13034
13610
  io.log(JSON.stringify(report));
13035
13611
  return report;
13036
13612
  }
13037
- program2.command("whoami").description('resolve the logged-in human: {login, source, sessionExpiresAt} JSON; source "unknown" (exit 0) when neither the Hub session nor gh can name them').action(async () => {
13613
+ program2.command("whoami").description('resolve the logged-in human: {login, source, sessionExpiresAt} JSON; source "unknown" (exit 0) when neither the Hub session nor gh can name them').option("--json", "machine-readable output (default)").action(async () => {
13038
13614
  await runWhoami();
13039
13615
  });
13040
13616
  program2.command("gc").description("dry-run cleanup for merged/closed PR branches and stale tracking refs").option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR branches and stale tracking refs").option("--json", "machine-readable output").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").action(async (o) => {
@@ -13045,7 +13621,14 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
13045
13621
  const plan2 = await gcPlan(o.remote, limit);
13046
13622
  if (o.apply && !o.json) console.log(formatGcPlan(plan2, false));
13047
13623
  let applyResult;
13048
- if (o.apply) applyResult = await applyGcPlan(plan2, o.remote);
13624
+ if (o.apply) {
13625
+ const deferredStore = await createDeferredWorktreeStore();
13626
+ await sweepDeferredWorktrees(
13627
+ deferredStore,
13628
+ worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout)
13629
+ ).catch(() => void 0);
13630
+ applyResult = await applyGcPlan(plan2, o.remote);
13631
+ }
13049
13632
  if (o.json) {
13050
13633
  console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, plan: plan2, applyResult }, null, 2));
13051
13634
  } else if (!o.apply) {
@@ -13310,12 +13893,12 @@ function registerNorthStarCommands(cmd) {
13310
13893
  if (!ok) process.exitCode = 1;
13311
13894
  }));
13312
13895
  cmd.command("list").description("list your North Star plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").option("--fresh", "bypass the local cache and read from the server").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
13313
- cmd.command("relevant").description("list North Stars relevant to your current task (branch + claimed issue + changed files)").option("--project <name>", "override the project key").option("--all", "include superseded/graduated and recent non-matching plans").option("--limit <n>", "max results (default 5)").option("--fresh", "bypass the local cache and read from the server").action((o) => withPlan(false, async (d) => {
13896
+ cmd.command("relevant").description("list North Stars relevant to your current task (branch + claimed issue + changed files)").option("--project <name>", "override the project key").option("--all", "include superseded/graduated and recent non-matching plans").option("--limit <n>", "max results (default 5)").option("--fresh", "bypass the local cache and read from the server").option("--json", "machine-readable output").action((o) => withPlan(false, async (d) => {
13314
13897
  const signals = await gatherRelevanceSignals();
13315
- await relevantPlans(d, signals, { project: o.project, includeAll: o.all, limit: o.limit ? Number(o.limit) : void 0, fresh: o.fresh });
13898
+ await relevantPlans(d, signals, { project: o.project, includeAll: o.all, limit: o.limit ? Number(o.limit) : void 0, fresh: o.fresh, json: o.json });
13316
13899
  }));
13317
13900
  cmd.command("sync").description("drain queued background pushes and refresh the local plan index").option("--quiet", "silent (background worker)").action((o) => withPlan(o.quiet ?? false, (d) => planSync(d, o)));
13318
- cmd.command("status").description("show pending/conflicted background pushes and the plan-cache age").action(() => withPlan(false, (d) => planStatus(d)));
13901
+ cmd.command("status").description("show pending/conflicted background pushes and the plan-cache age").option("--json", "machine-readable output").action((o) => withPlan(false, (d) => planStatus(d, o)));
13319
13902
  cmd.command("reconcile").description("refresh stale local etags from the server without --force (recovers from an object-store re-stamp)").action(() => withPlan(false, (d) => planReconcile(d)));
13320
13903
  cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
13321
13904
  (slug, o) => withPlan(false, async (d) => {
@@ -13954,6 +14537,41 @@ verify.command("synthesize").description("merge lens JSON array into a PanelRepo
13954
14537
  return fail(`verify synthesize: ${e.message}`);
13955
14538
  }
13956
14539
  });
14540
+ var fusion = verify.command("fusion").description("optional hosted fusion provider for grind verify (#1377)");
14541
+ fusion.command("plan").description("plan a hosted fusion job \u2014 print FusionPlan JSON (falls back to host panel when provider unset)").requiredOption("--criteria-file <path>", "UTF-8 file with success criteria").requiredOption("--diff-file <path>", "UTF-8 file with git diff output").option("--routing <routing>", "Balanced | Budget | Paranoid", "Balanced").option("--lenses <list>", `comma-separated lens names (default: ${GRIND_LENSES.join(",")})`, GRIND_LENSES.join(",")).option("--provider-url <url>", "fusion provider base URL (else MMI_FUSION_PROVIDER_URL)").option("--web-search", "enable bounded web search in fusion tool policy").action(async (o) => {
14542
+ try {
14543
+ const routing = parseFusionRouting(o.routing);
14544
+ const lenses = parseFusionLenses(o.lenses);
14545
+ const criteria = await (0, import_promises5.readFile)(o.criteriaFile, "utf8");
14546
+ const diff = await (0, import_promises5.readFile)(o.diffFile, "utf8");
14547
+ const plan2 = buildFusionPlan({
14548
+ routing,
14549
+ lenses,
14550
+ criteria,
14551
+ diff,
14552
+ providerUrl: o.providerUrl ?? null,
14553
+ toolPolicy: {
14554
+ webSearch: Boolean(o.webSearch),
14555
+ maxQueriesPerLens: 3,
14556
+ denyDomains: [...DEFAULT_SEARCH_DENY_DOMAINS]
14557
+ }
14558
+ });
14559
+ console.log(JSON.stringify(plan2));
14560
+ } catch (e) {
14561
+ return fail(`verify fusion plan: ${e.message}`);
14562
+ }
14563
+ });
14564
+ fusion.command("run").description("execute hosted fusion from a FusionPlan JSON file; prints PanelReport or fallback envelope").requiredOption("--plan-file <path>", "UTF-8 FusionPlan JSON from verify fusion plan").option("--provider-url <url>", "override fusion provider URL (else plan.provider or MMI_FUSION_PROVIDER_URL)").action(async (o) => {
14565
+ try {
14566
+ const raw = await (0, import_promises5.readFile)(o.planFile, "utf8");
14567
+ const plan2 = JSON.parse(raw);
14568
+ const result = await runFusionProvider(plan2, { providerUrl: o.providerUrl ?? plan2.provider ?? null });
14569
+ console.log(JSON.stringify(result));
14570
+ if (!result.ok) process.exitCode = 1;
14571
+ } catch (e) {
14572
+ return fail(`verify fusion run: ${e.message}`);
14573
+ }
14574
+ });
13957
14575
  program2.command("skill-lesson").description("file a skill-lesson on the Hub board (GitHub auth, dedups open lessons) and print {number,url} JSON").requiredOption("--skill <name>", `which skill misfired (${SKILL_NAMES.join(" | ")})`).option("--title <title>", "one-line summary of what misfired").option("--title-file <path|->", "read the one-line summary from a UTF-8 file, or from stdin with -").option("--body <body>", "lesson body: what misfired, the evidence, and the proposed amendment (markdown)").option("--body-file <path|->", "read the lesson body from a UTF-8 file, or from stdin with -").option("--priority <priority>", "urgent | high | medium | low (board Priority field when configured)", "medium").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO})`).option("--force", "file a new issue even when an open lesson looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 skill-lesson always prints JSON)").action(async (o) => {
13958
14576
  const targetRepo2 = o.repo ?? HUB_REPO;
13959
14577
  const sourceRepo = await resolveRepo(void 0);
@@ -14026,12 +14644,43 @@ pr.command("create").description("create a PR and print {number,url} JSON").opti
14026
14644
  const created = await ghCreate(buildPrArgs({ title, body, base: o.base, head: o.head, repo: o.repo }));
14027
14645
  console.log(JSON.stringify(created));
14028
14646
  });
14029
- async function remoteBranchExists(branch, options = {}) {
14647
+ async function remoteBranchExists2(branch, options = {}) {
14030
14648
  return checkRemoteBranchExists(branch, {
14031
14649
  execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
14032
14650
  }, options);
14033
14651
  }
14034
14652
  var COMPOSE_TIMEOUT_MS = 12e4;
14653
+ async function createDeferredWorktreeStore() {
14654
+ try {
14655
+ const { stdout } = await execFileP2("git", ["rev-parse", "--git-dir"], { timeout: GIT_TIMEOUT_MS });
14656
+ const registryPath = deferredWorktreesRegistryPath(stdout.trim());
14657
+ return {
14658
+ read: async () => {
14659
+ try {
14660
+ return parseDeferredWorktreesFile(await (0, import_promises5.readFile)(registryPath, "utf8"));
14661
+ } catch {
14662
+ return [];
14663
+ }
14664
+ },
14665
+ write: async (entries) => {
14666
+ try {
14667
+ await (0, import_promises5.mkdir)((0, import_node_path13.dirname)(registryPath), { recursive: true });
14668
+ await (0, import_promises5.writeFile)(registryPath, serializeDeferredWorktrees(entries), "utf8");
14669
+ } catch {
14670
+ }
14671
+ }
14672
+ };
14673
+ } catch {
14674
+ return void 0;
14675
+ }
14676
+ }
14677
+ function worktreeRemoveDeps(execGit) {
14678
+ return {
14679
+ git: execGit,
14680
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
14681
+ removeWorktreeDir: async (worktreePath) => (0, import_promises5.rm)(worktreePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
14682
+ };
14683
+ }
14035
14684
  function teardownWorktreeStage(worktreePath) {
14036
14685
  return runWorktreeStageTeardown(worktreePath, {
14037
14686
  hasStageState: (wt) => (0, import_node_fs14.existsSync)(stageStatePath(wt)),
@@ -14053,7 +14702,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
14053
14702
  const beforeWorktrees = parseWorktreePorcelain(
14054
14703
  (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
14055
14704
  );
14056
- const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
14705
+ const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists2(headRef);
14057
14706
  let remoteDeleteAttempted = false;
14058
14707
  let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
14059
14708
  await execFileP2("gh", buildPrMergeArgs({ number, repoArgs, method, auto: o.auto }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e) => {
@@ -14081,26 +14730,39 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
14081
14730
  attempted: false,
14082
14731
  reason: remoteNotAttemptedReason
14083
14732
  }) : await buildPrMergeRemoteBranchCleanupReport(headRef, {
14084
- exists: remoteBranchExists
14733
+ exists: remoteBranchExists2
14085
14734
  }, {
14086
14735
  attempted: remoteDeleteAttempted,
14087
14736
  existedBefore: remoteBefore,
14088
14737
  reason: remoteNotAttemptedReason
14089
14738
  });
14090
- const localCleanup = repoArgs.length ? {
14091
- branch: headRef,
14092
- localBranch: { name: headRef, status: "not-attempted", reason: "repo-option" },
14093
- worktree: void 0
14094
- } : await cleanupPrMergeLocalBranch(headRef, {
14095
- beforeWorktrees,
14096
- startingPath,
14097
- execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
14098
- teardownWorktreeStage,
14099
- // Hardened fallback when retried `git worktree remove` still hits a Windows file lock (#967).
14100
- // `fs.rm` recursive removes a junction as a link (it does not traverse into the target) and its
14101
- // own maxRetries/retryDelay rides out a handle that an indexer/antivirus releases a moment later.
14102
- removeWorktreeDir: async (worktreePath) => (0, import_promises5.rm)(worktreePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
14103
- });
14739
+ const deferredStore = await createDeferredWorktreeStore();
14740
+ let localCleanup;
14741
+ try {
14742
+ localCleanup = repoArgs.length ? {
14743
+ branch: headRef,
14744
+ localBranch: { name: headRef, status: "not-attempted", reason: "repo-option" },
14745
+ worktree: void 0
14746
+ } : await cleanupPrMergeLocalBranch(headRef, {
14747
+ beforeWorktrees,
14748
+ startingPath,
14749
+ pathExists: (p) => (0, import_node_fs14.existsSync)(p),
14750
+ execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
14751
+ teardownWorktreeStage,
14752
+ deferredStore,
14753
+ removeWorktreeDir: worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout).removeWorktreeDir
14754
+ });
14755
+ } catch (e) {
14756
+ localCleanup = {
14757
+ branch: headRef,
14758
+ localBranch: {
14759
+ name: headRef,
14760
+ status: "failed",
14761
+ reason: "cleanup-exception",
14762
+ error: e instanceof Error ? e.message : String(e)
14763
+ }
14764
+ };
14765
+ }
14104
14766
  console.log(JSON.stringify(buildPrMergeResultPayload({
14105
14767
  number,
14106
14768
  branch: headRef,
@@ -14567,10 +15229,11 @@ function renderHotfixStart(r) {
14567
15229
  }
14568
15230
  function renderHotfixRelease(r) {
14569
15231
  return [
14570
- `mmi-cli hotfix release: ${r.tag} at ${r.mergedSha.slice(0, 7)} on ${r.repo}`,
15232
+ `mmi-cli hotfix release: ${r.tag} at ${r.mergedSha.slice(0, 7)} on ${r.repo} (deployModel=${r.deployModel})`,
14571
15233
  ` - checks: ${r.checks}`,
14572
15234
  ` - ${r.tagNote}`,
14573
15235
  ` - ${r.releaseNote}`,
15236
+ ` - deploy: ${r.deployNote}`,
14574
15237
  ...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
14575
15238
  ` - ${r.verifyNote}`,
14576
15239
  ...r.announceNote ? [` - announce: ${r.announceNote}`] : [],
@@ -14661,6 +15324,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
14661
15324
  json: rawFlag("--json")
14662
15325
  };
14663
15326
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap apply: --class must be deployable or content");
15327
+ const bootstrapReleaseTrack = resolveBootstrapReleaseTrack(o.class, o.releaseTrack || void 0);
14664
15328
  let parsedRepo;
14665
15329
  try {
14666
15330
  parsedRepo = parseOwnerRepo(repo);
@@ -14680,7 +15344,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
14680
15344
  const eq = value.indexOf("=");
14681
15345
  if (eq > 0) rawVars[value.slice(0, eq)] = value.slice(eq + 1);
14682
15346
  }
14683
- const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class);
15347
+ const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class, bootstrapReleaseTrack);
14684
15348
  if (vars.PROJECT_ID) {
14685
15349
  try {
14686
15350
  const r = await gh(boardFieldsQueryArgs(vars.PROJECT_ID));
@@ -14748,7 +15412,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
14748
15412
  registerPayload = buildRegisterPayload(repo, o.class, vars, {
14749
15413
  projectType: o.projectType || void 0,
14750
15414
  deployModel: o.deployModel || void 0,
14751
- releaseTrack: o.releaseTrack || void 0
15415
+ releaseTrack: bootstrapReleaseTrack
14752
15416
  });
14753
15417
  } catch (e) {
14754
15418
  return fail(`bootstrap apply: ${e.message}`);
@@ -14975,6 +15639,7 @@ function cursorPluginCachePinSnapshots() {
14975
15639
  const path2 = (0, import_node_path13.join)(root, entry.name);
14976
15640
  const pluginJson = (0, import_node_path13.join)(path2, ".cursor-plugin", "plugin.json");
14977
15641
  const hooksJson = (0, import_node_path13.join)(path2, "hooks", "hooks.json");
15642
+ const cliBundle = (0, import_node_path13.join)(path2, "cli", "dist", "index.cjs");
14978
15643
  let isEmpty = true;
14979
15644
  try {
14980
15645
  isEmpty = (0, import_node_fs14.readdirSync)(path2).length === 0;
@@ -14986,6 +15651,7 @@ function cursorPluginCachePinSnapshots() {
14986
15651
  path: path2,
14987
15652
  hasPluginJson: (0, import_node_fs14.existsSync)(pluginJson),
14988
15653
  hasHooksJson: (0, import_node_fs14.existsSync)(hooksJson),
15654
+ hasCliBundle: (0, import_node_fs14.existsSync)(cliBundle),
14989
15655
  isEmpty
14990
15656
  };
14991
15657
  });
@@ -15050,6 +15716,39 @@ function quarantinePluginCacheDirs(plan2) {
15050
15716
  return moved;
15051
15717
  }
15052
15718
  var gitignorePath = () => (0, import_node_path13.join)(process.cwd(), ".gitignore");
15719
+ function readTextFile(path2) {
15720
+ try {
15721
+ if (!(0, import_node_fs14.existsSync)(path2)) return null;
15722
+ return (0, import_node_fs14.readFileSync)(path2, "utf8");
15723
+ } catch {
15724
+ return null;
15725
+ }
15726
+ }
15727
+ function playwrightMcpConfigSnapshots() {
15728
+ const cwd = process.cwd();
15729
+ const home = (0, import_node_os4.homedir)();
15730
+ const candidates = [
15731
+ (0, import_node_path13.join)(cwd, ".cursor", "mcp.json"),
15732
+ (0, import_node_path13.join)(home, ".cursor", "mcp.json"),
15733
+ (0, import_node_path13.join)(home, ".codex", "config.toml")
15734
+ ];
15735
+ const out = [];
15736
+ for (const path2 of candidates) {
15737
+ const content = readTextFile(path2);
15738
+ if (content != null) out.push({ path: path2, content });
15739
+ }
15740
+ return out;
15741
+ }
15742
+ function strayBrowserArtifactPaths() {
15743
+ const cwd = process.cwd();
15744
+ return STRAY_BROWSER_ARTIFACT_DIRS.filter((rel) => {
15745
+ try {
15746
+ return (0, import_node_fs14.existsSync)((0, import_node_path13.join)(cwd, rel));
15747
+ } catch {
15748
+ return false;
15749
+ }
15750
+ });
15751
+ }
15053
15752
  function readGitignore() {
15054
15753
  try {
15055
15754
  return (0, import_node_fs14.readFileSync)(gitignorePath(), "utf8");
@@ -15237,6 +15936,27 @@ async function runDoctor(opts, io = consoleIo) {
15237
15936
  hubCheckout: hubCheckoutForCursorSeed()
15238
15937
  })
15239
15938
  );
15939
+ const cursorPins = cursorPluginCachePinSnapshots() ?? [];
15940
+ checks.push(
15941
+ buildCursorHookCliCheck({
15942
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
15943
+ surface,
15944
+ pins: cursorPins,
15945
+ mmiCliOnPath: onPath
15946
+ })
15947
+ );
15948
+ checks.push(
15949
+ buildPlaywrightMcpVisionCapCheck({
15950
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
15951
+ configs: playwrightMcpConfigSnapshots()
15952
+ })
15953
+ );
15954
+ checks.push(
15955
+ buildBrowserArtifactsCheck({
15956
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
15957
+ strayPaths: strayBrowserArtifactPaths()
15958
+ })
15959
+ );
15240
15960
  const gaps = checks.filter((c) => !c.ok);
15241
15961
  if (opts.banner) {
15242
15962
  if (gaps.length) io.log(`\u26A0 MMI setup needed \u2014 ${gaps.map((g) => g.fix).join(" \xB7 ")} \xB7 guide: ${MMI_AGENTIC_ONBOARDING_GUIDE.url}`);
@@ -15265,7 +15985,7 @@ async function runDoctor(opts, io = consoleIo) {
15265
15985
  io.log(gaps.length ? `
15266
15986
  ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
15267
15987
  }
15268
- program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version, Cursor Team Marketplace plugin install), print fixes, and report per-surface versions + update recipes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for train preflights)").action((opts) => (
15988
+ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version, Cursor Team Marketplace plugin install, Playwright MCP vision caps, browser artifact hygiene), print fixes, and report per-surface versions + update recipes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for train preflights)").action((opts) => (
15269
15989
  // Commander maps `--no-repo-writes` to `repoWrites: false`; translate to the explicit `noRepoWrites` flag.
15270
15990
  runDoctor({ ...opts, noRepoWrites: opts.repoWrites === false })
15271
15991
  ));