mira-harness 0.2.0 → 0.2.1

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,7 +101,7 @@ 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. `--category --max --confirm --peer --gap --settle --timeout --list --catalog --quiet` |
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` |
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` |
@@ -130,13 +130,35 @@ per-feature flags — `MIRA_NO_BANNER=1` (mascot + tip), `MIRA_NO_NOTIFY=1` (com
130
130
  The built-in catalog (27 probes: `core` / `skills` / `generation` / `wallet`) is just a
131
131
  default. Point `--catalog <file.json>` (CLI) or `catalogFile` (MCP) at your own probe set
132
132
  to probe any bot — each entry needs `id` + `send` (`category` / `hypothesis` / `slow` /
133
- `confirm` / `note` optional). See [`examples/catalog.sample.json`](examples/catalog.sample.json):
133
+ `confirm` / `note` / `expect` optional). See [`examples/catalog.sample.json`](examples/catalog.sample.json):
134
134
 
135
135
  ```bash
136
136
  mira-harness loop --catalog ./examples/catalog.sample.json
137
137
  mira-harness catalog --catalog ./examples/catalog.sample.json --json
138
138
  ```
139
139
 
140
+ ### Assertions (PASS/FAIL)
141
+
142
+ Give a probe an optional `expect` block and `loop` grades it ✓/✗. The checks are **structural**
143
+ — @mira is an LLM (non-deterministic), so exact-text matches would flake:
144
+
145
+ | Check | Means |
146
+ |---|---|
147
+ | `replies: true` | a reply arrived (no timeout) |
148
+ | `textMatches: "<regex>"` | some message text matches (case-insensitive) |
149
+ | `minButtons` / `minLinks` | at least N inline buttons / links across messages |
150
+ | `hasWebApp: true` | a Mini App (`web_app` / startapp) "Launch" button is present |
151
+ | `media: "photo"` | a message carries media of that kind (`photo`/`video`/`audio`/…) |
152
+ | `maxFirstReplyMs` | first-reply latency within the bound |
153
+ | `json: true` | the first message text parses as JSON |
154
+
155
+ ```json
156
+ { "id": "json-strict", "send": "Reply with ONLY {\"ok\":true}", "expect": { "json": true } }
157
+ ```
158
+
159
+ Probes without `expect` stay observe-only (informational). `loop` **exits non-zero** if any
160
+ graded probe fails — so it drops straight into CI. Add `--no-fail` to report without failing.
161
+
140
162
  ## Use as a library
141
163
 
142
164
  The CLI is a thin frontend over an exported core:
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Probe assertions — turn an observed reply into a PASS/FAIL verdict.
3
+ *
4
+ * A probe's optional `expect` declares machine-checkable expectations; `evaluate`
5
+ * runs them against the captured ProbeResult. @mira is an LLM (non-deterministic),
6
+ * so the checks are deliberately STRUCTURAL and loose — a reply arrived, >= N
7
+ * links, valid JSON, a latency bound — never exact text, which would flake.
8
+ *
9
+ * Probes WITHOUT `expect` are not graded: they stay observe-only / informational.
10
+ */
11
+ import { z } from "zod";
12
+ import type { ProbeResult } from "./capture.js";
13
+ export declare const ExpectSchema: z.ZodObject<{
14
+ replies: z.ZodOptional<z.ZodBoolean>;
15
+ textMatches: z.ZodOptional<z.ZodString>;
16
+ minButtons: z.ZodOptional<z.ZodNumber>;
17
+ minLinks: z.ZodOptional<z.ZodNumber>;
18
+ hasWebApp: z.ZodOptional<z.ZodBoolean>;
19
+ media: z.ZodOptional<z.ZodEnum<{
20
+ photo: "photo";
21
+ video: "video";
22
+ audio: "audio";
23
+ document: "document";
24
+ webpage: "webpage";
25
+ other: "other";
26
+ }>>;
27
+ maxFirstReplyMs: z.ZodOptional<z.ZodNumber>;
28
+ json: z.ZodOptional<z.ZodBoolean>;
29
+ }, z.core.$strip>;
30
+ export type Expect = z.infer<typeof ExpectSchema>;
31
+ export interface Check {
32
+ name: string;
33
+ ok: boolean;
34
+ detail: string;
35
+ }
36
+ export interface Verdict {
37
+ ok: boolean;
38
+ checks: Check[];
39
+ }
40
+ /** Run a probe's expectations against its captured result. Pure — no network. */
41
+ export declare function evaluate(expect: Expect, result: ProbeResult): Verdict;
package/dist/catalog.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Expect } from "./assert.js";
1
2
  /** Built-in categories. Custom catalogs may use any category string. */
2
3
  export type ProbeCategory = "core" | "skills" | "generation" | "wallet";
3
4
  export interface Probe {
@@ -15,6 +16,8 @@ export interface Probe {
15
16
  */
16
17
  confirm?: boolean;
17
18
  note?: string;
19
+ /** Optional machine-checkable expectations — graded by `loop` (PASS/FAIL). */
20
+ expect?: Expect;
18
21
  }
19
22
  export declare const CATEGORIES: ProbeCategory[];
20
23
  /**
package/dist/cli.js CHANGED
@@ -6,16 +6,87 @@ import { Command } from "commander";
6
6
  // src/catalog.ts
7
7
  import { readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
+ import { z as z2 } from "zod";
10
+
11
+ // src/assert.ts
9
12
  import { z } from "zod";
13
+ var ExpectSchema = z.object({
14
+ replies: z.boolean().optional(),
15
+ textMatches: z.string().optional(),
16
+ minButtons: z.number().int().nonnegative().optional(),
17
+ minLinks: z.number().int().nonnegative().optional(),
18
+ hasWebApp: z.boolean().optional(),
19
+ media: z.enum(["photo", "video", "audio", "document", "webpage", "other"]).optional(),
20
+ maxFirstReplyMs: z.number().positive().optional(),
21
+ json: z.boolean().optional()
22
+ });
23
+ function evaluate(expect, result) {
24
+ const checks = [];
25
+ const texts = result.messages.map((m) => m.text).filter((t) => t.length > 0);
26
+ const firstText = texts[0] ?? "";
27
+ const buttons = result.messages.reduce((n, m) => n + m.buttons.length, 0);
28
+ const links = result.messages.reduce((n, m) => n + m.links.length, 0);
29
+ const webApp = result.messages.some((m) => m.buttons.some((b) => b.webAppUrl !== undefined));
30
+ const mediaKinds = result.messages.map((m) => m.media?.kind).filter((k) => k !== undefined);
31
+ const add = (name, ok, detail) => {
32
+ checks.push({ name, ok, detail });
33
+ };
34
+ if (expect.replies !== undefined) {
35
+ const replied = !result.timedOut && result.messages.length > 0;
36
+ add("replies", replied === expect.replies, result.timedOut ? "timed out" : `${result.messages.length} message(s)`);
37
+ }
38
+ if (expect.textMatches !== undefined) {
39
+ const pattern = expect.textMatches;
40
+ let compiled;
41
+ try {
42
+ compiled = new RegExp(pattern, "i");
43
+ } catch {
44
+ compiled = undefined;
45
+ }
46
+ const rx = compiled;
47
+ const ok = rx ? texts.some((t) => rx.test(t)) : false;
48
+ add("textMatches", ok, rx ? `/${pattern}/i` : `invalid regex: ${pattern}`);
49
+ }
50
+ if (expect.minButtons !== undefined) {
51
+ add("minButtons", buttons >= expect.minButtons, `${buttons} >= ${expect.minButtons}`);
52
+ }
53
+ if (expect.minLinks !== undefined) {
54
+ add("minLinks", links >= expect.minLinks, `${links} >= ${expect.minLinks}`);
55
+ }
56
+ if (expect.hasWebApp !== undefined) {
57
+ add("hasWebApp", webApp === expect.hasWebApp, `webApp=${webApp}`);
58
+ }
59
+ if (expect.media !== undefined) {
60
+ add("media", mediaKinds.includes(expect.media), `kinds=[${mediaKinds.join(",")}] want ${expect.media}`);
61
+ }
62
+ if (expect.maxFirstReplyMs !== undefined) {
63
+ const ms = result.firstReplyMs;
64
+ add("maxFirstReplyMs", ms !== null && ms <= expect.maxFirstReplyMs, ms === null ? "no reply" : `${ms}ms <= ${expect.maxFirstReplyMs}ms`);
65
+ }
66
+ if (expect.json === true) {
67
+ let ok = false;
68
+ try {
69
+ JSON.parse(firstText);
70
+ ok = firstText.length > 0;
71
+ } catch {
72
+ ok = false;
73
+ }
74
+ add("json", ok, ok ? "valid JSON" : "not valid JSON");
75
+ }
76
+ return { ok: checks.every((ch) => ch.ok), checks };
77
+ }
78
+
79
+ // src/catalog.ts
10
80
  var CATEGORIES = ["core", "skills", "generation", "wallet"];
11
- var ProbeSchema = z.object({
12
- id: z.string().min(1),
13
- category: z.string().default("custom"),
14
- hypothesis: z.string().default(""),
15
- send: z.string().min(1),
16
- slow: z.boolean().optional(),
17
- confirm: z.boolean().optional(),
18
- note: z.string().optional()
81
+ var ProbeSchema = z2.object({
82
+ id: z2.string().min(1),
83
+ category: z2.string().default("custom"),
84
+ hypothesis: z2.string().default(""),
85
+ send: z2.string().min(1),
86
+ slow: z2.boolean().optional(),
87
+ confirm: z2.boolean().optional(),
88
+ note: z2.string().optional(),
89
+ expect: ExpectSchema.optional()
19
90
  });
20
91
  function loadCatalog(file) {
21
92
  const path = resolve(process.cwd(), file);
@@ -26,7 +97,7 @@ function loadCatalog(file) {
26
97
  } catch {
27
98
  throw new Error(`catalog ${file} is not valid JSON`);
28
99
  }
29
- const parsed = z.array(ProbeSchema).safeParse(data);
100
+ const parsed = z2.array(ProbeSchema).safeParse(data);
30
101
  if (!parsed.success) {
31
102
  throw new Error(`invalid catalog ${file}: ${parsed.error.issues[0]?.message ?? "schema error"}`);
32
103
  }
@@ -39,7 +110,8 @@ var CATALOG = [
39
110
  id: "core-model",
40
111
  category: "core",
41
112
  hypothesis: "Which underlying model is Mira on? (docs say MiniMax M2.5)",
42
- send: "What AI model are you running on right now? Answer with just the model name."
113
+ send: "What AI model are you running on right now? Answer with just the model name.",
114
+ expect: { replies: true }
43
115
  },
44
116
  {
45
117
  id: "core-mem-set",
@@ -51,13 +123,15 @@ var CATALOG = [
51
123
  id: "core-mem-get",
52
124
  category: "core",
53
125
  hypothesis: "Does the fact from core-mem-set persist across separate sends?",
54
- send: "What did I tell you my favorite TON token is? Answer with one word."
126
+ send: "What did I tell you my favorite TON token is? Answer with one word.",
127
+ expect: { replies: true }
55
128
  },
56
129
  {
57
130
  id: "core-json",
58
131
  category: "core",
59
132
  hypothesis: "Will Mira return STRICT JSON only (usable for structured workflows)?",
60
- send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}'
133
+ send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}',
134
+ expect: { json: true }
61
135
  },
62
136
  {
63
137
  id: "core-reason",
@@ -69,7 +143,8 @@ var CATALOG = [
69
143
  id: "core-commands",
70
144
  category: "core",
71
145
  hypothesis: "What commands does Mira expose? (/help surface, buttons)",
72
- send: "/help"
146
+ send: "/help",
147
+ expect: { replies: true }
73
148
  },
74
149
  {
75
150
  id: "core-ja",
@@ -81,7 +156,8 @@ var CATALOG = [
81
156
  id: "skill-capabilities",
82
157
  category: "skills",
83
158
  hypothesis: "Does Mira enumerate its capabilities and surface buttons/links?",
84
- send: "What can you do? List your main capabilities."
159
+ send: "What can you do? List your main capabilities.",
160
+ expect: { replies: true }
85
161
  },
86
162
  {
87
163
  id: "skill-research",
@@ -89,7 +165,8 @@ var CATALOG = [
89
165
  hypothesis: "Web research returns source links (text_url) the harness can capture.",
90
166
  send: "Search the web for the latest TON ecosystem news and give me 3 source links.",
91
167
  slow: true,
92
- note: "expect links[] (text_url source links) from deep research"
168
+ note: "expect links[] (text_url source links) from deep research",
169
+ expect: { minLinks: 1 }
93
170
  },
94
171
  {
95
172
  id: "skill-translate",
@@ -116,7 +193,8 @@ var CATALOG = [
116
193
  send: "Generate an image: a friendly teal robot mascot holding a glowing TON diamond, flat vector style.",
117
194
  slow: true,
118
195
  confirm: true,
119
- note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo"
196
+ note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo",
197
+ expect: { replies: true, minButtons: 1 }
120
198
  },
121
199
  {
122
200
  id: "gen-music",
@@ -340,7 +418,8 @@ function listCatalog(opts = {}) {
340
418
  hypothesis: p.hypothesis,
341
419
  send: p.send,
342
420
  slow: Boolean(p.slow),
343
- confirm: Boolean(p.confirm)
421
+ confirm: Boolean(p.confirm),
422
+ expect: p.expect ?? null
344
423
  })), null, 2));
345
424
  return;
346
425
  }
@@ -751,13 +830,15 @@ function findConfirmButton(result) {
751
830
  }
752
831
  return;
753
832
  }
754
- function summarize(id, r) {
833
+ function summarize(id, r, verdict) {
755
834
  const btns = r.messages.reduce((n, m) => n + m.buttons.length, 0);
756
835
  const links = r.messages.reduce((n, m) => n + m.links.length, 0);
757
836
  const media = r.messages.filter((m) => m.media).map((m) => m.media?.kind).join(",");
758
837
  const head = r.timedOut ? c.yellow("TIMEOUT") : c.green(`${r.messages.length}msg`);
759
838
  const latency = r.firstReplyMs === null ? c.dim("—") : c.dim(`${(r.firstReplyMs / 1000).toFixed(1)}s`);
839
+ const mark = verdict ? verdict.ok ? c.green("✓") : c.red("✗") : c.dim("·");
760
840
  const parts = [
841
+ mark,
761
842
  c.cyan(id),
762
843
  head,
763
844
  latency,
@@ -809,6 +890,8 @@ async function loop(opts) {
809
890
  note(c.bold(`running ${probes.length} probe(s)${opts.category ? ` [${opts.category}]` : ""} -> @${peer}` + c.dim(` gap ${gap / 1000}s${opts.confirm ? " --confirm ON" : ""}`)));
810
891
  const client = await connect(session);
811
892
  let ran = 0;
893
+ let graded = 0;
894
+ let failed = 0;
812
895
  try {
813
896
  for (const [i, p] of probes.entries()) {
814
897
  if (existsSync(STOP_FILE)) {
@@ -818,9 +901,25 @@ async function loop(opts) {
818
901
  if (!opts.quiet)
819
902
  setTitle(`mira loop ${i + 1}/${probes.length} · ${p.category}`);
820
903
  const result = await withProgress(`${p.id} -> @${peer}`, () => sendAndCollect(client, peer, p.send, collectFor(p, opts)), opts.quiet);
821
- await appendRun(result, { probeId: p.id, category: p.category, hypothesis: p.hypothesis });
904
+ const verdict = p.expect ? evaluate(p.expect, result) : undefined;
905
+ await appendRun(result, {
906
+ probeId: p.id,
907
+ category: p.category,
908
+ hypothesis: p.hypothesis,
909
+ ...verdict ? { assert: verdict } : {}
910
+ });
822
911
  ran += 1;
823
- console.log(summarize(p.id, result));
912
+ if (verdict) {
913
+ graded += 1;
914
+ if (!verdict.ok)
915
+ failed += 1;
916
+ }
917
+ console.log(summarize(p.id, result, verdict));
918
+ if (verdict && !verdict.ok) {
919
+ for (const ch of verdict.checks.filter((x) => !x.ok)) {
920
+ note(c.dim(` ✗ ${ch.name}: ${ch.detail}`));
921
+ }
922
+ }
824
923
  if (opts.confirm && p.confirm) {
825
924
  const found = findConfirmButton(result);
826
925
  if (found && existsSync(STOP_FILE)) {
@@ -845,8 +944,12 @@ async function loop(opts) {
845
944
  clearTitle();
846
945
  await client.disconnect();
847
946
  }
848
- note(c.green(`done — ${ran} probe(s) logged.`));
947
+ note(c.green(`done — ${ran} probe(s) logged.`) + (graded ? c.dim(` · ${graded - failed}/${graded} assertions passed`) : ""));
849
948
  notify(`mira loop done — ${ran} probe(s) logged`, { quiet: opts.quiet });
949
+ if (failed > 0 && !opts.noFail) {
950
+ note(c.red(`${failed} probe(s) failed their assertions.`));
951
+ process.exit(1);
952
+ }
850
953
  }
851
954
 
852
955
  // src/commands/report.ts
@@ -890,16 +993,24 @@ function gist(r) {
890
993
  const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
891
994
  return truncate(first, 90);
892
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
+ }
893
1004
  function table(rows) {
894
- const head = `| Probe | Sent | Reply (gist) | Signals | First reply | Settled |
895
- ` + "|---|---|---|---|---|---|";
1005
+ const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
1006
+ ` + "|---|---|---|---|---|---|---|";
896
1007
  const body = rows.map((r) => {
897
1008
  const id = r.probeId ?? "—";
898
1009
  const sent = cell(truncate(r.sent, 40));
899
1010
  const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
900
1011
  const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
901
1012
  const total = `${(r.totalMs / 1000).toFixed(1)}s`;
902
- return `| \`${cell(id)}\` | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
1013
+ return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
903
1014
  }).join(`
904
1015
  `);
905
1016
  return `${head}
@@ -910,12 +1021,14 @@ function buildReport(records) {
910
1021
  const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
911
1022
  const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
912
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);
913
1026
  const out = [];
914
1027
  out.push("# Mira(@mira) probe report (auto-generated)");
915
1028
  out.push("");
916
1029
  out.push("> Distilled from the run log by `mira-harness report`.");
917
1030
  out.push("");
918
- out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
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**.`);
919
1032
  out.push("");
920
1033
  const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
921
1034
  const custom = [];
@@ -1063,6 +1176,8 @@ function stats(opts = {}) {
1063
1176
  const replied = records.filter((r) => !r.timedOut);
1064
1177
  const timedOut = records.length - replied.length;
1065
1178
  const lat = latencyStats(records);
1179
+ const graded = records.filter((r) => r.assert);
1180
+ const passed = graded.filter((r) => r.assert?.ok);
1066
1181
  const byCat = new Map;
1067
1182
  for (const r of records) {
1068
1183
  const key = r.category ?? "(uncategorized)";
@@ -1078,6 +1193,7 @@ function stats(opts = {}) {
1078
1193
  probes: records.length,
1079
1194
  replied: replied.length,
1080
1195
  timedOut,
1196
+ assertions: graded.length ? { graded: graded.length, passed: passed.length } : null,
1081
1197
  latencyMs: lat.count ? { min: lat.min, median: lat.median, p95: lat.p95, max: lat.max } : null,
1082
1198
  byCategory: Object.fromEntries([...byCat].map(([k, v]) => [k, v]))
1083
1199
  }, null, 2));
@@ -1086,6 +1202,10 @@ function stats(opts = {}) {
1086
1202
  const rate = (n, total) => `${total ? Math.round(n / total * 100) : 0}%`;
1087
1203
  note(c.bold(`\uD83D\uDCCA mira stats${opts.category ? ` [${opts.category}]` : ""}`));
1088
1204
  note(` ${c.cyan(String(records.length))} probes · ${c.green(`${replied.length} replied`)} · ` + (timedOut ? c.yellow(`${timedOut} timed out`) : c.dim("0 timed out")) + c.dim(` (${rate(replied.length, records.length)} reply rate)`));
1205
+ if (graded.length) {
1206
+ const allPass = passed.length === graded.length;
1207
+ note(` ${(allPass ? c.green : c.yellow)(`${passed.length}/${graded.length}`)} assertions passed` + c.dim(` (${rate(passed.length, graded.length)})`));
1208
+ }
1089
1209
  if (lat.count) {
1090
1210
  note("");
1091
1211
  note(c.bold(" first-reply latency"));
@@ -1193,7 +1313,7 @@ program.command("send").description("Send one probe to @mira and print the full
1193
1313
  noLog: opts.log === false
1194
1314
  });
1195
1315
  });
1196
- 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("-q, --quiet", "suppress progress spinners", false).action(async (opts) => {
1316
+ 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) => {
1197
1317
  await loop({
1198
1318
  category: opts.category,
1199
1319
  max: posInt("max", opts.max),
@@ -1204,6 +1324,7 @@ program.command("loop").description("Run the experiment catalog against @mira at
1204
1324
  timeout: ms("timeout", opts.timeout),
1205
1325
  list: opts.list,
1206
1326
  catalog: opts.catalog,
1327
+ noFail: opts.fail === false,
1207
1328
  quiet: opts.quiet
1208
1329
  });
1209
1330
  });
@@ -1,8 +1,10 @@
1
+ import type { Verdict } from "../assert.js";
1
2
  import type { ProbeResult } from "../capture.js";
2
3
  export type RunRecord = ProbeResult & {
3
4
  probeId?: string;
4
5
  category?: string;
5
6
  hypothesis?: string;
7
+ assert?: Verdict;
6
8
  };
7
9
  /**
8
10
  * Read the JSONL run log into records (malformed lines skipped). Throws ENOENT when
package/dist/index.js CHANGED
@@ -92,16 +92,87 @@ function extractMessage(msg, editCount = 0) {
92
92
  // src/catalog.ts
93
93
  import { readFileSync } from "node:fs";
94
94
  import { resolve } from "node:path";
95
+ import { z as z2 } from "zod";
96
+
97
+ // src/assert.ts
95
98
  import { z } from "zod";
99
+ var ExpectSchema = z.object({
100
+ replies: z.boolean().optional(),
101
+ textMatches: z.string().optional(),
102
+ minButtons: z.number().int().nonnegative().optional(),
103
+ minLinks: z.number().int().nonnegative().optional(),
104
+ hasWebApp: z.boolean().optional(),
105
+ media: z.enum(["photo", "video", "audio", "document", "webpage", "other"]).optional(),
106
+ maxFirstReplyMs: z.number().positive().optional(),
107
+ json: z.boolean().optional()
108
+ });
109
+ function evaluate(expect, result) {
110
+ const checks = [];
111
+ const texts = result.messages.map((m) => m.text).filter((t) => t.length > 0);
112
+ const firstText = texts[0] ?? "";
113
+ const buttons = result.messages.reduce((n, m) => n + m.buttons.length, 0);
114
+ const links = result.messages.reduce((n, m) => n + m.links.length, 0);
115
+ const webApp = result.messages.some((m) => m.buttons.some((b) => b.webAppUrl !== undefined));
116
+ const mediaKinds = result.messages.map((m) => m.media?.kind).filter((k) => k !== undefined);
117
+ const add = (name, ok, detail) => {
118
+ checks.push({ name, ok, detail });
119
+ };
120
+ if (expect.replies !== undefined) {
121
+ const replied = !result.timedOut && result.messages.length > 0;
122
+ add("replies", replied === expect.replies, result.timedOut ? "timed out" : `${result.messages.length} message(s)`);
123
+ }
124
+ if (expect.textMatches !== undefined) {
125
+ const pattern = expect.textMatches;
126
+ let compiled;
127
+ try {
128
+ compiled = new RegExp(pattern, "i");
129
+ } catch {
130
+ compiled = undefined;
131
+ }
132
+ const rx = compiled;
133
+ const ok = rx ? texts.some((t) => rx.test(t)) : false;
134
+ add("textMatches", ok, rx ? `/${pattern}/i` : `invalid regex: ${pattern}`);
135
+ }
136
+ if (expect.minButtons !== undefined) {
137
+ add("minButtons", buttons >= expect.minButtons, `${buttons} >= ${expect.minButtons}`);
138
+ }
139
+ if (expect.minLinks !== undefined) {
140
+ add("minLinks", links >= expect.minLinks, `${links} >= ${expect.minLinks}`);
141
+ }
142
+ if (expect.hasWebApp !== undefined) {
143
+ add("hasWebApp", webApp === expect.hasWebApp, `webApp=${webApp}`);
144
+ }
145
+ if (expect.media !== undefined) {
146
+ add("media", mediaKinds.includes(expect.media), `kinds=[${mediaKinds.join(",")}] want ${expect.media}`);
147
+ }
148
+ if (expect.maxFirstReplyMs !== undefined) {
149
+ const ms = result.firstReplyMs;
150
+ add("maxFirstReplyMs", ms !== null && ms <= expect.maxFirstReplyMs, ms === null ? "no reply" : `${ms}ms <= ${expect.maxFirstReplyMs}ms`);
151
+ }
152
+ if (expect.json === true) {
153
+ let ok = false;
154
+ try {
155
+ JSON.parse(firstText);
156
+ ok = firstText.length > 0;
157
+ } catch {
158
+ ok = false;
159
+ }
160
+ add("json", ok, ok ? "valid JSON" : "not valid JSON");
161
+ }
162
+ return { ok: checks.every((ch) => ch.ok), checks };
163
+ }
164
+
165
+ // src/catalog.ts
96
166
  var CATEGORIES = ["core", "skills", "generation", "wallet"];
97
- var ProbeSchema = z.object({
98
- id: z.string().min(1),
99
- category: z.string().default("custom"),
100
- hypothesis: z.string().default(""),
101
- send: z.string().min(1),
102
- slow: z.boolean().optional(),
103
- confirm: z.boolean().optional(),
104
- note: z.string().optional()
167
+ var ProbeSchema = z2.object({
168
+ id: z2.string().min(1),
169
+ category: z2.string().default("custom"),
170
+ hypothesis: z2.string().default(""),
171
+ send: z2.string().min(1),
172
+ slow: z2.boolean().optional(),
173
+ confirm: z2.boolean().optional(),
174
+ note: z2.string().optional(),
175
+ expect: ExpectSchema.optional()
105
176
  });
106
177
  function loadCatalog(file) {
107
178
  const path = resolve(process.cwd(), file);
@@ -112,7 +183,7 @@ function loadCatalog(file) {
112
183
  } catch {
113
184
  throw new Error(`catalog ${file} is not valid JSON`);
114
185
  }
115
- const parsed = z.array(ProbeSchema).safeParse(data);
186
+ const parsed = z2.array(ProbeSchema).safeParse(data);
116
187
  if (!parsed.success) {
117
188
  throw new Error(`invalid catalog ${file}: ${parsed.error.issues[0]?.message ?? "schema error"}`);
118
189
  }
@@ -125,7 +196,8 @@ var CATALOG = [
125
196
  id: "core-model",
126
197
  category: "core",
127
198
  hypothesis: "Which underlying model is Mira on? (docs say MiniMax M2.5)",
128
- send: "What AI model are you running on right now? Answer with just the model name."
199
+ send: "What AI model are you running on right now? Answer with just the model name.",
200
+ expect: { replies: true }
129
201
  },
130
202
  {
131
203
  id: "core-mem-set",
@@ -137,13 +209,15 @@ var CATALOG = [
137
209
  id: "core-mem-get",
138
210
  category: "core",
139
211
  hypothesis: "Does the fact from core-mem-set persist across separate sends?",
140
- send: "What did I tell you my favorite TON token is? Answer with one word."
212
+ send: "What did I tell you my favorite TON token is? Answer with one word.",
213
+ expect: { replies: true }
141
214
  },
142
215
  {
143
216
  id: "core-json",
144
217
  category: "core",
145
218
  hypothesis: "Will Mira return STRICT JSON only (usable for structured workflows)?",
146
- send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}'
219
+ send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}',
220
+ expect: { json: true }
147
221
  },
148
222
  {
149
223
  id: "core-reason",
@@ -155,7 +229,8 @@ var CATALOG = [
155
229
  id: "core-commands",
156
230
  category: "core",
157
231
  hypothesis: "What commands does Mira expose? (/help surface, buttons)",
158
- send: "/help"
232
+ send: "/help",
233
+ expect: { replies: true }
159
234
  },
160
235
  {
161
236
  id: "core-ja",
@@ -167,7 +242,8 @@ var CATALOG = [
167
242
  id: "skill-capabilities",
168
243
  category: "skills",
169
244
  hypothesis: "Does Mira enumerate its capabilities and surface buttons/links?",
170
- send: "What can you do? List your main capabilities."
245
+ send: "What can you do? List your main capabilities.",
246
+ expect: { replies: true }
171
247
  },
172
248
  {
173
249
  id: "skill-research",
@@ -175,7 +251,8 @@ var CATALOG = [
175
251
  hypothesis: "Web research returns source links (text_url) the harness can capture.",
176
252
  send: "Search the web for the latest TON ecosystem news and give me 3 source links.",
177
253
  slow: true,
178
- note: "expect links[] (text_url source links) from deep research"
254
+ note: "expect links[] (text_url source links) from deep research",
255
+ expect: { minLinks: 1 }
179
256
  },
180
257
  {
181
258
  id: "skill-translate",
@@ -202,7 +279,8 @@ var CATALOG = [
202
279
  send: "Generate an image: a friendly teal robot mascot holding a glowing TON diamond, flat vector style.",
203
280
  slow: true,
204
281
  confirm: true,
205
- note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo"
282
+ note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo",
283
+ expect: { replies: true, minButtons: 1 }
206
284
  },
207
285
  {
208
286
  id: "gen-music",
@@ -538,16 +616,24 @@ function gist(r) {
538
616
  const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
539
617
  return truncate(first, 90);
540
618
  }
619
+ function verdictCell(r) {
620
+ if (!r.assert)
621
+ return "—";
622
+ if (r.assert.ok)
623
+ return "✅";
624
+ const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
625
+ return `❌ ${cell(failed)}`;
626
+ }
541
627
  function table(rows) {
542
- const head = `| Probe | Sent | Reply (gist) | Signals | First reply | Settled |
543
- ` + "|---|---|---|---|---|---|";
628
+ const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
629
+ ` + "|---|---|---|---|---|---|---|";
544
630
  const body = rows.map((r) => {
545
631
  const id = r.probeId ?? "—";
546
632
  const sent = cell(truncate(r.sent, 40));
547
633
  const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
548
634
  const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
549
635
  const total = `${(r.totalMs / 1000).toFixed(1)}s`;
550
- return `| \`${cell(id)}\` | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
636
+ return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
551
637
  }).join(`
552
638
  `);
553
639
  return `${head}
@@ -558,12 +644,14 @@ function buildReport(records) {
558
644
  const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
559
645
  const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
560
646
  const max = latencies.length ? Math.max(...latencies) : 0;
647
+ const graded = records.filter((r) => r.assert);
648
+ const passed = graded.filter((r) => r.assert?.ok);
561
649
  const out = [];
562
650
  out.push("# Mira(@mira) probe report (auto-generated)");
563
651
  out.push("");
564
652
  out.push("> Distilled from the run log by `mira-harness report`.");
565
653
  out.push("");
566
- out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
654
+ 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**.`);
567
655
  out.push("");
568
656
  const builtin = CATEGORIES.filter((c) => records.some((r) => r.category === c));
569
657
  const custom = [];
package/dist/log.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Verdict } from "./assert.js";
1
2
  import type { ProbeResult } from "./capture.js";
2
3
  export declare const RUNS_FILE: string;
3
4
  /** Optional provenance attached by the loop runner (manual sends omit it). */
@@ -5,5 +6,7 @@ export interface RunMeta {
5
6
  probeId?: string;
6
7
  category?: string;
7
8
  hypothesis?: string;
9
+ /** Assertion verdict, present only when the probe declared `expect`. */
10
+ assert?: Verdict;
8
11
  }
9
12
  export declare function appendRun(result: ProbeResult, meta?: RunMeta): Promise<void>;
package/dist/mcp.js CHANGED
@@ -4,21 +4,92 @@
4
4
  import { existsSync } from "node:fs";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { z as z2 } from "zod";
7
+ import { z as z3 } from "zod";
8
8
 
9
9
  // src/catalog.ts
10
10
  import { readFileSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
+ import { z as z2 } from "zod";
13
+
14
+ // src/assert.ts
12
15
  import { z } from "zod";
16
+ var ExpectSchema = z.object({
17
+ replies: z.boolean().optional(),
18
+ textMatches: z.string().optional(),
19
+ minButtons: z.number().int().nonnegative().optional(),
20
+ minLinks: z.number().int().nonnegative().optional(),
21
+ hasWebApp: z.boolean().optional(),
22
+ media: z.enum(["photo", "video", "audio", "document", "webpage", "other"]).optional(),
23
+ maxFirstReplyMs: z.number().positive().optional(),
24
+ json: z.boolean().optional()
25
+ });
26
+ function evaluate(expect, result) {
27
+ const checks = [];
28
+ const texts = result.messages.map((m) => m.text).filter((t) => t.length > 0);
29
+ const firstText = texts[0] ?? "";
30
+ const buttons = result.messages.reduce((n, m) => n + m.buttons.length, 0);
31
+ const links = result.messages.reduce((n, m) => n + m.links.length, 0);
32
+ const webApp = result.messages.some((m) => m.buttons.some((b) => b.webAppUrl !== undefined));
33
+ const mediaKinds = result.messages.map((m) => m.media?.kind).filter((k) => k !== undefined);
34
+ const add = (name, ok, detail) => {
35
+ checks.push({ name, ok, detail });
36
+ };
37
+ if (expect.replies !== undefined) {
38
+ const replied = !result.timedOut && result.messages.length > 0;
39
+ add("replies", replied === expect.replies, result.timedOut ? "timed out" : `${result.messages.length} message(s)`);
40
+ }
41
+ if (expect.textMatches !== undefined) {
42
+ const pattern = expect.textMatches;
43
+ let compiled;
44
+ try {
45
+ compiled = new RegExp(pattern, "i");
46
+ } catch {
47
+ compiled = undefined;
48
+ }
49
+ const rx = compiled;
50
+ const ok = rx ? texts.some((t) => rx.test(t)) : false;
51
+ add("textMatches", ok, rx ? `/${pattern}/i` : `invalid regex: ${pattern}`);
52
+ }
53
+ if (expect.minButtons !== undefined) {
54
+ add("minButtons", buttons >= expect.minButtons, `${buttons} >= ${expect.minButtons}`);
55
+ }
56
+ if (expect.minLinks !== undefined) {
57
+ add("minLinks", links >= expect.minLinks, `${links} >= ${expect.minLinks}`);
58
+ }
59
+ if (expect.hasWebApp !== undefined) {
60
+ add("hasWebApp", webApp === expect.hasWebApp, `webApp=${webApp}`);
61
+ }
62
+ if (expect.media !== undefined) {
63
+ add("media", mediaKinds.includes(expect.media), `kinds=[${mediaKinds.join(",")}] want ${expect.media}`);
64
+ }
65
+ if (expect.maxFirstReplyMs !== undefined) {
66
+ const ms = result.firstReplyMs;
67
+ add("maxFirstReplyMs", ms !== null && ms <= expect.maxFirstReplyMs, ms === null ? "no reply" : `${ms}ms <= ${expect.maxFirstReplyMs}ms`);
68
+ }
69
+ if (expect.json === true) {
70
+ let ok = false;
71
+ try {
72
+ JSON.parse(firstText);
73
+ ok = firstText.length > 0;
74
+ } catch {
75
+ ok = false;
76
+ }
77
+ add("json", ok, ok ? "valid JSON" : "not valid JSON");
78
+ }
79
+ return { ok: checks.every((ch) => ch.ok), checks };
80
+ }
81
+
82
+ // src/catalog.ts
13
83
  var CATEGORIES = ["core", "skills", "generation", "wallet"];
14
- var ProbeSchema = z.object({
15
- id: z.string().min(1),
16
- category: z.string().default("custom"),
17
- hypothesis: z.string().default(""),
18
- send: z.string().min(1),
19
- slow: z.boolean().optional(),
20
- confirm: z.boolean().optional(),
21
- note: z.string().optional()
84
+ var ProbeSchema = z2.object({
85
+ id: z2.string().min(1),
86
+ category: z2.string().default("custom"),
87
+ hypothesis: z2.string().default(""),
88
+ send: z2.string().min(1),
89
+ slow: z2.boolean().optional(),
90
+ confirm: z2.boolean().optional(),
91
+ note: z2.string().optional(),
92
+ expect: ExpectSchema.optional()
22
93
  });
23
94
  function loadCatalog(file) {
24
95
  const path = resolve(process.cwd(), file);
@@ -29,7 +100,7 @@ function loadCatalog(file) {
29
100
  } catch {
30
101
  throw new Error(`catalog ${file} is not valid JSON`);
31
102
  }
32
- const parsed = z.array(ProbeSchema).safeParse(data);
103
+ const parsed = z2.array(ProbeSchema).safeParse(data);
33
104
  if (!parsed.success) {
34
105
  throw new Error(`invalid catalog ${file}: ${parsed.error.issues[0]?.message ?? "schema error"}`);
35
106
  }
@@ -42,7 +113,8 @@ var CATALOG = [
42
113
  id: "core-model",
43
114
  category: "core",
44
115
  hypothesis: "Which underlying model is Mira on? (docs say MiniMax M2.5)",
45
- send: "What AI model are you running on right now? Answer with just the model name."
116
+ send: "What AI model are you running on right now? Answer with just the model name.",
117
+ expect: { replies: true }
46
118
  },
47
119
  {
48
120
  id: "core-mem-set",
@@ -54,13 +126,15 @@ var CATALOG = [
54
126
  id: "core-mem-get",
55
127
  category: "core",
56
128
  hypothesis: "Does the fact from core-mem-set persist across separate sends?",
57
- send: "What did I tell you my favorite TON token is? Answer with one word."
129
+ send: "What did I tell you my favorite TON token is? Answer with one word.",
130
+ expect: { replies: true }
58
131
  },
59
132
  {
60
133
  id: "core-json",
61
134
  category: "core",
62
135
  hypothesis: "Will Mira return STRICT JSON only (usable for structured workflows)?",
63
- send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}'
136
+ send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}',
137
+ expect: { json: true }
64
138
  },
65
139
  {
66
140
  id: "core-reason",
@@ -72,7 +146,8 @@ var CATALOG = [
72
146
  id: "core-commands",
73
147
  category: "core",
74
148
  hypothesis: "What commands does Mira expose? (/help surface, buttons)",
75
- send: "/help"
149
+ send: "/help",
150
+ expect: { replies: true }
76
151
  },
77
152
  {
78
153
  id: "core-ja",
@@ -84,7 +159,8 @@ var CATALOG = [
84
159
  id: "skill-capabilities",
85
160
  category: "skills",
86
161
  hypothesis: "Does Mira enumerate its capabilities and surface buttons/links?",
87
- send: "What can you do? List your main capabilities."
162
+ send: "What can you do? List your main capabilities.",
163
+ expect: { replies: true }
88
164
  },
89
165
  {
90
166
  id: "skill-research",
@@ -92,7 +168,8 @@ var CATALOG = [
92
168
  hypothesis: "Web research returns source links (text_url) the harness can capture.",
93
169
  send: "Search the web for the latest TON ecosystem news and give me 3 source links.",
94
170
  slow: true,
95
- note: "expect links[] (text_url source links) from deep research"
171
+ note: "expect links[] (text_url source links) from deep research",
172
+ expect: { minLinks: 1 }
96
173
  },
97
174
  {
98
175
  id: "skill-translate",
@@ -119,7 +196,8 @@ var CATALOG = [
119
196
  send: "Generate an image: a friendly teal robot mascot holding a glowing TON diamond, flat vector style.",
120
197
  slow: true,
121
198
  confirm: true,
122
- note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo"
199
+ note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo",
200
+ expect: { replies: true, minButtons: 1 }
123
201
  },
124
202
  {
125
203
  id: "gen-music",
@@ -549,16 +627,24 @@ function gist(r) {
549
627
  const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
550
628
  return truncate(first, 90);
551
629
  }
630
+ function verdictCell(r) {
631
+ if (!r.assert)
632
+ return "—";
633
+ if (r.assert.ok)
634
+ return "✅";
635
+ const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
636
+ return `❌ ${cell(failed)}`;
637
+ }
552
638
  function table(rows) {
553
- const head = `| Probe | Sent | Reply (gist) | Signals | First reply | Settled |
554
- ` + "|---|---|---|---|---|---|";
639
+ const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
640
+ ` + "|---|---|---|---|---|---|---|";
555
641
  const body = rows.map((r) => {
556
642
  const id = r.probeId ?? "—";
557
643
  const sent = cell(truncate(r.sent, 40));
558
644
  const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
559
645
  const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
560
646
  const total = `${(r.totalMs / 1000).toFixed(1)}s`;
561
- return `| \`${cell(id)}\` | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
647
+ return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
562
648
  }).join(`
563
649
  `);
564
650
  return `${head}
@@ -569,12 +655,14 @@ function buildReport(records) {
569
655
  const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
570
656
  const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
571
657
  const max = latencies.length ? Math.max(...latencies) : 0;
658
+ const graded = records.filter((r) => r.assert);
659
+ const passed = graded.filter((r) => r.assert?.ok);
572
660
  const out = [];
573
661
  out.push("# Mira(@mira) probe report (auto-generated)");
574
662
  out.push("");
575
663
  out.push("> Distilled from the run log by `mira-harness report`.");
576
664
  out.push("");
577
- out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
665
+ 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**.`);
578
666
  out.push("");
579
667
  const builtin = CATEGORIES.filter((c) => records.some((r) => r.category === c));
580
668
  const custom = [];
@@ -672,9 +760,9 @@ server.registerTool("mira_send", {
672
760
  title: "Send one probe to @mira",
673
761
  description: "Send a message to @mira and capture the FULL settled reply (messages, buttons incl. web_app/startapp, links, media, edits, latency).",
674
762
  inputSchema: {
675
- message: z2.string().describe("the message to send to @mira"),
676
- settleMs: z2.number().int().positive().optional().describe("quiet window before concluding"),
677
- timeoutMs: z2.number().int().positive().optional().describe("give up if no reply by then")
763
+ message: z3.string().describe("the message to send to @mira"),
764
+ settleMs: z3.number().int().positive().optional().describe("quiet window before concluding"),
765
+ timeoutMs: z3.number().int().positive().optional().describe("give up if no reply by then")
678
766
  }
679
767
  }, async ({ message, settleMs, timeoutMs }) => {
680
768
  if (existsSync(STOP_FILE))
@@ -696,13 +784,13 @@ server.registerTool("mira_loop", {
696
784
  title: "Run the experiment catalog (observe-only)",
697
785
  description: "Send catalog probes to @mira at a human pace and capture replies. OBSERVE-ONLY: never presses buttons or spends credits (use the CLI `loop --confirm` for that).",
698
786
  inputSchema: {
699
- category: z2.string().optional().describe(`one of: ${CATEGORIES.join(", ")} (or any category in a custom catalog)`),
700
- max: z2.number().int().positive().max(50).optional().describe("max probes (default 6)"),
701
- peer: z2.string().optional().describe("'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer"),
702
- gapMs: z2.number().int().nonnegative().optional().describe("delay between sends (default 15000)"),
703
- settleMs: z2.number().int().positive().optional(),
704
- timeoutMs: z2.number().int().positive().optional(),
705
- catalogFile: z2.string().optional().describe("custom catalog JSON file (instead of the built-in catalog)")
787
+ category: z3.string().optional().describe(`one of: ${CATEGORIES.join(", ")} (or any category in a custom catalog)`),
788
+ max: z3.number().int().positive().max(50).optional().describe("max probes (default 6)"),
789
+ peer: z3.string().optional().describe("'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer"),
790
+ gapMs: z3.number().int().nonnegative().optional().describe("delay between sends (default 15000)"),
791
+ settleMs: z3.number().int().positive().optional(),
792
+ timeoutMs: z3.number().int().positive().optional(),
793
+ catalogFile: z3.string().optional().describe("custom catalog JSON file (instead of the built-in catalog)")
706
794
  }
707
795
  }, async ({ category, max, peer, gapMs, settleMs, timeoutMs, catalogFile }) => {
708
796
  let source;
@@ -757,8 +845,8 @@ server.registerTool("mira_catalog", {
757
845
  title: "List the experiment catalog",
758
846
  description: "List probes (id, category, hypothesis, flags) without sending anything.",
759
847
  inputSchema: {
760
- category: z2.string().optional().describe(`one of: ${CATEGORIES.join(", ")} (or any category in a custom catalog)`),
761
- catalogFile: z2.string().optional().describe("custom catalog JSON file (instead of the built-in catalog)")
848
+ category: z3.string().optional().describe(`one of: ${CATEGORIES.join(", ")} (or any category in a custom catalog)`),
849
+ catalogFile: z3.string().optional().describe("custom catalog JSON file (instead of the built-in catalog)")
762
850
  }
763
851
  }, async ({ category, catalogFile }) => {
764
852
  let source;
@@ -784,8 +872,8 @@ server.registerTool("mira_report", {
784
872
  title: "Render the run log as Markdown",
785
873
  description: "Distill the JSONL run log into a Markdown report (per-probe gist / signals / latency).",
786
874
  inputSchema: {
787
- inFile: z2.string().optional().describe("input JSONL path (default: the run log)"),
788
- category: z2.string().optional().describe("only include probes from this category")
875
+ inFile: z3.string().optional().describe("input JSONL path (default: the run log)"),
876
+ category: z3.string().optional().describe("only include probes from this category")
789
877
  }
790
878
  }, async ({ inFile, category }) => {
791
879
  try {
package/llms.txt CHANGED
@@ -19,7 +19,7 @@ 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> --quiet`. Observe-only unless `--confirm`.
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.
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.0",
3
+ "version": "0.2.1",
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": {