@mutmutco/cli 2.37.0 → 2.38.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/README.md CHANGED
@@ -55,7 +55,7 @@ mmi-cli doctor --json
55
55
  - `mmi-cli rcand`, `release`, and `hotfix` render guarded train plans; product trains trigger the Hub's central tenant deployer, while MMI-Hub releases directly from `development` to `main`.
56
56
  - `mmi-cli bootstrap`, `bootstrap verify`, and `bootstrap apply` plan, audit, and seed repo onboarding.
57
57
  - `mmi-cli access audit` checks collaborator roles and train-branch allowlists.
58
- - `mmi-cli scrooge report` reads `.mmi/scrooge/trace.jsonl` and prints per-session char/token savings from the org Scrooge PostToolUse hook (Claude Code).
58
+ - `mmi-cli throttle report` reads `.mmi/throttle/trace.jsonl` and prints gate denial stats from the org Scrooge v2 Read/Shell gates (Claude Code + Cursor IDE) — files ≥50 KB require an explicit positive Read `limit`; output separates `denied (block)` vs `would-block (observe)`.
59
59
  - `mmi-cli doctor` checks GitHub auth, repo config, CLI availability, plugin install/config/version state, and stale MMI plugin cache dirs, auto-repairing the safe gaps.
60
60
 
61
61
  Hub API calls do not send the raw GitHub token on every request. The CLI exchanges it at `/auth/session`
package/dist/index.cjs CHANGED
@@ -60,26 +60,61 @@ var init_client_version = __esm({
60
60
  function setInjectedStdin(payload) {
61
61
  injectedStdin = payload;
62
62
  }
63
- function stdinHasPipedInput(statFd = () => (0, import_node_fs2.fstatSync)(0)) {
63
+ function stdinHasPipedInput(statFd = () => (0, import_node_fs2.fstatSync)(0), getIsTTY = () => process.stdin.isTTY) {
64
64
  try {
65
65
  const stat = statFd();
66
- return stat.isFIFO() || stat.isFile();
66
+ if (stat.isFIFO() || stat.isFile()) return true;
67
+ if (stat.isCharacterDevice()) return false;
68
+ if (stat.isSocket()) return false;
69
+ if (getIsTTY() === true) return false;
70
+ return true;
67
71
  } catch {
68
72
  return false;
69
73
  }
70
74
  }
71
- async function readStdin() {
75
+ async function readStdin(opts = {}) {
72
76
  if (injectedStdin !== void 0) return injectedStdin;
73
77
  if (!stdinHasPipedInput()) return "";
78
+ const maxBytes = opts.maxBytes ?? STDIN_MAX_BYTES;
79
+ const timeoutMs = opts.timeoutMs ?? STDIN_DRAIN_TIMEOUT_MS;
74
80
  const chunks = [];
75
- for await (const chunk of process.stdin) chunks.push(chunk);
81
+ let total = 0;
82
+ const drain = (async () => {
83
+ for await (const chunk of process.stdin) {
84
+ const buf = chunk;
85
+ const room = maxBytes - total;
86
+ if (buf.length >= room) {
87
+ chunks.push(buf.subarray(0, room));
88
+ total = maxBytes;
89
+ break;
90
+ }
91
+ chunks.push(buf);
92
+ total += buf.length;
93
+ }
94
+ })().catch(() => {
95
+ });
96
+ let timer;
97
+ const timeout = new Promise((resolve) => {
98
+ timer = setTimeout(resolve, timeoutMs);
99
+ });
100
+ try {
101
+ await Promise.race([drain, timeout]);
102
+ } finally {
103
+ if (timer) clearTimeout(timer);
104
+ try {
105
+ process.stdin.unref();
106
+ } catch {
107
+ }
108
+ }
76
109
  return Buffer.concat(chunks).toString("utf8");
77
110
  }
78
- var import_node_fs2, injectedStdin;
111
+ var import_node_fs2, injectedStdin, STDIN_MAX_BYTES, STDIN_DRAIN_TIMEOUT_MS;
79
112
  var init_stdin_inject = __esm({
80
113
  "src/stdin-inject.ts"() {
81
114
  "use strict";
82
115
  import_node_fs2 = require("node:fs");
116
+ STDIN_MAX_BYTES = 8 * 1024 * 1024;
117
+ STDIN_DRAIN_TIMEOUT_MS = 5e3;
83
118
  }
84
119
  });
85
120
 
package/dist/main.cjs CHANGED
@@ -4113,19 +4113,54 @@ async function hubAuthToken(deps) {
4113
4113
  // src/stdin-inject.ts
4114
4114
  var import_node_fs6 = require("node:fs");
4115
4115
  var injectedStdin;
4116
- function stdinHasPipedInput(statFd = () => (0, import_node_fs6.fstatSync)(0)) {
4116
+ function stdinHasPipedInput(statFd = () => (0, import_node_fs6.fstatSync)(0), getIsTTY = () => process.stdin.isTTY) {
4117
4117
  try {
4118
4118
  const stat = statFd();
4119
- return stat.isFIFO() || stat.isFile();
4119
+ if (stat.isFIFO() || stat.isFile()) return true;
4120
+ if (stat.isCharacterDevice()) return false;
4121
+ if (stat.isSocket()) return false;
4122
+ if (getIsTTY() === true) return false;
4123
+ return true;
4120
4124
  } catch {
4121
4125
  return false;
4122
4126
  }
4123
4127
  }
4124
- async function readStdin() {
4128
+ var STDIN_MAX_BYTES = 8 * 1024 * 1024;
4129
+ var STDIN_DRAIN_TIMEOUT_MS = 5e3;
4130
+ async function readStdin(opts = {}) {
4125
4131
  if (injectedStdin !== void 0) return injectedStdin;
4126
4132
  if (!stdinHasPipedInput()) return "";
4133
+ const maxBytes = opts.maxBytes ?? STDIN_MAX_BYTES;
4134
+ const timeoutMs = opts.timeoutMs ?? STDIN_DRAIN_TIMEOUT_MS;
4127
4135
  const chunks = [];
4128
- for await (const chunk of process.stdin) chunks.push(chunk);
4136
+ let total = 0;
4137
+ const drain = (async () => {
4138
+ for await (const chunk of process.stdin) {
4139
+ const buf = chunk;
4140
+ const room = maxBytes - total;
4141
+ if (buf.length >= room) {
4142
+ chunks.push(buf.subarray(0, room));
4143
+ total = maxBytes;
4144
+ break;
4145
+ }
4146
+ chunks.push(buf);
4147
+ total += buf.length;
4148
+ }
4149
+ })().catch(() => {
4150
+ });
4151
+ let timer;
4152
+ const timeout = new Promise((resolve) => {
4153
+ timer = setTimeout(resolve, timeoutMs);
4154
+ });
4155
+ try {
4156
+ await Promise.race([drain, timeout]);
4157
+ } finally {
4158
+ if (timer) clearTimeout(timer);
4159
+ try {
4160
+ process.stdin.unref();
4161
+ } catch {
4162
+ }
4163
+ }
4129
4164
  return Buffer.concat(chunks).toString("utf8");
4130
4165
  }
4131
4166
 
@@ -5138,6 +5173,7 @@ function parseHandoffText(text) {
5138
5173
  northStarSlug,
5139
5174
  summary,
5140
5175
  sourceSessionId,
5176
+ sourceBranch: clean(raw.sourceBranch) || void 0,
5141
5177
  createdAt,
5142
5178
  claimedBySessionId: clean(raw.claimedBySessionId) || void 0,
5143
5179
  closedAt: clean(raw.closedAt) || void 0
@@ -5168,6 +5204,7 @@ function planOpenHandoff(input) {
5168
5204
  northStarSlug,
5169
5205
  summary,
5170
5206
  sourceSessionId,
5207
+ sourceBranch: clean(input.sourceBranch) || void 0,
5171
5208
  createdAt: input.createdAt
5172
5209
  };
5173
5210
  }
@@ -5181,8 +5218,9 @@ function closeHandoff(record, state, at, claimedBySessionId) {
5181
5218
  }
5182
5219
  function formatHandoffLine(item) {
5183
5220
  const r = item.record;
5221
+ const branch = r.sourceBranch ? ` branch:${r.sourceBranch}` : "";
5184
5222
  const suffix = r.state === "claimed" && r.claimedBySessionId ? ` -> ${r.claimedBySessionId}` : "";
5185
- return `${r.key} [${r.state}] northstar:${r.northStarSlug} source:${r.sourceSessionId}${suffix} - ${r.summary}`;
5223
+ return `${r.key} [${r.state}] northstar:${r.northStarSlug}${branch} source:${r.sourceSessionId}${suffix} - ${r.summary}`;
5186
5224
  }
5187
5225
 
5188
5226
  // src/handoff-commands.ts
@@ -5196,7 +5234,8 @@ async function fetchState(url, qs, retry = FOREGROUND_FETCH) {
5196
5234
  return { key: null, state: { head: body.head } };
5197
5235
  }
5198
5236
  async function fetchScopedSessions(url, project2, branch, retry = FOREGROUND_FETCH) {
5199
- const qs = new URLSearchParams({ project: project2, branch });
5237
+ const qs = new URLSearchParams({ project: project2 });
5238
+ if (branch) qs.set("branch", branch);
5200
5239
  const res = await fetchWithRetry(fetch, `${url}/saga/sessions?${qs}`, { headers: await hubHeaders() }, retry);
5201
5240
  if (!res.ok) return [];
5202
5241
  const body = await res.json();
@@ -5219,7 +5258,7 @@ async function collectScopedHandoffs(opts = {}) {
5219
5258
  if (!cfg.sagaApiUrl) return { handoffs: [], sessions: [] };
5220
5259
  const key = await sagaKey(cfg);
5221
5260
  const retry = opts.retry ?? FOREGROUND_FETCH;
5222
- const sessions = await fetchScopedSessions(cfg.sagaApiUrl, key.project, key.branch, retry);
5261
+ const sessions = await fetchScopedSessions(cfg.sagaApiUrl, key.project, void 0, retry);
5223
5262
  const seen = /* @__PURE__ */ new Set();
5224
5263
  const handoffs = [];
5225
5264
  for (const session of sessions) {
@@ -5238,7 +5277,7 @@ async function locateOpenHandoff(key, retry = FOREGROUND_FETCH) {
5238
5277
  const cfg = await loadConfig();
5239
5278
  if (!cfg.sagaApiUrl) return null;
5240
5279
  const scopeKey = await sagaKey(cfg);
5241
- const sessions = await fetchScopedSessions(cfg.sagaApiUrl, scopeKey.project, scopeKey.branch, retry);
5280
+ const sessions = await fetchScopedSessions(cfg.sagaApiUrl, scopeKey.project, void 0, retry);
5242
5281
  for (const session of sessions) {
5243
5282
  const head = await fetchSessionHead(cfg.sagaApiUrl, session, retry);
5244
5283
  if (!head) continue;
@@ -5277,6 +5316,7 @@ function deriveOpenFields(summary, opts, head, key) {
5277
5316
  northStarSlug: northStarSlug ?? "",
5278
5317
  summary: text ?? "",
5279
5318
  sourceSessionId: key.sessionId,
5319
+ sourceBranch: key.branch,
5280
5320
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
5281
5321
  });
5282
5322
  }
@@ -5338,6 +5378,8 @@ async function closeSourceHandoff(key, state, opts = {}, io = consoleIo) {
5338
5378
  return fail(`handoff ${state}: no open handoff matching ${key}`);
5339
5379
  }
5340
5380
  const closed = closeHandoff(located.item.record, state, (/* @__PURE__ */ new Date()).toISOString(), state === "claimed" ? current.sessionId : void 0);
5381
+ const sourceBranch = located.item.record.sourceBranch ?? located.key.branch;
5382
+ const crossBranch = state === "claimed" && !!sourceBranch && sourceBranch !== current.branch;
5341
5383
  await postKeyedNote(located.key, `handoff ${state} ${located.item.record.key}`, {
5342
5384
  handoffClose: { index: located.item.index, closedText: serializeHandoff(closed) }
5343
5385
  });
@@ -5350,9 +5392,16 @@ async function closeSourceHandoff(key, state, opts = {}, io = consoleIo) {
5350
5392
  verified: true
5351
5393
  });
5352
5394
  }
5353
- if (opts.json) return io.log(JSON.stringify({ ok: true, handoff: closed }));
5395
+ if (opts.json) {
5396
+ return io.log(JSON.stringify({ ok: true, handoff: closed, ...state === "claimed" ? { sourceBranch, crossBranch } : {} }));
5397
+ }
5354
5398
  io.log(`handoff ${state}: ${formatHandoffLine({ index: located.item.index, done: true, record: closed })}`);
5399
+ if (crossBranch) {
5400
+ io.log(`note: opened on branch '${sourceBranch}' (you are on '${current.branch}') \u2014 run \`git switch ${sourceBranch}\` to work where the source session left it.`);
5401
+ }
5355
5402
  }
5403
+ var runHandoffAccept = (key, opts = {}, io = consoleIo) => closeSourceHandoff(key, "claimed", opts, io);
5404
+ var runHandoffCancel = (key, opts = {}, io = consoleIo) => closeSourceHandoff(key, "cancelled", opts, io);
5356
5405
  async function runHandoffDecline(key, opts = {}, io = consoleIo) {
5357
5406
  const located = await locateOpenHandoff(key);
5358
5407
  if (!located) return fail(`handoff decline: no open handoff matching ${key}`);
@@ -5362,11 +5411,11 @@ async function runHandoffDecline(key, opts = {}, io = consoleIo) {
5362
5411
  function registerHandoffCommands(program3) {
5363
5412
  const handoff = program3.command("handoff").description("explicit saga + North Star handoff lifecycle");
5364
5413
  handoff.command("open [summary]").description("open a handoff bound to the current North Star slug").option("--key <slug|#issue>", "handoff key (defaults to the current North Star slug)").option("--north-star-slug <slug>", "North Star slug to bind (defaults to current saga anchor slug)").option("--message-file <path|->", "read the handoff summary from a UTF-8 file, or from stdin with - (avoids cmd.exe quoting)").option("--json", "machine-readable output").action((summary, opts) => runHandoffOpen(summary, opts));
5365
- handoff.command("list").description("list open handoffs for the current repo/branch").option("--all", "include claimed/cancelled records").option("--json", "machine-readable output").action(async (opts) => {
5414
+ handoff.command("list").description("list open handoffs for the current repo (any branch)").option("--all", "include claimed/cancelled records").option("--json", "machine-readable output").action(async (opts) => {
5366
5415
  await runHandoffList(opts);
5367
5416
  });
5368
- handoff.command("accept <key>").description("claim an open handoff and bind this session to its North Star").option("--json", "machine-readable output").action((key, opts) => closeSourceHandoff(key, "claimed", opts));
5369
- handoff.command("cancel <key>").description("close an open handoff without claiming it").option("--json", "machine-readable output").action((key, opts) => closeSourceHandoff(key, "cancelled", opts));
5417
+ handoff.command("accept <key>").description("claim an open handoff and bind this session to its North Star").option("--json", "machine-readable output").action((key, opts) => runHandoffAccept(key, opts));
5418
+ handoff.command("cancel <key>").description("close an open handoff without claiming it").option("--json", "machine-readable output").action((key, opts) => runHandoffCancel(key, opts));
5370
5419
  handoff.command("decline <key>").description("leave an open handoff unchanged so it is re-offered later").option("--json", "machine-readable output").action((key, opts) => runHandoffDecline(key, opts));
5371
5420
  }
5372
5421
 
@@ -6521,17 +6570,14 @@ function registerHonchoCommands(program3) {
6521
6570
  honcho.command("key").option("--json", "machine-readable output").description("print the resolved honcho identity (workspace, peer, session) \u2014 no write, no key").action((o) => runHonchoKey(o));
6522
6571
  }
6523
6572
 
6524
- // src/scrooge-commands.ts
6573
+ // src/throttle-commands.ts
6525
6574
  var import_node_fs11 = require("node:fs");
6526
6575
  var import_node_path9 = require("node:path");
6527
- var SCROOGE_TRACE_PATH = (0, import_node_path9.join)(".mmi", "scrooge", "trace.jsonl");
6576
+ var THROTTLE_TRACE_PATH = (0, import_node_path9.join)(".mmi", "throttle", "trace.jsonl");
6528
6577
  function resolveModeFromEnv() {
6529
- const v = String(process.env.MMI_SCROOGE ?? "conservative").trim().toLowerCase();
6530
- if (!v || v === "conservative") return "conservative";
6531
- if (v === "0" || v === "off" || v === "false") return "off";
6532
- if (v === "normal") return "normal";
6533
- if (v === "spike") return "spike";
6534
- return "conservative";
6578
+ const v = String(process.env.MMI_THROTTLE_MODE ?? "block").trim().toLowerCase();
6579
+ if (v === "observe") return "observe";
6580
+ return "block";
6535
6581
  }
6536
6582
  function parseTraceLines(raw) {
6537
6583
  const out = [];
@@ -6547,59 +6593,78 @@ function parseTraceLines(raw) {
6547
6593
  return out;
6548
6594
  }
6549
6595
  function summarizeTrace(entries) {
6550
- let tools = 0;
6551
- let charsIn = 0;
6552
- let charsOut = 0;
6553
- const passCounts = {};
6596
+ let denials = 0;
6597
+ let readBytesWouldBlock = 0;
6598
+ const byTool = {};
6599
+ const byReason = {};
6600
+ const bySurface = {};
6601
+ const byMode = {};
6554
6602
  for (const e of entries) {
6555
- tools += 1;
6556
- charsIn += Number(e.charsIn) || 0;
6557
- charsOut += Number(e.charsOut) || 0;
6558
- for (const p of e.passes ?? []) passCounts[p] = (passCounts[p] ?? 0) + 1;
6559
- }
6560
- const saved = Math.max(0, charsIn - charsOut);
6561
- return {
6562
- tools,
6563
- charsIn,
6564
- charsOut,
6565
- charsSaved: saved,
6566
- estTokensSaved: Math.round(saved / 4),
6567
- passCounts
6568
- };
6569
- }
6570
- function runScroogeReport(io, tracePath = SCROOGE_TRACE_PATH) {
6603
+ denials += 1;
6604
+ const tool = e.tool ?? "unknown";
6605
+ byTool[tool] = (byTool[tool] ?? 0) + 1;
6606
+ const reason = e.reasonId ?? "unknown";
6607
+ byReason[reason] = (byReason[reason] ?? 0) + 1;
6608
+ const surface = e.surface ?? "unknown";
6609
+ bySurface[surface] = (bySurface[surface] ?? 0) + 1;
6610
+ const mode = e.mode ?? "unknown";
6611
+ byMode[mode] = (byMode[mode] ?? 0) + 1;
6612
+ if (reason === "read_unbounded_large") {
6613
+ readBytesWouldBlock += Number(e.fileBytes) || 0;
6614
+ }
6615
+ }
6616
+ return { denials, readBytesWouldBlock, byTool, byReason, bySurface, byMode };
6617
+ }
6618
+ function runThrottleReport(io, tracePath = THROTTLE_TRACE_PATH) {
6571
6619
  const mode = resolveModeFromEnv();
6572
6620
  if (!(0, import_node_fs11.existsSync)(tracePath)) {
6573
- io.log(`Scrooge: no trace at ${tracePath} (hook has not compacted anything yet).`);
6574
- io.log(`Active mode: ${mode} (MMI_SCROOGE env; default conservative).`);
6621
+ io.log(`Throttle: no trace at ${tracePath} (gates have not denied anything yet).`);
6622
+ io.log(`Active mode: ${mode} (MMI_THROTTLE_MODE env; default block).`);
6575
6623
  return 0;
6576
6624
  }
6577
6625
  let raw = "";
6578
6626
  try {
6579
6627
  raw = (0, import_node_fs11.readFileSync)(tracePath, "utf8");
6580
6628
  } catch (e) {
6581
- io.err(`Scrooge: could not read trace: ${e.message}`);
6629
+ io.err(`Throttle: could not read trace: ${e.message}`);
6582
6630
  return 1;
6583
6631
  }
6584
6632
  const entries = parseTraceLines(raw);
6585
6633
  const s = summarizeTrace(entries);
6586
- io.log("Scrooge session summary");
6634
+ io.log("Throttle gate summary");
6587
6635
  io.log(` mode (current env): ${mode}`);
6588
- io.log(` compacted tool results: ${s.tools}`);
6589
- io.log(` chars in / out: ${s.charsIn} / ${s.charsOut}`);
6590
- io.log(` chars saved: ${s.charsSaved}`);
6591
- io.log(` est tokens saved (chars/4): ~${s.estTokensSaved}`);
6592
- const passes = Object.entries(s.passCounts).sort((a, b) => b[1] - a[1]);
6593
- if (passes.length) {
6594
- io.log(" passes:");
6595
- for (const [name, count] of passes) io.log(` ${name}: ${count}`);
6636
+ io.log(` trace entries: ${s.denials}`);
6637
+ io.log(` read bytes would-have-read (fs.stat only): ${s.readBytesWouldBlock}`);
6638
+ io.log(" (Shell denials are not converted to a tokens-saved figure \u2014 counterfactual.)");
6639
+ const modes = Object.entries(s.byMode).sort((a, b) => b[1] - a[1]);
6640
+ if (modes.length) {
6641
+ io.log(" by trace mode (at denial time):");
6642
+ for (const [name, count] of modes) {
6643
+ const label = name === "observe" ? "would-block (observe)" : name === "block" ? "denied (block)" : name;
6644
+ io.log(` ${label}: ${count}`);
6645
+ }
6646
+ }
6647
+ const tools = Object.entries(s.byTool).sort((a, b) => b[1] - a[1]);
6648
+ if (tools.length) {
6649
+ io.log(" by tool:");
6650
+ for (const [name, count] of tools) io.log(` ${name}: ${count}`);
6651
+ }
6652
+ const reasons = Object.entries(s.byReason).sort((a, b) => b[1] - a[1]);
6653
+ if (reasons.length) {
6654
+ io.log(" by reason:");
6655
+ for (const [name, count] of reasons) io.log(` ${name}: ${count}`);
6656
+ }
6657
+ const surfaces = Object.entries(s.bySurface).sort((a, b) => b[1] - a[1]);
6658
+ if (surfaces.length) {
6659
+ io.log(" by surface:");
6660
+ for (const [name, count] of surfaces) io.log(` ${name}: ${count}`);
6596
6661
  }
6597
6662
  return 0;
6598
6663
  }
6599
- function registerScroogeCommands(program3) {
6600
- const scrooge = program3.command("scrooge").description("Scrooge \u2014 org tool-output compaction (trace + report)");
6601
- scrooge.command("report").description("print char/token savings from .mmi/scrooge/trace.jsonl").action(() => {
6602
- const code = runScroogeReport({ log: (s) => console.log(s), err: (s) => console.error(s) });
6664
+ function registerThrottleCommands(program3) {
6665
+ const throttle = program3.command("throttle").description("Scrooge v2 \u2014 tool-economy gate trace + report");
6666
+ throttle.command("report").description("print gate denial stats from .mmi/throttle/trace.jsonl").action(() => {
6667
+ const code = runThrottleReport({ log: (s) => console.log(s), err: (s) => console.error(s) });
6603
6668
  process.exit(code);
6604
6669
  });
6605
6670
  }
@@ -8378,7 +8443,7 @@ var MANAGED_GITIGNORE_LINES = [
8378
8443
  // .mmi is agent/CI scratch — ignore the WHOLE tree at any depth (root + cli/.mmi, .github/workflows/.mmi,
8379
8444
  // and any future feature's subdir) so new scratch NEVER needs a new ignore line. `**/.mmi/*` ignores the
8380
8445
  // dir's contents (not the dir itself), so the one tracked file is re-included on the next line (#1550).
8381
- // This replaces the brittle per-path enumeration (saga/honcho/head-ts/scrooge/… — #1472 was one such miss).
8446
+ // This replaces the brittle per-path enumeration (saga/honcho/head-ts/throttle/… — #1472 was one such miss).
8382
8447
  "**/.mmi/*",
8383
8448
  // The single tracked .mmi file. A NEW tracked .mmi file would add its own `!` line here; everything else
8384
8449
  // under .mmi stays ignored automatically.
@@ -8495,10 +8560,16 @@ function resolveBootstrapReleaseTrack(cls, explicit) {
8495
8560
  if (cls === "content") return "trunk";
8496
8561
  return "full";
8497
8562
  }
8498
- function resolveReleaseTrack(meta, hints) {
8563
+ function metaIsHubControlRepo(meta, repo) {
8564
+ if (repo && repoIsHub(repo)) return true;
8565
+ const repos = meta?.repos;
8566
+ return Array.isArray(repos) && repos.some((r) => typeof r === "string" && repoIsHub(r));
8567
+ }
8568
+ function resolveReleaseTrack(meta, hints, repo) {
8499
8569
  const raw = typeof meta?.releaseTrack === "string" ? meta.releaseTrack : void 0;
8500
8570
  if (isReleaseTrack(raw)) return raw;
8501
8571
  if (meta?.class === "content" || meta?.deployModel === "content") return "trunk";
8572
+ if (metaIsHubControlRepo(meta, repo)) return "direct";
8502
8573
  const inferred = hints ? inferReleaseTrackFromBranches(hints) : void 0;
8503
8574
  if (inferred) return inferred;
8504
8575
  return "full";
@@ -8865,6 +8936,14 @@ async function contentExists(deps, repo, branch, path2) {
8865
8936
  return false;
8866
8937
  }
8867
8938
  }
8939
+ async function branchPresence(deps, repo, branch) {
8940
+ try {
8941
+ await deps.client.rest("GET", `repos/${repo}/branches/${encodeURIComponent(branch)}`);
8942
+ return true;
8943
+ } catch (e) {
8944
+ return e?.status === 404 ? false : void 0;
8945
+ }
8946
+ }
8868
8947
  function collectRegistryRepos(projects) {
8869
8948
  const seen = /* @__PURE__ */ new Set();
8870
8949
  for (const project2 of projects) {
@@ -8873,7 +8952,7 @@ function collectRegistryRepos(projects) {
8873
8952
  seen.add(repo);
8874
8953
  }
8875
8954
  }
8876
- if (!seen.has(HUB_REPO)) seen.add(HUB_REPO);
8955
+ if (![...seen].some((r) => r.toLowerCase() === HUB_REPO.toLowerCase())) seen.add(HUB_REPO);
8877
8956
  return [...seen].sort((a, b) => a.localeCompare(b));
8878
8957
  }
8879
8958
  async function resolveRepoMergeCiPolicy(repo, deps) {
@@ -8898,6 +8977,10 @@ async function auditRepoCi(repo, deps) {
8898
8977
  const meta = await deps.getProjectMeta(slugFromRepo(repo));
8899
8978
  const repoClass = classifyRepo(repo, meta);
8900
8979
  const checks = [];
8980
+ const registryCi = meta?.ci;
8981
+ const registryRequiredChecks = meta?.requiredChecks;
8982
+ const explicitNoCi = registryCi === "none" || registryRequiredChecks === null || Array.isArray(registryRequiredChecks) && registryRequiredChecks.length === 0;
8983
+ const deployableGated = repoClass === "deployable" && !explicitNoCi;
8901
8984
  const info = await restJson(deps, `repos/${repo}`, {});
8902
8985
  const baseBranch = repoClass === "content" ? "main" : "development";
8903
8986
  checks.push({
@@ -8917,7 +9000,7 @@ async function auditRepoCi(repo, deps) {
8917
9000
  detail: info.delete_branch_on_merge === true ? void 0 : "false or unavailable"
8918
9001
  });
8919
9002
  const hasGateWorkflow = repoClass === "hub" ? true : repoClass === "content" ? true : await contentExists(deps, repo, baseBranch, PRODUCT_GATE_PATH);
8920
- if (repoClass === "deployable") {
9003
+ if (deployableGated) {
8921
9004
  checks.push({
8922
9005
  ok: hasGateWorkflow,
8923
9006
  label: "gate workflow committed",
@@ -8942,7 +9025,7 @@ async function auditRepoCi(repo, deps) {
8942
9025
  label: "Hub required status checks active",
8943
9026
  detail: missing.length ? `missing: ${missing.join(", ")}` : void 0
8944
9027
  });
8945
- } else if (repoClass === "deployable") {
9028
+ } else if (deployableGated) {
8946
9029
  const missing = [PRODUCT_GATE_CONTEXT2].filter((c) => !statusChecks.has(c));
8947
9030
  checks.push({
8948
9031
  ok: missing.length === 0,
@@ -8951,29 +9034,42 @@ async function auditRepoCi(repo, deps) {
8951
9034
  remediation: missing.length ? `Import ${PRODUCT_RULESET_REF} as an active repository ruleset (GitHub \u2192 Settings \u2192 Rules \u2192 Rulesets) \u2014 target: bootstrap --apply automation (#1440)` : void 0
8952
9035
  });
8953
9036
  }
8954
- const registryCi = meta?.ci;
8955
- const registryRequiredChecks = meta?.requiredChecks;
8956
9037
  const workflowPaths = hasGateWorkflow && repoClass === "deployable" ? [PRODUCT_GATE_PATH] : [];
8957
9038
  const { policy, reason } = resolveMergeCiPolicy({
8958
9039
  workflowPaths,
8959
9040
  registryCi,
8960
9041
  registryRequiredChecks
8961
9042
  });
8962
- if (repoClass === "content") {
8963
- const explicitNoCi = registryCi === "none" || registryRequiredChecks === null || Array.isArray(registryRequiredChecks) && registryRequiredChecks.length === 0;
9043
+ if (repoClass === "content" || repoClass === "deployable" && explicitNoCi) {
8964
9044
  checks.push({
8965
9045
  ok: explicitNoCi,
8966
9046
  label: "registry META declares intentional no-ci",
8967
9047
  detail: explicitNoCi ? void 0 : "set ci:none and requiredChecks:[] in registry META",
8968
9048
  remediation: `mmi-cli project set ${repo} --var ci=none --var requiredChecks=[]`
8969
9049
  });
8970
- } else if (repoClass === "deployable") {
9050
+ } else if (deployableGated) {
8971
9051
  checks.push({
8972
9052
  ok: policy === "wait-for-checks",
8973
9053
  label: "merge CI policy is wait-for-checks",
8974
9054
  detail: `${policy} (${reason})`
8975
9055
  });
8976
9056
  }
9057
+ if (repoClass === "deployable") {
9058
+ const effectiveTrack = resolveReleaseTrack(meta, void 0, repo);
9059
+ if (effectiveTrack === "full" || effectiveTrack === "direct") {
9060
+ const rcPresent = await branchPresence(deps, repo, "rc");
9061
+ if (rcPresent !== void 0) {
9062
+ const trackExpectsRc = effectiveTrack === "full";
9063
+ const wantTrack = rcPresent ? "full" : "direct";
9064
+ checks.push({
9065
+ ok: trackExpectsRc === rcPresent,
9066
+ label: "release track matches branch topology",
9067
+ detail: trackExpectsRc === rcPresent ? void 0 : `registry resolves '${effectiveTrack}' but the rc branch ${rcPresent ? "exists" : "is absent"} \u2014 set release track '${wantTrack}'`,
9068
+ remediation: trackExpectsRc === rcPresent ? void 0 : `mmi-cli project set ${repo} --release-track ${wantTrack}`
9069
+ });
9070
+ }
9071
+ }
9072
+ }
8977
9073
  const ok = checks.every((c) => c.ok);
8978
9074
  return { repo, class: repoClass, mergePolicy: policy, ok, checks };
8979
9075
  }
@@ -9082,7 +9178,12 @@ async function putSeedFile(deps, repo, path2, content, branch) {
9082
9178
  }
9083
9179
  async function seedGateYml(repo, deps, meta, result) {
9084
9180
  const baseBranch = "development";
9085
- if (await contentExists(deps, repo, baseBranch, PRODUCT_GATE_PATH)) return;
9181
+ const releaseTrack = isReleaseTrack(meta?.releaseTrack) ? meta?.releaseTrack : void 0;
9182
+ const parsed = parseOwnerRepo(repo);
9183
+ if (await contentExists(deps, repo, baseBranch, PRODUCT_GATE_PATH)) {
9184
+ await seedRulesetRefIfMissing(repo, deps, withDerivedRepoVars({ REPO_SLUG: parsed.slug }, parsed, "deployable", releaseTrack), baseBranch, result);
9185
+ return;
9186
+ }
9086
9187
  if (!deps.readSeedFile && !deps.renderSeed) {
9087
9188
  result.skipped.push(`gate.yml missing but no seed-template source wired \u2014 run bootstrap apply`);
9088
9189
  return;
@@ -9092,8 +9193,6 @@ async function seedGateYml(repo, deps, meta, result) {
9092
9193
  result.skipped.push(`gate.yml missing \u2014 no registry gate config; set \`mmi-cli project set ${repo} --var gate={...}\` first, then re-run reconcile`);
9093
9194
  return;
9094
9195
  }
9095
- const releaseTrack = isReleaseTrack(meta?.releaseTrack) ? meta?.releaseTrack : void 0;
9096
- const parsed = parseOwnerRepo(repo);
9097
9196
  const derivedVars = withDerivedRepoVars({ ...gateConfigToVars(gate), REPO_SLUG: parsed.slug }, parsed, "deployable", releaseTrack);
9098
9197
  const rendered = renderSeedBody(deps, GATE_TEMPLATE_SEED, PRODUCT_GATE_PATH, derivedVars);
9099
9198
  if (rendered == null) {
@@ -9107,16 +9206,18 @@ async function seedGateYml(repo, deps, meta, result) {
9107
9206
  result.errors.push(`gate.yml re-seed failed: ${e.message}`);
9108
9207
  return;
9109
9208
  }
9110
- if (!await contentExists(deps, repo, baseBranch, PRODUCT_RULESET_REF)) {
9111
- const rulesetBody = renderSeedBody(deps, RULESET_TEMPLATE_SEED, PRODUCT_RULESET_REF, derivedVars);
9112
- if (rulesetBody != null) {
9113
- try {
9114
- await putSeedFile(deps, repo, PRODUCT_RULESET_REF, rulesetBody, baseBranch);
9115
- result.applied.push(`seeded ${PRODUCT_RULESET_REF}`);
9116
- } catch (e) {
9117
- result.errors.push(`ruleset reference re-seed failed: ${e.message}`);
9118
- }
9119
- }
9209
+ await seedRulesetRefIfMissing(repo, deps, derivedVars, baseBranch, result);
9210
+ }
9211
+ async function seedRulesetRefIfMissing(repo, deps, derivedVars, baseBranch, result) {
9212
+ if (await contentExists(deps, repo, baseBranch, PRODUCT_RULESET_REF)) return;
9213
+ if (!deps.readSeedFile && !deps.renderSeed) return;
9214
+ const rulesetBody = renderSeedBody(deps, RULESET_TEMPLATE_SEED, PRODUCT_RULESET_REF, derivedVars);
9215
+ if (rulesetBody == null) return;
9216
+ try {
9217
+ await putSeedFile(deps, repo, PRODUCT_RULESET_REF, rulesetBody, baseBranch);
9218
+ result.applied.push(`seeded ${PRODUCT_RULESET_REF}`);
9219
+ } catch (e) {
9220
+ result.errors.push(`ruleset reference seed failed: ${e.message}`);
9120
9221
  }
9121
9222
  }
9122
9223
  async function applyCiReconcileRepo(repo, deps) {
@@ -11849,7 +11950,8 @@ var ORG_SPINE_FILES = [
11849
11950
  "GEMINI.md",
11850
11951
  ".claude/settings.json",
11851
11952
  ".claude/output-styles/mmi-plain.md",
11852
- ".cursor/rules/mmi-plain-language.mdc"
11953
+ ".cursor/rules/mmi-plain-language.mdc",
11954
+ ".cursor/rules/mmi-tool-economy.mdc"
11853
11955
  ];
11854
11956
  function isSpinePath(path2) {
11855
11957
  return ORG_SPINE_FILES.includes(path2);
@@ -15930,7 +16032,8 @@ async function runRulesSync(opts, io = consoleIo) {
15930
16032
  "CLAUDE.md",
15931
16033
  ".claude/settings.json",
15932
16034
  ".claude/output-styles/mmi-plain.md",
15933
- ".cursor/rules/mmi-plain-language.mdc"
16035
+ ".cursor/rules/mmi-plain-language.mdc",
16036
+ ".cursor/rules/mmi-tool-economy.mdc"
15934
16037
  ];
15935
16038
  const fetched = await Promise.all(files.map(async (file) => {
15936
16039
  try {
@@ -15992,7 +16095,7 @@ docs.command("sync").option("--quiet", "stay silent unless something changed or
15992
16095
  registerSagaCommands(program2);
15993
16096
  registerHandoffCommands(program2);
15994
16097
  registerHonchoCommands(program2);
15995
- registerScroogeCommands(program2);
16098
+ registerThrottleCommands(program2);
15996
16099
  program2.command("commands").description("print the command manifest \u2014 every subcommand + its flags (ground against this instead of guessing)").option("--json", "machine-readable JSON: { name, version, tree, index } \u2014 index is a flat list of every leaf command path").action((o) => {
15997
16100
  const manifest = buildCommandManifest(program2);
15998
16101
  consoleIo.log(o.json ? JSON.stringify(manifest, null, 2) : formatManifestHuman(manifest));
@@ -16711,7 +16814,7 @@ project.command("get [owner/repo]").description("a project's META (board ids + p
16711
16814
  console.log(JSON.stringify(read.project));
16712
16815
  if (!o.json) {
16713
16816
  const m = read.project;
16714
- const track = resolveReleaseTrack(m);
16817
+ const track = resolveReleaseTrack(m, void 0, target);
16715
16818
  const stages = branchesForTrack(track).join(" -> ");
16716
16819
  const note = track === "direct" ? " (direct \u2014 no rc; /rcand refuses, /release ships development -> main)" : track === "trunk" ? " (trunk \u2014 main only)" : "";
16717
16820
  console.error(
package/dist/saga.cjs CHANGED
@@ -4370,19 +4370,54 @@ var injectedStdin;
4370
4370
  function setInjectedStdin(payload) {
4371
4371
  injectedStdin = payload;
4372
4372
  }
4373
- function stdinHasPipedInput(statFd = () => (0, import_node_fs6.fstatSync)(0)) {
4373
+ function stdinHasPipedInput(statFd = () => (0, import_node_fs6.fstatSync)(0), getIsTTY = () => process.stdin.isTTY) {
4374
4374
  try {
4375
4375
  const stat = statFd();
4376
- return stat.isFIFO() || stat.isFile();
4376
+ if (stat.isFIFO() || stat.isFile()) return true;
4377
+ if (stat.isCharacterDevice()) return false;
4378
+ if (stat.isSocket()) return false;
4379
+ if (getIsTTY() === true) return false;
4380
+ return true;
4377
4381
  } catch {
4378
4382
  return false;
4379
4383
  }
4380
4384
  }
4381
- async function readStdin() {
4385
+ var STDIN_MAX_BYTES = 8 * 1024 * 1024;
4386
+ var STDIN_DRAIN_TIMEOUT_MS = 5e3;
4387
+ async function readStdin(opts = {}) {
4382
4388
  if (injectedStdin !== void 0) return injectedStdin;
4383
4389
  if (!stdinHasPipedInput()) return "";
4390
+ const maxBytes = opts.maxBytes ?? STDIN_MAX_BYTES;
4391
+ const timeoutMs = opts.timeoutMs ?? STDIN_DRAIN_TIMEOUT_MS;
4384
4392
  const chunks = [];
4385
- for await (const chunk of process.stdin) chunks.push(chunk);
4393
+ let total = 0;
4394
+ const drain = (async () => {
4395
+ for await (const chunk of process.stdin) {
4396
+ const buf = chunk;
4397
+ const room = maxBytes - total;
4398
+ if (buf.length >= room) {
4399
+ chunks.push(buf.subarray(0, room));
4400
+ total = maxBytes;
4401
+ break;
4402
+ }
4403
+ chunks.push(buf);
4404
+ total += buf.length;
4405
+ }
4406
+ })().catch(() => {
4407
+ });
4408
+ let timer;
4409
+ const timeout = new Promise((resolve) => {
4410
+ timer = setTimeout(resolve, timeoutMs);
4411
+ });
4412
+ try {
4413
+ await Promise.race([drain, timeout]);
4414
+ } finally {
4415
+ if (timer) clearTimeout(timer);
4416
+ try {
4417
+ process.stdin.unref();
4418
+ } catch {
4419
+ }
4420
+ }
4386
4421
  return Buffer.concat(chunks).toString("utf8");
4387
4422
  }
4388
4423
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.37.0",
3
+ "version": "2.38.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",