cortad 0.3.1-rc.7 → 0.3.1-rc.8

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,6 +2,7 @@ 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";
6
7
  import { markRun, putBack, writesDirOf } from "./writes.mjs";
7
8
  import { serveMcp } from "./mcp.mjs";
@@ -18,6 +19,9 @@ import { makeVerbs, READY_MS } from "./verbs.mjs";
18
19
  const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
19
20
  const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
20
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 };
21
25
  export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
22
26
  export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
23
27
 
@@ -44,6 +48,7 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
44
48
  token: () => readToken(project),
45
49
  pending,
46
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 }),
47
52
  });
48
53
 
49
54
  if (command === WAITER) {
package/lib/data.mjs CHANGED
@@ -58,7 +58,7 @@ const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w
58
58
  const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
59
59
  const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
60
60
  // A test opens its own scratch file; that is not where the app keeps its data.
61
- const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
61
+ export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
62
62
  // A folder bigger than this is not copied: said instead, so the person decides.
63
63
  const FOLDER_CAP = 1024 ** 3;
64
64
 
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
@@ -30,7 +30,7 @@ export const TOOLS = [
30
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 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.",
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.",
package/lib/text.mjs CHANGED
@@ -68,7 +68,10 @@ function scoreText(d, of) {
68
68
  const heldBackLine = (h) => h.why === "not testable here"
69
69
  ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
70
70
  : `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
71
- const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
71
+ const haltLine = (h) => `${h.side === "ours" ? `Stopped ${SIDE.ours}` : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 240)}` : ""}. The score covers the turns before that.`;
72
+ // The pace a slow app was played at, and the replies our own wait cut off: both said as ours. A
73
+ // verify says the second in its own paired line.
74
+ const waitLines = (d) => [d.pace?.said, d.kind === "verify" ? null : d.waited?.said].filter(Boolean);
72
75
  const LAYER_WORDS = {
73
76
  retrieval: "what the store returned behind the reply, not how the reply was written",
74
77
  tools: "the tool call behind the reply",
@@ -82,8 +85,10 @@ function runLines(d, prefix = "") {
82
85
  const done = finished(d);
83
86
  const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
84
87
  const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
88
+ if (d.tested) lines.push(testedLine(d.tested));
85
89
  // Between two counts, what the run is doing now: four minutes of "0 of 15" read as nothing happening.
86
90
  if (!done && d.now?.doing) lines.push(d.now.doing);
91
+ lines.push(...waitLines(d));
87
92
  // A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
88
93
  // as a clean bill to bloom's agent, beside "zero verified fixes".
89
94
  if (done && d.kind !== "verify") lines.push(...measuredLines(d));
@@ -141,6 +146,16 @@ function stoppedLine(d) {
141
146
 
142
147
  const moveText = (m) => `held ${kn(m.before)} readings before, ${kn(m.after)} after; ${m.moved}, ${signed(m.point)} points${interval(m)}${m.insideNoise ? ", inside the noise" : ""}.`;
143
148
 
149
+ // Where the replies did not pair, the question's rate on the same trials before and after, with the
150
+ // trials the verify played new in the situation and the readings nobody could settle said apart.
151
+ function rateText(r) {
152
+ const unclear = [r.before.unclear && `${num(r.before.unclear)} unclear before`, r.after.unclear && `${num(r.after.unclear)} unclear after`].filter(Boolean).map((u) => `, ${u}`).join("");
153
+ const over = r.trials?.widened ? `, over ${plural(r.trials.replayed, "trial")} replayed and ${num(r.trials.widened)} new` : "";
154
+ const verdict = r.thin ? `too few to state a rate (${num(r.after.settled)} of the 22 it needs)` : `${r.moved}, ${signed(r.point)} points${interval(r)}${r.insideNoise ? ", inside the noise" : ""}`;
155
+ return `failed on ${num(r.before.failed)} of ${num(r.before.settled)} settled readings before, ${num(r.after.failed)} of ${num(r.after.settled)} after${unclear}${over}; ${verdict}.`;
156
+ }
157
+ const sideText = (m) => (m.rate ? rateText(m.rate) : m.unavailable ? null : moveText(m));
158
+
144
159
  // Over zero pairs there is no move to print: the line says verification is unavailable and why,
145
160
  // never "no change, 0 points" measured on nothing. The visible side's reason is the said line.
146
161
  function verifyLines(v) {
@@ -156,9 +171,9 @@ function verifyLines(v) {
156
171
  return lines;
157
172
  }
158
173
  const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
159
- if (v.visible && !v.visible.unavailable) lines.push(`Visible trials: ${moveText(v.visible)}`);
160
- if (v.holdout?.unavailable) lines.push(`Held-out trials: verification unavailable: ${v.holdout.unavailable}.`);
161
- else if (v.holdout) lines.push(`Held-out trials: ${moveText(v.holdout)}`);
174
+ const visible = v.visible && sideText(v.visible);
175
+ if (visible) lines.push(`Visible trials: ${visible}`);
176
+ if (v.holdout) lines.push(`Held-out trials: ${sideText(v.holdout) ?? `verification unavailable: ${v.holdout.unavailable}.`}`);
162
177
  else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
163
178
  if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
164
179
  if (v.said) lines.push(v.said);
@@ -177,10 +192,23 @@ export function runText(d) {
177
192
  return lines.join("\n");
178
193
  }
179
194
 
180
- export function startedText(d, kind, findingId) {
195
+ export function startedText(d, kind, findingId, said = []) {
181
196
  const verify = (d.kind ?? kind) === "verify";
182
197
  const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
183
- return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.${d.note ? `\n${d.note}` : ""}\nnext: run_status ${d.jobId}`;
198
+ return [`${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.`, ...said, d.note, `next: run_status ${d.jobId}`].filter(Boolean).join("\n");
199
+ }
200
+
201
+ export const restartedText = (paths) =>
202
+ `Your app was started again so this run plays your change (${plural(paths.length, "file")} changed since it started: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", ..." : ""}).`;
203
+
204
+ // Which code the run played: the app's start against the last change to its source (lib/fresh.mjs).
205
+ const clock = (iso) => new Date(iso).toTimeString().slice(0, 8);
206
+ export function testedLine(t) {
207
+ const started = clock(t.startedAt);
208
+ if (t.stale) return `Your app was already running when this command started and has been running since ${started}; ${t.change.path} changed at ${clock(t.change.at)}, so this run may have played the old code. Stop your app and run the command again.`;
209
+ if (!t.change) return `App started ${started}.`;
210
+ if (Date.parse(t.change.at) <= Date.parse(t.startedAt)) return `App started ${started}, after your last change at ${clock(t.change.at)}.`;
211
+ return `App started ${started}${t.reloads ? " and reloads itself on save" : ""}; your last change was at ${clock(t.change.at)}.`;
184
212
  }
185
213
 
186
214
  export const waitingText = (p, now, limitMs) =>
@@ -237,9 +265,11 @@ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n)
237
265
  function findingsHead(d) {
238
266
  if (!d.runId) return ["No run has finished on this repository yet."];
239
267
  const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
268
+ if (d.tested) lines.push(testedLine(d.tested));
240
269
  lines.push(...measuredLines(d));
241
270
  if (d.blocked) lines.push(d.blocked);
242
271
  if (d.halted) lines.push(haltLine(d.halted));
272
+ lines.push(...waitLines(d));
243
273
  const reads = readingsLine(d);
244
274
  if (reads) lines.push(reads);
245
275
  if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
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, finished, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
4
+ import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, 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
@@ -17,8 +17,9 @@ export const READY_MS = 240_000;
17
17
 
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
- // 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, writes = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
20
+ // the latest run at the time, so a newer run on the server outranks a stale file. A run this machine
21
+ // posted also keeps `tested`, which code it played (lib/fresh.mjs), for its run_status and findings.
22
+ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, writes = null, code = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
22
23
  const call = async (method, path, body, timeoutMs = 30_000) => {
23
24
  const key = typeof token === "function" ? token() : token;
24
25
  if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
@@ -40,6 +41,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
40
41
  };
41
42
  const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
42
43
  const iso = () => new Date(now()).toISOString();
44
+ const withTested = (d, id) => {
45
+ const kept = pending.read();
46
+ return kept?.tested && kept.jobId === id ? { ...d, tested: kept.tested } : d;
47
+ };
43
48
 
44
49
  // With show, one section of the read in full, paged; a name that is not a section is the plain status.
45
50
  const status = async ({ show, page } = {}) => {
@@ -50,24 +55,33 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
50
55
  };
51
56
 
52
57
  // Asks the server for the run and keeps its id here, so run_status finds it without one.
53
- const post = async (args, kind) => {
58
+ const post = async (args, kind, restarted = []) => {
59
+ const tested = code?.tested() ?? null;
54
60
  const res = await call("POST", "/mcp/run", args);
55
61
  if (res.status === 202 || (res.ok && res.data?.joined)) {
56
- pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
62
+ const mine = res.status === 202 ? tested : null;
63
+ pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso(), ...(mine ? { tested: mine } : {}) });
57
64
  try { writes?.mark(res.data.jobId); } catch { /* the app's writes go unrecorded this run */ }
58
- return { text: startedText(res.data, kind, args.findingId), data: res.data };
65
+ const said = [restarted.length ? restartedText(restarted) : "", mine?.stale ? testedLine(mine) : ""];
66
+ return { text: startedText(res.data, kind, args.findingId, said), data: res.data };
59
67
  }
60
68
  if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
61
69
  if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
62
70
  return failed(res);
63
71
  };
64
72
 
65
- // An app that is up gets the run now. One that is not is started in the background, and the run
66
- // is posted from there once it answers.
73
+ // An app that is up gets the run now, started again first when its code changed and nothing
74
+ // reloaded it; never under a run already playing, which is joined. One that is not up is started
75
+ // in the background, and the run is posted from there once it answers.
67
76
  const start = async (args, kind) => {
68
77
  const before = await call("GET", "/mcp/status");
69
78
  if (!before.ok) return failed(before);
70
- if (before.data.app?.state === "ready-to-test") return post(args, kind);
79
+ if (before.data.app?.state === "ready-to-test") {
80
+ const idle = !before.data.run || finished(before.data.run);
81
+ const got = code && idle ? await code.fresh() : {};
82
+ if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true };
83
+ return post(args, kind, got.restarted);
84
+ }
71
85
  pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
72
86
  const began = await startApp(args, kind);
73
87
  if (!began.ok) {
@@ -115,7 +129,8 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
115
129
  // The run is over: what the app wrote into its folder during it is put back, said before the next step.
116
130
  let back = "";
117
131
  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 };
132
+ const text = runText(withTested(res.data, id));
133
+ return { text: back ? withBack(text, back) : text, data: res.data };
119
134
  };
120
135
 
121
136
  // Every server page of a run's findings, so the pages the agent reads are cut by size here.
@@ -144,7 +159,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
144
159
 
145
160
  const findings = async ({ jobId, page } = {}) => {
146
161
  const res = await gather(jobId);
147
- return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
162
+ return res.ok ? { text: findingsText(withTested(res.data, res.data.runId), page), data: res.data } : failed(res);
148
163
  };
149
164
 
150
165
  const dispute = async ({ findingId, why, question, jobId }) => {
package/lib/writes.mjs CHANGED
@@ -23,11 +23,7 @@ export function markRun(dir, jobId, { keep = false } = {}) {
23
23
  // server's side, so no verb marks it) and again at every run a verb starts, so what a person wrote
24
24
  // between two runs is dropped from the record rather than undone.
25
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 */ }
26
+ const rows = rowsOf(dir);
31
27
  const base = resolve(root);
32
28
  const done = [];
33
29
  for (const row of rows) {
@@ -44,6 +40,16 @@ export function putBack(dir, root) {
44
40
  return saidOf(done);
45
41
  }
46
42
 
43
+ function rowsOf(dir) {
44
+ try {
45
+ return readFileSync(join(dir, "written.jsonl"), "utf8").split("\n").filter(Boolean)
46
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
47
+ } catch { return []; }
48
+ }
49
+
50
+ // The files the app itself wrote since the record began: its data, never a change to its code.
51
+ export const writtenPaths = (dir) => new Set(rowsOf(dir).map((row) => row.path).filter((p) => typeof p === "string"));
52
+
47
53
  export function saidOf(paths) {
48
54
  if (!paths.length) return "";
49
55
  const named = paths.slice(0, 3).join(", ") + (paths.length > 3 ? ` and ${paths.length - 3} more` : "");
package/local.mjs CHANGED
@@ -21,7 +21,8 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
21
21
  import { createInterface } from "node:readline";
22
22
  import { promisify } from "node:util";
23
23
  import { COMMANDS, main as face } from "./lib/cli.mjs";
24
- import { clearRunner, homeOf, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
24
+ import { clearRunner, homeOf, projectOf, readToken, writeApp, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
25
+ import { commandText, elapsedMs, RELOADER, sourceOf } from "./lib/fresh.mjs";
25
26
  import { registerAll } from "./lib/register.mjs";
26
27
  import { finished } from "./lib/text.mjs";
27
28
  import { lockHolds, makeLock } from "./lib/lock.mjs";
@@ -375,10 +376,13 @@ async function verb(job) {
375
376
  const url = hostUrl(port, path);
376
377
  const yours = (to) => { try { const u = new URL(to, url); return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(u.hostname) && u.port === String(port) ? u.href : null; } catch { return null; } };
377
378
  let jar = "";
379
+ // Held as long as the run waits for this reply: a course generator that takes ninety seconds
380
+ // a reply is waited on for three of them. A run that says nothing gets the old 170 seconds.
381
+ const holdMs = Math.min(Math.max(Number(b.waitMs) || 170_000, 1_000), 630_000);
378
382
  const sent = (at, over = {}) => fetch(at, {
379
383
  ...init, ...over,
380
384
  headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
381
- signal: AbortSignal.timeout(170_000),
385
+ signal: AbortSignal.timeout(holdMs),
382
386
  });
383
387
  const keep = (res) => { const set = res.headers.getSetCookie?.() ?? []; if (set.length) jar = [jar, ...set.map((c) => c.split(";")[0])].filter(Boolean).join("; "); };
384
388
  const sentBack = (res) => (res.status >= 300 && res.status < 400 ? yours(res.headers.get("location") ?? "") : null);
@@ -646,16 +650,32 @@ async function listenerOn(port) {
646
650
  // The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
647
651
  // while the parent is a runner, never into the person's own shell or terminal.
648
652
  const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
649
- async function supervisorOf(pid) {
650
- const rows = (await exec("ps", ["-axo", "pid=,ppid=,args="])).stdout.trim().split("\n").map((l) => l.trim());
651
- const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { ppid: Number(m[2]), args: m[3] }] : [0, null]; }));
652
- let top = pid;
653
- for (let i = 0; i < 8; i++) {
654
- const parent = table.get(table.get(top)?.ppid ?? 0);
653
+ async function chainOf(pid) {
654
+ const rows = (await exec("ps", ["-axo", "pid=,ppid=,etime=,args="])).stdout.trim().split("\n").map((l) => l.trim());
655
+ const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), etime: m[3], args: m[4] }] : [0, null]; }));
656
+ const chain = table.get(pid) ? [table.get(pid)] : [];
657
+ for (let i = 0; i < 8 && chain.length; i++) {
658
+ const parent = table.get(chain.at(-1).ppid);
655
659
  if (!parent || !RUNNER.test(parent.args)) break;
656
- top = table.get(top).ppid;
660
+ chain.push(parent);
657
661
  }
658
- return top;
662
+ return chain;
663
+ }
664
+ const supervisorOf = async (pid) => (await chainOf(pid)).at(-1)?.pid ?? pid;
665
+
666
+ // What the running app was started from, noted in app.json for the verbs, which run in another
667
+ // process (lib/fresh.mjs): when it started, whether this command started it, whether it reloads on
668
+ // save, and what each source file said then. An app found already running is noted by its own
669
+ // process's age and command line, with the file names alone.
670
+ let loaded = null;
671
+ const noteApp = () => writeApp(project, loaded?.at
672
+ ? { runner: process.pid, own: loaded.own, startedAt: new Date(loaded.at).toISOString(), reloads: loaded.reloads, files: loaded.files }
673
+ : { runner: process.pid });
674
+ async function noteAttached(port) {
675
+ const pid = await listenerOn(port);
676
+ const chain = pid ? await chainOf(pid).catch(() => []) : [];
677
+ loaded = { own: false, at: chain.length ? Date.now() - elapsedMs(chain[0].etime) : null, reloads: RELOADER.test(chain.map((p) => p.args).join("\n")), files: sourceOf(root, files, false) };
678
+ noteApp();
659
679
  }
660
680
 
661
681
  // Your app, started the way you start it, and watched until one of its own ports answers.
@@ -772,10 +792,17 @@ async function start(waitMs) {
772
792
  // The record of what the app writes opens with the app: the first run starts itself on the
773
793
  // server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
774
794
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
795
+ // Taken before the app reads a file: a save after this moment is a change it has not loaded.
796
+ const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
797
+ note.files = sourceOf(root, files);
798
+ loaded = note;
775
799
  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 });
776
800
  const mine = child;
777
801
  let seen = "";
778
- const onData = (d) => { const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s); };
802
+ const onData = (d) => {
803
+ const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s);
804
+ if (!note.reloads && RELOADER.test(s)) { note.reloads = true; if (note.answered && loaded === note) noteApp(); }
805
+ };
779
806
  mine.stdout.on("data", onData);
780
807
  mine.stderr.on("data", onData);
781
808
  let exited = null;
@@ -807,7 +834,11 @@ async function start(waitMs) {
807
834
  const plain = seen.replace(/\x1b\[[0-9;]*m/g, "");
808
835
  const said = [...plain.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b|\bport\s*[:=]?\s*(\d{4,5})\b/gi)].map((m) => Number(m[1] || m[2]));
809
836
  for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
810
- if (await answers(port)) return { port, exited: null, tail: "" };
837
+ if (await answers(port)) {
838
+ note.answered = true;
839
+ if (loaded === note) noteApp();
840
+ return { port, exited: null, tail: "" };
841
+ }
811
842
  }
812
843
  await new Promise((r) => setTimeout(r, 1000));
813
844
  }
@@ -1024,7 +1055,7 @@ const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd:
1024
1055
  async function appLife() {
1025
1056
  for (let first = true; ; first = false) {
1026
1057
  const got = await startApp();
1027
- if (got.port) { app = got; break; }
1058
+ if (got.port) { app = got; if (!launched) await noteAttached(app.port); break; }
1028
1059
  await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
1029
1060
  say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
1030
1061
  // Said with the same words the agent prompt waits for, so an agent holding the terminal reports
@@ -1086,6 +1117,7 @@ async function appLife() {
1086
1117
  say("saw your change, starting your app again");
1087
1118
  back = launched ? await launch(180_000) : await startApp();
1088
1119
  }
1120
+ if (back.port && !launched) await noteAttached(back.port);
1089
1121
  if (back.port) { app = { ...app, ...(back.cmd !== undefined ? back : {}), port: back.port }; up = true; downSince = 0; toldDown = false; appTold = Boolean((await announce().catch(() => null))?.ok); say(`your app is answering again on port ${app.port}`); }
1090
1122
  } finally { restarting = false; }
1091
1123
  }
@@ -1119,6 +1151,13 @@ async function close(code = 0) {
1119
1151
  }
1120
1152
  process.on("SIGINT", () => close(0));
1121
1153
  process.on("SIGTERM", () => close(0));
1154
+ // A verb in another process asks for the app to be started again before a run, because its code
1155
+ // changed and nothing reloaded it (lib/fresh.mjs). The new start is noted as the app answers; one
1156
+ // that fails is noted with its reason. A restart already under way answers the same way.
1157
+ process.on("SIGUSR2", () => {
1158
+ if (restarting || !app) return;
1159
+ void restartApp().then((out) => { if (out.error) writeApp(project, { runner: process.pid, failedAt: new Date().toISOString(), error: mask(out.error).slice(-600) }); });
1160
+ });
1122
1161
 
1123
1162
  let quiet = 0;
1124
1163
  // Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.7",
3
+ "version": "0.3.1-rc.8",
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"
@@ -10,6 +10,7 @@
10
10
  "README.md",
11
11
  "lib/cli.mjs",
12
12
  "lib/data.mjs",
13
+ "lib/fresh.mjs",
13
14
  "lib/home.mjs",
14
15
  "lib/identities.mjs",
15
16
  "lib/keys.mjs",
package/skill/SKILL.md CHANGED
@@ -49,14 +49,15 @@ 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.
52
+ Each finding ends with what a verify of it can show. When the trials it replays read the question too few times for a rate, the verify plays up to 12 more trials in that situation itself, counted as verify trials, and the finding says how many before one is spent.
53
53
 
54
54
  1. One change, in the file and near the line the finding names.
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.
55
+ 2. `verify <findingId>`. It answers within a second, and `run_status` follows it. When the app's code changed since the app started and nothing reloaded it, Cortad starts the app again first and the answer says so, so the saved edit is what plays; `run_status` names when the app started against the last change. When it also plays new trials in the finding's situation, the answer says how many.
56
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
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.
58
+ - `does not support keeping the fix`: the pairs, or the rate on the same trials, 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; most often the reader could not settle enough of the replies, which is on Cortad's side. A change the app's own tests support may stay, said as not yet verified by Cortad.
60
+ - Where the replies cannot be paired reply for reply, the verify compares the question's failure rate on the same trials before and now, with the new trials it played in the situation, and says both. Readings the reader could not settle are named and count on neither side.
60
61
  - A reply that failed before and no longer meets the question's condition is counted as gone on its own, not as a pass.
61
62
  - A verify that played no trial says so and compares nothing; the file stays as it was left.
62
63
  - 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.
@@ -162,7 +162,19 @@ A verify with no pair: the two runs read the finding's question on no common tri
162
162
  Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 1 of 1 trial played.
163
163
  Verify of finding:2 at backend/tools/refunds.py.
164
164
  Held-out trials: no pair for this question.
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
+ 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 replay of its trials alone cannot leave the noise: even if both failures hold on the replay, 2 readings cannot tell that apart from chance. A verify of it plays 10 more trials in this situation to reach about 22 readings.
166
+ For the person: the report is at https://cortad.com/lab
167
+ next: findings
168
+ ```
169
+
170
+ A verify whose replies did not pair: the replay took other turns and the reader settled other replies, so the question's failure rate on the same trials is compared instead, with the 10 new trials the verify played in the finding's situation. Readings the reader could not settle count on neither side.
171
+
172
+ ```
173
+ Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 11 of 11 trials played.
174
+ Verify of finding:2 at backend/tools/refunds.py.
175
+ Visible trials: failed on 2 of 2 settled readings before, 1 of 29 after, 4 unclear after, over 1 trial replayed and 10 new; improved, +97 points, interval 29 to 99.
176
+ Held-out trials: failed on 2 of 16 settled readings before, 0 of 15 after, 1 unclear after; too few to state a rate (15 of the 22 it needs).
177
+ On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 4 now. On the 10 more trials played in this situation, 0 of 30 replies were errors. Reply for reply, nothing could be paired: the reader could not settle any reply of this question that both runs read. Across the 1 trial replayed in this situation and the 10 more this verify played there, this question failed on 2 of 2 settled readings before and 1 of 29 now; 4 readings now could not be settled either way and count on neither side. As two separate samples the failure rate fell 97 points, and the real fall is between 29 and 99 points. That is outside the 18 points that come back different on temperature alone. The held-out trials have too few readings to say (15 of the 22 each rate needs). The evidence supports keeping the fix: this question failed on 2 of 2 settled readings before and 1 of 29 now, 97 points fewer.
166
178
  For the person: the report is at https://cortad.com/lab
167
179
  next: findings
168
180
  ```
@@ -221,6 +233,14 @@ Verify of finding:1 started: 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a.
221
233
  next: run_status 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a
222
234
  ```
223
235
 
236
+ When the trials it replays read the finding's question too few times for a rate, it says before they play how many new trials it adds in the finding's situation. They are verify trials from the plan.
237
+
238
+ ```
239
+ Verify of finding:2 started: 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a.
240
+ Playing 10 more trials in this situation to reach about 22 readings of this question; the run before read it 2 times here.
241
+ next: run_status 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a
242
+ ```
243
+
224
244
  ## field_connect
225
245
 
226
246
  ```