cortad 0.2.3 → 0.3.0-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/README.md +11 -2
- package/lib/cli.mjs +90 -38
- package/lib/home.mjs +9 -2
- package/lib/mcp.mjs +32 -12
- package/lib/pyhook/sitecustomize.py +396 -11
- package/lib/read-text.mjs +91 -0
- package/lib/register.mjs +64 -30
- package/lib/replay.mjs +43 -5
- package/lib/spec.mjs +20 -0
- package/lib/start.mjs +10 -0
- package/lib/stick.mjs +99 -0
- package/lib/text.mjs +268 -0
- package/lib/trace.cjs +303 -9
- package/lib/verbs.mjs +148 -107
- package/lib/words.mjs +37 -0
- package/local.mjs +47 -10
- package/package.json +6 -1
- package/skill/SKILL.md +75 -31
- package/skill/references/results.md +175 -34
package/lib/verbs.mjs
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath, sep } from "node:path";
|
|
3
|
+
import { SHOW, showText } from "./read-text.mjs";
|
|
4
|
+
import { fieldConnectText, fieldText, findingsText, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
|
|
5
|
+
|
|
6
|
+
// The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
|
|
7
|
+
// and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
|
|
8
|
+
// functions, so both faces say the same thing.
|
|
9
|
+
//
|
|
10
|
+
// Codex, Cursor CLI and Copilot CLI cut an MCP call at about 60 seconds. `run` and `verify` answer
|
|
11
|
+
// within a second; `run_status` holds for up to 45 seconds on the server and 58 in all.
|
|
12
|
+
|
|
13
|
+
const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person: sign in at cortad.com and run the command the connect screen shows, in this folder.";
|
|
14
|
+
const HOLD_S = 45;
|
|
15
|
+
// What the process that starts a run for an app that is still coming up allows it; lib/cli.mjs.
|
|
16
|
+
export const READY_MS = 240_000;
|
|
17
|
+
|
|
18
|
+
// `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
|
|
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, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
22
|
+
const call = async (method, path, body, timeoutMs = 30_000) => {
|
|
9
23
|
const key = typeof token === "function" ? token() : token;
|
|
10
24
|
if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
|
|
11
25
|
let res;
|
|
@@ -14,10 +28,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
14
28
|
method,
|
|
15
29
|
headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
16
30
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
17
|
-
signal: AbortSignal.timeout(
|
|
31
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
18
32
|
});
|
|
19
33
|
} catch (err) {
|
|
20
|
-
return { status: 0, ok: false, data: { error: `
|
|
34
|
+
return { status: 0, ok: false, data: { error: `Could not reach Cortad: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}.` } };
|
|
21
35
|
}
|
|
22
36
|
const text = await res.text();
|
|
23
37
|
let data = null;
|
|
@@ -25,37 +39,108 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
25
39
|
return { status: res.status, ok: res.ok, data };
|
|
26
40
|
};
|
|
27
41
|
const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
|
|
42
|
+
const iso = () => new Date(now()).toISOString();
|
|
43
|
+
|
|
44
|
+
// With show, one section of the read in full, paged; a name that is not a section is the plain status.
|
|
45
|
+
const status = async ({ show, page } = {}) => {
|
|
46
|
+
const section = SHOW.includes(show) ? show : null;
|
|
47
|
+
const res = await call("GET", `/mcp/status${section ? `?show=${section}` : ""}`);
|
|
48
|
+
if (!res.ok) return failed(res);
|
|
49
|
+
return { text: section ? showText(res.data, section, page) : statusText(res.data), data: res.data };
|
|
50
|
+
};
|
|
28
51
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
// Asks the server for the run and keeps its id here, so run_status finds it without one.
|
|
53
|
+
const post = async (args, kind) => {
|
|
54
|
+
const res = await call("POST", "/mcp/run", args);
|
|
55
|
+
if (res.status === 202 || (res.ok && res.data?.joined)) {
|
|
56
|
+
pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
|
|
57
|
+
return { text: startedText(res.data, kind, args.findingId), data: res.data };
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
60
|
+
if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
|
|
61
|
+
return failed(res);
|
|
32
62
|
};
|
|
33
63
|
|
|
34
|
-
//
|
|
35
|
-
// is
|
|
64
|
+
// An app that is up gets the run now. One that is not is started in the background, and the run
|
|
65
|
+
// is posted from there once it answers.
|
|
36
66
|
const start = async (args, kind) => {
|
|
37
67
|
const before = await call("GET", "/mcp/status");
|
|
38
68
|
if (!before.ok) return failed(before);
|
|
39
|
-
if (before.data.app?.state
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
if (before.data.app?.state === "ready-to-test") return post(args, kind);
|
|
70
|
+
pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
|
|
71
|
+
const began = await startApp(args, kind);
|
|
72
|
+
if (!began.ok) {
|
|
73
|
+
pending.write({ kind, startedAt: iso(), error: began.why });
|
|
74
|
+
return { text: began.why, isError: true };
|
|
42
75
|
}
|
|
43
|
-
|
|
44
|
-
if (res.status === 202 || (res.ok && res.data?.joined)) return { text: startedText(res.data, kind), data: res.data };
|
|
45
|
-
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
46
|
-
return failed(res);
|
|
76
|
+
return { text: STARTING, data: { pending: true } };
|
|
47
77
|
};
|
|
48
78
|
const run = () => start({}, "run");
|
|
49
|
-
const verify = ({ findingId, jobId }) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
|
|
79
|
+
const verify = ({ findingId, jobId } = {}) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
|
|
80
|
+
|
|
81
|
+
// With no id: the run this machine is still starting, held here until it has an id, or else the
|
|
82
|
+
// latest run on the server.
|
|
83
|
+
const resolve = async (until) => {
|
|
84
|
+
const latest = await call("GET", "/mcp/status");
|
|
85
|
+
if (!latest.ok) return failed(latest);
|
|
86
|
+
const newest = latest.data.run?.jobId ?? null;
|
|
87
|
+
let p = pending.read();
|
|
88
|
+
if (!p || p.jobId || (p.after ?? null) !== newest) return newest ? { id: newest } : { text: "No run yet.\nnext: status" };
|
|
89
|
+
const stale = () => now() - Date.parse(p.startedAt) > READY_MS + 30_000;
|
|
90
|
+
while (!p.jobId && !p.error && !stale() && now() < until) {
|
|
91
|
+
await sleep(1000);
|
|
92
|
+
p = pending.read() ?? p;
|
|
93
|
+
}
|
|
94
|
+
if (p.jobId) return { id: p.jobId };
|
|
95
|
+
if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
|
|
96
|
+
if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
|
|
97
|
+
return { text: waitingText(p, now(), READY_MS) };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const runStatus = async ({ jobId } = {}) => {
|
|
101
|
+
const began = now();
|
|
102
|
+
let id = jobId && jobId !== "pending" ? String(jobId) : null;
|
|
103
|
+
if (!id) {
|
|
104
|
+
const found = await resolve(began + HOLD_S * 1000);
|
|
105
|
+
if (!found.id) return found;
|
|
106
|
+
id = found.id;
|
|
107
|
+
}
|
|
108
|
+
const hold = Math.max(0, HOLD_S - Math.ceil((now() - began) / 1000));
|
|
109
|
+
const res = await call("GET", `/mcp/run/${encodeURIComponent(id)}${hold ? `?wait=${hold}` : ""}`, undefined, (hold + 13) * 1000);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const out = failed(res);
|
|
112
|
+
return { ...out, text: `${out.text}\nnext: run_status ${id}` };
|
|
113
|
+
}
|
|
114
|
+
return { text: runText(res.data), data: res.data };
|
|
115
|
+
};
|
|
50
116
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
117
|
+
// Every server page of a run's findings, so the pages the agent reads are cut by size here.
|
|
118
|
+
const gather = async (jobId) => {
|
|
119
|
+
const path = (page, id) => {
|
|
120
|
+
const q = new URLSearchParams({ ...(id ? { jobId: id } : {}), ...(page > 1 ? { page: String(page) } : {}) }).toString();
|
|
121
|
+
return `/mcp/findings${q ? `?${q}` : ""}`;
|
|
122
|
+
};
|
|
123
|
+
const first = await call("GET", path(1, jobId));
|
|
124
|
+
if (!first.ok) return first;
|
|
125
|
+
const d = first.data;
|
|
126
|
+
for (let page = 2; page <= Math.min(d.pages ?? 1, 20); page += 1) {
|
|
127
|
+
const more = await call("GET", path(page, d.runId ?? jobId));
|
|
128
|
+
if (!more.ok) return more;
|
|
129
|
+
d.findings = [...(d.findings ?? []), ...(more.data.findings ?? [])];
|
|
130
|
+
// Every page carries the whole byLine; merged by line with the ids joined, or the first
|
|
131
|
+
// page's groups print twice.
|
|
132
|
+
for (const g of more.data.byLine ?? []) {
|
|
133
|
+
const held = (d.byLine ?? []).find((h) => h.path === g.path && h.line === g.line);
|
|
134
|
+
if (held) held.findingIds = [...new Set([...held.findingIds, ...g.findingIds])];
|
|
135
|
+
else d.byLine = [...(d.byLine ?? []), g];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return first;
|
|
54
139
|
};
|
|
55
140
|
|
|
56
|
-
const findings = async ({ jobId } = {}) => {
|
|
57
|
-
const res = await
|
|
58
|
-
return res.ok ? { text: findingsText(res.data), data: res.data } : failed(res);
|
|
141
|
+
const findings = async ({ jobId, page } = {}) => {
|
|
142
|
+
const res = await gather(jobId);
|
|
143
|
+
return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
|
|
59
144
|
};
|
|
60
145
|
|
|
61
146
|
const dispute = async ({ findingId, why, question, jobId }) => {
|
|
@@ -65,9 +150,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
65
150
|
|
|
66
151
|
const fieldConnect = async () => {
|
|
67
152
|
const res = await call("POST", "/mcp/field/connect");
|
|
68
|
-
|
|
69
|
-
const d = res.data;
|
|
70
|
-
return { text: [d.connected ? "Production is connected." : "Production is not connected yet.", ...d.steps.map((s, i) => `${i + 1}. ${s}`)].join("\n"), data: d };
|
|
153
|
+
return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
|
|
71
154
|
};
|
|
72
155
|
|
|
73
156
|
const field = async ({ days } = {}) => {
|
|
@@ -75,82 +158,40 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
|
|
|
75
158
|
return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
|
|
76
159
|
};
|
|
77
160
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function startedText(d, kind) {
|
|
97
|
-
if (d.joined) return `A ${d.kind ?? kind} is already in flight: ${d.jobId}. Poll run_status every 30 seconds. Watch it: ${d.url}`;
|
|
98
|
-
return `${kind === "verify" ? "Verify" : "Run"} started: ${d.jobId}. Poll run_status every 30 seconds and stay quiet unless the count moved. Watch it: ${d.url}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function refusedText(d) {
|
|
102
|
-
const plans = (d.plans ?? []).map((p) => `${p.name} ($${p.monthlyUsd}/month)`).join(" or ");
|
|
103
|
-
if (d.refused === "plan") return `The first run was free. Another needs ${plans || "a plan"}: ${d.checkout}\nShow this link to the person in one sentence and wait for them.`;
|
|
104
|
-
return `${d.why} More on ${plans || "a bigger plan"}: ${d.checkout}\nShow this link to the person and wait.`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function runText(d) {
|
|
108
|
-
const head = `${d.kind} ${d.jobId} ${d.status}: ${d.of ? `played ${d.played} of ${d.of}.` : "starting, nothing played yet."}`;
|
|
109
|
-
const tail = [];
|
|
110
|
-
if (d.finished && typeof d.score === "number") tail.push(`Score ${d.score} of 100.`);
|
|
111
|
-
if (d.finished && typeof d.findings === "number") tail.push(d.findings ? `${plural(d.findings, "finding")}; call findings.` : "No findings stood out.");
|
|
112
|
-
if (d.stopped) {
|
|
113
|
-
const side = d.stopped.side === "theirs" ? " (their side)" : d.stopped.side === "ours" ? " (Cortad's side)" : "";
|
|
114
|
-
const why = `${d.stopped.why[0].toUpperCase()}${d.stopped.why.slice(1)} at turn ${d.stopped.after}${side}. ${d.stopped.fix}`;
|
|
115
|
-
tail.push(d.of && d.played < d.of ? `Stopped at ${d.played} of ${d.of}: ${why}` : why);
|
|
116
|
-
}
|
|
117
|
-
for (const f of d.faults ?? []) tail.push(`${f.what} ${f.fix}`);
|
|
118
|
-
if (d.error) tail.push(`Error: ${d.error}`);
|
|
119
|
-
if (d.verify) tail.push(verifyText(d.verify));
|
|
120
|
-
if (!d.finished) tail.push("Poll again in 30 seconds.");
|
|
121
|
-
return [head, ...tail, d.url].join(" ");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function verifyText(v) {
|
|
125
|
-
const move = v.visible;
|
|
126
|
-
const line = move
|
|
127
|
-
? `${v.file}:${v.line} · held ${move.before.k} of ${move.before.n} before, ${move.after.k} of ${move.after.n} after · move ${move.point} (${move.low} to ${move.high}) · ${move.moved}${move.insideNoise ? ", inside the noise" : ""}.`
|
|
128
|
-
: "";
|
|
129
|
-
const held = v.holdout ? ` Held-out situations: ${v.holdout.moved}.` : " Held-out situations: no pair.";
|
|
130
|
-
return `Verify of ${v.findingId}: ${line}${held}${v.overfit ? " OVERFIT: the visible cases moved and the held-out ones did not." : ""} ${v.said}`;
|
|
131
|
-
}
|
|
161
|
+
// `status --changed`: the files Cortad cites that changed since the latest run started, with the
|
|
162
|
+
// findings at them. Empty when there is nothing to say, or nothing could be asked.
|
|
163
|
+
const changed = async () => {
|
|
164
|
+
const [s, f] = await Promise.all([call("GET", "/mcp/status"), gather()]);
|
|
165
|
+
const r = s.ok ? s.data?.run : null;
|
|
166
|
+
if (!r) return { text: "" };
|
|
167
|
+
const kept = pending.read();
|
|
168
|
+
const since = r.startedAt ?? (kept?.jobId === r.jobId ? kept.startedAt : null);
|
|
169
|
+
const lines = f.ok ? linesOf(f.data) : [];
|
|
170
|
+
const read = s.data.read ?? {};
|
|
171
|
+
const paths = [
|
|
172
|
+
...lines.map((l) => l.path),
|
|
173
|
+
...(read.rules?.examples ?? []).map((e) => e.path),
|
|
174
|
+
...(read.machine?.misses ?? []).map((m) => m.path),
|
|
175
|
+
...(Array.isArray(read.rules?.files) ? read.rules.files : []),
|
|
176
|
+
].filter((p) => typeof p === "string" && p);
|
|
177
|
+
return { text: changedLine({ root, since, runId: r.jobId, paths, lines }) };
|
|
178
|
+
};
|
|
132
179
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const rows = d.findings.map((f, i) => [
|
|
136
|
-
`${i + 1}. ${f.id} · ${f.asks}`,
|
|
137
|
-
` held ${f.rate.k} of ${f.rate.n} (${pct(f.rate.k, f.rate.n)}, interval ${pct(Math.round(f.rate.lo * f.rate.n), f.rate.n)} to ${pct(Math.round(f.rate.hi * f.rate.n), f.rate.n)}) · ${f.file}:${f.line} · ${f.where}`,
|
|
138
|
-
...f.quotes.slice(0, 1).map((q) => ` reply ${q.reply}: "${q.quote}" (p=${q.p.toFixed(2)})`),
|
|
139
|
-
` replay: ${plural(f.replay.trials, "trial")} · verify ${f.id}`,
|
|
140
|
-
].join("\n"));
|
|
141
|
-
return [`Run ${d.runId}. ${d.read} ${d.heldBack}`, ...rows, `Fix one finding at a time, in the file it names, then verify it. ${d.url}`].join("\n");
|
|
180
|
+
// `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
|
|
181
|
+
return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field, post, changed };
|
|
142
182
|
}
|
|
143
183
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
184
|
+
// Only paths inside the repository are looked at; a path from the server never reaches outside it.
|
|
185
|
+
export function changedLine({ root, since, runId, paths, lines }) {
|
|
186
|
+
const t = Date.parse(since ?? "");
|
|
187
|
+
if (!Number.isFinite(t)) return "";
|
|
188
|
+
const base = realpathSync(root);
|
|
189
|
+
const changedFiles = [...new Set(paths)].filter((rel) => {
|
|
190
|
+
const abs = resolvePath(base, rel);
|
|
191
|
+
if (!abs.startsWith(base + sep)) return false;
|
|
192
|
+
try { return statSync(abs).mtimeMs > t; } catch { return false; }
|
|
193
|
+
});
|
|
194
|
+
if (!changedFiles.length) return "";
|
|
195
|
+
const at = (rel) => lines.filter((l) => l.path === rel).flatMap((l) => l.findingIds);
|
|
196
|
+
return `Changed since run ${runId}: ${changedFiles.map((rel) => (at(rel).length ? `${rel} (${at(rel).join(", ")})` : rel)).join(", ")}.`;
|
|
156
197
|
}
|
package/lib/words.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The small pieces every rendering shares: numbers with their units, file and line, whose side a
|
|
2
|
+
// stop is on, and the pages a long answer is cut into.
|
|
3
|
+
|
|
4
|
+
// Copilot cuts a result past about 10 KB; a page stays under this.
|
|
5
|
+
export const PAGE_CHARS = 9000;
|
|
6
|
+
export const SIDE = { theirs: "on the app's side", ours: "on Cortad's side" };
|
|
7
|
+
|
|
8
|
+
export const has = (v) => v !== undefined && v !== null;
|
|
9
|
+
export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
|
|
10
|
+
export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
|
|
11
|
+
export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
|
|
12
|
+
export const at = (path, line) => `${path}${has(line) ? `:${line}` : ""}`;
|
|
13
|
+
export const clip = (s, max) => (String(s).length > max ? `${String(s).slice(0, max - 3)}...` : String(s));
|
|
14
|
+
|
|
15
|
+
// One page of many, with the call that reads the next one: `next(2)` gives "findings with page 2".
|
|
16
|
+
export function pageOf(pages, page, next) {
|
|
17
|
+
const n = Math.min(Math.max(1, Math.floor(Number(page)) || 1), pages.length);
|
|
18
|
+
const tail = pages.length === 1 ? "" : n < pages.length ? `\npage ${n} of ${pages.length}, call ${next(n + 1)}` : `\npage ${n} of ${pages.length}`;
|
|
19
|
+
return `${pages[n - 1]}${tail}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Lines packed into pages under PAGE_CHARS; every page after the first opens with `continued`.
|
|
23
|
+
export function pack(head, lines, continued, budget = PAGE_CHARS - 80) {
|
|
24
|
+
const pages = [];
|
|
25
|
+
let page = head;
|
|
26
|
+
let filled = false;
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (filled && page.length + line.length + 1 > budget) {
|
|
29
|
+
pages.push(page);
|
|
30
|
+
page = continued;
|
|
31
|
+
filled = false;
|
|
32
|
+
}
|
|
33
|
+
page += `\n${line}`;
|
|
34
|
+
filled = true;
|
|
35
|
+
}
|
|
36
|
+
return [...pages, page];
|
|
37
|
+
}
|
package/local.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { promisify } from "node:util";
|
|
|
23
23
|
import { COMMANDS, main as face } from "./lib/cli.mjs";
|
|
24
24
|
import { clearRunner, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
|
|
25
25
|
import { registerAll } from "./lib/register.mjs";
|
|
26
|
+
import { finished } from "./lib/text.mjs";
|
|
26
27
|
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
27
28
|
import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
|
|
28
29
|
import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
@@ -50,7 +51,13 @@ const viaToken = argv.includes("--token");
|
|
|
50
51
|
// What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
|
|
51
52
|
// this package before anything here runs, and the first thing we printed used to be after the whole
|
|
52
53
|
// upload: a minute or more of a cursor sitting still, which reads as nothing happening.
|
|
53
|
-
|
|
54
|
+
// Without a terminal (a coding agent's shell) each new step is its own line, so the agent reading
|
|
55
|
+
// the output sees the folder being looked at and the upload counting up, not a two-minute blank.
|
|
56
|
+
let lastStep = "";
|
|
57
|
+
const step = (line) => {
|
|
58
|
+
if (process.stdout.isTTY) { process.stdout.write(`\rcortad ${line}\x1b[K`); return; }
|
|
59
|
+
if (line !== lastStep) { lastStep = line; say(line); }
|
|
60
|
+
};
|
|
54
61
|
const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
|
|
55
62
|
const stepDone = (line) => { clearStep(); say(line); };
|
|
56
63
|
if (!explain && !viaToken && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain | npx cortad status | run | findings | verify <id>");
|
|
@@ -100,7 +107,7 @@ function walk(dir, depth, out, envs, total) {
|
|
|
100
107
|
try { size = statSync(full).size; } catch { continue; }
|
|
101
108
|
if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
|
|
102
109
|
if (size > MAX_DATA && DATA_FILE.test(e.name) && !MANIFEST_FILE.test(e.name)) continue;
|
|
103
|
-
if (!shareable(relative(root, full))) continue;
|
|
110
|
+
if (!shareable(relative(root, full), false)) continue;
|
|
104
111
|
out.push(relative(root, full));
|
|
105
112
|
total += size;
|
|
106
113
|
}
|
|
@@ -121,12 +128,14 @@ function gitListed() {
|
|
|
121
128
|
} catch (e) { fail(`could not list this repository's files with git: ${e.message}`); }
|
|
122
129
|
}
|
|
123
130
|
// One rule for every file that could leave: the upload, and a read the engine asks for later.
|
|
124
|
-
function shareable(rel) {
|
|
131
|
+
function shareable(rel, askGit = true) {
|
|
125
132
|
const parts = rel.split(sep);
|
|
126
133
|
if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
|
|
127
|
-
if (listed === null) return true;
|
|
128
|
-
|
|
129
|
-
//
|
|
134
|
+
if (listed === null || listed.has(rel)) return true;
|
|
135
|
+
// Off the list git gave a moment ago: ignored, or inside a nested checkout. The walk takes git's
|
|
136
|
+
// word for it; asking per file was one process each, two minutes on a repository with a worktree
|
|
137
|
+
// inside it. A read asked later, of a file made after the list was taken (an agent edit), asks.
|
|
138
|
+
if (!askGit) return false;
|
|
130
139
|
try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
|
|
131
140
|
}
|
|
132
141
|
let listed = null;
|
|
@@ -384,7 +393,8 @@ async function verb(job) {
|
|
|
384
393
|
keep(res);
|
|
385
394
|
if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
|
|
386
395
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
387
|
-
|
|
396
|
+
// An OpenAPI document can run to megabytes; the caller asks for it whole.
|
|
397
|
+
const LIMIT = Number.isInteger(b.limit) && b.limit > 0 ? Math.min(b.limit, 8 * 1024 * 1024) : 262_144;
|
|
388
398
|
// A session cookie your app sets is its business: it is dropped here, and every other header masked.
|
|
389
399
|
const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
|
|
390
400
|
return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
|
|
@@ -425,7 +435,10 @@ async function verb(job) {
|
|
|
425
435
|
case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
|
|
426
436
|
// What your app spent on its providers, from the hook inside it. An app this command did not
|
|
427
437
|
// start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
|
|
428
|
-
|
|
438
|
+
// Masked like every other reply: a tool's answer can carry a value from their env files.
|
|
439
|
+
case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
|
|
440
|
+
// Every route your app holds, read by the hook off the app itself, masked like the meter.
|
|
441
|
+
case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
|
|
429
442
|
// A world is ended from this terminal, never from the cloud.
|
|
430
443
|
case "destroy": return { ok: true };
|
|
431
444
|
default: return { ok: true };
|
|
@@ -672,8 +685,28 @@ async function installOnce(said) {
|
|
|
672
685
|
return true;
|
|
673
686
|
}
|
|
674
687
|
|
|
688
|
+
// The port their start command names, moved when something else already holds it: two apps on one
|
|
689
|
+
// laptop both said --port 8000, and the second attached to the first's server as its own.
|
|
690
|
+
// Ports this program tried and lost: two connects on one machine can pick the same free port in
|
|
691
|
+
// the same second, and the one that binds second sees "address already in use".
|
|
692
|
+
const lost = new Set();
|
|
693
|
+
async function freePortAbove(port) {
|
|
694
|
+
for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
async function freed(cmd, lifted) {
|
|
698
|
+
const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
|
|
699
|
+
const named = m ? Number(m[2]) : Number(lifted.PORT) || 0;
|
|
700
|
+
if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
|
|
701
|
+
const port = await freePortAbove(named);
|
|
702
|
+
if (!port) return { cmd, lifted };
|
|
703
|
+
say(`port ${named} is taken on this machine, so your app starts on ${port}`);
|
|
704
|
+
// The same port their env file names, moved the same way.
|
|
705
|
+
return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
|
|
706
|
+
}
|
|
707
|
+
|
|
675
708
|
async function start(waitMs) {
|
|
676
|
-
const { cmd, lifted } = launched;
|
|
709
|
+
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
677
710
|
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
678
711
|
const mine = child;
|
|
679
712
|
let seen = "";
|
|
@@ -693,6 +726,10 @@ async function start(waitMs) {
|
|
|
693
726
|
// that holds the port is theirs and already running, which startApp turns into attaching to it.
|
|
694
727
|
if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
|
|
695
728
|
await stopApp(mine.pid);
|
|
729
|
+
// The port this start named was taken between the check and the bind: the next free one, at
|
|
730
|
+
// most three times, before the failure is theirs to read.
|
|
731
|
+
const named = /(?:--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd)?.[1] ?? lifted.PORT;
|
|
732
|
+
if (/EADDRINUSE|address already in use/i.test(seen) && named && lost.size < 3) { lost.add(Number(named)); return start(waitMs); }
|
|
696
733
|
return { port: null, exited: 1, tail: tail() };
|
|
697
734
|
}
|
|
698
735
|
// The port is what the app's own process group listens on. Never a guess: a developer's
|
|
@@ -981,7 +1018,7 @@ if (argv.includes("--until-idle") && stored) {
|
|
|
981
1018
|
let idleSince = Date.now();
|
|
982
1019
|
setInterval(async () => {
|
|
983
1020
|
const res = await call("GET", "/mcp/status", undefined, { headers: { authorization: `Bearer ${stored}` } }).catch(() => null);
|
|
984
|
-
if (res?.ok && res.data?.run && !res.data.run
|
|
1021
|
+
if (res?.ok && res.data?.run && !finished(res.data.run)) idleSince = Date.now();
|
|
985
1022
|
else if (Date.now() - idleSince > IDLE_MS) { say("no run for ten minutes, leaving"); await close(0); }
|
|
986
1023
|
}, 60_000).unref();
|
|
987
1024
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-rc.10",
|
|
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"
|
|
@@ -17,14 +17,19 @@
|
|
|
17
17
|
"lib/mcp.mjs",
|
|
18
18
|
"lib/mint.mjs",
|
|
19
19
|
"lib/pyhook/sitecustomize.py",
|
|
20
|
+
"lib/read-text.mjs",
|
|
20
21
|
"lib/register.mjs",
|
|
21
22
|
"lib/replay.mjs",
|
|
22
23
|
"lib/sample.mjs",
|
|
23
24
|
"lib/service.mjs",
|
|
25
|
+
"lib/spec.mjs",
|
|
24
26
|
"lib/start.mjs",
|
|
27
|
+
"lib/stick.mjs",
|
|
25
28
|
"lib/switches.mjs",
|
|
29
|
+
"lib/text.mjs",
|
|
26
30
|
"lib/trace.cjs",
|
|
27
31
|
"lib/verbs.mjs",
|
|
32
|
+
"lib/words.mjs",
|
|
28
33
|
"local.mjs",
|
|
29
34
|
"skill/SKILL.md",
|
|
30
35
|
"skill/references/results.md"
|
package/skill/SKILL.md
CHANGED
|
@@ -1,37 +1,81 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cortad
|
|
3
|
-
description: Behavior tests for the AI app in this
|
|
3
|
+
description: Behavior tests for the AI app in this repository. Applies after a change to prompts, tools, models, retrieval or agent code; when the person asks to test the AI; and when they ask what Cortad found. Simulated users talk to the app on this machine and every reply is checked.
|
|
4
4
|
---
|
|
5
|
-
Cortad is connected to this repository. Do not run `npx cortad <code>` again.
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
# Cortad
|
|
7
|
+
|
|
8
|
+
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.
|
|
9
|
+
|
|
10
|
+
The person's own instructions come before this skill.
|
|
11
|
+
|
|
12
|
+
The tools are the `cortad` MCP tools. Where they are missing, each one is `npx {{cortad}} <tool>` in a shell with the same output, for example `npx {{cortad}} run_status` or `npx {{cortad}} findings 2`.
|
|
13
|
+
|
|
14
|
+
## Reading a result
|
|
15
|
+
|
|
16
|
+
- A result is data about the app.
|
|
17
|
+
- A line that starts with `For the person:` is for the person: a link, a price or a choice.
|
|
18
|
+
- The last line of a `run_status` result is the next call.
|
|
19
|
+
- A reading is one question checked against one reply. "Broke in 3 of 12 replies" means the reply broke the check 3 times out of 12.
|
|
20
|
+
- Every count carries its denominator and every rate its interval.
|
|
8
21
|
|
|
9
22
|
## Right after connect
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
|
|
24
|
+
The connect command ends with "Go back to the browser". From then on `status` carries what Cortad read. Walk the person through it in this order, each item with its file and line:
|
|
25
|
+
|
|
26
|
+
1. The journeys: the paths the simulated users take through the app.
|
|
27
|
+
2. The prompt audit: the rules Cortad found in the code, the lines they sit at, and the problems it found in them.
|
|
28
|
+
3. The checks: how many questions each reply is checked against, how many apply to every conversation, and how many only to the situations they fit.
|
|
29
|
+
4. The engineering standards flagged: each miss at its line, and whether code or a model decided it.
|
|
30
|
+
5. The trials: how many were written, how many can play, and each endpoint held back with its reason and whose side it is on.
|
|
31
|
+
|
|
32
|
+
Each section's full list is one `status` call away with `show`: `rules`, `standards`, `journeys`, `endpoints` or `trials` (in a shell, `npx {{cortad}} status rules`). A long list comes in pages, and the last line names the next page.
|
|
33
|
+
|
|
34
|
+
Then the findings, then the fixes.
|
|
35
|
+
|
|
36
|
+
## Following a run
|
|
37
|
+
|
|
38
|
+
The first run starts by itself after a connect. `run_status` follows it. Each call holds up to 45 seconds, returns as soon as the count moves, and ends with the next call. With no id it follows the run this machine started last, or the latest run.
|
|
39
|
+
|
|
40
|
+
`run` starts a run when the person asks for one, and answers within a second. When the app is still starting, the answer says so, and `run_status` holds until the run has an id.
|
|
41
|
+
|
|
42
|
+
When the plan is spent, `run` answers with the ledger: the last run's score and findings, each fix verified since with its move, what the next run would play, the plan that covers it, and a `For the person:` line with the checkout link. Nothing ran.
|
|
43
|
+
|
|
44
|
+
## Findings
|
|
45
|
+
|
|
46
|
+
`findings` lists what failed, worst first, grouped by the file and line the rule lives at. Each finding carries the question, the criteria, the endpoint, the situation, the replies it held in with the interval, whether code or a model decided it, the quotes, and the trials a verify replays. "Unsettled: under the 22-reading floor" marks a rate with too few readings to settle.
|
|
47
|
+
|
|
48
|
+
Take the person through them worst first, each with its file and line. A long list comes in pages; the last line names the next page.
|
|
49
|
+
|
|
50
|
+
## Fixing one finding
|
|
51
|
+
|
|
52
|
+
1. One change, in the file and near the line the finding names.
|
|
53
|
+
2. `verify <findingId>`. It answers within a second, and `run_status` follows it. Cortad starts the app again itself before the replay, so the saved edit is what plays.
|
|
54
|
+
3. Read the move on the `Visible trials:` and `Held-out trials:` lines:
|
|
55
|
+
- `improved` without `inside the noise`, with the held-out line improved too or `no pair`: the behavior moved. The change stays.
|
|
56
|
+
- `no change` or `regressed`: the file goes back to how it was.
|
|
57
|
+
- `inside the noise` or `unsettled`: the trials cannot tell yet, which is not a verdict against the change. `verify` again to add readings; a change the app's own tests support may stay, said as not yet verified by Cortad.
|
|
58
|
+
- A verify that played no trial says so and compares nothing; the file stays as it was left.
|
|
59
|
+
- 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.
|
|
60
|
+
- `regressed`: the file goes back.
|
|
61
|
+
4. Then the next finding.
|
|
62
|
+
|
|
63
|
+
The trials, checks, seeds and held-out set belong to Cortad, and the app answers Cortad's simulated users the way it answers anyone. A number moves when the app's behavior moves.
|
|
64
|
+
|
|
65
|
+
## The second run
|
|
66
|
+
|
|
67
|
+
After the fixes, a second run measures the whole app again. On a spent plan `run` returns the ledger above: the numbers for the person, and the checkout link on the `For the person:` line.
|
|
68
|
+
|
|
69
|
+
## Making it stick
|
|
70
|
+
|
|
71
|
+
After the first run's findings, `npx {{cortad}} stick` makes this part of the repository: one line in AGENTS.md, CLAUDE.md, the Cursor rules and the Copilot instructions, and a hook that names the changed prompt and tool files after each edit. `npx {{cortad}} unstick` takes them out. Both print every file they changed.
|
|
72
|
+
|
|
73
|
+
## A check that reads wrong
|
|
74
|
+
|
|
75
|
+
`dispute <findingId> "<why>"` sends the note to the owner. The finding and its rate stay as they are until the owner decides.
|
|
76
|
+
|
|
77
|
+
## Production
|
|
78
|
+
|
|
79
|
+
`field_connect` gives the steps that send production replies to Cortad; the owner creates the key in the browser. `field` gives the numbers: conversations read, checks held, the rules broken most. Message text stays out.
|
|
80
|
+
|
|
81
|
+
One example of each result is in [references/results.md](references/results.md).
|