cortad 0.3.10 → 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,16 +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
48
  --verbose print your app's output
43
49
  ```
44
50
 
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.
52
+
45
53
  ## Files it creates
46
54
 
47
- - `~/.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
48
- - `~/.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
49
56
  - `$TMPDIR/cortad-<pid>/` removed on exit
50
57
 
51
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";
package/lib/cli.mjs CHANGED
@@ -25,7 +25,7 @@ const WAITER = "run-when-up";
25
25
  // How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
26
26
  // about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
27
27
  const RESTART_MS = { mcp: 45_000, shell: 150_000 };
28
- export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field"];
28
+ export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field", "world", "world_status", "test"];
29
29
  export const COMMANDS = new Set(["mcp", ...VERBS, "wait", "news", "run-status", "field-connect", "stick", "unstick", WAITER]);
30
30
  // A lowercase word this version has no command for, so it is never read as a connect code.
31
31
  export const unknownVerb = (word) => /^[a-z][a-z_]*$/.test(word ?? "") && !COMMANDS.has(word);
@@ -161,6 +161,9 @@ export function argsOf(command, rest) {
161
161
  return { about: words[0], kind: words[1], needed: words.slice(2).join(" "), ...(got ? { got } : {}), ...(tried ? { tried } : {}) };
162
162
  }
163
163
  case "field": return rest[0] ? { days: rest[0] } : {};
164
+ case "world": return rest.length ? { ask: rest.join(" ") } : {};
165
+ case "test": return rest.length ? { ask: rest.join(" ") } : {};
166
+ case "world_status": return rest[0] ? { jobId: rest[0] } : {};
164
167
  case "reach": {
165
168
  const paths = rest.filter((w) => w.startsWith("/"));
166
169
  return paths.length ? { paths } : {};
@@ -0,0 +1,202 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ // Their commit history, read here and sent after the code: every commit since the newest one Cortad
4
+ // holds, the whole history the first time, oldest first, each with its message, author, dates, the
5
+ // paths it moved and a diff cut to the files this program would upload anyway, with any line that
6
+ // carries a key replaced before it leaves. One connect sends up to a budget; a longer history goes
7
+ // on at the next connect from where this one stopped. Git never reaches the network for this: lazy
8
+ // fetching is off and every call has a time limit, so a blobless clone sends messages alone.
9
+
10
+ export const LIMITS = {
11
+ // Bytes of history one connect sends; the newer commits past it follow at the next connect.
12
+ budget: 64_000_000,
13
+ // Commits whose diffs one git call reads.
14
+ window: 200,
15
+ // A diff larger than this, after filtering, is reported by size and not sent.
16
+ diffBytes: 256_000,
17
+ // One request to the server, under its limits.
18
+ partBytes: 1_500_000,
19
+ partCommits: 500,
20
+ messageChars: 4000,
21
+ paths: 2000,
22
+ };
23
+
24
+ const SHA = /^[0-9a-f]{40}$/;
25
+ const HEADER = /[0-9a-f]{40}\x1f/;
26
+ const FORMAT = "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%B%x1e";
27
+ // GIT_NO_LAZY_FETCH is read by git 2.44 and later; the time limit covers the rest.
28
+ const ENV = { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
29
+
30
+ const git = (root, args, timeout = 30_000) =>
31
+ execFileSync("git", ["-C", root, ...args], { env: ENV, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024, timeout }).toString("utf8");
32
+
33
+ export function headOf(root) {
34
+ try { const sha = git(root, ["rev-parse", "HEAD"]).trim(); return SHA.test(sha) ? sha : null; } catch { return null; }
35
+ }
36
+
37
+ // Everything after `since` while it is still behind HEAD; the whole history when nothing is held or
38
+ // history was rewritten under it.
39
+ export function rangeOf(root, since) {
40
+ if (since && SHA.test(since)) {
41
+ try { git(root, ["merge-base", "--is-ancestor", since, "HEAD"]); return `${since}..HEAD`; } catch { /* unknown here, or rewritten */ }
42
+ }
43
+ return "HEAD";
44
+ }
45
+
46
+ // A rename as numstat writes it, read as the path it ended at: "{a => b}/x", "a => b".
47
+ export const renamedTo = (path) => path.replace(/\{([^{}]*) => ([^{}]*)\}/g, "$2").replace(/^[^{]* => /, "").replace(/\/{2,}/g, "/");
48
+
49
+ function statsOf(text) {
50
+ const changed = [];
51
+ for (const line of text.split("\n")) {
52
+ const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
53
+ if (m) changed.push({ path: renamedTo(m[3]), added: m[1] === "-" ? null : Number(m[1]), deleted: m[2] === "-" ? null : Number(m[2]) });
54
+ }
55
+ return changed;
56
+ }
57
+
58
+ // The commits of a range, oldest first, each with the paths it moved that `keep` allows; the rest
59
+ // are counted as hidden. One git call for everything; when the stats cannot be read without the
60
+ // network, the commits are sent without them.
61
+ export function listCommits(root, range, { keep }) {
62
+ let out;
63
+ let withStats = true;
64
+ try { out = git(root, ["log", FORMAT, "--numstat", "-M", range], 120_000); } catch {
65
+ withStats = false;
66
+ try { out = git(root, ["log", FORMAT, range], 120_000); } catch { return { commits: [], withStats: false }; }
67
+ }
68
+ const segments = out.split("\x1e");
69
+ const commits = [];
70
+ let header = segments[0] ?? "";
71
+ for (let i = 1; i <= segments.length; i += 1) {
72
+ const rest = segments[i] ?? "";
73
+ const at = rest.search(HEADER);
74
+ const stats = at < 0 ? rest : rest.slice(0, at);
75
+ const [sha, parents = "", name = "", email = "", authoredAt = "", committedAt = "", ...body] = header.replace(/^\n+/, "").split("\x1f");
76
+ if (SHA.test(sha ?? "")) {
77
+ const all = statsOf(stats);
78
+ const shown = all.filter((c) => keep(c.path));
79
+ commits.push({
80
+ sha,
81
+ parents: parents.split(" ").filter((p) => SHA.test(p)),
82
+ author: { name: name.slice(0, 200), email: email.slice(0, 320) },
83
+ authoredAt, committedAt,
84
+ message: body.join("\x1f").trimEnd().slice(0, LIMITS.messageChars),
85
+ stats: { files: all.length, insertions: all.reduce((n, c) => n + (c.added ?? 0), 0), deletions: all.reduce((n, c) => n + (c.deleted ?? 0), 0) },
86
+ changed: shown.slice(0, LIMITS.paths),
87
+ hidden: all.length - shown.length,
88
+ stripped: 0,
89
+ });
90
+ }
91
+ if (at < 0) break;
92
+ header = rest.slice(at);
93
+ }
94
+ commits.reverse();
95
+ return { commits, withStats };
96
+ }
97
+
98
+ // git writes a path with unusual characters C-quoted; a plain one as it is.
99
+ const unquote = (path) => (path.startsWith('"') && path.endsWith('"') ? JSON.parse(path) : path);
100
+
101
+ // The two paths of one file's diff: before and after. A rename with no edit has only the rename lines.
102
+ function pathsOf(file) {
103
+ const line = (re) => { const m = re.exec(file); return m ? unquote(m[1]) : null; };
104
+ const head = /^diff --git a\/(.*) b\/(.*)$/m.exec(file);
105
+ const a = line(/^--- a\/(.*)$/m) ?? line(/^rename from (.*)$/m) ?? line(/^copy from (.*)$/m) ?? (head ? unquote(head[1]) : null);
106
+ const b = line(/^\+\+\+ b\/(.*)$/m) ?? line(/^rename to (.*)$/m) ?? line(/^copy to (.*)$/m) ?? (head ? unquote(head[2]) : null);
107
+ return { a, b };
108
+ }
109
+
110
+ const HELD = "[line held back on this machine: it carries a key]";
111
+
112
+ // A patch cut to the files `keep` allows, every added or removed line that carries a key replaced.
113
+ // A patch that grows past the limit is given up at that point: `over` says so, and no line of it
114
+ // is read for keys, since the key detector works line by line and a generated file's one long
115
+ // line would cost minutes.
116
+ export function filterDiff(patch, keep, holdsKey, limit = LIMITS.diffBytes) {
117
+ let stripped = 0;
118
+ let bytes = 0;
119
+ const kept = [];
120
+ for (const file of patch.split(/^(?=diff --git )/m)) {
121
+ if (!file.startsWith("diff --git ")) continue;
122
+ const { a, b } = pathsOf(file);
123
+ if (!a || !b || !keep(a) || !keep(b)) continue;
124
+ bytes += Buffer.byteLength(file);
125
+ if (bytes > limit) return { diff: "", stripped: 0, over: true, bytes: Buffer.byteLength(patch) };
126
+ kept.push(file.split("\n").map((line) => {
127
+ const sign = line[0];
128
+ if ((sign === "+" || sign === "-") && !/^(\+\+\+|---) /.test(line) && holdsKey(line.slice(1))) { stripped += 1; return sign + HELD; }
129
+ return line;
130
+ }).join("\n"));
131
+ }
132
+ const diff = kept.join("");
133
+ return { diff, stripped, over: false, bytes: Buffer.byteLength(diff) };
134
+ }
135
+
136
+ // These commits' diffs in one git call, by sha. A diff over the limit is reported by size and not
137
+ // carried. A merge carries none, as in git log.
138
+ export function diffsOf(root, shas, { keep, holdsKey }) {
139
+ const diffs = new Map();
140
+ if (!shas.length) return diffs;
141
+ let out;
142
+ try { out = git(root, ["log", "--no-walk=unsorted", "-p", "--no-color", "--no-ext-diff", "-M", "--format=%x1eCOMMIT %H", ...shas]); } catch { return diffs; }
143
+ for (const chunk of out.split("\x1eCOMMIT ")) {
144
+ const sha = chunk.slice(0, 40);
145
+ if (!SHA.test(sha)) continue;
146
+ const { diff, stripped, over, bytes } = filterDiff(chunk.slice(41), keep, holdsKey);
147
+ diffs.set(sha, over || !diff ? { bytes, stripped } : { bytes, stripped, diff });
148
+ }
149
+ return diffs;
150
+ }
151
+
152
+ // A remote address with any credential removed; an scp-like address carries none.
153
+ export function safeRemote(url) {
154
+ if (!url) return null;
155
+ try { const u = new URL(url); return `${u.protocol}//${u.host}${u.pathname}`.slice(0, 300); } catch { return /^[\w.-]+@[\w.-]+:/.test(url) ? url.slice(0, 300) : null; }
156
+ }
157
+
158
+ // What the repository is: where it pushes, which branch, how many have written to it, when it began.
159
+ export function repoFacts(root) {
160
+ const read = (args, f = (s) => s.trim()) => { try { return f(git(root, args)); } catch { return null; } };
161
+ const facts = {
162
+ remote: safeRemote(read(["config", "--get", "remote.origin.url"])),
163
+ branch: read(["rev-parse", "--abbrev-ref", "HEAD"], (s) => s.trim().slice(0, 200)),
164
+ contributors: read(["log", "-5000", "--format=%ae"], (s) => new Set(s.split("\n").filter(Boolean)).size),
165
+ firstCommitAt: read(["log", "--format=%cI"], (s) => s.trim().split("\n").at(-1) || null),
166
+ };
167
+ return Object.fromEntries(Object.entries(facts).filter(([, v]) => v !== null && v !== undefined));
168
+ }
169
+
170
+ // The commits to send, oldest first, each with its diff, up to this connect's budget, cut into
171
+ // parts under the server's limits.
172
+ export function historyParts(root, since, { keep, holdsKey }) {
173
+ const { commits } = listCommits(root, rangeOf(root, since), { keep });
174
+ const sent = [];
175
+ let spent = 0;
176
+ for (let i = 0; i < commits.length && spent < LIMITS.budget; i += LIMITS.window) {
177
+ const window = commits.slice(i, i + LIMITS.window);
178
+ const diffs = diffsOf(root, window.map((c) => c.sha), { keep, holdsKey });
179
+ for (const c of window) {
180
+ const held = diffs.get(c.sha);
181
+ if (held) {
182
+ c.stripped = held.stripped;
183
+ c.diffBytes = held.bytes;
184
+ if (held.diff) c.diff = held.diff;
185
+ }
186
+ spent += JSON.stringify(c).length;
187
+ if (spent > LIMITS.budget) break;
188
+ sent.push(c);
189
+ }
190
+ }
191
+ const parts = [];
192
+ let part = [];
193
+ let bytes = 0;
194
+ for (const c of sent) {
195
+ const size = JSON.stringify(c).length;
196
+ if (part.length && (bytes + size > LIMITS.partBytes || part.length === LIMITS.partCommits)) { parts.push(part); part = []; bytes = 0; }
197
+ part.push(c);
198
+ bytes += size;
199
+ }
200
+ if (part.length) parts.push(part);
201
+ return parts;
202
+ }
@@ -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
@@ -0,0 +1,39 @@
1
+ // An env file read the way dotenv and python-dotenv read one: comments and `export` dropped, a quoted
2
+ // value kept whole across lines, an unquoted one cut at " #", ${NAME} filled from the names above it
3
+ // and then from `outer`. A bare $ is kept as written, as dotenv keeps it: "pa$$word" and a bcrypt hash
4
+ // are values, not references. A quote that never closes is the line as written. A line-by-line read
5
+ // handed an app the first line of a quoted PEM key and an unquoted value with its comment still on it.
6
+ export function parseEnv(text, outer = {}) {
7
+ const out = {};
8
+ const fill = (value) => value.replace(/(\\?)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, escaped, name) => (escaped ? whole.slice(1) : out[name] ?? outer[name] ?? ""));
9
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
10
+ for (let i = 0; i < lines.length; i++) {
11
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(lines[i]);
12
+ if (!m) continue;
13
+ const rest = m[2];
14
+ const quote = rest[0];
15
+ if (quote === '"' || quote === "'") {
16
+ let body = rest.slice(1);
17
+ let j = i;
18
+ while (!closes(body, quote) && j + 1 < lines.length) body += `\n${lines[++j]}`;
19
+ const end = closes(body, quote);
20
+ if (end) {
21
+ i = j;
22
+ const raw = body.slice(0, end - 1);
23
+ out[m[1]] = quote === "'" ? raw : fill(raw).replace(/\\n/g, "\n").replace(/\\(["\\$])/g, "$1");
24
+ continue;
25
+ }
26
+ }
27
+ out[m[1]] = fill(rest.replace(/\s+#.*$/, "").trim());
28
+ }
29
+ return out;
30
+ }
31
+ // One past the closing quote, or 0 when the value has none yet. A double quote escaped by a backslash
32
+ // does not close it.
33
+ function closes(body, quote) {
34
+ for (let i = 0; i < body.length; i++) {
35
+ if (quote === '"' && body[i] === "\\") { i++; continue; }
36
+ if (body[i] === quote) return i + 1;
37
+ }
38
+ return 0;
39
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { StringDecoder } from "node:string_decoder";
3
+ import { parseEnv } from "./env-files.mjs";
3
4
 
4
5
  // A model's or provider's name is how the app is configured, not a credential: status names it and
5
6
  // prices the run by it. A value under such a name that looks like a key stays masked all the same.
@@ -7,18 +8,25 @@ const NAMES_A_MODEL = /(?:^|_)(?:MODEL|MODELS|MODEL_NAME|MODEL_ID|PROVIDER)$/i;
7
8
  const KEY_LIKE = /[A-Za-z0-9]{24,}/;
8
9
 
9
10
  // Values from your env files, read here and only here, so nothing a command prints can carry one.
11
+ // Each value as the line holds it and as the app's own loader reads it (lib/env-files.mjs parseEnv),
12
+ // and each line of one that spans lines: the app is handed the parsed value, so a value that differs
13
+ // from its line (a trailing comment, an escaped quote, a key across lines) must be masked as parsed.
10
14
  export function secretValues(envFiles) {
11
15
  const values = new Set();
16
+ const add = (name, v) => {
17
+ if (NAMES_A_MODEL.test(name) && !KEY_LIKE.test(v)) return;
18
+ for (const one of v.includes("\n") ? [v, ...v.split("\n").map((l) => l.trim()).filter((l) => l.length >= 12)] : [v]) {
19
+ if (one.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(one)) values.add(one);
20
+ }
21
+ };
12
22
  for (const file of envFiles) {
13
23
  let text = "";
14
24
  try { text = readFileSync(file, "utf8"); } catch { continue; }
15
25
  for (const line of text.split("\n")) {
16
26
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
17
- if (!m) continue;
18
- const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
19
- if (NAMES_A_MODEL.test(m[1]) && !KEY_LIKE.test(v)) continue;
20
- if (v.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(v)) values.add(v);
27
+ if (m) add(m[1], m[2].trim().replace(/^(['"])(.*)\1$/, "$2"));
21
28
  }
29
+ for (const [name, v] of Object.entries(parseEnv(text))) add(name, v);
22
30
  }
23
31
  return [...values].sort((a, b) => b.length - a.length);
24
32
  }
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";
@@ -0,0 +1,47 @@
1
+ // What the agent reads back from the Intelligence Layer's tools, as plain lines: numbers, ids and
2
+ // the cause, never a user's words.
3
+
4
+ const n = (v) => (typeof v === "number" ? v.toLocaleString("en-US") : String(v ?? 0));
5
+ const when = (iso) => (iso ? new Date(iso).toISOString().replace("T", " ").slice(0, 16) + "Z" : "");
6
+
7
+ export function incidentLine(i) {
8
+ const where = i.segment ? (i.segment.kind === "country" ? ` from ${i.segment.value}` : ` on release ${i.segment.value}`) : "";
9
+ const cause = i.cause ? `; the change was ${i.cause.kind} ${i.cause.value ?? ""}`.trimEnd() + (i.cause.at ? ` at ${when(i.cause.at)}` : "") : "";
10
+ return `Since ${when(i.since)}, ${i.kind === "reaction" ? `people${where} reacted "${i.key}" after` : `replies${where} broke "${i.key}" in`} ${n(i.now.broke)} of ${n(i.now.of)}; before, ${n(i.before.broke)} of ${n(i.before.of)}${cause}.`;
11
+ }
12
+
13
+ export function intelligenceText(d) {
14
+ const lines = [];
15
+ lines.push(d.wrong?.length ? `Wrong now: ${d.wrong.length}.` : "Nothing is wrong right now.");
16
+ for (const i of d.wrong ?? []) lines.push(`- ${incidentLine(i)}`);
17
+ const t = d.conversations ?? {};
18
+ lines.push(`Last ${d.days} days: ${n(t.n)} conversations, ${n(t.read)} read, ${n(t.heldEvery)} kept every rule, ${n(t.got)} got what they came for, ${n(t.frustrated)} frustrated, ${n(t.wantsHuman)} asked for a person.`);
19
+ if (d.visits?.n) {
20
+ const r = d.visits.reactions ?? {};
21
+ lines.push(`Visits: ${n(d.visits.n)}; after answers people ${Object.entries(r).filter(([k]) => k !== "waited").map(([k, v]) => `${k} ${n(v)}`).join(", ") || "reacted to nothing yet"} (of ${n(r.waited)} answers seen).`);
22
+ }
23
+ if (d.checks?.length) lines.push(`Checks broken most: ${d.checks.filter((c) => c.broke).sort((a, b) => b.broke - a.broke).slice(0, 5).map((c) => `${c.id} ${n(c.broke)} of ${n(c.held + c.broke)}`).join("; ") || "none"}.`);
24
+ lines.push(`The page: ${d.url}`);
25
+ return lines.join("\n");
26
+ }
27
+
28
+ export function incidentsText(d) {
29
+ if (!d.open?.length) return `${d.said ?? "Nothing is wrong right now."}\n${d.url}`;
30
+ return [...d.open.map((i) => `${incidentLine(i)} Conversations: ${(i.conversations ?? []).join(", ")}`), d.said, d.url].filter(Boolean).join("\n");
31
+ }
32
+
33
+ const ruleWords = (r) => r.kind === "path" ? `a page ${r.match} ${r.value}` : r.kind === "click" ? `a click on ${r.text ? `"${r.text}"` : r.selector}` : r.kind === "event" ? `the page's event "${r.name}"` : `the backend's outcome "${r.name}"`;
34
+
35
+ export function outcomesText(d) {
36
+ if (d.proposed !== undefined) return `${n(d.proposed)} proposed. ${d.awaiting}`;
37
+ if (!d.outcomes?.length) return `No outcome is defined yet. Propose what the code treats as a good ending (a signup route, a checkout, a "done" button) with outcomes {proposals: [{name, rules, why}]}; ${d.shape}`;
38
+ return [...d.outcomes.map((o) => `${o.approved ? "Counted" : "Proposed, awaiting the person"}: ${o.name}: ${o.rules.map(ruleWords).join(", or ")}${o.why ? ` (${o.why})` : ""}`), d.url].join("\n");
39
+ }
40
+
41
+ export function queryText(d) {
42
+ if (d.error) return `${d.error}${d.sql ? `\n${d.sql}` : ""}`;
43
+ const rows = d.rows ?? [];
44
+ if (rows.length === 0) return `No rows.\n${d.sql}`;
45
+ const keys = Object.keys(rows[0]);
46
+ return [keys.join("\t"), ...rows.slice(0, 200).map((r) => keys.map((k) => String(r[k] ?? "")).join("\t")), rows.length > 200 ? `${rows.length - 200} more rows` : "", d.scannedBytes != null ? `Scanned ${n(d.scannedBytes)} bytes.` : ""].filter(Boolean).join("\n");
47
+ }
package/lib/keys.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseEnv } from "./env-files.mjs";
1
2
  // A file that carries a key stays on this machine, whatever git thinks of it: a catalogue that keeps
2
3
  // an AES key per video is tracked like code and is no more shareable than a .env.
3
4
  // The header alone is code that looks for keys; a key has its body after it.
@@ -21,18 +22,21 @@ export function holdsKeys(text, values = []) {
21
22
  const SECRET_NAME = /KEY|SECRET|TOKEN|PASSW|PRIVATE|CREDENTIAL/i;
22
23
  const EXAMPLE = /\.(example|sample|template|dist)$/i;
23
24
  export function secretEnvValues(envFiles, read) {
24
- const values = [];
25
+ const values = new Set();
26
+ // A value of few distinct characters is a placeholder or a published test key, not a secret.
27
+ const add = (v) => { if (v.length >= 12 && !/\s/.test(v) && new Set(v).size >= 8) values.add(v); };
25
28
  for (const file of envFiles) {
26
29
  if (EXAMPLE.test(file)) continue;
27
30
  let text = "";
28
31
  try { text = read(file); } catch { continue; }
29
32
  for (const line of text.split("\n")) {
30
33
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
31
- if (!m || !SECRET_NAME.test(m[1])) continue;
32
- const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
33
- // A value of few distinct characters is a placeholder or a published test key, not a secret.
34
- if (v.length >= 12 && !/\s/.test(v) && new Set(v).size >= 8) values.push(v);
34
+ if (m && SECRET_NAME.test(m[1])) add(m[2].trim().replace(/^(['"])(.*)\1$/, "$2"));
35
+ }
36
+ // As the app's loader reads it too, each line of a value that spans lines on its own.
37
+ for (const [name, v] of Object.entries(parseEnv(text))) {
38
+ if (SECRET_NAME.test(name)) for (const one of v.split("\n")) add(one.trim());
35
39
  }
36
40
  }
37
- return values;
41
+ return [...values];
38
42
  }
package/lib/lock.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  // bubblewrap. Inside it a shell line can read your project and run your tests, and it can write
4
4
  // only where your own .gitignore already says files are disposable (caches, build output, coverage)
5
5
  // and in temp. It cannot write a tracked file, cannot read your keys or environment files, and on a
6
- // Mac cannot read what other programs left in /tmp or reach the network beyond this machine. With
7
- // neither tool present there is no shell.
6
+ // Mac cannot read what other programs left in /tmp or reach the network beyond this machine, unless
7
+ // it runs as the app (a world's command): then it keeps the app's reach, since it runs the app's own
8
+ // AI, which calls the app's model provider. With neither tool present there is no shell.
8
9
  import { execFile, execFileSync } from "node:child_process";
9
10
  import { existsSync, realpathSync } from "node:fs";
10
11
  import { homedir, tmpdir } from "node:os";
@@ -58,7 +59,7 @@ export async function makeLock({ root, work }) {
58
59
  // Later rules win: home is shut, the project and the toolchains are opened again inside it, and
59
60
  // what the project ignores and your keys are shut last.
60
61
  const hide = ignored.hidden.map(({ path, dir }) => `(${dir ? "subpath" : "literal"} ${q(real(path) ?? path)})`);
61
- const profile = [
62
+ const rules = ({ network }) => [
62
63
  "(version 1)(allow default)",
63
64
  `(deny file-read-data (subpath ${q(real(home) ?? home)}))`,
64
65
  // /tmp holds what other programs on this machine logged; a shell line may write there, never read it.
@@ -67,17 +68,17 @@ export async function makeLock({ root, work }) {
67
68
  '(allow file-read-data (regex #"^/private/tmp/sh-thd"))',
68
69
  `(allow file-read-data (subpath ${q(project)}) ${[work, ...tools].map((p) => `(subpath ${q(p)})`).join(" ")})`,
69
70
  ...(hide.length ? [`(deny file-read-data file-write* ${hide.join(" ")})`] : []),
70
- "(deny network*)",
71
71
  // This machine only. Unix sockets stay shut: through one, a request rides out to the internet
72
72
  // by name on a system network helper even with every direct connection refused.
73
- '(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))',
73
+ ...(network ? [] : ['(deny network*)(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))']),
74
74
  "(deny file-write*)",
75
75
  `(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")} (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/tty") (subpath "/dev/fd"))`,
76
76
  // The same operation the project is opened with: a rule naming file-read-data outranks one naming
77
77
  // file-read*, so a wildcard deny here let every .env be read again.
78
78
  `(deny file-read* file-read-data ${SECRET_DIRS.map((d) => `(subpath ${q(join(home, d))})`).join(" ")} ${SECRET_FILES.map((f) => `(literal ${q(join(home, f))})`).join(" ")} (regex #"\\.env$") (regex #"/\\.env\\.(local|staging|stage|development|dev|test|production|prod)$"))`,
79
79
  ].join("");
80
- return { name: "seatbelt", wrap: (cmd) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", profile, "/bin/sh", "-c", cmd] }) };
80
+ const locked = rules({ network: false }), asApp = rules({ network: true });
81
+ return { name: "seatbelt", wrap: (cmd, { network = false } = {}) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", network ? asApp : locked, "/bin/sh", "-c", cmd] }) };
81
82
  }
82
83
  const bwrap = process.platform === "linux" ? ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync) : null;
83
84
  if (bwrap) {
package/lib/mcp.mjs CHANGED
@@ -10,8 +10,8 @@ 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.",
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. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
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
+ `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();
17
17
 
@@ -19,14 +19,14 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
19
19
  const none = { type: "object", properties: {}, additionalProperties: false };
20
20
  const readOnly = { readOnlyHint: true };
21
21
  // What a note to the Cortad team may be about: a tool, the connect command, or the skill text.
22
- const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "connect", "skill"];
22
+ const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "world", "world_status", "test", "connect", "intelligence", "incidents", "outcomes", "subscribe", "query", "skill"];
23
23
 
24
24
  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 } },
@@ -58,6 +58,39 @@ export const TOOLS = [
58
58
  { name: "field", title: "Production numbers", annotations: readOnly,
59
59
  description: "Production in numbers: conversations read, checks passed, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
60
60
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
61
+ { name: "world", title: "Test the AI in a world",
62
+ description: "For an AI in this app that is not a chat, such as a worker that reads data and writes a verdict: Cortad builds a copy of the product's data on this machine from the app's own seed, plants problems whose answers are known, runs the app's own detection on each, and scores what it raised against the answer key, repeated for a flip rate. Answers within a second with the world's id; world_status follows it. Say what was built or what to test, and the report keeps it.",
63
+ inputSchema: { type: "object", properties: { ask: str("What was built or what to test, in a sentence.", { maxLength: 2000 }) }, additionalProperties: false } },
64
+ { name: "world_status", title: "Where a world stands", annotations: readOnly,
65
+ description: "Where a world stands: planted problems found, missed, false alarms where nothing was planted, and flips between repeats, with what the app raised for each.",
66
+ inputSchema: { type: "object", properties: { jobId: str("The world's id, from world.") }, required: ["jobId"], additionalProperties: false } },
67
+ { name: "test", title: "Test what you built",
68
+ description: "The one call after a change: say what you built or want tested, in a sentence, and Cortad decides what kind of test that is. A world, where this app has one and the sentence names something it can plant; a run of the app's chat surfaces otherwise. Answers within a second with which kind started and the call that follows it.",
69
+ inputSchema: { type: "object", properties: { ask: str("What was built or what to test, in a sentence.", { maxLength: 2000 }) }, additionalProperties: false } },
70
+ { name: "connect", title: "Connect the live app", inputSchema: none,
71
+ description: "The steps to connect the live app, backend and page: a one-time code writes both keys into the env file, the SDK lines for the framework, the page tag, and where each conversation then appears. The same as field_connect." },
72
+ { name: "intelligence", title: "The live app in numbers", annotations: readOnly,
73
+ description: "What is happening to the live app: what is wrong now (since when, in how many of how many, the change before it), the window's conversations and checks, the visits and what people did after answers. No user's words.",
74
+ inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "Window in days. Default 7." } }, additionalProperties: false } },
75
+ { name: "incidents", title: "What went wrong", annotations: readOnly, inputSchema: none,
76
+ description: "Each open incident: the check or reaction, since when, now against before, the cause the data points at (a release, a changed prompt or model, tool errors, page errors), and the conversations to open on the page. Fix the cause named first, then verify." },
77
+ { name: "outcomes", title: "What counts as a good ending",
78
+ description: "What the app counts as an outcome and how often it is reached; or, with proposals, new ones read from the code (a signup route, a checkout, a done button), which the person approves on the page before they count.",
79
+ inputSchema: { type: "object", properties: { proposals: { type: "array", maxItems: 10, items: { type: "object", properties: { name: str("A short name, as the person would say it.", { maxLength: 64 }), why: str("What in the code makes this an outcome.", { maxLength: 500 }), rules: { type: "array", minItems: 1, maxItems: 10, items: { type: "object", properties: { kind: { type: "string", enum: ["path", "click", "event", "backend"] }, match: { type: "string", enum: ["exact", "starts", "contains"] }, value: { type: "string" }, text: { type: "string" }, selector: { type: "string" }, name: { type: "string" } }, required: ["kind"], additionalProperties: false } } }, required: ["name", "rules", "why"], additionalProperties: false } } }, additionalProperties: false } },
80
+ { name: "subscribe", title: "Hear of incidents",
81
+ description: "Registers a public https address that receives each incident as signed JSON (x-cortad-event, x-cortad-signature: sha256=HMAC of the body). Answers with the secret once; keep it where the app keeps its secrets.",
82
+ inputSchema: { type: "object", properties: { url: str("A public https address that takes a POST.", { maxLength: 500 }) }, required: ["url"], additionalProperties: false } },
83
+ { name: "query", title: "Ask the warehouse", annotations: readOnly,
84
+ description: "A structured question over the live app's events, readings or reactions: a table, a time range of at most 90 days, filters, a grouping by columns or by hour or day, measures (count, count_distinct, sum, avg, min, max, share), a limit. Compiled by Cortad, pinned to this app; no SQL is taken. Rows come back as a table.",
85
+ inputSchema: { type: "object", properties: {
86
+ table: { type: "string", enum: ["events", "readings", "reactions"] },
87
+ from: str("Start, ISO 8601."), to: str("End, ISO 8601, at most 90 days after from."),
88
+ filters: { type: "array", maxItems: 20, items: { type: "object", properties: { column: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "in", "gt", "gte", "lt", "lte", "null", "not_null"] }, value: {} }, required: ["column", "op"], additionalProperties: false } },
89
+ group: { type: "array", maxItems: 4, items: { type: "string" }, description: "Columns, or hour or day." },
90
+ measures: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", properties: { fn: { type: "string", enum: ["count", "count_distinct", "sum", "avg", "min", "max", "share"] }, column: { type: "string" }, equals: {}, as: { type: "string" } }, required: ["fn"], additionalProperties: false } },
91
+ order: { type: "object", properties: { by: { type: "string" }, dir: { type: "string", enum: ["asc", "desc"] } }, required: ["by"], additionalProperties: false },
92
+ limit: { type: "integer", minimum: 1, maximum: 1000 },
93
+ }, required: ["table", "from", "to", "measures"], additionalProperties: false } },
61
94
  ];
62
95
  const NAMES = new Set(TOOLS.map((t) => t.name));
63
96