switchroom 0.21.16 → 0.21.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/agent-scheduler/index.js +5 -0
  2. package/dist/auth-broker/index.js +5 -0
  3. package/dist/cli/notion-write-pretool.mjs +5 -0
  4. package/dist/cli/switchroom.js +1492 -1136
  5. package/dist/host-control/main.js +6 -1
  6. package/dist/vault/approvals/kernel-server.js +5 -0
  7. package/dist/vault/broker/server.js +5 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +9 -4
  11. package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
  12. package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
  13. package/telegram-plugin/uat/flip/allowlist.ts +349 -0
  14. package/telegram-plugin/uat/flip/gate.test.ts +153 -0
  15. package/telegram-plugin/uat/flip/gate.ts +232 -0
  16. package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
  17. package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
  18. package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
  19. package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
  20. package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
  21. package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
  22. package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
  23. package/telegram-plugin/uat/flip/recall-log.ts +178 -0
  24. package/telegram-plugin/uat/flip/report.ts +95 -0
  25. package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
  26. package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
  27. package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
  28. package/telegram-plugin/uat/runners/scorer.ts +1 -1
  29. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  30. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  31. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  32. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  33. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  34. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
  35. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  36. package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
  37. package/vendor/hindsight-memory/scripts/recall.py +144 -11
  38. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  39. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Allowlist add/remove helper for the Memory v2 M3 directive-flip UAT.
3
+ *
4
+ * The flip UAT needs a throwaway probe user temporarily on an agent's DM
5
+ * allowlist so the (later) Tier-2 mtcute probe can talk to it, then removed
6
+ * again with zero residue — even if the runner crashes mid-flight. This module
7
+ * is that add/remove half, and NOTHING else: it does not run probes, flip
8
+ * agents, or touch bank state.
9
+ *
10
+ * The file it edits is `~/.switchroom/agents/<agent>/telegram/access.json` —
11
+ * the gateway's DM allowlist (`allowFrom: string[]`, see
12
+ * `telegram-plugin/gateway/access-store.ts`). Two invariants make it
13
+ * crash-safe:
14
+ *
15
+ * 1. WHOLE-OBJECT parse → modify → serialize, written atomically (temp file
16
+ * + `rename`), mode 0600. We never hand-edit a field in place, so a
17
+ * concurrent gateway reader never sees a half-written file and no sibling
18
+ * key (`groups`, `pending`, telegram feature flags) is dropped.
19
+ *
20
+ * 2. A sidecar RECEIPT (`access.json.uat-allowlist-receipt.json`) is written
21
+ * BEFORE `access.json` is mutated on `add`. The receipt records whether
22
+ * the id was already present (`preexisting`) and a +4h `expiresAt`. Revert
23
+ * is driven off the receipt, so:
24
+ * - `remove` reverts iff a receipt exists AND `preexisting` was false
25
+ * (an id the UAT itself added). If the id was already there before the
26
+ * UAT touched it, revert leaves it and just clears the receipt.
27
+ * - `remove --sweep` scans every agent dir and reverts any receipt it
28
+ * finds — the idempotent crash-recovery path. `--sweep --expired`
29
+ * restricts that to receipts past their `expiresAt`.
30
+ *
31
+ * Because the receipt is the source of truth for "did WE add this", the tool
32
+ * is safe to run repeatedly: a second `add` for an id already added is a no-op
33
+ * that leaves the original receipt (and its original `addedAt`) intact, and a
34
+ * `remove`/`sweep` with no receipt does nothing.
35
+ */
36
+
37
+ import {
38
+ mkdirSync,
39
+ readFileSync,
40
+ writeFileSync,
41
+ renameSync,
42
+ existsSync,
43
+ rmSync,
44
+ readdirSync,
45
+ } from "node:fs";
46
+ import { homedir } from "node:os";
47
+ import { join } from "node:path";
48
+
49
+ /** Default allowlist TTL: the probe window before a sweep should reclaim. */
50
+ export const DEFAULT_TTL_MS = 4 * 60 * 60 * 1000; // 4h
51
+
52
+ /** The sidecar receipt written next to access.json. */
53
+ export interface AllowlistReceipt {
54
+ agent: string;
55
+ userId: string;
56
+ /** ISO timestamp the receipt was written (the add moment). */
57
+ addedAt: string;
58
+ /** ISO timestamp after which a `--sweep --expired` will reclaim this id. */
59
+ expiresAt: string;
60
+ /** True when `userId` was ALREADY on the allowlist before the UAT ran — in
61
+ * that case revert must NOT remove it. */
62
+ preexisting: boolean;
63
+ }
64
+
65
+ /** Minimal shape of access.json we touch. All other keys pass through
66
+ * untouched via the parsed object (we serialize the whole thing). */
67
+ interface AccessFile {
68
+ allowFrom?: string[];
69
+ [k: string]: unknown;
70
+ }
71
+
72
+ export interface AllowlistDeps {
73
+ /** Root agents dir. Defaults to `~/.switchroom/agents`. Injected in tests. */
74
+ agentsDir?: string;
75
+ /** Clock seam for deterministic tests. */
76
+ now?: () => number;
77
+ /** TTL override (ms) for the receipt `expiresAt`. */
78
+ ttlMs?: number;
79
+ }
80
+
81
+ export interface AddResult {
82
+ agent: string;
83
+ userId: string;
84
+ /** True when this call actually appended the id. */
85
+ added: boolean;
86
+ /** True when the id was already on the allowlist before this call. */
87
+ preexisting: boolean;
88
+ receiptPath: string;
89
+ }
90
+
91
+ export interface RemoveResult {
92
+ agent: string;
93
+ userId: string | null;
94
+ /** True when the id was removed from the allowlist by this call. */
95
+ reverted: boolean;
96
+ /** Why nothing was reverted, when `reverted` is false. */
97
+ reason?: "no-receipt" | "preexisting" | "not-present";
98
+ receiptCleared: boolean;
99
+ }
100
+
101
+ function resolveAgentsDir(deps: AllowlistDeps): string {
102
+ return deps.agentsDir ?? join(homedir(), ".switchroom", "agents");
103
+ }
104
+
105
+ /** Absolute path to an agent's access.json. */
106
+ export function accessFilePath(agentsDir: string, agent: string): string {
107
+ return join(agentsDir, agent, "telegram", "access.json");
108
+ }
109
+
110
+ /** Absolute path to an agent's UAT allowlist receipt. */
111
+ export function receiptPath(agentsDir: string, agent: string): string {
112
+ return accessFilePath(agentsDir, agent) + ".uat-allowlist-receipt.json";
113
+ }
114
+
115
+ /** Read + parse access.json, or `{}` when absent. Throws on malformed JSON —
116
+ * the UAT must NOT silently clobber a file it can't parse. */
117
+ function readAccess(path: string): AccessFile {
118
+ if (!existsSync(path)) return {};
119
+ const raw = readFileSync(path, "utf8");
120
+ const parsed = JSON.parse(raw) as AccessFile;
121
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
122
+ throw new Error(`access.json at ${path} is not a JSON object`);
123
+ }
124
+ return parsed;
125
+ }
126
+
127
+ /** Atomic whole-object write: temp file + rename, mode 0600, dir 0700. */
128
+ function atomicWriteJson(path: string, value: unknown): void {
129
+ const dir = join(path, "..");
130
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
132
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
133
+ renameSync(tmp, path);
134
+ }
135
+
136
+ function readReceipt(path: string): AllowlistReceipt | null {
137
+ if (!existsSync(path)) return null;
138
+ try {
139
+ return JSON.parse(readFileSync(path, "utf8")) as AllowlistReceipt;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Add `userId` to `<agent>`'s DM allowlist, writing the receipt FIRST.
147
+ *
148
+ * Crash-safety ordering: the receipt lands before access.json is mutated, so a
149
+ * crash between the two leaves a receipt whose `preexisting` correctly records
150
+ * the pre-mutation state — a later sweep reverts exactly what it should.
151
+ *
152
+ * Idempotent: if a receipt already exists for this agent (a prior add), this is
153
+ * a no-op that preserves the original receipt (and its `preexisting`/`addedAt`),
154
+ * so a double-add can't overwrite the truth about whether WE added the id.
155
+ */
156
+ export function addToAllowlist(agent: string, userId: string, deps: AllowlistDeps = {}): AddResult {
157
+ const agentsDir = resolveAgentsDir(deps);
158
+ const nowMs = (deps.now ?? Date.now)();
159
+ const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS;
160
+ const accessPath = accessFilePath(agentsDir, agent);
161
+ const rcptPath = receiptPath(agentsDir, agent);
162
+
163
+ const access = readAccess(accessPath);
164
+ const allowFrom = Array.isArray(access.allowFrom) ? access.allowFrom.map(String) : [];
165
+ const preexisting = allowFrom.includes(userId);
166
+
167
+ const existingReceipt = readReceipt(rcptPath);
168
+ if (existingReceipt) {
169
+ // A prior add already recorded the truth — do not re-mutate or re-stamp.
170
+ return {
171
+ agent,
172
+ userId,
173
+ added: false,
174
+ preexisting: existingReceipt.preexisting,
175
+ receiptPath: rcptPath,
176
+ };
177
+ }
178
+
179
+ // Receipt FIRST, before any mutation — the crash-safety invariant.
180
+ const receipt: AllowlistReceipt = {
181
+ agent,
182
+ userId,
183
+ addedAt: new Date(nowMs).toISOString(),
184
+ expiresAt: new Date(nowMs + ttlMs).toISOString(),
185
+ preexisting,
186
+ };
187
+ atomicWriteJson(rcptPath, receipt);
188
+
189
+ if (preexisting) {
190
+ // Already allowlisted — nothing to add, receipt records preexisting:true.
191
+ return { agent, userId, added: false, preexisting: true, receiptPath: rcptPath };
192
+ }
193
+
194
+ access.allowFrom = [...allowFrom, userId];
195
+ atomicWriteJson(accessPath, access);
196
+ return { agent, userId, added: true, preexisting: false, receiptPath: rcptPath };
197
+ }
198
+
199
+ /**
200
+ * Revert an agent's allowlist add. Only removes the id when a receipt exists
201
+ * AND `preexisting` was false; always clears the receipt when one exists.
202
+ */
203
+ export function removeFromAllowlist(agent: string, deps: AllowlistDeps = {}): RemoveResult {
204
+ const agentsDir = resolveAgentsDir(deps);
205
+ const accessPath = accessFilePath(agentsDir, agent);
206
+ const rcptPath = receiptPath(agentsDir, agent);
207
+
208
+ const receipt = readReceipt(rcptPath);
209
+ if (!receipt) {
210
+ return { agent, userId: null, reverted: false, reason: "no-receipt", receiptCleared: false };
211
+ }
212
+
213
+ // Clear the receipt regardless of the branch below — it is single-use.
214
+ const clearReceipt = () => {
215
+ rmSync(rcptPath, { force: true });
216
+ };
217
+
218
+ if (receipt.preexisting) {
219
+ // The id was there before the UAT — leave the allowlist, drop the receipt.
220
+ clearReceipt();
221
+ return {
222
+ agent,
223
+ userId: receipt.userId,
224
+ reverted: false,
225
+ reason: "preexisting",
226
+ receiptCleared: true,
227
+ };
228
+ }
229
+
230
+ const access = readAccess(accessPath);
231
+ const allowFrom = Array.isArray(access.allowFrom) ? access.allowFrom.map(String) : [];
232
+ if (!allowFrom.includes(receipt.userId)) {
233
+ // Already gone (idempotent re-run) — just clear the receipt.
234
+ clearReceipt();
235
+ return {
236
+ agent,
237
+ userId: receipt.userId,
238
+ reverted: false,
239
+ reason: "not-present",
240
+ receiptCleared: true,
241
+ };
242
+ }
243
+
244
+ access.allowFrom = allowFrom.filter((id) => id !== receipt.userId);
245
+ atomicWriteJson(accessPath, access);
246
+ clearReceipt();
247
+ return { agent, userId: receipt.userId, reverted: true, receiptCleared: true };
248
+ }
249
+
250
+ export interface SweepOptions extends AllowlistDeps {
251
+ /** When true, only revert receipts whose `expiresAt` is in the past. */
252
+ expiredOnly?: boolean;
253
+ }
254
+
255
+ /**
256
+ * Scan every agent dir under `agentsDir` and revert any UAT allowlist receipt.
257
+ * Idempotent — safe to run on a clean fleet (returns an empty result set). With
258
+ * `expiredOnly`, only receipts past their `expiresAt` are reverted.
259
+ */
260
+ export function sweepAllowlist(opts: SweepOptions = {}): RemoveResult[] {
261
+ const agentsDir = resolveAgentsDir(opts);
262
+ const nowMs = (opts.now ?? Date.now)();
263
+ const results: RemoveResult[] = [];
264
+ if (!existsSync(agentsDir)) return results;
265
+
266
+ for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
267
+ if (!entry.isDirectory()) continue;
268
+ const agent = entry.name;
269
+ const rcptPath = receiptPath(agentsDir, agent);
270
+ const receipt = readReceipt(rcptPath);
271
+ if (!receipt) continue;
272
+ if (opts.expiredOnly) {
273
+ const expiresMs = Date.parse(receipt.expiresAt);
274
+ if (!Number.isNaN(expiresMs) && expiresMs > nowMs) continue; // not yet expired
275
+ }
276
+ results.push(removeFromAllowlist(agent, opts));
277
+ }
278
+ return results;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // CLI
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function usage(): string {
286
+ return [
287
+ "usage: allowlist <command> [args]",
288
+ "",
289
+ " add <agent> <userId> add userId to <agent>'s DM allowlist (writes receipt first)",
290
+ " remove <agent> revert the receipted add for <agent>",
291
+ " remove --sweep revert every agent's receipt (idempotent)",
292
+ " remove --sweep --expired revert only receipts past their expiresAt",
293
+ "",
294
+ " --agents-dir <path> override the agents root (default ~/.switchroom/agents)",
295
+ ].join("\n");
296
+ }
297
+
298
+ /** Parse argv (sans node/script) and dispatch. Returns process exit code.
299
+ * Exported for direct unit testing of the arg surface. */
300
+ export function runAllowlistCli(argv: string[]): number {
301
+ const args = [...argv];
302
+ let agentsDir: string | undefined;
303
+ const dirIdx = args.indexOf("--agents-dir");
304
+ if (dirIdx !== -1) {
305
+ agentsDir = args[dirIdx + 1];
306
+ args.splice(dirIdx, 2);
307
+ }
308
+ const deps: AllowlistDeps = agentsDir ? { agentsDir } : {};
309
+
310
+ const cmd = args[0];
311
+ if (cmd === "add") {
312
+ const [, agent, userId] = args;
313
+ if (!agent || !userId) {
314
+ process.stderr.write("add requires <agent> <userId>\n" + usage() + "\n");
315
+ return 1;
316
+ }
317
+ const r = addToAllowlist(agent, userId, deps);
318
+ process.stdout.write(JSON.stringify(r) + "\n");
319
+ return 0;
320
+ }
321
+ if (cmd === "remove") {
322
+ if (args.includes("--sweep")) {
323
+ const expiredOnly = args.includes("--expired");
324
+ const rs = sweepAllowlist({ ...deps, expiredOnly });
325
+ process.stdout.write(JSON.stringify(rs) + "\n");
326
+ return 0;
327
+ }
328
+ const agent = args[1];
329
+ if (!agent) {
330
+ process.stderr.write("remove requires <agent> (or --sweep)\n" + usage() + "\n");
331
+ return 1;
332
+ }
333
+ const r = removeFromAllowlist(agent, deps);
334
+ process.stdout.write(JSON.stringify(r) + "\n");
335
+ return 0;
336
+ }
337
+
338
+ process.stderr.write(usage() + "\n");
339
+ return cmd ? 1 : 0;
340
+ }
341
+
342
+ // Direct-exec entry (bun/node run this file). Guarded so imports don't run it.
343
+ if (
344
+ typeof process !== "undefined" &&
345
+ process.argv[1] &&
346
+ (process.argv[1].endsWith("allowlist.ts") || process.argv[1].endsWith("allowlist.js"))
347
+ ) {
348
+ process.exit(runAllowlistCli(process.argv.slice(2)));
349
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Unit suite for the flip-gate verdict logic + report rendering. Runs under
3
+ * `bun test` (this tree is vitest-excluded) via the `uat/flip/` entry in
4
+ * telegram-plugin/scripts/bun-test-ci.sh. Pure fixtures — no IO.
5
+ */
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import { evaluateGate, runGate, type GateInput, type Tier2ProbeResults } from "./gate.js";
9
+ import { renderFlipReport, renderVerdictLine } from "./report.js";
10
+ import type { EquivalenceReport } from "./tier1-equivalence.js";
11
+ import type { DirectiveInjectionDelta } from "./recall-log.js";
12
+
13
+ function passingTier1(): EquivalenceReport {
14
+ return {
15
+ pass: true,
16
+ missing_from_rules: [],
17
+ truncated_or_drifted: [],
18
+ unsourced_rules: [],
19
+ renderedBytes: 1000,
20
+ budgetBytes: 6144,
21
+ withinBudget: true,
22
+ sentinelCount: 3,
23
+ ruleCount: 3,
24
+ sentinelMatchesCount: true,
25
+ residueDirectiveCount: 3,
26
+ };
27
+ }
28
+
29
+ function suppressedDelta(): DirectiveInjectionDelta {
30
+ return {
31
+ baseline: { rowCount: 2, maxDirectiveCount: 6, lastDirectiveCount: 6, everInjectedIds: ["a"], maxDirectivesOmitted: 0 },
32
+ postflip: { rowCount: 2, maxDirectiveCount: 0, lastDirectiveCount: 0, everInjectedIds: [], maxDirectivesOmitted: 0 },
33
+ volumeDelta: 6,
34
+ postflipFullySuppressed: true,
35
+ residualIds: [],
36
+ };
37
+ }
38
+
39
+ describe("evaluateGate", () => {
40
+ it("passes when Tier-1 is clean and no optional inputs are supplied", () => {
41
+ const v = evaluateGate({ agent: "ziggy", tier1: passingTier1() });
42
+ expect(v.pass).toBe(true);
43
+ // recall_log + tier2 recorded as skipped, not failing.
44
+ const skipped = v.checks.filter((c) => c.skipped).map((c) => c.name);
45
+ expect(skipped).toContain("recall_log: directives suppressed postflip");
46
+ expect(skipped).toContain("tier2: behavioural probes hold");
47
+ });
48
+
49
+ it("fails on a Tier-1 missing guardrail and names it", () => {
50
+ const t1 = passingTier1();
51
+ t1.pass = false;
52
+ t1.missing_from_rules = [{ id: "d9", name: "guard", reason: "unmapped" }];
53
+ const v = evaluateGate({ agent: "ziggy", tier1: t1 });
54
+ expect(v.pass).toBe(false);
55
+ const c = v.checks.find((c) => c.name === "tier1: no missing guardrails")!;
56
+ expect(c.pass).toBe(false);
57
+ expect(c.detail).toContain("d9(unmapped)");
58
+ });
59
+
60
+ it("fails on an over-budget Tier-1 block", () => {
61
+ const t1 = passingTier1();
62
+ t1.withinBudget = false;
63
+ t1.renderedBytes = 7000;
64
+ const v = evaluateGate({ agent: "ziggy", tier1: t1 });
65
+ expect(v.pass).toBe(false);
66
+ expect(v.checks.find((c) => c.name === "tier1: within 6144B budget")!.pass).toBe(false);
67
+ });
68
+
69
+ it("passes recall_log when postflip injection is fully suppressed", () => {
70
+ const v = evaluateGate({ agent: "ziggy", tier1: passingTier1(), recallLog: suppressedDelta() });
71
+ const c = v.checks.find((c) => c.name === "recall_log: directives suppressed postflip")!;
72
+ expect(c.pass).toBe(true);
73
+ expect(c.skipped).toBeUndefined();
74
+ expect(v.pass).toBe(true);
75
+ });
76
+
77
+ it("fails recall_log when directives are still injected postflip", () => {
78
+ const d = suppressedDelta();
79
+ d.postflipFullySuppressed = false;
80
+ d.postflip.maxDirectiveCount = 2;
81
+ d.residualIds = ["a", "z"];
82
+ const v = evaluateGate({ agent: "ziggy", tier1: passingTier1(), recallLog: d });
83
+ expect(v.pass).toBe(false);
84
+ const c = v.checks.find((c) => c.name === "recall_log: directives suppressed postflip")!;
85
+ expect(c.detail).toContain("a, z");
86
+ });
87
+
88
+ it("folds a Tier-2 result in when supplied (seam)", () => {
89
+ const failing: Tier2ProbeResults = { pass: false, probes: [{ directiveId: "d1", held: false }] };
90
+ const v = evaluateGate({ agent: "ziggy", tier1: passingTier1(), tier2: failing });
91
+ expect(v.pass).toBe(false);
92
+ const c = v.checks.find((c) => c.name === "tier2: behavioural probes hold")!;
93
+ expect(c.pass).toBe(false);
94
+ expect(c.skipped).toBeUndefined();
95
+ });
96
+
97
+ it("treats tier2:null as skipped, not a failure", () => {
98
+ const v = evaluateGate({ agent: "ziggy", tier1: passingTier1(), tier2: null });
99
+ expect(v.pass).toBe(true);
100
+ expect(v.checks.find((c) => c.name === "tier2: behavioural probes hold")!.skipped).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe("runGate", () => {
105
+ it("exit 0 when every agent passes", () => {
106
+ const inputs: GateInput[] = [
107
+ { agent: "a", tier1: passingTier1() },
108
+ { agent: "b", tier1: passingTier1() },
109
+ ];
110
+ expect(runGate(inputs).exitCode).toBe(0);
111
+ });
112
+
113
+ it("exit 1 when any agent fails", () => {
114
+ const bad = passingTier1();
115
+ bad.unsourced_rules = [{ id: "R-9", text: "invented" }];
116
+ const run = runGate([
117
+ { agent: "a", tier1: passingTier1() },
118
+ { agent: "b", tier1: bad },
119
+ ]);
120
+ expect(run.exitCode).toBe(1);
121
+ expect(run.verdicts.find((v) => v.agent === "b")!.pass).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("renderFlipReport", () => {
126
+ it("renders a PASS matrix with no triage rows", () => {
127
+ const run = runGate([{ agent: "ziggy", tier1: passingTier1(), recallLog: suppressedDelta() }]);
128
+ const md = renderFlipReport(run, { startedAt: new Date("2026-08-18T00:00:00Z"), durationSeconds: 1.2 });
129
+ expect(md).toContain("**Verdict:** PASS (1/1 agents green)");
130
+ expect(md).toContain("| `ziggy` | PASS |");
131
+ expect(md).toContain("No failing checks");
132
+ });
133
+
134
+ it("renders a FAIL matrix and lists the failing check verbatim", () => {
135
+ const bad = passingTier1();
136
+ bad.unsourced_rules = [{ id: "R-9", text: "invented" }];
137
+ const run = runGate([{ agent: "ziggy", tier1: bad }]);
138
+ const md = renderFlipReport(run);
139
+ expect(md).toContain("**Verdict:** FAIL");
140
+ expect(md).toContain("| `ziggy` | FAIL |");
141
+ expect(md).toContain("no unsourced rules");
142
+ expect(md).toContain("R-9");
143
+ });
144
+ });
145
+
146
+ describe("renderVerdictLine", () => {
147
+ it("summarizes one verdict compactly", () => {
148
+ const v = evaluateGate({ agent: "ziggy", tier1: passingTier1() });
149
+ const line = renderVerdictLine(v);
150
+ expect(line).toContain("PASS ziggy");
151
+ expect(line).toContain("within 6144B budget");
152
+ });
153
+ });