@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.
- package/lib/behavior-text.mjs +239 -0
- package/lib/cli.mjs +22 -2
- package/lib/dissection-text.mjs +1 -1
- package/lib/doors.mjs +89 -49
- package/lib/history/contract.mjs +29 -13
- package/lib/history/sources.mjs +11 -0
- package/lib/intelligence-pair.mjs +1 -1
- package/lib/intelligence-text.mjs +41 -18
- package/lib/intelligence.mjs +38 -14
- package/lib/layer.mjs +293 -1026
- package/lib/mcp.mjs +4 -4
- package/lib/message-text.cjs +48 -0
- package/lib/proxy.mjs +25 -0
- package/lib/replay.mjs +3 -1
- package/lib/setup-live-text.mjs +19 -0
- package/lib/setup-live.mjs +35 -0
- package/lib/start.mjs +22 -0
- package/lib/text.mjs +1 -1
- package/lib/trace.cjs +7 -1
- package/lib/verbs.mjs +44 -7
- package/lib/wire.cjs +10 -2
- package/local.mjs +29 -12
- package/package.json +6 -1
- package/skill/SKILL.md +46 -71
- package/skill/references/results.md +54 -8
|
@@ -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 } : {};
|
package/lib/dissection-text.mjs
CHANGED
|
@@ -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 ? [`
|
|
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
|
|
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
|
|
5
|
+
// or never reached the last nine at all. These seven are the questions an agent actually has.
|
|
6
6
|
//
|
|
7
|
-
// status
|
|
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
|
-
//
|
|
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
|
|
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", "
|
|
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", "
|
|
33
|
+
export const LANES = ["repository", "run", "production", "outcomes", "warehouse", "users"];
|
|
33
34
|
|
|
34
|
-
// The
|
|
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: [
|
|
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
|
|
40
|
-
measures: { type: "array",
|
|
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"
|
|
44
|
-
description: "
|
|
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: "
|
|
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
|
|
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:
|
|
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
|
|
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: "
|
|
71
|
-
description: "
|
|
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
|
|
74
|
-
id: str("The
|
|
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
|
|
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:
|
|
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: () =>
|
|
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.
|
|
172
|
-
: args.
|
|
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
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
const now = async (
|
|
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
|
|
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
|
-
|
|
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
|
|
222
|
-
default: return now(
|
|
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
|
|
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
|
|