@moda-ai/cli 1.28.1 → 1.28.4

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,13 @@ 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 } : {},
899
+ ...gateResult?.counterfactual ? { counterfactual: gateResult.counterfactual } : {},
634
900
  ...cases.length > 0 ? { cases } : {}
635
901
  }, {
636
902
  status: envelopeStatus,
637
903
  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"}`,
904
+ 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
905
  confidence: "high"
640
906
  },
641
907
  findings,
@@ -845,13 +1111,19 @@ async function runFixDetail(context, profileOptions, tenantId, fixId) {
845
1111
  context
846
1112
  });
847
1113
  }
1114
+ const fidelityCopy = fixVerifiedFidelityCopy(fix);
1115
+ const detailCf = counterfactualBlock(fix.gateResult ?? null);
848
1116
  if (context.outputMode === "human") {
849
- process.stderr.write(`MODA-FIX-${fix.shortRef} ${fix.status} ${fix.fixType}${fix.statusReason ? ` — ${fix.statusReason}` : ""}
1117
+ process.stderr.write(`MODA-FIX-${fix.shortRef} ${fix.status} ${fix.fixType}${fidelityCopy ? ` — ${fidelityCopy}` : ""}${fix.statusReason ? ` — ${fix.statusReason}` : ""}
850
1118
  `);
1119
+ if (detailCf) {
1120
+ process.stderr.write(`${counterfactualHumanLine(detailCf)}
1121
+ `);
1122
+ }
851
1123
  }
852
1124
  context.output.writeData(fix, {
853
1125
  summary: {
854
- text: `Fix MODA-FIX-${fix.shortRef} is ${fix.status}${fix.statusReason ? ` (${fix.statusReason})` : ""}.`,
1126
+ text: `Fix MODA-FIX-${fix.shortRef} is ${fix.status}${fix.statusReason ? ` (${fix.statusReason})` : ""}.${fidelityCopy ? ` ${fidelityCopy}.` : ""}`,
855
1127
  confidence: "high"
856
1128
  },
857
1129
  findings: gateFindings(fix),
@@ -867,18 +1139,7 @@ async function driveAdvance(args) {
867
1139
  if (row.status === lastStatus)
868
1140
  return;
869
1141
  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
- }
1142
+ announceFixTransition(args.context, row);
882
1143
  };
883
1144
  announce(fix);
884
1145
  while (ADVANCING_STATUSES.has(fix.status)) {
@@ -889,7 +1150,7 @@ async function driveAdvance(args) {
889
1150
  announce(fix);
890
1151
  if (!ADVANCING_STATUSES.has(fix.status))
891
1152
  break;
892
- if (fix.status === "PROPOSED" && /gate (pending|unavailable)/i.test(fix.statusReason ?? "")) {
1153
+ if (isRestingProposed(fix)) {
893
1154
  break;
894
1155
  }
895
1156
  await sleep(fix.status === "GATING" ? args.pollIntervalMs : Math.min(STEP_DELAY_MS, args.pollIntervalMs));
@@ -922,6 +1183,23 @@ async function fetchPacketFailSoft(tenantId, fixId, profileOptions) {
922
1183
  return null;
923
1184
  }
924
1185
  }
1186
+ function isRestingProposed(fix) {
1187
+ return fix.status === "PROPOSED" && /gate (pending|unavailable)/i.test(fix.statusReason ?? "");
1188
+ }
1189
+ function announceFixTransition(context, row) {
1190
+ const line = `fix MODA-FIX-${row.shortRef}: ${row.status}${row.statusReason ? ` — ${row.statusReason}` : ""}`;
1191
+ if (context.outputMode === "human") {
1192
+ process.stderr.write(`${line}
1193
+ `);
1194
+ } else if (context.outputMode === "agent-stream") {
1195
+ context.output.writeEvent({
1196
+ event: "progress",
1197
+ phase: "fix",
1198
+ message: line,
1199
+ elapsed_ms: Date.now() - context.startedAt
1200
+ });
1201
+ }
1202
+ }
925
1203
  function isArtifactFix(fix) {
926
1204
  return fix.fixType === "TOOL_SCHEMA" || fix.fixType === "SKILL";
927
1205
  }
@@ -1058,18 +1336,51 @@ function materializePacketCandidates(packet, fixId, human) {
1058
1336
  summarySuffix: summaryParts.length > 0 ? ` ${summaryParts.join(" ")}` : ""
1059
1337
  };
1060
1338
  }
1339
+ function baselineFidelityDescriptor(gate) {
1340
+ const fidelity = gate?.baselineFidelity;
1341
+ const base = fidelity?.prompt === "tenant" ? "tenant prompt baseline" : fidelity?.prompt === "generic" ? "standard-agent baseline" : null;
1342
+ if (fidelity?.skills === "observed") {
1343
+ return `${base ?? "baseline"} · skill recovered from production receipts`;
1344
+ }
1345
+ return base;
1346
+ }
1347
+ function verifiedFidelityCopy(gate) {
1348
+ const descriptor = baselineFidelityDescriptor(gate);
1349
+ return descriptor ? `Verified — ${descriptor}` : null;
1350
+ }
1351
+ function counterfactualBlock(gate) {
1352
+ if (!gate || gate.pending === true)
1353
+ return null;
1354
+ const cf = gate.counterfactual;
1355
+ if (!cf || typeof cf !== "object" || typeof cf.verdict !== "string" || !cf.verdict)
1356
+ return null;
1357
+ return cf;
1358
+ }
1359
+ function counterfactualSentence(cf) {
1360
+ return `prevented ${cf.wins ?? 0} of ${cf.informative ?? 0} replayed production failures (control still failed in ${cf.informative ?? 0}; regressions ${cf.losses ?? 0})`;
1361
+ }
1362
+ function counterfactualHumanLine(cf) {
1363
+ return `Counterfactual: ${cf.verdict} — ${counterfactualSentence(cf)}; anchors ${cf.anchors ?? 0}, abstained ${cf.abstained ?? 0}`;
1364
+ }
1365
+ function fixVerifiedFidelityCopy(fix) {
1366
+ if (fix.status !== "VERIFIED")
1367
+ return null;
1368
+ return verifiedFidelityCopy(fix.gateResult ?? null);
1369
+ }
1061
1370
  function gateFindings(fix) {
1062
1371
  const gateResult = fix.gateResult ?? null;
1063
- if (!gateResult)
1372
+ if (!gateResult || gateResult.pending === true)
1064
1373
  return [];
1065
1374
  const verdict = gateResult.verdict ?? fix.status;
1375
+ const fidelity = baselineFidelityDescriptor(gateResult);
1376
+ const cf = counterfactualBlock(gateResult);
1066
1377
  return [
1067
1378
  {
1068
1379
  id: "fix:gate",
1069
1380
  title: `gate ${verdict}: ${gateResult.reason ?? fix.statusReason ?? ""}`.trim(),
1070
1381
  status: verdict,
1071
1382
  severity: verdict === "pass" ? "info" : verdict === "fail" ? "error" : "warning",
1072
- description: formatGateScoreboard(gateResult)
1383
+ description: `${formatGateScoreboard(gateResult)}${fidelity ? `; baseline fidelity: ${fidelity}` : ""}` + `${cf ? `; counterfactual ${cf.verdict}: ${counterfactualSentence(cf)} (anchors ${cf.anchors ?? 0}, abstained ${cf.abstained ?? 0})` : ""}`
1073
1384
  }
1074
1385
  ];
1075
1386
  }
@@ -1087,9 +1398,17 @@ function printHumanGateVerdict(fix, gateRunId, gateResult, cases) {
1087
1398
  w(`Fix: MODA-FIX-${fix.shortRef} (${fix.status})`);
1088
1399
  w(`Gate run: ${gateRunId}`);
1089
1400
  w(`Verdict: ${gateResult?.verdict ?? "none"}${gateResult?.reason ? ` — ${gateResult.reason}` : ""}`);
1401
+ const fidelity = baselineFidelityDescriptor(gateResult);
1402
+ if (fidelity) {
1403
+ w(`Baseline: ${fidelity}`);
1404
+ }
1090
1405
  if (gateResult) {
1091
1406
  w(`Score: ${formatGateScoreboard(gateResult)}`);
1092
1407
  }
1408
+ const cf = counterfactualBlock(gateResult);
1409
+ if (cf) {
1410
+ w(counterfactualHumanLine(cf));
1411
+ }
1093
1412
  if (cases.length > 0) {
1094
1413
  w("");
1095
1414
  w("Per-case (holdout is the verdict; repair rows are training cases):");
@@ -4811,7 +5130,7 @@ function buildManifest(commands) {
4811
5130
  exit_codes: [
4812
5131
  { code: 0, name: "ok", meaning: "Command completed successfully." },
4813
5132
  { 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)." },
5133
+ { 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
5134
  { code: 4, name: "auth_required", meaning: "Run login_command, then resume." },
4816
5135
  { code: 5, name: "input_required", meaning: "Choose from input_request choices, then resume." },
4817
5136
  { code: 130, name: "cancelled", meaning: "Interrupted or output consumer closed." }
@@ -4882,7 +5201,7 @@ function event(name, when, terminal, deprecated = false) {
4882
5201
  function exitCodesForCommand(command) {
4883
5202
  if (command === "ask")
4884
5203
  return [0, 1, 3, 4, 5, 130];
4885
- if (command === "fix")
5204
+ if (command === "fix" || command === "fixes")
4886
5205
  return [0, 1, 3, 4, 130];
4887
5206
  if (command === "init" || command === "harness")
4888
5207
  return [0, 1, 4, 5, 130];
@@ -5055,6 +5374,12 @@ Commands:
5055
5374
 
5056
5375
  Fixes (a fix with proof):
5057
5376
  fixes Ranked Fix queue (--status=S --limit=N --cursor=TOKEN)
5377
+ fixes draft-batch Campaign mode: batch-draft fixes for the top-ranked problems
5378
+ (--limit=N, 1-25, default 10 — batches cap at 25: LLM spend guard)
5379
+ fixes drive Round-robin one advance per active fix per pass until all rest
5380
+ (--status=S narrows the server-side scan window to one advancing
5381
+ status, --timeout=ms default 2h, --pass-interval=ms default 20s;
5382
+ exit 3 when the timeout lapses with fixes still advancing)
5058
5383
  fix <fix_id> One Fix: status + gate result; --packet prints moda.fix_packet.v1
5059
5384
  (tool/skill candidates are also written under .moda/fixes/<ref>/);
5060
5385
  --wait drives a still-running pipeline to rest
@@ -6704,11 +7029,17 @@ var commandRegistry = createCommandRegistry([
6704
7029
  },
6705
7030
  {
6706
7031
  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"],
7032
+ description: 'List the ranked queue of Fixes ("a fix with proof"); campaign mode batch-drafts and drives them',
7033
+ examples: [
7034
+ "moda fixes",
7035
+ "moda fixes --status=VERIFIED",
7036
+ "moda fixes draft-batch --limit=10",
7037
+ "moda fixes drive",
7038
+ "moda fixes drive --status=SCOPING"
7039
+ ],
6709
7040
  auth: "none",
6710
7041
  outputModes: ["human", "json", "agent"],
6711
- mutability: "read",
7042
+ mutability: "write",
6712
7043
  validateConfigBeforeRun: false,
6713
7044
  resetApiRequestCountBeforeRun: false,
6714
7045
  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.4",
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-23T02:04:41.686Z",
4
+ "cli_version": "1.28.4",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",