humanish 0.109.1 → 0.110.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/ARCHITECTURE.md +14 -11
  2. package/CHANGELOG.md +24 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +97 -48
  5. package/dist/actors/computer-use/loop/ending.js +4 -4
  6. package/dist/actors/local-agent/claude-participant.d.ts +74 -0
  7. package/dist/actors/local-agent/claude-participant.js +243 -0
  8. package/dist/actors/local-agent/claude-session.d.ts +16 -3
  9. package/dist/actors/local-agent/claude-session.js +64 -23
  10. package/dist/actors/local-agent/cli.d.ts +11 -0
  11. package/dist/actors/local-agent/cli.js +103 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +18 -11
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/comms.js +25 -7
  16. package/dist/cli/commands/observe.js +4 -2
  17. package/dist/cli/commands/run-command.js +4 -3
  18. package/dist/cli/commands/run-signals.d.ts +2 -0
  19. package/dist/cli/commands/run-signals.js +96 -44
  20. package/dist/cli/commands/runs.d.ts +5 -0
  21. package/dist/cli/commands/runs.js +89 -67
  22. package/dist/cli/commands/setup.js +19 -5
  23. package/dist/cli/commands/study-format.js +29 -2
  24. package/dist/cli/commands/study-route-computer-use.js +2 -0
  25. package/dist/cli/commands/study-route-scripted.js +2 -0
  26. package/dist/cli/commands/study-route-shared-world.js +2 -0
  27. package/dist/cli/commands/study-route-terminal.js +2 -0
  28. package/dist/cli/commands/study.js +4 -3
  29. package/dist/cli/commands/tui.js +4 -3
  30. package/dist/cli/commands/watch.js +2 -2
  31. package/dist/cli/doctor.d.ts +7 -2
  32. package/dist/cli/doctor.js +47 -8
  33. package/dist/cli/findings.d.ts +103 -0
  34. package/dist/cli/findings.js +316 -0
  35. package/dist/cli/first-run-path.d.ts +5 -4
  36. package/dist/cli/first-run-path.js +14 -7
  37. package/dist/cli/io.d.ts +17 -3
  38. package/dist/cli/io.js +22 -4
  39. package/dist/cli/product-sentence.d.ts +3 -3
  40. package/dist/cli/product-sentence.js +3 -3
  41. package/dist/comms/receiving-runtime.js +1 -1
  42. package/dist/comms/setup.js +1 -1
  43. package/dist/evidence/redaction.d.ts +7 -3
  44. package/dist/evidence/redaction.js +68 -24
  45. package/dist/feedback/export-bundle.js +6 -1
  46. package/dist/feedback/export.js +3 -0
  47. package/dist/keys/env-file.d.ts +4 -0
  48. package/dist/keys/env-file.js +17 -0
  49. package/dist/keys/key-resolution.d.ts +7 -0
  50. package/dist/keys/key-resolution.js +10 -8
  51. package/dist/observer/data.d.ts +14 -1
  52. package/dist/observer/data.js +19 -9
  53. package/dist/observer/library.d.ts +4 -0
  54. package/dist/observer/library.js +18 -15
  55. package/dist/observer/render.js +8 -2
  56. package/dist/observer/run-routes.d.ts +2 -0
  57. package/dist/observer/run-routes.js +43 -34
  58. package/dist/observer-app.html +3 -3
  59. package/dist/routes/computer-use/bundle.js +3 -1
  60. package/dist/routes/computer-use/fanout-records.js +1 -1
  61. package/dist/routes/computer-use/live-phase.d.ts +1 -0
  62. package/dist/routes/computer-use/participant-execution.js +3 -1
  63. package/dist/routes/computer-use/participant-model.js +5 -2
  64. package/dist/routes/computer-use/plan.js +4 -4
  65. package/dist/routes/computer-use/preflight.js +2 -2
  66. package/dist/routes/computer-use/result.js +17 -28
  67. package/dist/routes/computer-use/single-bundle.js +7 -1
  68. package/dist/routes/computer-use/types.d.ts +6 -0
  69. package/dist/routes/scripted/bundle.js +4 -2
  70. package/dist/routes/scripted/plan.js +2 -2
  71. package/dist/routes/scripted/result.d.ts +3 -5
  72. package/dist/routes/scripted/result.js +16 -22
  73. package/dist/routes/scripted/scenario.js +4 -4
  74. package/dist/routes/scripted/types.d.ts +1 -0
  75. package/dist/routes/shared-world/bundle-records.js +6 -1
  76. package/dist/routes/shared-world/bundle.js +2 -0
  77. package/dist/routes/shared-world/plan.js +1 -1
  78. package/dist/routes/shared-world/result.js +22 -22
  79. package/dist/routes/shared-world/route.js +1 -1
  80. package/dist/routes/shared-world/types.d.ts +2 -0
  81. package/dist/routes/terminal/bundle.js +4 -2
  82. package/dist/routes/terminal/credentials.js +1 -1
  83. package/dist/routes/terminal/dry-run.js +8 -12
  84. package/dist/routes/terminal/ledger.d.ts +3 -3
  85. package/dist/routes/terminal/ledger.js +6 -6
  86. package/dist/routes/terminal/live-finish.js +23 -17
  87. package/dist/routes/terminal/plan.js +4 -4
  88. package/dist/routes/terminal/result.d.ts +6 -7
  89. package/dist/routes/terminal/result.js +6 -22
  90. package/dist/routes/terminal/session.js +1 -1
  91. package/dist/routes/terminal/types.d.ts +3 -0
  92. package/dist/run/bundle-shape.d.ts +3 -1
  93. package/dist/run/bundle-shape.js +26 -1
  94. package/dist/run/bundle.d.ts +30 -0
  95. package/dist/run/display.d.ts +59 -0
  96. package/dist/run/display.js +106 -0
  97. package/dist/run/dry-run.js +11 -7
  98. package/dist/run/judge.d.ts +3 -2
  99. package/dist/run/judge.js +10 -0
  100. package/dist/run/pricing.js +1 -1
  101. package/dist/run/projection.d.ts +0 -2
  102. package/dist/run/projection.js +0 -11
  103. package/dist/run/reclaim-outcomes.d.ts +89 -0
  104. package/dist/run/reclaim-outcomes.js +213 -0
  105. package/dist/run/reclaim.d.ts +18 -14
  106. package/dist/run/reclaim.js +179 -105
  107. package/dist/run/results.d.ts +11 -18
  108. package/dist/run/results.js +2 -2
  109. package/dist/run/run-index.d.ts +11 -1
  110. package/dist/run/run-index.js +11 -0
  111. package/dist/run/run.d.ts +34 -18
  112. package/dist/run/run.js +177 -72
  113. package/dist/run/sandbox-creates.d.ts +59 -0
  114. package/dist/run/sandbox-creates.js +138 -0
  115. package/dist/run/sandbox-ids.d.ts +53 -0
  116. package/dist/run/sandbox-ids.js +198 -0
  117. package/dist/run/sandbox-receipts.d.ts +16 -0
  118. package/dist/run/sandbox-receipts.js +70 -0
  119. package/dist/run/stats.d.ts +5 -0
  120. package/dist/run/stats.js +8 -4
  121. package/dist/run/status.d.ts +6 -9
  122. package/dist/run/status.js +7 -20
  123. package/dist/run/stored-runs.d.ts +5 -14
  124. package/dist/run/stored-runs.js +31 -137
  125. package/dist/run/streams.d.ts +3 -2
  126. package/dist/run-study.js +6 -1
  127. package/dist/study/doctor.d.ts +1 -1
  128. package/dist/study/doctor.js +7 -4
  129. package/dist/study/init-templates.js +2 -2
  130. package/dist/study/init.js +14 -3
  131. package/dist/study/keys.d.ts +12 -0
  132. package/dist/study/keys.js +32 -0
  133. package/dist/study/persona.d.ts +4 -0
  134. package/dist/study/persona.js +4 -2
  135. package/dist/study/types.d.ts +1 -1
  136. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  137. package/dist/substrates/e2b/peer-install.js +161 -0
  138. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  139. package/dist/substrates/e2b/sandbox.js +128 -35
  140. package/dist/substrates/e2b/sdk.d.ts +33 -0
  141. package/dist/substrates/e2b/sdk.js +21 -21
  142. package/dist/substrates/shell.d.ts +2 -0
  143. package/dist/substrates/shell.js +4 -0
  144. package/dist/tui-app.js +133 -133
  145. package/dist/verify/artifacts.d.ts +4 -0
  146. package/dist/verify/artifacts.js +8 -0
  147. package/dist/verify/liveness.d.ts +19 -3
  148. package/dist/verify/liveness.js +53 -28
  149. package/dist/verify/verify.d.ts +4 -1
  150. package/dist/verify/verify.js +27 -3
  151. package/docs/architecture/actor-contract.md +2 -1
  152. package/docs/architecture/observer.md +22 -7
  153. package/docs/architecture/restricted-codex-analysis.md +1 -1
  154. package/docs/architecture/task-protocol-support.md +13 -13
  155. package/docs/architecture/terminal-product-route.md +1 -1
  156. package/docs/contracts/run-bundle.md +90 -7
  157. package/docs/contracts/schemas.md +39 -22
  158. package/docs/contracts/study-analysis.md +45 -0
  159. package/docs/contracts/study-costs.md +8 -0
  160. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  161. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  162. package/docs/principles/invariants-and-defaults.md +11 -8
  163. package/package.json +5 -3
  164. package/skills/humanish/SKILL.md +18 -4
@@ -1,20 +1,27 @@
1
1
  // The run command's signal handling outside post-run analysis. The first interrupt, terminate or
2
- // hangup signal marks each unfinished run in this process interrupted, kills the E2B sandboxes
3
- // its create-time receipts name, runs the registered shutdown cleanups (watch's Observer server
4
- // and tunnel) and exits 128+n, all within one deadline; a second signal exits at once. Routes
5
- // take no abort signal, so the bundle is not finished, but the record ends at once and no
6
- // journaled sandbox waits for its timeout. Analysis has its own cancel handlers
2
+ // hangup signal prints one line saying humanish is stopping, refuses any further sandbox create,
3
+ // marks each unfinished run in this process interrupted, reclaims its sandboxes (by receipt, by
4
+ // the ids its in-flight creates report, and by its E2B tags), runs the registered shutdown
5
+ // cleanups (watch's Observer server and tunnel) and exits 128+n, all within one deadline. A
6
+ // second signal prints the reclaim command for each run not yet reclaimed and exits at once.
7
+ // Routes take no abort signal, so the bundle is not finished, but the record ends at once and no
8
+ // sandbox the run created waits for its timeout. Analysis has its own cancel handlers
7
9
  // (analysis-signals.ts) and takes over when it starts.
8
10
  import path from "node:path";
9
11
  import { activeRuns } from "../../run/active-runs.js";
10
12
  import { LATEST_RUN_ALIAS } from "../../run/locate.js";
11
13
  import { reclaimPinnedRunSandboxes, } from "../../run/reclaim.js";
14
+ import { stopSandboxCreates } from "../../run/sandbox-creates.js";
12
15
  import { SANDBOX_RECEIPTS_ARTIFACT } from "../../run/sandbox-receipts.js";
16
+ import { plural } from "../../run/text.js";
17
+ import { shellArg } from "../../substrates/shell.js";
13
18
  import { exitCodeForSignal } from "../observer-follow.js";
14
19
  /** How long the handler waits for interrupt, reclaim and cleanups before it exits anyway. */
15
20
  const INTERRUPT_RECLAIM_DEADLINE_MS = 10_000;
16
- /** Each kill's own request timeout, inside the deadline. */
21
+ /** Each kill's own request timeout, inside the deadline. Kills run concurrently. */
17
22
  const KILL_REQUEST_TIMEOUT_MS = 8_000;
23
+ /** How long reclaim waits for in-flight creates before it searches E2B by tag anyway. */
24
+ const CREATES_WAIT_MS = 6_000;
18
25
  const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
19
26
  let current;
20
27
  const shutdownCleanups = new Set();
@@ -39,7 +46,7 @@ export function beginRunSignalPhase(io, options = {}) {
39
46
  process.exit(code);
40
47
  });
41
48
  const handlers = new Map();
42
- let stopping = false;
49
+ let stopping;
43
50
  const end = () => {
44
51
  for (const [signal, handler] of handlers)
45
52
  target.removeListener(signal, handler);
@@ -48,16 +55,23 @@ export function beginRunSignalPhase(io, options = {}) {
48
55
  current = undefined;
49
56
  };
50
57
  const release = () => {
51
- if (!stopping)
58
+ if (stopping === undefined)
52
59
  end();
53
60
  };
54
61
  const onSignal = (signal) => {
55
- if (stopping) {
62
+ if (stopping !== undefined) {
63
+ const open = stopping.filter((stop) => stop.report !== null && stop.report?.clean !== true);
64
+ for (const stop of open)
65
+ say(io, `humanish: second ${signal}: exiting before run ${stop.run.runId}'s sandboxes were confirmed stopped; ${recovery(stop.run)} to stop what is left.`);
66
+ if (open.length === 0)
67
+ say(io, `humanish: second ${signal}: exiting.`);
56
68
  exit(exitCodeForSignal(signal));
57
69
  return;
58
70
  }
59
- stopping = true;
60
- void stopActiveRuns(io, signal, options)
71
+ // Synchronous, before anything is awaited: no create can start after this point.
72
+ stopping = activeRuns().map((run) => ({ run, creates: stopSandboxCreates(run.paths) }));
73
+ say(io, stoppingLine(signal, stopping, options.deadlineMs ?? INTERRUPT_RECLAIM_DEADLINE_MS));
74
+ void stopActiveRuns(io, signal, stopping, options)
61
75
  .catch(() => undefined)
62
76
  .finally(() => exit(exitCodeForSignal(signal)));
63
77
  };
@@ -74,48 +88,65 @@ export function beginRunSignalPhase(io, options = {}) {
74
88
  export function handOverRunSignals() {
75
89
  current?.release();
76
90
  }
91
+ function say(io, line) {
92
+ try {
93
+ io.writeErr(`${line}\n`);
94
+ }
95
+ catch {
96
+ // A hung-up terminal cannot take the line; the status record and reclaim receipt hold it.
97
+ }
98
+ }
99
+ function stoppingLine(signal, stopping, deadlineMs) {
100
+ const again = `a second ${signal} exits without waiting`;
101
+ if (stopping.length === 0)
102
+ return `humanish: ${signal}: stopping; no run had started.`;
103
+ const runs = stopping.map((stop) => stop.run.runId).join(", ");
104
+ return `humanish: ${signal}: stopping run ${runs} and reclaiming its sandboxes (up to ${deadlineMs / 1000} s); ${again}.`;
105
+ }
77
106
  /**
78
107
  * Interrupt, reclaim and the registered cleanups all run inside one deadline that starts before
79
108
  * the first status write is awaited, so a stalled write or kill still ends in a bounded exit.
80
109
  */
81
- async function stopActiveRuns(io, signal, options) {
110
+ async function stopActiveRuns(io, signal, stopping, options) {
82
111
  const deadlineMs = options.deadlineMs ?? INTERRUPT_RECLAIM_DEADLINE_MS;
83
- const runs = activeRuns();
84
- // Absent: still pending at the deadline. Null: the run had finished, so there is nothing to say.
85
- const reports = new Map();
86
112
  const work = Promise.all([
87
- ...runs.map(async (run) => {
113
+ ...stopping.map(async (stop) => {
88
114
  // False when the run had already finished: its route released what it held.
89
- if (!(await run.status.interrupt(signal))) {
90
- reports.set(run, null);
115
+ if (!(await stop.run.status.interrupt(signal))) {
116
+ stop.report = null;
91
117
  return;
92
118
  }
93
- const reclaimed = await reclaimPinnedRunSandboxes(run.cwd, run.paths, {
119
+ const reclaimed = await reclaimPinnedRunSandboxes(stop.run.cwd, stop.run.paths, {
94
120
  requestTimeoutMs: KILL_REQUEST_TIMEOUT_MS,
121
+ createsWaitMs: Math.min(CREATES_WAIT_MS, deadlineMs / 2),
95
122
  ...options.reclaim,
123
+ creates: stop.creates,
96
124
  }).catch(() => undefined);
97
- reports.set(run, reclaimSummary(run, reclaimed));
125
+ stop.report = reclaimSummary(stop.run, reclaimed);
98
126
  }),
99
127
  ...[...shutdownCleanups].map((cleanup) => cleanup().catch(() => undefined)),
100
128
  ]);
101
- const finished = await withDeadline(work, deadlineMs);
102
- for (const run of runs) {
103
- const report = reports.get(run);
104
- if (report === null)
129
+ const finished = await withDeadline(work, deadlineMs, options.deadline);
130
+ for (const stop of stopping) {
131
+ if (stop.report === null)
105
132
  continue;
106
- const text = report ??
107
- (finished === "deadline"
108
- ? `stopping did not finish within ${deadlineMs / 1000} s; ${recovery(run)}.`
109
- : `reclaim failed; ${recovery(run)}.`);
110
- try {
111
- io.writeErr(`humanish: ${signal}: run ${run.runId} marked interrupted; ${text}\n`);
112
- }
113
- catch {
114
- // A hung-up terminal cannot take the line; the status record and reclaim receipt hold it.
115
- }
133
+ const text = stop.report?.text ?? deadlineText(stop, finished, deadlineMs);
134
+ say(io, `humanish: ${signal}: run ${stop.run.runId} marked interrupted; ${text}`);
116
135
  }
117
136
  }
118
- function withDeadline(work, ms) {
137
+ function deadlineText(stop, finished, deadlineMs) {
138
+ if (finished !== "deadline")
139
+ return `reclaim failed; sandboxes unknown; ${recovery(stop.run)}.`;
140
+ const inFlight = stop.creates.inFlight();
141
+ const creates = inFlight === 0 ? "" : ` Still waiting on E2B: ${plural(inFlight, "sandbox create")}.`;
142
+ return `stopping did not finish within ${deadlineMs / 1000} s; sandboxes unknown.${creates} ${capitalize(recovery(stop.run))}; it also finds sandboxes by this run's E2B tags.`;
143
+ }
144
+ function capitalize(text) {
145
+ return text.charAt(0).toUpperCase() + text.slice(1);
146
+ }
147
+ function withDeadline(work, ms, wait) {
148
+ if (wait !== undefined)
149
+ return Promise.race([work, wait(ms).then(() => "deadline")]);
119
150
  let timer;
120
151
  const deadline = new Promise((resolve) => {
121
152
  timer = setTimeout(() => resolve("deadline"), ms);
@@ -127,20 +158,41 @@ function withDeadline(work, ms) {
127
158
  * to reclaim, which would resolve the newest run instead, so it gets its receipts path.
128
159
  */
129
160
  function recovery(run) {
130
- return run.runId === LATEST_RUN_ALIAS
131
- ? `its id is \`${LATEST_RUN_ALIAS}\`, which \`humanish reclaim --run\` reads as the newest run, so kill the ids in ${path.join(run.paths.relativeRunRoot, SANDBOX_RECEIPTS_ARTIFACT)} by hand`
132
- : `run \`humanish reclaim --run ${run.runId}\``;
161
+ if (run.runId === LATEST_RUN_ALIAS)
162
+ return `its id is \`${LATEST_RUN_ALIAS}\`, which \`humanish reclaim --run\` reads as the newest run, so kill the ids in ${path.join(run.paths.relativeRunRoot, SANDBOX_RECEIPTS_ARTIFACT)} by hand`;
163
+ const cwd = path.resolve(run.cwd) === process.cwd() ? "" : ` --cwd ${shellArg(run.cwd)}`;
164
+ return `run \`humanish reclaim --run ${shellArg(run.runId)}${cwd}\``;
133
165
  }
134
166
  function reclaimSummary(run, reclaimed) {
135
167
  if (reclaimed === undefined)
136
- return `reclaim failed; ${recovery(run)}.`;
168
+ return { text: `reclaim failed; sandboxes unknown; ${recovery(run)}.`, clean: false };
137
169
  if (reclaimed.error !== undefined)
138
- return reclaimed.error.message;
139
- if (reclaimed.outcomes.length === 0)
140
- return "no sandbox receipts to reclaim.";
170
+ return {
171
+ text: `${reclaimed.error.message} Sandboxes unknown; ${recovery(run)}.`,
172
+ clean: false,
173
+ };
141
174
  const counts = new Map();
142
175
  for (const outcome of reclaimed.outcomes)
143
176
  counts.set(outcome.state, (counts.get(outcome.state) ?? 0) + 1);
144
- const tally = [...counts].map(([state, count]) => `${count} ${state}`).join(", ");
145
- return reclaimed.ok ? `sandboxes: ${tally}.` : `sandboxes: ${tally}; ${recovery(run)} to retry.`;
177
+ const tally = counts.size === 0
178
+ ? "none created"
179
+ : [...counts].map(([state, count]) => `${count} ${state}`).join(", ");
180
+ switch (reclaimed.state) {
181
+ case "clean":
182
+ return {
183
+ text: `sandboxes clean: ${tally}; E2B lists none still tagged with this run.`,
184
+ clean: true,
185
+ };
186
+ case "unconfirmed":
187
+ return { text: `sandboxes unconfirmed: ${tally}; ${recovery(run)} to retry.`, clean: false };
188
+ default: {
189
+ const why = reclaimed.createsInFlight > 0
190
+ ? `${plural(reclaimed.createsInFlight, "sandbox create")} still waiting on E2B`
191
+ : `the E2B tag search ${reclaimed.tagSearch.status}${reclaimed.tagSearch.detail === undefined ? "" : ` (${reclaimed.tagSearch.detail})`}`;
192
+ return {
193
+ text: `sandboxes unknown: ${tally}, but ${why}; ${recovery(run)}.`,
194
+ clean: false,
195
+ };
196
+ }
197
+ }
146
198
  }
@@ -2,6 +2,11 @@ import { Command } from "commander";
2
2
  import { type CliIo } from "../io.js";
3
3
  export declare function registerRunCommand(parent: Command, io: CliIo): void;
4
4
  export declare function registerVerifyCommand(parent: Command, io: CliIo): void;
5
+ /**
6
+ * The deprecated `cleanup`, hidden from help: one warning, then exactly `reclaim --check`, with
7
+ * its output and exit code. The old command read only what computer-use runs recorded and passed
8
+ * when it found no record; the check asks E2B.
9
+ */
5
10
  export declare function registerCleanupCommand(parent: Command, io: CliIo): void;
6
11
  export declare function registerReviewCommand(parent: Command, io: CliIo): void;
7
12
  export declare function registerExportCommand(parent: Command, io: CliIo): void;
@@ -1,14 +1,16 @@
1
1
  import path from "node:path";
2
2
  import { Command, Option } from "commander";
3
- import { shellQuote } from "../../substrates/shell.js";
3
+ import { shellArg } from "../../substrates/shell.js";
4
4
  import { computeStats, formatStatsHuman } from "../../run/stats.js";
5
5
  import { DEFAULT_EXPORT_MAX_BYTES, exportRun, formatExportHuman } from "../../feedback/export.js";
6
- import { cleanupRun, listRuns, readReview } from "../../run/stored-runs.js";
6
+ import { listRuns, readReview } from "../../run/stored-runs.js";
7
7
  import { verifyRun } from "../../verify/verify.js";
8
8
  import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
9
9
  import { addRunOptions, handleRun } from "./run-command.js";
10
- import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, ENV_FILE_OPTION_DESCRIPTION, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
10
+ import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
11
11
  import { plural } from "../../run/text.js";
12
+ import { warnAndQueue } from "../deprecations.js";
13
+ import { formatFindings, readRunFindings } from "../findings.js";
12
14
  export function registerRunCommand(parent, io) {
13
15
  addRunOptions(parent
14
16
  .command("run")
@@ -30,30 +32,40 @@ export function registerVerifyCommand(parent, io) {
30
32
  io.setExitCode(result.ok ? 0 : 2);
31
33
  });
32
34
  }
35
+ const CLEANUP_RENAMED = "`cleanup` is now `reclaim --check`; `cleanup` is removed in 0.112.0.";
36
+ /**
37
+ * The deprecated `cleanup`, hidden from help: one warning, then exactly `reclaim --check`, with
38
+ * its output and exit code. The old command read only what computer-use runs recorded and passed
39
+ * when it found no record; the check asks E2B.
40
+ */
33
41
  export function registerCleanupCommand(parent, io) {
34
42
  parent
35
- .command("cleanup")
36
- .description("Check a run's recorded resources and write cleanup.json. It stops nothing; humanish reclaim stops leftover sandboxes.")
37
- .summary("Check that a run's resources were stopped.")
38
- .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
43
+ .command("cleanup", { hidden: true })
44
+ .description(`Deprecated: ${CLEANUP_RENAMED}`)
39
45
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
46
+ .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
47
+ .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
48
+ .addOption(envFileAliasOption())
40
49
  .option("--json", JSON_OPTION_DESCRIPTION)
41
50
  .action(async (options, command) => {
42
- const result = await cleanupRun(options.cwd, options.run);
43
- writeResult(command, io, result, (value) => formatCleanupHuman(value, options.cwd));
44
- io.setExitCode(result.ok ? 0 : 2);
51
+ warnAndQueue(command, io, CLEANUP_RENAMED);
52
+ await runReclaimCommand(io, command, { ...options, check: true });
45
53
  });
46
54
  }
47
55
  export function registerReviewCommand(parent, io) {
48
56
  parent
49
57
  .command("review")
50
- .description("Show a run's review: verdict, summary and gaps.")
51
- .summary("Build a review packet from verified run evidence.")
58
+ .description("Show a run's review: verdict, summary, gaps and the findings of its analysis, with the evidence each one cites.")
59
+ .summary("Show a run's outcome and analysis findings.")
52
60
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
53
61
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
54
62
  .option("--json", JSON_OPTION_DESCRIPTION)
55
63
  .action(async (options, command) => {
56
- const result = await readReview(options.cwd, options.run);
64
+ const review = await readReview(options.cwd, options.run);
65
+ // The verified run's exact id, so a run that moves `latest` meanwhile cannot swap the analysis.
66
+ const result = "verdict" in review
67
+ ? { ...review, analysis: await readRunFindings(options.cwd, review.runId) }
68
+ : review;
57
69
  writeResult(command, io, result, formatReviewHuman);
58
70
  io.setExitCode("ok" in result && result.ok === false ? 2 : 0);
59
71
  });
@@ -165,34 +177,57 @@ export function registerRuntimeCommands(parent, io) {
165
177
  export function registerReclaimCommand(parent, io) {
166
178
  parent
167
179
  .command("reclaim")
168
- .description("Kill an interrupted run's sandboxes by the exact ids journaled in its sandbox-receipts.ndjson; never enumerates the E2B account. Needs E2B_API_KEY in the environment or in --env-file.")
169
- .summary("Stop an interrupted run's leftover sandboxes.")
180
+ .description("Kill an interrupted run's sandboxes: the exact ids journaled in its sandbox-receipts.ndjson, and any sandbox E2B lists with this run's owner tags (a sandbox whose id never reached a receipt). It lists only sandboxes that match every tag of this run. --check asks E2B whether each still exists and kills nothing. Needs E2B_API_KEY in the environment or in --dotenv.")
181
+ .summary("Stop or check an interrupted run's sandboxes.")
170
182
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
171
183
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
172
184
  .addOption(new Option("--preflight", "Reclaim sandboxes left by interrupted `humanish study check` probes (journaled in .humanish/preflight) instead of a run's.").conflicts("run"))
173
- .option("--env-file <path>", ENV_FILE_OPTION_DESCRIPTION)
185
+ .option("--check", "Ask E2B whether each sandbox still exists; kill nothing and write nothing. Exits 0 only when all are gone.")
186
+ .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
187
+ .addOption(envFileAliasOption())
174
188
  .option("--json", JSON_OPTION_DESCRIPTION)
175
- .action(async (options, command) => {
176
- // The kill calls read E2B_API_KEY from the environment: the env file, then discovered keys.
177
- if (!(await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io })))
178
- return;
179
- const result = options.preflight
180
- ? await reclaimPreflightSandboxes(options.cwd)
181
- : await reclaimRunSandboxes(options.cwd, options.run);
182
- writeResult(command, io, result, formatReclaimHuman);
183
- io.setExitCode(result.ok ? 0 : 2);
184
- });
189
+ .action((options, command) => runReclaimCommand(io, command, options));
190
+ }
191
+ /** `reclaim`, and the deprecated `cleanup`, which runs it with `check`. */
192
+ async function runReclaimCommand(io, command, options) {
193
+ // The kill calls read E2B_API_KEY from the environment: the env file, then discovered keys.
194
+ if (!(await applyEnvFileOption({
195
+ command,
196
+ cwd: options.cwd,
197
+ envFile: dotenvPathOf(options, command, io),
198
+ io,
199
+ })))
200
+ return;
201
+ const hooks = { check: options.check === true };
202
+ const result = options.preflight
203
+ ? await reclaimPreflightSandboxes(options.cwd, hooks)
204
+ : await reclaimRunSandboxes(options.cwd, options.run, hooks);
205
+ writeResult(command, io, result, (value) => formatReclaimHuman(value, options.cwd, options.preflight === true));
206
+ io.setExitCode(result.ok ? 0 : 2);
185
207
  }
186
- function formatReclaimHuman(result) {
208
+ /** The command that finishes what a reclaim left open, with --cwd when it was given. */
209
+ function reclaimCommand(result, cwd, preflight) {
210
+ const target = preflight ? "--preflight" : `--run ${shellArg(result.runId)}`;
211
+ return `humanish reclaim ${target}${cwd === "." ? "" : ` --cwd ${shellArg(cwd)}`}`;
212
+ }
213
+ function formatReclaimHuman(result, cwd, preflight) {
187
214
  const lines = [];
188
- lines.push(`Reclaim ${result.runId}: ${result.ok ? "ok" : "failed"}, ${plural(result.receiptCount, "sandbox receipt")}.`);
215
+ const check = result.mode === "check";
216
+ const search = result.tagSearch.status === "done"
217
+ ? `E2B lists ${plural(result.tagSearch.found, "more sandbox", "more sandboxes")} tagged with it`
218
+ : `E2B tag search ${result.tagSearch.status}`;
219
+ lines.push(`${check ? "Reclaim check" : "Reclaim"} ${result.runId}: ${result.state}. ${plural(result.receiptCount, "sandbox receipt")}; ${search}.`);
189
220
  for (const outcome of result.outcomes) {
190
- lines.push(` ${outcome.sandboxId} (${outcome.laneId}): ${outcome.state}${outcome.detail ? ` — ${outcome.detail}` : ""}`);
221
+ lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
191
222
  }
192
223
  for (const warning of result.warnings)
193
224
  lines.push(` warning: ${warning}`);
194
- const stdout = lines.join("\n");
195
- return result.error === undefined ? stdout : { stdout: `${stdout}\n`, error: result.error };
225
+ if (result.error === undefined && result.state !== "clean")
226
+ lines.push(result.state === "running"
227
+ ? `To stop them, run ${reclaimCommand(result, cwd, preflight)}.`
228
+ : `Run ${reclaimCommand(result, cwd, preflight)} again once E2B is reachable; each sandbox's create-time timeout is the backstop.`);
229
+ const stdout = `${lines.join("\n")}\n`;
230
+ return result.error === undefined ? stdout : { stdout, error: result.error };
196
231
  }
197
232
  const REVIEW_VERDICTS = {
198
233
  // A dry run, or a live run with no participant result; review's result does not carry the mode.
@@ -202,7 +237,10 @@ const REVIEW_VERDICTS = {
202
237
  blocked: "blocked",
203
238
  timed_out: "timed out",
204
239
  };
205
- /** A run's review: its verdict, summary and gaps, and where review.json is. */
240
+ /**
241
+ * A run's review: its verdict, summary and gaps, its analysis findings, and where review.json is.
242
+ * A pass verdict on a run whose own ok is false leads with how the run ended (runDisplay), then why.
243
+ */
206
244
  function formatReviewHuman(result) {
207
245
  if (!("verdict" in result)) {
208
246
  // A run that did not pass verify has no review to show; verify says why.
@@ -219,12 +257,19 @@ function formatReviewHuman(result) {
219
257
  }
220
258
  : humanError(error);
221
259
  }
260
+ const verdict = REVIEW_VERDICTS[result.verdict];
261
+ const display = result.display;
262
+ const headline = result.verdict === "pass" && display !== undefined && display.state !== "passed"
263
+ ? `${display.label} (verdict pass)`
264
+ : verdict;
222
265
  return ([
223
- `humanish review ${result.runId}: ${REVIEW_VERDICTS[result.verdict]}`,
266
+ `humanish review ${result.runId}: ${headline}`,
267
+ ...(display?.reason === undefined ? [] : [`why: ${display.reason}`]),
224
268
  "",
225
269
  result.summary,
226
270
  ...(result.gaps.length === 0 ? [] : ["", "gaps:", ...result.gaps.map((gap) => `- ${gap}`)]),
227
271
  "",
272
+ ...formatFindings(result.analysis),
228
273
  `review: ${result.path}`,
229
274
  ].join("\n") + "\n");
230
275
  }
@@ -240,6 +285,12 @@ function shareSafetyLines(result) {
240
285
  .filter((reason) => reason.code !== "VERIFY_FAILED")
241
286
  .map((reason) => `share-safety: ${reason.code}: ${reason.message}`);
242
287
  }
288
+ /** For a run that is not finished, its liveness and sandbox state, so the line leads with them. */
289
+ function unfinishedNote(result) {
290
+ return result.unfinished === undefined
291
+ ? ""
292
+ : ` (${result.unfinished.liveness}, sandboxes ${result.unfinished.sandboxes})`;
293
+ }
243
294
  /**
244
295
  * One line for a pass. A failure lists only the failing checks. `--verbose` prints every check.
245
296
  * A run that does not exist prints only that.
@@ -248,12 +299,13 @@ function formatVerifyHuman(result) {
248
299
  if (result.error?.code === "HUMANISH_RUN_NOT_FOUND")
249
300
  return humanError(result.error);
250
301
  const runId = verifiedRunId(result);
302
+ const named = `${runId}${unfinishedNote(result)}`;
251
303
  const total = result.checks.length;
252
304
  const failed = result.checks.filter((check) => !check.ok);
253
305
  const lines = result.ok
254
- ? [`verified ${runId} · ${result.shareSafety.status} · ${total} checks passed`]
306
+ ? [`verified ${named} · ${result.shareSafety.status} · ${total} checks passed`]
255
307
  : [
256
- `verify failed: ${runId} · ${result.shareSafety.status} · ${failed.length} of ${total} checks failed`,
308
+ `verify failed: ${named} · ${result.shareSafety.status} · ${failed.length} of ${total} checks failed`,
257
309
  ...failed.map((check) => `- ${check.message}`),
258
310
  ];
259
311
  lines.push(...shareSafetyLines(result), ...result.warnings.map((warning) => `warning: ${warning}`));
@@ -267,43 +319,13 @@ function formatVerifyVerbose(result) {
267
319
  return humanError(result.error);
268
320
  return ([
269
321
  `humanish verify ${result.ok ? "passed" : "failed"}`,
270
- `run: ${verifiedRunId(result)}`,
322
+ `run: ${verifiedRunId(result)}${unfinishedNote(result)}`,
271
323
  `share-safety: ${result.shareSafety.status}`,
272
324
  ...result.shareSafety.reasons.map((reason) => `share-safety reason: ${reason.code}: ${reason.message}`),
273
325
  ...result.checks.map((check) => `- ${check.ok ? "ok" : "fail"} ${check.name}: ${check.message}`),
274
326
  ...result.warnings.map((warning) => `warning: ${warning}`),
275
327
  ].join("\n") + "\n");
276
328
  }
277
- /** A command-line argument, single-quoted only when the shell would split or expand it. */
278
- function shellArg(value) {
279
- return /^[\w@%+=:,./-]+$/.test(value) ? value : shellQuote(value);
280
- }
281
- /**
282
- * Cleanup only reads evidence and never stops a sandbox. A failed resource is an E2B sandbox not
283
- * recorded as stopped, which reclaim can stop from the run's sandbox receipts, so the last line
284
- * names it. A skipped resource is a provider reclaim does not handle, so it gets no line. `cwd` is
285
- * the --cwd the command was given, since cleanup's result masks it.
286
- */
287
- function reclaimPointer(result, cwd) {
288
- if (result.runId === undefined || result.summary.failed === 0)
289
- return [];
290
- const cwdFlag = cwd === "." ? "" : ` --cwd ${shellArg(cwd)}`;
291
- return [
292
- `To stop leftover sandboxes, run humanish reclaim --run ${shellArg(result.runId)}${cwdFlag}.`,
293
- ];
294
- }
295
- function formatCleanupHuman(result, cwd) {
296
- if (!result.ok && result.error)
297
- return humanError(result.error);
298
- return ([
299
- `humanish cleanup ${result.ok ? "passed" : "failed"}`,
300
- `run: ${result.runId ?? result.run}`,
301
- `resources: already-clean ${result.summary.alreadyClean}, skipped ${result.summary.skipped}, failed ${result.summary.failed}`,
302
- ...(result.cleanupPath ? [`cleanup: ${result.cleanupPath}`] : []),
303
- ...result.warnings.map((warning) => `warning: ${warning}`),
304
- ...reclaimPointer(result, cwd),
305
- ].join("\n") + "\n");
306
- }
307
329
  function formatRunsHuman(result) {
308
330
  if (!result.ok)
309
331
  return humanError(result.error);
@@ -312,6 +334,6 @@ function formatRunsHuman(result) {
312
334
  }
313
335
  return ([
314
336
  `latest: ${result.latest ?? "none"}`,
315
- ...result.runs.map((run) => `- ${run.runId} ${run.mode ?? "unknown"} ${run.createdAt ?? "unknown"} ${run.path}`),
337
+ ...result.runs.map((run) => `- ${run.runId} ${run.mode ?? "unknown"} ${run.display?.label ?? "unreadable"} ${run.createdAt ?? "unknown"} ${run.path}`),
316
338
  ].join("\n") + "\n");
317
339
  }
@@ -4,7 +4,7 @@ import { promptSecret } from "../secret-prompt.js";
4
4
  import { runInit } from "../../study/init.js";
5
5
  import { buildPayload, disabledByEnvironment, readTelemetryState, telemetryStatePath, writeTelemetryState, } from "../telemetry.js";
6
6
  import { doctor } from "../doctor.js";
7
- import { applyEnvFileOption, CLI_VERSION, CWD_OPTION_DESCRIPTION, ENV_FILE_OPTION_DESCRIPTION, JSON_OPTION_DESCRIPTION, markInvocationEnvelopeWritten, wantsJson, writeResult, formatCliError, } from "../io.js";
7
+ import { applyEnvFileOption, CLI_VERSION, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, markInvocationEnvelopeWritten, wantsJson, writeResult, formatCliError, } from "../io.js";
8
8
  import { plural } from "../../run/text.js";
9
9
  export function registerInitCommand(parent, io) {
10
10
  parent
@@ -51,13 +51,27 @@ export function registerDoctorCommand(parent, io) {
51
51
  .summary("Check what this project and machine need for a run.")
52
52
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
53
53
  .option("--study <study>", "Check the study's desktop, participant authentication and separate analysis requirements; no provider calls.")
54
- .option("--env-file <path>", ENV_FILE_OPTION_DESCRIPTION)
54
+ .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
55
+ .addOption(envFileAliasOption())
55
56
  .option("--json", JSON_OPTION_DESCRIPTION)
56
57
  .action(async (options, command) => {
57
- if (options.envFile &&
58
- !(await applyEnvFileOption({ command, cwd: options.cwd, envFile: options.envFile, io })))
58
+ const dotenv = dotenvPathOf(options, command, io);
59
+ let loaded;
60
+ if (dotenv &&
61
+ !(await applyEnvFileOption({
62
+ command,
63
+ cwd: options.cwd,
64
+ envFile: dotenv,
65
+ io,
66
+ onLoaded: (load) => {
67
+ loaded = load;
68
+ },
69
+ })))
59
70
  return;
60
- const result = await doctor(options.cwd, options.study ? { study: options.study } : {});
71
+ const result = await doctor(options.cwd, {
72
+ ...(options.study ? { study: options.study } : {}),
73
+ ...(loaded === undefined ? {} : { dotenv: loaded }),
74
+ });
61
75
  writeResult(command, io, result, formatDoctorHuman);
62
76
  // Behavioral change: was exit 1, every other structured command uses 2.
63
77
  io.setExitCode(result.ok ? 0 : 2);
@@ -15,6 +15,21 @@ function withError(error, lines) {
15
15
  const stdout = `${lines.join("\n")}\n`;
16
16
  return error === undefined ? stdout : { stdout, error };
17
17
  }
18
+ /** How a summary names a sandbox: by digest. The raw id is only in the run's receipts. */
19
+ function sandboxName(sandbox) {
20
+ return sandbox.sandboxIdDigest !== undefined
21
+ ? `[redacted-sandbox-id ${sandbox.sandboxIdDigest}]`
22
+ : sandbox.sandboxId;
23
+ }
24
+ /**
25
+ * One line per run naming the file that holds its raw sandbox ids, for the operator, when the
26
+ * result names a sandbox at all (each one carries a digest).
27
+ */
28
+ function sandboxIdsLine(result) {
29
+ return result.runId && JSON.stringify(result).includes('"sandboxIdDigest"')
30
+ ? [`sandbox ids: .humanish/runs/${result.runId}/sandbox-receipts.ndjson`]
31
+ : [];
32
+ }
18
33
  /** A participant's status in words: a dry run's placeholder status says that nothing ran live. */
19
34
  function participantStatus(status) {
20
35
  return status === "contract_proof_only" ? "dry run, nothing ran live" : status;
@@ -37,9 +52,10 @@ export function formatConcurrentSharedWorldStudyHuman(result) {
37
52
  ...result.roles.map((participant) => `persona ${participant.id} (${participant.persona}): ${participantStatus(participant.status)}${participant.session ? ` (${participant.session.completionReason})` : ""}${participant.ok ? "" : ", not ok"}`),
38
53
  ...(result.subjectSandbox
39
54
  ? [
40
- `subject sandbox: ${result.subjectSandbox.sandboxId} killed=${result.subjectSandbox.killed ? "yes" : "no"}`,
55
+ `subject sandbox: ${sandboxName(result.subjectSandbox)} killed=${result.subjectSandbox.killed ? "yes" : "no"}`,
41
56
  ]
42
57
  : []),
58
+ ...sandboxIdsLine(result),
43
59
  ...(result.observer?.observerPath ? [`observer: ${result.observer.observerPath}`] : []),
44
60
  ...(result.observer?.opened === undefined
45
61
  ? []
@@ -53,6 +69,10 @@ export function formatTerminalStudyHuman(result) {
53
69
  `run: ${result.runId}`,
54
70
  `actor: ${result.actor}`,
55
71
  `product: ${result.product}`,
72
+ ...(result.sandbox
73
+ ? [`sandbox: ${sandboxName(result.sandbox)} killed=${result.sandbox.killed ? "yes" : "no"}`]
74
+ : []),
75
+ ...sandboxIdsLine(result),
56
76
  ...(result.observer?.observerPath ? [`observer: ${result.observer.observerPath}`] : []),
57
77
  ...(result.observer?.opened === undefined
58
78
  ? []
@@ -72,6 +92,12 @@ export function formatScriptedStudyHuman(result) {
72
92
  ]
73
93
  : []),
74
94
  ...result.sessions.map((session) => `session ${session.surface}: ${session.status} (${session.completionReason}) · ${session.reason} [${session.screenshots} screenshot${session.screenshots === 1 ? "" : "s"}]`),
95
+ ...(result.subjectSandbox
96
+ ? [
97
+ `subject sandbox: ${sandboxName(result.subjectSandbox)} killed=${result.subjectSandbox.killed ? "yes" : "no"}`,
98
+ ]
99
+ : []),
100
+ ...sandboxIdsLine(result),
75
101
  ...(result.observer?.observerPath ? [`observer: ${result.observer.observerPath}`] : []),
76
102
  ...(result.observer?.opened === undefined
77
103
  ? []
@@ -105,9 +131,10 @@ export function formatCuaStudyHuman(result) {
105
131
  : []),
106
132
  ...(result.sandbox
107
133
  ? [
108
- `sandbox: ${result.sandbox.sandboxId} stream=${result.sandbox.streamUrlPresent ? "connected" : "missing"} killed=${result.sandbox.killed ? "yes" : "no"}`,
134
+ `sandbox: ${sandboxName(result.sandbox)} stream=${result.sandbox.streamUrlPresent ? "connected" : "missing"} killed=${result.sandbox.killed ? "yes" : "no"}`,
109
135
  ]
110
136
  : []),
137
+ ...sandboxIdsLine(result),
111
138
  ...(result.observer?.observerPath ? [`observer: ${result.observer.observerPath}`] : []),
112
139
  ...(result.observer?.opened === undefined
113
140
  ? []
@@ -9,6 +9,7 @@ import { cliAnalysisOptions } from "./analysis-signals.js";
9
9
  import { onRunShutdown } from "./run-signals.js";
10
10
  import { parseStudyCount, parseParticipantIds, parseObserverPort, wantsJson, writeResult, } from "../io.js";
11
11
  import { exposureRequestFromOptions, followObserver, planObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
12
+ import { writeRunFindings } from "../findings.js";
12
13
  import { formatCuaStudyHuman } from "./study-format.js";
13
14
  import { resolveRouteShouldOpen } from "./study-route-open.js";
14
15
  import { studyResultIdentity } from "../../run/study-result.js";
@@ -237,6 +238,7 @@ async function reportCuaRun(args, prepared, result, live) {
237
238
  }
238
239
  writeResult(args.command, args.io, output, formatCuaStudyHuman);
239
240
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
241
+ await writeRunFindings(args.command, args.io, args.options.cwd, result);
240
242
  if (server && (result.observer?.ok || attachedObserver)) {
241
243
  const followResult = output.observer?.ok
242
244
  ? output.observer
@@ -2,6 +2,7 @@ import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.
2
2
  import { Command } from "commander";
3
3
  import { cliAnalysisOptions } from "./analysis-signals.js";
4
4
  import { wantsJson, writeResult } from "../io.js";
5
+ import { writeRunFindings } from "../findings.js";
5
6
  import { showObserver } from "../observer-follow.js";
6
7
  import { formatScriptedStudyHuman } from "./study-format.js";
7
8
  import { observerOpen, resolveRouteShouldOpen, watchFinishedPlan } from "./study-route-open.js";
@@ -35,6 +36,7 @@ export function scriptedRouteRun(args) {
35
36
  const result = outcome.result;
36
37
  writeResult(args.command, args.io, result, formatScriptedStudyHuman);
37
38
  args.io.setExitCode(result.ok && automaticAnalysisSucceeded(result) ? 0 : 2);
39
+ await writeRunFindings(args.command, args.io, args.options.cwd, result);
38
40
  // Watch mode serves the Observer the route rendered through its finished run.
39
41
  if (finishedPlan !== undefined && result.ok && result.observer !== undefined) {
40
42
  await showObserver({