omp-conductor 0.19.7 → 0.20.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 (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
package/src/worker.ts CHANGED
@@ -12,8 +12,10 @@
12
12
 
13
13
  import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
+ import { knowledgeSection } from "./knowledge.ts";
15
16
  import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
16
17
  import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
18
+ import { gateToSpecPayload, TO_SPEC_SCHEMA, type ToSpecFailure } from "./to-spec.ts";
17
19
  import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
18
20
 
19
21
  /** Structured evidence fields from the worker's final report. */
@@ -53,6 +55,17 @@ export interface WorkerSettlement {
53
55
  blockers?: string[];
54
56
  /** Commands run as evidence, exactly as executed. */
55
57
  proof?: string[];
58
+ /**
59
+ * Durable facts about the REPOSITORY this run learned — where a subsystem is
60
+ * wired, which command actually proves a change, a test that looks like it
61
+ * asserts something and does not (Phase 3 knowledge overlay). Appended to the
62
+ * fleet's per-repo overlay at settlement and shown to every later run in the
63
+ * same repo, which is why they must be about the repo and not about this
64
+ * task: "I fixed the parser" helps nobody, "the parser's only caller is
65
+ * cli.ts:route" saves the next run a search. Optional, and every settlement
66
+ * that predates it stays valid.
67
+ */
68
+ discoveries?: string[];
56
69
  }
57
70
 
58
71
  /**
@@ -98,6 +111,16 @@ export const WORKER_SETTLEMENT_SCHEMA = {
98
111
  items: { type: "string" },
99
112
  description: "The commands you ran as evidence, exactly as executed.",
100
113
  },
114
+ discoveries: {
115
+ type: "array",
116
+ items: { type: "string" },
117
+ description:
118
+ "Optional. Durable facts about THIS REPOSITORY that cost you turns to find and would cost " +
119
+ "the next run the same: where a subsystem is wired, which command actually proves a change, " +
120
+ "a test that looks like it asserts something and does not. Not a summary of your task — " +
121
+ "these are shown to every later run in this repo, so leave the field out rather than " +
122
+ "filling it with what you did.",
123
+ },
101
124
  },
102
125
  } as const;
103
126
 
@@ -172,6 +195,219 @@ export function renderReviewRevisionPrompt(findings: string, round: number): str
172
195
  return `${REVIEW_REVISION_PROMPT}\n\nReview round ${round} — blocking findings:\n\n${findings}`;
173
196
  }
174
197
 
198
+ /**
199
+ * The template one fresh correction's brief is rendered from.
200
+ *
201
+ * Read per correction rather than cached, exactly like the dispatcher's own
202
+ * `buildBrief`: editing the brief then takes effect on the next correction
203
+ * instead of needing a daemon restart.
204
+ */
205
+ const CORRECTION_BRIEF_PATH = join(import.meta.dir, "briefs", "correction.md");
206
+
207
+ /**
208
+ * How much of the diff one fresh correction brief carries (#1046).
209
+ *
210
+ * The same bound, and the same reasoning, as {@link ADJUDICATION_DIFF_LIMIT}: a
211
+ * correction needs the change it is correcting, not every generated line, and an
212
+ * enormous PR must not crowd out the findings and criteria that follow it. The
213
+ * marker says exactly what was withheld, so the session reads the rest off the
214
+ * branch it already has checked out rather than assuming it saw everything.
215
+ */
216
+ export const CORRECTION_DIFF_LIMIT = 60_000;
217
+
218
+ /**
219
+ * Everything one fresh correction is told (#1046).
220
+ *
221
+ * Assembled by the caller from durable rows and live tracker reads, never from a
222
+ * worker's own claims — and, unlike a resumed revision, this is ALL the session
223
+ * gets. The exhausted implementation transcript is not replayed, so a field
224
+ * missing here is context the correction simply does not have.
225
+ */
226
+ export interface CorrectionEvidence {
227
+ /** Canonical `owner/repo` of the code repo, or the routing name when the
228
+ * clone URL yields none. Also the key the knowledge overlay is read under. */
229
+ repo: string;
230
+ issue: number;
231
+ issueTitle?: string;
232
+ /** The issue's acceptance criteria, verbatim. */
233
+ issueBody: string;
234
+ /** The pull request this correction must push onto. Never a second PR. */
235
+ prUrl: string;
236
+ /** The exact head the findings were written against. */
237
+ headSha: string;
238
+ /** The branch and worktree that already exist for this run. */
239
+ branch: string;
240
+ worktree: string;
241
+ /** Live check state at that head, as the tracker reported it. */
242
+ checks: string;
243
+ /** The diff at that head, bounded by {@link CORRECTION_DIFF_LIMIT}. */
244
+ diff: string;
245
+ round: number;
246
+ /** The review-round ceiling, when the caller knows it. */
247
+ maxRounds?: number;
248
+ /** This round's blocking findings, delivered verbatim. */
249
+ findings: string;
250
+ /** Earlier rounds and what became of them. A finding without its outcome
251
+ * reads as an outstanding complaint even when it was fixed, which is how a
252
+ * correction session re-does work that already landed. */
253
+ priorRounds: readonly AdjudicationRound[];
254
+ /**
255
+ * Why this round is a fresh session rather than a resume, in plain words —
256
+ * "the implementation session was killed by the turns cap", "the fleet's
257
+ * worker model changed after the rejected run". It goes in the brief because a
258
+ * session that does not know its transcript is gone will look for it: #1035's
259
+ * whole failure was correction rounds spent replaying an exhausted session,
260
+ * and a session told nothing behaves as if it were still that one.
261
+ */
262
+ reason: string;
263
+ /** The gate commands this correction must run before pushing. */
264
+ proofCommands: readonly string[];
265
+ /**
266
+ * The fleet's accumulated repo knowledge, pre-rendered by
267
+ * {@link knowledgeSection}. Omitted, it is read here from {@link repo} — the
268
+ * overlay exists to reach every session launched against a repo, so it must
269
+ * not depend on each caller remembering to pass it. Pass `""` to render none.
270
+ */
271
+ knowledge?: string;
272
+ }
273
+
274
+ /**
275
+ * The full opening prompt for one fresh correction round (#1046).
276
+ *
277
+ * Exported so a test can pin the exact payload without standing up a session:
278
+ * the brief IS the deliverable here. `REVIEW_REVISION_PROMPT` can be three
279
+ * sentences because the session it prompts already holds the issue, the plan and
280
+ * the diff; this one holds nothing, so every one of those facts has to arrive in
281
+ * the text — and a test that only asserted "a session was launched" would pass on
282
+ * an empty brief.
283
+ */
284
+ export function renderCorrectionBrief(evidence: CorrectionEvidence): string {
285
+ const template = readFileSync(CORRECTION_BRIEF_PATH, "utf8");
286
+ const rounds =
287
+ evidence.priorRounds.length === 0
288
+ ? "_(none recorded — this is the first review round of this run)_"
289
+ : evidence.priorRounds
290
+ .map((round) => `Round ${String(round.round)} (${round.outcome ?? "never settled"}):\n${round.findings.trim()}`)
291
+ .join("\n\n");
292
+ const diff =
293
+ evidence.diff.length <= CORRECTION_DIFF_LIMIT
294
+ ? evidence.diff
295
+ : `${evidence.diff.slice(0, CORRECTION_DIFF_LIMIT)}\n` +
296
+ `[diff truncated at ${String(CORRECTION_DIFF_LIMIT)} of ${String(evidence.diff.length)} characters — ` +
297
+ "read the remainder from your own checkout with `git diff origin/HEAD...HEAD`]";
298
+ const proof =
299
+ evidence.proofCommands.length === 0
300
+ ? "_The project names no gate commands. Run the repo's own tests and say exactly what you ran._"
301
+ : ["```sh", ...evidence.proofCommands, "```"].join("\n");
302
+ // The overlay's own trailing blank line separates it from the heading that
303
+ // follows; an absent overlay renders nothing at all, so the brief closes up
304
+ // around it rather than leaving an empty section behind.
305
+ const knowledge = evidence.knowledge ?? knowledgeSection(evidence.repo);
306
+ return renderBrief(template, {
307
+ ISSUE_NUMBER: String(evidence.issue),
308
+ ISSUE_TITLE: evidence.issueTitle ?? "(title unavailable at dispatch)",
309
+ REPO: evidence.repo,
310
+ PR_URL: evidence.prUrl,
311
+ HEAD_SHA: evidence.headSha,
312
+ CHECKS: evidence.checks,
313
+ WORKTREE: evidence.worktree,
314
+ BRANCH: evidence.branch,
315
+ ROUND:
316
+ evidence.maxRounds === undefined
317
+ ? String(evidence.round)
318
+ : `${String(evidence.round)} of ${String(evidence.maxRounds)} allowed`,
319
+ REASON: evidence.reason,
320
+ FINDINGS: evidence.findings.trim(),
321
+ ACCEPTANCE_CRITERIA: evidence.issueBody.trim(),
322
+ PRIOR_ROUNDS: rounds,
323
+ DIFF: diff,
324
+ PROOF_COMMANDS: proof,
325
+ KNOWLEDGE: knowledge,
326
+ });
327
+ }
328
+
329
+ /**
330
+ * What one fresh correction is launched with (#1046).
331
+ *
332
+ * Deliberately {@link WorkerOpts} minus two keys. `brief` is gone because this
333
+ * transport renders its own from `briefs/correction.md` — a caller that could
334
+ * pass a brief could pass the findings-only resume prompt, which is the failure
335
+ * this exists to end. `resume` is gone because it is not a decision available
336
+ * here: a "fresh correction" that resumes is #1046's named silent fake, and the
337
+ * cheapest way to make it unavailable is to make it inexpressible.
338
+ *
339
+ * Everything else — worktree, caps, sockets, grants, meters, pause control — is
340
+ * inherited unchanged, because a correction is an ordinary mutable worker run on
341
+ * an existing branch. The caller owns the run's identity and passes it through
342
+ * here: this transport creates no worktree, opens no pull request, and derives no
343
+ * issue or run of its own.
344
+ */
345
+ export interface CorrectionWorkerOpts extends Omit<WorkerOpts, "brief" | "resume"> {
346
+ /** The evidence the brief is rendered from. */
347
+ evidence: CorrectionEvidence;
348
+ /**
349
+ * The transcript of the session this correction replaces, when the caller
350
+ * recorded one. Used for exactly one thing: proving the lineage actually
351
+ * changed (see {@link CorrectionResult.freshSession}).
352
+ */
353
+ originSessionRef?: string;
354
+ }
355
+
356
+ /** What one fresh correction produced (#1046). */
357
+ export interface CorrectionResult extends WorkerResult {
358
+ /**
359
+ * The transcript this correction opened — the lineage #1045 persists as
360
+ * `correctionSessionRef`. Absent when the session opened none.
361
+ */
362
+ sessionRef?: string;
363
+ /**
364
+ * Whether this really was a new session. False when the harness handed back
365
+ * {@link CorrectionWorkerOpts.originSessionRef} — the exact silent fake #1046
366
+ * names, a new model string over the old session file — and false when no
367
+ * transcript was opened at all, because neither case proves a fresh lineage.
368
+ * A caller must not record a fresh-correction provenance it did not get.
369
+ */
370
+ freshSession: boolean;
371
+ }
372
+
373
+ /**
374
+ * Launch one fresh correction on an existing branch, worktree and pull request
375
+ * (#1046).
376
+ *
377
+ * The failure this ends (#1035): a capped implementation session kept receiving
378
+ * correction rounds by `resume: true` after it had exhausted its turns and after
379
+ * the fleet had switched worker models, so every round was spent replaying a dead
380
+ * transcript under a model that was no longer configured.
381
+ *
382
+ * So this composes {@link runWorker} — the same session primitive, the same caps,
383
+ * the same verb surface — with exactly two things different: the brief is
384
+ * self-contained ({@link renderCorrectionBrief}) and `resume` is never passed, so
385
+ * the harness opens a new transcript under whatever model the caller resolved.
386
+ * A second worker runtime would be a second set of cap, settlement and pause
387
+ * semantics to keep in sync, which is how the two paths drift apart.
388
+ *
389
+ * What it deliberately does NOT do: nothing here touches the run's identity. The
390
+ * branch, worktree, pull request, issue, run id and verb channel all arrive from
391
+ * the caller and leave unchanged, so a correction cannot open a second PR, create
392
+ * a second worktree, or start a new attempt against the issue.
393
+ */
394
+ export async function runCorrectionWorker(
395
+ o: CorrectionWorkerOpts,
396
+ deps: RunWorkerDeps = { createSession },
397
+ ): Promise<CorrectionResult> {
398
+ const { evidence, originSessionRef, ...worker } = o;
399
+ // No `resume` key exists on `worker` to forward — the type forbids one, so
400
+ // `runWorker` takes its fresh-session path and the harness opens a new
401
+ // transcript in the run's own session directory.
402
+ const result = await runWorker({ ...worker, brief: renderCorrectionBrief(evidence) }, deps);
403
+ const sessionRef = result.sessionFile;
404
+ return {
405
+ ...result,
406
+ ...(sessionRef === undefined ? {} : { sessionRef }),
407
+ freshSession: sessionRef !== undefined && sessionRef !== originSessionRef,
408
+ };
409
+ }
410
+
175
411
 
176
412
  /**
177
413
  * What the review-ceiling adjudicator is told (#877).
@@ -508,6 +744,19 @@ export interface WorkerResult {
508
744
  * "the tool was missing" apart.
509
745
  */
510
746
  graphTools?: GraphToolsObservation;
747
+ /**
748
+ * The repo facts this run reported in its structured settlement
749
+ * ({@link WorkerSettlement.discoveries}), verbatim and unjudged — the
750
+ * daemon appends them to the repo's knowledge overlay at settlement.
751
+ *
752
+ * Surfaced here rather than parsed back out of {@link report}: the rendered
753
+ * report is prose for a human, and re-deriving a list from it would be a
754
+ * second, weaker contract beside the schema the worker actually answered.
755
+ * Absent whenever the run yielded no structured settlement or named nothing
756
+ * — never an empty array, which would read as "the fleet learned nothing"
757
+ * rather than "the worker was not asked".
758
+ */
759
+ discoveries?: string[];
511
760
  }
512
761
 
513
762
  /**
@@ -619,7 +868,7 @@ export function structuredSettlement(content: unknown): WorkerSettlement | undef
619
868
  const value = record[key];
620
869
  if (typeof value === "string" && value !== "") settlement[key] = value;
621
870
  }
622
- for (const key of ["blockers", "proof"] as const) {
871
+ for (const key of ["blockers", "proof", "discoveries"] as const) {
623
872
  const value = record[key];
624
873
  if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
625
874
  settlement[key] = value;
@@ -807,6 +1056,313 @@ export async function runAdjudicator(
807
1056
  };
808
1057
  }
809
1058
 
1059
+ /**
1060
+ * The highest turn count any to-spec pass that finished used, measured across
1061
+ * both projects' grooming ledger on 2026-08-25 (#1064): of the fourteen
1062
+ * groom sessions ever run, the eight that finished verdicts used 10–19 turns
1063
+ * (BLOCKED ×6, PROMOTABLE ×1, NEEDS DECOMPOSITION ×1), while every session
1064
+ * killed at the ceiling was 21+ turns in when the abort fired. The ceiling
1065
+ * and its measurement live here, in one place: the ceiling is the measured
1066
+ * max plus one turn of headroom.
1067
+ */
1068
+ const TO_SPEC_MEASURED_FINISHED_MAX_TURNS = 19;
1069
+
1070
+ /**
1071
+ * The turn ceiling for one to-spec grooming scout (#1041, #1064).
1072
+ *
1073
+ * Half the adjudicator's 40, and for the opposite reason: an adjudication is
1074
+ * handed its whole evidence in the brief (issue, diff, checks, every prior
1075
+ * round) and spends its turns reasoning, while a scout is handed a candidate
1076
+ * and must go and READ — the source repo at a ref, the entry points, the
1077
+ * existing tests, the later work that may have retired the premise. That is
1078
+ * search, and search is where a session bounded only by a worker's budget
1079
+ * quietly spends a worker's budget. One turn above the highest measured
1080
+ * finishing pass is enough for a bounded repo walk plus the structured
1081
+ * answer; a scout still going after them has stopped grooming one candidate
1082
+ * and started exploring the repository, which is exactly the cost the
1083
+ * mechanical selection bounds everywhere else.
1084
+ */
1085
+ export const TO_SPEC_MAX_TURNS = TO_SPEC_MEASURED_FINISHED_MAX_TURNS + 1;
1086
+
1087
+ /** What one to-spec scout produced (#1041). */
1088
+ export interface ToSpecScoutResult {
1089
+ /**
1090
+ * The agent's answer, verbatim and unjudged — the structured payload it
1091
+ * yielded (through the repair round when the first answer failed), or a
1092
+ * fully conforming block recovered from its own text when it never yielded
1093
+ * (#1064). This string is `recordToSpecGrooming`'s `input`, and nothing
1094
+ * here reads a field of it beyond what the repair round must probe:
1095
+ * conductor re-validates every result through the strict parser at
1096
+ * persistence time, so a runner that judged would be a second, weaker
1097
+ * contract. Empty when the session produced no answer at all — the
1098
+ * narration stays in `report`, and the pass records `no-answer`.
1099
+ */
1100
+ raw: string;
1101
+ /** The session's last words, kept whether or not it answered: for a scout
1102
+ * that produced nothing this text IS the evidence of what went wrong, and
1103
+ * it is recorded as the refusal's context — never parsed as a verdict
1104
+ * (#1064). */
1105
+ report: string;
1106
+ /**
1107
+ * How `raw` arrived (#1064): through the `yield` tool, or recovered from
1108
+ * the session's text because it never yielded (`"text"` is the record's own
1109
+ * admission that the answer did not come through the contract), or `"none"`
1110
+ * when the pass produced no answer at all. Absent on results written before
1111
+ * #1064; callers read a non-empty `raw` as a yield then.
1112
+ */
1113
+ via?: "yield" | "text" | "none";
1114
+ /** Whether the one bounded repair round produced the carried answer
1115
+ * (#1064): the first answer parsed but violated the contract, the
1116
+ * validation error was handed back, and this raw is the re-answer. */
1117
+ repaired?: boolean;
1118
+ /** Whether the turn ceiling aborted the session before it finished — the
1119
+ * cap-kill shape of the no-answer class (#1064). */
1120
+ killedAtCeiling?: boolean;
1121
+ /** What actually ran it, for the durable provenance (#875). */
1122
+ model?: string;
1123
+ provider?: string;
1124
+ turns: number;
1125
+ spendUsd: number;
1126
+ }
1127
+
1128
+ export interface ToSpecScoutOpts {
1129
+ /** The rendered `briefs/to-spec.md` for exactly one candidate. */
1130
+ brief: string;
1131
+ /** A directory the session may read: everything about the candidate is in
1132
+ * the brief, and a scout has no branch — so this is the session directory,
1133
+ * never a run worktree. */
1134
+ cwd: string;
1135
+ /** The model pattern `project.groomRole` resolved to, in omp's model/role
1136
+ * syntax. Omitted leaves the harness to choose. */
1137
+ model?: string;
1138
+ sessionDir?: string;
1139
+ ompSettingsFile?: string;
1140
+ socketPath?: string;
1141
+ onSpawn?: (pid: number) => void;
1142
+ onChildLog?: (line: string) => void;
1143
+ maySpawn?: () => boolean;
1144
+ /** Hard turn ceiling; the daemon passes {@link TO_SPEC_MAX_TURNS}. */
1145
+ maxTurns: number;
1146
+ }
1147
+
1148
+ /**
1149
+ * Run one bounded to-spec grooming pass (#1041, #1064).
1150
+ *
1151
+ * The adjudicator's twin, and read-only by the same construction: no
1152
+ * `verbSocketPath`, so no conductor mutation verb — no label change, no
1153
+ * promotion, no push — is reachable from this session. That absence is the
1154
+ * whole safety argument for letting the daemon launch grooming unattended:
1155
+ * the scout cannot queue the candidate it is grooming, only describe it. The
1156
+ * queue label is added later, by the daemon, and only through the ready gate.
1157
+ *
1158
+ * Deliberate differences from {@link runAdjudicator}. It carries
1159
+ * {@link TO_SPEC_SCHEMA} with `outputSchemaMode: "strict"` — the to-spec
1160
+ * contract has always been invoked strictly (#772), and a permissive schema
1161
+ * would let a half-filled result through the harness only for the parser to
1162
+ * refuse it as malformed one layer later, spending the batch to learn nothing.
1163
+ * And the answer is carried UNJUDGED: `recordToSpecGrooming` is the single
1164
+ * persistence path and the only validator. The one exception is the repair
1165
+ * round (#1064): a payload that parses but violates the contract is probed
1166
+ * with the same collector parser (`gateToSpecPayload`), the validation error
1167
+ * is prompted back into the session, and the re-answer is carried instead —
1168
+ * a complete, source-verified verdict must not be discarded over a key name.
1169
+ * A probe that refuses a payload is stale narration, never a verdict.
1170
+ *
1171
+ * What comes back goes beyond a plain string:
1172
+ *
1173
+ * - `raw` is ONLY the structured answer — the yield payload, or a fully
1174
+ * conforming block recovered from the session's text when it never
1175
+ * yielded. The runner never substitutes narration for the answer, so a
1176
+ * session that produced no payload returns `raw: ""` and the pass records
1177
+ * `no-answer` rather than grading prose as JSON.
1178
+ * - `via` says how the answer arrived; a text-recovery carries `"text"` so
1179
+ * the durable record admits the answer did not come through the yield
1180
+ * contract even though its verdict may be used.
1181
+ * - `killedAtCeiling` names the turn-ceiling kill, which is what makes the
1182
+ * no-answer shape legible instead of a silent failure.
1183
+ */
1184
+ export async function runToSpecScout(
1185
+ o: ToSpecScoutOpts,
1186
+ deps: RunWorkerDeps = { createSession },
1187
+ ): Promise<ToSpecScoutResult> {
1188
+ let session: AgentSessionLike;
1189
+ try {
1190
+ session = await deps.createSession({
1191
+ cwd: o.cwd,
1192
+ ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
1193
+ ...(o.model === undefined ? {} : { model: o.model }),
1194
+ ...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
1195
+ ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
1196
+ ...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
1197
+ ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
1198
+ ...(o.maySpawn === undefined ? {} : { maySpawn: o.maySpawn }),
1199
+ // Worker role, and deliberately no `verbSocketPath` — see the doc
1200
+ // comment: a scout reads and answers, it never mutates.
1201
+ role: "worker",
1202
+ outputSchema: TO_SPEC_SCHEMA,
1203
+ outputSchemaMode: "strict",
1204
+ requireYieldTool: true,
1205
+ });
1206
+ } catch (err) {
1207
+ // The pre-spawn admission gate closed, or the harness could not start.
1208
+ // Both are "no answer", and the caller settles the grooming row on the
1209
+ // failure text rather than leaving the in-flight marker to expire.
1210
+ return {
1211
+ raw: "",
1212
+ report: err instanceof Error ? err.message : String(err),
1213
+ via: "none",
1214
+ turns: 0,
1215
+ spendUsd: 0,
1216
+ };
1217
+ }
1218
+
1219
+ let turns = 0;
1220
+ let spendUsd = 0;
1221
+ let report = "";
1222
+ let yielded = "";
1223
+ let model: string | undefined;
1224
+ let provider: string | undefined;
1225
+ let killedAtCeiling = false;
1226
+ // Re-armed for the repair round: the completion resolvers serve one prompt
1227
+ // at a time, and the repair prompt completes its own (turn-bounded) window.
1228
+ let finished = Promise.withResolvers<void>();
1229
+ session.on("turn_start", () => {
1230
+ turns += 1;
1231
+ if (turns > o.maxTurns) {
1232
+ killedAtCeiling = true;
1233
+ session.abort();
1234
+ }
1235
+ });
1236
+ session.on("message_end", (event) => {
1237
+ const message = field(event, "message");
1238
+ if (field(message, "role") !== "assistant") return;
1239
+ const content = field(message, "content");
1240
+ const payload = yieldedPayload(content);
1241
+ if (payload !== undefined) yielded = payload;
1242
+ const text = reportText(content);
1243
+ if (text !== "") report = text;
1244
+ const usage = field(message, "usage");
1245
+ const cost = usage === null ? null : field(usage, "cost");
1246
+ const total = cost === null ? null : field(cost, "total");
1247
+ if (typeof total === "number") spendUsd += total;
1248
+ const messageModel = field(message, "model");
1249
+ if (typeof messageModel === "string" && messageModel !== "") model = messageModel;
1250
+ const messageProvider = field(message, "provider");
1251
+ if (typeof messageProvider === "string" && messageProvider !== "") provider = messageProvider;
1252
+ });
1253
+ session.on("agent_end", (event) => {
1254
+ // Same rule as every other session runner: a non-terminal `agent_end`
1255
+ // means the harness will resume, so completing there would read a
1256
+ // mid-flight snapshot as the answer.
1257
+ if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
1258
+ });
1259
+ session.on("session_exit", () => finished.resolve());
1260
+
1261
+ /**
1262
+ * What the session produced after one prompt, and whether that answer is a
1263
+ * single re-answer away from the contract (#1064). The newest yield wins
1264
+ * and is carried unjudged; without a yield, a fully conforming fenced block
1265
+ * from the text is carried as the answer (marked `via: "text"`), a
1266
+ * repairable block is NOT carried but its failure is reported so the repair
1267
+ * round can run, and prose is never carried — narration is context, not an
1268
+ * answer.
1269
+ */
1270
+ const inspect = (): {
1271
+ payload: string;
1272
+ via: "yield" | "text" | "none";
1273
+ failure: ToSpecFailure | undefined;
1274
+ } => {
1275
+ if (yielded !== "") {
1276
+ const gate = gateToSpecPayload(yielded);
1277
+ return {
1278
+ payload: yielded,
1279
+ via: "yield",
1280
+ failure: gate.state === "repairable" ? gate.failure : undefined,
1281
+ };
1282
+ }
1283
+ const gate = gateToSpecPayload(report);
1284
+ if (gate.state === "conforming") return { payload: gate.body, via: "text", failure: undefined };
1285
+ if (gate.state === "repairable") return { payload: "", via: "none", failure: gate.failure };
1286
+ return { payload: "", via: "none", failure: undefined };
1287
+ };
1288
+
1289
+ let repaired = false;
1290
+ try {
1291
+ await session.prompt(o.brief);
1292
+ await finished.promise;
1293
+ let answer = inspect();
1294
+ // One bounded repair round (#1064): a payload that parsed but violated
1295
+ // the contract is handed back once with the validation error before any
1296
+ // refusal is persisted. The same turn ceiling still bounds the session —
1297
+ // a cap kill means the session is gone and there is nothing to prompt.
1298
+ if (answer.failure !== undefined && !killedAtCeiling) {
1299
+ repaired = true;
1300
+ finished = Promise.withResolvers<void>();
1301
+ try {
1302
+ await session.prompt(toSpecRepairPrompt(answer.failure.detail));
1303
+ await finished.promise;
1304
+ answer = inspect();
1305
+ } catch {
1306
+ // The session could not take a repair prompt (it had already
1307
+ // exited). Whatever the first round produced stands, unjudged; the
1308
+ // persistence layer refuses it, with the payload kept in the row.
1309
+ }
1310
+ }
1311
+ return {
1312
+ raw: answer.payload,
1313
+ report,
1314
+ via: answer.via,
1315
+ ...(repaired ? { repaired: true } : {}),
1316
+ ...(killedAtCeiling ? { killedAtCeiling: true } : {}),
1317
+ ...(model === undefined ? {} : { model }),
1318
+ ...(provider === undefined ? {} : { provider }),
1319
+ turns,
1320
+ spendUsd,
1321
+ };
1322
+ } finally {
1323
+ await disposeSession(session);
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * The one repair round's prompt (#1064): what failed and which field, plus
1329
+ * the yield contract the corrected answer must come through. Bounded by the
1330
+ * same remaining turn ceiling as the original pass.
1331
+ */
1332
+ function toSpecRepairPrompt(detail: string): string {
1333
+ return (
1334
+ "Your grooming verdict was received but failed validation and was NOT recorded.\n\n" +
1335
+ `Validation error: ${detail}\n\n` +
1336
+ "This is your one repair round. Yield the complete, corrected verdict payload through the yield tool — " +
1337
+ "an answer written as plain text is not an answer."
1338
+ );
1339
+ }
1340
+
1341
+ /**
1342
+ * The `yield` call's payload, serialized, or `undefined` when this message
1343
+ * carried none (#1041).
1344
+ *
1345
+ * Transport, not decoding: it walks to `arguments.result.data` and stringifies
1346
+ * whatever object is there without reading one field of it. That is the
1347
+ * difference from {@link structuredVerdict} and {@link structuredSettlement},
1348
+ * which validate because their callers act on the parse — here the strict
1349
+ * to-spec parser at persistence time is the only validator, and a shape this
1350
+ * function rejected would be a verdict silently downgraded to "no answer".
1351
+ */
1352
+ function yieldedPayload(content: unknown): string | undefined {
1353
+ if (!Array.isArray(content)) return undefined;
1354
+ let payload: string | undefined;
1355
+ for (const block of content) {
1356
+ if (field(block, "type") !== "toolCall") continue;
1357
+ if (field(block, "name") !== "yield") continue;
1358
+ const result = field(field(block, "arguments"), "result");
1359
+ const data = field(result, "data");
1360
+ if (data === null || data === undefined || typeof data !== "object") continue;
1361
+ payload = JSON.stringify(data);
1362
+ }
1363
+ return payload;
1364
+ }
1365
+
810
1366
  /**
811
1367
  * Render a parsed {@link WorkerSettlement} as the run's stored report (#540).
812
1368
  *
@@ -832,6 +1388,13 @@ export function renderSettlement(settlement: WorkerSettlement): string {
832
1388
  lines.push("", "proof:");
833
1389
  for (const item of settlement.proof) lines.push(` - ${item}`);
834
1390
  }
1391
+ // Rendered like the other list fields so the stored report shows what was
1392
+ // appended to the repo's knowledge overlay: an entry a reader disagrees with
1393
+ // is traceable to the run that claimed it.
1394
+ if (settlement.discoveries !== undefined && settlement.discoveries.length > 0) {
1395
+ lines.push("", "discoveries:");
1396
+ for (const item of settlement.discoveries) lines.push(` - ${item}`);
1397
+ }
835
1398
  return lines.join("\n");
836
1399
  }
837
1400
 
@@ -1385,6 +1948,12 @@ export async function runWorker(
1385
1948
  spendUsd,
1386
1949
  provider429Count,
1387
1950
  report: renderSettlement(structured),
1951
+ // The only exit that can carry them: a killed or unparseable run yielded
1952
+ // no settlement, and inventing an empty list there would tell the
1953
+ // knowledge overlay that the fleet asked and learned nothing.
1954
+ ...(structured.discoveries === undefined || structured.discoveries.length === 0
1955
+ ? {}
1956
+ : { discoveries: structured.discoveries }),
1388
1957
  }));
1389
1958
  }
1390
1959