cortad 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,16 @@
1
1
  # cortad
2
2
 
3
- Connects the AI app on your machine to Cortad, so Cortad can run test conversations against it, and gives your coding agent the tools to run them.
3
+ Runs Cortad's test conversations against the AI app on your machine, and gives your coding agent the tools to run them.
4
4
 
5
5
  ```
6
6
  npx cortad <code>
7
7
  ```
8
8
 
9
- Run it in your app's folder with the code from cortad.com. Nothing is installed in your project. 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 and Cursor on this machine. The first run starts on its own. Ctrl-C disconnects.
10
10
 
11
- ## What it does
11
+ ## Your coding agent
12
12
 
13
- - Starts your app with its dev script, or uses it if it is already running.
14
- - Sends Cortad's test conversations to your app on localhost and returns the replies.
15
- - Signs in as a test account when your app needs one: an account made for the session, never an existing user and never an admin.
16
- - Makes Cortad known to the coding agents on this machine (Claude Code, Codex, Cursor): an MCP entry and a skill in each one's own home folder, so `npx cortad mcp` answers them from then on. Nothing is written into your repository.
17
- - Never writes your files. The agent that edits your code is your own.
18
-
19
- ## For your coding agent
20
-
21
- After the first connect, your agent has eight tools: `status`, `run`, `run_status`, `findings`, `verify`, `dispute`, `field_connect`, `field`. The same eight work as shell commands:
13
+ After that, your agent has eight tools: `status`, `run`, `run_status`, `findings`, `verify`, `dispute`, `field_connect`, `field`. The same eight work as commands:
22
14
 
23
15
  ```
24
16
  npx cortad status
@@ -27,26 +19,7 @@ npx cortad findings
27
19
  npx cortad verify <findingId>
28
20
  ```
29
21
 
30
- A run needs your app up. When nothing on this machine is holding it, the command starts it with the key the first connect left in `~/.cortad`, and leaves when no run has needed it for ten minutes.
31
-
32
- ## What Cortad receives
33
-
34
- - The files git would commit, once. Nothing git ignores, and no env, key or data files.
35
- - Your app's replies to the test conversations, with values from your env files hidden and cookies removed.
36
-
37
- ## What stays on your machine
38
-
39
- - Your env files and their values.
40
- - Tokens and cookies. A signed-in test request gets its token added here.
41
- - The key in `~/.cortad/<project>/token` (readable by you only). It starts runs and reads findings for this one repository and nothing else; revoke it from your account page.
42
-
43
- ## The test shell
44
-
45
- Commands a run needs on this machine (your own test suite, a WebSocket door) are confined by the operating system (Seatbelt on macOS, bubblewrap on Linux). They can read your project and your toolchains, write only to temp and build folders, and reach only localhost.
46
-
47
- ## Loaded into your app
48
-
49
- When cortad starts your app it preloads one short file, `lib/trace.cjs` for Node or `lib/pyhook/sitecustomize.py` for Python. It notes which request called a model, so Cortad finds your chat route. It writes only to cortad's temp folder.
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.
50
23
 
51
24
  ## Flags
52
25
 
@@ -63,4 +36,4 @@ When cortad starts your app it preloads one short file, `lib/trace.cjs` for Node
63
36
  - `~/.cortad/identity.key` the seed for the session's test accounts
64
37
  - `$TMPDIR/cortad-<pid>/` removed on exit
65
38
 
66
- macOS and Linux, Node 20+. No dependencies, no install scripts.
39
+ 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.
package/lib/cli.mjs CHANGED
@@ -22,11 +22,11 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
22
22
  if (!trusted) { stderr.write(`cortad refusing: ${origin.host} is not Cortad.\n`); return 1; }
23
23
  const api = `${origin.origin}/api`;
24
24
  const project = projectOf(root);
25
- const token = readToken(project);
26
25
  const [command, ...rest] = argv;
27
26
  const mcp = command === "mcp";
28
27
  const children = [];
29
- const verbs = makeVerbs({ api, token, ensureRunner: () => ensureRunner({ root, project, env, keep: mcp, children, say: (line) => stderr.write(`cortad ${line}\n`) }) });
28
+ // 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`) }) });
30
30
 
31
31
  if (mcp) {
32
32
  const code = await serveMcp({ verbs, version: VERSION, log: (line) => stderr.write(`${line}\n`) });
@@ -1,4 +1,4 @@
1
- // Copied from brainsless-backend src/harness/identities.mjs. Keep the two in step: the recipes the
1
+ // The same file lives on the Cortad side. Keep the two in step: the recipes the
2
2
  // cloud sends are gated here again, on the machine that holds the secrets.
3
3
  // Signing in as the customer's own callers, from inside the box, through their own doors.
4
4
  //
package/lib/mcp.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  export const PROTOCOL = "2025-06-18";
6
6
 
7
7
  export const INSTRUCTIONS = [
8
- "Cortad is an independent behavior test lab for the AI app in this repository. It runs simulated users through the app on this machine and grades every reply with about 100 checks. It is already connected; never run `npx cortad <code>` again.",
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
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
10
  "Rules: a move inside the interval is not a fix. Never change or remove a case to move a number; never make the app recognise test traffic; if a check reads wrong, call dispute with why. Cases, checks, seeds and the holdout are not yours to edit, and Cortad refuses it anyway.",
11
11
  ].join("\n\n");
package/lib/mint.mjs CHANGED
@@ -137,7 +137,7 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
137
137
  // nothing and says so; port the program to this file if that turns out to be common.
138
138
  // A Firebase caller with no emulator on this machine is signed in at their real project, below.
139
139
  // Handed to the program it waited its whole deadline on an emulator that was never there, and
140
- // planless's sign-in arrived after the world had given up waiting for it.
140
+ // one app's sign-in arrived after the world had given up waiting for it.
141
141
  const merged = Object.assign({}, ...[...envs().all].reverse());
142
142
  const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
143
143
  const refused = new Map();
package/lib/replay.mjs CHANGED
@@ -19,7 +19,7 @@ export function makeCapture({ work, keepSecret, onDoor }) {
19
19
  let held = null;
20
20
  let alive = false;
21
21
  // The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
22
- // the hook, and databuddy was told we would see its messages while its API on Bun carried none.
22
+ // the hook, and one app was told we would see its messages while its API on Bun carried none.
23
23
  const ports = new Set();
24
24
  // Bun reads BUN_OPTIONS and splits it on spaces, quotes included, and only the `--preload=` form
25
25
  // leaves `bun run <script>` working. A hook path with a space in it is copied to one without.
package/lib/sample.mjs CHANGED
@@ -20,7 +20,7 @@ const PER_STORE = 6;
20
20
  const PER_STORE_MAX = 60;
21
21
  // A few rows from MANY collections, never many rows from few: a store whose collection names are
22
22
  // the product's own grid (jordan_grade-10_sem-2_math) is only read when every cell is read. Forty
23
- // eight of them, twenty two rows apiece, filled the whole answer with seven of ulaim's cells and
23
+ // eight of them, twenty two rows apiece, filled the whole answer with seven of one app's cells and
24
24
  // left grade 10 semester 2 maths unread, so the depth follows the breadth: how many rows a
25
25
  // collection gets is the answer's room divided by how many collections there are.
26
26
  const COLLECTIONS = 200;
@@ -122,7 +122,7 @@ async function qdrant(plan, names, env, per, until) {
122
122
  const each = Math.max(PER_MIN, Math.min(per, Math.floor(BUDGET / ROW_BYTES / Math.max(1, asking.length))));
123
123
  const rows = [];
124
124
  let read = 0;
125
- // Where each collection that had rows left off. On ulaim a third of the 177 collections are
125
+ // Where each collection that had rows left off. On one app a third of the 177 collections are
126
126
  // empty, so the even split left half the answer unspent at three pages a cell.
127
127
  const more = new Map();
128
128
  const scroll = async (name, limit, offset) => {
package/lib/service.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Some apps keep their sign-in on a different service than the one that serves the AI: databuddy
1
+ // Some apps keep their sign-in on a different service than the one that serves the AI: one app
2
2
  // serves chat from apps/api and mounts better-auth in apps/dashboard, and the command starts only
3
3
  // apps/api. A recipe that names such a service (src/setup/road-identities.ts) is minted against
4
4
  // that service instead, so nobody is asked to send a message by hand. Cookies set on localhost
@@ -62,7 +62,7 @@ export async function waitForPort(port, deadlineMs = 90_000) {
62
62
  }
63
63
 
64
64
  // The page a sign-in believes it is being asked from. Nearly every auth library refuses a request
65
- // whose Origin it does not trust: better-auth answered databuddy's own sign-up with 403
65
+ // whose Origin it does not trust: better-auth answered one app's own sign-up with 403
66
66
  // INVALID_ORIGIN because the ask went to 127.0.0.1 with no page behind it. Their own settings name
67
67
  // the page; where they do not, it is that port on localhost, which is what their own browser sends.
68
68
  export function originFor(port, origins = []) {
package/lib/trace.cjs CHANGED
@@ -76,7 +76,7 @@ if (FILE) {
76
76
  return als.run(ctx, () => emit.call(this, type, req, ...rest));
77
77
  };
78
78
 
79
- // Bun serves through Bun.serve, never node:http, so the patch above saw no request at all: databuddy's
79
+ // Bun serves through Bun.serve, never node:http, so the patch above saw no request at all: an
80
80
  // Elysia API on Bun took the customer's messages and nothing was written. Bun.serve is wrapped
81
81
  // instead, both the fetch handler and the per-route handlers Bun can dispatch to directly. The body
82
82
  // is read from a clone, so the app's own reader is untouched.
@@ -176,7 +176,7 @@ if (FILE) {
176
176
 
177
177
  // Outbound to a service their own settings name (a vector store, a database API, a search
178
178
  // host): which setting, which host and whether it answered. No body, no headers, no query. A
179
- // run that tested ulaim while every curriculum lookup failed inside Node never knew; this is
179
+ // run that tested a tutoring app while every lookup failed inside Node never knew; this is
180
180
  // the witness that lets the run say retrieval was not tested instead of grading without it.
181
181
  let depHosts = null, depSeen = -1;
182
182
  const depOf = (input) => {
package/lib/verbs.mjs CHANGED
@@ -6,12 +6,13 @@ const NOT_CONNECTED = "This project is not connected to Cortad yet. Ask the pers
6
6
 
7
7
  export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async () => ({ ok: true }) }) {
8
8
  const call = async (method, path, body) => {
9
- if (!token) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
9
+ const key = typeof token === "function" ? token() : token;
10
+ if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
10
11
  let res;
11
12
  try {
12
13
  res = await fetchImpl(`${api}${path}`, {
13
14
  method,
14
- headers: { authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
15
+ headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
15
16
  body: body === undefined ? undefined : JSON.stringify(body),
16
17
  signal: AbortSignal.timeout(60_000),
17
18
  });
@@ -108,6 +109,8 @@ export function runText(d) {
108
109
  const tail = [];
109
110
  if (d.finished && typeof d.score === "number") tail.push(`Score ${d.score} of 100.`);
110
111
  if (d.finished && typeof d.findings === "number") tail.push(d.findings ? `${plural(d.findings, "finding")}; call findings.` : "No findings stood out.");
112
+ if (d.stopped) tail.push(`Stopped after ${d.stopped.after} of ${d.of}: ${d.stopped.why}${d.stopped.side === "theirs" ? " (their side)" : d.stopped.side === "ours" ? " (Cortad's side)" : ""}. ${d.stopped.fix}`);
113
+ for (const f of d.faults ?? []) tail.push(`${f.what} ${f.fix}`);
111
114
  if (d.error) tail.push(`Error: ${d.error}`);
112
115
  if (d.verify) tail.push(verifyText(d.verify));
113
116
  if (!d.finished) tail.push("Poll again in 30 seconds.");
package/local.mjs CHANGED
@@ -440,7 +440,7 @@ const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir &&
440
440
  let appDir = root;
441
441
 
442
442
  // ---- a sign-in mounted in another workspace
443
- // databuddy serves its AI from apps/api and mounts sign-in in apps/dashboard: a test account can
443
+ // One app served its AI from apps/api and mounted sign-in in apps/dashboard: a test account can
444
444
  // only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
445
445
  const PORT_IN_SCRIPT = /(?:^|\s)(?:PORT=|-p[ =]|--port[ =])(\d{2,5})\b/;
446
446
  // The port a framework serves on when nobody names one. sveltekit and astro before vite: both bring
@@ -684,7 +684,7 @@ async function start(waitMs) {
684
684
  const tail = () => seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-25).join("\n");
685
685
  if (exited !== null) return { port: null, exited, tail: tail() };
686
686
  // nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
687
- // the wait ran its whole three minutes on planless with the reason sitting in the output.
687
+ // the wait ran its whole three minutes on one app with the reason sitting in the output.
688
688
  // Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
689
689
  // that holds the port is theirs and already running, which startApp turns into attaching to it.
690
690
  if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
@@ -737,7 +737,7 @@ async function listening(pid) {
737
737
  } catch { return []; }
738
738
  }
739
739
  // Your app and everything it started, stopped for certain. A process group is not enough: nodemon
740
- // gives the real server a group of its own, and planless's server outlived this command as an
740
+ // gives the real server a group of its own, and one app's server outlived this command as an
741
741
  // orphan still holding its port and its production connections. Asked first, then made to.
742
742
  async function stopApp(pid) {
743
743
  const family = await familyOf(pid).catch(() => [pid]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
package/skill/SKILL.md CHANGED
@@ -6,9 +6,17 @@ Cortad is connected to this repository. Do not run `npx cortad <code>` again.
6
6
 
7
7
  If the `cortad` MCP tools are in your tool list, use them. Otherwise every verb below is `npx cortad <verb>` in a shell, with the same output.
8
8
 
9
+ ## Right after connect
10
+ The first run starts by itself: never call `run` for it. As soon as the command says "Go back to the browser", tell the person, in your own words, all of this:
11
+ - Cortad has started their app on this machine and is reading their code to write realistic users of it, with situations that move.
12
+ - It will play those users through the app and grade every reply against about 100 checks: their own rules, and what a good reply is.
13
+ - The report lands in the browser, with each finding at a file and line, and you can fix and verify from here.
14
+ - They should go back to the browser now and finish the two onboarding questions while it plays; you will report when it is done.
15
+ Then `status` every 30 seconds, quietly, until the latest run is finished. Report the score, the number of findings and the link.
16
+
9
17
  ## The loop
10
18
  1. `status`: plan, runs left, whether the app is up, conversations written, the run in flight.
11
- 2. `run`: only when the person asks. The first run is free. After that it returns a checkout link; show it in one sentence and wait for the person.
19
+ 2. `run`: only when the person asks. The first run started by itself and was free. After that `run` returns a checkout link; show it in one sentence and wait for the person.
12
20
  3. `run_status <jobId>` every 30 seconds; say nothing unless the count moved. When it finishes, say the score, how many findings, and the link.
13
21
  4. `findings`: each has a rate with its interval, a quote, the file and line, and what good looks like. Start from the worst rate.
14
22
  5. Fix ONE finding: the smallest change in the file it names. Then `verify <findingId>`.
@@ -5,7 +5,7 @@ One result per verb, as the tool returns it. The numbers are from a walk of a tu
5
5
  ## status
6
6
 
7
7
  ```
8
- Cortad · ulaim · Free: 0 of 1 run left this month, 60 of 60 verify trials.
8
+ Cortad · your-app · Free: 0 of 1 run left this month, 60 of 60 verify trials.
9
9
  App: Your app answered during startup on port 3100. This is the last recorded state, not a new health check.
10
10
  Conversations written: 51. A run can start.
11
11
  Latest run 8f2a1c4e-... succeeded: played 51 of 51. Score 71 of 100. 7 findings; call findings. https://cortad.com/lab