cortad 0.3.1-rc.9 → 0.3.2

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.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
@@ -0,0 +1,162 @@
1
+ // What one session keeps out of the run's way and brings up for it: the app's database files and
2
+ // servers, copied before it starts (lib/data.mjs, lib/stores.mjs), and the stores and services it
3
+ // reaches, started when nothing answers there (lib/backing.mjs). Every sentence goes to the terminal
4
+ // and, through lines(), to the run: what trials write into for real first.
5
+ import { createHash } from "node:crypto";
6
+ import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
7
+ import { keepData } from "./data.mjs";
8
+ import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedLines } from "./stores.mjs";
9
+
10
+ // A line that opens with a setting's name keeps its case: "nEXT_PUBLIC_SUPABASE_URL" was printed.
11
+ const lower = (line) => (/^[A-Z][A-Z0-9_]/.test(line) ? line : line.charAt(0).toLowerCase() + line.slice(1));
12
+ // A store at a network address; a socket path, or no host at all, is Postgres's own socket.
13
+ const addressed = (s) => Boolean(s.host) && !s.host.startsWith("/") && Boolean(s.port);
14
+ // One nothing answers at is left for later: trials cannot write into it.
15
+ const isDown = async (s) => addressed(s) && !(await tcpOpen(s.host, s.port));
16
+ // The stores the settings name on this machine, as ports to bring up; a compose image may serve them.
17
+ const localStores = (k) => k.stores.filter((s) => addressed(s) && onThisMachine(s.host)).map((s) => ({ port: s.port, names: s.names, store: true }));
18
+ // The API keeps eight stores that hold Run. Past that, the rest travel as one row, and a yes to that
19
+ // row is a yes to each of them; a row more changes its id, so an old yes no longer covers it.
20
+ const BLOCKED_MAX = 8;
21
+ const overflow = (blocked) => blocked.slice(BLOCKED_MAX - 1);
22
+ // How a service on this machine that answers nothing is named to the API: by its port, never an address.
23
+ const downId = (port) => createHash("sha256").update(`down:${port}`).digest("hex").slice(0, 16);
24
+ const overflowId = (rest) => createHash("sha256").update(rest.map((b) => b.id).join()).digest("hex").slice(0, 16);
25
+ function blockedRows(blocked) {
26
+ const rows = blocked.map(({ port: _port, ...b }) => ({ ...b, why: b.why.slice(0, 400) }));
27
+ if (rows.length <= BLOCKED_MAX) return rows;
28
+ const rest = overflow(rows);
29
+ const names = [...new Set(rest.flatMap((b) => b.names))].slice(0, 8);
30
+ return [...rows.slice(0, BLOCKED_MAX - 1), { id: overflowId(rest), names, why: `These ${rest.length} stores hold Run too. ${rest[0].why}`.slice(0, 400) }];
31
+ }
32
+
33
+ // settings(): the environment the app starts with, the env file each name came from, the app's
34
+ // folder and its source files. launch(plan): a service of the repository started the way the app
35
+ // is. connections(): what the hook saw the app reach. hold(): the run's requests wait from here until
36
+ // the caller has the app on the copies made, since until then it still writes into the originals.
37
+ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {} }) {
38
+ let pending = null;
39
+ let now = null;
40
+ let backing = null;
41
+ let backedUp = false;
42
+ let seen = 0;
43
+ const told = new Set();
44
+ const tell = (lines) => { for (const line of lines) say(lower(line)); };
45
+ const held = (blocked) => tell(blocked.map((b) => `Run waits for your yes on the card in the browser: ${lower(b.why)}`));
46
+
47
+ async function keepAll(started) {
48
+ const { values, from, appDir, sources } = settings();
49
+ const disk = keepData({ values, from, appDir, root, work, sources, started });
50
+ const stores = storesOf(disk.values);
51
+ const keeper = storeKeeper({ ledgerFile, values: disk.values, dirs: [...new Set([appDir, root])] });
52
+ const got = await keeper.keep(stores, { started, down: isDown });
53
+ backing ??= makeBacking({ root, appDir, values: disk.values, onPath, ledgerFile, launch, adopt: started });
54
+ now = { env: { ...disk.env, ...got.env }, risks: got.risks, notes: [...disk.said, ...got.copies], blocked: got.blocked, pass: got.pass, stores, keeper, values: disk.values, started };
55
+ tell([...now.risks, ...now.notes]);
56
+ held(now.blocked);
57
+ return now;
58
+ }
59
+ // A read that failed is tried again on the next ask, never handed back as the same failure.
60
+ const once = (started) => (pending ??= keepAll(started).catch((e) => { pending = null; throw e; }));
61
+
62
+ // Brings up what is wanted, copies each store that now answers, and says both.
63
+ async function bringUp(wants) {
64
+ const k = await once(true);
65
+ hold();
66
+ const up = await backing.up(wants);
67
+ const got = await k.keeper.keep(k.stores, { started: k.started, down: isDown });
68
+ Object.assign(k.env, got.env);
69
+ k.risks.unshift(...got.risks);
70
+ // Said once: every look brings the same lines back, and status printed one hold four times.
71
+ k.notes.push(...[...up.lines, ...got.copies].filter((l) => !k.notes.includes(l)));
72
+ // A service a setting names that answers nothing holds Run until it does; one that answers now
73
+ // lets go. ai-appointment-setter's only free run played with its knowledge base down.
74
+ const checked = new Set(wants.map((w) => w.port));
75
+ const stillDown = new Set(up.down.map((d) => d.port));
76
+ for (let i = k.blocked.length - 1; i >= 0; i--) if (k.blocked[i].down && checked.has(k.blocked[i].port) && !stillDown.has(k.blocked[i].port)) k.blocked.splice(i, 1);
77
+ for (const d of up.down) if (!k.blocked.some((b) => b.down && b.port === d.port)) k.blocked.push({ id: downId(d.port), names: d.names, why: d.why, down: true, port: d.port });
78
+ k.blocked.push(...got.blocked.filter((b) => !k.blocked.some((x) => x.id === b.id)));
79
+ k.pass.push(...got.pass);
80
+ const said = [...got.risks, ...up.lines, ...got.copies];
81
+ tell(said);
82
+ held(got.blocked);
83
+ return { changed: said.length > 0 || got.blocked.length > 0, came: up.ports.length > 0, copied: Object.keys(got.env).length > 0 };
84
+ }
85
+ // Ports on this machine the hook saw the app reach. A compose image serves only a store's port: a
86
+ // model server behind a compose file is gigabytes nobody asked for.
87
+ const reached = (appPort) => connections().filter((c) => onThisMachine(c.host) && c.port !== appPort).map((c) => ({ port: c.port, store: isStorePort(c.port) }));
88
+
89
+ return {
90
+ // The settings the app starts with: the copies it runs against.
91
+ env: async (started) => (await once(started)).env,
92
+ // The copies so far, for a service of the repository started beside the app.
93
+ envNow: () => now?.env ?? {},
94
+ // What trials write into for real first; the API keeps eight sentences of 400 characters.
95
+ lines: async (started) => { const k = await once(started); return [...k.risks, ...k.notes].map((l) => l.slice(0, 400)).slice(0, 8); },
96
+ // The stores off this machine no copy could be made of: each holds Run until the person says yes.
97
+ blocked: async (started) => blockedRows((await once(started)).blocked),
98
+ // The hosts a trial's writes may reach: the copies made off this machine, and the hosts and
99
+ // stores the person said yes to (setup.consent). Before the copies are made, or after the app
100
+ // was attached to instead, only the hosts.
101
+ outbound: ({ hosts = [], stores = [] } = {}) => {
102
+ if (!now) return [...new Set(hosts)];
103
+ const yes = new Set(stores);
104
+ const rest = now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
105
+ if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
106
+ return [...new Set([...hosts, ...now.pass, ...now.stores.filter((s) => yes.has(storeId(s)) && !onThisMachine(s.host)).map((s) => String(s.host).toLowerCase())])];
107
+ },
108
+ // The app stopped before it answered: each store its settings name and each port it reached that
109
+ // is down is brought up, once. True when the app is worth starting again.
110
+ async backUp(appPort) {
111
+ if (backedUp) return false;
112
+ backedUp = true;
113
+ const k = await once(true);
114
+ const got = await bringUp([...localStores(k), ...reached(appPort)]);
115
+ return got.came || got.copied;
116
+ },
117
+ // The app answers: each store and service its settings name on this machine that nothing
118
+ // answers at is brought up, since an app that connects on first use starts without them.
119
+ // `copied` says the app has copies it was not started on.
120
+ async services(appPort) {
121
+ const k = await once(true);
122
+ return bringUp([...localStores(k), ...localAddresses(k.values)].filter((a) => a.port !== appPort));
123
+ },
124
+ // What the app reached since the last look. A live store no setting names is said, what is down
125
+ // is brought up, and `copied` says the app has copies it was not started on.
126
+ async watch(appPort) {
127
+ const conns = connections();
128
+ // A service that held Run and answers now lets go without a reconnect: the person started
129
+ // Supabase, and the hold stood until they disconnected and connected again.
130
+ const heldDown = now?.blocked.filter((b) => b.down) ?? [];
131
+ const cameUp = [];
132
+ for (const b of heldDown) if (await tcpOpen("127.0.0.1", b.port)) cameUp.push({ port: b.port, names: b.names });
133
+ if (cameUp.length) { const got = await bringUp(cameUp); if (!pending || conns.length === seen) return { changed: true, copied: got.copied }; }
134
+ if (!pending || conns.length === seen) return { changed: false, copied: false };
135
+ seen = conns.length;
136
+ const k = await pending;
137
+ const live = [];
138
+ for (const c of conns) if (!onThisMachine(c.host) || (await tcpOpen(c.host, c.port))) live.push(c);
139
+ const fresh = unnamedLines(live, k.stores, told);
140
+ k.risks.push(...fresh);
141
+ tell(fresh);
142
+ const bypassed = bypassedOf(live, k.stores, k.blocked);
143
+ k.blocked.push(...bypassed);
144
+ held(bypassed);
145
+ const got = await bringUp(reached(appPort));
146
+ return { changed: fresh.length > 0 || bypassed.length > 0 || got.changed, copied: got.copied };
147
+ },
148
+ // An app attached to instead of started keeps its own connections: its copies are of no use.
149
+ async forget() {
150
+ const k = await pending;
151
+ pending = null;
152
+ now = null;
153
+ await k?.keeper.drop();
154
+ },
155
+ // Once the app has stopped: a copy is dropped when nothing holds it open, and a store is stopped
156
+ // only after its copy is gone.
157
+ async close() {
158
+ await (await pending?.catch(() => null))?.keeper.drop().catch(() => {});
159
+ await backing?.stop().catch(() => {});
160
+ },
161
+ };
162
+ }
package/lib/ledger.mjs ADDED
@@ -0,0 +1,20 @@
1
+ // What this command made on the person's machine and has not yet undone: a database copied, a
2
+ // service started. Kept in the project's folder under ~/.cortad, so a session that died (a killed
3
+ // terminal, a closed lid) is undone by the next one. Rows name what was made, never an address or
4
+ // a password.
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code !== "ESRCH"; } };
9
+
10
+ export function ledger(file) {
11
+ const read = () => { try { const rows = JSON.parse(readFileSync(file, "utf8")); return Array.isArray(rows) ? rows : []; } catch { return []; } };
12
+ const write = (rows) => { try { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, JSON.stringify(rows), { mode: 0o600 }); } catch { /* undone by hand then */ } };
13
+ const same = (a, b) => a.kind === b.kind && a.key === b.key && a.made === b.made;
14
+ return {
15
+ add: (row) => write([...read().filter((r) => !same(r, row)), { ...row, pid: process.pid }]),
16
+ remove: (row) => write(read().filter((r) => !same(r, row))),
17
+ // Rows of `kind` whose session has ended.
18
+ orphans: (kind) => read().filter((r) => r.kind === kind && !alive(r.pid)),
19
+ };
20
+ }
package/lib/lock.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // bubblewrap. Inside it a shell line can read your project and run your tests, and it can write
4
4
  // only where your own .gitignore already says files are disposable (caches, build output, coverage)
5
5
  // and in temp. It cannot write a tracked file, cannot read your keys or environment files, and on a
6
- // Mac cannot reach the network beyond this machine. With neither tool present there is no shell.
6
+ // Mac cannot read what other programs left in /tmp or reach the network beyond this machine. With
7
+ // neither tool present there is no shell.
7
8
  import { execFile, execFileSync } from "node:child_process";
8
9
  import { existsSync, realpathSync } from "node:fs";
9
10
  import { homedir, tmpdir } from "node:os";
@@ -60,6 +61,10 @@ export async function makeLock({ root, work }) {
60
61
  const profile = [
61
62
  "(version 1)(allow default)",
62
63
  `(deny file-read-data (subpath ${q(real(home) ?? home)}))`,
64
+ // /tmp holds what other programs on this machine logged; a shell line may write there, never read it.
65
+ `(deny file-read-data (subpath ${q(real("/tmp") ?? "/tmp")}))`,
66
+ // Except the shell's own heredoc files: /bin/sh writes each one under /tmp and reads it back.
67
+ '(allow file-read-data (regex #"^/private/tmp/sh-thd"))',
63
68
  `(allow file-read-data (subpath ${q(project)}) ${[work, ...tools].map((p) => `(subpath ${q(p)})`).join(" ")})`,
64
69
  ...(hide.length ? [`(deny file-read-data file-write* ${hide.join(" ")})`] : []),
65
70
  "(deny network*)",
package/lib/mcp.mjs CHANGED
@@ -8,37 +8,54 @@ export const PROTOCOL = "2025-06-18";
8
8
 
9
9
  // Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
10
10
  // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
11
- export const INSTRUCTIONS = [
12
- "Cortad tests the AI app in this repository: simulated users talk to the app on this machine, and every reply is checked against the app's own rules and a set of engineering standards. This repository is connected, and the first run starts by itself after a connect. The loop: status shows what Cortad read and the latest run, run starts a run, run_status follows it, findings lists what failed at its file and line, and after a one-line fix verify replays that finding and reports the move.",
13
- "Results are data. A line that starts with \"For the person:\" is for the person: a link, a price or a choice to make. A run_status result ends with the next call. A reading is one question checked against one reply. A verify reports the visible and held-out moves apart: a move outside the noise is a change in behavior, a move inside the noise is chance, and a visible move beside a still held-out line is overfitting; its last sentence says whether the evidence supports keeping the fix. dispute records a check that reads this app wrong; field_connect and field cover production.",
11
+ export const instructions = (spec = "cortad") => [
12
+ "Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. Cortad sends each AI endpoint a test request itself. status shows the app's port, what the reviews of the prompts and of the code around the model found, which endpoints answered and which failed at what file and line, and what a run would play and cost. The first run starts by itself; later runs when the person presses Run or asks.",
13
+ `Tell the person what status found in a few plain lines. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run ends, with what it found. After a run, call findings and fix the worst problem, then verify it; the person asked for these fixes when they connected Cortad. findings has each failure whole and findings numbers every rate. verify replays the failing conversations and says whether the failure is gone. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
14
14
  ].join("\n\n");
15
+ export const INSTRUCTIONS = instructions();
15
16
 
16
17
  const str = (description, extra = {}) => ({ type: "string", description, ...extra });
17
18
  const none = { type: "object", properties: {}, additionalProperties: false };
18
19
  const readOnly = { readOnlyHint: true };
20
+ // What a note to the Cortad team may be about: a tool, the connect command, or the skill text.
21
+ const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "connect", "skill"];
19
22
 
20
23
  export const TOOLS = [
21
24
  { name: "status", title: "Cortad status", annotations: readOnly,
22
- description: "What Cortad read in this repository, the plan with runs left, whether the app is up, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval. With show, one section of the read in full, in pages.",
23
- inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section of the read to list in full: rules, standards, journeys, endpoints or trials." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
25
+ description: "What Cortad found in this repository: whether the app is up and on which port, the reviews of its prompts and of the code around its model, each endpoint's answer to Cortad's test request or the file and line it failed at, the plan with runs left, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval. With show, one section in full, in pages.",
26
+ inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section to list in full: rules, standards, journeys, endpoints, trials, records or reviews; or machine, what Cortad does on this machine." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
27
+ { name: "reach", title: "Send Cortad's test requests again",
28
+ description: "Sends Cortad's test request again to the endpoints status lists as failed or not yet answered, or to the paths named, from this machine to the app on its port. Each endpoint comes back with its HTTP status, its time and, when it is not 2xx, the start of what the app answered.",
29
+ inputSchema: { type: "object", properties: {
30
+ paths: { type: "array", items: { type: "string" }, description: "Only these endpoints, by path. Default: every endpoint status lists as failed or not yet answered." },
31
+ }, additionalProperties: false } },
24
32
  { name: "run", title: "Start a run", inputSchema: none,
25
- description: "Starts a run of the simulated conversations against the app on this machine and answers within a second with the run id. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
33
+ description: "Starts a run of conversations against the app on this machine and answers within a second with the run id; after the first run it plays what the files saved since the last full run reach, plus new conversations written for the change, and its result leads with what moved. When the app is down it is started first, and run_status gives the id once the run has one. Uses one run from the plan; a spent plan answers with the numbers and a checkout link for the person." },
26
34
  { name: "run_status", title: "Run progress", annotations: readOnly,
27
- description: "Where a run or verify stands: trials played of the total, the score with its interval, the findings, and for a verify the visible and held-out moves. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
35
+ description: "Where a run or verify stands: conversations played of the total, the findings, what the app's rules and users measured, and for a verify whether the failure is gone, shows less often, stayed or cannot be told yet. The call holds up to 45 seconds until the count moves, and ends with the next call. With no jobId it follows the run this machine started last, or the latest run.",
28
36
  inputSchema: { type: "object", properties: { jobId: str("A run or verify id, or the word pending. Omit for the run this machine started last.") }, additionalProperties: false } },
29
37
  { name: "findings", title: "Findings", annotations: readOnly,
30
- description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, 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
- 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 } },
38
+ description: "The failures of a run, worst first and grouped by file and line: the question, the conversations it failed in with the range, the exchange, what a verify replays and how many clean replays would show it gone, then where the conversations ended and how their simulated users fared. With show numbers, every number the run measured: each question and measurement by journey, simulated user and reply, with ranges and the exchange behind the worst group. Once a run has finished, a long list comes in pages.",
39
+ inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), show: { type: "string", enum: ["numbers"], description: "numbers: every number the run measured, in pages." }, page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
32
40
  { 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, 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.",
41
+ description: "Replays the conversations a finding failed on, word for word, after a change, and again by itself until an exact test says the failure is gone, shows less often, stayed, or cannot tell yet with how many clean replays would decide. Replies refused or failed on the replay are said first; conversations of the same question saved to test the fix are replayed once, and where they disagree with the replays beyond chance the first sentence says so; a crash:N finding replays the requests out when the app stopped. Answers within a second, run_status follows every round, and it uses verify conversations from the plan, not a run.",
34
42
  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
43
  { name: "dispute", title: "Dispute a check",
36
44
  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.",
37
45
  inputSchema: { type: "object", properties: { findingId: str("The finding id."), why: str("One or two sentences: what the check gets wrong about this app.", { maxLength: 500 }), question: str("The wording that fits this app.", { maxLength: 300 }) }, required: ["findingId", "why"], additionalProperties: false } },
46
+ { name: "feedback", title: "A note to the Cortad team",
47
+ description: "Sends the Cortad team a note about Cortad itself: a result that read wrong, something you needed that no tool gave, a question a result left open, or what worked. Filing one is part of using Cortad, and the team reads each note. Nothing about the run or its checks changes.",
48
+ inputSchema: { type: "object", properties: {
49
+ about: { type: "string", enum: ABOUT, description: "The tool the note is about, connect for the connect command, or skill for the instructions you were handed." },
50
+ kind: { type: "string", enum: ["problem", "idea", "question", "praise"], description: "problem: wrong or failed; idea: something you needed; question: something a result left open; praise: what worked." },
51
+ needed: str("What you were trying to do or find out.", { maxLength: 500 }),
52
+ got: str("What came back instead, in your own words; leave out your users' text and any key.", { maxLength: 1000 }),
53
+ tried: str("What you did about it: another call, a guess, or asked the person.", { maxLength: 500 }),
54
+ }, required: ["about", "kind", "needed"], additionalProperties: false } },
38
55
  { name: "field_connect", title: "Connect production", inputSchema: none,
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." },
56
+ description: "The steps to connect production, to see what real users are doing as it happens and where the AI lets them down. The owner creates the key in the browser." },
40
57
  { name: "field", title: "Production numbers", annotations: readOnly,
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.",
58
+ 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.",
42
59
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
43
60
  ];
44
61
  const NAMES = new Set(TOOLS.map((t) => t.name));
@@ -47,8 +64,17 @@ const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, messa
47
64
  const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
48
65
 
49
66
  // Serves until stdin closes. `verbs` is lib/verbs.mjs's object; `version` is the package's.
50
- export function serveMcp({ verbs, version, input = process.stdin, output = process.stdout, log = () => {} }) {
67
+ // `report` is lib/calls.mjs: handed each tool call once it has answered, and awaited before the exit.
68
+ export function serveMcp({ verbs, version, spec, input = process.stdin, output = process.stdout, log = () => {}, report = async () => {} }) {
51
69
  return new Promise((resolve) => {
70
+ let client = {};
71
+ const sending = new Set();
72
+ const count = (tool, began, text, failure) => {
73
+ const sent = Promise.resolve(report({ tool, durationMs: Date.now() - began, outputChars: text.length, ...failure, ...client })).catch(() => {}).finally(() => sending.delete(sent));
74
+ sending.add(sent);
75
+ };
76
+ let closed = false;
77
+ const close = () => { if (!closed) { closed = true; void Promise.all(sending).then(() => resolve(0)); } };
52
78
  const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
53
79
  let buffer = "";
54
80
  input.setEncoding("utf8");
@@ -61,8 +87,8 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
61
87
  if (line) void handle(line);
62
88
  }
63
89
  });
64
- input.on("end", () => resolve(0));
65
- input.on("close", () => resolve(0));
90
+ input.on("end", close);
91
+ input.on("close", close);
66
92
 
67
93
  async function handle(line) {
68
94
  let msg;
@@ -70,10 +96,12 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
70
96
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) { send(error(null, -32600, "invalid request")); return; }
71
97
  const { id, method, params } = msg;
72
98
  const notification = id === undefined || id === null;
99
+ const began = Date.now();
73
100
  try {
74
101
  switch (method) {
75
102
  case "initialize":
76
- return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: INSTRUCTIONS }));
103
+ client = { clientName: params?.clientInfo?.name, clientVersion: params?.clientInfo?.version };
104
+ return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: instructions(spec) }));
77
105
  case "notifications/initialized":
78
106
  case "notifications/cancelled":
79
107
  return;
@@ -84,15 +112,21 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
84
112
  case "tools/call": {
85
113
  const name = params?.name;
86
114
  const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
87
- if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
115
+ if (!verb) {
116
+ count("unknown", began, "", { errorCode: "unknown_tool" });
117
+ return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
118
+ }
88
119
  const out = await verb(params?.arguments ?? {});
89
- return send(result(id, { content: [{ type: "text", text: out.text }], ...(out.isError ? { isError: true } : {}) }));
120
+ count(name, began, out.text, out.isError ? { errorCode: out.code ?? "refused", error: out.why } : {});
121
+ const structured = out.structured && typeof out.structured === "object" && !Array.isArray(out.structured) ? { structuredContent: out.structured } : {};
122
+ return send(result(id, { content: [{ type: "text", text: out.text }], ...structured, ...(out.isError ? { isError: true } : {}) }));
90
123
  }
91
124
  default:
92
125
  if (!notification) send(error(id, -32601, `method not found: ${String(method)}`));
93
126
  }
94
127
  } catch (err) {
95
128
  log(`mcp ${method}: ${err?.stack ?? err}`);
129
+ if (method === "tools/call") count(NAMES.has(params?.name) ? params.name : "unknown", began, "", { errorCode: "internal" });
96
130
  if (!notification) send(result(id, { content: [{ type: "text", text: `Cortad could not answer: ${String(err?.message ?? err)}` }], isError: true }));
97
131
  }
98
132
  }