cortad 0.3.1-rc.13 → 0.3.1-rc.15

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.
@@ -29,7 +29,7 @@ function sessionLine(v) {
29
29
  if (!s || !(v.requests > 1)) return null;
30
30
  if (!s.held) return "No later request's prompt carried an earlier one.";
31
31
  const by = CARRIER[s.carrier] && s.key ? CARRIER[s.carrier](s.key) : "nothing the request sent, so every caller shares what the app keeps";
32
- return `A later request's prompt carried an earlier one, tied by ${by}.`;
32
+ return `A later request's prompt carried an earlier one, held by ${by}.`;
33
33
  }
34
34
 
35
35
  function rulesLine(r) {
@@ -97,8 +97,12 @@ function readyLine(r) {
97
97
  ? `about ${plural(r.minutes, "minute")} if your app answers several requests at once, or up to about ${plural(one.minutes, "minute")}${one.trials < r.trials ? ` and ${plural(one.trials, "trial")}` : ""} if it answers one at a time`
98
98
  : `about ${plural(r.minutes, "minute")}`);
99
99
  const usd = (x) => `$${x.toFixed(2)}`;
100
- const cost = has(r.usd) && `about ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}${soFar} on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}`;
101
- const parts = [fit, has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, time, cost].filter(Boolean);
100
+ // Some endpoints had no published rate or token counts: the price covers the others and says so.
101
+ const some = r.pricedOn ? `at least ${usd(r.usd)}, priced on ${num(r.pricedOn.endpoints)} of ${plural(r.pricedOn.of, "endpoint")}` : null;
102
+ const cost = has(r.usd) && `${some ?? `about ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}`}${soFar} on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}`;
103
+ // Follow-ups are played only where something breaks: said apart, never in the count above.
104
+ const followUps = r.followUps ? `and up to ${plural(r.followUps, "follow-up")} where something breaks` : null;
105
+ const parts = [fit, followUps, has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, time, cost].filter(Boolean);
102
106
  const rules = !has(r.rules) ? "" : r.rules === 0 ? " No rules of your own were read from the code, so the run checks replies against generic standards." : ` Replies are checked against the ${plural(r.rules, "rule")} read from your code.`;
103
107
  return `A run on the proven endpoints: ${parts.join(", ")}.${r.reading ? " The read is still adding trials; the run waits for it and plays them all." : ""}${rules}`;
104
108
  }
@@ -114,6 +118,6 @@ export function contactLines(c, data = []) {
114
118
  if (missing.length) lines.push(`Endpoints the read found that no request has reached (${num(missing.length)}):`, ...missing.map((m) => ` ${m.method} ${m.path}${m.file ? ` ${at(m.file, m.line)}` : ""}${m.excluded ? ` ${m.excluded}` : ""}`));
115
119
  const ready = readyLine(c.ready);
116
120
  if (ready) lines.push(ready);
117
- for (const b of c.ready?.blocked ?? []) lines.push(`Run waits${sideOf(b.side)}: ${b.door}: ${b.why}${b.question ? ` ${b.question}` : ""}`);
121
+ for (const b of c.ready?.blocked ?? []) lines.push(`Run is held${sideOf(b.side)}: ${b.door}: ${b.why}${b.question ? ` ${b.question}` : ""}`);
118
122
  return [...lines, ...said];
119
123
  }
@@ -1,4 +1,4 @@
1
- import { clip, closeToCall, has, num, plural } from "./words.mjs";
1
+ import { clip, has, num, plural } from "./words.mjs";
2
2
 
3
3
  // A finished run's dissection, as the server cut it (world-agent/dissection.ts): the run in a line,
4
4
  // what only running the app showed, what a code review would also have found, the gates a knock
@@ -167,7 +167,7 @@ function journeyLines(b, said) {
167
167
  const lines = [head, "How it went, at each conversation's last reply:"];
168
168
  const a = b.all;
169
169
  if (a?.got || a?.frustrated || a?.askedForPerson) {
170
- const parts = [a.got && gotSaid(a.got, said), a.unsure && closeToCall(a.unsure, said), a.frustrated && `${num(a.frustrated.k)} grew frustrated`, a.askedForPerson && `${num(a.askedForPerson.k)} asked for a person midway`].filter(Boolean);
170
+ const parts = [a.got && gotSaid(a.got, said), a.unsure && `${num(a.unsure)} could not be told`, a.frustrated && `${num(a.frustrated.k)} grew frustrated`, a.askedForPerson && `${num(a.askedForPerson.k)} asked for a person midway`].filter(Boolean);
171
171
  lines.push(` All ${plural(b.conversations, "conversation")}: ${parts.join("; ")}.`);
172
172
  }
173
173
  for (const r of j.rows.slice(0, JOURNEYS_SHOWN)) {
@@ -192,7 +192,7 @@ function heldEverywhereLines(b) {
192
192
  function notMeasuredLines(d, b) {
193
193
  const unplayed = b.journeys?.unplayed ?? [];
194
194
  const lines = [d.held, d.notMeasured, unplayed.length && `No conversation reached ${unplayed.length === 1 ? "this journey" : `these ${num(unplayed.length)} journeys`}: ${flat(unplayed.join(", "), 200)}.`].filter(Boolean);
195
- return lines.length ? ["What the numbers leave out, and why:", ...lines.map((l) => ` ${l}`)] : [];
195
+ return lines.length ? ["What was not measured, and why:", ...lines.map((l) => ` ${l}`)] : [];
196
196
  }
197
197
 
198
198
  // `room.lines`: what the one-line findings may take; past it a finding that may lead, an open gate or
@@ -7,15 +7,15 @@ import { at, clip, plural } from "./words.mjs";
7
7
 
8
8
  // A row the run held back names a door and why, or, when no door could take it, only the reason.
9
9
  export const heldBackLine = (h) => h.why === "not testable here"
10
- ? `Set aside: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
11
- : `Set aside: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
10
+ ? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
11
+ : `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
12
12
 
13
13
  const HELD_SHOWN = 6;
14
14
 
15
15
  export function heldLines(held) {
16
16
  const rows = (held ?? []).filter((h) => h.trials > 0);
17
17
  const lines = rows.slice(0, HELD_SHOWN).map(heldBackLine);
18
- if (rows.length > HELD_SHOWN) lines.push(`Set aside: ${plural(rows.slice(HELD_SHOWN).reduce((n, h) => n + h.trials, 0), "more trial")} for ${plural(rows.length - HELD_SHOWN, "other reason")}.`);
18
+ if (rows.length > HELD_SHOWN) lines.push(`Held back: ${plural(rows.slice(HELD_SHOWN).reduce((n, h) => n + h.trials, 0), "more trial")} for ${plural(rows.length - HELD_SHOWN, "other reason")}.`);
19
19
  return lines;
20
20
  }
21
21
 
package/lib/keeping.mjs CHANGED
@@ -42,7 +42,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
42
42
  let seen = 0;
43
43
  const told = new Set();
44
44
  const tell = (lines) => { for (const line of lines) say(lower(line)); };
45
- const held = (blocked) => tell(blocked.map((b) => `Run waits until this is fixed: ${lower(b.why)}`));
45
+ const held = (blocked) => tell(blocked.map((b) => `Run is held until this is fixed: ${lower(b.why)}`));
46
46
 
47
47
  async function keepAll(started) {
48
48
  const { values, from, appDir, sources } = settings();
package/lib/mcp.mjs CHANGED
@@ -38,7 +38,7 @@ export const TOOLS = [
38
38
  { name: "field_connect", title: "Connect production", inputSchema: none,
39
39
  description: "How production replies reach Cortad, so real conversations are read with the same checks. The ingest key is created by the owner in the browser." },
40
40
  { name: "field", title: "Production numbers", annotations: readOnly,
41
- description: "Production in numbers: conversations read, checks passed, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
41
+ description: "Production in numbers: conversations read, checks held, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
42
42
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
43
43
  ];
44
44
  const NAMES = new Set(TOOLS.map((t) => t.name));
@@ -1,4 +1,4 @@
1
- import { clip, closeToCall, has, num, pack, pageOf, plural } from "./words.mjs";
1
+ import { clip, has, num, pack, pageOf, plural } from "./words.mjs";
2
2
 
3
3
  // Every number the run measured, whole, one call away (`findings numbers`): each group with its count
4
4
  // by class, of how many, the interval on the share that matters, and one exchange from the worst
@@ -14,9 +14,9 @@ const SPLIT_WORDS = { journey: "By journey", segment: "By persona segment", situ
14
14
 
15
15
  // One group as a row under its header: the counts in the header's order, of how many, the average
16
16
  // on a scale, then the share named with its interval.
17
- function row(t, d, said) {
17
+ function row(t, d) {
18
18
  const of = units(d.of, t.unit);
19
- const unsure = d.unsure ? `; ${closeToCall(d.unsure, said)}` : "";
19
+ const unsure = d.unsure ? ` (${num(d.unsure)} unsure)` : "";
20
20
  const share = d.bad && d.of ? `, ${interval(d.bad)}` : "";
21
21
  if (t.kind === "question") return `${num(d.counts?.broke ?? 0)} of ${of}${share}${unsure}`;
22
22
  const counts = t.classes.map((c) => num(d.counts?.[c] ?? 0)).join(", ");
@@ -30,14 +30,13 @@ const headerOf = (t) => (t.kind === "question"
30
30
  : ` Counted as: ${isScale(t) ? `levels ${t.classes.join(", ")}` : t.classes.join(", ")} of ${t.unit}${t.badSaid ? `; then how many ${t.badSaid}, with the 95% interval` : ""}.`);
31
31
 
32
32
  function tableBlock(t) {
33
- const said = {};
34
33
  const lines = [`${t.kind === "question" ? `${t.id} ` : ""}${clip(t.title, 400)}${t.from ? ` (${t.from})` : ""}`];
35
34
  if (t.legend) lines.push(` ${clip(t.legend, 500)}`);
36
- lines.push(headerOf(t), ` All: ${row(t, t.all, said)}`);
35
+ lines.push(headerOf(t), ` All: ${row(t, t.all)}`);
37
36
  for (const split of ["journey", "segment", "situation", "reply"]) {
38
37
  const groups = t.by?.[split] ?? [];
39
38
  if (!groups.length || (split !== "reply" && groups.length === 1 && groups[0].of === t.all.of)) continue;
40
- lines.push(` ${SPLIT_WORDS[split]}:`, ...groups.map((d) => ` ${clip(d.value, 90)}${d.kept ? " (kept back)" : ""}: ${row(t, d, said)}`));
39
+ lines.push(` ${SPLIT_WORDS[split]}:`, ...groups.map((d) => ` ${clip(d.value, 90)}${d.kept ? " (kept back)" : ""}: ${row(t, d)}`));
41
40
  }
42
41
  const w = t.worst;
43
42
  if (w) {
package/lib/proof.mjs CHANGED
@@ -547,8 +547,12 @@ const sentence = ({ model, site }) => (model ? " The model said it."
547
547
  // The tools the request's last model call asked for, when they ran inside this request and no model
548
548
  // call came after them: the app closed the request before the model could answer from their result
549
549
  // (an AI SDK route left at one step). A tool the page runs itself and sends back is not this.
550
+ // A last call that wrote words answered: its request carries the tool calls before it in its history
551
+ // (MIT Learn AI's answering call resent search_courses), which is not the request stopping there.
550
552
  function endedOnTool(ex) {
551
- const asked = [...new Set(arr(generation(ex).at(-1)?.called).map((c) => str(c?.name)).filter(Boolean))];
553
+ const last = generation(ex).at(-1);
554
+ if (str(last?.reply).trim()) return null;
555
+ const asked = [...new Set(arr(last?.called).map((c) => str(c?.name)).filter(Boolean))];
552
556
  if (!asked.length) return null;
553
557
  const inApp = new Set(ex.deps.flatMap((d) => arr(d.called).map((c) => str(c?.name))));
554
558
  const streamed = eventsOf(str(ex.reply)).events.some((e) => e && typeof e === "object" && /tool.*(?:output|result)/i.test(str(e.type)));
@@ -171,7 +171,14 @@ def _install():
171
171
  # What the app's tools answered, as the prompt of the next model call carries them (chat tool
172
172
  # messages, responses function outputs, Anthropic tool_result blocks, Gemini functionResponse
173
173
  # parts): the material a reply's facts rest on. Bounded per call.
174
- tool_text, tools_max = 3000, 12
174
+ # A tool's answer is kept to the cap the reader takes, and one longer says it was cut (wire.cjs toolText).
175
+ tool_text, tools_max = 12000, 12
176
+
177
+ def tool_text_row(name, full, **extra):
178
+ out = {"name": str(name or "")[:80], "text": full[:tool_text], **extra}
179
+ if len(full) > tool_text:
180
+ out["cut"] = True
181
+ return out
175
182
 
176
183
  def text_of(v):
177
184
  if isinstance(v, str):
@@ -214,9 +221,10 @@ def _install():
214
221
  out, names = [], {}
215
222
 
216
223
  def add(name, text):
217
- t = text_of(text)[:tool_text]
224
+ full = text_of(text)
225
+ t = full[:tool_text]
218
226
  if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
219
- out.append({"name": str(name or "")[:80], "text": t})
227
+ out.append(tool_text_row(name, full))
220
228
 
221
229
  messages = items(body.get("messages"))
222
230
  for m in messages:
@@ -645,9 +653,10 @@ def _install():
645
653
  calls, names, answers = {}, {}, []
646
654
 
647
655
  def answer(name, said):
648
- t = text_of(said)[:tool_text]
656
+ full = text_of(said)
657
+ t = full[:tool_text]
649
658
  if name and t.strip() and len(answers) < tools_max and not any(a["name"] == name and a["text"] == t for a in answers):
650
- answers.append({"name": str(name)[:80], "text": t, "provider": True})
659
+ answers.append(tool_text_row(name, full, provider=True))
651
660
 
652
661
  for e in events:
653
662
  if not isinstance(e, dict):
@@ -1731,7 +1740,7 @@ def _install():
1731
1740
  text = "raised %s: %s" % (type(error).__name__, error) if error is not None else returned(value)
1732
1741
  row = {"at": int(time.time() * 1000), "host": "in-app", "status": 200,
1733
1742
  "called": [{"name": name, "arguments": args_text(plain_of(named))}],
1734
- "tools": [{"name": name, "text": str(text)[:tool_text]}]}
1743
+ "tools": [tool_text_row(name, str(text))]}
1735
1744
  row.update(inside_of(req))
1736
1745
  write({"dep": row})
1737
1746
  except Exception:
@@ -2448,7 +2457,7 @@ def _install():
2448
2457
  if held_here(url, method, lambda: auth_of(self, k)) and not reads_only(text(body, reply_max)):
2449
2458
  port = held_port()
2450
2459
  if port is None:
2451
- raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
2460
+ raise module.ClientConnectionError("this write was held and could not be answered")
2452
2461
  parts = urlsplit(url)
2453
2462
  n = str(next(held_sent))
2454
2463
  held_waiting[n] = (url, method, ctx.get())
package/lib/read-text.mjs CHANGED
@@ -13,7 +13,7 @@ const rulesHead = (rules) => {
13
13
  return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
14
14
  };
15
15
  // What the last run found about the rule beside it; a rule it never asked is not a rule that held.
16
- const measuredOf = (m) => (m === null ? "; asked of no reply yet" : m ? `; asked of ${plural(m.asked, "reply", "replies")}, passed in ${num(m.held)}${m.asked > m.held ? `, broke in ${num(m.asked - m.held)}` : ""}` : "");
16
+ const measuredOf = (m) => (m === null ? "; not measured yet" : m ? `; asked of ${plural(m.asked, "reply", "replies")}, held in ${num(m.held)}${m.asked > m.held ? `, broke in ${num(m.asked - m.held)}` : ""}` : "");
17
17
  const ruleLine = (e) => ` ${e.path ? `${at(e.path, e.line)} ` : ""}"${clip(e.text, 300)}"${"measured" in e ? measuredOf(e.measured) : ""}`;
18
18
  const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
19
19
  const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed${has(m.problems) ? `, from ${plural(m.problems, "problem")}` : ""}`]);
@@ -65,7 +65,7 @@ export function readLines(r) {
65
65
  const q = r.questions;
66
66
  if (q) lines.push(`Questions: ${num(q.total)}${has(q.everywhere) ? `; ${num(q.everywhere)} asked in every conversation` : ""}${has(q.placed) ? `, ${num(q.placed)} placed in the situations they fit` : ""}.`);
67
67
  const t = r.trials;
68
- if (t) lines.push(trialsHead(t), ...(t.held ?? []).map((h) => ` ${h.surface}: ${plural(h.n, "trial")} set aside${heldWhy(h)}`));
68
+ if (t) lines.push(trialsHead(t), ...(t.held ?? []).map((h) => ` ${h.surface}: ${plural(h.n, "trial")} held back${heldWhy(h)}`));
69
69
  return lines;
70
70
  }
71
71
 
@@ -104,7 +104,7 @@ function sectionOf(r, show) {
104
104
  default: {
105
105
  const t = r.trials ?? {};
106
106
  const all = t.all ?? (t.held ?? []).map((h) => ({ surface: h.surface, held: h.n, why: h.why, side: h.side }));
107
- const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)} set aside`].filter(Boolean).join(", ")}${x.held ? heldWhy(x) : ""}`;
107
+ const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)} held back`].filter(Boolean).join(", ")}${x.held ? heldWhy(x) : ""}`;
108
108
  return { head: trialsHead(t), lines: all.map(line), continued: "Trials, continued." };
109
109
  }
110
110
  }
package/lib/stick.mjs CHANGED
@@ -74,7 +74,7 @@ function write(root, rel, change, done) {
74
74
  if (next.trim() === "") {
75
75
  rmSync(file);
76
76
  for (let dir = dirname(file); dir.startsWith(`${resolve(root)}${sep}`) && !readdirSync(dir).length; dir = dirname(dir)) rmdirSync(dir);
77
- return `${rel}: removed, it had only what stick wrote`;
77
+ return `${rel}: removed, it held only what stick wrote`;
78
78
  }
79
79
  mkdirSync(dirname(file), { recursive: true });
80
80
  writeFileSync(file, next);
package/lib/stores.mjs CHANGED
@@ -126,6 +126,8 @@ function fromParts(values) {
126
126
  for (const [p, hostName] of hosts) {
127
127
  // A host setting left blank is a store the app is set up not to use (QDRANT_HOST= beside QDRANT_PATH).
128
128
  if (hostName && !String(values[hostName] ?? "").trim()) continue;
129
+ // A host setting that holds a whole address (REDIS_HOST=redis://...) is fromUrl's, not a host.
130
+ if (hostName && String(values[hostName]).includes("://")) continue;
129
131
  const parts = {};
130
132
  for (const name of Object.keys(values)) {
131
133
  if (!name.startsWith(p) || name === hostName) continue;
@@ -235,9 +237,13 @@ const HELD = {
235
237
  bypassed: () => ["your app reached it anyway, since it loads its settings file over the settings it was started with", "Load that file without overriding what is already set"],
236
238
  other: (s) => ["the copy did not go through", `Point ${s.names[0]} at a database on this machine`],
237
239
  };
240
+ // The other way out is the owner's yes on the card in the browser, which lets trials write into it as
241
+ // it is: an agent told only to move the store spent ten minutes on a local Qdrant the card had offered
242
+ // to do without (ulaim, 2026-10-01).
238
243
  const heldWhy = (s, why, tool) => {
239
244
  const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
240
- return `Trials would write into the ${whatOf(s)} that ${listed(s.names)} ${s.names.length === 1 ? "names" : "name"}, on a server off this machine, and ${reason}. ${fix}, then run the command again.`;
245
+ const yes = why === "started" ? "" : " Or the owner can say yes to it on the card in the browser, and trials write into it as it is.";
246
+ return `Trials would write into the ${whatOf(s)} that ${listed(s.names)} ${s.names.length === 1 ? "names" : "name"}, on a server off this machine, and ${reason}. ${fix}, then run the command again.${yes}`;
241
247
  };
242
248
  const hostedKept = (s, got) => (got.how === "neon"
243
249
  ? `Your app runs against a Neon branch of its ${whatOf(s)}, made for this session through ${listed(s.names)}; the original is not touched, and the branch is deleted when this command ends.`
package/lib/text.mjs CHANGED
@@ -30,6 +30,8 @@ export const finished = (run) => Boolean(run) && (run.finished ?? DONE.has(run.s
30
30
  export function statusText(d) {
31
31
  const lines = [];
32
32
  if (d.repository?.name) lines.push(`Cortad · ${d.repository.name}`);
33
+ // What changed since the last status call, first: the read landing, endpoints proven, a run.
34
+ if (d.news?.length) lines.push("New since your last call:", ...d.news.map((n) => ` ${n}`));
33
35
  const plan = planLine(d.plan);
34
36
  if (plan) lines.push(plan);
35
37
  lines.push(...appLines(d));
@@ -86,7 +88,7 @@ function measuredLines(d) {
86
88
  if (of) lines.push(`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured.`);
87
89
  // The dissection says what was not measured in its own line.
88
90
  const groups = d.dissection ? [] : (d.notMeasured?.groups ?? []).filter((g) => g.count > 0 && g.said);
89
- if (groups.length) lines.push(`${plural(d.notMeasured.total, "check")} with no reply to read: ${groups.map((g) => clip(g.said, 240)).join("; ")}.`);
91
+ if (groups.length) lines.push(`${num(d.notMeasured.total)} not measured: ${groups.map((g) => clip(g.said, 240)).join("; ")}.`);
90
92
  // An answer from before failedReplies says its failures here, never folded into a number.
91
93
  if (!d.failedReplies && d.replies?.crashed > 0) lines.push(`${num(d.replies.crashed)} of ${plural(d.replies.total, "reply", "replies")} were failures and are not counted.`);
92
94
  // The server's own sentence already says the count.
@@ -171,7 +173,7 @@ function trialsLine(d) {
171
173
  const t = d.trials;
172
174
  // A verify replays the trials it names, so it has no written count of its own to add up.
173
175
  if (!t || !has(t.written) || d.kind === "verify") return null;
174
- const parts = [`${num(t.chosen)} chosen for this run`, t.heldBack && `${num(t.heldBack)} set aside`, t.notPlanned && `${num(t.notPlanned)} not planned`].filter(Boolean);
176
+ const parts = [`${num(t.chosen)} chosen for this run`, t.heldBack && `${num(t.heldBack)} held back`, t.notPlanned && `${num(t.notPlanned)} not planned`].filter(Boolean);
175
177
  const rest = t.notPlayed ? `; ${num(t.played)} played, ${num(t.notPlayed)} ${finished(d) ? "not played" : "still to play"}` : "";
176
178
  return `Trials: ${num(t.written)} written: ${parts.join(", ")}${rest}.`;
177
179
  }
@@ -299,7 +301,7 @@ function ledgerLines(l) {
299
301
  for (const f of l.fixed) lines.push(` ${f.path ? `${at(f.path, f.line)} (${f.findingId})` : f.findingId}: ${f.verdict}; ${num(f.after.failed)} of ${plural(f.after.replays, "replay")} failed, against ${num(f.before.failed)} of ${num(f.before.trials)} before${keptSaid(f.heldOut)}.`);
300
302
  }
301
303
  const n = l.nextRun;
302
- if (n) lines.push(`The next run would play ${[plural(n.trials, "trial"), has(n.heldOut) && `${num(n.heldOut)} kept back`, has(n.newFromChanges) && `${num(n.newFromChanges)} new from the changes`].filter(Boolean).join(", ")}.`);
304
+ if (n) lines.push(`The next run would play ${[plural(n.trials, "trial"), has(n.heldOut) && `${num(n.heldOut)} held out`, has(n.newFromChanges) && `${num(n.newFromChanges)} new from the changes`].filter(Boolean).join(", ")}.`);
303
305
  const p = l.plan;
304
306
  if (p) lines.push(`The ${p.name} plan, $${p.monthlyUsd} a month${has(p.allowed) ? `, includes ${units(p.allowed, p.unit)}` : ""}.`);
305
307
  if (l.field === null) lines.push("Production: not connected.");
@@ -342,7 +344,7 @@ function findingsTail(d) {
342
344
  for (const s of [d.read, d.heldBack, d.findings?.length ? null : d.why]) if (s) lines.push(s);
343
345
  // A finding in a situation the run keeps back: what failed and where, so the rule can be fixed;
344
346
  // its inputs and trials stay unseen, and verify replays it all the same.
345
- for (const k of d.keptBack ?? []) lines.push(`Kept back ${k.id}: "${clip(k.asks, 160)}"${k.file ? ` at ${at(k.file, k.line)}` : ""}, ${k.trials ? `failed in ${num(k.trials.failed)} of ${plural(k.trials.of, "trial")}` : `passed in ${kn(k.rate)}${has(k.reply) ? ` at reply ${num(k.reply)}` : ""}`}${k.quote ? `; the reply said "${clip(k.quote, 120)}"` : ""}. Its inputs are not shown; verify ${k.id} replays them.`);
347
+ for (const k of d.keptBack ?? []) lines.push(`Kept back ${k.id}: "${clip(k.asks, 160)}"${k.file ? ` at ${at(k.file, k.line)}` : ""}, ${k.trials ? `failed in ${num(k.trials.failed)} of ${plural(k.trials.of, "trial")}` : `held ${kn(k.rate)}${has(k.reply) ? ` at reply ${num(k.reply)}` : ""}`}${k.quote ? `; the reply said "${clip(k.quote, 120)}"` : ""}. Its inputs are not shown; verify ${k.id} replays them.`);
346
348
  return lines.join("\n");
347
349
  }
348
350
 
@@ -414,10 +416,10 @@ const pct = (x) => `${Math.round(x * 100)}%`;
414
416
  // whether the passages MISS the point left an agent unable to tell pass from fail.
415
417
  function shareLine(f) {
416
418
  const t = f.trials;
417
- if (t) return ` Failed in ${num(t.failed)} of ${plural(t.of, "trial")}, between ${pct(t.lo)} and ${pct(t.hi)} of trials${f.unsettled ? "; not settled yet: its interval is still wider than one visit in five" : ""}.`;
419
+ if (t) return ` Failed in ${num(t.failed)} of ${plural(t.of, "trial")}, between ${pct(t.lo)} and ${pct(t.hi)} of trials${f.unsettled ? "; unsettled: too few trials yet to say it fails in one visit in five" : ""}.`;
418
420
  const r = f.rate;
419
421
  if (!r) return "";
420
- return ` Broke in ${num(r.n - r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * (r.n - r.k)) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round((1 - r.hi) * 100)}% to ${Math.round((1 - r.lo) * 100)}%` : ""}.${f.unsettled ? " Not settled yet: its interval is still wider than one visit in five." : ""}`;
422
+ return ` Broke in ${num(r.n - r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * (r.n - r.k)) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round((1 - r.hi) * 100)}% to ${Math.round((1 - r.lo) * 100)}%` : ""}.${f.unsettled ? " Unsettled." : ""}`;
421
423
  }
422
424
 
423
425
  // One quoted break: what the trial sent, the reply, the sentence it broke on, what the reply was
@@ -429,8 +431,6 @@ function quoteLines(q, f, logs) {
429
431
  if (q.asked) lines.push(` Sent: "${clip(q.asked, 300)}"`);
430
432
  lines.push(` Reply ${q.reply}: "${clip(q.answer ?? q.quote, 400)}"${about ? ` (${about})` : ""}`);
431
433
  if (q.answer && q.quote && q.quote !== q.answer) lines.push(` It broke on: "${clip(q.quote, 300)}"`);
432
- // How sure the reading was that this reply broke the check, and who decided it.
433
- if (has(q.p)) lines.push(` ${num(Math.round(q.p * 100))} in 100 sure${q.decidedBy === "code" ? ", decided by code" : q.decidedBy === "text-judge" ? ", decided by the second judge" : ""}.`);
434
434
  if (q.after) lines.push(` Then the customer: "${clip(q.after, 300)}"`);
435
435
  if (q.checked) lines.push(` ${q.checked}`);
436
436
  for (const p of (q.passages ?? []).slice(0, 3)) lines.push(` Passage${p.source ? ` (${clip(p.source, 60)})` : ""}: "${clip(p.text, 240)}"`);
@@ -472,11 +472,11 @@ export function fieldText(d) {
472
472
  if (!t || !t.n) return [`No production conversations read in the last ${plural(d.days, "day")}.`, ...link].join("\n");
473
473
  const lines = [
474
474
  `Production, last ${plural(d.days, "day")}: ${plural(t.n, "conversation")}, ${num(t.read)} read.`,
475
- `Rule checks passed: ${pct(t.rulingsHeld, t.rulings)} of ${num(t.rulings)}${t.rulingsUnsure ? ` (${num(t.rulingsUnsure)} too close to call)` : ""}. Resolved ${pct(t.resolved, t.read)}, frustrated ${pct(t.frustrated, t.read)}, asked for a human ${pct(t.wantsHuman, t.read)}, unanswered ${pct(t.unanswered, t.read)}.`,
475
+ `Rule checks held: ${pct(t.rulingsHeld, t.rulings)} of ${num(t.rulings)}${t.rulingsUnsure ? ` (${num(t.rulingsUnsure)} unsure)` : ""}. Resolved ${pct(t.resolved, t.read)}, frustrated ${pct(t.frustrated, t.read)}, asked for a human ${pct(t.wantsHuman, t.read)}, unanswered ${pct(t.unanswered, t.read)}.`,
476
476
  ];
477
477
  const broke = (d.rules ?? []).filter((r) => r.broke > 0).sort((a, b) => b.broke - a.broke).slice(0, 5);
478
478
  if (broke.length) lines.push(`Rules broken most: ${broke.map((r) => `${r.id} (${num(r.broke)})`).join(", ")}.`);
479
- if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conversation")}, ${pct(j.rulingsHeld, j.rulings)} passed`).join("; ")}.`);
479
+ if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conversation")}, ${pct(j.rulingsHeld, j.rulings)} held`).join("; ")}.`);
480
480
  return [...lines, ...link].join("\n");
481
481
  }
482
482
 
package/lib/trace.cjs CHANGED
@@ -17,7 +17,7 @@ if (FILE) {
17
17
  const http = require("node:http");
18
18
  const https = require("node:https");
19
19
  const als = new AsyncLocalStorage();
20
- const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder } = require("./wire.cjs");
20
+ const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder } = require("./wire.cjs");
21
21
  const row = (value) => { try { fs.appendFileSync(FILE, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
22
22
  // What the app writes into its own folder while a run is on: each file copied once before its
23
23
  // first write, so the command puts every one back when the run ends (lib/writes.mjs). Trials
@@ -611,7 +611,7 @@ if (FILE) {
611
611
  args.forEach((a, i) => { named[IDENT.test(names[i] || "") ? names[i] : String(i)] = plainOf(a); });
612
612
  let text;
613
613
  try { text = error ? `raised ${(error && error.name) || "an error"}: ${(error && error.message) || String(error)}` : returned(value); } catch { text = "returned a value this hook cannot read"; }
614
- row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(ctx ? { ex: ctx.id } : {}), ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [{ name, text: String(text).slice(0, TOOL_TEXT) }] } });
614
+ row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(ctx ? { ex: ctx.id } : {}), ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [toolText(name, String(text))] } });
615
615
  };
616
616
  const TOOL = Symbol.for("cortad.wrapped");
617
617
  const wrappers = new WeakMap();
package/lib/wire.cjs CHANGED
@@ -55,7 +55,11 @@ const partsOf = (text) => text.split(/\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>/)
55
55
  // messages of a chat body, the function outputs of a responses body, the tool_result blocks of
56
56
  // an Anthropic one, the functionResponse parts of a Gemini one. They are the material a reply's
57
57
  // facts rest on, and without them a real ticket id read as invented. Bounded per call.
58
- const TOOL_TEXT = 3000, TOOLS_MAX = 12;
58
+ // A tool's answer is kept to the cap the reader takes (12,000 characters), and one longer says it was
59
+ // cut: MIT Learn AI's 8,000-character search results were kept at 3,000, and a podcast episode in
60
+ // the part cut off was graded as made up. A cut answer stands the passage questions down.
61
+ const TOOL_TEXT = 12000, TOOLS_MAX = 12;
62
+ const toolText = (name, full, extra = {}) => ({ name: String(name || "").slice(0, 80), text: full.slice(0, TOOL_TEXT), ...extra, ...(full.length > TOOL_TEXT ? { cut: true } : {}) });
59
63
  const textOf = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v.map((p) => (p && typeof p === "object" ? (p.text || p.content || "") : String(p || ""))).filter(Boolean).join("\n") : v && typeof v === "object" ? JSON.stringify(v) : "");
60
64
  // Only what came after the person's latest message belongs to this turn: a thread the app
61
65
  // resends whole carries every earlier turn's tool answers too.
@@ -71,7 +75,7 @@ const toolsIn = (sent) => {
71
75
  if (!body || typeof body !== "object") return undefined;
72
76
  const out = [];
73
77
  const names = new Map();
74
- const add = (name, text) => { const t = textOf(text).slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
78
+ const add = (name, text) => { const full = textOf(text); const t = full.slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push(toolText(name, full)); };
75
79
  for (const m of list(body.messages)) {
76
80
  for (const c of list(m.tool_calls)) if (c.id && c.function) names.set(c.id, c.function.name);
77
81
  for (const c of list(m.content)) if (c.type === "tool_use" && c.id) names.set(c.id, c.name);
@@ -323,7 +327,7 @@ const providerIn = (sent, events) => {
323
327
  const offered = list(body && body.tools).map((t) => t.type).filter((t) => typeof t === "string" && t !== "function" && t !== "custom");
324
328
  const named = (kind) => offered.find((t) => t.startsWith(kind)) || kind;
325
329
  const calls = new Map(), names = new Map(), answers = [];
326
- const answer = (name, text) => { const t = textOf(text).slice(0, TOOL_TEXT); if (name && t.trim() && answers.length < TOOLS_MAX && !answers.some((a) => a.name === name && a.text === t)) answers.push({ name: String(name).slice(0, 80), text: t, provider: true }); };
330
+ const answer = (name, text) => { const full = textOf(text); const t = full.slice(0, TOOL_TEXT); if (name && t.trim() && answers.length < TOOLS_MAX && !answers.some((a) => a.name === name && a.text === t)) answers.push(toolText(name, full, { provider: true })); };
327
331
  for (const e of events) {
328
332
  if (!e || typeof e !== "object") continue;
329
333
  const order = [];
@@ -813,6 +817,6 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
813
817
 
814
818
  module.exports = {
815
819
  MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, MODEL_HOST,
816
- scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
820
+ scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
817
821
  makeRecorder,
818
822
  };
package/lib/words.mjs CHANGED
@@ -12,8 +12,6 @@ export const has = (v) => v !== undefined && v !== null;
12
12
  export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
13
13
  export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
14
14
  export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
15
- // A reading between 35 and 85 in 100 is too close to call; a block says that band the first time.
16
- export const closeToCall = (n, said = {}) => { const band = said.close ? "" : " (between 35 and 85 in 100)"; said.close = true; return `${num(n)} too close to call${band}`; };
17
15
  // A line past the end of the file as it stands on this machine is not an address: the file moved
18
16
  // on since it was read, and an agent was sent to line 204 of a 172-line file. The file stays.
19
17
  // Read each time, never kept: the agent edits these files while this server runs. Only a path
package/local.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
13
13
  // never leaves this machine. Ctrl-C ends everything.
14
14
 
15
+ import { storesOf } from "./lib/stores.mjs";
15
16
  import { notAnApp, withoutStores } from "./lib/app-port.mjs";
16
17
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
17
18
  import { spawn, execFile, execFileSync } from "node:child_process";
@@ -508,6 +509,8 @@ async function verb(job) {
508
509
  }
509
510
  // The person changed what they said yes to while this command runs.
510
511
  case "consent": await allowOutbound(b); return { ok: true };
512
+ // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
513
+ case "say": { const line = String(b.line ?? "").replace(/\s+/g, " ").trim().slice(0, 400); if (line) say(line); return { ok: true }; }
511
514
  case "restart": return restartApp();
512
515
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
513
516
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
@@ -661,7 +664,7 @@ const ownFolder = (dir) => { try { const at = realpathSync(dir); return at === r
661
664
  // carries it to the verbs, and the screen gets the app's own words beside it.
662
665
  function whyNot(up) {
663
666
  const h = up.held;
664
- if (h) return `port ${h.port} is in use by another program (pid ${h.pid}${h.command ? `, ${h.command}` : ""}), so your app cannot listen there${up.fixed ? "; your app sets that port in its own code, so it cannot be moved" : ""}. Stop that program, then save a file here: your app is started again by itself.`;
667
+ if (h) return `port ${h.port} is held by another program (pid ${h.pid}${h.command ? `, ${h.command}` : ""}), so your app cannot listen there${up.fixed ? "; your app sets that port in its own code, so it cannot be moved" : ""}. Stop that program, then save a file here: your app is started again by itself.`;
665
668
  const wrongNode = pinned.major && !pinned.bin ? ` This project pins Node ${pinned.major} and this shell runs Node ${shellNode}: switch to ${pinned.major}.` : "";
666
669
  return `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}.${wrongNode} Fix it and save a file here: your app is started again by itself.`;
667
670
  }
@@ -861,7 +864,10 @@ async function start(waitMs, tries = 3) {
861
864
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
862
865
  // Taken before the app reads a file: a save after this moment is a change it has not loaded.
863
866
  const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
864
- note.files = sourceOf(root, files);
867
+ // The settings files count as what it loaded: an env file is rarely tracked, and an agent's edit to
868
+ // one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
869
+ note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
870
+ storesAt ??= storesNamed();
865
871
  loaded = note;
866
872
  const copies = await keeping.env(true);
867
873
  await allowOutbound();
@@ -957,10 +963,21 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
957
963
  // Every save that changes what your app runs starts it again, when it does not reload by itself: an
958
964
  // agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
959
965
  // at again after each restart, for a save made while it was under way.
966
+ // The stores the settings name, as copies are made of them: a change here is a change of what the app
967
+ // writes into, so its copies and holds are made again for the new settings, and said again.
968
+ const storesNamed = () => { try { return JSON.stringify(storesOf({ ...envExports(envFiles), ...process.env }).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
969
+ let storesAt = null;
960
970
  async function restartOnSave() {
961
971
  while (!closing) {
962
972
  await sourceChanged();
963
- while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) await restartApp();
973
+ while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) {
974
+ const named = storesNamed();
975
+ const moved = storesAt !== null && named !== storesAt;
976
+ storesAt = named;
977
+ if (moved) await keeping.forget().catch(() => {});
978
+ await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
979
+ if (moved) await announce().catch(() => null);
980
+ }
964
981
  }
965
982
  }
966
983
  async function restartNow() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.13",
3
+ "version": "0.3.1-rc.15",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
package/skill/SKILL.md CHANGED
@@ -25,7 +25,7 @@ The connect command ends with "Go back to the browser".
25
25
 
26
26
  1. The connect command says whether it started the app and the port to send requests to. If it says the app did not start, fix what it names and save; it starts the app again by itself. Requests are seen in an app the command started, or at the port it says to send them to.
27
27
  2. Send one real request to each endpoint that reaches the model, the way the app's own client does: its route, its body, its sign-in. On a chat, send a second message in the same conversation. When `status` asks for a request signed in as another customer, send it: each customer you sign in as becomes an account of its own in the run.
28
- 3. `status` then shows what Cortad read (rules, journeys, simulated users, endpoints, standards) and each endpoint your requests reached with what it did inside the app: the model and how many calls a request made, the seconds a reply took, which of the rules read from the code the prompts carried, the tools that ran, the passages handed to the model, and problems a line of code decides, at their file and line. Endpoints the read found that no request reached are listed apart. The line `A run on the proven endpoints:` says how many conversations, about how long and about what it costs on the app's own key; any `Run waits` line says whose side it is on.
28
+ 3. `status` then shows what Cortad read (rules, journeys, simulated users, endpoints, standards) and each endpoint your requests reached with what it did inside the app: the model and how many calls a request made, the seconds a reply took, which of the rules read from the code the prompts carried, the tools that ran, the passages handed to the model, and problems a line of code decides, at their file and line. Endpoints the read found that no request reached are listed apart. The line `A run on the proven endpoints:` says how many conversations, about how long and about what it costs on the app's own key; any `Run is held` line says whose side it is on.
29
29
  4. The first run is free. Before it, read `status` for what would make the run measure the setup instead of the app:
30
30
  - a service down: a `Problem` line where a retrieval got no answer, or a tool came back as an error;
31
31
  - a request that ended on a tool call: `The request ended right after ... ran: no model call came after it`, which every conversation on that endpoint would repeat;
@@ -41,7 +41,7 @@ The connect command ends with "Go back to the browser".
41
41
 
42
42
  ## The first run: the baseline
43
43
 
44
- The first finished run on a repository prints its baseline, in this order: one line on what ran; who the users are, as the read found them in the code, with how many of each one's conversations got what they came for; the journeys and how each went, with the checks that failed most there; the checks no conversation broke; what broke, found only by running the app, each with its file and line; what a code review would also find; what the numbers leave out and why; one line on production.
44
+ The first finished run on a repository prints its baseline, in this order: one line on what ran; who the users are, as the read found them in the code, with how many of each one's conversations got what they came for; the journeys and how each went, with the checks that failed most there; the checks no conversation broke; what broke, found only by running the app, each with its file and line; what a code review would also find; what was not measured and why; one line on production.
45
45
 
46
46
  How to read it:
47
47
 
@@ -49,8 +49,8 @@ How to read it:
49
49
  - "Got what they came for" is read at each conversation's last reply, in full or in part.
50
50
  - What broke was found by simulated customers who write the way the read says that user writes and answer each reply as it comes. Each finding carries its reach, the exchange (`Sent`, `Broke on`, `Then`) and where the fix belongs.
51
51
  - A hand-written script sends the requests someone wrote and checks the strings they expected. The exchange shows whether a finding took a customer who pressed, came back or wrote their own way; say which findings a script would also have caught.
52
- - What passed is measured too: a check no conversation broke, asked often enough that its interval tops out under one in five, leaving out any that names a tool no conversation ran.
53
- - "What the numbers leave out, and why" is what this run says nothing about.
52
+ - What held is measured too: a check no conversation broke, asked often enough that its interval tops out under one in five, leaving out any that names a tool no conversation ran.
53
+ - "What was not measured, and why" is what this run says nothing about.
54
54
  - Some conversations are kept back from you; a verify replays them too, so a fix is graded on conversations you have not read.
55
55
 
56
56
  Tell the person what the run found and what it did not, with the numbers as printed.
@@ -78,7 +78,7 @@ The trials, checks, seeds and the conversations kept back belong to Cortad, and
78
78
  After the fixes, `run` again. It plays what your changes since the last full run reach: those conversations again, each paired with its earlier self, and 8 to 12 new ones written for the change; it says what it left out. Its text leads with what moved: each behavior's failing count before and now, whether that is beyond chance, and the conversation that turned. Worse comes first.
79
79
 
80
80
 
81
- Production comes up after the first run. A verify whose failure is gone, and gone or less often on the conversations kept back from you, while production is not connected, ends with a "For the person" line that offers it; the plan line after a run names the production replies the plan above reads, and a spent plan's answer says whether production is connected. `field_connect` gives the steps; the owner creates the key in the browser. `field` gives the numbers: conversations read, checks passed, the rules broken most. Message text stays out.
81
+ Production comes up after the first run. A verify whose failure is gone, and gone or less often on the conversations kept back from you, while production is not connected, ends with a "For the person" line that offers it; the plan line after a run names the production replies the plan above reads, and a spent plan's answer says whether production is connected. `field_connect` gives the steps; the owner creates the key in the browser. `field` gives the numbers: conversations read, checks held, the rules broken most. Message text stays out.
82
82
 
83
83
  ## Making it stick
84
84
 
@@ -57,7 +57,7 @@ Engineering standards: 38 decided, 35 met, 3 missed.
57
57
  apps/api/src/tools/search.ts:51 Tool errors reach the model as errors: search returns an empty list when the index is down (decided by a model)
58
58
  Questions: 112; 40 asked in every conversation, 72 placed in the situations they fit.
59
59
  Trials: 58 written.
60
- POST /api/homework/upload: 7 trials set aside, on the app's side: the route needs a signed file URL, and no test account can make one
60
+ POST /api/homework/upload: 7 trials held back, on the app's side: the route needs a signed file URL, and no test account can make one
61
61
  Latest run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
62
62
  Production: not connected.
63
63
  For the person: https://cortad.com/lab shows this in the browser.
@@ -109,7 +109,7 @@ Last run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: 3 findings, 51 of 51 trials playe
109
109
  2 fixes verified since that run:
110
110
  apps/api/src/agent/prompt.ts:41 (finding:1): gone; 0 of 3 replays failed, against 3 of 3 before; on the conversations kept back from you, 1 of 2 failed before, 0 of 2 after, not beyond chance.
111
111
  apps/api/src/agent/system.ts:12 (finding:2): gone; 0 of 7 replays failed, against 4 of 4 before; the fix made the failure worse on the conversations kept back from you (0 of 8 failed before, 6 of 8 after).
112
- The next run would play 58 trials, 7 kept back, 5 new from the changes.
112
+ The next run would play 58 trials, 7 held out, 5 new from the changes.
113
113
  The Hobby plan, $99 a month, includes 10 runs.
114
114
  Production: not connected.
115
115
  Nothing ran.
@@ -118,14 +118,14 @@ For the person: plans and checkout at https://cortad.com/pricing?checkout=ship
118
118
 
119
119
  ## run_status
120
120
 
121
- Playing. The call waited up to 45 seconds:
121
+ Playing. The call held up to 45 seconds:
122
122
 
123
123
  ```
124
124
  Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
125
125
  next: run_status 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b
126
126
  ```
127
127
 
128
- Finished, the first run on a repository: its baseline. One line on what ran, then who the users are as the read found them in the code, with how many of each one's conversations got what they came for, in full or in part, read at the last reply; the journeys the read named and how each went, with the one or two checks that failed most there; the checks no conversation broke, asked often enough that each fails less than one time in five; then what broke, found only by running the app, each finding with its reach in conversations, the line of the app that wrote the breaking reply, the tools behind it, the layer a fix belongs in (prompt, flow around the model, tool or routing) and the exchange it broke on; the gates a knock found open; what a code review would also have found; what the numbers leave out; the run's model, time and spend; and one line on production. Every rate carries its 95% interval, said in words the first time. The findings take the room the picture leaves, the rest one line each, and findings shows them whole.
128
+ Finished, the first run on a repository: its baseline. One line on what ran, then who the users are as the read found them in the code, with how many of each one's conversations got what they came for, in full or in part, read at the last reply; the journeys the read named and how each went, with the one or two checks that failed most there; the checks no conversation broke, asked often enough that each fails less than one time in five; then what broke, found only by running the app, each finding with its reach in conversations, the line of the app that wrote the breaking reply, the tools behind it, the layer a fix belongs in (prompt, flow around the model, tool or routing) and the exchange it broke on; the gates a knock found open; what a code review would also have found; what was held back and not measured; the run's model, time and spend; and one line on production. Every rate carries its 95% interval, said in words the first time. The findings take the room the picture leaves, the rest one line each, and findings shows them whole.
129
129
 
130
130
  A finding that sends customers away counts, for one journey or for everything else customers asked at one endpoint, the conversations whose last reply declined them or pointed them somewhere else, or where they asked for a person or said the app could not help and were not handed on; a conversation that ended with the customer served is never counted. It names the line of the prompt, or the tool those asks needed that the call writing the reply was given and never ran, that at least two and at least half of the replies read point at, reading at most 8 of them, and says where the fix belongs is not known when neither holds; it shows what the customer said next, and stays unconfirmed until its conversations were checked a second time. The same cut comes as structured data beside the text:
131
131
 
@@ -175,9 +175,9 @@ access:1 GET /api/admin/students answered 200 to a request with no sign-in; it
175
175
  Harm: data exposure.
176
176
  At apps/api/src/routes/admin.ts:14
177
177
  verify access:1 knocks the routes again.
178
- What the numbers leave out, and why:
179
- Set aside: 7 conversations (POST /api/homework/upload: the route needs a signed file URL, and no test account can make one).
180
- 28 checks with no reply to read: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no conversation yet.
178
+ What was not measured, and why:
179
+ Held: 7 conversations held back (POST /api/homework/upload: the route needs a signed file URL, and no test account can make one).
180
+ 28 checks 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 conversation yet.
181
181
  No conversation reached this journey: account recovery.
182
182
  Your app answered on gpt-4o-mini. The run took 7 min 32 s and spent $0.41 on your key over 214 model calls.
183
183
  Replies took 3.1 s at the median and 11.4 s at the slowest, over 138 replies.
@@ -343,7 +343,7 @@ finding:2 Is the reply written in a language other than the one the student wro
343
343
  A verify replays its 4 failing conversations. 2 clean replays show it gone.
344
344
 
345
345
  finding:3 Does the reply promise a refund the billing page does not offer?
346
- Failed in 2 of 7 trials, between 8% and 64% of trials; not settled yet: its interval is still wider than one visit in five.
346
+ Failed in 2 of 7 trials, between 8% and 64% of trials; unsettled: too few trials yet to say it fails in one visit in five.
347
347
  Sent: "We are on the paid plan. What happens if we cancel?"
348
348
  Reply 1: "You can get a full refund any time in the first 60 days." (trial 77a0c5d1)
349
349
  At apps/api/src/agent/prompt.ts:44
@@ -357,7 +357,7 @@ finding:3 Does the reply promise a refund the billing page does not offer?
357
357
  "Never state a refund policy the product does not publish." (apps/api/src/agent/prompt.ts:41): broke in 9 of 12 trials.
358
358
  "Answer in the language the student writes in." (apps/api/src/agent/system.ts:12): broke in 2 of 26 trials.
359
359
  112 of 140 checks measured.
360
- 28 checks with no reply to read: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no conversation yet.
360
+ 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 conversation yet.
361
361
  3 findings stand in the 12 situations you can read.
362
362
  2 findings stand in 3 situations kept back from you. You cannot read them, and a fix is graded on those too.
363
363
  ```
@@ -408,8 +408,8 @@ Production is not connected.
408
408
 
409
409
  ```
410
410
  Production, last 30 days: 4,812 conversations, 4,790 read.
411
- Rule checks passed: 93% of 61,204 (1,120 too close to call). Resolved 71%, frustrated 6%, asked for a human 2%, unanswered 4%.
411
+ Rule checks held: 93% of 61,204 (1,120 unsure). Resolved 71%, frustrated 6%, asked for a human 2%, unanswered 4%.
412
412
  Rules broken most: rule:answer-first (412), rule:language (188), rule:cite-source (97).
413
- By journey: homework help 3,102 conversations, 94% passed; billing question 410 conversations, 88% passed.
413
+ By journey: homework help 3,102 conversations, 94% held; billing question 410 conversations, 88% held.
414
414
  For the person: https://cortad.com/lab#field
415
415
  ```