cortad 0.3.1-rc.4 → 0.3.1-rc.6

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/lib/cli.mjs CHANGED
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
6
+ import { markRun, putBack, writesDirOf } from "./writes.mjs";
6
7
  import { serveMcp } from "./mcp.mjs";
7
8
  import { SHOW } from "./read-text.mjs";
8
9
  import { cliSpec, VERSION } from "./spec.mjs";
@@ -35,9 +36,11 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
35
36
  const children = [];
36
37
  const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
37
38
  // The key is read at every call: the MCP process outlives a connect that writes it after the process started.
39
+ const writes = { mark: (id) => markRun(writesDirOf(homeOf(project)), id), putBack: () => putBack(writesDirOf(homeOf(project)), root) };
38
40
  const verbs = makeVerbs({
39
41
  api: `${origin.origin}/api`,
40
42
  root,
43
+ writes,
41
44
  token: () => readToken(project),
42
45
  pending,
43
46
  startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
package/lib/mcp.mjs CHANGED
@@ -10,7 +10,7 @@ export const PROTOCOL = "2025-06-18";
10
10
  // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
11
11
  export const INSTRUCTIONS = [
12
12
  "Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
13
- "Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting. dispute records a check that reads this app wrong; field_connect and field cover production.",
13
+ "Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting; its last sentence says whether the evidence supports keeping the fix. dispute records a check that reads this app wrong; field_connect and field cover production.",
14
14
  ].join("\n\n");
15
15
 
16
16
  const str = (description, extra = {}) => ({ type: "string", description, ...extra });
@@ -27,10 +27,10 @@ export const TOOLS = [
27
27
  description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
28
28
  inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
29
29
  { name: "findings", title: "Findings", annotations: readOnly,
30
- description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, and the trials a verify replays. Applies once a run has finished. A long list comes in pages.",
30
+ description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, the trials a verify replays and whether a verify of it can leave the noise. Applies once a run has finished. A long list comes in pages.",
31
31
  inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
32
32
  { name: "verify", title: "Verify a fix",
33
- description: "Replays one finding's trials with the same seeds after a change and reports the move, visible and held-out apart. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
33
+ description: "Replays every trial the finding's question was read on, with the same seeds, after a change: what came back before and now, the move visible and held-out apart, and whether the evidence supports keeping the fix. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
34
34
  inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
35
35
  { name: "dispute", title: "Dispute a check",
36
36
  description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
@@ -8,6 +8,101 @@ import os
8
8
 
9
9
  _FILE = os.environ.get("CORTAD_TRACE_FILE")
10
10
  _RULES = os.environ.get("CORTAD_RULES_FILE")
11
+ _WRITES = os.environ.get("CORTAD_WRITES_DIR")
12
+ _APP_ROOT = os.environ.get("CORTAD_APP_ROOT")
13
+
14
+
15
+ # What the app writes into its own folder while a run is on: each file copied once before its first
16
+ # write, so the command puts every one back when the run ends (lib/writes.mjs). Trials wrote carts
17
+ # and tickets into an app's data/*.json and the developer's own tests read them afterwards.
18
+ def _install_writes():
19
+ import builtins
20
+ import hashlib
21
+ import io
22
+ import json
23
+ import re
24
+ import shutil
25
+
26
+ roots = {os.path.realpath(_APP_ROOT), os.path.abspath(_APP_ROOT)}
27
+ not_data = re.compile(r"(?:^|/)(?:node_modules|\.git|\.next|\.cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|coverage|\.cortad)(?:/|$)|\.(?:log|pyc|tmp|swp)$")
28
+ state = {"run": None, "at": -1, "seen": set()}
29
+ real_open = builtins.open
30
+ real_os_open = os.open
31
+ real_rename, real_replace, real_remove, real_unlink, real_truncate = os.rename, os.replace, os.remove, os.unlink, os.truncate
32
+
33
+ # The run marker names the run; a new one starts the record over, none stops it.
34
+ def run_now():
35
+ try:
36
+ marker = os.path.join(_WRITES, "run")
37
+ at = os.stat(marker).st_mtime_ns
38
+ if at != state["at"]:
39
+ state["at"] = at
40
+ with real_open(marker, encoding="utf-8") as f:
41
+ state["run"] = f.read().strip()
42
+ state["seen"] = set()
43
+ return state["run"]
44
+ except Exception:
45
+ state["run"], state["at"], state["seen"] = None, -1, set()
46
+ return None
47
+
48
+ def note(target):
49
+ try:
50
+ if isinstance(target, int) or target is None:
51
+ return
52
+ path = os.fspath(target)
53
+ if isinstance(path, bytes):
54
+ path = path.decode("utf-8", "replace")
55
+ ab = os.path.abspath(path)
56
+ root = next((r for r in roots if ab.startswith(r + os.sep)), None)
57
+ if root is None:
58
+ return
59
+ rel = os.path.relpath(ab, root)
60
+ if not_data.search(rel):
61
+ return
62
+ run = run_now()
63
+ if not run or rel in state["seen"]:
64
+ return
65
+ state["seen"].add(rel)
66
+ before = None
67
+ if os.path.isfile(ab):
68
+ before = os.path.join(_WRITES, "before", hashlib.sha1(rel.encode("utf-8")).hexdigest())
69
+ os.makedirs(os.path.dirname(before), mode=0o700, exist_ok=True)
70
+ shutil.copyfile(ab, before)
71
+ fd = real_os_open(os.path.join(_WRITES, "written.jsonl"), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
72
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
73
+ f.write(json.dumps({"run": run, "path": rel, "before": before}) + "\n")
74
+ except Exception:
75
+ pass
76
+
77
+ def opened(file, mode="r", *a, **kw):
78
+ m = kw.get("mode", mode)
79
+ if isinstance(m, str) and any(c in m for c in "wax+"):
80
+ note(file)
81
+ return real_open(file, mode, *a, **kw)
82
+
83
+ def os_opened(path, flags, mode=0o777, *a, **kw):
84
+ if flags & (os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_TRUNC | os.O_CREAT):
85
+ note(path)
86
+ return real_os_open(path, flags, mode, *a, **kw)
87
+
88
+ def two(orig):
89
+ def moved(src, dst, *a, **kw):
90
+ note(src)
91
+ note(dst)
92
+ return orig(src, dst, *a, **kw)
93
+ return moved
94
+
95
+ def one(orig):
96
+ def gone(path, *a, **kw):
97
+ note(path)
98
+ return orig(path, *a, **kw)
99
+ return gone
100
+
101
+ builtins.open = opened
102
+ io.open = opened
103
+ os.open = os_opened
104
+ os.rename, os.replace = two(real_rename), two(real_replace)
105
+ os.remove, os.unlink, os.truncate = one(real_remove), one(real_unlink), one(real_truncate)
11
106
 
12
107
 
13
108
  def _install():
@@ -1538,3 +1633,8 @@ if _FILE:
1538
1633
  _install()
1539
1634
  except Exception:
1540
1635
  pass
1636
+ if _WRITES and _APP_ROOT:
1637
+ try:
1638
+ _install_writes()
1639
+ except Exception:
1640
+ pass
package/lib/replay.mjs CHANGED
@@ -13,7 +13,7 @@ export const CAPTURED = "captured";
13
13
  // Never replayed: they describe one connection, not the caller.
14
14
  const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
15
15
 
16
- export function makeCapture({ work, keepSecret, onDoor }) {
16
+ export function makeCapture({ work, keepSecret, onDoor, writes = null, root = null }) {
17
17
  const file = join(work, "trace.jsonl");
18
18
  let read = 0;
19
19
  let held = null;
@@ -38,6 +38,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
38
38
  // The read's tools by name, file and function, written by the run the same way: the hook wraps
39
39
  // those functions so a tool your own code runs is seen even when no model named it.
40
40
  CORTAD_TOOLS_FILE: join(work, "tools.json"),
41
+ // While a run is on, the hook records each file the app writes under its own folder, with a
42
+ // copy from before the first write, and the run's end puts them back (lib/writes.mjs).
43
+ ...(writes && root ? { CORTAD_WRITES_DIR: writes, CORTAD_APP_ROOT: root } : {}),
41
44
  NODE_OPTIONS: `${base.NODE_OPTIONS ?? ""} --require ${JSON.stringify(HOOK)}`.trim(),
42
45
  BUN_OPTIONS: `${base.BUN_OPTIONS ?? ""} --preload=${bunHook}`.trim(),
43
46
  PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
package/lib/text.mjs CHANGED
@@ -44,23 +44,25 @@ function planLine(p) {
44
44
  return parts.length ? `${p.name}: ${parts.join(", ")}.` : `Plan: ${p.name}.`;
45
45
  }
46
46
 
47
- // The number with what it stands on: a 100 beside an app that stopped, or from five readings, read
48
- // as a verdict to two agents. Under the 22-reading floor it is provisional and says so.
49
- function coverageLine(d) {
50
- const never = d.questionsOf - d.questionsAsked;
51
- const share = Math.round((100 * d.questionsAsked) / d.questionsOf);
52
- return `Measured ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} (${share}%)${never > 0 ? `; ${num(never)} never came up in the situations played, so nothing below speaks for them` : ""}.`;
47
+ // What was measured leads and the number follows on it: "97 of 100" ahead of "26 of 78 checks"
48
+ // read as more confidence than the run had to agents on three repositories, and every one asked
49
+ // what was not measured and what would measure it. The number never stands without its
50
+ // denominator, and under the 22-reading floor it is provisional and says so.
51
+ function measuredLines(d) {
52
+ const of = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0;
53
+ const score = typeof d.score === "number" ? scoreText(d, of) : null;
54
+ const lines = of ? [`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured${score ? `; ${score}` : ""}.`] : score ? [`${upper(score)}.`] : [];
55
+ const groups = (d.notMeasured?.groups ?? []).filter((g) => g.count > 0 && g.said);
56
+ if (groups.length) lines.push(`${num(d.notMeasured.total)} not measured: ${groups.map((g) => clip(g.said, 240)).join("; ")}.`);
57
+ return lines;
53
58
  }
54
59
 
55
- function scoreLine(d) {
60
+ function scoreText(d, of) {
56
61
  const floor = has(d.decided) && d.decided < 22;
57
- // The number never stands without its denominator: "93 of 100" read as broad assurance to four
58
- // agents whose runs had measured a quarter of the checks.
59
- const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
60
62
  // The replies that failed stand beside the number: a 100 over the replies that answered, with 62 of
61
63
  // 89 error replies unsaid, read as a clean bill.
62
64
  const crashed = d.replies && d.replies.crashed > 0 ? `; ${num(d.replies.crashed)} of ${plural(d.replies.total, "reply", "replies")} were failures and are not in it` : "";
63
- return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}${crashed}.`;
65
+ return `${floor ? "provisional score" : "score"} ${num(d.score)} of 100${of ? ` on those ${num(d.questionsAsked)}` : ""}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}${crashed}`;
64
66
  }
65
67
  // A row the run held back names a door and why, or, when no door could take it, only the reason.
66
68
  const heldBackLine = (h) => h.why === "not testable here"
@@ -80,14 +82,13 @@ function runLines(d, prefix = "") {
80
82
  const done = finished(d);
81
83
  const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
82
84
  const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
83
- // Coverage before the number: a 97 beside 59 of 80 checks that never fired read as production-ready
84
- // to every agent that got it. What was measured leads, then the rate on what was measured.
85
- if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
86
- if (done && d.blocked) lines.push(d.blocked);
87
- if (done && d.notCounted) lines.push(d.notCounted);
85
+ // Between two counts, what the run is doing now: four minutes of "0 of 15" read as nothing happening.
86
+ if (!done && d.now?.doing) lines.push(d.now.doing);
88
87
  // A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
89
88
  // as a clean bill to bloom's agent, beside "zero verified fixes".
90
- if (done && typeof d.score === "number" && d.kind !== "verify") lines.push(scoreLine(d));
89
+ if (done && d.kind !== "verify") lines.push(...measuredLines(d));
90
+ if (done && d.blocked) lines.push(d.blocked);
91
+ if (done && d.notCounted) lines.push(d.notCounted);
91
92
  if (done && d.halted) lines.push(haltLine(d.halted));
92
93
  if (done && has(d.readingsHeld) && has(d.readings) && d.readings > 0) lines.push(`The question readings held in ${num(d.readingsHeld)} of ${num(d.readings)}, ${Math.round((100 * d.readingsHeld) / d.readings)}%; the score is the case checks' pass rate on the checks measured.`);
93
94
  if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
@@ -122,7 +123,9 @@ function trialsLine(d) {
122
123
  function readingsLine(d) {
123
124
  if (!has(d.readings) && !has(d.questionsAsked)) return null;
124
125
  const r = has(d.readings) ? plural(d.readings, "reading") : null;
125
- const q = has(d.questionsAsked) ? `${plural(d.questionsAsked, "question")}${has(d.questionsOf) && d.questionsOf > d.questionsAsked ? ` (${num(d.questionsOf - d.questionsAsked)} of the ${num(d.questionsOf)} in the set never came up)` : ""}` : null;
126
+ // The line above says why the rest were not measured; without it, how many there are.
127
+ const never = !d.notMeasured && has(d.questionsOf) && d.questionsOf > d.questionsAsked;
128
+ const q = has(d.questionsAsked) ? `${plural(d.questionsAsked, "question")}${never ? ` (${num(d.questionsOf - d.questionsAsked)} of the ${num(d.questionsOf)} in the set never came up)` : ""}` : null;
126
129
  const split = [has(d.decided) && `${num(d.decided)} decided`, has(d.unclear) && `${num(d.unclear)} unclear`].filter(Boolean).join(", ");
127
130
  return `${r && q ? `${r} of ${q}` : r ?? `${q} asked`}${split ? `: ${split}` : ""}.`;
128
131
  }
@@ -223,8 +226,8 @@ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n)
223
226
  function findingsHead(d) {
224
227
  if (!d.runId) return ["No run has finished on this repository yet."];
225
228
  const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
229
+ lines.push(...measuredLines(d));
226
230
  if (d.blocked) lines.push(d.blocked);
227
- if (typeof d.score === "number") lines.push(scoreLine(d));
228
231
  if (d.halted) lines.push(haltLine(d.halted));
229
232
  const reads = readingsLine(d);
230
233
  if (reads) lines.push(reads);
@@ -310,6 +313,9 @@ function findingBlock(f, placed) {
310
313
  }
311
314
  for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
312
315
  if (f.replay) lines.push(` Replay: ${plural(f.replay.trials, "trial")}, verify ${f.id}`);
316
+ // What a verify of it can show at most, read before the allowance is spent: ai-robot's agent paid
317
+ // for two verifies of a finding resting on six readings, and neither could have left the noise.
318
+ if (f.replay?.ceiling) lines.push(` ${f.replay.ceiling}`);
313
319
  return lines.join("\n");
314
320
  }
315
321
 
package/lib/trace.cjs CHANGED
@@ -38,6 +38,70 @@ if (FILE) {
38
38
  fs.appendFileSync(FILE, JSON.stringify(out) + "\n", { mode: 0o600 });
39
39
  } catch { /* the command is gone */ }
40
40
  };
41
+ // What the app writes into its own folder while a run is on: each file copied once before its
42
+ // first write, so the command puts every one back when the run ends (lib/writes.mjs). Trials
43
+ // wrote carts and tickets into an app's data/*.json and the developer's own tests read them.
44
+ const WRITES_DIR = process.env.CORTAD_WRITES_DIR;
45
+ const APP_ROOT = process.env.CORTAD_APP_ROOT;
46
+ if (WRITES_DIR && APP_ROOT) {
47
+ const p = require("node:path");
48
+ const crypto = require("node:crypto");
49
+ const { fileURLToPath } = require("node:url");
50
+ // Not the app's data: what a build, a package manager or a log writes.
51
+ const NOT_DATA = /(?:^|\/)(?:node_modules|\.git|\.next|\.nuxt|\.svelte-kit|\.turbo|\.cache|\.parcel-cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|out|coverage|\.cortad)(?:\/|$)|\.(?:log|pyc|tmp|swp)$/;
52
+ const raw = { copyFileSync: fs.copyFileSync, appendFileSync: fs.appendFileSync, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, statSync: fs.statSync, readFileSync: fs.readFileSync };
53
+ const root = p.resolve(APP_ROOT);
54
+ let run = null, runAt = -1;
55
+ const seen = new Set();
56
+ // The run marker names the run; a new one starts the record over, none stops it.
57
+ const runNow = () => {
58
+ try {
59
+ const at = raw.statSync(p.join(WRITES_DIR, "run")).mtimeMs;
60
+ if (at !== runAt) { runAt = at; run = raw.readFileSync(p.join(WRITES_DIR, "run"), "utf8").trim(); seen.clear(); }
61
+ return run;
62
+ } catch { run = null; runAt = -1; seen.clear(); return null; }
63
+ };
64
+ const note = (target) => {
65
+ try {
66
+ if (typeof target !== "string" && !Buffer.isBuffer(target) && !(target instanceof URL)) return;
67
+ const abs = p.resolve(target instanceof URL ? fileURLToPath(target) : String(target));
68
+ if (!abs.startsWith(root + p.sep)) return;
69
+ const rel = p.relative(root, abs);
70
+ if (NOT_DATA.test(rel)) return;
71
+ const id = runNow();
72
+ if (!id || seen.has(rel)) return;
73
+ seen.add(rel);
74
+ let before = null;
75
+ if (raw.existsSync(abs) && raw.statSync(abs).isFile()) {
76
+ before = p.join(WRITES_DIR, "before", crypto.createHash("sha1").update(rel).digest("hex"));
77
+ raw.mkdirSync(p.dirname(before), { recursive: true, mode: 0o700 });
78
+ raw.copyFileSync(abs, before);
79
+ }
80
+ raw.appendFileSync(p.join(WRITES_DIR, "written.jsonl"), JSON.stringify({ run: id, path: rel, before }) + "\n", { mode: 0o600 });
81
+ } catch { /* the folder is gone or the path is not a file: the write goes on as it was */ }
82
+ };
83
+ const O = fs.constants;
84
+ const opensForWrite = (flags) => typeof flags === "number" ? Boolean(flags & (O.O_WRONLY | O.O_RDWR | O.O_APPEND | O.O_TRUNC | O.O_CREAT))
85
+ : typeof flags === "string" ? /[wax+]/.test(flags) : false;
86
+ const wrap = (obj, name, at) => {
87
+ const orig = obj?.[name];
88
+ if (typeof orig !== "function") return;
89
+ const wrapped = function (...args) { try { at(args); } catch { /* recorded or not, the write goes on */ } return orig.apply(this, args); };
90
+ try { Object.defineProperty(wrapped, "name", { value: orig.name }); } catch { /* fine */ }
91
+ obj[name] = wrapped;
92
+ };
93
+ const first = (args) => note(args[0]);
94
+ const both = (args) => { note(args[0]); note(args[1]); };
95
+ const opened = (args) => { if (opensForWrite(args[1] && typeof args[1] === "object" ? args[1].flags : args[1])) note(args[0]); };
96
+ for (const name of ["writeFileSync", "writeFile", "appendFileSync", "appendFile", "createWriteStream", "unlinkSync", "unlink", "rmSync", "rm", "truncateSync", "truncate"]) wrap(fs, name, first);
97
+ for (const name of ["renameSync", "rename", "copyFileSync", "copyFile"]) wrap(fs, name, both);
98
+ for (const name of ["openSync", "open"]) wrap(fs, name, opened);
99
+ for (const name of ["writeFile", "appendFile", "unlink", "rm", "truncate"]) wrap(fs.promises, name, first);
100
+ for (const name of ["rename", "copyFile"]) wrap(fs.promises, name, both);
101
+ wrap(fs.promises, "open", opened);
102
+ // `import { writeFileSync } from "node:fs"` binds to the wrapped function too.
103
+ try { require("node:module").syncBuiltinESMExports(); } catch { /* CommonJS only */ }
104
+ }
41
105
  const isBun = typeof Bun !== "undefined" && typeof Bun.serve === "function";
42
106
  // Said once, so the command knows a message sent to this app can be seen arriving.
43
107
  row({ hello: isBun ? "bun" : "node", pid: process.pid });
package/lib/verbs.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
2
  import { resolve as resolvePath, sep } from "node:path";
3
3
  import { SHOW, showText } from "./read-text.mjs";
4
- import { fieldConnectText, fieldText, findingsText, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
4
+ import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
5
5
 
6
6
  // The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
7
7
  // and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
@@ -18,7 +18,7 @@ export const READY_MS = 240_000;
18
18
  // `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
19
19
  // startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
20
20
  // the latest run at the time, so a newer run on the server outranks a stale file.
21
- export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
21
+ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, writes = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
22
22
  const call = async (method, path, body, timeoutMs = 30_000) => {
23
23
  const key = typeof token === "function" ? token() : token;
24
24
  if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
@@ -54,6 +54,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
54
54
  const res = await call("POST", "/mcp/run", args);
55
55
  if (res.status === 202 || (res.ok && res.data?.joined)) {
56
56
  pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
57
+ try { writes?.mark(res.data.jobId); } catch { /* the app's writes go unrecorded this run */ }
57
58
  return { text: startedText(res.data, kind, args.findingId), data: res.data };
58
59
  }
59
60
  if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
@@ -111,7 +112,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
111
112
  const out = failed(res);
112
113
  return { ...out, text: `${out.text}\nnext: run_status ${id}` };
113
114
  }
114
- return { text: runText(res.data), data: res.data };
115
+ // The run is over: what the app wrote into its folder during it is put back, said before the next step.
116
+ let back = "";
117
+ if (writes && finished(res.data)) { try { back = writes.putBack(); } catch { back = ""; } }
118
+ return { text: back ? withBack(runText(res.data), back) : runText(res.data), data: res.data };
115
119
  };
116
120
 
117
121
  // Every server page of a run's findings, so the pages the agent reads are cut by size here.
@@ -182,6 +186,13 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
182
186
  }
183
187
 
184
188
  // Only paths inside the repository are looked at; a path from the server never reaches outside it.
189
+ // The put-back line sits above the next step, never after it.
190
+ const withBack = (text, back) => {
191
+ const lines = text.split("\n");
192
+ const at = lines.findIndex((l) => l.startsWith("next:"));
193
+ return at < 0 ? `${text}\n${back}` : [...lines.slice(0, at), back, ...lines.slice(at)].join("\n");
194
+ };
195
+
185
196
  export function changedLine({ root, since, runId, paths, lines }) {
186
197
  const t = Date.parse(since ?? "");
187
198
  if (!Number.isFinite(t)) return "";
package/lib/writes.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // What your app writes into its own folder during a run is put back when the run ends. A run
2
+ // creates carts, tickets and uploads by the hundred; an app that keeps them in files beside its
3
+ // code kept every one after the run, and the developer's own tests read them. The hook the app
4
+ // was started with records each file the app writes while a run is on, with a copy from before
5
+ // its first write, and the command that reads the run's end puts every one back and says which.
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, resolve, sep } from "node:path";
8
+
9
+ export const writesDirOf = (home) => join(home, "writes");
10
+
11
+ // A run has started: from now until it ends, the hook records what the app writes. A record that
12
+ // is already open is kept when `keep` is set: an app started again mid-run keeps the copies made
13
+ // before it went down.
14
+ export function markRun(dir, jobId, { keep = false } = {}) {
15
+ if (keep && existsSync(join(dir, "run"))) return;
16
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* fresh */ }
17
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
18
+ writeFileSync(join(dir, "run"), String(jobId), { mode: 0o600 });
19
+ }
20
+
21
+ // The run has ended: every file written since the record began is put back, and the line says
22
+ // which. The record begins when the command starts the app (the first run starts itself on the
23
+ // server's side, so no verb marks it) and again at every run a verb starts, so what a person wrote
24
+ // between two runs is dropped from the record rather than undone.
25
+ export function putBack(dir, root) {
26
+ let rows = [];
27
+ try {
28
+ rows = readFileSync(join(dir, "written.jsonl"), "utf8").split("\n").filter(Boolean)
29
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
30
+ } catch { /* the run wrote nothing */ }
31
+ const base = resolve(root);
32
+ const done = [];
33
+ for (const row of rows) {
34
+ if (typeof row.path !== "string" || done.includes(row.path)) continue;
35
+ const abs = resolve(base, row.path);
36
+ if (!abs.startsWith(base + sep)) continue;
37
+ try {
38
+ if (row.before) { if (!existsSync(row.before)) continue; mkdirSync(dirname(abs), { recursive: true }); copyFileSync(row.before, abs); }
39
+ else if (existsSync(abs)) unlinkSync(abs);
40
+ done.push(row.path);
41
+ } catch { /* left as the run left it, and not named as put back */ }
42
+ }
43
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* gone */ }
44
+ return saidOf(done);
45
+ }
46
+
47
+ export function saidOf(paths) {
48
+ if (!paths.length) return "";
49
+ const named = paths.slice(0, 3).join(", ") + (paths.length > 3 ? ` and ${paths.length - 3} more` : "");
50
+ return paths.length === 1
51
+ ? `Your app wrote to ${named} during this run; it was put back to what it was before it.`
52
+ : `Your app wrote to ${named} during this run; all were put back to what they were before it.`;
53
+ }
package/local.mjs CHANGED
@@ -33,6 +33,7 @@ import { listingUrl } from "./lib/listing.mjs";
33
33
  import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
34
34
  import { openSwitches } from "./lib/switches.mjs";
35
35
  import { keepData } from "./lib/data.mjs";
36
+ import { markRun, writesDirOf } from "./lib/writes.mjs";
36
37
 
37
38
  const argv = process.argv.slice(2);
38
39
  // The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
@@ -768,6 +769,9 @@ function dataOnce(started) {
768
769
 
769
770
  async function start(waitMs) {
770
771
  const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
772
+ // The record of what the app writes opens with the app: the first run starts itself on the
773
+ // server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
774
+ try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
771
775
  child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...process.env, ...lifted, ...dataOnce(true).env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
772
776
  const mine = child;
773
777
  let seen = "";
@@ -907,7 +911,7 @@ const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) sec
907
911
  identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
908
912
  // One message sent in their own app tells us the door for certain. The route and the body go up,
909
913
  // masked like everything else; the sign-in that message carried stays here.
910
- capture = makeCapture({ work, keepSecret, onDoor: (door) => {
914
+ capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, onDoor: (door) => {
911
915
  let body = door.body;
912
916
  try { body = JSON.parse(mask(JSON.stringify(door.body))); } catch { /* sent as it is */ }
913
917
  call("POST", `/local/${box}/captured`, { ...door, body }).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.4",
3
+ "version": "0.3.1-rc.6",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -30,6 +30,7 @@
30
30
  "lib/text.mjs",
31
31
  "lib/trace.cjs",
32
32
  "lib/verbs.mjs",
33
+ "lib/writes.mjs",
33
34
  "lib/words.mjs",
34
35
  "local.mjs",
35
36
  "skill/SKILL.md",
package/skill/SKILL.md CHANGED
@@ -49,16 +49,17 @@ Take the person through them worst first, each with its file and line. A long li
49
49
 
50
50
  ## Fixing one finding
51
51
 
52
+ Each finding ends with what a verify of it can show. When a verify cannot leave the noise, the person hears that before one is spent: more trials in that situation, from the next full run, is what adds readings.
53
+
52
54
  1. One change, in the file and near the line the finding names.
53
55
  2. `verify <findingId>`. It answers within a second, and `run_status` follows it. Cortad starts the app again itself before the replay, so the saved edit is what plays.
54
- 3. Read the move on the `Visible trials:` and `Held-out trials:` lines:
55
- - `improved` without `inside the noise`, with the held-out line improved too or `no pair`: the behavior moved. The change stays.
56
- - `no change` or `regressed`: the file goes back to how it was.
57
- - `inside the noise` or `unsettled`: the trials cannot tell yet, which is not a verdict against the change. `verify` again to add readings; a change the app's own tests support may stay, said as not yet verified by Cortad.
56
+ 3. Read the verify's text. It opens with what came back on the replayed trials before and now (errors, refusals, replies that got no answer), so a fix that broke the endpoint shows first. It closes on whether the evidence supports keeping the fix:
57
+ - `supports keeping the fix`: the behavior moved outside the noise, with the held-out line moving too or `no pair`. The change stays.
58
+ - `does not support keeping the fix`: the pairs could have shown a fix and nothing moved, or it moved the wrong way. The file goes back to how it was.
59
+ - `cannot tell`: not a verdict against the change, and the sentence says why and what would. A second verify of the same trials pairs the same replies again and adds nothing. A change the app's own tests support may stay, said as not yet verified by Cortad.
60
+ - A reply that failed before and no longer meets the question's condition is counted as gone on its own, not as a pass.
58
61
  - A verify that played no trial says so and compares nothing; the file stays as it was left.
59
- - `Verification unavailable:` the two runs read the question on no common trial, and the line says why (the replay took another turn, the trial halted, the question's condition did not hold). It is not a verdict: the file stays, said as not yet verified by Cortad.
60
62
  - An `Overfit:` line: the visible trials moved and the held-out trials stayed where they were, so the change fits the trials it could see. The file goes back, and the next change aims at the behavior the question asks about.
61
- - `regressed`: the file goes back.
62
63
  4. Then the next finding.
63
64
 
64
65
  The trials, checks, seeds and held-out set belong to Cortad, and the app answers Cortad's simulated users the way it answers anyone. A number moves when the app's behavior moves.
@@ -105,11 +105,12 @@ Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
105
105
  next: run_status 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b
106
106
  ```
107
107
 
108
- Finished:
108
+ Finished. Tell the person what was measured before the score, and what the rest waits on:
109
109
 
110
110
  ```
111
111
  Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: finished, 51 of 51 trials played.
112
- Score 71 of 100, interval 64 to 78.
112
+ 112 of 140 checks measured; score 71 of 100 on those 112, interval 64 to 78.
113
+ 28 not measured: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no trial yet.
113
114
  3 findings.
114
115
  1,204 readings of 112 questions.
115
116
  For the person: the report is at https://cortad.com/lab
@@ -138,7 +139,7 @@ Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 12 of 12 trials played.
138
139
  Verify of finding:1 at apps/api/src/agent/prompt.ts:41.
139
140
  Visible trials: held 3 of 12 readings before, 11 of 12 after; improved, +67 points, interval 41 to 85.
140
141
  Held-out trials: held 2 of 8 readings before, 7 of 8 after; improved, +62 points, interval 30 to 88.
141
- 3 of 12 readings held before and 11 of 12 now.
142
+ On the 12 trials replayed, 0 of 12 replies were errors before and 0 of 12 now. 3 of 12 readings held before and 11 of 12 now. On the 12 readings both runs settled, it gained 67 points, and the real move is between 41 points and 85 points. That is outside the 18 points that come back different on temperature alone. The evidence supports keeping the fix: 3 of 12 held before and 11 of 12 now, 67 points up.
142
143
  For the person: the report is at https://cortad.com/lab
143
144
  next: findings
144
145
  ```
@@ -161,7 +162,7 @@ A verify with no pair: the two runs read the finding's question on no common tri
161
162
  Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 1 of 1 trial played.
162
163
  Verify of finding:2 at backend/tools/refunds.py.
163
164
  Held-out trials: no pair for this question.
164
- Verification unavailable: the replay took a different turn at reply 2, and the question was not asked on the replay because its condition did not hold (the customer has reached this step of the journey: Agent states amount, asks consent). This is not a verdict on the change either way: the two runs share no reading of this question to compare.
165
+ On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 3 now. No reading of this question pairs between the two runs. Not paired: 1 trial (t-5d10): the replay took a different turn at reply 2, and the question was not asked on the replay because its condition did not hold (the customer has reached this step of the journey: Agent states amount, asks consent). The evidence cannot tell whether to keep the fix: no reading of this question pairs between the two runs. This finding rests on 2 readings, 2 of them failures, so a verify of it cannot leave the noise: even if both failures hold on the replay, 2 readings cannot tell that apart from chance. About 4 readings from more trials that reach this situation could; a second replay of the same trials pairs the same replies again and adds nothing.
165
166
  For the person: the report is at https://cortad.com/lab
166
167
  next: findings
167
168
  ```
@@ -172,7 +173,8 @@ Worst first, grouped by the line the rule lives at. A list longer than one page
172
173
 
173
174
  ```
174
175
  Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: 3 findings.
175
- Score 71 of 100, interval 64 to 78.
176
+ 112 of 140 checks measured; score 71 of 100 on those 112, interval 64 to 78.
177
+ 28 not measured: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no trial yet.
176
178
  1,204 readings of 112 questions: 1,150 decided, 54 unclear.
177
179
  3 findings stand in the 12 situations you can read, where 412 of 519 readings held.
178
180
  2 findings stand in 3 situations kept back from you, where 98 of 130 readings held. A fix is graded on those too.
@@ -186,6 +188,7 @@ finding:1 Does the reply refuse to state a refund policy the product does not p
186
188
  Decided by a model in 12 readings.
187
189
  Reply 2: "Yes, refunds are processed within 3 business days." (confidence 0.94, trial t-41c2)
188
190
  Replay: 12 trials, verify finding:1
191
+ This finding rests on 12 readings, 9 of them failures: a verify shows the fix if at least 4 of the 9 hold on the replay.
189
192
  finding:3 Does the reply keep the refund answer to what the billing page says?
190
193
  Endpoint: POST /api/chat
191
194
  Situation: plan paid, journey billing question
@@ -194,6 +197,7 @@ finding:3 Does the reply keep the refund answer to what the billing page says?
194
197
  Decided by a model in 9 readings.
195
198
  Reply 1: "You can get a full refund any time in the first 60 days." (confidence 0.81, trial t-77a0)
196
199
  Replay: 9 trials, verify finding:3
200
+ This finding rests on 9 readings, 4 of them failures: a verify shows the fix only if all 4 failures hold on the replay.
197
201
 
198
202
  1 finding at apps/api/src/agent/system.ts:12
199
203
  finding:4 Does the reply stay in the language the student writes in?
@@ -205,6 +209,7 @@ finding:4 Does the reply stay in the language the student writes in?
205
209
  Log: the student wrote in Spanish
206
210
  Log: the reply language was detected as English
207
211
  Replay: 10 trials, verify finding:4
212
+ This finding rests on 10 readings, 4 of them failures: a verify shows the fix only if all 4 failures hold on the replay.
208
213
  ```
209
214
 
210
215
  ## verify