@moda-ai/cli 1.28.1 → 1.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -385,9 +385,16 @@ var STEP_DELAY_MS = 2000;
385
385
  var MAX_WAIT_TIMEOUT_MS = 24 * 60 * 60 * 1000;
386
386
  var MAX_POLL_INTERVAL_MS = 120000;
387
387
  var PER_CASE_FINDINGS_CAP = 50;
388
+ var DRIVE_PASS_INTERVAL_MS = 20000;
389
+ var DRIVE_LIST_LIMIT = 100;
390
+ var DRIVE_LIST_MAX_PAGES = 10;
391
+ var DRAFT_BATCH_LIMIT_MAX = 25;
392
+ var DRAFT_BATCH_TIMEOUT_MS = 300000;
388
393
  var ADVANCING_STATUSES = new Set(["DRAFT", "SCOPING", "PROPOSING", "PROPOSED", "GATING"]);
389
394
  var FIX_USAGE = [
390
395
  "Usage: moda fixes [--status=S] [--limit=N] [--cursor=TOKEN]",
396
+ " moda fixes draft-batch [--limit=N] (N is 1-25, default 10)",
397
+ " moda fixes drive [--status=S] [--timeout=ms] [--pass-interval=ms]",
391
398
  " moda fix <fix_id> [--packet] [--wait]",
392
399
  " moda fix start <problem_id> [--type=auto|prompt] [--wait] [--timeout=ms]",
393
400
  " moda fix verify <fix_id> [--prompt-file=PATH] [--no-wait] [--timeout=ms]",
@@ -401,6 +408,16 @@ async function runFixesCommand(context) {
401
408
  validateConfig();
402
409
  const profileOptions = profileOptionsFor(context);
403
410
  const tenantId = requireTenantId(context.flags, profileOptions);
411
+ switch (context.positional) {
412
+ case "draft-batch":
413
+ return runFixesDraftBatch(context, profileOptions, tenantId);
414
+ case "drive":
415
+ return runFixesDrive(context, profileOptions, tenantId);
416
+ case undefined:
417
+ break;
418
+ default:
419
+ throw new CliInputError(`Unknown fixes subcommand '${context.positional}'`, FIX_USAGE);
420
+ }
404
421
  const query = new URLSearchParams;
405
422
  if (context.flags.status)
406
423
  query.set("status", context.flags.status);
@@ -440,6 +457,251 @@ async function runFixesCommand(context) {
440
457
  });
441
458
  return EXIT_OK;
442
459
  }
460
+ async function runFixesDraftBatch(context, profileOptions, tenantId) {
461
+ const usage = "Usage: moda fixes draft-batch [--limit=N] (N is 1-25, default 10)";
462
+ let limit;
463
+ if (context.flags.limit) {
464
+ const parsed = Number(context.flags.limit);
465
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > DRAFT_BATCH_LIMIT_MAX) {
466
+ throw new CliInputError(`--limit must be an integer between 1 and ${DRAFT_BATCH_LIMIT_MAX} (got ${context.flags.limit}) — batches cap at ${DRAFT_BATCH_LIMIT_MAX} (LLM spend guard)`, usage);
467
+ }
468
+ limit = parsed;
469
+ }
470
+ const result = await callControlAPI(`${fixesBase(tenantId)}/draft-batch`, { method: "POST", body: JSON.stringify(limit !== undefined ? { limit } : {}) }, profileOptions, { timeoutMs: DRAFT_BATCH_TIMEOUT_MS, retries: 0 });
471
+ const drafted = result.drafted ?? [];
472
+ const skipped = result.skipped ?? [];
473
+ if (context.outputMode === "human") {
474
+ const w = (line) => process.stderr.write(`${line}
475
+ `);
476
+ w(`Drafted ${drafted.length} fix(es) (batch cap ${DRAFT_BATCH_LIMIT_MAX} — LLM spend guard):`);
477
+ for (const fix of drafted) {
478
+ w(` MODA-FIX-${fix.shortRef} ${fix.status} problem ${fix.problemId}`);
479
+ }
480
+ if (skipped.length > 0) {
481
+ w(`Skipped ${skipped.length} problem(s):`);
482
+ for (const skip of skipped) {
483
+ w(` ${skip.problemId}: ${skip.reason}`);
484
+ }
485
+ }
486
+ if (drafted.length > 0) {
487
+ w("Next: moda fixes drive");
488
+ }
489
+ }
490
+ context.output.writeData({
491
+ drafted: drafted.map((fix) => ({
492
+ fixId: fix.id,
493
+ shortRef: fix.shortRef,
494
+ problemId: fix.problemId,
495
+ status: fix.status,
496
+ problemRank: fix.problemRank ?? null
497
+ })),
498
+ skipped
499
+ }, {
500
+ summary: {
501
+ text: `Drafted ${drafted.length} fix(es) for the top-ranked problems; skipped ${skipped.length} (reasons in data.skipped). Batches cap at ${DRAFT_BATCH_LIMIT_MAX} — an LLM spend guard; run another batch after this one lands.`,
502
+ confidence: "high"
503
+ },
504
+ nextCommands: drafted.length > 0 ? [
505
+ {
506
+ command: "moda fixes drive",
507
+ purpose: "Round-robin advance every drafted fix (scope → propose → gate) until all rest.",
508
+ mutability: "write",
509
+ requires_approval: false
510
+ }
511
+ ] : []
512
+ });
513
+ return EXIT_OK;
514
+ }
515
+ async function runFixesDrive(context, profileOptions, tenantId) {
516
+ const timeoutMs = parsePositiveInt(context.flags.timeout, DEFAULT_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS);
517
+ const passIntervalMs = parsePositiveInt(context.flags["pass-interval"], DRIVE_PASS_INTERVAL_MS, MAX_POLL_INTERVAL_MS);
518
+ const deadline = Date.now() + timeoutMs;
519
+ const statusFilter = (context.flags.status ?? "").trim().toUpperCase();
520
+ if (statusFilter && !ADVANCING_STATUSES.has(statusFilter)) {
521
+ throw new CliInputError(`--status must be one of the advancing statuses (${[...ADVANCING_STATUSES].join(", ")}) — a resting fix has nothing to drive (got ${context.flags.status})`, FIX_USAGE);
522
+ }
523
+ const statusParam = statusFilter ? `&status=${encodeURIComponent(statusFilter)}` : "";
524
+ const snapshot = [];
525
+ const snapshotWarnings = [];
526
+ let coverageTruncated = false;
527
+ const seenIds = new Set;
528
+ let reorderDetected = false;
529
+ let multiPage = false;
530
+ let scanTruncated = false;
531
+ let cursor = null;
532
+ for (let page = 0;page < DRIVE_LIST_MAX_PAGES; page++) {
533
+ const data = await callControlAPI(`${fixesBase(tenantId)}?limit=${DRIVE_LIST_LIMIT}${statusParam}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""), { method: "GET" }, profileOptions);
534
+ for (const fix of data.fixes ?? []) {
535
+ if (seenIds.has(fix.id)) {
536
+ if (!reorderDetected) {
537
+ reorderDetected = true;
538
+ coverageTruncated = true;
539
+ snapshotWarnings.push("The fix queue was re-ranked while paging (a page repeated a fix), so rows may have shifted past the page boundary and been skipped — coverage is not guaranteed. Re-run `moda fixes drive` to pick up anything missed.");
540
+ }
541
+ continue;
542
+ }
543
+ seenIds.add(fix.id);
544
+ snapshot.push(fix);
545
+ }
546
+ if (data.pagination?.scan_truncated) {
547
+ coverageTruncated = true;
548
+ scanTruncated = true;
549
+ snapshotWarnings.push(statusFilter ? `Status ${statusFilter} alone exceeds the backend list scan window — driving the newest ${snapshot.length}; older ${statusFilter} fixes are not reachable through drive, advance them directly with \`moda fix <id> --wait\`.` : "Fix queue exceeds the backend list scan window — driving the newest fixes only. Re-running unfiltered rescans the same rows; drive each advancing status separately (`moda fixes drive --status SCOPING`, `--status PROPOSED`, …) — a status filter narrows the server-side scan window and reaches older fixes.");
550
+ }
551
+ cursor = data.pagination?.has_more ? data.pagination?.next_cursor ?? null : null;
552
+ if (!cursor)
553
+ break;
554
+ if (!multiPage) {
555
+ multiPage = true;
556
+ coverageTruncated = true;
557
+ snapshotWarnings.push("The queue needed more than one page, and the backend re-ranks it per request — rows can shift across a page boundary and be skipped without any visible duplicate, so this drive cannot prove it covered every advancing fix. Re-run `moda fixes drive` until it exits 0 (single page = provably complete).");
558
+ }
559
+ if (page === DRIVE_LIST_MAX_PAGES - 1) {
560
+ coverageTruncated = true;
561
+ scanTruncated = true;
562
+ snapshotWarnings.push(`Fix queue exceeds ${DRIVE_LIST_LIMIT * DRIVE_LIST_MAX_PAGES} rows — driving the first ${snapshot.length}; narrow with \`moda fixes drive --status <ADVANCING_STATUS>\` to reach the rest (a plain re-run pages the same rank order).`);
563
+ }
564
+ }
565
+ const tracked = new Map;
566
+ for (const fix of snapshot) {
567
+ if (ADVANCING_STATUSES.has(fix.status) && !isRestingProposed(fix)) {
568
+ tracked.set(fix.id, fix);
569
+ }
570
+ }
571
+ if (tracked.size === 0) {
572
+ context.output.writeData({
573
+ fixes: [],
574
+ passes: 0,
575
+ rested: 0,
576
+ stragglers: [],
577
+ timedOut: false,
578
+ coverageTruncated
579
+ }, {
580
+ ...coverageTruncated ? { status: "degraded" } : {},
581
+ summary: {
582
+ text: coverageTruncated ? "Nothing advancing inside the scanned window, but the queue scan was truncated — fixes outside it may still be advancing (see warnings)." : "No fixes in an advancing status — nothing to drive.",
583
+ confidence: coverageTruncated ? "medium" : "high"
584
+ },
585
+ ...snapshotWarnings.length > 0 ? { warnings: snapshotWarnings } : {},
586
+ nextCommands: [
587
+ {
588
+ command: "moda fixes draft-batch",
589
+ purpose: "Batch-draft fixes for the top-ranked problems (cap 25 — LLM spend guard).",
590
+ mutability: "write",
591
+ requires_approval: true
592
+ }
593
+ ]
594
+ });
595
+ return coverageTruncated ? EXIT_DEGRADED : EXIT_OK;
596
+ }
597
+ const isRested = (fix) => !ADVANCING_STATUSES.has(fix.status) || isRestingProposed(fix);
598
+ const activeIds = () => [...tracked.values()].filter((fix) => !isRested(fix)).map((fix) => fix.id);
599
+ const lastAnnounced = new Map;
600
+ const announce = (row) => {
601
+ if (lastAnnounced.get(row.id) === row.status)
602
+ return;
603
+ lastAnnounced.set(row.id, row.status);
604
+ announceFixTransition(context, row);
605
+ };
606
+ for (const fix of tracked.values())
607
+ announce(fix);
608
+ const errors = new Map;
609
+ let errorCount = 0;
610
+ let passes = 0;
611
+ let timedOut = false;
612
+ while (activeIds().length > 0) {
613
+ if (Date.now() >= deadline) {
614
+ timedOut = true;
615
+ break;
616
+ }
617
+ passes += 1;
618
+ for (const fixId of activeIds()) {
619
+ if (Date.now() >= deadline) {
620
+ timedOut = true;
621
+ break;
622
+ }
623
+ try {
624
+ const next = await advanceFix(tenantId, fixId, profileOptions);
625
+ tracked.set(fixId, next);
626
+ errors.delete(fixId);
627
+ announce(next);
628
+ } catch (err) {
629
+ errorCount += 1;
630
+ const message = err instanceof Error ? err.message : String(err);
631
+ errors.set(fixId, message);
632
+ if (context.outputMode === "human") {
633
+ const known = tracked.get(fixId);
634
+ process.stderr.write(`fix MODA-FIX-${known?.shortRef ?? fixId}: advance failed — ${message}
635
+ `);
636
+ }
637
+ }
638
+ }
639
+ if (timedOut || activeIds().length === 0)
640
+ break;
641
+ await sleep(passIntervalMs);
642
+ }
643
+ const landings = [...tracked.values()].map((fix) => ({
644
+ shortRef: fix.shortRef,
645
+ status: fix.status,
646
+ fixType: fix.fixType,
647
+ statusReason: fix.statusReason ?? null
648
+ }));
649
+ const stragglers = [...tracked.values()].filter((fix) => !isRested(fix));
650
+ const warnings = [
651
+ ...snapshotWarnings,
652
+ ...[...errors.entries()].map(([fixId, message]) => {
653
+ const fix = tracked.get(fixId);
654
+ return `advance failed for MODA-FIX-${fix?.shortRef ?? fixId}: ${message}`;
655
+ })
656
+ ];
657
+ if (errorCount > errors.size) {
658
+ warnings.push(`${errorCount} advance error(s) total across ${passes} pass(es) — each was retried on the next pass.`);
659
+ }
660
+ const degraded = timedOut || coverageTruncated;
661
+ const restedCount = tracked.size - stragglers.length;
662
+ const coverageNote = coverageTruncated ? " Coverage was incomplete: the queue scan was truncated, so fixes outside the scanned window were never driven (see warnings)." : "";
663
+ if (context.outputMode === "human") {
664
+ process.stderr.write(timedOut ? `
665
+ Timed out after ${timeoutMs}ms with ${stragglers.length} of ${tracked.size} fix(es) still advancing.${coverageNote}
666
+ ` : `
667
+ ${coverageTruncated ? `Rested all ${tracked.size} scanned fix(es)` : `All ${tracked.size} fix(es) rested`} after ${passes} pass(es).${coverageNote}
668
+ `);
669
+ }
670
+ context.output.writeData({
671
+ fixes: landings,
672
+ passes,
673
+ rested: restedCount,
674
+ stragglers: stragglers.map((fix) => fix.shortRef),
675
+ timedOut,
676
+ coverageTruncated
677
+ }, {
678
+ ...degraded ? { status: "degraded" } : {},
679
+ summary: {
680
+ text: timedOut ? `Drive timed out after ${timeoutMs}ms: ${stragglers.length} of ${tracked.size} fix(es) still advancing (${stragglers.map((fix) => `MODA-FIX-${fix.shortRef}`).join(", ")}). Re-run \`moda fixes drive\` to resume.${coverageNote}` : coverageTruncated ? `Rested all ${tracked.size} scanned fix(es) after ${passes} pass(es), but the queue was NOT swept.${coverageNote}` : `All ${tracked.size} fix(es) rested after ${passes} pass(es).`,
681
+ confidence: "high"
682
+ },
683
+ ...warnings.length > 0 ? { warnings } : {},
684
+ nextCommands: [
685
+ timedOut ? {
686
+ command: "moda fixes drive",
687
+ purpose: "Resume driving the stragglers — advance-on-poll picks up where it left off.",
688
+ mutability: "write",
689
+ requires_approval: false
690
+ } : coverageTruncated ? {
691
+ command: !scanTruncated ? "moda fixes drive" : statusFilter ? "moda fix <fix_id> --wait" : "moda fixes drive --status=SCOPING",
692
+ purpose: !scanTruncated ? "The queue was re-ranked mid-page — re-run to pick up any fix that shifted past the boundary." : statusFilter ? `Status ${statusFilter} overflows the scan window — advance the older fixes individually.` : "Drive each advancing status separately to reach fixes outside the unfiltered scan window.",
693
+ mutability: "write",
694
+ requires_approval: false
695
+ } : {
696
+ command: "moda fixes",
697
+ purpose: "Review where every fix landed (VERIFIED fixes are ready to deliver).",
698
+ mutability: "read",
699
+ requires_approval: false
700
+ }
701
+ ]
702
+ });
703
+ return degraded ? EXIT_DEGRADED : EXIT_OK;
704
+ }
443
705
  async function runFixCommand(context) {
444
706
  validateConfig();
445
707
  const profileOptions = profileOptionsFor(context);
@@ -514,14 +776,15 @@ async function runFixStart(context, profileOptions, tenantId) {
514
776
  context
515
777
  });
516
778
  const packet = await fetchPacketFailSoft(tenantId, created.id, profileOptions);
779
+ const fidelityCopy = fixVerifiedFidelityCopy(fix);
517
780
  if (context.outputMode === "human") {
518
781
  process.stderr.write(`
519
- Fix MODA-FIX-${fix.shortRef} rested at ${fix.status}${fix.statusReason ? ` — ${fix.statusReason}` : ""}
782
+ Fix MODA-FIX-${fix.shortRef} rested at ${fix.status}${fidelityCopy ? ` — ${fidelityCopy}` : ""}${fix.statusReason ? ` — ${fix.statusReason}` : ""}
520
783
  `);
521
784
  }
522
785
  context.output.writeData({ fix, ...packet ? { packet } : {} }, {
523
786
  summary: {
524
- text: `Fix MODA-FIX-${fix.shortRef} reached ${fix.status}${fix.statusReason ? ` (${fix.statusReason})` : ""}.`,
787
+ text: `Fix MODA-FIX-${fix.shortRef} reached ${fix.status}${fix.statusReason ? ` (${fix.statusReason})` : ""}.${fidelityCopy ? ` ${fidelityCopy}.` : ""}`,
525
788
  confidence: "high"
526
789
  },
527
790
  findings: gateFindings(fix),
@@ -624,6 +887,7 @@ async function runFixVerify(context, profileOptions, tenantId) {
624
887
  severity: baseline === "pass" && candidate === "fail" ? "error" : candidate === "fail" ? "warning" : "info"
625
888
  });
626
889
  }
890
+ const fidelityCopy = verifiedFidelityCopy(gateResult);
627
891
  context.output.writeData({
628
892
  fixId: fix.id,
629
893
  shortRef: fix.shortRef,
@@ -631,11 +895,12 @@ async function runFixVerify(context, profileOptions, tenantId) {
631
895
  gateRunId: enqueue.gateRunId,
632
896
  verdict: verdict || null,
633
897
  gateResult,
898
+ ...gateResult?.baselineFidelity ? { baselineFidelity: gateResult.baselineFidelity } : {},
634
899
  ...cases.length > 0 ? { cases } : {}
635
900
  }, {
636
901
  status: envelopeStatus,
637
902
  summary: {
638
- text: verdict === "pass" ? `Gate passed on the frozen holdout: ${gateResult?.reason ?? ""}`.trim() : verdict === "fail" ? `Gate failed: ${gateResult?.reason ?? fix.statusReason ?? "candidate did not beat baseline"}` : `Gate degraded (${fix.status}): ${gateResult?.reason ?? fix.statusReason ?? "no verdict"}`,
903
+ text: verdict === "pass" ? `${fidelityCopy ? `${fidelityCopy}. ` : ""}Gate passed on the frozen holdout: ${gateResult?.reason ?? ""}`.trim() : verdict === "fail" ? `Gate failed: ${gateResult?.reason ?? fix.statusReason ?? "candidate did not beat baseline"}` : `Gate degraded (${fix.status}): ${gateResult?.reason ?? fix.statusReason ?? "no verdict"}`,
639
904
  confidence: "high"
640
905
  },
641
906
  findings,
@@ -845,13 +1110,14 @@ async function runFixDetail(context, profileOptions, tenantId, fixId) {
845
1110
  context
846
1111
  });
847
1112
  }
1113
+ const fidelityCopy = fixVerifiedFidelityCopy(fix);
848
1114
  if (context.outputMode === "human") {
849
- process.stderr.write(`MODA-FIX-${fix.shortRef} ${fix.status} ${fix.fixType}${fix.statusReason ? ` — ${fix.statusReason}` : ""}
1115
+ process.stderr.write(`MODA-FIX-${fix.shortRef} ${fix.status} ${fix.fixType}${fidelityCopy ? ` — ${fidelityCopy}` : ""}${fix.statusReason ? ` — ${fix.statusReason}` : ""}
850
1116
  `);
851
1117
  }
852
1118
  context.output.writeData(fix, {
853
1119
  summary: {
854
- text: `Fix MODA-FIX-${fix.shortRef} is ${fix.status}${fix.statusReason ? ` (${fix.statusReason})` : ""}.`,
1120
+ text: `Fix MODA-FIX-${fix.shortRef} is ${fix.status}${fix.statusReason ? ` (${fix.statusReason})` : ""}.${fidelityCopy ? ` ${fidelityCopy}.` : ""}`,
855
1121
  confidence: "high"
856
1122
  },
857
1123
  findings: gateFindings(fix),
@@ -867,18 +1133,7 @@ async function driveAdvance(args) {
867
1133
  if (row.status === lastStatus)
868
1134
  return;
869
1135
  lastStatus = row.status;
870
- const line = `fix MODA-FIX-${row.shortRef}: ${row.status}${row.statusReason ? ` — ${row.statusReason}` : ""}`;
871
- if (args.context.outputMode === "human") {
872
- process.stderr.write(`${line}
873
- `);
874
- } else if (args.context.outputMode === "agent-stream") {
875
- args.context.output.writeEvent({
876
- event: "progress",
877
- phase: "fix",
878
- message: line,
879
- elapsed_ms: Date.now() - args.context.startedAt
880
- });
881
- }
1136
+ announceFixTransition(args.context, row);
882
1137
  };
883
1138
  announce(fix);
884
1139
  while (ADVANCING_STATUSES.has(fix.status)) {
@@ -889,7 +1144,7 @@ async function driveAdvance(args) {
889
1144
  announce(fix);
890
1145
  if (!ADVANCING_STATUSES.has(fix.status))
891
1146
  break;
892
- if (fix.status === "PROPOSED" && /gate (pending|unavailable)/i.test(fix.statusReason ?? "")) {
1147
+ if (isRestingProposed(fix)) {
893
1148
  break;
894
1149
  }
895
1150
  await sleep(fix.status === "GATING" ? args.pollIntervalMs : Math.min(STEP_DELAY_MS, args.pollIntervalMs));
@@ -922,6 +1177,23 @@ async function fetchPacketFailSoft(tenantId, fixId, profileOptions) {
922
1177
  return null;
923
1178
  }
924
1179
  }
1180
+ function isRestingProposed(fix) {
1181
+ return fix.status === "PROPOSED" && /gate (pending|unavailable)/i.test(fix.statusReason ?? "");
1182
+ }
1183
+ function announceFixTransition(context, row) {
1184
+ const line = `fix MODA-FIX-${row.shortRef}: ${row.status}${row.statusReason ? ` — ${row.statusReason}` : ""}`;
1185
+ if (context.outputMode === "human") {
1186
+ process.stderr.write(`${line}
1187
+ `);
1188
+ } else if (context.outputMode === "agent-stream") {
1189
+ context.output.writeEvent({
1190
+ event: "progress",
1191
+ phase: "fix",
1192
+ message: line,
1193
+ elapsed_ms: Date.now() - context.startedAt
1194
+ });
1195
+ }
1196
+ }
925
1197
  function isArtifactFix(fix) {
926
1198
  return fix.fixType === "TOOL_SCHEMA" || fix.fixType === "SKILL";
927
1199
  }
@@ -1058,18 +1330,36 @@ function materializePacketCandidates(packet, fixId, human) {
1058
1330
  summarySuffix: summaryParts.length > 0 ? ` ${summaryParts.join(" ")}` : ""
1059
1331
  };
1060
1332
  }
1333
+ function baselineFidelityDescriptor(gate) {
1334
+ const fidelity = gate?.baselineFidelity;
1335
+ const base = fidelity?.prompt === "tenant" ? "tenant prompt baseline" : fidelity?.prompt === "generic" ? "standard-agent baseline" : null;
1336
+ if (fidelity?.skills === "observed") {
1337
+ return `${base ?? "baseline"} · skill recovered from production receipts`;
1338
+ }
1339
+ return base;
1340
+ }
1341
+ function verifiedFidelityCopy(gate) {
1342
+ const descriptor = baselineFidelityDescriptor(gate);
1343
+ return descriptor ? `Verified — ${descriptor}` : null;
1344
+ }
1345
+ function fixVerifiedFidelityCopy(fix) {
1346
+ if (fix.status !== "VERIFIED")
1347
+ return null;
1348
+ return verifiedFidelityCopy(fix.gateResult ?? null);
1349
+ }
1061
1350
  function gateFindings(fix) {
1062
1351
  const gateResult = fix.gateResult ?? null;
1063
- if (!gateResult)
1352
+ if (!gateResult || gateResult.pending === true)
1064
1353
  return [];
1065
1354
  const verdict = gateResult.verdict ?? fix.status;
1355
+ const fidelity = baselineFidelityDescriptor(gateResult);
1066
1356
  return [
1067
1357
  {
1068
1358
  id: "fix:gate",
1069
1359
  title: `gate ${verdict}: ${gateResult.reason ?? fix.statusReason ?? ""}`.trim(),
1070
1360
  status: verdict,
1071
1361
  severity: verdict === "pass" ? "info" : verdict === "fail" ? "error" : "warning",
1072
- description: formatGateScoreboard(gateResult)
1362
+ description: `${formatGateScoreboard(gateResult)}${fidelity ? `; baseline fidelity: ${fidelity}` : ""}`
1073
1363
  }
1074
1364
  ];
1075
1365
  }
@@ -1087,6 +1377,10 @@ function printHumanGateVerdict(fix, gateRunId, gateResult, cases) {
1087
1377
  w(`Fix: MODA-FIX-${fix.shortRef} (${fix.status})`);
1088
1378
  w(`Gate run: ${gateRunId}`);
1089
1379
  w(`Verdict: ${gateResult?.verdict ?? "none"}${gateResult?.reason ? ` — ${gateResult.reason}` : ""}`);
1380
+ const fidelity = baselineFidelityDescriptor(gateResult);
1381
+ if (fidelity) {
1382
+ w(`Baseline: ${fidelity}`);
1383
+ }
1090
1384
  if (gateResult) {
1091
1385
  w(`Score: ${formatGateScoreboard(gateResult)}`);
1092
1386
  }
@@ -4811,7 +5105,7 @@ function buildManifest(commands) {
4811
5105
  exit_codes: [
4812
5106
  { code: 0, name: "ok", meaning: "Command completed successfully." },
4813
5107
  { code: 1, name: "error", meaning: "Command, input, API, network, or unexpected failure." },
4814
- { code: 3, name: "degraded", meaning: "Degraded result: `moda ask` local fallback, or `moda fix verify` gate inconclusive/blocked-coverage (never treat as a pass)." },
5108
+ { code: 3, name: "degraded", meaning: "Degraded result: `moda ask` local fallback, `moda fix verify` gate inconclusive/blocked-coverage (never treat as a pass), or `moda fixes drive` timeout with fixes still advancing." },
4815
5109
  { code: 4, name: "auth_required", meaning: "Run login_command, then resume." },
4816
5110
  { code: 5, name: "input_required", meaning: "Choose from input_request choices, then resume." },
4817
5111
  { code: 130, name: "cancelled", meaning: "Interrupted or output consumer closed." }
@@ -4882,7 +5176,7 @@ function event(name, when, terminal, deprecated = false) {
4882
5176
  function exitCodesForCommand(command) {
4883
5177
  if (command === "ask")
4884
5178
  return [0, 1, 3, 4, 5, 130];
4885
- if (command === "fix")
5179
+ if (command === "fix" || command === "fixes")
4886
5180
  return [0, 1, 3, 4, 130];
4887
5181
  if (command === "init" || command === "harness")
4888
5182
  return [0, 1, 4, 5, 130];
@@ -5055,6 +5349,12 @@ Commands:
5055
5349
 
5056
5350
  Fixes (a fix with proof):
5057
5351
  fixes Ranked Fix queue (--status=S --limit=N --cursor=TOKEN)
5352
+ fixes draft-batch Campaign mode: batch-draft fixes for the top-ranked problems
5353
+ (--limit=N, 1-25, default 10 — batches cap at 25: LLM spend guard)
5354
+ fixes drive Round-robin one advance per active fix per pass until all rest
5355
+ (--status=S narrows the server-side scan window to one advancing
5356
+ status, --timeout=ms default 2h, --pass-interval=ms default 20s;
5357
+ exit 3 when the timeout lapses with fixes still advancing)
5058
5358
  fix <fix_id> One Fix: status + gate result; --packet prints moda.fix_packet.v1
5059
5359
  (tool/skill candidates are also written under .moda/fixes/<ref>/);
5060
5360
  --wait drives a still-running pipeline to rest
@@ -6704,11 +7004,17 @@ var commandRegistry = createCommandRegistry([
6704
7004
  },
6705
7005
  {
6706
7006
  name: "fixes",
6707
- description: 'List the ranked queue of Fixes ("a fix with proof") for this tenant',
6708
- examples: ["moda fixes", "moda fixes --status=VERIFIED", "moda fixes --limit=10"],
7007
+ description: 'List the ranked queue of Fixes ("a fix with proof"); campaign mode batch-drafts and drives them',
7008
+ examples: [
7009
+ "moda fixes",
7010
+ "moda fixes --status=VERIFIED",
7011
+ "moda fixes draft-batch --limit=10",
7012
+ "moda fixes drive",
7013
+ "moda fixes drive --status=SCOPING"
7014
+ ],
6709
7015
  auth: "none",
6710
7016
  outputModes: ["human", "json", "agent"],
6711
- mutability: "read",
7017
+ mutability: "write",
6712
7018
  validateConfigBeforeRun: false,
6713
7019
  resetApiRequestCountBeforeRun: false,
6714
7020
  telemetry: "result",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.28.1",
3
+ "version": "1.28.3",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-08-19T22:54:45.735Z",
4
- "cli_version": "1.28.1",
3
+ "bundled_at": "2026-08-21T03:28:44.293Z",
4
+ "cli_version": "1.28.3",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",