omp-conductor 0.13.0 → 0.15.0

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 (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
@@ -43,7 +43,7 @@ import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
44
  import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
45
45
  import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
46
- import type { readBaseChain as readBaseChainType } from "../gitops.ts";
46
+ import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
47
47
  import { releaseRefusal } from "../release-policy.ts";
48
48
  import { LIVE_STATES } from "../store.ts";
49
49
  import type {
@@ -61,6 +61,7 @@ import type {
61
61
  VerbName,
62
62
  VerbRefusal,
63
63
  } from "../types.ts";
64
+ import { prUrlParts } from "../tracker/github.ts";
64
65
  import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
65
66
  import {
66
67
  peerVerdict,
@@ -265,9 +266,9 @@ export interface ReleaseFacts {
265
266
  openPrs: number;
266
267
  /** Queue depth, or `undefined` when the tracker could not be read. */
267
268
  queueDepth: number | undefined;
268
- /** Newest observed verdict for the released routed repository. */
269
+ /** Current live-head workflow verdict for the released routed repository. */
269
270
  baseCheck?: RunRecord["baseCheck"];
270
- /** Evidence attached to a newest red verdict. */
271
+ /** Evidence attached to a current red verdict. */
271
272
  redBase?: string;
272
273
  }
273
274
 
@@ -637,6 +638,20 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
637
638
  .find((candidate) => candidate.prUrl === prUrl);
638
639
  }
639
640
 
641
+ /**
642
+ * Whether a PR URL names a repository this project routes.
643
+ *
644
+ * Orchestrator status / update-branch may act on open project PRs that no run
645
+ * opened (workflow-generated release pins, etc.). Scope is the routed clone
646
+ * slugs — not "any PR a run once recorded".
647
+ */
648
+ function prInProjectRouting(project: ProjectConfig, prUrl: string): boolean {
649
+ const parts = prUrlParts(prUrl);
650
+ if (parts === undefined) return false;
651
+ const slug = `${parts.owner}/${parts.repo}`;
652
+ return Object.values(project.routing.repos).some((repo) => repoSlugFor(repo) === slug);
653
+ }
654
+
640
655
  /** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
641
656
  function orchestratorPauseRefusal(
642
657
  deps: VerbDeps,
@@ -688,8 +703,12 @@ async function prUpdateBranchVerb(
688
703
  target = own;
689
704
  } else {
690
705
  target = runForPr(deps, project.name, prUrl);
691
- if (target === undefined) {
692
- return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
706
+ if (target === undefined && !prInProjectRouting(project, prUrl)) {
707
+ return refuse(
708
+ "pr-not-this-run",
709
+ `refused: ${prUrl} is not a pull request in ${project.name}'s routed repositories ` +
710
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
711
+ );
693
712
  }
694
713
  }
695
714
 
@@ -709,15 +728,15 @@ async function prUpdateBranchVerb(
709
728
  state === undefined
710
729
  ? `refused: the tracker could not say whether ${prUrl} is open. Refusing rather than acting on an unknown state.`
711
730
  : `refused: ${prUrl} is ${state}, not open.`,
712
- target.issue,
731
+ target?.issue,
713
732
  );
714
733
  }
715
734
 
716
735
  const outcome = await deps.actions.updatePrBranch(prUrl);
717
736
  if (!outcome.ok) {
718
- return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target.issue);
737
+ return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target?.issue);
719
738
  }
720
- return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target.issue);
739
+ return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target?.issue);
721
740
  }
722
741
 
723
742
  async function prMergeVerb(
@@ -978,18 +997,17 @@ async function releaseVerb(
978
997
  }
979
998
  }
980
999
  const wantsBase = policy.release.requires.includes("base-branch-green");
981
- const latestBase = wantsBase
982
- ? deps.store.latestBaseChecks(project.name, 0).find((run) => run.repo === repoName)
1000
+ const health = wantsBase
1001
+ ? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
983
1002
  : undefined;
984
- const redBase = latestBase?.settlementFlags?.find(
985
- (flag) => flag.kind === "base-branch-red",
986
- )?.detail;
987
1003
  const unmet = releaseRequirementRefusal(policy.release.requires, {
988
1004
  unsettledRuns: active.length,
989
1005
  openPrs: active.filter((r) => r.prUrl !== undefined).length,
990
1006
  queueDepth,
991
- ...(latestBase?.baseCheck === undefined ? {} : { baseCheck: latestBase.baseCheck }),
992
- ...(redBase === undefined ? {} : { redBase }),
1007
+ ...(health === undefined ? {} : { baseCheck: health.verdict }),
1008
+ ...(health?.verdict === "red" && health.detail !== undefined
1009
+ ? { redBase: health.detail }
1010
+ : {}),
993
1011
  });
994
1012
  if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
995
1013
 
@@ -1062,11 +1080,15 @@ async function prStatusVerb(
1062
1080
  return refuse("malformed-argument", "refused: conductor_pr_status needs prUrl when it has no run to infer one from.");
1063
1081
  }
1064
1082
  const target = runForPr(deps, project.name, asked);
1065
- if (target === undefined) {
1066
- return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
1083
+ if (target === undefined && !prInProjectRouting(project, asked)) {
1084
+ return refuse(
1085
+ "pr-not-this-run",
1086
+ `refused: ${asked} is not a pull request in ${project.name}'s routed repositories ` +
1087
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
1088
+ );
1067
1089
  }
1068
1090
  prUrl = asked;
1069
- issue = target.issue;
1091
+ issue = target?.issue;
1070
1092
  }
1071
1093
 
1072
1094
  const askedHead = args["headSha"];
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The presentation surface the setup wizard ({@link setup-wizard.ts}) codes
3
+ * against. Nothing about it is terminal-specific: any driver that satisfies this
4
+ * exact shape can drive the wizard, and today that driver is the terminal
5
+ * `omp-conductor setup` verb (#309 deleted the in-session dialog).
6
+ *
7
+ * Three of the four methods are prompt-bearing. The load-bearing contracts,
8
+ * each of which the wizard already relies on and a future refactor would break
9
+ * silently:
10
+ *
11
+ * - `input` resolves the placeholder on an empty submit ("Enter accepts what
12
+ * you see"), so a re-run that Enters through a prompt re-affirms the shown
13
+ * default instead of blanking it.
14
+ * - `select` resolves the chosen option's **label**, never its index — the
15
+ * label *is* the config value for the closed-vocabulary questions, so a
16
+ * lookup table that disagreed with the vocabulary it was built from would
17
+ * corrupt the written config.
18
+ * - `select` honours `dialogOptions.initialIndex` so a bare Enter re-affirms
19
+ * the current setting (see the "cannot be a confirm" note in the wizard).
20
+ * - `confirm` is `y/N`, defaulting to no.
21
+ *
22
+ * Every prompt resolves `undefined` when the operator dismisses it (Ctrl-C /
23
+ * EOF), which every wizard caller already treats as the cancel path
24
+ * (`Cancelled`). The terminal UI guarantees the readline interface is closed on
25
+ * every exit path so the verb cannot hang the terminal.
26
+ */
27
+ export interface WizardUi {
28
+ notify(message: string, type?: "info" | "warning" | "error"): void;
29
+ /**
30
+ * Yes/no. `undefined` means the operator *dismissed* the prompt rather than
31
+ * answering it, which is a different thing from "no": the wizard turns it into
32
+ * `Cancelled` and abandons the run. Collapsing the two is how Ctrl-C at a
33
+ * confirm used to record a silent "no" and carry on to the next question.
34
+ */
35
+ confirm(title: string, message: string): Promise<boolean | undefined>;
36
+ /** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
37
+ * the placeholder. */
38
+ input(title: string, placeholder?: string): Promise<string | undefined>;
39
+ /** Numbered single-choice list. Resolves the chosen option's label, or
40
+ * `undefined` when dismissed. */
41
+ select(
42
+ title: string,
43
+ options: { label: string; description?: string }[],
44
+ dialogOptions?: { initialIndex?: number },
45
+ ): Promise<string | undefined>;
46
+ }
47
+
48
+ import { createInterface, type Interface } from "node:readline/promises";
49
+ import { stdin, stdout } from "node:process";
50
+ import type { Readable, Writable } from "node:stream";
51
+
52
+ /**
53
+ * A `WizardUi` over a real terminal — or a pipe, which is how the verb is
54
+ * scripted in tests — backed by `node:readline/promises`.
55
+ *
56
+ * **One interface for the whole wizard, closed once.** The first version of this
57
+ * created and closed a readline interface per prompt, on the theory that
58
+ * releasing stdin after each question could not leak. It cannot: closing a
59
+ * readline interface ends the shared stdin stream, so the *second* prompt of a
60
+ * piped run had no readable input left and the run hung indefinitely — the exact
61
+ * failure this comment used to claim it prevented. The interface therefore lives
62
+ * as long as the wizard, and {@link TerminalUi.close} releases it once, from the
63
+ * caller's `finally`.
64
+ *
65
+ * Dismissal is the interface's own `close`: `rl.question()` on an exhausted
66
+ * stream never settles on its own, so EOF (a script that ran out of answers) and
67
+ * Ctrl-C both close the interface, and the line reader turns that into the
68
+ * `undefined` every wizard caller reads as cancel.
69
+ *
70
+ * `io` exists so the contracts below can be tested in-process against a pipe
71
+ * rather than only through a spawned CLI; production callers pass nothing.
72
+ */
73
+ export interface TerminalUi extends WizardUi {
74
+ /** Releases stdin. Idempotent; call it from a `finally`. */
75
+ close(): void;
76
+ }
77
+
78
+ export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
79
+ const input = io.input ?? stdin;
80
+ const output = io.output ?? stdout;
81
+ const rl: Interface = createInterface({ input, output });
82
+ let closed = false;
83
+ const close = (): void => {
84
+ if (closed) return;
85
+ closed = true;
86
+ rl.close();
87
+ };
88
+ // Ctrl-C is a cancel, not a signal to the whole process: closing the interface
89
+ // makes every pending and subsequent prompt resolve `undefined`, which the
90
+ // wizard already turns into `Cancelled`.
91
+ rl.once("SIGINT", close);
92
+
93
+ // Lines are buffered as they arrive rather than read on demand, because on a
94
+ // non-TTY stdin readline drains the whole pipe at once and emits `line` for
95
+ // every one of them immediately. `rl.question()` only captures the line that
96
+ // arrives *after* it is called, so a scripted run lost every answer that had
97
+ // already flown past and then hit EOF — three prompts resolving `undefined`
98
+ // and a wizard that cancelled itself. Buffering makes a pipe and a terminal
99
+ // behave the same: nothing is read before it is asked for, nothing is lost.
100
+ const buffered: string[] = [];
101
+ let waiting: ((value: string | undefined) => void) | undefined;
102
+ let ended = false;
103
+ const deliver = (value: string | undefined): boolean => {
104
+ const resolve = waiting;
105
+ if (resolve === undefined) return false;
106
+ waiting = undefined;
107
+ resolve(value);
108
+ return true;
109
+ };
110
+ rl.on("line", (text: string) => {
111
+ if (!deliver(text)) buffered.push(text);
112
+ });
113
+ rl.once("close", () => {
114
+ ended = true;
115
+ deliver(undefined);
116
+ });
117
+
118
+ /** One line, or `undefined` when the operator dismissed the prompt. */
119
+ const line = async (query: string): Promise<string | undefined> => {
120
+ if (closed && buffered.length === 0) return undefined;
121
+ output.write(query);
122
+ const ready = buffered.shift();
123
+ if (ready !== undefined) return ready;
124
+ if (ended) return undefined;
125
+ return await new Promise<string | undefined>((resolve) => {
126
+ waiting = resolve;
127
+ });
128
+ };
129
+
130
+ return {
131
+ close,
132
+ notify(message) {
133
+ output.write(`${message}\n`);
134
+ },
135
+ async confirm(title, message) {
136
+ for (;;) {
137
+ const answer = await line(`${title} — ${message} (y/N) `);
138
+ // Dismissal is NOT "no". Ctrl-C or EOF here means the operator left, and
139
+ // reporting `false` made the interview march on to the next question with
140
+ // a silent "no" recorded — so `Ctrl-C abandons the run` was untrue for
141
+ // every yes/no prompt. The wizard turns this into `Cancelled`.
142
+ if (answer === undefined) return undefined;
143
+ const a = answer.trim().toLowerCase();
144
+ if (a === "y" || a === "yes") return true;
145
+ // Enter is still the safe answer: no, and the interview continues.
146
+ if (a === "n" || a === "no" || a === "") return false;
147
+ output.write("Please answer y or n.\n");
148
+ }
149
+ },
150
+ async input(title, placeholder) {
151
+ const query =
152
+ placeholder !== undefined && placeholder.length > 0 ? `${title} [${placeholder}]: ` : `${title}: `;
153
+ const answer = await line(query);
154
+ if (answer === undefined) return undefined;
155
+ // "Enter accepts what you see": an empty submit keeps the placeholder.
156
+ return answer.trim().length === 0 ? (placeholder ?? "") : answer;
157
+ },
158
+ async select(title, options, dialogOptions) {
159
+ output.write(`${title}\n`);
160
+ options.forEach((o, i) => {
161
+ output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`);
162
+ });
163
+ const initial = dialogOptions?.initialIndex ?? 0;
164
+ const defaultLabel = options[initial]?.label ?? "";
165
+ for (;;) {
166
+ const answer = await line(
167
+ `Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
168
+ );
169
+ if (answer === undefined) return undefined;
170
+ const a = answer.trim();
171
+ if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
172
+ const n = Number(a);
173
+ if (Number.isInteger(n)) {
174
+ const picked = options[n - 1];
175
+ if (picked !== undefined) return picked.label;
176
+ }
177
+ output.write(`Please enter a number between 1 and ${options.length}.\n`);
178
+ }
179
+ },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The test seam: a `WizardUi` driven by a script keyed by a substring of each
185
+ * prompt's title.
186
+ *
187
+ * **Anything unscripted takes the answer an operator gets by pressing Enter** —
188
+ * an empty `input` (which the wizard reads as "accept the shown default"), `no`
189
+ * on a `confirm`, and the row the caller put the cursor on for a `select`. That
190
+ * leniency is deliberate and is itself the thing under test: "Enter accepts what
191
+ * you see" is the contract the whole wizard is built on, so a test that has to
192
+ * script an answer is a test whose default moved. Throwing on an unscripted
193
+ * prompt would make that property untestable.
194
+ *
195
+ * Asking an *unintended* question is caught a stronger way instead: the suite
196
+ * pins the full ordered list of prompts, so a question that appears or vanishes
197
+ * fails on the transcript rather than on a missing script entry.
198
+ *
199
+ * - `input`: the scripted string, else `""` (Enter → the wizard's fallback).
200
+ * - `confirm`: the scripted boolean, else `false`. `null` dismisses the prompt
201
+ * (`undefined`), which the wizard reads as cancel — the same spelling
202
+ * `select` uses.
203
+ * - `select`: a number picks that option's **label**; a string matches an
204
+ * offered label by prefix; `null` dismisses the prompt (`undefined`, which
205
+ * the wizard reads as cancel); unscripted takes `initialIndex`'s label.
206
+ * - An array answers successive prompts sharing the same title; the last entry
207
+ * repeats.
208
+ */
209
+ export interface ScriptedAnswers {
210
+ input?: Record<string, string>;
211
+ confirm?: Record<string, boolean | boolean[] | null>;
212
+ select?: Record<string, string | number | null>;
213
+ }
214
+
215
+ export function scriptedUi(script: ScriptedAnswers = {}): WizardUi {
216
+ const seen = new Map<string, number>();
217
+ const answer = <T>(table: Record<string, T> | undefined, title: string): T | undefined => {
218
+ for (const [key, value] of Object.entries(table ?? {})) {
219
+ if (!title.includes(key)) continue;
220
+ if (!Array.isArray(value)) return value;
221
+ const n = seen.get(key) ?? 0;
222
+ seen.set(key, n + 1);
223
+ return (value[Math.min(n, value.length - 1)] ?? value.at(-1)) as T;
224
+ }
225
+ return undefined;
226
+ };
227
+
228
+ return {
229
+ notify() {
230
+ /* recorded by the caller's wrapper when a test asserts on notices */
231
+ },
232
+ async confirm(title) {
233
+ const scripted = answer(script.confirm, title);
234
+ if (scripted === null) return undefined;
235
+ return Array.isArray(scripted) ? false : (scripted ?? false);
236
+ },
237
+ async input(title) {
238
+ return answer(script.input, title) ?? "";
239
+ },
240
+ async select(title, options, dialogOptions) {
241
+ const chosen = answer(script.select, title);
242
+ if (chosen === null) return undefined;
243
+ if (typeof chosen === "number") return options[chosen]?.label;
244
+ if (typeof chosen === "string") return options.find((o) => o.label.startsWith(chosen))?.label ?? chosen;
245
+ // Enter: the row the caller put the cursor on.
246
+ return options[dialogOptions?.initialIndex ?? 0]?.label;
247
+ },
248
+ };
249
+ }
package/src/worker.ts CHANGED
@@ -22,6 +22,8 @@ const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
22
22
 
23
23
  /** Any explicit verdict line, whatever it claims. */
24
24
  const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
25
+ /** GitHub PR URLs in unstructured prose; capture their canonical `owner/repo`. */
26
+ const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/\d+\b/gi;
25
27
 
26
28
  /** `{{KEY}}` placeholders in a brief template. */
27
29
  const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
@@ -63,6 +65,8 @@ export interface WorkerOpts {
63
65
  brief: string;
64
66
  cwd: string;
65
67
  caps: Caps;
68
+ /** Canonical `owner/repo` identity used to scope prose-only PR URLs. */
69
+ repoSlug?: string;
66
70
  /**
67
71
  * Directory the harness writes this run's transcript into — a directory, not
68
72
  * a file. The SDK takes no `sessionFile` input, so naming a path here would
@@ -179,15 +183,23 @@ export function renderBrief(template: string, vars: Record<string, string>): str
179
183
  * asks the tracker to verify those facts independently. Missing or malformed
180
184
  * evidence fails closed.
181
185
  */
182
- export function deriveResult(report: string): {
186
+ export function deriveResult(report: string, repoSlug?: string): {
183
187
  state: RunState;
184
188
  prUrl?: string;
185
189
  headSha?: string;
186
190
  } {
187
- const prUrl = PR_URL_PATTERN.exec(report)?.[1];
191
+ const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
188
192
  const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
189
- if (PUSHED_GREEN_PATTERN.test(report) && prUrl !== undefined && headSha !== undefined) {
190
- return { state: "pushed-green", prUrl, headSha };
193
+ if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
194
+ return { state: "pushed-green", prUrl: structuredPrUrl, headSha };
195
+ }
196
+
197
+ let prUrl = structuredPrUrl;
198
+ if (prUrl === undefined && repoSlug !== undefined) {
199
+ const expectedRepo = repoSlug.toLowerCase();
200
+ for (const match of report.matchAll(GITHUB_PR_URL_PATTERN)) {
201
+ if (match[1]?.toLowerCase() === expectedRepo) prUrl = match[0];
202
+ }
191
203
  }
192
204
 
193
205
  const state: RunState = BLOCKED_PATTERN.test(report) ? "blocked" : "failed";
@@ -424,7 +436,7 @@ export async function runWorker(
424
436
  const text = reportText(field(message, "content"));
425
437
  if (text !== "") {
426
438
  report = text;
427
- const stated = deriveResult(text);
439
+ const stated = deriveResult(text, o.repoSlug);
428
440
  if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
429
441
  claim = { prUrl: stated.prUrl, headSha: stated.headSha };
430
442
  }
@@ -554,7 +566,7 @@ export async function runWorker(
554
566
  return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
555
567
  }
556
568
  return withSessionFacts({
557
- ...deriveResult(report),
569
+ ...deriveResult(report, o.repoSlug),
558
570
  turns,
559
571
  spendUsd,
560
572
  report,
@@ -597,7 +609,12 @@ function field(source: unknown, key: string): unknown {
597
609
  }
598
610
 
599
611
  /** Flatten an assistant message's content blocks to their plain text. */
600
- function reportText(content: unknown): string {
612
+ /**
613
+ * The newest assistant text, flattened out of whatever block shape the harness
614
+ * sent. Exported for ./setup-probe.ts, which collects a probe's answer the same
615
+ * way a worker's report is collected — one spelling, so the two cannot drift.
616
+ */
617
+ export function reportText(content: unknown): string {
601
618
  if (typeof content === "string") return content.trim();
602
619
  const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
603
620
  const parts: string[] = [];