cortad 0.3.0-rc.8 → 0.3.0
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/identities.mjs +112 -2
- package/lib/mint.mjs +11 -3
- package/lib/read-text.mjs +3 -1
- package/lib/start.mjs +46 -3
- package/lib/text.mjs +53 -5
- package/local.mjs +46 -11
- package/package.json +1 -1
- package/skill/SKILL.md +3 -1
- package/skill/references/results.md +1 -1
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
|
@@ -16,7 +16,9 @@ const ruleLine = (e) => ` ${at(e.path, e.line)} "${clip(e.text, 300)}"`;
|
|
|
16
16
|
const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
|
|
17
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
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
|
-
|
|
19
|
+
// Which trials can be played is decided when a run knocks: a read that said "21 playable, none held
|
|
20
|
+
// back" was followed by a run that held 28 back, and three agents called the counts contradictory.
|
|
21
|
+
const trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`]);
|
|
20
22
|
const heldWhy = (h) => `${SIDE[h.side] ? `, ${SIDE[h.side]}` : ""}${h.why ? `: ${clip(h.why, 300)}` : ""}`;
|
|
21
23
|
const named = (label, group, max = 12) => {
|
|
22
24
|
if (!group) return null;
|
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,6 +22,7 @@ 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
|
+
if (d.read?.complete === false) lines.push("The read of your repository is still running; the lists below fill in as it finishes.");
|
|
25
26
|
if (d.read) lines.push(...readLines(d.read));
|
|
26
27
|
if (d.run === null) lines.push("No run yet.");
|
|
27
28
|
else if (d.run) lines.push(...runLines(d.run, "latest "));
|
|
@@ -39,12 +40,51 @@ function planLine(p) {
|
|
|
39
40
|
return parts.length ? `${p.name}: ${parts.join(", ")}.` : `Plan: ${p.name}.`;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// The number with what it stands on: a 100 beside an app that stopped, or from five readings, read
|
|
44
|
+
// as a verdict to two agents. Under the 22-reading floor it is provisional and says so.
|
|
45
|
+
function coverageLine(d) {
|
|
46
|
+
const never = d.questionsOf - d.questionsAsked;
|
|
47
|
+
const share = Math.round((100 * d.questionsAsked) / d.questionsOf);
|
|
48
|
+
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` : ""}.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scoreLine(d) {
|
|
52
|
+
const floor = has(d.decided) && d.decided < 22;
|
|
53
|
+
// The number never stands without its denominator: "93 of 100" read as broad assurance to four
|
|
54
|
+
// agents whose runs had measured a quarter of the checks.
|
|
55
|
+
const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
|
|
56
|
+
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` : ""}.`;
|
|
57
|
+
}
|
|
58
|
+
// A row the run held back names a door and why, or, when no door could take it, only the reason.
|
|
59
|
+
const heldBackLine = (h) => h.why === "not testable here"
|
|
60
|
+
? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200)}.`
|
|
61
|
+
: `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
|
|
62
|
+
const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
|
|
63
|
+
const LAYER_WORDS = {
|
|
64
|
+
retrieval: "what the store returned behind the reply, not how the reply was written",
|
|
65
|
+
tools: "the tool call behind the reply",
|
|
66
|
+
memory: "what the app kept between turns",
|
|
67
|
+
application: "an error in the app around the model",
|
|
68
|
+
instructions: "the model against its own instructions",
|
|
69
|
+
upsell: "what the reply offered beyond the ask",
|
|
70
|
+
};
|
|
71
|
+
|
|
42
72
|
function runLines(d, prefix = "") {
|
|
43
73
|
const done = finished(d);
|
|
44
74
|
const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
|
|
45
75
|
const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
|
|
46
|
-
|
|
76
|
+
// Coverage before the number: a 97 beside 59 of 80 checks that never fired read as production-ready
|
|
77
|
+
// to every agent that got it. What was measured leads, then the rate on what was measured.
|
|
78
|
+
if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
|
|
79
|
+
if (done && d.blocked) lines.push(d.blocked);
|
|
80
|
+
if (done && d.notCounted) lines.push(d.notCounted);
|
|
81
|
+
// A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
|
|
82
|
+
// as a clean bill to bloom's agent, beside "zero verified fixes".
|
|
83
|
+
if (done && typeof d.score === "number" && d.kind !== "verify") lines.push(scoreLine(d));
|
|
84
|
+
if (done && d.halted) lines.push(haltLine(d.halted));
|
|
85
|
+
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.`);
|
|
47
86
|
if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
|
|
87
|
+
for (const h of (d.heldBack ?? []).filter((h) => h.trials > 0).slice(0, 6)) lines.push(heldBackLine(h));
|
|
48
88
|
const reads = readingsLine(d);
|
|
49
89
|
if (reads) lines.push(reads);
|
|
50
90
|
if (d.stopped) lines.push(stoppedLine(d));
|
|
@@ -58,7 +98,7 @@ function runLines(d, prefix = "") {
|
|
|
58
98
|
function readingsLine(d) {
|
|
59
99
|
if (!has(d.readings) && !has(d.questionsAsked)) return null;
|
|
60
100
|
const r = has(d.readings) ? plural(d.readings, "reading") : null;
|
|
61
|
-
const q = has(d.questionsAsked) ? plural(d.questionsAsked, "question") : null;
|
|
101
|
+
const q = has(d.questionsAsked) ? `${plural(d.questionsAsked, "question")}${has(d.questionsOf) && d.questionsOf > d.questionsAsked ? ` (${num(d.questionsOf - d.questionsAsked)} of the ${num(d.questionsOf)} in the set never came up)` : ""}` : null;
|
|
62
102
|
const split = [has(d.decided) && `${num(d.decided)} decided`, has(d.unclear) && `${num(d.unclear)} unclear`].filter(Boolean).join(", ");
|
|
63
103
|
return `${r && q ? `${r} of ${q}` : r ?? `${q} asked`}${split ? `: ${split}` : ""}.`;
|
|
64
104
|
}
|
|
@@ -99,7 +139,7 @@ export function runText(d) {
|
|
|
99
139
|
export function startedText(d, kind, findingId) {
|
|
100
140
|
const verify = (d.kind ?? kind) === "verify";
|
|
101
141
|
const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
|
|
102
|
-
return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}
|
|
142
|
+
return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.${d.note ? `\n${d.note}` : ""}\nnext: run_status ${d.jobId}`;
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
export const waitingText = (p, now, limitMs) =>
|
|
@@ -153,8 +193,11 @@ export function linesOf(d) {
|
|
|
153
193
|
export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n) => `findings with page ${n}`);
|
|
154
194
|
|
|
155
195
|
function findingsHead(d) {
|
|
196
|
+
if (!d.runId) return ["No run has finished on this repository yet."];
|
|
156
197
|
const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
|
|
157
|
-
if (
|
|
198
|
+
if (d.blocked) lines.push(d.blocked);
|
|
199
|
+
if (typeof d.score === "number") lines.push(scoreLine(d));
|
|
200
|
+
if (d.halted) lines.push(haltLine(d.halted));
|
|
158
201
|
const reads = readingsLine(d);
|
|
159
202
|
if (reads) lines.push(reads);
|
|
160
203
|
if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
|
|
@@ -201,7 +244,9 @@ function findingBlock(f, placed) {
|
|
|
201
244
|
if (!placed && f.file) lines.push(` At ${at(f.file, f.line)}`);
|
|
202
245
|
if (f.criteria) lines.push(` Criteria: ${clip(f.criteria, 600)}`);
|
|
203
246
|
if (f.door) lines.push(` Endpoint: ${f.door}`);
|
|
247
|
+
else if (f.doors?.length) lines.push(` Endpoints: ${f.doors.join(", ")}`);
|
|
204
248
|
if (f.where) lines.push(` Situation: ${f.where}`);
|
|
249
|
+
if (f.layer && LAYER_WORDS[f.layer]) lines.push(` Layer: ${f.layer}, ${LAYER_WORDS[f.layer]}.`);
|
|
205
250
|
const r = f.rate;
|
|
206
251
|
// The break count, whichever way the question is phrased: "held in 21 of 33" under a question
|
|
207
252
|
// that asks whether the passages MISS the point left an agent unable to tell pass from fail.
|
|
@@ -210,7 +255,10 @@ function findingBlock(f, placed) {
|
|
|
210
255
|
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);
|
|
211
256
|
if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
|
|
212
257
|
for (const q of (f.quotes ?? []).slice(0, 3)) {
|
|
213
|
-
const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`].filter(Boolean).join(", ");
|
|
258
|
+
const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`, q.door && `at ${q.door}`].filter(Boolean).join(", ");
|
|
259
|
+
// The request beside the reply: an agent handed "HTTP 502 happened" did the rest of the
|
|
260
|
+
// investigation itself.
|
|
261
|
+
if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
|
|
214
262
|
lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
|
|
215
263
|
}
|
|
216
264
|
for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
|
package/local.mjs
CHANGED
|
@@ -370,8 +370,8 @@ async function verb(job) {
|
|
|
370
370
|
// method", which is what your chat looked like from the outside. So the chain is walked the
|
|
371
371
|
// way a browser walks it, one GET with redirects followed, and the request is sent again with
|
|
372
372
|
// 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
|
|
373
|
+
const url = hostUrl(port, path);
|
|
374
|
+
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
375
|
let jar = "";
|
|
376
376
|
const sent = (at, over = {}) => fetch(at, {
|
|
377
377
|
...init, ...over,
|
|
@@ -448,10 +448,26 @@ async function verb(job) {
|
|
|
448
448
|
const exec = promisify(execFile);
|
|
449
449
|
|
|
450
450
|
// ---- start or attach to the app
|
|
451
|
+
// The address the app answers on: 127.0.0.1, or [::1] when it bound IPv6 localhost alone, which a
|
|
452
|
+
// Vite dev server does and which a probe of 127.0.0.1 waited three minutes on.
|
|
453
|
+
let appHost = "127.0.0.1";
|
|
454
|
+
const hostUrl = (port, path = "/") => `http://${appHost === "::1" ? "[::1]" : appHost}:${port}${path}`;
|
|
451
455
|
const answers = async (port) => {
|
|
452
|
-
|
|
453
|
-
|
|
456
|
+
for (const host of [appHost, appHost === "::1" ? "127.0.0.1" : "::1"]) {
|
|
457
|
+
try {
|
|
458
|
+
await fetch(`http://${host === "::1" ? "[::1]" : host}:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
|
|
459
|
+
appHost = host;
|
|
460
|
+
return true;
|
|
461
|
+
} catch { /* the other family next */ }
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
454
464
|
};
|
|
465
|
+
// The values an env file beside the app declares, exported into its process the way `source .env`
|
|
466
|
+
// would: an app with no dotenv loader of its own crashed every route without them. Example files
|
|
467
|
+
// hold placeholders and are left out; the shell's own environment and the lifted limits win.
|
|
468
|
+
function envExports(envFiles) {
|
|
469
|
+
return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
|
|
470
|
+
}
|
|
455
471
|
const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
|
|
456
472
|
// Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
|
|
457
473
|
let appDir = root;
|
|
@@ -516,7 +532,9 @@ async function ask(question) {
|
|
|
516
532
|
let child = null;
|
|
517
533
|
async function startApp() {
|
|
518
534
|
const wanted = Number(flag("--port"));
|
|
519
|
-
|
|
535
|
+
// "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
|
|
536
|
+
// read as the latter it said nothing was answering and never ran the command it was given.
|
|
537
|
+
if (wanted && !flag("--start")) {
|
|
520
538
|
if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
|
|
521
539
|
const took = await takeOver(wanted);
|
|
522
540
|
if (took) return took;
|
|
@@ -687,14 +705,22 @@ async function installOnce(said) {
|
|
|
687
705
|
|
|
688
706
|
// The port their start command names, moved when something else already holds it: two apps on one
|
|
689
707
|
// laptop both said --port 8000, and the second attached to the first's server as its own.
|
|
708
|
+
// Ports this program tried and lost: two connects on one machine can pick the same free port in
|
|
709
|
+
// the same second, and the one that binds second sees "address already in use".
|
|
710
|
+
const lost = new Set();
|
|
690
711
|
async function freePortAbove(port) {
|
|
691
|
-
for (let next = port + 1; next < port + 30; next++) if (!(await listenerOn(next))) return next;
|
|
712
|
+
for (let next = port + 1; next < port + 30; next++) if (!lost.has(next) && !(await listenerOn(next))) return next;
|
|
692
713
|
return null;
|
|
693
714
|
}
|
|
715
|
+
// The port a bind failure names, in the app's own words: uvicorn's "('127.0.0.1', 8105): address
|
|
716
|
+
// already in use", Node's "EADDRINUSE: address already in use :::3000".
|
|
717
|
+
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);
|
|
694
718
|
async function freed(cmd, lifted) {
|
|
695
719
|
const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
|
|
696
|
-
|
|
697
|
-
|
|
720
|
+
// The app's own env file names its port too: yunqiao's PORT=8105 sat in .env, and a stale copy
|
|
721
|
+
// on that port was reported as "your app stopped" instead of moved past.
|
|
722
|
+
const named = m ? Number(m[2]) : Number(lifted.PORT) || Number(envExports(envFiles).PORT) || 0;
|
|
723
|
+
if (!named || (!lost.has(named) && !(await listenerOn(named)))) return { cmd, lifted };
|
|
698
724
|
const port = await freePortAbove(named);
|
|
699
725
|
if (!port) return { cmd, lifted };
|
|
700
726
|
say(`port ${named} is taken on this machine, so your app starts on ${port}`);
|
|
@@ -704,7 +730,7 @@ async function freed(cmd, lifted) {
|
|
|
704
730
|
|
|
705
731
|
async function start(waitMs) {
|
|
706
732
|
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
707
|
-
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 });
|
|
733
|
+
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
708
734
|
const mine = child;
|
|
709
735
|
let seen = "";
|
|
710
736
|
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); };
|
|
@@ -716,13 +742,20 @@ async function start(waitMs) {
|
|
|
716
742
|
const started = Date.now();
|
|
717
743
|
while (Date.now() - started < waitMs) {
|
|
718
744
|
const tail = () => seen.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(-25).join("\n");
|
|
719
|
-
|
|
745
|
+
// The port was taken: the app says so and exits at once, so this is read before the exit code,
|
|
746
|
+
// or a bed whose .env named a port another round still held printed "your app stopped" three times.
|
|
747
|
+
const taken = /EADDRINUSE|address already in use/i.test(seen);
|
|
748
|
+
if (exited !== null && !taken) return { port: null, exited, tail: tail() };
|
|
720
749
|
// nodemon and its kind outlive the app they watch: the app is gone, the process is not, and
|
|
721
750
|
// the wait ran its whole three minutes on one app with the reason sitting in the output.
|
|
722
751
|
// Also: the port is taken, under a watcher that does not exit when its app cannot listen. The app
|
|
723
752
|
// that holds the port is theirs and already running, which startApp turns into attaching to it.
|
|
724
753
|
if (/app crashed - waiting for file changes|waiting for (?:file )?changes before restart|Failed running|EADDRINUSE|address already in use/i.test(seen)) {
|
|
725
754
|
await stopApp(mine.pid);
|
|
755
|
+
// The port this start named was taken between the check and the bind: the next free one, at
|
|
756
|
+
// most three times, before the failure is theirs to read.
|
|
757
|
+
const named = /(?:--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd)?.[1] ?? lifted.PORT ?? envExports(envFiles).PORT ?? (portInError(seen) || undefined);
|
|
758
|
+
if (/EADDRINUSE|address already in use/i.test(seen) && named && lost.size < 3) { lost.add(Number(named)); return start(waitMs); }
|
|
726
759
|
return { port: null, exited: 1, tail: tail() };
|
|
727
760
|
}
|
|
728
761
|
// The port is what the app's own process group listens on. Never a guess: a developer's
|
|
@@ -881,6 +914,7 @@ await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
|
|
|
881
914
|
const bytes = readFileSync(archive);
|
|
882
915
|
const PART = 1_500_000;
|
|
883
916
|
let resumed = false;
|
|
917
|
+
let moved = false;
|
|
884
918
|
const parts = Math.max(1, Math.ceil(bytes.length / PART));
|
|
885
919
|
for (let off = 0; off < bytes.length; off += PART) {
|
|
886
920
|
const last = off + PART >= bytes.length;
|
|
@@ -892,12 +926,13 @@ for (let off = 0; off < bytes.length; off += PART) {
|
|
|
892
926
|
if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
|
|
893
927
|
if (last) {
|
|
894
928
|
resumed = put.data?.resumed === true;
|
|
929
|
+
moved = put.data?.moved === true;
|
|
895
930
|
if (typeof put.data?.machineKey === "string" && put.data.machineKey) writeToken(project, put.data.machineKey);
|
|
896
931
|
writeDigest(project, treeDigest);
|
|
897
932
|
}
|
|
898
933
|
}
|
|
899
934
|
// Unchanged code has already been read: coming back says so instead of claiming a second read.
|
|
900
|
-
stepDone(resumed ? "connected · nothing changed since last time" : "connected");
|
|
935
|
+
stepDone(moved ? "connected · your change is in; the read from before stands" : resumed ? "connected · nothing changed since last time" : "connected");
|
|
901
936
|
// The coding agents on this machine learn about Cortad now, once: an MCP entry and a skill in each
|
|
902
937
|
// one's own home folder. A run started by an agent later comes back through lib/cli.mjs.
|
|
903
938
|
if (!viaToken) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
package/skill/SKILL.md
CHANGED
|
@@ -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.
|