cortad 0.3.0 → 0.3.1-rc.10

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
@@ -2,7 +2,9 @@ import { closeSync, openSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { makeCode } from "./fresh.mjs";
5
6
  import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
7
+ import { markRun, putBack, writesDirOf } from "./writes.mjs";
6
8
  import { serveMcp } from "./mcp.mjs";
7
9
  import { SHOW } from "./read-text.mjs";
8
10
  import { cliSpec, VERSION } from "./spec.mjs";
@@ -17,6 +19,9 @@ import { makeVerbs, READY_MS } from "./verbs.mjs";
17
19
  const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
18
20
  const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
19
21
  const WAITER = "run-when-up";
22
+ // How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
23
+ // about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
24
+ const RESTART_MS = { mcp: 45_000, shell: 150_000 };
20
25
  export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
21
26
  export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
22
27
 
@@ -35,12 +40,15 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
35
40
  const children = [];
36
41
  const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
37
42
  // The key is read at every call: the MCP process outlives a connect that writes it after the process started.
43
+ const writes = { mark: (id) => markRun(writesDirOf(homeOf(project)), id), putBack: () => putBack(writesDirOf(homeOf(project)), root) };
38
44
  const verbs = makeVerbs({
39
45
  api: `${origin.origin}/api`,
40
46
  root,
47
+ writes,
41
48
  token: () => readToken(project),
42
49
  pending,
43
50
  startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
51
+ code: makeCode({ project, root, waitMs: mcp ? RESTART_MS.mcp : RESTART_MS.shell }),
44
52
  });
45
53
 
46
54
  if (command === WAITER) {
package/lib/data.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // Your app's own database, kept out of the run's way. A run creates people, conversations and
2
+ // records by the hundred; written into the file your app keeps its real data in, they stay there
3
+ // after the run. So an app started here runs against a copy of its database file, made in this
4
+ // command's own temp folder and pointed at through the same variable, and the original is never
5
+ // opened. What cannot be copied (a database server, a file your code names with no variable in
6
+ // front of it, an app that was already running) is said before the first run, in one sentence.
7
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
+
10
+ const FILE_DB = /\.(?:db|sqlite3?|duckdb)$/i;
11
+ // sqlite:///x.db (SQLAlchemy, relative), sqlite:////abs/x.db, sqlite+aiosqlite:///x.db, file:./dev.db (Prisma, libsql).
12
+ const FILE_URL = /^(sqlite(?:\+\w+)?:\/\/\/|file:(?:\/\/(?=\/))?)([^?]*)(\?.*)?$/i;
13
+ const SERVER_URL = /^(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|mssql|sqlserver|cockroachdb)(?:\+\w+)?:\/\//i;
14
+ const ENGINE = { postgres: "Postgres", postgresql: "Postgres", cockroachdb: "CockroachDB", mysql: "MySQL", mariadb: "MariaDB", mongodb: "MongoDB", "mongodb+srv": "MongoDB", mssql: "SQL Server", sqlserver: "SQL Server" };
15
+
16
+ // What one value names: a database file (with how to write the value again for another path), a
17
+ // database server, or nothing.
18
+ export function databaseOf(value) {
19
+ const v = String(value ?? "").trim();
20
+ if (!v) return null;
21
+ const server = SERVER_URL.exec(v);
22
+ if (server) return { server: ENGINE[server[1].toLowerCase()] ?? server[1] };
23
+ const url = FILE_URL.exec(v);
24
+ if (url) {
25
+ const path = decodeURIComponent(url[2]);
26
+ if (!path || path === ":memory:" || path.startsWith(":memory")) return null;
27
+ return { path, write: (to) => `${url[1]}${to}${url[3] ?? ""}`, prisma: /^file:/i.test(url[1]) };
28
+ }
29
+ if (!v.includes("://") && FILE_DB.test(v)) return { path: v, write: (to) => to };
30
+ return null;
31
+ }
32
+
33
+ // A folder the app keeps its data in, named by a variable (DATA_DIR=./data): a database file sits
34
+ // directly inside it. The whole folder is the database then, uploads and backups beside it.
35
+ const FOLDER_NAME = /DATA|DB|DATABASE|STORAGE|STORE|PERSIST|SQLITE|DUCK/i;
36
+ function dataFolder(name, value, dirs, appDir, root) {
37
+ const v = String(value ?? "").trim();
38
+ if (!FOLDER_NAME.test(name) || !v || v.includes("://") || FILE_DB.test(v) || !/^[.~/\w]/.test(v)) return null;
39
+ for (const place of [appDir, ...dirs].map((d) => resolve(d, v))) {
40
+ if (place === root || place === appDir || !place.startsWith(root + "/")) continue;
41
+ let names;
42
+ try { if (!statSync(place).isDirectory()) continue; names = readdirSync(place); } catch { continue; }
43
+ if (names.some((n) => FILE_DB.test(n))) return place;
44
+ }
45
+ return null;
46
+ }
47
+ const sizeOf = (dir, cap) => {
48
+ let total = 0;
49
+ const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (total > cap) return; const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) total += statSync(p).size; } };
50
+ try { walk(dir); } catch { /* counted what it could */ }
51
+ return total;
52
+ };
53
+
54
+ // A name the code reads with a default written beside it: os.getenv("DATABASE_URL", "sqlite:///./app.db"),
55
+ // process.env.DB_PATH ?? "./data.db". With nothing in the environment, the default is the database.
56
+ const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w*)['"]\s*,\s*['"]([^'"\n]+)['"]|process\.env(?:\.([A-Za-z_]\w*)|\[\s*['"]([A-Za-z_]\w*)['"]\s*\])\s*(?:\?\?|\|\|)\s*['"]([^'"\n]+)['"]/g;
57
+ // A database file opened by a path written in the code, with no variable in front of it.
58
+ const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
59
+ const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
60
+ // A test opens its own scratch file; that is not where the app keeps its data.
61
+ export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
62
+ // A folder bigger than this is not copied: said instead, so the person decides.
63
+ const FOLDER_CAP = 1024 ** 3;
64
+
65
+ function scan(sources) {
66
+ const defaults = {};
67
+ const opened = [];
68
+ for (const file of sources) {
69
+ if (!CODE.test(file) || TEST.test(file)) continue;
70
+ let text = "";
71
+ try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
72
+ for (const m of text.matchAll(DEFAULTED)) {
73
+ const name = m[1] ?? m[3] ?? m[4];
74
+ const value = m[2] ?? m[5];
75
+ if (name && !(name in defaults) && databaseOf(value)) defaults[name] = value;
76
+ }
77
+ for (const m of text.matchAll(OPENED)) { const db = databaseOf(m[1]); if (db?.path) opened.push({ file, path: db.path }); }
78
+ }
79
+ return { defaults, opened };
80
+ }
81
+
82
+ // Where a relative path lands. The app's own folder first, which is where it runs; then the folder
83
+ // of the env file that named it, which some settings loaders resolve against; for Prisma, the folder
84
+ // its schema lives in. The first place the file exists is the one; none, and it is the app's folder.
85
+ function located(db, dirs, appDir) {
86
+ if (isAbsolute(db.path)) return db.path;
87
+ const prisma = db.prisma ? [join(appDir, "prisma")] : [];
88
+ const places = [...prisma, appDir, ...dirs].map((d) => resolve(d, db.path));
89
+ return places.find((p) => existsSync(p)) ?? places[0];
90
+ }
91
+
92
+ // values: the environment the app will start with, names to values. from: the env file each name
93
+ // was read from, when there is one. Returns the variables to start the app with, and what to say.
94
+ export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
95
+ const { defaults, opened } = scan(sources);
96
+ const all = { ...defaults, ...values };
97
+ const env = {};
98
+ const copied = [];
99
+ const said = [];
100
+ const moved = new Set();
101
+ const rel = (p) => relative(root, p) || basename(p);
102
+ for (const [name, value] of Object.entries(all)) {
103
+ const db = databaseOf(value);
104
+ const folder = db ? null : dataFolder(name, value, from[name] ? [dirname(from[name])] : [], appDir, root);
105
+ if (folder) {
106
+ moved.add(folder);
107
+ if (!started) { said.push(`Your app was already running when this command started, so it writes to its own data folder, ${rel(folder)}/: trials write into it. Stop your app and run the command again to run against a copy.`); continue; }
108
+ if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { said.push(`Your data folder ${rel(folder)}/ (${name}) is over 1 GB, so it is not copied: trials write into it.`); continue; }
109
+ const to = join(work, "data", String(copied.length), basename(folder));
110
+ cpSync(folder, to, { recursive: true });
111
+ env[name] = to;
112
+ copied.push({ name, from: folder, to });
113
+ said.push(`Your app runs against a copy of its data folder ${rel(folder)}/, through ${name}; the original is not touched.`);
114
+ continue;
115
+ }
116
+ if (!db) continue;
117
+ if (db.server) {
118
+ said.push(`Trials write into the ${db.server} database ${name} names: a database server cannot be copied here, so what a run creates stays in it. Point ${name} at a scratch database before a run if that one holds real data.`);
119
+ continue;
120
+ }
121
+ const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
122
+ moved.add(original);
123
+ if (!started) {
124
+ said.push(`Your app was already running when this command started, so it writes to its own database, ${rel(original)}: trials write into it. Stop your app and run the command again to run against a copy.`);
125
+ continue;
126
+ }
127
+ const to = join(work, "data", String(copied.length), basename(original));
128
+ mkdirSync(dirname(to), { recursive: true });
129
+ // The file and the journal beside it: SQLite keeps recent writes in -wal until a checkpoint.
130
+ if (existsSync(original)) for (const tail of ["", "-wal", "-shm", "-journal"]) if (existsSync(original + tail)) copyFileSync(original + tail, to + tail);
131
+ env[name] = db.write(to);
132
+ copied.push({ name, from: original, to });
133
+ said.push(`Your app runs against a copy of ${rel(original)}, through ${name}; the original is not touched.`);
134
+ }
135
+ for (const o of opened) {
136
+ const path = resolve(dirname(o.file), o.path);
137
+ const cwdPath = resolve(appDir, o.path);
138
+ if ([path, cwdPath].some((p) => moved.has(p) || [...moved].some((m) => p.startsWith(m + "/")))) continue;
139
+ moved.add(cwdPath);
140
+ said.push(`Your app opens ${o.path} by a path written in ${rel(o.file)}, with no variable to point it at a copy: trials write into that file.`);
141
+ }
142
+ return { env, copied, said };
143
+ }
package/lib/fresh.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // Which code a run plays. The command holding the app up (local.mjs) notes in app.json, each time
2
+ // the app starts, when it started, whether it started it, whether it reloads on save, and what each
3
+ // source file said then. Before a run is asked for, the verbs compare the files as they stand with
4
+ // that note: an app without a reloader is started again first, and the run's text says which code
5
+ // it played. yunqiao's `python run.py` served the old routes through a verify its agent had just
6
+ // fixed them for.
7
+ import { createHash } from "node:crypto";
8
+ import { readFileSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { TEST } from "./data.mjs";
11
+ import { homeOf, readApp, readRunner } from "./home.mjs";
12
+ import { writesDirOf, writtenPaths } from "./writes.mjs";
13
+
14
+ // A command, or an app's own output, that says it picks a saved file up by itself. Not a compiler
15
+ // in watch mode (tsc --watch rebuilds, the server it feeds keeps running), and not vite, which
16
+ // reloads the page and never the server behind it.
17
+ export const RELOADER = new RegExp([
18
+ String.raw`--reload\b`, String.raw`\bnodemon\b`, String.raw`\bts-node-dev\b`, String.raw`\btsx\s+watch\b`,
19
+ String.raw`\b(?:node|tsx|bun|deno|nest)\b[^&|;\n]*\s--(?:watch|hot)\b`,
20
+ String.raw`\b(?:next|nuxt|astro|remix|fastapi)\s+dev\b`, String.raw`\brails\s+s(?:erver)?\b`,
21
+ String.raw`\bmanage\.py\s+runserver\b(?![^&|;\n]*--noreload)`, String.raw`\bflask\b[^&|;\n]*\s--(?:debug|reload)\b`,
22
+ String.raw`(?:^|[;&|\n]\s*)air\b`, String.raw`\bwatchexec\b`, String.raw`\bcargo[- ]watch\b`,
23
+ "Will watch for changes", "Started reloader process", "watching for file changes", String.raw`Restarting with (?:stat|watchdog|inotify|fsevents)`,
24
+ String.raw`\[nodemon\]`, "Restarting '", "WatchFiles detected changes", String.raw`Reloading\.\.\.`, "changed, reloading",
25
+ ].join("|"), "i");
26
+
27
+ // `npm run dev` says nothing by itself; the script it names does, and the one that names.
28
+ export function commandText(cmd, dir) {
29
+ let scripts = {};
30
+ try { scripts = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).scripts ?? {}; } catch { /* not a Node app */ }
31
+ let text = String(cmd ?? "");
32
+ for (let i = 0, at = text; i < 3; i++) {
33
+ const name = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/.exec(at)?.[1];
34
+ if (!name || typeof scripts[name] !== "string") break;
35
+ at = scripts[name];
36
+ text += `\n${at}`;
37
+ }
38
+ return text;
39
+ }
40
+
41
+ // ps's etime, [[dd-]hh:]mm:ss, in milliseconds.
42
+ export function elapsedMs(etime) {
43
+ const [days, clock] = String(etime).includes("-") ? String(etime).split("-") : [0, String(etime)];
44
+ const [s = 0, m = 0, h = 0] = clock.split(":").map(Number).reverse();
45
+ return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
46
+ }
47
+
48
+ // Not the app's code: this command's own line and hooks, the coding agents' folders, and tests.
49
+ const NOT_APP = /^(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$|^\.(?:claude|codex|cursor|agents)\//;
50
+ const digestOf = (file) => { try { return createHash("sha1").update(readFileSync(file)).digest("hex"); } catch { return ""; } };
51
+
52
+ // What each source file says now. Names alone for an app this command did not start: what it
53
+ // loaded is unknown, so only a file's time can be compared.
54
+ export const sourceOf = (root, files, hash = true) =>
55
+ Object.fromEntries(files.filter((rel) => !NOT_APP.test(rel) && !TEST.test(rel)).map((rel) => [rel, hash ? digestOf(join(root, rel)) : ""]));
56
+
57
+ // The files that differ from what the app started with, newest first, and the newest change of
58
+ // all. A file rewritten with the bytes it had (a run's writes put back) has not changed.
59
+ export function changesOf({ root, app, skip = new Set() }) {
60
+ const since = Date.parse(app.startedAt);
61
+ const changed = [];
62
+ let last = null;
63
+ for (const [rel, was] of Object.entries(app.files ?? {})) {
64
+ if (skip.has(rel)) continue;
65
+ let at;
66
+ try { at = statSync(join(root, rel)).mtimeMs; } catch { continue; }
67
+ if (at > since && was && digestOf(join(root, rel)) === was) continue;
68
+ if (at > since) changed.push({ path: rel, at });
69
+ if (!last || at > last.at) last = { path: rel, at };
70
+ }
71
+ return { changed: changed.sort((a, b) => b.at - a.at), last };
72
+ }
73
+
74
+ // What the run's text says about the code it played (lib/text.mjs testedLine).
75
+ export const testedOf = (app, { changed, last }) => ({
76
+ startedAt: app.startedAt,
77
+ ...(last ? { change: { path: last.path, at: new Date(last.at).toISOString() } } : {}),
78
+ ...(app.reloads ? { reloads: true } : changed.length && !app.own ? { stale: true } : {}),
79
+ });
80
+
81
+ // The verbs' side. `fresh` starts the app again through the process holding it up when its code
82
+ // changed and nothing reloaded it, and waits until it answers on the new code; `tested` is what
83
+ // the run is about to play.
84
+ export function makeCode({ project, root, waitMs, base, signal = (pid) => process.kill(pid, "SIGUSR2"), now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
85
+ const current = () => {
86
+ const app = readApp(project, base);
87
+ return app?.startedAt && app.runner === readRunner(project, base)?.pid ? app : null;
88
+ };
89
+ const changes = (app) => changesOf({ root, app, skip: writtenPaths(writesDirOf(homeOf(project, base))) });
90
+ const tested = () => { const app = current(); return app ? testedOf(app, changes(app)) : null; };
91
+
92
+ const fresh = async () => {
93
+ const app = current();
94
+ const { changed } = app ? changes(app) : { changed: [] };
95
+ if (!changed.length || !app.own || app.reloads) return {};
96
+ const asked = now();
97
+ try { signal(app.runner); } catch { return { error: "The process holding your app up could not be asked to start it again for your change." }; }
98
+ while (now() - asked < waitMs) {
99
+ await sleep(500);
100
+ const next = readApp(project, base);
101
+ if (next?.failedAt && Date.parse(next.failedAt) >= asked) return { error: `Your app was started again to play your change and did not come back. ${next.error ?? ""}`.trim() };
102
+ if (next?.startedAt && Date.parse(next.startedAt) >= asked) return { restarted: changed.map((c) => c.path) };
103
+ if (!readRunner(project, base)) return { error: "The process holding your app up ended while starting it again for your change." };
104
+ }
105
+ return { error: `Your app was started again to play your change and has not answered after ${Math.round(waitMs / 1000)} seconds.` };
106
+ };
107
+ return { fresh, tested };
108
+ }
package/lib/home.mjs CHANGED
@@ -11,6 +11,7 @@ export const projectOf = (root) => createHash("sha256").update(realpathSync(root
11
11
  export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
12
12
 
13
13
  const read = (file) => { try { return readFileSync(file, "utf8").trim() || null; } catch { return null; } };
14
+ const readJson = (file) => { const raw = read(file); try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
14
15
  const write = (dir, name, text) => { mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(join(dir, name), text, { mode: 0o600 }); };
15
16
 
16
17
  export const readToken = (project, base) => read(join(homeOf(project, base), "token"));
@@ -32,10 +33,12 @@ export function readRunner(project, base) {
32
33
  export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
33
34
  export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
34
35
 
35
- export function readPending(project, base) {
36
- const raw = read(join(homeOf(project, base), "pending.json"));
37
- try { return raw ? JSON.parse(raw) : null; } catch { return null; }
38
- }
36
+ // The app that runner holds up, as it noted it last (lib/fresh.mjs): when it started, whether that
37
+ // runner started it, and what its source files said then.
38
+ export const readApp = (project, base) => readJson(join(homeOf(project, base), "app.json"));
39
+ export const writeApp = (project, app, base) => write(homeOf(project, base), "app.json", JSON.stringify(app));
40
+
41
+ export const readPending = (project, base) => readJson(join(homeOf(project, base), "pending.json"));
39
42
  export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
40
43
 
41
44
  export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
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, and when those read it too few times for a rate, plays up to 12 more trials in the finding's situation: what came back before and now, the move visible and held-out apart (reply by reply, or the question's failure rate on the same trials where the replies cannot be paired), and whether the evidence supports keeping the fix. Answers within a second and says how many new trials it plays; 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.",