cortad 0.3.1-rc.4 → 0.3.1-rc.6
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/cli.mjs +3 -0
- package/lib/mcp.mjs +3 -3
- package/lib/pyhook/sitecustomize.py +100 -0
- package/lib/replay.mjs +4 -1
- package/lib/text.mjs +25 -19
- package/lib/trace.cjs +64 -0
- package/lib/verbs.mjs +14 -3
- package/lib/writes.mjs +53 -0
- package/local.mjs +5 -1
- package/package.json +2 -1
- package/skill/SKILL.md +7 -6
- package/skill/references/results.md +10 -5
package/lib/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
|
|
6
|
+
import { markRun, putBack, writesDirOf } from "./writes.mjs";
|
|
6
7
|
import { serveMcp } from "./mcp.mjs";
|
|
7
8
|
import { SHOW } from "./read-text.mjs";
|
|
8
9
|
import { cliSpec, VERSION } from "./spec.mjs";
|
|
@@ -35,9 +36,11 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
|
|
|
35
36
|
const children = [];
|
|
36
37
|
const pending = { read: () => readPending(project), write: (p) => writePending(project, p) };
|
|
37
38
|
// The key is read at every call: the MCP process outlives a connect that writes it after the process started.
|
|
39
|
+
const writes = { mark: (id) => markRun(writesDirOf(homeOf(project)), id), putBack: () => putBack(writesDirOf(homeOf(project)), root) };
|
|
38
40
|
const verbs = makeVerbs({
|
|
39
41
|
api: `${origin.origin}/api`,
|
|
40
42
|
root,
|
|
43
|
+
writes,
|
|
41
44
|
token: () => readToken(project),
|
|
42
45
|
pending,
|
|
43
46
|
startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
|
package/lib/mcp.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export const PROTOCOL = "2025-06-18";
|
|
|
10
10
|
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
11
11
|
export const INSTRUCTIONS = [
|
|
12
12
|
"Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
|
|
13
|
-
"Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting. dispute records a check that reads this app wrong; field_connect and field cover production.",
|
|
13
|
+
"Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting; its last sentence says whether the evidence supports keeping the fix. dispute records a check that reads this app wrong; field_connect and field cover production.",
|
|
14
14
|
].join("\n\n");
|
|
15
15
|
|
|
16
16
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
@@ -27,10 +27,10 @@ export const TOOLS = [
|
|
|
27
27
|
description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
|
|
28
28
|
inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
|
|
29
29
|
{ name: "findings", title: "Findings", annotations: readOnly,
|
|
30
|
-
description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes,
|
|
30
|
+
description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, the trials a verify replays and whether a verify of it can leave the noise. Applies once a run has finished. A long list comes in pages.",
|
|
31
31
|
inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
|
|
32
32
|
{ name: "verify", title: "Verify a fix",
|
|
33
|
-
description: "Replays
|
|
33
|
+
description: "Replays every trial the finding's question was read on, with the same seeds, after a change: what came back before and now, the move visible and held-out apart, and whether the evidence supports keeping the fix. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
|
|
34
34
|
inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
|
|
35
35
|
{ name: "dispute", title: "Dispute a check",
|
|
36
36
|
description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
|
|
@@ -8,6 +8,101 @@ import os
|
|
|
8
8
|
|
|
9
9
|
_FILE = os.environ.get("CORTAD_TRACE_FILE")
|
|
10
10
|
_RULES = os.environ.get("CORTAD_RULES_FILE")
|
|
11
|
+
_WRITES = os.environ.get("CORTAD_WRITES_DIR")
|
|
12
|
+
_APP_ROOT = os.environ.get("CORTAD_APP_ROOT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# What the app writes into its own folder while a run is on: each file copied once before its first
|
|
16
|
+
# write, so the command puts every one back when the run ends (lib/writes.mjs). Trials wrote carts
|
|
17
|
+
# and tickets into an app's data/*.json and the developer's own tests read them afterwards.
|
|
18
|
+
def _install_writes():
|
|
19
|
+
import builtins
|
|
20
|
+
import hashlib
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
|
|
26
|
+
roots = {os.path.realpath(_APP_ROOT), os.path.abspath(_APP_ROOT)}
|
|
27
|
+
not_data = re.compile(r"(?:^|/)(?:node_modules|\.git|\.next|\.cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|coverage|\.cortad)(?:/|$)|\.(?:log|pyc|tmp|swp)$")
|
|
28
|
+
state = {"run": None, "at": -1, "seen": set()}
|
|
29
|
+
real_open = builtins.open
|
|
30
|
+
real_os_open = os.open
|
|
31
|
+
real_rename, real_replace, real_remove, real_unlink, real_truncate = os.rename, os.replace, os.remove, os.unlink, os.truncate
|
|
32
|
+
|
|
33
|
+
# The run marker names the run; a new one starts the record over, none stops it.
|
|
34
|
+
def run_now():
|
|
35
|
+
try:
|
|
36
|
+
marker = os.path.join(_WRITES, "run")
|
|
37
|
+
at = os.stat(marker).st_mtime_ns
|
|
38
|
+
if at != state["at"]:
|
|
39
|
+
state["at"] = at
|
|
40
|
+
with real_open(marker, encoding="utf-8") as f:
|
|
41
|
+
state["run"] = f.read().strip()
|
|
42
|
+
state["seen"] = set()
|
|
43
|
+
return state["run"]
|
|
44
|
+
except Exception:
|
|
45
|
+
state["run"], state["at"], state["seen"] = None, -1, set()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def note(target):
|
|
49
|
+
try:
|
|
50
|
+
if isinstance(target, int) or target is None:
|
|
51
|
+
return
|
|
52
|
+
path = os.fspath(target)
|
|
53
|
+
if isinstance(path, bytes):
|
|
54
|
+
path = path.decode("utf-8", "replace")
|
|
55
|
+
ab = os.path.abspath(path)
|
|
56
|
+
root = next((r for r in roots if ab.startswith(r + os.sep)), None)
|
|
57
|
+
if root is None:
|
|
58
|
+
return
|
|
59
|
+
rel = os.path.relpath(ab, root)
|
|
60
|
+
if not_data.search(rel):
|
|
61
|
+
return
|
|
62
|
+
run = run_now()
|
|
63
|
+
if not run or rel in state["seen"]:
|
|
64
|
+
return
|
|
65
|
+
state["seen"].add(rel)
|
|
66
|
+
before = None
|
|
67
|
+
if os.path.isfile(ab):
|
|
68
|
+
before = os.path.join(_WRITES, "before", hashlib.sha1(rel.encode("utf-8")).hexdigest())
|
|
69
|
+
os.makedirs(os.path.dirname(before), mode=0o700, exist_ok=True)
|
|
70
|
+
shutil.copyfile(ab, before)
|
|
71
|
+
fd = real_os_open(os.path.join(_WRITES, "written.jsonl"), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
72
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
73
|
+
f.write(json.dumps({"run": run, "path": rel, "before": before}) + "\n")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def opened(file, mode="r", *a, **kw):
|
|
78
|
+
m = kw.get("mode", mode)
|
|
79
|
+
if isinstance(m, str) and any(c in m for c in "wax+"):
|
|
80
|
+
note(file)
|
|
81
|
+
return real_open(file, mode, *a, **kw)
|
|
82
|
+
|
|
83
|
+
def os_opened(path, flags, mode=0o777, *a, **kw):
|
|
84
|
+
if flags & (os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_TRUNC | os.O_CREAT):
|
|
85
|
+
note(path)
|
|
86
|
+
return real_os_open(path, flags, mode, *a, **kw)
|
|
87
|
+
|
|
88
|
+
def two(orig):
|
|
89
|
+
def moved(src, dst, *a, **kw):
|
|
90
|
+
note(src)
|
|
91
|
+
note(dst)
|
|
92
|
+
return orig(src, dst, *a, **kw)
|
|
93
|
+
return moved
|
|
94
|
+
|
|
95
|
+
def one(orig):
|
|
96
|
+
def gone(path, *a, **kw):
|
|
97
|
+
note(path)
|
|
98
|
+
return orig(path, *a, **kw)
|
|
99
|
+
return gone
|
|
100
|
+
|
|
101
|
+
builtins.open = opened
|
|
102
|
+
io.open = opened
|
|
103
|
+
os.open = os_opened
|
|
104
|
+
os.rename, os.replace = two(real_rename), two(real_replace)
|
|
105
|
+
os.remove, os.unlink, os.truncate = one(real_remove), one(real_unlink), one(real_truncate)
|
|
11
106
|
|
|
12
107
|
|
|
13
108
|
def _install():
|
|
@@ -1538,3 +1633,8 @@ if _FILE:
|
|
|
1538
1633
|
_install()
|
|
1539
1634
|
except Exception:
|
|
1540
1635
|
pass
|
|
1636
|
+
if _WRITES and _APP_ROOT:
|
|
1637
|
+
try:
|
|
1638
|
+
_install_writes()
|
|
1639
|
+
except Exception:
|
|
1640
|
+
pass
|
package/lib/replay.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export const CAPTURED = "captured";
|
|
|
13
13
|
// Never replayed: they describe one connection, not the caller.
|
|
14
14
|
const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
|
|
15
15
|
|
|
16
|
-
export function makeCapture({ work, keepSecret, onDoor }) {
|
|
16
|
+
export function makeCapture({ work, keepSecret, onDoor, writes = null, root = null }) {
|
|
17
17
|
const file = join(work, "trace.jsonl");
|
|
18
18
|
let read = 0;
|
|
19
19
|
let held = null;
|
|
@@ -38,6 +38,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
38
38
|
// The read's tools by name, file and function, written by the run the same way: the hook wraps
|
|
39
39
|
// those functions so a tool your own code runs is seen even when no model named it.
|
|
40
40
|
CORTAD_TOOLS_FILE: join(work, "tools.json"),
|
|
41
|
+
// While a run is on, the hook records each file the app writes under its own folder, with a
|
|
42
|
+
// copy from before the first write, and the run's end puts them back (lib/writes.mjs).
|
|
43
|
+
...(writes && root ? { CORTAD_WRITES_DIR: writes, CORTAD_APP_ROOT: root } : {}),
|
|
41
44
|
NODE_OPTIONS: `${base.NODE_OPTIONS ?? ""} --require ${JSON.stringify(HOOK)}`.trim(),
|
|
42
45
|
BUN_OPTIONS: `${base.BUN_OPTIONS ?? ""} --preload=${bunHook}`.trim(),
|
|
43
46
|
PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
|
package/lib/text.mjs
CHANGED
|
@@ -44,23 +44,25 @@ function planLine(p) {
|
|
|
44
44
|
return parts.length ? `${p.name}: ${parts.join(", ")}.` : `Plan: ${p.name}.`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
// as
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// What was measured leads and the number follows on it: "97 of 100" ahead of "26 of 78 checks"
|
|
48
|
+
// read as more confidence than the run had to agents on three repositories, and every one asked
|
|
49
|
+
// what was not measured and what would measure it. The number never stands without its
|
|
50
|
+
// denominator, and under the 22-reading floor it is provisional and says so.
|
|
51
|
+
function measuredLines(d) {
|
|
52
|
+
const of = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0;
|
|
53
|
+
const score = typeof d.score === "number" ? scoreText(d, of) : null;
|
|
54
|
+
const lines = of ? [`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured${score ? `; ${score}` : ""}.`] : score ? [`${upper(score)}.`] : [];
|
|
55
|
+
const groups = (d.notMeasured?.groups ?? []).filter((g) => g.count > 0 && g.said);
|
|
56
|
+
if (groups.length) lines.push(`${num(d.notMeasured.total)} not measured: ${groups.map((g) => clip(g.said, 240)).join("; ")}.`);
|
|
57
|
+
return lines;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
function
|
|
60
|
+
function scoreText(d, of) {
|
|
56
61
|
const floor = has(d.decided) && d.decided < 22;
|
|
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
62
|
// The replies that failed stand beside the number: a 100 over the replies that answered, with 62 of
|
|
61
63
|
// 89 error replies unsaid, read as a clean bill.
|
|
62
64
|
const crashed = d.replies && d.replies.crashed > 0 ? `; ${num(d.replies.crashed)} of ${plural(d.replies.total, "reply", "replies")} were failures and are not in it` : "";
|
|
63
|
-
return `${floor ? "
|
|
65
|
+
return `${floor ? "provisional score" : "score"} ${num(d.score)} of 100${of ? ` on those ${num(d.questionsAsked)}` : ""}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}${crashed}`;
|
|
64
66
|
}
|
|
65
67
|
// A row the run held back names a door and why, or, when no door could take it, only the reason.
|
|
66
68
|
const heldBackLine = (h) => h.why === "not testable here"
|
|
@@ -80,14 +82,13 @@ function runLines(d, prefix = "") {
|
|
|
80
82
|
const done = finished(d);
|
|
81
83
|
const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
|
|
82
84
|
const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
|
|
86
|
-
if (done && d.blocked) lines.push(d.blocked);
|
|
87
|
-
if (done && d.notCounted) lines.push(d.notCounted);
|
|
85
|
+
// Between two counts, what the run is doing now: four minutes of "0 of 15" read as nothing happening.
|
|
86
|
+
if (!done && d.now?.doing) lines.push(d.now.doing);
|
|
88
87
|
// A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
|
|
89
88
|
// as a clean bill to bloom's agent, beside "zero verified fixes".
|
|
90
|
-
if (done &&
|
|
89
|
+
if (done && d.kind !== "verify") lines.push(...measuredLines(d));
|
|
90
|
+
if (done && d.blocked) lines.push(d.blocked);
|
|
91
|
+
if (done && d.notCounted) lines.push(d.notCounted);
|
|
91
92
|
if (done && d.halted) lines.push(haltLine(d.halted));
|
|
92
93
|
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.`);
|
|
93
94
|
if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
|
|
@@ -122,7 +123,9 @@ function trialsLine(d) {
|
|
|
122
123
|
function readingsLine(d) {
|
|
123
124
|
if (!has(d.readings) && !has(d.questionsAsked)) return null;
|
|
124
125
|
const r = has(d.readings) ? plural(d.readings, "reading") : null;
|
|
125
|
-
|
|
126
|
+
// The line above says why the rest were not measured; without it, how many there are.
|
|
127
|
+
const never = !d.notMeasured && has(d.questionsOf) && d.questionsOf > d.questionsAsked;
|
|
128
|
+
const q = has(d.questionsAsked) ? `${plural(d.questionsAsked, "question")}${never ? ` (${num(d.questionsOf - d.questionsAsked)} of the ${num(d.questionsOf)} in the set never came up)` : ""}` : null;
|
|
126
129
|
const split = [has(d.decided) && `${num(d.decided)} decided`, has(d.unclear) && `${num(d.unclear)} unclear`].filter(Boolean).join(", ");
|
|
127
130
|
return `${r && q ? `${r} of ${q}` : r ?? `${q} asked`}${split ? `: ${split}` : ""}.`;
|
|
128
131
|
}
|
|
@@ -223,8 +226,8 @@ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n)
|
|
|
223
226
|
function findingsHead(d) {
|
|
224
227
|
if (!d.runId) return ["No run has finished on this repository yet."];
|
|
225
228
|
const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
|
|
229
|
+
lines.push(...measuredLines(d));
|
|
226
230
|
if (d.blocked) lines.push(d.blocked);
|
|
227
|
-
if (typeof d.score === "number") lines.push(scoreLine(d));
|
|
228
231
|
if (d.halted) lines.push(haltLine(d.halted));
|
|
229
232
|
const reads = readingsLine(d);
|
|
230
233
|
if (reads) lines.push(reads);
|
|
@@ -310,6 +313,9 @@ function findingBlock(f, placed) {
|
|
|
310
313
|
}
|
|
311
314
|
for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
|
|
312
315
|
if (f.replay) lines.push(` Replay: ${plural(f.replay.trials, "trial")}, verify ${f.id}`);
|
|
316
|
+
// What a verify of it can show at most, read before the allowance is spent: ai-robot's agent paid
|
|
317
|
+
// for two verifies of a finding resting on six readings, and neither could have left the noise.
|
|
318
|
+
if (f.replay?.ceiling) lines.push(` ${f.replay.ceiling}`);
|
|
313
319
|
return lines.join("\n");
|
|
314
320
|
}
|
|
315
321
|
|
package/lib/trace.cjs
CHANGED
|
@@ -38,6 +38,70 @@ if (FILE) {
|
|
|
38
38
|
fs.appendFileSync(FILE, JSON.stringify(out) + "\n", { mode: 0o600 });
|
|
39
39
|
} catch { /* the command is gone */ }
|
|
40
40
|
};
|
|
41
|
+
// What the app writes into its own folder while a run is on: each file copied once before its
|
|
42
|
+
// first write, so the command puts every one back when the run ends (lib/writes.mjs). Trials
|
|
43
|
+
// wrote carts and tickets into an app's data/*.json and the developer's own tests read them.
|
|
44
|
+
const WRITES_DIR = process.env.CORTAD_WRITES_DIR;
|
|
45
|
+
const APP_ROOT = process.env.CORTAD_APP_ROOT;
|
|
46
|
+
if (WRITES_DIR && APP_ROOT) {
|
|
47
|
+
const p = require("node:path");
|
|
48
|
+
const crypto = require("node:crypto");
|
|
49
|
+
const { fileURLToPath } = require("node:url");
|
|
50
|
+
// Not the app's data: what a build, a package manager or a log writes.
|
|
51
|
+
const NOT_DATA = /(?:^|\/)(?:node_modules|\.git|\.next|\.nuxt|\.svelte-kit|\.turbo|\.cache|\.parcel-cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|out|coverage|\.cortad)(?:\/|$)|\.(?:log|pyc|tmp|swp)$/;
|
|
52
|
+
const raw = { copyFileSync: fs.copyFileSync, appendFileSync: fs.appendFileSync, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, statSync: fs.statSync, readFileSync: fs.readFileSync };
|
|
53
|
+
const root = p.resolve(APP_ROOT);
|
|
54
|
+
let run = null, runAt = -1;
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
// The run marker names the run; a new one starts the record over, none stops it.
|
|
57
|
+
const runNow = () => {
|
|
58
|
+
try {
|
|
59
|
+
const at = raw.statSync(p.join(WRITES_DIR, "run")).mtimeMs;
|
|
60
|
+
if (at !== runAt) { runAt = at; run = raw.readFileSync(p.join(WRITES_DIR, "run"), "utf8").trim(); seen.clear(); }
|
|
61
|
+
return run;
|
|
62
|
+
} catch { run = null; runAt = -1; seen.clear(); return null; }
|
|
63
|
+
};
|
|
64
|
+
const note = (target) => {
|
|
65
|
+
try {
|
|
66
|
+
if (typeof target !== "string" && !Buffer.isBuffer(target) && !(target instanceof URL)) return;
|
|
67
|
+
const abs = p.resolve(target instanceof URL ? fileURLToPath(target) : String(target));
|
|
68
|
+
if (!abs.startsWith(root + p.sep)) return;
|
|
69
|
+
const rel = p.relative(root, abs);
|
|
70
|
+
if (NOT_DATA.test(rel)) return;
|
|
71
|
+
const id = runNow();
|
|
72
|
+
if (!id || seen.has(rel)) return;
|
|
73
|
+
seen.add(rel);
|
|
74
|
+
let before = null;
|
|
75
|
+
if (raw.existsSync(abs) && raw.statSync(abs).isFile()) {
|
|
76
|
+
before = p.join(WRITES_DIR, "before", crypto.createHash("sha1").update(rel).digest("hex"));
|
|
77
|
+
raw.mkdirSync(p.dirname(before), { recursive: true, mode: 0o700 });
|
|
78
|
+
raw.copyFileSync(abs, before);
|
|
79
|
+
}
|
|
80
|
+
raw.appendFileSync(p.join(WRITES_DIR, "written.jsonl"), JSON.stringify({ run: id, path: rel, before }) + "\n", { mode: 0o600 });
|
|
81
|
+
} catch { /* the folder is gone or the path is not a file: the write goes on as it was */ }
|
|
82
|
+
};
|
|
83
|
+
const O = fs.constants;
|
|
84
|
+
const opensForWrite = (flags) => typeof flags === "number" ? Boolean(flags & (O.O_WRONLY | O.O_RDWR | O.O_APPEND | O.O_TRUNC | O.O_CREAT))
|
|
85
|
+
: typeof flags === "string" ? /[wax+]/.test(flags) : false;
|
|
86
|
+
const wrap = (obj, name, at) => {
|
|
87
|
+
const orig = obj?.[name];
|
|
88
|
+
if (typeof orig !== "function") return;
|
|
89
|
+
const wrapped = function (...args) { try { at(args); } catch { /* recorded or not, the write goes on */ } return orig.apply(this, args); };
|
|
90
|
+
try { Object.defineProperty(wrapped, "name", { value: orig.name }); } catch { /* fine */ }
|
|
91
|
+
obj[name] = wrapped;
|
|
92
|
+
};
|
|
93
|
+
const first = (args) => note(args[0]);
|
|
94
|
+
const both = (args) => { note(args[0]); note(args[1]); };
|
|
95
|
+
const opened = (args) => { if (opensForWrite(args[1] && typeof args[1] === "object" ? args[1].flags : args[1])) note(args[0]); };
|
|
96
|
+
for (const name of ["writeFileSync", "writeFile", "appendFileSync", "appendFile", "createWriteStream", "unlinkSync", "unlink", "rmSync", "rm", "truncateSync", "truncate"]) wrap(fs, name, first);
|
|
97
|
+
for (const name of ["renameSync", "rename", "copyFileSync", "copyFile"]) wrap(fs, name, both);
|
|
98
|
+
for (const name of ["openSync", "open"]) wrap(fs, name, opened);
|
|
99
|
+
for (const name of ["writeFile", "appendFile", "unlink", "rm", "truncate"]) wrap(fs.promises, name, first);
|
|
100
|
+
for (const name of ["rename", "copyFile"]) wrap(fs.promises, name, both);
|
|
101
|
+
wrap(fs.promises, "open", opened);
|
|
102
|
+
// `import { writeFileSync } from "node:fs"` binds to the wrapped function too.
|
|
103
|
+
try { require("node:module").syncBuiltinESMExports(); } catch { /* CommonJS only */ }
|
|
104
|
+
}
|
|
41
105
|
const isBun = typeof Bun !== "undefined" && typeof Bun.serve === "function";
|
|
42
106
|
// Said once, so the command knows a message sent to this app can be seen arriving.
|
|
43
107
|
row({ hello: isBun ? "bun" : "node", pid: process.pid });
|
package/lib/verbs.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve as resolvePath, sep } from "node:path";
|
|
3
3
|
import { SHOW, showText } from "./read-text.mjs";
|
|
4
|
-
import { fieldConnectText, fieldText, findingsText, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
|
|
4
|
+
import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
|
|
5
5
|
|
|
6
6
|
// The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
|
|
7
7
|
// and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
|
|
@@ -18,7 +18,7 @@ export const READY_MS = 240_000;
|
|
|
18
18
|
// `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
|
|
19
19
|
// startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
|
|
20
20
|
// the latest run at the time, so a newer run on the server outranks a stale file.
|
|
21
|
-
export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
21
|
+
export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, writes = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
22
22
|
const call = async (method, path, body, timeoutMs = 30_000) => {
|
|
23
23
|
const key = typeof token === "function" ? token() : token;
|
|
24
24
|
if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
|
|
@@ -54,6 +54,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
54
54
|
const res = await call("POST", "/mcp/run", args);
|
|
55
55
|
if (res.status === 202 || (res.ok && res.data?.joined)) {
|
|
56
56
|
pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
|
|
57
|
+
try { writes?.mark(res.data.jobId); } catch { /* the app's writes go unrecorded this run */ }
|
|
57
58
|
return { text: startedText(res.data, kind, args.findingId), data: res.data };
|
|
58
59
|
}
|
|
59
60
|
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
@@ -111,7 +112,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
111
112
|
const out = failed(res);
|
|
112
113
|
return { ...out, text: `${out.text}\nnext: run_status ${id}` };
|
|
113
114
|
}
|
|
114
|
-
|
|
115
|
+
// The run is over: what the app wrote into its folder during it is put back, said before the next step.
|
|
116
|
+
let back = "";
|
|
117
|
+
if (writes && finished(res.data)) { try { back = writes.putBack(); } catch { back = ""; } }
|
|
118
|
+
return { text: back ? withBack(runText(res.data), back) : runText(res.data), data: res.data };
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
// Every server page of a run's findings, so the pages the agent reads are cut by size here.
|
|
@@ -182,6 +186,13 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
// Only paths inside the repository are looked at; a path from the server never reaches outside it.
|
|
189
|
+
// The put-back line sits above the next step, never after it.
|
|
190
|
+
const withBack = (text, back) => {
|
|
191
|
+
const lines = text.split("\n");
|
|
192
|
+
const at = lines.findIndex((l) => l.startsWith("next:"));
|
|
193
|
+
return at < 0 ? `${text}\n${back}` : [...lines.slice(0, at), back, ...lines.slice(at)].join("\n");
|
|
194
|
+
};
|
|
195
|
+
|
|
185
196
|
export function changedLine({ root, since, runId, paths, lines }) {
|
|
186
197
|
const t = Date.parse(since ?? "");
|
|
187
198
|
if (!Number.isFinite(t)) return "";
|
package/lib/writes.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// What your app writes into its own folder during a run is put back when the run ends. A run
|
|
2
|
+
// creates carts, tickets and uploads by the hundred; an app that keeps them in files beside its
|
|
3
|
+
// code kept every one after the run, and the developer's own tests read them. The hook the app
|
|
4
|
+
// was started with records each file the app writes while a run is on, with a copy from before
|
|
5
|
+
// its first write, and the command that reads the run's end puts every one back and says which.
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const writesDirOf = (home) => join(home, "writes");
|
|
10
|
+
|
|
11
|
+
// A run has started: from now until it ends, the hook records what the app writes. A record that
|
|
12
|
+
// is already open is kept when `keep` is set: an app started again mid-run keeps the copies made
|
|
13
|
+
// before it went down.
|
|
14
|
+
export function markRun(dir, jobId, { keep = false } = {}) {
|
|
15
|
+
if (keep && existsSync(join(dir, "run"))) return;
|
|
16
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* fresh */ }
|
|
17
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
18
|
+
writeFileSync(join(dir, "run"), String(jobId), { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The run has ended: every file written since the record began is put back, and the line says
|
|
22
|
+
// which. The record begins when the command starts the app (the first run starts itself on the
|
|
23
|
+
// server's side, so no verb marks it) and again at every run a verb starts, so what a person wrote
|
|
24
|
+
// between two runs is dropped from the record rather than undone.
|
|
25
|
+
export function putBack(dir, root) {
|
|
26
|
+
let rows = [];
|
|
27
|
+
try {
|
|
28
|
+
rows = readFileSync(join(dir, "written.jsonl"), "utf8").split("\n").filter(Boolean)
|
|
29
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
30
|
+
} catch { /* the run wrote nothing */ }
|
|
31
|
+
const base = resolve(root);
|
|
32
|
+
const done = [];
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
if (typeof row.path !== "string" || done.includes(row.path)) continue;
|
|
35
|
+
const abs = resolve(base, row.path);
|
|
36
|
+
if (!abs.startsWith(base + sep)) continue;
|
|
37
|
+
try {
|
|
38
|
+
if (row.before) { if (!existsSync(row.before)) continue; mkdirSync(dirname(abs), { recursive: true }); copyFileSync(row.before, abs); }
|
|
39
|
+
else if (existsSync(abs)) unlinkSync(abs);
|
|
40
|
+
done.push(row.path);
|
|
41
|
+
} catch { /* left as the run left it, and not named as put back */ }
|
|
42
|
+
}
|
|
43
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* gone */ }
|
|
44
|
+
return saidOf(done);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saidOf(paths) {
|
|
48
|
+
if (!paths.length) return "";
|
|
49
|
+
const named = paths.slice(0, 3).join(", ") + (paths.length > 3 ? ` and ${paths.length - 3} more` : "");
|
|
50
|
+
return paths.length === 1
|
|
51
|
+
? `Your app wrote to ${named} during this run; it was put back to what it was before it.`
|
|
52
|
+
: `Your app wrote to ${named} during this run; all were put back to what they were before it.`;
|
|
53
|
+
}
|
package/local.mjs
CHANGED
|
@@ -33,6 +33,7 @@ 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
35
|
import { keepData } from "./lib/data.mjs";
|
|
36
|
+
import { markRun, writesDirOf } from "./lib/writes.mjs";
|
|
36
37
|
|
|
37
38
|
const argv = process.argv.slice(2);
|
|
38
39
|
// The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
|
|
@@ -768,6 +769,9 @@ function dataOnce(started) {
|
|
|
768
769
|
|
|
769
770
|
async function start(waitMs) {
|
|
770
771
|
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
772
|
+
// The record of what the app writes opens with the app: the first run starts itself on the
|
|
773
|
+
// server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
|
|
774
|
+
try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
|
|
771
775
|
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 });
|
|
772
776
|
const mine = child;
|
|
773
777
|
let seen = "";
|
|
@@ -907,7 +911,7 @@ const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) sec
|
|
|
907
911
|
identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
|
|
908
912
|
// One message sent in their own app tells us the door for certain. The route and the body go up,
|
|
909
913
|
// masked like everything else; the sign-in that message carried stays here.
|
|
910
|
-
capture = makeCapture({ work, keepSecret, onDoor: (door) => {
|
|
914
|
+
capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, onDoor: (door) => {
|
|
911
915
|
let body = door.body;
|
|
912
916
|
try { body = JSON.parse(mask(JSON.stringify(door.body))); } catch { /* sent as it is */ }
|
|
913
917
|
call("POST", `/local/${box}/captured`, { ...door, body }).catch(() => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.1-rc.
|
|
3
|
+
"version": "0.3.1-rc.6",
|
|
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"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"lib/text.mjs",
|
|
31
31
|
"lib/trace.cjs",
|
|
32
32
|
"lib/verbs.mjs",
|
|
33
|
+
"lib/writes.mjs",
|
|
33
34
|
"lib/words.mjs",
|
|
34
35
|
"local.mjs",
|
|
35
36
|
"skill/SKILL.md",
|
package/skill/SKILL.md
CHANGED
|
@@ -49,16 +49,17 @@ Take the person through them worst first, each with its file and line. A long li
|
|
|
49
49
|
|
|
50
50
|
## Fixing one finding
|
|
51
51
|
|
|
52
|
+
Each finding ends with what a verify of it can show. When a verify cannot leave the noise, the person hears that before one is spent: more trials in that situation, from the next full run, is what adds readings.
|
|
53
|
+
|
|
52
54
|
1. One change, in the file and near the line the finding names.
|
|
53
55
|
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
|
-
3. Read the
|
|
55
|
-
- `
|
|
56
|
-
- `
|
|
57
|
-
- `
|
|
56
|
+
3. Read the verify's text. It opens with what came back on the replayed trials before and now (errors, refusals, replies that got no answer), so a fix that broke the endpoint shows first. It closes on whether the evidence supports keeping the fix:
|
|
57
|
+
- `supports keeping the fix`: the behavior moved outside the noise, with the held-out line moving too or `no pair`. The change stays.
|
|
58
|
+
- `does not support keeping the fix`: the pairs could have shown a fix and nothing moved, or it moved the wrong way. The file goes back to how it was.
|
|
59
|
+
- `cannot tell`: not a verdict against the change, and the sentence says why and what would. A second verify of the same trials pairs the same replies again and adds nothing. A change the app's own tests support may stay, said as not yet verified by Cortad.
|
|
60
|
+
- A reply that failed before and no longer meets the question's condition is counted as gone on its own, not as a pass.
|
|
58
61
|
- A verify that played no trial says so and compares nothing; the file stays as it was left.
|
|
59
|
-
- `Verification unavailable:` the two runs read the question on no common trial, and the line says why (the replay took another turn, the trial halted, the question's condition did not hold). It is not a verdict: the file stays, said as not yet verified by Cortad.
|
|
60
62
|
- 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.
|
|
61
|
-
- `regressed`: the file goes back.
|
|
62
63
|
4. Then the next finding.
|
|
63
64
|
|
|
64
65
|
The trials, checks, seeds and held-out set belong to Cortad, and the app answers Cortad's simulated users the way it answers anyone. A number moves when the app's behavior moves.
|
|
@@ -105,11 +105,12 @@ Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
|
|
|
105
105
|
next: run_status 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
Finished:
|
|
108
|
+
Finished. Tell the person what was measured before the score, and what the rest waits on:
|
|
109
109
|
|
|
110
110
|
```
|
|
111
111
|
Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: finished, 51 of 51 trials played.
|
|
112
|
-
|
|
112
|
+
112 of 140 checks measured; score 71 of 100 on those 112, interval 64 to 78.
|
|
113
|
+
28 not measured: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no trial yet.
|
|
113
114
|
3 findings.
|
|
114
115
|
1,204 readings of 112 questions.
|
|
115
116
|
For the person: the report is at https://cortad.com/lab
|
|
@@ -138,7 +139,7 @@ Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 12 of 12 trials played.
|
|
|
138
139
|
Verify of finding:1 at apps/api/src/agent/prompt.ts:41.
|
|
139
140
|
Visible trials: held 3 of 12 readings before, 11 of 12 after; improved, +67 points, interval 41 to 85.
|
|
140
141
|
Held-out trials: held 2 of 8 readings before, 7 of 8 after; improved, +62 points, interval 30 to 88.
|
|
141
|
-
3 of 12 readings held before and 11 of 12 now.
|
|
142
|
+
On the 12 trials replayed, 0 of 12 replies were errors before and 0 of 12 now. 3 of 12 readings held before and 11 of 12 now. On the 12 readings both runs settled, it gained 67 points, and the real move is between 41 points and 85 points. That is outside the 18 points that come back different on temperature alone. The evidence supports keeping the fix: 3 of 12 held before and 11 of 12 now, 67 points up.
|
|
142
143
|
For the person: the report is at https://cortad.com/lab
|
|
143
144
|
next: findings
|
|
144
145
|
```
|
|
@@ -161,7 +162,7 @@ A verify with no pair: the two runs read the finding's question on no common tri
|
|
|
161
162
|
Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 1 of 1 trial played.
|
|
162
163
|
Verify of finding:2 at backend/tools/refunds.py.
|
|
163
164
|
Held-out trials: no pair for this question.
|
|
164
|
-
|
|
165
|
+
On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 3 now. No reading of this question pairs between the two runs. Not paired: 1 trial (t-5d10): the replay took a different turn at reply 2, and the question was not asked on the replay because its condition did not hold (the customer has reached this step of the journey: Agent states amount, asks consent). The evidence cannot tell whether to keep the fix: no reading of this question pairs between the two runs. This finding rests on 2 readings, 2 of them failures, so a verify of it cannot leave the noise: even if both failures hold on the replay, 2 readings cannot tell that apart from chance. About 4 readings from more trials that reach this situation could; a second replay of the same trials pairs the same replies again and adds nothing.
|
|
165
166
|
For the person: the report is at https://cortad.com/lab
|
|
166
167
|
next: findings
|
|
167
168
|
```
|
|
@@ -172,7 +173,8 @@ Worst first, grouped by the line the rule lives at. A list longer than one page
|
|
|
172
173
|
|
|
173
174
|
```
|
|
174
175
|
Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: 3 findings.
|
|
175
|
-
|
|
176
|
+
112 of 140 checks measured; score 71 of 100 on those 112, interval 64 to 78.
|
|
177
|
+
28 not measured: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no trial yet.
|
|
176
178
|
1,204 readings of 112 questions: 1,150 decided, 54 unclear.
|
|
177
179
|
3 findings stand in the 12 situations you can read, where 412 of 519 readings held.
|
|
178
180
|
2 findings stand in 3 situations kept back from you, where 98 of 130 readings held. A fix is graded on those too.
|
|
@@ -186,6 +188,7 @@ finding:1 Does the reply refuse to state a refund policy the product does not p
|
|
|
186
188
|
Decided by a model in 12 readings.
|
|
187
189
|
Reply 2: "Yes, refunds are processed within 3 business days." (confidence 0.94, trial t-41c2)
|
|
188
190
|
Replay: 12 trials, verify finding:1
|
|
191
|
+
This finding rests on 12 readings, 9 of them failures: a verify shows the fix if at least 4 of the 9 hold on the replay.
|
|
189
192
|
finding:3 Does the reply keep the refund answer to what the billing page says?
|
|
190
193
|
Endpoint: POST /api/chat
|
|
191
194
|
Situation: plan paid, journey billing question
|
|
@@ -194,6 +197,7 @@ finding:3 Does the reply keep the refund answer to what the billing page says?
|
|
|
194
197
|
Decided by a model in 9 readings.
|
|
195
198
|
Reply 1: "You can get a full refund any time in the first 60 days." (confidence 0.81, trial t-77a0)
|
|
196
199
|
Replay: 9 trials, verify finding:3
|
|
200
|
+
This finding rests on 9 readings, 4 of them failures: a verify shows the fix only if all 4 failures hold on the replay.
|
|
197
201
|
|
|
198
202
|
1 finding at apps/api/src/agent/system.ts:12
|
|
199
203
|
finding:4 Does the reply stay in the language the student writes in?
|
|
@@ -205,6 +209,7 @@ finding:4 Does the reply stay in the language the student writes in?
|
|
|
205
209
|
Log: the student wrote in Spanish
|
|
206
210
|
Log: the reply language was detected as English
|
|
207
211
|
Replay: 10 trials, verify finding:4
|
|
212
|
+
This finding rests on 10 readings, 4 of them failures: a verify shows the fix only if all 4 failures hold on the replay.
|
|
208
213
|
```
|
|
209
214
|
|
|
210
215
|
## verify
|