@mutmutco/cli 2.37.0 → 2.38.1
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 +1 -1
- package/dist/index.cjs +40 -5
- package/dist/main.cjs +186 -83
- package/dist/saga.cjs +39 -4
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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)
|
|
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
|
|
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) =>
|
|
5369
|
-
handoff.command("cancel <key>").description("close an open handoff without claiming it").option("--json", "machine-readable output").action((key, 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/
|
|
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
|
|
6576
|
+
var THROTTLE_TRACE_PATH = (0, import_node_path9.join)(".mmi", "throttle", "trace.jsonl");
|
|
6528
6577
|
function resolveModeFromEnv() {
|
|
6529
|
-
const v = String(process.env.
|
|
6530
|
-
if (
|
|
6531
|
-
|
|
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
|
|
6551
|
-
let
|
|
6552
|
-
|
|
6553
|
-
const
|
|
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
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
};
|
|
6569
|
-
}
|
|
6570
|
-
function
|
|
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(`
|
|
6574
|
-
io.log(`Active mode: ${mode} (
|
|
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(`
|
|
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("
|
|
6634
|
+
io.log("Throttle gate summary");
|
|
6587
6635
|
io.log(` mode (current env): ${mode}`);
|
|
6588
|
-
io.log(`
|
|
6589
|
-
io.log(`
|
|
6590
|
-
io.log(
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
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
|
|
6600
|
-
const
|
|
6601
|
-
|
|
6602
|
-
const code =
|
|
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/
|
|
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
|
|
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.
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.38.1",
|
|
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",
|