@mutmutco/cli 3.36.1 → 3.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.
Files changed (2) hide show
  1. package/dist/main.cjs +233 -212
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -4107,33 +4107,6 @@ var import_node_path6 = require("node:path");
4107
4107
  var import_node_child_process4 = require("node:child_process");
4108
4108
  var import_node_fs6 = require("node:fs");
4109
4109
  var import_node_path4 = require("node:path");
4110
-
4111
- // src/plan-scratch.ts
4112
- var metaKey = (project2, slug) => `${project2}/${slug}`;
4113
- function parseMeta(raw) {
4114
- if (!raw) return {};
4115
- try {
4116
- const o = JSON.parse(raw);
4117
- return o && typeof o === "object" ? o : {};
4118
- } catch {
4119
- return {};
4120
- }
4121
- }
4122
- function hashContent(s) {
4123
- let h = 2166136261;
4124
- for (let i = 0; i < s.length; i++) {
4125
- h ^= s.charCodeAt(i);
4126
- h = Math.imul(h, 16777619);
4127
- }
4128
- return (h >>> 0).toString(16);
4129
- }
4130
-
4131
- // src/rules-sync.ts
4132
- function normalizeEol(s) {
4133
- return s.replace(/\r\n/g, "\n");
4134
- }
4135
-
4136
- // src/scratch-gc.ts
4137
4110
  var PLAN_ADVISORY_AGE_MS = 30 * 24 * 36e5;
4138
4111
  var ROOT_SCRATCH_STALE_MS = 24 * 36e5;
4139
4112
  var SCRATCH_GC_THROTTLE_MS = 24 * 36e5;
@@ -4155,16 +4128,16 @@ function markScratchGcRun(stampPath, now = Date.now()) {
4155
4128
  }
4156
4129
  }
4157
4130
  function executeScratchGc(repoRoot2, opts, now = Date.now()) {
4158
- const snap = collectScratchSnapshot(repoRoot2, { project: opts.project });
4131
+ const snap = collectScratchSnapshot(repoRoot2);
4159
4132
  const plan = planScratchGc(snap, now);
4160
4133
  if (!opts.apply) return { plan };
4161
- return { plan, applied: applyScratchGc(plan, repoRoot2, now, opts.project) };
4134
+ return { plan, applied: applyScratchGc(plan, repoRoot2, now) };
4162
4135
  }
4163
- function buildPrMergeScratchHousekeeping(repoRoot2, deps = {}, project2) {
4136
+ function buildPrMergeScratchHousekeeping(repoRoot2, deps = {}) {
4164
4137
  const runScratchGc = deps.runScratchGc ?? executeScratchGc;
4165
4138
  try {
4166
- const scratchApply = runScratchGc(repoRoot2, { apply: true, project: project2 });
4167
- const scratchAfter = runScratchGc(repoRoot2, { apply: false, project: project2 });
4139
+ const scratchApply = runScratchGc(repoRoot2, { apply: true });
4140
+ const scratchAfter = runScratchGc(repoRoot2, { apply: false });
4168
4141
  const applied = scratchApply.applied;
4169
4142
  const pruned = applied?.pruned.length ?? 0;
4170
4143
  const skipped = applied?.skipped ?? 0;
@@ -4228,58 +4201,16 @@ function planScratchGc(snap, now = Date.now()) {
4228
4201
  });
4229
4202
  }
4230
4203
  }
4231
- if (snap.syncQueueSlugs === null) {
4232
- for (const f of snap.planMdFiles) {
4233
- if (now - f.mtimeMs <= PLAN_ADVISORY_AGE_MS) continue;
4234
- candidates.push({
4235
- path: f.path,
4236
- family: "plan",
4237
- kind: "file",
4238
- tier: "advisory",
4239
- reason: "North Star sync queue unreadable; kept",
4240
- bytes: f.bytes
4241
- });
4242
- }
4243
- } else {
4244
- for (const f of snap.planMdFiles) {
4245
- if (now - f.mtimeMs <= PLAN_ADVISORY_AGE_MS) continue;
4246
- const slug = f.name.replace(/\.md$/, "");
4247
- if (snap.syncQueueSlugs.has(slug)) {
4248
- candidates.push({
4249
- path: f.path,
4250
- family: "plan",
4251
- kind: "file",
4252
- tier: "advisory",
4253
- reason: "pending North Star sync; kept",
4254
- bytes: f.bytes
4255
- });
4256
- continue;
4257
- }
4258
- const hash = f.hash;
4259
- const meta = snap.planMeta;
4260
- const entry = hash && meta ? syncedPlanMetaEntry(meta, snap.project, slug, hash) : void 0;
4261
- if (entry) {
4262
- candidates.push({
4263
- path: f.path,
4264
- family: "plan",
4265
- kind: "file",
4266
- tier: "safe-auto",
4267
- reason: "North Star synced hash confirmed",
4268
- bytes: f.bytes,
4269
- hash
4270
- });
4271
- } else {
4272
- candidates.push({
4273
- path: f.path,
4274
- family: "plan",
4275
- kind: "file",
4276
- tier: "advisory",
4277
- reason: "local plan content is not confirmed synced; kept",
4278
- bytes: f.bytes,
4279
- ...hash ? { hash } : {}
4280
- });
4281
- }
4282
- }
4204
+ for (const f of snap.planMdFiles) {
4205
+ if (now - f.mtimeMs <= PLAN_ADVISORY_AGE_MS) continue;
4206
+ candidates.push({
4207
+ path: f.path,
4208
+ family: "plan",
4209
+ kind: "file",
4210
+ tier: "advisory",
4211
+ reason: "plan older than the advisory floor; review and prune manually",
4212
+ bytes: f.bytes
4213
+ });
4283
4214
  }
4284
4215
  return {
4285
4216
  candidates,
@@ -4287,47 +4218,6 @@ function planScratchGc(snap, now = Date.now()) {
4287
4218
  advisory: candidates.filter((c) => c.tier === "advisory")
4288
4219
  };
4289
4220
  }
4290
- function syncedPlanMetaEntry(meta, project2, slug, hash) {
4291
- if (!project2) return false;
4292
- const entry = meta[metaKey(project2, slug)];
4293
- return Boolean(entry?.hash === hash && entry.syncedAt);
4294
- }
4295
- function readPlanMeta(plansRoot) {
4296
- try {
4297
- return parseMeta((0, import_node_fs6.readFileSync)((0, import_node_path4.join)(plansRoot, ".plan-meta.json"), "utf8"));
4298
- } catch {
4299
- return null;
4300
- }
4301
- }
4302
- function readSyncQueueSlugs(plansRoot) {
4303
- let queueRaw;
4304
- try {
4305
- queueRaw = (0, import_node_fs6.readFileSync)((0, import_node_path4.join)(plansRoot, ".sync-queue.json"), "utf8");
4306
- } catch (e) {
4307
- const code = typeof e === "object" && e && "code" in e ? String(e.code ?? "") : "";
4308
- return code === "ENOENT" || code === "ENOTDIR" ? /* @__PURE__ */ new Set() : null;
4309
- }
4310
- try {
4311
- const parsed = JSON.parse(queueRaw);
4312
- const entries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];
4313
- return new Set(entries.map((e) => e.slug).filter((s) => typeof s === "string"));
4314
- } catch {
4315
- return null;
4316
- }
4317
- }
4318
- function physicalPlanCandidateStillAllowed(candidatePath, repoAnchor, lstat = import_node_fs6.lstatSync) {
4319
- const path2 = candidatePath.replace(/\\/g, "/").replace(/\/+$/, "");
4320
- const parent = (0, import_node_path4.dirname)(path2).replace(/\\/g, "/").replace(/\/+$/, "");
4321
- const name = (0, import_node_path4.basename)(path2);
4322
- const plansDir = `${repoAnchor}/plans`;
4323
- if (parent !== plansDir || !name.endsWith(".md")) return false;
4324
- try {
4325
- const st = lstat(plansDir);
4326
- return st.isDirectory() && !st.isSymbolicLink();
4327
- } catch {
4328
- return false;
4329
- }
4330
- }
4331
4221
  function formatScratchGcPlan(plan, applied, applyResult) {
4332
4222
  if (plan.candidates.length === 0) return "scratch GC: nothing to prune \u2014 local scratch is clean.";
4333
4223
  const lines = [];
@@ -4373,7 +4263,7 @@ function treeOlderThan(root, now, floor) {
4373
4263
  }
4374
4264
  return true;
4375
4265
  }
4376
- function applyScratchGc(plan, repoRoot2, now = Date.now(), project2) {
4266
+ function applyScratchGc(plan, repoRoot2, now = Date.now()) {
4377
4267
  const result = { pruned: [], skipped: 0, bytes: 0 };
4378
4268
  let repoAnchor;
4379
4269
  try {
@@ -4402,30 +4292,7 @@ function applyScratchGc(plan, repoRoot2, now = Date.now(), project2) {
4402
4292
  result.skipped += 1;
4403
4293
  continue;
4404
4294
  }
4405
- if (c.family === "plan") {
4406
- if (!c.hash || !physicalPlanCandidateStillAllowed(real, repoAnchor)) {
4407
- result.skipped += 1;
4408
- continue;
4409
- }
4410
- const plansRoot = `${repoAnchor}/plans`;
4411
- const slug = (0, import_node_path4.basename)(c.path).replace(/\.md$/, "");
4412
- const pending = readSyncQueueSlugs(plansRoot);
4413
- if (pending === null || pending.has(slug)) {
4414
- result.skipped += 1;
4415
- continue;
4416
- }
4417
- const currentHash = hashContent(normalizeEol((0, import_node_fs6.readFileSync)(c.path, "utf8")));
4418
- if (currentHash !== c.hash) {
4419
- result.skipped += 1;
4420
- continue;
4421
- }
4422
- const meta = readPlanMeta(plansRoot);
4423
- if (!meta || !syncedPlanMetaEntry(meta, project2, slug, currentHash)) {
4424
- result.skipped += 1;
4425
- continue;
4426
- }
4427
- }
4428
- const floor = c.family === "plan" ? PLAN_ADVISORY_AGE_MS : c.family === "scratch-dir" || c.family === "scratch-file" ? ROOT_SCRATCH_STALE_MS : 0;
4295
+ const floor = c.family === "scratch-dir" || c.family === "scratch-file" ? ROOT_SCRATCH_STALE_MS : 0;
4429
4296
  if (floor > 0 && now - st.mtimeMs <= floor) {
4430
4297
  result.skipped += 1;
4431
4298
  continue;
@@ -4453,7 +4320,6 @@ function rootCandidateStillAllowed(c, realPath, repoAnchor) {
4453
4320
  const name = (0, import_node_path4.basename)(path2);
4454
4321
  if (c.family === "scratch-dir") return c.kind === "dir" && parent === repoAnchor && ROOT_SCRATCH_DIRS.has(name);
4455
4322
  if (c.family === "scratch-file") return (c.kind ?? "file") === "file" && parent === repoAnchor && ROOT_SCRATCH_FILE_PREFIXES.some((prefix) => name.startsWith(prefix));
4456
- if (c.family === "plan") return physicalPlanCandidateStillAllowed(path2, repoAnchor);
4457
4323
  return false;
4458
4324
  }
4459
4325
  function trackedPathStatus(c, realPath, repoAnchor) {
@@ -4509,7 +4375,6 @@ function rootScratchDirSnapshot(root, readdir2, stat2) {
4509
4375
  function collectScratchSnapshot(repoRoot2, deps = {}) {
4510
4376
  const readdir2 = deps.readdir ?? import_node_fs6.readdirSync;
4511
4377
  const stat2 = deps.stat ?? import_node_fs6.statSync;
4512
- const readFile6 = deps.readFile ?? import_node_fs6.readFileSync;
4513
4378
  const plansRoot = (0, import_node_path4.join)(repoRoot2, "plans");
4514
4379
  const rootScratchFiles = [];
4515
4380
  try {
@@ -4540,37 +4405,13 @@ function collectScratchSnapshot(repoRoot2, deps = {}) {
4540
4405
  const full = (0, import_node_path4.join)(plansRoot, ent.name);
4541
4406
  try {
4542
4407
  const st = stat2(full);
4543
- const raw = readFile6(full, "utf8");
4544
- planMdFiles.push({ path: full, dir: plansRoot, name: ent.name, mtimeMs: st.mtimeMs, bytes: st.size, kind: "file", hash: hashContent(normalizeEol(raw)) });
4408
+ planMdFiles.push({ path: full, dir: plansRoot, name: ent.name, mtimeMs: st.mtimeMs, bytes: st.size, kind: "file" });
4545
4409
  } catch {
4546
4410
  }
4547
4411
  }
4548
4412
  } catch {
4549
4413
  }
4550
- let planMeta = {};
4551
- try {
4552
- planMeta = parseMeta(readFile6((0, import_node_path4.join)(plansRoot, ".plan-meta.json"), "utf8"));
4553
- } catch {
4554
- planMeta = {};
4555
- }
4556
- const project2 = deps.project?.trim() || void 0;
4557
- const syncQueueSlugs = (() => {
4558
- let queueRaw;
4559
- try {
4560
- queueRaw = readFile6((0, import_node_path4.join)(plansRoot, ".sync-queue.json"), "utf8");
4561
- } catch (e) {
4562
- const code = typeof e === "object" && e && "code" in e ? String(e.code ?? "") : "";
4563
- return code === "ENOENT" || code === "ENOTDIR" ? /* @__PURE__ */ new Set() : null;
4564
- }
4565
- try {
4566
- const parsed = JSON.parse(queueRaw);
4567
- const entries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];
4568
- return new Set(entries.map((e) => e.slug).filter((s) => typeof s === "string"));
4569
- } catch {
4570
- return null;
4571
- }
4572
- })();
4573
- return { repoRoot: repoRoot2, rootScratchFiles, planMdFiles, planMeta, project: project2, syncQueueSlugs };
4414
+ return { repoRoot: repoRoot2, rootScratchFiles, planMdFiles };
4574
4415
  }
4575
4416
 
4576
4417
  // src/repo-runtime-state.ts
@@ -9440,8 +9281,8 @@ var MANAGED_GITIGNORE_LINES = [
9440
9281
  "/tmp/",
9441
9282
  // Ad-hoc agent scratch at repo root (e.g. tmp_diff.txt) — same class as /tmp/ (#1676).
9442
9283
  "/tmp_*",
9443
- // Plan scratch at ANY depth (root plans/, cli/plans/, .cursor/plans/) — AI planning docs are S3-synced
9444
- // via `mmi-cli northstar push` / auto-save on write; never git-tracked (AGENTS.md "Repo cleanliness", #1550, #1842).
9284
+ // Plan scratch at ANY depth (root plans/, cli/plans/, .cursor/plans/) — AI planning docs are managed by
9285
+ // the personal `jerv` CLI's plan store; never git-tracked (AGENTS.md "Repo cleanliness", #1550, #1842).
9445
9286
  "**/plans/",
9446
9287
  // Superpowers plan/spec output is scratch too; authoritative docs live directly under docs/.
9447
9288
  "docs/superpowers/",
@@ -9526,6 +9367,117 @@ function isGateWorkflowPath(path2) {
9526
9367
  const base = path2.split(/[/\\]/).pop() ?? path2;
9527
9368
  return /^(?:.+[-_.])?gate\.ya?ml$/i.test(base);
9528
9369
  }
9370
+ function unquoteInline(value) {
9371
+ const v = value.trim();
9372
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1).replace(/\\(["\\])/g, "$1");
9373
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
9374
+ return v;
9375
+ }
9376
+ var BLOCK_SCALAR = /^[|>][+-]?\s*$/;
9377
+ function readScalar(lines, from, inlineValue, keyIndent) {
9378
+ if (!BLOCK_SCALAR.test(inlineValue)) return unquoteInline(inlineValue);
9379
+ const body = [];
9380
+ for (let k = from + 1; k < lines.length; k++) {
9381
+ if (lines[k].indent <= keyIndent) break;
9382
+ body.push(lines[k].text);
9383
+ }
9384
+ return body.join("\n");
9385
+ }
9386
+ function readWith(lines, from, inlineValue, withIndent) {
9387
+ if (inlineValue.startsWith("{")) {
9388
+ const cmd = inlineValue.match(/command:\s*("[^"]*"|'[^']*'|[^,}]+)/);
9389
+ const ms = inlineValue.match(/max-seconds:\s*("[^"]*"|'[^']*'|[^,}]+)/);
9390
+ return {
9391
+ command: cmd ? unquoteInline(cmd[1].trim()) : void 0,
9392
+ maxSeconds: ms ? unquoteInline(ms[1].trim()) : void 0
9393
+ };
9394
+ }
9395
+ const out = {};
9396
+ for (let j = from + 1; j < lines.length; j++) {
9397
+ if (lines[j].indent <= withIndent) break;
9398
+ const cm = lines[j].text.match(/^command:\s?(.*)$/);
9399
+ if (cm && out.command === void 0) {
9400
+ out.command = readScalar(lines, j, cm[1].trim(), lines[j].indent);
9401
+ continue;
9402
+ }
9403
+ const mm = lines[j].text.match(/^max-seconds:\s?(.*)$/);
9404
+ if (mm && out.maxSeconds === void 0) {
9405
+ out.maxSeconds = readScalar(lines, j, mm[1].trim(), lines[j].indent);
9406
+ continue;
9407
+ }
9408
+ }
9409
+ return out;
9410
+ }
9411
+ function parseStepLines(lines) {
9412
+ const step = {};
9413
+ const base = Math.min(...lines.map((l) => l.indent));
9414
+ for (let i = 0; i < lines.length; i++) {
9415
+ const { indent, text } = lines[i];
9416
+ if (indent !== base) continue;
9417
+ const m = text.match(/^([A-Za-z][\w-]*):\s?(.*)$/);
9418
+ if (!m) continue;
9419
+ const key = m[1];
9420
+ const val = m[2].trim();
9421
+ if (key === "uses") step.uses = unquoteInline(val);
9422
+ else if (key === "run") step.run = readScalar(lines, i, val, base);
9423
+ else if (key === "with") {
9424
+ const w = readWith(lines, i, val, base);
9425
+ step.withCommand = w.command;
9426
+ step.withMaxSeconds = w.maxSeconds;
9427
+ }
9428
+ }
9429
+ return step;
9430
+ }
9431
+ function parseGateSteps(body) {
9432
+ const lines = body.split(/\r?\n/);
9433
+ const steps = [];
9434
+ let stepsIndent = -1;
9435
+ let itemIndent = -1;
9436
+ let current = null;
9437
+ const flush2 = () => {
9438
+ if (current && current.length) steps.push(parseStepLines(current));
9439
+ current = null;
9440
+ };
9441
+ for (const raw of lines) {
9442
+ const trimmed = raw.trim();
9443
+ if (!trimmed || trimmed.startsWith("#")) continue;
9444
+ const indent = raw.match(/^(\s*)/)[1].length;
9445
+ if (stepsIndent === -1) {
9446
+ if (/^steps:\s*$/.test(trimmed)) stepsIndent = indent;
9447
+ continue;
9448
+ }
9449
+ if (indent <= stepsIndent) {
9450
+ flush2();
9451
+ itemIndent = -1;
9452
+ stepsIndent = /^steps:\s*$/.test(trimmed) ? indent : -1;
9453
+ continue;
9454
+ }
9455
+ if (/^-\s?/.test(trimmed) && (itemIndent === -1 || indent === itemIndent)) {
9456
+ itemIndent = indent;
9457
+ flush2();
9458
+ current = [{ indent: indent + 2, text: trimmed.replace(/^-\s?/, "") }];
9459
+ continue;
9460
+ }
9461
+ if (current) current.push({ indent, text: trimmed });
9462
+ }
9463
+ flush2();
9464
+ return steps;
9465
+ }
9466
+ function isNoOpCommand(command) {
9467
+ const parts = command.split(/&&|\|\||;|\r?\n/).map((p) => p.trim()).filter(Boolean);
9468
+ if (parts.length === 0) return true;
9469
+ return parts.every((p) => /^(?::$|true$|false$|exit(?:\s|$)|echo(?:\s|$)|printf(?:\s|$)|#)/.test(p));
9470
+ }
9471
+ var TEST_RUNNER_AT_START = new RegExp(
9472
+ "^(?:(?:npm|yarn|pnpm|bun)\\s+(?:run\\s+)?(?:test|t)(?:[:\\s]|$)|(?:npx|bunx|pnpm\\s+dlx)\\s+(?:vitest|jest|mocha|playwright|cypress|ava|tap)\\b|(?:vitest|jest|mocha|pytest|py\\.test|phpunit|rspec)\\b|python[0-9.]*\\s+-m\\s+(?:pytest|unittest)\\b|(?:poetry|pipenv)\\s+run\\s+(?:pytest|py\\.test|python[0-9.]*\\s+-m\\s+(?:pytest|unittest))\\b|(?:bundle\\s+exec\\s+)?rspec\\b|(?:go|cargo|dotnet|mvn|gradle|swift)\\s+test\\b|(?:rails|php\\s+artisan)\\s+test\\b|playwright\\s+test\\b)",
9473
+ "i"
9474
+ );
9475
+ function runsTestRunner(command) {
9476
+ return command.split(/&&|\|\||;|\||\r?\n/).map((sub) => sub.trim().replace(/^[!(]+\s*/, "")).some((sub) => TEST_RUNNER_AT_START.test(sub));
9477
+ }
9478
+ function firstLine(command) {
9479
+ return command.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? command.trim();
9480
+ }
9529
9481
  function checkGateBudget(files, opts = {}) {
9530
9482
  const failures = [];
9531
9483
  const notes = [];
@@ -9555,18 +9507,35 @@ function checkGateBudget(files, opts = {}) {
9555
9507
  notes.push(`${path2}: run-with-budget pinned to ${remote[1].slice(0, 12)} (blessed ${BLESSED_RUN_WITH_BUDGET_SHA.slice(0, 12)}) \u2014 valid but outdated`);
9556
9508
  }
9557
9509
  }
9558
- const ceilingLines = [...body.matchAll(/^\s*max-seconds:\s*(.+?)\s*$/gm)].map((m) => m[1].replace(/\s+#.*$/, ""));
9559
- if (ceilingLines.length === 0) {
9560
- failures.push(`${path2}: run-with-budget has no max-seconds input \u2014 the budget step guards nothing`);
9561
- continue;
9510
+ const steps = parseGateSteps(body);
9511
+ const budgetSteps = steps.filter((step) => step.uses?.includes("run-with-budget"));
9512
+ if (budgetSteps.length === 0) {
9513
+ if (![...body.matchAll(/^\s*max-seconds:\s*(.+?)\s*$/gm)].some((m) => /^['"]?\d+['"]?$/.test(m[1].replace(/\s+#.*$/, "").trim()))) {
9514
+ failures.push(`${path2}: run-with-budget has no max-seconds input \u2014 the budget step guards nothing`);
9515
+ }
9562
9516
  }
9563
- for (const raw of ceilingLines) {
9564
- const literal = /^['"]?(\d+)['"]?$/.exec(raw);
9565
- const seconds = literal ? Number(literal[1]) : NaN;
9566
- if (!literal || seconds < 1) {
9567
- failures.push(`${path2}: max-seconds '${raw}' is not a literal positive integer`);
9568
- } else if (seconds > MAX_SECONDS_CEILING) {
9569
- failures.push(`${path2}: max-seconds ${seconds} exceeds the org ceiling ${MAX_SECONDS_CEILING} \u2014 a budget that generous guards nothing; measure the gate and set a real ceiling`);
9517
+ for (const step of budgetSteps) {
9518
+ if (!step.withCommand) {
9519
+ failures.push(`${path2}: run-with-budget step has no with.command \u2014 the budget wraps nothing; put the gate's check command in it`);
9520
+ } else if (isNoOpCommand(step.withCommand)) {
9521
+ failures.push(`${path2}: run-with-budget step wraps a no-op command '${firstLine(step.withCommand)}' \u2014 a budgeted decoy leaves the real gate check unbounded (#3247)`);
9522
+ }
9523
+ const rawMs = step.withMaxSeconds?.replace(/\s+#.*$/, "").trim();
9524
+ if (!rawMs) {
9525
+ failures.push(`${path2}: run-with-budget step has no max-seconds input \u2014 the budget step guards nothing`);
9526
+ } else {
9527
+ const literal = /^['"]?(\d+)['"]?$/.exec(rawMs);
9528
+ const seconds = literal ? Number(literal[1]) : NaN;
9529
+ if (!literal || seconds < 1) {
9530
+ failures.push(`${path2}: max-seconds '${rawMs}' is not a literal positive integer`);
9531
+ } else if (seconds > MAX_SECONDS_CEILING) {
9532
+ failures.push(`${path2}: max-seconds ${seconds} exceeds the org ceiling ${MAX_SECONDS_CEILING} \u2014 a budget that generous guards nothing; measure the gate and set a real ceiling`);
9533
+ }
9534
+ }
9535
+ }
9536
+ for (const step of steps) {
9537
+ if (step.run && runsTestRunner(step.run)) {
9538
+ failures.push(`${path2}: the gate's check command runs in an unbudgeted 'run:' step ('${firstLine(step.run)}') \u2014 wrap it in the run-with-budget step so it has a wall-clock ceiling (#3247)`);
9570
9539
  }
9571
9540
  }
9572
9541
  }
@@ -10490,6 +10459,13 @@ var import_node_fs14 = require("node:fs");
10490
10459
  var import_node_path12 = require("node:path");
10491
10460
  var import_node_net = require("node:net");
10492
10461
  var import_node_util6 = require("node:util");
10462
+
10463
+ // src/rules-sync.ts
10464
+ function normalizeEol(s) {
10465
+ return s.replace(/\r\n/g, "\n");
10466
+ }
10467
+
10468
+ // src/stage-runner.ts
10493
10469
  var execFileP4 = (0, import_node_util6.promisify)(import_node_child_process7.execFile);
10494
10470
  var DOCKER_TIMEOUT_MS = 15e3;
10495
10471
  var EARLY_EXIT_GRACE_MS = 2e3;
@@ -11100,10 +11076,6 @@ async function runStage(config = {}, opts = {}) {
11100
11076
  }
11101
11077
 
11102
11078
  // src/wave-status.ts
11103
- function parseNorthStarSlugFromHead(headText) {
11104
- const m = /northstar:`([^`]+)`/.exec(headText);
11105
- return m?.[1];
11106
- }
11107
11079
  function parseNextFromHead(headText) {
11108
11080
  const m = /\*\*NEXT:\*\*\s*(.+)/.exec(headText);
11109
11081
  return m?.[1]?.trim();
@@ -11136,7 +11108,6 @@ function formatWaveStatus(rows) {
11136
11108
  lines.push(` ${tags.join(" \xB7 ")}`);
11137
11109
  if (row.pr) lines.push(` PR #${row.pr.number} (${row.pr.state})${row.pr.url ? ` ${row.pr.url}` : ""}`);
11138
11110
  if (row.stage) lines.push(` stage: port ${row.stage.port}${row.stage.url ? ` ${row.stage.url}` : ""}`);
11139
- if (row.northStarSlug) lines.push(` compass: northstar:${row.northStarSlug}`);
11140
11111
  if (row.next) lines.push(` next: ${row.next}`);
11141
11112
  }
11142
11113
  return lines.join("\n");
@@ -11162,7 +11133,6 @@ async function collectWaveStatus(deps) {
11162
11133
  primary: wt.path === primaryPath,
11163
11134
  dirty,
11164
11135
  stage: readStageSummary(wt.path),
11165
- northStarSlug: headText ? parseNorthStarSlugFromHead(headText) : void 0,
11166
11136
  next: headText ? parseNextFromHead(headText) : void 0
11167
11137
  });
11168
11138
  }
@@ -11266,6 +11236,17 @@ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
11266
11236
  for (const label of labels ?? []) args.push("--label", label);
11267
11237
  return args;
11268
11238
  }
11239
+ async function ensureLabelsExist(labels, repo, deps = {}) {
11240
+ const run = deps.run ?? execFileP2;
11241
+ for (const label of new Set(labels)) {
11242
+ const args = ["label", "create", label, "--color", "ededed"];
11243
+ if (repo) args.push("--repo", repo);
11244
+ try {
11245
+ await run("gh", args, { timeout: GH_MUTATION_TIMEOUT_MS });
11246
+ } catch {
11247
+ }
11248
+ }
11249
+ }
11269
11250
  async function bodyArgsViaFile(args, deps = {}) {
11270
11251
  const i = args.indexOf("--body");
11271
11252
  if (i === -1 || i + 1 >= args.length) return { args, cleanup: async () => {
@@ -11991,6 +11972,10 @@ function parseNpmVersion(stdout) {
11991
11972
  function staleTrainCliMessage(report, commandName) {
11992
11973
  return `running mmi-cli ${report.currentVersion} is stale against released ${report.releasedVersion}; this is recoverable \u2014 run \`npm install -g @mutmutco/cli@latest\` to update to ${report.releasedVersion} (a standalone CLI shadows a plugin-provided mmi-cli on PATH and needs no Claude restart), then rerun ${commandName} so it uses the current train path. This gate runs before any train mutation, so this invocation changed nothing; any partial train state (local merge, pushed tag) is from an earlier run and the rerun resumes it safely`;
11993
11974
  }
11975
+ function versionAutoUpdateAction(report, releasedSource) {
11976
+ if (report.ok || report.staleAgainst !== "released") return "none";
11977
+ return releasedSource === "gh" ? "npm-unreachable" : "npm";
11978
+ }
11994
11979
 
11995
11980
  // src/plugin-cache-prune.ts
11996
11981
  var PLUGIN_CACHE_KEEP = 2;
@@ -17453,8 +17438,8 @@ function scheduleRecordFromWorkflow(repo, workflowPath, yamlText) {
17453
17438
  throw new Error(`schedules lift: ${expectedId} header \`llm: ${h.llm}\` does not classify (yes / no / embeddings) \u2014 an unreadable declaration would register a row whose LLM column lies.`);
17454
17439
  }
17455
17440
  const target = header.target;
17456
- if (target !== void 0 && !/^[A-Za-z0-9_-]{1,140}$/.test(target)) {
17457
- throw new Error(`schedules lift: ${expectedId} header \`target: ${target}\` is not a bare lambda function name (allowed: A-Z a-z 0-9 _ -; max 140).`);
17441
+ if (target !== void 0 && !/^mmi-fleet-[A-Za-z0-9_-]{1,130}$/.test(target)) {
17442
+ throw new Error(`schedules lift: ${expectedId} header \`target: ${target}\` must be a bare lambda function name in the reserved fleet namespace \`mmi-fleet-*\` (allowed after the prefix: A-Z a-z 0-9 _ -; total max 140). The dispatcher can only invoke mmi-fleet-* functions.`);
17458
17443
  }
17459
17444
  return {
17460
17445
  id: expectedId,
@@ -20751,6 +20736,7 @@ function validateBatchSpecs(specs) {
20751
20736
  return { ok: errors.length === 0, errors, validated };
20752
20737
  }
20753
20738
  async function createIssuesBatch(specs, options, deps = {}) {
20739
+ const ensureLabels = deps.ensureLabels ?? ensureLabelsExist;
20754
20740
  const client = deps.client ?? defaultGitHubClient();
20755
20741
  const validation = validateBatchSpecs(specs);
20756
20742
  if (!validation.ok) {
@@ -20763,6 +20749,14 @@ ${lines}`);
20763
20749
  throw new Error("could not resolve repo \u2014 pass --repo owner/repo or set repo per row");
20764
20750
  }
20765
20751
  const rowRepo = (spec) => spec.repo ?? defaultRepo;
20752
+ const labelsByRepo = /* @__PURE__ */ new Map();
20753
+ for (const { spec } of validation.validated) {
20754
+ if (!spec.labels?.length) continue;
20755
+ const bucket = labelsByRepo.get(rowRepo(spec)) ?? /* @__PURE__ */ new Set();
20756
+ for (const label of spec.labels) bucket.add(label);
20757
+ labelsByRepo.set(rowRepo(spec), bucket);
20758
+ }
20759
+ for (const [repo, labels] of labelsByRepo) await ensureLabels([...labels], repo);
20766
20760
  const created = [];
20767
20761
  const failures = [];
20768
20762
  for (const entry of validation.validated) {
@@ -21170,6 +21164,15 @@ async function fetchNpmReleasedVersion() {
21170
21164
  return void 0;
21171
21165
  }
21172
21166
  }
21167
+ var NPM_INSTALL_TIMEOUT_MS = 12e4;
21168
+ async function npmSelfUpdateCli() {
21169
+ try {
21170
+ await runHostBin("npm", ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_INSTALL_TIMEOUT_MS });
21171
+ return { ok: true, detail: "npm install -g @mutmutco/cli@latest exited 0" };
21172
+ } catch (e) {
21173
+ return { ok: false, detail: e.message.trim().slice(0, 200).replace(/\s+/g, " ") };
21174
+ }
21175
+ }
21173
21176
  function snapshotPluginGuardInput(surface = detectSurface(process.env), isOrgRepo = false) {
21174
21177
  const homeDir = surface === "codex" ? ".codex" : ".claude";
21175
21178
  const installed = readInstalledPlugins(surface);
@@ -22597,7 +22600,25 @@ async function runDoctorClean(opts, io, deps) {
22597
22600
  const plugin = checkClaudePlugin({ installed, released, guardState: deps.pluginGuardState(isOrgRepo) });
22598
22601
  checks.push(plugin);
22599
22602
  if (!plugin.ok) restartPending = true;
22600
- checks.push(checkCliVersion({ currentVersion: deps.currentCliVersion(), releasedVersion: released }));
22603
+ const cliInput = { currentVersion: deps.currentCliVersion(), releasedVersion: released };
22604
+ const cliReport = buildVersionLagReport(cliInput);
22605
+ if (apply && deps.updateCli && versionAutoUpdateAction(cliReport) === "npm") {
22606
+ const heal = await deps.updateCli();
22607
+ const healEvidence = [`running: ${cliReport.currentVersion}`, `published: ${cliReport.releasedVersion}`, `heal: ${heal.detail}`];
22608
+ checks.push(heal.ok ? {
22609
+ ok: true,
22610
+ label: `mmi-cli ${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
22611
+ detail: "self-updated via npm install -g; the next mmi-cli invocation runs the new version",
22612
+ verbose: healEvidence
22613
+ } : {
22614
+ ok: false,
22615
+ label: `mmi-cli ${cliReport.currentVersion} \u2192 ${cliReport.releasedVersion}`,
22616
+ fix: `self-update failed (${heal.detail}) \u2014 run \`npm install -g @mutmutco/cli@latest\``,
22617
+ verbose: healEvidence
22618
+ });
22619
+ } else {
22620
+ checks.push(checkCliVersion(cliInput));
22621
+ }
22601
22622
  checks.push(checkPluginCache(deps.pluginCache()));
22602
22623
  if (!opts.fast && !opts.banner && !opts.preflight) {
22603
22624
  const probe = await deps.schedulesNotebook().catch((e) => ({
@@ -22803,6 +22824,8 @@ function mmiDoctorDeps() {
22803
22824
  installedPluginVersion: installedClaudePluginVersion,
22804
22825
  pluginGuardState: claudePluginGuardState,
22805
22826
  releasedVersion: fetchNpmReleasedVersion,
22827
+ // #3272: the --apply self-heal for a stale running CLI — npm global, shadows any plugin shim (#2879).
22828
+ updateCli: npmSelfUpdateCli,
22806
22829
  currentCliVersion: resolveClientVersion,
22807
22830
  readGitignore,
22808
22831
  writeGitignore,
@@ -23068,7 +23091,7 @@ gcCmd.command("sweep-deferred").description("retry IDE-locked deferred worktree
23068
23091
  process.exit(process.exitCode ?? 0);
23069
23092
  });
23070
23093
  });
23071
- gcCmd.option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR local+remote branches, linked worktrees, and stale tracking refs").option("--json", "machine-readable output").option("--scratch", "prune safe local scratch and plans whose current hash is confirmed synced to North Star (#1864) instead of git branches/refs").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").action(async (o) => {
23094
+ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR local+remote branches, linked worktrees, and stale tracking refs").option("--json", "machine-readable output").option("--scratch", "prune safe local scratch (#1864) instead of git branches/refs; local plans are surfaced advisory-only, never auto-pruned").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").action(async (o) => {
23072
23095
  if (o.apply && o.dryRun) return fail("worktree gc: choose either --dry-run or --apply");
23073
23096
  if (o.scratch) {
23074
23097
  try {
@@ -23888,6 +23911,7 @@ withExamples(mutating(
23888
23911
  let title;
23889
23912
  let issueType;
23890
23913
  let extraLabels = [];
23914
+ let targetRepo2;
23891
23915
  try {
23892
23916
  issueType = resolveCreateType(o.type, "issue create");
23893
23917
  title = await resolveIssueTitle({ title: o.title, titleFile: o.titleFile }, { readFile: import_promises7.readFile, readStdin });
@@ -23895,33 +23919,30 @@ withExamples(mutating(
23895
23919
  if (o.idempotencyKey) body = appendIdempotencyMarker(body, o.idempotencyKey);
23896
23920
  priority = resolveCreatePriority(o.priority, "issue create");
23897
23921
  extraLabels = [...o.label ?? []];
23922
+ targetRepo2 = await resolveRepo(o.repo);
23923
+ if (!targetRepo2) {
23924
+ return fail("issue create: could not resolve the target repo \u2014 run inside a git checkout or pass --repo <owner/repo>");
23925
+ }
23898
23926
  args = buildIssueArgs({
23899
23927
  type: issueType,
23900
23928
  title,
23901
23929
  body,
23902
23930
  priority,
23903
- repo: o.repo,
23931
+ repo: targetRepo2,
23904
23932
  labels: extraLabels.length ? extraLabels : void 0
23905
23933
  });
23906
23934
  if (o.parent !== void 0) parseIssueRef(o.parent);
23907
23935
  } catch (e) {
23908
23936
  return fail(`issue create: ${e.message}`, e instanceof TextArgError ? { code: e.code, offending_flag: e.offendingFlag } : void 0);
23909
23937
  }
23910
- for (const label of extraLabels) {
23911
- const la = ["label", "create", label, "--color", "ededed"];
23912
- if (o.repo) la.push("--repo", o.repo);
23913
- try {
23914
- await execFileP2("gh", la, { timeout: GH_MUTATION_TIMEOUT_MS });
23915
- } catch {
23916
- }
23917
- }
23938
+ await ensureLabelsExist(extraLabels, targetRepo2);
23918
23939
  const created = await ghCreate(args);
23919
- const { projectItemId, onBoard } = await attachToProject(created.number, o.repo, priority);
23940
+ const { projectItemId, onBoard } = await attachToProject(created.number, targetRepo2, priority);
23920
23941
  let parent;
23921
23942
  let parentLinkError;
23922
23943
  if (o.parent !== void 0) {
23923
23944
  try {
23924
- parent = await linkSubIssue(ghRunner2, o.parent, created.url, o.repo);
23945
+ parent = await linkSubIssue(ghRunner2, o.parent, created.url, targetRepo2);
23925
23946
  } catch (e) {
23926
23947
  const err = e;
23927
23948
  parentLinkError = (err.stderr || err.message || String(e)).trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.36.1",
3
+ "version": "3.38.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",