mira-harness 0.2.5 → 0.2.7

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
@@ -100,8 +100,8 @@ a "typing…" fallback for a slow bot — replies run 5–62s) and capture, per
100
100
  |---|---|
101
101
  | `login` | One-time interactive login → prints `TG_SESSION` |
102
102
  | `doctor` | Check `.env` / session / connectivity / @mira resolution (read-only) |
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 --grep --no-fail --quiet` |
103
+ | `send [message...]` | One probe → full reply as JSON (message via arg or stdin). `--quiet --settle --timeout --no-log --expect-*` |
104
+ | `loop` | Run the catalog paced; grades `expect` probes (exit 1 on failure). `--category --max --confirm --peer --gap --settle --timeout --list --catalog --grep --only --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` |
@@ -164,8 +164,10 @@ graded probe fails — so it drops straight into CI. Add `--no-fail` to report w
164
164
 
165
165
  `assert` re-grades a **saved** run log against the catalog's `expect` (offline, no @mira) — the
166
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`.
167
+ and a way to gate a committed run-log fixture in CI **without a Telegram session**. For a one-off
168
+ check, `send` takes inline assertions: `send "What can you do?" --expect-min-links 1 --expect-max-ms 60000`
169
+ (any `--expect-*` exits 1 on failure). Run a subset of probes by id with `loop --grep <regex>` or
170
+ `loop --only <id1,id2>`; emit a catalog JSON Schema for your editor with `schema`.
169
171
 
170
172
  ### Drift detection
171
173
 
package/dist/catalog.d.ts CHANGED
@@ -85,3 +85,6 @@ export declare function probesFor(category?: string, source?: Probe[]): Probe[];
85
85
  /** Keep probes whose `id` matches the pattern (regex, case-insensitive; falls
86
86
  * back to substring on an invalid regex). Used by `loop --grep` to run a subset. */
87
87
  export declare function grepProbes(probes: Probe[], pattern: string): Probe[];
88
+ /** Keep only probes whose `id` is in the comma-separated list (exact match).
89
+ * Used by `loop --only` for precise selection. */
90
+ export declare function onlyProbes(probes: Probe[], idsCsv: string): Probe[];
package/dist/cli.js CHANGED
@@ -3,11 +3,6 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/catalog.ts
7
- import { readFileSync } from "node:fs";
8
- import { resolve } from "node:path";
9
- import { z as z2 } from "zod";
10
-
11
6
  // src/assert.ts
12
7
  import { z } from "zod";
13
8
  var ExpectSchema = z.object({
@@ -77,6 +72,9 @@ function evaluate(expect, result) {
77
72
  }
78
73
 
79
74
  // src/catalog.ts
75
+ import { readFileSync } from "node:fs";
76
+ import { resolve } from "node:path";
77
+ import { z as z2 } from "zod";
80
78
  var CATEGORIES = ["core", "skills", "generation", "wallet"];
81
79
  var ProbeSchema = z2.object({
82
80
  id: z2.string().min(1),
@@ -307,6 +305,10 @@ function grepProbes(probes, pattern) {
307
305
  const needle = pattern.toLowerCase();
308
306
  return probes.filter((p) => p.id.toLowerCase().includes(needle));
309
307
  }
308
+ function onlyProbes(probes, idsCsv) {
309
+ const ids = new Set(idsCsv.split(",").map((s) => s.trim()).filter(Boolean));
310
+ return probes.filter((p) => ids.has(p.id));
311
+ }
310
312
 
311
313
  // src/commands/assert.ts
312
314
  import { resolve as resolve4 } from "node:path";
@@ -432,6 +434,9 @@ function clearTitle() {
432
434
  // src/commands/report.ts
433
435
  import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
434
436
  import { resolve as resolve3 } from "node:path";
437
+ function isRunRecord(x) {
438
+ return typeof x === "object" && x !== null && Array.isArray(x.messages) && typeof x.sent === "string";
439
+ }
435
440
  function loadRunRecords(inFile, category) {
436
441
  const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
437
442
  const raw = readFileSync2(path, "utf8");
@@ -442,7 +447,9 @@ function loadRunRecords(inFile, category) {
442
447
  if (!t)
443
448
  continue;
444
449
  try {
445
- records.push(JSON.parse(t));
450
+ const rec = JSON.parse(t);
451
+ if (isRunRecord(rec))
452
+ records.push(rec);
446
453
  } catch {}
447
454
  }
448
455
  return category ? records.filter((r) => r.category === category) : records;
@@ -613,6 +620,8 @@ function listCatalog(opts = {}) {
613
620
  let probes = probesFor(opts.category, source);
614
621
  if (opts.grep)
615
622
  probes = grepProbes(probes, opts.grep);
623
+ if (opts.only)
624
+ probes = onlyProbes(probes, opts.only);
616
625
  if (opts.max !== undefined)
617
626
  probes = probes.slice(0, opts.max);
618
627
  if (opts.json) {
@@ -863,7 +872,14 @@ function req(name) {
863
872
  return v;
864
873
  }
865
874
  var tgEnv = {
866
- apiId: () => Number(req("TG_API_ID")),
875
+ apiId: () => {
876
+ const raw = req("TG_API_ID");
877
+ const n = Number(raw);
878
+ if (!Number.isInteger(n) || n <= 0) {
879
+ throw new Error(`TG_API_ID must be a positive integer (got "${raw}") — see .env.example`);
880
+ }
881
+ return n;
882
+ },
867
883
  apiHash: () => req("TG_API_HASH"),
868
884
  session: () => process.env.TG_SESSION ?? "",
869
885
  miraPeer: process.env.MIRA_PEER ?? "mira",
@@ -1173,7 +1189,7 @@ async function loop(opts) {
1173
1189
  process.exit(1);
1174
1190
  }
1175
1191
  if (opts.list) {
1176
- listCatalog({ category: opts.category, max: opts.max, catalog: opts.catalog, grep: opts.grep });
1192
+ listCatalog({ category: opts.category, max: opts.max, catalog: opts.catalog, grep: opts.grep, only: opts.only });
1177
1193
  return;
1178
1194
  }
1179
1195
  const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
@@ -1192,10 +1208,15 @@ async function loop(opts) {
1192
1208
  console.error("TG_SESSION is empty — run `mira-harness login` first, then put it in .env.");
1193
1209
  process.exit(1);
1194
1210
  }
1195
- const matched = opts.grep ? grepProbes(probesFor(opts.category, source), opts.grep) : probesFor(opts.category, source);
1211
+ let matched = probesFor(opts.category, source);
1212
+ if (opts.grep)
1213
+ matched = grepProbes(matched, opts.grep);
1214
+ if (opts.only)
1215
+ matched = onlyProbes(matched, opts.only);
1196
1216
  const probes = matched.slice(0, opts.max);
1197
1217
  if (!probes.length) {
1198
- console.error(opts.grep ? `no probes match --grep "${opts.grep}".` : "no probes selected.");
1218
+ const why = opts.only ? `--only "${opts.only}"` : opts.grep ? `--grep "${opts.grep}"` : undefined;
1219
+ console.error(why ? `no probes match ${why}.` : "no probes selected.");
1199
1220
  process.exit(1);
1200
1221
  }
1201
1222
  const gap = opts.gap ?? DEFAULT_GAP_MS;
@@ -1313,10 +1334,13 @@ async function send(rawMessage, opts = {}) {
1313
1334
  collect.firstReplyTimeoutMs = opts.timeout;
1314
1335
  const peer = tgEnv.miraPeer;
1315
1336
  const client = await connect(session);
1337
+ let verdict;
1316
1338
  try {
1317
1339
  const result = await withProgress(`@${peer}`, () => sendAndCollect(client, peer, message, collect), opts.quiet);
1340
+ if (opts.expect)
1341
+ verdict = evaluate(opts.expect, result);
1318
1342
  if (!opts.noLog)
1319
- await appendRun(result);
1343
+ await appendRun(result, verdict ? { assert: verdict } : {});
1320
1344
  if (!opts.quiet) {
1321
1345
  note(result.timedOut ? c.yellow("no reply (timed out)") : c.green(`${result.messages.length} message(s) · first reply ${((result.firstReplyMs ?? 0) / 1000).toFixed(1)}s`));
1322
1346
  }
@@ -1324,6 +1348,16 @@ async function send(rawMessage, opts = {}) {
1324
1348
  } finally {
1325
1349
  await client.disconnect();
1326
1350
  }
1351
+ if (verdict) {
1352
+ if (!opts.quiet) {
1353
+ note(verdict.ok ? c.green("✓ assertions passed") : c.red("✗ assertions failed"));
1354
+ if (!verdict.ok)
1355
+ for (const ch of verdict.checks.filter((x) => !x.ok))
1356
+ note(c.dim(` ✗ ${ch.name}: ${ch.detail}`));
1357
+ }
1358
+ if (!verdict.ok)
1359
+ process.exit(1);
1360
+ }
1327
1361
  }
1328
1362
 
1329
1363
  // src/commands/stats.ts
@@ -1501,6 +1535,41 @@ function posInt(name, v) {
1501
1535
  }
1502
1536
  return n;
1503
1537
  }
1538
+ function nonNegInt(name, v) {
1539
+ const n = Number(v);
1540
+ if (!Number.isInteger(n) || n < 0) {
1541
+ console.error(`--${name} must be a non-negative integer (got "${v}")`);
1542
+ process.exit(1);
1543
+ }
1544
+ return n;
1545
+ }
1546
+ function buildSendExpect(o) {
1547
+ const raw = {};
1548
+ if (o.expectReply)
1549
+ raw.replies = true;
1550
+ if (o.expectJson)
1551
+ raw.json = true;
1552
+ if (o.expectText !== undefined)
1553
+ raw.textMatches = o.expectText;
1554
+ if (o.expectMinLinks !== undefined)
1555
+ raw.minLinks = nonNegInt("expect-min-links", o.expectMinLinks);
1556
+ if (o.expectMinButtons !== undefined)
1557
+ raw.minButtons = nonNegInt("expect-min-buttons", o.expectMinButtons);
1558
+ if (o.expectWebapp)
1559
+ raw.hasWebApp = true;
1560
+ if (o.expectMedia !== undefined)
1561
+ raw.media = o.expectMedia;
1562
+ if (o.expectMaxMs !== undefined)
1563
+ raw.maxFirstReplyMs = ms("expect-max-ms", o.expectMaxMs);
1564
+ if (!Object.keys(raw).length)
1565
+ return;
1566
+ const parsed = ExpectSchema.safeParse(raw);
1567
+ if (!parsed.success) {
1568
+ console.error(`invalid --expect-* value: ${parsed.error.issues[0]?.message ?? "schema error"}`);
1569
+ process.exit(1);
1570
+ }
1571
+ return parsed.data;
1572
+ }
1504
1573
  var program = new Command;
1505
1574
  var version = getVersion();
1506
1575
  program.name("mira-harness").description("Automated probe harness for the @mira Telegram bot (GramJS userbot).").version(version, "-V, --version");
@@ -1509,15 +1578,16 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
1509
1578
  });
1510
1579
  program.command("login").description("One-time interactive login -> prints TG_SESSION for .env").action(login);
1511
1580
  program.command("doctor").description("Check .env, session, connectivity and @mira resolution (read-only)").action(doctor);
1512
- program.command("send").description("Send one probe to @mira and print the full settled reply as JSON").argument("[message...]", "the message (omit to read from stdin)").option("-q, --quiet", "suppress the progress spinner / status line", false).option("--settle <ms>", "quiet window before concluding a reply").option("--timeout <ms>", "give up if no reply by this many ms").option("--no-log", "do not append the result to the run log").action(async (parts, opts) => {
1581
+ program.command("send").description("Send one probe to @mira and print the full settled reply as JSON").argument("[message...]", "the message (omit to read from stdin)").option("-q, --quiet", "suppress the progress spinner / status line", false).option("--settle <ms>", "quiet window before concluding a reply").option("--timeout <ms>", "give up if no reply by this many ms").option("--no-log", "do not append the result to the run log").option("--expect-reply", "assert a reply arrives (any --expect-* exits 1 on failure)").option("--expect-json", "assert the first reply text is valid JSON").option("--expect-text <regex>", "assert some reply text matches this regex (case-insensitive)").option("--expect-min-links <n>", "assert at least N links across the reply").option("--expect-min-buttons <n>", "assert at least N inline buttons").option("--expect-webapp", "assert a web_app/startapp Launch button is present").option("--expect-media <kind>", "assert media of this kind (photo|video|audio|document|webpage|other)").option("--expect-max-ms <ms>", "assert the first reply arrives within this many ms").action(async (parts, opts) => {
1513
1582
  await send(parts.join(" "), {
1514
1583
  quiet: opts.quiet,
1515
1584
  settle: ms("settle", opts.settle),
1516
1585
  timeout: ms("timeout", opts.timeout),
1517
- noLog: opts.log === false
1586
+ noLog: opts.log === false,
1587
+ expect: buildSendExpect(opts)
1518
1588
  });
1519
1589
  });
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) => {
1590
+ 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("--only <ids>", "run only probes with these comma-separated ids (exact match)").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) => {
1521
1591
  await loop({
1522
1592
  category: opts.category,
1523
1593
  max: posInt("max", opts.max),
@@ -1529,6 +1599,7 @@ program.command("loop").description("Run the experiment catalog against @mira at
1529
1599
  list: opts.list,
1530
1600
  catalog: opts.catalog,
1531
1601
  grep: opts.grep,
1602
+ only: opts.only,
1532
1603
  noFail: opts.fail === false,
1533
1604
  quiet: opts.quiet
1534
1605
  });
@@ -1573,7 +1644,21 @@ Examples:
1573
1644
  `);
1574
1645
  if (process.argv.length <= 2) {
1575
1646
  banner(version);
1576
- program.outputHelp();
1647
+ console.log(` Drive @mira from your terminal — QA, learn, and assert on its behavior.
1648
+ `);
1649
+ console.log(` ${c.bold("Commands")}`);
1650
+ const cmds = program.commands.filter((cmd) => cmd.name() !== "help");
1651
+ const pad = Math.max(...cmds.map((cmd) => cmd.name().length));
1652
+ for (const cmd of cmds) {
1653
+ console.log(` ${c.cyan(cmd.name().padEnd(pad))} ${c.dim(cmd.description())}`);
1654
+ }
1655
+ console.log(`
1656
+ ${c.bold("Quick start")}`);
1657
+ for (const ex of ["login", "doctor", 'send "What can you do?"', "loop --category core"]) {
1658
+ console.log(c.dim(` $ mira-harness ${ex}`));
1659
+ }
1660
+ console.log(`
1661
+ Run ${c.bold("mira-harness --help")} for all options + examples, or ${c.bold("mira-harness <command> --help")}.`);
1577
1662
  process.exit(0);
1578
1663
  }
1579
1664
  program.parseAsync(process.argv).then(() => process.exit(0)).catch((e) => {
@@ -7,9 +7,9 @@ export type RunRecord = ProbeResult & {
7
7
  assert?: Verdict;
8
8
  };
9
9
  /**
10
- * Read the JSONL run log into records (malformed lines skipped). Throws ENOENT when
11
- * the file is absent — callers decide how to surface that. Shared by `report` and
12
- * `stats` so the two stay in lockstep on the log format.
10
+ * Read the JSONL run log into records (malformed/wrong-shape lines skipped). Throws
11
+ * ENOENT when the file is absent — callers decide how to surface that. Shared by
12
+ * `report` and `stats` so the two stay in lockstep on the log format.
13
13
  */
14
14
  export declare function loadRunRecords(inFile?: string, category?: string): RunRecord[];
15
15
  export declare function buildReport(records: RunRecord[]): string;
package/dist/env.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import "dotenv/config";
9
9
  export declare const tgEnv: {
10
+ /** Telegram api_id — a positive integer. Reject non-numeric/zero up front so
11
+ * `doctor` flags a bad value instead of passing NaN into GramJS (cryptic later). */
10
12
  apiId: () => number;
11
13
  apiHash: () => string;
12
14
  /** Empty until `mira-harness login` mints it; required by send/loop. */
package/dist/index.js CHANGED
@@ -393,6 +393,10 @@ function grepProbes(probes, pattern) {
393
393
  const needle = pattern.toLowerCase();
394
394
  return probes.filter((p) => p.id.toLowerCase().includes(needle));
395
395
  }
396
+ function onlyProbes(probes, idsCsv) {
397
+ const ids = new Set(idsCsv.split(",").map((s) => s.trim()).filter(Boolean));
398
+ return probes.filter((p) => ids.has(p.id));
399
+ }
396
400
  // src/client.ts
397
401
  import { Api, TelegramClient } from "telegram";
398
402
  import { EditedMessage } from "telegram/events/EditedMessage.js";
@@ -410,7 +414,14 @@ function req(name) {
410
414
  return v;
411
415
  }
412
416
  var tgEnv = {
413
- apiId: () => Number(req("TG_API_ID")),
417
+ apiId: () => {
418
+ const raw = req("TG_API_ID");
419
+ const n = Number(raw);
420
+ if (!Number.isInteger(n) || n <= 0) {
421
+ throw new Error(`TG_API_ID must be a positive integer (got "${raw}") — see .env.example`);
422
+ }
423
+ return n;
424
+ },
414
425
  apiHash: () => req("TG_API_HASH"),
415
426
  session: () => process.env.TG_SESSION ?? "",
416
427
  miraPeer: process.env.MIRA_PEER ?? "mira",
@@ -592,6 +603,9 @@ async function appendRun(result, meta = {}) {
592
603
  }
593
604
 
594
605
  // src/commands/report.ts
606
+ function isRunRecord(x) {
607
+ return typeof x === "object" && x !== null && Array.isArray(x.messages) && typeof x.sent === "string";
608
+ }
595
609
  function loadRunRecords(inFile, category) {
596
610
  const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
597
611
  const raw = readFileSync2(path, "utf8");
@@ -602,7 +616,9 @@ function loadRunRecords(inFile, category) {
602
616
  if (!t)
603
617
  continue;
604
618
  try {
605
- records.push(JSON.parse(t));
619
+ const rec = JSON.parse(t);
620
+ if (isRunRecord(rec))
621
+ records.push(rec);
606
622
  } catch {}
607
623
  }
608
624
  return category ? records.filter((r) => r.category === category) : records;
package/dist/mcp.js CHANGED
@@ -310,6 +310,10 @@ function grepProbes(probes, pattern) {
310
310
  const needle = pattern.toLowerCase();
311
311
  return probes.filter((p) => p.id.toLowerCase().includes(needle));
312
312
  }
313
+ function onlyProbes(probes, idsCsv) {
314
+ const ids = new Set(idsCsv.split(",").map((s) => s.trim()).filter(Boolean));
315
+ return probes.filter((p) => ids.has(p.id));
316
+ }
313
317
 
314
318
  // src/client.ts
315
319
  import { Api, TelegramClient } from "telegram";
@@ -420,7 +424,14 @@ function req(name) {
420
424
  return v;
421
425
  }
422
426
  var tgEnv = {
423
- apiId: () => Number(req("TG_API_ID")),
427
+ apiId: () => {
428
+ const raw = req("TG_API_ID");
429
+ const n = Number(raw);
430
+ if (!Number.isInteger(n) || n <= 0) {
431
+ throw new Error(`TG_API_ID must be a positive integer (got "${raw}") — see .env.example`);
432
+ }
433
+ return n;
434
+ },
424
435
  apiHash: () => req("TG_API_HASH"),
425
436
  session: () => process.env.TG_SESSION ?? "",
426
437
  miraPeer: process.env.MIRA_PEER ?? "mira",
@@ -603,6 +614,9 @@ async function appendRun(result, meta = {}) {
603
614
  }
604
615
 
605
616
  // src/commands/report.ts
617
+ function isRunRecord(x) {
618
+ return typeof x === "object" && x !== null && Array.isArray(x.messages) && typeof x.sent === "string";
619
+ }
606
620
  function loadRunRecords(inFile, category) {
607
621
  const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
608
622
  const raw = readFileSync2(path, "utf8");
@@ -613,7 +627,9 @@ function loadRunRecords(inFile, category) {
613
627
  if (!t)
614
628
  continue;
615
629
  try {
616
- records.push(JSON.parse(t));
630
+ const rec = JSON.parse(t);
631
+ if (isRunRecord(rec))
632
+ records.push(rec);
617
633
  } catch {}
618
634
  }
619
635
  return category ? records.filter((r) => r.category === category) : records;
package/llms.txt CHANGED
@@ -18,8 +18,8 @@ so the only programmatic path is a userbot (a real user account over MTProto / G
18
18
 
19
19
  ## CLI commands
20
20
  - `doctor` — env / session / connectivity / @mira resolution check (read-only). Run this first.
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> --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.
21
+ - `send [message...]` — one probe → full reply as JSON. Message via arg or stdin. Flags: `--settle <ms> --timeout <ms> --quiet --no-log`. Inline assertions (any sets exit 1 on failure): `--expect-reply --expect-json --expect-text <regex> --expect-min-links <n> --expect-min-buttons <n> --expect-webapp --expect-media <kind> --expect-max-ms <ms>`.
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; `--only <id1,id2>` selects exact ids.
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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mira-harness",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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": {