cortad 0.3.1-rc.1 → 0.3.1-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cli.mjs +8 -0
- package/lib/data.mjs +1 -1
- package/lib/fresh.mjs +108 -0
- package/lib/home.mjs +7 -4
- package/lib/mcp.mjs +3 -3
- package/lib/pyhook/sitecustomize.py +1024 -86
- package/lib/read-text.mjs +12 -2
- package/lib/replay.mjs +25 -7
- package/lib/text.mjs +127 -35
- package/lib/trace.cjs +651 -57
- package/lib/verbs.mjs +37 -11
- package/lib/writes.mjs +59 -0
- package/local.mjs +79 -19
- package/package.json +3 -1
- package/skill/SKILL.md +9 -6
- package/skill/references/results.md +40 -4
package/lib/cli.mjs
CHANGED
|
@@ -2,7 +2,9 @@ import { closeSync, openSync } from "node:fs";
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { makeCode } from "./fresh.mjs";
|
|
5
6
|
import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
|
|
7
|
+
import { markRun, putBack, writesDirOf } from "./writes.mjs";
|
|
6
8
|
import { serveMcp } from "./mcp.mjs";
|
|
7
9
|
import { SHOW } from "./read-text.mjs";
|
|
8
10
|
import { cliSpec, VERSION } from "./spec.mjs";
|
|
@@ -17,6 +19,9 @@ import { makeVerbs, READY_MS } from "./verbs.mjs";
|
|
|
17
19
|
const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
|
|
18
20
|
const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
|
|
19
21
|
const WAITER = "run-when-up";
|
|
22
|
+
// How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
|
|
23
|
+
// about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
|
|
24
|
+
const RESTART_MS = { mcp: 45_000, shell: 150_000 };
|
|
20
25
|
export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
|
|
21
26
|
export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
|
|
22
27
|
|
|
@@ -35,12 +40,15 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
|
|
|
35
40
|
const children = [];
|
|
36
41
|
const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
|
|
37
42
|
// The key is read at every call: the MCP process outlives a connect that writes it after the process started.
|
|
43
|
+
const writes = { mark: (id) => markRun(writesDirOf(homeOf(project)), id), putBack: () => putBack(writesDirOf(homeOf(project)), root) };
|
|
38
44
|
const verbs = makeVerbs({
|
|
39
45
|
api: `${origin.origin}/api`,
|
|
40
46
|
root,
|
|
47
|
+
writes,
|
|
41
48
|
token: () => readToken(project),
|
|
42
49
|
pending,
|
|
43
50
|
startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
|
|
51
|
+
code: makeCode({ project, root, waitMs: mcp ? RESTART_MS.mcp : RESTART_MS.shell }),
|
|
44
52
|
});
|
|
45
53
|
|
|
46
54
|
if (command === WAITER) {
|
package/lib/data.mjs
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
|
@@ -10,7 +10,7 @@ export const PROTOCOL = "2025-06-18";
|
|
|
10
10
|
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
11
11
|
export const INSTRUCTIONS = [
|
|
12
12
|
"Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
|
|
13
|
-
"Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting. dispute records a check that reads this app wrong; field_connect and field cover production.",
|
|
13
|
+
"Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting; its last sentence says whether the evidence supports keeping the fix. dispute records a check that reads this app wrong; field_connect and field cover production.",
|
|
14
14
|
].join("\n\n");
|
|
15
15
|
|
|
16
16
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
@@ -27,10 +27,10 @@ export const TOOLS = [
|
|
|
27
27
|
description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
|
|
28
28
|
inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
|
|
29
29
|
{ name: "findings", title: "Findings", annotations: readOnly,
|
|
30
|
-
description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes,
|
|
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
|
|
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.",
|