cortad 0.1.14 → 0.2.0

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 CHANGED
@@ -1,19 +1,33 @@
1
1
  # cortad
2
2
 
3
- Connects the AI app on your machine to Cortad, so Cortad can run test conversations against it.
3
+ Connects the AI app on your machine to Cortad, so Cortad can run test conversations against it, and gives your coding agent the tools to run them.
4
4
 
5
5
  ```
6
6
  npx cortad <code>
7
7
  ```
8
8
 
9
- Run it in your app's folder with the code from cortad.com. Nothing is installed. Ctrl-C disconnects.
9
+ Run it in your app's folder with the code from cortad.com. Nothing is installed in your project. Ctrl-C disconnects.
10
10
 
11
11
  ## What it does
12
12
 
13
13
  - Starts your app with its dev script, or uses it if it is already running.
14
14
  - Sends Cortad's test conversations to your app on localhost and returns the replies.
15
15
  - Signs in as a test account when your app needs one: an account made for the session, never an existing user and never an admin.
16
- - Lets the Cortad agent edit files in your project. Every edit can be undone from the browser. Git is left alone.
16
+ - Makes Cortad known to the coding agents on this machine (Claude Code, Codex, Cursor): an MCP entry and a skill in each one's own home folder, so `npx cortad mcp` answers them from then on. Nothing is written into your repository.
17
+ - Never writes your files. The agent that edits your code is your own.
18
+
19
+ ## For your coding agent
20
+
21
+ After the first connect, your agent has eight tools: `status`, `run`, `run_status`, `findings`, `verify`, `dispute`, `field_connect`, `field`. The same eight work as shell commands:
22
+
23
+ ```
24
+ npx cortad status
25
+ npx cortad run
26
+ npx cortad findings
27
+ npx cortad verify <findingId>
28
+ ```
29
+
30
+ A run needs your app up. When nothing on this machine is holding it, the command starts it with the key the first connect left in `~/.cortad`, and leaves when no run has needed it for ten minutes.
17
31
 
18
32
  ## What Cortad receives
19
33
 
@@ -24,10 +38,11 @@ Run it in your app's folder with the code from cortad.com. Nothing is installed.
24
38
 
25
39
  - Your env files and their values.
26
40
  - Tokens and cookies. A signed-in test request gets its token added here.
41
+ - The key in `~/.cortad/<project>/token` (readable by you only). It starts runs and reads findings for this one repository and nothing else; revoke it from your account page.
27
42
 
28
- ## The agent's shell
43
+ ## The test shell
29
44
 
30
- Commands the agent runs are confined by the operating system (Seatbelt on macOS, bubblewrap on Linux). They can read your project and your toolchains, write only to temp and build folders, and reach only localhost.
45
+ Commands a run needs on this machine (your own test suite, a WebSocket door) are confined by the operating system (Seatbelt on macOS, bubblewrap on Linux). They can read your project and your toolchains, write only to temp and build folders, and reach only localhost.
31
46
 
32
47
  ## Loaded into your app
33
48
 
@@ -44,7 +59,8 @@ When cortad starts your app it preloads one short file, `lib/trace.cjs` for Node
44
59
 
45
60
  ## Files it creates
46
61
 
47
- - `~/.cortad/checkpoints/` undo data for agent edits
48
- - `$TMPDIR/cortad-<pid>/` removed on exit
62
+ - `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up
63
+ - `~/.cortad/identity.key` the seed for the session's test accounts
64
+ - `$TMPDIR/cortad-<pid>/` removed on exit
49
65
 
50
66
  macOS and Linux, Node 20+. No dependencies, no install scripts.
package/lib/cli.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import { openSync, readFileSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { homeOf, projectOf, readRunner, readToken, writeRunner } from "./home.mjs";
6
+ import { serveMcp } from "./mcp.mjs";
7
+ import { makeVerbs } from "./verbs.mjs";
8
+
9
+ // `npx cortad mcp` and `npx cortad <verb>`: the two faces a coding agent uses after the first
10
+ // connect. Neither uploads, starts or edits anything by itself. When a run needs the app up and
11
+ // nothing on this machine is holding it, the runner (local.mjs with the stored key) is started.
12
+ const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
13
+ const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
14
+ const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
15
+ export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
16
+ export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect"]);
17
+
18
+ export async function main(argv, { root = process.cwd(), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
19
+ const origin = new URL(env.CORTAD_ORIGIN || "https://cortad.com");
20
+ const trusted = (origin.protocol === "https:" && OURS.some((host) => origin.hostname === host || origin.hostname.endsWith(`.${host}`)))
21
+ || origin.hostname === "localhost" || origin.hostname === "127.0.0.1";
22
+ if (!trusted) { stderr.write(`cortad refusing: ${origin.host} is not Cortad.\n`); return 1; }
23
+ const api = `${origin.origin}/api`;
24
+ const project = projectOf(root);
25
+ const token = readToken(project);
26
+ const [command, ...rest] = argv;
27
+ const mcp = command === "mcp";
28
+ const children = [];
29
+ const verbs = makeVerbs({ api, token, ensureRunner: () => ensureRunner({ root, project, env, keep: mcp, children, say: (line) => stderr.write(`cortad ${line}\n`) }) });
30
+
31
+ if (mcp) {
32
+ const code = await serveMcp({ verbs, version: VERSION, log: (line) => stderr.write(`${line}\n`) });
33
+ for (const child of children) try { child.kill("SIGTERM"); } catch { /* gone */ }
34
+ return code;
35
+ }
36
+ const verb = verbs[command.replace(/-/g, "_")];
37
+ const out = await verb(argsOf(command, rest));
38
+ stdout.write(`${out.text}\n`);
39
+ return out.isError ? 1 : 0;
40
+ }
41
+
42
+ // Positional arguments for the shell face: `verify f1`, `run_status <jobId>`, `dispute f1 "why"`.
43
+ function argsOf(command, rest) {
44
+ switch (command.replace(/-/g, "_")) {
45
+ case "run_status": return { jobId: rest[0] };
46
+ case "findings": return rest[0] ? { jobId: rest[0] } : {};
47
+ case "verify": return { findingId: rest[0], ...(rest[1] ? { jobId: rest[1] } : {}) };
48
+ case "dispute": return { findingId: rest[0], why: rest.slice(1).join(" ") };
49
+ case "field": return rest[0] ? { days: rest[0] } : {};
50
+ default: return {};
51
+ }
52
+ }
53
+
54
+ // The app has to be up on this machine for a run. A connect session in another terminal is holding
55
+ // it; failing that, local.mjs is started here with the stored key and does what the first connect
56
+ // did: attach, upload if the tree changed, start the app, announce it, answer the knocks. From the
57
+ // MCP it lives as long as the MCP does; from a shell command it stays until the runs stop for a
58
+ // while, then leaves (--until-idle).
59
+ const READY_MS = 240_000;
60
+ export async function ensureRunner({ root, project, env, keep, children, say, poll = statusOf }) {
61
+ if (!readRunner(project)) {
62
+ const log = openSync(join(homeOf(project), "runner.log"), "a");
63
+ const child = spawn(process.execPath, [LOCAL, "--token", ...(keep ? [] : ["--until-idle"])], {
64
+ cwd: root, env, stdio: ["ignore", log, log], detached: !keep,
65
+ });
66
+ if (!keep) child.unref(); else children.push(child);
67
+ writeRunner(project, { pid: child.pid, startedAt: new Date().toISOString(), by: keep ? "mcp" : "cli" });
68
+ say("starting your app for the run");
69
+ }
70
+ const until = Date.now() + READY_MS;
71
+ while (Date.now() < until) {
72
+ await new Promise((r) => setTimeout(r, 3000));
73
+ const state = await poll(env, project);
74
+ if (state === "ready-to-test") return { ok: true };
75
+ if (!readRunner(project)) return { ok: false, why: `Your app did not come up. The runner's output is in ${join(homeOf(project), "runner.log")}.` };
76
+ }
77
+ return { ok: false, why: "Your app did not come up within four minutes. Run the command from the connect screen in a terminal to see why." };
78
+ }
79
+
80
+ async function statusOf(env, project) {
81
+ const token = readToken(project);
82
+ if (!token) return null;
83
+ try {
84
+ const res = await fetch(`${new URL(env.CORTAD_ORIGIN || "https://cortad.com").origin}/api/mcp/status`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
85
+ return res.ok ? (await res.json()).app?.state ?? null : null;
86
+ } catch { return null; }
87
+ }
package/lib/home.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // What this machine keeps between sessions, per project, under ~/.cortad/<project>/: the key the
7
+ // first connect left behind, the digest of the tree last uploaded, and which process is holding the
8
+ // app up. The folder's path never leaves the machine; the project is a hash of it.
9
+ export const projectOf = (root) => createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
10
+ export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
11
+
12
+ const read = (file) => { try { return readFileSync(file, "utf8").trim() || null; } catch { return null; } };
13
+ const write = (dir, name, text) => { mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(join(dir, name), text, { mode: 0o600 }); };
14
+
15
+ export const readToken = (project, base) => read(join(homeOf(project, base), "token"));
16
+ export const writeToken = (project, token, base) => write(homeOf(project, base), "token", token);
17
+
18
+ export const readDigest = (project, base) => read(join(homeOf(project, base), "digest"));
19
+ export const writeDigest = (project, digest, base) => write(homeOf(project, base), "digest", digest);
20
+
21
+ // The process holding this project's app up for runs, if one is alive. A pid that no longer answers
22
+ // is a stale file, not a runner.
23
+ export function readRunner(project, base) {
24
+ const raw = read(join(homeOf(project, base), "runner.json"));
25
+ if (!raw) return null;
26
+ let runner;
27
+ try { runner = JSON.parse(raw); } catch { return null; }
28
+ if (!Number.isInteger(runner?.pid)) return null;
29
+ try { process.kill(runner.pid, 0); return runner; } catch { return null; }
30
+ }
31
+ export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
32
+ export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
33
+
34
+ export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
package/lib/mcp.mjs ADDED
@@ -0,0 +1,80 @@
1
+ // The MCP face: newline-delimited JSON-RPC 2.0 on stdin and stdout, protocol 2025-06-18, no
2
+ // dependency. stdout carries protocol messages and nothing else; everything a person might read
3
+ // goes to stderr. The tools are lib/verbs.mjs, one to one.
4
+
5
+ export const PROTOCOL = "2025-06-18";
6
+
7
+ export const INSTRUCTIONS = [
8
+ "Cortad is an independent behavior test lab for the AI app in this repository. It runs simulated users through the app on this machine and grades every reply with about 100 checks. It is already connected; never run `npx cortad <code>` again.",
9
+ "The loop: status, then run only when the person asks (the first run is free; after that run returns a checkout link, show it in one sentence and wait), poll run_status every 30 seconds and stay quiet unless the count moved, findings, fix one finding at a time in the repo, verify <id>, read the move.",
10
+ "Rules: a move inside the interval is not a fix. Never change or remove a case to move a number; never make the app recognise test traffic; if a check reads wrong, call dispute with why. Cases, checks, seeds and the holdout are not yours to edit, and Cortad refuses it anyway.",
11
+ ].join("\n\n");
12
+
13
+ const str = (description, extra = {}) => ({ type: "string", description, ...extra });
14
+
15
+ export const TOOLS = [
16
+ { name: "status", description: "Where this repository stands with Cortad: plan and runs left, whether the app is up, conversations written, the latest run, whether production is connected. Call first.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
17
+ { name: "run", description: "Run the whole suite of simulated conversations against the app on this machine. Only when the person asks. The first run is free; afterwards it returns a checkout link to show the person. Starts the app if it is not up.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
18
+ { name: "run_status", description: "Where a run or verify stands: played n of N, the score when finished, the paired move for a verify. Poll every 30 seconds.", inputSchema: { type: "object", properties: { jobId: str("The id run or verify returned.") }, required: ["jobId"], additionalProperties: false } },
19
+ { name: "findings", description: "The failures of the latest run: each with its rate and interval, a quote, the file and line, what good looks like. Start from the worst.", inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest.") }, additionalProperties: false } },
20
+ { name: "verify", description: "After a fix: replay one finding's trials with the same seeds and read the move, visible and held-out apart. Spends verify trials, not a run.", 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 } },
21
+ { name: "dispute", description: "Say that a finding's check reads wrong. Changes nothing; the note goes to the owner.", inputSchema: { type: "object", properties: { findingId: str("The finding id."), why: str("One or two sentences: what the check gets wrong about this app.", { maxLength: 500 }), question: str("The wording you would ask instead.", { maxLength: 300 }) }, required: ["findingId", "why"], additionalProperties: false } },
22
+ { name: "field_connect", description: "How to connect production so real conversations are read with the same checks. Returns the steps; the key is created by the owner in the browser, never here.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
23
+ { name: "field", description: "Production in numbers only: conversations read, rulings held, resolved, frustrated, asks for a human, the rules broken most. Never message text.", inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
24
+ ];
25
+
26
+ const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
27
+ const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
28
+
29
+ // Serves until stdin closes. `verbs` is lib/verbs.mjs's object; `version` is the package's.
30
+ export function serveMcp({ verbs, version, input = process.stdin, output = process.stdout, log = () => {} }) {
31
+ return new Promise((resolve) => {
32
+ const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
33
+ let buffer = "";
34
+ input.setEncoding("utf8");
35
+ input.on("data", (chunk) => {
36
+ buffer += chunk;
37
+ let at;
38
+ while ((at = buffer.indexOf("\n")) >= 0) {
39
+ const line = buffer.slice(0, at).trim();
40
+ buffer = buffer.slice(at + 1);
41
+ if (line) void handle(line);
42
+ }
43
+ });
44
+ input.on("end", () => resolve(0));
45
+ input.on("close", () => resolve(0));
46
+
47
+ async function handle(line) {
48
+ let msg;
49
+ try { msg = JSON.parse(line); } catch { send(error(null, -32700, "parse error")); return; }
50
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) { send(error(null, -32600, "invalid request")); return; }
51
+ const { id, method, params } = msg;
52
+ const notification = id === undefined || id === null;
53
+ try {
54
+ switch (method) {
55
+ case "initialize":
56
+ return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: INSTRUCTIONS }));
57
+ case "notifications/initialized":
58
+ case "notifications/cancelled":
59
+ return;
60
+ case "ping":
61
+ return send(result(id, {}));
62
+ case "tools/list":
63
+ return send(result(id, { tools: TOOLS }));
64
+ case "tools/call": {
65
+ const name = params?.name;
66
+ const verb = Object.hasOwn(verbs, name) ? verbs[name] : null;
67
+ if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
68
+ const out = await verb(params?.arguments ?? {});
69
+ return send(result(id, { content: [{ type: "text", text: out.text }], ...(out.isError ? { isError: true } : {}) }));
70
+ }
71
+ default:
72
+ if (!notification) send(error(id, -32601, `method not found: ${String(method)}`));
73
+ }
74
+ } catch (err) {
75
+ log(`mcp ${method}: ${err?.stack ?? err}`);
76
+ if (!notification) send(result(id, { content: [{ type: "text", text: `Cortad could not answer: ${String(err?.message ?? err)}` }], isError: true }));
77
+ }
78
+ }
79
+ });
80
+ }
@@ -0,0 +1,91 @@
1
+ import { execFile } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+
8
+ // Making Cortad known to the coding agents on this machine: an MCP entry in each client that is
9
+ // here, and the skill that teaches the loop. Nothing is written into the repository; everything
10
+ // lands in the client's own home folders, the way the client's own `add` command would put it.
11
+ // Idempotent: run twice, it adds nothing twice.
12
+ const exec = promisify(execFile);
13
+ const SERVER = { command: "npx", args: ["-y", "cortad@latest", "mcp"] };
14
+ const SKILL_SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
15
+
16
+ export const onPath = (bin, env = process.env) =>
17
+ (env.PATH ?? "").split(delimiter).some((dir) => dir && existsSync(join(dir, bin)));
18
+
19
+ export function detectClients({ home = homedir(), env = process.env } = {}) {
20
+ return {
21
+ claude: onPath("claude", env) || existsSync(join(home, ".claude")),
22
+ codex: onPath("codex", env) || existsSync(join(home, ".codex")),
23
+ cursor: existsSync(join(home, ".cursor")),
24
+ };
25
+ }
26
+
27
+ // Returns the names of the clients that now know Cortad, for the one line the command prints.
28
+ export async function registerAll({ home = homedir(), env = process.env, run = exec, skillSrc = SKILL_SRC } = {}) {
29
+ const found = detectClients({ home, env });
30
+ const added = [];
31
+ if (found.claude) {
32
+ await registerClaude({ home, env, run });
33
+ installSkill(join(home, ".claude", "skills", "cortad"), skillSrc);
34
+ added.push("Claude Code");
35
+ }
36
+ if (found.codex) {
37
+ await registerCodex({ home, env, run });
38
+ installSkill(join(home, ".agents", "skills", "cortad"), skillSrc);
39
+ added.push("Codex");
40
+ }
41
+ if (found.cursor) {
42
+ registerCursor({ home });
43
+ added.push("Cursor");
44
+ }
45
+ return added;
46
+ }
47
+
48
+ // The client's own command when it is here, so its config is written the way it writes it; the file
49
+ // itself only when the folder exists without the binary on this PATH.
50
+ async function registerClaude({ home, env, run }) {
51
+ if (onPath("claude", env)) {
52
+ try { await run("claude", ["mcp", "add", "--scope", "user", "cortad", "--", SERVER.command, ...SERVER.args], { env }); return; }
53
+ catch (err) { if (/already exists/i.test(String(err?.stderr ?? err?.message))) return; }
54
+ }
55
+ const file = join(home, ".claude.json");
56
+ const config = readJson(file) ?? {};
57
+ config.mcpServers = { ...(config.mcpServers ?? {}), cortad: { type: "stdio", ...SERVER } };
58
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
59
+ }
60
+
61
+ async function registerCodex({ home, env, run }) {
62
+ if (onPath("codex", env)) {
63
+ try { await run("codex", ["mcp", "add", "cortad", "--", SERVER.command, ...SERVER.args], { env }); return; }
64
+ catch (err) { if (/already exists/i.test(String(err?.stderr ?? err?.message))) return; }
65
+ }
66
+ const file = join(home, ".codex", "config.toml");
67
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
68
+ if (/^\[mcp_servers\.cortad\]/m.test(text)) return;
69
+ mkdirSync(dirname(file), { recursive: true });
70
+ writeFileSync(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}\n[mcp_servers.cortad]\ncommand = "${SERVER.command}"\nargs = ${JSON.stringify(SERVER.args)}\n`);
71
+ }
72
+
73
+ function registerCursor({ home }) {
74
+ const file = join(home, ".cursor", "mcp.json");
75
+ const config = readJson(file) ?? {};
76
+ if (config.mcpServers?.cortad) return;
77
+ config.mcpServers = { ...(config.mcpServers ?? {}), cortad: { type: "stdio", ...SERVER } };
78
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
79
+ }
80
+
81
+ // SKILL.md and its references, copied whole so a newer package refreshes the words.
82
+ export function installSkill(dir, src = SKILL_SRC) {
83
+ mkdirSync(join(dir, "references"), { recursive: true });
84
+ copyFileSync(join(src, "SKILL.md"), join(dir, "SKILL.md"));
85
+ copyFileSync(join(src, "references", "results.md"), join(dir, "references", "results.md"));
86
+ }
87
+
88
+ function readJson(file) {
89
+ if (!existsSync(file)) return null;
90
+ try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; }
91
+ }
package/lib/verbs.mjs ADDED
@@ -0,0 +1,149 @@
1
+ // The eight things a coding agent may ask of Cortad, each one call to the API with this machine's
2
+ // key and one rendering as short text. The MCP tools and the `npx cortad <verb>` commands are the
3
+ // same functions, so both faces say the same thing.
4
+
5
+ const NOT_CONNECTED = "This project is not connected to Cortad yet. Ask the person to open cortad.com, sign in, and run the command the connect screen shows, from this folder.";
6
+
7
+ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async () => ({ ok: true }) }) {
8
+ const call = async (method, path, body) => {
9
+ if (!token) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
10
+ let res;
11
+ try {
12
+ res = await fetchImpl(`${api}${path}`, {
13
+ method,
14
+ headers: { authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
15
+ body: body === undefined ? undefined : JSON.stringify(body),
16
+ signal: AbortSignal.timeout(60_000),
17
+ });
18
+ } catch (err) {
19
+ return { status: 0, ok: false, data: { error: `could not reach Cortad: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}` } };
20
+ }
21
+ const text = await res.text();
22
+ let data = null;
23
+ try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
24
+ return { status: res.status, ok: res.ok, data };
25
+ };
26
+ const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
27
+
28
+ const status = async () => {
29
+ const res = await call("GET", "/mcp/status");
30
+ return res.ok ? { text: statusText(res.data), data: res.data } : failed(res);
31
+ };
32
+
33
+ // A run, or a verify when a finding is named. When the app is not up on this machine, the runner
34
+ // is started first: the process that uploads the tree, starts the app and answers the knocks.
35
+ const start = async (args, kind) => {
36
+ const before = await call("GET", "/mcp/status");
37
+ if (!before.ok) return failed(before);
38
+ if (before.data.app?.state !== "ready-to-test") {
39
+ const runner = await ensureRunner(before.data);
40
+ if (!runner.ok) return { text: runner.why, data: runner, isError: true };
41
+ }
42
+ const res = await call("POST", "/mcp/run", args);
43
+ if (res.status === 202 || (res.ok && res.data?.joined)) return { text: startedText(res.data, kind), data: res.data };
44
+ if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
45
+ return failed(res);
46
+ };
47
+ const run = () => start({}, "run");
48
+ const verify = ({ findingId, jobId }) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
49
+
50
+ const runStatus = async ({ jobId }) => {
51
+ const res = await call("GET", `/mcp/run/${encodeURIComponent(jobId)}`);
52
+ return res.ok ? { text: runText(res.data), data: res.data } : failed(res);
53
+ };
54
+
55
+ const findings = async ({ jobId } = {}) => {
56
+ const res = await call("GET", `/mcp/findings${jobId ? `?jobId=${encodeURIComponent(jobId)}` : ""}`);
57
+ return res.ok ? { text: findingsText(res.data), data: res.data } : failed(res);
58
+ };
59
+
60
+ const dispute = async ({ findingId, why, question, jobId }) => {
61
+ const res = await call("POST", "/mcp/dispute", { findingId, why, ...(question ? { question } : {}), ...(jobId ? { jobId } : {}) });
62
+ return res.ok ? { text: res.data.said, data: res.data } : failed(res);
63
+ };
64
+
65
+ const fieldConnect = async () => {
66
+ const res = await call("POST", "/mcp/field/connect");
67
+ if (!res.ok) return failed(res);
68
+ const d = res.data;
69
+ return { text: [d.connected ? "Production is connected." : "Production is not connected yet.", ...d.steps.map((s, i) => `${i + 1}. ${s}`)].join("\n"), data: d };
70
+ };
71
+
72
+ const field = async ({ days } = {}) => {
73
+ const res = await call("GET", `/mcp/field${days ? `?days=${Number(days)}` : ""}`);
74
+ return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
75
+ };
76
+
77
+ return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field };
78
+ }
79
+
80
+ const pct = (k, n) => (n ? `${Math.round((100 * k) / n)}%` : "n/a");
81
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
82
+
83
+ export function statusText(d) {
84
+ const p = d.plan;
85
+ const lines = [
86
+ `Cortad · ${d.repository.name} · ${p.name}: ${p.runs.left} of ${plural(p.runs.allowed, "run")} left this month, ${p.trials.left} of ${p.trials.allowed} verify trials.`,
87
+ `App: ${d.app.said}`,
88
+ `Conversations written: ${d.cases.written}.${d.cases.ready ? " A run can start." : ""}`,
89
+ ];
90
+ lines.push(d.run ? `Latest ${runText(d.run)}` : "No run yet.");
91
+ lines.push(d.field.connected ? "Production: connected." : `Production: not connected. field_connect says how.`);
92
+ return lines.join("\n");
93
+ }
94
+
95
+ export function startedText(d, kind) {
96
+ if (d.joined) return `A ${d.kind ?? kind} is already in flight: ${d.jobId}. Poll run_status every 30 seconds. Watch it: ${d.url}`;
97
+ 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}`;
98
+ }
99
+
100
+ export function refusedText(d) {
101
+ const plans = (d.plans ?? []).map((p) => `${p.name} ($${p.monthlyUsd}/month)`).join(" or ");
102
+ 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.`;
103
+ return `${d.why} More on ${plans || "a bigger plan"}: ${d.checkout}\nShow this link to the person and wait.`;
104
+ }
105
+
106
+ export function runText(d) {
107
+ const head = `${d.kind} ${d.jobId} ${d.status}: ${d.of ? `played ${d.played} of ${d.of}.` : "starting, nothing played yet."}`;
108
+ const tail = [];
109
+ if (d.finished && typeof d.score === "number") tail.push(`Score ${d.score} of 100.`);
110
+ if (d.finished && typeof d.findings === "number") tail.push(d.findings ? `${plural(d.findings, "finding")}; call findings.` : "No findings stood out.");
111
+ if (d.error) tail.push(`Error: ${d.error}`);
112
+ if (d.verify) tail.push(verifyText(d.verify));
113
+ if (!d.finished) tail.push("Poll again in 30 seconds.");
114
+ return [head, ...tail, d.url].join(" ");
115
+ }
116
+
117
+ export function verifyText(v) {
118
+ const move = v.visible;
119
+ const line = move
120
+ ? `${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" : ""}.`
121
+ : "";
122
+ const held = v.holdout ? ` Held-out situations: ${v.holdout.moved}.` : " Held-out situations: no pair.";
123
+ return `Verify of ${v.findingId}: ${line}${held}${v.overfit ? " OVERFIT: the visible cases moved and the held-out ones did not." : ""} ${v.said}`;
124
+ }
125
+
126
+ export function findingsText(d) {
127
+ if (!d.findings?.length) return [d.why ?? `Run ${d.runId}: no question held worse in one situation than this app does everywhere else.`, d.read, d.heldBack, d.url].filter(Boolean).join(" ");
128
+ const rows = d.findings.map((f, i) => [
129
+ `${i + 1}. ${f.id} · ${f.asks}`,
130
+ ` 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}`,
131
+ ...f.quotes.slice(0, 1).map((q) => ` reply ${q.reply}: "${q.quote}" (p=${q.p.toFixed(2)})`),
132
+ ` replay: ${plural(f.replay.trials, "trial")} · verify ${f.id}`,
133
+ ].join("\n"));
134
+ 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");
135
+ }
136
+
137
+ export function fieldText(d) {
138
+ const t = d.totals;
139
+ if (!t || !t.n) return `No production conversations read in the last ${d.days} days. ${d.url}`;
140
+ const lines = [
141
+ `Production, last ${d.days} days: ${plural(t.n, "conversation")}, ${t.read} read.`,
142
+ `Rulings held: ${pct(t.rulingsHeld, t.rulings)} of ${t.rulings}${t.rulingsUnsure ? ` (${t.rulingsUnsure} unsure)` : ""}. Resolved ${pct(t.resolved, t.read)}, frustrated ${pct(t.frustrated, t.read)}, asked for a human ${pct(t.wantsHuman, t.read)}, unanswered ${pct(t.unanswered, t.read)}.`,
143
+ ];
144
+ const broke = (d.rules ?? []).filter((r) => r.broke > 0).sort((a, b) => b.broke - a.broke).slice(0, 5);
145
+ if (broke.length) lines.push(`Rules broken most: ${broke.map((r) => `${r.id} (${r.broke})`).join(", ")}.`);
146
+ if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conv")}, ${pct(j.rulingsHeld, j.rulings)} held`).join("; ")}.`);
147
+ lines.push(d.url);
148
+ return lines.join("\n");
149
+ }
package/local.mjs CHANGED
@@ -1,25 +1,28 @@
1
1
  #!/usr/bin/env node
2
- // Brainsless on your own machine. Run from your repository's root with the code the connect
3
- // screen showed:
2
+ // Cortad on your own machine. Run from your repository's root with the code the connect screen
3
+ // showed:
4
4
  // npx cortad ABCD2345 [--port 3000] [--start "npm run dev"] [--verbose]
5
5
  //
6
6
  // What it does: signs in with the code, uploads your source files once (never .env, never
7
7
  // node_modules) so your code can be read, starts your app the way you start it, then holds one
8
8
  // outbound connection open and does what a run asks: a request to your app, a file read, a shell
9
- // line, an edit. An edit goes through one door (lib/door.mjs) that saves what was there first, so
10
- // every change can be put back from the browser; the shell is locked by the operating system
11
- // (lib/lock.mjs) and cannot write your code at all. Nothing here touches git. Your environment
9
+ // line. The shell is locked by the operating system (lib/lock.mjs) and cannot write your code;
10
+ // nothing here writes your files at all. It also makes Cortad known to the coding agents on this
11
+ // machine (lib/register.mjs): an MCP entry and a skill, so `npx cortad mcp` answers them from then
12
+ // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
12
13
  // never leaves this machine. Ctrl-C ends everything.
13
14
 
14
15
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
15
16
  import { spawn, execFile, execFileSync } from "node:child_process";
16
17
  import { createHash } from "node:crypto";
17
18
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
18
- import { homedir, tmpdir } from "node:os";
19
+ import { homedir, hostname, tmpdir } from "node:os";
19
20
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
21
  import { createInterface } from "node:readline";
21
22
  import { promisify } from "node:util";
22
- import { openDoor } from "./lib/door.mjs";
23
+ import { COMMANDS, main as face } from "./lib/cli.mjs";
24
+ import { clearRunner, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
25
+ import { registerAll } from "./lib/register.mjs";
23
26
  import { lockHolds, makeLock } from "./lib/lock.mjs";
24
27
  import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
25
28
  import { CAPTURED, makeCapture } from "./lib/replay.mjs";
@@ -30,20 +33,27 @@ import { installPlan, missingDependency, startPlan, workspaces } from "./lib/sta
30
33
  import { openSwitches } from "./lib/switches.mjs";
31
34
 
32
35
  const argv = process.argv.slice(2);
36
+ // The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
37
+ // client starts on every session, and the same verbs as shell commands. Neither runs the connect
38
+ // below, and `npx cortad findings` is a verb, never a code.
39
+ if (COMMANDS.has(argv[0] ?? "")) process.exit(await face(argv));
33
40
  const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
34
41
  const verbose = argv.includes("--verbose");
35
- const code = (argv.find((a) => /^[A-Za-z0-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
42
+ // A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
43
+ const code = (argv.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
36
44
  const say = (line) => console.log(`cortad ${line}`);
37
45
  const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
38
46
 
39
47
  const explain = argv.includes("--explain");
48
+ // Started by lib/cli.mjs for a run on a later day: no code, the key the first connect left behind.
49
+ const viaToken = argv.includes("--token");
40
50
  // What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
41
51
  // this package before anything here runs, and the first thing we printed used to be after the whole
42
52
  // upload: a minute or more of a cursor sitting still, which reads as nothing happening.
43
53
  const step = (line) => { if (process.stdout.isTTY) process.stdout.write(`\rcortad ${line}\x1b[K`); };
44
54
  const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
45
55
  const stepDone = (line) => { clearStep(); say(line); };
46
- if (!explain && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain");
56
+ 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>");
47
57
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
48
58
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
49
59
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
@@ -254,10 +264,10 @@ const mask = (text) => { let s = String(text ?? ""); for (const v of secrets) s
254
264
  // ---- the wire
255
265
  let box = "";
256
266
  let key = "";
257
- async function call(method, path, body, { raw = false, timeoutMs = 60_000 } = {}) {
267
+ async function call(method, path, body, { raw = false, timeoutMs = 60_000, headers = {} } = {}) {
258
268
  const res = await fetch(`${api}${path}`, {
259
269
  method,
260
- headers: { ...(key ? { "x-local-key": key } : {}), "content-type": raw ? "application/octet-stream" : "application/json" },
270
+ headers: { ...(key ? { "x-local-key": key } : {}), "content-type": raw ? "application/octet-stream" : "application/json", ...headers },
261
271
  body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
262
272
  signal: AbortSignal.timeout(timeoutMs),
263
273
  });
@@ -272,8 +282,6 @@ const work = join(tmpdir(), `cortad-${process.pid}`);
272
282
  mkdirSync(work, { recursive: true });
273
283
  const bootLog = join(work, "boot.log");
274
284
  writeFileSync(bootLog, "");
275
- // Opened once the server says whose session this is.
276
- let door = null;
277
285
  // Files nobody may read through this program: keys, and git's own internals.
278
286
  const SECRET_PATH = /(?:^|\/)(?:\.git|\.ssh|\.gnupg|\.aws|\.npmrc|\.netrc|id_(?:rsa|ed25519|ecdsa)[^/]*|[^/]*\.(?:pem|key|p12|pfx|jks|keystore))(?:\/|$)/;
279
287
  // The engine's paths, as this machine has them. Its scratch files live in this program's own
@@ -393,24 +401,18 @@ async function verb(job) {
393
401
  }
394
402
  case "write": {
395
403
  const bytes = Buffer.from(String(b.b64 ?? ""), "base64");
396
- // The engine's own scratch file: this program's temp folder, not your code.
404
+ // The engine's own scratch file: this program's temp folder, never your code. Nothing on this
405
+ // machine writes your files; the agent that edits them is your own.
397
406
  const mine = scratch(b.path);
398
- if (mine) {
399
- try { mkdirSync(dirname(mine), { recursive: true }); if (b.append) appendFileSync(mine, bytes); else writeFileSync(mine, bytes); return { success: true, stderr: "" }; }
400
- catch (e) { return { success: false, stderr: String(e.message) }; }
401
- }
402
- // Your code: the one door, which saves what was there before anything changes.
403
- return door.write(translate(b.path), bytes, { checkpoint: typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : "manual", append: b.append === true, exclusive: b.exclusive === true });
407
+ if (!mine) return { success: false, stderr: "this program does not write your files" };
408
+ try { mkdirSync(dirname(mine), { recursive: true }); if (b.append) appendFileSync(mine, bytes); else writeFileSync(mine, bytes); return { success: true, stderr: "" }; }
409
+ catch (e) { return { success: false, stderr: String(e.message) }; }
404
410
  }
405
- case "changes": return door.changes();
406
- case "diff": return door.diff(String(b.path ?? ""));
407
411
  case "mint": return identities ? JSON.parse(mask(JSON.stringify(await mintAcross({
408
412
  recipes: b.recipes, root, appDir, onPath, appPort: app?.port, portFor: serviceUp,
409
413
  mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
410
414
  originFor: (port) => originFor(port, envOrigins(envFiles)),
411
415
  })))) : { identities: [] };
412
- case "restore": return door.restore(String(b.checkpoint ?? ""));
413
- case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
414
416
  case "restart": return restartApp();
415
417
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
416
418
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
@@ -777,8 +779,9 @@ if (explain) {
777
779
  `would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan?.cmd ? `${plan.cmd} (in ${rel(plan.cwd)})` : plan?.noServer ? "nothing: this repository has no server to run" : "asks you how your app starts"}`,
778
780
  `would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (for this session only)`}`,
779
781
  `loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
780
- `agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
781
- `agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
782
+ `your files never written by this program; your own coding agent edits them`,
783
+ `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
784
+ `for your agent an MCP entry and a skill in each coding agent's own home folder (Claude Code, Codex, Cursor), and a key in ~/.cortad for later runs`,
782
785
  ``,
783
786
  `first files ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", ..." : ""}`,
784
787
  ].join("\n"));
@@ -798,16 +801,22 @@ if (!files.length) fail("no source files here to read.");
798
801
 
799
802
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
800
803
  // connection, and the path itself never leaves this machine.
801
- const project = createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
804
+ const project = projectOf(root);
805
+ // The key the last connect left for this project, if any: the token face signs in with it. A connect
806
+ // from the screen always asks for a fresh one, since the code may belong to another account or site.
807
+ const stored = readToken(project);
808
+ if (viaToken && !stored) fail("this project has no stored key. Run the command from the connect screen once.");
802
809
  // A network that drops while connecting ends here in a sentence, never a stack trace: running the
803
810
  // command again starts a clean connection.
804
811
  const unreachable = (err) => fail(`could not reach ${origin.host}: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}. Check your connection and run the command again.`);
805
- const attach = await call("POST", "/local/attach", { code, name: basename(root), project }).catch(unreachable);
812
+ const attach = await call("POST", "/local/attach", { ...(viaToken ? {} : { code }), name: basename(root), project },
813
+ viaToken ? { headers: { authorization: `Bearer ${stored}` } } : {}).catch(unreachable);
806
814
  if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
807
815
  box = attach.data.box;
808
816
  key = attach.data.key;
809
- // A server that names no journal gets one for this session alone: never another account's edits.
810
- door = openDoor(root, { owner: typeof attach.data.journal === "string" && attach.data.journal ? attach.data.journal : box });
817
+ // This process is the one holding the app up for this project: lib/cli.mjs reads the file before
818
+ // starting another.
819
+ writeRunner(project, { pid: process.pid, startedAt: new Date().toISOString(), by: viaToken ? "token" : "connect" });
811
820
 
812
821
  const list = join(work, "files.txt");
813
822
  writeFileSync(list, files.join("\n") + "\n");
@@ -842,12 +851,25 @@ const parts = Math.max(1, Math.ceil(bytes.length / PART));
842
851
  for (let off = 0; off < bytes.length; off += PART) {
843
852
  const last = off + PART >= bytes.length;
844
853
  step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
845
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 }).catch(unreachable);
854
+ // The last part asks for this machine's key when none is stored yet: minted once the repository
855
+ // row exists, kept in ~/.cortad for the runs a coding agent asks for on later days.
856
+ const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
857
+ { raw: true, timeoutMs: 120_000, ...(last && !viaToken ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
846
858
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
847
- if (last) resumed = put.data?.resumed === true;
859
+ if (last) {
860
+ resumed = put.data?.resumed === true;
861
+ if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
862
+ writeDigest(project, treeDigest);
863
+ }
848
864
  }
849
865
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
850
866
  stepDone(resumed ? "connected · nothing changed since last time" : "connected");
867
+ // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
868
+ // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
869
+ if (!viaToken) {
870
+ const added = await registerAll().catch((err) => { if (verbose) say(`could not register with your coding agents: ${err?.message ?? err}`); return []; });
871
+ if (added.length) say(`added to ${added.join(", ")} · ask ${added.length === 1 ? "it" : "them"} for cortad any time`);
872
+ }
851
873
 
852
874
  lock = await makeLock({ root, work });
853
875
  if (lock && !lockHolds(lock, root)) lock = null;
@@ -883,7 +905,9 @@ async function appLife() {
883
905
  if (got.port) { app = got; break; }
884
906
  await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
885
907
  say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
886
- if (first) say("leave this open. Ctrl-C disconnects.");
908
+ // Said with the same words the agent prompt waits for, so an agent holding the terminal reports
909
+ // the failure instead of waiting for a line that never comes.
910
+ if (first) say("your app did not start. Go back to the browser; it says what stopped it. Ctrl-C disconnects.");
887
911
  await sourceChanged();
888
912
  say("saw your change, starting your app again");
889
913
  }
@@ -946,14 +970,25 @@ void appLife().catch((e) => fail(String(e?.message ?? e)));
946
970
  // Keep the laptop awake while a world stands on it.
947
971
  if (process.platform === "darwin") spawn("caffeinate", ["-i", "-w", String(process.pid)], { stdio: "ignore", detached: true }).unref();
948
972
 
973
+ // Started by a shell verb rather than a person, this process leaves once no run has needed it for
974
+ // a while, so an app is not held up all night for a verify that finished at noon.
975
+ const IDLE_MS = 10 * 60_000;
976
+ if (argv.includes("--until-idle") && stored) {
977
+ let idleSince = Date.now();
978
+ setInterval(async () => {
979
+ const res = await call("GET", "/mcp/status", undefined, { headers: { authorization: `Bearer ${stored}` } }).catch(() => null);
980
+ if (res?.ok && res.data?.run && !res.data.run.finished) idleSince = Date.now();
981
+ else if (Date.now() - idleSince > IDLE_MS) { say("no run for ten minutes, leaving"); await close(0); }
982
+ }, 60_000).unref();
983
+ }
984
+
949
985
  async function close(code = 0) {
950
986
  if (closing) return;
951
987
  closing = true;
988
+ clearRunner(project);
952
989
  await call("DELETE", `/local/${box}`).catch(() => {});
953
990
  if (child?.pid) await stopApp(child.pid);
954
991
  for (const kid of sidecars) if (kid.pid) await stopApp(kid.pid);
955
- const pending = door?.pending() ?? 0;
956
- if (pending) say(`${pending} agent edit${pending === 1 ? " is" : "s are"} waiting for you to keep or undo. Run the command again to review ${pending === 1 ? "it" : "them"} in the browser.`);
957
992
  await exec("rm", ["-rf", work]).catch(() => {});
958
993
  process.exit(code);
959
994
  }
package/package.json CHANGED
@@ -1,27 +1,33 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.14",
4
- "description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
3
+ "version": "0.2.0",
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"
7
7
  },
8
8
  "type": "module",
9
9
  "files": [
10
10
  "README.md",
11
- "lib/door.mjs",
11
+ "lib/cli.mjs",
12
+ "lib/home.mjs",
12
13
  "lib/identities.mjs",
13
- "lib/listing.mjs",
14
14
  "lib/keys.mjs",
15
+ "lib/listing.mjs",
15
16
  "lib/lock.mjs",
17
+ "lib/mcp.mjs",
16
18
  "lib/mint.mjs",
17
19
  "lib/pyhook/sitecustomize.py",
20
+ "lib/register.mjs",
18
21
  "lib/replay.mjs",
19
22
  "lib/sample.mjs",
20
23
  "lib/service.mjs",
21
24
  "lib/start.mjs",
22
25
  "lib/switches.mjs",
23
26
  "lib/trace.cjs",
24
- "local.mjs"
27
+ "lib/verbs.mjs",
28
+ "local.mjs",
29
+ "skill/SKILL.md",
30
+ "skill/references/results.md"
25
31
  ],
26
32
  "scripts": {
27
33
  "test": "node --test lib/*.test.mjs"
package/skill/SKILL.md ADDED
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: cortad
3
+ description: Behavior tests for the AI app in this repo. Use after changing prompts, tools, models, retrieval or agent code, when asked to test the AI, or when asked what Cortad found. Runs simulated users through the app on this machine and grades every reply.
4
+ ---
5
+ Cortad is connected to this repository. Do not run `npx cortad <code>` again.
6
+
7
+ If the `cortad` MCP tools are in your tool list, use them. Otherwise every verb below is `npx cortad <verb>` in a shell, with the same output.
8
+
9
+ ## The loop
10
+ 1. `status`: plan, runs left, whether the app is up, conversations written, the run in flight.
11
+ 2. `run`: only when the person asks. The first run is free. After that it returns a checkout link; show it in one sentence and wait for the person.
12
+ 3. `run_status <jobId>` every 30 seconds; say nothing unless the count moved. When it finishes, say the score, how many findings, and the link.
13
+ 4. `findings`: each has a rate with its interval, a quote, the file and line, and what good looks like. Start from the worst rate.
14
+ 5. Fix ONE finding: the smallest change in the file it names. Then `verify <findingId>`.
15
+ 6. `run_status` on the verify until it finishes, then read the move. Inside the interval is not a fix: put the file back and say so. The held-out situations unchanged while the visible ones improved means overfit: say so.
16
+
17
+ ## Rules
18
+ - Never change or remove a case, check, seed or the holdout to move a number. Cortad refuses it; do not look for another way.
19
+ - Never make the app detect Cortad's traffic (headers, test accounts, timing, environment).
20
+ - A check that reads wrong: `dispute <findingId> "<why>"`. The owner decides in the browser.
21
+ - Production text is never returned; `field` gives numbers only.
22
+
23
+ ## What to tell the person
24
+ - Run started: one line with the link, and that you will report when it is done.
25
+ - Findings: the worst first, in their words, with the file and line.
26
+ - A verify: before, after, the interval, the held-out line.
27
+ - Anything you cannot do (billing, promoting a case, changing a check): the link into cortad.com.
28
+
29
+ One example result per verb is in [references/results.md](references/results.md).
@@ -0,0 +1,82 @@
1
+ # What each verb answers
2
+
3
+ One result per verb, as the tool returns it. The numbers are from a walk of a tutoring app; yours differ.
4
+
5
+ ## status
6
+
7
+ ```
8
+ Cortad · ulaim · Free: 0 of 1 run left this month, 60 of 60 verify trials.
9
+ App: Your app answered during startup on port 3100. This is the last recorded state, not a new health check.
10
+ Conversations written: 51. A run can start.
11
+ Latest run 8f2a1c4e-... succeeded: played 51 of 51. Score 71 of 100. 7 findings; call findings. https://cortad.com/lab
12
+ Production: not connected. field_connect says how.
13
+ ```
14
+
15
+ ## run
16
+
17
+ ```
18
+ Run started: 9a10b3d2-.... Poll run_status every 30 seconds and stay quiet unless the count moved. Watch it: https://cortad.com/lab
19
+ ```
20
+
21
+ When the plan is spent:
22
+
23
+ ```
24
+ The first run was free. Another needs Hobby ($99/month) or Growth ($499/month): https://cortad.com/pricing?checkout=ship
25
+ Show this link to the person in one sentence and wait for them.
26
+ ```
27
+
28
+ ## run_status
29
+
30
+ ```
31
+ run 9a10b3d2-... running: played 14 of 51. Poll again in 30 seconds. https://cortad.com/lab
32
+ ```
33
+
34
+ ```
35
+ verify 7c31e0aa-... succeeded: played 12 of 12. Verify of finding:1: apps/api/src/agent/prompt.ts:41 · held 3 of 12 before, 11 of 12 after · move 67 (41 to 85) · improved. Held-out situations: no change. The move is outside the noise. https://cortad.com/lab
36
+ ```
37
+
38
+ ## findings
39
+
40
+ ```
41
+ Run 8f2a1c4e-.... 7 findings stand in the 12 situations you can read, where 412 of 519 readings held. 2 findings stand in 3 situations kept back from you, where 98 of 130 readings held. You cannot read them, and a fix is graded on those too.
42
+ 1. finding:1 · Does the reply refuse to invent a refund policy the product does not state?
43
+ held 3 of 12 (25%, interval 8% to 53%) · apps/api/src/agent/prompt.ts:41 · plan free, journey billing
44
+ reply 2: "Yes, refunds are processed within 3 business days." (p=0.94)
45
+ replay: 12 trials · verify finding:1
46
+ 2. finding:4 · Does the reply stay in the student's language?
47
+ held 6 of 10 (60%, interval 31% to 83%) · apps/api/src/agent/system.ts:12 · grade 9, journey homework
48
+ reply 1: "Sure! Let's solve this together." (p=0.88)
49
+ replay: 10 trials · verify finding:4
50
+ Fix one finding at a time, in the file it names, then verify it. https://cortad.com/lab
51
+ ```
52
+
53
+ ## verify
54
+
55
+ ```
56
+ Verify started: 7c31e0aa-.... Poll run_status every 30 seconds and stay quiet unless the count moved. Watch it: https://cortad.com/lab
57
+ ```
58
+
59
+ ## dispute
60
+
61
+ ```
62
+ The dispute is in the owner's log. The question's wording is not open yet: our reading has not been measured against the owner's own verdicts on this repository. The check and its rate are unchanged.
63
+ ```
64
+
65
+ ## field_connect
66
+
67
+ ```
68
+ Production is not connected yet.
69
+ 1. The owner creates the key at https://cortad.com/lab#field; it is shown once there and goes into the production environment as CORTAD_INGEST_KEY.
70
+ 2. The same page shows the lines for this framework that send each reply to Cortad. Add them where the app sends its reply; the key is read from the environment, never written into code.
71
+ 3. Deploy. Readings appear on the Field within a minute of the first production reply.
72
+ ```
73
+
74
+ ## field
75
+
76
+ ```
77
+ Production, last 30 days: 4,812 conversations, 4,790 read.
78
+ Rulings held: 93% of 61,204 (1,120 unsure). Resolved 71%, frustrated 6%, asked for a human 2%, unanswered 4%.
79
+ Rules broken most: rule:answer-first (412), rule:language (188), rule:cite-source (97).
80
+ By journey: homework 3,102 convs, 94% held; billing 410 convs, 88% held.
81
+ https://cortad.com/lab#field
82
+ ```
package/lib/door.mjs DELETED
@@ -1,174 +0,0 @@
1
- // The one door a change to your files goes through. Nothing else in this program can write inside
2
- // your repository: the shell it runs for a world is locked by the operating system, and every edit
3
- // arrives here. Before a byte changes, what was there is saved outside the project, so any change
4
- // can be put back and nothing here ever touches git: no commit, no stage, no stash, no branch.
5
- import { execFileSync } from "node:child_process";
6
- import { createHash } from "node:crypto";
7
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
8
- import { homedir } from "node:os";
9
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
10
-
11
- const sha = (bytes) => createHash("sha256").update(bytes).digest("hex");
12
- // Never written, whoever asks: environments, keys, git's own data, and dependencies.
13
- const ENV_FILE = /^\.env(\..*)?$/;
14
- const KEY_FILE = /^(?:id_(?:rsa|ed25519|ecdsa).*|.*\.(?:pem|key|p12|pfx|jks|keystore)|\.npmrc|\.netrc|\.pypirc)$/;
15
- const CLOSED_DIR = new Set([".git", "node_modules", ".ssh", ".aws", ".gnupg"]);
16
-
17
- // `owner` names whose checkpoints these are: the account and connection the server attached this
18
- // folder to. Keyed by the folder alone, a second account on the same folder was shown the first
19
- // account's edits as its own.
20
- export function openDoor(root, { store = join(homedir(), ".cortad", "checkpoints"), owner = "" } = {}) {
21
- const realRoot = realpathSync(root);
22
- const dir = join(store, sha(`${owner}\0${realRoot}`).slice(0, 16));
23
- const blobs = join(dir, "blobs");
24
- const journalFile = join(dir, "journal.json");
25
- mkdirSync(blobs, { recursive: true });
26
- let journal = { seq: 0, entries: [] };
27
- try { journal = JSON.parse(readFileSync(journalFile, "utf8")); } catch { /* first session here */ }
28
- const save = () => { const tmp = `${journalFile}.${process.pid}`; writeFileSync(tmp, JSON.stringify(journal)); renameSync(tmp, journalFile); };
29
-
30
- // Where a path really lands, or why it is refused. Decided on the real filesystem, not on how the
31
- // path is spelled: a link inside the repository that points out of it is a way out of it.
32
- function target(path) {
33
- const abs = resolve(realRoot, String(path ?? ""));
34
- if (abs !== realRoot && !abs.startsWith(realRoot + sep)) return { ok: false, why: "outside your repository" };
35
- const rel = relative(realRoot, abs);
36
- if (!rel) return { ok: false, why: "not a file" };
37
- const parts = rel.split(sep);
38
- if (parts.some((p) => CLOSED_DIR.has(p))) return { ok: false, why: `inside ${parts.find((p) => CLOSED_DIR.has(p))}, which is never edited` };
39
- const leaf = parts.at(-1);
40
- if (ENV_FILE.test(leaf)) return { ok: false, why: "an environment file, which is never edited" };
41
- if (KEY_FILE.test(leaf)) return { ok: false, why: "a key or credential file, which is never edited" };
42
- let standing = abs;
43
- while (!existsSync(standing)) standing = dirname(standing);
44
- const landed = realpathSync(standing);
45
- if (landed !== realRoot && !landed.startsWith(realRoot + sep)) return { ok: false, why: "a link that leads outside your repository" };
46
- if (existsSync(abs) || isLink(abs)) {
47
- const info = lstatSync(abs);
48
- // A link or a hard-linked file cannot be put back faithfully, so it is never changed.
49
- if (info.isSymbolicLink()) return { ok: false, why: "a symbolic link" };
50
- if (!info.isFile()) return { ok: false, why: "not a regular file" };
51
- if (info.nlink > 1) return { ok: false, why: "hard-linked to another file" };
52
- }
53
- return { ok: true, abs, rel: parts.join("/") };
54
- }
55
- const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
56
-
57
- // Written beside the file and moved into place, so a failure leaves the original standing.
58
- function place(abs, bytes, mode) {
59
- mkdirSync(dirname(abs), { recursive: true });
60
- const tmp = join(dirname(abs), `.${basename(abs)}.bl-${process.pid}-${journal.seq}`);
61
- writeFileSync(tmp, bytes, { flag: "wx", mode });
62
- try { renameSync(tmp, abs); } catch (err) { rmSync(tmp, { force: true }); throw err; }
63
- }
64
-
65
- function write(path, bytes, { checkpoint = "manual", append = false, exclusive = false } = {}) {
66
- const t = target(path);
67
- if (!t.ok) return { success: false, stderr: `refused: ${path} is ${t.why}` };
68
- const existed = existsSync(t.abs);
69
- if (exclusive && existed) return { success: false, stderr: `refused: ${t.rel} already exists` };
70
- const before = existed ? readFileSync(t.abs) : null;
71
- const mode = existed ? lstatSync(t.abs).mode & 0o777 : 0o644;
72
- const next = append && before ? Buffer.concat([before, bytes]) : bytes;
73
- // The first touch inside a checkpoint holds the true "before"; later touches only move "after".
74
- let entry = journal.entries.find((e) => e.checkpoint === checkpoint && e.rel === t.rel);
75
- if (!entry) {
76
- journal.seq += 1;
77
- entry = { seq: journal.seq, checkpoint, rel: t.rel, existed, mode, blob: existed ? `${journal.seq}.before` : null, afterHash: null, at: new Date().toISOString() };
78
- if (before) writeFileSync(join(blobs, entry.blob), before);
79
- journal.entries.push(entry);
80
- save();
81
- }
82
- try { place(t.abs, next, mode); } catch (err) { return { success: false, stderr: `could not write ${t.rel}: ${err.code ?? err.message}` }; }
83
- entry.afterHash = sha(next);
84
- save();
85
- return { success: true, stderr: "" };
86
- }
87
-
88
- const currentHash = (abs) => (existsSync(abs) && !isLink(abs) ? sha(readFileSync(abs)) : null);
89
-
90
- // What is pending, as a person would ask it: which files, how much, and whether they have since
91
- // edited one themselves (in which case putting it back would erase their work, so it is said).
92
- function changes() {
93
- const byPath = new Map();
94
- for (const e of journal.entries) if (!byPath.has(e.rel)) byPath.set(e.rel, { first: e, last: e }); else byPath.get(e.rel).last = e;
95
- const files = [...byPath.entries()].map(([rel, { first, last }]) => {
96
- const abs = join(realRoot, rel);
97
- const counts = lineCounts(first.blob ? join(blobs, first.blob) : null, existsSync(abs) ? abs : null);
98
- return { path: rel, status: first.existed ? "modified" : "added", ...counts, conflict: currentHash(abs) !== last.afterHash };
99
- });
100
- const checkpoints = [];
101
- for (const e of journal.entries) {
102
- const held = checkpoints.find((c) => c.id === e.checkpoint);
103
- if (held) { if (!held.paths.includes(e.rel)) held.paths.push(e.rel); } else checkpoints.push({ id: e.checkpoint, at: e.at, paths: [e.rel] });
104
- }
105
- return { files, checkpoints };
106
- }
107
-
108
- // Back to how things stood before a checkpoint: that change and everything after it, newest
109
- // first. A file they edited after the change is left exactly as it is, and named.
110
- function restore(checkpoint) {
111
- const from = journal.entries.findIndex((e) => e.checkpoint === checkpoint);
112
- if (from < 0) return { restored: [], skipped: [], missing: true };
113
- const restored = [];
114
- const skipped = [];
115
- const blocked = new Set();
116
- const kept = journal.entries.slice(0, from);
117
- const stay = [];
118
- for (const e of journal.entries.slice(from).reverse()) {
119
- if (blocked.has(e.rel)) { stay.unshift(e); continue; }
120
- const t = target(e.rel);
121
- const why = !t.ok ? `it is now ${t.why}` : currentHash(t.abs) !== e.afterHash ? "you changed it after this edit, so it was left as it is" : null;
122
- if (why) { blocked.add(e.rel); skipped.push({ path: e.rel, why }); stay.unshift(e); continue; }
123
- if (e.existed) place(t.abs, readFileSync(join(blobs, e.blob)), e.mode); else unlinkSync(t.abs);
124
- if (e.blob) rmSync(join(blobs, e.blob), { force: true });
125
- if (!restored.includes(e.rel)) restored.push(e.rel);
126
- }
127
- journal.entries = [...kept, ...stay];
128
- save();
129
- return { restored, skipped };
130
- }
131
-
132
- // Accepted: the change stays and the way back is let go. Up to a checkpoint, or everything.
133
- function keep(checkpoint) {
134
- let upTo = journal.entries.length;
135
- if (checkpoint) { const last = journal.entries.map((e) => e.checkpoint).lastIndexOf(checkpoint); if (last < 0) return { kept: 0, missing: true }; upTo = last + 1; }
136
- const gone = journal.entries.slice(0, upTo);
137
- for (const e of gone) if (e.blob) rmSync(join(blobs, e.blob), { force: true });
138
- journal.entries = journal.entries.slice(upTo);
139
- save();
140
- return { kept: new Set(gone.map((e) => e.rel)).size };
141
- }
142
-
143
- // One pending file, before and after, as the unified diff an editor would show. "Before" is how
144
- // the file stood ahead of the first change still pending, so several edits read as one.
145
- function diff(path) {
146
- const rel = String(path ?? "").replace(/^\/+/, "");
147
- const first = journal.entries.find((e) => e.rel === rel);
148
- if (!first) return { diff: "", missing: true };
149
- const abs = join(realRoot, rel);
150
- try {
151
- const out = spawnDiff(first.blob ? join(blobs, first.blob) : "/dev/null", existsSync(abs) ? abs : "/dev/null", ["-u", "--label", `a/${rel}`, "--label", `b/${rel}`]);
152
- return { diff: out.slice(0, 120_000), truncated: out.length > 120_000 };
153
- } catch { return { diff: "", missing: true }; }
154
- }
155
-
156
- return { target, write, changes, restore, keep, diff, pending: () => new Set(journal.entries.map((e) => e.rel)).size };
157
- }
158
-
159
- // Lines added and removed, from the system's own diff. Null when it cannot say.
160
- function lineCounts(beforeFile, afterFile) {
161
- try {
162
- const out = spawnDiff(beforeFile ?? "/dev/null", afterFile ?? "/dev/null");
163
- let added = 0; let removed = 0;
164
- for (const line of out.split("\n")) {
165
- if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
166
- else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
167
- }
168
- return { added, removed };
169
- } catch { return { added: null, removed: null }; }
170
- }
171
- function spawnDiff(a, b, flags = ["-U0"]) {
172
- try { return execFileSync("diff", [...flags, a, b], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); }
173
- catch (err) { if (err.status === 1 && typeof err.stdout === "string") return err.stdout; throw err; }
174
- }