mira-harness 0.2.1 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.js +267 -143
  3. package/llms.txt +1 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -106,6 +106,7 @@ a "typing…" fallback for a slow bot — replies run 5–62s) and capture, per
106
106
  | `watch` | Live-tail @mira's messages (observe-only). `--peer` |
107
107
  | `report` | Distill the run log into Markdown. `--in --out --category` |
108
108
  | `stats` | At-a-glance dashboard: totals, latency records, sparkline. `--in --category --json` |
109
+ | `diff` | Compare two run logs for @mira behavioral drift (exit 1 on a regression). `--json --no-fail` |
109
110
 
110
111
  Run `mira-harness --help` (or `<command> --help`) for full options.
111
112
 
@@ -159,6 +160,19 @@ Give a probe an optional `expect` block and `loop` grades it ✓/✗. The checks
159
160
  Probes without `expect` stay observe-only (informational). `loop` **exits non-zero** if any
160
161
  graded probe fails — so it drops straight into CI. Add `--no-fail` to report without failing.
161
162
 
163
+ ### Drift detection
164
+
165
+ `diff` compares two run logs and flags how @mira's behavior **changed** (structural, not
166
+ exact text). **Regressions** — an assertion that flipped ✓→✗, a probe that now times out, a
167
+ >2× latency blow-up — exit non-zero; surface changes (buttons / links / media) and
168
+ improvements are reported but pass. Snapshot a baseline, re-run later, diff:
169
+
170
+ ```bash
171
+ MIRA_RUNS_FILE=baseline.jsonl mira-harness loop --category core # snapshot a baseline
172
+ mira-harness loop --category core # a later run -> mira-runs.jsonl
173
+ mira-harness diff baseline.jsonl # vs the current run log
174
+ ```
175
+
162
176
  ## Use as a library
163
177
 
164
178
  The CLI is a thin frontend over an exported core:
package/dist/cli.js CHANGED
@@ -430,6 +430,263 @@ function listCatalog(opts = {}) {
430
430
  }
431
431
  }
432
432
 
433
+ // src/commands/diff.ts
434
+ import { resolve as resolve4 } from "node:path";
435
+
436
+ // src/log.ts
437
+ import { appendFile, mkdir } from "node:fs/promises";
438
+ import { dirname, resolve as resolve2 } from "node:path";
439
+ var RUNS_FILE = resolve2(process.cwd(), process.env.MIRA_RUNS_FILE ?? "mira-runs.jsonl");
440
+ async function appendRun(result, meta = {}) {
441
+ await mkdir(dirname(RUNS_FILE), { recursive: true });
442
+ const record = { ...meta, ...result };
443
+ await appendFile(RUNS_FILE, `${JSON.stringify(record)}
444
+ `, "utf8");
445
+ }
446
+
447
+ // src/commands/report.ts
448
+ import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
449
+ import { resolve as resolve3 } from "node:path";
450
+ function loadRunRecords(inFile, category) {
451
+ const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
452
+ const raw = readFileSync2(path, "utf8");
453
+ const records = [];
454
+ for (const line of raw.split(`
455
+ `)) {
456
+ const t = line.trim();
457
+ if (!t)
458
+ continue;
459
+ try {
460
+ records.push(JSON.parse(t));
461
+ } catch {}
462
+ }
463
+ return category ? records.filter((r) => r.category === category) : records;
464
+ }
465
+ function truncate(s, n) {
466
+ const flat = s.replace(/\s+/g, " ").trim();
467
+ return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat;
468
+ }
469
+ function cell(s) {
470
+ return s.replace(/\|/g, "\\|");
471
+ }
472
+ function signals(r) {
473
+ const btns = r.messages.reduce((n, m) => n + m.buttons.length, 0);
474
+ const links = r.messages.reduce((n, m) => n + m.links.length, 0);
475
+ const media = r.messages.filter((m) => m.media).map((m) => m.media?.kind);
476
+ const edits = r.messages.reduce((n, m) => n + m.editCount, 0);
477
+ return [
478
+ btns ? `${btns}btn` : "",
479
+ links ? `${links}link` : "",
480
+ media.length ? `media=${media.join(",")}` : "",
481
+ edits ? `${edits}edit` : ""
482
+ ].filter(Boolean).join(" ") || "—";
483
+ }
484
+ function gist(r) {
485
+ const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
486
+ return truncate(first, 90);
487
+ }
488
+ function verdictCell(r) {
489
+ if (!r.assert)
490
+ return "—";
491
+ if (r.assert.ok)
492
+ return "✅";
493
+ const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
494
+ return `❌ ${cell(failed)}`;
495
+ }
496
+ function table(rows) {
497
+ const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
498
+ ` + "|---|---|---|---|---|---|---|";
499
+ const body = rows.map((r) => {
500
+ const id = r.probeId ?? "—";
501
+ const sent = cell(truncate(r.sent, 40));
502
+ const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
503
+ const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
504
+ const total = `${(r.totalMs / 1000).toFixed(1)}s`;
505
+ return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
506
+ }).join(`
507
+ `);
508
+ return `${head}
509
+ ${body}`;
510
+ }
511
+ function buildReport(records) {
512
+ const replied = records.filter((r) => !r.timedOut);
513
+ const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
514
+ const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
515
+ const max = latencies.length ? Math.max(...latencies) : 0;
516
+ const graded = records.filter((r) => r.assert);
517
+ const passed = graded.filter((r) => r.assert?.ok);
518
+ const out = [];
519
+ out.push("# Mira(@mira) probe report (auto-generated)");
520
+ out.push("");
521
+ out.push("> Distilled from the run log by `mira-harness report`.");
522
+ out.push("");
523
+ out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + (graded.length ? `**${passed.length}/${graded.length}** assertions passed · ` : "") + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
524
+ out.push("");
525
+ const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
526
+ const custom = [];
527
+ for (const r of records) {
528
+ if (r.category && !CATEGORIES.includes(r.category) && !custom.includes(r.category)) {
529
+ custom.push(r.category);
530
+ }
531
+ }
532
+ for (const cat of [...builtin, ...custom]) {
533
+ out.push(`## ${cat}`, "", table(records.filter((r) => r.category === cat)), "");
534
+ }
535
+ const uncategorized = records.filter((r) => !r.category);
536
+ if (uncategorized.length)
537
+ out.push("## (uncategorized)", "", table(uncategorized), "");
538
+ return out.join(`
539
+ `);
540
+ }
541
+ function renderReport(inFile, category) {
542
+ const records = loadRunRecords(inFile, category);
543
+ if (!records.length) {
544
+ throw new Error(category ? `no probes in category "${category}" in the run log.` : "run log is empty.");
545
+ }
546
+ return buildReport(records);
547
+ }
548
+ function report(opts) {
549
+ let md;
550
+ try {
551
+ md = renderReport(opts.in, opts.category);
552
+ } catch (e) {
553
+ if (e?.code === "ENOENT") {
554
+ const path = opts.in ? resolve3(process.cwd(), opts.in) : RUNS_FILE;
555
+ console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
556
+ } else {
557
+ console.error(e instanceof Error ? e.message : String(e));
558
+ }
559
+ process.exit(1);
560
+ }
561
+ if (opts.out) {
562
+ const dest = resolve3(process.cwd(), opts.out);
563
+ writeFileSync(dest, `${md}
564
+ `, "utf8");
565
+ console.error(`wrote report to ${opts.out}`);
566
+ } else {
567
+ console.log(md);
568
+ }
569
+ }
570
+
571
+ // src/commands/diff.ts
572
+ function lastByProbe(records) {
573
+ const m = new Map;
574
+ for (const r of records) {
575
+ if (r.probeId)
576
+ m.set(r.probeId, r);
577
+ }
578
+ return m;
579
+ }
580
+ function signals2(r) {
581
+ const buttons = r.messages.reduce((n, m) => n + m.buttons.length, 0);
582
+ const links = r.messages.reduce((n, m) => n + m.links.length, 0);
583
+ const media = r.messages.map((m) => m.media?.kind).filter((k) => k !== undefined).join(",");
584
+ return { buttons, links, media };
585
+ }
586
+ function diffRuns(baseline, current) {
587
+ const base = lastByProbe(baseline);
588
+ const cur = lastByProbe(current);
589
+ const items = [];
590
+ for (const id of [...new Set([...base.keys(), ...cur.keys()])].sort()) {
591
+ const b = base.get(id);
592
+ const c2 = cur.get(id);
593
+ if (b && !c2) {
594
+ items.push({ probeId: id, kind: "removed", what: "in baseline, not in current" });
595
+ continue;
596
+ }
597
+ if (c2 && !b) {
598
+ items.push({ probeId: id, kind: "added", what: "new in current (not in baseline)" });
599
+ continue;
600
+ }
601
+ if (!b || !c2)
602
+ continue;
603
+ if (!b.timedOut && c2.timedOut)
604
+ items.push({ probeId: id, kind: "regression", what: "now times out (was replying)" });
605
+ else if (b.timedOut && !c2.timedOut)
606
+ items.push({ probeId: id, kind: "improvement", what: "now replies (was timing out)" });
607
+ const ba = b.assert;
608
+ const ca = c2.assert;
609
+ if (ba && ca && ba.ok !== ca.ok) {
610
+ if (ba.ok && !ca.ok) {
611
+ const failed = ca.checks.filter((x) => !x.ok).map((x) => x.name).join(", ");
612
+ items.push({ probeId: id, kind: "regression", what: `assertion now FAILS (${failed})` });
613
+ } else {
614
+ items.push({ probeId: id, kind: "improvement", what: "assertion now passes" });
615
+ }
616
+ }
617
+ if (b.firstReplyMs !== null && c2.firstReplyMs !== null) {
618
+ const bms = b.firstReplyMs;
619
+ const cms = c2.firstReplyMs;
620
+ if (cms > bms * 2 && cms - bms > 5000) {
621
+ items.push({
622
+ probeId: id,
623
+ kind: "regression",
624
+ what: `first reply ${(bms / 1000).toFixed(1)}s -> ${(cms / 1000).toFixed(1)}s (>2x slower)`
625
+ });
626
+ }
627
+ }
628
+ const bs = signals2(b);
629
+ const cs = signals2(c2);
630
+ if (bs.buttons !== cs.buttons)
631
+ items.push({ probeId: id, kind: "change", what: `buttons ${bs.buttons} -> ${cs.buttons}` });
632
+ if (bs.links !== cs.links)
633
+ items.push({ probeId: id, kind: "change", what: `links ${bs.links} -> ${cs.links}` });
634
+ if (bs.media !== cs.media)
635
+ items.push({ probeId: id, kind: "change", what: `media [${bs.media}] -> [${cs.media}]` });
636
+ }
637
+ return items;
638
+ }
639
+ function load(path, label) {
640
+ try {
641
+ return loadRunRecords(path);
642
+ } catch (e) {
643
+ const shown = path ? resolve4(process.cwd(), path) : RUNS_FILE;
644
+ if (e?.code === "ENOENT")
645
+ console.error(`${label}: no run log at ${shown}`);
646
+ else
647
+ console.error(`${label}: ${e instanceof Error ? e.message : String(e)}`);
648
+ return null;
649
+ }
650
+ }
651
+ function diff(opts) {
652
+ const base = load(opts.baseline, "baseline");
653
+ const cur = load(opts.current, "current");
654
+ if (!base || !cur)
655
+ process.exit(1);
656
+ if (!base.length)
657
+ note(c.yellow(`baseline ${opts.baseline} has no records.`));
658
+ if (!cur.length)
659
+ note(c.yellow(`current ${opts.current ?? "(run log)"} has no records.`));
660
+ const items = diffRuns(base, cur);
661
+ const regressions = items.filter((i) => i.kind === "regression");
662
+ if (opts.json) {
663
+ console.log(JSON.stringify({ regressions: regressions.length, drift: items.length, items }, null, 2));
664
+ if (regressions.length && !opts.noFail)
665
+ process.exit(1);
666
+ return;
667
+ }
668
+ note(c.bold(`drift ${opts.baseline} -> ${opts.current ?? "(current run log)"}`));
669
+ if (!items.length) {
670
+ note(c.green(" no drift detected."));
671
+ return;
672
+ }
673
+ const paint = {
674
+ regression: (s) => c.red(s),
675
+ improvement: (s) => c.green(s),
676
+ change: (s) => c.dim(s),
677
+ added: (s) => c.dim(s),
678
+ removed: (s) => c.dim(s)
679
+ };
680
+ for (const i of items) {
681
+ note(` ${paint[i.kind](i.kind.padEnd(11))} ${c.cyan(i.probeId)} — ${i.what}`);
682
+ }
683
+ note(regressions.length ? c.red(`
684
+ ${regressions.length} regression(s).`) : c.green(`
685
+ no regressions.`));
686
+ if (regressions.length && !opts.noFail)
687
+ process.exit(1);
688
+ }
689
+
433
690
  // src/client.ts
434
691
  import { Api, TelegramClient } from "telegram";
435
692
  import { EditedMessage } from "telegram/events/EditedMessage.js";
@@ -602,7 +859,7 @@ function buildCollector(peer, client, t, sent, opts) {
602
859
  const collected = new Map;
603
860
  const start = Date.now();
604
861
  let firstReplyMs = null;
605
- return new Promise((resolve2) => {
862
+ return new Promise((resolve5) => {
606
863
  let settleTimer;
607
864
  let firstTimer;
608
865
  let hardTimer;
@@ -632,7 +889,7 @@ function buildCollector(peer, client, t, sent, opts) {
632
889
  const finish = () => {
633
890
  cleanup();
634
891
  const messages = [...collected.values()].sort((a, b) => a.msg.id - b.msg.id).map(({ msg, editCount }) => extractMessage(msg, editCount));
635
- resolve2({
892
+ resolve5({
636
893
  peer,
637
894
  sent,
638
895
  messages,
@@ -797,19 +1054,6 @@ This string grants full access to your account. Never commit it.
797
1054
 
798
1055
  // src/commands/loop.ts
799
1056
  import { existsSync } from "node:fs";
800
-
801
- // src/log.ts
802
- import { appendFile, mkdir } from "node:fs/promises";
803
- import { dirname, resolve as resolve2 } from "node:path";
804
- var RUNS_FILE = resolve2(process.cwd(), process.env.MIRA_RUNS_FILE ?? "mira-runs.jsonl");
805
- async function appendRun(result, meta = {}) {
806
- await mkdir(dirname(RUNS_FILE), { recursive: true });
807
- const record = { ...meta, ...result };
808
- await appendFile(RUNS_FILE, `${JSON.stringify(record)}
809
- `, "utf8");
810
- }
811
-
812
- // src/commands/loop.ts
813
1057
  var STOP_FILE = "STOP_MIRA";
814
1058
  var DEFAULT_GAP_MS = 15000;
815
1059
  var SLOW = { firstReplyTimeoutMs: 90000, maxMs: 240000, typingGraceMs: 90000 };
@@ -952,130 +1196,6 @@ async function loop(opts) {
952
1196
  }
953
1197
  }
954
1198
 
955
- // src/commands/report.ts
956
- import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
957
- import { resolve as resolve3 } from "node:path";
958
- function loadRunRecords(inFile, category) {
959
- const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
960
- const raw = readFileSync2(path, "utf8");
961
- const records = [];
962
- for (const line of raw.split(`
963
- `)) {
964
- const t = line.trim();
965
- if (!t)
966
- continue;
967
- try {
968
- records.push(JSON.parse(t));
969
- } catch {}
970
- }
971
- return category ? records.filter((r) => r.category === category) : records;
972
- }
973
- function truncate(s, n) {
974
- const flat = s.replace(/\s+/g, " ").trim();
975
- return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat;
976
- }
977
- function cell(s) {
978
- return s.replace(/\|/g, "\\|");
979
- }
980
- function signals(r) {
981
- const btns = r.messages.reduce((n, m) => n + m.buttons.length, 0);
982
- const links = r.messages.reduce((n, m) => n + m.links.length, 0);
983
- const media = r.messages.filter((m) => m.media).map((m) => m.media?.kind);
984
- const edits = r.messages.reduce((n, m) => n + m.editCount, 0);
985
- return [
986
- btns ? `${btns}btn` : "",
987
- links ? `${links}link` : "",
988
- media.length ? `media=${media.join(",")}` : "",
989
- edits ? `${edits}edit` : ""
990
- ].filter(Boolean).join(" ") || "—";
991
- }
992
- function gist(r) {
993
- const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
994
- return truncate(first, 90);
995
- }
996
- function verdictCell(r) {
997
- if (!r.assert)
998
- return "—";
999
- if (r.assert.ok)
1000
- return "✅";
1001
- const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
1002
- return `❌ ${cell(failed)}`;
1003
- }
1004
- function table(rows) {
1005
- const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
1006
- ` + "|---|---|---|---|---|---|---|";
1007
- const body = rows.map((r) => {
1008
- const id = r.probeId ?? "—";
1009
- const sent = cell(truncate(r.sent, 40));
1010
- const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
1011
- const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
1012
- const total = `${(r.totalMs / 1000).toFixed(1)}s`;
1013
- return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
1014
- }).join(`
1015
- `);
1016
- return `${head}
1017
- ${body}`;
1018
- }
1019
- function buildReport(records) {
1020
- const replied = records.filter((r) => !r.timedOut);
1021
- const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
1022
- const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
1023
- const max = latencies.length ? Math.max(...latencies) : 0;
1024
- const graded = records.filter((r) => r.assert);
1025
- const passed = graded.filter((r) => r.assert?.ok);
1026
- const out = [];
1027
- out.push("# Mira(@mira) probe report (auto-generated)");
1028
- out.push("");
1029
- out.push("> Distilled from the run log by `mira-harness report`.");
1030
- out.push("");
1031
- out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + (graded.length ? `**${passed.length}/${graded.length}** assertions passed · ` : "") + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
1032
- out.push("");
1033
- const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
1034
- const custom = [];
1035
- for (const r of records) {
1036
- if (r.category && !CATEGORIES.includes(r.category) && !custom.includes(r.category)) {
1037
- custom.push(r.category);
1038
- }
1039
- }
1040
- for (const cat of [...builtin, ...custom]) {
1041
- out.push(`## ${cat}`, "", table(records.filter((r) => r.category === cat)), "");
1042
- }
1043
- const uncategorized = records.filter((r) => !r.category);
1044
- if (uncategorized.length)
1045
- out.push("## (uncategorized)", "", table(uncategorized), "");
1046
- return out.join(`
1047
- `);
1048
- }
1049
- function renderReport(inFile, category) {
1050
- const records = loadRunRecords(inFile, category);
1051
- if (!records.length) {
1052
- throw new Error(category ? `no probes in category "${category}" in the run log.` : "run log is empty.");
1053
- }
1054
- return buildReport(records);
1055
- }
1056
- function report(opts) {
1057
- let md;
1058
- try {
1059
- md = renderReport(opts.in, opts.category);
1060
- } catch (e) {
1061
- if (e?.code === "ENOENT") {
1062
- const path = opts.in ? resolve3(process.cwd(), opts.in) : RUNS_FILE;
1063
- console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
1064
- } else {
1065
- console.error(e instanceof Error ? e.message : String(e));
1066
- }
1067
- process.exit(1);
1068
- }
1069
- if (opts.out) {
1070
- const dest = resolve3(process.cwd(), opts.out);
1071
- writeFileSync(dest, `${md}
1072
- `, "utf8");
1073
- console.error(`wrote report to ${opts.out}`);
1074
- } else {
1075
- console.log(md);
1076
- }
1077
- }
1078
-
1079
1199
  // src/commands/send.ts
1080
1200
  import { existsSync as existsSync2 } from "node:fs";
1081
1201
  var STOP_FILE2 = "STOP_MIRA";
@@ -1123,7 +1243,7 @@ async function send(rawMessage, opts = {}) {
1123
1243
  }
1124
1244
 
1125
1245
  // src/commands/stats.ts
1126
- import { resolve as resolve4 } from "node:path";
1246
+ import { resolve as resolve5 } from "node:path";
1127
1247
  var SPARK = "▁▂▃▄▅▆▇█";
1128
1248
  function sparkline(values) {
1129
1249
  if (!values.length)
@@ -1162,7 +1282,7 @@ function stats(opts = {}) {
1162
1282
  records = loadRunRecords(opts.in, opts.category);
1163
1283
  } catch (e) {
1164
1284
  if (e?.code === "ENOENT") {
1165
- const path = opts.in ? resolve4(process.cwd(), opts.in) : RUNS_FILE;
1285
+ const path = opts.in ? resolve5(process.cwd(), opts.in) : RUNS_FILE;
1166
1286
  console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
1167
1287
  } else {
1168
1288
  console.error(e instanceof Error ? e.message : String(e));
@@ -1253,11 +1373,11 @@ async function watch(opts = {}) {
1253
1373
  const client = await connect(session);
1254
1374
  const unsubscribe = await subscribe(client, peer, (m, kind) => note(line(peer, m, kind)));
1255
1375
  note(c.bold(`watching @${peer} — Ctrl-C to stop`));
1256
- await new Promise((resolve5) => {
1376
+ await new Promise((resolve6) => {
1257
1377
  process.once("SIGINT", () => {
1258
1378
  note(c.dim(`
1259
1379
  stopping…`));
1260
- resolve5();
1380
+ resolve6();
1261
1381
  });
1262
1382
  });
1263
1383
  unsubscribe();
@@ -1338,6 +1458,9 @@ program.command("report").description("Distill the run log (JSONL) into a Markdo
1338
1458
  program.command("stats").description("At-a-glance run-log dashboard: totals, latency records, sparkline").option("--in <file>", "input JSONL (default: the run log)").option("-c, --category <category>", "only include probes from this category").option("--json", "output a JSON summary instead of the colored dashboard", false).action((opts) => {
1339
1459
  stats({ in: opts.in, category: opts.category, json: opts.json });
1340
1460
  });
1461
+ program.command("diff").description("Compare two run logs for @mira behavioral drift (exit 1 on a regression)").argument("<baseline>", "baseline run log (JSONL)").argument("[current]", "current run log (JSONL); defaults to the run log").option("--json", "output the drift as JSON", false).option("--no-fail", "report regressions but still exit 0").action((baseline, current, opts) => {
1462
+ diff({ baseline, current, json: opts.json, noFail: !opts.fail });
1463
+ });
1341
1464
  program.addHelpText("after", `
1342
1465
  Examples:
1343
1466
  $ mira-harness login
@@ -1352,6 +1475,7 @@ Examples:
1352
1475
  $ mira-harness watch
1353
1476
  $ mira-harness report --category core --out report.md
1354
1477
  $ mira-harness stats
1478
+ $ mira-harness diff baseline.jsonl
1355
1479
  `);
1356
1480
  if (process.argv.length <= 2) {
1357
1481
  banner(version);
package/llms.txt CHANGED
@@ -24,6 +24,7 @@ so the only programmatic path is a userbot (a real user account over MTProto / G
24
24
  - `watch` — live-tail @mira's messages, observe-only. Flag: `--peer`.
25
25
  - `report` — distill the JSONL run log into Markdown. Flags: `--in <file> --out <file> --category`.
26
26
  - `stats` — at-a-glance run-log dashboard: totals, first-reply latency records (fastest / median / p95 / slowest), an ASCII sparkline, and a per-category breakdown. Flags: `--in <file> --category --json`.
27
+ - `diff <baseline> [current]` — compare two run logs for @mira behavioral drift (matches probes by id; structural, not exact-text). Regressions (assertion ✓→✗, new timeout, >2x latency) **exit non-zero**; surface changes / improvements are reported. `current` defaults to the run log. Flags: `--json --no-fail`.
27
28
  - Help: `mira-harness --help` or `<command> --help`.
28
29
 
29
30
  ## MCP server (bin: mira-harness-mcp, entry dist/mcp.js)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mira-harness",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "A CLI + MCP dev-tool for communicating with the @mira Telegram bot — capture its full reply (buttons/links/media) and run a self-driving experiment catalog.",
5
5
  "type": "module",
6
6
  "bin": {