cortad 0.3.0 → 0.3.1-rc.1

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/data.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // Your app's own database, kept out of the run's way. A run creates people, conversations and
2
+ // records by the hundred; written into the file your app keeps its real data in, they stay there
3
+ // after the run. So an app started here runs against a copy of its database file, made in this
4
+ // command's own temp folder and pointed at through the same variable, and the original is never
5
+ // opened. What cannot be copied (a database server, a file your code names with no variable in
6
+ // front of it, an app that was already running) is said before the first run, in one sentence.
7
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
+
10
+ const FILE_DB = /\.(?:db|sqlite3?|duckdb)$/i;
11
+ // sqlite:///x.db (SQLAlchemy, relative), sqlite:////abs/x.db, sqlite+aiosqlite:///x.db, file:./dev.db (Prisma, libsql).
12
+ const FILE_URL = /^(sqlite(?:\+\w+)?:\/\/\/|file:(?:\/\/(?=\/))?)([^?]*)(\?.*)?$/i;
13
+ const SERVER_URL = /^(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|mssql|sqlserver|cockroachdb)(?:\+\w+)?:\/\//i;
14
+ const ENGINE = { postgres: "Postgres", postgresql: "Postgres", cockroachdb: "CockroachDB", mysql: "MySQL", mariadb: "MariaDB", mongodb: "MongoDB", "mongodb+srv": "MongoDB", mssql: "SQL Server", sqlserver: "SQL Server" };
15
+
16
+ // What one value names: a database file (with how to write the value again for another path), a
17
+ // database server, or nothing.
18
+ export function databaseOf(value) {
19
+ const v = String(value ?? "").trim();
20
+ if (!v) return null;
21
+ const server = SERVER_URL.exec(v);
22
+ if (server) return { server: ENGINE[server[1].toLowerCase()] ?? server[1] };
23
+ const url = FILE_URL.exec(v);
24
+ if (url) {
25
+ const path = decodeURIComponent(url[2]);
26
+ if (!path || path === ":memory:" || path.startsWith(":memory")) return null;
27
+ return { path, write: (to) => `${url[1]}${to}${url[3] ?? ""}`, prisma: /^file:/i.test(url[1]) };
28
+ }
29
+ if (!v.includes("://") && FILE_DB.test(v)) return { path: v, write: (to) => to };
30
+ return null;
31
+ }
32
+
33
+ // A folder the app keeps its data in, named by a variable (DATA_DIR=./data): a database file sits
34
+ // directly inside it. The whole folder is the database then, uploads and backups beside it.
35
+ const FOLDER_NAME = /DATA|DB|DATABASE|STORAGE|STORE|PERSIST|SQLITE|DUCK/i;
36
+ function dataFolder(name, value, dirs, appDir, root) {
37
+ const v = String(value ?? "").trim();
38
+ if (!FOLDER_NAME.test(name) || !v || v.includes("://") || FILE_DB.test(v) || !/^[.~/\w]/.test(v)) return null;
39
+ for (const place of [appDir, ...dirs].map((d) => resolve(d, v))) {
40
+ if (place === root || place === appDir || !place.startsWith(root + "/")) continue;
41
+ let names;
42
+ try { if (!statSync(place).isDirectory()) continue; names = readdirSync(place); } catch { continue; }
43
+ if (names.some((n) => FILE_DB.test(n))) return place;
44
+ }
45
+ return null;
46
+ }
47
+ const sizeOf = (dir, cap) => {
48
+ let total = 0;
49
+ const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (total > cap) return; const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) total += statSync(p).size; } };
50
+ try { walk(dir); } catch { /* counted what it could */ }
51
+ return total;
52
+ };
53
+
54
+ // A name the code reads with a default written beside it: os.getenv("DATABASE_URL", "sqlite:///./app.db"),
55
+ // process.env.DB_PATH ?? "./data.db". With nothing in the environment, the default is the database.
56
+ const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w*)['"]\s*,\s*['"]([^'"\n]+)['"]|process\.env(?:\.([A-Za-z_]\w*)|\[\s*['"]([A-Za-z_]\w*)['"]\s*\])\s*(?:\?\?|\|\|)\s*['"]([^'"\n]+)['"]/g;
57
+ // A database file opened by a path written in the code, with no variable in front of it.
58
+ const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
59
+ const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
60
+ // A test opens its own scratch file; that is not where the app keeps its data.
61
+ const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
62
+ // A folder bigger than this is not copied: said instead, so the person decides.
63
+ const FOLDER_CAP = 1024 ** 3;
64
+
65
+ function scan(sources) {
66
+ const defaults = {};
67
+ const opened = [];
68
+ for (const file of sources) {
69
+ if (!CODE.test(file) || TEST.test(file)) continue;
70
+ let text = "";
71
+ try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
72
+ for (const m of text.matchAll(DEFAULTED)) {
73
+ const name = m[1] ?? m[3] ?? m[4];
74
+ const value = m[2] ?? m[5];
75
+ if (name && !(name in defaults) && databaseOf(value)) defaults[name] = value;
76
+ }
77
+ for (const m of text.matchAll(OPENED)) { const db = databaseOf(m[1]); if (db?.path) opened.push({ file, path: db.path }); }
78
+ }
79
+ return { defaults, opened };
80
+ }
81
+
82
+ // Where a relative path lands. The app's own folder first, which is where it runs; then the folder
83
+ // of the env file that named it, which some settings loaders resolve against; for Prisma, the folder
84
+ // its schema lives in. The first place the file exists is the one; none, and it is the app's folder.
85
+ function located(db, dirs, appDir) {
86
+ if (isAbsolute(db.path)) return db.path;
87
+ const prisma = db.prisma ? [join(appDir, "prisma")] : [];
88
+ const places = [...prisma, appDir, ...dirs].map((d) => resolve(d, db.path));
89
+ return places.find((p) => existsSync(p)) ?? places[0];
90
+ }
91
+
92
+ // values: the environment the app will start with, names to values. from: the env file each name
93
+ // was read from, when there is one. Returns the variables to start the app with, and what to say.
94
+ export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
95
+ const { defaults, opened } = scan(sources);
96
+ const all = { ...defaults, ...values };
97
+ const env = {};
98
+ const copied = [];
99
+ const said = [];
100
+ const moved = new Set();
101
+ const rel = (p) => relative(root, p) || basename(p);
102
+ for (const [name, value] of Object.entries(all)) {
103
+ const db = databaseOf(value);
104
+ const folder = db ? null : dataFolder(name, value, from[name] ? [dirname(from[name])] : [], appDir, root);
105
+ if (folder) {
106
+ moved.add(folder);
107
+ if (!started) { said.push(`Your app was already running when this command started, so it writes to its own data folder, ${rel(folder)}/: trials write into it. Stop your app and run the command again to run against a copy.`); continue; }
108
+ if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { said.push(`Your data folder ${rel(folder)}/ (${name}) is over 1 GB, so it is not copied: trials write into it.`); continue; }
109
+ const to = join(work, "data", String(copied.length), basename(folder));
110
+ cpSync(folder, to, { recursive: true });
111
+ env[name] = to;
112
+ copied.push({ name, from: folder, to });
113
+ said.push(`Your app runs against a copy of its data folder ${rel(folder)}/, through ${name}; the original is not touched.`);
114
+ continue;
115
+ }
116
+ if (!db) continue;
117
+ if (db.server) {
118
+ said.push(`Trials write into the ${db.server} database ${name} names: a database server cannot be copied here, so what a run creates stays in it. Point ${name} at a scratch database before a run if that one holds real data.`);
119
+ continue;
120
+ }
121
+ const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
122
+ moved.add(original);
123
+ if (!started) {
124
+ said.push(`Your app was already running when this command started, so it writes to its own database, ${rel(original)}: trials write into it. Stop your app and run the command again to run against a copy.`);
125
+ continue;
126
+ }
127
+ const to = join(work, "data", String(copied.length), basename(original));
128
+ mkdirSync(dirname(to), { recursive: true });
129
+ // The file and the journal beside it: SQLite keeps recent writes in -wal until a checkpoint.
130
+ if (existsSync(original)) for (const tail of ["", "-wal", "-shm", "-journal"]) if (existsSync(original + tail)) copyFileSync(original + tail, to + tail);
131
+ env[name] = db.write(to);
132
+ copied.push({ name, from: original, to });
133
+ said.push(`Your app runs against a copy of ${rel(original)}, through ${name}; the original is not touched.`);
134
+ }
135
+ for (const o of opened) {
136
+ const path = resolve(dirname(o.file), o.path);
137
+ const cwdPath = resolve(appDir, o.path);
138
+ if ([path, cwdPath].some((p) => moved.has(p) || [...moved].some((m) => p.startsWith(m + "/")))) continue;
139
+ moved.add(cwdPath);
140
+ said.push(`Your app opens ${o.path} by a path written in ${rel(o.file)}, with no variable to point it at a copy: trials write into that file.`);
141
+ }
142
+ return { env, copied, said };
143
+ }
package/lib/read-text.mjs CHANGED
@@ -12,10 +12,16 @@ const rulesHead = (rules) => {
12
12
  const files = Array.isArray(rules.files) ? rules.files.length : rules.files;
13
13
  return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
14
14
  };
15
- const ruleLine = (e) => ` ${at(e.path, e.line)} "${clip(e.text, 300)}"`;
15
+ // What the last run found about the rule beside it; a rule it never asked is not a rule that held.
16
+ const measuredOf = (m) => (m === null ? "; not measured yet" : m ? `; asked of ${plural(m.asked, "reply", "replies")}, held in ${num(m.held)}${m.asked > m.held ? `, broke in ${num(m.asked - m.held)}` : ""}` : "");
17
+ const ruleLine = (e) => ` ${e.path ? `${at(e.path, e.line)} ` : ""}"${clip(e.text, 300)}"${"measured" in e ? measuredOf(e.measured) : ""}`;
16
18
  const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
17
- const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed`]);
18
- const standardLine = (x) => ` ${x.path ? `${at(x.path, x.line)} ` : ""}${x.standard}${x.detail ? `: ${clip(x.detail, 240)}` : ""}${x.decidedBy ? ` (decided by ${x.decidedBy === "code" ? "code" : "a model"})` : ""}`;
19
+ const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed${has(m.problems) ? `, from ${plural(m.problems, "problem")}` : ""}`]);
20
+ const decided = (x) => (x.decidedBy ? ` (decided by ${x.decidedBy === "code" ? "code" : "a model"})` : "");
21
+ // A miss is one problem with the standards it fails beneath it; an older API sent one standard a row.
22
+ const standardLine = (x) => Array.isArray(x.standards)
23
+ ? ` ${x.path ? `${at(x.path, x.line)} ` : ""}${clip(x.problem, 240)}${decided(x)}\n ${x.standards.length === 1 ? "Fails" : `Fails ${num(x.standards.length)}`}: ${x.standards.join("; ")}`
24
+ : ` ${x.path ? `${at(x.path, x.line)} ` : ""}${x.standard}${x.detail ? `: ${clip(x.detail, 240)}` : ""}${decided(x)}`;
19
25
  // Which trials can be played is decided when a run knocks: a read that said "21 playable, none held
20
26
  // back" was followed by a run that held 28 back, and three agents called the counts contradictory.
21
27
  const trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`]);
@@ -41,8 +47,10 @@ export function readLines(r) {
41
47
  const m = r.machine;
42
48
  if (m) {
43
49
  const misses = m.misses ?? [];
44
- lines.push(standardsHead(m), ...misses.slice(0, 12).map(standardLine));
45
- if (misses.length > 12) lines.push(` and ${misses.length - 12} more.`);
50
+ // "3 missed" was read off a list of three under a head that said thirteen: the list says it is a part.
51
+ lines.push(standardsHead(m), ...(misses.length > 3 && has(m.missed) && m.missed > 3 ? [` 3 of the ${num(m.missed)} missed:`] : []), ...misses.slice(0, 3).map(standardLine));
52
+ const more = (m.problems ?? misses.length) - Math.min(misses.length, 3);
53
+ if (more > 0) lines.push(` and ${plural(more, "more problem")}; status with show standards lists them all.`);
46
54
  }
47
55
  const q = r.questions;
48
56
  if (q) lines.push(`Questions: ${num(q.total)}${has(q.everywhere) ? `; ${num(q.everywhere)} asked in every conversation` : ""}${has(q.placed) ? `, ${num(q.placed)} placed in the situations they fit` : ""}.`);
@@ -70,7 +78,7 @@ function sectionOf(r, show) {
70
78
  const mets = m.mets ?? [];
71
79
  return {
72
80
  head: standardsHead(m),
73
- lines: [misses.length && counted("Missed", m.missed, misses.length), ...misses.map(standardLine), mets.length && counted("Met", m.met, mets.length), ...mets.map(standardLine)].filter(Boolean),
81
+ lines: [misses.length && (has(m.problems) ? counted("Problems", m.problems, misses.length) : counted("Missed", m.missed, misses.length)), ...misses.map(standardLine), mets.length && counted("Met", m.met, mets.length), ...mets.map(standardLine)].filter(Boolean),
74
82
  continued: "Engineering standards, continued.",
75
83
  };
76
84
  }
package/lib/text.mjs CHANGED
@@ -22,10 +22,14 @@ export function statusText(d) {
22
22
  const plan = planLine(d.plan);
23
23
  if (plan) lines.push(plan);
24
24
  if (d.app?.said) lines.push(`App: ${d.app.said}`);
25
+ for (const s of d.app?.data ?? []) lines.push(`Data: ${s}`);
25
26
  if (d.read?.complete === false) lines.push("The read of your repository is still running; the lists below fill in as it finishes.");
26
27
  if (d.read) lines.push(...readLines(d.read));
27
28
  if (d.run === null) lines.push("No run yet.");
28
29
  else if (d.run) lines.push(...runLines(d.run, "latest "));
30
+ // Trials the read wrote after this run chose its own: the one count that is not the run's.
31
+ const since = (d.read?.trials?.written ?? 0) - (d.run?.trials?.written ?? Infinity);
32
+ if (since > 0 && d.run?.kind !== "verify") lines.push(`${plural(since, "more trial")} written since this run; the next run chooses from them too.`);
29
33
  if (d.field) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
30
34
  if (d.links?.lab && (d.read || d.run)) lines.push(`For the person: ${d.links.lab} shows this in the browser.`);
31
35
  if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
@@ -57,7 +61,7 @@ function scoreLine(d) {
57
61
  }
58
62
  // A row the run held back names a door and why, or, when no door could take it, only the reason.
59
63
  const heldBackLine = (h) => h.why === "not testable here"
60
- ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200)}.`
64
+ ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
61
65
  : `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
62
66
  const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
63
67
  const LAYER_WORDS = {
@@ -84,16 +88,33 @@ function runLines(d, prefix = "") {
84
88
  if (done && d.halted) lines.push(haltLine(d.halted));
85
89
  if (done && has(d.readingsHeld) && has(d.readings) && d.readings > 0) lines.push(`The question readings held in ${num(d.readingsHeld)} of ${num(d.readings)}, ${Math.round((100 * d.readingsHeld) / d.readings)}%; the score is the case checks' pass rate on the checks measured.`);
86
90
  if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
87
- for (const h of (d.heldBack ?? []).filter((h) => h.trials > 0).slice(0, 6)) lines.push(heldBackLine(h));
91
+ const trials = trialsLine(d);
92
+ if (trials) lines.push(trials);
93
+ // Every row, or six and the rest in one line, so the rows always sum to the held-back count.
94
+ const heldRows = (d.heldBack ?? []).filter((h) => h.trials > 0);
95
+ for (const h of heldRows.slice(0, 6)) lines.push(heldBackLine(h));
96
+ if (heldRows.length > 6) lines.push(`Held back: ${plural(heldRows.slice(6).reduce((n, h) => n + h.trials, 0), "more trial")} for ${plural(heldRows.length - 6, "other reason")}.`);
88
97
  const reads = readingsLine(d);
89
98
  if (reads) lines.push(reads);
90
99
  if (d.stopped) lines.push(stoppedLine(d));
91
100
  for (const f of d.faults ?? []) lines.push(`Fault${SIDE[f.side] ? ` ${SIDE[f.side]}` : ""}: ${f.what}${f.fix ? ` ${f.fix}` : ""}`);
101
+ for (const s of d.data ?? []) lines.push(`Data: ${s}`);
92
102
  if (d.error) lines.push(`Error: ${d.error}`);
93
103
  if (d.verify) lines.push(...verifyLines(d.verify));
94
104
  return lines;
95
105
  }
96
106
 
107
+ // The run's trials in one sentence that adds up: written = chosen + held back + not planned, and
108
+ // chosen = played + not played. Three counts from three places never met before.
109
+ function trialsLine(d) {
110
+ const t = d.trials;
111
+ // A verify replays the trials it names, so it has no written count of its own to add up.
112
+ if (!t || !has(t.written) || d.kind === "verify") return null;
113
+ const parts = [`${num(t.chosen)} chosen for this run`, t.heldBack && `${num(t.heldBack)} held back`, t.notPlanned && `${num(t.notPlanned)} not planned`].filter(Boolean);
114
+ const rest = t.notPlayed ? `; ${num(t.played)} played, ${num(t.notPlayed)} ${finished(d) ? "not played" : "still to play"}` : "";
115
+ return `Trials: ${num(t.written)} written: ${parts.join(", ")}${rest}.`;
116
+ }
117
+
97
118
  // A reading is one question checked against one reply.
98
119
  function readingsLine(d) {
99
120
  if (!has(d.readings) && !has(d.questionsAsked)) return null;
@@ -180,7 +201,7 @@ function ledgerLines(l) {
180
201
  // Findings grouped by the line they live at: the server's byLine, or the findings' own file and
181
202
  // line when it sends none. The same line from two server pages is one group.
182
203
  export function linesOf(d) {
183
- const rows = d.byLine ?? (d.findings ?? []).filter((f) => f.file).map((f) => ({ path: f.file, line: f.line, findingIds: [f.id] }));
204
+ const rows = (d.byLine ?? (d.findings ?? []).map((f) => ({ path: f.file, line: f.line, findingIds: [f.id] }))).filter((r) => r.path);
184
205
  const merged = new Map();
185
206
  for (const r of rows) {
186
207
  const key = at(r.path, r.line);
@@ -246,6 +267,13 @@ function findingBlock(f, placed) {
246
267
  if (f.door) lines.push(` Endpoint: ${f.door}`);
247
268
  else if (f.doors?.length) lines.push(` Endpoints: ${f.doors.join(", ")}`);
248
269
  if (f.where) lines.push(` Situation: ${f.where}`);
270
+ if (f.fix) lines.push(` Fix: ${f.fix}`);
271
+ // One cause, one finding: the same sentence broke these too, and they are not findings of their own.
272
+ if (f.alsoIn?.length) {
273
+ lines.push(` Same cause in ${plural(f.alsoIn.length, "more place")}, the same reply sentence in each:`);
274
+ for (const a of f.alsoIn.slice(0, 8)) lines.push(` ${a.where}: ${clip(a.asks, 160)}, broke in ${num(a.rate.n - a.rate.k)} of ${plural(a.rate.n, "reply", "replies")}`);
275
+ if (f.alsoIn.length > 8) lines.push(` and ${num(f.alsoIn.length - 8)} more.`);
276
+ }
249
277
  if (f.layer && LAYER_WORDS[f.layer]) lines.push(` Layer: ${f.layer}, ${LAYER_WORDS[f.layer]}.`);
250
278
  const r = f.rate;
251
279
  // The break count, whichever way the question is phrased: "held in 21 of 33" under a question
@@ -254,12 +282,24 @@ function findingBlock(f, placed) {
254
282
  if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
255
283
  const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
256
284
  if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
285
+ const logsShown = new Set();
286
+ let logRoom = 30;
257
287
  for (const q of (f.quotes ?? []).slice(0, 3)) {
258
288
  const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`, q.door && `at ${q.door}`].filter(Boolean).join(", ");
259
289
  // The request beside the reply: an agent handed "HTTP 502 happened" did the rest of the
260
290
  // investigation itself.
261
291
  if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
262
292
  lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
293
+ // What a developer reproduces it from: the request as sent, the body back, the app's own log.
294
+ if (q.request) lines.push(` Request: ${q.request.method} ${q.request.path}${q.request.body ? ` ${clip(q.request.body, 600)}` : ""}`);
295
+ if (has(q.response)) lines.push(` Response: ${q.response ? clip(q.response, 600) : "(empty body)"}`);
296
+ // A page stays under what a client keeps: the same log twice is said once, and a finding
297
+ // prints thirty log lines at most.
298
+ const log = (q.log ?? []).join("\n");
299
+ if (log && logsShown.has(log)) lines.push(" Log: the same lines as the reply above.");
300
+ else for (const line of (q.log ?? []).slice(0, logRoom)) { lines.push(` Log: ${clip(line, 240)}`); logRoom -= 1; }
301
+ if (log) logsShown.add(log);
302
+ if (q.at && q.at !== at(f.file, f.line)) lines.push(` At ${q.at}`);
263
303
  }
264
304
  for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
265
305
  if (f.replay) lines.push(` Replay: ${plural(f.replay.trials, "trial")}, verify ${f.id}`);
package/lib/words.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, sep } from "node:path";
3
+
1
4
  // The small pieces every rendering shares: numbers with their units, file and line, whose side a
2
5
  // stop is on, and the pages a long answer is cut into.
3
6
 
@@ -9,7 +12,21 @@ export const has = (v) => v !== undefined && v !== null;
9
12
  export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
10
13
  export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
11
14
  export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
12
- export const at = (path, line) => `${path}${has(line) ? `:${line}` : ""}`;
15
+ // A line past the end of the file as it stands on this machine is not an address: the file moved
16
+ // on since it was read, and an agent was sent to line 204 of a 172-line file. The file stays.
17
+ // Read each time, never kept: the agent edits these files while this server runs. Only a path
18
+ // inside the project is opened, and only its length is used.
19
+ const linesIn = (path) => {
20
+ const root = process.cwd();
21
+ const file = resolve(root, path);
22
+ if (file !== root && !file.startsWith(`${root}${sep}`)) return null;
23
+ try { return readFileSync(file, "utf8").split("\n").length; } catch { return null; }
24
+ };
25
+ export const at = (path, line) => {
26
+ const n = has(line) && line > 0 && path ? linesIn(path) : null;
27
+ // A line of 0 is no line: "finding at :0" read to an agent as an address that pointed nowhere.
28
+ return `${path}${has(line) && line > 0 && !(n !== null && line > n) ? `:${line}` : ""}`;
29
+ };
13
30
  export const clip = (s, max) => (String(s).length > max ? `${String(s).slice(0, max - 3)}...` : String(s));
14
31
 
15
32
  // One page of many, with the call that reads the next one: `next(2)` gives "findings with page 2".
package/local.mjs CHANGED
@@ -32,6 +32,7 @@ import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
32
32
  import { listingUrl } from "./lib/listing.mjs";
33
33
  import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
34
34
  import { openSwitches } from "./lib/switches.mjs";
35
+ import { keepData } from "./lib/data.mjs";
35
36
 
36
37
  const argv = process.argv.slice(2);
37
38
  // The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
@@ -427,6 +428,18 @@ async function verb(job) {
427
428
  mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
428
429
  originFor: (port) => originFor(port, envOrigins(envFiles)),
429
430
  })))) : { identities: [] };
431
+ // The last of what your app printed, for a reply it failed: the traceback a developer reads.
432
+ // Masked like everything else, and the folder named so its paths can be read as your files.
433
+ case "log": {
434
+ const want = Math.min(Math.max(Number(b.bytes) || 16_000, 1), 200_000);
435
+ try {
436
+ const all = readFileSync(bootLog);
437
+ const text = mask(all.subarray(Math.max(0, all.length - want)).toString("utf8").replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
438
+ // A JVM stack names a class, not a path: the source files it could be are sent with it.
439
+ const jvm = /\bat\s+[\w$.]+\([\w$]+\.(?:java|kt|scala|groovy):\d+\)/.test(text);
440
+ return { text, root, size: all.length, ...(jvm ? { files: files.filter((f) => /\.(?:java|kt|scala|groovy)$/.test(f)).slice(0, 20_000) } : {}) };
441
+ } catch { return { text: "", root, size: 0 }; }
442
+ }
430
443
  case "restart": return restartApp();
431
444
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
432
445
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
@@ -575,7 +588,7 @@ async function startApp() {
575
588
  const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
576
589
  for (const port of new Set(ports)) {
577
590
  if (await answers(port)) {
578
- launched = null; child = null;
591
+ launched = null; child = null; data = null;
579
592
  const took = await takeOver(port);
580
593
  if (took) return took;
581
594
  say(`your app is already running on port ${port}, so that one is used; its request limits stay as they are`);
@@ -728,9 +741,27 @@ async function freed(cmd, lifted) {
728
741
  return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
729
742
  }
730
743
 
744
+ // Your app's database, read once per session: a copy made before the first start, and every
745
+ // restart after it runs against the same copy, so a crash mid-run does not lose what the run made.
746
+ // Told in the terminal here and to the run with the app (announce), so status and the report say it.
747
+ let data = null;
748
+ function dataOnce(started) {
749
+ if (data) return data;
750
+ const values = { ...envExports(envFiles), ...process.env };
751
+ const from = {};
752
+ for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
753
+ let text = "";
754
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
755
+ for (const line of text.split("\n")) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); if (m && !(m[1] in process.env)) from[m[1]] = file; }
756
+ }
757
+ data = keepData({ values, from, appDir, root, work, sources: files.map((f) => join(root, f)), started });
758
+ for (const line of data.said) say(line.charAt(0).toLowerCase() + line.slice(1));
759
+ return data;
760
+ }
761
+
731
762
  async function start(waitMs) {
732
763
  const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
733
- child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...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 });
764
+ child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...process.env, ...lifted, ...dataOnce(true).env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
734
765
  const mine = child;
735
766
  let seen = "";
736
767
  const onData = (d) => { const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s); };
@@ -847,6 +878,7 @@ if (explain) {
847
878
  `would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (for this session only)`}`,
848
879
  `loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
849
880
  `your files never written by this program; your own coding agent edits them`,
881
+ `your database a database file your env or code names is copied to this program's temp folder and your app is started on the copy; a database server is named, not copied`,
850
882
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
851
883
  `for your agent an MCP entry and a skill in each coding agent's own home folder (Claude Code, Codex, Cursor), and a key in ~/.cortad for later runs`,
852
884
  ``,
@@ -963,7 +995,7 @@ function sourceChanged() {
963
995
  }
964
996
  // `watching` says whether a message sent to this app can be seen arriving: only an app this command
965
997
  // started carries the hook, and only a runtime the hook exists for.
966
- const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
998
+ const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: dataOnce(Boolean(launched)).said.slice(0, 8), watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
967
999
 
968
1000
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
969
1001
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
@@ -1010,7 +1042,10 @@ async function appLife() {
1010
1042
  const busy = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
1011
1043
  if (busy || downFor < (crashed ? 4_000 : 10_000)) continue;
1012
1044
  // Down for real. The screen is told, so it never shows an app that is not there.
1013
- if (!toldDown) toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000) }).catch(() => null))?.ok);
1045
+ // `exited`: the process this command started is gone or its watcher said it crashed, not a process
1046
+ // running and not answering. The run tells the two apart: one is their crash, the other may be our
1047
+ // load. An app it only attached to is not its to watch, so nothing is said either way.
1048
+ if (!toldDown) toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000), ...(launched ? { exited: Boolean(appGone || crashed) } : {}) }).catch(() => null))?.ok);
1014
1049
  // An app that went quiet or exited did not stop because of a save (it was killed, it ran out
1015
1050
  // of memory, it crashed on a request), so waiting for a save would wait forever. It is started
1016
1051
  // here, whoever started it first: an app this command only attached to (yours, from another
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-rc.1",
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"
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "README.md",
11
11
  "lib/cli.mjs",
12
+ "lib/data.mjs",
12
13
  "lib/home.mjs",
13
14
  "lib/identities.mjs",
14
15
  "lib/keys.mjs",
@@ -129,6 +129,8 @@ For the person: the report is at https://cortad.com/lab
129
129
  next: findings
130
130
  ```
131
131
 
132
+ Your app stopped during the run and was started again: that is a finding of its own, `crash:1`, with the error your app printed and the turn it happened at. Fix the error it quotes. It has no trials to replay, so `verify` refuses it; the next run that finishes without it is the proof. A `Data:` line says whether the run wrote to a copy of your app's database or into the real one.
133
+
132
134
  A verify that holds: both intervals are above zero and the held-out trials moved with the visible ones.
133
135
 
134
136
  ```