cortad 0.3.1-rc.7 → 0.3.1-rc.9

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 CHANGED
@@ -2,6 +2,7 @@ import { closeSync, openSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { makeCode } from "./fresh.mjs";
5
6
  import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
6
7
  import { markRun, putBack, writesDirOf } from "./writes.mjs";
7
8
  import { serveMcp } from "./mcp.mjs";
@@ -18,6 +19,9 @@ import { makeVerbs, READY_MS } from "./verbs.mjs";
18
19
  const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
19
20
  const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
20
21
  const WAITER = "run-when-up";
22
+ // How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
23
+ // about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
24
+ const RESTART_MS = { mcp: 45_000, shell: 150_000 };
21
25
  export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
22
26
  export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
23
27
 
@@ -44,6 +48,7 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
44
48
  token: () => readToken(project),
45
49
  pending,
46
50
  startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
51
+ code: makeCode({ project, root, waitMs: mcp ? RESTART_MS.mcp : RESTART_MS.shell }),
47
52
  });
48
53
 
49
54
  if (command === WAITER) {
package/lib/data.mjs CHANGED
@@ -58,7 +58,7 @@ const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w
58
58
  const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
59
59
  const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
60
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$/;
61
+ export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
62
62
  // A folder bigger than this is not copied: said instead, so the person decides.
63
63
  const FOLDER_CAP = 1024 ** 3;
64
64
 
package/lib/fresh.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // Which code a run plays. The command holding the app up (local.mjs) notes in app.json, each time
2
+ // the app starts, when it started, whether it started it, whether it reloads on save, and what each
3
+ // source file said then. Before a run is asked for, the verbs compare the files as they stand with
4
+ // that note: an app without a reloader is started again first, and the run's text says which code
5
+ // it played. yunqiao's `python run.py` served the old routes through a verify its agent had just
6
+ // fixed them for.
7
+ import { createHash } from "node:crypto";
8
+ import { readFileSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { TEST } from "./data.mjs";
11
+ import { homeOf, readApp, readRunner } from "./home.mjs";
12
+ import { writesDirOf, writtenPaths } from "./writes.mjs";
13
+
14
+ // A command, or an app's own output, that says it picks a saved file up by itself. Not a compiler
15
+ // in watch mode (tsc --watch rebuilds, the server it feeds keeps running), and not vite, which
16
+ // reloads the page and never the server behind it.
17
+ export const RELOADER = new RegExp([
18
+ String.raw`--reload\b`, String.raw`\bnodemon\b`, String.raw`\bts-node-dev\b`, String.raw`\btsx\s+watch\b`,
19
+ String.raw`\b(?:node|tsx|bun|deno|nest)\b[^&|;\n]*\s--(?:watch|hot)\b`,
20
+ String.raw`\b(?:next|nuxt|astro|remix|fastapi)\s+dev\b`, String.raw`\brails\s+s(?:erver)?\b`,
21
+ String.raw`\bmanage\.py\s+runserver\b(?![^&|;\n]*--noreload)`, String.raw`\bflask\b[^&|;\n]*\s--(?:debug|reload)\b`,
22
+ String.raw`(?:^|[;&|\n]\s*)air\b`, String.raw`\bwatchexec\b`, String.raw`\bcargo[- ]watch\b`,
23
+ "Will watch for changes", "Started reloader process", "watching for file changes", String.raw`Restarting with (?:stat|watchdog|inotify|fsevents)`,
24
+ String.raw`\[nodemon\]`, "Restarting '", "WatchFiles detected changes", String.raw`Reloading\.\.\.`, "changed, reloading",
25
+ ].join("|"), "i");
26
+
27
+ // `npm run dev` says nothing by itself; the script it names does, and the one that names.
28
+ export function commandText(cmd, dir) {
29
+ let scripts = {};
30
+ try { scripts = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).scripts ?? {}; } catch { /* not a Node app */ }
31
+ let text = String(cmd ?? "");
32
+ for (let i = 0, at = text; i < 3; i++) {
33
+ const name = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/.exec(at)?.[1];
34
+ if (!name || typeof scripts[name] !== "string") break;
35
+ at = scripts[name];
36
+ text += `\n${at}`;
37
+ }
38
+ return text;
39
+ }
40
+
41
+ // ps's etime, [[dd-]hh:]mm:ss, in milliseconds.
42
+ export function elapsedMs(etime) {
43
+ const [days, clock] = String(etime).includes("-") ? String(etime).split("-") : [0, String(etime)];
44
+ const [s = 0, m = 0, h = 0] = clock.split(":").map(Number).reverse();
45
+ return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
46
+ }
47
+
48
+ // Not the app's code: this command's own line and hooks, the coding agents' folders, and tests.
49
+ const NOT_APP = /^(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$|^\.(?:claude|codex|cursor|agents)\//;
50
+ const digestOf = (file) => { try { return createHash("sha1").update(readFileSync(file)).digest("hex"); } catch { return ""; } };
51
+
52
+ // What each source file says now. Names alone for an app this command did not start: what it
53
+ // loaded is unknown, so only a file's time can be compared.
54
+ export const sourceOf = (root, files, hash = true) =>
55
+ Object.fromEntries(files.filter((rel) => !NOT_APP.test(rel) && !TEST.test(rel)).map((rel) => [rel, hash ? digestOf(join(root, rel)) : ""]));
56
+
57
+ // The files that differ from what the app started with, newest first, and the newest change of
58
+ // all. A file rewritten with the bytes it had (a run's writes put back) has not changed.
59
+ export function changesOf({ root, app, skip = new Set() }) {
60
+ const since = Date.parse(app.startedAt);
61
+ const changed = [];
62
+ let last = null;
63
+ for (const [rel, was] of Object.entries(app.files ?? {})) {
64
+ if (skip.has(rel)) continue;
65
+ let at;
66
+ try { at = statSync(join(root, rel)).mtimeMs; } catch { continue; }
67
+ if (at > since && was && digestOf(join(root, rel)) === was) continue;
68
+ if (at > since) changed.push({ path: rel, at });
69
+ if (!last || at > last.at) last = { path: rel, at };
70
+ }
71
+ return { changed: changed.sort((a, b) => b.at - a.at), last };
72
+ }
73
+
74
+ // What the run's text says about the code it played (lib/text.mjs testedLine).
75
+ export const testedOf = (app, { changed, last }) => ({
76
+ startedAt: app.startedAt,
77
+ ...(last ? { change: { path: last.path, at: new Date(last.at).toISOString() } } : {}),
78
+ ...(app.reloads ? { reloads: true } : changed.length && !app.own ? { stale: true } : {}),
79
+ });
80
+
81
+ // The verbs' side. `fresh` starts the app again through the process holding it up when its code
82
+ // changed and nothing reloaded it, and waits until it answers on the new code; `tested` is what
83
+ // the run is about to play.
84
+ export function makeCode({ project, root, waitMs, base, signal = (pid) => process.kill(pid, "SIGUSR2"), now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
85
+ const current = () => {
86
+ const app = readApp(project, base);
87
+ return app?.startedAt && app.runner === readRunner(project, base)?.pid ? app : null;
88
+ };
89
+ const changes = (app) => changesOf({ root, app, skip: writtenPaths(writesDirOf(homeOf(project, base))) });
90
+ const tested = () => { const app = current(); return app ? testedOf(app, changes(app)) : null; };
91
+
92
+ const fresh = async () => {
93
+ const app = current();
94
+ const { changed } = app ? changes(app) : { changed: [] };
95
+ if (!changed.length || !app.own || app.reloads) return {};
96
+ const asked = now();
97
+ try { signal(app.runner); } catch { return { error: "The process holding your app up could not be asked to start it again for your change." }; }
98
+ while (now() - asked < waitMs) {
99
+ await sleep(500);
100
+ const next = readApp(project, base);
101
+ if (next?.failedAt && Date.parse(next.failedAt) >= asked) return { error: `Your app was started again to play your change and did not come back. ${next.error ?? ""}`.trim() };
102
+ if (next?.startedAt && Date.parse(next.startedAt) >= asked) return { restarted: changed.map((c) => c.path) };
103
+ if (!readRunner(project, base)) return { error: "The process holding your app up ended while starting it again for your change." };
104
+ }
105
+ return { error: `Your app was started again to play your change and has not answered after ${Math.round(waitMs / 1000)} seconds.` };
106
+ };
107
+ return { fresh, tested };
108
+ }
package/lib/home.mjs CHANGED
@@ -11,6 +11,7 @@ export const projectOf = (root) => createHash("sha256").update(realpathSync(root
11
11
  export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
12
12
 
13
13
  const read = (file) => { try { return readFileSync(file, "utf8").trim() || null; } catch { return null; } };
14
+ const readJson = (file) => { const raw = read(file); try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
14
15
  const write = (dir, name, text) => { mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(join(dir, name), text, { mode: 0o600 }); };
15
16
 
16
17
  export const readToken = (project, base) => read(join(homeOf(project, base), "token"));
@@ -32,10 +33,12 @@ export function readRunner(project, base) {
32
33
  export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
33
34
  export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
34
35
 
35
- export function readPending(project, base) {
36
- const raw = read(join(homeOf(project, base), "pending.json"));
37
- try { return raw ? JSON.parse(raw) : null; } catch { return null; }
38
- }
36
+ // The app that runner holds up, as it noted it last (lib/fresh.mjs): when it started, whether that
37
+ // runner started it, and what its source files said then.
38
+ export const readApp = (project, base) => readJson(join(homeOf(project, base), "app.json"));
39
+ export const writeApp = (project, app, base) => write(homeOf(project, base), "app.json", JSON.stringify(app));
40
+
41
+ export const readPending = (project, base) => readJson(join(homeOf(project, base), "pending.json"));
39
42
  export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
40
43
 
41
44
  export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
package/lib/mcp.mjs CHANGED
@@ -30,7 +30,7 @@ export const TOOLS = [
30
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 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.",
33
+ description: "Replays every trial the finding's question was read on, with the same seeds, after a change, and when those read it too few times for a rate, plays up to 12 more trials in the finding's situation: what came back before and now, the move visible and held-out apart (reply by reply, or the question's failure rate on the same trials where the replies cannot be paired), and whether the evidence supports keeping the fix. Answers within a second and says how many new trials it plays; 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.",
@@ -579,7 +579,7 @@ def _install():
579
579
  # person's latest message on, and Anthropic document and search_result blocks. The app's own
580
580
  # instructions are not passages: only what sits under such a label is.
581
581
  passage_text, passages_max = 3000, 6
582
- material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b|检索|知识|参考资料|资料|上下文|文档|背景", re.I)
582
+ material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关", re.I)
583
583
  heading = re.compile(r"^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$")
584
584
  # An inline label is a noun phrase that ends in the material word ("context:", "background:",
585
585
  # "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
@@ -587,6 +587,36 @@ def _install():
587
587
  named = re.compile(r"(?:" + material.pattern + r")\s*$", re.I)
588
588
  tagged = re.compile(r"<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)</\1>")
589
589
 
590
+ # A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
591
+ # that open on markdown headings holds every chunk, where stopping at its first paragraph kept
592
+ # one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
593
+ # label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
594
+ # "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
595
+ # between its paragraphs.
596
+ markdown = re.compile(r"^\s*(#{1,6})\s")
597
+
598
+ def ends_block(label, line):
599
+ if not heading.match(line):
600
+ return False
601
+ outer, inner = markdown.match(label), markdown.match(line)
602
+ return not inner or bool(outer and len(inner.group(1)) <= len(outer.group(1)))
603
+
604
+ def asks(para):
605
+ m = inline.match(para.split("\n")[0])
606
+ return bool(m and not named.search(m.group(1)))
607
+
608
+ # A heading inside a fenced block is the material's own text: a README chunk's "## Features".
609
+ fence = re.compile(r"^\s*(?:```|~~~)", re.M)
610
+
611
+ def add_block(add, name, paras):
612
+ text = ""
613
+ for p in paras:
614
+ if text and len(text) + len(p) + 2 > passage_text:
615
+ add(name, text)
616
+ text = ""
617
+ text += ("\n\n" if text else "") + p
618
+ add(name, text)
619
+
590
620
  def labelled(text, add):
591
621
  def lift(m):
592
622
  if material.search(m.group(1).replace("_", " ")):
@@ -599,13 +629,14 @@ def _install():
599
629
  lines = paras[i].split("\n")
600
630
  head = lines[0]
601
631
  if heading.match(head) and material.search(head):
602
- body = "\n".join(lines[1:])
603
- # A heading alone on its paragraph labels what follows, up to the next heading.
604
- if not body.strip():
605
- while i + 1 < len(paras) and len(body) < passage_text and not heading.match(paras[i + 1].split("\n")[0]):
606
- i += 1
607
- body += ("\n\n" if body else "") + paras[i]
608
- add(re.sub(r"[#::\[\]=]", "", head).strip(), body)
632
+ block = ["\n".join(lines[1:])]
633
+ fenced = len(fence.findall(block[0])) % 2 == 1
634
+ while i + 1 < len(paras) and (fenced or (not ends_block(head, paras[i + 1].split("\n")[0]) and not (i + 2 == len(paras) and asks(paras[i + 1])))):
635
+ i += 1
636
+ block.append(paras[i])
637
+ if len(fence.findall(paras[i])) % 2 == 1:
638
+ fenced = not fenced
639
+ add_block(add, re.sub(r"[#::\[\]=]", "", head).strip(), block)
609
640
  else:
610
641
  label = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
611
642
  if label:
@@ -855,7 +886,76 @@ def _install():
855
886
  except Exception:
856
887
  return ""
857
888
 
858
- def meter(url, sent, status, kind, raw, turn=None):
889
+ # Which of the app's own lines made a model call, nearest first: the run names the code path that
890
+ # ran by it, where the read could only name the door. Its frames on this stack outside installed
891
+ # packages, then, when the call runs in a task another task awaits (LangChain runs each step of a
892
+ # chain as its own task), the awaiting task's. Relative to the folder the app was started in.
893
+ root = os.getcwd().replace(os.sep, "/").rstrip("/") + "/"
894
+ installed = re.compile(r"/(?:site-packages|dist-packages|\.venv|venv|__pypackages__)/|/lib/python\d")
895
+
896
+ def ours(frame, out):
897
+ f = frame.f_code.co_filename.replace(os.sep, "/")
898
+ if f.startswith(root) and not installed.search(f[len(root) - 1:]):
899
+ at = "%s:%d" % (f[len(root):], frame.f_lineno)
900
+ if at not in out:
901
+ out.append(at)
902
+ return len(out) >= 3
903
+
904
+ # The task that waits on this one: its wake-up is among this one's callbacks, directly, or behind
905
+ # the future an asyncio.gather callback closes over.
906
+ def awaiting(fut, depth=0):
907
+ for cb in getattr(fut, "_callbacks", None) or ():
908
+ fn = cb[0] if isinstance(cb, tuple) else cb
909
+ owner = getattr(fn, "__self__", None)
910
+ if owner is not None and owner is not fut and hasattr(owner, "get_coro"):
911
+ return owner
912
+ for cell in (getattr(fn, "__closure__", None) or ()) if depth < 2 else ():
913
+ try:
914
+ inner = cell.cell_contents
915
+ except ValueError:
916
+ continue
917
+ found = awaiting(inner, depth + 1) if inner is not fut and hasattr(inner, "_callbacks") else None
918
+ if found is not None:
919
+ return found
920
+ return None
921
+
922
+ # An embedding call answers nobody, so where it was made names no path a reply came down.
923
+ embedding = re.compile(r"/embeddings$", re.I)
924
+
925
+ def callers_now(url):
926
+ if embedding.search(urlsplit(str(url)).path or ""):
927
+ return None
928
+ out = []
929
+ try:
930
+ # Below the event loop's own frames is whatever started the loop, not this call's path.
931
+ frame = sys._getframe(1)
932
+ while frame is not None and not frame.f_code.co_filename.replace(os.sep, "/").endswith("/asyncio/events.py"):
933
+ if ours(frame, out):
934
+ return out
935
+ frame = frame.f_back
936
+ aio = sys.modules.get("asyncio")
937
+ try:
938
+ task = aio.current_task() if aio else None
939
+ except RuntimeError:
940
+ task = None
941
+ for _ in range(8):
942
+ task = awaiting(task) if task is not None else None
943
+ if task is None:
944
+ break
945
+ chain, coro = [], task.get_coro()
946
+ while coro is not None and len(chain) < 64:
947
+ f = getattr(coro, "cr_frame", None) or getattr(coro, "gi_frame", None) or getattr(coro, "ag_frame", None)
948
+ if f is not None:
949
+ chain.append(f)
950
+ coro = getattr(coro, "cr_await", None) or getattr(coro, "gi_yieldfrom", None) or getattr(coro, "ag_await", None)
951
+ for f in reversed(chain):
952
+ if ours(f, out):
953
+ return out
954
+ except Exception:
955
+ pass
956
+ return out or None
957
+
958
+ def meter(url, sent, status, kind, raw, turn=None, caller=None):
859
959
  try:
860
960
  tokens, model, events = read_reply(kind, (raw or "")[:reply_max])
861
961
  parts = urlsplit(str(url))
@@ -878,6 +978,8 @@ def _install():
878
978
  passages = passages_in(as_text)
879
979
  if passages:
880
980
  row["passages"] = passages
981
+ if caller:
982
+ row["caller"] = caller
881
983
  write({"call": row})
882
984
  except Exception:
883
985
  pass
@@ -1425,7 +1527,7 @@ def _install():
1425
1527
  finally:
1426
1528
  self.finish()
1427
1529
 
1428
- def watch(request, response, sent):
1530
+ def watch(request, response, sent, caller):
1429
1531
  model = is_model_call(request.url)
1430
1532
  if not model and not is_retrieval(request.url):
1431
1533
  dep(request.url, response.status_code)
@@ -1433,13 +1535,13 @@ def _install():
1433
1535
  kind = response.headers.get("content-type", "")
1434
1536
  if getattr(response, "_content", None) is not None:
1435
1537
  if model:
1436
- meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"))
1538
+ meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"), caller=caller)
1437
1539
  else:
1438
1540
  dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")))
1439
1541
  return response
1440
1542
  turn = turn_now()
1441
1543
  if model:
1442
- done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn)
1544
+ done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn, caller)
1443
1545
  else:
1444
1546
  done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), turn=turn)
1445
1547
  stream = response.stream
@@ -1448,7 +1550,7 @@ def _install():
1448
1550
  elif hasattr(stream, "__iter__"):
1449
1551
  response.stream = SyncKept(stream, done)
1450
1552
  elif model:
1451
- meter(request.url, sent, response.status_code, kind, "")
1553
+ meter(request.url, sent, response.status_code, kind, "", caller=caller)
1452
1554
  else:
1453
1555
  dep(request.url, response.status_code)
1454
1556
  return response
@@ -1458,6 +1560,7 @@ def _install():
1458
1560
  if cls is module.Client:
1459
1561
  def sync_send(self, request, *a, _send=send, **k):
1460
1562
  sent = sent_of(request)
1563
+ caller = callers_now(request.url) if is_model_call(request.url) else None
1461
1564
  try:
1462
1565
  note(request.url, sent)
1463
1566
  except Exception:
@@ -1466,18 +1569,19 @@ def _install():
1466
1569
  response = _send(self, request, *a, **k)
1467
1570
  except Exception as err:
1468
1571
  if is_model_call(request.url):
1469
- meter(request.url, sent, 0, "", "")
1572
+ meter(request.url, sent, 0, "", "", caller=caller)
1470
1573
  else:
1471
1574
  dep(request.url, 0, type(err).__name__)
1472
1575
  raise
1473
1576
  try:
1474
- return watch(request, response, sent)
1577
+ return watch(request, response, sent, caller)
1475
1578
  except Exception:
1476
1579
  return response
1477
1580
  cls.send = sync_send
1478
1581
  else:
1479
1582
  async def async_send(self, request, *a, _send=send, **k):
1480
1583
  sent = sent_of(request)
1584
+ caller = callers_now(request.url) if is_model_call(request.url) else None
1481
1585
  try:
1482
1586
  note(request.url, sent)
1483
1587
  except Exception:
@@ -1486,12 +1590,12 @@ def _install():
1486
1590
  response = await _send(self, request, *a, **k)
1487
1591
  except Exception as err:
1488
1592
  if is_model_call(request.url):
1489
- meter(request.url, sent, 0, "", "")
1593
+ meter(request.url, sent, 0, "", "", caller=caller)
1490
1594
  else:
1491
1595
  dep(request.url, 0, type(err).__name__)
1492
1596
  raise
1493
1597
  try:
1494
- return watch(request, response, sent)
1598
+ return watch(request, response, sent, caller)
1495
1599
  except Exception:
1496
1600
  return response
1497
1601
  cls.send = async_send
@@ -1500,6 +1604,7 @@ def _install():
1500
1604
  send = module.Session.send
1501
1605
 
1502
1606
  def sent(self, request, *a, **k):
1607
+ caller = callers_now(request.url) if is_model_call(request.url) else None
1503
1608
  try:
1504
1609
  note(request.url, request.body)
1505
1610
  except Exception:
@@ -1517,7 +1622,7 @@ def _install():
1517
1622
  if is_model_call(request.url):
1518
1623
  # A streamed reply is counted as a call whose counts were not read.
1519
1624
  raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
1520
- meter(request.url, text(request.body), response.status_code, response.headers.get("content-type", ""), raw)
1625
+ meter(request.url, text(request.body), response.status_code, response.headers.get("content-type", ""), raw, caller=caller)
1521
1626
  except Exception:
1522
1627
  pass
1523
1628
  return response
@@ -1540,7 +1645,7 @@ def _install():
1540
1645
  dep(str_or_url, 0, type(err).__name__)
1541
1646
  raise
1542
1647
  if is_model_call(str_or_url):
1543
- response._cortad = (str_or_url, text(body), turn_now())
1648
+ response._cortad = (str_or_url, text(body), turn_now(), callers_now(str_or_url))
1544
1649
  elif is_retrieval(str_or_url):
1545
1650
  response._cortad_dep = (str_or_url, turn_now())
1546
1651
  else:
@@ -1559,7 +1664,7 @@ def _install():
1559
1664
  call = getattr(self, "_cortad", None)
1560
1665
  if call:
1561
1666
  self._cortad = None
1562
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2])
1667
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2], call[3])
1563
1668
  return raw
1564
1669
 
1565
1670
  def release_kept(self, *a, **k):
@@ -1570,7 +1675,7 @@ def _install():
1570
1675
  call = getattr(self, "_cortad", None)
1571
1676
  if call:
1572
1677
  self._cortad = None
1573
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2])
1678
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2], call[3])
1574
1679
  return release(self, *a, **k)
1575
1680
 
1576
1681
  module.ClientSession._request = requested
package/lib/replay.mjs CHANGED
@@ -121,6 +121,9 @@ const calledOf = (v) => {
121
121
  return called.length ? { called } : {};
122
122
  };
123
123
  const passagesOf = (v) => (texts(v).length ? { passages: texts(v).slice(0, 6) } : {});
124
+ // Which of the app's own lines made the call, nearest first, as "path:line".
125
+ const AT = /^[^\n\r\t]{1,300}:\d{1,7}$/;
126
+ const callerOf = (v) => { const caller = Array.isArray(v) ? v.filter((c) => typeof c === "string" && AT.test(c)).slice(0, 3) : []; return caller.length ? { caller } : {}; };
124
127
  const meter = (() => {
125
128
  const rows = [];
126
129
  const deps = [];
@@ -131,7 +134,7 @@ const meter = (() => {
131
134
  rows.push({
132
135
  at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
133
136
  promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
134
- usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages),
137
+ usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages), ...callerOf(call.caller),
135
138
  });
136
139
  if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
137
140
  },
@@ -159,7 +162,7 @@ const meter = (() => {
159
162
  }
160
163
  return {
161
164
  totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
162
- rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools, called, passages }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) })),
165
+ rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools, called, passages, caller }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}) })),
163
166
  deps: deps.slice(-400).reverse(),
164
167
  };
165
168
  },
package/lib/text.mjs CHANGED
@@ -68,7 +68,10 @@ function scoreText(d, of) {
68
68
  const heldBackLine = (h) => h.why === "not testable here"
69
69
  ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
70
70
  : `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
71
- 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.`;
71
+ const haltLine = (h) => `${h.side === "ours" ? `Stopped ${SIDE.ours}` : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 240)}` : ""}. The score covers the turns before that.`;
72
+ // The pace a slow app was played at, and the replies our own wait cut off: both said as ours. A
73
+ // verify says the second in its own paired line.
74
+ const waitLines = (d) => [d.pace?.said, d.kind === "verify" ? null : d.waited?.said].filter(Boolean);
72
75
  const LAYER_WORDS = {
73
76
  retrieval: "what the store returned behind the reply, not how the reply was written",
74
77
  tools: "the tool call behind the reply",
@@ -82,8 +85,10 @@ function runLines(d, prefix = "") {
82
85
  const done = finished(d);
83
86
  const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
84
87
  const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
88
+ if (d.tested) lines.push(testedLine(d.tested));
85
89
  // Between two counts, what the run is doing now: four minutes of "0 of 15" read as nothing happening.
86
90
  if (!done && d.now?.doing) lines.push(d.now.doing);
91
+ lines.push(...waitLines(d));
87
92
  // A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
88
93
  // as a clean bill to bloom's agent, beside "zero verified fixes".
89
94
  if (done && d.kind !== "verify") lines.push(...measuredLines(d));
@@ -141,24 +146,40 @@ function stoppedLine(d) {
141
146
 
142
147
  const moveText = (m) => `held ${kn(m.before)} readings before, ${kn(m.after)} after; ${m.moved}, ${signed(m.point)} points${interval(m)}${m.insideNoise ? ", inside the noise" : ""}.`;
143
148
 
149
+ // Where the replies did not pair, the question's rate on the same trials before and after, with the
150
+ // trials the verify played new in the situation and the readings nobody could settle said apart.
151
+ function rateText(r) {
152
+ const unclear = [r.before.unclear && `${num(r.before.unclear)} unclear before`, r.after.unclear && `${num(r.after.unclear)} unclear after`].filter(Boolean).map((u) => `, ${u}`).join("");
153
+ const over = r.trials?.widened ? `, over ${plural(r.trials.replayed, "trial")} replayed and ${num(r.trials.widened)} new` : "";
154
+ const verdict = r.thin ? `too few to state a rate (${num(r.after.settled)} of the 22 it needs)` : `${r.moved}, ${signed(r.point)} points${interval(r)}${r.insideNoise ? ", inside the noise" : ""}`;
155
+ return `failed on ${num(r.before.failed)} of ${num(r.before.settled)} settled readings before, ${num(r.after.failed)} of ${num(r.after.settled)} after${unclear}${over}; ${verdict}.`;
156
+ }
157
+ const sideText = (m) => (m.rate ? rateText(m.rate) : m.unavailable ? null : moveText(m));
158
+
144
159
  // Over zero pairs there is no move to print: the line says verification is unavailable and why,
145
160
  // never "no change, 0 points" measured on nothing. The visible side's reason is the said line.
146
161
  function verifyLines(v) {
147
- // An access verify is a re-knock, not a trial move: the routes side by side, then the count.
162
+ // An access verify is a re-knock, not a trial move: the routes side by side, then the count. Only
163
+ // a route knocked and answered both times is compared; one the app no longer has or did not answer
164
+ // is named as such, never read as a closed gate.
148
165
  if (v.access) {
149
166
  const lines = [`Verify of ${v.findingId}.`];
150
167
  for (const r of v.routes ?? []) {
151
- const then = r.wasOpen ? `answered ${r.before} with no sign-in` : `refused (${r.before || "no answer"})`;
152
- const now = r.nowOpen ? `answers ${r.after} with no sign-in` : r.after ? `refuses (${r.after})` : "did not answer";
153
- lines.push(` ${r.method} ${r.route}: ${then}, now ${now}.`);
168
+ const then = r.wasOpen ? `answered ${r.before} with no sign-in` : `refused (${r.before})`;
169
+ const now =
170
+ r.state === "answered" ? (r.nowOpen ? `now answers ${r.after} with no sign-in` : `now refuses (${r.after})`)
171
+ : r.state === "gone" ? "your app no longer has this route"
172
+ : r.state === "no-answer" ? "your app did not answer this time"
173
+ : "was not knocked again";
174
+ lines.push(` ${r.method} ${r.route}: ${then}, ${now}.`);
154
175
  }
155
176
  if (v.said) lines.push(v.said);
156
177
  return lines;
157
178
  }
158
179
  const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
159
- if (v.visible && !v.visible.unavailable) lines.push(`Visible trials: ${moveText(v.visible)}`);
160
- if (v.holdout?.unavailable) lines.push(`Held-out trials: verification unavailable: ${v.holdout.unavailable}.`);
161
- else if (v.holdout) lines.push(`Held-out trials: ${moveText(v.holdout)}`);
180
+ const visible = v.visible && sideText(v.visible);
181
+ if (visible) lines.push(`Visible trials: ${visible}`);
182
+ if (v.holdout) lines.push(`Held-out trials: ${sideText(v.holdout) ?? `verification unavailable: ${v.holdout.unavailable}.`}`);
162
183
  else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
163
184
  if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
164
185
  if (v.said) lines.push(v.said);
@@ -177,10 +198,23 @@ export function runText(d) {
177
198
  return lines.join("\n");
178
199
  }
179
200
 
180
- export function startedText(d, kind, findingId) {
201
+ export function startedText(d, kind, findingId, said = []) {
181
202
  const verify = (d.kind ?? kind) === "verify";
182
203
  const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
183
- return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.${d.note ? `\n${d.note}` : ""}\nnext: run_status ${d.jobId}`;
204
+ return [`${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.`, ...said, d.note, `next: run_status ${d.jobId}`].filter(Boolean).join("\n");
205
+ }
206
+
207
+ export const restartedText = (paths) =>
208
+ `Your app was started again so this run plays your change (${plural(paths.length, "file")} changed since it started: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", ..." : ""}).`;
209
+
210
+ // Which code the run played: the app's start against the last change to its source (lib/fresh.mjs).
211
+ const clock = (iso) => new Date(iso).toTimeString().slice(0, 8);
212
+ export function testedLine(t) {
213
+ const started = clock(t.startedAt);
214
+ if (t.stale) return `Your app was already running when this command started and has been running since ${started}; ${t.change.path} changed at ${clock(t.change.at)}, so this run may have played the old code. Stop your app and run the command again.`;
215
+ if (!t.change) return `App started ${started}.`;
216
+ if (Date.parse(t.change.at) <= Date.parse(t.startedAt)) return `App started ${started}, after your last change at ${clock(t.change.at)}.`;
217
+ return `App started ${started}${t.reloads ? " and reloads itself on save" : ""}; your last change was at ${clock(t.change.at)}.`;
184
218
  }
185
219
 
186
220
  export const waitingText = (p, now, limitMs) =>
@@ -237,9 +271,11 @@ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n)
237
271
  function findingsHead(d) {
238
272
  if (!d.runId) return ["No run has finished on this repository yet."];
239
273
  const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
274
+ if (d.tested) lines.push(testedLine(d.tested));
240
275
  lines.push(...measuredLines(d));
241
276
  if (d.blocked) lines.push(d.blocked);
242
277
  if (d.halted) lines.push(haltLine(d.halted));
278
+ lines.push(...waitLines(d));
243
279
  const reads = readingsLine(d);
244
280
  if (reads) lines.push(reads);
245
281
  if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
@@ -325,6 +361,9 @@ function findingBlock(f, placed) {
325
361
  // investigation itself.
326
362
  if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
327
363
  lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
364
+ // What the reader checked the reply against: what their app retrieved for it, the start of each.
365
+ if (q.checked) lines.push(` ${q.checked}`);
366
+ for (const p of (q.passages ?? []).slice(0, 3)) lines.push(` Passage${p.source ? ` (${clip(p.source, 60)})` : ""}: "${clip(p.text, 240)}"`);
328
367
  // What a developer reproduces it from: the request as sent, the body back, the app's own log.
329
368
  if (q.request) lines.push(` Request: ${q.request.method} ${q.request.path}${q.request.body ? ` ${clip(q.request.body, 600)}` : ""}`);
330
369
  if (has(q.response)) lines.push(` Response: ${q.response ? clip(q.response, 600) : "(empty body)"}`);
package/lib/trace.cjs CHANGED
@@ -119,15 +119,33 @@ if (FILE) {
119
119
  .replace(/\*(\w+)/g, "{$1}");
120
120
  const joined = (a, b) => ("/" + [a, b].map((s) => String(s).replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/"));
121
121
  const nodePath = require("node:path");
122
+ // The frames of the stack it is called from, nearest first, outside this hook and node_modules,
123
+ // relative to the folder the app was started in. An awaited call keeps its awaiting frames in
124
+ // the stack, and an SDK is deeper than the default ten frames.
125
+ const stackFrames = () => {
126
+ const limit = Error.stackTraceLimit;
127
+ Error.stackTraceLimit = 60;
128
+ const stack = String(new Error().stack);
129
+ Error.stackTraceLimit = limit;
130
+ const out = [];
131
+ for (const line of stack.split("\n")) {
132
+ const m = /\(?(?:file:\/\/)?(\/[^()]+?):(\d+):\d+\)?$/.exec(line.trim());
133
+ if (m && m[1] !== __filename && !m[1].includes("/node_modules/")) out.push({ file: nodePath.relative(process.cwd(), decodeURI(m[1])), line: Number(m[2]) });
134
+ }
135
+ return out;
136
+ };
122
137
  // The file that registered a route: the first frame outside this hook and outside node_modules.
123
- const callerFile = () => {
124
- for (const line of String(new Error().stack).split("\n").slice(2)) {
125
- const m = /\(?(?:file:\/\/)?(\/[^()]+?):\d+:\d+\)?$/.exec(line.trim());
126
- if (!m || m[1] === __filename || m[1].includes("/node_modules/")) continue;
127
- const rel = nodePath.relative(process.cwd(), decodeURI(m[1]));
128
- return rel.startsWith("..") ? "" : rel;
138
+ const callerFile = () => { const f = stackFrames()[0]; return f && !f.file.startsWith("..") ? f.file : ""; };
139
+ // Which of the app's own lines made a model call, nearest first: the run names the code path that
140
+ // ran by it, where the read could only name the door.
141
+ const callers = () => {
142
+ const out = [];
143
+ for (const f of stackFrames()) {
144
+ const at = `${f.file}:${f.line}`;
145
+ if (!f.file.startsWith("..") && !out.includes(at)) out.push(at);
146
+ if (out.length === 3) break;
129
147
  }
130
- return "";
148
+ return out.length ? out : undefined;
131
149
  };
132
150
  const seen = new Set();
133
151
  let nest = false;
@@ -561,13 +579,36 @@ if (FILE) {
561
579
  // person's latest message on, and Anthropic document and search_result blocks. The app's own
562
580
  // instructions are not passages: only what sits under such a label is.
563
581
  const PASSAGE_TEXT = 3000, PASSAGES_MAX = 6;
564
- const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b|检索|知识|参考资料|资料|上下文|文档|背景/i;
582
+ const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关/i;
565
583
  const HEADING = /^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$/;
566
584
  // An inline label is a noun phrase that ends in the material word ("context:", "background:",
567
585
  // "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
568
586
  const INLINE = /^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S/;
569
587
  const NAMED = new RegExp(`(?:${MATERIAL.source})\\s*$`, "i");
570
588
  const TAGGED = /<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)<\/\1>/g;
589
+ // A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
590
+ // that open on markdown headings holds every chunk, where stopping at its first paragraph kept
591
+ // one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
592
+ // label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
593
+ // "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
594
+ // between its paragraphs.
595
+ const MARKDOWN = /^\s*(#{1,6})\s/;
596
+ const endsBlock = (label, line) => {
597
+ if (!HEADING.test(line)) return false;
598
+ const outer = MARKDOWN.exec(label), inner = MARKDOWN.exec(line);
599
+ return !inner || Boolean(outer && inner[1].length <= outer[1].length);
600
+ };
601
+ const asks = (para) => { const m = INLINE.exec(para.split("\n")[0]); return Boolean(m && !NAMED.test(m[1])); };
602
+ // A heading inside a fenced block is the material's own text: a README chunk's "## Features".
603
+ const fences = (para) => (para.match(/^\s*(?:```|~~~)/gm) || []).length;
604
+ const addBlock = (add, name, paras) => {
605
+ let text = "";
606
+ for (const p of paras) {
607
+ if (text && text.length + p.length + 2 > PASSAGE_TEXT) { add(name, text); text = ""; }
608
+ text += (text ? "\n\n" : "") + p;
609
+ }
610
+ add(name, text);
611
+ };
571
612
  const labelled = (text, add) => {
572
613
  const rest = String(text || "").replace(TAGGED, (all, tag, inner) => { if (MATERIAL.test(tag.replace(/_/g, " "))) { add(tag, inner); return ""; } return all; });
573
614
  const paras = rest.split(/\n\s*\n/);
@@ -575,10 +616,13 @@ if (FILE) {
575
616
  const lines = paras[i].split("\n");
576
617
  const head = lines[0];
577
618
  if (HEADING.test(head) && MATERIAL.test(head)) {
578
- let body = lines.slice(1).join("\n");
579
- // A heading alone on its paragraph labels what follows, up to the next heading.
580
- if (!body.trim()) while (i + 1 < paras.length && body.length < PASSAGE_TEXT && !HEADING.test(paras[i + 1].split("\n")[0])) body += (body ? "\n\n" : "") + paras[++i];
581
- add(head.replace(/[#::[\]=]/g, "").trim(), body);
619
+ const block = [lines.slice(1).join("\n")];
620
+ let fenced = fences(block[0]) % 2 === 1;
621
+ while (i + 1 < paras.length && (fenced || (!endsBlock(head, paras[i + 1].split("\n")[0]) && !(i + 2 === paras.length && asks(paras[i + 1]))))) {
622
+ block.push(paras[++i]);
623
+ if (fences(paras[i]) % 2 === 1) fenced = !fenced;
624
+ }
625
+ addBlock(add, head.replace(/[#::[\]=]/g, "").trim(), block);
582
626
  continue;
583
627
  }
584
628
  const inline = lines.map((l) => INLINE.exec(l)).find((m) => m && NAMED.test(m[1]));
@@ -636,6 +680,8 @@ if (FILE) {
636
680
  // Where models are served, by host, and the paths every OpenAI-shaped or vendor endpoint ends with.
637
681
  const MODEL_HOST = /(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|googleapis\.com|openai\.azure\.com|cognitiveservices\.azure\.com|amazonaws\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn|ai-gateway\.vercel\.sh|gateway\.ai\.cloudflare\.com|helicone\.ai|portkey\.ai)$/i;
638
682
  const MODEL_PATH = /\/(?:chat\/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|\/invoke(?:-with-response-stream)?$|\/api\/(?:chat|generate)$/i;
683
+ // An embedding call answers nobody, so where it was made names no path a reply came down.
684
+ const EMBEDDING = /\/embeddings$/i;
639
685
  const isModelCall = (host, path) => {
640
686
  const h = String(host || "").replace(/:\d+$/, "");
641
687
  const p = String(path || "").split("?")[0];
@@ -790,14 +836,14 @@ if (FILE) {
790
836
  const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
791
837
  return m ? decodeURIComponent(m[1] || m[2]) : "";
792
838
  };
793
- const meter = ({ host, path, sent, status, type, body, turn }) => {
839
+ const meter = ({ host, path, sent, status, type, body, turn, caller }) => {
794
840
  try {
795
841
  const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
796
842
  const rules = rulesIn(sent);
797
843
  const tools = toolsIn(sent);
798
844
  const called = calledIn(sent, reply.events);
799
845
  const passages = passagesIn(sent);
800
- row({ call: { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) } });
846
+ row({ call: { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}) } });
801
847
  } catch { /* the command is gone */ }
802
848
  };
803
849
 
@@ -1014,7 +1060,7 @@ if (FILE) {
1014
1060
  }
1015
1061
  const sent = bodyText(init && init.body);
1016
1062
  note(als.getStore(), sent);
1017
- const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn };
1063
+ const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn, caller: EMBEDDING.test(url.pathname) ? undefined : callers() };
1018
1064
  return realFetch.apply(this, arguments).then((res) => {
1019
1065
  try {
1020
1066
  // A clone is a tee: your app's branch gets every byte as fast as it reads, this one is read to the end.
@@ -1043,7 +1089,8 @@ if (FILE) {
1043
1089
  const end = req.end;
1044
1090
  req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
1045
1091
  req.end = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); sent = Buffer.concat(parts).toString("utf8"); note(ctx, sent); return end.call(this, chunk, ...r); };
1046
- const call = { host: String(host || ""), path: String(path || "").split("?")[0], turn: ctx && ctx.turn };
1092
+ const bare = String(path || "").split("?")[0];
1093
+ const call = { host: String(host || ""), path: bare, turn: ctx && ctx.turn, caller: EMBEDDING.test(bare) ? undefined : callers() };
1047
1094
  let done = false;
1048
1095
  const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
1049
1096
  req.once("error", () => once({ status: 0, type: "", body: "" }));
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, finished, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
4
+ import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, 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
@@ -17,8 +17,9 @@ export const READY_MS = 240_000;
17
17
 
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
- // 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, writes = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
20
+ // the latest run at the time, so a newer run on the server outranks a stale file. A run this machine
21
+ // posted also keeps `tested`, which code it played (lib/fresh.mjs), for its run_status and findings.
22
+ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, writes = null, code = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
22
23
  const call = async (method, path, body, timeoutMs = 30_000) => {
23
24
  const key = typeof token === "function" ? token() : token;
24
25
  if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
@@ -40,6 +41,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
40
41
  };
41
42
  const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
42
43
  const iso = () => new Date(now()).toISOString();
44
+ const withTested = (d, id) => {
45
+ const kept = pending.read();
46
+ return kept?.tested && kept.jobId === id ? { ...d, tested: kept.tested } : d;
47
+ };
43
48
 
44
49
  // With show, one section of the read in full, paged; a name that is not a section is the plain status.
45
50
  const status = async ({ show, page } = {}) => {
@@ -50,24 +55,33 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
50
55
  };
51
56
 
52
57
  // Asks the server for the run and keeps its id here, so run_status finds it without one.
53
- const post = async (args, kind) => {
58
+ const post = async (args, kind, restarted = []) => {
59
+ const tested = code?.tested() ?? null;
54
60
  const res = await call("POST", "/mcp/run", args);
55
61
  if (res.status === 202 || (res.ok && res.data?.joined)) {
56
- pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso() });
62
+ const mine = res.status === 202 ? tested : null;
63
+ pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso(), ...(mine ? { tested: mine } : {}) });
57
64
  try { writes?.mark(res.data.jobId); } catch { /* the app's writes go unrecorded this run */ }
58
- return { text: startedText(res.data, kind, args.findingId), data: res.data };
65
+ const said = [restarted.length ? restartedText(restarted) : "", mine?.stale ? testedLine(mine) : ""];
66
+ return { text: startedText(res.data, kind, args.findingId, said), data: res.data };
59
67
  }
60
68
  if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
61
69
  if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
62
70
  return failed(res);
63
71
  };
64
72
 
65
- // An app that is up gets the run now. One that is not is started in the background, and the run
66
- // is posted from there once it answers.
73
+ // An app that is up gets the run now, started again first when its code changed and nothing
74
+ // reloaded it; never under a run already playing, which is joined. One that is not up is started
75
+ // in the background, and the run is posted from there once it answers.
67
76
  const start = async (args, kind) => {
68
77
  const before = await call("GET", "/mcp/status");
69
78
  if (!before.ok) return failed(before);
70
- if (before.data.app?.state === "ready-to-test") return post(args, kind);
79
+ if (before.data.app?.state === "ready-to-test") {
80
+ const idle = !before.data.run || finished(before.data.run);
81
+ const got = code && idle ? await code.fresh() : {};
82
+ if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true };
83
+ return post(args, kind, got.restarted);
84
+ }
71
85
  pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
72
86
  const began = await startApp(args, kind);
73
87
  if (!began.ok) {
@@ -115,7 +129,8 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
115
129
  // The run is over: what the app wrote into its folder during it is put back, said before the next step.
116
130
  let back = "";
117
131
  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 };
132
+ const text = runText(withTested(res.data, id));
133
+ return { text: back ? withBack(text, back) : text, data: res.data };
119
134
  };
120
135
 
121
136
  // Every server page of a run's findings, so the pages the agent reads are cut by size here.
@@ -144,7 +159,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
144
159
 
145
160
  const findings = async ({ jobId, page } = {}) => {
146
161
  const res = await gather(jobId);
147
- return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
162
+ return res.ok ? { text: findingsText(withTested(res.data, res.data.runId), page), data: res.data } : failed(res);
148
163
  };
149
164
 
150
165
  const dispute = async ({ findingId, why, question, jobId }) => {
package/lib/writes.mjs CHANGED
@@ -23,11 +23,7 @@ export function markRun(dir, jobId, { keep = false } = {}) {
23
23
  // server's side, so no verb marks it) and again at every run a verb starts, so what a person wrote
24
24
  // between two runs is dropped from the record rather than undone.
25
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 */ }
26
+ const rows = rowsOf(dir);
31
27
  const base = resolve(root);
32
28
  const done = [];
33
29
  for (const row of rows) {
@@ -44,6 +40,16 @@ export function putBack(dir, root) {
44
40
  return saidOf(done);
45
41
  }
46
42
 
43
+ function rowsOf(dir) {
44
+ try {
45
+ return readFileSync(join(dir, "written.jsonl"), "utf8").split("\n").filter(Boolean)
46
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
47
+ } catch { return []; }
48
+ }
49
+
50
+ // The files the app itself wrote since the record began: its data, never a change to its code.
51
+ export const writtenPaths = (dir) => new Set(rowsOf(dir).map((row) => row.path).filter((p) => typeof p === "string"));
52
+
47
53
  export function saidOf(paths) {
48
54
  if (!paths.length) return "";
49
55
  const named = paths.slice(0, 3).join(", ") + (paths.length > 3 ? ` and ${paths.length - 3} more` : "");
package/local.mjs CHANGED
@@ -21,7 +21,8 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
21
21
  import { createInterface } from "node:readline";
22
22
  import { promisify } from "node:util";
23
23
  import { COMMANDS, main as face } from "./lib/cli.mjs";
24
- import { clearRunner, homeOf, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
24
+ import { clearRunner, homeOf, projectOf, readToken, writeApp, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
25
+ import { commandText, elapsedMs, RELOADER, sourceOf } from "./lib/fresh.mjs";
25
26
  import { registerAll } from "./lib/register.mjs";
26
27
  import { finished } from "./lib/text.mjs";
27
28
  import { lockHolds, makeLock } from "./lib/lock.mjs";
@@ -375,10 +376,13 @@ async function verb(job) {
375
376
  const url = hostUrl(port, path);
376
377
  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; } };
377
378
  let jar = "";
379
+ // Held as long as the run waits for this reply: a course generator that takes ninety seconds
380
+ // a reply is waited on for three of them. A run that says nothing gets the old 170 seconds.
381
+ const holdMs = Math.min(Math.max(Number(b.waitMs) || 170_000, 1_000), 630_000);
378
382
  const sent = (at, over = {}) => fetch(at, {
379
383
  ...init, ...over,
380
384
  headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
381
- signal: AbortSignal.timeout(170_000),
385
+ signal: AbortSignal.timeout(holdMs),
382
386
  });
383
387
  const keep = (res) => { const set = res.headers.getSetCookie?.() ?? []; if (set.length) jar = [jar, ...set.map((c) => c.split(";")[0])].filter(Boolean).join("; "); };
384
388
  const sentBack = (res) => (res.status >= 300 && res.status < 400 ? yours(res.headers.get("location") ?? "") : null);
@@ -646,16 +650,32 @@ async function listenerOn(port) {
646
650
  // The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
647
651
  // while the parent is a runner, never into the person's own shell or terminal.
648
652
  const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
649
- async function supervisorOf(pid) {
650
- const rows = (await exec("ps", ["-axo", "pid=,ppid=,args="])).stdout.trim().split("\n").map((l) => l.trim());
651
- const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { ppid: Number(m[2]), args: m[3] }] : [0, null]; }));
652
- let top = pid;
653
- for (let i = 0; i < 8; i++) {
654
- const parent = table.get(table.get(top)?.ppid ?? 0);
653
+ async function chainOf(pid) {
654
+ const rows = (await exec("ps", ["-axo", "pid=,ppid=,etime=,args="])).stdout.trim().split("\n").map((l) => l.trim());
655
+ const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), etime: m[3], args: m[4] }] : [0, null]; }));
656
+ const chain = table.get(pid) ? [table.get(pid)] : [];
657
+ for (let i = 0; i < 8 && chain.length; i++) {
658
+ const parent = table.get(chain.at(-1).ppid);
655
659
  if (!parent || !RUNNER.test(parent.args)) break;
656
- top = table.get(top).ppid;
660
+ chain.push(parent);
657
661
  }
658
- return top;
662
+ return chain;
663
+ }
664
+ const supervisorOf = async (pid) => (await chainOf(pid)).at(-1)?.pid ?? pid;
665
+
666
+ // What the running app was started from, noted in app.json for the verbs, which run in another
667
+ // process (lib/fresh.mjs): when it started, whether this command started it, whether it reloads on
668
+ // save, and what each source file said then. An app found already running is noted by its own
669
+ // process's age and command line, with the file names alone.
670
+ let loaded = null;
671
+ const noteApp = () => writeApp(project, loaded?.at
672
+ ? { runner: process.pid, own: loaded.own, startedAt: new Date(loaded.at).toISOString(), reloads: loaded.reloads, files: loaded.files }
673
+ : { runner: process.pid });
674
+ async function noteAttached(port) {
675
+ const pid = await listenerOn(port);
676
+ const chain = pid ? await chainOf(pid).catch(() => []) : [];
677
+ loaded = { own: false, at: chain.length ? Date.now() - elapsedMs(chain[0].etime) : null, reloads: RELOADER.test(chain.map((p) => p.args).join("\n")), files: sourceOf(root, files, false) };
678
+ noteApp();
659
679
  }
660
680
 
661
681
  // Your app, started the way you start it, and watched until one of its own ports answers.
@@ -772,10 +792,17 @@ async function start(waitMs) {
772
792
  // The record of what the app writes opens with the app: the first run starts itself on the
773
793
  // server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
774
794
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
795
+ // Taken before the app reads a file: a save after this moment is a change it has not loaded.
796
+ const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
797
+ note.files = sourceOf(root, files);
798
+ loaded = note;
775
799
  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 });
776
800
  const mine = child;
777
801
  let seen = "";
778
- 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); };
802
+ const onData = (d) => {
803
+ const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s);
804
+ if (!note.reloads && RELOADER.test(s)) { note.reloads = true; if (note.answered && loaded === note) noteApp(); }
805
+ };
779
806
  mine.stdout.on("data", onData);
780
807
  mine.stderr.on("data", onData);
781
808
  let exited = null;
@@ -807,7 +834,11 @@ async function start(waitMs) {
807
834
  const plain = seen.replace(/\x1b\[[0-9;]*m/g, "");
808
835
  const said = [...plain.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b|\bport\s*[:=]?\s*(\d{4,5})\b/gi)].map((m) => Number(m[1] || m[2]));
809
836
  for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
810
- if (await answers(port)) return { port, exited: null, tail: "" };
837
+ if (await answers(port)) {
838
+ note.answered = true;
839
+ if (loaded === note) noteApp();
840
+ return { port, exited: null, tail: "" };
841
+ }
811
842
  }
812
843
  await new Promise((r) => setTimeout(r, 1000));
813
844
  }
@@ -1024,7 +1055,7 @@ const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd:
1024
1055
  async function appLife() {
1025
1056
  for (let first = true; ; first = false) {
1026
1057
  const got = await startApp();
1027
- if (got.port) { app = got; break; }
1058
+ if (got.port) { app = got; if (!launched) await noteAttached(app.port); break; }
1028
1059
  await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
1029
1060
  say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
1030
1061
  // Said with the same words the agent prompt waits for, so an agent holding the terminal reports
@@ -1086,6 +1117,7 @@ async function appLife() {
1086
1117
  say("saw your change, starting your app again");
1087
1118
  back = launched ? await launch(180_000) : await startApp();
1088
1119
  }
1120
+ if (back.port && !launched) await noteAttached(back.port);
1089
1121
  if (back.port) { app = { ...app, ...(back.cmd !== undefined ? back : {}), port: back.port }; up = true; downSince = 0; toldDown = false; appTold = Boolean((await announce().catch(() => null))?.ok); say(`your app is answering again on port ${app.port}`); }
1090
1122
  } finally { restarting = false; }
1091
1123
  }
@@ -1119,6 +1151,13 @@ async function close(code = 0) {
1119
1151
  }
1120
1152
  process.on("SIGINT", () => close(0));
1121
1153
  process.on("SIGTERM", () => close(0));
1154
+ // A verb in another process asks for the app to be started again before a run, because its code
1155
+ // changed and nothing reloaded it (lib/fresh.mjs). The new start is noted as the app answers; one
1156
+ // that fails is noted with its reason. A restart already under way answers the same way.
1157
+ process.on("SIGUSR2", () => {
1158
+ if (restarting || !app) return;
1159
+ void restartApp().then((out) => { if (out.error) writeApp(project, { runner: process.pid, failedAt: new Date().toISOString(), error: mask(out.error).slice(-600) }); });
1160
+ });
1122
1161
 
1123
1162
  let quiet = 0;
1124
1163
  // Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.7",
3
+ "version": "0.3.1-rc.9",
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"
@@ -10,6 +10,7 @@
10
10
  "README.md",
11
11
  "lib/cli.mjs",
12
12
  "lib/data.mjs",
13
+ "lib/fresh.mjs",
13
14
  "lib/home.mjs",
14
15
  "lib/identities.mjs",
15
16
  "lib/keys.mjs",
package/skill/SKILL.md CHANGED
@@ -49,14 +49,15 @@ 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.
52
+ Each finding ends with what a verify of it can show. When the trials it replays read the question too few times for a rate, the verify plays up to 12 more trials in that situation itself, counted as verify trials, and the finding says how many before one is spent.
53
53
 
54
54
  1. One change, in the file and near the line the finding names.
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.
55
+ 2. `verify <findingId>`. It answers within a second, and `run_status` follows it. When the app's code changed since the app started and nothing reloaded it, Cortad starts the app again first and the answer says so, so the saved edit is what plays; `run_status` names when the app started against the last change. When it also plays new trials in the finding's situation, the answer says how many.
56
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
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.
58
+ - `does not support keeping the fix`: the pairs, or the rate on the same trials, 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; most often the reader could not settle enough of the replies, which is on Cortad's side. A change the app's own tests support may stay, said as not yet verified by Cortad.
60
+ - Where the replies cannot be paired reply for reply, the verify compares the question's failure rate on the same trials before and now, with the new trials it played in the situation, and says both. Readings the reader could not settle are named and count on neither side.
60
61
  - A reply that failed before and no longer meets the question's condition is counted as gone on its own, not as a pass.
61
62
  - A verify that played no trial says so and compares nothing; the file stays as it was left.
62
63
  - 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.
@@ -162,7 +162,19 @@ A verify with no pair: the two runs read the finding's question on no common tri
162
162
  Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 1 of 1 trial played.
163
163
  Verify of finding:2 at backend/tools/refunds.py.
164
164
  Held-out trials: no pair for this question.
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
+ 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 replay of its trials alone cannot leave the noise: even if both failures hold on the replay, 2 readings cannot tell that apart from chance. A verify of it plays 10 more trials in this situation to reach about 22 readings.
166
+ For the person: the report is at https://cortad.com/lab
167
+ next: findings
168
+ ```
169
+
170
+ A verify whose replies did not pair: the replay took other turns and the reader settled other replies, so the question's failure rate on the same trials is compared instead, with the 10 new trials the verify played in the finding's situation. Readings the reader could not settle count on neither side.
171
+
172
+ ```
173
+ Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 11 of 11 trials played.
174
+ Verify of finding:2 at backend/tools/refunds.py.
175
+ Visible trials: failed on 2 of 2 settled readings before, 1 of 29 after, 4 unclear after, over 1 trial replayed and 10 new; improved, +97 points, interval 29 to 99.
176
+ Held-out trials: failed on 2 of 16 settled readings before, 0 of 15 after, 1 unclear after; too few to state a rate (15 of the 22 it needs).
177
+ On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 4 now. On the 10 more trials played in this situation, 0 of 30 replies were errors. Reply for reply, nothing could be paired: the reader could not settle any reply of this question that both runs read. Across the 1 trial replayed in this situation and the 10 more this verify played there, this question failed on 2 of 2 settled readings before and 1 of 29 now; 4 readings now could not be settled either way and count on neither side. As two separate samples the failure rate fell 97 points, and the real fall is between 29 and 99 points. That is outside the 18 points that come back different on temperature alone. The held-out trials have too few readings to say (15 of the 22 each rate needs). The evidence supports keeping the fix: this question failed on 2 of 2 settled readings before and 1 of 29 now, 97 points fewer.
166
178
  For the person: the report is at https://cortad.com/lab
167
179
  next: findings
168
180
  ```
@@ -221,6 +233,14 @@ Verify of finding:1 started: 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a.
221
233
  next: run_status 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a
222
234
  ```
223
235
 
236
+ When the trials it replays read the finding's question too few times for a rate, it says before they play how many new trials it adds in the finding's situation. They are verify trials from the plan.
237
+
238
+ ```
239
+ Verify of finding:2 started: 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a.
240
+ Playing 10 more trials in this situation to reach about 22 readings of this question; the run before read it 2 times here.
241
+ next: run_status 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a
242
+ ```
243
+
224
244
  ## field_connect
225
245
 
226
246
  ```