cortad 0.3.1-rc.8 → 0.3.1

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 (56) 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/cli.mjs +113 -30
  6. package/lib/contact-text.mjs +167 -0
  7. package/lib/data.mjs +16 -22
  8. package/lib/dissection-text.mjs +232 -0
  9. package/lib/document.mjs +75 -0
  10. package/lib/env-secrets.mjs +48 -0
  11. package/lib/failed-text.mjs +36 -0
  12. package/lib/fresh.mjs +33 -12
  13. package/lib/health.mjs +61 -0
  14. package/lib/held.mjs +67 -0
  15. package/lib/home.mjs +55 -17
  16. package/lib/hosted.mjs +201 -0
  17. package/lib/identities.mjs +1 -1
  18. package/lib/keeping.mjs +162 -0
  19. package/lib/ledger.mjs +20 -0
  20. package/lib/lock.mjs +6 -1
  21. package/lib/mcp.mjs +33 -15
  22. package/lib/mint.mjs +179 -38
  23. package/lib/mongo.mjs +74 -0
  24. package/lib/numbers-text.mjs +68 -0
  25. package/lib/pg.mjs +118 -0
  26. package/lib/proc.mjs +100 -0
  27. package/lib/proof.mjs +1014 -0
  28. package/lib/proxy.mjs +184 -0
  29. package/lib/pyhook/cortad_sites.py +218 -0
  30. package/lib/pyhook/cortad_tied.py +291 -0
  31. package/lib/pyhook/sitecustomize.py +1267 -153
  32. package/lib/qdrant.mjs +47 -0
  33. package/lib/reach.mjs +223 -0
  34. package/lib/read-text.mjs +78 -24
  35. package/lib/redis.mjs +130 -0
  36. package/lib/register.mjs +36 -27
  37. package/lib/replay.mjs +467 -34
  38. package/lib/runner.mjs +85 -0
  39. package/lib/service.mjs +25 -0
  40. package/lib/spec.mjs +7 -4
  41. package/lib/start.mjs +33 -2
  42. package/lib/stick.mjs +65 -25
  43. package/lib/stores.mjs +347 -0
  44. package/lib/switches.mjs +36 -0
  45. package/lib/tether.mjs +9 -0
  46. package/lib/text.mjs +321 -177
  47. package/lib/tied.cjs +217 -0
  48. package/lib/trace.cjs +399 -480
  49. package/lib/verbs.mjs +142 -31
  50. package/lib/wire.cjs +882 -0
  51. package/lib/words.mjs +2 -0
  52. package/lib/writes.mjs +2 -2
  53. package/local.mjs +661 -297
  54. package/package.json +30 -1
  55. package/skill/SKILL.md +49 -42
  56. 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,7 +64,7 @@ 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
+ export function serveMcp({ verbs, version, spec, input = process.stdin, output = process.stdout, log = () => {} }) {
51
68
  return new Promise((resolve) => {
52
69
  const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
53
70
  let buffer = "";
@@ -73,7 +90,7 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
73
90
  try {
74
91
  switch (method) {
75
92
  case "initialize":
76
- return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: INSTRUCTIONS }));
93
+ return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: instructions(spec) }));
77
94
  case "notifications/initialized":
78
95
  case "notifications/cancelled":
79
96
  return;
@@ -86,7 +103,8 @@ export function serveMcp({ verbs, version, input = process.stdin, output = proce
86
103
  const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
87
104
  if (!verb) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
88
105
  const out = await verb(params?.arguments ?? {});
89
- return send(result(id, { content: [{ type: "text", text: out.text }], ...(out.isError ? { isError: true } : {}) }));
106
+ const structured = out.structured && typeof out.structured === "object" && !Array.isArray(out.structured) ? { structuredContent: out.structured } : {};
107
+ return send(result(id, { content: [{ type: "text", text: out.text }], ...structured, ...(out.isError ? { isError: true } : {}) }));
90
108
  }
91
109
  default:
92
110
  if (!notification) send(error(id, -32601, `method not found: ${String(method)}`));
package/lib/mint.mjs CHANGED
@@ -100,57 +100,169 @@ async function firebaseIdToken(account, apiKey) {
100
100
  return res.ok && out?.idToken ? { idToken: out.idToken } : { error: String(out?.error?.message ?? `status ${res.status}`).slice(0, 120) };
101
101
  }
102
102
 
103
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
104
+ // A server key goes only over TLS, or to a service on this machine (a local Supabase).
105
+ function serviceOrigin(value) {
106
+ try { const u = new URL(value); return u.protocol === "https:" || (u.protocol === "http:" && LOOPBACK.has(u.hostname)) ? u.origin : null; } catch { return null; }
107
+ }
108
+ async function send(url, { method = "POST", headers = {}, body } = {}) {
109
+ const res = await fetch(url, { method, headers: { "content-type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(20_000) });
110
+ return { ok: res.ok, status: res.status, json: await res.json().catch(() => null) };
111
+ }
112
+ const saidBy = (r) => String(r.json?.errors?.[0]?.long_message ?? r.json?.msg ?? r.json?.error_description ?? r.json?.message ?? `status ${r.status}`).slice(0, 120);
113
+ const refusedBy = (who, r) => ({ status: "refused", note: `${who} refused the test account: ${saidBy(r)}` });
114
+ // An account the service already held may have been given a role since this machine made it.
115
+ const ELEVATED_ACCOUNT = { status: "refused", note: "the test account carries an admin role; only an ordinary account is signed in" };
116
+ const elevated = (...claims) => claims.some((c) => c && ELEVATED.test(JSON.stringify(c)));
117
+
118
+ // Clerk's Backend API, in their development instance only: the account, a session for it, and the
119
+ // session's token, which lives a minute and is minted again from the same session before it lapses.
120
+ export async function clerkToken(env, account, session) {
121
+ const key = env.CLERK_SECRET_KEY;
122
+ if (!key) return { status: "absent", note: "no CLERK_SECRET_KEY in your environment" };
123
+ if (!key.startsWith("sk_test_")) return { status: "refused", note: "your CLERK_SECRET_KEY is a production key; test accounts are made only in a development instance" };
124
+ const origin = serviceOrigin(env.CLERK_API_URL || "https://api.clerk.com");
125
+ if (!origin) return { status: "refused", note: "CLERK_API_URL is not an https address" };
126
+ const api = `${origin}/v1`;
127
+ const auth = { authorization: `Bearer ${key}` };
128
+ const tokenOf = async (id) => (await send(`${api}/sessions/${encodeURIComponent(id)}/tokens`, { headers: auth })).json?.jwt;
129
+ const again = session && await tokenOf(session);
130
+ if (again) return { token: again, session };
131
+ // Clerk sends nothing to a +clerk_test address; it exists for exactly this.
132
+ const email = `${account.username}+clerk_test@example.com`;
133
+ const found = await send(`${api}/users?email_address=${encodeURIComponent(email)}`, { method: "GET", headers: auth });
134
+ let user = Array.isArray(found.json) ? found.json[0] : null;
135
+ if (!user) {
136
+ // No password: a session is made through this API, and an instance that signs in only by email
137
+ // code or OAuth refuses a user created with one.
138
+ const made = await send(`${api}/users`, { headers: auth, body: { email_address: [email], skip_password_requirement: true } });
139
+ if (!made.ok || !made.json?.id) return refusedBy("your Clerk instance", made);
140
+ user = made.json;
141
+ }
142
+ if (elevated(user.public_metadata, user.private_metadata)) return ELEVATED_ACCOUNT;
143
+ const opened = await send(`${api}/sessions`, { headers: auth, body: { user_id: user.id } });
144
+ if (!opened.ok || !opened.json?.id) return refusedBy("your Clerk instance", opened);
145
+ const token = await tokenOf(opened.json.id);
146
+ return token ? { token, session: opened.json.id } : { status: "refused", note: "your Clerk instance issued no session token" };
147
+ }
148
+
149
+ const SUPABASE_URL = /^(?:NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|PUBLIC_)?SUPABASE_URL$/;
150
+ const SUPABASE_SERVICE = /^SUPABASE_(?:SERVICE_ROLE|SERVICE|SECRET)_KEY$/;
151
+ const SUPABASE_PUBLIC = /^(?:NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|PUBLIC_)?SUPABASE_(?:ANON|PUBLISHABLE)(?:_DEFAULT)?_KEY$/;
152
+ const named = (env, pattern) => Object.entries(env).find(([name, value]) => pattern.test(name) && value)?.[1] ?? null;
153
+
154
+ // Supabase Auth's admin API makes the account already confirmed, with the service key their server
155
+ // holds; a password sign-in then issues the session their own client would hold.
156
+ export async function supabaseSession(env, account) {
157
+ const url = named(env, SUPABASE_URL);
158
+ const service = named(env, SUPABASE_SERVICE);
159
+ if (!url || !service) return { status: "absent", note: `no ${url ? "SUPABASE_SERVICE_ROLE_KEY" : "SUPABASE_URL"} in your environment` };
160
+ const origin = serviceOrigin(url);
161
+ if (!origin) return { status: "refused", note: "SUPABASE_URL is not an https address" };
162
+ const made = await send(`${origin}/auth/v1/admin/users`, { headers: { apikey: service, authorization: `Bearer ${service}` }, body: { email: account.email, password: account.password, email_confirm: true } });
163
+ if (!made.ok && !/already|exists|registered/i.test(saidBy(made))) return refusedBy("your Supabase project", made);
164
+ const got = await send(`${origin}/auth/v1/token?grant_type=password`, { headers: { apikey: named(env, SUPABASE_PUBLIC) ?? service }, body: { email: account.email, password: account.password } });
165
+ if (!got.ok || typeof got.json?.access_token !== "string") return refusedBy("your Supabase project", got);
166
+ if (elevated(got.json.user?.app_metadata, got.json.user?.role)) return ELEVATED_ACCOUNT;
167
+ return { session: got.json, origin };
168
+ }
169
+
170
+ // @supabase/ssr's own cookie: the session as base64url JSON under sb-<ref>-auth-token, cut into
171
+ // numbered chunks past 3180 characters the way the library cuts it.
172
+ export function supabaseCookie(origin, session) {
173
+ const name = `sb-${new URL(origin).hostname.split(".")[0]}-auth-token`;
174
+ const value = `base64-${Buffer.from(JSON.stringify(session)).toString("base64url")}`;
175
+ if (value.length <= 3180) return `${name}=${value}`;
176
+ return value.match(/.{1,3180}/g).map((part, i) => `${name}.${i}=${part}`).join("; ");
177
+ }
178
+
179
+ // Each hosted service's sign-in, as the value the role's header carries and how long it holds.
180
+ const SIGN_IN = new Map([
181
+ ["firebase", async ({ env, all }) => {
182
+ const account = serviceAccountOf(env);
183
+ const apiKey = webApiKeyOf(all);
184
+ if (!account || !apiKey) return { status: "absent", note: !account ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
185
+ const got = await firebaseIdToken(account, apiKey);
186
+ if (!got.idToken) return { status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
187
+ return { value: `Bearer ${got.idToken}`, ttl: 3600, door: "your Firebase project", said: `signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)` };
188
+ }],
189
+ ["clerk", async ({ env, account, session }) => {
190
+ const got = await clerkToken(env, account, session);
191
+ return got.token ? { value: `Bearer ${got.token}`, ttl: 60, session: got.session, door: "your Clerk development instance", said: "made a test account in your Clerk development instance for the run to sign in with" } : got;
192
+ }],
193
+ ["supabase-admin", async ({ env, account, header }) => {
194
+ const got = await supabaseSession(env, account);
195
+ if (!got.session) return got;
196
+ const s = got.session;
197
+ return {
198
+ value: header === "cookie" ? supabaseCookie(got.origin, s) : `Bearer ${s.access_token}`, secrets: [s.access_token, s.refresh_token],
199
+ ttl: Number(s.expires_in) || 3600, door: "your Supabase project", said: "made a test account in your Supabase project for the run to sign in with",
200
+ };
201
+ }],
202
+ ]);
203
+ // Renewed this long after a sign-in: Clerk's token lives 60 s, Supabase's and Firebase's an hour.
204
+ const RENEW_MS = { firebase: 50 * 60_000, clerk: 50_000, "supabase-admin": 55 * 60_000 };
205
+ const PER_PERSON = new Set(["clerk", "supabase-admin"]);
206
+ // A simulated person's own caller: the run sends `clerk:p1a2b3c4` for the person behind a trial.
207
+ const PERSONA = /^([a-z][a-z0-9_-]{0,30}):p[0-9a-f]{8}$/;
208
+
103
209
  export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], say, keepSecret, appDir = () => root }) {
104
210
  const dir = join(work, "identities");
105
211
  const headerFile = (role) => join(dir, `bl-identity-${slug(role)}.header`);
106
- const firebaseRoles = new Map();
212
+ const hosted = new Map();
213
+ // The roles signed in on this machine so far, in the order they were: the connect command's own
214
+ // test requests ask as the first when an endpoint refuses a visitor.
215
+ const minted = new Set();
107
216
  const envs = () => {
108
217
  // The app's own folder first: in a monorepo the root's env file is not the backend's.
109
218
  const homes = [appDir(), root];
110
219
  const own = homes.flatMap((h) => [envFiles.find((f) => dirname(f) === h && basename(f) === ".env"), envFiles.find((f) => dirname(f) === h)]).find(Boolean) ?? envFiles[0];
111
220
  return { path: own ?? join(root, ".env"), all: [process.env, ...envFiles.map(readEnv)] };
112
221
  };
113
- const keep = (role, header, value) => { writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 }); keepSecret(value.replace(/^Bearer\s+/i, "")); };
222
+ const merged = () => Object.assign({}, ...[...envs().all].reverse());
223
+ const keep = (role, header, value, secrets = []) => {
224
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
225
+ writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 });
226
+ for (const secret of [value.replace(/^Bearer\s+/i, ""), ...secrets]) keepSecret(secret);
227
+ };
114
228
 
115
- async function firebase(row) {
229
+ // A hosted service's sign-in, kept on this machine and renewed through headerFor. Only the
230
+ // bearer or the cookie is ever filled, whatever header a recipe names.
231
+ async function signIn(row) {
232
+ const header = row.header === "cookie" ? "cookie" : "authorization";
116
233
  const { all } = envs();
117
- const merged = Object.assign({}, ...[...all].reverse());
118
- if (merged.FIREBASE_AUTH_EMULATOR_HOST) return row;
119
- const account = serviceAccountOf(merged);
120
- const apiKey = webApiKeyOf(all);
121
- if (!account || !apiKey) return { ...row, status: "absent", note: !account ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
122
- const got = await firebaseIdToken(account, apiKey).catch((e) => ({ error: String(e?.message ?? e).slice(0, 120) }));
123
- if (!got.idToken) return { ...row, status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
124
- keep(row.role, row.header, `Bearer ${got.idToken}`);
125
- if (!firebaseRoles.has(row.role)) say(`signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)`);
126
- firebaseRoles.set(row.role, { header: row.header, at: Date.now() });
127
- return { ...row, status: "minted", door: "your Firebase project", expires: Math.floor(Date.now() / 1000) + 3600 };
234
+ const got = await SIGN_IN.get(row.kind)({ env: merged(), all, header, account: accountsFor(root, [row.role])[row.role], session: hosted.get(row.role)?.session })
235
+ .catch((e) => ({ status: "unreachable", note: String(e?.message ?? e).slice(0, 120) }));
236
+ if (!got.value) return { ...row, status: got.status, note: got.note };
237
+ keep(row.role, header, got.value, got.secrets);
238
+ if (!hosted.has(row.role) && !PERSONA.test(row.role)) say(got.said);
239
+ hosted.set(row.role, { row: { ...row, header }, at: Date.now(), session: got.session });
240
+ return { ...row, header, status: "minted", door: got.door, expires: Math.floor(Date.now() / 1000) + got.ttl };
128
241
  }
129
242
 
130
243
  async function mint(body, port) {
131
244
  let recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
132
245
  // On a refused first pass the raise asks again with this flag: sign in with the credential the
133
246
  // app's own environment names, filling its sign-in form or a Basic header from that pair.
134
- if (body?.envCreds) {
135
- const names = Object.keys(Object.assign({}, ...[...envs().all].reverse()));
136
- recipes = envCredentialRecipes(recipes, names, body.loginRoutes).slice(0, 12);
137
- }
247
+ if (body?.envCreds) recipes = envCredentialRecipes(recipes, Object.keys(merged()), body.loginRoutes).slice(0, 12);
138
248
  if (!recipes.length || !port) return { identities: [] };
139
249
  mkdirSync(dir, { recursive: true, mode: 0o700 });
140
250
  const out = join(dir, "bl-identities.json");
141
251
  rmSync(out, { force: true });
142
252
  // ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
143
253
  // nothing and says so; port the program to this file if that turns out to be common.
144
- // A Firebase caller with no emulator on this machine is signed in at their real project, below.
145
- // Handed to the program it waited its whole deadline on an emulator that was never there, and
146
- // one app's sign-in arrived after the world had given up waiting for it.
147
- const merged = Object.assign({}, ...[...envs().all].reverse());
148
- const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
254
+ // A caller a hosted service verifies is signed in at that service, below; a Firebase caller goes
255
+ // to the program only when an emulator stands in for their project. Handed to the program it
256
+ // waited its whole deadline on an emulator that was never there, and one app's sign-in arrived
257
+ // after the world had given up waiting for it.
258
+ const env = merged();
259
+ const emulated = Boolean(env.FIREBASE_AUTH_EMULATOR_HOST);
260
+ const signsHere = (r) => SIGN_IN.has(r?.kind) && !(r.kind === "firebase" && emulated);
149
261
  const refused = new Map();
150
262
  // A pair the environment names for the app's own account is the one credential the raise is
151
263
  // allowed to reuse: the elevation and existing-account guards do not apply to it.
152
- for (const r of recipes) { const why = r?.envCred ? null : refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
153
- const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r?.kind !== "firebase" || emulated));
264
+ for (const r of recipes) { const why = r?.envCred ? null : refusal(r, env, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
265
+ const forProgram = recipes.filter((r) => !refused.has(r?.role) && !signsHere(r));
154
266
  // The program signs in once per role value ("staff" as sales, support, marketing), each named
155
267
  // role:value; an account made for "staff" alone was never the one it asked for.
156
268
  const accounts = accountsFor(root, forProgram.flatMap((r) => (Array.isArray(r.roles) && r.roles.length ? r.roles.map((v) => `${r.role}:${v}`) : [r.role])));
@@ -166,22 +278,42 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
166
278
  if (typeof recipe?.role !== "string" || !ROLE.test(recipe.role)) continue;
167
279
  const blank = { role: recipe.role, kind: recipe.kind, header: recipe.header, opens: [], door: recipe.header, where: recipe.where };
168
280
  if (refused.has(recipe.role)) { rows.push({ ...blank, status: "refused", note: refused.get(recipe.role) }); continue; }
169
- const row = known.get(recipe.role) ?? (recipe.kind === "firebase" && !emulated ? { ...blank, status: "upstream" } : { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
170
- rows.push(recipe.kind === "firebase" && row.status !== "minted" ? await firebase(row) : row);
281
+ if (signsHere(recipe)) { rows.push(await signIn({ ...blank, status: "upstream" })); continue; }
282
+ rows.push(known.get(recipe.role) ?? { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
283
+ }
284
+ for (const row of rows.filter((r) => r.status === "minted")) {
285
+ minted.add(row.role);
286
+ if (existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
171
287
  }
172
- for (const row of rows) if (row.status === "minted" && existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
173
288
  return { identities: rows };
174
289
  }
175
290
 
176
- // The header a role speaks with, or nothing. A Firebase token lives an hour; it is renewed here
177
- // when a run outlasts it.
178
- async function headerFor(role) {
179
- if (!ROLE.test(role)) return null;
180
- const fb = firebaseRoles.get(role);
181
- if (fb && Date.now() - fb.at > 50 * 60_000) await firebase({ role, header: fb.header, kind: "firebase" }).catch(() => null);
182
- // A token the app's door issued, or a credential that is itself one of their secrets (an admin
183
- // key). In the sandbox the second never leaves the box; here it never leaves this machine, which
184
- // is the same promise, because this is where the request is sent from.
291
+ // A hosted token renewed before it lapses, and a simulated person's own account signed in the
292
+ // first time that person speaks. One sign-in per role at a time: lanes asking together share it.
293
+ const renewing = new Map();
294
+ const tried = new Set();
295
+ const shared = new Set();
296
+ async function due(role) {
297
+ const held = hosted.get(role);
298
+ if (held) { if (Date.now() - held.at > RENEW_MS[held.row.kind]) await signIn(held.row); return; }
299
+ const owner = hosted.get(PERSONA.exec(role)?.[1]);
300
+ if (!owner || !PER_PERSON.has(owner.row.kind) || tried.has(role)) return;
301
+ tried.add(role);
302
+ const got = await signIn({ ...owner.row, role });
303
+ if (got.status !== "minted" && !shared.has(owner.row.role)) {
304
+ shared.add(owner.row.role);
305
+ say(`a simulated person's own test account could not be made (${got.note ?? got.status}), so that person signs in as the shared test account`);
306
+ }
307
+ }
308
+ const renewed = (role) => {
309
+ if (!renewing.has(role)) renewing.set(role, due(role).catch(() => null).finally(() => renewing.delete(role)));
310
+ return renewing.get(role);
311
+ };
312
+
313
+ // A token the app's door issued, or a credential that is itself one of their secrets (an admin
314
+ // key). In the sandbox the second never leaves the box; here it never leaves this machine, which
315
+ // is the same promise, because this is where the request is sent from.
316
+ function held(role) {
185
317
  const file = [headerFile(role), headerFile(role).replace(/\.header$/, ".sealed")].find((f) => { try { return statSync(f).isFile(); } catch { return false; } });
186
318
  if (!file) return null;
187
319
  const line = readFileSync(file, "utf8").split("\n")[0] ?? "";
@@ -189,5 +321,14 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
189
321
  return at > 0 ? { name: line.slice(0, at).trim().toLowerCase(), value: line.slice(at + 2).trim() } : null;
190
322
  }
191
323
 
192
- return { mint, headerFor };
324
+ // The header a role speaks with, or nothing. A person whose own account could not be made speaks
325
+ // as the role that person belongs to, the one account every trial used before.
326
+ async function headerFor(role) {
327
+ if (!ROLE.test(role)) return null;
328
+ await renewed(role);
329
+ const base = PERSONA.exec(role)?.[1];
330
+ return held(role) ?? (base ? held(base) : null);
331
+ }
332
+
333
+ return { mint, headerFor, roles: () => [...minted] };
193
334
  }