mira-harness 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,12 +101,14 @@ a "typing…" fallback for a slow bot — replies run 5–62s) and capture, per
101
101
  | `login` | One-time interactive login → prints `TG_SESSION` |
102
102
  | `doctor` | Check `.env` / session / connectivity / @mira resolution (read-only) |
103
103
  | `send [message...]` | One probe → full reply as JSON (message via arg or stdin). `--quiet --settle --timeout --no-log` |
104
- | `loop` | Run the catalog paced; grades `expect` probes (exit 1 on failure). `--category --max --confirm --peer --gap --settle --timeout --list --catalog --no-fail --quiet` |
104
+ | `loop` | Run the catalog paced; grades `expect` probes (exit 1 on failure). `--category --max --confirm --peer --gap --settle --timeout --list --catalog --grep --no-fail --quiet` |
105
105
  | `catalog` | List the catalog (no sends). `--category --catalog --json` |
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
109
  | `diff` | Compare two run logs for @mira behavioral drift (exit 1 on a regression). `--json --no-fail` |
110
+ | `assert` | Re-grade a saved run log against a catalog's `expect`, offline (exit 1 on failure). `--in --catalog --category --json --no-fail` |
111
+ | `schema` | Print the JSON Schema for a custom catalog file (editor autocomplete / validation). `--out` |
110
112
 
111
113
  Run `mira-harness --help` (or `<command> --help`) for full options.
112
114
 
@@ -160,6 +162,11 @@ Give a probe an optional `expect` block and `loop` grades it ✓/✗. The checks
160
162
  Probes without `expect` stay observe-only (informational). `loop` **exits non-zero** if any
161
163
  graded probe fails — so it drops straight into CI. Add `--no-fail` to report without failing.
162
164
 
165
+ `assert` re-grades a **saved** run log against the catalog's `expect` (offline, no @mira) — the
166
+ fast loop for *developing* assertions (capture once, then tune `expect` and re-grade instantly),
167
+ and a way to gate a committed run-log fixture in CI **without a Telegram session**. Run a subset
168
+ of probes by id with `loop --grep <regex>`; emit a catalog JSON Schema for your editor with `schema`.
169
+
163
170
  ### Drift detection
164
171
 
165
172
  `diff` compares two run logs and flags how @mira's behavior **changed** (structural, not
package/dist/catalog.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import { type Expect } from "./assert.js";
2
3
  /** Built-in categories. Custom catalogs may use any category string. */
3
4
  export type ProbeCategory = "core" | "skills" | "generation" | "wallet";
@@ -20,6 +21,59 @@ export interface Probe {
20
21
  expect?: Expect;
21
22
  }
22
23
  export declare const CATEGORIES: ProbeCategory[];
24
+ export declare const ProbeSchema: z.ZodObject<{
25
+ id: z.ZodString;
26
+ category: z.ZodDefault<z.ZodString>;
27
+ hypothesis: z.ZodDefault<z.ZodString>;
28
+ send: z.ZodString;
29
+ slow: z.ZodOptional<z.ZodBoolean>;
30
+ confirm: z.ZodOptional<z.ZodBoolean>;
31
+ note: z.ZodOptional<z.ZodString>;
32
+ expect: z.ZodOptional<z.ZodObject<{
33
+ replies: z.ZodOptional<z.ZodBoolean>;
34
+ textMatches: z.ZodOptional<z.ZodString>;
35
+ minButtons: z.ZodOptional<z.ZodNumber>;
36
+ minLinks: z.ZodOptional<z.ZodNumber>;
37
+ hasWebApp: z.ZodOptional<z.ZodBoolean>;
38
+ media: z.ZodOptional<z.ZodEnum<{
39
+ photo: "photo";
40
+ video: "video";
41
+ audio: "audio";
42
+ document: "document";
43
+ webpage: "webpage";
44
+ other: "other";
45
+ }>>;
46
+ maxFirstReplyMs: z.ZodOptional<z.ZodNumber>;
47
+ json: z.ZodOptional<z.ZodBoolean>;
48
+ }, z.core.$strip>>;
49
+ }, z.core.$strip>;
50
+ /** A whole catalog file = an array of probes. */
51
+ export declare const CatalogSchema: z.ZodArray<z.ZodObject<{
52
+ id: z.ZodString;
53
+ category: z.ZodDefault<z.ZodString>;
54
+ hypothesis: z.ZodDefault<z.ZodString>;
55
+ send: z.ZodString;
56
+ slow: z.ZodOptional<z.ZodBoolean>;
57
+ confirm: z.ZodOptional<z.ZodBoolean>;
58
+ note: z.ZodOptional<z.ZodString>;
59
+ expect: z.ZodOptional<z.ZodObject<{
60
+ replies: z.ZodOptional<z.ZodBoolean>;
61
+ textMatches: z.ZodOptional<z.ZodString>;
62
+ minButtons: z.ZodOptional<z.ZodNumber>;
63
+ minLinks: z.ZodOptional<z.ZodNumber>;
64
+ hasWebApp: z.ZodOptional<z.ZodBoolean>;
65
+ media: z.ZodOptional<z.ZodEnum<{
66
+ photo: "photo";
67
+ video: "video";
68
+ audio: "audio";
69
+ document: "document";
70
+ webpage: "webpage";
71
+ other: "other";
72
+ }>>;
73
+ maxFirstReplyMs: z.ZodOptional<z.ZodNumber>;
74
+ json: z.ZodOptional<z.ZodBoolean>;
75
+ }, z.core.$strip>>;
76
+ }, z.core.$strip>>;
23
77
  /**
24
78
  * Load a custom probe catalog from a JSON file (an array of probe objects).
25
79
  * Throws a clear error on a missing file or an entry that fails validation.
@@ -28,3 +82,6 @@ export declare function loadCatalog(file: string): Probe[];
28
82
  export declare const CATALOG: Probe[];
29
83
  /** Filter probes by category. `source` defaults to the built-in CATALOG. */
30
84
  export declare function probesFor(category?: string, source?: Probe[]): Probe[];
85
+ /** Keep probes whose `id` matches the pattern (regex, case-insensitive; falls
86
+ * back to substring on an invalid regex). Used by `loop --grep` to run a subset. */
87
+ export declare function grepProbes(probes: Probe[], pattern: string): Probe[];
package/dist/cli.js CHANGED
@@ -88,6 +88,7 @@ var ProbeSchema = z2.object({
88
88
  note: z2.string().optional(),
89
89
  expect: ExpectSchema.optional()
90
90
  });
91
+ var CatalogSchema = z2.array(ProbeSchema);
91
92
  function loadCatalog(file) {
92
93
  const path = resolve(process.cwd(), file);
93
94
  const raw = readFileSync(path, "utf8");
@@ -293,6 +294,33 @@ function probesFor(category, source = CATALOG) {
293
294
  return source;
294
295
  return source.filter((p) => p.category === category);
295
296
  }
297
+ function grepProbes(probes, pattern) {
298
+ let compiled;
299
+ try {
300
+ compiled = new RegExp(pattern, "i");
301
+ } catch {
302
+ compiled = undefined;
303
+ }
304
+ const rx = compiled;
305
+ if (rx)
306
+ return probes.filter((p) => rx.test(p.id));
307
+ const needle = pattern.toLowerCase();
308
+ return probes.filter((p) => p.id.toLowerCase().includes(needle));
309
+ }
310
+
311
+ // src/commands/assert.ts
312
+ import { resolve as resolve4 } from "node:path";
313
+
314
+ // src/log.ts
315
+ import { appendFile, mkdir } from "node:fs/promises";
316
+ import { dirname, resolve as resolve2 } from "node:path";
317
+ var RUNS_FILE = resolve2(process.cwd(), process.env.MIRA_RUNS_FILE ?? "mira-runs.jsonl");
318
+ async function appendRun(result, meta = {}) {
319
+ await mkdir(dirname(RUNS_FILE), { recursive: true });
320
+ const record = { ...meta, ...result };
321
+ await appendFile(RUNS_FILE, `${JSON.stringify(record)}
322
+ `, "utf8");
323
+ }
296
324
 
297
325
  // src/ui.ts
298
326
  import pc from "picocolors";
@@ -334,7 +362,7 @@ var TIPS = [
334
362
  "MIRA_NO_BANNER=1 hides this banner · --quiet hides all decoration"
335
363
  ];
336
364
  function banner(version, opts = {}) {
337
- if (opts.quiet || process.env.MIRA_NO_BANNER || !process.stderr.isTTY)
365
+ if (opts.quiet || process.env.MIRA_NO_BANNER || !process.stderr.isTTY || !process.stdout.isTTY)
338
366
  return;
339
367
  const tip = TIPS[new Date().getDate() % TIPS.length];
340
368
  const lines = [
@@ -401,49 +429,6 @@ function clearTitle() {
401
429
  process.stderr.write("\x1B]0;\x07");
402
430
  }
403
431
 
404
- // src/commands/catalog.ts
405
- function listCatalog(opts = {}) {
406
- const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
407
- if (!opts.catalog && opts.category && !CATEGORIES.includes(opts.category)) {
408
- note(c.red(`unknown category "${opts.category}" (one of: ${CATEGORIES.join(", ")})`));
409
- process.exit(1);
410
- }
411
- let probes = probesFor(opts.category, source);
412
- if (opts.max !== undefined)
413
- probes = probes.slice(0, opts.max);
414
- if (opts.json) {
415
- console.log(JSON.stringify(probes.map((p) => ({
416
- id: p.id,
417
- category: p.category,
418
- hypothesis: p.hypothesis,
419
- send: p.send,
420
- slow: Boolean(p.slow),
421
- confirm: Boolean(p.confirm),
422
- expect: p.expect ?? null
423
- })), null, 2));
424
- return;
425
- }
426
- note(c.bold(`${probes.length} probe(s)${opts.category ? ` [${opts.category}]` : ""}`));
427
- for (const p of probes) {
428
- const tags = [p.slow ? c.yellow("slow") : "", p.confirm ? c.magenta("confirm") : ""].filter(Boolean).join(" ");
429
- note(` ${c.cyan(p.id.padEnd(16))} ${c.dim(p.category.padEnd(11))} ${p.hypothesis} ${tags}`);
430
- }
431
- }
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
432
  // src/commands/report.ts
448
433
  import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
449
434
  import { resolve as resolve3 } from "node:path";
@@ -568,7 +553,89 @@ function report(opts) {
568
553
  }
569
554
  }
570
555
 
556
+ // src/commands/assert.ts
557
+ function assertLog(opts = {}) {
558
+ let records;
559
+ try {
560
+ records = loadRunRecords(opts.in, opts.category);
561
+ } catch (e) {
562
+ if (e?.code === "ENOENT") {
563
+ const path = opts.in ? resolve4(process.cwd(), opts.in) : RUNS_FILE;
564
+ console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
565
+ } else {
566
+ console.error(e instanceof Error ? e.message : String(e));
567
+ }
568
+ process.exit(1);
569
+ }
570
+ const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
571
+ const byId = new Map(source.map((p) => [p.id, p]));
572
+ const graded = [];
573
+ for (const r of records) {
574
+ if (!r.probeId)
575
+ continue;
576
+ const p = byId.get(r.probeId);
577
+ if (!p?.expect)
578
+ continue;
579
+ graded.push({ id: r.probeId, verdict: evaluate(p.expect, r) });
580
+ }
581
+ const failed = graded.filter((g) => !g.verdict.ok);
582
+ if (opts.json) {
583
+ console.log(JSON.stringify({ graded: graded.length, passed: graded.length - failed.length, results: graded }, null, 2));
584
+ if (failed.length && !opts.noFail)
585
+ process.exit(1);
586
+ return;
587
+ }
588
+ note(c.bold(`assert ${opts.catalog ?? "(built-in catalog)"} × ${opts.in ?? "(run log)"}`));
589
+ if (!graded.length) {
590
+ note(c.dim(" no records matched a probe with `expect` — nothing to grade."));
591
+ return;
592
+ }
593
+ for (const g of graded) {
594
+ note(` ${g.verdict.ok ? c.green("✓") : c.red("✗")} ${c.cyan(g.id)}`);
595
+ if (!g.verdict.ok) {
596
+ for (const ch of g.verdict.checks.filter((x) => !x.ok))
597
+ note(c.dim(` ✗ ${ch.name}: ${ch.detail}`));
598
+ }
599
+ }
600
+ note((failed.length ? c.red : c.green)(`
601
+ ${graded.length - failed.length}/${graded.length} assertions passed.`));
602
+ if (failed.length && !opts.noFail)
603
+ process.exit(1);
604
+ }
605
+
606
+ // src/commands/catalog.ts
607
+ function listCatalog(opts = {}) {
608
+ const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
609
+ if (!opts.catalog && opts.category && !CATEGORIES.includes(opts.category)) {
610
+ note(c.red(`unknown category "${opts.category}" (one of: ${CATEGORIES.join(", ")})`));
611
+ process.exit(1);
612
+ }
613
+ let probes = probesFor(opts.category, source);
614
+ if (opts.grep)
615
+ probes = grepProbes(probes, opts.grep);
616
+ if (opts.max !== undefined)
617
+ probes = probes.slice(0, opts.max);
618
+ if (opts.json) {
619
+ console.log(JSON.stringify(probes.map((p) => ({
620
+ id: p.id,
621
+ category: p.category,
622
+ hypothesis: p.hypothesis,
623
+ send: p.send,
624
+ slow: Boolean(p.slow),
625
+ confirm: Boolean(p.confirm),
626
+ expect: p.expect ?? null
627
+ })), null, 2));
628
+ return;
629
+ }
630
+ note(c.bold(`${probes.length} probe(s)${opts.category ? ` [${opts.category}]` : ""}`));
631
+ for (const p of probes) {
632
+ const tags = [p.slow ? c.yellow("slow") : "", p.confirm ? c.magenta("confirm") : ""].filter(Boolean).join(" ");
633
+ note(` ${c.cyan(p.id.padEnd(16))} ${c.dim(p.category.padEnd(11))} ${p.hypothesis} ${tags}`);
634
+ }
635
+ }
636
+
571
637
  // src/commands/diff.ts
638
+ import { resolve as resolve5 } from "node:path";
572
639
  function lastByProbe(records) {
573
640
  const m = new Map;
574
641
  for (const r of records) {
@@ -640,7 +707,7 @@ function load(path, label) {
640
707
  try {
641
708
  return loadRunRecords(path);
642
709
  } catch (e) {
643
- const shown = path ? resolve4(process.cwd(), path) : RUNS_FILE;
710
+ const shown = path ? resolve5(process.cwd(), path) : RUNS_FILE;
644
711
  if (e?.code === "ENOENT")
645
712
  console.error(`${label}: no run log at ${shown}`);
646
713
  else
@@ -859,7 +926,7 @@ function buildCollector(peer, client, t, sent, opts) {
859
926
  const collected = new Map;
860
927
  const start = Date.now();
861
928
  let firstReplyMs = null;
862
- return new Promise((resolve5) => {
929
+ return new Promise((resolve6) => {
863
930
  let settleTimer;
864
931
  let firstTimer;
865
932
  let hardTimer;
@@ -889,7 +956,7 @@ function buildCollector(peer, client, t, sent, opts) {
889
956
  const finish = () => {
890
957
  cleanup();
891
958
  const messages = [...collected.values()].sort((a, b) => a.msg.id - b.msg.id).map(({ msg, editCount }) => extractMessage(msg, editCount));
892
- resolve5({
959
+ resolve6({
893
960
  peer,
894
961
  sent,
895
962
  messages,
@@ -1106,7 +1173,7 @@ async function loop(opts) {
1106
1173
  process.exit(1);
1107
1174
  }
1108
1175
  if (opts.list) {
1109
- listCatalog({ category: opts.category, max: opts.max, catalog: opts.catalog });
1176
+ listCatalog({ category: opts.category, max: opts.max, catalog: opts.catalog, grep: opts.grep });
1110
1177
  return;
1111
1178
  }
1112
1179
  const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
@@ -1125,9 +1192,10 @@ async function loop(opts) {
1125
1192
  console.error("TG_SESSION is empty — run `mira-harness login` first, then put it in .env.");
1126
1193
  process.exit(1);
1127
1194
  }
1128
- const probes = probesFor(opts.category, source).slice(0, opts.max);
1195
+ const matched = opts.grep ? grepProbes(probesFor(opts.category, source), opts.grep) : probesFor(opts.category, source);
1196
+ const probes = matched.slice(0, opts.max);
1129
1197
  if (!probes.length) {
1130
- console.error("no probes selected.");
1198
+ console.error(opts.grep ? `no probes match --grep "${opts.grep}".` : "no probes selected.");
1131
1199
  process.exit(1);
1132
1200
  }
1133
1201
  const gap = opts.gap ?? DEFAULT_GAP_MS;
@@ -1196,6 +1264,22 @@ async function loop(opts) {
1196
1264
  }
1197
1265
  }
1198
1266
 
1267
+ // src/commands/schema.ts
1268
+ import { writeFileSync as writeFileSync2 } from "node:fs";
1269
+ import { resolve as resolve6 } from "node:path";
1270
+ import { z as z3 } from "zod";
1271
+ function schema(opts = {}) {
1272
+ const json = JSON.stringify(z3.toJSONSchema(CatalogSchema), null, 2);
1273
+ if (opts.out) {
1274
+ const dest = resolve6(process.cwd(), opts.out);
1275
+ writeFileSync2(dest, `${json}
1276
+ `, "utf8");
1277
+ console.error(`wrote catalog JSON Schema to ${opts.out}`);
1278
+ } else {
1279
+ console.log(json);
1280
+ }
1281
+ }
1282
+
1199
1283
  // src/commands/send.ts
1200
1284
  import { existsSync as existsSync2 } from "node:fs";
1201
1285
  var STOP_FILE2 = "STOP_MIRA";
@@ -1243,7 +1327,7 @@ async function send(rawMessage, opts = {}) {
1243
1327
  }
1244
1328
 
1245
1329
  // src/commands/stats.ts
1246
- import { resolve as resolve5 } from "node:path";
1330
+ import { resolve as resolve7 } from "node:path";
1247
1331
  var SPARK = "▁▂▃▄▅▆▇█";
1248
1332
  function sparkline(values) {
1249
1333
  if (!values.length)
@@ -1282,7 +1366,7 @@ function stats(opts = {}) {
1282
1366
  records = loadRunRecords(opts.in, opts.category);
1283
1367
  } catch (e) {
1284
1368
  if (e?.code === "ENOENT") {
1285
- const path = opts.in ? resolve5(process.cwd(), opts.in) : RUNS_FILE;
1369
+ const path = opts.in ? resolve7(process.cwd(), opts.in) : RUNS_FILE;
1286
1370
  console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
1287
1371
  } else {
1288
1372
  console.error(e instanceof Error ? e.message : String(e));
@@ -1373,11 +1457,11 @@ async function watch(opts = {}) {
1373
1457
  const client = await connect(session);
1374
1458
  const unsubscribe = await subscribe(client, peer, (m, kind) => note(line(peer, m, kind)));
1375
1459
  note(c.bold(`watching @${peer} — Ctrl-C to stop`));
1376
- await new Promise((resolve6) => {
1460
+ await new Promise((resolve8) => {
1377
1461
  process.once("SIGINT", () => {
1378
1462
  note(c.dim(`
1379
1463
  stopping…`));
1380
- resolve6();
1464
+ resolve8();
1381
1465
  });
1382
1466
  });
1383
1467
  unsubscribe();
@@ -1433,7 +1517,7 @@ program.command("send").description("Send one probe to @mira and print the full
1433
1517
  noLog: opts.log === false
1434
1518
  });
1435
1519
  });
1436
- program.command("loop").description("Run the experiment catalog against @mira at a human pace").option("-c, --category <category>", `only this category (${CATEGORIES.join(" | ")})`).option("-m, --max <n>", "max probes this run", "6").option("--confirm", "press a safe ✅ Confirm on generation probes (spends credits)", false).option("--peer <peer>", "'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer").option("--gap <ms>", "delay between sends").option("--settle <ms>", "quiet window before concluding a reply").option("--timeout <ms>", "give up if no reply by this many ms").option("--list", "list the probes that would run, then exit (no sends)", false).option("--catalog <file>", "custom catalog JSON file (instead of the built-in catalog)").option("--no-fail", "report failed assertions but still exit 0 (default: exit 1 on failure)").option("-q, --quiet", "suppress progress spinners", false).action(async (opts) => {
1520
+ program.command("loop").description("Run the experiment catalog against @mira at a human pace").option("-c, --category <category>", `only this category (${CATEGORIES.join(" | ")})`).option("-m, --max <n>", "max probes this run", "6").option("--confirm", "press a safe ✅ Confirm on generation probes (spends credits)", false).option("--peer <peer>", "'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer").option("--gap <ms>", "delay between sends").option("--settle <ms>", "quiet window before concluding a reply").option("--timeout <ms>", "give up if no reply by this many ms").option("--list", "list the probes that would run, then exit (no sends)", false).option("--catalog <file>", "custom catalog JSON file (instead of the built-in catalog)").option("--grep <pattern>", "run only probes whose id matches this regex (case-insensitive)").option("--no-fail", "report failed assertions but still exit 0 (default: exit 1 on failure)").option("-q, --quiet", "suppress progress spinners", false).action(async (opts) => {
1437
1521
  await loop({
1438
1522
  category: opts.category,
1439
1523
  max: posInt("max", opts.max),
@@ -1444,6 +1528,7 @@ program.command("loop").description("Run the experiment catalog against @mira at
1444
1528
  timeout: ms("timeout", opts.timeout),
1445
1529
  list: opts.list,
1446
1530
  catalog: opts.catalog,
1531
+ grep: opts.grep,
1447
1532
  noFail: opts.fail === false,
1448
1533
  quiet: opts.quiet
1449
1534
  });
@@ -1461,6 +1546,12 @@ program.command("stats").description("At-a-glance run-log dashboard: totals, lat
1461
1546
  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
1547
  diff({ baseline, current, json: opts.json, noFail: !opts.fail });
1463
1548
  });
1549
+ program.command("assert").description("Re-grade a saved run log against a catalog's `expect` (offline; exit 1 on failure)").option("--in <file>", "input JSONL (default: the run log)").option("--catalog <file>", "catalog with expectations (default: the built-in catalog)").option("-c, --category <category>", "only include probes from this category").option("--json", "output the results as JSON", false).option("--no-fail", "report failures but still exit 0").action((opts) => {
1550
+ assertLog({ in: opts.in, catalog: opts.catalog, category: opts.category, json: opts.json, noFail: !opts.fail });
1551
+ });
1552
+ program.command("schema").description("Print the JSON Schema for a custom catalog file (editor autocomplete / validation)").option("--out <file>", "write to a file instead of stdout").action((opts) => {
1553
+ schema({ out: opts.out });
1554
+ });
1464
1555
  program.addHelpText("after", `
1465
1556
  Examples:
1466
1557
  $ mira-harness login
@@ -1476,6 +1567,9 @@ Examples:
1476
1567
  $ mira-harness report --category core --out report.md
1477
1568
  $ mira-harness stats
1478
1569
  $ mira-harness diff baseline.jsonl
1570
+ $ mira-harness loop --grep core-json
1571
+ $ mira-harness assert
1572
+ $ mira-harness schema > catalog.schema.json
1479
1573
  `);
1480
1574
  if (process.argv.length <= 2) {
1481
1575
  banner(version);
package/dist/index.js CHANGED
@@ -174,6 +174,7 @@ var ProbeSchema = z2.object({
174
174
  note: z2.string().optional(),
175
175
  expect: ExpectSchema.optional()
176
176
  });
177
+ var CatalogSchema = z2.array(ProbeSchema);
177
178
  function loadCatalog(file) {
178
179
  const path = resolve(process.cwd(), file);
179
180
  const raw = readFileSync(path, "utf8");
@@ -379,6 +380,19 @@ function probesFor(category, source = CATALOG) {
379
380
  return source;
380
381
  return source.filter((p) => p.category === category);
381
382
  }
383
+ function grepProbes(probes, pattern) {
384
+ let compiled;
385
+ try {
386
+ compiled = new RegExp(pattern, "i");
387
+ } catch {
388
+ compiled = undefined;
389
+ }
390
+ const rx = compiled;
391
+ if (rx)
392
+ return probes.filter((p) => rx.test(p.id));
393
+ const needle = pattern.toLowerCase();
394
+ return probes.filter((p) => p.id.toLowerCase().includes(needle));
395
+ }
382
396
  // src/client.ts
383
397
  import { Api, TelegramClient } from "telegram";
384
398
  import { EditedMessage } from "telegram/events/EditedMessage.js";
package/dist/mcp.js CHANGED
@@ -91,6 +91,7 @@ var ProbeSchema = z2.object({
91
91
  note: z2.string().optional(),
92
92
  expect: ExpectSchema.optional()
93
93
  });
94
+ var CatalogSchema = z2.array(ProbeSchema);
94
95
  function loadCatalog(file) {
95
96
  const path = resolve(process.cwd(), file);
96
97
  const raw = readFileSync(path, "utf8");
@@ -296,6 +297,19 @@ function probesFor(category, source = CATALOG) {
296
297
  return source;
297
298
  return source.filter((p) => p.category === category);
298
299
  }
300
+ function grepProbes(probes, pattern) {
301
+ let compiled;
302
+ try {
303
+ compiled = new RegExp(pattern, "i");
304
+ } catch {
305
+ compiled = undefined;
306
+ }
307
+ const rx = compiled;
308
+ if (rx)
309
+ return probes.filter((p) => rx.test(p.id));
310
+ const needle = pattern.toLowerCase();
311
+ return probes.filter((p) => p.id.toLowerCase().includes(needle));
312
+ }
299
313
 
300
314
  // src/client.ts
301
315
  import { Api, TelegramClient } from "telegram";
package/llms.txt CHANGED
@@ -19,12 +19,14 @@ so the only programmatic path is a userbot (a real user account over MTProto / G
19
19
  ## CLI commands
20
20
  - `doctor` — env / session / connectivity / @mira resolution check (read-only). Run this first.
21
21
  - `send [message...]` — one probe → full reply as JSON. Message via arg or stdin. Flags: `--settle <ms> --timeout <ms> --quiet --no-log`.
22
- - `loop` — run the catalog, paced. Flags: `--category <core|skills|generation|wallet> --max <n> --peer <experiment> --gap <ms> --settle <ms> --timeout <ms> --list --catalog <file.json> --no-fail --quiet`. Observe-only unless `--confirm`. Grades probes that declare `expect` (PASS/FAIL) and **exits non-zero** if any graded probe fails (`--no-fail` to report without failing) — drops straight into CI.
22
+ - `loop` — run the catalog, paced. Flags: `--category <core|skills|generation|wallet> --max <n> --peer <experiment> --gap <ms> --settle <ms> --timeout <ms> --list --catalog <file.json> --grep <regex> --no-fail --quiet`. Observe-only unless `--confirm`. Grades probes that declare `expect` (PASS/FAIL) and **exits non-zero** if any graded probe fails (`--no-fail` to report without failing) — drops straight into CI. `--grep <regex>` runs only probes whose id matches.
23
23
  - `catalog` — list the catalog, no network. Flags: `--category --catalog <file.json> --json`.
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
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`.
28
+ - `assert` — re-grade a SAVED run log against a catalog's `expect`, offline (no network). The fast loop for developing assertions, and CI-able **without a Telegram session** (grade a committed run-log fixture). **Exits non-zero** on failure. Flags: `--in <file> --catalog <file.json> --category --json --no-fail`.
29
+ - `schema` — print the JSON Schema for a custom catalog file (an array of probes), derived from the loader's zod schema. Wire into an editor for autocomplete/validation. Flag: `--out <file>`.
28
30
  - Help: `mira-harness --help` or `<command> --help`.
29
31
 
30
32
  ## 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.3",
3
+ "version": "0.2.5",
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": {