cortad 0.3.1-rc.9 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
package/lib/verbs.mjs CHANGED
@@ -1,25 +1,44 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
2
  import { resolve as resolvePath, sep } from "node:path";
3
- import { SHOW, showText } from "./read-text.mjs";
4
- import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, waitingText } from "./text.mjs";
3
+ import { machineText, SHOW, showText } from "./read-text.mjs";
4
+ import { numbersText } from "./numbers-text.mjs";
5
+ import { askReach } from "./auto-reach.mjs";
6
+ import { homeOf, projectOf } from "./home.mjs";
7
+ import { reach as reachApp } from "./reach.mjs";
8
+ import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStartedText, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, upWaitingText, waitingText } from "./text.mjs";
5
9
 
6
- // The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
7
- // and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
8
- // functions, so both faces say the same thing.
10
+ // The ten things a coding agent may ask of Cortad, each a call to the API with this machine's key
11
+ // and a rendering from lib/text.mjs; `reach` also sends to the app on this machine. The MCP tools
12
+ // and the `npx cortad <verb>` commands are the same functions, so both faces say the same thing.
9
13
  //
10
- // Codex, Cursor CLI and Copilot CLI cut an MCP call at about 60 seconds. `run` and `verify` answer
11
- // within a second; `run_status` holds for up to 45 seconds on the server and 58 in all.
14
+ // Codex, Cursor CLI and Copilot CLI cut an MCP call at about 60 seconds. `run_status` holds for up
15
+ // to 45 seconds on the server and 58 in all. `run` and `verify` answer within a second, except while
16
+ // the run before them has its numbers in and its job is still ending: the server holds them until it
17
+ // has, up to the same 45 seconds.
12
18
 
13
19
  const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person: sign in at cortad.com and run the command the connect screen shows, in this folder.";
14
20
  const HOLD_S = 45;
21
+ // `wait`: how often it asks whether a run has started, how long the connect command may be gone
22
+ // before no run can start, and how long it waits for one at all.
23
+ const WAIT_POLL_MS = 10_000;
24
+ const GONE_MS = 60_000;
25
+ const IDLE_MS = 6 * 3600_000;
15
26
  // What the process that starts a run for an app that is still coming up allows it; lib/cli.mjs.
16
27
  export const READY_MS = 240_000;
28
+ // The most changed files a run is scoped by; past it the run replays everything.
29
+ const MOST_CHANGED = 2000;
17
30
 
18
31
  // `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
19
32
  // startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
20
33
  // the latest run at the time, so a newer run on the server outranks a stale file. A run this machine
21
34
  // posted also keeps `tested`, which code it played (lib/fresh.mjs), for its run_status and findings.
22
- export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, writes = null, code = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
35
+ // `runner` is the record of the process holding the app up on this machine (lib/runner.mjs): the
36
+ // app's state is read there and nowhere else. `seen` is told each run the agent has now read to its
37
+ // end, so what was kept to tell it of that run is not said again. `wasRead` says whether the agent has read a run, and
38
+ // `spec` is the package the agent calls back, npx -y <spec>.
39
+ // `appLog` is the file the connect command keeps the app's output in; its path is said here, on this
40
+ // machine, beside a failure, and never sent.
41
+ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch, startApp, pending, root, writes = null, appLog = null, code = null, seen = () => {}, wasRead = () => false, spec = "cortad", now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
23
42
  const call = async (method, path, body, timeoutMs = 30_000) => {
24
43
  const key = typeof token === "function" ? token() : token;
25
44
  if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
@@ -39,8 +58,17 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
39
58
  try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
40
59
  return { status: res.status, ok: res.ok, data };
41
60
  };
42
- const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
61
+ // `code` and `why` are what lib/calls.mjs counts a failure under. `why` is set only where the
62
+ // sentence is Cortad's own: a failure that quotes the app's output carries a code and no words.
63
+ const failed = (res) => {
64
+ const text = res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`;
65
+ const code = res.status ? `http_${res.status}` : text === NOT_CONNECTED ? "not_connected" : "unreachable";
66
+ return { text, data: res.data, isError: true, code, why: text };
67
+ };
43
68
  const iso = () => new Date(now()).toISOString();
69
+ const heard = (id) => { try { seen(String(id)); } catch { /* said once more by the hook */ } };
70
+ // Empty for an app the connect command found already running.
71
+ const logSaid = (failing) => (failing && appLog && sizeOf(appLog) > 0 ? `Your app's whole output from this session is in ${appLog}.` : "");
44
72
  const withTested = (d, id) => {
45
73
  const kept = pending.read();
46
74
  return kept?.tested && kept.jobId === id ? { ...d, tested: kept.tested } : d;
@@ -48,16 +76,37 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
48
76
 
49
77
  // With show, one section of the read in full, paged; a name that is not a section is the plain status.
50
78
  const status = async ({ show, page } = {}) => {
79
+ // What the connect command does here is this machine's to say: nothing is sent.
80
+ if (show === "machine") return { text: machineText(runner()?.machine ?? null).join("\n") };
51
81
  const section = SHOW.includes(show) ? show : null;
52
- const res = await call("GET", `/mcp/status${section ? `?show=${section}` : ""}`);
82
+ // The plain status is the one read that prints the server's news, and says so; a section prints
83
+ // none, so it takes none.
84
+ // The reviews are written when the read ends: asked for by name, the answer holds for that.
85
+ const hold = section === "reviews" ? 45 : 0;
86
+ const res = await call("GET", `/mcp/status${section ? `?show=${section}&quiet=1${hold ? `&wait=${hold}` : ""}` : "?news=1"}`, undefined, hold ? (hold + 13) * 1000 : undefined);
53
87
  if (!res.ok) return failed(res);
54
- return { text: section ? showText(res.data, section, page) : statusText(res.data), data: res.data };
88
+ // Status says a finished run itself, so the line kept to tell the agent of it is not said again.
89
+ if (finished(res.data?.run) && res.data.run.jobId) heard(res.data.run.jobId);
90
+ const here = runner();
91
+ const data = here ? { ...res.data, runner: here } : res.data;
92
+ const log = section ? "" : logSaid(appFailed(data.run) || (data.contact?.proven ?? []).some((v) => v.problems?.length));
93
+ const text = section ? showText(data, section, page) : statusText(data);
94
+ return { text: log ? withBack(text, log) : text, data };
95
+ };
96
+
97
+ // The files saved since the run the next one is compared with (status `baseline`), which scope a
98
+ // run after a change on Cortad's side. None when this machine cannot say: the run replays everything.
99
+ const changeOf = async (seen) => {
100
+ const base = (seen ?? (await call("GET", "/mcp/status?quiet=1")).data)?.baseline;
101
+ const files = base?.jobId ? code?.saved?.(base.startedAt) : null;
102
+ return files && files.length <= MOST_CHANGED ? { since: base.jobId, files } : null;
55
103
  };
56
104
 
57
105
  // Asks the server for the run and keeps its id here, so run_status finds it without one.
58
- const post = async (args, kind, restarted = []) => {
106
+ const post = async (args, kind, restarted = [], seen = null) => {
59
107
  const tested = code?.tested() ?? null;
60
- const res = await call("POST", "/mcp/run", args);
108
+ const change = kind === "run" ? await changeOf(seen) : null;
109
+ const res = await call("POST", "/mcp/run", change ? { ...args, change } : args, (HOLD_S + 13) * 1000);
61
110
  if (res.status === 202 || (res.ok && res.data?.joined)) {
62
111
  const mine = res.status === 202 ? tested : null;
63
112
  pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso(), ...(mine ? { tested: mine } : {}) });
@@ -65,30 +114,33 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
65
114
  const said = [restarted.length ? restartedText(restarted) : "", mine?.stale ? testedLine(mine) : ""];
66
115
  return { text: startedText(res.data, kind, args.findingId, said), data: res.data };
67
116
  }
68
- if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
69
- if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
117
+ if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true, code: "plan_spent" };
118
+ if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true, code: "run_refused" };
70
119
  return failed(res);
71
120
  };
72
121
 
73
- // An app that is up gets the run now, started again first when its code changed and nothing
74
- // reloaded it; never under a run already playing, which is joined. One that is not up is started
75
- // in the background, and the run is posted from there once it answers.
122
+ // An app the runner says is up, and the server is ready to play, gets the run now, started again
123
+ // first when its code changed and nothing reloaded it; never under a run already playing, which is
124
+ // joined. One that could not start says why and nothing is posted. Otherwise the runner is started
125
+ // if none holds the app, and the run is posted in the background once it can be.
76
126
  const start = async (args, kind) => {
77
- const before = await call("GET", "/mcp/status");
127
+ const here = runner();
128
+ if (here?.state === "failed") return { text: notStartedText(here), data: { app: here }, isError: true, code: "app_not_started" };
129
+ const before = await call("GET", "/mcp/status?quiet=1");
78
130
  if (!before.ok) return failed(before);
79
- if (before.data.app?.state === "ready-to-test") {
131
+ if (here?.state === "up" && before.data.app?.state === "ready-to-test") {
80
132
  const idle = !before.data.run || finished(before.data.run);
81
133
  const got = code && idle ? await code.fresh() : {};
82
- if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true };
83
- return post(args, kind, got.restarted);
134
+ if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true, code: "app_not_started" };
135
+ return post(args, kind, got.restarted, before.data);
84
136
  }
85
137
  pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
86
138
  const began = await startApp(args, kind);
87
139
  if (!began.ok) {
88
140
  pending.write({ kind, startedAt: iso(), error: began.why });
89
- return { text: began.why, isError: true };
141
+ return { text: began.why, isError: true, code: "app_not_started" };
90
142
  }
91
- return { text: STARTING, data: { pending: true } };
143
+ return { text: here?.state === "up" ? upWaitingText(here, before.data.app) : STARTING, data: { pending: true } };
92
144
  };
93
145
  const run = () => start({}, "run");
94
146
  const verify = ({ findingId, jobId } = {}) => start({ findingId, ...(jobId ? { jobId } : {}) }, "verify");
@@ -96,7 +148,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
96
148
  // With no id: the run this machine is still starting, held here until it has an id, or else the
97
149
  // latest run on the server.
98
150
  const resolve = async (until) => {
99
- const latest = await call("GET", "/mcp/status");
151
+ const latest = await call("GET", "/mcp/status?quiet=1");
100
152
  if (!latest.ok) return failed(latest);
101
153
  const newest = latest.data.run?.jobId ?? null;
102
154
  let p = pending.read();
@@ -107,9 +159,9 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
107
159
  p = pending.read() ?? p;
108
160
  }
109
161
  if (p.jobId) return { id: p.jobId };
110
- if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
111
- if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
112
- return { text: waitingText(p, now(), READY_MS) };
162
+ if (p.error) return { text: `${p.error}\nnext: status`, isError: true, code: "app_not_started" };
163
+ if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true, code: "app_not_started" };
164
+ return { text: waitingText(p, now(), READY_MS, runner(), latest.data.app) };
113
165
  };
114
166
 
115
167
  const runStatus = async ({ jobId } = {}) => {
@@ -124,13 +176,48 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
124
176
  const res = await call("GET", `/mcp/run/${encodeURIComponent(id)}${hold ? `?wait=${hold}` : ""}`, undefined, (hold + 13) * 1000);
125
177
  if (!res.ok) {
126
178
  const out = failed(res);
127
- return { ...out, text: `${out.text}\nnext: run_status ${id}` };
179
+ // Unreached is this terminal's side only: the run is on Cortad's servers and goes on without it.
180
+ const said = res.status === 0 ? `${out.text} The run is on Cortad's side and goes on without this call; nothing was lost or charged by it.` : out.text;
181
+ return { ...out, text: `${said}\nnext: run_status ${id}` };
128
182
  }
183
+ // A finished run's dissection is also handed over as data, for a client that reads structured results.
184
+ return { text: shown(res.data, id), data: res.data, ...(res.data?.dissection ? { structured: res.data.dissection } : {}) };
185
+ };
186
+
187
+ const shown = (d, id) => {
129
188
  // The run is over: what the app wrote into its folder during it is put back, said before the next step.
130
189
  let back = "";
131
- if (writes && finished(res.data)) { try { back = writes.putBack(); } catch { back = ""; } }
132
- const text = runText(withTested(res.data, id));
133
- return { text: back ? withBack(text, back) : text, data: res.data };
190
+ if (writes && finished(d)) { try { back = writes.putBack(); } catch { back = ""; } }
191
+ // The id asked for and the run the answer names: a verify that played another round names its latest.
192
+ if (finished(d)) { heard(id); if (d?.jobId && d.jobId !== id) heard(d.jobId); }
193
+ return [logSaid(appFailed(d)), back].filter(Boolean).reduce(withBack, runText(withTested(d, id)));
194
+ };
195
+
196
+ // `npx cortad wait`: returns when the next run ends, started from the browser or by the agent, with
197
+ // what it found. A coding agent starts it in the background and is woken when it exits; it is a shell
198
+ // verb alone, since an MCP call is cut at about 60 seconds. A run playing when it starts is that run,
199
+ // and so is a finished one the agent has not read: a first run can end before the agent starts this.
200
+ const wait = async () => {
201
+ if (!(typeof token === "function" ? token() : token)) return { text: NOT_CONNECTED, isError: true, code: "not_connected", why: NOT_CONNECTED };
202
+ const began = now();
203
+ let id = null;
204
+ let goneAt = null;
205
+ for (;;) {
206
+ if (runner()) goneAt = null;
207
+ else if (now() - (goneAt ??= now()) >= GONE_MS) return { text: "The connect command stopped, so no run can start from the browser.\nFor the person: run the connect command again in this folder.\nnext: status", isError: true };
208
+ if (now() - began >= IDLE_MS) return { text: `No run ended in 6 hours.\nnext: npx -y ${spec} wait` };
209
+ const res = id ? await call("GET", `/mcp/run/${encodeURIComponent(id)}?wait=${HOLD_S}`, undefined, (HOLD_S + 13) * 1000) : await call("GET", "/mcp/status?quiet=1");
210
+ // A key that no longer opens this repository, or a run that is gone, does not come back by asking again.
211
+ if ([401, 403, 404].includes(res.status)) return failed(res);
212
+ if (res.ok && id && finished(res.data)) {
213
+ const text = shown(res.data, id).replace(/\nnext: [^\n]*$/, "");
214
+ return { text: `${text}\nnext: findings ${res.data.jobId ?? id}`, data: res.data };
215
+ }
216
+ const r = res.ok && !id ? res.data?.run : null;
217
+ if (r?.jobId && r.kind !== "verify" && (!finished(r) || !wasRead(r.jobId))) { id = r.jobId; continue; }
218
+ // A run being followed holds on the server; a failed call, or no run yet, is asked again later.
219
+ if (!id || !res.ok) await sleep(WAIT_POLL_MS);
220
+ }
134
221
  };
135
222
 
136
223
  // Every server page of a run's findings, so the pages the agent reads are cut by size here.
@@ -157,8 +244,15 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
157
244
  return first;
158
245
  };
159
246
 
160
- const findings = async ({ jobId, page } = {}) => {
247
+ // With show numbers: every number the run measured, whole, paged here.
248
+ const findings = async ({ jobId, page, show } = {}) => {
249
+ if (show === "numbers") {
250
+ const q = new URLSearchParams({ show, ...(jobId ? { jobId } : {}) }).toString();
251
+ const res = await call("GET", `/mcp/findings?${q}`);
252
+ return res.ok ? { text: numbersText(res.data, page), data: res.data } : failed(res);
253
+ }
161
254
  const res = await gather(jobId);
255
+ if (res.ok && res.data.runId) heard(res.data.runId);
162
256
  return res.ok ? { text: findingsText(withTested(res.data, res.data.runId), page), data: res.data } : failed(res);
163
257
  };
164
258
 
@@ -167,6 +261,23 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
167
261
  return res.ok ? { text: res.data.said, data: res.data } : failed(res);
168
262
  };
169
263
 
264
+ // A note about Cortad itself, filed where the browser's feedback box files, for the Cortad team to read.
265
+ const feedback = async ({ about, kind, needed, got, tried }) => {
266
+ const res = await call("POST", "/mcp/feedback", { about, kind, needed, ...(got ? { got } : {}), ...(tried ? { tried } : {}) });
267
+ return res.ok ? { text: res.data.said, data: res.data } : failed(res);
268
+ };
269
+
270
+ // Cortad's test request to each endpoint not yet answered, from this machine (lib/reach.mjs). The
271
+ // connect command sends it, signed in where it signed a test account in; the server is asked only
272
+ // for the list.
273
+ const reach = async ({ paths } = {}) => {
274
+ const here = runner();
275
+ if (here?.state === "up" && here.reach === "ask" && root) return askReach(homeOf(projectOf(root)), paths);
276
+ const res = here?.state === "up" ? await call("GET", "/mcp/status?quiet=1") : null;
277
+ if (res && !res.ok) return failed(res);
278
+ return reachApp({ runner: here, contact: res?.data?.contact, paths, fetchImpl });
279
+ };
280
+
170
281
  const fieldConnect = async () => {
171
282
  const res = await call("POST", "/mcp/field/connect");
172
283
  return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
@@ -178,9 +289,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
178
289
  };
179
290
 
180
291
  // `status --changed`: the files Cortad cites that changed since the latest run started, with the
181
- // findings at them. Empty when there is nothing to say, or nothing could be asked.
292
+ // findings at them. Empty when there is nothing to say, or nothing could be asked. The code review's
293
+ // files are those of the review shown: the read's misses, or the lines Cortad's team's review cites.
182
294
  const changed = async () => {
183
- const [s, f] = await Promise.all([call("GET", "/mcp/status"), gather()]);
295
+ const [s, f] = await Promise.all([call("GET", "/mcp/status?quiet=1"), gather()]);
184
296
  const r = s.ok ? s.data?.run : null;
185
297
  if (!r) return { text: "" };
186
298
  const kept = pending.read();
@@ -190,18 +302,23 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
190
302
  const paths = [
191
303
  ...lines.map((l) => l.path),
192
304
  ...(read.rules?.examples ?? []).map((e) => e.path),
193
- ...(read.machine?.misses ?? []).map((m) => m.path),
305
+ ...(read.machine?.misses ?? read.machine?.cited ?? []).map((m) => m.path),
194
306
  ...(Array.isArray(read.rules?.files) ? read.rules.files : []),
195
307
  ].filter((p) => typeof p === "string" && p);
196
308
  return { text: changedLine({ root, since, runId: r.jobId, paths, lines }) };
197
309
  };
198
310
 
199
311
  // `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
200
- return { status, run, run_status: runStatus, findings, verify, dispute, field_connect: fieldConnect, field, post, changed };
312
+ return { status, reach, run, run_status: runStatus, wait, findings, verify, dispute, feedback, field_connect: fieldConnect, field, post, changed };
201
313
  }
202
314
 
315
+ const sizeOf = (path) => { try { return statSync(path).size; } catch { return 0; } };
316
+
317
+ // A run whose replies failed, or with a fault on the app's side, is one whose app output is worth reading.
318
+ const appFailed = (run) => Boolean(run?.failedReplies?.rows?.length || run?.replies?.crashed > 0 || (run?.faults ?? []).some((f) => f.side === "theirs"));
319
+
203
320
  // Only paths inside the repository are looked at; a path from the server never reaches outside it.
204
- // The put-back line sits above the next step, never after it.
321
+ // A line said after a result sits above the next step, never after it.
205
322
  const withBack = (text, back) => {
206
323
  const lines = text.split("\n");
207
324
  const at = lines.findIndex((l) => l.startsWith("next:"));