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 +143 -0
- package/lib/identities.mjs +112 -2
- package/lib/mint.mjs +11 -3
- package/lib/read-text.mjs +17 -7
- package/lib/start.mjs +46 -3
- package/lib/text.mjs +73 -5
- package/lib/words.mjs +18 -1
- package/local.mjs +76 -13
- package/package.json +2 -1
- package/skill/SKILL.md +3 -1
- package/skill/references/results.md +3 -1
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/identities.mjs
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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 ?? []).
|
|
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
|
-
|
|
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 =
|
|
374
|
-
const yours = (to) => { try { const u = new URL(to, url); return
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
- `
|
|
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
|
|
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
|
```
|