cortad 0.3.0-rc.9 → 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
+ }
@@ -26,9 +26,18 @@ const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|
26
26
  // before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
27
27
  const WHERE = /^[\w./:@()[\] -]{1,300}$/;
28
28
  const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
29
- const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase"]);
29
+ const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic"]);
30
30
  const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
31
31
 
32
+ // A credential the app's own environment names for its own account: a user-side name and a
33
+ // password-side name that share a prefix. The prefix is the name with its trailing token removed,
34
+ // so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
35
+ const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
36
+ const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
37
+ const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
38
+ const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
39
+ const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
40
+
32
41
  const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
33
42
  export const identityHeaderFile = (role) => (ROLE.test(role) ? HEADER_FILE.replace("%s.%s", `${slug(role)}.header`) : null);
34
43
  // The curl argument a probe inside the box adds to speak as a role: the header line is read there,
@@ -450,6 +459,18 @@ def sealed(recipe, env):
450
459
  return {"status": "sealed", "header": recipe["header"], "line": "%s: %s" % (recipe["header"], bearer(recipe["header"], v)), "note": " or ".join(recipe["env"])}
451
460
 
452
461
 
462
+ # HTTP Basic from the username and password the app's own environment names: env[0] is the user,
463
+ # env[1] the password. The line carries the scheme verbatim, so it is not run through bearer().
464
+ def basic(recipe, env):
465
+ names = recipe.get("env", [])
466
+ user = env.get(names[0]) if len(names) > 0 else None
467
+ pw = env.get(names[1]) if len(names) > 1 else None
468
+ if not user or not pw:
469
+ return {"status": "absent", "note": " and ".join(names)}
470
+ raw = base64.b64encode(("%s:%s" % (user, pw)).encode("utf-8")).decode("ascii")
471
+ return {"status": "minted", "header": recipe["header"], "line": "%s: Basic %s" % (recipe["header"], raw), "expires": int(time.time()) + TTL, "note": " and ".join(names)}
472
+
473
+
453
474
  def keep(path, line):
454
475
  fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
455
476
  with os.fdopen(fd, "w", encoding="utf-8") as f:
@@ -509,10 +530,15 @@ def main():
509
530
  got = login(recipe, role, role_value, env)
510
531
  elif recipe["kind"] == "firebase":
511
532
  got = firebase(recipe, role, env)
533
+ elif recipe["kind"] == "basic":
534
+ got = basic(recipe, env)
512
535
  else:
513
536
  got = {"status": "upstream", "note": " or ".join(recipe["env"]) or recipe["role"]}
514
537
  except Exception as err:
515
538
  got = {"status": "unreachable", "note": type(err).__name__}
539
+ # A sign-in the environment named its own credential for records which names it used.
540
+ if recipe.get("envCred") and got.get("status") == "minted" and not got.get("note"):
541
+ got["note"] = " and ".join(recipe.get("env", []))
516
542
  line = got.pop("line", None)
517
543
  if line:
518
544
  keep(FILE % (slug(role), "header" if got["status"] == "minted" else "sealed"), line)
@@ -551,12 +577,96 @@ export function parseIdentityReport(text) {
551
577
  return { identities };
552
578
  }
553
579
 
580
+ // The credential pairs an environment names, by shared prefix. Names only, never values.
581
+ export function credentialPairs(names) {
582
+ const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === "string" && ENV_NAME.test(n));
583
+ const users = list.filter((n) => USER_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(USER_TOKEN, "") }));
584
+ const secrets = list.filter((n) => SECRET_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(SECRET_TOKEN, "") }));
585
+ const pairs = [];
586
+ const seen = new Set();
587
+ for (const u of users) {
588
+ const s = secrets.find((x) => x.prefix === u.prefix);
589
+ if (s && !seen.has(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
590
+ }
591
+ return pairs;
592
+ }
593
+
594
+ const routeRole = (route) => {
595
+ const seg = route.split("/").filter((s) => s && !/^(?:api|v\d+|auth)$/i.test(s)).pop() ?? "user";
596
+ const r = seg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 20);
597
+ return /^[a-z]/.test(r) ? r : "user";
598
+ };
599
+ const uniqueRole = (base, used) => {
600
+ const stem = /^[a-z]/.test(base) ? base : "account";
601
+ let role = stem;
602
+ for (let n = 2; used.has(role); n += 1) role = `${stem}-${n}`;
603
+ used.add(role);
604
+ return role;
605
+ };
606
+
607
+ // When the app's own environment names a credential pair, the box can sign in with it. Each login
608
+ // recipe whose sign-in form takes a username or email and a password is filled from the pair
609
+ // instead of a generated account; each discovered sign-in route with no recipe becomes one; and an
610
+ // HTTP Basic identity is minted for a door that answers 401 with a Basic challenge. Values are
611
+ // opened by the mint program on the machine, never here: this only rewrites which env name a field
612
+ // reads. A register step (one of several, not a sign-in) keeps its fresh account.
613
+ export function envCredentialRecipes(recipes, names, loginRoutes = []) {
614
+ const list = Array.isArray(recipes) ? recipes : [];
615
+ const pairs = credentialPairs(names);
616
+ if (!pairs.length) return list;
617
+ const pair = pairs[0];
618
+ const userSrc = `env:${pair.user}`;
619
+ const passSrc = `env:${pair.secret}`;
620
+ const used = new Set(list.map((r) => r?.role).filter(Boolean));
621
+ const covered = new Set();
622
+ const out = list.map((r) => {
623
+ if (r?.kind !== "login" || !Array.isArray(r.steps)) return r;
624
+ let touched = false;
625
+ const steps = r.steps.map((s) => {
626
+ if (r.steps.length !== 1 && !SIGN_IN_ROUTE.test(s.route)) return s;
627
+ const body = { ...(s.body ?? {}) };
628
+ let hit = false;
629
+ for (const [f, v] of Object.entries(body)) {
630
+ if (typeof v !== "string" || !v.startsWith("gen:")) continue;
631
+ if (CRED_USER_FIELD.test(f)) { body[f] = userSrc; hit = true; }
632
+ else if (CRED_PASS_FIELD.test(f)) { body[f] = passSrc; hit = true; }
633
+ }
634
+ if (!hit) return s;
635
+ covered.add(s.route);
636
+ touched = true;
637
+ return { ...s, body };
638
+ });
639
+ return touched ? { ...r, steps, env: [...new Set([...(r.env ?? []), pair.user, pair.secret])], envCred: true } : r;
640
+ });
641
+ const known = new Set(out.flatMap((r) => (r?.steps ?? []).map((s) => s.route)));
642
+ const userField = /_EMAIL$/.test(pair.user) ? "email" : "username";
643
+ for (const route of (Array.isArray(loginRoutes) ? loginRoutes : []).slice(0, 6)) {
644
+ if (typeof route !== "string" || !route.startsWith("/") || known.has(route) || covered.has(route)) continue;
645
+ out.push({
646
+ kind: "login", role: uniqueRole(routeRole(route), used), header: "authorization", env: [pair.user, pair.secret],
647
+ steps: [{ route, body: { [userField]: userSrc, password: passSrc } }], opens: [], envCred: true,
648
+ where: `your environment (${pair.user} and ${pair.secret})`,
649
+ });
650
+ }
651
+ // A Basic identity for each pair as well: a door that wants a sign-in form ignores it, and a door
652
+ // behind HTTP Basic (which declares no route to fill) is reached by nothing else.
653
+ for (const p of pairs) {
654
+ out.push({
655
+ kind: "basic", role: uniqueRole(slug(p.prefix.toLowerCase()) || "account", used), header: "authorization",
656
+ env: [p.user, p.secret], opens: [], envCred: true, where: `your environment (${p.user} and ${p.secret})`,
657
+ });
658
+ }
659
+ return out;
660
+ }
661
+
554
662
  // What each row means to the person whose app this is.
555
663
  export function identitySentence(row) {
556
664
  switch (row.status) {
557
665
  case "minted": return row.kind === "firebase"
558
666
  ? `signed in as ${row.role} through the Firebase emulator standing in for your project; the token stays inside the sandbox`
559
- : `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
667
+ : row.kind === "basic"
668
+ ? `signed in as ${row.role} with the username and password your environment sets (${row.note ?? "two variables"}); the header stays inside the sandbox`
669
+ : `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
560
670
  case "sealed": return `${row.role} is the sealed ${row.note} value itself; it never leaves the sandbox, so only the compile's own probe can speak as it`;
561
671
  case "absent": return `could not sign in as ${row.role}: ${row.note} is not in the environment you sealed`;
562
672
  case "refused": return `your door refused the ${row.role} sign-in (${row.note}), so cases behind it are counted and not driven`;
package/lib/mint.mjs CHANGED
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
10
10
  import { homedir } from "node:os";
11
11
  import { basename, dirname, join } from "node:path";
12
12
  import { promisify } from "node:util";
13
- import { identityScript, parseIdentityReport } from "./identities.mjs";
13
+ import { envCredentialRecipes, identityScript, parseIdentityReport } from "./identities.mjs";
14
14
 
15
15
  const exec = promisify(execFile);
16
16
  export const AS_HEADER = "x-cortad-as";
@@ -128,7 +128,13 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
128
128
  }
129
129
 
130
130
  async function mint(body, port) {
131
- const recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
131
+ let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
132
+ // On a refused first pass the raise asks again with this flag: sign in with the credential the
133
+ // app's own environment names, filling its sign-in form or a Basic header from that pair.
134
+ if (body?.envCreds) {
135
+ const names = Object.keys(Object.assign({}, ...[...envs().all].reverse()));
136
+ recipes = envCredentialRecipes(recipes, names, body.loginRoutes).slice(0, 12);
137
+ }
132
138
  if (!recipes.length || !port) return { identities: [] };
133
139
  mkdirSync(dir, { recursive: true, mode: 0o700 });
134
140
  const out = join(dir, "bl-identities.json");
@@ -141,7 +147,9 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
141
147
  const merged = Object.assign({}, ...[...envs().all].reverse());
142
148
  const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
143
149
  const refused = new Map();
144
- for (const r of recipes) { const why = refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
150
+ // A pair the environment names for the app's own account is the one credential the raise is
151
+ // allowed to reuse: the elevation and existing-account guards do not apply to it.
152
+ for (const r of recipes) { const why = r?.envCred ? null : refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
145
153
  const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r?.kind !== "firebase" || emulated));
146
154
  // The program signs in once per role value ("staff" as sales, support, marketing), each named
147
155
  // role:value; an account made for "staff" alone was never the one it asked for.
package/lib/read-text.mjs CHANGED
@@ -12,11 +12,19 @@ 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 trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`, has(t.playable) && `${num(t.playable)} playable`]);
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)}`;
25
+ // Which trials can be played is decided when a run knocks: a read that said "21 playable, none held
26
+ // back" was followed by a run that held 28 back, and three agents called the counts contradictory.
27
+ const trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`]);
20
28
  const heldWhy = (h) => `${SIDE[h.side] ? `, ${SIDE[h.side]}` : ""}${h.why ? `: ${clip(h.why, 300)}` : ""}`;
21
29
  const named = (label, group, max = 12) => {
22
30
  if (!group) return null;
@@ -39,8 +47,10 @@ export function readLines(r) {
39
47
  const m = r.machine;
40
48
  if (m) {
41
49
  const misses = m.misses ?? [];
42
- lines.push(standardsHead(m), ...misses.slice(0, 12).map(standardLine));
43
- 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.`);
44
54
  }
45
55
  const q = r.questions;
46
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` : ""}.`);
@@ -68,7 +78,7 @@ function sectionOf(r, show) {
68
78
  const mets = m.mets ?? [];
69
79
  return {
70
80
  head: standardsHead(m),
71
- 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),
72
82
  continued: "Engineering standards, continued.",
73
83
  };
74
84
  }
package/lib/start.mjs CHANGED
@@ -38,22 +38,61 @@ export function lockRoot(dir, root) {
38
38
  // The manager this repository was installed with, named by its lockfile.
39
39
  const manager = (dir) => (existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm");
40
40
 
41
+ // A script that only bundles or serves a browser build: the AI is never behind it.
42
+ const BUNDLER = /\b(?:vite|next|nuxt|astro|svelte-kit|vinxi|webpack(?:-dev-server)?|react-scripts|parcel|rsbuild|storybook)\b/;
43
+ // A script that runs a server file, or runs everything at once.
44
+ const RUNS_SERVER = /\b(?:node|nodemon|tsx|ts-node(?:-dev)?|bun run|deno run|concurrently|npm-run-all|run-p|turbo run)\b|\.(?:sh|mjs|cjs|js|ts)\b/;
45
+ const SERVER_SCRIPT = /^(?:dev:(?:full|all|server|api|backend)|server|start:server|backend(?::dev)?|api(?::dev)?|start:api|serve:api|start)$/;
46
+ // The script that starts the AI, not the page: flowviz's dev runs vite and its server runs node
47
+ // server.js with openai behind express; tududi's dev is the frontend and backend:dev the API.
48
+ function serverScript(scripts, deps) {
49
+ const first = ["dev", "develop", "start:dev", "serve", "start"].find((s) => scripts?.[s]);
50
+ if (!first) return null;
51
+ const command = String(scripts[first]);
52
+ const frontendOnly = BUNDLER.test(command) && !RUNS_SERVER.test(command.replace(BUNDLER, ""));
53
+ if (!frontendOnly || !deps.some((d) => SERVER.test(d) || MODEL_SDK.test(d))) return first;
54
+ const better = Object.keys(scripts).filter((s) => SERVER_SCRIPT.test(s) && s !== first && RUNS_SERVER.test(String(scripts[s])) && !(BUNDLER.test(String(scripts[s])) && !/concurrently|npm-run-all|run-p|\.sh\b/.test(String(scripts[s]))));
55
+ const rank = (s) => (/full|all/.test(s) ? 0 : /server|backend|api/.test(s) ? 1 : 2);
56
+ return better.sort((a, b) => rank(a) - rank(b))[0] ?? first;
57
+ }
58
+
41
59
  function nodeStart(dir, onPath) {
42
60
  const pkg = json(join(dir, "package.json"));
43
61
  if (!pkg) return null;
44
- const script = ["dev", "develop", "start:dev", "serve", "start"].find((s) => pkg.scripts?.[s]);
62
+ const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
63
+ const script = serverScript(pkg.scripts, deps);
45
64
  if (!script) return null;
46
65
  const locked = manager(dir);
47
- const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
48
66
  // A lockfile names the manager the repository was installed with, not one this machine has.
49
67
  return { cmd: `${onPath(locked) ? locked : "npm"} run ${script}`, serves: deps.some((d) => SERVER.test(d)) || SERVES_JS.test(pkg.scripts[script]) };
50
68
  }
51
69
 
70
+ // A package that serves through its own console script: pyproject [project.scripts] names it, and
71
+ // its command module defines a serve or run command (typer, click, argparse). RepoWiki starts with
72
+ // `repowiki serve --port N`; no module-level app object exists to hand uvicorn.
73
+ function consoleServe(dir, venv) {
74
+ if (!venv) return null;
75
+ const toml = read(join(dir, "pyproject.toml"));
76
+ const block = /\[project\.scripts\]([^[]*)/.exec(toml)?.[1] ?? "";
77
+ for (const m of block.matchAll(/^\s*"?([\w.-]+)"?\s*=\s*"([\w.]+):(\w+)"/gm)) {
78
+ const [, name, module] = m;
79
+ const file = ["src/", ""].map((base) => join(dir, base, `${module.split(".").join("/")}.py`)).find(existsSync);
80
+ const text = file ? read(file) : "";
81
+ const sub = /(?:def serve\(|@\w+\.command\(\s*(?:name\s*=\s*)?["']serve["']|add_parser\(\s*["']serve["']|["']serve["']\s*:)/.test(text) ? "serve"
82
+ : /(?:def run\(|add_parser\(\s*["']run["']|@\w+\.command\(\s*(?:name\s*=\s*)?["']run["'])/.test(text) ? "run" : null;
83
+ const bin = join(dirname(venv), name);
84
+ if (sub && existsSync(bin)) return { cmd: `${JSON.stringify(bin)} ${sub}${/--port/.test(text) ? " --port 8000" : ""}`, serves: true };
85
+ }
86
+ return null;
87
+ }
88
+
52
89
  function pythonStart(dir, onPath) {
53
90
  if (!["requirements.txt", "pyproject.toml", "manage.py", "Pipfile", "uv.lock"].some((f) => existsSync(join(dir, f)))) return null;
54
91
  const venv = [".venv/bin/python", "venv/bin/python", "env/bin/python"].map((p) => join(dir, p)).find(existsSync);
55
92
  const py = venv ? JSON.stringify(venv) : existsSync(join(dir, "uv.lock")) && onPath("uv") ? "uv run python" : existsSync(join(dir, "poetry.lock")) && onPath("poetry") ? "poetry run python" : "python3";
56
93
  if (existsSync(join(dir, "manage.py"))) return { cmd: `${py} manage.py runserver`, serves: true };
94
+ const served = consoleServe(dir, venv);
95
+ if (served) return served;
57
96
  const named = [...new Set([...ENTRIES, ...entriesIn(dir)])].map((entry) => [entry, read(join(dir, entry))]).filter(([, text]) => text);
58
97
  const manifest = `${read(join(dir, "requirements.txt"))}\n${read(join(dir, "pyproject.toml"))}\n${read(join(dir, "Pipfile"))}`;
59
98
  // The file that serves before the file that merely runs: src/run_agent.py runs a conversation in
@@ -115,7 +154,11 @@ export function startPlan({ root, typed, onPath }) {
115
154
  if (typed) return { cmd: typed, cwd: root };
116
155
  const pkg = json(join(root, "package.json"));
117
156
  const mono = Boolean(pkg?.workspaces) || existsSync(join(root, "pnpm-workspace.yaml")) || existsSync(join(root, "turbo.json")) || existsSync(join(root, "lerna.json"));
118
- const own = mono ? null : startOf(root, onPath);
157
+ // A root with both manifests: the side that names a model SDK is the app; the other is its page.
158
+ const rootPkg = json(join(root, "package.json"));
159
+ const rootPy = `${read(join(root, "requirements.txt"))}\n${read(join(root, "pyproject.toml"))}`;
160
+ const pythonFirst = Boolean(rootPkg) && PY_MODEL.test(rootPy) && !Object.keys({ ...(rootPkg.dependencies ?? {}), ...(rootPkg.devDependencies ?? {}) }).some((d) => MODEL_SDK.test(d));
161
+ const own = mono ? null : pythonFirst ? (pythonStart(root, onPath) ?? nodeStart(root, onPath)) : startOf(root, onPath);
119
162
  if (own) return { cmd: own.cmd, cwd: root };
120
163
  const ranked = workspaces(root).map((dir) => ({ dir, start: startOf(dir, onPath), weight: weight(dir, relative(root, dir)) })).filter((w) => w.start).sort((a, b) => b.weight - a.weight);
121
164
  const best = ranked[0];
package/lib/text.mjs CHANGED
@@ -22,9 +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}`);
26
+ if (d.read?.complete === false) lines.push("The read of your repository is still running; the lists below fill in as it finishes.");
25
27
  if (d.read) lines.push(...readLines(d.read));
26
28
  if (d.run === null) lines.push("No run yet.");
27
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.`);
28
33
  if (d.field) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
29
34
  if (d.links?.lab && (d.read || d.run)) lines.push(`For the person: ${d.links.lab} shows this in the browser.`);
30
35
  if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
@@ -41,10 +46,23 @@ function planLine(p) {
41
46
 
42
47
  // The number with what it stands on: a 100 beside an app that stopped, or from five readings, read
43
48
  // as a verdict to two agents. Under the 22-reading floor it is provisional and says so.
49
+ function coverageLine(d) {
50
+ const never = d.questionsOf - d.questionsAsked;
51
+ const share = Math.round((100 * d.questionsAsked) / d.questionsOf);
52
+ return `Measured ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} (${share}%)${never > 0 ? `; ${num(never)} never came up in the situations played, so nothing below speaks for them` : ""}.`;
53
+ }
54
+
44
55
  function scoreLine(d) {
45
56
  const floor = has(d.decided) && d.decided < 22;
46
- return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
57
+ // The number never stands without its denominator: "93 of 100" read as broad assurance to four
58
+ // agents whose runs had measured a quarter of the checks.
59
+ const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
60
+ return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
47
61
  }
62
+ // A row the run held back names a door and why, or, when no door could take it, only the reason.
63
+ const heldBackLine = (h) => h.why === "not testable here"
64
+ ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
65
+ : `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
48
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.`;
49
67
  const LAYER_WORDS = {
50
68
  retrieval: "what the store returned behind the reply, not how the reply was written",
@@ -59,18 +77,44 @@ function runLines(d, prefix = "") {
59
77
  const done = finished(d);
60
78
  const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
61
79
  const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
62
- if (done && typeof d.score === "number") lines.push(scoreLine(d));
80
+ // Coverage before the number: a 97 beside 59 of 80 checks that never fired read as production-ready
81
+ // to every agent that got it. What was measured leads, then the rate on what was measured.
82
+ if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
83
+ if (done && d.blocked) lines.push(d.blocked);
84
+ if (done && d.notCounted) lines.push(d.notCounted);
85
+ // A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
86
+ // as a clean bill to bloom's agent, beside "zero verified fixes".
87
+ if (done && typeof d.score === "number" && d.kind !== "verify") lines.push(scoreLine(d));
63
88
  if (done && d.halted) lines.push(haltLine(d.halted));
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.`);
64
90
  if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
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")}.`);
65
97
  const reads = readingsLine(d);
66
98
  if (reads) lines.push(reads);
67
99
  if (d.stopped) lines.push(stoppedLine(d));
68
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}`);
69
102
  if (d.error) lines.push(`Error: ${d.error}`);
70
103
  if (d.verify) lines.push(...verifyLines(d.verify));
71
104
  return lines;
72
105
  }
73
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
+
74
118
  // A reading is one question checked against one reply.
75
119
  function readingsLine(d) {
76
120
  if (!has(d.readings) && !has(d.questionsAsked)) return null;
@@ -116,7 +160,7 @@ export function runText(d) {
116
160
  export function startedText(d, kind, findingId) {
117
161
  const verify = (d.kind ?? kind) === "verify";
118
162
  const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
119
- return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.\nnext: run_status ${d.jobId}`;
163
+ return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.${d.note ? `\n${d.note}` : ""}\nnext: run_status ${d.jobId}`;
120
164
  }
121
165
 
122
166
  export const waitingText = (p, now, limitMs) =>
@@ -157,7 +201,7 @@ function ledgerLines(l) {
157
201
  // Findings grouped by the line they live at: the server's byLine, or the findings' own file and
158
202
  // line when it sends none. The same line from two server pages is one group.
159
203
  export function linesOf(d) {
160
- 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);
161
205
  const merged = new Map();
162
206
  for (const r of rows) {
163
207
  const key = at(r.path, r.line);
@@ -170,7 +214,9 @@ export function linesOf(d) {
170
214
  export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n) => `findings with page ${n}`);
171
215
 
172
216
  function findingsHead(d) {
217
+ if (!d.runId) return ["No run has finished on this repository yet."];
173
218
  const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
219
+ if (d.blocked) lines.push(d.blocked);
174
220
  if (typeof d.score === "number") lines.push(scoreLine(d));
175
221
  if (d.halted) lines.push(haltLine(d.halted));
176
222
  const reads = readingsLine(d);
@@ -221,6 +267,13 @@ function findingBlock(f, placed) {
221
267
  if (f.door) lines.push(` Endpoint: ${f.door}`);
222
268
  else if (f.doors?.length) lines.push(` Endpoints: ${f.doors.join(", ")}`);
223
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
+ }
224
277
  if (f.layer && LAYER_WORDS[f.layer]) lines.push(` Layer: ${f.layer}, ${LAYER_WORDS[f.layer]}.`);
225
278
  const r = f.rate;
226
279
  // The break count, whichever way the question is phrased: "held in 21 of 33" under a question
@@ -229,9 +282,24 @@ function findingBlock(f, placed) {
229
282
  if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
230
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);
231
284
  if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
285
+ const logsShown = new Set();
286
+ let logRoom = 30;
232
287
  for (const q of (f.quotes ?? []).slice(0, 3)) {
233
- const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`].filter(Boolean).join(", ");
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(", ");
289
+ // The request beside the reply: an agent handed "HTTP 502 happened" did the rest of the
290
+ // investigation itself.
291
+ if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
234
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}`);
235
303
  }
236
304
  for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
237
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
@@ -370,8 +371,8 @@ async function verb(job) {
370
371
  // method", which is what your chat looked like from the outside. So the chain is walked the
371
372
  // way a browser walks it, one GET with redirects followed, and the request is sent again with
372
373
  // the session your app just handed out. The cookie is kept here and never leaves this machine.
373
- const url = `http://127.0.0.1:${port}${path}`;
374
- const yours = (to) => { try { const u = new URL(to, url); return u.hostname === "127.0.0.1" && u.port === String(port) ? u.href : null; } catch { return null; } };
374
+ const url = hostUrl(port, path);
375
+ const yours = (to) => { try { const u = new URL(to, url); return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(u.hostname) && u.port === String(port) ? u.href : null; } catch { return null; } };
375
376
  let jar = "";
376
377
  const sent = (at, over = {}) => fetch(at, {
377
378
  ...init, ...over,
@@ -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
@@ -448,10 +461,26 @@ async function verb(job) {
448
461
  const exec = promisify(execFile);
449
462
 
450
463
  // ---- start or attach to the app
464
+ // The address the app answers on: 127.0.0.1, or [::1] when it bound IPv6 localhost alone, which a
465
+ // Vite dev server does and which a probe of 127.0.0.1 waited three minutes on.
466
+ let appHost = "127.0.0.1";
467
+ const hostUrl = (port, path = "/") => `http://${appHost === "::1" ? "[::1]" : appHost}:${port}${path}`;
451
468
  const answers = async (port) => {
452
- try { await fetch(`http://127.0.0.1:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" }); return true; }
453
- catch { return false; }
469
+ for (const host of [appHost, appHost === "::1" ? "127.0.0.1" : "::1"]) {
470
+ try {
471
+ await fetch(`http://${host === "::1" ? "[::1]" : host}:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
472
+ appHost = host;
473
+ return true;
474
+ } catch { /* the other family next */ }
475
+ }
476
+ return false;
454
477
  };
478
+ // The values an env file beside the app declares, exported into its process the way `source .env`
479
+ // would: an app with no dotenv loader of its own crashed every route without them. Example files
480
+ // hold placeholders and are left out; the shell's own environment and the lifted limits win.
481
+ function envExports(envFiles) {
482
+ return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
483
+ }
455
484
  const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
456
485
  // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
457
486
  let appDir = root;
@@ -516,7 +545,9 @@ async function ask(question) {
516
545
  let child = null;
517
546
  async function startApp() {
518
547
  const wanted = Number(flag("--port"));
519
- if (wanted) {
548
+ // "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
549
+ // read as the latter it said nothing was answering and never ran the command it was given.
550
+ if (wanted && !flag("--start")) {
520
551
  if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
521
552
  const took = await takeOver(wanted);
522
553
  if (took) return took;
@@ -557,7 +588,7 @@ async function startApp() {
557
588
  const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
558
589
  for (const port of new Set(ports)) {
559
590
  if (await answers(port)) {
560
- launched = null; child = null;
591
+ launched = null; child = null; data = null;
561
592
  const took = await takeOver(port);
562
593
  if (took) return took;
563
594
  say(`your app is already running on port ${port}, so that one is used; its request limits stay as they are`);
@@ -694,9 +725,14 @@ async function freePortAbove(port) {
694
725
  for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
695
726
  return null;
696
727
  }
728
+ // The port a bind failure names, in the app's own words: uvicorn's "('127.0.0.1', 8105): address
729
+ // already in use", Node's "EADDRINUSE: address already in use :::3000".
730
+ const portInError = (said) => Number(/(\d{4,5})\)?:?\s*address already in use/i.exec(said)?.[1] ?? /(?:EADDRINUSE|address already in use)\D{0,40}?(\d{4,5})\b/i.exec(said)?.[1] ?? 0);
697
731
  async function freed(cmd, lifted) {
698
732
  const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
699
- const named = m ? Number(m[2]) : Number(lifted.PORT) || 0;
733
+ // The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
734
+ // on that port was reported as "your app stopped" instead of moved past.
735
+ const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(envExports(envFiles).PORT) || 0;
700
736
  if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
701
737
  const port = await freePortAbove(named);
702
738
  if (!port) return { cmd, lifted };
@@ -705,9 +741,27 @@ async function freed(cmd, lifted) {
705
741
  return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
706
742
  }
707
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
+
708
762
  async function start(waitMs) {
709
763
  const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
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 });
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 });
711
765
  const mine = child;
712
766
  let seen = "";
713
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); };
@@ -719,7 +773,10 @@ async function start(waitMs) {
719
773
  const started = Date.now();
720
774
  while (Date.now() - started < waitMs) {
721
775
  const tail = () => seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-25).join("\n");
722
- if (exited !== null) return { port: null, exited, tail: tail() };
776
+ // The port was taken: the app says so and exits at once, so this is read before the exit code,
777
+ // or a bed whose .env named a port another round still held printed "your app stopped" three times.
778
+ const taken = /EADDRINUSE|address already in use/i.test(seen);
779
+ if (exited !== null && !taken) return { port: null, exited, tail: tail() };
723
780
  // nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
724
781
  // the wait ran its whole three minutes on one app with the reason sitting in the output.
725
782
  // Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
@@ -728,7 +785,7 @@ async function start(waitMs) {
728
785
  await stopApp(mine.pid);
729
786
  // The port this start named was taken between the check and the bind: the next free one, at
730
787
  // 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;
788
+ const named = /(?:--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd)?.[1] ?? lifted.PORT ?? envExports(envFiles).PORT ?? (portInError(seen) || undefined);
732
789
  if (/EADDRINUSE|address already in use/i.test(seen) && named && lost.size < 3) { lost.add(Number(named)); return start(waitMs); }
733
790
  return { port: null, exited: 1, tail: tail() };
734
791
  }
@@ -821,6 +878,7 @@ if (explain) {
821
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)`}`,
822
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`,
823
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`,
824
882
  `test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
825
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`,
826
884
  ``,
@@ -888,6 +946,7 @@ await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
888
946
  const bytes = readFileSync(archive);
889
947
  const PART = 1_500_000;
890
948
  let resumed = false;
949
+ let moved = false;
891
950
  const parts = Math.max(1, Math.ceil(bytes.length / PART));
892
951
  for (let off = 0; off < bytes.length; off += PART) {
893
952
  const last = off + PART >= bytes.length;
@@ -899,12 +958,13 @@ for (let off = 0; off < bytes.length; off += PART) {
899
958
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
900
959
  if (last) {
901
960
  resumed = put.data?.resumed === true;
961
+ moved = put.data?.moved === true;
902
962
  if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
903
963
  writeDigest(project, treeDigest);
904
964
  }
905
965
  }
906
966
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
907
- stepDone(resumed ? "connected · nothing changed since last time" : "connected");
967
+ stepDone(moved ? "connected · your change is in; the read from before stands" : resumed ? "connected · nothing changed since last time" : "connected");
908
968
  // The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
909
969
  // one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
910
970
  if (!viaToken) {
@@ -935,7 +995,7 @@ function sourceChanged() {
935
995
  }
936
996
  // `watching` says whether a message sent to this app can be seen arriving: only an app this command
937
997
  // started carries the hook, and only a runtime the hook exists for.
938
- 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)) });
939
999
 
940
1000
  // Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
941
1001
  // and starts it again the moment you save a fix, and the browser is told each time it answers, so a
@@ -982,7 +1042,10 @@ async function appLife() {
982
1042
  const busy = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
983
1043
  if (busy || downFor < (crashed ? 4_000 : 10_000)) continue;
984
1044
  // Down for real. The screen is told, so it never shows an app that is not there.
985
- 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);
986
1049
  // An app that went quiet or exited did not stop because of a save (it was killed, it ran out
987
1050
  // of memory, it crashed on a request), so waiting for a save would wait forever. It is started
988
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-rc.9",
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",
package/skill/SKILL.md CHANGED
@@ -53,7 +53,9 @@ Take the person through them worst first, each with its file and line. A long li
53
53
  2. `verify <findingId>`. It answers within a second, and `run_status` follows it. Cortad starts the app again itself before the replay, so the saved edit is what plays.
54
54
  3. Read the move on the `Visible trials:` and `Held-out trials:` lines:
55
55
  - `improved` without `inside the noise`, with the held-out line improved too or `no pair`: the behavior moved. The change stays.
56
- - `inside the noise`, `no change` or `unsettled`: the trials cannot tell the change from chance. The file goes back to how it was.
56
+ - `no change` or `regressed`: the file goes back to how it was.
57
+ - `inside the noise` or `unsettled`: the trials cannot tell yet, which is not a verdict against the change. `verify` again to add readings; a change the app's own tests support may stay, said as not yet verified by Cortad.
58
+ - A verify that played no trial says so and compares nothing; the file stays as it was left.
57
59
  - An `Overfit:` line: the visible trials moved and the held-out trials stayed where they were, so the change fits the trials it could see. The file goes back, and the next change aims at the behavior the question asks about.
58
60
  - `regressed`: the file goes back.
59
61
  4. Then the next finding.
@@ -35,7 +35,7 @@ Engineering standards: 38 decided, 35 met, 3 missed.
35
35
  apps/api/src/agent/system.ts:30 User text stays out of the system prompt: the student's name is written into the system prompt (decided by a model)
36
36
  apps/api/src/tools/search.ts:51 Tool errors reach the model as errors: search returns an empty list when the index is down (decided by a model)
37
37
  Questions: 112; 40 asked in every conversation, 72 placed in the situations they fit.
38
- Trials: 58 written, 51 playable.
38
+ Trials: 58 written.
39
39
  POST /api/homework/upload: 7 trials held back, on the app's side: the route needs a signed file URL, and no test account can make one
40
40
  Latest run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
41
41
  410 readings of 112 questions.
@@ -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
  ```