switchroom 0.21.17 → 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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * recall_log.jsonl reader for the Memory v2 M3 directive-flip UAT gate.
3
+ *
4
+ * The recall hook appends one JSON row per turn to
5
+ * `<agent>/.claude/plugins/data/hindsight-memory-inline/state/recall_log.jsonl`
6
+ * (see `vendor/hindsight-memory/scripts/recall.py` `_write_recall_log`). Three
7
+ * fields on each row make the flip measurable WITHOUT a model:
8
+ *
9
+ * - `directive_count` — how many active directives were injected into
10
+ * the `<active_directives>` block that turn.
11
+ * - `directives_omitted` — how many the MAX_DIRECTIVES cap dropped.
12
+ * - `directive_ids` — the exact ids injected, priority-descending.
13
+ *
14
+ * The flip's whole point is that AFTER `memory.inject_directives:false` no
15
+ * directives are injected — so `directive_count` collapses to 0 and
16
+ * `directive_ids` empties. This reader + {@link directiveInjectionDelta} turn
17
+ * that into a deterministic before/after assertion the gate can fail on.
18
+ *
19
+ * HONEST SCOPE NOTE (real-source finding): recall_log rows carry directive
20
+ * COUNTS and IDS, not rendered token bytes — the byte/token size of the
21
+ * residue lives in the M2 residue harness (`src/memory/directive-residue.ts`)
22
+ * and the Tier-1 rules-block budget, not here. So the "token delta" the gate
23
+ * consumes from this file is a directive-injection-VOLUME delta (count of
24
+ * directives no longer injected), which is the recall_log's real signal;
25
+ * pairing it with the residue harness's byte number is the caller's job.
26
+ */
27
+
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+
32
+ /** One recall_log.jsonl row. Only the fields this gate reads are typed; the
33
+ * row carries many more (see recall.py) and they pass through untouched. */
34
+ export interface RecallLogRow {
35
+ /** ISO-8601 UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`). */
36
+ ts?: string;
37
+ directive_count?: number | null;
38
+ directives_omitted?: number | null;
39
+ directive_ids?: string[] | null;
40
+ [k: string]: unknown;
41
+ }
42
+
43
+ /** Absolute path to an agent's recall_log.jsonl. */
44
+ export function recallLogPath(agentsDir: string, agent: string): string {
45
+ return join(
46
+ agentsDir,
47
+ agent,
48
+ ".claude",
49
+ "plugins",
50
+ "data",
51
+ "hindsight-memory-inline",
52
+ "state",
53
+ "recall_log.jsonl",
54
+ );
55
+ }
56
+
57
+ export interface ReadRecallLogOptions {
58
+ /** Root agents dir. Defaults to `~/.switchroom/agents`. */
59
+ agentsDir?: string;
60
+ /** Return only the last N rows (the "tail"). Omit for all rows. */
61
+ tail?: number;
62
+ }
63
+
64
+ /**
65
+ * Read + parse an agent's recall_log.jsonl. Tolerant: a blank or malformed
66
+ * line is skipped, not thrown on (the log is append-only and the last line can
67
+ * be a partial write). Missing file ⇒ empty array. Rows come back in file
68
+ * order (oldest first); `tail` slices the most-recent N.
69
+ */
70
+ export function readRecallLog(agent: string, opts: ReadRecallLogOptions = {}): RecallLogRow[] {
71
+ const agentsDir = opts.agentsDir ?? join(homedir(), ".switchroom", "agents");
72
+ const path = recallLogPath(agentsDir, agent);
73
+ if (!existsSync(path)) return [];
74
+ const rows: RecallLogRow[] = [];
75
+ for (const line of readFileSync(path, "utf8").split("\n")) {
76
+ const trimmed = line.trim();
77
+ if (trimmed.length === 0) continue;
78
+ try {
79
+ rows.push(JSON.parse(trimmed) as RecallLogRow);
80
+ } catch {
81
+ // partial / corrupt line — skip.
82
+ }
83
+ }
84
+ return typeof opts.tail === "number" ? rows.slice(-opts.tail) : rows;
85
+ }
86
+
87
+ function num(v: number | null | undefined): number {
88
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
89
+ }
90
+
91
+ export interface InjectionSummary {
92
+ rowCount: number;
93
+ /** Peak `directive_count` seen across the window. */
94
+ maxDirectiveCount: number;
95
+ /** `directive_count` on the most-recent row (null when no rows). */
96
+ lastDirectiveCount: number | null;
97
+ /** Union of every id that appeared in any row's `directive_ids`. */
98
+ everInjectedIds: string[];
99
+ /** Peak `directives_omitted` — >0 means the cap was dropping real rules. */
100
+ maxDirectivesOmitted: number;
101
+ }
102
+
103
+ /** Summarize the directive-injection signal over a window of rows. */
104
+ export function summarizeInjection(rows: readonly RecallLogRow[]): InjectionSummary {
105
+ const ids = new Set<string>();
106
+ let maxCount = 0;
107
+ let maxOmitted = 0;
108
+ for (const r of rows) {
109
+ maxCount = Math.max(maxCount, num(r.directive_count));
110
+ maxOmitted = Math.max(maxOmitted, num(r.directives_omitted));
111
+ for (const id of r.directive_ids ?? []) ids.add(id);
112
+ }
113
+ const last = rows.length > 0 ? rows[rows.length - 1] : null;
114
+ return {
115
+ rowCount: rows.length,
116
+ maxDirectiveCount: maxCount,
117
+ lastDirectiveCount: last ? num(last.directive_count) : null,
118
+ everInjectedIds: [...ids],
119
+ maxDirectivesOmitted: maxOmitted,
120
+ };
121
+ }
122
+
123
+ export interface DirectiveInjectionDelta {
124
+ baseline: InjectionSummary;
125
+ postflip: InjectionSummary;
126
+ /** Drop in peak injected-directive volume (baseline − postflip). Positive is
127
+ * the expected direction: the flip stopped injecting directives. */
128
+ volumeDelta: number;
129
+ /** True when the postflip window injected ZERO directives — the flip's
130
+ * success condition on the recall_log side. */
131
+ postflipFullySuppressed: boolean;
132
+ /** Ids still injected postflip (should be empty after a real flip). */
133
+ residualIds: string[];
134
+ }
135
+
136
+ /**
137
+ * Compare a baseline window (before the flip) against a postflip window and
138
+ * report the directive-injection-volume delta. `postflipFullySuppressed` is
139
+ * the gate's success condition: after `inject_directives:false`, no directive
140
+ * is injected, so postflip `maxDirectiveCount` is 0 and `residualIds` empty.
141
+ *
142
+ * This is DETERMINISTIC and pure — the caller supplies the two windows
143
+ * (typically `readRecallLog(...).slice()` around the flip timestamp); this does
144
+ * no IO of its own.
145
+ */
146
+ export function directiveInjectionDelta(
147
+ baselineRows: readonly RecallLogRow[],
148
+ postflipRows: readonly RecallLogRow[],
149
+ ): DirectiveInjectionDelta {
150
+ const baseline = summarizeInjection(baselineRows);
151
+ const postflip = summarizeInjection(postflipRows);
152
+ return {
153
+ baseline,
154
+ postflip,
155
+ volumeDelta: baseline.maxDirectiveCount - postflip.maxDirectiveCount,
156
+ postflipFullySuppressed: postflip.maxDirectiveCount === 0 && postflip.everInjectedIds.length === 0,
157
+ residualIds: postflip.everInjectedIds,
158
+ };
159
+ }
160
+
161
+ /** Split a single row stream into baseline/postflip windows at a flip
162
+ * timestamp (ISO string). Rows with `ts < flipTs` are baseline; `ts >=
163
+ * flipTs` are postflip. A row missing `ts` is treated as baseline (it
164
+ * predates the instrumented flip). Convenience over hand-slicing. */
165
+ export function partitionByFlip(
166
+ rows: readonly RecallLogRow[],
167
+ flipTs: string,
168
+ ): { baseline: RecallLogRow[]; postflip: RecallLogRow[] } {
169
+ const flipMs = Date.parse(flipTs);
170
+ const baseline: RecallLogRow[] = [];
171
+ const postflip: RecallLogRow[] = [];
172
+ for (const r of rows) {
173
+ const t = r.ts ? Date.parse(r.ts) : NaN;
174
+ if (Number.isNaN(t) || Number.isNaN(flipMs) || t < flipMs) baseline.push(r);
175
+ else postflip.push(r);
176
+ }
177
+ return { baseline, postflip };
178
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Markdown report renderer for the M3 directive-flip UAT gate.
3
+ *
4
+ * Same shape as the agent-self-sufficiency runner's report
5
+ * (`telegram-plugin/uat/runners/report.ts`): a headline verdict, a per-agent ×
6
+ * per-check pass/fail matrix an operator reads in one glance, then a verbatim
7
+ * triage list of every failing check so a PR reviewer can diff without
8
+ * re-running. Pure — takes the gate verdicts, returns a string.
9
+ */
10
+
11
+ import type { GateRun, GateVerdict, GateCheck } from "./gate.js";
12
+
13
+ export interface FlipReportOptions {
14
+ startedAt?: Date;
15
+ durationSeconds?: number;
16
+ }
17
+
18
+ function mark(c: GateCheck): string {
19
+ if (c.skipped) return "—";
20
+ return c.pass ? "✅" : "❌";
21
+ }
22
+
23
+ export function renderFlipReport(run: GateRun, opts: FlipReportOptions = {}): string {
24
+ const { verdicts } = run;
25
+ const passed = verdicts.filter((v) => v.pass).length;
26
+ const lines: string[] = [];
27
+
28
+ lines.push("# M3 directive-flip UAT gate report");
29
+ lines.push("");
30
+ if (opts.startedAt) lines.push(`- **Run start:** ${opts.startedAt.toISOString()}`);
31
+ if (typeof opts.durationSeconds === "number")
32
+ lines.push(`- **Duration:** ${opts.durationSeconds.toFixed(1)}s`);
33
+ lines.push(`- **Agents:** ${verdicts.map((v) => v.agent).join(", ") || "(none)"}`);
34
+ lines.push(`- **Verdict:** ${run.exitCode === 0 ? "PASS" : "FAIL"} (${passed}/${verdicts.length} agents green)`);
35
+ lines.push("");
36
+
37
+ // Per-agent × per-check matrix. Check names are stable across agents, so use
38
+ // the first verdict's check order as the column set.
39
+ const checkNames = verdicts[0]?.checks.map((c) => c.name) ?? [];
40
+ if (checkNames.length > 0 && verdicts.length > 0) {
41
+ lines.push("## Check matrix");
42
+ lines.push("");
43
+ lines.push(`| Agent | Verdict | ${checkNames.map((n) => shortName(n)).join(" | ")} |`);
44
+ lines.push(`|---|---|${checkNames.map(() => "---").join("|")}|`);
45
+ for (const v of verdicts) {
46
+ const byName = new Map(v.checks.map((c) => [c.name, c]));
47
+ const cells = checkNames.map((n) => {
48
+ const c = byName.get(n);
49
+ return c ? mark(c) : "?";
50
+ });
51
+ lines.push(`| \`${v.agent}\` | ${v.pass ? "PASS" : "FAIL"} | ${cells.join(" | ")} |`);
52
+ }
53
+ lines.push("");
54
+ lines.push("_Legend: ✅ pass · ❌ fail · — skipped (input not supplied)._");
55
+ lines.push("");
56
+ }
57
+
58
+ // Triage — every failing check, verbatim detail.
59
+ const failing: Array<{ agent: string; check: GateCheck }> = [];
60
+ for (const v of verdicts) {
61
+ for (const c of v.checks) {
62
+ if (!c.pass && !c.skipped) failing.push({ agent: v.agent, check: c });
63
+ }
64
+ }
65
+ lines.push("## Triage — failing checks");
66
+ lines.push("");
67
+ if (failing.length === 0) {
68
+ lines.push("No failing checks. Every ran check passed.");
69
+ } else {
70
+ lines.push("| Agent | Check | Detail |");
71
+ lines.push("|---|---|---|");
72
+ for (const f of failing) {
73
+ lines.push(`| \`${f.agent}\` | ${escapeCell(f.check.name)} | ${escapeCell(f.check.detail)} |`);
74
+ }
75
+ }
76
+ lines.push("");
77
+
78
+ return lines.join("\n");
79
+ }
80
+
81
+ /** Render just one agent's verdict as a compact block (for a per-agent log). */
82
+ export function renderVerdictLine(v: GateVerdict): string {
83
+ const parts = v.checks.map((c) => `${mark(c)} ${shortName(c.name)}`);
84
+ return `${v.pass ? "PASS" : "FAIL"} ${v.agent}: ${parts.join(" · ")}`;
85
+ }
86
+
87
+ /** Drop the `tierN: ` / `recall_log: ` prefix for a compact column header. */
88
+ function shortName(name: string): string {
89
+ const idx = name.indexOf(": ");
90
+ return idx === -1 ? name : name.slice(idx + 2);
91
+ }
92
+
93
+ function escapeCell(s: string): string {
94
+ return s.replace(/\|/g, "\\|").replace(/\n/g, " ").replace(/`/g, "ʼ");
95
+ }