probatio 0.0.0-stage → 0.0.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.
@@ -0,0 +1,256 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { SCHEMA_VERSION } from "../contract.js";
4
+ import { filesInDiff } from "../mutate/patch.js";
5
+ const METRIC = /^(holdout|real \(train\)|synthetic-1|synthetic-2):\s+(\d+)\/(\d+)(?:\s+\([^)]*\))?(?:\s+missed\s+(.+))?$/;
6
+ const PREFIX = /^(?:ho|r|h|v)-/;
7
+ /** Names of tests that killed nothing. This never deletes a file. */
8
+ export function advisePrune(kills) {
9
+ const advice = [...kills.entries()]
10
+ .filter(([, killed]) => killed === 0)
11
+ .map(([name]) => name)
12
+ .sort();
13
+ return { mode: "advisory", deletedTests: 0, advice };
14
+ }
15
+ export function parseUnionTsv(text) {
16
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
17
+ if (lines.length === 0)
18
+ return [];
19
+ const columns = lines[0].split("\t").map(parseHeader);
20
+ return lines.slice(1).map((line) => {
21
+ const cells = line.split("\t");
22
+ const row = emptyRow("union");
23
+ for (let i = 0; i < columns.length; i++) {
24
+ const column = columns[i];
25
+ const cell = (cells[i] ?? "").trim();
26
+ if (column.kind === "set") {
27
+ row.set = cell;
28
+ row.name = cell;
29
+ }
30
+ else if (column.kind === "lines") {
31
+ const lines = Number(cell);
32
+ if (!Number.isInteger(lines))
33
+ throw new Error(`lines is not an integer: ${cell}`);
34
+ row.lines = lines;
35
+ }
36
+ else if (column.total === null) {
37
+ throw new Error(`column ${column.kind} has no total`);
38
+ }
39
+ else {
40
+ assign(row, column.kind, parseCaught(cell, column.total));
41
+ }
42
+ }
43
+ return row;
44
+ });
45
+ }
46
+ export function parseDirectScores(text) {
47
+ const rows = [];
48
+ let current = null;
49
+ for (const raw of text.split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (!line)
52
+ continue;
53
+ const metric = line.match(METRIC);
54
+ if (!metric) {
55
+ current = emptyRow("direct");
56
+ current.name = line;
57
+ current.set = line.split("(")[0].trim().split(/\s+/).join("");
58
+ rows.push(current);
59
+ continue;
60
+ }
61
+ if (!current)
62
+ throw new Error(`metric before a suite: ${line}`);
63
+ assign(current, metricKind(metric[1]), { caught: Number(metric[2]), total: Number(metric[3]) });
64
+ if (metric[4])
65
+ current.missed.push(...metric[4].trim().split(/\s+/).filter(Boolean));
66
+ }
67
+ return rows;
68
+ }
69
+ export function reportMatrix(input) {
70
+ const rows = [...parseUnionTsv(input.scores), ...parseDirectScores(input.direct)];
71
+ if (rows.length === 0) {
72
+ return {
73
+ schemaVersion: SCHEMA_VERSION,
74
+ ok: false,
75
+ command: "matrix.report",
76
+ summary: "The score files have no suite rows.",
77
+ next: "Pass --scores and --direct that contain the recorded table.",
78
+ nextCall: null,
79
+ };
80
+ }
81
+ const reference = rows.find((row) => row.source === "union" && row.holdout) ?? null;
82
+ const gapRow = weakestHoldout(rows.filter((row) => row.source === "direct"));
83
+ const gaps = resolveGaps(gapRow?.missed ?? [], input.patchDirs);
84
+ const shown = gaps.slice(0, 10);
85
+ const collapse = {
86
+ batch2: rows
87
+ .filter((row) => row.source === "union" && lowerRate(row.synthetic2, row.synthetic1))
88
+ .map((row) => ({ set: row.set, synthetic1: row.synthetic1, synthetic2: row.synthetic2 })),
89
+ holdoutBelowReference: reference
90
+ ? rows
91
+ .filter((row) => row !== reference && lowerRate(row.holdout, reference.holdout))
92
+ .map((row) => ({ set: row.set, holdout: row.holdout, reference: reference.holdout }))
93
+ : [],
94
+ };
95
+ const pruning = advisePrune(input.kills);
96
+ const fullPath = path.join(input.outDir, "matrix.json");
97
+ const complete = {
98
+ schemaVersion: SCHEMA_VERSION,
99
+ ok: true,
100
+ command: "matrix.report",
101
+ summary: summarize(gapRow, collapse.batch2, gaps.length, pruning.deletedTests),
102
+ next: nextSentence(gaps),
103
+ nextCall: null,
104
+ rows,
105
+ collapse,
106
+ gaps,
107
+ rest: 0,
108
+ pruning,
109
+ full: fullPath,
110
+ };
111
+ mkdirSync(input.outDir, { recursive: true });
112
+ writeFileSync(fullPath, `${JSON.stringify(complete, null, 2)}\n`);
113
+ return {
114
+ ...complete,
115
+ gaps: shown,
116
+ rest: gaps.length - shown.length,
117
+ };
118
+ }
119
+ export function readKills(file) {
120
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
121
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
122
+ throw new Error("--kills must be a JSON object of test name to kill count");
123
+ const kills = new Map();
124
+ for (const [name, value] of Object.entries(parsed)) {
125
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
126
+ throw new Error(`kill count for ${name} must be a non-negative integer`);
127
+ }
128
+ kills.set(name, value);
129
+ }
130
+ return kills;
131
+ }
132
+ function summarize(gapRow, batch2, gapCount, deletedTests) {
133
+ const parts = [];
134
+ if (gapRow?.holdout) {
135
+ const train = gapRow.real ? ` Train real is ${gapRow.real.caught}/${gapRow.real.total}.` : "";
136
+ parts.push(`${gapRow.set} holdout is ${gapRow.holdout.caught}/${gapRow.holdout.total}.${train}`);
137
+ }
138
+ if (batch2.length > 0) {
139
+ const bits = batch2.map((item) => {
140
+ const left = item.synthetic1;
141
+ const right = item.synthetic2;
142
+ return `${item.set} synthetic-1 ${left?.caught}/${left?.total} against synthetic-2 ${right?.caught}/${right?.total}`;
143
+ });
144
+ parts.push(`Cover collapse on batch 2: ${bits.join("; ")}.`);
145
+ }
146
+ parts.push(`${gapCount} gaps. Pruning is advisory and deleted ${deletedTests} tests.`);
147
+ return parts.join(" ");
148
+ }
149
+ function nextSentence(gaps) {
150
+ const first = gaps.find((gap) => gap.file !== null && gap.line !== null) ?? gaps[0];
151
+ if (!first)
152
+ return "No gaps.";
153
+ if (first.file === null || first.line === null)
154
+ return `${first.id} has no patch, so there is no file:line.`;
155
+ return `${first.id} at ${first.file}:${first.line}`;
156
+ }
157
+ function weakestHoldout(rows) {
158
+ let best = null;
159
+ let bestRate = Number.POSITIVE_INFINITY;
160
+ for (const row of rows) {
161
+ const value = rate(row.holdout);
162
+ if (value === null || value >= bestRate)
163
+ continue;
164
+ best = row;
165
+ bestRate = value;
166
+ }
167
+ return best;
168
+ }
169
+ function lowerRate(left, right) {
170
+ const a = rate(left);
171
+ const b = rate(right);
172
+ return a !== null && b !== null && a < b;
173
+ }
174
+ function rate(count) {
175
+ if (!count || count.total === 0)
176
+ return null;
177
+ return count.caught / count.total;
178
+ }
179
+ function resolveGaps(ids, patchDirs) {
180
+ const unique = [];
181
+ const seen = new Set();
182
+ for (const id of ids) {
183
+ if (seen.has(id))
184
+ continue;
185
+ seen.add(id);
186
+ unique.push(id);
187
+ }
188
+ unique.sort();
189
+ return unique.map((id) => locate(id, patchDirs));
190
+ }
191
+ function locate(id, patchDirs) {
192
+ const bare = id.replace(PREFIX, "");
193
+ const names = [...new Set([`${id}.patch`, `r-${bare}.patch`, `h-${bare}.patch`, `${bare}.patch`, `ho-${bare}.patch`])];
194
+ for (const dir of patchDirs) {
195
+ for (const name of names) {
196
+ const candidate = path.join(dir, name);
197
+ let raw;
198
+ try {
199
+ raw = readFileSync(candidate, "utf8");
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ const locations = filesInDiff(raw);
205
+ const first = locations[0];
206
+ return { id, file: first?.file ?? null, line: first?.line ?? null, locations };
207
+ }
208
+ }
209
+ return { id, file: null, line: null, locations: [] };
210
+ }
211
+ function parseHeader(cell) {
212
+ const match = cell.trim().match(/^(.*?)\s*\((\d+)\)$/);
213
+ if (!match)
214
+ return { kind: cell.trim(), total: null };
215
+ return { kind: metricKind(match[1].trim()), total: Number(match[2]) };
216
+ }
217
+ function metricKind(name) {
218
+ if (name === "synthetic-1")
219
+ return "synthetic1";
220
+ if (name === "synthetic-2")
221
+ return "synthetic2";
222
+ if (name === "real (train)" || name === "real")
223
+ return "real";
224
+ return name;
225
+ }
226
+ function parseCaught(cell, total) {
227
+ const match = cell.match(/^(\d+)/);
228
+ if (!match)
229
+ throw new Error(`cannot read a count from "${cell}"`);
230
+ return { caught: Number(match[1]), total };
231
+ }
232
+ function assign(row, kind, count) {
233
+ if (kind === "holdout")
234
+ row.holdout = count;
235
+ else if (kind === "real")
236
+ row.real = count;
237
+ else if (kind === "synthetic1")
238
+ row.synthetic1 = count;
239
+ else if (kind === "synthetic2")
240
+ row.synthetic2 = count;
241
+ else
242
+ throw new Error(`unknown score column ${kind}`);
243
+ }
244
+ function emptyRow(source) {
245
+ return {
246
+ set: "",
247
+ name: "",
248
+ source,
249
+ lines: null,
250
+ holdout: null,
251
+ real: null,
252
+ synthetic1: null,
253
+ synthetic2: null,
254
+ missed: [],
255
+ };
256
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const here = path.dirname(fileURLToPath(import.meta.url));
7
+ let buffer = Buffer.alloc(0);
8
+ process.stdin.on("data", (chunk) => {
9
+ buffer = Buffer.concat([buffer, chunk]);
10
+ drain();
11
+ });
12
+ process.stdin.on("end", () => process.exit(0));
13
+ function drain() {
14
+ for (;;) {
15
+ const headerEnd = buffer.indexOf("\r\n\r\n");
16
+ if (headerEnd === -1)
17
+ return;
18
+ const header = buffer.subarray(0, headerEnd).toString("utf8");
19
+ const match = header.match(/Content-Length:\s*(\d+)/i);
20
+ const start = headerEnd + 4;
21
+ if (!match) {
22
+ buffer = buffer.subarray(start);
23
+ continue;
24
+ }
25
+ const length = Number(match[1]);
26
+ if (buffer.length < start + length)
27
+ return;
28
+ const body = buffer.subarray(start, start + length).toString("utf8");
29
+ buffer = buffer.subarray(start + length);
30
+ let message;
31
+ try {
32
+ message = JSON.parse(body);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ handle(message);
38
+ }
39
+ }
40
+ function handle(message) {
41
+ if (!message.method)
42
+ return;
43
+ if (message.id === undefined || message.id === null)
44
+ return;
45
+ if (message.method === "initialize") {
46
+ send({
47
+ jsonrpc: "2.0",
48
+ id: message.id,
49
+ result: {
50
+ protocolVersion: "2024-11-05",
51
+ capabilities: { tools: {} },
52
+ serverInfo: { name: "probatio", version: "0.0.1" },
53
+ },
54
+ });
55
+ return;
56
+ }
57
+ if (message.method === "tools/list") {
58
+ send({
59
+ jsonrpc: "2.0",
60
+ id: message.id,
61
+ result: {
62
+ tools: [
63
+ {
64
+ name: "probatio",
65
+ description: "Run one probatio command. argv is the CLI words and flags.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: { argv: { type: "array", items: { type: "string" } } },
69
+ required: ["argv"],
70
+ },
71
+ },
72
+ ],
73
+ },
74
+ });
75
+ return;
76
+ }
77
+ if (message.method === "tools/call") {
78
+ const argv = message.params?.arguments?.argv;
79
+ if (!Array.isArray(argv) || argv.some((item) => typeof item !== "string")) {
80
+ send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "argv must be an array of strings" } });
81
+ return;
82
+ }
83
+ const cli = cliCommand();
84
+ const child = spawnSync(cli.bin, [...cli.args, ...argv], { encoding: "utf8" });
85
+ const text = child.stdout ?? "";
86
+ let envelope = null;
87
+ try {
88
+ envelope = JSON.parse(text);
89
+ }
90
+ catch {
91
+ envelope = null;
92
+ }
93
+ if (!envelope || typeof envelope !== "object") {
94
+ send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "the command did not return one JSON object" } });
95
+ return;
96
+ }
97
+ send({
98
+ jsonrpc: "2.0",
99
+ id: message.id,
100
+ result: {
101
+ content: [{ type: "text", text: text.trim() }],
102
+ structuredContent: envelope,
103
+ isError: envelope.ok !== true,
104
+ },
105
+ });
106
+ }
107
+ }
108
+ function cliCommand() {
109
+ const compiled = path.join(here, "cli.js");
110
+ if (existsSync(compiled))
111
+ return { bin: process.execPath, args: [compiled] };
112
+ const tsx = path.resolve(here, "..", "node_modules", ".bin", "tsx");
113
+ return { bin: tsx, args: [path.join(here, "cli.ts")] };
114
+ }
115
+ function send(message) {
116
+ const payload = Buffer.from(JSON.stringify(message), "utf8");
117
+ process.stdout.write(`Content-Length: ${payload.length}\r\n\r\n`);
118
+ process.stdout.write(payload);
119
+ }
@@ -0,0 +1,222 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { SCHEMA_VERSION } from "../contract.js";
5
+ import { makeRng, shuffle } from "../mutate/ids.js";
6
+ export const YARDSTICK = "these tests fit the yardstick, not the code.";
7
+ const FINDING_STATUS = new Set(["open", "fixed", "equivalent", "wont-fix"]);
8
+ export function lineHash(line) {
9
+ return createHash("sha256").update(line).digest("hex").slice(0, 16);
10
+ }
11
+ export function readSourceLine(file, line) {
12
+ if (!existsSync(file))
13
+ return null;
14
+ const lines = readFileSync(file, "utf8").split(/\r?\n/);
15
+ if (line < 1 || line > lines.length)
16
+ return null;
17
+ return lines[line - 1];
18
+ }
19
+ /** `Guard-Change: <guard>: <why>`. A guard such as `connect.test.ts:118` keeps its colon. */
20
+ export function guardChanges(message) {
21
+ const named = new Map();
22
+ for (const match of message.matchAll(/^Guard-Change:\s*(.+?):\s+(.+)$/gm))
23
+ named.set(match[1], match[2]);
24
+ return named;
25
+ }
26
+ export function emptyState() {
27
+ return { schemaVersion: SCHEMA_VERSION, gaps: [], decisions: [] };
28
+ }
29
+ export function loadState(dir) {
30
+ const file = path.join(dir, "state.json");
31
+ if (!existsSync(file))
32
+ return emptyState();
33
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
34
+ return { schemaVersion: SCHEMA_VERSION, gaps: parsed.gaps ?? [], decisions: parsed.decisions ?? [] };
35
+ }
36
+ export function saveState(dir, state) {
37
+ mkdirSync(dir, { recursive: true });
38
+ const ordered = {
39
+ schemaVersion: SCHEMA_VERSION,
40
+ gaps: [...state.gaps].sort((a, b) => a.id.localeCompare(b.id)),
41
+ decisions: [...state.decisions].sort((a, b) => a.id.localeCompare(b.id)),
42
+ };
43
+ writeFileSync(path.join(dir, "state.json"), `${JSON.stringify(ordered, null, 2)}\n`);
44
+ }
45
+ export function loadSealed(dir) {
46
+ const file = path.join(dir, "sealed.json");
47
+ if (!existsSync(file))
48
+ return [];
49
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
50
+ return [...new Set(parsed.ids ?? [])].sort();
51
+ }
52
+ export function sealIds(dir, ids) {
53
+ const merged = [...new Set([...loadSealed(dir), ...ids])].sort();
54
+ mkdirSync(dir, { recursive: true });
55
+ writeFileSync(path.join(dir, "sealed.json"), `${JSON.stringify({ ids: merged }, null, 2)}\n`);
56
+ }
57
+ /** A stable share of the ids. The same seed returns the same slice. */
58
+ export function chooseSealed(ids, fraction, seed) {
59
+ if (ids.length === 0 || fraction <= 0)
60
+ return [];
61
+ const count = Math.min(ids.length, Math.max(1, Math.round(ids.length * fraction)));
62
+ return shuffle([...ids], makeRng(seed)).slice(0, count).sort();
63
+ }
64
+ export function parseGuard(guard) {
65
+ const match = guard.match(/^(.*):(\d+)$/);
66
+ if (!match)
67
+ throw new Error("guard must be file:line");
68
+ return { file: match[1], line: Number(match[2]) };
69
+ }
70
+ export function fixGap(state, input) {
71
+ const located = parseGuard(input.guard);
72
+ const absolute = path.resolve(input.root, located.file);
73
+ const text = readSourceLine(absolute, located.line);
74
+ if (text === null)
75
+ throw new Error("guard line does not exist");
76
+ const hash = lineHash(text);
77
+ const gaps = state.gaps.filter((gap) => gap.id !== input.id);
78
+ gaps.push({
79
+ id: input.id,
80
+ status: "fixed",
81
+ file: input.file,
82
+ line: input.line,
83
+ guard: input.guard,
84
+ fixedIn: input.commit,
85
+ guardFile: located.file,
86
+ guardLineNo: located.line,
87
+ guardLine: text,
88
+ guardHash: hash,
89
+ });
90
+ const decisions = state.decisions.filter((item) => item.id !== input.id);
91
+ decisions.push({ id: input.id, file: located.file, line: located.line, hash, status: "fixed", reason: `guarded by ${input.guard}` });
92
+ return { ...state, gaps, decisions };
93
+ }
94
+ export function revertGap(state, id) {
95
+ const gap = state.gaps.find((item) => item.id === id);
96
+ if (!gap || !gap.fixedIn || !gap.guard)
97
+ throw new Error("that id is not a fixed gap");
98
+ const text = `fixed in ${gap.fixedIn}, guarded by ${gap.guard}; the guard broke`;
99
+ const gaps = state.gaps.map((item) => (item.id === id ? { ...item, status: "regression" } : item));
100
+ return { state: { ...state, gaps }, text };
101
+ }
102
+ export function guardAllows(state, guard, message, root) {
103
+ const owners = state.gaps.filter((gap) => gap.guard === guard && (gap.status === "fixed" || gap.status === "regression"));
104
+ if (owners.length === 0)
105
+ return { ok: true, summary: `${guard} guards nothing.` };
106
+ const owner = owners[0];
107
+ if (guardChanges(message).has(guard))
108
+ return { ok: true, summary: `${guard} changed with a Guard-Change trailer.` };
109
+ const file = owner.guardFile ? path.resolve(root, owner.guardFile) : "";
110
+ const line = owner.guardLineNo && file ? readSourceLine(file, owner.guardLineNo) : null;
111
+ const weakened = line === null || (owner.guardLine !== null && line !== owner.guardLine);
112
+ if (!weakened)
113
+ return { ok: true, summary: `${guard} is intact.` };
114
+ return { ok: false, summary: `Refusing to change ${guard}. It is the guard of ${owner.id}.` };
115
+ }
116
+ export function appendFinding(dir, finding) {
117
+ if (!FINDING_STATUS.has(finding.status))
118
+ throw new Error("status must be open, fixed, equivalent, or wont-fix");
119
+ if (!finding.reason || !finding.reason.trim())
120
+ throw new Error("a finding needs a reason");
121
+ mkdirSync(dir, { recursive: true });
122
+ appendFileSync(path.join(dir, "findings.jsonl"), `${JSON.stringify(finding)}\n`);
123
+ }
124
+ export function loadFindings(dir) {
125
+ const file = path.join(dir, "findings.jsonl");
126
+ if (!existsSync(file))
127
+ return [];
128
+ return readFileSync(file, "utf8")
129
+ .split(/\r?\n/)
130
+ .filter((line) => line.trim().length > 0)
131
+ .map((line) => JSON.parse(line));
132
+ }
133
+ export function yardstickSentence(score) {
134
+ if (!score)
135
+ return null;
136
+ if (score.visible.after > score.visible.before && score.sealed.after <= score.sealed.before)
137
+ return YARDSTICK;
138
+ return null;
139
+ }
140
+ export function statusView(dir, root) {
141
+ const state = loadState(dir);
142
+ const sealed = new Set(loadSealed(dir));
143
+ const hidden = (id) => sealed.has(id);
144
+ const open = state.gaps.filter((gap) => gap.status === "open" && !hidden(gap.id));
145
+ const shown = open.slice(0, 10);
146
+ const fixes = state.gaps
147
+ .filter((gap) => gap.status === "fixed" && !hidden(gap.id))
148
+ .map((gap) => ({ id: gap.id, fixedIn: gap.fixedIn, guard: gap.guard }));
149
+ const guards = [...new Set(state.gaps.filter((gap) => gap.guard && !hidden(gap.id)).map((gap) => gap.guard))].sort();
150
+ const decisions = state.decisions
151
+ .filter((item) => !hidden(item.id))
152
+ .map((item) => {
153
+ const current = readSourceLine(path.resolve(root, item.file), item.line);
154
+ const review = current !== null && lineHash(current) === item.hash ? "current" : "stale";
155
+ return { ...item, review };
156
+ });
157
+ const regressions = state.gaps
158
+ .filter((gap) => gap.status === "regression" && !hidden(gap.id) && gap.fixedIn && gap.guard)
159
+ .map((gap) => ({ id: gap.id, text: `fixed in ${gap.fixedIn}, guarded by ${gap.guard}; the guard broke` }));
160
+ const findings = loadFindings(dir).filter((item) => !hidden(item.id)).slice(-10);
161
+ return {
162
+ gaps: shown,
163
+ rest: Math.max(open.length - shown.length, 0),
164
+ recentFixes: fixes,
165
+ protectedGuards: guards,
166
+ decisions,
167
+ regressions,
168
+ findings,
169
+ sealedCount: sealed.size,
170
+ };
171
+ }
172
+ export function renderPage(view, summary) {
173
+ const lines = ["# PROBATIO", "", summary, "", "## Gaps"];
174
+ if (view.gaps.length === 0)
175
+ lines.push("None.");
176
+ for (const gap of view.gaps)
177
+ lines.push(`- ${gap.id} at ${gap.file}:${gap.line}`);
178
+ if (view.rest > 0)
179
+ lines.push(`- ${view.rest} more`);
180
+ lines.push("", "## Recent fixes");
181
+ if (view.recentFixes.length === 0)
182
+ lines.push("None.");
183
+ for (const fix of view.recentFixes)
184
+ lines.push(`- ${fix.id} fixed in ${fix.fixedIn}, guard ${fix.guard}`);
185
+ lines.push("", "## Protected guards");
186
+ if (view.protectedGuards.length === 0)
187
+ lines.push("None.");
188
+ for (const guard of view.protectedGuards)
189
+ lines.push(`- ${guard}`);
190
+ lines.push("", "## Decisions");
191
+ if (view.decisions.length === 0)
192
+ lines.push("None.");
193
+ for (const decision of view.decisions)
194
+ lines.push(`- ${decision.id} ${decision.status} ${decision.review} ${decision.reason}`);
195
+ lines.push("", "## Regressions");
196
+ if (view.regressions.length === 0)
197
+ lines.push("None.");
198
+ for (const item of view.regressions)
199
+ lines.push(`- ${item.id}: ${item.text}`);
200
+ lines.push("");
201
+ return lines.join("\n");
202
+ }
203
+ export function statusEnvelope(dir, root, score, agent) {
204
+ const view = statusView(dir, root);
205
+ const yard = yardstickSentence(score);
206
+ const summary = yard ?? `${view.gaps.length} open gaps, ${view.recentFixes.length} fixes, ${view.decisions.length} decisions.`;
207
+ const first = view.regressions[0]?.text ?? (view.gaps[0] ? `${view.gaps[0].id} at ${view.gaps[0].file}:${view.gaps[0].line}` : "Nothing waiting.");
208
+ const page = renderPage(view, summary);
209
+ return {
210
+ page,
211
+ envelope: {
212
+ schemaVersion: SCHEMA_VERSION,
213
+ ok: true,
214
+ command: "status",
215
+ summary,
216
+ next: first,
217
+ nextCall: null,
218
+ agent: agent ?? "unknown",
219
+ ...view,
220
+ },
221
+ };
222
+ }