@cortad/connector 0.6.1 → 0.6.3

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.
@@ -0,0 +1,239 @@
1
+ import { at, clip, num, plural, upper } from "./words.mjs";
2
+
3
+ // The behavior review as a coding agent reads it: who the users were, what they wanted, who achieved
4
+ // it, what the app did well and where it gave way, and only then where in the code each problem comes
5
+ // from. Every count has its denominator and every rate its range. Only a simulated conversation is
6
+ // ever quoted: a real user's words could instruct the agent reading this, so a production
7
+ // conversation is said by its id and the address where the owner reads it, whatever the server sends.
8
+
9
+ const PROBLEMS_SHOWN = 3;
10
+ // The largest personas in full, and every flagged one; the rest a line each.
11
+ const PERSONAS_SHOWN = 6;
12
+ const PLACES_SHOWN = 8;
13
+ const IDS_SHOWN = 5;
14
+ const QUOTE_CHARS = 240;
15
+
16
+ const short = (id) => String(id).slice(0, 8);
17
+ const pct = (s) => (s.of ? Math.round((100 * s.n) / s.of) : 0);
18
+ export const rateSaid = (s) => `${num(s.n)} of ${plural(s.of, "conversation")}, ${pct(s)}% (${s.low}% to ${s.high}%)`;
19
+ const day = (iso) => new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
20
+ const time = (iso) => new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" });
21
+ const ended = (t) => (/[.?!]$/.test(t) ? t : `${t}.`);
22
+ const quoted = (s) => `"${clip(String(s).replace(/"/g, "'"), QUOTE_CHARS)}"`;
23
+
24
+ export function pointSaid(p) {
25
+ if (p.kind === "production") return p.from === p.to ? `production on ${day(p.from)}` : `production from ${day(p.from)} to ${day(p.to)}`;
26
+ const commit = p.commit ? ` on commit ${p.commit.slice(0, 7)}${p.message ? ` (${quoted(p.message)})` : ""}` : "";
27
+ return `the simulation of ${day(p.at)} at ${time(p.at)} UTC${commit}`;
28
+ }
29
+
30
+ const who = (p) => (p.kind === "production" ? "real users" : "simulated users");
31
+
32
+ function behaves(m) {
33
+ if (!m) return null;
34
+ const parts = [`${plural(m.messages, "message")} a conversation`];
35
+ if (m.words) parts.push(`a first message of about ${plural(m.words, "word")}`);
36
+ const share = (v, what) => (v > 0 ? `${Math.round(v * 100)}% of them ${what}` : null);
37
+ const more = [share(m.pushesBack, "push back"), share(m.asksForPerson, "ask for a person"), share(m.leftUnanswered, "leave a question unanswered"), share(m.comesBack, "come back on another day")].filter(Boolean);
38
+ return `They send ${parts.join(", with ")}${more.length ? `; ${more.join(", ")}` : ""}.`;
39
+ }
40
+
41
+ // personas() writes each insight with its own counts and ranges; the conversations are added.
42
+ const insightLine = (i) => {
43
+ const ids = (i.evidence?.conversations ?? []).slice(0, IDS_SHOWN).map(short).join(", ");
44
+ return ` - ${i.text}${ids ? ` Shown in ${ids}.` : ""}`;
45
+ };
46
+ const wellLine = (w) => ` - ${w.text.replace(/\.$/, "")}: in ${rateSaid(w.share)}${w.apart && w.others ? `, against ${pct(w.others)}% (${w.others.low}% to ${w.others.high}%) for the other users` : ""}.`;
47
+ const insightsOf = (p, kind) => (p.insights ?? []).filter((i) => i.kind === kind);
48
+
49
+ function problemLines(p, real, quote) {
50
+ const lines = [` - ${p.said ?? `${p.text.replace(/\.$/, "")}: in ${rateSaid(p.share)}.`}${p.findingId ? ` (${p.findingId})` : ""}`];
51
+ const ids = p.conversations.slice(0, IDS_SHOWN).map(short);
52
+ if (ids.length) lines.push(` Shown in ${ids.join(", ")}${p.conversations.length > ids.length ? ` and ${num(p.conversations.length - ids.length)} more` : ""}.`);
53
+ if (quote && p.quote && !real) {
54
+ lines.push(` In ${short(p.quote.conversation)}:`);
55
+ if (p.quote.asked) lines.push(` The user: ${quoted(p.quote.asked)}`);
56
+ lines.push(` Reply ${p.quote.reply}: ${quoted(p.quote.quote || p.quote.said)}`);
57
+ }
58
+ return lines;
59
+ }
60
+
61
+ function personaLines(p, i, point, whole) {
62
+ const real = point.kind === "production";
63
+ const head = `${i + 1}. ${p.name}${p.flagged ? " [flagged: these users misuse the app]" : ""}: ${plural(p.users, real ? "user" : "simulated user")}, ${plural(p.conversations, "conversation")}.`;
64
+ const lines = [head];
65
+ if (p.flagged && p.why) lines.push(` Why flagged: ${p.why}`);
66
+ if (p.facts?.length) lines.push(` Who they are: ${p.facts.map((f) => `${f.key} ${f.values.map((v) => `${v.value} ${Math.round(v.share * 100)}%`).join(", ")}`).join("; ")}.`);
67
+ const b = behaves(p.measures);
68
+ if (b) lines.push(` ${b}`);
69
+ lines.push(p.servedSaid ? ` ${p.servedSaid}` : p.served ? ` They achieved their goal in ${rateSaid(p.served)}.` : " Whether they achieved their goal is not known for these conversations.");
70
+ lines.push(...insightsOf(p, "served").map(insightLine));
71
+ if (insightsOf(p, "trait").length) lines.push(" How they behave, read from their conversations:", ...insightsOf(p, "trait").map(insightLine));
72
+ const well = (p.well ?? []).map(wellLine);
73
+ if (well.length) lines.push(" What the app did well with them:", ...well);
74
+ const shown = whole ? p.problems : p.problems.slice(0, PROBLEMS_SHOWN);
75
+ const read = insightsOf(p, "problem");
76
+ if (shown.length || read.length) {
77
+ lines.push(" Where it gave way:", ...read.map(insightLine));
78
+ shown.forEach((x, k) => lines.push(...problemLines(x, real, whole || k === 0)));
79
+ } else lines.push(" No problem was seen in more than one of their conversations.");
80
+ const more = p.problems.length - shown.length;
81
+ if (more > 0) lines.push(` ${plural(more, "more problem")} in more than one conversation: behavior with persona: "${p.name}" lists them.`);
82
+ if (p.seenOnce) lines.push(` ${plural(p.seenOnce, "problem was", "problems were")} seen in one conversation alone, which is not yet a pattern.`);
83
+ if (whole && p.list?.length) {
84
+ lines.push(` Their conversations${p.list.length < p.conversations ? `, the first ${num(p.list.length)} of ${num(p.conversations)}` : ""}:`);
85
+ for (const c of p.list) {
86
+ const got = c.served === true ? "achieved their goal" : c.served === false ? "did not achieve their goal" : "not known whether they achieved it";
87
+ const what = real ? got : `${quoted(c.asked)}; ${got}`;
88
+ lines.push(` - ${short(c.id)}: ${what}${c.problems.length ? `; went wrong: ${c.problems.map((t) => t.replace(/\.$/, "")).join("; ")}` : ""}.${real && c.open ? ` Open: ${c.open}` : ""}`);
89
+ }
90
+ }
91
+ return lines;
92
+ }
93
+
94
+ function codeLines(code) {
95
+ if (!code.length) return [];
96
+ const lines = ["Where each problem comes from in the code:"];
97
+ for (const place of code.slice(0, PLACES_SHOWN)) {
98
+ lines.push(`- ${at(place.path, place.line)}`);
99
+ for (const p of place.problems) lines.push(` ${p.text.replace(/\.$/, "")}: ${plural(p.conversations, "conversation")} of ${p.personas.length === 1 ? `"${p.personas[0]}"` : plural(p.personas.length, "persona")}.${p.findingId ? ` Verify with findingId "${p.findingId}" replays them after a fix.` : ""}`);
100
+ }
101
+ if (code.length > PLACES_SHOWN) lines.push(`${plural(code.length - PLACES_SHOWN, "more place")} in the code, each behind fewer conversations.`);
102
+ return lines;
103
+ }
104
+
105
+ export function behaviorText({ review: r }) {
106
+ const real = r.point.kind === "production";
107
+ const one = r.personas.length === 1 && r.personas[0].list;
108
+ const lines = [`How your app behaved with its users: ${pointSaid(r.point)}.`];
109
+ const served = r.served ? ` Of the ${plural(r.served.of, "conversation")} where it could be told, ${num(r.served.n)} achieved their goal: ${pct(r.served)}% (${r.served.low}% to ${r.served.high}%).` : "";
110
+ lines.push(one
111
+ ? `One persona in full, of ${plural(r.users, real ? "real user" : "simulated user")} and ${plural(r.conversations, "conversation")} in all.`
112
+ : `${usersSaid(r.users, r.personas.length, real)}, in ${plural(r.conversations, "conversation")}.${served}`);
113
+ if (real && r.point.open) lines.push(`Real users' words are not shown here. The owner reads any conversation below at ${r.point.open}<id>.`);
114
+ lines.push("");
115
+ const full = r.personas.filter((p, i) => i < PERSONAS_SHOWN || p.flagged);
116
+ r.personas.forEach((p, i) => { if (full.includes(p)) lines.push(...personaLines(p, i, r.point, Boolean(one)), ""); });
117
+ const rest = r.personas.filter((p) => !full.includes(p));
118
+ if (rest.length) {
119
+ lines.push(`${plural(rest.length, "more persona")}, smaller; behavior with persona opens any of them:`);
120
+ for (const p of rest) lines.push(`- ${p.name}: ${plural(p.users, real ? "user" : "simulated user")}, ${plural(p.conversations, "conversation")}${p.served ? `, achieved their goal in ${rateSaid(p.served)}` : ""}${p.problems[0] ? `; most often: ${p.problems[0].text.replace(/\.$/, "")}, in ${num(p.problems[0].share.n)} of ${num(p.problems[0].share.of)}` : ""}.`);
121
+ lines.push("");
122
+ }
123
+ if (r.habits && !one) {
124
+ lines.push(`The app's habits across personas, read from ${plural(r.habits.conversations, "conversation")}:`);
125
+ if (r.habits.doesWell) lines.push(`- Does well: ${r.habits.doesWell}`);
126
+ if (r.habits.givesWay) lines.push(`- Gives way: ${r.habits.givesWay}`);
127
+ if (r.habits.pattern) lines.push(`- The habit behind it: ${r.habits.pattern}`);
128
+ lines.push("");
129
+ }
130
+ lines.push(...codeLines(r.code));
131
+ const top = r.personas[0]?.name;
132
+ lines.push(one
133
+ ? `next: tell the person how their AI behaved with "${top}", in plain words, then what you would change and why it follows${real ? "" : `; behavior with against: "production" says how these simulated users compare with real ones`}`
134
+ : `next: tell the person how their AI behaved with their users, persona by persona, then what you would change and why it follows${top ? `; behavior with persona: "${top}" opens the largest in full` : ""}`);
135
+ return lines.join("\n");
136
+ }
137
+
138
+ // Users of personas; where each persona holds one user, the grouping says nothing and is not claimed.
139
+ const usersSaid = (users, personas, real) => {
140
+ const who = plural(users, real ? "real user" : "simulated user");
141
+ return personas >= users ? `${upper(who)}, each with a goal of their own` : `${upper(who)} of ${plural(personas, "persona")}`;
142
+ };
143
+
144
+ // A simulation against production compares two kinds of users, not a before and an after: the words
145
+ // are about how hard the simulation is on the app next to its real users.
146
+ function simulatedAgainstReal(served) {
147
+ const [sim, real] = [served.now, served.before];
148
+ const said = `Simulated users achieved their goal in ${pct(sim)}% of conversations (${sim.low}% to ${sim.high}%), real users in ${pct(real)}% (${real.low}% to ${real.high}%)`;
149
+ return served.move.moved === "up" ? `${said}: the simulation is easier on your app than your real users are.`
150
+ : served.move.moved === "down" ? `${said}: the simulation is harder on your app than your real users are.`
151
+ : `${said}: no clear difference.`;
152
+ }
153
+
154
+ const changeWord = { better: "Better", worse: "Worse", new: "New", gone: "Gone", same: "Same" };
155
+ // Against production the two sides are two kinds of users at once, not a before and an after.
156
+ const unlikeWord = { better: "Less often with simulated users", worse: "More often with simulated users", new: "Only simulated users met this", gone: "Only real users met this", same: "Same" };
157
+ const moveSaid = (m) => `${m.points > 0 ? "up" : m.points < 0 ? "down" : "no change,"} ${Math.abs(m.points)} points (${m.low} to ${m.high})`;
158
+
159
+ export function againstText({ against: d }) {
160
+ const vsReal = d.before.kind !== d.now.kind;
161
+ const lines = ["What changed for each persona.", `Now: ${pointSaid(d.now)}${vsReal ? `, its ${who(d.now)}` : ""}.`, `Before: ${pointSaid(d.before)}${vsReal ? `, its ${who(d.before)}` : ""}.`];
162
+ if (vsReal) lines.push("Where the two behave differently, the simulation is not playing your users as they are.");
163
+ lines.push("A rate counts as moved only where its range on the difference leaves out zero.");
164
+ lines.push("");
165
+ const words = vsReal ? unlikeWord : changeWord;
166
+ for (const p of d.personas.filter((x) => !x.only)) {
167
+ lines.push(`- ${p.name}: ${plural(p.users.now, "user")} now, ${num(p.users.before)} before.`);
168
+ if (vsReal && p.measures.now && p.measures.before) {
169
+ const m = (x) => `${plural(x.messages, "message")} a conversation${x.words ? `, first message about ${plural(x.words, "word")}` : ""}`;
170
+ lines.push(` ${upper(who(d.now))}: ${m(p.measures.now)}. ${upper(who(d.before))}: ${m(p.measures.before)}.`);
171
+ }
172
+ if (vsReal && p.served.now && p.served.before) lines.push(` ${simulatedAgainstReal(p.served)}`);
173
+ else if (p.served.now && p.served.before) lines.push(` Achieved their goal: ${pct(p.served.now)}% now (${p.served.now.low}% to ${p.served.now.high}%), ${pct(p.served.before)}% before (${p.served.before.low}% to ${p.served.before.high}%): ${p.served.change === "same" ? "no clear change" : p.served.change}, ${moveSaid(p.served.move)}.`);
174
+ for (const x of p.problems) lines.push(` ${words[x.change]}: ${ended(x.text)} ${num(x.now.n)} of ${num(x.now.of)} now, ${num(x.before.n)} of ${num(x.before.of)} before: ${moveSaid(x.move)}.${x.findingId ? ` (${x.findingId})` : ""}`);
175
+ if (p.unchanged) lines.push(` ${plural(p.unchanged, "other problem")} did not clearly move.`);
176
+ if (p.oneSided) lines.push(` ${plural(p.oneSided, "problem was", "problems were")} checked on one side only, so not compared.`);
177
+ }
178
+ for (const side of ["now", "before"]) {
179
+ const only = d.personas.filter((x) => x.only === side);
180
+ const label = vsReal ? (side === "now" ? "Only in the simulation" : "Only in production, never played by this simulation") : `Only ${side}`;
181
+ if (only.length) lines.push(`- ${label}: ${only.map((x) => `${x.name} (${plural(side === "now" ? x.users.now : x.users.before, "user")})`).join("; ")}.`);
182
+ }
183
+ lines.push(vsReal
184
+ ? `next: tell the person where the simulation plays their users unlike real ones, in plain words; behavior with persona: "<name>" opens one`
185
+ : `next: tell the person which personas got better or worse and why, in plain words; behavior with persona: "<name>" opens one`);
186
+ return lines.join("\n");
187
+ }
188
+
189
+ function entryLine(e) {
190
+ const served = e.served ? `, achieved their goal in ${rateSaid(e.served)}` : "";
191
+ const head = `- ${upper(pointSaid(e.point))}${e.point.kind === "simulation" ? ` (id ${e.point.id})` : ""}: ${usersSaid(e.users, e.personas, e.point.kind === "production")}${served}.`;
192
+ if (!e.change) return head;
193
+ const c = e.change;
194
+ const moved = [c.worse && `${num(c.worse)} worse`, c.new && `${num(c.new)} new`, c.better && `${num(c.better)} better`, c.gone && `${num(c.gone)} gone`].filter(Boolean);
195
+ const servedMove = c.served && c.served.moved !== "same" ? `achieving their goal went ${moveSaid(c.served)}` : "achieving their goal did not clearly move";
196
+ return `${head}\n Since the one below: ${servedMove}; problems: ${moved.length ? moved.join(", ") : "none clearly moved"}.`;
197
+ }
198
+
199
+ export function historyText({ history: h }) {
200
+ const lines = ["The history of how your app behaved, newest first.", "", "Simulations:"];
201
+ lines.push(...(h.simulations.length ? h.simulations.map(entryLine) : ["- None has finished yet."]));
202
+ lines.push("", "Production, by day:");
203
+ lines.push(...(h.days.length ? h.days.map(entryLine) : ["- No production conversations in the last 30 days."]));
204
+ lines.push(`next: behavior with at: "<id>" reads one of these; with against: "previous" it says what changed persona by persona`);
205
+ return lines.join("\n");
206
+ }
207
+
208
+ // How much of the scope a search could reach, said every time: a search that finds nothing among
209
+ // conversations not indexed yet has not looked at them.
210
+ const reachSaid = (d) => (d.unindexed > 0
211
+ ? `${plural(d.unindexed, "conversation")} in scope ${d.unindexed === 1 ? "is" : "are"} not searchable yet and ${d.unindexed === 1 ? "is" : "are"} being indexed now.`
212
+ : "Every conversation in scope is searchable.");
213
+
214
+ export function searchText(d, query) {
215
+ const hits = d.hits ?? [];
216
+ if (!hits.length) return `No conversation matches ${quoted(query)}. ${reachSaid(d)}\nnext: search with other words, or behavior for the whole review`;
217
+ const worded = hits.every((h) => h.match === "words");
218
+ const lines = [worded
219
+ ? `No conversation is about ${quoted(query)} as asked; ${plural(hits.length, "conversation")} ${hits.length === 1 ? "holds" : "hold"} its words, in the order closest to it.`
220
+ : `${plural(hits.length, "conversation")} ${hits.length === 1 ? "matches" : "match"} ${quoted(query)}, closest first.`];
221
+ // The route's share is counted in conversations, its range too.
222
+ if (d.share && !worded) lines.push(`About ${num(d.share.n)} of ${plural(d.share.of, "conversation")} searched are about this, ${pct(d.share)}%; between ${num(d.share.low)} and ${num(d.share.high)}.`);
223
+ lines.push(reachSaid(d));
224
+ if (hits.some((h) => h.source === "production")) lines.push("Real users' words are not shown here; each one's address opens it for the owner.");
225
+ hits.forEach((h, i) => {
226
+ if (h.source === "production") {
227
+ lines.push(`${i + 1}. ${short(h.conversationId)}, a real user${h.persona ? ` of "${h.persona}"` : ""}.`);
228
+ if (h.outcome) lines.push(` How it ended: ${h.outcome}`);
229
+ if (h.open) lines.push(` Open: ${h.open}`);
230
+ return;
231
+ }
232
+ lines.push(`${i + 1}. ${short(h.conversationId)}, a simulated user${h.persona ? ` of "${h.persona}"` : ""}: asked ${quoted(h.asked)}`);
233
+ if (h.outcome) lines.push(` How it ended: ${h.outcome}`);
234
+ if (h.quote) lines.push(` The app said: ${quoted(h.quote)}`);
235
+ });
236
+ const personas = [...new Set(hits.map((h) => h.persona).filter(Boolean))];
237
+ lines.push(personas.length ? `next: behavior with persona: "${personas[0]}" shows how that persona fared as a whole` : "next: behavior for the whole review");
238
+ return lines.join("\n");
239
+ }
package/lib/cli.mjs CHANGED
@@ -27,7 +27,7 @@ const WAITER = "run-when-up";
27
27
  // How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
28
28
  // about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
29
29
  const RESTART_MS = { mcp: 45_000, shell: 150_000 };
30
- export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field", "world", "world_status", "test"];
30
+ export const VERBS = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback", "field_connect", "field", "world", "world_status", "test", "behavior", "search"];
31
31
  export const COMMANDS = new Set(["mcp", ...VERBS, "wait", "news", "run-status", "field-connect", "stick", "unstick", "intelligence", WAITER]);
32
32
  // A lowercase word this version has no command for, so it is never read as a connect code.
33
33
  export const unknownVerb = (word) => /^[a-z][a-z_]*$/.test(word ?? "") && !COMMANDS.has(word);
@@ -66,6 +66,7 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
66
66
  seen: (runId) => dropNews(project, runId),
67
67
  wasRead: (runId) => wasHeard(project, runId),
68
68
  spec: npxName(cliSpec({ env })),
69
+ origin: origin.origin === "https://cortad.com" ? null : origin.origin,
69
70
  startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
70
71
  code: makeCode({ project, root, waitMs: mcp ? RESTART_MS.mcp : RESTART_MS.shell }),
71
72
  });
@@ -130,10 +131,12 @@ const olderThan = (version, floor) => {
130
131
  for (let i = 0; i < 4; i += 1) if (a[i] !== b[i]) return a[i] < b[i];
131
132
  return false;
132
133
  };
134
+ // Reads that start nothing on this machine, and that a connect command older than this one does not have.
135
+ const HERE = new Set(["behavior", "search"]);
133
136
  export function asRunner(verbs, { runnerVersion, runAt: other, own = VERSION }) {
134
137
  return Object.fromEntries(VERB_NAMES.map((name) => [name, async (args) => {
135
138
  const version = runnerVersion();
136
- if (!version || version === own || !RELEASE.test(version)) return verbs[name](args);
139
+ if (!version || version === own || !RELEASE.test(version) || HERE.has(name)) return verbs[name](args);
137
140
  const floor = CAP_FROM[name];
138
141
  const cap = floor ? cappedAt(args?.conversations).conversations : undefined;
139
142
  if (cap && olderThan(version, floor)) return { text: heldRunText(version, cap, floor), isError: true, code: "older_command" };
@@ -209,6 +212,23 @@ export function argsOf(command, rest) {
209
212
  return { ...(words.length ? { ask: words.join(" ") } : {}), ...(features?.length ? { features } : {}), ...(conversations ? { conversations: Number(conversations) } : {}), ...(account === "make" ? { account } : {}) };
210
213
  }
211
214
  case "world_status": return rest[0] ? { jobId: rest[0] } : {};
215
+ // `behavior [at] [--persona "<name>"] [--against previous] [--history]`.
216
+ case "behavior": {
217
+ const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
218
+ const FLAGS = ["--persona", "--against"];
219
+ const at = rest.find((w, i) => !w.startsWith("-") && !FLAGS.includes(rest[i - 1]));
220
+ const persona = flag("--persona");
221
+ const against = flag("--against");
222
+ return { ...(at ? { at } : {}), ...(persona ? { persona } : {}), ...(against ? { against } : {}), ...(rest.includes("--history") ? { history: true } : {}) };
223
+ }
224
+ // `search "<what to look for>" [--where production] [--persona "<name>"] [--limit 20]`.
225
+ case "search": {
226
+ const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
227
+ const FLAGS = ["--where", "--persona", "--limit"];
228
+ const words = rest.filter((w, i) => !FLAGS.includes(w) && !FLAGS.includes(rest[i - 1]));
229
+ const [where, persona, limit] = FLAGS.map(flag);
230
+ return { query: words.join(" "), ...(where ? { where } : {}), ...(persona ? { persona } : {}), ...(limit ? { limit: Number(limit) } : {}) };
231
+ }
212
232
  case "reach": {
213
233
  const paths = rest.filter((w) => w.startsWith("/"));
214
234
  return paths.length ? { paths } : {};
@@ -119,7 +119,7 @@ const inCodeLines = (d, room) => section("Also in the code; a code review would
119
119
  const gateLines = (d, room) => section("Open gates, found by sending requests to your app's routes:", d.gates, room);
120
120
  const shortLines = (room) => [
121
121
  ...(room.leaders?.length ? [`Also to act on, each shown in full by findings: ${room.leaders.join(", ")}.`] : []),
122
- ...(room.short ? [`findings shows the last ${plural(room.short, "finding")} in full${room.unlisted ? `; ${num(room.unlisted)} of them are not listed here` : ""}.`] : []),
122
+ ...(room.short ? [`behavior shows the last ${plural(room.short, "finding")} in full${room.unlisted ? `; ${num(room.unlisted)} of them are not listed here` : ""}.`] : []),
123
123
  ];
124
124
 
125
125
  // The first run on a repository is the baseline: who the simulated users were, what they asked for,
package/lib/doors.mjs CHANGED
@@ -1,15 +1,16 @@
1
- // THE SIX DOORS.
1
+ // THE SEVEN DOORS.
2
2
  //
3
3
  // Twenty tools asked a coding agent to match a description to the moment it was in, and twenty
4
4
  // descriptions with close cousins among them is a choice nobody gets right: it opened the wrong one
5
- // or never reached the last nine at all. These six are the questions an agent actually has.
5
+ // or never reached the last nine at all. These seven are the questions an agent actually has.
6
6
  //
7
- // status what do I need to know about this app right now
7
+ // status where do things stand: the connection, the endpoints, a run playing, what waits on the person
8
8
  // test test what I just built
9
- // verify did my fix hold
9
+ // behavior how did the app behave with its users, at one point in time, and what changed
10
+ // search find the conversations about something, by what they mean
11
+ // verify did the behavior move after my fix
10
12
  // connect connect the live app
11
- // tell tell the Cortad team something, or say what this app counts as a good ending
12
- // dispute this check reads my app wrong
13
+ // tell tell the Cortad team something, propose what counts, or say a check reads my app wrong
13
14
  //
14
15
  // The questions did not shrink. Each door answers more, and `status` with nothing said answers
15
16
  // whichever of them matters at this moment, so the common case is no choice at all.
@@ -26,35 +27,43 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
26
27
  const readOnly = { readOnlyHint: true };
27
28
 
28
29
  // What a note to the Cortad team may be about: a door, the connect command, or the skill text.
29
- const ABOUT = ["status", "test", "verify", "connect", "tell", "dispute", "skill"];
30
+ const ABOUT = ["status", "test", "behavior", "search", "verify", "connect", "tell", "skill"];
30
31
 
31
32
  // The lanes `status` opens when it is asked for one by name. Left out, it opens the one that matters.
32
- export const LANES = ["repository", "run", "findings", "production", "outcomes", "warehouse"];
33
+ export const LANES = ["repository", "run", "production", "outcomes", "warehouse", "users"];
33
34
 
34
- // The warehouse question, unchanged: the one door parameter that is a shape rather than a word.
35
+ // The one door parameter that is a shape rather than a word: a question Cortad compiles and pins to
36
+ // this app. The tables are the users lane's, and the warehouse's events and readings.
37
+ const USER_TABLES = ["conversations", "answers", "questions", "people", "profiles", "visits", "moments", "reactions"];
38
+ const WAREHOUSE_TABLES = ["events", "readings"];
35
39
  const ASK = { type: "object", properties: {
36
- table: { type: "string", enum: ["events", "readings", "reactions"] },
37
- from: str("Start, ISO 8601."), to: str("End, ISO 8601, at most 90 days after from."),
38
- filters: { type: "array", maxItems: 20, items: { type: "object", properties: { column: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "in", "gt", "gte", "lt", "lte", "null", "not_null"] }, value: {} }, required: ["column", "op"], additionalProperties: false } },
39
- group: { type: "array", maxItems: 4, items: { type: "string" }, description: "Columns, or hour or day." },
40
- measures: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", properties: { fn: { type: "string", enum: ["count", "count_distinct", "sum", "avg", "min", "max", "share"] }, column: { type: "string" }, equals: {}, as: { type: "string" } }, required: ["fn"], additionalProperties: false } },
40
+ table: { type: "string", enum: [...USER_TABLES, ...WAREHOUSE_TABLES] },
41
+ from: str("Start, ISO 8601. Default 30 days before to."), to: str("End, ISO 8601, at most 90 days after from. Default now."),
42
+ filters: { type: "array", maxItems: 20, items: { type: "object", properties: { column: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "in", "gt", "gte", "lt", "lte", "contains", "null", "not_null"] }, value: {} }, required: ["column", "op"], additionalProperties: false } },
43
+ group: { type: "array", maxItems: 4, items: { type: "string" }, description: "Columns, or hour, day or week." },
44
+ measures: { type: "array", maxItems: 8, items: { type: "object", properties: { fn: { type: "string", enum: ["count", "count_distinct", "sum", "avg", "min", "max", "share", "p50", "p90"] }, column: { type: "string" }, equals: {}, as: { type: "string" } }, required: ["fn"], additionalProperties: false } },
45
+ columns: { type: "array", maxItems: 20, items: { type: "string" }, description: "Single rows with these columns instead of measures." },
41
46
  order: { type: "object", properties: { by: { type: "string" }, dir: { type: "string", enum: ["asc", "desc"] } }, required: ["by"], additionalProperties: false },
42
47
  limit: { type: "integer", minimum: 1, maximum: 1000 },
43
- }, required: ["table", "from", "to", "measures"], additionalProperties: false,
44
- description: "A question over the live app's events, readings or reactions, for about warehouse. Compiled by Cortad and pinned to this app; no SQL is taken." };
48
+ }, required: ["table"], additionalProperties: false,
49
+ description: "Your own question over one table that status about users lists, compiled by Cortad and pinned to this app; no SQL is taken. events and readings are the warehouse's." };
50
+
51
+ const ANSWER = { type: "string", enum: ["users", "feature", "stuck", "change"], description: "A ready answer about the people using the live app. users: who they are, by user profile. feature: how people use what subject names, before you edit it. stuck: where people get stuck across every visit and conversation, with the replays to watch. change: how the same people fared after the release or time in subject." };
45
52
 
46
53
  const OUTCOME_RULE = { type: "object", properties: { kind: { type: "string", enum: ["path", "click", "event", "backend"] }, match: { type: "string", enum: ["exact", "starts", "contains"] }, value: { type: "string" }, text: { type: "string" }, selector: { type: "string" }, name: { type: "string" } }, required: ["kind"], additionalProperties: false };
47
54
 
48
55
  export const DOORS = [
49
56
  { name: "status", title: "What to know about this app right now", annotations: readOnly,
50
- description: "Everything worth knowing about the AI in this repository, in one call. Said plainly with nothing asked for: a run still playing, or what failed in the one that just finished, or something going wrong in the live app, whichever is the thing to act on now. Applies at the start of a session, right after a connect, before a commit that changes prompts, tools, models or retrieval, and any time you need to know where things stand. With about, one lane in full instead.",
57
+ description: "Where things stand, in one call: the connection, which endpoints answer, a run playing or just finished, the live app going wrong, and what waits on the person. Applies at the start of a session, after a connect, and whenever you need it. With about, one lane in full.",
51
58
  inputSchema: { type: "object", properties: {
52
- about: { type: "string", enum: LANES, description: "repository: what Cortad found in this code, each endpoint's answer, the plan. run: where a run or verify stands, and what a finished one found. findings: every failure of the last run, whole, worst first. production: the live app in numbers, what is wrong in it now and what the data says caused it. outcomes: what this app counts as a good ending and how often it is reached. warehouse: a question over the live app's own events, with ask." },
59
+ about: { type: "string", enum: LANES, description: "repository: what Cortad found in this code, each endpoint's answer, the plan. run: where a run or verify stands. production: the live app in numbers, what is wrong in it now and what the data says caused it. outcomes: what this app counts as a good ending and how often it is reached. warehouse: a question over the live app's own events, with ask. users: the people using the live app, their profiles, conversations, the behavioral questions and their answers, visits, what people did after each answer and reactions; with nothing else, the tables and what each column means." },
53
60
  id: str("A run's id, or a world's. Omit for the one this machine started last."),
54
- show: { type: "string", enum: [...SHOW, "numbers"], description: "One list in full, in pages: for about repository, rules, standards, journeys, endpoints, trials, records or reviews, or machine, what Cortad does on this machine; for about findings, numbers, every number the run measured." },
61
+ show: { type: "string", enum: SHOW, description: "One list in full, in pages, for about repository: rules, standards, journeys, endpoints, trials, records or reviews, or machine, what Cortad does on this machine." },
55
62
  page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." },
56
- days: { type: "integer", minimum: 1, maximum: 180, description: "The window for about production. Default 7." },
63
+ days: { type: "integer", minimum: 1, maximum: 180, description: "The window for about production, default 7, or for an answer about users, default 30 and at most 90." },
57
64
  ask: ASK,
65
+ answer: ANSWER,
66
+ subject: str("For answer feature: a page path, an app route, the part of the app that answers, or a topic. For answer change: a release, or the ISO time a change went out.", { maxLength: 200 }),
58
67
  }, additionalProperties: false } },
59
68
 
60
69
  { name: "test", title: "Test what you built",
@@ -67,11 +76,29 @@ export const DOORS = [
67
76
  account: { type: "string", enum: ["make"], description: "The person's yes to Cortad making one test account on the app's own sign-up door, for endpoints that need a signed-in user. Recorded; it starts nothing. Only with their word, said in this conversation." },
68
77
  }, additionalProperties: false } },
69
78
 
70
- { name: "verify", title: "Did your fix hold",
71
- description: "Replays the conversations a failure happened in, 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 be told yet with how many clean replays would decide. Conversations saved to test the fix are played once and said apart, so a fix is graded on conversations nobody read while writing it. It also says what happened the last time this same failure was made to pass, and how long that lasted. Answers within a second; status about run follows every round. Uses verify conversations from the plan, not a run.",
79
+ { name: "behavior", title: "How the app behaved with its users", annotations: readOnly,
80
+ description: "How the AI behaved with its users at one point in time, top down: how many users of how many personas achieved their goal; each persona, largest first, with who they are, how they behave, what the app did well and where it gave way, each problem with how often and the conversations that show it; the app's habits; last, where each problem comes from in the code. Default: the latest finished simulation. Applies after every run, before changing prompts, tools, models or retrieval, and when the person asks how their AI behaves.",
81
+ inputSchema: { type: "object", properties: {
82
+ at: str("A simulation's id, a commit the simulation played on, or production. Default: the latest finished simulation.", { maxLength: 64 }),
83
+ persona: str("One persona by name, in full, with its conversations.", { maxLength: 200 }),
84
+ against: str("Another point: previous, production, a simulation's id or a commit. What got better, worse, new or gone per persona, with ranges; against production, where simulated users behave unlike real ones.", { maxLength: 64 }),
85
+ history: { type: "boolean", description: "The log, newest first: each simulation with its commit and each production day, one line each with what changed since the one before." },
86
+ }, additionalProperties: false } },
87
+
88
+ { name: "search", title: "Find conversations by meaning", annotations: readOnly,
89
+ description: "Finds conversations about something by what they mean, across simulations and production: who asked, what, how it ended and what the app said, with how many of all conversations are about it. Applies when the person names a kind of user or a moment, or before you change how the app handles one.",
90
+ inputSchema: { type: "object", properties: {
91
+ query: str("What to look for, in words.", { maxLength: 500 }),
92
+ where: str("simulation, production, or one simulation's id. Default: everywhere.", { maxLength: 64 }),
93
+ persona: str("Only this persona's conversations, by name.", { maxLength: 200 }),
94
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "The most conversations to return. Default 10." },
95
+ }, required: ["query"], additionalProperties: false } },
96
+
97
+ { name: "verify", title: "Did the behavior move",
98
+ description: "After a fix, replays the conversations a problem happened in, word for word, until an exact test says whether the behavior moved: gone, less often, stayed, or not yet decided with how many clean replays would decide. Saved conversations nobody read while writing the fix are played once and said apart. Answers within a second; status about run follows every round. Uses verify conversations from the plan, not a run.",
72
99
  inputSchema: { type: "object", properties: {
73
- findingId: str("The failure's id, from status about findings."),
74
- id: str("The run the failure came from. Omit for the latest."),
100
+ findingId: str("The problem's id as behavior names it, such as finding:1."),
101
+ id: str("The simulation the problem came from. Omit for the latest."),
75
102
  }, required: ["findingId"], additionalProperties: false } },
76
103
 
77
104
  { name: "connect", title: "Connect the live app",
@@ -81,7 +108,7 @@ export const DOORS = [
81
108
  description: "The steps to connect the live app, so Cortad sees what real users are doing as it happens and where the AI lets them down: a one-time code writes both keys into the env file, the SDK lines for this framework, the page tag, and where each conversation then appears. The owner approves it in a browser where they are signed in." },
82
109
 
83
110
  { name: "tell", title: "Tell the team",
84
- description: "Say one of three things, one of them per call: a note to the Cortad team about Cortad itself, what this app counts as a good ending, or an address of yours that should receive each incident in the live app. The note is for a result that read wrong, something you needed that no door gave, a question an answer left open, or what worked; the team reads each one, and filing one is part of using Cortad. An outcome is read from the app's own code and the person approves it in the browser before any of it counts. An address is sent each incident as signed JSON, and the secret comes back once. Nothing about a run or its checks changes.",
111
+ description: "Say one of five things, one of them per call: a note to the Cortad team about Cortad itself; that a problem's question reads this app wrong (findingId and why); what this app counts as a good ending; behavioral questions the live app's answers should be asked; or an address of yours that should receive each incident in the live app. The note is for a result that read wrong, something you needed that no door gave, a question an answer left open, or what worked; the team reads each one. A question that reads the app wrong keeps its problem and rate as they are, and the note goes to the owner. An outcome is read from the app's own code, and a behavioral question from what behavior or status about users shows no question catches yet; the person approves each in the browser before any of it counts. An address is sent each incident as signed JSON, and the secret comes back once.",
85
112
  inputSchema: { type: "object", properties: {
86
113
  about: { type: "string", enum: ABOUT, description: "For a note: the door it is about, connect for the connect command, or skill for the instructions you were handed." },
87
114
  kind: { type: "string", enum: ["problem", "idea", "question", "praise"], description: "For a note. problem: wrong or failed; idea: something you needed; question: something an answer left open; praise: what worked." },
@@ -94,16 +121,17 @@ export const DOORS = [
94
121
  why: str("What in the code makes this an outcome.", { maxLength: 500 }),
95
122
  rules: { type: "array", minItems: 1, maxItems: 10, items: OUTCOME_RULE },
96
123
  }, required: ["name", "rules", "why"], additionalProperties: false } },
124
+ questions: { type: "array", maxItems: 5, description: "Behavioral questions to ask of every answer the live app gives, for the person's yes.",
125
+ items: { type: "object", properties: {
126
+ question: str("A yes-or-no question about one answer, ending in ?, where yes is what the app should do.", { maxLength: 240 }),
127
+ why: str("What in status about users or the code makes the case.", { maxLength: 500 }),
128
+ conversations: { type: "array", maxItems: 10, items: { type: "string" }, description: "Conversation ids from status about users that show it." },
129
+ }, required: ["question", "why"], additionalProperties: false } },
97
130
  incidentsTo: str("A public https address of yours that takes a POST and should receive each incident.", { maxLength: 500 }),
131
+ findingId: str("For a question that reads the app wrong: the problem's id, as behavior names it."),
132
+ why: str("With findingId: one or two sentences on what the question gets wrong about this app.", { maxLength: 500 }),
133
+ question: str("With findingId: the wording that fits this app.", { maxLength: 300 }),
98
134
  }, additionalProperties: false } },
99
-
100
- { name: "dispute", title: "This question reads your app wrong",
101
- description: "Records that a failure's question reads this app wrong, with the reason and an optional better wording. The failure and its rate stay as they are; the note goes to the owner. The honest exit when a check is measuring something this app was not built to do.",
102
- inputSchema: { type: "object", properties: {
103
- findingId: str("The failure's id, from status about findings."),
104
- why: str("One or two sentences: what the question gets wrong about this app.", { maxLength: 500 }),
105
- question: str("The wording that fits this app.", { maxLength: 300 }),
106
- }, required: ["findingId", "why"], additionalProperties: false } },
107
135
  ];
108
136
 
109
137
  export const DOOR_NAMES = new Set(DOORS.map((d) => d.name));
@@ -114,7 +142,8 @@ const MOVED = {
114
142
  reach: 'test with endpoints: ["/your/path"]',
115
143
  run: "test",
116
144
  run_status: 'status about: "run"',
117
- findings: 'status about: "findings"',
145
+ findings: "behavior",
146
+ dispute: "tell with findingId and why",
118
147
  feedback: "tell",
119
148
  field_connect: "connect",
120
149
  field: 'status about: "production"',
@@ -131,7 +160,7 @@ const MOVED = {
131
160
  // to run the call in (lib/cli.mjs asRunner).
132
161
  export const RETIRED = Object.keys(MOVED);
133
162
  export const VERB_NAMES = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "feedback",
134
- "field_connect", "field", "world", "world_status", "test", "connect", "intelligence", "incidents", "outcomes", "subscribe", "query"];
163
+ "field_connect", "field", "world", "world_status", "test", "connect", "intelligence", "incidents", "outcomes", "subscribe", "query", "users", "behavior", "search"];
135
164
 
136
165
  export const movedTo = (name) => MOVED[name] ?? null;
137
166
  export const movedSaid = (name) => `${name} is now ${MOVED[name]}. This is that answer; call it that way next time.`;
@@ -143,7 +172,8 @@ export const movedSaid = (name) => `${name} is now ${MOVED[name]}. This is that
143
172
  const NEXT = {
144
173
  run_status: (id) => lane("run", id),
145
174
  world_status: (id) => lane("run", id),
146
- findings: () => lane("findings"),
175
+ findings: () => "behavior",
176
+ behavior: (id) => (id ? `behavior with at: "${id}"` : "behavior"),
147
177
  run: () => "test",
148
178
  field: () => lane("production"),
149
179
  intelligence: () => lane("production"),
@@ -168,8 +198,8 @@ const idOf = (args) => args.id ?? args.jobId;
168
198
  // The lane a call is for. A parameter that belongs to one lane names it on its own, so an agent that
169
199
  // asks for a section or hands over a question lands there rather than on whatever matters now.
170
200
  const laneOf = (args) => args.about
171
- ?? (args.ask ? "warehouse"
172
- : args.show === "numbers" ? "findings"
201
+ ?? (args.answer || args.subject ? "users"
202
+ : args.ask ? (WAREHOUSE_TABLES.includes(args.ask.table) ? "warehouse" : "users")
173
203
  : args.show ? "repository"
174
204
  : idOf(args) ? "run"
175
205
  : args.days ? "production"
@@ -177,10 +207,10 @@ const laneOf = (args) => args.about
177
207
 
178
208
  // `verbs` is lib/verbs.mjs's object, already wrapped for an older connect command (cli.mjs asRunner).
179
209
  export function makeDoors(verbs) {
180
- // With no lane asked for: the one to act on. A run playing or just finished outranks the live app,
181
- // because the agent is standing in front of it; a finished run with failures is read as its
182
- // failures, which is the call every agent made next anyway.
183
- const now = async (page) => {
210
+ // With no lane asked for: the one to act on. A run playing outranks the live app, because the agent
211
+ // is standing in front of it; a simulation that finished is said as where things stand, with
212
+ // behavior as the next call, since how its users fared is that door's whole answer.
213
+ const now = async () => {
184
214
  const read = await verbs.status({});
185
215
  const run = read.data?.run;
186
216
  // The read is the only call that takes what the server kept to say, and it is taken once, so it
@@ -193,9 +223,10 @@ export function makeDoors(verbs) {
193
223
  // With no run to act on, a live app that is failing its people is the thing to act on.
194
224
  const idle = () => (read.data?.field?.problems ? carried(production({})) : read);
195
225
  if (!run?.jobId) return idle();
196
- // Still playing, or a verify: the run's own answer. Over, with failures: the failures.
226
+ // Still playing, or a verify: the run's own answer. Over: where things stand, then behavior.
197
227
  if (!finished(run) || run.verify) return carried(verbs.run_status({ jobId: run.jobId }));
198
- return (run.findings ?? 0) > 0 ? carried(verbs.findings({ jobId: run.jobId, ...(page ? { page } : {}) })) : idle();
228
+ if (!((run.findings ?? 0) > 0)) return idle();
229
+ return /\nnext: [^\n]*$/.test(read.text) ? read : { ...read, text: `${read.text}\nnext: behavior ${run.jobId}` };
199
230
  };
200
231
 
201
232
  // What is happening in the live app and what is wrong in it, as one answer: the numbers alone left
@@ -213,13 +244,13 @@ export function makeDoors(verbs) {
213
244
  switch (laneOf(args)) {
214
245
  case "repository": return verbs.status({ ...(args.show ? { show: args.show } : {}), ...(args.page ? { page: args.page } : {}) });
215
246
  case "run": return id ? runOrWorld(id) : verbs.run_status({});
216
- case "findings": return verbs.findings({ ...(id ? { jobId: id } : {}), ...(args.show === "numbers" ? { show: "numbers" } : {}), ...(args.page ? { page: args.page } : {}) });
217
247
  case "production": return production(args);
218
248
  case "outcomes": return verbs.outcomes({});
249
+ case "users": return verbs.users({ ...(args.answer ? { answer: args.answer } : {}), ...(args.subject ? { subject: args.subject } : {}), ...(args.days ? { days: args.days } : {}), ...(args.ask ? { ask: args.ask } : {}) });
219
250
  case "warehouse": return args.ask
220
251
  ? verbs.query(args.ask)
221
- : { text: "A warehouse question names its table (events, readings or reactions), its from and to, and at least one measure. Send it as ask.", isError: true, code: "no_ask" };
222
- default: return now(args.page);
252
+ : { text: "A warehouse question names its table (events or readings), its from and to, and at least one measure. Send it as ask.", isError: true, code: "no_ask" };
253
+ default: return now();
223
254
  }
224
255
  };
225
256
 
@@ -229,21 +260,30 @@ export function makeDoors(verbs) {
229
260
  : verbs.test({ ...(args.ask ? { ask: args.ask } : {}), ...(args.conversations ? { conversations: args.conversations } : {}), ...(Array.isArray(args.features) ? { features: args.features.map(String) } : {}) }));
230
261
 
231
262
  const tell = async (args = {}) => {
263
+ if (args.findingId) {
264
+ if (!args.why) return { text: "A question that reads the app wrong is said with findingId and why: what the question gets wrong about this app.", isError: true, code: "no_why" };
265
+ return verbs.dispute({ findingId: String(args.findingId), why: String(args.why), ...(args.question ? { question: String(args.question) } : {}), ...(idOf(args) ? { jobId: idOf(args) } : {}) });
266
+ }
232
267
  if (args.outcomes) return verbs.outcomes({ proposals: args.outcomes });
268
+ if (args.questions) return verbs.users({ propose: args.questions });
233
269
  if (args.incidentsTo) return verbs.subscribe({ url: args.incidentsTo });
234
270
  if (!args.about || !args.kind || !args.needed) {
235
- return { text: `A note names what it is about (${ABOUT.join(", ")}), its kind (problem, idea, question or praise), and what you needed. To say what counts as a good ending send outcomes instead, and to hear of incidents send incidentsTo.`, isError: true, code: "no_note" };
271
+ return { text: `A note names what it is about (${ABOUT.join(", ")}), its kind (problem, idea, question or praise), and what you needed. To say a question reads the app wrong send findingId and why, to say what counts as a good ending send outcomes, to propose behavioral questions send questions, and to hear of incidents send incidentsTo.`, isError: true, code: "no_note" };
236
272
  }
237
273
  return verbs.feedback({ about: args.about, kind: args.kind, needed: args.needed, ...(args.got ? { got: args.got } : {}), ...(args.tried ? { tried: args.tried } : {}) });
238
274
  };
239
275
 
276
+ const behavior = (args = {}) => verbs.behavior({ ...(args.at ? { at: args.at } : {}), ...(args.persona ? { persona: args.persona } : {}), ...(args.against ? { against: args.against } : {}), ...(args.history === true ? { history: true } : {}) });
277
+ const search = (args = {}) => verbs.search({ query: args.query, ...(args.where ? { where: args.where } : {}), ...(args.persona ? { persona: args.persona } : {}), ...(Number.isSafeInteger(args.limit) ? { limit: args.limit } : {}) });
278
+
240
279
  return {
241
280
  status,
242
281
  test,
282
+ behavior,
283
+ search,
243
284
  verify: (args = {}) => verbs.verify({ findingId: args.findingId, ...(idOf(args) ? { jobId: idOf(args) } : {}) }),
244
285
  connect: (args = {}) => verbs.connect(typeof args.site === "string" && args.site ? { site: args.site } : {}),
245
286
  tell,
246
- dispute: (args = {}) => verbs.dispute(args),
247
287
  };
248
288
  }
249
289