cortad 0.3.1-rc.6 → 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 +5 -0
- package/lib/data.mjs +1 -1
- package/lib/fresh.mjs +108 -0
- package/lib/home.mjs +7 -4
- package/lib/mcp.mjs +1 -1
- package/lib/text.mjs +61 -6
- package/lib/verbs.mjs +26 -11
- package/lib/writes.mjs +11 -5
- package/local.mjs +52 -13
- package/package.json +2 -1
- package/skill/SKILL.md +5 -4
- package/skill/references/results.md +21 -1
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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" ?
|
|
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,13 +146,34 @@ 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) {
|
|
162
|
+
// An access verify is a re-knock, not a trial move: the routes side by side, then the count.
|
|
163
|
+
if (v.access) {
|
|
164
|
+
const lines = [`Verify of ${v.findingId}.`];
|
|
165
|
+
for (const r of v.routes ?? []) {
|
|
166
|
+
const then = r.wasOpen ? `answered ${r.before} with no sign-in` : `refused (${r.before || "no answer"})`;
|
|
167
|
+
const now = r.nowOpen ? `answers ${r.after} with no sign-in` : r.after ? `refuses (${r.after})` : "did not answer";
|
|
168
|
+
lines.push(` ${r.method} ${r.route}: ${then}, now ${now}.`);
|
|
169
|
+
}
|
|
170
|
+
if (v.said) lines.push(v.said);
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
147
173
|
const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
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}.`}`);
|
|
151
177
|
else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
|
|
152
178
|
if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
|
|
153
179
|
if (v.said) lines.push(v.said);
|
|
@@ -166,10 +192,23 @@ export function runText(d) {
|
|
|
166
192
|
return lines.join("\n");
|
|
167
193
|
}
|
|
168
194
|
|
|
169
|
-
export function startedText(d, kind, findingId) {
|
|
195
|
+
export function startedText(d, kind, findingId, said = []) {
|
|
170
196
|
const verify = (d.kind ?? kind) === "verify";
|
|
171
197
|
const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
|
|
172
|
-
return `${what} ${d.joined ? "already playing" : "started"}: ${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)}.`;
|
|
173
212
|
}
|
|
174
213
|
|
|
175
214
|
export const waitingText = (p, now, limitMs) =>
|
|
@@ -226,13 +265,18 @@ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n)
|
|
|
226
265
|
function findingsHead(d) {
|
|
227
266
|
if (!d.runId) return ["No run has finished on this repository yet."];
|
|
228
267
|
const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
|
|
268
|
+
if (d.tested) lines.push(testedLine(d.tested));
|
|
229
269
|
lines.push(...measuredLines(d));
|
|
230
270
|
if (d.blocked) lines.push(d.blocked);
|
|
231
271
|
if (d.halted) lines.push(haltLine(d.halted));
|
|
272
|
+
lines.push(...waitLines(d));
|
|
232
273
|
const reads = readingsLine(d);
|
|
233
274
|
if (reads) lines.push(reads);
|
|
234
275
|
if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
|
|
235
276
|
for (const s of [d.read, d.heldBack, d.findings?.length ? null : d.why]) if (s) lines.push(s);
|
|
277
|
+
// A finding in a situation the run keeps back: what failed and where, so the rule can be fixed;
|
|
278
|
+
// its inputs and trials stay unseen, and verify replays it all the same.
|
|
279
|
+
for (const k of d.keptBack ?? []) lines.push(`Kept back ${k.id}: "${clip(k.asks, 160)}"${k.file ? ` at ${at(k.file, k.line)}` : ""}, held ${kn(k.rate)} at reply ${num(k.reply)}${k.quote ? `; the reply said "${clip(k.quote, 120)}"` : ""}. Its inputs are not shown; verify ${k.id} replays them.`);
|
|
236
280
|
return lines.join("\n");
|
|
237
281
|
}
|
|
238
282
|
|
|
@@ -271,6 +315,17 @@ function findingsPages(d, budget = PAGE_CHARS - 60) {
|
|
|
271
315
|
}
|
|
272
316
|
|
|
273
317
|
function findingBlock(f, placed) {
|
|
318
|
+
// A gate the run knocked open: the route, the address, the request sent and the status it answered
|
|
319
|
+
// with no sign-in. Its own words, then how to prove the fix.
|
|
320
|
+
if (f.kind === "access-open") {
|
|
321
|
+
const lines = [`${f.id} ${f.says}`];
|
|
322
|
+
if (f.path) lines.push(` At ${at(f.path, f.line)}`);
|
|
323
|
+
if (f.standard) lines.push(` Standard: ${clip(f.standard, 200)}`);
|
|
324
|
+
lines.push(` Sent: ${f.request.method} ${f.request.path}${f.request.body ? ` ${clip(f.request.body, 200)}` : ""}${f.as === "another-user" ? " as another user" : " with no sign-in"}`);
|
|
325
|
+
lines.push(` Answered: ${f.status}${has(f.response) && f.response ? `, ${clip(f.response, 400)}` : ""}`);
|
|
326
|
+
lines.push(` Replay: verify ${f.id}`);
|
|
327
|
+
return lines.join("\n");
|
|
328
|
+
}
|
|
274
329
|
const lines = [`${f.id} ${f.asks}`];
|
|
275
330
|
if (!placed && f.file) lines.push(` At ${at(f.file, f.line)}`);
|
|
276
331
|
if (f.criteria) lines.push(` Criteria: ${clip(f.criteria, 600)}`);
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
// is
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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]),
|
|
652
|
-
|
|
653
|
-
for (let i = 0; i < 8; i++) {
|
|
654
|
-
const parent = table.get(
|
|
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
|
-
|
|
660
|
+
chain.push(parent);
|
|
657
661
|
}
|
|
658
|
-
return
|
|
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) => {
|
|
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))
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
```
|