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/lib/verbs.mjs CHANGED
@@ -1,11 +1,25 @@
1
- // The eight things a coding agent may ask of Cortad, each one call to the API with this machine's
2
- // key and one rendering as short text. The MCP tools and the `npx cortad <verb>` commands are the
3
- // same functions, so both faces say the same thing.
4
-
5
- const NOT_CONNECTED = "This project is not connected to Cortad yet. Ask the person to open cortad.com, sign in, and run the command the connect screen shows, from this folder.";
6
-
7
- export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async () => ({ ok: true }) }) {
8
- const call = async (method, path, body) => {
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { resolve as resolvePath, sep } from "node:path";
3
+ import { SHOW, showText } from "./read-text.mjs";
4
+ import { fieldConnectText, fieldText, findingsText, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
5
+
6
+ // The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
7
+ // and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
8
+ // functions, so both faces say the same thing.
9
+ //
10
+ // Codex, Cursor CLI and Copilot CLI cut an MCP call at about 60 seconds. `run` and `verify` answer
11
+ // within a second; `run_status` holds for up to 45 seconds on the server and 58 in all.
12
+
13
+ const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person: sign in at cortad.com and run the command the connect screen shows, in this folder.";
14
+ const HOLD_S = 45;
15
+ // What the process that starts a run for an app that is still coming up allows it; lib/cli.mjs.
16
+ export const READY_MS = 240_000;
17
+
18
+ // `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
19
+ // startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
20
+ // the latest run at the time, so a newer run on the server outranks a stale file.
21
+ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
22
+ const call = async (method, path, body, timeoutMs = 30_000) => {
9
23
  const key = typeof token === "function" ? token() : token;
10
24
  if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
11
25
  let res;
@@ -14,10 +28,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
14
28
  method,
15
29
  headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
16
30
  body: body === undefined ? undefined : JSON.stringify(body),
17
- signal: AbortSignal.timeout(60_000),
31
+ signal: AbortSignal.timeout(timeoutMs),
18
32
  });
19
33
  } catch (err) {
20
- return { status: 0, ok: false, data: { error: `could not reach Cortad: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}` } };
34
+ return { status: 0, ok: false, data: { error: `Could not reach Cortad: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}.` } };
21
35
  }
22
36
  const text = await res.text();
23
37
  let data = null;
@@ -25,37 +39,108 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
25
39
  return { status: res.status, ok: res.ok, data };
26
40
  };
27
41
  const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
42
+ const iso = () => new Date(now()).toISOString();
43
+
44
+ // With show, one section of the read in full, paged; a name that is not a section is the plain status.
45
+ const status = async ({ show, page } = {}) => {
46
+ const section = SHOW.includes(show) ? show : null;
47
+ const res = await call("GET", `/mcp/status${section ? `?show=${section}` : ""}`);
48
+ if (!res.ok) return failed(res);
49
+ return { text: section ? showText(res.data, section, page) : statusText(res.data), data: res.data };
50
+ };
28
51
 
29
- const status = async () => {
30
- const res = await call("GET", "/mcp/status");
31
- return res.ok ? { text: statusText(res.data), data: res.data } : failed(res);
52
+ // Asks the server for the run and keeps its id here, so run_status finds it without one.
53
+ const post = async (args, kind) => {
54
+ const res = await call("POST", "/mcp/run", args);
55
+ if (res.status === 202 || (res.ok && res.data?.joined)) {
56
+ pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
57
+ return { text: startedText(res.data, kind, args.findingId), data: res.data };
58
+ }
59
+ if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
60
+ if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
61
+ return failed(res);
32
62
  };
33
63
 
34
- // A run, or a verify when a finding is named. When the app is not up on this machine, the runner
35
- // is started first: the process that uploads the tree, starts the app and answers the knocks.
64
+ // An app that is up gets the run now. One that is not is started in the background, and the run
65
+ // is posted from there once it answers.
36
66
  const start = async (args, kind) => {
37
67
  const before = await call("GET", "/mcp/status");
38
68
  if (!before.ok) return failed(before);
39
- if (before.data.app?.state !== "ready-to-test") {
40
- const runner = await ensureRunner(before.data);
41
- if (!runner.ok) return { text: runner.why, data: runner, isError: true };
69
+ if (before.data.app?.state === "ready-to-test") return post(args, kind);
70
+ pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
71
+ const began = await startApp(args, kind);
72
+ if (!began.ok) {
73
+ pending.write({ kind, startedAt: iso(), error: began.why });
74
+ return { text: began.why, isError: true };
42
75
  }
43
- const res = await call("POST", "/mcp/run", args);
44
- if (res.status === 202 || (res.ok && res.data?.joined)) return { text: startedText(res.data, kind), data: res.data };
45
- if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
46
- return failed(res);
76
+ return { text: STARTING, data: { pending: true } };
47
77
  };
48
78
  const run = () => start({}, "run");
49
- const verify = ({ findingId, jobId }) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
79
+ const verify = ({ findingId, jobId } = {}) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
80
+
81
+ // With no id: the run this machine is still starting, held here until it has an id, or else the
82
+ // latest run on the server.
83
+ const resolve = async (until) => {
84
+ const latest = await call("GET", "/mcp/status");
85
+ if (!latest.ok) return failed(latest);
86
+ const newest = latest.data.run?.jobId ?? null;
87
+ let p = pending.read();
88
+ if (!p || p.jobId || (p.after ?? null) !== newest) return newest ? { id: newest } : { text: "No run yet.\nnext: status" };
89
+ const stale = () => now() - Date.parse(p.startedAt) > READY_MS + 30_000;
90
+ while (!p.jobId && !p.error && !stale() && now() < until) {
91
+ await sleep(1000);
92
+ p = pending.read() ?? p;
93
+ }
94
+ if (p.jobId) return { id: p.jobId };
95
+ if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
96
+ if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
97
+ return { text: waitingText(p, now(), READY_MS) };
98
+ };
99
+
100
+ const runStatus = async ({ jobId } = {}) => {
101
+ const began = now();
102
+ let id = jobId && jobId !== "pending" ? String(jobId) : null;
103
+ if (!id) {
104
+ const found = await resolve(began + HOLD_S * 1000);
105
+ if (!found.id) return found;
106
+ id = found.id;
107
+ }
108
+ const hold = Math.max(0, HOLD_S - Math.ceil((now() - began) / 1000));
109
+ const res = await call("GET", `/mcp/run/${encodeURIComponent(id)}${hold ? `?wait=${hold}` : ""}`, undefined, (hold + 13) * 1000);
110
+ if (!res.ok) {
111
+ const out = failed(res);
112
+ return { ...out, text: `${out.text}\nnext: run_status ${id}` };
113
+ }
114
+ return { text: runText(res.data), data: res.data };
115
+ };
50
116
 
51
- const runStatus = async ({ jobId }) => {
52
- const res = await call("GET", `/mcp/run/${encodeURIComponent(jobId)}`);
53
- return res.ok ? { text: runText(res.data), data: res.data } : failed(res);
117
+ // Every server page of a run's findings, so the pages the agent reads are cut by size here.
118
+ const gather = async (jobId) => {
119
+ const path = (page, id) => {
120
+ const q = new URLSearchParams({ ...(id ? { jobId: id } : {}), ...(page > 1 ? { page: String(page) } : {}) }).toString();
121
+ return `/mcp/findings${q ? `?${q}` : ""}`;
122
+ };
123
+ const first = await call("GET", path(1, jobId));
124
+ if (!first.ok) return first;
125
+ const d = first.data;
126
+ for (let page = 2; page <= Math.min(d.pages ?? 1, 20); page += 1) {
127
+ const more = await call("GET", path(page, d.runId ?? jobId));
128
+ if (!more.ok) return more;
129
+ d.findings = [...(d.findings ?? []), ...(more.data.findings ?? [])];
130
+ // Every page carries the whole byLine; merged by line with the ids joined, or the first
131
+ // page's groups print twice.
132
+ for (const g of more.data.byLine ?? []) {
133
+ const held = (d.byLine ?? []).find((h) => h.path === g.path && h.line === g.line);
134
+ if (held) held.findingIds = [...new Set([...held.findingIds, ...g.findingIds])];
135
+ else d.byLine = [...(d.byLine ?? []), g];
136
+ }
137
+ }
138
+ return first;
54
139
  };
55
140
 
56
- const findings = async ({ jobId } = {}) => {
57
- const res = await call("GET", `/mcp/findings${jobId ? `?jobId=${encodeURIComponent(jobId)}` : ""}`);
58
- return res.ok ? { text: findingsText(res.data), data: res.data } : failed(res);
141
+ const findings = async ({ jobId, page } = {}) => {
142
+ const res = await gather(jobId);
143
+ return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
59
144
  };
60
145
 
61
146
  const dispute = async ({ findingId, why, question, jobId }) => {
@@ -65,9 +150,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
65
150
 
66
151
  const fieldConnect = async () => {
67
152
  const res = await call("POST", "/mcp/field/connect");
68
- if (!res.ok) return failed(res);
69
- const d = res.data;
70
- return { text: [d.connected ? "Production is connected." : "Production is not connected yet.", ...d.steps.map((s, i) => `${i + 1}. ${s}`)].join("\n"), data: d };
153
+ return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
71
154
  };
72
155
 
73
156
  const field = async ({ days } = {}) => {
@@ -75,78 +158,40 @@ export function makeVerbs({ api, token, fetchImpl = fetch, ensureRunner = async
75
158
  return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
76
159
  };
77
160
 
78
- return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field };
79
- }
80
-
81
- const pct = (k, n) => (n ? `${Math.round((100 * k) / n)}%` : "n/a");
82
- const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
83
-
84
- export function statusText(d) {
85
- const p = d.plan;
86
- const lines = [
87
- `Cortad · ${d.repository.name} · ${p.name}: ${p.runs.left} of ${plural(p.runs.allowed, "run")} left this month, ${p.trials.left} of ${p.trials.allowed} verify trials.`,
88
- `App: ${d.app.said}`,
89
- `Conversations written: ${d.cases.written}.${d.cases.ready ? " A run can start." : ""}`,
90
- ];
91
- lines.push(d.run ? `Latest ${runText(d.run)}` : "No run yet.");
92
- lines.push(d.field.connected ? "Production: connected." : `Production: not connected. field_connect says how.`);
93
- return lines.join("\n");
94
- }
95
-
96
- export function startedText(d, kind) {
97
- if (d.joined) return `A ${d.kind ?? kind} is already in flight: ${d.jobId}. Poll run_status every 30 seconds. Watch it: ${d.url}`;
98
- return `${kind === "verify" ? "Verify" : "Run"} started: ${d.jobId}. Poll run_status every 30 seconds and stay quiet unless the count moved. Watch it: ${d.url}`;
99
- }
100
-
101
- export function refusedText(d) {
102
- const plans = (d.plans ?? []).map((p) => `${p.name} ($${p.monthlyUsd}/month)`).join(" or ");
103
- if (d.refused === "plan") return `The first run was free. Another needs ${plans || "a plan"}: ${d.checkout}\nShow this link to the person in one sentence and wait for them.`;
104
- return `${d.why} More on ${plans || "a bigger plan"}: ${d.checkout}\nShow this link to the person and wait.`;
105
- }
106
-
107
- export function runText(d) {
108
- const head = `${d.kind} ${d.jobId} ${d.status}: ${d.of ? `played ${d.played} of ${d.of}.` : "starting, nothing played yet."}`;
109
- const tail = [];
110
- if (d.finished && typeof d.score === "number") tail.push(`Score ${d.score} of 100.`);
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}`);
114
- if (d.error) tail.push(`Error: ${d.error}`);
115
- if (d.verify) tail.push(verifyText(d.verify));
116
- if (!d.finished) tail.push("Poll again in 30 seconds.");
117
- return [head, ...tail, d.url].join(" ");
118
- }
119
-
120
- export function verifyText(v) {
121
- const move = v.visible;
122
- const line = move
123
- ? `${v.file}:${v.line} · held ${move.before.k} of ${move.before.n} before, ${move.after.k} of ${move.after.n} after · move ${move.point} (${move.low} to ${move.high}) · ${move.moved}${move.insideNoise ? ", inside the noise" : ""}.`
124
- : "";
125
- const held = v.holdout ? ` Held-out situations: ${v.holdout.moved}.` : " Held-out situations: no pair.";
126
- return `Verify of ${v.findingId}: ${line}${held}${v.overfit ? " OVERFIT: the visible cases moved and the held-out ones did not." : ""} ${v.said}`;
127
- }
161
+ // `status --changed`: the files Cortad cites that changed since the latest run started, with the
162
+ // findings at them. Empty when there is nothing to say, or nothing could be asked.
163
+ const changed = async () => {
164
+ const [s, f] = await Promise.all([call("GET", "/mcp/status"), gather()]);
165
+ const r = s.ok ? s.data?.run : null;
166
+ if (!r) return { text: "" };
167
+ const kept = pending.read();
168
+ const since = r.startedAt ?? (kept?.jobId === r.jobId ? kept.startedAt : null);
169
+ const lines = f.ok ? linesOf(f.data) : [];
170
+ const read = s.data.read ?? {};
171
+ const paths = [
172
+ ...lines.map((l) => l.path),
173
+ ...(read.rules?.examples ?? []).map((e) => e.path),
174
+ ...(read.machine?.misses ?? []).map((m) => m.path),
175
+ ...(Array.isArray(read.rules?.files) ? read.rules.files : []),
176
+ ].filter((p) => typeof p === "string" && p);
177
+ return { text: changedLine({ root, since, runId: r.jobId, paths, lines }) };
178
+ };
128
179
 
129
- export function findingsText(d) {
130
- if (!d.findings?.length) return [d.why ?? `Run ${d.runId}: no question held worse in one situation than this app does everywhere else.`, d.read, d.heldBack, d.url].filter(Boolean).join(" ");
131
- const rows = d.findings.map((f, i) => [
132
- `${i + 1}. ${f.id} · ${f.asks}`,
133
- ` held ${f.rate.k} of ${f.rate.n} (${pct(f.rate.k, f.rate.n)}, interval ${pct(Math.round(f.rate.lo * f.rate.n), f.rate.n)} to ${pct(Math.round(f.rate.hi * f.rate.n), f.rate.n)}) · ${f.file}:${f.line} · ${f.where}`,
134
- ...f.quotes.slice(0, 1).map((q) => ` reply ${q.reply}: "${q.quote}" (p=${q.p.toFixed(2)})`),
135
- ` replay: ${plural(f.replay.trials, "trial")} · verify ${f.id}`,
136
- ].join("\n"));
137
- return [`Run ${d.runId}. ${d.read} ${d.heldBack}`, ...rows, `Fix one finding at a time, in the file it names, then verify it. ${d.url}`].join("\n");
180
+ // `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
181
+ return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field, post, changed };
138
182
  }
139
183
 
140
- export function fieldText(d) {
141
- const t = d.totals;
142
- if (!t || !t.n) return `No production conversations read in the last ${d.days} days. ${d.url}`;
143
- const lines = [
144
- `Production, last ${d.days} days: ${plural(t.n, "conversation")}, ${t.read} read.`,
145
- `Rulings held: ${pct(t.rulingsHeld, t.rulings)} of ${t.rulings}${t.rulingsUnsure ? ` (${t.rulingsUnsure} unsure)` : ""}. Resolved ${pct(t.resolved, t.read)}, frustrated ${pct(t.frustrated, t.read)}, asked for a human ${pct(t.wantsHuman, t.read)}, unanswered ${pct(t.unanswered, t.read)}.`,
146
- ];
147
- const broke = (d.rules ?? []).filter((r) => r.broke > 0).sort((a, b) => b.broke - a.broke).slice(0, 5);
148
- if (broke.length) lines.push(`Rules broken most: ${broke.map((r) => `${r.id} (${r.broke})`).join(", ")}.`);
149
- if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conv")}, ${pct(j.rulingsHeld, j.rulings)} held`).join("; ")}.`);
150
- lines.push(d.url);
151
- return lines.join("\n");
184
+ // Only paths inside the repository are looked at; a path from the server never reaches outside it.
185
+ export function changedLine({ root, since, runId, paths, lines }) {
186
+ const t = Date.parse(since ?? "");
187
+ if (!Number.isFinite(t)) return "";
188
+ const base = realpathSync(root);
189
+ const changedFiles = [...new Set(paths)].filter((rel) => {
190
+ const abs = resolvePath(base, rel);
191
+ if (!abs.startsWith(base + sep)) return false;
192
+ try { return statSync(abs).mtimeMs > t; } catch { return false; }
193
+ });
194
+ if (!changedFiles.length) return "";
195
+ const at = (rel) => lines.filter((l) => l.path === rel).flatMap((l) => l.findingIds);
196
+ return `Changed since run ${runId}: ${changedFiles.map((rel) => (at(rel).length ? `${rel} (${at(rel).join(", ")})` : rel)).join(", ")}.`;
152
197
  }
package/lib/words.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // The small pieces every rendering shares: numbers with their units, file and line, whose side a
2
+ // stop is on, and the pages a long answer is cut into.
3
+
4
+ // Copilot cuts a result past about 10 KB; a page stays under this.
5
+ export const PAGE_CHARS = 9000;
6
+ export const SIDE = { theirs: "on the app's side", ours: "on Cortad's side" };
7
+
8
+ export const has = (v) => v !== undefined && v !== null;
9
+ export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
10
+ export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
11
+ export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
12
+ export const at = (path, line) => `${path}${has(line) ? `:${line}` : ""}`;
13
+ export const clip = (s, max) => (String(s).length > max ? `${String(s).slice(0, max - 3)}...` : String(s));
14
+
15
+ // One page of many, with the call that reads the next one: `next(2)` gives "findings with page 2".
16
+ export function pageOf(pages, page, next) {
17
+ const n = Math.min(Math.max(1, Math.floor(Number(page)) || 1), pages.length);
18
+ const tail = pages.length === 1 ? "" : n < pages.length ? `\npage ${n} of ${pages.length}, call ${next(n + 1)}` : `\npage ${n} of ${pages.length}`;
19
+ return `${pages[n - 1]}${tail}`;
20
+ }
21
+
22
+ // Lines packed into pages under PAGE_CHARS; every page after the first opens with `continued`.
23
+ export function pack(head, lines, continued, budget = PAGE_CHARS - 80) {
24
+ const pages = [];
25
+ let page = head;
26
+ let filled = false;
27
+ for (const line of lines) {
28
+ if (filled && page.length + line.length + 1 > budget) {
29
+ pages.push(page);
30
+ page = continued;
31
+ filled = false;
32
+ }
33
+ page += `\n${line}`;
34
+ filled = true;
35
+ }
36
+ return [...pages, page];
37
+ }
package/local.mjs CHANGED
@@ -23,6 +23,7 @@ import { promisify } from "node:util";
23
23
  import { COMMANDS, main as face } from "./lib/cli.mjs";
24
24
  import { clearRunner, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
25
25
  import { registerAll } from "./lib/register.mjs";
26
+ import { finished } from "./lib/text.mjs";
26
27
  import { lockHolds, makeLock } from "./lib/lock.mjs";
27
28
  import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
28
29
  import { CAPTURED, makeCapture } from "./lib/replay.mjs";
@@ -50,7 +51,13 @@ const viaToken = argv.includes("--token");
50
51
  // What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
51
52
  // this package before anything here runs, and the first thing we printed used to be after the whole
52
53
  // upload: a minute or more of a cursor sitting still, which reads as nothing happening.
53
- const step = (line) => { if (process.stdout.isTTY) process.stdout.write(`\rcortad ${line}\x1b[K`); };
54
+ // Without a terminal (a coding agent's shell) each new step is its own line, so the agent reading
55
+ // the output sees the folder being looked at and the upload counting up, not a two-minute blank.
56
+ let lastStep = "";
57
+ const step = (line) => {
58
+ if (process.stdout.isTTY) { process.stdout.write(`\rcortad ${line}\x1b[K`); return; }
59
+ if (line !== lastStep) { lastStep = line; say(line); }
60
+ };
54
61
  const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
55
62
  const stepDone = (line) => { clearStep(); say(line); };
56
63
  if (!explain && !viaToken && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain | npx cortad status | run | findings | verify <id>");
@@ -100,7 +107,7 @@ function walk(dir, depth, out, envs, total) {
100
107
  try { size = statSync(full).size; } catch { continue; }
101
108
  if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
102
109
  if (size > MAX_DATA && DATA_FILE.test(e.name) && !MANIFEST_FILE.test(e.name)) continue;
103
- if (!shareable(relative(root, full))) continue;
110
+ if (!shareable(relative(root, full), false)) continue;
104
111
  out.push(relative(root, full));
105
112
  total += size;
106
113
  }
@@ -121,12 +128,14 @@ function gitListed() {
121
128
  } catch (e) { fail(`could not list this repository's files with git: ${e.message}`); }
122
129
  }
123
130
  // One rule for every file that could leave: the upload, and a read the engine asks for later.
124
- function shareable(rel) {
131
+ function shareable(rel, askGit = true) {
125
132
  const parts = rel.split(sep);
126
133
  if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
127
- if (listed === null) return true;
128
- if (listed.has(rel)) return true;
129
- // A file made after the list was taken (an agent edit) is asked of git directly.
134
+ if (listed === null || listed.has(rel)) return true;
135
+ // Off the list git gave a moment ago: ignored, or inside a nested checkout. The walk takes git's
136
+ // word for it; asking per file was one process each, two minutes on a repository with a worktree
137
+ // inside it. A read asked later, of a file made after the list was taken (an agent edit), asks.
138
+ if (!askGit) return false;
130
139
  try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
131
140
  }
132
141
  let listed = null;
@@ -182,6 +191,15 @@ const ENV_READ = /(?:process\.env(?:\.|\[\s*['"])|os\.(?:environ\.get|getenv)\(\
182
191
  const lifted = (name) => (/(TOKEN_BUDGET|TOKENS)/.test(name) ? "1000000000" : "1000000");
183
192
  const liftable = (name) => THROUGHPUT.test(name) && !GUARDED.test(name) && !SWITCH.test(name);
184
193
  function liftedLimits(envFiles, sources = []) {
194
+ // Only a limit the code reads is raised: a line in .env that nothing reads is not a limit, and
195
+ // naming it as one raised was the tell an engineer caught first.
196
+ const read = new Set();
197
+ for (const file of sources) {
198
+ if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
199
+ let text = "";
200
+ try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
201
+ for (const m of text.matchAll(ENV_READ)) if (liftable(m[1])) read.add(m[1]);
202
+ }
185
203
  const out = {};
186
204
  for (const file of envFiles) {
187
205
  let text = "";
@@ -191,15 +209,10 @@ function liftedLimits(envFiles, sources = []) {
191
209
  if (!m) continue;
192
210
  const [, name, raw] = m;
193
211
  const value = raw.trim().replace(/^(['"])(.*)\1$/, "$2");
194
- if (liftable(name) && /^\d+$/.test(value)) out[name] = lifted(name);
212
+ if (liftable(name) && /^\d+$/.test(value) && (!sources.length || read.has(name))) out[name] = lifted(name);
195
213
  }
196
214
  }
197
- for (const file of sources) {
198
- if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
199
- let text = "";
200
- try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
201
- for (const m of text.matchAll(ENV_READ)) if (liftable(m[1]) && !(m[1] in out)) out[m[1]] = lifted(m[1]);
202
- }
215
+ for (const name of read) if (!(name in out)) out[name] = lifted(name);
203
216
  return out;
204
217
  }
205
218
 
@@ -380,7 +393,8 @@ async function verb(job) {
380
393
  keep(res);
381
394
  if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
382
395
  const buf = Buffer.from(await res.arrayBuffer());
383
- const LIMIT = 262_144;
396
+ // An OpenAPI document can run to megabytes; the caller asks for it whole.
397
+ const LIMIT = Number.isInteger(b.limit) && b.limit > 0 ? Math.min(b.limit, 8 * 1024 * 1024) : 262_144;
384
398
  // A session cookie your app sets is its business: it is dropped here, and every other header masked.
385
399
  const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
386
400
  return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
@@ -421,7 +435,10 @@ async function verb(job) {
421
435
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
422
436
  // What your app spent on its providers, from the hook inside it. An app this command did not
423
437
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
424
- case "usage": return capture?.usage() ?? {};
438
+ // Masked like every other reply: a tool's answer can carry a value from their env files.
439
+ case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
440
+ // Every route your app holds, read by the hook off the app itself, masked like the meter.
441
+ case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
425
442
  // A world is ended from this terminal, never from the cloud.
426
443
  case "destroy": return { ok: true };
427
444
  default: return { ok: true };
@@ -668,8 +685,28 @@ async function installOnce(said) {
668
685
  return true;
669
686
  }
670
687
 
688
+ // The port their start command names, moved when something else already holds it: two apps on one
689
+ // laptop both said --port 8000, and the second attached to the first's server as its own.
690
+ // Ports this program tried and lost: two connects on one machine can pick the same free port in
691
+ // the same second, and the one that binds second sees "address already in use".
692
+ const lost = new Set();
693
+ async function freePortAbove(port) {
694
+ for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
695
+ return null;
696
+ }
697
+ async function freed(cmd, lifted) {
698
+ const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
699
+ const named = m ? Number(m[2]) : Number(lifted.PORT) || 0;
700
+ if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
701
+ const port = await freePortAbove(named);
702
+ if (!port) return { cmd, lifted };
703
+ say(`port ${named} is taken on this machine, so your app starts on ${port}`);
704
+ // The same port their env file names, moved the same way.
705
+ return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
706
+ }
707
+
671
708
  async function start(waitMs) {
672
- const { cmd, lifted } = launched;
709
+ const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
673
710
  child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
674
711
  const mine = child;
675
712
  let seen = "";
@@ -689,6 +726,10 @@ async function start(waitMs) {
689
726
  // that holds the port is theirs and already running, which startApp turns into attaching to it.
690
727
  if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
691
728
  await stopApp(mine.pid);
729
+ // The port this start named was taken between the check and the bind: the next free one, at
730
+ // most three times, before the failure is theirs to read.
731
+ const named = /(?:--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd)?.[1] ?? lifted.PORT;
732
+ if (/EADDRINUSE|address already in use/i.test(seen) && named && lost.size < 3) { lost.add(Number(named)); return start(waitMs); }
692
733
  return { port: null, exited: 1, tail: tail() };
693
734
  }
694
735
  // The port is what the app's own process group listens on. Never a guess: a developer's
@@ -977,7 +1018,7 @@ if (argv.includes("--until-idle") && stored) {
977
1018
  let idleSince = Date.now();
978
1019
  setInterval(async () => {
979
1020
  const res = await call("GET", "/mcp/status", undefined, { headers: { authorization: `Bearer ${stored}` } }).catch(() => null);
980
- if (res?.ok && res.data?.run && !res.data.run.finished) idleSince = Date.now();
1021
+ if (res?.ok && res.data?.run && !finished(res.data.run)) idleSince = Date.now();
981
1022
  else if (Date.now() - idleSince > IDLE_MS) { say("no run for ten minutes, leaving"); await close(0); }
982
1023
  }, 60_000).unref();
983
1024
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.2.2",
3
+ "version": "0.3.0-rc.10",
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"
@@ -17,14 +17,19 @@
17
17
  "lib/mcp.mjs",
18
18
  "lib/mint.mjs",
19
19
  "lib/pyhook/sitecustomize.py",
20
+ "lib/read-text.mjs",
20
21
  "lib/register.mjs",
21
22
  "lib/replay.mjs",
22
23
  "lib/sample.mjs",
23
24
  "lib/service.mjs",
25
+ "lib/spec.mjs",
24
26
  "lib/start.mjs",
27
+ "lib/stick.mjs",
25
28
  "lib/switches.mjs",
29
+ "lib/text.mjs",
26
30
  "lib/trace.cjs",
27
31
  "lib/verbs.mjs",
32
+ "lib/words.mjs",
28
33
  "local.mjs",
29
34
  "skill/SKILL.md",
30
35
  "skill/references/results.md"