cortad 0.2.2 → 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 CHANGED
@@ -6,7 +6,7 @@ Runs Cortad's test conversations against the AI app on your machine, and gives y
6
6
  npx cortad <code>
7
7
  ```
8
8
 
9
- Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex and Cursor on this machine. The first run starts on its own. Ctrl-C disconnects.
9
+ Run it in your app's folder with the code from cortad.com. It starts your app, connects it to Cortad, and adds Cortad to Claude Code, Codex, Cursor and Copilot on this machine. The first run starts on its own. Ctrl-C disconnects.
10
10
 
11
11
  ## Your coding agent
12
12
 
@@ -21,6 +21,15 @@ npx cortad verify <findingId>
21
21
 
22
22
  A run needs your app up. If nothing on this machine is holding it, the command starts it with the key in `~/.cortad` and stops it ten minutes after the last run.
23
23
 
24
+ To make checking with Cortad part of the repository:
25
+
26
+ ```
27
+ npx cortad stick one line in AGENTS.md, CLAUDE.md, .cursor/rules and .github/copilot-instructions.md, and an after-edit hook for Claude Code and Codex
28
+ npx cortad unstick takes them out
29
+ ```
30
+
31
+ Both print every file they changed. Neither touches git.
32
+
24
33
  ## Flags
25
34
 
26
35
  ```
@@ -32,7 +41,7 @@ A run needs your app up. If nothing on this machine is holding it, the command s
32
41
 
33
42
  ## Files it creates
34
43
 
35
- - `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up
44
+ - `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up, the run it asked for last
36
45
  - `~/.cortad/identity.key` the seed for the session's test accounts
37
46
  - `$TMPDIR/cortad-<pid>/` removed on exit
38
47
 
package/lib/cli.mjs CHANGED
@@ -1,49 +1,82 @@
1
- import { openSync, readFileSync } from "node:fs";
1
+ import { closeSync, openSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { homeOf, projectOf, readRunner, readToken, writeRunner } from "./home.mjs";
5
+ import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
6
6
  import { serveMcp } from "./mcp.mjs";
7
- import { makeVerbs } from "./verbs.mjs";
7
+ import { SHOW } from "./read-text.mjs";
8
+ import { cliSpec, VERSION } from "./spec.mjs";
9
+ import { hookOutput, stick, unstick } from "./stick.mjs";
10
+ import { makeVerbs, READY_MS } from "./verbs.mjs";
8
11
 
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
+ // `npx cortad mcp`, `npx cortad <verb>` and `npx cortad stick`: what a coding agent uses after the
13
+ // first connect. None of them uploads or edits anything by itself. When a run needs the app up and
14
+ // nothing on this machine is holding it, the runner (local.mjs with the stored key) is started, and
15
+ // a second detached process posts the run once the app answers, so `run` returns at once from
16
+ // either face and the shell face exits after printing.
12
17
  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
18
  const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
19
+ const WAITER = "run-when-up";
15
20
  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"]);
21
+ export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
17
22
 
18
23
  export async function main(argv, { root = process.cwd(), env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
24
+ const [command, ...rest] = argv;
25
+ if (command === "stick" || command === "unstick") {
26
+ stdout.write(`${(command === "stick" ? stick : unstick)(root, { spec: cliSpec({ env }) }).join("\n")}\n`);
27
+ return 0;
28
+ }
19
29
  const origin = new URL(env.CORTAD_ORIGIN || "https://cortad.com");
20
30
  const trusted = (origin.protocol === "https:" && OURS.some((host) => origin.hostname === host || origin.hostname.endsWith(`.${host}`)))
21
31
  || origin.hostname === "localhost" || origin.hostname === "127.0.0.1";
22
32
  if (!trusted) { stderr.write(`cortad refusing: ${origin.host} is not Cortad.\n`); return 1; }
23
- const api = `${origin.origin}/api`;
24
33
  const project = projectOf(root);
25
- const [command, ...rest] = argv;
26
34
  const mcp = command === "mcp";
27
35
  const children = [];
36
+ const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
28
37
  // The key is read at every call: the MCP process outlives a connect that writes it after the process started.
29
- const verbs = makeVerbs({ api, token: () => readToken(project), ensureRunner: () => ensureRunner({ root, project, env, keep: mcp, children, say: (line) => stderr.write(`cortad ${line}\n`) }) });
38
+ const verbs = makeVerbs({
39
+ api: `${origin.origin}/api`,
40
+ root,
41
+ token: () => readToken(project),
42
+ pending,
43
+ startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
44
+ });
30
45
 
46
+ if (command === WAITER) {
47
+ await runWhenUp({ verbs, project, env, pending, request: requestOf(rest[0]) });
48
+ return 0;
49
+ }
31
50
  if (mcp) {
32
51
  const code = await serveMcp({ verbs, version: VERSION, log: (line) => stderr.write(`${line}\n`) });
33
52
  for (const child of children) try { child.kill("SIGTERM"); } catch { /* gone */ }
34
53
  return code;
35
54
  }
36
- const verb = verbs[command.replace(/-/g, "_")];
37
- const out = await verb(argsOf(command, rest));
55
+ // The hook face: one line or nothing, never an error on every edit in a folder that is not connected.
56
+ if (command === "status" && rest.includes("--changed")) {
57
+ const { text } = await verbs.changed();
58
+ if (text) stdout.write(`${rest.includes("--hook") ? hookOutput(text) : text}\n`);
59
+ return 0;
60
+ }
61
+ const out = await verbs[command.replace(/-/g, "_")](argsOf(command, rest));
38
62
  stdout.write(`${out.text}\n`);
39
63
  return out.isError ? 1 : 0;
40
64
  }
41
65
 
42
- // Positional arguments for the shell face: `verify f1`, `run_status <jobId>`, `dispute f1 "why"`.
66
+ // Positional arguments for the shell face: `status rules 2`, `verify f1`, `run_status <jobId>`,
67
+ // `findings 2`, `dispute f1 "why"`. A bare number is a page; in findings anything else is a run id.
43
68
  function argsOf(command, rest) {
69
+ const page = rest.find((w) => /^\d+$/.test(w));
44
70
  switch (command.replace(/-/g, "_")) {
45
- case "run_status": return { jobId: rest[0] };
46
- case "findings": return rest[0] ? { jobId: rest[0] } : {};
71
+ case "status": {
72
+ const show = rest.find((w) => SHOW.includes(w));
73
+ return { ...(show ? { show } : {}), ...(page ? { page: Number(page) } : {}) };
74
+ }
75
+ case "run_status": return rest[0] ? { jobId: rest[0] } : {};
76
+ case "findings": {
77
+ const jobId = rest.find((w) => w !== "--page" && !/^\d+$/.test(w));
78
+ return { ...(jobId ? { jobId } : {}), ...(page ? { page: Number(page) } : {}) };
79
+ }
47
80
  case "verify": return { findingId: rest[0], ...(rest[1] ? { jobId: rest[1] } : {}) };
48
81
  case "dispute": return { findingId: rest[0], why: rest.slice(1).join(" ") };
49
82
  case "field": return rest[0] ? { days: rest[0] } : {};
@@ -51,37 +84,56 @@ function argsOf(command, rest) {
51
84
  }
52
85
  }
53
86
 
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 }) {
87
+ // What the waiter was asked for, from its own command line: a run, or a verify of one finding.
88
+ function requestOf(raw) {
89
+ let value = {};
90
+ try { value = JSON.parse(raw ?? "{}"); } catch { /* a run */ }
91
+ const pick = (v) => (typeof v === "string" && v.length <= 200 ? v : undefined);
92
+ const findingId = pick(value.args?.findingId);
93
+ const jobId = pick(value.args?.jobId);
94
+ return findingId ? { kind: "verify", args: { findingId, ...(jobId ? { jobId } : {}) } } : { kind: "run", args: {} };
95
+ }
96
+
97
+ // Returns at once. The runner holds the app up: from the MCP it lives as long as the MCP does;
98
+ // from a shell command it stays until the runs stop for a while (--until-idle). The waiter is
99
+ // detached either way and ends once the run is posted or the app did not come up.
100
+ export function startApp({ root, project, env, keep, children, args, kind, spawnImpl = spawn }) {
101
+ const log = openSync(join(homeOf(project), "runner.log"), "a");
61
102
  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);
103
+ const child = spawnImpl(process.execPath, [LOCAL, "--token", ...(keep ? [] : ["--until-idle"])], { cwd: root, env, stdio: ["ignore", log, log], detached: !keep });
104
+ if (keep) children.push(child); else child.unref();
67
105
  writeRunner(project, { pid: child.pid, startedAt: new Date().toISOString(), by: keep ? "mcp" : "cli" });
68
- say("starting your app for the run");
69
106
  }
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")}.` };
107
+ spawnImpl(process.execPath, [LOCAL, WAITER, JSON.stringify({ kind, args })], { cwd: root, env, stdio: ["ignore", log, log], detached: true }).unref();
108
+ closeSync(log);
109
+ return { ok: true };
110
+ }
111
+
112
+ // The waiter: posts the run once the app answers and leaves the outcome in pending.json.
113
+ export async function runWhenUp({ verbs, project, env, pending, request, poll = appOf, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), readyMs = READY_MS }) {
114
+ const log = join(homeOf(project), "runner.log");
115
+ const fail = (text) => pending.write({ ...pending.read(), error: text });
116
+ const until = now() + readyMs;
117
+ let said = "";
118
+ while (now() < until) {
119
+ await sleep(3000);
120
+ const app = await poll(env, project);
121
+ if (app?.state === "ready-to-test") {
122
+ const out = await verbs.post(request.args, request.kind);
123
+ if (!out.data?.jobId) fail(out.text);
124
+ return;
125
+ }
126
+ if (app?.said) said = ` ${app.said}`;
127
+ if (!readRunner(project)) return fail(`The process holding your app up ended before the app answered.${said} Its output is in ${log}.\nNothing ran.`);
76
128
  }
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." };
129
+ fail(`Your app did not answer within ${Math.round(readyMs / 60_000)} minutes.${said} Its output is in ${log}.\nNothing ran.`);
78
130
  }
79
131
 
80
- async function statusOf(env, project) {
132
+ async function appOf(env, project) {
81
133
  const token = readToken(project);
82
134
  if (!token) return null;
83
135
  try {
84
136
  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;
137
+ return res.ok ? (await res.json()).app ?? null : null;
86
138
  } catch { return null; }
87
139
  }
package/lib/home.mjs CHANGED
@@ -4,8 +4,9 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
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.
7
+ // first connect left behind, the digest of the tree last uploaded, which process is holding the app
8
+ // up, and the run this machine asked for last. The folder's path never leaves the machine; the
9
+ // project is a hash of it.
9
10
  export const projectOf = (root) => createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
10
11
  export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
11
12
 
@@ -31,4 +32,10 @@ export function readRunner(project, base) {
31
32
  export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
32
33
  export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
33
34
 
35
+ export function readPending(project, base) {
36
+ const raw = read(join(homeOf(project, base), "pending.json"));
37
+ try { return raw ? JSON.parse(raw) : null; } catch { return null; }
38
+ }
39
+ export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
40
+
34
41
  export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
package/lib/mcp.mjs CHANGED
@@ -2,26 +2,46 @@
2
2
  // dependency. stdout carries protocol messages and nothing else; everything a person might read
3
3
  // goes to stderr. The tools are lib/verbs.mjs, one to one.
4
4
 
5
+ import { SHOW } from "./read-text.mjs";
6
+
5
7
  export const PROTOCOL = "2025-06-18";
6
8
 
9
+ // Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
10
+ // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
7
11
  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. The first run starts by itself after a connect; the skill says what to tell the person then.",
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.",
12
+ "Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
13
+ "Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting. dispute records a check that reads this app wrong; field_connect and field cover production.",
11
14
  ].join("\n\n");
12
15
 
13
16
  const str = (description, extra = {}) => ({ type: "string", description, ...extra });
17
+ const none = { type: "object", properties: {}, additionalProperties: false };
18
+ const readOnly = { readOnlyHint: true };
14
19
 
15
20
  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 } },
21
+ { name: "status", title: "Cortad status", annotations: readOnly,
22
+ description: "What Cortad read in this repository, the plan with runs left, whether the app is up, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval. With show, one section of the read in full, in pages.",
23
+ inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section of the read to list in full: rules, standards, journeys, endpoints or trials." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
24
+ { name: "run", title: "Start a run", inputSchema: none,
25
+ description: "Starts a run of the simulated conversations against the app on this machine and answers within a second with the run id. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
26
+ { name: "run_status", title: "Run progress", annotations: readOnly,
27
+ description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
28
+ inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
29
+ { name: "findings", title: "Findings", annotations: readOnly,
30
+ description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, and the trials a verify replays. Applies once a run has finished. A long list comes in pages.",
31
+ inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
32
+ { name: "verify", title: "Verify a fix",
33
+ description: "Replays one finding's trials with the same seeds after a change and reports the move, visible and held-out apart. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
34
+ inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
35
+ { name: "dispute", title: "Dispute a check",
36
+ description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
37
+ 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 that fits this app.", { maxLength: 300 }) }, required: ["findingId", "why"], additionalProperties: false } },
38
+ { name: "field_connect", title: "Connect production", inputSchema: none,
39
+ description: "How production replies reach Cortad, so real conversations are read with the same checks. The ingest key is created by the owner in the browser." },
40
+ { name: "field", title: "Production numbers", annotations: readOnly,
41
+ description: "Production in numbers: conversations read, checks held, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
42
+ inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
24
43
  ];
44
+ const NAMES = new Set(TOOLS.map((t) => t.name));
25
45
 
26
46
  const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
27
47
  const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
@@ -63,7 +83,7 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
63
83
  return send(result(id, { tools: TOOLS }));
64
84
  case "tools/call": {
65
85
  const name = params?.name;
66
- const verb = Object.hasOwn(verbs, name) ? verbs[name] : null;
86
+ const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
67
87
  if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
68
88
  const out = await verb(params?.arguments ?? {});
69
89
  return send(result(id, { content: [{ type: "text", text: out.text }], ...(out.isError ? { isError: true } : {}) }));