cortad 0.3.11 → 0.4.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
@@ -6,11 +6,16 @@ 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, Cursor and Copilot on this machine. Cortad sends each AI endpoint it finds a test request, and the first run starts by itself once they have answered; later runs start when you press Run in the browser or ask your agent for one. Ctrl-C disconnects.
9
+ Run it in your app's folder with the code from cortad.com and your own start command after `--`: Cortad runs that command as you typed it, with its hook inside and nothing else added, connects the app, and adds Cortad to Claude Code, Codex, Cursor and Copilot on this machine. Then send your AI one message the way a user would; that one request is what Cortad learns the endpoint from. Runs start when you press Run in the browser or ask your agent for one. Ctrl-C disconnects.
10
+
11
+ ```
12
+ npx cortad <code> -- npm run dev your command, as you start it, from the terminal that holds its settings
13
+ npx cortad <code> --port 3000 an app already running: its replies are seen, its model calls are not
14
+ ```
10
15
 
11
16
  ## Your coding agent
12
17
 
13
- After that, your agent has ten tools: `status`, `reach`, `run`, `run_status`, `findings`, `verify`, `dispute`, `feedback`, `field_connect`, `field`. The same ten work as commands:
18
+ After that, your agent has the tools `status`, `reach`, `run`, `run_status`, `findings`, `verify`, `dispute`, `feedback`, `field_connect`, `field`, `world`, `world_status`, `test`, `connect`, `intelligence`, `incidents`, `outcomes`, `subscribe` and `query`. The same work as commands:
14
19
 
15
20
  ```
16
21
  npx cortad status
@@ -22,7 +27,7 @@ npx cortad verify <findingId>
22
27
 
23
28
  `npx cortad wait` returns when the next run ends, with what it found; an agent starts it in the background so the run's end wakes it.
24
29
 
25
- 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.
30
+ A run needs your app up. If nothing on this machine is holding it, the command starts it with the key and the start command kept in `~/.cortad`, and stops it ten minutes after the last run.
26
31
 
27
32
  To make checking with Cortad part of the repository:
28
33
 
@@ -36,19 +41,18 @@ The hooks run after an edit and on your next message: the first names the prompt
36
41
  ## Flags
37
42
 
38
43
  ```
44
+ -- <command> how your app starts; everything after -- is run as typed (--start "cmd" says the same)
45
+ --port 3000 use an app that is already running, as it is
46
+ --port 3000 -- <command> start a container and watch the port it publishes; the model base URL to set inside it is printed
39
47
  --explain show what would be sent and started, then exit
40
- --port 3000 use an app that is already running
41
- --start "make dev" how your app starts
42
- --data .env.staging copy the databases this settings file names, not the ones your app's settings name
43
48
  --verbose print your app's output
44
49
  ```
45
50
 
46
- Your app runs on copies of its databases, made when it starts and deleted when the command ends; the originals are only read. A database it uses that cannot be copied holds Run until you say yes to writing into it.
51
+ Nothing about your environment is guessed or changed. Your app gets this terminal's environment and its own request limits raised for the session, by name; its env files are read only to hide their values in what leaves this machine. Nothing is copied or started beside it: test conversations write where your app writes, after your yes in the browser.
47
52
 
48
53
  ## Files it creates
49
54
 
50
- - `~/.cortad/<project>/` the key, the last tree digest, which process holds your app up, your app's output in `app.log`, the run it asked for last, in `news.txt` the line a finished run left for your agent, removed once the agent has it, and in `heard.txt` the ids of the runs your agent has read
51
- - `~/.cortad/identity.key` the seed for the session's test accounts
55
+ - `~/.cortad/<project>/` the key, the start command, the last tree digest, which process holds your app up, your app's output in `app.log`, the sign-in your own request carried, the run it asked for last, in `news.txt` the line a finished run left for your agent, removed once the agent has it, and in `heard.txt` the ids of the runs your agent has read
52
56
  - `$TMPDIR/cortad-<pid>/` removed on exit
53
57
 
54
58
  macOS and Linux, Node 20 or later. No dependencies. What is sent and what stays on your machine: `npx cortad --explain`, and cortad.com/privacy.
@@ -1,8 +1,9 @@
1
- // The connect command tests the endpoints the read lists by itself, as soon as status lists them:
2
- // no agent, no headers from anyone. Each endpoint is sent once per connection, and again only when
3
- // the file it is written in changes or the agent asks with `reach`. The agent's ask reaches this
4
- // process through two files beside the runner record, since the identities this machine signed in
5
- // live here and nowhere else.
1
+ // The agent's `reach`, sent by the connect command: one request to each endpoint the ask names, or to
2
+ // every endpoint status lists as not reached, from this machine to the app on its port. The ask
3
+ // reaches this process through two files beside the runner record, since the sign-in the person's own
4
+ // request carried lives here and nowhere else. A round with no ask sends each due endpoint once and
5
+ // again only when its file changes; the connect command runs none, so nothing reaches the app on
6
+ // Cortad's own account.
6
7
  import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
8
  import { join } from "node:path";
8
9
  import { notUp, picked, reachText, REACH_MS, sendOurs, testedLine } from "./reach.mjs";
@@ -125,11 +125,10 @@ function unreachedLine(m) {
125
125
  return ` ${m.method} ${m.path}${m.file ? ` ${at(m.file, m.line)}` : ""}${said ? ` ${said}` : ""}`;
126
126
  }
127
127
 
128
- // Cortad sends the rest itself, said only where the server has a request ready for one of them.
128
+ // The agent sends each one message, or asks Cortad to send the request it read where there is one.
129
129
  function reachLine(missing) {
130
- if (!missing.some((m) => m.request && !m.unsure)) return null;
131
- const except = [missing.some((m) => m.unsure || m.byHand) && "marked by hand", missing.some((m) => m.excluded) && "left out of runs"].filter(Boolean);
132
- return `Cortad sends each of them a test request${except.length ? `, except any ${except.join(" or ")}` : ""}.`;
130
+ const ready = missing.filter((m) => m.request && !m.unsure && !m.excluded);
131
+ return `Send each one message as a user would, signed in where your app needs it${ready.length ? `, or call reach to send the request Cortad read for ${ready.length === missing.length ? "them" : `${num(ready.length)} of them`}` : ""}.`;
133
132
  }
134
133
 
135
134
  // `data`: what the command did with the app's stores and services, said right under the run it is
package/lib/env-files.mjs CHANGED
@@ -1,7 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename, dirname, join, sep } from "node:path";
3
- import { listingUrl } from "./listing.mjs";
4
-
5
1
  // An env file read the way dotenv and python-dotenv read one: comments and `export` dropped, a quoted
6
2
  // value kept whole across lines, an unquoted one cut at " #", ${NAME} filled from the names above it
7
3
  // and then from `outer`. A bare $ is kept as written, as dotenv keeps it: "pa$$word" and a bcrypt hash
@@ -41,84 +37,3 @@ function closes(body, quote) {
41
37
  }
42
38
  return 0;
43
39
  }
44
-
45
- const read = (file, outer) => { try { return parseEnv(readFileSync(file, "utf8"), outer); } catch { return {}; } };
46
- const PLACEHOLDERS = /\.(?:example|sample)$/;
47
-
48
- // The folders from the app's own up to the repository root, nearest first.
49
- const upFrom = (appDir, root) => {
50
- const dirs = [];
51
- for (let d = appDir; ; d = dirname(d)) {
52
- dirs.push(d);
53
- if (d === root || !d.startsWith(root + sep) || dirname(d) === d) return dirs;
54
- }
55
- };
56
-
57
- // What a start by hand gives the app from its env files: the `.env` nearest the app, as `source .env`
58
- // does for an app with no loader of its own. A name another env file there sets to another value is
59
- // left to the app's own loader, which reads its files by its own mode and order: put in the process,
60
- // it outranks that loader (the process wins in dotenv, python-dotenv, pydantic-settings, Next and
61
- // Vite). Nitham's app reads .env.test then .env.local; the test file's key, handed over this way,
62
- // was refused on every call it made under Cortad and on none started by hand.
63
- export function appEnv(envFiles, appDir, root, outer = {}) {
64
- const base = upFrom(appDir, root).map((d) => join(d, ".env")).find((f) => envFiles.includes(f));
65
- if (!base) return {};
66
- const values = read(base, outer);
67
- const beside = envFiles.filter((f) => f !== base && !PLACEHOLDERS.test(f) && (dirname(f) === dirname(base) || dirname(f) === appDir));
68
- for (const file of beside) {
69
- for (const [name, value] of Object.entries(read(file, outer))) if (name in values && values[name] !== value) delete values[name];
70
- }
71
- return values;
72
- }
73
-
74
- // The real env files in the app's folders, lowest first, the way dotenv-flow, Next and Vite rank them
75
- // in development: .env, then the development file, then .env.local, and the file of the mode the shell
76
- // names above all. A test, staging or stage file of another mode goes under them all and still counts:
77
- // Nitham's app reads .env.test in its local mode, and a store only it names is a store the app writes
78
- // into. Placeholders are left out.
79
- const RANK = { ".env": 1, ".env.development": 2, ".env.dev": 2, ".env.local": 3, ".env.development.local": 4 };
80
- const MODE_FILE = /^\.env\.(test|staging|stage)$/;
81
- export function rankedFiles(envFiles, appDir, root, mode = "") {
82
- const dirs = upFrom(appDir, root).reverse();
83
- const want = mode.toLowerCase() === "staging" ? "stage" : mode.toLowerCase();
84
- const rank = (f) => {
85
- const name = basename(f);
86
- const m = MODE_FILE.exec(name);
87
- if (m) return (m[1] === "staging" ? "stage" : m[1]) === want ? 5 : 0;
88
- return RANK[name] ?? -1;
89
- };
90
- return envFiles.filter((f) => dirs.includes(dirname(f)) && !PLACEHOLDERS.test(f) && rank(f) >= 0)
91
- .sort((a, b) => dirs.indexOf(dirname(a)) - dirs.indexOf(dirname(b)) || rank(a) - rank(b));
92
- }
93
-
94
- // The values the app's settings could settle on, ranked as above: for reading which stores and ports
95
- // the app names, and for a shell that cannot read the files itself. `outer` names the mode and fills ${NAME}.
96
- export function localEnv(envFiles, appDir, root, outer = {}) {
97
- const mode = outer.ENV ?? outer.APP_ENV ?? outer.NODE_ENV ?? outer.ENVIRONMENT ?? "";
98
- return Object.assign({}, ...rankedFiles(envFiles, appDir, root, mode).map((f) => read(f, outer)));
99
- }
100
-
101
- // Each real env file in the app's folders, read whole: a key is only ever sent with the address the
102
- // same file gives it, or one the app's development files give every key.
103
- export function fileValues(envFiles, appDir, root, outer = {}) {
104
- const dirs = upFrom(appDir, root);
105
- return envFiles.filter((f) => dirs.includes(dirname(f)) && !PLACEHOLDERS.test(f)).map((f) => read(f, outer));
106
- }
107
-
108
- // The values a development start reads whatever its mode: .env, the development files and .env.local,
109
- // never a test, staging or stage file.
110
- export function devEnv(envFiles, appDir, root, outer = {}) {
111
- return Object.assign({}, ...rankedFiles(envFiles, appDir, root).filter((f) => !MODE_FILE.test(basename(f))).map((f) => read(f, outer)));
112
- }
113
-
114
- // The key and address pairs a provider's model listing is asked with: the shell's key, else each
115
- // distinct key a file holds. A key goes only to the address the shell or its own file names, else the
116
- // one the development files name, else the provider's own; where only a test or staging file names
117
- // an address, which the app may not use, the key is not sent at all.
118
- export function keyTries(name, canonical, { shell, files, dev }) {
119
- const prefix = name.replace(/_API_KEY$/, "");
120
- const named = (v) => Boolean(v[`${prefix}_BASE_URL`] || v[`${prefix}_API_BASE`]);
121
- const addressFor = (own) => (named(shell) || named(own) || named(dev) || !files.some(named) ? listingUrl(name, canonical, { ...dev, ...own, ...shell }) : null);
122
- return [...new Map((shell[name] ? [shell] : files.filter((v) => v[name]))
123
- .map((v) => ({ key: v[name], url: addressFor(v) })).filter((t) => t.url).map((t) => [`${t.url} ${t.key}`, t])).values()];
124
- }
package/lib/files.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // Which of the repository's files are code, and which are tests: where a problem is placed at its
2
+ // line (lib/replay.mjs), and which files count as the app a run plays (lib/fresh.mjs).
3
+ export const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
4
+ // A test opens its own scratch file; that is not where the app keeps its data.
5
+ export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
package/lib/fresh.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { readFileSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { TEST } from "./data.mjs";
10
+ import { TEST } from "./files.mjs";
11
11
  import { homeOf, readApp } from "./home.mjs";
12
12
  import { readRunner } from "./runner.mjs";
13
13
  import { writesDirOf, writtenPaths } from "./writes.mjs";
package/lib/mcp.mjs CHANGED
@@ -10,7 +10,7 @@ export const PROTOCOL = "2025-06-18";
10
10
  // Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
11
11
  // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
12
12
  export const instructions = (spec = "cortad") => [
13
- "Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. Cortad sends each AI endpoint a test request itself. status says whether the app is up, what Cortad's audit of the AI code found, and what a run would play and cost. A run starts when the person presses Run in the browser or asks you.",
13
+ "Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. You connect the app with its own start command after `--`, then send each AI endpoint one message as a user would; Cortad learns the endpoint from that request. status says whether the app is up, what Cortad's audit of the AI code found, and what a run would play and cost. A run starts when the person presses Run in the browser or asks you.",
14
14
  `Tell the person what status found in three or four short, plain lines, leaving out file paths, endpoints, status codes and setting names unless they ask. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run or verify ends, with what it found. After a run, call findings and tell the person what their users ran into and what you would change, worst first, three problems at most; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate. connect gives the steps to connect the live app. intelligence says what is happening to it and incidents what went wrong and why; outcomes, subscribe and query are the layer's other tools. dispute records a check that reads this app wrong.`,
15
15
  ].join("\n\n");
16
16
  export const INSTRUCTIONS = instructions();
@@ -25,8 +25,8 @@ export const TOOLS = [
25
25
  { name: "status", title: "Cortad status", annotations: readOnly,
26
26
  description: "What Cortad found in this repository: whether the app is up and on which port, its audit of the prompts and of the code around the model, each endpoint's answer to Cortad's test request or the file and line it failed at, the plan with runs left, 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 in full, in pages.",
27
27
  inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section to list in full: rules, standards, journeys, endpoints, trials, records or reviews; or machine, what Cortad does on this machine." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
28
- { name: "reach", title: "Send Cortad's test requests again",
29
- description: "Sends Cortad's test request again to the endpoints status lists as failed or not yet answered, or to the paths named, from this machine to the app on its port. Each endpoint comes back with its HTTP status, its time and, when it is not 2xx, the start of what the app answered.",
28
+ { name: "reach", title: "Send a request Cortad read to an endpoint",
29
+ description: "Sends the request Cortad read for an endpoint, to the ones status lists as failed or not yet answered, or to the paths named, from this machine to the app on its port, signed in as your own request was where one was seen. Each endpoint comes back with its HTTP status, its time and, when it is not 2xx, the start of what the app answered. A message you send the app yourself does the same.",
30
30
  inputSchema: { type: "object", properties: {
31
31
  paths: { type: "array", items: { type: "string" }, description: "Only these endpoints, by path. Default: every endpoint status lists as failed or not yet answered." },
32
32
  }, additionalProperties: false } },
package/lib/proc.mjs CHANGED
@@ -58,7 +58,9 @@ export async function listenerOn(port) {
58
58
  // already in use", Go's "0.0.0.0:8080: bind: address already in use", Node's "EADDRINUSE: address
59
59
  // already in use :::3000" or "... 127.0.0.1:3000", Rails' "... for "127.0.0.1" port 3000". 0 when
60
60
  // it names none.
61
- export const portInError = (said) => Number(/[:\s(,](\d{4,5})\)?:?[^\n\d]{0,12}address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)[^\n]{0,60}?[:\s](\d{4,5})\b/i.exec(said)?.[1] ?? 0);
61
+ // The port in the app's own words for a taken one, however its runtime spells the address: Node's
62
+ // "EADDRINUSE: address already in use :::3100", uvicorn's "('127.0.0.1', 58889): [errno 48] address already in use".
63
+ export const portInError = (said) => Number(/[:\s(,](\d{4,5})\)?:?[^\n]{0,40}?address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)[^\n]{0,60}?[:\s](\d{4,5})\b/i.exec(said)?.[1] ?? 0);
62
64
 
63
65
  // Whoever holds a port: its pid, the program and its first argument (never the rest of its command
64
66
  // line, which can carry a key), and the folder it runs in.
package/lib/proxy.mjs CHANGED
@@ -34,12 +34,23 @@ const asked = (raw) => (/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/.test(raw) ? [...n
34
34
  // ponytail: a worker slower than this gets a request said unseen; the wait per app if one is seen.
35
35
  const UNSEEN_MS = 3000;
36
36
 
37
- const listen = async (server) => { server.listen(0, "127.0.0.1"); await once(server, "listening"); return server.address().port; };
37
+ // On the port asked for when it is free, else on any: a container's settings name the model port once.
38
+ const listen = async (server, port = 0) => {
39
+ const at = await new Promise((resolve) => {
40
+ server.once("error", () => resolve(null));
41
+ server.listen(port, "127.0.0.1", () => resolve(server.address().port));
42
+ });
43
+ if (at !== null) return at;
44
+ server.removeAllListeners("error");
45
+ server.listen(0, "127.0.0.1");
46
+ await once(server, "listening");
47
+ return server.address().port;
48
+ };
38
49
 
39
50
  // `file`: the trace file the command reads. `target()`: where the app answers now, { host, port };
40
51
  // it moves when the app is started again on another port. `onSeen`: the first model call came through.
41
52
  // `onUnseen(door)`: a request the app answered in words with no model call begun, said once, as "POST /chat".
42
- export async function openProxy({ file, rulesFile, target, onSeen = () => {}, onUnseen = () => {} }) {
53
+ export async function openProxy({ file, rulesFile, target, modelPort: wantedModelPort = 0, onSeen = () => {}, onUnseen = () => {} }) {
43
54
  const row = (value) => { try { appendFileSync(file, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
44
55
  const rec = makeRecorder({ row, rulesFile });
45
56
  // The requests in flight through the front that a model call can be made for.
@@ -156,7 +167,7 @@ export async function openProxy({ file, rulesFile, target, onSeen = () => {}, on
156
167
  const model = createServer((req, res) => { relay(req, res).catch(() => res.destroy()); });
157
168
 
158
169
  const frontPort = await listen(front);
159
- const modelPort = await listen(model);
170
+ const modelPort = await listen(model, wantedModelPort);
160
171
  row({ hello: "proxy", pid: process.pid });
161
172
  const via = (upstream) => {
162
173
  if (!slots.includes(upstream)) slots.push(upstream);