@mutmutco/cli 2.31.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/index.cjs CHANGED
@@ -62,7 +62,7 @@ function setInjectedStdin(payload) {
62
62
  }
63
63
  async function readStdin() {
64
64
  if (injectedStdin !== void 0) return injectedStdin;
65
- if (process.stdin.isTTY) return "";
65
+ if (process.stdin.isTTY !== false) return "";
66
66
  const chunks = [];
67
67
  for await (const chunk of process.stdin) chunks.push(chunk);
68
68
  return Buffer.concat(chunks).toString("utf8");
package/dist/main.cjs CHANGED
@@ -4112,7 +4112,7 @@ async function hubAuthToken(deps) {
4112
4112
  var injectedStdin;
4113
4113
  async function readStdin() {
4114
4114
  if (injectedStdin !== void 0) return injectedStdin;
4115
- if (process.stdin.isTTY) return "";
4115
+ if (process.stdin.isTTY !== false) return "";
4116
4116
  const chunks = [];
4117
4117
  for await (const chunk of process.stdin) chunks.push(chunk);
4118
4118
  return Buffer.concat(chunks).toString("utf8");
@@ -5257,6 +5257,15 @@ function buildIngestPayload(args) {
5257
5257
 
5258
5258
  // src/honcho-client.ts
5259
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
+ }
5260
5269
  var enc = encodeURIComponent;
5261
5270
  var base = (apiUrl) => apiUrl.replace(/\/+$/, "");
5262
5271
  var honchoRoutes = {
@@ -5323,20 +5332,28 @@ async function fetchPeerCard(cfg, peer, opts = {}) {
5323
5332
  try {
5324
5333
  const path2 = honchoRoutes.peerCard(cfg.workspace, peer, opts.project);
5325
5334
  const res = await request(cfg, fetchImpl, "GET", path2, void 0, timeoutMs);
5326
- if (!res.ok) return null;
5335
+ if (!res.ok) return { status: "error", httpStatus: res.status };
5327
5336
  const json = await res.json();
5328
- return formatPeerCardLines(json?.peer_card, maxChars);
5329
- } catch {
5330
- 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 };
5331
5341
  }
5332
5342
  }
5333
5343
  async function fetchPeerCardWithFallback(cfg, peer, project2, opts = {}) {
5334
5344
  if (project2) {
5335
5345
  const scoped = await fetchPeerCard(cfg, peer, { ...opts, project: project2 });
5336
- 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
+ }
5337
5350
  }
5338
5351
  const wide = await fetchPeerCard(cfg, peer, opts);
5339
- 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" };
5340
5357
  }
5341
5358
  async function dialecticChat(cfg, peer, query, opts = {}, fetchImpl = fetch) {
5342
5359
  try {
@@ -5346,25 +5363,44 @@ async function dialecticChat(cfg, peer, query, opts = {}, fetchImpl = fetch) {
5346
5363
  ...opts.target ? { target: opts.target } : {},
5347
5364
  ...opts.sessionId ? { session_id: opts.sessionId } : {}
5348
5365
  }, opts.timeoutMs ?? 15e3);
5349
- if (!res.ok) return null;
5366
+ if (!res.ok) return { status: "error", httpStatus: res.status };
5350
5367
  const json = await res.json();
5351
- return (json?.content ?? "").trim() || null;
5352
- } catch {
5353
- 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 };
5354
5372
  }
5355
5373
  }
5356
5374
  async function probeHoncho(cfg, fetchImpl = fetch, timeoutMs = 3e3, opts = {}) {
5375
+ const emptyQueue = { pending: null, inProgress: null, stalled: false };
5357
5376
  try {
5358
5377
  const healthRes = await request(cfg, fetchImpl, "GET", honchoRoutes.health(), void 0, timeoutMs);
5359
5378
  if (!healthRes.ok) {
5360
- 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 };
5361
5380
  }
5362
5381
  const authPath = opts.peer ? honchoRoutes.peerCard(cfg.workspace, opts.peer) : honchoRoutes.queueStatus(cfg.workspace);
5363
5382
  const authRes = await request(cfg, fetchImpl, "GET", authPath, void 0, timeoutMs);
5364
5383
  const authOk = authRes.status !== 401 && authRes.status !== 403;
5365
- 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 };
5366
5402
  } catch {
5367
- return { reachable: false, authOk: false };
5403
+ return { reachable: false, authOk: false, queue: emptyQueue };
5368
5404
  }
5369
5405
  }
5370
5406
 
@@ -5552,8 +5588,21 @@ async function runHonchoContext(opts, io = consoleIo) {
5552
5588
  return;
5553
5589
  }
5554
5590
  const key = await sagaKey(cfg);
5555
- const { profile, scope } = await fetchPeerCardWithFallback(hc, peer, key.project);
5556
- 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
+ }
5557
5606
  if (!profile) return;
5558
5607
  io.log(opts.banner ? profileBanner(profile, peer, scope === "project" ? key.project : void 0) : profile);
5559
5608
  }
@@ -5577,9 +5626,21 @@ async function runHonchoChat(query, opts, io = consoleIo) {
5577
5626
  io.err("honcho chat: query is empty after redaction");
5578
5627
  return;
5579
5628
  }
5580
- const answer = await dialecticChat(hc, peer, safeQuery, { target: opts.target });
5581
- if (opts.json) return io.log(JSON.stringify({ peer, query, answer }));
5582
- 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)");
5583
5644
  }
5584
5645
  async function runHonchoHealth(o, io = consoleIo) {
5585
5646
  const cfg = await loadConfig();
@@ -5587,12 +5648,19 @@ async function runHonchoHealth(o, io = consoleIo) {
5587
5648
  const apiKey = await honchoApiKey();
5588
5649
  const hc = apiKey ? { apiUrl, apiKey, workspace } : null;
5589
5650
  const peer = honchoPeerId(await honchoLogin(cfg), cfg);
5590
- 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
+ };
5591
5658
  const pending = readHonchoPending().length;
5592
5659
  const ingestSkip = readIngestSkip();
5593
5660
  const ingestSkipMessage = formatIngestSkip(ingestSkip);
5661
+ const deriverStalled = liveness.queue.stalled;
5594
5662
  const report = {
5595
- ok: !!hc && liveness.reachable && liveness.authOk,
5663
+ ok: !!hc && liveness.reachable && liveness.authOk && !deriverStalled,
5596
5664
  configured: !!hc,
5597
5665
  apiUrl,
5598
5666
  apiKeyConfigured: !!apiKey,
@@ -5603,6 +5671,11 @@ async function runHonchoHealth(o, io = consoleIo) {
5603
5671
  peer: peer ?? null,
5604
5672
  workspace,
5605
5673
  pending,
5674
+ deriverQueue: {
5675
+ pending: liveness.queue.pending,
5676
+ inProgress: liveness.queue.inProgress,
5677
+ stalled: deriverStalled
5678
+ },
5606
5679
  ingestSkip: ingestSkip ?? null,
5607
5680
  ingestSkipMessage: ingestSkipMessage ?? null
5608
5681
  };
@@ -5610,6 +5683,7 @@ async function runHonchoHealth(o, io = consoleIo) {
5610
5683
  if (o.banner) {
5611
5684
  if (report.configured && !report.reachable) io.log(`honcho: configured but unreachable (${pending} pending)`);
5612
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)`);
5613
5687
  if (ingestSkipMessage) io.log(`honcho: ${ingestSkipMessage}`);
5614
5688
  return;
5615
5689
  }
@@ -5618,8 +5692,12 @@ async function runHonchoHealth(o, io = consoleIo) {
5618
5692
  if (report.configured && !report.reachable) io.log(" - service unreachable");
5619
5693
  if (report.configured && report.reachable && !report.authOk) io.log(" - API key rejected (401/403) \u2014 rotate or fix vault path");
5620
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)`);
5621
5696
  if (ingestSkipMessage) io.log(` - ${ingestSkipMessage}`);
5622
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
+ }
5623
5701
  }
5624
5702
  async function runHonchoKey(o, io = consoleIo) {
5625
5703
  const cfg = await loadConfig();
@@ -7151,7 +7229,7 @@ ${buildReportBody(body, sourceRepo)}`;
7151
7229
 
7152
7230
  // src/skill-lesson.ts
7153
7231
  var SKILL_LESSON_LABEL = "skill-lesson";
7154
- 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"];
7155
7233
  function assertSkillName(name) {
7156
7234
  const match = SKILL_NAMES.find((skill) => skill === name);
7157
7235
  if (!match) throw new Error(`unknown skill "${name}" \u2014 expected one of: ${SKILL_NAMES.join(", ")}`);
@@ -7277,6 +7355,129 @@ function buildPanelPlan(input) {
7277
7355
  };
7278
7356
  }
7279
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
+
7280
7481
  // src/gc.ts
7281
7482
  var DEFERRED_SWEEP_COMMAND = "mmi-cli gc --apply";
7282
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.";
@@ -7626,9 +7827,10 @@ function selectPrMergeCleanupWorktree(branch, before, after, startingPath) {
7626
7827
  if (startingPath && before.some((w) => w.branch === branch && samePath(w.path, startingPath))) return startingPath;
7627
7828
  return void 0;
7628
7829
  }
7629
- function selectSafeWorktreeCwd(worktrees, targetPath) {
7830
+ function selectSafeWorktreeCwd(worktrees, targetPath, options) {
7630
7831
  if (!targetPath) return void 0;
7631
- 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;
7632
7834
  }
7633
7835
  function branchMissingFromList(branch, stdout) {
7634
7836
  const names = stdout.split(/\r?\n/).map((line) => line.replace(/^\*\s*/, "").trim()).filter(Boolean);
@@ -7659,7 +7861,9 @@ async function cleanupPrMergeLocalBranch(branch, options) {
7659
7861
  }
7660
7862
  const beforeWorktrees = options.beforeWorktrees ?? [];
7661
7863
  const wtPath = selectPrMergeCleanupWorktree(branch, beforeWorktrees, afterWorktrees, options.startingPath);
7662
- const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
7864
+ const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath, {
7865
+ pathExists: options.pathExists
7866
+ });
7663
7867
  const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
7664
7868
  const removeDeps = {
7665
7869
  git,
@@ -7688,23 +7892,36 @@ async function cleanupPrMergeLocalBranch(branch, options) {
7688
7892
  if (outcome.status === "removed") {
7689
7893
  report.worktree = { path: wtPath, status: "removed", stageTeardown, recovery: outcome.recovery };
7690
7894
  } else if (isPersistentWorktreeLockFailure(outcome) && options.deferredStore) {
7691
- const { newlyRegistered } = await registerDeferredWorktree(options.deferredStore, {
7692
- path: wtPath,
7693
- branch,
7694
- reason: "lock-held"
7695
- });
7696
- report.worktree = {
7697
- path: wtPath,
7698
- status: "deferred",
7699
- reason: "lock-held",
7700
- error: outcome.error,
7701
- deferredNote: DEFERRED_NOTE,
7702
- deferredSweepCommand: DEFERRED_SWEEP_COMMAND,
7703
- ...newlyRegistered ? { safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath) } : {},
7704
- stageTeardown
7705
- };
7706
- report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-deferred" };
7707
- return report;
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
+ }
7708
7925
  } else {
7709
7926
  report.worktree = {
7710
7927
  path: wtPath,
@@ -8583,6 +8800,48 @@ function buildHubCompatCheck(input) {
8583
8800
  }
8584
8801
  return { ok: versionAtLeast(input.installedVersion, min), label: `${label}: requires >= ${min}`, fix: HUB_COMPAT_FIX };
8585
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
+ }
8586
8845
 
8587
8846
  // src/stage-live.ts
8588
8847
  var import_node_net = require("node:net");
@@ -8956,10 +9215,22 @@ function resolveDeployModel(meta, repo) {
8956
9215
  function projectTypeClearsWebProfile(projectType, deployModel) {
8957
9216
  return projectType === "content" || projectType === "desktop-game" || projectType === "non-deployable" || projectType === "cli-tool" || deployModel === "content" || deployModel === "none" || deployModel === "registry-publish";
8958
9217
  }
8959
- 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) {
8960
9229
  const raw = typeof meta?.releaseTrack === "string" ? meta.releaseTrack : void 0;
8961
9230
  if (isReleaseTrack(raw)) return raw;
8962
9231
  if (meta?.class === "content" || meta?.deployModel === "content") return "trunk";
9232
+ const inferred = hints ? inferReleaseTrackFromBranches(hints) : void 0;
9233
+ if (inferred) return inferred;
8963
9234
  return "full";
8964
9235
  }
8965
9236
  function branchesForTrack(track) {
@@ -9110,6 +9381,18 @@ function ensurePositiveCount(out, emptyMessage) {
9110
9381
  if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
9111
9382
  if (count <= 0) throw new Error(emptyMessage);
9112
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
+ }
9113
9396
  async function buildTrainApplyContext(deps) {
9114
9397
  const repo = requireValue(clean(await deps.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])), "repo");
9115
9398
  const [owner, name] = repo.split("/");
@@ -9543,7 +9826,8 @@ async function runTrainApply(command, deps, options = {}) {
9543
9826
  await requireCleanTree(deps);
9544
9827
  await deps.run("git", ["fetch", "origin"]);
9545
9828
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
9546
- const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta) === "direct";
9829
+ const branchHints = await loadReleaseTrackBranchHints(deps);
9830
+ const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta, branchHints) === "direct";
9547
9831
  if (command === "rcand") {
9548
9832
  await requireBranch(deps, "development");
9549
9833
  if (directTrack) {
@@ -9635,13 +9919,16 @@ async function runTrainApply(command, deps, options = {}) {
9635
9919
  if (command === "release" && options.dev) {
9636
9920
  await requireBranch(deps, "development");
9637
9921
  await ffOnlyPull(deps, "development");
9638
- const rcOnlyOut = (await deps.run("git", ["rev-list", "--count", "--right-only", "--cherry-pick", "--no-merges", "origin/development...origin/rc"])).trim();
9639
- const rcOnly = Number.parseInt(rcOnlyOut, 10);
9640
- if (!Number.isFinite(rcOnly)) throw new Error(`release --dev: could not count rc-only commits for the guard: ${rcOnlyOut || "(empty)"}`);
9641
- if (rcOnly > 0) {
9642
- throw new Error(
9643
- `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.`
9644
- );
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
+ }
9645
9932
  }
9646
9933
  ensurePositiveCount(
9647
9934
  await deps.run("git", ["rev-list", "--count", "origin/main..origin/development"]),
@@ -9649,7 +9936,7 @@ async function runTrainApply(command, deps, options = {}) {
9649
9936
  );
9650
9937
  const deployModel2 = await preflight(deps, ctx, "main", meta);
9651
9938
  const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
9652
- const rcShaAtRelease = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
9939
+ const rcShaAtRelease = hasRcBranch ? clean(await deps.run("git", ["rev-parse", "origin/rc"])) : "";
9653
9940
  const foldPaths2 = await resolveFoldPaths(deps, deployModel2);
9654
9941
  const tolerated2 = [...foldPaths2, ...RELEASE_TOLERATED_PATHS];
9655
9942
  const predicted2 = await predictMergeConflicts(deps, "origin/main", "origin/development");
@@ -9677,14 +9964,18 @@ async function runTrainApply(command, deps, options = {}) {
9677
9964
  const announceNote2 = deps.announce ? (await deps.announce({ repo: ctx.repo, tag: tag2, summaryFile: options.announceSummaryFile })).note : void 0;
9678
9965
  const autoRunSince2 = (deps.now ?? Date.now)();
9679
9966
  const d2 = await dispatchDeploy(deps, ctx, "main", "main", deployModel2, watch, autoRunSince2, releaseSha2, "report");
9680
- 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" };
9681
9968
  const devRollForward2 = await rollDevelopmentForward(deps, ctx, tag2);
9682
9969
  let rcAlignment2;
9683
- try {
9684
- await deps.run("git", ["push", "origin", "main:rc"]);
9685
- rcAlignment2 = "origin/rc aligned to the released main";
9686
- } catch (e) {
9687
- 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";
9688
9979
  }
9689
9980
  const environments2 = await buildEnvironments(deps, ctx, deployModel2, d2.deployStatus, retirement2);
9690
9981
  return {
@@ -11298,8 +11589,9 @@ function parseOwnerRepo(repo) {
11298
11589
  }
11299
11590
  var DEFAULT_INSTALL_CMD = "npm ci";
11300
11591
  var DEFAULT_GATE_CMD = "npm run check";
11301
- function gateSeedVars(cls) {
11302
- if (cls === "content") {
11592
+ function gateSeedVars(cls, releaseTrack) {
11593
+ const track = releaseTrack ?? (cls === "content" ? "trunk" : "full");
11594
+ if (track === "trunk") {
11303
11595
  return {
11304
11596
  GATE_CMD: DEFAULT_GATE_CMD,
11305
11597
  GATE_PUSH_BRANCHES_YAML: "[main]",
@@ -11307,6 +11599,14 @@ function gateSeedVars(cls) {
11307
11599
  GATE_RULESET_BRANCH_REFS_JSON: '["refs/heads/main"]'
11308
11600
  };
11309
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
+ }
11310
11610
  return {
11311
11611
  GATE_CMD: DEFAULT_GATE_CMD,
11312
11612
  GATE_PUSH_BRANCHES_YAML: "[development, rc, main]",
@@ -11314,13 +11614,14 @@ function gateSeedVars(cls) {
11314
11614
  GATE_RULESET_BRANCH_REFS_JSON: '["refs/heads/development", "refs/heads/rc", "refs/heads/main"]'
11315
11615
  };
11316
11616
  }
11317
- function withDerivedRepoVars(vars, parsed, cls) {
11617
+ function withDerivedRepoVars(vars, parsed, cls, releaseTrack) {
11318
11618
  const out = { ...vars };
11319
11619
  out.REPO_NAME ??= parsed.name;
11320
11620
  out.REPO_SLUG ??= parsed.slug;
11321
11621
  out.CLASS ??= cls;
11322
11622
  out.INSTALL_CMD ??= DEFAULT_INSTALL_CMD;
11323
- for (const [key, value] of Object.entries(gateSeedVars(cls))) {
11623
+ const track = releaseTrack ?? resolveBootstrapReleaseTrack(cls);
11624
+ for (const [key, value] of Object.entries(gateSeedVars(cls, track))) {
11324
11625
  out[key] ??= value;
11325
11626
  }
11326
11627
  return out;
@@ -11432,8 +11733,8 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
11432
11733
  class: cls,
11433
11734
  projectType,
11434
11735
  deployModel,
11435
- // #917: only emit when explicitly direct/trunk; absent registry resolves to `full` (no clobber).
11436
- 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),
11437
11738
  // Board coords (from GraphQL at bootstrap, passed as --var by the skill).
11438
11739
  projectOwner: vars.PROJECT_OWNER || void 0,
11439
11740
  projectNumber: num(vars.PROJECT_NUMBER),
@@ -11447,6 +11748,11 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
11447
11748
  kbPointer: `kb/projects/${slug}.md`
11448
11749
  };
11449
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
+ }
11450
11756
  return payload;
11451
11757
  }
11452
11758
  var BOARD_FIELD_VAR_MAP = {
@@ -11459,9 +11765,18 @@ var BOARD_FIELD_VAR_MAP = {
11459
11765
  options: { Urgent: "PRIORITY_URGENT", High: "PRIORITY_HIGH", Medium: "PRIORITY_MEDIUM", Low: "PRIORITY_LOW" }
11460
11766
  }
11461
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
+ }
11462
11773
  function extractBoardFieldVars(fieldsJson) {
11463
11774
  const out = {};
11464
- 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;
11465
11780
  if (!Array.isArray(nodes)) return out;
11466
11781
  for (const node of nodes) {
11467
11782
  const field = node;
@@ -11479,7 +11794,7 @@ function extractBoardFieldVars(fieldsJson) {
11479
11794
  return out;
11480
11795
  }
11481
11796
  function boardFieldsQueryArgs(projectId) {
11482
- 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 } } } } } } }";
11483
11798
  return ["api", "graphql", "-f", `query=${query}`, "-f", `id=${projectId}`];
11484
11799
  }
11485
11800
  function serializeRegistry(obj) {
@@ -11724,6 +12039,14 @@ function dnsErrorToResolution(code) {
11724
12039
  }
11725
12040
  var STAGES = ["dev", "rc", "main"];
11726
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
+ }
11727
12050
  function slugOfRepo(repoOrSlug) {
11728
12051
  return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
11729
12052
  }
@@ -11922,6 +12245,7 @@ function buildV2HealPatch(repoOrSlug, meta) {
11922
12245
  }
11923
12246
  }
11924
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));
11925
12249
  return { slug, patch, appOwnedGaps };
11926
12250
  }
11927
12251
  async function runV2Heal(repoOrSlug, opts, deps) {
@@ -12011,7 +12335,7 @@ async function buildV2Doctor(repoOrSlug, deps) {
12011
12335
  const missing = required.filter((key) => !presentSecrets.has(key));
12012
12336
  return [stage2, { required, present, missing }];
12013
12337
  }));
12014
- 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));
12015
12339
  const ok = !secretsError && metaMissing.length === 0 && Object.values(deployCoords).every((v) => v.ok) && Object.values(secrets2).every((v) => v.missing.length === 0);
12016
12340
  const edgeDomainWarnings = deps.resolveDns ? await probeEdgeDomains(meta, deps.resolveDns) : [];
12017
12341
  return {
@@ -12732,9 +13056,13 @@ async function relevantPlans(deps, signals, opts = {}) {
12732
13056
  deps.err(`northstar relevant: ${e.message}`);
12733
13057
  return;
12734
13058
  }
12735
- 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
+ }
12736
13063
  const ranked = rankPlansByRelevance(plans, signals, { includeAll: opts.includeAll });
12737
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 }));
12738
13066
  if (!top.length) {
12739
13067
  return deps.log(`no task-relevant North Stars among ${plans.length} for this repo \u2014 \`mmi-cli northstar relevant --all\` lists recent ones`);
12740
13068
  }
@@ -12816,9 +13144,14 @@ async function planSync(deps, opts = {}) {
12816
13144
  if (!opts.quiet) deps.err(`northstar sync: list refresh failed: ${e.message}`);
12817
13145
  }
12818
13146
  }
12819
- async function planStatus(deps) {
13147
+ async function planStatus(deps, opts = {}) {
12820
13148
  const queue = parseQueue(deps.readQueueRaw());
12821
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
+ }
12822
13155
  for (const e of queue) {
12823
13156
  if (e.conflict) deps.err(`${e.slug} \xB7 CONFLICT \u2014 ${e.conflict}`);
12824
13157
  else if (e.deadLettered) deps.err(`${e.slug} \xB7 DEAD-LETTER \u2014 ${e.deadLettered}`);
@@ -13277,7 +13610,7 @@ async function runWhoami(io = consoleIo) {
13277
13610
  io.log(JSON.stringify(report));
13278
13611
  return report;
13279
13612
  }
13280
- 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 () => {
13281
13614
  await runWhoami();
13282
13615
  });
13283
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) => {
@@ -13560,12 +13893,12 @@ function registerNorthStarCommands(cmd) {
13560
13893
  if (!ok) process.exitCode = 1;
13561
13894
  }));
13562
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)));
13563
- 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) => {
13564
13897
  const signals = await gatherRelevanceSignals();
13565
- 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 });
13566
13899
  }));
13567
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)));
13568
- 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)));
13569
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)));
13570
13903
  cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
13571
13904
  (slug, o) => withPlan(false, async (d) => {
@@ -14204,6 +14537,41 @@ verify.command("synthesize").description("merge lens JSON array into a PanelRepo
14204
14537
  return fail(`verify synthesize: ${e.message}`);
14205
14538
  }
14206
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
+ });
14207
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) => {
14208
14576
  const targetRepo2 = o.repo ?? HUB_REPO;
14209
14577
  const sourceRepo = await resolveRepo(void 0);
@@ -14276,7 +14644,7 @@ pr.command("create").description("create a PR and print {number,url} JSON").opti
14276
14644
  const created = await ghCreate(buildPrArgs({ title, body, base: o.base, head: o.head, repo: o.repo }));
14277
14645
  console.log(JSON.stringify(created));
14278
14646
  });
14279
- async function remoteBranchExists(branch, options = {}) {
14647
+ async function remoteBranchExists2(branch, options = {}) {
14280
14648
  return checkRemoteBranchExists(branch, {
14281
14649
  execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
14282
14650
  }, options);
@@ -14295,7 +14663,11 @@ async function createDeferredWorktreeStore() {
14295
14663
  }
14296
14664
  },
14297
14665
  write: async (entries) => {
14298
- await (0, import_promises5.writeFile)(registryPath, serializeDeferredWorktrees(entries), "utf8");
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
+ }
14299
14671
  }
14300
14672
  };
14301
14673
  } catch {
@@ -14330,7 +14702,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
14330
14702
  const beforeWorktrees = parseWorktreePorcelain(
14331
14703
  (await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
14332
14704
  );
14333
- const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
14705
+ const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists2(headRef);
14334
14706
  let remoteDeleteAttempted = false;
14335
14707
  let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
14336
14708
  await execFileP2("gh", buildPrMergeArgs({ number, repoArgs, method, auto: o.auto }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e) => {
@@ -14358,25 +14730,39 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
14358
14730
  attempted: false,
14359
14731
  reason: remoteNotAttemptedReason
14360
14732
  }) : await buildPrMergeRemoteBranchCleanupReport(headRef, {
14361
- exists: remoteBranchExists
14733
+ exists: remoteBranchExists2
14362
14734
  }, {
14363
14735
  attempted: remoteDeleteAttempted,
14364
14736
  existedBefore: remoteBefore,
14365
14737
  reason: remoteNotAttemptedReason
14366
14738
  });
14367
14739
  const deferredStore = await createDeferredWorktreeStore();
14368
- const localCleanup = repoArgs.length ? {
14369
- branch: headRef,
14370
- localBranch: { name: headRef, status: "not-attempted", reason: "repo-option" },
14371
- worktree: void 0
14372
- } : await cleanupPrMergeLocalBranch(headRef, {
14373
- beforeWorktrees,
14374
- startingPath,
14375
- execGit: async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
14376
- teardownWorktreeStage,
14377
- deferredStore,
14378
- removeWorktreeDir: worktreeRemoveDeps(async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout).removeWorktreeDir
14379
- });
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
+ }
14380
14766
  console.log(JSON.stringify(buildPrMergeResultPayload({
14381
14767
  number,
14382
14768
  branch: headRef,
@@ -14938,6 +15324,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
14938
15324
  json: rawFlag("--json")
14939
15325
  };
14940
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);
14941
15328
  let parsedRepo;
14942
15329
  try {
14943
15330
  parsedRepo = parseOwnerRepo(repo);
@@ -14957,7 +15344,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
14957
15344
  const eq = value.indexOf("=");
14958
15345
  if (eq > 0) rawVars[value.slice(0, eq)] = value.slice(eq + 1);
14959
15346
  }
14960
- const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class);
15347
+ const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class, bootstrapReleaseTrack);
14961
15348
  if (vars.PROJECT_ID) {
14962
15349
  try {
14963
15350
  const r = await gh(boardFieldsQueryArgs(vars.PROJECT_ID));
@@ -15025,7 +15412,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
15025
15412
  registerPayload = buildRegisterPayload(repo, o.class, vars, {
15026
15413
  projectType: o.projectType || void 0,
15027
15414
  deployModel: o.deployModel || void 0,
15028
- releaseTrack: o.releaseTrack || void 0
15415
+ releaseTrack: bootstrapReleaseTrack
15029
15416
  });
15030
15417
  } catch (e) {
15031
15418
  return fail(`bootstrap apply: ${e.message}`);
@@ -15329,6 +15716,39 @@ function quarantinePluginCacheDirs(plan2) {
15329
15716
  return moved;
15330
15717
  }
15331
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
+ }
15332
15752
  function readGitignore() {
15333
15753
  try {
15334
15754
  return (0, import_node_fs14.readFileSync)(gitignorePath(), "utf8");
@@ -15525,6 +15945,18 @@ async function runDoctor(opts, io = consoleIo) {
15525
15945
  mmiCliOnPath: onPath
15526
15946
  })
15527
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
+ );
15528
15960
  const gaps = checks.filter((c) => !c.ok);
15529
15961
  if (opts.banner) {
15530
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}`);
@@ -15553,7 +15985,7 @@ async function runDoctor(opts, io = consoleIo) {
15553
15985
  io.log(gaps.length ? `
15554
15986
  ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
15555
15987
  }
15556
- 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) => (
15557
15989
  // Commander maps `--no-repo-writes` to `repoWrites: false`; translate to the explicit `noRepoWrites` flag.
15558
15990
  runDoctor({ ...opts, noRepoWrites: opts.repoWrites === false })
15559
15991
  ));
package/dist/saga.cjs CHANGED
@@ -4058,7 +4058,7 @@ function setInjectedStdin(payload) {
4058
4058
  }
4059
4059
  async function readStdin() {
4060
4060
  if (injectedStdin !== void 0) return injectedStdin;
4061
- if (process.stdin.isTTY) return "";
4061
+ if (process.stdin.isTTY !== false) return "";
4062
4062
  const chunks = [];
4063
4063
  for await (const chunk of process.stdin) chunks.push(chunk);
4064
4064
  return Buffer.concat(chunks).toString("utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
4
4
  "description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",